diff --git a/.cirrus.yml b/.cirrus.yml new file mode 100644 index 000000000..11c733437 --- /dev/null +++ b/.cirrus.yml @@ -0,0 +1,327 @@ +container: + dockerfile: ci/cirrus.Dockerfile + +env: + EMAIL: cirrus@cirrus-ci.org + +device_matrix_template: &DEVICE_MATRIX_TEMPLATE + - env: + DEVICE: --trezor-1 + depends_on: + - Trezor 1 Sim Builder + - dist_builder + - bitcoind_builder + fetch_sim_script: + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/Trezor 1 Sim Builder/sim/trezor-firmware.tar.gz" + - tar -xvf "trezor-firmware.tar.gz" + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/bitcoind_builder/bitcoin/bitcoin.tar.gz" + - tar -xvf "bitcoin.tar.gz" + - env: + DEVICE: --trezor-t + depends_on: + - Trezor T Sim Builder + - dist_builder + - bitcoind_builder + fetch_sim_script: + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/Trezor T Sim Builder/sim/trezor-firmware.tar.gz" + - tar -xvf "trezor-firmware.tar.gz" + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/bitcoind_builder/bitcoin/bitcoin.tar.gz" + - tar -xvf "bitcoin.tar.gz" + - env: + DEVICE: --coldcard + depends_on: + - Coldcard Sim Builder + - dist_builder + - bitcoind_builder + fetch_sim_script: + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/Coldcard Sim Builder/sim/coldcard-mpy.tar.gz" + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/bitcoind_builder/bitcoin/bitcoin.tar.gz" + - tar -xvf "bitcoin.tar.gz" + sim_install_script: + - pushd test/work; git clone --recursive https://github.com/Coldcard/firmware.git; popd + - tar -xvf "coldcard-mpy.tar.gz" + - pushd test/work/firmware; git am ../../data/coldcard-multisig.patch; popd + - poetry run pip install -r test/work/firmware/requirements.txt + - pip install -r test/work/firmware/requirements.txt + - env: + DEVICE: --bitbox01 + depends_on: + - Bitbox01 Sim Builder + - dist_builder + - bitcoind_builder + fetch_sim_script: + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/Bitbox01 Sim Builder/sim/mcu.tar.gz" + - tar -xvf "mcu.tar.gz" + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/bitcoind_builder/bitcoin/bitcoin.tar.gz" + - tar -xvf "bitcoin.tar.gz" + - env: + DEVICE: --jade + depends_on: + - Jade Sim Builder + - dist_builder + - bitcoind_builder + fetch_sim_script: + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/Jade Sim Builder/sim/jade.tar.gz" + - tar -xvf "jade.tar.gz" + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/bitcoind_builder/bitcoin/bitcoin.tar.gz" + - tar -xvf "bitcoin.tar.gz" + - env: + DEVICE: --ledger + depends_on: + - Ledger Sim Builder + - Ledger App Builder + - dist_builder + - bitcoind_builder + fetch_sim_script: + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/Ledger Sim Builder/sim/speculos.tar.gz" + - tar -xvf "speculos.tar.gz" + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/bitcoind_builder/bitcoin/bitcoin.tar.gz" + - tar -xvf "bitcoin.tar.gz" + fetch_ledger_app_script: + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/Ledger App Builder/built_ledger_app/app-bitcoin-new/bin/app.elf" + - mv app.elf test/work/speculos/apps/btc-test.elf + sim_install_script: + - poetry run pip install construct flask-restful jsonschema mnemonic pyelftools pillow requests + - pip install construct flask-restful jsonschema mnemonic pyelftools pillow requests + - env: + DEVICE: --ledger-legacy + depends_on: + - Ledger Sim Builder + - dist_builder + - bitcoind_builder + fetch_sim_script: + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/Ledger Sim Builder/sim/speculos.tar.gz" + - tar -xvf "speculos.tar.gz" + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/bitcoind_builder/bitcoin/bitcoin.tar.gz" + - tar -xvf "bitcoin.tar.gz" + sim_install_script: + - poetry run pip install construct flask-restful jsonschema mnemonic pyelftools pillow requests + - pip install construct flask-restful jsonschema mnemonic pyelftools pillow requests + - env: + DEVICE: --keepkey + depends_on: + - Keepkey Sim Builder + - dist_builder + - bitcoind_builder + fetch_sim_script: + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/Keepkey Sim Builder/sim/keepkey-firmware.tar.gz" + - tar -xvf "keepkey-firmware.tar.gz" + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/bitcoind_builder/bitcoin/bitcoin.tar.gz" + - tar -xvf "bitcoin.tar.gz" + +lint_task: + test_script: + - flake8 + +task: + install_script: + - poetry install + matrix: + - name: Type Check + type_check_script: > + poetry run + mypy + hwi.py + hwilib/_base58.py + hwilib/_bech32.py + hwilib/_cli.py + hwilib/commands.py + hwilib/common.py + hwilib/descriptor.py + hwilib/devices/bitbox02.py + hwilib/devices/coldcard.py + hwilib/devices/digitalbitbox.py + hwilib/devices/jade.py + hwilib/devices/__init__.py + hwilib/devices/keepkey.py + hwilib/devices/ledger.py + hwilib/devices/trezor.py + hwilib/errors.py + hwilib/_script.py + hwilib/_serialize.py + hwilib/tx.py + hwilib/hwwclient.py + hwilib/__init__.py + hwilib/key.py + hwilib/udevinstaller.py + - name: Non-Device Tests + test_script: cd test; poetry run ./run_tests.py; cd .. + +wine_builder_task: + container: + dockerfile: contrib/build.Dockerfile + build_script: + - contrib/build_wine.sh + - find dist -type f -exec sha256sum {} \; + +bitcoind_builder_task: + bitcoind_cache: + folder: test/work/bitcoin + ccache_cache: + folder: /root/.ccache + env: + BUILD_BITCOIND: 1 + build_script: + - cd test; ./setup_environment.sh --bitcoind; cd .. + - tar -czf bitcoin.tar.gz test/work/bitcoin + bitcoin_artifacts: + path: "bitcoin.tar.gz" + +task: + env: + DEVICE: --trezor-1 + name: Trezor 1 Sim Builder + sim_work_cache: + folder: test/work/trezor-firmware + build_script: + - cd test; ./setup_environment.sh $DEVICE; cd .. + - tar -czf trezor-firmware.tar.gz test/work/trezor-firmware + sim_artifacts: + path: "trezor-firmware.tar.gz" + +task: + env: + DEVICE: --trezor-t + name: Trezor T Sim Builder + sim_work_cache: + folder: test/work/trezor-firmware + build_script: + - cd test; ./setup_environment.sh $DEVICE; cd .. + - tar -czf trezor-firmware.tar.gz test/work/trezor-firmware + sim_artifacts: + path: "trezor-firmware.tar.gz" + +task: + env: + DEVICE: --coldcard + name: Coldcard Sim Builder + sim_work_cache: + folder: test/work/firmware + build_script: + - cd test; ./setup_environment.sh $DEVICE; cd .. + - tar -czf coldcard-mpy.tar.gz test/work/firmware/external/micropython/ports/unix/coldcard-mpy test/work/firmware/unix/coldcard-mpy test/work/firmware/unix/l-mpy test/work/firmware/unix/l-port + sim_artifacts: + path: "coldcard-mpy.tar.gz" + +task: + env: + DEVICE: --bitbox01 + name: Bitbox01 Sim Builder + sim_work_cache: + folder: test/work/mcu + build_script: + - cd test; ./setup_environment.sh $DEVICE; cd .. + - tar -czf mcu.tar.gz test/work/mcu + sim_artifacts: + path: "mcu.tar.gz" + +task: + env: + DEVICE: --jade + name: Jade Sim Builder + sim_work_cache: + folder: test/work/jade + build_script: + - cd test; ./setup_environment.sh $DEVICE; cd .. + - tar -czf jade.tar.gz test/work/jade/simulator + sim_artifacts: + path: "jade.tar.gz" + +task: + env: + DEVICE: --ledger + name: Ledger Sim Builder + sim_work_cache: + folder: test/work/speculos + build_script: + - cd test; ./setup_environment.sh $DEVICE; cd .. + - tar -czf speculos.tar.gz test/work/speculos + sim_artifacts: + path: "speculos.tar.gz" + +task: + env: + DEVICE: --keepkey + name: Keepkey Sim Builder + sim_work_cache: + folder: test/work/keepkey-firmware + build_script: + - cd test; ./setup_environment.sh $DEVICE; cd .. + - tar -czf keepkey-firmware.tar.gz test/work/keepkey-firmware/bin + sim_artifacts: + path: "keepkey-firmware.tar.gz" + + +dist_builder_task: + container: + dockerfile: contrib/build.Dockerfile + build_script: + - contrib/build_bin.sh + - contrib/build_dist.sh + - find dist -type f -exec sha256sum {} \; + built_dist_artifacts: + path: "dist/*" + +ledger_app_builder_task: + name: Ledger App Builder + container: + dockerfile: ci/ledger-app-builder.Dockerfile + build_script: + - git clone https://github.com/LedgerHQ/app-bitcoin-new.git + - cd app-bitcoin-new + - make DEBUG=1 + built_ledger_app_artifacts: + path: app-bitcoin-new/bin/app.elf + +task: + matrix: + << : *DEVICE_MATRIX_TEMPLATE + fetch_dist_script: + - wget -nv "https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/dist_builder/built_dist.zip" + - unzip built_dist.zip + matrix: + - name: $DEVICE Wheel + install_script: pip install dist/*.whl + test_script: cd test; ./run_tests.py $DEVICE --interface=cli --device-only; cd .. + - name: $DEVICE Sdist + install_script: pip install $(find dist -name "*.tar.gz" -a -not -name "*amd64*") + test_script: cd test; ./run_tests.py $DEVICE --interface=cli --device-only; cd .. + - name: $DEVICE Bindist + install_script: poetry install + untar_bindist_script: cd dist; tar -xvf hwi*linux*.tar.gz; cd .. + test_script: cd test; poetry run ./run_tests.py $DEVICE --interface=bindist --device-only; cd .. + on_failure: + failed_script: tail -v -n +1 test/*.std* + +task: + matrix: + - container: + dockerfile: ci/cirrus.Dockerfile + env: + PYTHON: 3.7 + - container: + dockerfile: ci/py38.Dockerfile + env: + PYTHON: 3.8 + - container: + dockerfile: ci/py39.Dockerfile + env: + PYTHON: 3.9 + - container: + dockerfile: ci/py310.Dockerfile + env: + PYTHON: 3.10 + install_script: poetry install + matrix: + << : *DEVICE_MATRIX_TEMPLATE + matrix: + - env: + INTERFACE: library + - env: + INTERFACE: cli + - env: + INTERFACE: stdin + name: Python $PYTHON $DEVICE $INTERFACE + test_script: cd test; poetry run ./run_tests.py $DEVICE --interface=$INTERFACE --device-only; cd .. + on_failure: + failed_script: tail -v -n +1 test/*.std* diff --git a/.flake8 b/.flake8 index d2decef36..372b9d084 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] -exclude = *.pyc,__pycache__,hwilib/devices/btchip/,hwilib/devices/ckcc/,hwilib/devices/trezorlib/,test/work/ +exclude = *.pyc,__pycache__,hwilib/devices/ledger_bitcoin/,hwilib/devices/btchip,hwilib/devices/ckcc/,hwilib/devices/jadepy/,hwilib/devices/trezorlib/,test/work/,hwilib/ui ignore = E261,E302,E305,E501,E722,W5 per-file-ignores = setup.py:E122 diff --git a/.gitignore b/.gitignore index 091dd55d7..c97e1b36e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,21 @@ test/emulator.img test/work pip-wheel-metadata .mypy_cache/ + +# Qt stuff +hwiqt.pyproject.user +hwilib/ui/ui_*.py + +*.stderr +*.stdout + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +.vscode diff --git a/.python-version b/.python-version index 424e1794d..f69abe410 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.6.8 +3.9.7 diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..c1543b649 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,5 @@ +version: 2 + +python: + install: + - requirements: docs/requirements.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e417794e5..000000000 --- a/.travis.yml +++ /dev/null @@ -1,120 +0,0 @@ -language: python -os: linux -dist: xenial -python: - - '3.6.8' -cache: - pip: true - ccache: true - directories: - - test/work -addons: - apt: - sources: - - sourceline: 'ppa:bitcoin/bitcoin' - packages: - - libdb4.8-dev - - libdb4.8++-dev - - build-essential - - curl - - git - - libsdl2-dev - - libsdl2-image-dev - - gcc-arm-none-eabi - - libnewlib-arm-none-eabi - - libudev-dev - - libtool - - autotools-dev - - automake - - pkg-config - - bsdmainutils - - libssl-dev - - libevent-dev - - libboost-system-dev - - libboost-filesystem-dev - - libboost-chrono-dev - - libboost-test-dev - - libboost-thread-dev - - libusb-1.0-0-dev - - protobuf-compiler - - cython3 - - ccache -install: - - pip install pipenv pysdl2 protobuf poetry - # From trezor-mcu to get the correct protobuf version - - curl -LO "https://github.com/google/protobuf/releases/download/v3.4.0/protoc-3.4.0-linux-x86_64.zip" - - unzip "protoc-3.4.0-linux-x86_64.zip" -d protoc - - export PATH="$(pwd)/protoc/bin:$PATH" - # Build emulators/simulators and bitcoind - - cd test; ./setup_environment.sh; cd .. - - pip uninstall -y trezor # Hack to get rid of master branch version of trezor that is installed for trezor-mcu build - - poetry install -jobs: - include: - - name: lint - stage: lint - install: - - pip install flake8 - script: flake8 - - name: Type annotation checking - stage: lint - install: - - pip install mypy - script: mypy hwilib/base58.py - - name: Run non-device tests only - stage: test - script: cd test; poetry run ./run_tests.py - - name: With process_commands interface - stage: test - script: cd test; poetry run ./run_tests.py --all --interface=library - - name: With command line interface - stage: test - script: cd test; poetry run ./run_tests.py --all --interface=cli - - name: With stdin interface - stage: test - script: cd test; poetry run ./run_tests.py --all --interface=stdin - - name: With wheel command line interface - stage: test - services: docker - before_script: - - docker build -t hwi-builder -f contrib/build.Dockerfile . - script: - - docker run -it --name hwi-builder -v $PWD:/opt/hwi --rm --workdir /opt/hwi hwi-builder /bin/bash -c "contrib/build_dist.sh" - - sudo chown -R `whoami`:`whoami` dist/ - - pip install dist/*.whl - - cd test; ./run_tests.py --all --interface=cli - - name: With sdist command line interface - stage: test - services: docker - before_script: - - docker build -t hwi-builder -f contrib/build.Dockerfile . - script: - - docker run -it --name hwi-builder -v $PWD:/opt/hwi --rm --workdir /opt/hwi hwi-builder /bin/bash -c "contrib/build_dist.sh" - - sudo chown -R `whoami`:`whoami` dist/ - - pip install dist/*.tar.gz - - cd test; ./run_tests.py --all --interface=cli - - name: With linux binary distribution command line interface - stage: test - services: docker - before_script: - - docker build -t hwi-builder -f contrib/build.Dockerfile . - script: - - docker run -it --name hwi-builder -v $PWD:/opt/hwi --rm --workdir /opt/hwi hwi-builder /bin/bash -c "contrib/build_bin.sh && contrib/build_wine.sh && contrib/build_dist.sh" - - sudo chown -R `whoami`:`whoami` dist/ - - cd test; poetry run ./run_tests.py --all --interface=bindist - - cd ..; sha256sum dist/* - - name: macOS binary distribution (no tests) - stage: test - os: osx - osx_image: xcode7.3 - language: generic - addons: - artifacts: - working_dir: dist - install: - - brew update && brew upgrade pyenv - - brew install libusb - - cat contrib/reproducible-python.diff | PYTHON_CONFIGURE_OPTS="--enable-framework" BUILD_DATE="Jan 1 2019" BUILD_TIME="00:00:00" pyenv install -kp 3.6.8 - script: - - contrib/build_bin.sh - - shasum -a 256 dist/* diff --git a/README.md b/README.md index c7cdfdf2b..cae951e55 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,26 @@ # Bitcoin Hardware Wallet Interface -[![Build Status](https://travis-ci.org/bitcoin-core/HWI.svg?branch=master)](https://travis-ci.org/bitcoin-core/HWI) +[![Build Status](https://api.cirrus-ci.com/github/bitcoin-core/HWI.svg)](https://cirrus-ci.com/github/bitcoin-core/HWI) +[![Documentation Status](https://readthedocs.org/projects/hwi/badge/?version=latest)](https://hwi.readthedocs.io/en/latest/?badge=latest) The Bitcoin Hardware Wallet Interface is a Python library and command line tool for interacting with hardware wallets. It provides a standard way for software to work with hardware wallets without needing to implement device specific drivers. Python software can use the provided library (`hwilib`). Software in other languages can execute the `hwi` tool. +Caveat emptor: Inclusion of a specific hardware wallet vendor does not imply any endorsement of quality or security. + ## Prerequisites Python 3 is required. The libraries and [udev rules](hwilib/udev/README.md) for each device must also be installed. Some libraries will need to be installed For Ubuntu/Debian: ``` -sudo apt install libusb-1.0-0-dev libudev-dev +sudo apt install libusb-1.0-0-dev libudev-dev python3-dev +``` + +For Centos: +``` +sudo yum -y install python3-devel libusbx-devel systemd-devel ``` For macOS: @@ -20,22 +28,37 @@ For macOS: brew install libusb ``` -This project uses the [Poetry](https://github.com/sdispater/poetry) dependency manager. -Once HWI's source has been downloaded with git clone, it and its dependencies can be installed via poetry by execting the following in the root source directory: +## Install + +``` +git clone https://github.com/bitcoin-core/HWI.git +cd HWI +poetry install # or 'pip3 install .' or 'python3 setup.py install' +``` + +This project uses the [Poetry](https://github.com/sdispater/poetry) dependency manager. HWI and its dependencies can be installed via poetry by executing the following in the root source directory: ``` poetry install ``` -Pip can also be used to install all of the dependencies (in virtualenv or system) required for operation and development. See `pyproject.toml` for all dependencies. Dependencies under `[tool.poetry.dependecies]` are user dependencies, and `[tool.poetry.dev-dependencies]` for development based dependencies. +Pip can also be used to automatically install HWI and its dependencies using the `setup.py` file (which is usually in sync with `pyproject.toml`): -## Install +``` +pip3 install . +``` + +The `setup.py` file can be used to install HWI and its dependencies so long as `setuptools` is also installed: ``` -git clone https://github.com/bitcoin-core/HWI.git -cd HWI +pip3 install -U setuptools +python3 setup.py install ``` +## Dependencies + +See `pyproject.toml` for all dependencies. Dependencies under `[tool.poetry.dependencies]` are user dependencies, and `[tool.poetry.dev-dependencies]` for development based dependencies. These dependencies will be installed with any of the three above installation methods. + ## Usage To use, first enumerate all devices and find the one that you want to use with @@ -54,39 +77,25 @@ All output will be in JSON form and sent to `stdout`. Additional information or prompts will be sent to `stderr` and will not necessarily be in JSON. This additional information is for debugging purposes. -## Device Support - -The below table lists what devices and features are supported for each device. - -Please also see [docs](docs/) for additional information about each device. - -| Feature \ Device | Ledger Nano X | Ledger Nano S | Trezor One | Trezor Model T | Digital BitBox | KeepKey | Coldcard | -|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| Support Planned | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Implemented | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| xpub retrieval | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Message Signing | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Device Setup | N/A | N/A | Yes | Yes | Yes | Yes | N/A | -| Device Wipe | N/A | N/A | Yes | Yes | Yes | Yes | N/A | -| Device Recovery | N/A | N/A | Yes | Yes | N/A | Yes | N/A | -| Device Backup | N/A | N/A | N/A | N/A | Yes | N/A | Yes | -| P2PKH Inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| P2SH-P2WPKH Inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| P2WPKH Inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| P2SH Multisig Inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| P2SH-P2WSH Multisig Inputs | Yes | Yes | Yes | Yes | Yes | No | Yes | -| P2WSH Multisig Inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Bare Multisig Inputs | Yes | Yes | N/A | N/A | Yes | N/A | N/A | -| Arbitrary scriptPubKey Inputs | Yes | Yes | N/A | N/A | Yes | N/A | N/A | -| Arbitrary redeemScript Inputs | Yes | Yes | N/A | N/A | Yes | N/A | N/A | -| Arbitrary witnessScript Inputs | Yes | Yes | N/A | N/A | Yes | N/A | N/A | -| Non-wallet inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Mixed Segwit and Non-Segwit Inputs | N/A | N/A | Yes | N/A | Yes | Yes | Yes | -| Display on device screen | Yes | Yes | Yes | Yes | N/A | Yes | Yes | - -## Using with Bitcoin Core - -See [Using Bitcoin Core with Hardware Wallets](docs/bitcoin-core-usage.md). +To see a complete list of available commands and global parameters, run +`./hwi.py --help`. To see options specific to a particular command, +pass the `--help` parameter after the command name; for example: + +``` +./hwi.py getdescriptors --help +``` + +## Documentation + +Documentation for HWI can be found on [readthedocs.io](https://hwi.readthedocs.io/). + +### Device Support + +For documentation on devices supported and how they are supported, please check the [device support page](https://hwi.readthedocs.io/en/latest/devices/index.html#support-matrix) + +### Using with Bitcoin Core + +See [Using Bitcoin Core with Hardware Wallets](https://hwi.readthedocs.io/en/latest/examples/bitcoin-core-usage.html). ## License diff --git a/ci/cirrus.Dockerfile b/ci/cirrus.Dockerfile new file mode 100644 index 000000000..2a23a59a9 --- /dev/null +++ b/ci/cirrus.Dockerfile @@ -0,0 +1,91 @@ +FROM python:3.7 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update +RUN apt-get install -y \ + autotools-dev \ + automake \ + bsdmainutils \ + build-essential \ + ccache \ + clang \ + cmake \ + curl \ + cython3 \ + gcc-arm-none-eabi \ + gcc-arm-linux-gnueabihf \ + git \ + libboost-system-dev \ + libboost-filesystem-dev \ + libboost-chrono-dev \ + libboost-test-dev \ + libboost-thread-dev \ + libc6-dev-armhf-cross \ + libdb-dev \ + libdb++-dev \ + libevent-dev \ + libgcrypt20-dev \ + libnewlib-arm-none-eabi \ + libpcsclite-dev \ + libsdl2-dev \ + libsdl2-image-dev \ + libssl-dev \ + libtool \ + libudev-dev \ + libusb-1.0-0-dev \ + ninja-build \ + pkg-config \ + qemu-user-static \ + swig + +RUN pip install poetry flake8 +RUN wget https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init +RUN chmod +x rustup-init && ./rustup-init -y +ENV PATH="/root/.cargo/bin:$PATH" +RUN wget https://github.com/protocolbuffers/protobuf/releases/download/v22.0/protoc-22.0-linux-x86_64.zip +RUN unzip protoc-22.0-linux-x86_64.zip -d /usr/local +RUN protoc --version + +#################### +# Local build/test steps +# ----------------- +# To install all simulators/tests locally, uncomment the block below, +# then build the docker image and interactively run the tests +# as needed. +# e.g., +# docker build -f ci/cirrus.Dockerfile -t hwi_test . +# docker run -it --entrypoint /bin/bash hwi_test +# cd test; poetry run ./run_tests.py --ledger --coldcard --interface=cli --device-only +#################### + +#################### +#ENV EMAIL=email +#COPY pyproject.toml pyproject.toml +#RUN poetry run pip install construct pyelftools mnemonic jsonschema +# +## Set up environments first to take advantage of layer caching +#RUN mkdir test +#COPY test/setup_environment.sh test/setup_environment.sh +#COPY test/data/coldcard-multisig.patch test/data/coldcard-multisig.patch +## One by one to allow for intermediate caching of successful builds +#RUN cd test; ./setup_environment.sh --trezor-1 +#RUN cd test; ./setup_environment.sh --trezor-t +#RUN cd test; ./setup_environment.sh --coldcard +#RUN cd test; ./setup_environment.sh --bitbox01 +#RUN cd test; ./setup_environment.sh --ledger +#RUN cd test; ./setup_environment.sh --keepkey +#RUN cd test; ./setup_environment.sh --jade +#RUN cd test; ./setup_environment.sh --bitcoind +# +## Once everything has been built, put rest of files in place +## which have higher turn-over. +#COPY test/ test/ +#COPY hwi.py hwi-qt.py README.md / +#COPY hwilib/ /hwilib/ +#RUN poetry install +# +#################### + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 +ENV LANGUAGE=C.UTF-8 diff --git a/ci/ledger-app-builder.Dockerfile b/ci/ledger-app-builder.Dockerfile new file mode 100644 index 000000000..72480edd5 --- /dev/null +++ b/ci/ledger-app-builder.Dockerfile @@ -0,0 +1,80 @@ +# Copied from https://github.com/LedgerHQ/ledger-app-builder/blob/229b03cf20630e6bdc66d7f1ce33c70b2fd2b9e5/Dockerfile + +FROM ubuntu:20.04 +ENV LANG C.UTF-8 + +ARG DEBIAN_FRONTEND=noninteractive + +ARG LLVM_VERSION=12 + +RUN apt-get update && apt-get upgrade -qy && \ + apt-get install -qy \ + clang-$LLVM_VERSION \ + clang-tools-$LLVM_VERSION \ + clang-format-$LLVM_VERSION \ + cmake \ + curl \ + doxygen \ + git \ + lcov \ + libbsd-dev \ + libcmocka0 \ + libcmocka-dev \ + lld-$LLVM_VERSION \ + make \ + protobuf-compiler \ + python-is-python3 \ + python3 \ + python3-pip && \ + apt-get autoclean -y && \ + apt-get autoremove -y && \ + apt-get clean + +# Create generic clang & lld symbolic links to their installed version +RUN cd /usr/bin && \ + find . -name "*-"$LLVM_VERSION | sed "s/^\(.*\)\(-"$LLVM_VERSION"\)$/ln -s \1\2 \1/" | sh + +# ARM Embedded Toolchain +# Integrity is checked using the MD5 checksum provided by ARM at https://developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-rm/downloads +RUN curl -sSfL -o arm-toolchain.tar.bz2 "https://armkeil.blob.core.windows.net/developer/Files/downloads/gnu-rm/10.3-2021.10/gcc-arm-none-eabi-10.3-2021.10-x86_64-linux.tar.bz2" && \ + echo 2383e4eb4ea23f248d33adc70dc3227e arm-toolchain.tar.bz2 > /tmp/arm-toolchain.md5 && \ + md5sum --check /tmp/arm-toolchain.md5 && rm /tmp/arm-toolchain.md5 && \ + tar xf arm-toolchain.tar.bz2 -C /opt && \ + rm arm-toolchain.tar.bz2 + +# Adding GCC to PATH and defining rustup/cargo home directories +ENV PATH=/opt/gcc-arm-none-eabi-10.3-2021.10/bin:$PATH \ + RUSTUP_HOME=/opt/rustup \ + CARGO_HOME=/opt/.cargo + +# Install rustup to manage rust toolchains +RUN curl https://sh.rustup.rs -sSf | \ + sh -s -- --default-toolchain stable -y + +# Adding cargo binaries to PATH +ENV PATH=${CARGO_HOME}/bin:${PATH} + +# Adding ARMV6M target to the default toolchain +RUN rustup target add thumbv6m-none-eabi + +# Python packages commonly used by apps +RUN pip3 install ledgerblue pytest + +# Latest Nano S SDK +ENV NANOS_SDK=/opt/nanos-secure-sdk +RUN git clone --branch 2.1.0 --depth 1 https://github.com/LedgerHQ/nanos-secure-sdk.git "${NANOS_SDK}" + +# Latest Nano X SDK +ENV NANOX_SDK=/opt/nanox-secure-sdk +RUN git clone --branch 2.0.2-2 --depth 1 https://github.com/LedgerHQ/nanox-secure-sdk.git "${NANOX_SDK}" + +# Latest Nano S+ SDK +ENV NANOSP_SDK=/opt/nanosplus-secure-sdk +RUN git clone --branch 1.0.2 --depth 1 https://github.com/LedgerHQ/nanosplus-secure-sdk.git "${NANOSP_SDK}" + +# Default SDK +ENV BOLOS_SDK=${NANOS_SDK} + +WORKDIR /app + +CMD ["/usr/bin/env", "bash"] diff --git a/ci/py310.Dockerfile b/ci/py310.Dockerfile new file mode 100644 index 000000000..e7a28ded2 --- /dev/null +++ b/ci/py310.Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.10 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update +RUN apt-get install -y \ + cython3 \ + git \ + libpcsclite-dev \ + libsdl2-dev \ + libsdl2-image-dev \ + libudev-dev \ + libusb-1.0-0-dev \ + qemu-user-static \ + swig + +RUN pip install poetry flake8 + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 +ENV LANGUAGE=C.UTF-8 diff --git a/ci/py38.Dockerfile b/ci/py38.Dockerfile new file mode 100644 index 000000000..8b194ea39 --- /dev/null +++ b/ci/py38.Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.8 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update +RUN apt-get install -y \ + cython3 \ + git \ + libpcsclite-dev \ + libsdl2-dev \ + libsdl2-image-dev \ + libudev-dev \ + libusb-1.0-0-dev \ + qemu-user-static \ + swig + +RUN pip install poetry flake8 + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 +ENV LANGUAGE=C.UTF-8 diff --git a/ci/py39.Dockerfile b/ci/py39.Dockerfile new file mode 100644 index 000000000..b1cac0d8b --- /dev/null +++ b/ci/py39.Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.9 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update +RUN apt-get install -y \ + cython3 \ + git \ + libpcsclite-dev \ + libsdl2-dev \ + libsdl2-image-dev \ + libudev-dev \ + libusb-1.0-0-dev \ + qemu-user-static \ + swig + +RUN pip install poetry flake8 + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 +ENV LANGUAGE=C.UTF-8 diff --git a/contrib/build.Dockerfile b/contrib/build.Dockerfile index e15839ada..622926491 100644 --- a/contrib/build.Dockerfile +++ b/contrib/build.Dockerfile @@ -27,17 +27,21 @@ RUN apt-get install -y \ libudev-dev \ faketime \ zip \ - dos2unix + dos2unix \ + g++-mingw-w64-x86-64 \ + qt5-default RUN curl https://pyenv.run | bash -ENV PATH="/root/.pyenv/bin:$PATH" +ENV PYENV_ROOT="/root/.pyenv" +ENV PATH="$PYENV_ROOT/bin:$PATH" + COPY contrib/reproducible-python.diff /opt/reproducible-python.diff ENV PYTHON_CONFIGURE_OPTS="--enable-shared" ENV BUILD_DATE="Jan 1 2019" ENV BUILD_TIME="00:00:00" -RUN eval "$(pyenv init -)" && eval "$(pyenv virtualenv-init -)" && cat /opt/reproducible-python.diff | pyenv install -kp 3.6.8 +RUN eval "$(pyenv init --path)" && eval "$(pyenv virtualenv-init -)" && cat /opt/reproducible-python.diff | pyenv install -kp 3.9.7 -RUN dpkg --add-architecture i386 +RUN dpkg --add-architecture i386 RUN wget -nc https://dl.winehq.org/wine-builds/winehq.key RUN apt-key add winehq.key RUN echo "deb https://dl.winehq.org/wine-builds/debian/ stretch main" >> /etc/apt/sources.list @@ -48,3 +52,7 @@ RUN apt-get install --install-recommends -y \ wine-stable \ winehq-stable \ p7zip-full + +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 +ENV LANGUAGE=C.UTF-8 diff --git a/contrib/build_bin.sh b/contrib/build_bin.sh index e7b550aa6..42264c1b7 100755 --- a/contrib/build_bin.sh +++ b/contrib/build_bin.sh @@ -1,28 +1,36 @@ #! /bin/bash # Script for building standalone binary releases deterministically +# Usage: First script parameter can be `--without-gui` to build without UI support -eval "$(pyenv init -)" +set -ex + +eval "$(pyenv init --path)" eval "$(pyenv virtualenv-init -)" pip install -U pip pip install poetry -# Setup poetry and install the dependencies -poetry install +gui_support="${1:---with-gui}"; -# We now need to remove debugging symbols and build id from the hidapi SO file -so_dir=`dirname $(dirname $(poetry run which python))`/lib/python3.6/site-packages -find ${so_dir} -name '*.so' -type f -execdir strip '{}' \; -if [[ $OSTYPE != *"darwin"* ]]; then - find ${so_dir} -name '*.so' -type f -execdir strip -R .note.gnu.build-id '{}' \; +# Setup poetry and install the dependencies +if [[ $gui_support == "--with-gui" ]]; then + poetry install -E qt +else + poetry install fi # We also need to change the timestamps of all of the base library files -lib_dir=`pyenv root`/versions/3.6.8/lib/python3.6 +lib_dir=`pyenv root`/versions/3.9.7/lib/python3.9 TZ=UTC find ${lib_dir} -name '*.py' -type f -execdir touch -t "201901010000.00" '{}' \; # Make the standalone binary export PYTHONHASHSEED=42 poetry run pyinstaller hwi.spec + +if [[ $gui_support == "--with-gui" ]]; then + poetry run contrib/generate-ui.sh + poetry run pyinstaller hwi-qt.spec +fi + unset PYTHONHASHSEED # Make the final compressed package @@ -32,5 +40,21 @@ OS=`uname | tr '[:upper:]' '[:lower:]'` if [[ $OS == "darwin" ]]; then OS="mac" fi -tar -czf "hwi-${VERSION}-${OS}-amd64.tar.gz" hwi +target_tarfile="hwi-${VERSION}-${OS}-amd64.tar.gz" + +if [[ $gui_support == "--with-gui" ]]; then + tar -czf $target_tarfile hwi hwi-qt +else + tar -czf $target_tarfile hwi +fi + +# Copy the binaries to subdir for shasum +target_dir="$target_tarfile.dir" +mkdir $target_dir +mv hwi $target_dir + +if [[ $gui_support == "--with-gui" ]]; then + mv hwi-qt $target_dir +fi + popd diff --git a/contrib/build_dist.sh b/contrib/build_dist.sh index b6b7d6b75..e15b9e5d9 100755 --- a/contrib/build_dist.sh +++ b/contrib/build_dist.sh @@ -1,13 +1,22 @@ #! /bin/bash # Script for building pypi distribution archives deterministically +# Usage: First script parameter can be `--without-gui` to build without UI support -eval "$(pyenv init -)" +set -ex + +eval "$(pyenv init --path)" eval "$(pyenv virtualenv-init -)" pip install -U pip pip install poetry +gui_support="${1:---with-gui}"; + # Setup poetry and install the dependencies -poetry install +if [[ $gui_support == "--with-gui" ]]; then + poetry install -E qt +else + poetry install +fi # Make the distribution archives for pypi poetry build -f wheel diff --git a/contrib/build_wine.sh b/contrib/build_wine.sh index 59d6cffe4..22f927f56 100755 --- a/contrib/build_wine.sh +++ b/contrib/build_wine.sh @@ -1,16 +1,17 @@ #!/bin/bash # Script which sets up Wine and builds the Windows standalone binary -set -e +set -ex -PYTHON_VERSION=3.6.8 +PYTHON_VERSION=3.9.7 PYTHON_FOLDER="python3" PYHOME="c:/$PYTHON_FOLDER" PYTHON="wine $PYHOME/python.exe -OO -B" -LIBUSB_URL=https://github.com/libusb/libusb/releases/download/v1.0.22/libusb-1.0.22.7z -LIBUSB_HASH="671f1a420757b4480e7fadc8313d6fb3cbb75ca00934c417c1efa6e77fb8779b" +LIBUSB_VERSION=1.0.23 +LIBUSB_URL=https://github.com/libusb/libusb/releases/download/v1.0.23/libusb-1.0.23.tar.bz2 +LIBUSB_HASH="db11c06e958a82dac52cf3c65cb4dd2c3f339c8a988665110e0d24d19312ad8d" WINDOWS_SDK_URL=http://go.microsoft.com/fwlink/p/?LinkID=2033686 WINDOWS_SDK_HASH="016981259708e1afcab666c7c1ff44d1c4d63b5e778af8bc41b4f6db3d27961a" @@ -20,7 +21,7 @@ wine 'wineboot' # Install Python # Get the PGP keys -wget -N -c "https://www.python.org/static/files/pubkeys.txt" +wget -O pubkeys.txt -N -c "https://keybase.io/stevedower/pgp_keys.asc?fingerprint=7ed10b6531d7c8e1bc296021fc624643487034e5" gpg --import pubkeys.txt rm pubkeys.txt @@ -33,11 +34,15 @@ for msifile in core dev exe lib pip tools; do rm $msifile.msi* done -# Get libusb -wget -N -c -O libusb.7z "$LIBUSB_URL" -echo "$LIBUSB_HASH libusb.7z" | sha256sum -c -7za x -olibusb libusb.7z -aoa -cp libusb/MS64/dll/libusb-1.0.dll ~/.wine/drive_c/python3/ +# Get and build libusb +wget -N -c -O libusb.tar.bz2 "$LIBUSB_URL" +echo "$LIBUSB_HASH libusb.tar.bz2" | sha256sum -c +tar -xf libusb.tar.bz2 +pushd "libusb-$LIBUSB_VERSION" +./configure --host=x86_64-w64-mingw32 +faketime -f "2019-01-01 00:00:00" make +cp libusb/.libs/libusb-1.0.dll ~/.wine/drive_c/python3/ +popd rm -r libusb* # Get the Windows SDK @@ -62,15 +67,34 @@ TZ=UTC find ${lib_dir} -name '*.py' -type f -execdir touch -t "201901010000.00" # Install python dependencies POETRY="wine $PYHOME/Scripts/poetry.exe" sleep 5 # For some reason, pausing for a few seconds makes the next step work -$POETRY install +$POETRY install -E qt + +# make the ui files +pushd hwilib/ui +for file in *.ui +do + gen_file=ui_`echo $file| cut -d. -f1`.py + $POETRY run pyside2-uic $file -o $gen_file + sed -i 's/raise()/raise_()/g' $gen_file +done +popd # Do the build export PYTHONHASHSEED=42 $POETRY run pyinstaller hwi.spec +$POETRY run pyinstaller hwi-qt.spec unset PYTHONHASHSEED # Make the final compressed package pushd dist VERSION=`$POETRY run hwi --version | cut -d " " -f 2 | dos2unix` -zip "hwi-${VERSION}-windows-amd64.zip" hwi.exe +target_zipfile="hwi-${VERSION}-windows-amd64.zip" +zip $target_zipfile hwi.exe hwi-qt.exe + +# Copy the binaries to subdir for shasum +target_dir="$target_zipfile.dir" +mkdir $target_dir +mv hwi.exe $target_dir +mv hwi-qt.exe $target_dir + popd diff --git a/contrib/generate-ui.sh b/contrib/generate-ui.sh new file mode 100755 index 000000000..9fc982ea9 --- /dev/null +++ b/contrib/generate-ui.sh @@ -0,0 +1,12 @@ +#! /bin/bash + +set -ex + +pushd hwilib/ui +for file in *.ui +do + gen_file=ui_`echo $file| cut -d. -f1`.py + pyside2-uic $file -o $gen_file + sed -i'' -e 's/raise()/raise_()/g' $gen_file +done +popd diff --git a/contrib/generate_setup.sh b/contrib/generate_setup.sh index 8786487ba..3e3424d8a 100755 --- a/contrib/generate_setup.sh +++ b/contrib/generate_setup.sh @@ -1,10 +1,10 @@ #! /bin/bash # Generates the setup.py file -set -e +set -ex # Setup poetry and install the dependencies -poetry install +poetry install -E qt # Build the source distribution poetry build -f sdist @@ -30,3 +30,4 @@ tar -xf $tarball $toextract mv $toextract . dir=`echo $toextract | cut -f1 -d"/"` rm -r $dir +sed -i 's/distutils.core/setuptools/g' setup.py diff --git a/contrib/make_shasums.sh b/contrib/make_shasums.sh new file mode 100755 index 000000000..4a7949cff --- /dev/null +++ b/contrib/make_shasums.sh @@ -0,0 +1,14 @@ +#! /bin/bash +# Script for generating the SHA256SUMS.txt file + +set -ex + +pushd dist + +sums=SHA256SUMS.txt +sum_files=`find . -type f -not -name *$sums* | sort` +sha256sum $sum_files > $sums +sed -i 's/\.\///g' $sums +sed -i 's/\.dir//g' $sums + +popd diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html new file mode 100644 index 000000000..cf2b2cd07 --- /dev/null +++ b/docs/_templates/layout.html @@ -0,0 +1,8 @@ +{% extends "!layout.html" %} + {% block footer %} {{ super() }} + + + +{% endblock %} diff --git a/docs/bitcoin-core-usage.md b/docs/bitcoin-core-usage.md deleted file mode 100644 index 608fdc762..000000000 --- a/docs/bitcoin-core-usage.md +++ /dev/null @@ -1,294 +0,0 @@ -# Using Bitcoin Core with Hardware Wallets - -This approach is fairly manual, requires the command line, and Bitcoin Core >=0.18.0. - -Note: For this guide, code lines prefixed with `$` means that the command is typed in the terminal. Lines without `$` are output of the commands. - -### Disclaimer - -We are not liable for any coins that may be lost through this method. The software mentioned may have bugs. Use at your own risk. - -## Software - -### Bitcoin Core - -This method of using hardware wallets uses Bitcoin Core as the wallet for monitoring the blockchain. It allows a user to use their own full node instead of relying on an SPV wallet or vendor provided software. - -HWI works with Bitcoin Core as of commit [c576979b78b541bf3b4a7cbeee989b55d268e3e1](https://github.com/bitcoin/bitcoin/commit/c576979b78b541bf3b4a7cbeee989b55d268e3e1). It is usable with Bitcoin Core >=0.18.0. - -## Setup - -Clone Bitcoin Core and build it. Clone HWI. - -``` -$ git clone https://github.com/bitcoin/bitcoin.git -$ cd bitcoin -$ ./autogen.sh -$ ./configure -$ make -$ src/bitcoind -daemon -addresstype=bech32 -changetype=bech32 -$ cd .. -$ git clone https://github.com/bitcoin-core/HWI.git -$ cd HWI -$ python3 setup.py install -``` - -You may need some dependencies, on ubuntu install `libudev-dev` and `libusb-1.0-0-dev` - -Now we need to find our hardware wallet. We do this using: - -``` -$ ./hwi.py enumerate -[{"fingerprint": "8038ecd9", "serial_number": "205A32753042", "type": "coldcard", "path": "0001:0005:00"}] -``` - -For this example, we will use the Coldcard. As we can see, the device path is `0001:0005:00`. The fingerprint of the master key is `8038ecd9`. Now that we have the device, we can issue commands to it. So now we want to get some keys and import them into Core. -We will be fetching keys at the BIP 84 default. If `--path` and `--internal` are not -specified, both receiving and change address descriptors are generated. - -``` -$ ./hwi.py -f 8038ecd9 getkeypool --wpkh 0 1000 -[{"desc": "wpkh([8038ecd9/84h/0h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/0/*)#36sal9a4", "internal": false, "range": [0, 1000], "timestamp": "now", "keypool": true, "watchonly": true}, {"desc": "wpkh([8038ecd9/84h/0h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/1/*)#nl2rc26w", "internal": true, "range": [0, 1000], "timestamp": "now", "keypool": true, "watchonly": true}] -``` - -We now create a new Bitcoin Core wallet and import the keys into Bitcoin Core. The output is formatted properly for Bitcoin Core so it can be copy and pasted. - -``` -$ ../bitcoin/src/bitcoin-cli createwallet "coldcard" true -{ - "name": "coldcard", - "warning": "" -} -$ ../bitcoin/src/bitcoin-cli -rpcwallet=coldcard importmulti '[{"desc": "wpkh([8038ecd9/84h/0h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/0/*)#36sal9a4", "internal": false, "range": [0, 1000], "timestamp": "now", "keypool": true, "watchonly": true}, {"desc": "wpkh([8038ecd9/84h/0h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/1/*)#nl2rc26w", "internal": true, "range": [0, 1000], "timestamp": "now", "keypool": true, "watchonly": true}]' - -[ - { - "success": true - }, - { - "success": true - } -] -``` - -The Bitcoin Core wallet is now setup to watch two thousand keys (1000 normal, 1000 change) from your hardware wallet and you can use it to track your balances and create transactions. The transactions will need to be signed through HWI. - -If the wallet was previously used, you will need to rescan the blockchain. You can either do this using the `rescanblockchain` command or editing the `timestamp` in the `importmulti` command. -Here are some examples (`` refers to a block height before the wallet was created). - -``` -$ ../bitcoin/src/bitcoin-cli rescanblockchain -$ ../bitcoin/src/bitcoin-cli rescanblockchain 500000 # Rescan from block 500000 - -$ ../bitcoin/src/bitcoin-cli -rpcwallet=coldcard importmulti '[{"internal": true, "timestamp": , "desc": "wpkh([8038ecd9/84h/0h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/1/*)#qw4uzsdd", "keypool": true, "range": [0, 1000], "watchonly": true}]' -$ ../bitcoin/src/bitcoin-cli -rpcwallet=coldcard importmulti '[{"internal": true, "timestamp": 500000, "desc": "wpkh([8038ecd9/84h/0h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/1/*)#qw4uzsdd", "keypool": true, "range": [0, 1000], "watchonly": true}]' # Imports and rescans from block 500000 -``` - -## Usage - -Usage of this primarily involves Bitcoin Core. Currently the GUI only supports generating new receive addresses (once all of the keys are imported) so this guide will only cover the command line. - -### Receiving - -From the folder containing `bitcoin` and `HWI`, go into `bitcoin`. We will be doing most of the commands here. - -``` -$ cd bitcoin -``` - -To get a new address, use `getnewaddress` as you normally would - -``` -$ src/bitcoin-cli -rpcwallet=coldcard getnewaddress -bcrt1qu8qe24zq5e2ahh4nkl6g5ysxlpn3nyf0xt026s -``` - -This address belongs to your hardware wallet. You can check this by doing `getaddressinfo`: - -``` -$ src/bitcoin-cli -rpcwallet=coldcard getaddressinfo bcrt1qu8qe24zq5e2ahh4nkl6g5ysxlpn3nyf0xt026s -{ - "address": "bcrt1qu8qe24zq5e2ahh4nkl6g5ysxlpn3nyf0xt026s", - "scriptPubKey": "0014e1c1955440a655dbdeb3b7f48a1206f86719912f", - "ismine": false, - "iswatchonly": true, - "solvable": true, - "isscript": false, - "iswitness": true, - "witness_version": 0, - "witness_program": "e1c1955440a655dbdeb3b7f48a1206f86719912f", - "pubkey": "022320f1cf72e7ba2cef6be32d7493ce3bd4c6a2575fe51ce260377adc165603d4", - "label": "", - "ischange": false, - "timestamp": 1541688305, - "hdkeypath": "m/84'/1'/0'/0/0", - "hdseedid": "0000000000000000000000000000000000000000", - "hdmasterkeyid": "00000000000000000000000000000000d9ec3880", - "labels": [ - { - "name": "", - "purpose": "receive" - } - ] -} - -``` -Notice how the pubkey is the one that was specified as the very first thing being imported to your wallet. - -You can give this out to people as you normally would. When coins are sent to it, you will see them in your Bitcoin Core wallet as watch-only. - -## Sending - -To send Bitcoin, we will use `walletcreatefundedpsbt`. This will create a Partially Signed Bitcoin Transaction which is funded by inputs from the wallets (i.e. your watching only inputs selected with Bitcoin Core's coin selection algorithm). -This PSBT can be used with HWI to produce a signed PSBT which can then be finalized and broadcast. - -For example, suppose I am sending to 1 BTC to bc1q257z5t76hedc36wmmzva05890ny3kxd7xfwrgy. First I create a funded psbt with BIP 32 derivation paths to be included: -``` -$ src/bitcoin-cli -rpcwallet=coldcard walletcreatefundedpsbt '[]' '[{"bc1q257z5t76hedc36wmmzva05890ny3kxd7xfwrgy":1}]' 0 '{"includeWatching":true}' true -{ - "psbt": "cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgYCIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9QYgDjs2VQAAIABAACAAAAAgAAAAAAAAAAAAAAiAgP0HMQ2K693zCXTCudBUzemDhxLmFGETOnAV7vgDz2r9RiAOOzZVAAAgAEAAIAAAACAAQAAAAAAAAAA", - "fee": 0.00002820, - "changepos": 1 -} - -``` - -Now I take the updated psbt and inspect it with `decodepsbt`: - -``` -$ src/bitcoin-cli decodepsbt cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgYCIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9QYgDjs2VQAAIABAACAAAAAgAAAAAAAAAAAAAAiAgP0HMQ2K693zCXTCudBUzemDhxLmFGETOnAV7vgDz2r9RiAOOzZVAAAgAEAAIAAAACAAQAAAAAAAAAA -{ - "tx": { - "txid": "e51392c82e13bbfe714c73361aff14ac1a1637abf37587a562844ae5a4265adf", - "hash": "e51392c82e13bbfe714c73361aff14ac1a1637abf37587a562844ae5a4265adf", - "version": 2, - "size": 113, - "vsize": 113, - "weight": 452, - "locktime": 0, - "vin": [ - { - "txid": "b61f6f2e9a11558bcbdf12dfcb5dbd5aa1cbde621e9918600c7eec94405a0a4f", - "vout": 0, - "scriptSig": { - "asm": "", - "hex": "" - }, - "sequence": 4294967294 - } - ], - "vout": [ - { - "value": 1.00000000, - "n": 0, - "scriptPubKey": { - "asm": "0 553c2a2fdabe5b88e9dbd899d7d0e57cc91b19be", - "hex": "0014553c2a2fdabe5b88e9dbd899d7d0e57cc91b19be", - "reqSigs": 1, - "type": "witness_v0_keyhash", - "addresses": [ - "bc1q257z5t76hedc36wmmzva05890ny3kxd7xfwrgy" - ] - } - }, - { - "value": 3.99997180, - "n": 1, - "scriptPubKey": { - "asm": "0 b1ee5f7591b8fb37ca97903b388dc39a859411fc", - "hex": "0014b1ee5f7591b8fb37ca97903b388dc39a859411fc", - "reqSigs": 1, - "type": "witness_v0_keyhash", - "addresses": [ - "bc1qk8h97av3hran0j5hjqan3rwrn2zegy0unusy49" - ] - } - } - ] - }, - "unknown": { - }, - "inputs": [ - { - "witness_utxo": { - "amount": 5.00000000, - "scriptPubKey": { - "asm": "0 e1c1955440a655dbdeb3b7f48a1206f86719912f", - "hex": "0014e1c1955440a655dbdeb3b7f48a1206f86719912f", - "type": "witness_v0_keyhash", - "address": "bc1qu8qe24zq5e2ahh4nkl6g5ysxlpn3nyf0wyd5k2" - } - }, - "bip32_derivs": [ - { - "pubkey": "022320f1cf72e7ba2cef6be32d7493ce3bd4c6a2575fe51ce260377adc165603d4", - "master_fingerprint": "8038ecd9", - "path": "m/84'/1'/0'/0/0" - } - ] - } - ], - "outputs": [ - { - }, - { - "bip32_derivs": [ - { - "pubkey": "03f41cc4362baf77cc25d30ae7415337a60e1c4b9851844ce9c057bbe00f3dabf5", - "master_fingerprint": "8038ecd9", - "path": "m/84'/1'/0'/1/0" - } - ] - } - ], - "fee": 0.00002820 -} - -``` - -Once the transaction has been inspected and everything looks good, the transaction can now be signed using HWI. - -``` -$ cd ../HWI -$ ./hwi.py -f 8038ecd9 --testnet signtx cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgYCIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9QYgDjs2VQAAIABAACAAAAAgAAAAAAAAAAAAAAiAgP0HMQ2K693zCXTCudBUzemDhxLmFGETOnAV7vgDz2r9RiAOOzZVAAAgAEAAIAAAACAAQAAAAAAAAAA - -``` -Follow the onscreen instructions, check everything, and approve the transaction. The result will look like: -``` -{"psbt": "cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgICIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9RIMEUCIQDMECVXsrFK5XbMQn5yVCvm3zWF1kdCgepf3RSqFDDmAAIgQtty07rN4zBWMjd1qVOtkgOHBAlGaO2Se3LkiNsABYcBAQMEAQAAACIGAiMg8c9y57os72vjLXSTzjvUxqJXX+Uc4mA3etwWVgPUGIA47NlUAACAAQAAgAAAAIAAAAAAAAAAAAAAIgID9BzENiuvd8wl0wrnQVM3pg4cS5hRhEzpwFe74A89q/UYgDjs2VQAAIABAACAAAAAgAEAAAAAAAAAAA=="} -``` - -We can now take the PSBT, finalize it, and broadcast it with Bitcoin Core - -``` -$ cd ../bitcoin -$ src/bitcoin-cli finalizepsbt cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgICIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9RIMEUCIQDMECVXsrFK5XbMQn5yVCvm3zWF1kdCgepf3RSqFDDmAAIgQtty07rN4zBWMjd1qVOtkgOHBAlGaO2Se3LkiNsABYcBAQMEAQAAACIGAiMg8c9y57os72vjLXSTzjvUxqJXX+Uc4mA3etwWVgPUGIA47NlUAACAAQAAgAAAAIAAAAAAAAAAAAAAIgID9BzENiuvd8wl0wrnQVM3pg4cS5hRhEzpwFe74A89q/UYgDjs2VQAAIABAACAAAAAgAEAAAAAAAAAAA== -{ - "hex": "020000000001014f0a5a4094ec7e0c6018991e62decba15abd5dcbdf12dfcb8b55119a2e6f1fb60000000000feffffff0200e1f50500000000160014553c2a2fdabe5b88e9dbd899d7d0e57cc91b19befc78d71700000000160014b1ee5f7591b8fb37ca97903b388dc39a859411fc02483045022100cc102557b2b14ae576cc427e72542be6df3585d6474281ea5fdd14aa1430e600022042db72d3bacde33056323775a953ad92038704094668ed927b72e488db0005870121022320f1cf72e7ba2cef6be32d7493ce3bd4c6a2575fe51ce260377adc165603d400000000", - "complete": true -} -$ src/bitcoin-cli sendrawtransaction 020000000001014f0a5a4094ec7e0c6018991e62decba15abd5dcbdf12dfcb8b55119a2e6f1fb60000000000feffffff0200e1f50500000000160014553c2a2fdabe5b88e9dbd899d7d0e57cc91b19befc78d71700000000160014b1ee5f7591b8fb37ca97903b388dc39a859411fc02483045022100cc102557b2b14ae576cc427e72542be6df3585d6474281ea5fdd14aa1430e600022042db72d3bacde33056323775a953ad92038704094668ed927b72e488db0005870121022320f1cf72e7ba2cef6be32d7493ce3bd4c6a2575fe51ce260377adc165603d400000000 -e51392c82e13bbfe714c73361aff14ac1a1637abf37587a562844ae5a4265adf -``` - -### Refilling the keypools - -When the keypools run out, they can be refilled by using the `getkeypool` commands as done in the beginning, but with different starting and ending indexes. For example, to refill my keypools, I would use the following `getkeypool` commands: - -``` -$ ./hwi.py -f 8038ecd9 getkeypool --wpkh 1000 2000 -$ ./hwi.py -f 8038ecd9 getkeypool --wpkh --internal 1000 2000 -``` -The output can be imported with `importmulti` as shown in the Setup steps. - -## Derivation Path BIP Compliance - -The instructions above use BIP 84 to derive keys used for P2WPKH addresses (bech32 addresses). -HWI follows BIPs 44, 84, and 49. By default, descriptors will be for P2PKH addresses with keys derived at `m/44h/0h/0h/0` for normal receiving keys and `m/44h/0h/0h/1` for change keys. -Using the `--wpkh` option will result in P2WPKH addresses with keys derived at `m/84h/0h/0h/0` for normal receiving keys and `m/84h/0h/0h/1` for change keys. -Using the `sh_wpkh` option will result in P2SH nested P2WPKH addresses with keys derived at `m/49h/0h/0h/0` for normal receiving keys and `m/49h/0h/0h/1` for change keys. - -To actually get the correct address type when using `getnewaddress` from Bitcoin Core, you will need to additionally set `-addresstype=p2sh-segwit` and `-changetype=p2sh-segwit`. -This can be set in the command line (as shown in the example) or in your bitcoin.conf file. - -Alternative derivation paths can also be chosen using the `--path` option and specifying your own derivation path. diff --git a/docs/coldcard.md b/docs/coldcard.md deleted file mode 100644 index 236f9f8c2..000000000 --- a/docs/coldcard.md +++ /dev/null @@ -1,27 +0,0 @@ -# ColdCard - -The ColdCard is partially supported by HWI - -Current implemented commands are: - -* `getmasterxpub` -* `signtx` (only single key) -* `getxpub` -- `setup` -- `wipe` -- `restore` -- `backup` -- `displayaddress` -- `signmessage` - -## Notes on `setup`, `wipe`, and `restore` - -The Coldcard does not allow you to setup, wipe, or restore the device via software. That is done on the device itself. The implementation here is just to let users know those commands do not work. - -## Note on `backup` - -The `backup` command will create a backup file in the current working directory. This file is protected by the passphrase shown on the Coldcard during the backup process. - -## Caveat for `signtx` - -The Coldcard firmware only supports signing single key transactions. It cannot sign multisig or arbitrary scripts yet. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..5956eda79 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,75 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +sys.path.insert(0, os.path.abspath('..')) + + +# -- Project information ----------------------------------------------------- + +project = 'Hardware Wallet Interface' +copyright = '2021, The Hardware Wallet Interface Developers' +author = 'The Hardware Wallet Interface Developers' + +# The full version, including alpha/beta/rc tags +release = '2.0.0-dev' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx_rtd_theme", + "sphinxcontrib.autoprogram", +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# Autodoc options +autodoc_default_options = { + "inherited-members": True, +} + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# Autodoc config to include type hints in the description +autodoc_typehints = "description" + +# Order the autodoc members by type +autodoc_member_order = "bysource" + +# Show both class and init docstring +autoclass_content = "both" + +# Mock these imports +autodoc_mock_imports = ["hid", "ecdsa", "pyaes", "mnemonic", "typing_extensions", "usb1", "PySide2"] diff --git a/docs/development/index.rst b/docs/development/index.rst new file mode 100644 index 000000000..43c58b57b --- /dev/null +++ b/docs/development/index.rst @@ -0,0 +1,8 @@ +Development +*********** + +.. toctree:: + :caption: Contents: + + release-process + internal-api diff --git a/docs/development/internal-api.rst b/docs/development/internal-api.rst new file mode 100644 index 000000000..3de8092ed --- /dev/null +++ b/docs/development/internal-api.rst @@ -0,0 +1,13 @@ +Internal API Documentation +========================== + +In addition to the public API, the classes and functions documented here are available for use within HWI itself. + +.. automodule:: hwilib._base58 + :members: +.. automodule:: hwilib._bech32 + :members: +.. automodule:: hwilib._script + :members: +.. automodule:: hwilib._serialize + :members: diff --git a/docs/development/release-process.rst b/docs/development/release-process.rst new file mode 100644 index 000000000..9e764b84f --- /dev/null +++ b/docs/development/release-process.rst @@ -0,0 +1,46 @@ +Release Process +*************** + +1. Bump version number in ``pyproject.toml`` and ``hwilib/__init__.py``, generate the setup.py file, and git tag release +2. Build distribution archives for PyPi with ``contrib/build_dist.sh`` +3. For MacOS and Linux, use ``contrib/build_bin.sh``. This needs to be run on a macOS machine for the macOS binary and on a Linux machine for the linux one. +4. For Windows, use ``contrib/build_wine.sh`` to build the Windows binary using wine +5. Make ``SHA256SUMS.txt`` using ``contrib/make_shasums.sh``. +6. Make ``SHA256SUMS.txt.asc`` using ``gpg --clearsign SHA256SUMS.txt`` +7. Upload distribution archives to PyPi +8. Upload distribution archives and standalone binaries to Github + +Deterministic builds with Docker +================================ + +Create the docker image:: + + docker build --no-cache -t hwi-builder -f contrib/build.Dockerfile . + +Build everything:: + + docker run -it --name hwi-builder -v $PWD:/opt/hwi --rm --workdir /opt/hwi hwi-builder /bin/bash -c "contrib/build_bin.sh && contrib/build_dist.sh && contrib/build_wine.sh" + +Building macOS binary +===================== + +Note that the macOS build is non-deterministic. + +First install `pyenv `_ using whichever method you prefer. + +Then a deterministic build of Python 3.9.7 needs to be installed. This can be done with the patch in ``contrib/reproducible-python.diff``. First ``cd`` into HWI's source tree. Then use:: + + cat contrib/reproducible-python.diff | PYTHON_CONFIGURE_OPTS="--enable-framework" BUILD_DATE="Jan 1 2019" BUILD_TIME="00:00:00" pyenv install -kp 3.9.7 + +Make sure that python 3.9.7 is active:: + + $ python --version + Python 3.9.7 + +Now install `Poetry `_ with ``pip install poetry`` + +Additional dependencies can be installed with:: + + brew install libusb + +Build the binaries by using ``contrib/build_bin.sh``. diff --git a/docs/devices/index.rst b/docs/devices/index.rst new file mode 100644 index 000000000..62bd179ed --- /dev/null +++ b/docs/devices/index.rst @@ -0,0 +1,110 @@ +Supported Devices +***************** + +Support Matrix +============== + +The table below lists what devices and features are supported for each device. + +* ``✓`` - supported by the firmware and implemented in HWI +* ``✗`` - supported by the firmware and not implemented in HWI +* ``―`` - not supported by the firmware + ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Feature \\ Device | Ledger Nano X | Ledger Nano S | Trezor One | Trezor Model T | BitBox01 | BitBox02 | KeepKey | Coldcard | Blockstream Jade | ++====================================+===============+===============+==============+================+==========+==========+=========+==========+==================+ +| Support Planned | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Implemented | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| xpub retrieval | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Message Signing | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Device Setup | ― | ― | ✓ | ✓ | ✓ | ✓ | ✓ | ― | ― | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Device Wipe | ― | ― | ✓ | ✓ | ✓ | ✓ | ✓ | ― | ― | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Device Recovery | ― | ― | ✓ | ✓ | ― | ✓ | ✓ | ― | ― | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Device Backup | ― | ― | ― | ― | ✓ | ✓ | ― | ✓ | ― | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| P2PKH Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ― | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| P2SH-P2WPKH Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| P2WPKH Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| P2SH Multisig Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ― | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| P2SH-P2WSH Multisig Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| P2WSH Multisig Inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Bare Multisig Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Taproot Inputs | ✓* | ✓* | ✓* | ✓* | ― | ― | ― | ― | ― | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Arbitrary scriptPubKey Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Arbitrary redeemScript Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Arbitrary witnessScript Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Non-wallet inputs | ✓ | ✓ | ✗\ :sup:`1` | ✗\ :sup:`2` | ✓ | ― | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Mixed Segwit and Non-Segwit Inputs | ― | ― | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ +| Display on device screen | ✓ | ✓ | ✓ | ✓ | ― | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+--------------+----------------+----------+----------+---------+----------+------------------+ + +* 1 - Support removed for devices with firmware 1.10.6 and greater. +* 2 - Support removed for devices with firmware 2.4.4 and greater. + +\* There are some caveats. See the `sign_tx` for these devices. + +Support Policy +================ + +For a device to be supported by HWI, it must: + +* Use open source firmware as much as possible + + * Entirely closed source devices will be rejected + * Devices may have closed source firmware components if required to under a NDA (e.g. a secure element with NDA) + +* Publicly documented communication protocol + + * It is preferred to both document the protocol and provide a Python library for using it + * The library, with its own documentation, can suffice as "publicly documented" + +* Either (but preferably both): + + * A simulator/emulator is available for automated tests to be run + * A promise to maintain and support from the vendor: + +Device support may be dropped: + +* If promised vendor maintenance and support disappears + + * If there are continuous issues with the device and the vendor has failed to provide support and updates + +* If the device no longer receives security updates and there are known vulnerabilities and issues + +Device APIs +=========== + +.. automodule:: hwilib.devices.ledger + :members: +.. automodule:: hwilib.devices.trezor + :members: +.. automodule:: hwilib.devices.digitalbitbox + :members: +.. automodule:: hwilib.devices.bitbox02 + :members: +.. automodule:: hwilib.devices.keepkey + :members: +.. automodule:: hwilib.devices.coldcard + :members: +.. automodule:: hwilib.devices.jade + :members: diff --git a/docs/digitalbitbox.md b/docs/digitalbitbox.md deleted file mode 100644 index 1c83efeba..000000000 --- a/docs/digitalbitbox.md +++ /dev/null @@ -1,35 +0,0 @@ -# Digital BitBox - -The Digital BitBox is supported by HWI - -Current implemented commands are: - -* `getmasterxpub` -* `signtx` -* `getxpub` (with some caveats) -- `setup` -- `wipe` -- `restore` -- `backup` -- `displayaddress` -- `signmessage` - -## Usage Notes - -You must specify your Digital BitBox password using the `-p` option. E.g. - -``` -./hwi.py -t digitalbitbox -d 0001:0001:00 -p password getmasterxpub -``` - -## `getxpub` Caveats - -The Digital BitBox requires that one of the levels in the derivation path is hardened. - -## Note on `restore` - -The Digital BitBox does not allow users to restore a backup or seed via software. - -## Note on `displayaddress` - -The Digital BitBox does not have a screen to display an address on, so the implementation just raises an error stating this. diff --git a/docs/examples.md b/docs/examples.md deleted file mode 100644 index 30b4a1443..000000000 --- a/docs/examples.md +++ /dev/null @@ -1,145 +0,0 @@ -# Examples - -Example using a Ledger Nano S: - -``` -./hwi.py enumerate -[{"type": "ledger", "path": "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0", "serial_number": "0001"}, {"type": "ledger", "path": "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@1/IOUSBHostHIDDevice@14200000,1", "serial_number": "0001"}] -``` -The OS in this case is macOS v10.13.6 (Darwin Kernel Version 17.7.0). In Linux the -"path" is shorter. - -## Extracting xpubs - -Bitcoin Core v0.17.0 and later allows you to retrieve the unspent transaction outputs (utxo) -relevant for a set of [Output Descriptors](https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md) with the `scantxoutset` RPC call. - -To retrieve the outputs relevant for a specific hardware wallet it is -necessary: - -1. to derive the xpub of the hardware wallet until the last hardened level - with HWI (because the private key is required) -2. to use the obtained xpub to compose the output descriptor - -These are some schemas used in hardware wallets, with the data necessary to -build the appropriate output descriptor: - -| Used schema | hardened path | further derivation | Output type | -|-------------| ------------- | -------------------|-------------| -| BIP44 | m/44h/0h/0h | /0/* and /1/* | pkh() | -| BIP49 | m/49h/0h/0h | /0/* and /1/* | sh(wpkh()) | -| BIP84 | m/84h/0h/0h | /0/* and /1/* | wpkh() | - -NOTE: -1. We could also use "combo()" in all cases as "Output Type" because it is a - "bundle" which includes pk(KEY) and pkh(KEY). If the key is compressed, it - also includes wpkh(KEY) and sh(wpkh(KEY)). - -2. It is possible to specify how many outputs to search for by setting the - maximum index of the derivation with the "range" key. In the examples - it is set to 100. - -3. The search returns zero outputs (the hardware wallet is empty). - -### [BIP44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) - -1. To obtain the xpub relative to the last hardened level (m/44h/0h/0h) - -``` -./hwi.py -t "ledger" -d "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0" getxpub m/44h/0h/0h -=> b'e0c4000000' -<= b'1b30010208010003'9000 -=> b'f026000000' -<= b''6d00 -=> b'e04000000d038000002c8000000080000000' -<= b'4104f4b866b49fb76529a076a1c5b25216c1f4b970cb8e3db9874beb15c5371fdb93747fde522d63be4a564dcda8a71c889f5165eac2990cafee9d416141ae8b09c722313667774c7a76697157783146317a653365676850464d58655438666a57466f4b66f9a82310c4530360ec3fee42049fbb7a3c0bfa72fdf2c5b25b09f1c3df21c938'9000 -=> b'e040000009028000002c80000000' -<= b'4104280c846650d7771396a679a55b30c558501f0b5554160c1fbd1d7301c845dacc10c256af2c8d6a13ae4a83763fa747c0d4c09cfa60bfc16714e10b0a938a4a6a2231485451557a6535486571334872553755435174564652745a535839615352674a65d62f97789c088a0b0c3ed57754f75273c6696c0d7812c702ca4f2f72c8631c04'9000 -{"xpub": "xpub6CyidiQae2HF71YigFJqteLsRi9D1EvZJm1Lr4DWWxFVruf3vDSbfyxD9znqVkUTUzc4EdgxDRoHXn64gMbFXQGKXg5nPNfvyVcpuPNn92n"} -``` - -2. With this xpub it is possible extract the relevant UTXOs using the -`scantxoutset` RPC call in Bitcoin Core v0.17.0. - -``` -bitcoin-cli scantxoutset start '[{"desc":"pkh(xpub6CyidiQae2HF71YigFJqteLsRi9D1EvZJm1Lr4DWWxFVruf3vDSbfyxD9znqVkUTUzc4EdgxDRoHXn64gMbFXQGKXg5nPNfvyVcpuPNn92n/0/*)","range":100}, - {"desc":"pkh(xpub6CyidiQae2HF71YigFJqteLsRi9D1EvZJm1Lr4DWWxFVruf3vDSbfyxD9znqVkUTUzc4EdgxDRoHXn64gMbFXQGKXg5nPNfvyVcpuPNn92n/1/*)","range":100}]' -{ - "success": true, - "searched_items": 49507771, - "unspents": [ - ], - "total_amount": 0.00000000 -} -``` - -### [BIP49](https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki) - -1. To obtain the xpub relative to the last hardened level (m/49h/0h/0h) - -``` - ./hwi.py -t "ledger" -d "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0" getxpub m/49h/0h/0h -=> b'e0c4000000' -<= b'1b30010208010003'9000 -=> b'f026000000' -<= b''6d00 -=> b'e04000000d03800000318000000080000000' -<= b'410437c2c1ebd83155843b3e8528b43b9786a8dc144df151b27677b76443e54b466d46b0d909d07065a2305cbba41709c78d886be37e446352186a682e9a3f9e2adc22314a594538323869434b7043576368665377396832746857377a533469486e4c444444dcdbabc6f75fbe7609bab04beb88566e3bfc98f66ab030d1af2a070f4064ec'9000 -=> b'e040000009028000003180000000' -<= b'4104c34926ea569d26e4ca06ccae25fa4332a07df69fb922a73131cfccf6a544aa3309af253eb5cee3caf8ca9a347a9e8d4429ac55b7a13f72aca36ebb51ca0f489e22314e546e3969454c587046324264664b6f326f316265785a72526e75396d65764663b310aae1803b63157ef3bb7394f985126e5f9ad4b3a6bcb118cd97875dc0e1ce'9000 -{"xpub": "xpub6DP8WTA5cy2qWzdtjMUpLJHkzonepEZytzxFLMzkrcW7U4prscYnmXRQ8BesvMP3iqgQUWisAU6ipXnZw2HnNreEPYJW6TUCAfmwJPyYgG6"} -``` -2. With this xpub it is possible extract the relevant UTXOs using the -`scantxoutset` RPC call in Bitcoin Core v0.17.0. - -``` -bitcoin-cli scantxoutset start '[{"desc":"sh(wpkh(xpub6DP8WTA5cy2qWzdtjMUpLJHkzonepEZytzxFLMzkrcW7U4prscYnmXRQ8BesvMP3iqgQUWisAU6ipXnZw2HnNreEPYJW6TUCAfmwJPyYgG6/0/*))","range":100}, - {"desc":"sh(wpkh(xpub6DP8WTA5cy2qWzdtjMUpLJHkzonepEZytzxFLMzkrcW7U4prscYnmXRQ8BesvMP3iqgQUWisAU6ipXnZw2HnNreEPYJW6TUCAfmwJPyYgG6/1/*))","range":100}]' -{ - "success": true, - "searched_items": 49507771, - "unspents": [ - ], - "total_amount": 0.00000000 -} -``` - -### [BIP84](https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki) - -1. To obtain the xpub relative to the last hardened level (m/84h/0h/0h) - -``` - ./hwi.py -t "ledger" -d "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0" getxpub m/84h/0h/0h -=> b'e0c4000000' -<= b'1b30010208010003'9000 -=> b'f026000000' -<= b''6d00 -=> b'e04000000d03800000548000000080000000' -<= b'4104c79ce10d23b84ec27996e02b83964ec1953fb474ba358e70de62a09cee28dd6590f76b105fb2707c74bbefff0b4aea4156364dd813826848e8c3240d286781b722314270736737486455576a483753704535386e6d62654642773367595a554536776b2017f28f680893adfc004f5ec6db3654577c19b463326329b5d1d90de8dc24cf'9000 -=> b'e040000009028000005480000000' -<= b'410483472c03c4157d1b0f8ad98c9391dfbfc820e0180d683658ed863609da5f866aafa260048bc42cd97cb997479fd2619c5d160af68a442a80567b41fe3e763fbe22314e5531544d796971575871367278746375424a3433376d4e75736d745a73554769c03458c3a331489e3271a24a76f4ab024e040e7de7b5e88d8ce058d414f565c2'9000 -{"xpub": "xpub6DP9afdc7qsz7s7mwAvciAR2dV6vPC3gyiQbqKDzDcPAq3UQChKPimHc3uCYfTTkpoXdwRTFnVTBdFpM9ysbf6KV34uMqkD3zXr6FzkJtcB"} -``` - -2. With this xpub it is possible extract the relevant UTXOs using the -`scantxoutset` RPC call in Bitcoin Core v0.17.0. - -``` -bitcoin-cli scantxoutset start '[{"desc":"wpkh(xpub6DP9afdc7qsz7s7mwAvciAR2dV6vPC3gyiQbqKDzDcPAq3UQChKPimHc3uCYfTTkpoXdwRTFnVTBdFpM9ysbf6KV34uMqkD3zXr6FzkJtcB/0/*)","range":100}, - {"desc":"wpkh(xpub6DP9afdc7qsz7s7mwAvciAR2dV6vPC3gyiQbqKDzDcPAq3UQChKPimHc3uCYfTTkpoXdwRTFnVTBdFpM9ysbf6KV34uMqkD3zXr6FzkJtcB/1/*)","range":100}]' -{ - "success": true, - "searched_items": 49507771, - "unspents": [ - ], - "total_amount": 0.00000000 -} -``` - -### Binary format handling - -The input and output format supported by HWI is base64, which is prescribed by BIP174 as the string format. Note that the PSBT standard also allows for binary formatting when stored as a file. There is no direct support within HWI, but this can be easily accomplished using common utilities. A bash command-line example is detailed below, where the PSBT binary file is stored in `example.psbt` and only the common utilities `base64` and `jq` are required: - -``` -cat example.psbt | base64 --wrap=0 | ./hwi.py -t ledger --stdin signtx | jq .[] --raw-output | base64 -d > example_result.psbt -``` diff --git a/docs/examples/examples.rst b/docs/examples/examples.rst new file mode 100644 index 000000000..5498222f9 --- /dev/null +++ b/docs/examples/examples.rst @@ -0,0 +1,156 @@ +Examples +******** + +Example using a Ledger Nano S:: + + ./hwi.py enumerate + [{"type": "ledger", "path": "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0", "serial_number": "0001"}, {"type": "ledger", "path": "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@1/IOUSBHostHIDDevice@14200000,1", "serial_number": "0001"}] + +The OS in this case is macOS v10.13.6 (Darwin Kernel Version 17.7.0). In Linux the +"path" is shorter. + +Extracting xpubs +================ + +Bitcoin Core v0.17.0 and later allows you to retrieve the unspent transaction outputs (utxo) +relevant for a set of `Output Descriptors `_ with the ``scantxoutset`` RPC call. + +To retrieve the outputs relevant for a specific hardware wallet it is +necessary: + +1. to derive the xpub of the hardware wallet until the last hardened level + with HWI (because the private key is required) +2. to use the obtained xpub to compose the output descriptor + +These are some schemas used in hardware wallets, with the data necessary to +build the appropriate output descriptor: + ++-------------+---------------+--------------------+-------------+ +| Used schema | hardened path | further derivation | Output type | ++=============+===============+====================+=============+ +| BIP44 | m/44h/0h/0h | /0/* and /1/* | pkh() | ++-------------+---------------+--------------------+-------------+ +| BIP49 | m/49h/0h/0h | /0/* and /1/* | sh(wpkh()) | ++-------------+---------------+--------------------+-------------+ +| BIP84 | m/84h/0h/0h | /0/* and /1/* | wpkh() | ++-------------+---------------+--------------------+-------------+ + +NOTE: + +1. We could also use "combo()" in all cases as "Output Type" because it is a + "bundle" which includes pk(KEY) and pkh(KEY). If the key is compressed, it + also includes wpkh(KEY) and sh(wpkh(KEY)). + +2. It is possible to specify how many outputs to search for by setting the + maximum index of the derivation with the "range" key. In the examples + it is set to 100. + +3. The search returns zero outputs (the hardware wallet is empty). + +`BIP44 `_ +------------------------------------------------------------------------- + +1. To obtain the xpub relative to the last hardened level (m/44h/0h/0h) + +:: + + ./hwi.py -t "ledger" -d "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0" getxpub m/44h/0h/0h + => b'e0c4000000' + <= b'1b30010208010003'9000 + => b'f026000000' + <= b''6d00 + => b'e04000000d038000002c8000000080000000' + <= b'4104f4b866b49fb76529a076a1c5b25216c1f4b970cb8e3db9874beb15c5371fdb93747fde522d63be4a564dcda8a71c889f5165eac2990cafee9d416141ae8b09c722313667774c7a76697157783146317a653365676850464d58655438666a57466f4b66f9a82310c4530360ec3fee42049fbb7a3c0bfa72fdf2c5b25b09f1c3df21c938'9000 + => b'e040000009028000002c80000000' + <= b'4104280c846650d7771396a679a55b30c558501f0b5554160c1fbd1d7301c845dacc10c256af2c8d6a13ae4a83763fa747c0d4c09cfa60bfc16714e10b0a938a4a6a2231485451557a6535486571334872553755435174564652745a535839615352674a65d62f97789c088a0b0c3ed57754f75273c6696c0d7812c702ca4f2f72c8631c04'9000 + {"xpub": "xpub6CyidiQae2HF71YigFJqteLsRi9D1EvZJm1Lr4DWWxFVruf3vDSbfyxD9znqVkUTUzc4EdgxDRoHXn64gMbFXQGKXg5nPNfvyVcpuPNn92n"} + +2. With this xpub it is possible extract the relevant UTXOs using the +``scantxoutset`` RPC call in Bitcoin Core v0.17.0. + +:: + + bitcoin-cli scantxoutset start '[{"desc":"pkh(xpub6CyidiQae2HF71YigFJqteLsRi9D1EvZJm1Lr4DWWxFVruf3vDSbfyxD9znqVkUTUzc4EdgxDRoHXn64gMbFXQGKXg5nPNfvyVcpuPNn92n/0/*)","range":100}, + {"desc":"pkh(xpub6CyidiQae2HF71YigFJqteLsRi9D1EvZJm1Lr4DWWxFVruf3vDSbfyxD9znqVkUTUzc4EdgxDRoHXn64gMbFXQGKXg5nPNfvyVcpuPNn92n/1/*)","range":100}]' + { + "success": true, + "searched_items": 49507771, + "unspents": [ + ], + "total_amount": 0.00000000 + } + +`BIP49 `_ +------------------------------------------------------------------------- + +1. To obtain the xpub relative to the last hardened level (m/49h/0h/0h) + +:: + + ./hwi.py -t "ledger" -d "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0" getxpub m/49h/0h/0h + => b'e0c4000000' + <= b'1b30010208010003'9000 + => b'f026000000' + <= b''6d00 + => b'e04000000d03800000318000000080000000' + <= b'410437c2c1ebd83155843b3e8528b43b9786a8dc144df151b27677b76443e54b466d46b0d909d07065a2305cbba41709c78d886be37e446352186a682e9a3f9e2adc22314a594538323869434b7043576368665377396832746857377a533469486e4c444444dcdbabc6f75fbe7609bab04beb88566e3bfc98f66ab030d1af2a070f4064ec'9000 + => b'e040000009028000003180000000' + <= b'4104c34926ea569d26e4ca06ccae25fa4332a07df69fb922a73131cfccf6a544aa3309af253eb5cee3caf8ca9a347a9e8d4429ac55b7a13f72aca36ebb51ca0f489e22314e546e3969454c587046324264664b6f326f316265785a72526e75396d65764663b310aae1803b63157ef3bb7394f985126e5f9ad4b3a6bcb118cd97875dc0e1ce'9000 + {"xpub": "xpub6DP8WTA5cy2qWzdtjMUpLJHkzonepEZytzxFLMzkrcW7U4prscYnmXRQ8BesvMP3iqgQUWisAU6ipXnZw2HnNreEPYJW6TUCAfmwJPyYgG6"} + +2. With this xpub it is possible extract the relevant UTXOs using the +``scantxoutset`` RPC call in Bitcoin Core v0.17.0. + +:: + + bitcoin-cli scantxoutset start '[{"desc":"sh(wpkh(xpub6DP8WTA5cy2qWzdtjMUpLJHkzonepEZytzxFLMzkrcW7U4prscYnmXRQ8BesvMP3iqgQUWisAU6ipXnZw2HnNreEPYJW6TUCAfmwJPyYgG6/0/*))","range":100}, + {"desc":"sh(wpkh(xpub6DP8WTA5cy2qWzdtjMUpLJHkzonepEZytzxFLMzkrcW7U4prscYnmXRQ8BesvMP3iqgQUWisAU6ipXnZw2HnNreEPYJW6TUCAfmwJPyYgG6/1/*))","range":100}]' + { + "success": true, + "searched_items": 49507771, + "unspents": [ + ], + "total_amount": 0.00000000 + } + +`BIP84 `_ +------------------------------------------------------------------------- + +1. To obtain the xpub relative to the last hardened level (m/84h/0h/0h) + +:: + + ./hwi.py -t "ledger" -d "IOService:/AppleACPIPlatformExpert/PCI0@0/AppleACPIPCI/XHC1@14/XHC1@14000000/HS02@14200000/Nano S@14200000/Nano S@0/IOUSBHostHIDDevice@14200000,0" getxpub m/84h/0h/0h + => b'e0c4000000' + <= b'1b30010208010003'9000 + => b'f026000000' + <= b''6d00 + => b'e04000000d03800000548000000080000000' + <= b'4104c79ce10d23b84ec27996e02b83964ec1953fb474ba358e70de62a09cee28dd6590f76b105fb2707c74bbefff0b4aea4156364dd813826848e8c3240d286781b722314270736737486455576a483753704535386e6d62654642773367595a554536776b2017f28f680893adfc004f5ec6db3654577c19b463326329b5d1d90de8dc24cf'9000 + => b'e040000009028000005480000000' + <= b'410483472c03c4157d1b0f8ad98c9391dfbfc820e0180d683658ed863609da5f866aafa260048bc42cd97cb997479fd2619c5d160af68a442a80567b41fe3e763fbe22314e5531544d796971575871367278746375424a3433376d4e75736d745a73554769c03458c3a331489e3271a24a76f4ab024e040e7de7b5e88d8ce058d414f565c2'9000 + {"xpub": "xpub6DP9afdc7qsz7s7mwAvciAR2dV6vPC3gyiQbqKDzDcPAq3UQChKPimHc3uCYfTTkpoXdwRTFnVTBdFpM9ysbf6KV34uMqkD3zXr6FzkJtcB"} + +2. With this xpub it is possible extract the relevant UTXOs using the +``scantxoutset`` RPC call in Bitcoin Core v0.17.0. + +:: + + bitcoin-cli scantxoutset start '[{"desc":"wpkh(xpub6DP9afdc7qsz7s7mwAvciAR2dV6vPC3gyiQbqKDzDcPAq3UQChKPimHc3uCYfTTkpoXdwRTFnVTBdFpM9ysbf6KV34uMqkD3zXr6FzkJtcB/0/*)","range":100}, + {"desc":"wpkh(xpub6DP9afdc7qsz7s7mwAvciAR2dV6vPC3gyiQbqKDzDcPAq3UQChKPimHc3uCYfTTkpoXdwRTFnVTBdFpM9ysbf6KV34uMqkD3zXr6FzkJtcB/1/*)","range":100}]' + { + "success": true, + "searched_items": 49507771, + "unspents": [ + ], + "total_amount": 0.00000000 + } + +Binary format handling +====================== + +The input and output format supported by HWI is base64, which is prescribed by BIP174 as the string format. Note that the PSBT standard also allows for binary formatting when stored as a file. There is no direct support within HWI, but this can be easily accomplished using common utilities. A bash command-line example is detailed below, where the PSBT binary file is stored in ``example.psbt`` and only the common utilities ``base64`` and ``jq`` are required: + +:: + + cat example.psbt | base64 --wrap=0 | ./hwi.py -t ledger --stdin signtx | jq .[] --raw-output | base64 -d > example_result.psbt diff --git a/docs/examples/index.rst b/docs/examples/index.rst new file mode 100644 index 000000000..f95cedb8d --- /dev/null +++ b/docs/examples/index.rst @@ -0,0 +1,9 @@ +Example Usage +============= + +.. toctree:: + :maxdepth: 1 + + bitcoin-core-usage + examples + walkthrough/walkthrough diff --git a/docs/examples/syscoin-core-usage.rst b/docs/examples/syscoin-core-usage.rst new file mode 100644 index 000000000..ef143cd3e --- /dev/null +++ b/docs/examples/syscoin-core-usage.rst @@ -0,0 +1,322 @@ +Using Syscoin Core with Hardware Wallets +**************************************** + +This approach is fairly manual, requires the command line, and Syscoin Core >=4.4.2. + +Note: For this guide, code lines prefixed with ``$`` means that the command is typed in the terminal. Lines without ``$`` are output of the commands. + +Disclaimer +========== + +We are not liable for any coins that may be lost through this method. The software mentioned may have bugs. Use at your own risk. + +Software +-------- + +Syscoin Core +^^^^^^^^^^^^ + +This method of using hardware wallets uses Syscoin Core as the wallet for monitoring the blockchain. It allows a user to use their own full node instead of relying on an SPV wallet or vendor provided software. + +HWI works with Syscoin Core >=4.3.0. +However this guide will require Syscoin Core >=4.4.2 as it uses Descriptor Wallets. + +Setup +===== + +Clone Syscoin Core and build it. Clone HWI. + +:: + + $ git clone https://github.com/syscoin/syscoin.git + $ cd syscoin + $ ./autogen.sh + $ ./configure + $ make + $ src/syscoind -daemon -addresstype=bech32 -changetype=bech32 + $ cd .. + $ git clone https://github.com/syscoin/HWI.git + $ cd HWI + $ python3 setup.py install + +You may need some dependencies, on ubuntu install ``libudev-dev`` and ``libusb-1.0-0-dev`` + +Now we need to find our hardware wallet. We do this using:: + + $ ./hwi.py enumerate + [{"type": "ledger", "model": "ledger_nano_s_plus", "label": null, "path": "DevSrvsID:4294983427", "fingerprint": "c04584f8", "needs_pin_sent": false, "needs_passphrase_sent": false}] + +For this example, we will use the Coldcard. As we can see, the device path is ``0003:0005:00``. The fingerprint of the master key is ``c04584f8``. Now that we have the device, we can issue commands to it. So now we want to get some keys and import them into Core. +We will be fetching keys at the BIP 84 default. If ``--path`` and ``--internal`` are not +specified, both receiving and change address descriptors are generated. + +:: + + $ ./hwi.py -f c04584f8 getkeypool 0 1000 + [{"desc": "wpkh([c04584f8/84h/57h/0h]xpub6CbVv9x2j42AWb1RrkewKtu5FEgwMaJpj5WQNviJzT86imJcwXDVsXVvES2523dYVt2Yf9egTqeopes7jfH7oT2nkLtQqE1fByKxzf6fbNV/0/*)#la4zw75u", "range": [0, 1000], "timestamp": "now", "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([c04584f8/84h/57h/0h]xpub6CbVv9x2j42AWb1RrkewKtu5FEgwMaJpj5WQNviJzT86imJcwXDVsXVvES2523dYVt2Yf9egTqeopes7jfH7oT2nkLtQqE1fByKxzf6fbNV/1/*)#wfsrntyy", "range": [0, 1000], "timestamp": "now", "internal": true, "keypool": true, "active": true, "watchonly": true}] + +We now create a new Syscoin Core Descriptor Wallet and import the keys into Syscoin Core. The output is formatted properly for Syscoin Core so it can be copy and pasted. + +:: + + $ ../syscoin/src/syscoin-cli -named createwallet wallet_name=ledger disable_private_keys=true descriptors=true + { + "name": "ledger", + "warning": "" + } + $ ../syscoin/src/syscoin-cli -rpcwallet=ledger importdescriptors '[{"desc": "wpkh([c04584f8/84h/57h/0h]xpub6CbVv9x2j42AWb1RrkewKtu5FEgwMaJpj5WQNviJzT86imJcwXDVsXVvES2523dYVt2Yf9egTqeopes7jfH7oT2nkLtQqE1fByKxzf6fbNV/0/*)#la4zw75u", "range": [0, 1000], "timestamp": "now", "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([c04584f8/84h/57h/0h]xpub6CbVv9x2j42AWb1RrkewKtu5FEgwMaJpj5WQNviJzT86imJcwXDVsXVvES2523dYVt2Yf9egTqeopes7jfH7oT2nkLtQqE1fByKxzf6fbNV/1/*)#wfsrntyy", "range": [0, 1000], "timestamp": "now", "internal": true, "keypool": true, "active": true, "watchonly": true}]' + [ + { + "success": true + }, + { + "success": true + } + ] + +The Syscoin Core wallet is now setup to watch two thousand keys (1000 normal, 1000 change) from your hardware wallet and you can use it to track your balances and create transactions. The transactions will need to be signed through HWI. + +If the wallet was previously used, you will need to rescan the blockchain. You can either do this using the ``rescanblockchain`` command or editing the ``timestamp`` in the ``importdescriptors`` command. +Here are some examples (```` refers to a block height before the wallet was created). + +:: + + $ ../syscoin/src/syscoin-cli rescanblockchain + $ ../syscoin/src/syscoin-cli rescanblockchain 500000 # Rescan from block 500000 + + $ ../syscoin/src/syscoin-cli -rpcwallet=ledger importdescriptors '[{"desc": "wpkh([c04584f8/84h/57h/0h]xpub6CbVv9x2j42AWb1RrkewKtu5FEgwMaJpj5WQNviJzT86imJcwXDVsXVvES2523dYVt2Yf9egTqeopes7jfH7oT2nkLtQqE1fByKxzf6fbNV/0/*)#la4zw75u", "range": [0, 1000], "timestamp": , "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([c04584f8/84h/57h/0h]xpub6CbVv9x2j42AWb1RrkewKtu5FEgwMaJpj5WQNviJzT86imJcwXDVsXVvES2523dYVt2Yf9egTqeopes7jfH7oT2nkLtQqE1fByKxzf6fbNV/1/*)#wfsrntyy", "range": [0, 1000], "timestamp": , "internal": true, "keypool": true, "active": true, "watchonly": true}]' + $ ../syscoin/src/syscoin-cli -rpcwallet=ledger importdescriptors '[{"desc": "wpkh([c04584f8/84h/57h/0h]xpub6CbVv9x2j42AWb1RrkewKtu5FEgwMaJpj5WQNviJzT86imJcwXDVsXVvES2523dYVt2Yf9egTqeopes7jfH7oT2nkLtQqE1fByKxzf6fbNV/0/*)#la4zw75u", "range": [0, 1000], "timestamp": "500000", "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([c04584f8/84h/57h/0h]xpub6CbVv9x2j42AWb1RrkewKtu5FEgwMaJpj5WQNviJzT86imJcwXDVsXVvES2523dYVt2Yf9egTqeopes7jfH7oT2nkLtQqE1fByKxzf6fbNV/1/*)#wfsrntyy", "range": [0, 1000], "timestamp": "500000", "internal": true, "keypool": true, "active": true, "watchonly": true}]' # Imports and rescans from block 500000 + +Usage +===== + +Usage of this primarily involves Syscoin Core. Currently the GUI only supports generating new receive addresses (once all of the keys are imported) so this guide will only cover the command line. + +Receiving +--------- + +From the folder containing ``syscoin`` and ``HWI``, go into ``syscoin``. We will be doing most of the commands here. + +:: + + $ cd syscoin + +To get a new address, use ``getnewaddress`` as you normally would + +:: + + $ src/syscoin-cli -rpcwallet=ledger getnewaddress + sys1qvmnd8x2c59zfmkast45gc7k356mdm6jr20mgzh + +This address belongs to your hardware wallet. You can check this by doing ``getaddressinfo``:: + + $ src/syscoin-cli -rpcwallet=ledger getaddressinfo sys1qvmnd8x2c59zfmkast45gc7k356mdm6jr20mgzh + { + "address": "sys1qvmnd8x2c59zfmkast45gc7k356mdm6jr20mgzh", + "scriptPubKey": "001466e6d39958a1449ddbb05d688c7ad1a6b6ddea43", + "ismine": true, + "solvable": true, + "desc": "wpkh([c04584f8/84'/57'/0'/0/0]020815f579ead84a2889b1181270e60d8aea86ed33f0910465c7b7bb4df9468660)#ky5qntkj", + "parent_desc": "wpkh([c04584f8/84'/57'/0']xpub6CbVv9x2j42AWb1RrkewKtu5FEgwMaJpj5WQNviJzT86imJcwXDVsXVvES2523dYVt2Yf9egTqeopes7jfH7oT2nkLtQqE1fByKxzf6fbNV/0/*)#ldg7anwl", + "iswatchonly": false, + "isscript": false, + "iswitness": true, + "witness_version": 0, + "witness_program": "66e6d39958a1449ddbb05d688c7ad1a6b6ddea43", + "pubkey": "020815f579ead84a2889b1181270e60d8aea86ed33f0910465c7b7bb4df9468660", + "ischange": false, + "timestamp": 1600722790, + "hdkeypath": "m/84'/57'/0'/0/0", + "hdseedid": "0000000000000000000000000000000000000000", + "hdmasterfingerprint": "c04584f8", + "labels": [ + "" + ] + } + +You can give this out to people as you normally would. When coins are sent to it, you will see them in your Syscoin Core wallet as watch-only. + +Sending +======= +.. Todo: Update to Syscoin PSBT +To send Syscoin, we will use ``walletcreatefundedpsbt``. This will create a Partially Signed Syscoin Transaction which is funded by inputs from the wallets (i.e. your watching only inputs selected with Syscoin Core's coin selection algorithm). +This PSBT can be used with HWI to produce a signed PSBT which can then be finalized and broadcast. + +For example, suppose I am sending to 0.01 SYS to sys1qwz7u96hxs768mp7dwqv7947lqyv74a37568urm. First I create a funded psbt with BIP 32 derivation paths to be included:: + + $ src/syscoin-cli -rpcwallet=ledger walletcreatefundedpsbt '[]' '[{"sys1qwz7u96hxs768mp7dwqv7947lqyv74a37568urm": 0.01}]' 0 '{"includeWatching":true}' true + { + "psbt": "cHNidP8BAHECAAAAASCTHvv5pfzAy/U1E0GAxm1girdVOuKC+P9ZM0X2WPj2AAAAAAD9////AkuahwAAAAAAFgAU0E3TVKQZwup+LviIL/+9cPckEyxAQg8AAAAAABYAFHC9wurmh7R9h81wGeLX3wEZ6vY+AAAAAAABAFICAAAAAbLHoztGRND+QNRLdna/d/KF8uQSHMd9JuAIGHgqFDcEAAAAAAD9////ARjdlgAAAAAAFgAUZubTmVihRJ3bsF1ojHrRprbd6kMAAAAAAQEfGN2WAAAAAAAWABRm5tOZWKFEnduwXWiMetGmtt3qQyIGAggV9Xnq2EooibEYEnDmDYrqhu0z8JEEZce3u035RoZgGMBFhPhUAACAOQAAgAAAAIAAAAAAAAAAAAAiAgLlwYRz+zQdf4MnKBE+vZ9148G0zecYIm75HeqQqn/6gxjARYT4VAAAgDkAAIAAAACAAQAAAAEAAAAAAA==", + "fee": 0.00000141, + "changepos": 0 + } + + +Now I take the updated psbt and inspect it with ``decodepsbt``:: + + $ src/syscoin-cli decodepsbt "cHNidP8BAHECAAAAASCTHvv5pfzAy/U1E0GAxm1girdVOuKC+P9ZM0X2WPj2AAAAAAD9////AkuahwAAAAAAFgAU0E3TVKQZwup+LviIL/+9cPckEyxAQg8AAAAAABYAFHC9wurmh7R9h81wGeLX3wEZ6vY+AAAAAAABAFICAAAAAbLHoztGRND+QNRLdna/d/KF8uQSHMd9JuAIGHgqFDcEAAAAAAD9////ARjdlgAAAAAAFgAUZubTmVihRJ3bsF1ojHrRprbd6kMAAAAAAQEfGN2WAAAAAAAWABRm5tOZWKFEnduwXWiMetGmtt3qQyIGAggV9Xnq2EooibEYEnDmDYrqhu0z8JEEZce3u035RoZgGMBFhPhUAACAOQAAgAAAAIAAAAAAAAAAAAAiAgLlwYRz+zQdf4MnKBE+vZ9148G0zecYIm75HeqQqn/6gxjARYT4VAAAgDkAAIAAAACAAQAAAAEAAAAAAA==" + { + "tx": { + "txid": "cb8083a9830938ca426f8e2963a8ba4ff65176fbf7afc241e79839866f720add", + "hash": "cb8083a9830938ca426f8e2963a8ba4ff65176fbf7afc241e79839866f720add", + "version": 2, + "size": 113, + "vsize": 113, + "weight": 452, + "locktime": 0, + "vin": [ + { + "txid": "f6f858f6453359fff882e23a55b78a606dc680411335f5cbc0fca5f9fb1e9320", + "vout": 0, + "scriptSig": { + "asm": "", + "hex": "" + }, + "sequence": 4294967293 + } + ], + "vout": [ + { + "value": 0.08886859, + "n": 0, + "scriptPubKey": { + "asm": "0 d04dd354a419c2ea7e2ef8882fffbd70f724132c", + "desc": "addr(sys1q6pxax49yr8pw5l3wlzyzllaawrmjgyev2aeqqc)#6vvmfj6x", + "hex": "0014d04dd354a419c2ea7e2ef8882fffbd70f724132c", + "address": "sys1q6pxax49yr8pw5l3wlzyzllaawrmjgyev2aeqqc", + "type": "witness_v0_keyhash" + } + }, + { + "value": 0.01000000, + "n": 1, + "scriptPubKey": { + "asm": "0 70bdc2eae687b47d87cd7019e2d7df0119eaf63e", + "desc": "addr(sys1qwz7u96hxs768mp7dwqv7947lqyv74a37568urm)#jhr3jcrd", + "hex": "001470bdc2eae687b47d87cd7019e2d7df0119eaf63e", + "address": "sys1qwz7u96hxs768mp7dwqv7947lqyv74a37568urm", + "type": "witness_v0_keyhash" + } + } + ] + }, + "global_xpubs": [ + ], + "psbt_version": 0, + "proprietary": [ + ], + "unknown": { + }, + "inputs": [ + { + "witness_utxo": { + "amount": 0.09887000, + "scriptPubKey": { + "asm": "0 66e6d39958a1449ddbb05d688c7ad1a6b6ddea43", + "desc": "addr(sys1qvmnd8x2c59zfmkast45gc7k356mdm6jr20mgzh)#tstr6vhd", + "hex": "001466e6d39958a1449ddbb05d688c7ad1a6b6ddea43", + "address": "sys1qvmnd8x2c59zfmkast45gc7k356mdm6jr20mgzh", + "type": "witness_v0_keyhash" + } + }, + "non_witness_utxo": { + "txid": "f6f858f6453359fff882e23a55b78a606dc680411335f5cbc0fca5f9fb1e9320", + "hash": "f6f858f6453359fff882e23a55b78a606dc680411335f5cbc0fca5f9fb1e9320", + "version": 2, + "size": 82, + "vsize": 82, + "weight": 328, + "locktime": 0, + "vin": [ + { + "txid": "0437142a781808e0267dc71c12e4f285f277bf76764bd440fed044463ba3c7b2", + "vout": 0, + "scriptSig": { + "asm": "", + "hex": "" + }, + "sequence": 4294967293 + } + ], + "vout": [ + { + "value": 0.09887000, + "n": 0, + "scriptPubKey": { + "asm": "0 66e6d39958a1449ddbb05d688c7ad1a6b6ddea43", + "desc": "addr(sys1qvmnd8x2c59zfmkast45gc7k356mdm6jr20mgzh)#tstr6vhd", + "hex": "001466e6d39958a1449ddbb05d688c7ad1a6b6ddea43", + "address": "sys1qvmnd8x2c59zfmkast45gc7k356mdm6jr20mgzh", + "type": "witness_v0_keyhash" + } + } + ] + }, + "bip32_derivs": [ + { + "pubkey": "020815f579ead84a2889b1181270e60d8aea86ed33f0910465c7b7bb4df9468660", + "master_fingerprint": "c04584f8", + "path": "m/84'/57'/0'/0/0" + } + ] + } + ], + "outputs": [ + { + "bip32_derivs": [ + { + "pubkey": "02e5c18473fb341d7f832728113ebd9f75e3c1b4cde718226ef91dea90aa7ffa83", + "master_fingerprint": "c04584f8", + "path": "m/84'/57'/0'/1/1" + } + ] + }, + { + } + ], + "fee": 0.00000141 + } + +Once the transaction has been inspected and everything looks good, the transaction can now be signed using HWI. +.. Todo: Update to Syscoin PSBT +:: + + $ cd ../HWI + $ ./hwi.py -f c04584f8 signtx "cHNidP8BAHECAAAAASCTHvv5pfzAy/U1E0GAxm1girdVOuKC+P9ZM0X2WPj2AAAAAAD9////AkuahwAAAAAAFgAU0E3TVKQZwup+LviIL/+9cPckEyxAQg8AAAAAABYAFHC9wurmh7R9h81wGeLX3wEZ6vY+AAAAAAABAFICAAAAAbLHoztGRND+QNRLdna/d/KF8uQSHMd9JuAIGHgqFDcEAAAAAAD9////ARjdlgAAAAAAFgAUZubTmVihRJ3bsF1ojHrRprbd6kMAAAAAAQEfGN2WAAAAAAAWABRm5tOZWKFEnduwXWiMetGmtt3qQyIGAggV9Xnq2EooibEYEnDmDYrqhu0z8JEEZce3u035RoZgGMBFhPhUAACAOQAAgAAAAIAAAAAAAAAAAAAiAgLlwYRz+zQdf4MnKBE+vZ9148G0zecYIm75HeqQqn/6gxjARYT4VAAAgDkAAIAAAACAAQAAAAEAAAAAAA==" + +Follow the onscreen instructions, check everything, and approve the transaction. The result will look like:: + + {"psbt": "cHNidP8BAHECAAAAASCTHvv5pfzAy/U1E0GAxm1girdVOuKC+P9ZM0X2WPj2AAAAAAD9////AkuahwAAAAAAFgAU0E3TVKQZwup+LviIL/+9cPckEyxAQg8AAAAAABYAFHC9wurmh7R9h81wGeLX3wEZ6vY+AAAAAAABAFICAAAAAbLHoztGRND+QNRLdna/d/KF8uQSHMd9JuAIGHgqFDcEAAAAAAD9////ARjdlgAAAAAAFgAUZubTmVihRJ3bsF1ojHrRprbd6kMAAAAAAQEfGN2WAAAAAAAWABRm5tOZWKFEnduwXWiMetGmtt3qQyICAggV9Xnq2EooibEYEnDmDYrqhu0z8JEEZce3u035RoZgRzBEAiB8BdTSbZpe2+tNY06QYz1KoKhCftM4Q0Ab4PzdCeQIqAIgNCx/p0JinTATQE9fPC6UilftEPKNhkDDSRM1317u2MYBIgYCCBX1eerYSiiJsRgScOYNiuqG7TPwkQRlx7e7TflGhmAYwEWE+FQAAIA5AACAAAAAgAAAAAAAAAAAACICAuXBhHP7NB1/gycoET69n3XjwbTN5xgibvkd6pCqf/qDGMBFhPhUAACAOQAAgAAAAIABAAAAAQAAAAAA", "signed": true} + +We can now take the PSBT, finalize it, and broadcast it with Syscoin Core + +.. Todo: Update to Syscoin PSBT +:: + + $ cd ../syscoin + $ src/syscoin-cli finalizepsbt cHNidP8BAHECAAAAASCTHvv5pfzAy/U1E0GAxm1girdVOuKC+P9ZM0X2WPj2AAAAAAD9////AkuahwAAAAAAFgAU0E3TVKQZwup+LviIL/+9cPckEyxAQg8AAAAAABYAFHC9wurmh7R9h81wGeLX3wEZ6vY+AAAAAAABAFICAAAAAbLHoztGRND+QNRLdna/d/KF8uQSHMd9JuAIGHgqFDcEAAAAAAD9////ARjdlgAAAAAAFgAUZubTmVihRJ3bsF1ojHrRprbd6kMAAAAAAQEfGN2WAAAAAAAWABRm5tOZWKFEnduwXWiMetGmtt3qQyICAggV9Xnq2EooibEYEnDmDYrqhu0z8JEEZce3u035RoZgRzBEAiB8BdTSbZpe2+tNY06QYz1KoKhCftM4Q0Ab4PzdCeQIqAIgNCx/p0JinTATQE9fPC6UilftEPKNhkDDSRM1317u2MYBIgYCCBX1eerYSiiJsRgScOYNiuqG7TPwkQRlx7e7TflGhmAYwEWE+FQAAIA5AACAAAAAgAAAAAAAAAAAACICAuXBhHP7NB1/gycoET69n3XjwbTN5xgibvkd6pCqf/qDGMBFhPhUAACAOQAAgAAAAIABAAAAAQAAAAAA + { + "hex": "0200000000010120931efbf9a5fcc0cbf535134180c66d608ab7553ae282f8ff593345f658f8f60000000000fdffffff024b9a870000000000160014d04dd354a419c2ea7e2ef8882fffbd70f724132c40420f000000000016001470bdc2eae687b47d87cd7019e2d7df0119eaf63e0247304402207c05d4d26d9a5edbeb4d634e90633d4aa0a8427ed33843401be0fcdd09e408a80220342c7fa742629d3013404f5f3c2e948a57ed10f28d8640c3491335df5eeed8c60121020815f579ead84a2889b1181270e60d8aea86ed33f0910465c7b7bb4df946866000000000", + "complete": true + } + $ src/syscoin-cli sendrawtransaction 0200000000010120931efbf9a5fcc0cbf535134180c66d608ab7553ae282f8ff593345f658f8f60000000000fdffffff024b9a870000000000160014d04dd354a419c2ea7e2ef8882fffbd70f724132c40420f000000000016001470bdc2eae687b47d87cd7019e2d7df0119eaf63e0247304402207c05d4d26d9a5edbeb4d634e90633d4aa0a8427ed33843401be0fcdd09e408a80220342c7fa742629d3013404f5f3c2e948a57ed10f28d8640c3491335df5eeed8c60121020815f579ead84a2889b1181270e60d8aea86ed33f0910465c7b7bb4df946866000000000 + cb8083a9830938ca426f8e2963a8ba4ff65176fbf7afc241e79839866f720add + +Refilling the keypools +---------------------- + +Descriptor wallets will constantly generate new addresses from the imported descriptors. +It is not necessary to import additional keys or descriptors to refresh the keypool, Syscoin Core will do so automatically by using the descriptors. + +Derivation Path BIP Compliance +============================== + +The instructions above use BIP 84 to derive keys used for P2WPKH addresses (bech32 addresses). +HWI follows BIPs 44, 84, and 49. By default, descriptors will be for P2WPKH addresses with keys derived at ``m/84h/57h/0h/0`` for normal receiving keys and ``m/84h/57h/0h/1`` for change keys. +Using the ``--addr-type legacy`` option will result in P2PKH addresses with keys derived at ``m/44h/57h/0h/0`` for normal receiving keys and ``m/44h/57h/0h/1`` for change keys. +Using the ``--addr-type sh_wit`` option will result in P2SH nested P2WPKH addresses with keys derived at ``m/49h/57h/0h/0`` for normal receiving keys and ``m/49h/57h/0h/1`` for change keys. + +To actually get the correct address type when using ``getnewaddress`` from Syscoin Core, you will need to additionally set ``-addresstype=p2sh-segwit`` and ``-changetype=p2sh-segwit``. +This can be set in the command line (as shown in the example) or in your syscoin.conf file. + +Alternative derivation paths can also be chosen using the ``--path`` option and specifying your own derivation path. diff --git a/docs/examples/walkthrough/Screenshot01_HWI_Empty-State.png b/docs/examples/walkthrough/Screenshot01_HWI_Empty-State.png new file mode 100644 index 000000000..e6dbaaf4c Binary files /dev/null and b/docs/examples/walkthrough/Screenshot01_HWI_Empty-State.png differ diff --git a/docs/examples/walkthrough/Screenshot02_HWI_HWW-Selected.png b/docs/examples/walkthrough/Screenshot02_HWI_HWW-Selected.png new file mode 100644 index 000000000..c94fec2f0 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot02_HWI_HWW-Selected.png differ diff --git a/docs/examples/walkthrough/Screenshot03-Core-Initial-Wallet-Overview.png b/docs/examples/walkthrough/Screenshot03-Core-Initial-Wallet-Overview.png new file mode 100644 index 000000000..37de9f77b Binary files /dev/null and b/docs/examples/walkthrough/Screenshot03-Core-Initial-Wallet-Overview.png differ diff --git a/docs/examples/walkthrough/Screenshot04_HWI_Address-Display-Request.png b/docs/examples/walkthrough/Screenshot04_HWI_Address-Display-Request.png new file mode 100644 index 000000000..c6ae4d856 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot04_HWI_Address-Display-Request.png differ diff --git a/docs/examples/walkthrough/Screenshot05_HWW_Display-Receive-Address.png b/docs/examples/walkthrough/Screenshot05_HWW_Display-Receive-Address.png new file mode 100644 index 000000000..94d6772a1 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot05_HWW_Display-Receive-Address.png differ diff --git a/docs/examples/walkthrough/Screenshot06_HWI_Address-Display-Response.png b/docs/examples/walkthrough/Screenshot06_HWI_Address-Display-Response.png new file mode 100644 index 000000000..b0a78b3b3 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot06_HWI_Address-Display-Response.png differ diff --git a/docs/examples/walkthrough/Screenshot07_Core_Console-getaddressinfo.png b/docs/examples/walkthrough/Screenshot07_Core_Console-getaddressinfo.png new file mode 100644 index 000000000..48dc22133 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot07_Core_Console-getaddressinfo.png differ diff --git a/docs/examples/walkthrough/Screenshot08_HWI_Address-Display-Request.png b/docs/examples/walkthrough/Screenshot08_HWI_Address-Display-Request.png new file mode 100644 index 000000000..e3a5dc891 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot08_HWI_Address-Display-Request.png differ diff --git a/docs/examples/walkthrough/Screenshot09_HWW_Display-Change-Address.png b/docs/examples/walkthrough/Screenshot09_HWW_Display-Change-Address.png new file mode 100644 index 000000000..1e645ad6a Binary files /dev/null and b/docs/examples/walkthrough/Screenshot09_HWW_Display-Change-Address.png differ diff --git a/docs/examples/walkthrough/Screenshot10_HWI_Address-Display-Response.png b/docs/examples/walkthrough/Screenshot10_HWI_Address-Display-Response.png new file mode 100644 index 000000000..fe7e5a2d6 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot10_HWI_Address-Display-Response.png differ diff --git a/docs/examples/walkthrough/Screenshot11_Core_Console-getaddressinfo.png b/docs/examples/walkthrough/Screenshot11_Core_Console-getaddressinfo.png new file mode 100644 index 000000000..32c3b4cf5 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot11_Core_Console-getaddressinfo.png differ diff --git a/docs/examples/walkthrough/Screenshot12_Core_Send-Tab.png b/docs/examples/walkthrough/Screenshot12_Core_Send-Tab.png new file mode 100644 index 000000000..70e9a6d4b Binary files /dev/null and b/docs/examples/walkthrough/Screenshot12_Core_Send-Tab.png differ diff --git a/docs/examples/walkthrough/Screenshot13_Core_Create-Unsigned-Tx.png b/docs/examples/walkthrough/Screenshot13_Core_Create-Unsigned-Tx.png new file mode 100644 index 000000000..7548e802c Binary files /dev/null and b/docs/examples/walkthrough/Screenshot13_Core_Create-Unsigned-Tx.png differ diff --git a/docs/examples/walkthrough/Screenshot14_Core_Paste-PSBT-to-Clipboard.png b/docs/examples/walkthrough/Screenshot14_Core_Paste-PSBT-to-Clipboard.png new file mode 100644 index 000000000..6a6588680 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot14_Core_Paste-PSBT-to-Clipboard.png differ diff --git a/docs/examples/walkthrough/Screenshot15_HWI_Empty-PSBT.png b/docs/examples/walkthrough/Screenshot15_HWI_Empty-PSBT.png new file mode 100644 index 000000000..3fc20ab65 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot15_HWI_Empty-PSBT.png differ diff --git a/docs/examples/walkthrough/Screenshot16_HWI_Prepare-PSBT-signing.png b/docs/examples/walkthrough/Screenshot16_HWI_Prepare-PSBT-signing.png new file mode 100644 index 000000000..468107706 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot16_HWI_Prepare-PSBT-signing.png differ diff --git a/docs/examples/walkthrough/Screenshot17_HWW_Confirm-Amount-Destination.png b/docs/examples/walkthrough/Screenshot17_HWW_Confirm-Amount-Destination.png new file mode 100644 index 000000000..6962ac268 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot17_HWW_Confirm-Amount-Destination.png differ diff --git a/docs/examples/walkthrough/Screenshot18_HWW_Confirm-Locktime.png b/docs/examples/walkthrough/Screenshot18_HWW_Confirm-Locktime.png new file mode 100644 index 000000000..5cad208c2 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot18_HWW_Confirm-Locktime.png differ diff --git a/docs/examples/walkthrough/Screenshot19_HWW_Confirm-Amount-Fees.png b/docs/examples/walkthrough/Screenshot19_HWW_Confirm-Amount-Fees.png new file mode 100644 index 000000000..2816e1956 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot19_HWW_Confirm-Amount-Fees.png differ diff --git a/docs/examples/walkthrough/Screenshot20_Core_Console_getblockcount.png b/docs/examples/walkthrough/Screenshot20_Core_Console_getblockcount.png new file mode 100644 index 000000000..8c9df4eef Binary files /dev/null and b/docs/examples/walkthrough/Screenshot20_Core_Console_getblockcount.png differ diff --git a/docs/examples/walkthrough/Screenshot21_HWI_Show-Signed-PSBT.png b/docs/examples/walkthrough/Screenshot21_HWI_Show-Signed-PSBT.png new file mode 100644 index 000000000..683998efb Binary files /dev/null and b/docs/examples/walkthrough/Screenshot21_HWI_Show-Signed-PSBT.png differ diff --git a/docs/examples/walkthrough/Screenshot22_Core_Broadcast-Signed-PSBT.png b/docs/examples/walkthrough/Screenshot22_Core_Broadcast-Signed-PSBT.png new file mode 100644 index 000000000..91148e9e7 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot22_Core_Broadcast-Signed-PSBT.png differ diff --git a/docs/examples/walkthrough/Screenshot23_Core_Transactions-Tab.png b/docs/examples/walkthrough/Screenshot23_Core_Transactions-Tab.png new file mode 100644 index 000000000..98de1b8af Binary files /dev/null and b/docs/examples/walkthrough/Screenshot23_Core_Transactions-Tab.png differ diff --git a/docs/examples/walkthrough/Screenshot24_Core_Transaction-Details.png b/docs/examples/walkthrough/Screenshot24_Core_Transaction-Details.png new file mode 100644 index 000000000..45541d5c4 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot24_Core_Transaction-Details.png differ diff --git a/docs/examples/walkthrough/Screenshot25_Core_Console-gettransaction.png b/docs/examples/walkthrough/Screenshot25_Core_Console-gettransaction.png new file mode 100644 index 000000000..a2be04ee4 Binary files /dev/null and b/docs/examples/walkthrough/Screenshot25_Core_Console-gettransaction.png differ diff --git a/docs/examples/walkthrough/walkthrough.rst b/docs/examples/walkthrough/walkthrough.rst new file mode 100644 index 000000000..86bc9db1b --- /dev/null +++ b/docs/examples/walkthrough/walkthrough.rst @@ -0,0 +1,188 @@ +Walkthrough: Using a Hardware Wallet with Bitcoin Core Wallet +****************************************************************** + +Summary: On this page we describe step-by-step and show in screenshots how to use a hardware wallet and HWI +together with a Bitcoin Core Wallet. As hardware wallet example we have used a Trezor. + +Create a watch-only Bitcoin Core wallet for Trezor +================================================== + +Create your watch-only Bitcoin Core Wallet as described in `Using Bitcoin Core with Hardware Wallets <../bitcoin-core-usage.rst>`_. +You find all the details well described in this link. But in summary, one opens a terminal and runs ``bitcoind``. E.g. + +:: + + bitcoind -testnet -datadir=$HOME/.bitcoin-testnet + +for a testnet ``bitcoind`` daemon, or + +:: + + bitcoind + +for a mainnet, i.e. regular, ``bitcoind`` daemon. + +Then in another terminal run commands similar to these, adapted to your environment: + +:: + + hwi.py enumerate # this shows you the fingerprint of your hardware wallet + FINGERPRINT_TESTNET="yourHardwareWalletFingerprint" # shown by "hwi enumerate" + # in this example we use SEGWIT BECH32 ADDRESSES + DERIVATIONPATH_TESTNET=1 # testnet uses derivation paths like m/84h/1h/0h/0/* and m/84h/1h/0h/1/* + DERIVATIONPATH_MAINNET=0 # mainnet uses derivation paths like m/84h/0h/0h/0/* and m/84h/0h/0h/1/* + # if the mainnet path is used on testnet, it will work too, but Trezor device gives warnings + # of unknown address on Trezor display. This is not recommended. Use the correct derivation path + # for the corresponding network! + wallet=wallet.test + rec=$(hwi --testnet -f $FINGERPRINT_TESTNET getkeypool --addr-type wit --path m/84h/${DERIVATIONPATH_TESTNET}h/0h/0/* --keypool 0 1000) + chg=$(hwi --testnet -f $FINGERPRINT_TESTNET getkeypool --addr-type wit --path m/84h/${DERIVATIONPATH_TESTNET}h/0h/1/* --keypool --internal 0 1000) + bitcoin-cli -testnet createwallet "$wallet" true + bitcoin-cli -testnet -rpcwallet="$wallet" importmulti "$rec" + bitcoin-cli -testnet -rpcwallet="$wallet" importmulti "$chg" + echo "If the hardware wallet has been used before and holds funds then you should rescan. Rescanning might take 30 minutes." + bitcoin-cli -testnet -rpcwallet="$wallet" rescanblockchain # full rescan + # after rescan unload wallet + bitcoin-cli -testnet -rpcwallet="$wallet" unloadwallet + +This script needs to be adapted to your needs. If you are creating a wallet for mainnet get rid of ``-testnet`` and ``--testnet`` and +use ``DERIVATIONPATH_MAINNET`` instead of ``DERIVATIONPATH_TESTNET``. Adapt the derivation paths to your needs. +Now that the watch-only Bitcoin Core wallet has been created, stop ``bitcoind`` with control-C. We are ready to use the wallet. + +Send funds with Bitcoin Core and Trezor using HWI +================================================= + +* our example does everything on the Bitcoin testnet, so watch out, your addresses and paths will differ +* TREZOR: plug in your hardware wallet, e.g. your Trezor, put in the PIN if any +* HWI: type ``hwi-qt.py --testnet`` to start HWI GUI for testnet (type ``hwi-qt.py`` to start HWI GUI for mainnet) + +.. image:: Screenshot01_HWI_Empty-State.png + +* TREZOR: your hardware wallet, e.g. Trezor, might prompt you for a passphrase, enter passphrase on hardware wallet (if any) +* HWI: select your hardware wallet in HWI GUI + +.. image:: Screenshot02_HWI_HWW-Selected.png + +* CORE: start Bitcoin Core wallet, e.g. ``bitcoin-qt -testnet`` (or ``bitcoin-qt`` for mainnet) + +.. image:: Screenshot03-Core-Initial-Wallet-Overview.png + +* on the very first run it might be a good idea to verify that the wallet has been created correctly +* on first run **verify your wallet** (optional) +* HWI: HWI GUI -> "Display Address", since we use BECH32 address, select "P2WPKH", + enter "m/84h/1h/0h/0/0" (testnet derivation path) (or "m/84h/0h/0h/0/0" on mainnet). + This path represents the first receiving address. Click "Go". + In our example, it shows address "tb1q0r2gn9wzfjm5j5zshx5yp5342h928c8pmllfep". + +.. image:: Screenshot04_HWI_Address-Display-Request.png + +.. image:: Screenshot05_HWW_Display-Receive-Address.png + +.. image:: Screenshot06_HWI_Address-Display-Response.png + +* CORE: In Core Wallet, open "Console", enter ``getaddressinfo tb1q0r2gn9wzfjm5j5zshx5yp5342h928c8pmllfep``, + observe these values: + It is crucial that ``solvable`` shows as ``true``! + + * "solvable": true, + * "iswatchonly": true, + * "hdkeypath": "m/84'/1'/0'/0/0", + +.. image:: Screenshot07_Core_Console-getaddressinfo.png + +* HWI: In HWI GUI main window click "Display Address", since we use BECH32 address, + select "P2WPKH", enter "m/84h/1h/0h/1/0" (testnet derivation path) (or "m/84h/0h/0h/1/0" on mainnet). + This path represents the first change address. Click "Go". + In our example it shows address "tb1qca3u0ka22c934jfqw7gjr9vg4gwwjldpzatrh5". + +.. image:: Screenshot08_HWI_Address-Display-Request.png + +.. image:: Screenshot09_HWW_Display-Change-Address.png + +.. image:: Screenshot10_HWI_Address-Display-Response.png + +* CORE: In Core Wallet, open "Console", enter ``getaddressinfo tb1qca3u0ka22c934jfqw7gjr9vg4gwwjldpzatrh5``, + observe these values: + It is crucial that ``solvable`` shows as ``true``! + + * "solvable": true, + * "iswatchonly": true, + * "hdkeypath": "m/84'/1'/0'/1/0", + +.. image:: Screenshot11_Core_Console-getaddressinfo.png + +* If you see the same addresses for the same paths on Trezor, in HWI and in Bitcoin Core Wallet + you can rest assured that the wallet has been created correctly and + that the Bitcoin Core wallet corresponds to your Trezor device. + +* Now let us **send funds**. + +* CORE: To send funds, open the "Send" tab in Bitcoin Core Wallet, + then select input, amount, fees, etc. Once satisfied, click "Create Unsigned", + verify any displayed information, then click "Create Unsigned" again. + The PSBT (Partially Signed Bitcoin Transaction) is now on the clipboard. + +.. image:: Screenshot12_Core_Send-Tab.png + +.. image:: Screenshot13_Core_Create-Unsigned-Tx.png + +.. image:: Screenshot14_Core_Paste-PSBT-to-Clipboard.png + +* HWI: In HWI GUI main window click "Sign PSBT", then paste PSBT from clipboard + into the above text field. After paste, click "Sign PSBT". + +.. image:: Screenshot15_HWI_Empty-PSBT.png + +.. image:: Screenshot16_HWI_Prepare-PSBT-signing.png + +* TREZOR: verify signing on Trezor, accept operation on Trezor if all is correct + +.. image:: Screenshot17_HWW_Confirm-Amount-Destination.png + +.. image:: Screenshot18_HWW_Confirm-Locktime.png + +.. image:: Screenshot19_HWW_Confirm-Amount-Fees.png + +* CORE: Trezor prints blockheight of locktime which can optionally be verified in + Bitcoin Core Wallet (Console -> ``getblockcount``). For a simple send the locktime + is now and you should get the current blockheight. + +.. image:: Screenshot20_Core_Console_getblockcount.png + +* HWI: upon accepting on Trezor, the HWI bottom text area is filled. + Select the bottom output, and copy full output from the bottom text area to the clipboard + +.. image:: Screenshot21_HWI_Show-Signed-PSBT.png + +* CORE: In Bitcoin Core Wallet, go to the pull-down menu: select File -> Load from Clipboard. + +.. image:: Screenshot22_Core_Broadcast-Signed-PSBT.png + +* CORE: In Core Wallet, visually verify again, then click "Broadcast Tx" button. + Once broadcasted, click "Close". + The funds have been sent to the mempool awaiting confirmations on the Bitcoin network. + +* CORE: In Core Wallet, go to "Transactions" tab. Here you can find the just + sent transaction in the top line. Wait for confirmations. + +.. image:: Screenshot23_Core_Transactions-Tab.png + +* CORE: Optionally double click transaction to see transaction details. + +.. image:: Screenshot24_Core_Transaction-Details.png + +* CORE: Optionally, one can also see the transaction details in the + Console -> ``gettransaction 58d9dccd190250742c47733f3c0f0d33075d65621196434f163f92b69847843f`` + +.. image:: Screenshot25_Core_Console-gettransaction.png + +* HWI: close HWI GUI +* CORE: close Core wallet +* you are done! Pad yourself on the shoulder ;) + +Versions Used +============= + +* This walk-trough was done in January 2021 +* HWI version 2.0.0-dev +* Bitcoin 0.21.0 diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..5b5039ebb --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,19 @@ +Welcome to Hardware Wallet Interface's documentation! +===================================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + usage/index + devices/index + development/index + examples/index + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/keepkey.md b/docs/keepkey.md deleted file mode 100644 index 3cb5d8221..000000000 --- a/docs/keepkey.md +++ /dev/null @@ -1,26 +0,0 @@ -# KeepKey - -The KeepKey is partially supported by HWI - -Current implemented commands are: - -* `getmasterxpub` -* `getxpub` -- `setup` -- `wipe` -- `restore` -- `backup` -- `signtx` -- `displayaddress` -- `signmessage` - -## `signtx` Caveats - -Due to the limitations of the KeepKey, some transactions cannot be signed by a KeepKey. - -- Multisig inputs are limited to at most n-of-15 multisigs. This is a firmware limitation. -* Transactions with arbitrary input scripts (scriptPubKey, redeemScript, or witnessScript) and arbitrary output scripts cannot be signed. This is a firmware limitation. - -## Note on `backup` - -Once the device is backed up at setup, the seed words will not be shown again to be backed up. The implementation here lets users know that `backup` does not work. diff --git a/docs/ledger.md b/docs/ledger.md deleted file mode 100644 index 332d0067c..000000000 --- a/docs/ledger.md +++ /dev/null @@ -1,30 +0,0 @@ -# Ledger Nano X - -Currently identical to Nano S. - -# Ledger Nano S - -The Ledger Nano S is supported by HWI. -Note that the Bitcoin App must be installed and running on the device. - -Currently implemented commands: - -* `getmasterxpub` -* `signtx` (with some caveats) -* `getxpub` -* `signmessage` -- `displayaddress` -- `setup` -- `wipe` -- `restore` -- `backup` - -## `signtx` Caveats - -Due to device limitations, not all kinds of transactions can be signed by a Ledger Nano S or X. - -* Transactions containing both segwit and non-segwit inputs are not entirely supported; only the segwit inputs will be signed in this case. - -## Notes on `setup`, `wipe`, `restore`, and `backup` - -The Ledger does not allow you to setup, wipe, restore, or backup it via software. That is done on the device itself. The implementation here is just to let users know those commands do not work. diff --git a/docs/release-process.md b/docs/release-process.md deleted file mode 100644 index 626012999..000000000 --- a/docs/release-process.md +++ /dev/null @@ -1,51 +0,0 @@ -# Release Process - -1. Bump version number in `pyproject.toml` and `hwilib/__init__.py`, generate the setup.py file, and git tag release -2. Build distribution archives for PyPi with `contrib/build_dist.sh` -3. For MacOS and Linux, use `contrib/build_bin.sh`. This needs to be run on a MacOS machine for the MacOS binary and on a Linux machine for the linux one. -4. For Windows, use `contrib/build_wine.sh` to build the Windows binary using wine -5. Upload distribution archives to PyPi -6. Upload distribution archives and standalone binaries to Github - -## Deterministic builds with Docker - -Create the docker image: - -``` -docker build --no-cache -t hwi-builder -f contrib/build.Dockerfile . -``` - -Build everything - -``` -docker run -it --name hwi-builder -v $PWD:/opt/hwi --rm --workdir /opt/hwi hwi-builder /bin/bash -c "contrib/build_bin.sh && contrib/build_dist.sh && contrib/build_wine.sh" -``` - -## Building macOS binary - -Note that the macOS build is non-deterministic. - -First install [pyenv](https://github.com/pyenv/pyenv) using whichever method you prefer. - -Then a deterministic build of Python 3.6.8 needs to be installed. This can be done with the patch in `contrib/reproducible-python.diff`. First `cd` into HWI's source tree. Then use: - -``` -cat contrib/reproducible-python.diff | PYTHON_CONFIGURE_OPTS="--enable-framework" BUILD_DATE="Jan 1 2019" BUILD_TIME="00:00:00" pyenv install -kp 3.6.8 -``` - -Make sure that python 3.6.8 is active - -``` -$ python --version -Python 3.6.8 -``` - -Now install [Poetry](https://github.com/sdispater/poetry) with `pip install poetry` - -Additional dependencies can be installed with: - -``` -brew install libusb -``` - -Build the binaries by using `contrib/build_bin.sh`. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..e289e8bd7 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinxcontrib-autoprogram>=0.1.5 +sphinx>=3.2.1 diff --git a/docs/trezor.md b/docs/trezor.md deleted file mode 100644 index 82fe79020..000000000 --- a/docs/trezor.md +++ /dev/null @@ -1,26 +0,0 @@ -# Trezor - -The Trezor is partially supported by HWI - -Current implemented commands are: - -* `getmasterxpub` -* `signtx` (with some caveats) -* `getxpub` -- `displayaddress` -- `setup` -- `wipe` -- `restore` -- `backup` - -## `signtx` Caveats - -Due to the limitations of the Trezor, some transactions cannot be signed by a Trezor. - -- Multisig inputs are limited to at most n-of-15 multisigs. This is a firmware limitation. -* Transactions with arbitrary input scripts (scriptPubKey, redeemScript, or witnessScript) and arbitrary output scripts cannot be signed. This is a firmware limitation. -* Send-to-self transactions will result in no prompt for outputs as all outputs will be detected as change. - -## Note on `backup` - -Once the device is backed up at setup, the seed words will not be shown again to be backed up. The implementation here lets users know that `backup` does not work. diff --git a/docs/usage/api-usage.rst b/docs/usage/api-usage.rst new file mode 100644 index 000000000..2ed097e7e --- /dev/null +++ b/docs/usage/api-usage.rst @@ -0,0 +1,21 @@ +API Usage +========= + +The library API for use by projects importing ``hwilib`` can be found here. + +.. automodule:: hwilib.hwwclient + :members: +.. automodule:: hwilib.commands + :members: +.. automodule:: hwilib.errors + :members: +.. automodule:: hwilib.udevinstaller + :members: +.. automodule:: hwilib.psbt + :members: +.. automodule:: hwilib.descriptor + :members: +.. automodule:: hwilib.key + :members: +.. automodule:: hwilib.common + :members: diff --git a/docs/usage/cli-usage.rst b/docs/usage/cli-usage.rst new file mode 100644 index 000000000..9d2a71c06 --- /dev/null +++ b/docs/usage/cli-usage.rst @@ -0,0 +1,11 @@ +Command Line Usage +****************** + +HWI is primarily used from the command line. +Users can use the ``hwi`` command directly, or the HWI self-contained binaries can be distributed with third party software and executed by the software. + +The usage of ``hwi`` can be found with ``hwi --help``. + +.. autoprogram:: hwilib._cli:get_parser() + :prog: hwi + :groups: diff --git a/docs/usage/index.rst b/docs/usage/index.rst new file mode 100644 index 000000000..85a3f6caa --- /dev/null +++ b/docs/usage/index.rst @@ -0,0 +1,9 @@ +Usage +***** + +.. toctree:: + :maxdepth: 2 + + installation + cli-usage + api-usage diff --git a/docs/usage/installation.rst b/docs/usage/installation.rst new file mode 100644 index 000000000..e1251c052 --- /dev/null +++ b/docs/usage/installation.rst @@ -0,0 +1,44 @@ +Installation +************ + +HWI is distributed in 2 different ways: + +1. Self-contained executable binaries +2. Python package + +Binaries +======== + +The self-contained binaries are available for download from the `releases page `_. + +Download and extract the package for your operating system and architecture. +The ``hwi`` binary (``hwi.exe`` for Windows) is a command line tool and executed from the terminal (command prompt in Windows). +The ``hwi-qt`` binary (``hwi-qt.exe`` for Windows) is a GUI tool and can be executed as any typical application. + +Python Package +============== + +The python packages are distributed both from the `releases page `_ and from `PyPi `_. + +In either case, make sure that you have installed ``pip`` and that it is update to date. + +From Releases +------------- + +Download either the Python wheel ``hwi--py3-none-any.whl`` or the source package ``hwi-.tar.gz``. +It is recommended to use the wheel over the source package unless your Python installation does not support wheels. + +Install the downloaded file using ``pip``. For example:: + + pip install hwi-1.1.2-py3-none-any.whl + +or:: + + pip install hwi-1.1.2.tar.gz + +From PyPI +--------- + +As HWI is also uploaded to PyPi, it can be installed with:: + + pip install hwi diff --git a/hwi-qt.py b/hwi-qt.py new file mode 100755 index 000000000..1da390381 --- /dev/null +++ b/hwi-qt.py @@ -0,0 +1,7 @@ +#! /usr/bin/env python3 + +if __name__ == '__main__': + from hwilib._gui import main + main() +else: + raise ImportError('hwi-qt is not importable. Import hwilib instead') diff --git a/hwi-qt.spec b/hwi-qt.spec new file mode 100644 index 000000000..f35fb73c0 --- /dev/null +++ b/hwi-qt.spec @@ -0,0 +1,48 @@ +# -*- mode: python ; coding: utf-8 -*- + +import platform +import subprocess + +block_cipher = None + +def get_libusb_path(): + if platform.system() == 'Windows': + return 'c:/python3/libusb-1.0.dll' + if platform.system() == 'Darwin': + proc = subprocess.Popen(['brew', '--prefix', 'libusb'], stdout=subprocess.PIPE) + prefix = proc.communicate()[0].rstrip().decode() + return os.path.join(prefix, "lib", "libusb-1.0.dylib") + if platform.system() == 'Linux': + for lib_dir in ['/lib/x86_64-linux-gnu', '/usr/lib64', '/usr/lib', '/lib']: + libusb_path = os.path.join(lib_dir, 'libusb-1.0.so.0') + if os.path.exists(libusb_path): + return libusb_path + raise RuntimeError(f"Unsupported platform: {platform.system()}") + +a = Analysis(['hwi-qt.py'], + binaries=[(get_libusb_path(), '.')], + datas=[], + hiddenimports=[], + hookspath=['contrib/pyinstaller-hooks/'], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False) +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='hwi-qt', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False ) diff --git a/hwi.py b/hwi.py index 23a1e3032..7a59887cf 100755 --- a/hwi.py +++ b/hwi.py @@ -3,7 +3,7 @@ # Hardware wallet interaction script if __name__ == '__main__': - from hwilib.cli import main + from hwilib._cli import main main() else: raise ImportError('hwi is not importable. Import hwilib instead') diff --git a/hwi.spec b/hwi.spec index 94b7c4163..1502458f8 100644 --- a/hwi.spec +++ b/hwi.spec @@ -1,21 +1,26 @@ # -*- mode: python -*- import platform import subprocess +import os block_cipher = None -binaries = [] -if platform.system() == 'Windows': - binaries = [("c:/python3/libusb-1.0.dll", ".")] -elif platform.system() == 'Linux': - binaries = [("/lib/x86_64-linux-gnu/libusb-1.0.so.0", ".")] -elif platform.system() == 'Darwin': - find_brew_libusb_proc = subprocess.Popen(['brew', '--prefix', 'libusb'], stdout=subprocess.PIPE) - libusb_path = find_brew_libusb_proc.communicate()[0] - binaries = [(libusb_path.rstrip().decode() + "/lib/libusb-1.0.dylib", ".")] +def get_libusb_path(): + if platform.system() == "Windows": + return "c:/python3/libusb-1.0.dll" + if platform.system() == "Darwin": + proc = subprocess.Popen(["brew", "--prefix", "libusb"], stdout=subprocess.PIPE) + prefix = proc.communicate()[0].rstrip().decode() + return os.path.join(prefix, "lib", "libusb-1.0.dylib") + if platform.system() == "Linux": + for lib_dir in ["/lib/x86_64-linux-gnu", "/usr/lib64", "/lib64" "/usr/lib", "/lib"]: + libusb_path = os.path.join(lib_dir, "libusb-1.0.so.0") + if os.path.exists(libusb_path): + return libusb_path + raise RuntimeError(f"Unsupported platform: {platform.system()}") a = Analysis(['hwi.py'], - binaries=binaries, + binaries=[(get_libusb_path(), '.')], datas=[], hiddenimports=[], hookspath=['contrib/pyinstaller-hooks/'], diff --git a/hwilib/__init__.py b/hwilib/__init__.py index a6221b3de..b19ee4b77 100644 --- a/hwilib/__init__.py +++ b/hwilib/__init__.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = "2.2.1" diff --git a/hwilib/base58.py b/hwilib/_base58.py similarity index 55% rename from hwilib/base58.py rename to hwilib/_base58.py index 34c59cb7f..829e30203 100644 --- a/hwilib/base58.py +++ b/hwilib/_base58.py @@ -1,3 +1,7 @@ +""" +Base 58 conversion utilities +**************************** +""" # # base58.py @@ -8,14 +12,23 @@ # file COPYING or http://www.opensource.org/licenses/mit-license.php. # -from .serializations import hash256 -import struct from binascii import hexlify, unhexlify from typing import List + +from .common import hash256 +from .errors import BadArgumentError + + b58_digits: str = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + def encode(b: bytes) -> str: - """Encode bytes to a base58-encoded string""" + """ + Encode bytes to a base58-encoded string + + :param b: Bytes to encode + :return: Base58 encoded string of ``b`` + """ # Convert big-endian bytes to integer n: int = int('0x0' + hexlify(b).decode('utf8'), 16) @@ -38,7 +51,12 @@ def encode(b: bytes) -> str: return b58_digits[0] * pad + res def decode(s: str) -> bytes: - """Decode a base58-encoding string, returning bytes""" + """ + Decode a base58-encoding string, returning bytes + + :param s: Base48 string to decode + :return: Bytes encoded by ``s`` + """ if not s: return b'' @@ -47,7 +65,7 @@ def decode(s: str) -> bytes: for c in s: n *= 58 if c not in b58_digits: - raise ValueError('Character %r is not a valid base58 character' % c) + raise BadArgumentError('Character %r is not a valid base58 character' % c) digit = b58_digits.index(c) n += digit @@ -66,33 +84,73 @@ def decode(s: str) -> bytes: break return b'\x00' * pad + res -def get_xpub_fingerprint(s: str) -> str: +def get_xpub_fingerprint(s: str) -> bytes: + """ + Get the parent fingerprint from an extended public key + + :param s: The extended pubkey + :return: The parent fingerprint bytes + """ data = decode(s) fingerprint = data[5:9] - return struct.unpack(" str: - data = decode(xpub) - fingerprint = data[5:9] - return hexlify(fingerprint).decode() + """ + Get the parent fingerprint as a hex string from an extended public key -def get_xpub_fingerprint_as_id(xpub: str) -> str: + :param s: The extended pubkey + :return: The parent fingerprint as a hex string + """ data = decode(xpub) fingerprint = data[5:9] return hexlify(fingerprint).decode() def to_address(b: bytes, version: bytes) -> str: + """ + Base58 Check Encode the data with the version number. + Used to encode legacy style addresses. + + :param b: The data to encode + :param version: The version number to encode with + :return: The Base58 Check Encoded string + """ data = version + b checksum = hash256(data)[0:4] data += checksum return encode(data) def xpub_to_pub_hex(xpub: str) -> str: + """ + Get the public key as a string from the extended public key. + + :param xpub: The extended pubkey + :return: The pubkey hex string + """ data = decode(xpub) pubkey = data[-37:-4] return hexlify(pubkey).decode() + +def xpub_to_xonly_pub_hex(xpub: str) -> str: + """ + Get the public key as a string from the extended public key. + + :param xpub: The extended pubkey + :return: The pubkey hex string + """ + data = decode(xpub) + pubkey = data[-36:-4] + return hexlify(pubkey).decode() + + def xpub_main_2_test(xpub: str) -> str: + """ + Convert an extended pubkey from mainnet version to testnet version. + + :param xpub: The extended pubkey + :return: The extended pubkey re-encoded using testnet version bytes + """ data = decode(xpub) test_data = b'\x04\x35\x87\xCF' + data[4:-4] checksum = hash256(test_data)[0:4] diff --git a/hwilib/bech32.py b/hwilib/_bech32.py similarity index 62% rename from hwilib/bech32.py rename to hwilib/_bech32.py index 68f246874..815c66650 100644 --- a/hwilib/bech32.py +++ b/hwilib/_bech32.py @@ -18,13 +18,33 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -"""Reference implementation for Bech32 and segwit addresses.""" +""" +Bech32 Conversion Utilities +*************************** + +Reference implementation for Bech32 and segwit addresses. +""" + +from enum import Enum +from typing import ( + List, + Optional, + Tuple, + Union, +) CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +BECH32_CONST = 1 +BECH32M_CONST = 0x2bc830a3 + +class Encoding(Enum): + """Enumeration type to list the various supported encodings.""" + BECH32 = 1 + BECH32M = 2 -def bech32_polymod(values): +def bech32_polymod(values: List[int]) -> int: """Internal function that computes the Bech32 checksum.""" generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] chk = 1 @@ -36,48 +56,56 @@ def bech32_polymod(values): return chk -def bech32_hrp_expand(hrp): +def bech32_hrp_expand(hrp: str) -> List[int]: """Expand the HRP into values for checksum computation.""" return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] -def bech32_verify_checksum(hrp, data): +def bech32_verify_checksum(hrp: str, data: List[int]) -> Optional[Encoding]: """Verify a checksum given HRP and converted data characters.""" - return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1 + check = bech32_polymod(bech32_hrp_expand(hrp) + data) + if check == BECH32_CONST: + return Encoding.BECH32 + elif check == BECH32M_CONST: + return Encoding.BECH32M + else: + return None -def bech32_create_checksum(hrp, data): +def bech32_create_checksum(encoding: Encoding, hrp: str, data: List[int]) -> List[int]: """Compute the checksum values given HRP and data.""" values = bech32_hrp_expand(hrp) + data - polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1 + const = BECH32M_CONST if encoding == Encoding.BECH32M else BECH32_CONST + polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] -def bech32_encode(hrp, data): +def bech32_encode(encoding: Encoding, hrp: str, data: List[int]) -> str: """Compute a Bech32 string given HRP and data values.""" - combined = data + bech32_create_checksum(hrp, data) + combined = data + bech32_create_checksum(encoding, hrp, data) return hrp + '1' + ''.join([CHARSET[d] for d in combined]) -def bech32_decode(bech): +def bech32_decode(bech: str) -> Tuple[Optional[Encoding], Optional[str], Optional[List[int]]]: """Validate a Bech32 string, and determine HRP and data.""" if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or (bech.lower() != bech and bech.upper() != bech)): - return (None, None) + return (None, None, None) bech = bech.lower() pos = bech.rfind('1') if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: - return (None, None) + return (None, None, None) if not all(x in CHARSET for x in bech[pos + 1:]): - return (None, None) + return (None, None, None) hrp = bech[:pos] data = [CHARSET.find(x) for x in bech[pos + 1:]] - if not bech32_verify_checksum(hrp, data): - return (None, None) - return (hrp, data[:-6]) + encoding = bech32_verify_checksum(hrp, data) + if encoding is None: + return (None, None, None) + return (encoding, hrp, data[:-6]) -def convertbits(data, frombits, tobits, pad=True): +def convertbits(data: Union[bytes, List[int]], frombits: int, tobits: int, pad: bool = True) -> Optional[List[int]]: """General power-of-2 base conversion.""" acc = 0 bits = 0 @@ -100,10 +128,10 @@ def convertbits(data, frombits, tobits, pad=True): return ret -def decode(hrp, addr): +def decode(hrp: str, addr: str) -> Tuple[Optional[int], Optional[List[int]]]: """Decode a segwit address.""" - hrpgot, data = bech32_decode(addr) - if hrpgot != hrp: + encoding, hrpgot, data = bech32_decode(addr) + if hrpgot != hrp or hrpgot is None or data is None: return (None, None) decoded = convertbits(data[1:], 5, 8, False) if decoded is None or len(decoded) < 2 or len(decoded) > 40: @@ -112,12 +140,18 @@ def decode(hrp, addr): return (None, None) if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: return (None, None) + if (data[0] == 0 and encoding != Encoding.BECH32) or (data[0] != 0 and encoding != Encoding.BECH32M): + return (None, None) return (data[0], decoded) -def encode(hrp, witver, witprog): +def encode(hrp: str, witver: int, witprog: bytes) -> Optional[str]: """Encode a segwit address.""" - ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5)) + encoding = Encoding.BECH32 if witver == 0 else Encoding.BECH32M + conv_bits = convertbits(witprog, 8, 5) + if conv_bits is None: + return None + ret = bech32_encode(encoding, hrp, [witver] + conv_bits) if decode(hrp, ret) == (None, None): return None return ret diff --git a/hwilib/cli.py b/hwilib/_cli.py similarity index 66% rename from hwilib/cli.py rename to hwilib/_cli.py index a2ead1436..acd4b2578 100644 --- a/hwilib/cli.py +++ b/hwilib/_cli.py @@ -1,16 +1,39 @@ #! /usr/bin/env python3 -from .commands import backup_device, displayaddress, enumerate, find_device, \ - get_client, getmasterxpub, getxpub, getkeypool, getdescriptors, prompt_pin, restore_device, send_pin, setup_device, \ - signmessage, signtx, wipe_device, install_udev_rules +from .commands import ( + backup_device, + displayaddress, + enumerate, + find_device, + get_client, + getmasterxpub, + getxpub, + getkeypool, + getdescriptors, + prompt_pin, + toggle_passphrase, + restore_device, + send_pin, + setup_device, + signmessage, + signtx, + wipe_device, + install_udev_rules, +) +from .common import ( + AddressType, + Chain, +) from .errors import ( handle_errors, DEVICE_CONN_ERROR, HELP_TEXT, MISSING_ARGUMENTS, NO_DEVICE_TYPE, - UNAVAILABLE_ACTION + UnavailableActionError, + UNKNOWN_ERROR, ) +from .hwwclient import HardwareWalletClient from . import __version__ import argparse @@ -19,94 +42,109 @@ import json import sys -def backup_device_handler(args, client): +from typing import ( + Any, + Dict, + IO, + List, + NoReturn, + Optional, + Union, +) + + +def backup_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]: return backup_device(client, label=args.label, backup_passphrase=args.backup_passphrase) -def displayaddress_handler(args, client): - return displayaddress(client, desc=args.desc, path=args.path, sh_wpkh=args.sh_wpkh, wpkh=args.wpkh) +def displayaddress_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]: + return displayaddress(client, desc=args.desc, path=args.path, addr_type=args.addr_type) -def enumerate_handler(args): - return enumerate(password=args.password) +def enumerate_handler(args: argparse.Namespace) -> List[Dict[str, Any]]: + return enumerate(password=args.password, expert=args.expert, chain=args.chain) -def getmasterxpub_handler(args, client): - return getmasterxpub(client) +def getmasterxpub_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]: + return getmasterxpub(client, addrtype=args.addr_type, account=args.account) -def getxpub_handler(args, client): - return getxpub(client, path=args.path) +def getxpub_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]: + return getxpub(client, path=args.path, expert=args.expert) -def getkeypool_handler(args, client): - return getkeypool(client, path=args.path, start=args.start, end=args.end, internal=args.internal, keypool=args.keypool, account=args.account, sh_wpkh=args.sh_wpkh, wpkh=args.wpkh) +def getkeypool_handler(args: argparse.Namespace, client: HardwareWalletClient) -> List[Dict[str, Any]]: + return getkeypool(client, path=args.path, start=args.start, end=args.end, internal=args.internal, keypool=args.keypool, account=args.account, addr_type=args.addr_type, addr_all=args.all) -def getdescriptors_handler(args, client): +def getdescriptors_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, List[str]]: return getdescriptors(client, account=args.account) -def restore_device_handler(args, client): +def restore_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]: if args.interactive: - return restore_device(client, label=args.label) - return {'error': 'restore requires interactive mode', 'code': UNAVAILABLE_ACTION} + return restore_device(client, label=args.label, word_count=args.word_count) + raise UnavailableActionError("restore requires interactive mode") -def setup_device_handler(args, client): +def setup_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]: if args.interactive: return setup_device(client, label=args.label, backup_passphrase=args.backup_passphrase) - return {'error': 'setup requires interactive mode', 'code': UNAVAILABLE_ACTION} + raise UnavailableActionError("setup requires interactive mode") -def signmessage_handler(args, client): +def signmessage_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, str]: return signmessage(client, message=args.message, path=args.path) -def signtx_handler(args, client): +def signtx_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, Union[bool, str]]: return signtx(client, psbt=args.psbt) -def wipe_device_handler(args, client): +def wipe_device_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]: return wipe_device(client) -def prompt_pin_handler(args, client): +def prompt_pin_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]: return prompt_pin(client) -def send_pin_handler(args, client): +def toggle_passphrase_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]: + return toggle_passphrase(client) + +def send_pin_handler(args: argparse.Namespace, client: HardwareWalletClient) -> Dict[str, bool]: return send_pin(client, pin=args.pin) -def install_udev_rules_handler(args): +def install_udev_rules_handler(args: argparse.Namespace) -> Dict[str, bool]: return install_udev_rules('udev', args.location) class HWIHelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter): pass class HWIArgumentParser(argparse.ArgumentParser): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.formatter_class = HWIHelpFormatter - def print_usage(self, file=None): + def print_usage(self, file: Optional[IO[str]] = None) -> None: if file is None: file = sys.stderr super().print_usage(file) - def print_help(self, file=None): + def print_help(self, file: Optional[IO[str]] = None) -> None: if file is None: file = sys.stderr super().print_help(file) error = {'error': 'Help text requested', 'code': HELP_TEXT} print(json.dumps(error)) - def error(self, message): + def error(self, message: str) -> NoReturn: self.print_usage(sys.stderr) args = {'prog': self.prog, 'message': message} error = {'error': '%(prog)s: error: %(message)s' % args, 'code': MISSING_ARGUMENTS} print(json.dumps(error)) self.exit(2) -def process_commands(cli_args): +def get_parser() -> HWIArgumentParser: parser = HWIArgumentParser(description='Hardware Wallet Interface, version {}.\nAccess and send commands to a hardware wallet device. Responses are in JSON format.'.format(__version__)) parser.add_argument('--device-path', '-d', help='Specify the device path of the device to connect to') parser.add_argument('--device-type', '-t', help='Specify the type of device that will be connected. If `--device-path` not given, the first device of this type enumerated is used.') - parser.add_argument('--password', '-p', help='Device password if it has one (e.g. DigitalBitbox)', default='') + parser.add_argument('--password', '-p', help='Device password if it has one (e.g. DigitalBitbox)') parser.add_argument('--stdinpass', help='Enter the device password on the command line', action='store_true') - parser.add_argument('--testnet', help='Use testnet prefixes', action='store_true') + parser.add_argument('--chain', help='Select chain to work with', type=Chain.argparse, choices=list(Chain), default=Chain.MAIN) # type: ignore parser.add_argument('--debug', help='Print debug statements', action='store_true') parser.add_argument('--fingerprint', '-f', help='Specify the device to connect to using the first 4 bytes of the hash160 of the master public key. It will connect to the first device that matches this fingerprint.') parser.add_argument('--version', action='version', version='%(prog)s {}'.format(__version__)) parser.add_argument('--stdin', help='Enter commands and arguments via stdin', action='store_true') parser.add_argument('--interactive', '-i', help='Use some commands interactively. Currently required for all device configuration commands', action='store_true') + parser.add_argument('--expert', help='Do advanced things and get more detailed information returned from some commands. Use at your own risk.', action='store_true') subparsers = parser.add_subparsers(description='Commands', dest='command') # work-around to make subparser required @@ -115,7 +153,9 @@ def process_commands(cli_args): enumerate_parser = subparsers.add_parser('enumerate', help='List all available devices') enumerate_parser.set_defaults(func=enumerate_handler) - getmasterxpub_parser = subparsers.add_parser('getmasterxpub', help='Get the extended public key at m/44\'/0\'/0\'') + getmasterxpub_parser = subparsers.add_parser('getmasterxpub', help='Get the extended public key for BIP 44 standard derivation paths. Convenience function to get xpubs given the address type, account, and chain type.') + getmasterxpub_parser.add_argument("--addr-type", help="Get the master xpub used to derive addresses for this address type", type=AddressType.argparse, choices=list(AddressType), default=AddressType.WIT) # type: ignore + getmasterxpub_parser.add_argument("--account", help="The account number", type=int, default=0) getmasterxpub_parser.set_defaults(func=getmasterxpub_handler) signtx_parser = subparsers.add_parser('signtx', help='Sign a PSBT') @@ -136,10 +176,11 @@ def process_commands(cli_args): kparg_group.add_argument('--keypool', action='store_true', dest='keypool', help='Indicates that the keys are to be imported to the keypool', default=True) kparg_group.add_argument('--nokeypool', action='store_false', dest='keypool', help='Indicates that the keys are not to be imported to the keypool', default=False) getkeypool_parser.add_argument('--internal', action='store_true', help='Indicates that the keys are change keys') - getkeypool_parser.add_argument('--sh_wpkh', action='store_true', help='Generate p2sh-nested segwit addresses (default path: m/49h/0h/0h/[0,1]/*)') - getkeypool_parser.add_argument('--wpkh', action='store_true', help='Generate bech32 addresses (default path: m/84h/0h/0h/[0,1]/*)') + kp_type_group = getkeypool_parser.add_mutually_exclusive_group() + kp_type_group.add_argument("--addr-type", help="The address type (and default derivation path) to produce descriptors for", type=AddressType.argparse, choices=list(AddressType), default=AddressType.WIT) # type: ignore + kp_type_group.add_argument('--all', action='store_true', help='Generate addresses for all standard address types (default paths: ``m/{44,49,84}h/0h/0h/[0,1]/*)``') getkeypool_parser.add_argument('--account', help='BIP43 account', type=int, default=0) - getkeypool_parser.add_argument('--path', help='Derivation path, default follows BIP43 convention, e.g. m/84h/0h/0h/1/* with --wpkh --internal. If this argument and --internal is not given, both internal and external keypools will be returned.') + getkeypool_parser.add_argument('--path', help='Derivation path, default follows BIP43 convention, e.g. ``m/84h/0h/0h/1/*`` with --addr-type wpkh --internal. If this argument and --internal is not given, both internal and external keypools will be returned.') getkeypool_parser.add_argument('start', type=int, help='The index to start at.') getkeypool_parser.add_argument('end', type=int, help='The index to end at.') getkeypool_parser.set_defaults(func=getkeypool_handler) @@ -151,9 +192,8 @@ def process_commands(cli_args): displayaddr_parser = subparsers.add_parser('displayaddress', help='Display an address') group = displayaddr_parser.add_mutually_exclusive_group(required=True) group.add_argument('--desc', help='Output Descriptor. E.g. wpkh([00000000/84h/0h/0h]xpub.../0/0), where 00000000 must match --fingerprint and xpub can be obtained with getxpub. See doc/descriptors.md in Bitcoin Core') - group.add_argument('--path', help='The BIP 32 derivation path of the key embedded in the address, default follows BIP43 convention, e.g. m/84h/0h/0h/1/*') - displayaddr_parser.add_argument('--sh_wpkh', action='store_true', help='Display the p2sh-nested segwit address associated with this key path') - displayaddr_parser.add_argument('--wpkh', action='store_true', help='Display the bech32 version of the address associated with this key path') + group.add_argument('--path', help='The BIP 32 derivation path of the key embedded in the address, default follows BIP43 convention, e.g. ``m/84h/0h/0h/1/*``') + displayaddr_parser.add_argument("--addr-type", help="The address type to display", type=AddressType.argparse, choices=list(AddressType), default=AddressType.WIT) # type: ignore displayaddr_parser.set_defaults(func=displayaddress_handler) setupdev_parser = subparsers.add_parser('setup', help='Setup a device. Passphrase protection uses the password given by -p. Requires interactive mode') @@ -165,6 +205,7 @@ def process_commands(cli_args): wipedev_parser.set_defaults(func=wipe_device_handler) restore_parser = subparsers.add_parser('restore', help='Initiate the device restoring process. Requires interactive mode') + restore_parser.add_argument('--word_count', '-w', help='Word count of your BIP39 recovery phrase (options: 12/18/24)', type=int, default=24) restore_parser.add_argument('--label', '-l', help='The name to give to the device', default='') restore_parser.set_defaults(func=restore_device_handler) @@ -176,6 +217,9 @@ def process_commands(cli_args): promptpin_parser = subparsers.add_parser('promptpin', help='Have the device prompt for your PIN') promptpin_parser.set_defaults(func=prompt_pin_handler) + togglepassphrase_parser = subparsers.add_parser('togglepassphrase', help='Toggle BIP39 passphrase protection') + togglepassphrase_parser.set_defaults(func=toggle_passphrase_handler) + sendpin_parser = subparsers.add_parser('sendpin', help='Send the numeric positions for your PIN to the device') sendpin_parser.add_argument('pin', help='The numeric positions of the PIN') sendpin_parser.set_defaults(func=send_pin_handler) @@ -185,6 +229,11 @@ def process_commands(cli_args): udevrules_parser.add_argument('--location', help='The path where the udev rules files will be copied', default='/etc/udev/rules.d/') udevrules_parser.set_defaults(func=install_udev_rules_handler) + return parser + +def process_commands(cli_args: List[str]) -> Any: + parser = get_parser() + if any(arg == '--stdin' for arg in cli_args): while True: try: @@ -206,7 +255,7 @@ def process_commands(cli_args): device_type = args.device_type password = args.password command = args.command - result = {} + result: Dict[str, Any] = {} # Setup debug logging logging.basicConfig(level=logging.DEBUG if args.debug else logging.WARNING) @@ -228,18 +277,19 @@ def process_commands(cli_args): # Auto detect if we are using fingerprint or type to identify device if args.fingerprint or (args.device_type and not args.device_path): - client = find_device(args.device_path, args.password, args.device_type, args.fingerprint) + client = find_device(args.password, args.device_type, args.fingerprint, args.expert, args.chain) if not client: return {'error': 'Could not find device with specified fingerprint', 'code': DEVICE_CONN_ERROR} elif args.device_type and args.device_path: with handle_errors(result=result, code=DEVICE_CONN_ERROR): - client = get_client(device_type, device_path, password) + client = get_client(device_type, device_path, password, args.expert, args.chain) if 'error' in result: return result else: return {'error': 'You must specify a device type or fingerprint for all commands except enumerate', 'code': NO_DEVICE_TYPE} - client.is_testnet = args.testnet + if client is None: + return {"error": "Unable to communicated with device", "code": UNKNOWN_ERROR} # Do the commands with handle_errors(result=result, debug=args.debug): @@ -250,6 +300,6 @@ def process_commands(cli_args): return result -def main(): +def main() -> None: result = process_commands(sys.argv[1:]) print(json.dumps(result)) diff --git a/hwilib/_gui.py b/hwilib/_gui.py new file mode 100644 index 000000000..cb37b4422 --- /dev/null +++ b/hwilib/_gui.py @@ -0,0 +1,497 @@ +#! /usr/bin/env python3 + +import json +import logging +import sys +import time +from typing import Callable + +from . import commands, __version__ +from ._cli import HWIArgumentParser +from .errors import handle_errors, DEVICE_NOT_INITIALIZED +from .common import AddressType, Chain + +try: + from .ui.ui_bitbox02pairing import Ui_BitBox02PairingDialog + from .ui.ui_displayaddressdialog import Ui_DisplayAddressDialog + from .ui.ui_getxpubdialog import Ui_GetXpubDialog + from .ui.ui_getkeypooloptionsdialog import Ui_GetKeypoolOptionsDialog + from .ui.ui_mainwindow import Ui_MainWindow + from .ui.ui_sendpindialog import Ui_SendPinDialog + from .ui.ui_setpassphrasedialog import Ui_SetPassphraseDialog + from .ui.ui_signmessagedialog import Ui_SignMessageDialog + from .ui.ui_signpsbtdialog import Ui_SignPSBTDialog +except ImportError: + print('Could not import UI files, did you run contrib/generate-ui.sh') + exit(-1) + +from PySide2.QtGui import QRegExpValidator +from PySide2.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLineEdit, QMessageBox, QMainWindow +from PySide2.QtCore import QCoreApplication, QRegExp, Signal, Slot + +import bitbox02.util + +def do_command(f, *args, **kwargs): + result = {} + with handle_errors(result=result): + result = f(*args, **kwargs) + if 'error' in result: + msg = 'Error: {}\nCode:{}'.format(result['error'], result['code']) + QMessageBox.critical(None, "An Error Occurred", msg) + return None + return result + +class SetPassphraseDialog(QDialog): + def __init__(self): + super(SetPassphraseDialog, self).__init__() + self.ui = Ui_SetPassphraseDialog() + self.ui.setupUi(self) + self.setWindowTitle('Set Passphrase') + + self.ui.passphrase_lineedit.setFocus() + +class SendPinDialog(QDialog): + pin_sent_success = Signal() + + def __init__(self, client, prompt_pin=True): + super(SendPinDialog, self).__init__() + self.ui = Ui_SendPinDialog() + self.ui.setupUi(self) + self.setWindowTitle('Send Pin') + self.client = client + self.ui.pin_lineedit.setFocus() + self.ui.pin_lineedit.setValidator(QRegExpValidator(QRegExp("[1-9]+"), None)) + self.ui.pin_lineedit.setEchoMode(QLineEdit.Password) + + self.ui.p1_button.clicked.connect(self.button_clicked(1)) + self.ui.p2_button.clicked.connect(self.button_clicked(2)) + self.ui.p3_button.clicked.connect(self.button_clicked(3)) + self.ui.p4_button.clicked.connect(self.button_clicked(4)) + self.ui.p5_button.clicked.connect(self.button_clicked(5)) + self.ui.p6_button.clicked.connect(self.button_clicked(6)) + self.ui.p7_button.clicked.connect(self.button_clicked(7)) + self.ui.p8_button.clicked.connect(self.button_clicked(8)) + self.ui.p9_button.clicked.connect(self.button_clicked(9)) + + self.accepted.connect(self.sendpindialog_accepted) + if prompt_pin: + do_command(commands.prompt_pin, self.client) + + def button_clicked(self, number): + @Slot() + def button_clicked_num(): + self.ui.pin_lineedit.setText(self.ui.pin_lineedit.text() + str(number)) + return button_clicked_num + + @Slot() + def sendpindialog_accepted(self): + pin = self.ui.pin_lineedit.text() + + # Send the pin + do_command(commands.send_pin, self.client, pin) + self.client.close() + self.client = None + self.pin_sent_success.emit() + +class GetXpubDialog(QDialog): + def __init__(self, client): + super(GetXpubDialog, self).__init__() + self.ui = Ui_GetXpubDialog() + self.ui.setupUi(self) + self.setWindowTitle('Get xpub') + self.client = client + + self.ui.path_lineedit.setValidator(QRegExpValidator(QRegExp("m(/[0-9]+['Hh]?)+"), None)) + self.ui.path_lineedit.setFocus() + self.ui.buttonBox.button(QDialogButtonBox.Close).setAutoDefault(False) + + self.ui.getxpub_button.clicked.connect(self.getxpub_button_clicked) + self.ui.buttonBox.clicked.connect(self.accept) + + @Slot() + def getxpub_button_clicked(self): + path = self.ui.path_lineedit.text() + res = do_command(commands.getxpub, self.client, path) + self.ui.xpub_lineedit.setText(res['xpub']) + +class SignPSBTDialog(QDialog): + def __init__(self, client): + super(SignPSBTDialog, self).__init__() + self.ui = Ui_SignPSBTDialog() + self.ui.setupUi(self) + self.setWindowTitle('Sign PSBT') + self.client = client + + self.ui.psbt_in_textedit.setFocus() + + self.ui.sign_psbt_button.clicked.connect(self.sign_psbt_button_clicked) + self.ui.buttonBox.clicked.connect(self.accept) + + @Slot() + def sign_psbt_button_clicked(self): + psbt_str = self.ui.psbt_in_textedit.toPlainText() + res = do_command(commands.signtx, self.client, psbt_str) + self.ui.psbt_out_textedit.setPlainText(res['psbt']) + +class SignMessageDialog(QDialog): + def __init__(self, client): + super(SignMessageDialog, self).__init__() + self.ui = Ui_SignMessageDialog() + self.ui.setupUi(self) + self.setWindowTitle('Sign Message') + self.client = client + + self.ui.path_lineedit.setValidator(QRegExpValidator(QRegExp("m(/[0-9]+['Hh]?)+"), None)) + self.ui.msg_textedit.setFocus() + + self.ui.signmsg_button.clicked.connect(self.signmsg_button_clicked) + self.ui.buttonBox.clicked.connect(self.accept) + + @Slot() + def signmsg_button_clicked(self): + msg_str = self.ui.msg_textedit.toPlainText() + path = self.ui.path_lineedit.text() + res = do_command(commands.signmessage, self.client, msg_str, path) + self.ui.sig_textedit.setPlainText(res['signature']) + +class DisplayAddressDialog(QDialog): + def __init__(self, client): + super(DisplayAddressDialog, self).__init__() + self.ui = Ui_DisplayAddressDialog() + self.ui.setupUi(self) + self.setWindowTitle('Display Address') + self.client = client + + self.ui.path_lineedit.setValidator(QRegExpValidator(QRegExp("m(/[0-9]+['Hh]?)+"), None)) + self.ui.path_lineedit.setFocus() + + self.ui.go_button.clicked.connect(self.go_button_clicked) + self.ui.buttonBox.clicked.connect(self.accept) + + @Slot() + def go_button_clicked(self): + path = self.ui.path_lineedit.text() + if self.ui.sh_wpkh_radio.isChecked(): + addrtype = AddressType.SH_WIT + elif self.ui.wpkh_radio.isChecked(): + addrtype = AddressType.WIT + elif self.ui.pkh_radio.isChecked(): + addrtype = AddressType.LEGACY + else: + assert False # How did this happen? + res = do_command(commands.displayaddress, self.client, path, addr_type=addrtype) + self.ui.address_lineedit.setText(res['address']) + +class GetKeypoolOptionsDialog(QDialog): + def __init__(self, opts): + super(GetKeypoolOptionsDialog, self).__init__() + self.ui = Ui_GetKeypoolOptionsDialog() + self.ui.setupUi(self) + self.setWindowTitle('Set getkeypool options') + + self.ui.start_spinbox.setValue(opts['start']) + self.ui.end_spinbox.setValue(opts['end']) + self.ui.internal_checkbox.setChecked(opts['internal']) + self.ui.keypool_checkbox.setChecked(opts['keypool']) + self.ui.account_spinbox.setValue(opts['account']) + self.ui.path_lineedit.setValidator(QRegExpValidator(QRegExp("m(/[0-9]+['Hh]?)+"), None)) + if opts['account_used']: + self.ui.account_radio.setChecked(True) + self.ui.path_radio.setChecked(False) + self.ui.path_lineedit.setEnabled(False) + self.ui.account_spinbox.setEnabled(True) + self.ui.account_spinbox.setValue(opts['account']) + else: + self.ui.account_radio.setChecked(False) + self.ui.path_radio.setChecked(True) + self.ui.path_lineedit.setEnabled(True) + self.ui.account_spinbox.setEnabled(False) + self.ui.path_lineedit.setText(opts['path']) + self.ui.sh_wpkh_radio.setChecked(opts['addrtype'] == AddressType.SH_WIT) + self.ui.wpkh_radio.setChecked(opts['addrtype'] == AddressType.WIT) + self.ui.pkh_radio.setChecked(opts['addrtype'] == AddressType.LEGACY) + + self.ui.account_radio.toggled.connect(self.toggle_account) + + @Slot() + def toggle_account(self, checked): + if checked: + self.ui.path_lineedit.setEnabled(False) + self.ui.account_spinbox.setEnabled(True) + else: + self.ui.path_lineedit.setEnabled(True) + self.ui.account_spinbox.setEnabled(False) + +class BitBox02PairingDialog(QDialog): + def __init__(self, pairing_code: str, device_response: Callable[[], bool]): + super(BitBox02PairingDialog, self).__init__() + self.ui = Ui_BitBox02PairingDialog() + self.ui.setupUi(self) + self.setWindowTitle('Verify BitBox02 pairing code') + self.ui.pairingCode.setText(pairing_code.replace("\n", "
")) + self.ui.buttonBox.setEnabled(False) + self.device_response = device_response + self.painted = False + + def paintEvent(self, ev): + super().paintEvent(ev) + self.painted = True + + def enable_buttons(self): + self.ui.buttonBox.setEnabled(True) + +class BitBox02NoiseConfig(bitbox02.util.BitBoxAppNoiseConfig): + """ GUI elements to perform the BitBox02 pairing and attestatoin check """ + + def show_pairing(self, code: str, device_response: Callable[[], bool]) -> bool: + dialog = BitBox02PairingDialog(code, device_response) + dialog.show() + # render the window since the next operation is blocking + while True: + QCoreApplication.processEvents() + if dialog.painted: + break + time.sleep(0.1) + if not device_response(): + return False + dialog.enable_buttons() + dialog.exec_() + return dialog.result() == QDialog.Accepted + + def attestation_check(self, result: bool) -> None: + if not result: + QMessageBox.warning( + None, + "BitBox02 attestation check", + "BitBox02 attestation check failed. Your BitBox02 might not be genuine. Please contact support@shiftcrypto.ch if the problem persists.", + ) + +class HWIQt(QMainWindow): + def __init__(self, passphrase='', chain=Chain.MAIN): + super(HWIQt, self).__init__() + self.ui = Ui_MainWindow() + self.ui.setupUi(self) + self.setWindowTitle('HWI Qt') + + self.devices = [] + self.client = None + self.device_info = {} + self.passphrase = passphrase + self.chain = chain + self.current_dialog = None + self.getkeypool_opts = { + 'start': 0, + 'end': 1000, + 'account': 0, + 'internal': False, + 'keypool': True, + 'addrtype': AddressType.SH_WIT, + 'path': None, + 'account_used': True + } + + self.ui.enumerate_refresh_button.clicked.connect(self.refresh_clicked) + self.ui.setpass_button.clicked.connect(self.show_setpassphrasedialog) + self.ui.sendpin_button.clicked.connect(lambda: self.show_sendpindialog(prompt_pin=True)) + self.ui.getxpub_button.clicked.connect(self.show_getxpubdialog) + self.ui.signtx_button.clicked.connect(self.show_signpsbtdialog) + self.ui.signmsg_button.clicked.connect(self.show_signmessagedialog) + self.ui.display_addr_button.clicked.connect(self.show_displayaddressdialog) + self.ui.getkeypool_opts_button.clicked.connect(self.show_getkeypooloptionsdialog) + self.ui.toggle_passphrase_button.clicked.connect(self.toggle_passphrase) + + self.ui.enumerate_combobox.currentIndexChanged.connect(self.get_client_and_device_info) + + def clear_info(self): + self.ui.getxpub_button.setEnabled(False) + self.ui.signtx_button.setEnabled(False) + self.ui.signmsg_button.setEnabled(False) + self.ui.display_addr_button.setEnabled(False) + self.ui.getkeypool_opts_button.setEnabled(False) + self.ui.toggle_passphrase_button.setEnabled(False) + self.ui.keypool_textedit.clear() + self.ui.desc_textedit.clear() + + @Slot() + def refresh_clicked(self): + if self.client: + self.client.close() + self.client = None + + self.devices = commands.enumerate(self.passphrase) + self.ui.enumerate_combobox.currentIndexChanged.disconnect() + self.ui.enumerate_combobox.clear() + self.ui.enumerate_combobox.addItem('') + for dev in self.devices: + fingerprint = 'none' + if 'fingerprint' in dev: + fingerprint = dev['fingerprint'] + dev_str = '{} fingerprint:{} path:{}'.format(dev['model'], fingerprint, dev['path']) + self.ui.enumerate_combobox.addItem(dev_str) + self.ui.enumerate_combobox.currentIndexChanged.connect(self.get_client_and_device_info) + self.clear_info() + + @Slot() + def show_setpassphrasedialog(self): + self.current_dialog = SetPassphraseDialog() + self.current_dialog.accepted.connect(self.setpassphrasedialog_accepted) + self.current_dialog.exec_() + + @Slot() + def setpassphrasedialog_accepted(self): + self.passphrase = self.current_dialog.ui.passphrase_lineedit.text() + self.current_dialog = None + + @Slot() + def get_client_and_device_info(self, index): + self.ui.sendpin_button.setEnabled(False) + if index == 0: + self.clear_info() + return + + self.ui.getxpub_button.setEnabled(True) + self.ui.signtx_button.setEnabled(True) + self.ui.display_addr_button.setEnabled(True) + self.ui.getkeypool_opts_button.setEnabled(True) + + # Get the client + self.device_info = self.devices[index - 1] + self.client = commands.get_client(self.device_info['model'], self.device_info['path'], self.passphrase, self.chain) + + if self.device_info['type'] == 'bitbox02': + self.client.set_noise_config(BitBox02NoiseConfig()) + + self.ui.setpass_button.setEnabled(self.device_info['type'] != 'bitbox02') + self.ui.signmsg_button.setEnabled(True) + self.ui.toggle_passphrase_button.setEnabled(self.device_info['type'] in ('trezor', 'keepkey', 'bitbox02', )) + + self.get_device_info() + + def get_device_info(self): + # Enable the sendpin button if it's a trezor and it needs it + if self.device_info['needs_pin_sent']: + self.ui.sendpin_button.setEnabled(True) + self.clear_info() + return + else: + self.ui.sendpin_button.setEnabled(False) + + # If it isn't initialized, show an error but don't do anything + if 'code' in self.device_info and self.device_info['code'] == DEVICE_NOT_INITIALIZED: + self.clear_info() + QMessageBox.information(None, "Not initialized yet", 'Device is not initialized yet') + return + + # do getkeypool and getdescriptors + keypool = do_command(commands.getkeypool, self.client, + None if self.getkeypool_opts['account_used'] else self.getkeypool_opts['path'], + self.getkeypool_opts['start'], + self.getkeypool_opts['end'], + self.getkeypool_opts['internal'], + self.getkeypool_opts['keypool'], + self.getkeypool_opts['account'], + self.getkeypool_opts['addrtype']) + descriptors = do_command(commands.getdescriptors, self.client, self.getkeypool_opts['account']) + + self.ui.keypool_textedit.setPlainText(json.dumps(keypool, indent=2)) + self.ui.desc_textedit.setPlainText(json.dumps(descriptors, indent=2)) + + @Slot() + def show_sendpindialog(self, prompt_pin=True): + self.current_dialog = SendPinDialog(self.client, prompt_pin) + self.current_dialog.pin_sent_success.connect(self.sendpindialog_accepted) + self.current_dialog.exec_() + + @Slot() + def sendpindialog_accepted(self): + self.current_dialog = None + + curr_index = self.ui.enumerate_combobox.currentIndex() + self.refresh_clicked() + self.ui.enumerate_combobox.setCurrentIndex(curr_index) + + @Slot() + def show_getxpubdialog(self): + self.current_dialog = GetXpubDialog(self.client) + self.current_dialog.exec_() + + @Slot() + def show_signpsbtdialog(self): + self.current_dialog = SignPSBTDialog(self.client) + self.current_dialog.exec_() + + @Slot() + def show_signmessagedialog(self): + self.current_dialog = SignMessageDialog(self.client) + self.current_dialog.exec_() + + @Slot() + def show_displayaddressdialog(self): + self.current_dialog = DisplayAddressDialog(self.client) + self.current_dialog.exec_() + + @Slot() + def show_getkeypooloptionsdialog(self): + self.current_dialog = GetKeypoolOptionsDialog(self.getkeypool_opts) + self.current_dialog.accepted.connect(self.getkeypooloptionsdialog_accepted) + self.current_dialog.exec_() + + @Slot() + def getkeypooloptionsdialog_accepted(self): + self.getkeypool_opts['start'] = self.current_dialog.ui.start_spinbox.value() + self.getkeypool_opts['end'] = self.current_dialog.ui.end_spinbox.value() + self.getkeypool_opts['internal'] = self.current_dialog.ui.internal_checkbox.isChecked() + self.getkeypool_opts['keypool'] = self.current_dialog.ui.keypool_checkbox.isChecked() + self.getkeypool_opts['addrtype'] = AddressType.LEGACY + if self.current_dialog.ui.sh_wpkh_radio.isChecked(): + self.getkeypool_opts['addrtype'] = AddressType.SH_WIT + if self.current_dialog.ui.wpkh_radio.isChecked(): + self.getkeypool_opts['addrtype'] = AddressType.WIT + if self.current_dialog.ui.pkh_radio.isChecked(): + self.getkeypool_opts['addrtype'] = AddressType.LEGACY + if self.current_dialog.ui.account_radio.isChecked(): + self.getkeypool_opts['account'] = self.current_dialog.ui.account_spinbox.value() + self.getkeypool_opts['account_used'] = True + else: + self.getkeypool_opts['path'] = self.current_dialog.ui.path_lineedit.text() + self.getkeypool_opts['account_used'] = False + self.current_dialog = None + self.get_device_info() + + @Slot() + def toggle_passphrase(self): + do_command(commands.toggle_passphrase, self.client) + if self.device_info['model'] == "keepkey": + self.show_sendpindialog(prompt_pin=False) + +def process_gui_commands(cli_args): + parser = HWIArgumentParser(description='Hardware Wallet Interface Qt, version {}.\nInteractively access and send commands to a hardware wallet device with a GUI. Responses are in JSON format.'.format(__version__)) + parser.add_argument('--password', '-p', help='Device password if it has one (e.g. DigitalBitbox)', default='') + parser.add_argument('--chain', help='Select chain to work with', type=Chain.argparse, choices=list(Chain), default=Chain.MAIN) + parser.add_argument('--debug', help='Print debug statements', action='store_true') + parser.add_argument('--version', action='version', version='%(prog)s {}'.format(__version__)) + + # Parse arguments again for anything entered over stdin + args = parser.parse_args(cli_args) + + result = {} + + # Setup debug logging + logging.basicConfig(level=logging.DEBUG if args.debug else logging.WARNING) + + # Qt setup + app = QApplication() + + window = HWIQt(args.password, args.chain) + + window.refresh_clicked() + + window.show() + ret = app.exec_() + result = {'success': ret == 0} + + return result + +def main(): + result = process_gui_commands(sys.argv[1:]) + print(json.dumps(result)) diff --git a/hwilib/_script.py b/hwilib/_script.py new file mode 100644 index 000000000..95aef03e3 --- /dev/null +++ b/hwilib/_script.py @@ -0,0 +1,155 @@ +""" +Bitcoin Script utilities +************************ +""" + +from typing import ( + Optional, + Sequence, + Tuple, +) + + +def is_opreturn(script: bytes) -> bool: + """ + Determine whether a script is an OP_RETURN output script. + + :param script: The script + :returns: Whether the script is an OP_RETURN output script + """ + return script[0] == 0x6a + + +def is_p2sh(script: bytes) -> bool: + """ + Determine whether a script is a P2SH output script. + + :param script: The script + :returns: Whether the script is a P2SH output script + """ + return len(script) == 23 and script[0] == 0xa9 and script[1] == 0x14 and script[22] == 0x87 + + +def is_p2pkh(script: bytes) -> bool: + """ + Determine whether a script is a P2PKH output script. + + :param script: The script + :returns: Whether the script is a P2PKH output script + """ + return len(script) == 25 and script[0] == 0x76 and script[1] == 0xa9 and script[2] == 0x14 and script[23] == 0x88 and script[24] == 0xac + + +def is_p2pk(script: bytes) -> bool: + """ + Determine whether a script is a P2PK output script. + + :param script: The script + :returns: Whether the script is a P2PK output script + """ + return (len(script) == 35 or len(script) == 67) and (script[0] == 0x21 or script[0] == 0x41) and script[-1] == 0xac + + +def is_witness(script: bytes) -> Tuple[bool, int, bytes]: + """ + Determine whether a script is a segwit output script. + If so, also returns the witness version and witness program. + + :param script: The script + :returns: A tuple of a bool indicating whether the script is a segwit output script, + an int representing the witness version, + and the bytes of the witness program. + """ + if len(script) < 4 or len(script) > 42: + return (False, 0, b"") + + if script[0] != 0 and (script[0] < 81 or script[0] > 96): + return (False, 0, b"") + + if script[1] + 2 == len(script): + return (True, script[0] - 0x50 if script[0] else 0, script[2:]) + + return (False, 0, b"") + + +def is_p2wpkh(script: bytes) -> bool: + """ + Determine whether a script is a P2WPKH output script. + + :param script: The script + :returns: Whether the script is a P2WPKH output script + """ + is_wit, wit_ver, wit_prog = is_witness(script) + if not is_wit: + return False + elif wit_ver != 0: + return False + return len(wit_prog) == 20 + + +def is_p2wsh(script: bytes) -> bool: + """ + Determine whether a script is a P2WSH output script. + + :param script: The script + :returns: Whether the script is a P2WSH output script + """ + is_wit, wit_ver, wit_prog = is_witness(script) + if not is_wit: + return False + elif wit_ver != 0: + return False + return len(wit_prog) == 32 + +def is_p2tr(script: bytes) -> bool: + """ + Determine whether a script is a P2TR output script. + + :param script: The script + :returns: Whether the script is a P2TR output script + """ + is_wit, wit_ver, wit_prog = is_witness(script) + if not is_wit: + return False + elif wit_ver != 1: + return False + return len(wit_prog) == 32 + + +# Only handles up to 15 of 15. Returns None if this script is not a +# multisig script. Returns (m, pubkeys) otherwise. +def parse_multisig(script: bytes) -> Optional[Tuple[int, Sequence[bytes]]]: + """ + Determine whether a script is a multisig script. If so, determine the parameters of that multisig. + + :param script: The script + :returns: ``None`` if the script is not multisig. + If multisig, returns a tuple of the number of signers required, + and a sequence of public key bytes. + """ + # Get m + m = script[0] - 80 + if m < 1 or m > 15: + return None + + # Get pubkeys + pubkeys = [] + offset = 1 + while True: + pubkey_len = script[offset] + if pubkey_len != 33: + break + offset += 1 + pubkeys.append(script[offset:offset + 33]) + offset += 33 + + # Check things at the end + n = script[offset] - 80 + if n != len(pubkeys): + return None + offset += 1 + op_cms = script[offset] + if op_cms != 174: + return None + + return (m, pubkeys) diff --git a/hwilib/_serialize.py b/hwilib/_serialize.py new file mode 100644 index 000000000..7640e5d38 --- /dev/null +++ b/hwilib/_serialize.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +# Copyright (c) 2010 ArtForz -- public domain half-a-node +# Copyright (c) 2012 Jeff Garzik +# Copyright (c) 2010-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. +""" +Bitcoin Object Python Serializations +************************************ + +Modified from the test/test_framework/mininode.py file from the +Bitcoin repository +""" + +import struct + +from typing import ( + List, + Sequence, + TypeVar, + Callable, +) +from typing_extensions import Protocol + +class Readable(Protocol): + def read(self, n: int = -1) -> bytes: + ... + +class Deserializable(Protocol): + def deserialize(self, f: Readable) -> None: + ... + +class Serializable(Protocol): + def serialize(self) -> bytes: + ... + + +# Serialization/deserialization tools +def ser_compact_size(size: int) -> bytes: + """ + Serialize an integer using Bitcoin's compact size unsigned integer serialization. + + :param size: The int to serialize + :returns: The int serialized as a compact size unsigned integer + """ + r = b"" + if size < 253: + r = struct.pack("B", size) + elif size < 0x10000: + r = struct.pack(" int: + """ + Deserialize a compact size unsigned integer from the beginning of the byte stream. + + :param f: The byte stream + :returns: The integer that was serialized + """ + nit: int = struct.unpack(" bytes: + """ + Deserialize a variable length byte string serialized with Bitcoin's variable length string serialization from a byte stream. + + :param f: The byte stream + :returns: The byte string that was serialized + """ + nit = deser_compact_size(f) + return f.read(nit) + +def ser_string(s: bytes) -> bytes: + """ + Serialize a byte string with Bitcoin's variable length string serialization. + + :param s: The byte string to be serialized + :returns: The serialized byte string + """ + return ser_compact_size(len(s)) + s + +def deser_uint256(f: Readable) -> int: + """ + Deserialize a 256 bit integer serialized with Bitcoin's 256 bit integer serialization from a byte stream. + + :param f: The byte stream. + :returns: The integer that was serialized + """ + r = 0 + for i in range(8): + t = struct.unpack(" bytes: + """ + Serialize a 256 bit integer with Bitcoin's 256 bit integer serialization. + + :param u: The integer to serialize + :returns: The serialized 256 bit integer + """ + rs = b"" + for _ in range(8): + rs += struct.pack(">= 32 + return rs + + +def uint256_from_str(s: bytes) -> int: + """ + Deserialize a 256 bit integer serialized with Bitcoin's 256 bit integer serialization from a byte string. + + :param s: The byte string + :returns: The integer that was serialized + """ + r = 0 + t = struct.unpack(" List[D]: + """ + Deserialize a vector of objects with Bitcoin's object vector serialization from a byte stream. + + :param f: The byte stream + :param c: The class of object to deserialize for each object in the vector + :returns: A list of objects that were serialized + """ + nit = deser_compact_size(f) + r = [] + for _ in range(nit): + t = c() + t.deserialize(f) + r.append(t) + return r + + +def ser_vector(v: Sequence[Serializable]) -> bytes: + """ + Serialize a vector of objects with Bitcoin's object vector serialzation. + + :param v: The list of objects to serialize + :returns: The serialized objects + """ + r = ser_compact_size(len(v)) + for i in v: + r += i.serialize() + return r + + +def deser_string_vector(f: Readable) -> List[bytes]: + """ + Deserialize a vector of byte strings from a byte stream. + + :param f: The byte stream + :returns: The list of byte strings that were serialized + """ + nit = deser_compact_size(f) + r = [] + for _ in range(nit): + t = deser_string(f) + r.append(t) + return r + + +def ser_string_vector(v: List[bytes]) -> bytes: + """ + Serialize a list of byte strings as a vector of byte strings. + + :param v: The list of byte strings to serialize + :returns: The serialized list of byte strings + """ + r = ser_compact_size(len(v)) + for sv in v: + r += ser_string(sv) + return r + +def ser_sig_der(r: bytes, s: bytes) -> bytes: + """ + Serialize the ``r`` and ``s`` values of an ECDSA signature using DER. + + :param r: The ``r`` value bytes + :param s: The ``s`` value bytes + :returns: The DER encoded signature + """ + sig = b"\x30" + + # Make r and s as short as possible + ri = 0 + for b in r: + if b == 0: + ri += 1 + else: + break + r = r[ri:] + si = 0 + for b in s: + if b == 0: + si += 1 + else: + break + s = s[si:] + + # Make positive of neg + first = r[0] + if first & (1 << 7) != 0: + r = b"\x00" + r + first = s[0] + if first & (1 << 7) != 0: + s = b"\x00" + s + + # Write total length + total_len = len(r) + len(s) + 4 + sig += struct.pack("B", total_len) + + # write r + sig += b"\x02" + sig += struct.pack("B", len(r)) + sig += r + + # write s + sig += b"\x02" + sig += struct.pack("B", len(s)) + sig += s + + sig += b"\x01" + return sig + +def ser_sig_compact(r: bytes, s: bytes, recid: bytes) -> bytes: + """ + Serialize the ``r`` and ``s`` values of an ECDSA signature using the compact signature serialization scheme. + + :param r: The ``r`` value bytes + :param s: The ``s`` value bytes + :returns: The compact signature + """ + rec = struct.unpack("B", recid)[0] + prefix = struct.pack("B", 27 + 4 + rec) + + sig = b"" + sig += prefix + sig += r + s + + return sig diff --git a/hwilib/commands.py b/hwilib/commands.py index 9d02a9b7a..7169ecdc2 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -1,27 +1,98 @@ #! /usr/bin/env python3 -# Hardware wallet interaction script +""" +Commands +******** + +The functions in this module are the primary way to interact with hardware wallets. +Each function that takes a ``client`` uses a :class:`~hwilib.hwwclient.HardwareWalletClient`. +The functions then call public members of that client to retrieve the data needed. + +Clients can be constructed using :func:`~find_device` or :func:`~get_client`. + +The :func:`~enumerate` function returns information about what devices are available to be connected to. +These information can then be used with :func:`~find_device` or :func:`~get_client` to get a :class:`~hwilib.hwwclient.HardwareWalletClient`. + +Note that this documentation does not specify every exception that can be raised. +Many exceptions are buried within the functions implemented by each device's :class:`~hwilib.hwwclient.HardwareWalletClient`. +For more information about the exceptions that those can raise, please see the specific client documentation. +""" import importlib +import logging import platform -from .serializations import PSBT -from .base58 import get_xpub_fingerprint_as_id, get_xpub_fingerprint_hex, xpub_to_pub_hex -from .errors import UnknownDeviceError, BAD_ARGUMENT, NOT_IMPLEMENTED -from .descriptor import Descriptor +from ._base58 import xpub_to_pub_hex, xpub_to_xonly_pub_hex +from .key import ( + get_bip44_purpose, + get_bip44_chain, + H_, + HARDENED_FLAG, + is_hardened, + KeyOriginInfo, + parse_path, +) +from .errors import ( + BadArgumentError, + NotImplementedError, + UnknownDeviceError, + UnavailableActionError, +) +from .descriptor import ( + Descriptor, + parse_descriptor, + MultisigDescriptor, + TRDescriptor, + PKHDescriptor, + PubkeyProvider, + SHDescriptor, + WPKHDescriptor, + WSHDescriptor, +) from .devices import __all__ as all_devs +from .common import ( + AddressType, + Chain, +) +from .hwwclient import HardwareWalletClient +from .psbt import PSBT + +from itertools import count +from typing import ( + Any, + Dict, + List, + Optional, + Union, +) + + +py_enumerate = enumerate + # Get the client for the device -def get_client(device_type, device_path, password=''): +def get_client(device_type: str, device_path: str, password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> Optional[HardwareWalletClient]: + """ + Returns a HardwareWalletClient for the given device type at the device path + + :param device_type: The type of device + :param device_path: The path specifying where the device can be accessed as returned by :func:`~enumerate` + :param password: The password to use for this device + :param expert: Whether the device should be opened in expert mode (prints more information for some commands) + :param chain: The Chain this client will be using + :return: A :class:`~hwilib.hwwclient.HardwareWalletClient` to interact with the device + :raises: UnknownDeviceError: if the device type is not known by HWI + """ + device_type = device_type.split('_')[0] class_name = device_type.capitalize() module = device_type.lower() - client = None + client: Optional[HardwareWalletClient] = None try: imported_dev = importlib.import_module('.devices.' + module, __package__) client_constructor = getattr(imported_dev, class_name + 'Client') - client = client_constructor(device_path, password) + client = client_constructor(device_path, password, expert, chain) except ImportError: if client: client.close() @@ -30,166 +101,334 @@ def get_client(device_type, device_path, password=''): return client # Get a list of all available hardware wallets -def enumerate(password=''): - result = [] +def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> List[Dict[str, Any]]: + """ + Enumerate all of the devices that HWI can potentially access. + + :param password: The password to use for devices which take passwords from the host. + :return: A list of devices for which clients can be created for. + """ + + result: List[Dict[str, Any]] = [] for module in all_devs: try: imported_dev = importlib.import_module('.devices.' + module, __package__) - result.extend(imported_dev.enumerate(password)) - except ImportError: - pass # Ignore ImportErrors, the user may not have all device dependencies installed + result.extend(imported_dev.enumerate(password, expert, chain)) + except ImportError as e: + # Warn for ImportErrors, but largely ignore them to allow users not install + # all device dependencies if only one or some devices are wanted. + logging.warn(f"{e}, required for {module}. Ignore if you do not want this device.") + pass return result # Fingerprint or device type required -def find_device(device_path, password='', device_type=None, fingerprint=None): +def find_device( + password: Optional[str] = None, + device_type: Optional[str] = None, + fingerprint: Optional[str] = None, + expert: bool = False, + chain: Chain = Chain.MAIN, +) -> Optional[HardwareWalletClient]: + """ + Find a device from the device type or fingerprint and get a client to access it. + This is used as an alternative to :func:`~get_client` if the device path is not known. + + :param password: A password that may be needed to access the device if it can take passwords from the host + :param device_type: The type of device. The client returned will be for this type of device. + If not provided, the fingerprint must be provided + :param fingerprint: The fingerprint of the master public key for the device. + The client returned will have a master public key fingerprint matching this. + If not provided, device_type must be provided. + :param expert: Whether the device should be opened in expert mode (enables additional output for some actions) + :param chain: The Chain this client will be using + :return: A client to interact with the found device + """ + devices = enumerate(password) for d in devices: if device_type is not None and d['type'] != device_type and d['model'] != device_type: continue client = None try: - client = get_client(d['type'], d['path'], password) - - master_fpr = d.get('fingerprint', None) - if master_fpr is None: - master_xpub = client.get_pubkey_at_path('m/0h')['xpub'] - master_fpr = get_xpub_fingerprint_hex(master_xpub) - - if fingerprint and master_fpr != fingerprint: - client.close() - continue - else: - client.fingerprint = master_fpr - return client - except: + assert isinstance(d["type"], str) + assert isinstance(d["path"], str) + client = get_client(d['type'], d['path'], password, expert, chain) + if client is None: + raise Exception() + + if fingerprint: + master_fpr = d.get('fingerprint', None) + if master_fpr is None: + master_fpr = client.get_master_fingerprint().hex() + + if master_fpr != fingerprint: + client.close() + continue + return client + except Exception: if client: client.close() pass # Ignore things we wouldn't get fingerprints for return None -def getmasterxpub(client): - return client.get_master_xpub() - -def signtx(client, psbt): +def getmasterxpub(client: HardwareWalletClient, addrtype: AddressType = AddressType.WIT, account: int = 0) -> Dict[str, str]: + """ + Get the master extended public key from a client + + :param client: The client to interact with + :return: A dictionary containing the public key at the ``m/44'/0'/0'`` derivation path. + Returned as ``{"xpub": }``. + """ + return {"xpub": client.get_master_xpub(addrtype, account).to_string()} + +def signtx(client: HardwareWalletClient, psbt: str) -> Dict[str, Union[bool, str]]: + """ + Sign a Partially Signed Bitcoin Transaction (PSBT) with the client. + + :param client: The client to interact with + :param psbt: The PSBT to sign + :return: A dictionary containing the processed PSBT serialized in Base64. + Returned as ``{"psbt": }``. + """ # Deserialize the transaction tx = PSBT() tx.deserialize(psbt) - return client.sign_tx(tx) - -def getxpub(client, path): - return client.get_pubkey_at_path(path) - -def signmessage(client, message, path): - return client.sign_message(message, path) - -def getkeypool_inner(client, path, start, end, internal=False, keypool=True, account=0, sh_wpkh=False, wpkh=True): - if sh_wpkh and wpkh: - return {'error': 'Both `--wpkh` and `--sh_wpkh` can not be selected at the same time.', 'code': BAD_ARGUMENT} - - try: - master_xpub = client.get_pubkey_at_path('m/0h')['xpub'] - except NotImplementedError as e: - return {'error': str(e), 'code': NOT_IMPLEMENTED} + result = client.sign_tx(tx).serialize() + return {"psbt": result, "signed": result != psbt} + +def getxpub(client: HardwareWalletClient, path: str, expert: bool = False) -> Dict[str, Any]: + """ + Get the master public key at a path from a client + + :param client: The client to interact with + :param path: The derivation path for the public key to retrieve + :param expert: Whether to provide more information intended for experts. + :return: A dictionary containing the public key at the ``bip32_path``. + With expert mode, the information contained within the xpub are decoded and displayed. + Returned as ``{"xpub": }``. + """ + xpub = client.get_pubkey_at_path(path) + result: Dict[str, Any] = {"xpub": xpub.to_string()} + if expert: + result.update(xpub.get_printable_dict()) + return result - desc = getdescriptor(client, master_xpub, client.is_testnet, path, internal, sh_wpkh, wpkh, account, start, end) +def signmessage(client: HardwareWalletClient, message: str, path: str) -> Dict[str, str]: + """ + Sign a message using the key at the derivation path with the client. + + The message will be signed using the Bitcoin signed message standard used by Bitcoin Core. + The message can be either a string which is then encoded to bytes, or bytes. + + :param client: The client to interact with + :param message: The message to sign + :param path: The derivation path for the key to sign with + :return: A dictionary containing the signature. + Returned as ``{"signature": }``. + """ + return {"signature": client.sign_message(message, path)} + +def getkeypool_inner( + client: HardwareWalletClient, + path: str, + start: int, + end: int, + internal: bool = False, + keypool: bool = True, + account: int = 0, + addr_type: AddressType = AddressType.WIT +) -> List[Dict[str, Any]]: + """ + :meta private: + + Construct a single dictionary that specifies a single descriptor and the extra fields needed for ``importmulti`` or ``importdescriptors`` to import it. + + :param path: The derivation path for the key in the descriptor + :param start: The start index of the range, inclusive + :param end: The end index of the range, inclusive + :param internal: Whether to specify this import is change + :param keypool: Whether to specify this import should be added to the keypool + :param account: The BIP 44 account to use if ``path`` is not specified + :param addr_type: The type of address the descriptor should create + """ + master_fpr = client.get_master_fingerprint() + + desc = getdescriptor(client, master_fpr, path, internal, addr_type, account, start, end) if not isinstance(desc, Descriptor): return desc - this_import = {} + this_import: Dict[str, Any] = {} - this_import['desc'] = desc.serialize() + this_import['desc'] = desc.to_string() this_import['range'] = [start, end] this_import['timestamp'] = 'now' this_import['internal'] = internal this_import['keypool'] = keypool + this_import['active'] = keypool this_import['watchonly'] = True return [this_import] -def getdescriptor(client, master_xpub, testnet=False, path=None, internal=False, sh_wpkh=False, wpkh=True, account=0, start=None, end=None): - master_fpr = get_xpub_fingerprint_as_id(master_xpub) - testnet = client.is_testnet - +def getdescriptor( + client: HardwareWalletClient, + master_fpr: bytes, + path: Optional[str] = None, + internal: bool = False, + addr_type: AddressType = AddressType.WIT, + account: int = 0, + start: Optional[int] = None, + end: Optional[int] = None +) -> Descriptor: + """ + Get a descriptor from the client. + + :param client: The client to interact with + :param master_fpr: The hex string for the master fingerprint of the device to use in the descriptor + :param path: The derivation path for the xpub from which additional keys will be derived. + :param internal: Whether the dictionary should indicate that the descriptor should be for change addresses + :param addr_type: The type of address the descriptor should create + :param account: The BIP 44 account to use if ``path`` is not specified + :param start: The start of the range to import, inclusive + :param end: The end of the range to import, inclusive + :return: The descriptor constructed given the above arguments and key fetched from the device + :raises: BadArgumentError: if an argument is malformed or missing. + """ + + parsed_path = [] if not path: - # Master key: - path = "m/" - # Purpose - if wpkh: - path += "84'/" - elif sh_wpkh: - path += "49'/" - else: - path += "44'/" + parsed_path.append(H_(get_bip44_purpose(addr_type))) # Coin type - if testnet: - path += "1'/" - else: - path += "0'/" + parsed_path.append(H_(get_bip44_chain(client.chain))) # Account - path += str(account) + '\'/' + parsed_path.append(H_(account)) # Receive or change if internal: - path += "1/*" + parsed_path.append(1) else: - path += "0/*" + parsed_path.append(0) else: if path[0] != "m": - return {'error': 'Path must start with m/', 'code': BAD_ARGUMENT} + raise BadArgumentError("Path must start with m/") if path[-1] != "*": - return {'error': 'Path must end with /*', 'code': BAD_ARGUMENT} + raise BadArgumentError("Path must end with /*") + parsed_path = parse_path(path[:-2]) # Find the last hardened derivation: - path = path.replace('\'', 'h') - path_suffix = '' - for component in path.split("/")[::-1]: - if component[-1] == 'h' or component[-1] == 'm': + for i, p in zip(count(len(parsed_path) - 1, -1), reversed(parsed_path)): + if is_hardened(p): break - path_suffix = '/' + component + path_suffix - path_base = path.rsplit(path_suffix)[0] + i += 1 - # Get the key at the base - if client.xpub_cache.get(path_base) is None: - client.xpub_cache[path_base] = client.get_pubkey_at_path(path_base)['xpub'] + origin = KeyOriginInfo(master_fpr, parsed_path[:i]) + path_base = origin.get_derivation_path() - return Descriptor(master_fpr, path_base.replace('m', ''), client.xpub_cache.get(path_base), path_suffix, client.is_testnet, sh_wpkh, wpkh) + path_suffix = "" + for p in parsed_path[i:]: + hardened = is_hardened(p) + p &= ~HARDENED_FLAG + path_suffix += "/{}{}".format(p, "h" if hardened else "") + path_suffix += "/*" -# wrapper to allow both internal and external entries when path not given -def getkeypool(client, path, start, end, internal=False, keypool=True, account=0, sh_wpkh=False, wpkh=True): + # Get the key at the base + if client.xpub_cache.get(path_base) is None: + client.xpub_cache[path_base] = client.get_pubkey_at_path(path_base).to_string() + + pubkey = PubkeyProvider(origin, client.xpub_cache.get(path_base, ""), path_suffix) + if addr_type is AddressType.LEGACY: + return PKHDescriptor(pubkey) + elif addr_type is AddressType.SH_WIT: + return SHDescriptor(WPKHDescriptor(pubkey)) + elif addr_type is AddressType.WIT: + return WPKHDescriptor(pubkey) + elif addr_type is AddressType.TAP: + if not client.can_sign_taproot(): + raise UnavailableActionError("Device does not support Taproot") + return TRDescriptor(pubkey) + else: + raise ValueError("Unknown address type") + +def getkeypool( + client: HardwareWalletClient, + path: str, + start: int, + end: int, + internal: bool = False, + keypool: bool = True, + account: int = 0, + addr_type: AddressType = AddressType.WIT, + addr_all: bool = False +) -> List[Dict[str, Any]]: + """ + Get a dictionary which can be passed to Bitcoin Core's ``importmulti`` or ``importdescriptors`` RPCs to import a watchonly wallet based on the client. + By default, a descriptor for legacy addresses is returned. + + :param client: The client to interact with + :param path: The derivation path for the xpub from which additional keys will be derived. + :param start: The start of the range to import, inclusive + :param end: The end of the range to import, inclusive + :param internal: Whether the dictionary should indicate that the descriptor should be for change addresses + :param keypool: Whether the dictionary should indicate that the dsecriptor should be added to the Bitcoin Core keypool/addresspool + :param account: The BIP 44 account to use if ``path`` is not specified + :param addr_type: The address type + :param addr_all: Whether to return a multiple descriptors for every address type + :return: The dictionary containing the descriptor and all of the arguments for ``importmulti`` or ``importdescriptors`` + :raises: BadArgumentError: if an argument is malformed or missing. + """ + supports_taproot = client.can_sign_taproot() + + addr_types = [addr_type] + if addr_all: + addr_types = list(AddressType) + elif not supports_taproot and addr_type == AddressType.TAP: + raise UnavailableActionError("Device does not support Taproot") + + if not supports_taproot and AddressType.TAP in addr_types: + del addr_types[addr_types.index(AddressType.TAP)] + + # When no specific path or internal-ness is specified, create standard types + chains: List[Dict[str, Any]] = [] if path is None and not internal: - internal_chain = getkeypool_inner(client, None, start, end, True, keypool, account, sh_wpkh, wpkh) - external_chain = getkeypool_inner(client, None, start, end, False, keypool, account, sh_wpkh, wpkh) - # Report the first error we encounter - for chain in [internal_chain, external_chain]: - if 'error' in chain: - return chain - # No errors, return pair - return internal_chain + external_chain + for addr_type in addr_types: + for internal_addr in [False, True]: + chains = chains + getkeypool_inner(client, None, start, end, internal_addr, keypool, account, addr_type) + return chains else: - return getkeypool_inner(client, path, start, end, internal, keypool, account, sh_wpkh, wpkh) + assert len(addr_types) == 1 + return getkeypool_inner(client, path, start, end, internal, keypool, account, addr_types[0]) -def getdescriptors(client, account=0): - try: - master_xpub = client.get_pubkey_at_path('m/0h')['xpub'] - except NotImplementedError as e: - return {'error': str(e), 'code': NOT_IMPLEMENTED} +def getdescriptors( + client: HardwareWalletClient, + account: int = 0 +) -> Dict[str, List[str]]: + """ + Get descriptors from the client. + + :param client: The client to interact with + :param account: The BIP 44 account to use + :return: Multiple descriptors from the device matching the BIP 44 standard paths and the given ``account``. + :raises: BadArgumentError: if an argument is malformed or missing. + """ + master_fpr = client.get_master_fingerprint() result = {} for internal in [False, True]: descriptors = [] - desc1 = getdescriptor(client, master_xpub=master_xpub, testnet=client.is_testnet, internal=internal, sh_wpkh=False, wpkh=False, account=account) - desc2 = getdescriptor(client, master_xpub=master_xpub, testnet=client.is_testnet, internal=internal, sh_wpkh=True, wpkh=False, account=account) - desc3 = getdescriptor(client, master_xpub=master_xpub, testnet=client.is_testnet, internal=internal, sh_wpkh=False, wpkh=True, account=account) - for desc in [desc1, desc2, desc3]: + for addr_type in list(AddressType): + try: + desc = getdescriptor(client, master_fpr=master_fpr, internal=internal, addr_type=addr_type, account=account) + except UnavailableActionError: + # Device does not support this address type or network. Skip. + continue if not isinstance(desc, Descriptor): return desc - descriptors.append(desc.serialize()) + descriptors.append(desc.to_string()) if internal: result["internal"] = descriptors else: @@ -197,50 +436,156 @@ def getdescriptors(client, account=0): return result -def displayaddress(client, path=None, desc=None, sh_wpkh=False, wpkh=False): +def displayaddress( + client: HardwareWalletClient, + path: Optional[str] = None, + desc: Optional[str] = None, + addr_type: AddressType = AddressType.WIT +) -> Dict[str, str]: + """ + Display an address on the device for client. + The address can be specified by the path with additional parameters, or by a descriptor. + + :param client: The client to interact with + :param path: The path of the address to display. Mutually exclusive with ``desc`` + :param desc: The descriptor to display the address for. Mutually exclusive with ``path`` + :param addr_type: The address type to return. Only works with ``path`` + :return: A dictionary containing the address displayed. + Returned as ``{"address": }``. + :raises: BadArgumentError: if an argument is malformed, missing, or conflicts. + """ if path is not None: - if sh_wpkh and wpkh: - return {'error': 'Both `--wpkh` and `--sh_wpkh` can not be selected at the same time.', 'code': BAD_ARGUMENT} - return client.display_address(path, sh_wpkh, wpkh) + return {"address": client.display_singlesig_address(path, addr_type)} elif desc is not None: - if client.fingerprint is None: - master_xpub = client.get_pubkey_at_path('m/0h')['xpub'] - client.fingerprint = get_xpub_fingerprint_hex(master_xpub) - - if sh_wpkh or wpkh: - return {'error': ' `--wpkh` and `--sh_wpkh` can not be combined with --desc', 'code': BAD_ARGUMENT} - descriptor = Descriptor.parse(desc, client.is_testnet) - if descriptor is None: - return {'error': 'Unable to parse descriptor: ' + desc, 'code': BAD_ARGUMENT} - if descriptor.m_path is None: - return {'error': 'Descriptor missing origin info: ' + desc, 'code': BAD_ARGUMENT} - if descriptor.origin_fingerprint != client.fingerprint: - return {'error': 'Descriptor fingerprint does not match device: ' + desc, 'code': BAD_ARGUMENT} - xpub = client.get_pubkey_at_path(descriptor.m_path_base)['xpub'] - if descriptor.base_key != xpub and descriptor.base_key != xpub_to_pub_hex(xpub): - return {'error': 'Key in descriptor does not match device: ' + desc, 'code': BAD_ARGUMENT} - return client.display_address(descriptor.m_path, descriptor.sh_wpkh, descriptor.wpkh) - -def setup_device(client, label='', backup_passphrase=''): - return client.setup_device(label, backup_passphrase) - -def wipe_device(client): - return client.wipe_device() - -def restore_device(client, label): - return client.restore_device(label) - -def backup_device(client, label='', backup_passphrase=''): - return client.backup_device(label, backup_passphrase) - -def prompt_pin(client): - return client.prompt_pin() - -def send_pin(client, pin): - return client.send_pin(pin) - -def install_udev_rules(source, location): + descriptor = parse_descriptor(desc) + addr_type = AddressType.LEGACY + is_sh = isinstance(descriptor, SHDescriptor) + is_wsh = isinstance(descriptor, WSHDescriptor) + if is_sh or is_wsh: + assert len(descriptor.subdescriptors) == 1 + descriptor = descriptor.subdescriptors[0] + if isinstance(descriptor, WSHDescriptor): + is_wsh = True + assert len(descriptor.subdescriptors) == 1 + descriptor = descriptor.subdescriptors[0] + if isinstance(descriptor, MultisigDescriptor): + if is_sh and is_wsh: + addr_type = AddressType.SH_WIT + elif not is_sh and is_wsh: + addr_type = AddressType.WIT + return {"address": client.display_multisig_address(addr_type, descriptor)} + is_wpkh = isinstance(descriptor, WPKHDescriptor) + if isinstance(descriptor, PKHDescriptor) or is_wpkh or isinstance(descriptor, TRDescriptor): + pubkey = descriptor.pubkeys[0] + if pubkey.origin is None: + raise BadArgumentError(f"Descriptor missing origin info: {desc}") + if pubkey.origin.fingerprint != client.get_master_fingerprint(): + raise BadArgumentError(f"Descriptor fingerprint does not match device: {desc}") + xpub = client.get_pubkey_at_path(pubkey.origin.get_derivation_path()).to_string() + if pubkey.pubkey != xpub and pubkey.pubkey != xpub_to_pub_hex(xpub) and pubkey.pubkey != xpub_to_xonly_pub_hex(xpub): + raise BadArgumentError(f"Key in descriptor does not match device: {desc}") + if is_sh and is_wpkh: + addr_type = AddressType.SH_WIT + elif not is_sh and is_wpkh: + addr_type = AddressType.WIT + elif isinstance(descriptor, TRDescriptor): + addr_type = AddressType.TAP + return {"address": client.display_singlesig_address(pubkey.get_full_derivation_path(0), addr_type)} + raise BadArgumentError("Missing both path and descriptor") + +def setup_device(client: HardwareWalletClient, label: str = "", backup_passphrase: str = "") -> Dict[str, bool]: + """ + Setup a device that has not yet been initialized. + + :param client: The client to interact with + :param label: The label to apply to the newly setup device + :param backup_passphrase: The passphrase to use for the backup, if backups are encrypted for that device + :return: A dictionary with the ``success`` key. + """ + return {"success": client.setup_device(label, backup_passphrase)} + +def wipe_device(client: HardwareWalletClient) -> Dict[str, bool]: + """ + Wipe a device + + :param client: The client to interact with + :return: A dictionary with the ``success`` key. + """ + return {"success": client.wipe_device()} + +def restore_device(client: HardwareWalletClient, label: str = "", word_count: int = 24) -> Dict[str, bool]: + """ + Restore a backup to a device that has not yet been initialized. + + :param client: The client to interact with + :param label: The label to apply to the newly setup device + :param word_count: The number of words in the recovery phrase + :return: A dictionary with the ``success`` key. + """ + return {"success": client.restore_device(label, word_count)} + +def backup_device(client: HardwareWalletClient, label: str = "", backup_passphrase: str = "") -> Dict[str, bool]: + """ + Create a backup of the device + + :param client: The client to interact with + :param label: The label to apply to the newly setup device + :param backup_passphrase: The passphrase to use for the backup, if backups are encrypted for that device + :return: A dictionary with the ``success`` key. + """ + return {"success": client.backup_device(label, backup_passphrase)} + +def prompt_pin(client: HardwareWalletClient) -> Dict[str, bool]: + """ + Trigger the device to show the setup for PIN entry. + + :param client: The client to interact with + :return: A dictionary with the ``success`` key. + """ + return {"success": client.prompt_pin()} + +def send_pin(client: HardwareWalletClient, pin: str) -> Dict[str, bool]: + """ + Send a PIN to the device after :func:`prompt_pin` has been called. + + :param client: The client to interact with + :param pin: The PIN to send + :return: A dictionary with the ``success`` key. + """ + return {"success": client.send_pin(pin)} + +def toggle_passphrase(client: HardwareWalletClient) -> Dict[str, bool]: + """ + Toggle whether the device is using a BIP 39 passphrase. + + :param client: The client to interact with + :return: A dictionary with the ``success`` key. + """ + return {"success": client.toggle_passphrase()} + +def install_udev_rules(source: str, location: str) -> Dict[str, bool]: + """ + Install the udev rules to the local machine. + The rules will be copied from the source to the location. + ``udevadm`` will also be triggered and the rules reloaded so that the devices can be plugged in and used immediately. + A ``plugdev`` group will also be created if it does not exist and the user will be added to it. + + The recommended source location is ``hwilib/udev``. The recommended destination location is ``/etc/udev/rules.d`` + + This function is equivalent to:: + + sudo cp hwilib/udev/*rules /etc/udev/rules.d/ + sudo udevadm trigger + sudo udevadm control --reload-rules + sudo groupadd plugdev + sudo usermod -aG plugdev `whoami` + + :param source: The directory containing the udev rules to install + :param location: The directory to install the udev rules to + :return: A dictionary with the ``success`` key. + :raises: NotImplementedError: if udev rules cannot be installed on this system, i.e. it is not linux. + """ if platform.system() == "Linux": from .udevinstaller import UDevInstaller - return UDevInstaller.install(source, location) - return {'error': 'udev rules are not needed on your platform', 'code': NOT_IMPLEMENTED} + return {"success": UDevInstaller.install(source, location)} + raise NotImplementedError("udev rules are not needed on your platform") diff --git a/hwilib/common.py b/hwilib/common.py new file mode 100644 index 000000000..6e4d5b47f --- /dev/null +++ b/hwilib/common.py @@ -0,0 +1,98 @@ +""" +Common Classes and Utilities +**************************** +""" + +import hashlib + +from enum import Enum + +from typing import Union + + +class Chain(Enum): + """ + The blockchain network to use + """ + MAIN = 0 #: Bitcoin Main network + TEST = 1 #: Bitcoin Test network + REGTEST = 2 #: Bitcoin Core Regression Test network + SIGNET = 3 #: Bitcoin Signet + + def __str__(self) -> str: + return self.name.lower() + + def __repr__(self) -> str: + return str(self) + + @staticmethod + def argparse(s: str) -> Union['Chain', str]: + try: + return Chain[s.upper()] + except KeyError: + return s + + +class AddressType(Enum): + """ + The type of address to use + """ + LEGACY = 1 #: Legacy address type. P2PKH for single sig, P2SH for scripts. + WIT = 2 #: Native segwit v0 address type. P2WPKH for single sig, P2WPSH for scripts. + SH_WIT = 3 #: Nested segwit v0 address type. P2SH-P2WPKH for single sig, P2SH-P2WPSH for scripts. + TAP = 4 #: Segwit v1 Taproot address type. P2TR always. + + def __str__(self) -> str: + return self.name.lower() + + def __repr__(self) -> str: + return str(self) + + @staticmethod + def argparse(s: str) -> Union['AddressType', str]: + try: + return AddressType[s.upper()] + except KeyError: + return s + + +def sha256(s: bytes) -> bytes: + """ + Perform a single SHA256 hash. + + :param s: Bytes to hash + :return: The hash + """ + return hashlib.new('sha256', s).digest() + + +def ripemd160(s: bytes) -> bytes: + """ + Perform a single RIPEMD160 hash. + + :param s: Bytes to hash + :return: The hash + """ + return hashlib.new('ripemd160', s).digest() + + +def hash256(s: bytes) -> bytes: + """ + Perform a double SHA256 hash. + A SHA256 is performed on the input, and then a second + SHA256 is performed on the result of the first SHA256 + + :param s: Bytes to hash + :return: The hash + """ + return sha256(sha256(s)) + + +def hash160(s: bytes) -> bytes: + """ + perform a single SHA256 hash followed by a single RIPEMD160 hash on the result of the SHA256 hash. + + :param s: Bytes to hash + :return: The hash + """ + return ripemd160(sha256(s)) diff --git a/hwilib/descriptor.py b/hwilib/descriptor.py index 5c6eb5bd9..31b1480a5 100644 --- a/hwilib/descriptor.py +++ b/hwilib/descriptor.py @@ -1,8 +1,39 @@ -import re +""" +Output Script Descriptors +************************* -# From: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp +HWI has a more limited implementation of descriptors. +See `Bitcoin Core's documentation `_ for more details on descriptors. -def PolyMod(c, val): +This implementation only supports ``sh()``, ``wsh()``, ``pkh()``, ``wpkh()``, ``multi()``, and ``sortedmulti()`` descriptors. +Descriptors can be parsed, however the actual scripts are not generated. +""" + + +from .key import ExtendedKey, KeyOriginInfo, parse_path +from .common import hash160, sha256 + +from binascii import unhexlify +from collections import namedtuple +from enum import Enum +from typing import ( + List, + Optional, + Tuple, +) + + +MAX_TAPROOT_NODES = 128 + + +ExpandedScripts = namedtuple("ExpandedScripts", ["output_script", "redeem_script", "witness_script"]) + +def PolyMod(c: int, val: int) -> int: + """ + :meta private: + Function to compute modulo over the polynomial used for descriptor checksums + From: https://github.com/bitcoin/bitcoin/blob/master/src/script/descriptor.cpp + """ c0 = c >> 35 c = ((c & 0x7ffffffff) << 5) ^ val if (c0 & 1): @@ -17,7 +48,13 @@ def PolyMod(c, val): c ^= 0x644d626ffd return c -def DescriptorChecksum(desc): +def DescriptorChecksum(desc: str) -> str: + """ + Compute the checksum for a descriptor + + :param desc: The descriptor string to compute a checksum for + :return: A checksum + """ INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" @@ -41,99 +78,557 @@ def DescriptorChecksum(desc): c = PolyMod(c, 0) c ^= 1 - ret = [None] * 8 + ret = [''] * 8 for j in range(0, 8): ret[j] = CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31] return ''.join(ret) -def AddChecksum(desc): +def AddChecksum(desc: str) -> str: + """ + Compute and attach the checksum for a descriptor + + :param desc: The descriptor string to add a checksum to + :return: Descriptor with checksum + """ return desc + "#" + DescriptorChecksum(desc) -class Descriptor: - def __init__(self, origin_fingerprint, origin_path, base_key, path_suffix, testnet, sh_wpkh, wpkh): - self.origin_fingerprint = origin_fingerprint - self.origin_path = origin_path - self.path_suffix = path_suffix - self.base_key = base_key - self.testnet = testnet - self.sh_wpkh = sh_wpkh - self.wpkh = wpkh - self.m_path = None - - if origin_path: - self.m_path_base = "m" + origin_path - self.m_path = "m" + origin_path + (path_suffix or "") + +class PubkeyProvider(object): + """ + A public key expression in a descriptor. + Can contain the key origin info, the pubkey itself, and subsequent derivation paths for derivation from the pubkey + The pubkey can be a typical pubkey or an extended pubkey. + """ + def __init__( + self, + origin: Optional['KeyOriginInfo'], + pubkey: str, + deriv_path: Optional[str] + ) -> None: + """ + :param origin: The key origin if one is available + :param pubkey: The public key. Either a hex string or a serialized extended pubkey + :param deriv_path: Additional derivation path if the pubkey is an extended pubkey + """ + self.origin = origin + self.pubkey = pubkey + self.deriv_path = deriv_path + + # Make ExtendedKey from pubkey if it isn't hex + self.extkey = None + try: + unhexlify(self.pubkey) + # Is hex, normal pubkey + except Exception: + # Not hex, maybe xpub + self.extkey = ExtendedKey.deserialize(self.pubkey) @classmethod - def parse(cls, desc, testnet=False): - sh_wpkh = None - wpkh = None - origin_fingerprint = None - origin_path = None - base_key_and_path_match = None - base_key = None - path_suffix = None - - # Check the checksum - check_split = desc.split('#') - if len(check_split) > 2: - return None - if len(check_split) == 2: - if len(check_split[1]) != 8: - return None - checksum = DescriptorChecksum(check_split[0]) - if not checksum.strip(): - return None - if checksum != check_split[1]: - return None - desc = check_split[0] - - if desc.startswith("sh(wpkh("): - sh_wpkh = True - elif desc.startswith("wpkh("): - wpkh = True - - origin_match = re.search(r"\[(.*)\]", desc) - if origin_match: - origin = origin_match.group(1) - match = re.search(r"^([0-9a-fA-F]{8})(\/.*)", origin) - if match: - origin_fingerprint = match.group(1) - origin_path = match.group(2) - # Replace h with ' - origin_path = origin_path.replace('h', '\'') - - base_key_and_path_match = re.search(r"\[.*\](\w+)([\/\)][\d'\/\*]*)", desc) - else: - base_key_and_path_match = re.search(r"\((\w+)([\/\)][\d'\/\*]*)", desc) + def parse(cls, s: str) -> 'PubkeyProvider': + """ + Deserialize a key expression from the string into a ``PubkeyProvider``. + + :param s: String containing the key expression + :return: A new ``PubkeyProvider`` containing the details given by ``s`` + """ + origin = None + deriv_path = None + + if s[0] == "[": + end = s.index("]") + origin = KeyOriginInfo.from_string(s[1:end]) + s = s[end + 1:] + + pubkey = s + slash_idx = s.find("/") + if slash_idx != -1: + pubkey = s[:slash_idx] + deriv_path = s[slash_idx:] + + return cls(origin, pubkey, deriv_path) + + def to_string(self, hardened_char: str = "h") -> str: + """ + Serialize the pubkey expression to a string to be used in a descriptor + + :return: The pubkey expression as a string + """ + s = "" + if self.origin: + s += "[{}]".format(self.origin.to_string(hardened_char)) + s += self.pubkey + if self.deriv_path: + s += self.deriv_path + return s + + def get_pubkey_bytes(self, pos: int) -> bytes: + if self.extkey is not None: + if self.deriv_path is not None: + path_str = self.deriv_path[1:] + if path_str[-1] == "*": + path_str = path_str[:-1] + str(pos) + path = parse_path(path_str) + child_key = self.extkey.derive_pub_path(path) + return child_key.pubkey + else: + return self.extkey.pubkey + return unhexlify(self.pubkey) + + def get_full_derivation_path(self, pos: int) -> str: + """ + Returns the full derivation path at the given position, including the origin + """ + path = self.origin.get_derivation_path() if self.origin is not None else "m/" + path += self.deriv_path if self.deriv_path is not None else "" + if path[-1] == "*": + path = path[:-1] + str(pos) + return path + + def get_full_derivation_int_list(self, pos: int) -> List[int]: + """ + Returns the full derivation path as an integer list at the given position. + Includes the origin and master key fingerprint as an int + """ + path: List[int] = self.origin.get_full_int_list() if self.origin is not None else [] + if self.deriv_path is not None: + der_split = self.deriv_path.split("/") + for p in der_split: + if not p: + continue + if p == "*": + i = pos + elif p[-1] in "'phHP": + assert len(p) >= 2 + i = int(p[:-1]) | 0x80000000 + else: + i = int(p) + path.append(i) + return path + + def __lt__(self, other: 'PubkeyProvider') -> bool: + return self.pubkey < other.pubkey + + +class Descriptor(object): + r""" + An abstract class for Descriptors themselves. + Descriptors can contain multiple :class:`PubkeyProvider`\ s and multiple ``Descriptor`` as subdescriptors. + """ + def __init__( + self, + pubkeys: List['PubkeyProvider'], + subdescriptors: List['Descriptor'], + name: str + ) -> None: + r""" + :param pubkeys: The :class:`PubkeyProvider`\ s that are part of this descriptor + :param subdescriptor: The ``Descriptor``\ s that are part of this descriptor + :param name: The name of the function for this descriptor + """ + self.pubkeys = pubkeys + self.subdescriptors = subdescriptors + self.name = name + + def to_string_no_checksum(self, hardened_char: str = "h") -> str: + """ + Serializes the descriptor as a string without the descriptor checksum + + :return: The descriptor string + """ + return "{}({}{})".format( + self.name, + ",".join([p.to_string(hardened_char) for p in self.pubkeys]), + self.subdescriptors[0].to_string_no_checksum(hardened_char) if len(self.subdescriptors) > 0 else "" + ) + + def to_string(self, hardened_char: str = "h") -> str: + """ + Serializes the descriptor as a string with the checksum + + :return: The descriptor with a checksum + """ + return AddChecksum(self.to_string_no_checksum(hardened_char)) + + def expand(self, pos: int) -> "ExpandedScripts": + """ + Returns the scripts for a descriptor at the given `pos` for ranged descriptors. + """ + raise NotImplementedError("The Descriptor base class does not implement this method") + + +class PKDescriptor(Descriptor): + """ + A descriptor for ``pk()`` descriptors + """ + def __init__( + self, + pubkey: 'PubkeyProvider' + ) -> None: + """ + :param pubkey: The :class:`PubkeyProvider` for this descriptor + """ + super().__init__([pubkey], [], "pk") + + +class PKHDescriptor(Descriptor): + """ + A descriptor for ``pkh()`` descriptors + """ + def __init__( + self, + pubkey: 'PubkeyProvider' + ) -> None: + """ + :param pubkey: The :class:`PubkeyProvider` for this descriptor + """ + super().__init__([pubkey], [], "pkh") + + def expand(self, pos: int) -> "ExpandedScripts": + script = b"\x76\xa9\x14" + hash160(self.pubkeys[0].get_pubkey_bytes(pos)) + b"\x88\xac" + return ExpandedScripts(script, None, None) + - if base_key_and_path_match: - base_key = base_key_and_path_match.group(1) - path_suffix = base_key_and_path_match.group(2) - if path_suffix == ")": - path_suffix = None +class WPKHDescriptor(Descriptor): + """ + A descriptor for ``wpkh()`` descriptors + """ + def __init__( + self, + pubkey: 'PubkeyProvider' + ) -> None: + """ + :param pubkey: The :class:`PubkeyProvider` for this descriptor + """ + super().__init__([pubkey], [], "wpkh") + + def expand(self, pos: int) -> "ExpandedScripts": + script = b"\x00\x14" + hash160(self.pubkeys[0].get_pubkey_bytes(pos)) + return ExpandedScripts(script, None, None) + + +class MultisigDescriptor(Descriptor): + """ + A descriptor for ``multi()`` and ``sortedmulti()`` descriptors + """ + def __init__( + self, + pubkeys: List['PubkeyProvider'], + thresh: int, + is_sorted: bool + ) -> None: + r""" + :param pubkeys: The :class:`PubkeyProvider`\ s for this descriptor + :param thresh: The number of keys required to sign this multisig + :param is_sorted: Whether this is a ``sortedmulti()`` descriptor + """ + super().__init__(pubkeys, [], "sortedmulti" if is_sorted else "multi") + self.thresh = thresh + self.is_sorted = is_sorted + if self.is_sorted: + self.pubkeys.sort() + + def to_string_no_checksum(self, hardened_char: str = "h") -> str: + return "{}({},{})".format(self.name, self.thresh, ",".join([p.to_string(hardened_char) for p in self.pubkeys])) + + def expand(self, pos: int) -> "ExpandedScripts": + if self.thresh > 16: + m = b"\x01" + self.thresh.to_bytes(1, "big") else: - if origin_match is None: - return None + m = (self.thresh + 0x50).to_bytes(1, "big") if self.thresh > 0 else b"\x00" + n = (len(self.pubkeys) + 0x50).to_bytes(1, "big") if len(self.pubkeys) > 0 else b"\x00" + script: bytes = m + der_pks = [p.get_pubkey_bytes(pos) for p in self.pubkeys] + if self.is_sorted: + der_pks.sort() + for pk in der_pks: + script += len(pk).to_bytes(1, "big") + pk + script += n + b"\xae" + + return ExpandedScripts(script, None, None) + + +class SHDescriptor(Descriptor): + """ + A descriptor for ``sh()`` descriptors + """ + def __init__( + self, + subdescriptor: 'Descriptor' + ) -> None: + """ + :param subdescriptor: The :class:`Descriptor` that is a sub-descriptor for this descriptor + """ + super().__init__([], [subdescriptor], "sh") + + def expand(self, pos: int) -> "ExpandedScripts": + assert len(self.subdescriptors) == 1 + redeem_script, _, witness_script = self.subdescriptors[0].expand(pos) + script = b"\xa9\x14" + hash160(redeem_script) + b"\x87" + return ExpandedScripts(script, redeem_script, witness_script) + + +class WSHDescriptor(Descriptor): + """ + A descriptor for ``wsh()`` descriptors + """ + def __init__( + self, + subdescriptor: 'Descriptor' + ) -> None: + """ + :param subdescriptor: The :class:`Descriptor` that is a sub-descriptor for this descriptor + """ + super().__init__([], [subdescriptor], "wsh") + + def expand(self, pos: int) -> "ExpandedScripts": + assert len(self.subdescriptors) == 1 + witness_script, _, _ = self.subdescriptors[0].expand(pos) + script = b"\x00\x20" + sha256(witness_script) + return ExpandedScripts(script, None, witness_script) + + +class TRDescriptor(Descriptor): + """ + A descriptor for ``tr()`` descriptors + """ + def __init__( + self, + internal_key: 'PubkeyProvider', + subdescriptors: List['Descriptor'] = [], + depths: List[int] = [] + ) -> None: + r""" + :param internal_key: The :class:`PubkeyProvider` that is the internal key for this descriptor + :param subdescriptors: The :class:`Descriptor`\ s that are the leaf scripts for this descriptor + :param depths: The depths of the leaf scripts in the same order as `subdescriptors` + """ + super().__init__([internal_key], subdescriptors, "tr") + self.depths = depths + + def to_string_no_checksum(self, hardened_char: str = "h") -> str: + r = f"{self.name}({self.pubkeys[0].to_string(hardened_char)}" + path: List[bool] = [] # Track left or right for each depth + for p, depth in enumerate(self.depths): + r += "," + while len(path) <= depth: + if len(path) > 0: + r += "{" + path.append(False) + r += self.subdescriptors[p].to_string_no_checksum(hardened_char) + while len(path) > 0 and path[-1]: + if len(path) > 0: + r += "}" + path.pop() + if len(path) > 0: + path[-1] = True + r += ")" + return r + +def _get_func_expr(s: str) -> Tuple[str, str]: + """ + Get the function name and then the expression inside + + :param s: The string that begins with a function name + :return: The function name as the first element of the tuple, and the expression contained within the function as the second element + :raises: ValueError: if a matching pair of parentheses cannot be found + """ + start = s.index("(") + end = s.rindex(")") + return s[0:start], s[start + 1:end] + + +def _get_const(s: str, const: str) -> str: + """ + Get the first character of the string, make sure it is the expected character, + and return the rest of the string + + :param s: The string that begins with a constant character + :param const: The constant character + :return: The remainder of the string without the constant character + :raises: ValueError: if the first character is not the constant character + """ + if s[0] != const: + raise ValueError(f"Expected '{const}' but got '{s[0]}'") + return s[1:] + + +def _get_expr(s: str) -> Tuple[str, str]: + """ + Extract the expression that ``s`` begins with. + + This will return the initial part of ``s``, up to the first comma or closing brace, + skipping ones that are surrounded by braces. + + :param s: The string to extract the expression from + :return: A pair with the first item being the extracted expression and the second the rest of the string + """ + level: int = 0 + for i, c in enumerate(s): + if c in ["(", "{"]: + level += 1 + elif level > 0 and c in [")", "}"]: + level -= 1 + elif level == 0 and c in [")", "}", ","]: + break + return s[0:i], s[i:] + +def parse_pubkey(expr: str) -> Tuple['PubkeyProvider', str]: + """ + Parses an individual pubkey expression from a string that may contain more than one pubkey expression. + + :param expr: The expression to parse a pubkey expression from + :return: The :class:`PubkeyProvider` that is parsed as the first item of a tuple, and the remainder of the expression as the second item. + """ + end = len(expr) + comma_idx = expr.find(",") + next_expr = "" + if comma_idx != -1: + end = comma_idx + next_expr = expr[end + 1:] + return PubkeyProvider.parse(expr[:end]), next_expr + + +class _ParseDescriptorContext(Enum): + """ + :meta private: + + Enum representing the level that we are in when parsing a descriptor. + Some expressions aren't allowed at certain levels, this helps us track those. + """ + + TOP = 1 + """The top level, not within any descriptor""" + + P2SH = 2 + """Within a ``sh()`` descriptor""" + + P2WSH = 3 + """Within a ``wsh()`` descriptor""" + + P2TR = 4 + """Within a ``tr()`` descriptor""" + + +def _parse_descriptor(desc: str, ctx: '_ParseDescriptorContext') -> 'Descriptor': + """ + :meta private: - return cls(origin_fingerprint, origin_path, base_key, path_suffix, testnet, sh_wpkh, wpkh) + Parse a descriptor given the context level we are in. + Used recursively to parse subdescriptors - def serialize(self): - descriptor_open = 'pkh(' - descriptor_close = ')' - origin = '' - path_suffix = '' + :param desc: The descriptor string to parse + :param ctx: The :class:`_ParseDescriptorContext` indicating the level we are in + :return: The parsed descriptor + :raises: ValueError: if the descriptor is malformed + """ + func, expr = _get_func_expr(desc) + if func == "pk": + pubkey, expr = parse_pubkey(expr) + if expr: + raise ValueError("more than one pubkey in pk descriptor") + return PKDescriptor(pubkey) + if func == "pkh": + if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH): + raise ValueError("Can only have pkh at top level, in sh(), or in wsh()") + pubkey, expr = parse_pubkey(expr) + if expr: + raise ValueError("More than one pubkey in pkh descriptor") + return PKHDescriptor(pubkey) + if func == "sortedmulti" or func == "multi": + if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH or ctx == _ParseDescriptorContext.P2WSH): + raise ValueError("Can only have multi/sortedmulti at top level, in sh(), or in wsh()") + is_sorted = func == "sortedmulti" + comma_idx = expr.index(",") + thresh = int(expr[:comma_idx]) + expr = expr[comma_idx + 1:] + pubkeys = [] + while expr: + pubkey, expr = parse_pubkey(expr) + pubkeys.append(pubkey) + if len(pubkeys) == 0 or len(pubkeys) > 16: + raise ValueError("Cannot have {} keys in a multisig; must have between 1 and 16 keys, inclusive".format(len(pubkeys))) + elif thresh < 1: + raise ValueError("Multisig threshold cannot be {}, must be at least 1".format(thresh)) + elif thresh > len(pubkeys): + raise ValueError("Multisig threshold cannot be larger than the number of keys; threshold is {} but only {} keys specified".format(thresh, len(pubkeys))) + if ctx == _ParseDescriptorContext.TOP and len(pubkeys) > 3: + raise ValueError("Cannot have {} pubkeys in bare multisig: only at most 3 pubkeys") + return MultisigDescriptor(pubkeys, thresh, is_sorted) + if func == "wpkh": + if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH): + raise ValueError("Can only have wpkh() at top level or inside sh()") + pubkey, expr = parse_pubkey(expr) + if expr: + raise ValueError("More than one pubkey in pkh descriptor") + return WPKHDescriptor(pubkey) + if func == "sh": + if ctx != _ParseDescriptorContext.TOP: + raise ValueError("Can only have sh() at top level") + subdesc = _parse_descriptor(expr, _ParseDescriptorContext.P2SH) + return SHDescriptor(subdesc) + if func == "wsh": + if not (ctx == _ParseDescriptorContext.TOP or ctx == _ParseDescriptorContext.P2SH): + raise ValueError("Can only have wsh() at top level or inside sh()") + subdesc = _parse_descriptor(expr, _ParseDescriptorContext.P2WSH) + return WSHDescriptor(subdesc) + if func == "tr": + if ctx != _ParseDescriptorContext.TOP: + raise ValueError("Can only have tr at top level") + internal_key, expr = parse_pubkey(expr) + subscripts = [] + depths = [] + if expr: + # Path from top of the tree to what we're currently processing. + # branches[i] == False: left branch in the i'th step from the top + # branches[i] == true: right branch + branches = [] + while True: + # Process open braces + while True: + try: + expr = _get_const(expr, "{") + branches.append(False) + except ValueError: + break + if len(branches) > MAX_TAPROOT_NODES: + raise ValueError("tr() supports at most {MAX_TAPROOT_NODES} nesting levels") + # Process script expression + sarg, expr = _get_expr(expr) + subscripts.append(_parse_descriptor(sarg, _ParseDescriptorContext.P2TR)) + depths.append(len(branches)) + # Process closing braces + while len(branches) > 0 and branches[-1]: + expr = _get_const(expr, "}") + branches.pop() + # If we're at the end of a left branch, expect a comma + if len(branches) > 0 and not branches[-1]: + expr = _get_const(expr, ",") + branches[-1] = True - if self.wpkh: - descriptor_open = 'wpkh(' - elif self.sh_wpkh: - descriptor_open = 'sh(wpkh(' - descriptor_close = '))' + if len(branches) == 0: + break + return TRDescriptor(internal_key, subscripts, depths) + if ctx == _ParseDescriptorContext.P2SH: + raise ValueError("A function is needed within P2SH") + elif ctx == _ParseDescriptorContext.P2WSH: + raise ValueError("A function is needed within P2WSH") + raise ValueError("{} is not a valid descriptor function".format(func)) - if self.origin_fingerprint and self.origin_path: - origin = '[' + self.origin_fingerprint + self.origin_path + ']' - if self.path_suffix: - path_suffix = self.path_suffix +def parse_descriptor(desc: str) -> 'Descriptor': + """ + Parse a descriptor string into a :class:`Descriptor`. + Validates the checksum if one is provided in the string - return AddChecksum(descriptor_open + origin + self.base_key + path_suffix + descriptor_close) + :param desc: The descriptor string + :return: The parsed :class:`Descriptor` + :raises: ValueError: if the descriptor string is malformed + """ + i = desc.find("#") + if i != -1: + checksum = desc[i + 1:] + desc = desc[:i] + computed = DescriptorChecksum(desc) + if computed != checksum: + raise ValueError("The checksum does not match; Got {}, expected {}".format(checksum, computed)) + return _parse_descriptor(desc, _ParseDescriptorContext.TOP) diff --git a/hwilib/devices/__init__.py b/hwilib/devices/__init__.py index a4f93f114..77fa0ffba 100644 --- a/hwilib/devices/__init__.py +++ b/hwilib/devices/__init__.py @@ -3,5 +3,7 @@ 'ledger', 'keepkey', 'digitalbitbox', - 'coldcard' + 'coldcard', + 'bitbox02', + 'jade' ] diff --git a/hwilib/devices/bitbox02.py b/hwilib/devices/bitbox02.py new file mode 100644 index 000000000..c56b1fae6 --- /dev/null +++ b/hwilib/devices/bitbox02.py @@ -0,0 +1,925 @@ +""" +BitBox02 +******** +""" + +from typing import ( + cast, + Any, + Callable, + Dict, + Optional, + Mapping, + Union, + Tuple, + List, + Sequence, + TypeVar, +) +import base64 +import builtins +import sys +from functools import wraps + +import base58 + +from ..descriptor import MultisigDescriptor +from ..hwwclient import HardwareWalletClient +from ..key import ExtendedKey +from .._script import ( + is_p2pkh, + is_p2wpkh, + is_p2wsh, + is_p2tr, + parse_multisig, +) +from ..psbt import PSBT +from ..tx import ( + CTxOut, +) +from .._serialize import ( + ser_uint256, + ser_sig_der, +) +from ..errors import ( + HWWError, + ActionCanceledError, + BadArgumentError, + DeviceNotReadyError, + UnavailableActionError, + DEVICE_NOT_INITIALIZED, + handle_errors, + common_err_msgs, +) +from ..key import ( + KeyOriginInfo, + parse_path, +) +from ..common import ( + AddressType, + Chain, +) + +import hid + +from bitbox02 import util +from bitbox02 import bitbox02 +from bitbox02.communication import ( + devices, + u2fhid, + FirmwareVersionOutdatedException, + Bitbox02Exception, + UserAbortException, + HARDENED, + ERR_GENERIC, +) + +from bitbox02.communication.bitbox_api_protocol import ( + Platform, + BitBox02Edition, + BitBoxNoiseConfig, +) + +class BitBox02Error(UnavailableActionError): + def __init__(self, msg: str): + """ + BitBox02 unexpected error. The BitBox02 does not return give granular error messages, + so we give hints to as what could be wrong. + """ + msg = "Input error: {}. A keypath might be invalid. Supported keypaths are: ".format( + msg + ) + msg += "m/49'/0'/ for p2wpkh-p2sh; " + msg += "m/84'/0'/ for p2wpkh; " + msg += "m/86'/0'/ for p2tr; " + msg += "m/48'/0'//2' for p2wsh multisig; " + msg += "m/48'/0'//1' for p2wsh-p2sh multisig; " + msg += "m/48'/0'/' for any supported multisig; " + msg += "account can be between 0' and 99'; " + msg += "For address keypaths, append /0/
for a receive and /1/ for a change address." + super().__init__(msg) + + +ERR_INVALID_INPUT = 101 + +PURPOSE_P2WPKH_P2SH = 49 + HARDENED +PURPOSE_P2WPKH = 84 + HARDENED +PURPOSE_MULTISIG_P2WSH = 48 + HARDENED + +# External GUI tools using hwi.py as a command line tool to integrate hardware wallets usually do +# not have an actual terminal for IO. +_using_external_gui = not sys.stdout.isatty() +if _using_external_gui: + _unpaired_errmsg = "Device not paired yet. Please pair using the BitBoxApp, then close the BitBoxApp and try again." +else: + _unpaired_errmsg = "Device not paired yet. Please use any subcommand to pair" + + +class SilentNoiseConfig(util.BitBoxAppNoiseConfig): + """ + Used during `enumerate()`. Raises an exception if the device is unpaired. + Attestation check is silent. + + Rationale: enumerate() should not show any dialogs. + """ + + def show_pairing(self, code: str, device_response: Callable[[], bool]) -> bool: + raise DeviceNotReadyError(_unpaired_errmsg) + + def attestation_check(self, result: bool) -> None: + pass + + +class CLINoiseConfig(util.BitBoxAppNoiseConfig): + """ Noise pairing and attestation check handling in the terminal (stdin/stdout) """ + + def show_pairing(self, code: str, device_response: Callable[[], bool]) -> bool: + if _using_external_gui: + # The user can't see the pairing in the terminal. The + # output format is also not appropriate for parsing by + # external tools doing inter process communication using + # stdin/stdout. For now, we direct the user to pair in the + # BitBoxApp instead. + raise DeviceNotReadyError(_unpaired_errmsg) + + print("Please compare and confirm the pairing code on your BitBox02:") + print(code) + if not device_response(): + return False + return input("Accept pairing? [y]/n: ").strip() != "n" + + def attestation_check(self, result: bool) -> None: + if result: + sys.stderr.write("BitBox02 attestation check PASSED\n") + else: + sys.stderr.write("BitBox02 attestation check FAILED\n") + sys.stderr.write( + "Your BitBox02 might not be genuine. Please contact support@shiftcrypto.ch if the problem persists.\n" + ) + + +def _keypath_hardened_prefix(keypath: Sequence[int]) -> Sequence[int]: + for i, e in builtins.enumerate(keypath): + if e & HARDENED == 0: + return keypath[:i] + return keypath + + +def _xpubs_equal_ignoring_version(xpub1: bytes, xpub2: bytes) -> bool: + """ + Xpubs: 78 bytes. Returns true if the xpubs are equal, ignoring the 4 byte version. + The version is not important and allows compatibility with Electrum, which exports PSBTs with + xpubs using Electrum-style xpub versions. + """ + return xpub1[4:] == xpub2[4:] + + +def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> List[Dict[str, Any]]: + """ + Enumerate all BitBox02 devices. Bootloaders excluded. + """ + result = [] + for device_info in devices.get_any_bitbox02s(): + path = device_info["path"].decode() + client = Bitbox02Client(path) + client.set_noise_config(SilentNoiseConfig()) + d_data: Dict[str, object] = {} + bb02 = None + with handle_errors(common_err_msgs["enumerate"], d_data): + bb02 = client.init(expect_initialized=None) + version, platform, edition, unlocked = bitbox02.BitBox02.get_info( + client.transport + ) + if platform != Platform.BITBOX02: + client.close() + continue + if edition not in (BitBox02Edition.MULTI, BitBox02Edition.BTCONLY): + client.close() + continue + + assert isinstance(edition, BitBox02Edition) + + d_data.update( + { + "type": "bitbox02", + "path": path, + "model": { + BitBox02Edition.MULTI: "bitbox02_multi", + BitBox02Edition.BTCONLY: "bitbox02_btconly", + }[edition], + "needs_pin_sent": False, + "needs_passphrase_sent": False, + } + ) + + if bb02 is not None: + with handle_errors(common_err_msgs["enumerate"], d_data): + if not bb02.device_info()["initialized"]: + raise DeviceNotReadyError( + "BitBox02 is not initialized. Please initialize it using the BitBoxApp." + ) + elif not unlocked: + raise DeviceNotReadyError( + "Please load wallet to unlock." + if _using_external_gui + else "Please use any subcommand to unlock" + ) + d_data["fingerprint"] = client.get_master_fingerprint().hex() + + result.append(d_data) + + client.close() + return result + + +T = TypeVar("T", bound=Callable[..., Any]) + + +def bitbox02_exception(f: T) -> T: + """ + Maps bitbox02 library exceptions into a HWI exceptions. + """ + + @wraps(f) + def func(*args, **kwargs): # type: ignore + """ Wraps f, mapping exceptions. """ + try: + return f(*args, **kwargs) + except UserAbortException: + raise ActionCanceledError("{} canceled".format(f.__name__)) + except Bitbox02Exception as exc: + if exc.code in (ERR_GENERIC, ERR_INVALID_INPUT): + raise BitBox02Error(str(exc)) + raise exc + except FirmwareVersionOutdatedException as exc: + raise DeviceNotReadyError(str(exc)) + + return cast(T, func) + + +# This class extends the HardwareWalletClient for BitBox02 specific things +class Bitbox02Client(HardwareWalletClient): + def __init__(self, path: str, password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> None: + """ + Initializes a new BitBox02 client instance. + """ + if password is not None: + raise BadArgumentError( + "The BitBox02 does not accept a passphrase from the host. Please enable the passphrase option and enter the passphrase on the device during unlock." + ) + super().__init__(path, password=password, expert=expert, chain=chain) + + hid_device = hid.device() + hid_device.open_path(path.encode()) + self.transport = u2fhid.U2FHid(hid_device) + self.device_path = path + + # use self.init() to access self.bb02. + self.bb02: Optional[bitbox02.BitBox02] = None + + self.noise_config: BitBoxNoiseConfig = CLINoiseConfig() + + def set_noise_config(self, noise_config: BitBoxNoiseConfig) -> None: + self.noise_config = noise_config + + def init(self, expect_initialized: Optional[bool] = True) -> bitbox02.BitBox02: + if self.bb02 is not None: + return self.bb02 + + for device_info in devices.get_any_bitbox02s(): + if device_info["path"].decode() != self.device_path: + continue + + bb02 = bitbox02.BitBox02( + transport=self.transport, + device_info=device_info, + noise_config=self.noise_config, + ) + try: + bb02.check_min_version() + except FirmwareVersionOutdatedException as exc: + sys.stderr.write("WARNING: {}\n".format(exc)) + raise + self.bb02 = bb02 + is_initialized = bb02.device_info()["initialized"] + if expect_initialized is not None: + if expect_initialized: + if not is_initialized: + raise HWWError( + "The BitBox02 must be initialized first.", + DEVICE_NOT_INITIALIZED, + ) + elif is_initialized: + raise UnavailableActionError( + "The BitBox02 must be wiped before setup." + ) + + return bb02 + raise Exception( + "Could not find the hid device info for path {}".format(self.device_path) + ) + + def close(self) -> None: + self.transport.close() + + def get_master_fingerprint(self) -> bytes: + """ + HWI by default retrieves the fingerprint at m/ by getting the xpub at m/0', which contains the parent fingerprint. + The BitBox02 does not support querying arbitrary keypaths, but has an api call return the fingerprint at m/. + """ + bb02 = self.init() + return bb02.root_fingerprint() + + def prompt_pin(self) -> bool: + raise UnavailableActionError( + "The BitBox02 does not need a PIN sent from the host" + ) + + def send_pin(self, pin: str) -> bool: + raise UnavailableActionError( + "The BitBox02 does not need a PIN sent from the host" + ) + + def _get_coin(self) -> "bitbox02.btc.BTCCoin.V": + if self.chain != Chain.MAIN: + return bitbox02.btc.TBTC + return bitbox02.btc.BTC + + def _get_xpub(self, keypath: Sequence[int]) -> str: + xpub_type = ( + bitbox02.btc.BTCPubRequest.TPUB + if self.chain != Chain.MAIN + else bitbox02.btc.BTCPubRequest.XPUB + ) + return self.init().btc_xpub( + keypath, coin=self._get_coin(), xpub_type=xpub_type, display=False + ) + + def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey: + """ + Fetch the public key at the derivation path. + + The BitBox02 has strict keypath validation. + + The only accepted keypaths for xpubs are (as of firmware v9.4.0): + + - `m/49'/0'/` for `p2wpkh-p2sh` (segwit wrapped in P2SH) + - `m/84'/0'/` for `p2wpkh` (native segwit v0) + - `m/86'/0'/` for `p2tr` (native segwit v1) + - `m/48'/0'//2'` for p2wsh multisig (native segwit v0 multisig). + - `m/48'/0'//1'` for p2wsh-p2sh multisig (p2sh-wrapped segwit v0 multisig). + - `m/48'/0'/` for p2wsh and p2wsh-p2sh multisig. + + `account'` can be between `0'` and `99'`. + + For address keypaths, append `/0/
` for a receive and `/1/` for a change + address. Up to `10000` addresses are supported. + + In testnet mode, the second element must be `1'` (e.g. `m/49'/1'/...`). + + Public keys for the Legacy address type (i.e. P2WPKH and P2SH multisig) derivation path is unsupported. + """ + path_uint32s = parse_path(bip32_path) + try: + xpub_str = self._get_xpub(path_uint32s) + except Bitbox02Exception as exc: + raise BitBox02Error(str(exc)) + xpub = ExtendedKey.deserialize(xpub_str) + return xpub + + def _maybe_register_script_config( + self, script_config: bitbox02.btc.BTCScriptConfig, keypath: Sequence[int] + ) -> None: + bb02 = self.init() + is_registered = bb02.btc_is_script_config_registered( + self._get_coin(), script_config, keypath + ) + if not is_registered: + bb02.btc_register_script_config( + coin=self._get_coin(), + script_config=script_config, + keypath=keypath, + name="", # enter name on the device + xpub_type=bitbox02.btc.BTCRegisterScriptConfigRequest.AUTO_XPUB_TPUB, + ) + + def _multisig_scriptconfig( + self, + threshold: int, + origin_infos: Mapping[bytes, KeyOriginInfo], + script_type: "bitbox02.btc.BTCScriptConfig.Multisig.ScriptType.V", + ) -> Tuple[bytes, bitbox02.btc.BTCScriptConfigWithKeypath]: + """ + From a threshold, {xpub: KeyOriginInfo} mapping and multisig script type, + return our xpub and the BitBox02 multisig script config. + """ + # Figure out which of the cosigners is us. + device_fingerprint = self.get_master_fingerprint() + our_xpub_index = None + our_account_keypath = None + + xpubs: List[bytes] = [] + for i, (xpub, keyinfo) in builtins.enumerate(origin_infos.items()): + xpubs.append(xpub) + if device_fingerprint == keyinfo.fingerprint and keyinfo.path: + if _xpubs_equal_ignoring_version( + base58.b58decode_check(self._get_xpub(keyinfo.path)), xpub + ): + our_xpub_index = i + our_account_keypath = keyinfo.path + + if our_xpub_index is None: + raise BadArgumentError("This BitBox02 is not one of the cosigners") + assert our_account_keypath + + if len(xpubs) != len(set(xpubs)): + raise BadArgumentError("Duplicate xpubs not supported") + + return ( + xpubs[our_xpub_index], + bitbox02.btc.BTCScriptConfigWithKeypath( + script_config=bitbox02.btc.BTCScriptConfig( + multisig=bitbox02.btc.BTCScriptConfig.Multisig( + threshold=threshold, + xpubs=[util.parse_xpub(base58.b58encode_check(xpub).decode()) for xpub in xpubs], + our_xpub_index=our_xpub_index, + script_type=script_type, + ) + ), + keypath=our_account_keypath, + ), + ) + + @bitbox02_exception + def display_singlesig_address( + self, + bip32_path: str, + addr_type: AddressType, + ) -> str: + if addr_type == AddressType.SH_WIT: + script_config = bitbox02.btc.BTCScriptConfig( + simple_type=bitbox02.btc.BTCScriptConfig.P2WPKH_P2SH + ) + elif addr_type == AddressType.WIT: + script_config = bitbox02.btc.BTCScriptConfig( + simple_type=bitbox02.btc.BTCScriptConfig.P2WPKH + ) + elif addr_type == AddressType.LEGACY: + raise UnavailableActionError( + "The BitBox02 does not support legacy p2pkh addresses" + ) + elif addr_type == AddressType.TAP: + script_config = bitbox02.btc.BTCScriptConfig( + simple_type=bitbox02.btc.BTCScriptConfig.P2TR + ) + else: + raise BadArgumentError("Unknown address type") + address = self.init().btc_address( + parse_path(bip32_path), + coin=self._get_coin(), + script_config=script_config, + display=True, + ) + return address + + @bitbox02_exception + def display_multisig_address( + self, + addr_type: AddressType, + multisig: MultisigDescriptor, + ) -> str: + if not multisig.is_sorted: + raise BadArgumentError("BitBox02 only supports sortedmulti descriptors") + + path_suffixes = set(p.deriv_path for p in multisig.pubkeys) + if len(path_suffixes) != 1: + # Path suffix refers to the path after the account-level xpub, usually //
. + # The BitBox02 currently enforces that all of them are the same. + raise BadArgumentError("All multisig path suffixes must be the same") + + # Figure out which of the cosigners is us. + key_origin_infos = {} + keypaths = {} + for pk in multisig.pubkeys: + assert pk.extkey and pk.origin + key_origin_infos[pk.extkey.serialize()] = pk.origin + keypaths[pk.extkey.serialize()] = pk.get_full_derivation_path(0) + + if addr_type == AddressType.SH_WIT: + script_type = bitbox02.btc.BTCScriptConfig.Multisig.P2WSH_P2SH + elif addr_type == AddressType.WIT: + script_type = bitbox02.btc.BTCScriptConfig.Multisig.P2WSH + else: + raise BadArgumentError( + "BitBox02 currently only supports the following multisig script types: P2WSH, P2WSH_P2SH" + ) + our_xpub, script_config_with_keypath = self._multisig_scriptconfig( + multisig.thresh, key_origin_infos, script_type + ) + script_config = script_config_with_keypath.script_config + account_keypath: Sequence[int] = script_config_with_keypath.keypath + self._maybe_register_script_config(script_config, account_keypath) + keypath = parse_path(keypaths[our_xpub]) + + bb02 = self.init() + address = bb02.btc_address( + keypath, coin=self._get_coin(), script_config=script_config, display=True + ) + return address + + @bitbox02_exception + def sign_tx(self, psbt: PSBT) -> PSBT: + """ + Sign a transaction with the BitBox02. + + The BitBox02 allows mixing inputs of different script types (e.g. and `p2wpkh-p2sh` `p2wpkh`), as + long as the keypaths use the appropriate bip44 purpose field per input (e.g. `49'` and `84'`) and + all account indexes are the same. + + Transactions with legacy inputs are not supported. + """ + def find_our_key( + keypaths: Dict[bytes, KeyOriginInfo] + ) -> Tuple[Optional[bytes], Optional[Sequence[int]]]: + """ + Keypaths is a map of pubkey to hd keypath, where the first element in the keypath is the master + fingerprint. We attempt to find the key which belongs to the BitBox02 by matching the fingerprint, + and then matching the pubkey. + Returns the pubkey and the keypath, without the fingerprint. + """ + for pubkey, origin in keypaths.items(): + # Cheap check if the key is ours. + if origin.fingerprint != master_fp: + continue + + # Expensive check if the key is ours. + # TODO: check for fingerprint collision + # keypath_account = keypath[:-2] + + return pubkey, origin.path + return None, None + + script_configs: List[bitbox02.btc.BTCScriptConfigWithKeypath] = [] + + def add_script_config( + script_config: bitbox02.btc.BTCScriptConfigWithKeypath + ) -> int: + # Find index of script config if already added. + script_config_index = next( + ( + i + for i, e in builtins.enumerate(script_configs) + if e.SerializeToString() == script_config.SerializeToString() + ), + None, + ) + if script_config_index is not None: + return script_config_index + script_configs.append(script_config) + return len(script_configs) - 1 + + def script_config_from_utxo( + output: CTxOut, + keypath: Sequence[int], + redeem_script: bytes, + witness_script: bytes, + ) -> bitbox02.btc.BTCScriptConfigWithKeypath: + if is_p2pkh(output.scriptPubKey): + raise BadArgumentError( + "The BitBox02 does not support legacy p2pkh scripts" + ) + if is_p2wpkh(output.scriptPubKey): + return bitbox02.btc.BTCScriptConfigWithKeypath( + script_config=bitbox02.btc.BTCScriptConfig( + simple_type=bitbox02.btc.BTCScriptConfig.P2WPKH + ), + keypath=_keypath_hardened_prefix(keypath), + ) + if output.is_p2sh() and is_p2wpkh(redeem_script): + return bitbox02.btc.BTCScriptConfigWithKeypath( + script_config=bitbox02.btc.BTCScriptConfig( + simple_type=bitbox02.btc.BTCScriptConfig.P2WPKH_P2SH + ), + keypath=_keypath_hardened_prefix(keypath), + ) + if is_p2tr(output.scriptPubKey): + return bitbox02.btc.BTCScriptConfigWithKeypath( + script_config=bitbox02.btc.BTCScriptConfig( + simple_type=bitbox02.btc.BTCScriptConfig.P2TR + ), + keypath=_keypath_hardened_prefix(keypath), + ) + # Check for segwit multisig (p2wsh or p2wsh-p2sh). + is_p2wsh_p2sh = output.is_p2sh() and is_p2wsh(redeem_script) + if output.is_p2wsh() or is_p2wsh_p2sh: + multisig = parse_multisig(witness_script) + if multisig: + threshold, _ = multisig + # We assume that all xpubs in the PSBT are part of the multisig. This is okay + # since the BitBox02 enforces the same script type for all inputs and + # changes. If that should change, we need to find and use the subset of xpubs + # corresponding to the public keys in the current multisig script. + _, script_config = self._multisig_scriptconfig( + threshold, + psbt.xpub, + bitbox02.btc.BTCScriptConfig.Multisig.P2WSH + if output.is_p2wsh() + else bitbox02.btc.BTCScriptConfig.Multisig.P2WSH_P2SH, + ) + return script_config + + raise BadArgumentError("Input or change script type not recognized.") + + master_fp = self.get_master_fingerprint() + + inputs: List[bitbox02.BTCInputType] = [] + + bip44_account = None + + # One pubkey per input. The pubkey identifies the key per input with which we sign. There + # must be exactly one pubkey per input that belongs to the BitBox02. + found_pubkeys: List[bytes] = [] + + for input_index, psbt_in in builtins.enumerate(psbt.inputs): + assert psbt_in.prev_txid is not None + assert psbt_in.prev_out is not None + assert psbt_in.sequence is not None + + if psbt_in.sighash and psbt_in.sighash != 1: + raise BadArgumentError( + "The BitBox02 only supports SIGHASH_ALL. Found sighash: {}".format( + psbt_in.sighash + ) + ) + + utxo = None + prevtx = None + + # psbt_in.witness_utxo was originally used for segwit utxo's, but since it was + # discovered that the amounts are not correctly committed to in the segwit sighash, the + # full prevtx (non_witness_utxo) is supplied for both segwit and non-segwit inputs. + # See + # - https://medium.com/shiftcrypto/bitbox-app-firmware-update-6-2020-c70f733a5330 + # - https://blog.trezor.io/details-of-firmware-updates-for-trezor-one-version-1-9-1-and-trezor-model-t-version-2-3-1-1eba8f60f2dd. + # - https://github.com/zkSNACKs/WalletWasabi/pull/3822 + # The BitBox02 requires all prevtxs if not all of the inputs are taproot. + + if psbt_in.non_witness_utxo: + assert psbt_in.non_witness_utxo.sha256 is not None + if psbt_in.prev_txid != ser_uint256(psbt_in.non_witness_utxo.sha256): + raise BadArgumentError( + "Input {} has a non_witness_utxo with the wrong hash".format( + input_index + ) + ) + assert psbt_in.prev_out is not None + utxo = psbt_in.non_witness_utxo.vout[psbt_in.prev_out] + prevtx = psbt_in.non_witness_utxo + elif psbt_in.witness_utxo: + utxo = psbt_in.witness_utxo + if utxo is None: + raise BadArgumentError("No utxo found for input {}".format(input_index)) + + key_origin_infos = psbt_in.hd_keypaths.copy() + if len(psbt_in.tap_internal_key) > 0: + # adding taproot keys to the keypaths to be checked + for pubkey, (leaf_hashes, key_origin_info) in psbt_in.tap_bip32_paths.items(): + if len(leaf_hashes) > 0: + raise BadArgumentError( + "The BitBox02 does not support Taproot script path spending. Found leaf hashes: {}" + .format(leaf_hashes) + ) + key_origin_infos[pubkey] = key_origin_info + + found_pubkey, keypath = find_our_key(key_origin_infos) + + if not found_pubkey: + raise BadArgumentError("No key found for input {}".format(input_index)) + assert keypath is not None + found_pubkeys.append(found_pubkey) + + if bip44_account is None: + bip44_account = keypath[2] + elif bip44_account != keypath[2]: + raise BadArgumentError( + "The bip44 account index must be the same for all inputs and changes" + ) + + script_config_index = add_script_config( + script_config_from_utxo( + utxo, keypath, psbt_in.redeem_script, psbt_in.witness_script + ) + ) + inputs.append( + { + "prev_out_hash": psbt_in.prev_txid, + "prev_out_index": psbt_in.prev_out, + "prev_out_value": utxo.nValue, + "sequence": psbt_in.sequence, + "keypath": keypath, + "script_config_index": script_config_index, + "prev_tx": None if prevtx is None else { + "version": prevtx.nVersion, + "locktime": prevtx.nLockTime, + "inputs": [ + { + "prev_out_hash": ser_uint256(prev_in.prevout.hash), + "prev_out_index": prev_in.prevout.n, + "signature_script": prev_in.scriptSig, + "sequence": prev_in.nSequence, + } + for prev_in in prevtx.vin + ], + "outputs": [ + { + "value": prev_out.nValue, + "pubkey_script": prev_out.scriptPubKey, + } + for prev_out in prevtx.vout + ], + }, + } + ) + + outputs: List[bitbox02.BTCOutputType] = [] + + for output_index, psbt_out in builtins.enumerate(psbt.outputs): + tx_out = psbt_out.get_txout() + + key_origin_infos = psbt_out.hd_keypaths.copy() + if len(psbt_out.tap_internal_key) > 0: + # adding taproot keys to the keypaths to be checked + for pubkey, (leaf_hashes, key_origin_info) in psbt_out.tap_bip32_paths.items(): + if len(leaf_hashes) > 0: + raise BadArgumentError( + "The BitBox02 does not support Taproot script path spending. Found leaf hashes: {}" + .format(leaf_hashes) + ) + key_origin_infos.update({pubkey: key_origin_info}) + + _, keypath = find_our_key(key_origin_infos) + + is_change = keypath and keypath[-2] == 1 + if is_change: + assert keypath is not None + script_config_index = add_script_config( + script_config_from_utxo( + tx_out, keypath, psbt_out.redeem_script, psbt_out.witness_script + ) + ) + outputs.append( + bitbox02.BTCOutputInternal( + keypath=keypath, + value=tx_out.nValue, + script_config_index=script_config_index, + ) + ) + else: + if tx_out.is_p2pkh(): + output_type = bitbox02.btc.P2PKH + output_payload = tx_out.scriptPubKey[3:23] + elif is_p2wpkh(tx_out.scriptPubKey): + output_type = bitbox02.btc.P2WPKH + output_payload = tx_out.scriptPubKey[2:] + elif tx_out.is_p2sh(): + output_type = bitbox02.btc.P2SH + output_payload = tx_out.scriptPubKey[2:22] + elif is_p2wsh(tx_out.scriptPubKey): + output_type = bitbox02.btc.P2WSH + output_payload = tx_out.scriptPubKey[2:] + elif is_p2tr(tx_out.scriptPubKey): + output_type = bitbox02.btc.P2TR + output_payload = tx_out.scriptPubKey[2:] + else: + raise BadArgumentError( + "Output type not recognized of output {}".format(output_index) + ) + + outputs.append( + bitbox02.BTCOutputExternal( + output_type=output_type, + output_payload=output_payload, + value=tx_out.nValue, + ) + ) + + assert bip44_account is not None + if ( + len(script_configs) == 1 + and script_configs[0].script_config.WhichOneof("config") == "multisig" + ): + self._maybe_register_script_config( + script_configs[0].script_config, script_configs[0].keypath + ) + + assert psbt.tx_version is not None + sigs = self.init().btc_sign( + self._get_coin(), + script_configs, + inputs=inputs, + outputs=outputs, + locktime=psbt.compute_lock_time(), + version=psbt.tx_version, + ) + + for (_, sig), pubkey, psbt_in in zip(sigs, found_pubkeys, psbt.inputs): + r, s = sig[:32], sig[32:64] + + if len(psbt_in.tap_internal_key) > 0: + # taproot keypath input + psbt_in.tap_key_sig = sig + else: + # ser_sig_der() adds SIGHASH_ALL + psbt_in.partial_sigs[pubkey] = ser_sig_der(r, s) + + return psbt + + @bitbox02_exception + def sign_message( + self, message: Union[str, bytes], bip32_path: str + ) -> str: + if isinstance(message, str): + message = message.encode("utf-8") + keypath = parse_path(bip32_path) + purpose = keypath[0] + simple_type = { + PURPOSE_P2WPKH: bitbox02.btc.BTCScriptConfig.P2WPKH, + PURPOSE_P2WPKH_P2SH: bitbox02.btc.BTCScriptConfig.P2WPKH_P2SH, + }.get(purpose) + if simple_type is None: + raise BitBox02Error( + "For message signing, the keypath bip44 purpose must be 84' or 49'" + ) + _, _, sig65 = self.init().btc_sign_msg( + self._get_coin(), + bitbox02.btc.BTCScriptConfigWithKeypath( + script_config=bitbox02.btc.BTCScriptConfig( + simple_type=simple_type, + ), + keypath=keypath, + ), + message) + return base64.b64encode(sig65).decode("ascii") + + @bitbox02_exception + def toggle_passphrase(self) -> bool: + bb02 = self.init() + info = bb02.device_info() + if info["mnemonic_passphrase_enabled"]: + bb02.disable_mnemonic_passphrase() + else: + bb02.enable_mnemonic_passphrase() + return True + + @bitbox02_exception + def setup_device( + self, label: str = "", passphrase: str = "" + ) -> bool: + if passphrase: + raise UnavailableActionError( + "Passphrase not needed when setting up a BitBox02." + ) + + bb02 = self.init(expect_initialized=False) + + if label: + bb02.set_device_name(label) + if not bb02.set_password(): + return False + return bb02.create_backup() + + @bitbox02_exception + def wipe_device(self) -> bool: + return self.init().reset() + + @bitbox02_exception + def backup_device( + self, label: str = "", passphrase: str = "" + ) -> bool: + if label or passphrase: + raise UnavailableActionError( + "Label/passphrase not needed when exporting mnemonic from the BitBox02." + ) + + self.init().show_mnemonic() + return True + + @bitbox02_exception + def restore_device( + self, label: str = "", word_count: int = 24 + ) -> bool: + bb02 = self.init(expect_initialized=False) + + if label: + bb02.set_device_name(label) + + bb02.restore_from_mnemonic() + return True + + def can_sign_taproot(self) -> bool: + """ + The BitBox02 does not support Taproot yet. + + :returns: False, always + """ + return False diff --git a/hwilib/devices/btchip/README.md b/hwilib/devices/btchip/README.md deleted file mode 100644 index c2eeb3f1f..000000000 --- a/hwilib/devices/btchip/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Ledger Nano S and Nano X Library - -This is a stripped down and modified version of the official [btchip-python](https://github.com/LedgerHQ/btchip-python) library. - -This stripped down version was made at commit [fe82d7f5638169f583a445b8e200fd1c9f3ea218](https://github.com/LedgerHQ/btchip-python/tree/fe82d7f5638169f583a445b8e200fd1c9f3ea218). - -## Changes - -- Removed support for Ledger HW.1 and other unused things diff --git a/hwilib/devices/ckcc/README.md b/hwilib/devices/ckcc/README.md index bfab35b02..8db249b74 100644 --- a/hwilib/devices/ckcc/README.md +++ b/hwilib/devices/ckcc/README.md @@ -2,7 +2,7 @@ This is a stripped down and modified version of the official [ckcc-protocol](https://github.com/Coldcard/ckcc-protocol) library. -This stripped down version was made at commit [49fa0265df4c9d0d0d915ccd4dc41b06104d6738](https://github.com/Coldcard/ckcc-protocol/tree/49fa0265df4c9d0d0d915ccd4dc41b06104d6738). +This stripped down version was made at commit [ca8d2b7808784a9f4927f3250bf52d2623a4e15b](https://github.com/Coldcard/ckcc-protocol/tree/ca8d2b7808784a9f4927f3250bf52d2623a4e15b). ## Changes diff --git a/hwilib/devices/ckcc/__init__.py b/hwilib/devices/ckcc/__init__.py index c016e7664..b2f0b70ea 100644 --- a/hwilib/devices/ckcc/__init__.py +++ b/hwilib/devices/ckcc/__init__.py @@ -1,5 +1,5 @@ -__version__ = '0.7.2' +__version__ = '1.0.2' __all__ = [ "client", "protocol", "constants" ] diff --git a/hwilib/devices/ckcc/client.py b/hwilib/devices/ckcc/client.py index 3159cdbda..bd4b91ea0 100644 --- a/hwilib/devices/ckcc/client.py +++ b/hwilib/devices/ckcc/client.py @@ -92,12 +92,12 @@ def resync(self): # check the above all worked err = self.dev.error() - if err != '': + if err and ('not implemented yet' not in err) and (err != 'Success'): raise RuntimeError('hidapi: '+err) assert self.dev.get_serial_number_string() == self.serial - def send_recv(self, msg, expect_errors=False, verbose=0, timeout=1000, encrypt=True): + def send_recv(self, msg, expect_errors=False, verbose=0, timeout=3000, encrypt=True): # first byte of each 64-byte packet encodes length or packet-offset assert 4 <= len(msg) <= MAX_MSG_LEN, "msg length: %d" % len(msg) @@ -140,6 +140,10 @@ def send_recv(self, msg, expect_errors=False, verbose=0, timeout=1000, encrypt=T while 1: buf = self.dev.read(64, timeout_ms=(timeout or 0)) + if not buf and timeout: + # give it another try + buf = self.dev.read(64, timeout_ms=timeout) + assert buf, "timeout reading USB EP" # (trusting more than usual here) @@ -159,10 +163,10 @@ def send_recv(self, msg, expect_errors=False, verbose=0, timeout=1000, encrypt=T print("Rx [%2d]: %r" % (len(resp), b2a_hex(bytes(resp)))) return CCProtocolUnpacker.decode(resp) - except CCProtoError as e: + except CCProtoError: if expect_errors: raise raise - except: + except Exception: #print("Corrupt response: %r" % resp) raise @@ -217,8 +221,8 @@ def aes_setup(self, session_key): # - count must start at zero, and increment in LSB for each block. import pyaes - self.encrypt_request = pyaes.AESModeOfOperationCTR(session_key, pyaes.Counter(0)).decrypt - self.decrypt_response = pyaes.AESModeOfOperationCTR(session_key, pyaes.Counter(0)).encrypt + self.encrypt_request = pyaes.AESModeOfOperationCTR(session_key, pyaes.Counter(0)).encrypt + self.decrypt_response = pyaes.AESModeOfOperationCTR(session_key, pyaes.Counter(0)).decrypt def start_encryption(self): # setup encryption on the link @@ -258,7 +262,8 @@ def mitm_verify(self, sig, expected_xpub): # If Pycoin is not available, do it using ecdsa from ecdsa import BadSignatureError, SECP256k1, VerifyingKey - pubkey, chaincode = decode_xpub(expected_xpub) + # of the returned (pubkey, chaincode) tuple, chaincode is not used + pubkey, _ = decode_xpub(expected_xpub) vk = VerifyingKey.from_string(get_pubkey_string(pubkey), curve=SECP256k1) try: ok = vk.verify_digest(sig[1:], self.session_key) @@ -325,6 +330,15 @@ def download_file(self, length, checksum, blksize=1024, file_number=1): return data + def hash_password(self, text_password): + # Turn text password into a key for use in HSM auth protocol + from hashlib import pbkdf2_hmac, sha256 + from .constants import PBKDF2_ITER_COUNT + + salt = sha256(b'pepper' + self.serial.encode('ascii')).digest() + + return pbkdf2_hmac('sha256', text_password, salt, PBKDF2_ITER_COUNT) + class UnixSimulatorPipe: # Use a UNIX pipe to the simulator instead of a real USB connection. @@ -335,7 +349,8 @@ def __init__(self, path): self.pipe = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) try: self.pipe.connect(path) - except FileNotFoundError: + except Exception: + self.close() raise RuntimeError("Cannot connect to simulator. Is it running?") instance = 0 @@ -376,7 +391,8 @@ def close(self): self.pipe.close() try: os.unlink(self.pipe_name) - except: pass + except Exception: + pass def get_serial_number_string(self): return 'simulator' diff --git a/hwilib/devices/ckcc/constants.py b/hwilib/devices/ckcc/constants.py index 94998382f..ebbd93770 100644 --- a/hwilib/devices/ckcc/constants.py +++ b/hwilib/devices/ckcc/constants.py @@ -24,6 +24,24 @@ # Max length of text messages for signing MSG_SIGNING_MAX_LENGTH = const(240) +# Types of user auth we support +USER_AUTH_TOTP = const(1) # RFC6238 +USER_AUTH_HOTP = const(2) # RFC4226 +USER_AUTH_HMAC = const(3) # PBKDF2('hmac-sha256', secret, sha256(psbt), PBKDF2_ITER_COUNT) +USER_AUTH_SHOW_QR = const(0x80) # show secret on Coldcard screen (best for TOTP enroll) + +MAX_USERNAME_LEN = 16 +PBKDF2_ITER_COUNT = 2500 + +# Max depth for derived keys, in PSBT files, and USB commands +MAX_PATH_DEPTH = const(12) + +# Bitmask used in sign_transaction (stxn) command +STXN_FINALIZE = const(0x01) +STXN_VISUALIZE = const(0x02) +STXN_SIGNED = const(0x04) +STXN_FLAGS_MASK = const(0x07) + # Bit values for address types AFC_PUBKEY = const(0x01) # pay to hash of pubkey AFC_SEGWIT = const(0x02) # requires a witness to spend @@ -51,6 +69,7 @@ # BIP-174 aka PSBT defined values # PSBT_GLOBAL_UNSIGNED_TX = const(0) +PSBT_GLOBAL_XPUB = const(1) PSBT_IN_NON_WITNESS_UTXO = const(0) PSBT_IN_WITNESS_UTXO = const(1) diff --git a/hwilib/devices/ckcc/protocol.py b/hwilib/devices/ckcc/protocol.py index 8467cba69..92431910e 100644 --- a/hwilib/devices/ckcc/protocol.py +++ b/hwilib/devices/ckcc/protocol.py @@ -11,6 +11,12 @@ class CCProtoError(RuntimeError): def __str__(self): return self.args[0] +class CCFramingError(CCProtoError): + # Typically framing errors are caused by multiple + # programs trying to talk to Coldcard at same time, + # and the encryption state gets confused. + pass + class CCUserRefused(RuntimeError): def __str__(self): return 'You refused permission to do the operation' @@ -42,6 +48,15 @@ def ping(msg): # returns whatever binary you give it return b'ping' + bytes(msg) + @staticmethod + def bip39_passphrase(pw): + return b'pass' + bytes(pw, 'utf8') + + @staticmethod + def get_passphrase_done(): + # poll completion of BIP39 encryption change (provides root xpub) + return b'pwok' + @staticmethod def check_mitm(): return b'mitm' @@ -72,10 +87,11 @@ def sha256(): return b'sha2' @staticmethod - def sign_transaction(length, file_sha, finalize=False): + def sign_transaction(length, file_sha, finalize=False, flags=0x0): # must have already uploaded binary, and give expected sha256 assert len(file_sha) == 32 - return pack('<4sII32s', b'stxn', length, int(finalize), file_sha) + flags |= (STXN_FINALIZE if finalize else 0x00) + return pack('<4sII32s', b'stxn', length, int(flags), file_sha) @staticmethod def sign_message(raw_msg, subpath='m', addr_fmt=AF_CLASSIC): @@ -98,6 +114,17 @@ def get_signed_txn(): # poll completion/results of transaction signing return b'stok' + @staticmethod + def multisig_enroll(length, file_sha): + # multisig details must already be uploaded as a text file, this starts approval process. + assert len(file_sha) == 32 + return pack('<4sI32s', b'enrl', length, file_sha) + + @staticmethod + def multisig_check(M, N, xfp_xor): + # do we have a wallet already that matches M+N and xor(*xfps)? + return pack('<4s3I', b'msck', M, N, xfp_xor) + @staticmethod def get_xpub(subpath='m'): # takes a string, like: m/44'/0'/23/23 @@ -105,10 +132,38 @@ def get_xpub(subpath='m'): @staticmethod def show_address(subpath, addr_fmt=AF_CLASSIC): - # takes a string, like: m/44'/0'/23/23 - # shows on screen, no feedback from user expected + # - takes a string, like: m/44'/0'/23/23 + # - shows on screen, no feedback from user expected + assert not (addr_fmt & AFC_SCRIPT) return pack('<4sI', b'show', addr_fmt) + subpath.encode('ascii') + @staticmethod + def show_p2sh_address(M, xfp_paths, witdeem_script, addr_fmt=AF_P2SH): + # For multisig (aka) P2SH cases, you will need all the info required to build + # the redeem script, and the Coldcard must already have been enrolled + # into the wallet. + # - redeem script must be provided + # - full subkey paths for each involved key is required in a list of lists of ints, where + # is a XFP and derivation path, like in BIP174 + # - the order of xfp_paths must match the order of pubkeys in + # redeem script (after BIP67 sort). This allows for dup xfp values. + assert addr_fmt & AFC_SCRIPT + assert 30 <= len(witdeem_script) <= 520 + + rv = pack('<4sIBBH', b'p2sh', addr_fmt, M, len(xfp_paths), len(witdeem_script)) + rv += witdeem_script + + for xfp_path in xfp_paths: + ln = len(xfp_path) + rv += pack('= 15 + assert len(psbt_sha) == 32 + digest = hmac.new(key, psbt_sha, sha256).digest() + + num = struct.unpack('>I', digest[-4:])[0] & 0x7fffffff + + return '%06d' % (num % 1000000) + + # EOF diff --git a/hwilib/devices/coldcard.py b/hwilib/devices/coldcard.py index 55ea54558..c3c9d0269 100644 --- a/hwilib/devices/coldcard.py +++ b/hwilib/devices/coldcard.py @@ -1,12 +1,59 @@ -# Coldcard interaction script - -from binascii import b2a_hex +""" +Coldcard +******** +""" + +from typing import ( + Dict, + List, + Union, +) + +from ..descriptor import MultisigDescriptor from ..hwwclient import HardwareWalletClient -from ..errors import ActionCanceledError, BadArgumentError, DeviceBusyError, DeviceFailureError, UnavailableActionError, common_err_msgs, handle_errors -from .ckcc.client import ColdcardDevice, COINKITE_VID, CKCC_PID -from .ckcc.protocol import CCProtocolPacker, CCBusyError, CCProtoError, CCUserRefused -from .ckcc.constants import MAX_BLK_LEN, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH -from ..base58 import xpub_main_2_test +from ..errors import ( + ActionCanceledError, + BadArgumentError, + DeviceBusyError, + DeviceFailureError, + UnavailableActionError, + common_err_msgs, + handle_errors, +) +from .ckcc.client import ( + ColdcardDevice, + COINKITE_VID, + CKCC_PID, +) +from .ckcc.protocol import ( + CCProtocolPacker, + CCBusyError, + CCProtoError, + CCUserRefused, +) +from .ckcc.constants import ( + MAX_BLK_LEN, + AF_P2WPKH, + AF_CLASSIC, + AF_P2WPKH_P2SH, + AF_P2WSH, + AF_P2SH, + AF_P2WSH_P2SH, +) +from .._base58 import ( + get_xpub_fingerprint, +) +from ..key import ( + ExtendedKey, +) +from ..psbt import ( + PSBT, +) +from ..common import ( + AddressType, + Chain, +) +from functools import wraps from hashlib import sha256 import base64 @@ -15,13 +62,21 @@ import sys import time import struct -from binascii import hexlify + +from binascii import b2a_hex +from typing import ( + Any, + Callable, + Optional, +) CC_SIMULATOR_SOCK = '/tmp/ckcc-simulator.sock' # Using the simulator: https://github.com/Coldcard/firmware/blob/master/unix/README.md -def coldcard_exception(f): - def func(*args, **kwargs): + +def coldcard_exception(f: Callable[..., Any]) -> Callable[..., Any]: + @wraps(f) + def func(*args: Any, **kwargs: Any) -> Any: try: return f(*args, **kwargs) except CCProtoError as e: @@ -35,8 +90,8 @@ def func(*args, **kwargs): # This class extends the HardwareWalletClient for ColdCard specific things class ColdcardClient(HardwareWalletClient): - def __init__(self, path, password=''): - super(ColdcardClient, self).__init__(path, password) + def __init__(self, path: str, password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> None: + super(ColdcardClient, self).__init__(path, password, expert, chain) # Simulator hard coded pipe socket if path == CC_SIMULATOR_SOCK: self.device = ColdcardDevice(sn=path) @@ -45,86 +100,113 @@ def __init__(self, path, password=''): device.open_path(path.encode()) self.device = ColdcardDevice(dev=device) - # Must return a dict with the xpub - # Retrieves the public key at the specified BIP 32 derivation path @coldcard_exception - def get_pubkey_at_path(self, path): + def get_pubkey_at_path(self, path: str) -> ExtendedKey: self.device.check_mitm() path = path.replace('h', '\'') path = path.replace('H', '\'') - xpub = self.device.send_recv(CCProtocolPacker.get_xpub(path), timeout=None) - if self.is_testnet: - return {'xpub': xpub_main_2_test(xpub)} - else: - return {'xpub': xpub} + xpub_str = self.device.send_recv(CCProtocolPacker.get_xpub(path), timeout=None) + xpub = ExtendedKey.deserialize(xpub_str) + if self.chain != Chain.MAIN: + xpub.version = ExtendedKey.TESTNET_PUBLIC + return xpub - def _get_fingerprint_hex(self): + def get_master_fingerprint(self) -> bytes: # quick method to get fingerprint of wallet - return hexlify(struct.pack(' PSBT: + """ + Sign a transaction with the Coldcard. + + - The Coldcard firmware only supports signing single key and multisig transactions. It cannot sign arbitrary scripts. + - Multisigs need to be registered on the device before a transaction spending that multisig will be signed by the device. + - Multisigs must use BIP 67. This can be accomplished in Bitcoin Core using the `sortedmulti()` descriptor, available in Bitcoin Core 0.20. + """ self.device.check_mitm() - # Get psbt in hex and then make binary - fd = io.BytesIO(base64.b64decode(tx.serialize())) - - # learn size (portable way) - sz = fd.seek(0, 2) - fd.seek(0) - - left = sz - chk = sha256() - for pos in range(0, sz, MAX_BLK_LEN): - here = fd.read(min(MAX_BLK_LEN, left)) - if not here: + # Get this devices master key fingerprint + xpub = self.device.send_recv(CCProtocolPacker.get_xpub('m/0\''), timeout=None) + master_fp = get_xpub_fingerprint(xpub) + + # For multisigs, we may need to do multiple passes if we appear in an input multiple times + passes = 1 + for psbt_in in tx.inputs: + our_keys = 0 + for key in psbt_in.hd_keypaths.keys(): + keypath = psbt_in.hd_keypaths[key] + if keypath.fingerprint == master_fp and key not in psbt_in.partial_sigs: + our_keys += 1 + if our_keys > passes: + passes = our_keys + + for _ in range(passes): + # Get psbt in hex and then make binary + tx.convert_to_v0() + fd = io.BytesIO(base64.b64decode(tx.serialize())) + + # learn size (portable way) + sz = fd.seek(0, 2) + fd.seek(0) + + left = sz + chk = sha256() + for pos in range(0, sz, MAX_BLK_LEN): + here = fd.read(min(MAX_BLK_LEN, left)) + if not here: + break + left -= len(here) + result = self.device.send_recv(CCProtocolPacker.upload(pos, sz, here)) + assert result == pos + chk.update(here) + + # do a verify + expect = chk.digest() + result = self.device.send_recv(CCProtocolPacker.sha256()) + assert len(result) == 32 + if result != expect: + raise DeviceFailureError("Wrong checksum:\nexpect: %s\n got: %s" % (b2a_hex(expect).decode('ascii'), b2a_hex(result).decode('ascii'))) + + # start the signing process + ok = self.device.send_recv(CCProtocolPacker.sign_transaction(sz, expect), timeout=None) + assert ok is None + if self.device.is_simulator: + self.device.send_recv(CCProtocolPacker.sim_keypress(b'y')) + + print("Waiting for OK on the Coldcard...", file=sys.stderr) + + while 1: + time.sleep(0.250) + done = self.device.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None) + if done is None: + continue break - left -= len(here) - result = self.device.send_recv(CCProtocolPacker.upload(pos, sz, here)) - assert result == pos - chk.update(here) - - # do a verify - expect = chk.digest() - result = self.device.send_recv(CCProtocolPacker.sha256()) - assert len(result) == 32 - if result != expect: - raise DeviceFailureError("Wrong checksum:\nexpect: %s\n got: %s" % (b2a_hex(expect).decode('ascii'), b2a_hex(result).decode('ascii'))) - - # start the signing process - ok = self.device.send_recv(CCProtocolPacker.sign_transaction(sz, expect), timeout=None) - assert ok is None - if self.device.is_simulator: - self.device.send_recv(CCProtocolPacker.sim_keypress(b'y')) - print("Waiting for OK on the Coldcard...", file=sys.stderr) + if len(done) != 2: + raise DeviceFailureError('Failed: %r' % done) - while 1: - time.sleep(0.250) - done = self.device.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None) - if done is None: - continue - break + result_len, result_sha = done - if len(done) != 2: - raise DeviceFailureError('Failed: %r' % done) + result = self.device.download_file(result_len, result_sha, file_number=1) - result_len, result_sha = done + tx = PSBT() + tx.deserialize(base64.b64encode(result).decode()) - result = self.device.download_file(result_len, result_sha, file_number=1) - return {'psbt': base64.b64encode(result).decode()} + return tx - # Must return a base64 encoded string with the signed message - # The message can be any string. keypath is the bip 32 derivation path for the key to sign with @coldcard_exception - def sign_message(self, message, keypath): + def sign_message(self, message: Union[str, bytes], keypath: str) -> str: self.device.check_mitm() keypath = keypath.replace('h', '\'') keypath = keypath.replace('H', '\'') - ok = self.device.send_recv(CCProtocolPacker.sign_message(message.encode(), keypath, AF_CLASSIC), timeout=None) + msg = message + if not isinstance(message, bytes): + msg = message.encode() + ok = self.device.send_recv( + CCProtocolPacker.sign_message(msg, keypath, AF_CLASSIC), timeout=None + ) assert ok is None if self.device.is_simulator: self.device.send_recv(CCProtocolPacker.sim_keypress(b'y')) @@ -140,44 +222,120 @@ def sign_message(self, message, keypath): if len(done) != 2: raise DeviceFailureError('Failed: %r' % done) - addr, raw = done + _, raw = done sig = str(base64.b64encode(raw), 'ascii').replace('\n', '') - return {"signature": sig} + return sig - # Display address of specified type on the device. Only supports single-key based addresses. @coldcard_exception - def display_address(self, keypath, p2sh_p2wpkh, bech32): + def display_singlesig_address( + self, + keypath: str, + addr_type: AddressType, + ) -> str: self.device.check_mitm() keypath = keypath.replace('h', '\'') keypath = keypath.replace('H', '\'') - if p2sh_p2wpkh: - format = AF_P2WPKH_P2SH - elif bech32: - format = AF_P2WPKH + if addr_type == AddressType.SH_WIT: + addr_fmt = AF_P2WPKH_P2SH + elif addr_type == AddressType.WIT: + addr_fmt = AF_P2WPKH + elif addr_type == AddressType.LEGACY: + addr_fmt = AF_CLASSIC + elif addr_type == AddressType.TAP: + raise UnavailableActionError("Coldcard does not support displaying Taproot addresses yet") else: - format = AF_CLASSIC - address = self.device.send_recv(CCProtocolPacker.show_address(keypath, format), timeout=None) + raise BadArgumentError("Unknown address type") + + payload = CCProtocolPacker.show_address(keypath, addr_fmt=addr_fmt) + + address = self.device.send_recv(payload, timeout=None) + assert isinstance(address, str) + if self.device.is_simulator: self.device.send_recv(CCProtocolPacker.sim_keypress(b'y')) - return {'address': address} + return address + + @coldcard_exception + def display_multisig_address( + self, + addr_type: AddressType, + multisig: MultisigDescriptor, + ) -> str: + if not multisig.is_sorted: + raise BadArgumentError("Coldcards only allow sortedmulti descriptors") + + self.device.check_mitm() + + if addr_type == AddressType.SH_WIT: + addr_fmt = AF_P2WSH_P2SH + elif addr_type == AddressType.WIT: + addr_fmt = AF_P2WSH + elif addr_type == AddressType.LEGACY: + addr_fmt = AF_P2SH + else: + raise BadArgumentError("Unknown address type") + + if not 1 <= len(multisig.pubkeys) <= 15: + raise BadArgumentError("Must provide 1 to 15 keypaths to display a multisig address") + + redeem_script = (80 + int(multisig.thresh)).to_bytes(1, byteorder="little") - # Setup a new device - def setup_device(self, label='', passphrase=''): + if not 1 <= multisig.thresh <= len(multisig.pubkeys): + raise BadArgumentError("Either the redeem script provided is invalid or the keypaths provided are insufficient") + + xfp_paths = [] + sorted_keys = sorted(zip([p.get_pubkey_bytes(0) for p in multisig.pubkeys], multisig.pubkeys)) + for pk, p in sorted_keys: + xfp_paths.append(p.get_full_derivation_int_list(0)) + redeem_script += len(pk).to_bytes(1, byteorder="little") + pk + + redeem_script += (80 + len(multisig.pubkeys)).to_bytes(1, byteorder="little") + redeem_script += b"\xae" + + payload = CCProtocolPacker.show_p2sh_address(multisig.thresh, xfp_paths, redeem_script, addr_fmt=addr_fmt) + + address = self.device.send_recv(payload, timeout=None) + assert isinstance(address, str) + + if self.device.is_simulator: + self.device.send_recv(CCProtocolPacker.sim_keypress(b'y')) + return address + + def setup_device(self, label: str = "", passphrase: str = "") -> bool: + """ + The Coldcard does not support setup via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Coldcard does not support software setup') - # Wipe this device - def wipe_device(self): + def wipe_device(self) -> bool: + """ + The Coldcard does not support wiping via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Coldcard does not support wiping via software') - # Restore device from mnemonic or xprv - def restore_device(self, label=''): + def restore_device(self, label: str = "", word_count: int = 24) -> bool: + """ + The Coldcard does not support restoring via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Coldcard does not support restoring via software') - # Begin backup process @coldcard_exception - def backup_device(self, label='', passphrase=''): + def backup_device(self, label: str = "", passphrase: str = "") -> bool: + """ + Creates a backup file in the current working directory. This file is protected by the + passphrase shown on the Coldcard. + + :param label: Value is ignored + :param passphrase: Value is ignored + """ self.device.check_mitm() ok = self.device.send_recv(CCProtocolPacker.start_backup()) @@ -203,60 +361,77 @@ def backup_device(self, label='', passphrase=''): result = self.device.download_file(result_len, result_sha, file_number=0) filename = time.strftime('backup-%Y%m%d-%H%M.7z') open(filename, 'wb').write(result) - return {'success': True, 'message': 'The backup has been written to {}'.format(filename)} + return True - # Close the device - def close(self): + def close(self) -> None: self.device.close() - # Prompt pin - def prompt_pin(self): + def prompt_pin(self) -> bool: + """ + The Coldcard does not need a PIN sent from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Coldcard does not need a PIN sent from the host') - # Send pin - def send_pin(self, pin): + def send_pin(self, pin: str) -> bool: + """ + The Coldcard does not need a PIN sent from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Coldcard does not need a PIN sent from the host') -def enumerate(password=''): + def toggle_passphrase(self) -> bool: + """ + The Coldcard does not support toggling passphrase from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('The Coldcard does not support toggling passphrase from the host') + + def can_sign_taproot(self) -> bool: + """ + The Coldard does not support Taproot yet. + + :returns: False, always + """ + return False + + +def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> List[Dict[str, Any]]: results = [] - for d in hid.enumerate(COINKITE_VID, CKCC_PID): - d_data = {} + devices = hid.enumerate(COINKITE_VID, CKCC_PID) + devices.append({'path': CC_SIMULATOR_SOCK.encode()}) + for d in devices: + d_data: Dict[str, Any] = {} path = d['path'].decode() d_data['type'] = 'coldcard' d_data['model'] = 'coldcard' + d_data['label'] = None d_data['path'] = path - d_data['needs_passphrase'] = False + d_data['needs_pin_sent'] = False + d_data['needs_passphrase_sent'] = False + + if path == CC_SIMULATOR_SOCK: + d_data['model'] += '_simulator' client = None with handle_errors(common_err_msgs["enumerate"], d_data): - client = ColdcardClient(path) - d_data['fingerprint'] = client._get_fingerprint_hex() + try: + client = ColdcardClient(path) + d_data['fingerprint'] = client.get_master_fingerprint().hex() + except RuntimeError as e: + # Skip the simulator if it's not there + if str(e) == 'Cannot connect to simulator. Is it running?': + continue + else: + raise e if client: client.close() results.append(d_data) - # Check if the simulator is there - client = None - try: - client = ColdcardClient(CC_SIMULATOR_SOCK) - - d_data = {} - d_data['fingerprint'] = client._get_fingerprint_hex() - d_data['type'] = 'coldcard' - d_data['model'] = 'coldcard_simulator' - d_data['path'] = CC_SIMULATOR_SOCK - d_data['needs_pin_sent'] = False - d_data['needs_passphrase_sent'] = False - results.append(d_data) - except RuntimeError as e: - if str(e) == 'Cannot connect to simulator. Is it running?': - pass - else: - raise e - if client: - client.close() - return results diff --git a/hwilib/devices/digitalbitbox.py b/hwilib/devices/digitalbitbox.py index bc74a019e..a3693b7b9 100644 --- a/hwilib/devices/digitalbitbox.py +++ b/hwilib/devices/digitalbitbox.py @@ -1,4 +1,7 @@ -# Digital Bitbox interaction script +""" +BitBox01 +******** +""" import hid import struct @@ -13,11 +16,54 @@ import socket import sys import time - +from functools import wraps +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Union, +) + +from ..common import ( + AddressType, + Chain, + hash256, +) +from ..descriptor import MultisigDescriptor from ..hwwclient import HardwareWalletClient -from ..errors import ActionCanceledError, BadArgumentError, DeviceFailureError, DeviceAlreadyInitError, DEVICE_NOT_INITIALIZED, DeviceNotReadyError, NoPasswordError, UnavailableActionError, common_err_msgs, handle_errors -from ..serializations import CTransaction, hash256, ser_sig_der, ser_sig_compact, ser_compact_size -from ..base58 import get_xpub_fingerprint, xpub_main_2_test, get_xpub_fingerprint_hex +from ..errors import ( + ActionCanceledError, + BadArgumentError, + DeviceFailureError, + DeviceAlreadyInitError, + DEVICE_NOT_INITIALIZED, + DeviceNotReadyError, + NoPasswordError, + UnavailableActionError, + common_err_msgs, + handle_errors, +) +from ..key import ( + ExtendedKey, +) +from .._script import ( + is_p2pk, + is_p2pkh, + is_p2sh, + is_p2wpkh, + is_p2wsh, + is_witness, +) +from ..psbt import PSBT +from .._serialize import ( + ser_sig_der, + ser_sig_compact, + ser_string, + ser_compact_size, +) applen = 225280 # flash size minus bootloader length chunksize = 8 * 512 @@ -32,7 +78,7 @@ DBB_DEVICE_ID = 0x2402 # Errors codes from the device -bad_args = [ +bad_args: List[Union[int, str]] = [ 102, # The password length must be at least " STRINGIFY(PASSWORD_LEN_MIN) " characters. 103, # No input received. 104, # Invalid command. @@ -51,7 +97,9 @@ 251, # Could not generate key. ] -device_failures = [ +bad_args.extend([str(x) for x in bad_args]) + +device_failures: List[Union[int, str]] = [ 101, # Please set a password. 107, # Output buffer overflow. 200, # Seed creation requires an SD card for automatic encrypted backup of the seed. @@ -78,29 +126,36 @@ 903, # attempts remain before the device is reset. The next login requires holding the touch button. ] -cancels = [ +device_failures.extend([str(x) for x in device_failures]) + +cancels: List[Union[int, str]] = [ 600, # Aborted by user. 601, # Touchbutton timed out. ] +cancels.extend([str(x) for x in cancels]) + ERR_MEM_SETUP = 503 # Device initialization in progress. class DBBError(Exception): - def __init__(self, error): + def __init__(self, error: Dict[str, Dict[str, Union[str, int]]]) -> None: Exception.__init__(self) self.error = error - def get_error(self): + def get_error(self) -> str: + assert isinstance(self.error["error"]["message"], str) return self.error['error']['message'] - def get_code(self): + def get_code(self) -> Union[str, int]: + assert isinstance(self.error["error"]["code"], int) or isinstance(self.error["error"]["code"], str) return self.error['error']['code'] - def __str__(self): + def __str__(self) -> str: return 'Error: {}, Code: {}'.format(self.error['error']['message'], self.error['error']['code']) -def digitalbitbox_exception(f): - def func(*args, **kwargs): +def digitalbitbox_exception(f: Callable[..., Any]) -> Any: + @wraps(f) + def func(*args: Any, **kwargs: Any) -> Any: try: return f(*args, **kwargs) except DBBError as e: @@ -110,52 +165,51 @@ def func(*args, **kwargs): raise DeviceFailureError(e.get_error()) elif e.get_code() in cancels: raise ActionCanceledError(e.get_error()) - elif e.get_code() == ERR_MEM_SETUP: + elif e.get_code() == ERR_MEM_SETUP or e.get_code() == str(ERR_MEM_SETUP): raise DeviceNotReadyError(e.get_error()) return func -def aes_encrypt_with_iv(key, iv, data): +def aes_encrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes: aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv) aes = pyaes.Encrypter(aes_cbc) e = aes.feed(data) + aes.feed() # empty aes.feed() appends pkcs padding + assert isinstance(e, bytes) return e -def aes_decrypt_with_iv(key, iv, data): +def aes_decrypt_with_iv(key: bytes, iv: bytes, data: bytes) -> bytes: aes_cbc = pyaes.AESModeOfOperationCBC(key, iv=iv) aes = pyaes.Decrypter(aes_cbc) s = aes.feed(data) + aes.feed() # empty aes.feed() strips pkcs padding + assert isinstance(s, bytes) return s -def encrypt_aes(secret, s): +def encrypt_aes(secret: bytes, s: bytes) -> bytes: iv = bytes(os.urandom(16)) ct = aes_encrypt_with_iv(secret, iv, s) e = iv + ct return e -def decrypt_aes(secret, e): +def decrypt_aes(secret: bytes, e: bytes) -> bytes: iv, e = e[:16], e[16:] s = aes_decrypt_with_iv(secret, iv, e) return s -def sha256(x): - return hashlib.sha256(x).digest() - -def sha512(x): +def sha512(x: bytes) -> bytes: return hashlib.sha512(x).digest() -def double_hash(x): - if type(x) is not bytearray: +def double_hash(x: Union[str, bytes]) -> bytes: + if not isinstance(x, bytes): x = x.encode('utf-8') - return sha256(sha256(x)) + return hash256(x) -def derive_keys(x): +def derive_keys(x: str) -> Tuple[bytes, bytes]: h = double_hash(x) h = sha512(h) return (h[:len(h) // 2], h[len(h) // 2:]) -def to_string(x, enc): +def to_string(x: Union[str, bytes, bytearray], enc: str) -> str: if isinstance(x, (bytes, bytearray)): return x.decode(enc) if isinstance(x, str): @@ -164,30 +218,32 @@ def to_string(x, enc): raise DeviceFailureError("Not a string or bytes like object") class BitboxSimulator(): - def __init__(self, ip, port): + def __init__(self, ip: str, port: int) -> None: self.ip = ip self.port = port self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket.connect((self.ip, self.port)) self.socket.settimeout(1) - def send_recv(self, msg): + def send_recv(self, msg: bytes) -> bytes: self.socket.sendall(msg) data = self.socket.recv(3584) return data - def close(self): + def close(self) -> None: self.socket.close() - def get_serial_number_string(self): + def get_serial_number_string(self) -> str: return 'dbb_fw:v5.0.0' -def send_frame(data, device): +Device = Union[BitboxSimulator, hid.device] + +def send_frame(data: bytes, device: hid.device) -> None: data = bytearray(data) data_len = len(data) seq = 0 idx = 0 - write = [] + write = b"" while idx < data_len: if idx == 0: # INIT frame @@ -201,7 +257,7 @@ def send_frame(data, device): idx += len(write) -def read_frame(device): +def read_frame(device: hid.device) -> bytes: # INIT response read = bytearray(device.read(usb_report_size)) cid = ((read[0] * 256 + read[1]) * 256 + read[2]) * 256 + read[3] @@ -218,15 +274,14 @@ def read_frame(device): assert cmd == HWW_CMD, '- USB command frame mismatch' return data -def get_firmware_version(device): +def get_firmware_version(device: Device) -> Tuple[int, int, int]: serial_number = device.get_serial_number_string() split_serial = serial_number.split(':') firm_ver = split_serial[1][1:] # Version is vX.Y.Z, we just need X.Y.Z split_ver = firm_ver.split('.') return (int(split_ver[0]), int(split_ver[1]), int(split_ver[2])) # major, minor, revision -def send_plain(msg, device): - reply = "" +def send_plain(msg: bytes, device: Device) -> Dict[str, Any]: try: if isinstance(device, BitboxSimulator): r = device.send_recv(msg) @@ -234,7 +289,7 @@ def send_plain(msg, device): firm_ver = get_firmware_version(device) if (firm_ver[0] == 2 and firm_ver[1] == 0) or (firm_ver[0] == 1): hidBufSize = 4096 - device.write('\0' + msg + '\0' * (hidBufSize - len(msg))) + device.write(b"\0" + msg + b"\0" * (hidBufSize - len(msg))) r = bytearray() while len(r) < hidBufSize: r += bytearray(device.read(hidBufSize)) @@ -243,14 +298,14 @@ def send_plain(msg, device): r = read_frame(device) r = r.rstrip(b' \t\r\n\0') r = r.replace(b"\0", b'') - r = to_string(r, 'utf8') - reply = json.loads(r) + result = json.loads(to_string(r, "utf8")) + assert isinstance(result, dict) + return result except Exception as e: - reply = json.loads('{"error":"Exception caught while sending plaintext message to DigitalBitbox ' + str(e) + '"}') - return reply + return {"error": f"Exception caught while sending plaintext message to DigitalBitbox {str(e)}"} -def send_encrypt(msg, password, device): - reply = "" +def send_encrypt(message: str, password: str, device: Device) -> Dict[str, Any]: + msg = message.encode("utf8") try: firm_ver = get_firmware_version(device) if firm_ver[0] >= 5: @@ -272,80 +327,111 @@ def send_encrypt(msg, password, device): raise Exception("Failed to validate HMAC") else: msg = b64_unencoded - reply = decrypt_aes(encryption_key, msg) - reply = json.loads(reply.decode("utf-8")) - if 'error' in reply: - password = None + plaintext = decrypt_aes(encryption_key, msg) + result = json.loads(plaintext.decode("utf-8")) + assert isinstance(result, dict) + return result + else: + return reply except Exception as e: - reply = {'error': 'Exception caught while sending encrypted message to DigitalBitbox ' + str(e)} - return reply + return {'error': 'Exception caught while sending encrypted message to DigitalBitbox ' + str(e)} -def stretch_backup_key(password): +def stretch_backup_key(password: str) -> str: key = hashlib.pbkdf2_hmac('sha512', password.encode(), b'Digital Bitbox', 20480) return binascii.hexlify(key).decode() -def format_backup_filename(name): +def format_backup_filename(name: str) -> str: return '{}-{}.pdf'.format(name, time.strftime('%Y-%m-%d-%H-%M-%S', time.localtime())) # This class extends the HardwareWalletClient for Digital Bitbox specific things class DigitalbitboxClient(HardwareWalletClient): - def __init__(self, path, password): - super(DigitalbitboxClient, self).__init__(path, password) - if not password: + def __init__(self, path: str, password: Optional[str], expert: bool = False, chain: Chain = Chain.MAIN) -> None: + """ + The `DigitalbitboxClient` is a `HardwareWalletClient` for interacting with BitBox01 devices (previously known as the Digital BitBox). + + :param path: Path to the device as given by `enumerate` + :param password: The password required to communicate with the device. Must be provided. + :param expert: Whether to be in expert mode and return additional information. + """ + if password is None: raise NoPasswordError('Password must be supplied for digital BitBox') + super(DigitalbitboxClient, self).__init__(path, password, expert, chain) if path.startswith('udp:'): split_path = path.split(':') ip = split_path[1] port = int(split_path[2]) - self.device = BitboxSimulator(ip, port) + self.device: Device = BitboxSimulator(ip, port) else: self.device = hid.device() self.device.open_path(path.encode()) - self.password = password + self.password: str = password - # Must return a dict with the xpub - # Retrieves the public key at the specified BIP 32 derivation path @digitalbitbox_exception - def get_pubkey_at_path(self, path): + def get_pubkey_at_path(self, path: str) -> ExtendedKey: + """ + Retrieve the public key at the path. + The BitBox01 requires that at least one of the levels in the path is hardened. + + :param path: Path to retrieve the public key at. + """ if '\'' not in path and 'h' not in path and 'H' not in path: raise BadArgumentError('The digital bitbox requires one part of the derivation path to be derived using hardened keys') reply = send_encrypt('{"xpub":"' + path + '"}', self.password, self.device) if 'error' in reply: raise DBBError(reply) - if self.is_testnet: - return {'xpub': xpub_main_2_test(reply['xpub'])} - else: - return {'xpub': reply['xpub']} + xpub = ExtendedKey.deserialize(reply["xpub"]) + if self.chain != Chain.MAIN: + xpub.version = ExtendedKey.TESTNET_PUBLIC + return xpub - # Must return a hex string with the signed transaction - # The tx must be in the PSBT format @digitalbitbox_exception - def sign_tx(self, tx): + def sign_tx(self, tx: PSBT) -> PSBT: - # Create a transaction with all scriptsigs blanekd out - blank_tx = CTransaction(tx.tx) + # Create a transaction with all scriptsigs blanked out + blank_tx = tx.get_unsigned_tx() # Get the master key fingerprint - master_fp = get_xpub_fingerprint(self.get_pubkey_at_path('m/0h')['xpub']) + master_fp = self.get_master_fingerprint() # create sighashes sighash_tuples = [] for txin, psbt_in, i_num in zip(blank_tx.vin, tx.inputs, range(len(blank_tx.vin))): sighash = b"" + utxo = None + if psbt_in.witness_utxo: + utxo = psbt_in.witness_utxo if psbt_in.non_witness_utxo: + if txin.prevout.hash != psbt_in.non_witness_utxo.sha256: + raise BadArgumentError('Input {} has a non_witness_utxo with the wrong hash'.format(i_num)) utxo = psbt_in.non_witness_utxo.vout[txin.prevout.n] + if utxo is None: + continue + scriptcode = utxo.scriptPubKey + + # Check if P2SH + p2sh = False + if is_p2sh(scriptcode): + # Look up redeemscript + if len(psbt_in.redeem_script) == 0: + continue + scriptcode = psbt_in.redeem_script + p2sh = True - # Check if P2SH - if utxo.is_p2sh(): - # Look up redeemscript - redeemscript = psbt_in.redeem_script + is_wit, _, _ = is_witness(scriptcode) + + # Check if P2WSH + if is_p2wsh(scriptcode): + # Look up witnessscript + if len(psbt_in.witness_script) == 0: + continue + scriptcode = psbt_in.witness_script + + if not is_wit: + if p2sh or is_p2pkh(scriptcode) or is_p2pk(scriptcode): # Add to blank_tx - txin.scriptSig = redeemscript - # Check if P2PKH - elif utxo.is_p2pkh() or utxo.is_p2pk(): - txin.scriptSig = psbt_in.non_witness_utxo.vout[txin.prevout.n].scriptPubKey + txin.scriptSig = scriptcode # We don't know what this is, skip it else: continue @@ -357,7 +443,8 @@ def sign_tx(self, tx): # Hash it sighash += hash256(ser_tx) txin.scriptSig = b"" - elif psbt_in.witness_utxo: + else: + assert psbt_in.witness_utxo is not None # Calculate hashPrevouts and hashSequence prevouts_preimage = b"" sequence_preimage = b"" @@ -373,25 +460,10 @@ def sign_tx(self, tx): outputs_preimage += output.serialize() hashOutputs = hash256(outputs_preimage) - # Get the scriptCode - scriptCode = b"" - witness_program = b"" - if psbt_in.witness_utxo.is_p2sh(): - # Look up redeemscript - redeemscript = psbt_in.redeem_script - witness_program = redeemscript - else: - witness_program = psbt_in.witness_utxo.scriptPubKey - - # Check if witness_program is script hash - if len(witness_program) == 34 and witness_program[0] == 0x00 and witness_program[1] == 0x20: - # look up witnessscript and set as scriptCode - witnessscript = psbt_in.witness_script - scriptCode += ser_compact_size(len(witnessscript)) + witnessscript - else: - scriptCode += b"\x19\x76\xa9\x14" - scriptCode += witness_program[2:] - scriptCode += b"\x88\xac" + # Check if scriptcode is p2wpkh + if is_p2wpkh(scriptcode): + _, _, wit_prog = is_witness(scriptcode) + scriptcode = b"\x76\xa9\x14" + wit_prog + b"\x88\xac" # Make sighash preimage preimage = b"" @@ -399,11 +471,11 @@ def sign_tx(self, tx): preimage += hashPrevouts preimage += hashSequence preimage += txin.prevout.serialize() - preimage += scriptCode + preimage += ser_string(scriptcode) preimage += struct.pack("= 0x80000000: - keypath_str += str(index - 0x80000000) + 'h' - else: - keypath_str += str(index) + keypath_str = keypath.get_derivation_path() # Create tuples and add to List tup = (binascii.hexlify(sighash).decode(), keypath_str, i_num, pubkey) @@ -427,55 +493,59 @@ def sign_tx(self, tx): # Return early if nothing to do if len(sighash_tuples) == 0: - return {'psbt': tx.serialize()} - - # Sign the sighashes - to_send = '{"sign":{"data":[' - for tup in sighash_tuples: - to_send += '{"hash":"' - to_send += tup[0] - to_send += '","keypath":"' - to_send += tup[1] - to_send += '"},' - if to_send[-1] == ',': - to_send = to_send[:-1] - to_send += ']}}' - logging.debug(to_send) - - reply = send_encrypt(to_send, self.password, self.device) - logging.debug(reply) - if 'error' in reply: - raise DBBError(reply) - print("Touch the device for 3 seconds to sign. Touch briefly to cancel", file=sys.stderr) - reply = send_encrypt(to_send, self.password, self.device) - logging.debug(reply) - if 'error' in reply: - raise DBBError(reply) + return tx + + for i in range(0, len(sighash_tuples), 15): + tups = sighash_tuples[i:i + 15] + + # Sign the sighashes + to_send = '{"sign":{"data":[' + for tup in tups: + to_send += '{"hash":"' + to_send += tup[0] + to_send += '","keypath":"' + to_send += tup[1] + to_send += '"},' + if to_send[-1] == ',': + to_send = to_send[:-1] + to_send += ']}}' + logging.debug(to_send) + + reply = send_encrypt(to_send, self.password, self.device) + logging.debug(reply) + if 'error' in reply: + raise DBBError(reply) + print("Touch the device for 3 seconds to sign. Touch briefly to cancel", file=sys.stderr) + reply = send_encrypt(to_send, self.password, self.device) + logging.debug(reply) + if 'error' in reply: + raise DBBError(reply) + + # Extract sigs + sigs = [] + for item in reply['sign']: + sigs.append(binascii.unhexlify(item['sig'])) + + # Make sigs der + der_sigs = [] + for sig in sigs: + der_sigs.append(ser_sig_der(sig[0:32], sig[32:64])) + + # add sigs to tx + for tup, sig in zip(tups, der_sigs): + tx.inputs[tup[2]].partial_sigs[tup[3]] = sig + + return tx - # Extract sigs - sigs = [] - for item in reply['sign']: - sigs.append(binascii.unhexlify(item['sig'])) - - # Make sigs der - der_sigs = [] - for sig in sigs: - der_sigs.append(ser_sig_der(sig[0:32], sig[32:64])) - - # add sigs to tx - for tup, sig in zip(sighash_tuples, der_sigs): - tx.inputs[tup[2]].partial_sigs[tup[3]] = sig - - return {'psbt': tx.serialize()} - - # Must return a base64 encoded string with the signed message - # The message can be any string @digitalbitbox_exception - def sign_message(self, message, keypath): + def sign_message(self, message: Union[str, bytes], keypath: str) -> str: to_hash = b"" to_hash += self.message_magic to_hash += ser_compact_size(len(message)) - to_hash += message.encode() + if isinstance(message, bytes): + to_hash += message + else: + to_hash += message.encode() hashed_message = hash256(to_hash) @@ -502,18 +572,29 @@ def sign_message(self, message, keypath): compact_sig = ser_sig_compact(r, s, recid) logging.debug(binascii.hexlify(compact_sig)) - return {"signature": base64.b64encode(compact_sig).decode('utf-8')} + return base64.b64encode(compact_sig).decode('utf-8') + + def display_singlesig_address(self, keypath: str, addr_type: AddressType) -> str: + """ + The BitBox01 does not have a screen to display addresses on. - # Display address of specified type on the device. Only supports single-key based addresses. - def display_address(self, keypath, p2sh_p2wpkh, bech32): + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('The Digital Bitbox does not have a screen to display addresses on') + + def display_multisig_address(self, addr_type: AddressType, multisig: MultisigDescriptor) -> str: + """ + The BitBox01 does not have a screen to display addresses on. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Digital Bitbox does not have a screen to display addresses on') - # Setup a new device @digitalbitbox_exception - def setup_device(self, label='', passphrase=''): + def setup_device(self, label: str = "", passphrase: str = "") -> bool: # Make sure this is not initialized reply = send_encrypt('{"device" : "info"}', self.password, self.device) - if 'error' not in reply or ('error' in reply and reply['error']['code'] != 101): + if 'error' not in reply or ('error' in reply and (reply['error']['code'] != 101 and reply['error']['code'] != '101')): raise DeviceAlreadyInitError('Device is already initialized. Use wipe first and try again') # Need a wallet name and backup passphrase @@ -521,33 +602,35 @@ def setup_device(self, label='', passphrase=''): raise BadArgumentError('The label and backup passphrase for a new Digital Bitbox wallet must be specified and cannot be empty') # Set password - to_send = {'password': self.password} + to_send: Dict[str, Any] = {'password': self.password} reply = send_plain(json.dumps(to_send).encode(), self.device) # Now make the wallet key = stretch_backup_key(passphrase) backup_filename = format_backup_filename(label) to_send = {'seed': {'source': 'create', 'key': key, 'filename': backup_filename}} - reply = send_encrypt(json.dumps(to_send).encode(), self.password, self.device) + reply = send_encrypt(json.dumps(to_send), self.password, self.device) if 'error' in reply: - return {'success': False, 'error': reply['error']['message']} - return {'success': True} + raise DeviceFailureError(reply['error']['message']) + return True - # Wipe this device @digitalbitbox_exception - def wipe_device(self): + def wipe_device(self) -> bool: reply = send_encrypt('{"reset" : "__ERASE__"}', self.password, self.device) if 'error' in reply: - return {'success': False, 'error': reply['error']['message']} - return {'success': True} + raise DeviceFailureError(reply["error"]["message"]) + return True - # Restore device from mnemonic or xprv - def restore_device(self, label=''): + def restore_device(self, label: str = "", word_count: int = 24) -> bool: + """ + The BitBox01 does not support restoring via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Digital Bitbox does not support restoring via software') - # Begin backup process @digitalbitbox_exception - def backup_device(self, label='', passphrase=''): + def backup_device(self, label: str = "", passphrase: str = "") -> bool: # Need a wallet name and backup passphrase if not label or not passphrase: raise BadArgumentError('The label and backup passphrase for a Digital Bitbox backup must be specified and cannot be empty') @@ -555,24 +638,48 @@ def backup_device(self, label='', passphrase=''): key = stretch_backup_key(passphrase) backup_filename = format_backup_filename(label) to_send = {'backup': {'source': 'all', 'key': key, 'filename': backup_filename}} - reply = send_encrypt(json.dumps(to_send).encode(), self.password, self.device) + reply = send_encrypt(json.dumps(to_send), self.password, self.device) if 'error' in reply: raise DBBError(reply) - return {'success': True} + return True - # Close the device - def close(self): + def close(self) -> None: self.device.close() - # Prompt pin - def prompt_pin(self): + def prompt_pin(self) -> bool: + """ + The BitBox01 does not need a PIN sent from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Digital Bitbox does not need a PIN sent from the host') - # Send pin - def send_pin(self, pin): + def send_pin(self, pin: str) -> bool: + """ + The BitBox01 does not need a PIN sent from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Digital Bitbox does not need a PIN sent from the host') -def enumerate(password=''): + def toggle_passphrase(self) -> bool: + """ + The BitBox01 does not support toggling passphrase from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('The Digital Bitbox does not support toggling passphrase from the host') + + def can_sign_taproot(self) -> bool: + """ + The BitBox01 does not support Taproot as it is no longer supported by the manufacturer + + :returns: False, always + """ + return False + + +def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> List[Dict[str, Any]]: results = [] devices = hid.enumerate(DBB_VENDOR_ID, DBB_DEVICE_ID) # Try connecting to simulator @@ -581,16 +688,17 @@ def enumerate(password=''): dev.send_recv(b'{"device" : "info"}') devices.append({'path': b'udp:127.0.0.1:35345', 'interface_number': 0}) dev.close() - except: + except Exception: pass for d in devices: if ('interface_number' in d and d['interface_number'] == 0 or ('usage_page' in d and d['usage_page'] == 0xffff)): - d_data = {} + d_data: Dict[str, Any] = {} path = d['path'].decode() d_data['type'] = 'digitalbitbox' d_data['model'] = 'digitalbitbox_01' + d_data['label'] = None if path == 'udp:127.0.0.1:35345': d_data['model'] += '_simulator' d_data['path'] = path @@ -600,13 +708,12 @@ def enumerate(password=''): client = DigitalbitboxClient(path, password) # Check initialized - reply = send_encrypt('{"device" : "info"}', password, client.device) - if 'error' in reply and reply['error']['code'] == 101: + reply = send_encrypt('{"device" : "info"}', "" if password is None else password, client.device) + if 'error' in reply and (reply['error']['code'] == 101 or reply['error']['code'] == '101'): d_data['error'] = 'Not initialized' d_data['code'] = DEVICE_NOT_INITIALIZED else: - master_xpub = client.get_pubkey_at_path('m/0h')['xpub'] - d_data['fingerprint'] = get_xpub_fingerprint_hex(master_xpub) + d_data['fingerprint'] = client.get_master_fingerprint().hex() d_data['needs_pin_sent'] = False d_data['needs_passphrase_sent'] = True diff --git a/hwilib/devices/jade.py b/hwilib/devices/jade.py new file mode 100644 index 000000000..58832bad5 --- /dev/null +++ b/hwilib/devices/jade.py @@ -0,0 +1,552 @@ +""" +Blockstream Jade Devices +************************ +""" + +from .jadepy import jade +from .jadepy.jade import JadeAPI, JadeError + +from serial.tools import list_ports + +from functools import wraps +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Sequence, + Tuple, + Union +) +from ..descriptor import MultisigDescriptor +from ..hwwclient import HardwareWalletClient +from ..errors import ( + ActionCanceledError, + BadArgumentError, + DeviceConnectionError, + DeviceFailureError, + DeviceNotReadyError, + UnavailableActionError, + common_err_msgs, + handle_errors +) +from ..common import ( + AddressType, + Chain, + sha256 +) +from ..key import ( + ExtendedKey, + KeyOriginInfo, + is_hardened, + parse_path +) +from ..psbt import PSBT +from .._script import ( + is_p2sh, + is_p2wpkh, + is_p2wsh, + is_witness, + parse_multisig +) + +import logging +import semver +import os + +# The test emulator port +SIMULATOR_PATH = 'tcp:127.0.0.1:30121' + +JADE_DEVICE_IDS = [(0x10c4, 0xea60), (0x1a86, 0x55d4)] +HAS_NETWORKING = hasattr(jade, '_http_request') + +py_enumerate = enumerate # To use the enumerate built-in, since the name is overridden below + +def jade_exception(f: Callable[..., Any]) -> Any: + @wraps(f) + def func(*args: Any, **kwargs: Any) -> Any: + try: + return f(*args, **kwargs) + except ValueError as e: + raise BadArgumentError(str(e)) + except JadeError as e: + if e.code == JadeError.USER_CANCELLED: + raise ActionCanceledError(f'{f.__name__} canceled by user') + elif e.code == JadeError.BAD_PARAMETERS: + raise BadArgumentError(e.message) + elif e.code == JadeError.INTERNAL_ERROR: + raise DeviceFailureError(e.message) + elif e.code == JadeError.HW_LOCKED: + raise DeviceConnectionError('Device is locked') + elif e.code == JadeError.NETWORK_MISMATCH: + raise DeviceConnectionError('Network/chain selection error') + elif e.code in [JadeError.INVALID_REQUEST, JadeError.UNKNOWN_METHOD, JadeError.PROTOCOL_ERROR]: + raise DeviceConnectionError('Messaging/communiciation error') + else: + raise e + return func + +# This class extends the HardwareWalletClient for Blockstream Jade specific things +class JadeClient(HardwareWalletClient): + MIN_SUPPORTED_FW_VERSION = semver.VersionInfo(0, 1, 32) + + NETWORKS = {Chain.MAIN: 'mainnet', + Chain.TEST: 'testnet', + Chain.SIGNET: 'testnet', # same as far as Jade is concerned + Chain.REGTEST: 'localtest'} + + def _network(self) -> str: + if self.chain not in self.NETWORKS: + raise BadArgumentError(f'Unhandled network: {self.chain}') + return self.NETWORKS[self.chain] + + ADDRTYPES = {AddressType.LEGACY: 'pkh(k)', + AddressType.WIT: 'wpkh(k)', + AddressType.SH_WIT: 'sh(wpkh(k))'} + + MULTI_ADDRTYPES = {AddressType.LEGACY: 'sh(multi(k))', + AddressType.WIT: 'wsh(multi(k))', + AddressType.SH_WIT: 'sh(wsh(multi(k)))'} + + @classmethod + def _convertAddrType(cls, addrType: AddressType, multisig: bool) -> str: + return cls.MULTI_ADDRTYPES[addrType] if multisig else cls.ADDRTYPES[addrType] + + # Derive a deterministic name for a multisig registration record (ignoring bip67 key sorting) + @staticmethod + def _get_multisig_name(type: str, threshold: int, signers: List[Tuple[bytes, Sequence[int]]]) -> str: + # Concatenate script-type, threshold, and all signers fingerprints and derivation paths (sorted) + summary = type + '|' + str(threshold) + '|' + for fingerprint, path in sorted(signers): + summary += fingerprint.hex() + '|' + str(path) + '|' + + # Hash it, get the first 6-bytes as hex, prepend with 'hwi' + hash_summary = sha256(summary.encode()).hex() + return 'hwi' + hash_summary[:12] + + def __init__(self, path: str, password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN, timeout: Optional[int] = None) -> None: + super(JadeClient, self).__init__(path, password, expert, chain) + self.jade = JadeAPI.create_serial(path, timeout=timeout) + self.jade.connect() + + verinfo = self.jade.get_version_info() + uninitialized = verinfo['JADE_STATE'] not in ['READY', 'TEMP'] + + # Check minimum supported firmware version (ignore candidate/build parts) + fw_version = semver.parse_version_info(verinfo['JADE_VERSION']) + if self.MIN_SUPPORTED_FW_VERSION > fw_version.finalize_version(): + raise DeviceNotReadyError(f'Jade fw version: {fw_version} - minimum required version: {self.MIN_SUPPORTED_FW_VERSION}. ' + 'Please update using a Blockstream Green companion app') + if path == SIMULATOR_PATH: + if uninitialized: + # Connected to simulator but it appears to have no wallet set + raise DeviceNotReadyError('Use JadeAPI.set_[seed|mnemonic] to set simulator wallet') + else: + if uninitialized and not HAS_NETWORKING: + # Wallet not initialised/unlocked nor do we have networking dependencies + # User must use 'Recovery Phrase Login' or 'QR Unlock' feature to access wallet + raise DeviceNotReadyError('Use "Recovery Phrase Login" or "QR PIN Unlock" feature on Jade hw to access wallet') + + # Push some host entropy into jade + self.jade.add_entropy(os.urandom(32)) + + # Authenticate the user - this may require a PIN and pinserver interaction + # (if we have required networking dependencies) + authenticated = False + while not authenticated: + authenticated = self.jade.auth_user(self._network()) + + # Retrieves the public key at the specified BIP 32 derivation path + @jade_exception + def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey: + path = parse_path(bip32_path) + xpub = self.jade.get_xpub(self._network(), path) + ext_key = ExtendedKey.deserialize(xpub) + return ext_key + + # Walk the PSBT looking for inputs we can sign. Push any signatures into the + # 'partial_sigs' map in the input, and return the updated PSBT. + @jade_exception + def sign_tx(self, tx: PSBT) -> PSBT: + """ + Sign a transaction with the Blockstream Jade. + """ + # Helper to get multisig record for change output + def _parse_signers(hd_keypath_origins: List[KeyOriginInfo]) -> Tuple[List[Tuple[bytes, Sequence[int]]], List[Sequence[int]]]: + # Split the path at the last hardened path element + def _split_at_last_hardened_element(path: Sequence[int]) -> Tuple[Sequence[int], Sequence[int]]: + for i in range(len(path), 0, -1): + if is_hardened(path[i - 1]): + return (path[:i], path[i:]) + return ([], path) + + signers = [] + paths = [] + for origin in hd_keypath_origins: + prefix, suffix = _split_at_last_hardened_element(origin.path) + signers.append((origin.fingerprint, prefix)) + paths.append(suffix) + return signers, paths + + c_txn = tx.get_unsigned_tx() + master_fp = self.get_master_fingerprint() + signing_singlesigs = False + signing_multisigs = {} + need_to_sign = True + + while need_to_sign: + signing_pubkeys: List[Optional[bytes]] = [None] * len(tx.inputs) + need_to_sign = False + + # Signing input details + jade_inputs = [] + for n_vin, psbtin in py_enumerate(tx.inputs): + # Get bip32 path to use to sign, if required for this input + path = None + multisig_input = len(psbtin.hd_keypaths) > 1 + for pubkey, origin in psbtin.hd_keypaths.items(): + if origin.fingerprint == master_fp and len(origin.path) > 0: + if not multisig_input: + signing_singlesigs = True + + if psbtin.partial_sigs.get(pubkey, None) is None: + # hw to sign this input - it is not already signed + if signing_pubkeys[n_vin] is None: + signing_pubkeys[n_vin] = pubkey + path = origin.path + else: + # Additional signature needed for this input - ie. a multisig where this wallet is + # multiple signers? Clumsy, but just loop and go through the signing procedure again. + need_to_sign = True + + # Get the tx and prevout/scriptcode + utxo = None + p2sh = False + input_txn_bytes = None + if psbtin.witness_utxo: + utxo = psbtin.witness_utxo + if psbtin.non_witness_utxo: + if psbtin.prev_txid != psbtin.non_witness_utxo.hash: + raise BadArgumentError(f'Input {n_vin} has a non_witness_utxo with the wrong hash') + assert psbtin.prev_out is not None + utxo = psbtin.non_witness_utxo.vout[psbtin.prev_out] + input_txn_bytes = psbtin.non_witness_utxo.serialize_without_witness() + if utxo is None: + raise Exception('PSBT is missing input utxo information, cannot sign') + sats_value = utxo.nValue + scriptcode = utxo.scriptPubKey + + if is_p2sh(scriptcode): + scriptcode = psbtin.redeem_script + p2sh = True + + witness_input, witness_version, witness_program = is_witness(scriptcode) + + if witness_input: + if is_p2wsh(scriptcode): + scriptcode = psbtin.witness_script + elif is_p2wpkh(scriptcode): + scriptcode = b'\x76\xa9\x14' + witness_program + b'\x88\xac' + else: + continue + + # If we are signing a multisig input, deduce the potential + # registration details and cache as a potential change wallet + if multisig_input and path and scriptcode and (p2sh or witness_input): + parsed = parse_multisig(scriptcode) + if parsed: + addr_type = AddressType.LEGACY if not witness_input else AddressType.WIT if not p2sh else AddressType.SH_WIT + script_variant = self._convertAddrType(addr_type, multisig=True) + threshold = parsed[0] + + pubkeys = parsed[1] + hd_keypath_origins = [psbtin.hd_keypaths[pubkey] for pubkey in pubkeys] + + signers, paths = _parse_signers(hd_keypath_origins) + multisig_name = self._get_multisig_name(script_variant, threshold, signers) + signing_multisigs[multisig_name] = (script_variant, threshold, signers) + + # Build the input and add to the list - include some host entropy for AE sigs (although we won't verify) + jade_inputs.append({'is_witness': witness_input, 'satoshi': sats_value, 'script': scriptcode, 'path': path, + 'input_tx': input_txn_bytes, 'ae_host_entropy': os.urandom(32), 'ae_host_commitment': os.urandom(32)}) + + # Change output details + # This is optional, in that if we send it Jade validates the change output script + # and the user need not confirm that output. If not passed the change output must + # be confirmed by the user on the hwwallet screen, like any other spend output. + change: List[Optional[Dict[str, Any]]] = [None] * len(tx.outputs) + + # Skip automatic change validation in expert mode - user checks *every* output on hw + if not self.expert: + # If signing multisig inputs, get registered multisigs details in case we + # see any multisig outputs which may be change which we can auto-validate. + # ie. filter speculative 'signing multisigs' to ones actually registered on the hw + if signing_multisigs: + registered_multisigs = self.jade.get_registered_multisigs() + signing_multisigs = {k: v for k, v in signing_multisigs.items() + if k in registered_multisigs + and registered_multisigs[k]['variant'] == v[0] + and registered_multisigs[k]['threshold'] == v[1] + and registered_multisigs[k]['num_signers'] == len(v[2])} + + # Look at every output... + for n_vout, (txout, psbtout) in py_enumerate(zip(c_txn.vout, tx.outputs)): + num_signers = len(psbtout.hd_keypaths) + + if num_signers == 1 and signing_singlesigs: + # Single-sig output - since we signed singlesig inputs this could be our change + for pubkey, origin in psbtout.hd_keypaths.items(): + # Considers 'our' outputs as potential change as far as Jade is concerned + # ie. can be verified and auto-confirmed. + # Is this ok, or should check path also, assuming bip44-like ? + if origin.fingerprint == master_fp and len(origin.path) > 0: + change_addr_type = None + if txout.is_p2pkh(): + change_addr_type = AddressType.LEGACY + elif txout.is_witness()[0] and not txout.is_p2wsh(): + change_addr_type = AddressType.WIT # ie. p2wpkh + elif txout.is_p2sh() and is_witness(psbtout.redeem_script)[0]: + change_addr_type = AddressType.SH_WIT + else: + continue + + script_variant = self._convertAddrType(change_addr_type, multisig=False) + change[n_vout] = {'path': origin.path, 'variant': script_variant} + + elif num_signers > 1 and signing_multisigs: + # Multisig output - since we signed multisig inputs this could be our change + candidate_multisigs = {k: v for k, v in signing_multisigs.items() if len(v[2]) == num_signers} + if not candidate_multisigs: + continue + + for pubkey, origin in psbtout.hd_keypaths.items(): + if origin.fingerprint == master_fp and len(origin.path) > 0: + change_addr_type = None + if txout.is_p2sh() and not is_witness(psbtout.redeem_script)[0]: + change_addr_type = AddressType.LEGACY + scriptcode = psbtout.redeem_script + elif txout.is_p2wsh() and not txout.is_p2sh(): + change_addr_type = AddressType.WIT + scriptcode = psbtout.witness_script + elif txout.is_p2sh() and is_witness(psbtout.redeem_script)[0]: + change_addr_type = AddressType.SH_WIT + scriptcode = psbtout.witness_script + else: + continue + + parsed = parse_multisig(scriptcode) + if parsed: + script_variant = self._convertAddrType(change_addr_type, multisig=True) + threshold = parsed[0] + + pubkeys = parsed[1] + hd_keypath_origins = [psbtout.hd_keypaths[pubkey] for pubkey in pubkeys] + + signers, paths = _parse_signers(hd_keypath_origins) + multisig_name = self._get_multisig_name(script_variant, threshold, signers) + matched_multisig = candidate_multisigs.get(multisig_name) + + if matched_multisig and matched_multisig[0] == script_variant and matched_multisig[1] == threshold and sorted(matched_multisig[2]) == sorted(signers): + change[n_vout] = {'paths': paths, 'multisig_name': multisig_name} + + # The txn itself + txn_bytes = c_txn.serialize_without_witness() + + # Request Jade generate the signatures for our inputs. + # Change details are passed to be validated on the hw (user does not confirm) + signatures = self.jade.sign_tx(self._network(), txn_bytes, jade_inputs, change, True) + + # Push sigs into PSBT structure as appropriate + for psbtin, signer_pubkey, sigdata in zip(tx.inputs, signing_pubkeys, signatures): + signer_commitment, sig = sigdata + if signer_pubkey and sig: + psbtin.partial_sigs[signer_pubkey] = sig + + # Return the updated psbt + return tx + + # Sign message, confirmed on device + @jade_exception + def sign_message(self, message: Union[str, bytes], bip32_path: str) -> str: + path = parse_path(bip32_path) + if isinstance(message, bytes) or isinstance(message, bytearray): + message = message.decode('utf-8') + + # NOTE: tests fail if we try to use AE signatures, so stick with default (rfc6979) + signature = self.jade.sign_message(path, message) + return str(signature) + + # Display address of specified type on the device. + @jade_exception + def display_singlesig_address(self, bip32_path: str, addr_type: AddressType) -> str: + path = parse_path(bip32_path) + script_variant = self._convertAddrType(addr_type, multisig=False) + address = self.jade.get_receive_address(self._network(), path, variant=script_variant) + return str(address) + + # Display multisig address of specified type on the device. + @jade_exception + def display_multisig_address(self, addr_type: AddressType, multisig: MultisigDescriptor) -> str: + signer_origins = [] + signers = [] + paths = [] + for pubkey in multisig.pubkeys: + if pubkey.extkey is None: + raise BadArgumentError('Blockstream Jade can only generate addresses for multisigs with full extended keys') + if pubkey.origin is None: + raise BadArgumentError('Blockstream Jade can only generate addresses for multisigs with key origin information') + if pubkey.deriv_path is None: + raise BadArgumentError('Blockstream Jade can only generate addresses for multisigs with key derivation paths') + + if pubkey.origin.path and not is_hardened(pubkey.origin.path[-1]): + logging.warning(f'Final element of origin path {pubkey.origin.path} unhardened') + logging.warning('Blockstream Jade may not be able to identify change sent back to this descriptor') + + # Tuple to derive deterministic name for the registrtion + signer_origins.append((pubkey.origin.fingerprint, pubkey.origin.path)) + + # We won't include the additional path in the multisig registration + signers.append({'fingerprint': pubkey.origin.fingerprint, + 'derivation': pubkey.origin.path, + 'xpub': pubkey.pubkey, + 'path': []}) + + # Instead hold it as the address path + path = pubkey.deriv_path[1:] if pubkey.deriv_path[0] == '/' else pubkey.deriv_path + paths.append(parse_path(path)) + + if multisig.is_sorted and paths[:-1] != paths[1:]: + logging.warning('Sorted multisig with different derivations per signer') + logging.warning('Blockstream Jade may not be able to validate change sent back to this descriptor') + + # Get a deterministic name for this multisig wallet (ignoring bip67 key sorting) + script_variant = self._convertAddrType(addr_type, multisig=True) + multisig_name = self._get_multisig_name(script_variant, multisig.thresh, signer_origins) + + # Need to ensure this multisig wallet is registered first + # (Note: 're-registering' is a no-op) + self.jade.register_multisig(self._network(), multisig_name, script_variant, multisig.is_sorted, multisig.thresh, signers) + address = self.jade.get_receive_address(self._network(), paths, multisig_name=multisig_name) + + return str(address) + + # Setup a new device + def setup_device(self, label: str = "", passphrase: str = "") -> bool: + """ + Blockstream Jade does not support setup via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('Blockstream Jade does not support software setup') + + # Wipe this device + def wipe_device(self) -> bool: + """ + Blockstream Jade does not support wiping via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('Blockstream Jade does not support wiping via software') + + # Restore device from mnemonic or xprv + def restore_device(self, label: str = "", word_count: int = 24) -> bool: + """ + Blockstream Jade does not support restoring via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('Blockstream Jade does not support restoring via software') + + # Begin backup process + def backup_device(self, label: str = "", passphrase: str = "") -> bool: + """ + Blockstream Jade does not support backing up via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('Blockstream Jade does not support creating a backup via software') + + # Close the device + def close(self) -> None: + self.jade.disconnect() + + # Prompt pin + def prompt_pin(self) -> bool: + """ + Blockstream Jade does not need a PIN sent from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('Blockstream Jade does not need a PIN sent from the host') + + # Send pin + def send_pin(self, pin: str) -> bool: + """ + Blockstream Jade does not need a PIN sent from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('Blockstream Jade does not need a PIN sent from the host') + + # Toggle passphrase + def toggle_passphrase(self) -> bool: + """ + Blockstream Jade does not support toggling passphrase from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('Blockstream Jade does not support toggling passphrase from the host') + + @jade_exception + def can_sign_taproot(self) -> bool: + """ + Blockstream Jade does not currently support Taproot. + + :returns: False, always + """ + return False + + +def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> List[Dict[str, Any]]: + results = [] + + def _get_device_entry(device_model: str, device_path: str) -> Dict[str, Any]: + d_data: Dict[str, Any] = {} + d_data['type'] = 'jade' + d_data['model'] = device_model + d_data['path'] = device_path + d_data['needs_pin_sent'] = False + d_data['needs_passphrase_sent'] = False + + client = None + with handle_errors(common_err_msgs['enumerate'], d_data): + client = JadeClient(device_path, password, expert, chain, timeout=1) + d_data['fingerprint'] = client.get_master_fingerprint().hex() + + if client: + client.close() + + return d_data + + # Jade is not really an HID device, it shows as a serial/com port device. + # Scan com ports looking for the relevant vid and pid, and use 'path' to + # hold the path to the serial port device, eg. /dev/ttyUSB0 + for devinfo in list_ports.comports(): + if (devinfo.vid, devinfo.pid) in JADE_DEVICE_IDS: + results.append(_get_device_entry('jade', devinfo.device)) + + # If we can connect to the simulator, add it too + try: + with JadeAPI.create_serial(SIMULATOR_PATH, timeout=1) as jade: + verinfo = jade.get_version_info() + + if verinfo is not None: + results.append(_get_device_entry('jade_simulator', SIMULATOR_PATH)) + + except Exception as e: + # If we get any sort of error do not add the simulator + logging.debug(f'Failed to connect to Jade simulator at {SIMULATOR_PATH}') + logging.debug(e) + + return results diff --git a/hwilib/devices/jadepy/README.md b/hwilib/devices/jadepy/README.md new file mode 100644 index 000000000..6ce232e0c --- /dev/null +++ b/hwilib/devices/jadepy/README.md @@ -0,0 +1,9 @@ +# Python Jade Library + +This is a slightly stripped down version of the official [Jade](https://github.com/Blockstream/Jade) python library. + +This stripped down version was made from tag [0.1.38](https://github.com/Blockstream/Jade/releases/tag/0.1.38) + +## Changes + +- Removed BLE module, reducing transitive dependencies diff --git a/hwilib/devices/jadepy/__init__.py b/hwilib/devices/jadepy/__init__.py new file mode 100644 index 000000000..64e2ceb7e --- /dev/null +++ b/hwilib/devices/jadepy/__init__.py @@ -0,0 +1,4 @@ +from .jade import JadeAPI +from .jade_error import JadeError + +__version__ = "0.2.0" diff --git a/hwilib/devices/jadepy/jade.py b/hwilib/devices/jadepy/jade.py new file mode 100644 index 000000000..c7955c1f5 --- /dev/null +++ b/hwilib/devices/jadepy/jade.py @@ -0,0 +1,1786 @@ +import cbor +import hashlib +import json +import time +import logging +import collections +import collections.abc +import traceback +import random +import sys + +# JadeError +from .jade_error import JadeError + +# Low-level comms backends +from .jade_serial import JadeSerialImpl +from .jade_tcp import JadeTCPImpl + +# 'jade' logger +logger = logging.getLogger('jade') +device_logger = logging.getLogger('jade-device') + +# BLE comms backend is optional +# It relies on the BLE dependencies being available +try: + from .jade_ble import JadeBleImpl +except ImportError as e: + logger.warn(e) + logger.warn('BLE scanning/connectivity will not be available') + + +# Default serial connection +DEFAULT_SERIAL_DEVICE = '/dev/ttyUSB0' +DEFAULT_BAUD_RATE = 115200 +DEFAULT_SERIAL_TIMEOUT = 120 + +# Default BLE connection +DEFAULT_BLE_DEVICE_NAME = 'Jade' +DEFAULT_BLE_SERIAL_NUMBER = None +DEFAULT_BLE_SCAN_TIMEOUT = 60 + + +def _hexlify(data): + """ + Helper to map bytes-like types into hex-strings + to make for prettier message-logging. + + Parameters + ---------- + data : any + The object to hexlify. + - bytes or bytearrays have 'hex()' method invoked + - list and dicts (values) have this function mapped over them + - Otherwise the input is returned unchanged + """ + if data is None: + return None + elif isinstance(data, bytes) or isinstance(data, bytearray): + return data.hex() + elif isinstance(data, list): + return [_hexlify(item) for item in data] + elif isinstance(data, dict): + return {k: _hexlify(v) for k, v in data.items()} + else: + return data + + +try: + import requests + + def _http_request(params): + """ + Simple http request function which can be used when a Jade response + requires an external http call. + The default implementation used in JadeAPI._jadeRpc() below. + NOTE: Only available if the 'requests' dependency is available. + + Callers can supply their own implmentation of this call where it is required. + + Parameters + ---------- + data : dict + A dictionary structure describing the http call to make + + Returns + ------- + dict + with single key 'body', whose value is the json returned from the call + + """ + logger.debug('_http_request: {}'.format(params)) + + # Use the first non-onion url + url = [url for url in params['urls'] if not url.endswith('.onion')][0] + if params['method'] == 'GET': + assert 'data' not in params, 'Cannot pass body to requests.get' + f = requests.get(url) + elif params['method'] == 'POST': + data = json.dumps(params['data']) + f = requests.post(url, data) + + logger.debug("http_request received reply: {}".format(f.text)) + + if f.status_code != 200: + logger.error("http error {} : {}".format(f.status_code, f.text)) + raise ValueError(f.status_code) + + assert params['accept'] == 'json' + f = f.json() + + return {'body': f} + +except ImportError as e: + logger.info(e) + logger.info('Default _http_requests() function will not be available') + + +class JadeAPI: + """ + High-Level Jade Client API + Builds on a JadeInterface to provide a meaningful API + + Either: + a) use with JadeAPI.create_[serial|ble]() as jade: + (recommended) + or: + b) use JadeAPI.create_[serial|ble], then call connect() before + using, and disconnect() when finished + (caveat cranium) + or: + c) use ctor to wrap existing JadeInterface instance + (caveat cranium) + """ + + def __init__(self, jade): + assert jade is not None + self.jade = jade + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc, tb): + if (exc_type): + logger.info("Exception causing JadeAPI context exit.") + logger.info(exc_type) + logger.info(exc) + traceback.print_tb(tb) + self.disconnect(exc_type is not None) + + @staticmethod + def create_serial(device=None, baud=None, timeout=None): + """ + Create a JadeAPI object using the serial interface described. + + Parameters + ---------- + device : str, optional + The device identifier for the serial device. + Underlying implementation will default (to /dev/ttyUSB0) + + baud : int, optional + The communication baud rate. + Underlying implementation will default (to 115200) + + timeout : int, optional + The serial read timeout when awaiting messages. + Underlying implementation will default (to 120s) + + Returns + ------- + JadeAPI + API object configured to use given serial parameters. + NOTE: the api instance has not yet tried to contact the hw + - caller must call 'connect()' before trying to use the Jade. + """ + impl = JadeInterface.create_serial(device, baud, timeout) + return JadeAPI(impl) + + @staticmethod + def create_ble(device_name=None, serial_number=None, + scan_timeout=None, loop=None): + """ + Create a JadeAPI object using the BLE interface described. + NOTE: raises JadeError if BLE dependencies not installed. + + Parameters + ---------- + device_name : str, optional + The device name of the desired BLE device. + Underlying implementation will default (to 'Jade') + + serial_number : int, optional + The serial number of the desired BLE device + - used to disambiguate multiple beacons with the same 'device name' + Underlying implementation will connect to the first beacon it scans + with the matching 'device name'. + + scan_timeout : int, optional + The timeout when scanning for devices which match the device name/serial number. + Underlying implementation will default (to 60s) + + loop : optional + The asynchio event loop to use, if required. + Underlying implementation will default (to asyncio.get_event_loop()) + + Returns + ------- + JadeAPI + API object configured to use given BLE parameters. + NOTE: the api instance has not yet tried to contact the hw + - caller must call 'connect()' before trying to use the Jade. + + Raises + ------ + JadeError if BLE backend not available (ie. BLE dependencies not installed) + """ + impl = JadeInterface.create_ble(device_name, serial_number, + scan_timeout, loop) + return JadeAPI(impl) + + def connect(self): + """ + Try to connect the underlying transport interface (eg. serial, ble, etc.) + Raises an exception on failure. + """ + self.jade.connect() + + def disconnect(self, drain=False): + """ + Disconnect the underlying transport (eg. serial, ble, etc.) + + Parameters + ---------- + drain : bool, optional + When true log any/all remaining messages/data, otherwise silently discard. + NOTE: can prevent disconnection if data is arriving constantly. + Defaults to False. + """ + self.jade.disconnect(drain) + + def drain(self): + """ + Log any/all outstanding messages/data. + NOTE: can run indefinitely if data is arriving constantly. + """ + self.jade.drain() + + @staticmethod + def _get_result_or_raise_error(reply): + """ + Raise any error message returned from a Jade rpc call as an exception. + + Parameters + ---------- + reply : dict + Dictionary representing a reply from a Jade rpc call. + + Returns + ------- + dict + Any nested 'result' structure, if the reply is not an error. + + Raises + ------ + JadeError + If the reply represented an error, including all details received. + """ + if 'error' in reply: + e = reply['error'] + raise JadeError(e.get('code'), e.get('message'), e.get('data')) + + return reply['result'] + + def _jadeRpc(self, method, params=None, inputid=None, http_request_fn=None, long_timeout=False): + """ + Helper to make a request/reply rpc call over the underlying transport interface. + NOTE: interface must be 'connected'. + + If the call returns an 'http_request' structure, this is handled here and the http + call is made, and the result is passed into the rpc method given in 'on reply', by + calling this function recursively. + + Parameters + ---------- + method : str + rpc method to invoke + + params : dict, optional + any parameters to pass to the rpc method + Defaults to None. + + inputid : str, optional + Any specific 'id' to use in the rpc message. + Defaults to a using a pseudo-random id generated in-situ. + + http_request_fn : function, optional + A function which accepts a dict (containing a description of the http request), makes + the described http call, and returns the body data in an element called 'body'. + Defaults to _http_request() above. + + long_timeout : bool, optional + Whether the rpc call should use an indefinitely long timeout, rather than that set on + construction. + (Useful if the call involves a non-trivial user interaction with the device.) + Defaults to False. + + Returns + ------- + dict + The reply from the rpc call. + NOTE: will return the last/final reply after a sequence of calls, where 'http_request' + was returned and remote data was fetched and passed into s subsequent call. + """ + newid = inputid if inputid else str(random.randint(100000, 999999)) + request = self.jade.build_request(newid, method, params) + reply = self.jade.make_rpc_call(request, long_timeout) + result = self._get_result_or_raise_error(reply) + + # The Jade can respond with a request for interaction with a remote + # http server. This is used for interaction with the pinserver but the + # code below acts as a dumb proxy and simply makes the http request and + # forwards the response back to the Jade. + # Note: the function called to make the http-request can be passed in, + # or it can default to the simple _http_request() function above, if available. + if isinstance(result, collections.abc.Mapping) and 'http_request' in result: + this_module = sys.modules[__name__] + make_http_request = http_request_fn or getattr(this_module, '_http_request', None) + assert make_http_request, 'Default _http_request() function not available' + + http_request = result['http_request'] + http_response = make_http_request(http_request['params']) + return self._jadeRpc( + http_request['on-reply'], + http_response['body'], + http_request_fn=make_http_request, + long_timeout=long_timeout) + + return result + + def get_version_info(self): + """ + RPC call to fetch summary details pertaining to the hardware unit and running firmware. + + Returns + ------- + dict + Contains keys for various info describing the hw and running fw + """ + return self._jadeRpc('get_version_info') + + def add_entropy(self, entropy): + """ + RPC call to add client entropy into the unit RNG entropy pool. + + Parameters + ---------- + entropy : bytes + Bytes to fold into the hw entropy pool. + + Returns + ------- + bool + True on success + """ + params = {'entropy': entropy} + return self._jadeRpc('add_entropy', params) + + def set_epoch(self, epoch=None): + """ + RPC call to set the current time epoch value, required for TOTP use. + NOTE: The time is lost on each power-down and must be reset on restart/reconnect before + TOTP can be used. + + Parameters + ---------- + epoch : int, optional + Current epoch value, in seconds. Defaults to int(time.time()) value. + + Returns + ------- + bool + True on success + """ + params = {'epoch': epoch if epoch is not None else int(time.time())} + return self._jadeRpc('set_epoch', params) + + def ota_update(self, fwcmp, fwlen, chunksize, patchlen=None, cb=None): + """ + RPC call to attempt to update the unit's firmware. + + Parameters + ---------- + fwcmp : bytes + The compressed firmware image to upload to the Jade unit. Can be a full firmware or + and incremental diff to be applied to the currently running firmware image. + fwlen : int + The size of the new complete (uncompressed) firmware image (after any delta is applied). + chunksize : int + The size of the chunks used to upload the compressed firmware. Each chunk is uploaded + and ack'd by the hw unit. + The maximum supported chunk size is given in the version info data, under the key + 'JADE_OTA_MAX_CHUNK'. + patchlen: int, optional + If the compressed firmware bytes are an incremental diff to be applied to the running + firmware image, this is the size of that patch when uncompressed. + Defaults to None, implying the compressed data is a full firmware image upload. + (Compare with fwlen - the size of the final fw image.) + cb : function, optional + Callback function accepting two integers - the amount of compressed firmware sent thus + far, and the total length of the compressed firmware to send. + If passed, this function is invoked each time a fw chunk is successfully uploaded and + ack'd by the hw, to notify of upload progress. + Defaults to None, and nothing is called to report upload progress. + + Returns + ------- + bool + True if no errors were reported - on next restart the hw unit will attempt to boot the + new firmware. + """ + + # Compute the sha256 hash of the compressed file being uploaded + cmphasher = hashlib.sha256() + cmphasher.update(fwcmp) + cmphash = cmphasher.digest() + cmplen = len(fwcmp) + + # Initiate OTA + ota_method = 'ota' + params = {'fwsize': fwlen, + 'cmpsize': cmplen, + 'cmphash': cmphash} + + if patchlen is not None: + ota_method = 'ota_delta' + params['patchsize'] = patchlen + + result = self._jadeRpc(ota_method, params) + assert result is True + + # Write binary chunks + written = 0 + while written < cmplen: + remaining = cmplen - written + length = min(remaining, chunksize) + chunk = bytes(fwcmp[written:written + length]) + result = self._jadeRpc('ota_data', chunk) + assert result is True + written += length + + if (cb): + cb(written, cmplen) + + # All binary data uploaded + return self._jadeRpc('ota_complete') + + def run_remote_selfcheck(self): + """ + RPC call to run in-built tests. + NOTE: Only available in a DEBUG build of the firmware. + + Returns + ------- + bool + True on success. + """ + return self._jadeRpc('debug_selfcheck', long_timeout=True) + + def capture_image_data(self, check_qr=False): + """ + RPC call to capture raw image data from the camera. + See also scan_qr() below. + NOTE: Only available in a DEBUG build of the firmware. + + Parameters + ---------- + check_qr : bool, optional + If True only images which contain a valid qr code are captured and returned. + If False, any image is considered valid and is returned. + Defaults to False + + Returns + ------- + bytes + Raw image data from the camera framebuffer + """ + params = {'check_qr': check_qr} + return self._jadeRpc('debug_capture_image_data', params) + + def scan_qr(self, image): + """ + RPC call to scan a passed image and return any data extracted from any qr image. + Exercises the camera image capture, but ignores result and uses passed image instead. + See also capture_image_data() above. + NOTE: Only available in a DEBUG build of the firmware. + + Parameters + ---------- + image : bytes + The image data (as obtained from capture_image_data() above). + + Returns + ------- + bytes + String or byte data obtained from the image (via qr code) + """ + params = {'image': image} + return self._jadeRpc('debug_scan_qr', params) + + def clean_reset(self): + """ + RPC call to clean/reset memory and storage, as much as is practical. + NOTE: Only available in a DEBUG build of the firmware. + + Returns + ------- + bool + True on success. + """ + return self._jadeRpc('debug_clean_reset') + + def set_mnemonic(self, mnemonic, passphrase=None, temporary_wallet=False): + """ + RPC call to set the wallet mnemonic (in RAM only - flash storage is untouched). + NOTE: Only available in a DEBUG build of the firmware. + + Parameters + ---------- + mnemonic : str + The wallet mnemonic to set. + + passphrase : str, optional + Any bip39 passphrase to apply. + Defaults to None. + + temporary_wallet : bool, optional + Whether to treat this wallet/mnemonic as an 'Emergency Restore' temporary wallet, as + opposed to one successfully loaded from the flash storage. + NOTE: in either case the wallet is only set in RAM, and flash storage is not affected. + Defaults to False. + + Returns + ------- + bool + True on success. + """ + params = {'mnemonic': mnemonic, 'passphrase': passphrase, + 'temporary_wallet': temporary_wallet} + return self._jadeRpc('debug_set_mnemonic', params) + + def set_seed(self, seed): + """ + RPC call to set the wallet seed. + NOTE: Only available in a DEBUG build of the firmware. + NOTE: Setting a seed always sets a 'temporary' wallet. + + Parameters + ---------- + seed : bytes + The wallet seed to set as a temporary wallet (cannot be persisted in flash). + + Returns + ------- + bool + True on success. + """ + params = {'seed': seed} + return self._jadeRpc('debug_set_mnemonic', params) + + def set_pinserver(self, urlA=None, urlB=None, pubkey=None, cert=None): + """ + RPC call to explicitly set (override) the details of the blind pinserver used to + authenticate the PIN entered on the Jade unit. + This data is recorded in the hw flash, and returned to the caller when authenticating + (in auth_user(), below). + + Parameters + ---------- + urlA : str, optional + The primary url of the pinserver to use. + + urlB : str, optional + Any secondary url of the pinserver to use. + + pubkey : bytes, optional + The public key used to verify pinserver signed payloads. + + cert : bytes, optional + Any additional certificate required to verify the pinserver identity. + + Returns + ------- + bool + True on success. + """ + params = {} + if urlA is not None or urlB is not None: + params['urlA'] = urlA + params['urlB'] = urlB + if pubkey is not None: + params['pubkey'] = pubkey + if cert is not None: + params['certificate'] = cert + return self._jadeRpc('update_pinserver', params) + + def reset_pinserver(self, reset_details, reset_certificate): + """ + RPC call to reset any formerly overidden pinserver details to their defauts. + + Parameters + ---------- + reset_details : bool, optional + If set, any overidden urls and pubkey are reset to their defaults. + + reset_certificate : bool, optional + If set, any additional certificate is reset (to None). + + Returns + ------- + bool + True on success. + """ + params = {'reset_details': reset_details, + 'reset_certificate': reset_certificate} + return self._jadeRpc('update_pinserver', params) + + def auth_user(self, network, http_request_fn=None, epoch=None): + """ + RPC call to authenticate the user on the hw device, for using with the network provided. + + Parameters + ---------- + network : str + The name of the network intended for use - eg. 'mainnet', 'liquid', 'testnet' etc. + This is verified against the networks allowed on the hardware. + + http_request_fn : function, optional + Optional http-request function to pass http requests to the Jade pinserver. + Default behaviour is to use the '_http_request()' function which defers to the + 'requests' module. + If the 'reqests' module is not available, no default http-request function is created, + and one must be supplied here. + + epoch : int, optional + Current epoch value, in seconds. Defaults to int(time.time()) value. + + Returns + ------- + bool + True is returned immediately if the hw is already unlocked for use on the given network. + True if the PIN is entered and verified with the remote blind pinserver. + False if the PIN entered was incorrect. + """ + params = {'network': network, 'epoch': epoch if epoch is not None else int(time.time())} + return self._jadeRpc('auth_user', params, + http_request_fn=http_request_fn, + long_timeout=True) + + def register_otp(self, otp_name, otp_uri): + """ + RPC call to register a new OTP record on the hw device. + + Parameters + ---------- + otp_name : str + An identifying name for this OTP record + + otp_uri : str + The uri of this OTP record - must begin 'otpauth://' + + Returns + ------- + bool + True if the OTP uri was validated and persisted on the hw + """ + params = {'name': otp_name, 'uri': otp_uri} + return self._jadeRpc('register_otp', params) + + def get_otp_code(self, otp_name, value_override=None): + """ + RPC call to fetch a new OTP code from the hw device. + + Parameters + ---------- + otp_name : str + An identifying name for the OTP record to use + + value_override : int + An overriding HOTP counter or TOTP timestamp to use. + NOTE: Only available in a DEBUG build of the firmware. + + Returns + ------- + bool + True if the OTP uri was validated and persisted on the hw + """ + params = {'name': otp_name} + if value_override is not None: + params['override'] = value_override + return self._jadeRpc('get_otp_code', params) + + def get_xpub(self, network, path): + """ + RPC call to fetch an xpub for the given bip32 path for the given network. + + Parameters + ---------- + network : str + Network to which the xpub applies - eg. 'mainnet', 'liquid', 'testnet', etc. + + path : [int] + bip32 path for which the xpub should be generated. + + Returns + ------- + str + base58 encoded xpub + """ + params = {'network': network, 'path': path} + return self._jadeRpc('get_xpub', params) + + def get_registered_multisigs(self): + """ + RPC call to fetch brief summaries of any multisig wallets registered to this signer. + + Returns + ------- + dict + Brief description of registered multisigs, keyed by registration name. + Each entry contains keys: + variant - str, script type, eg. 'sh(wsh(multi(k)))' + sorted - boolean, whether bip67 key sorting is applied + threshold - int, number of signers required,N + num_signers - total number of signatories, M + master_blinding_key - 32-bytes, any liquid master blinding key for this wallet + """ + return self._jadeRpc('get_registered_multisigs') + + def register_multisig(self, network, multisig_name, variant, sorted_keys, threshold, signers, + master_blinding_key=None): + """ + RPC call to register a new multisig wallet, which must contain the hw signer. + A registration name is provided - if it already exists that record is overwritten. + + Parameters + ---------- + network : string + Network to which the multisig should apply - eg. 'mainnet', 'liquid', 'testnet', etc. + + multisig_name : string + Name to use to identify this multisig wallet registration record. + If a registration record exists with the name given, that record is overwritten. + + variant : str + The script type - one of 'sh(multi(k))', 'wsh(multi(k))', 'sh(wsh(multi(k)))' + + sorted_keys : bool + Whether this is a 'sortedmulti()' wallet - ie. whether to apply bip67 sorting to the + pubkeys when generating redeem scripts. + + threshold : int + Number of signers required. + + signers : [dict] + Description of signers - should include keys: + - 'fingerprint' - 4 bytes, origin fingerprint + - 'derivation' - [int], bip32 path from origin to signer xpub provided + - 'xpub' - str, base58 xpub of signer - will be verified for hw unit signer + - 'path' - [int], any fixed path to always apply after the xpub - usually empty. + + master_blinding_key : 32-bytes, optional + The master blinding key to use for this multisig wallet on liquid. + Optional, defaults to None. + Logically mandatory when 'network' indicates a liquid network and the Jade is to be + used to generate confidential addresses, blinding keys, blinding nonces, asset blinding + factors or output commitments. + + Returns + ------- + bool + True on success, implying the mutisig wallet can now be used. + """ + params = {'network': network, 'multisig_name': multisig_name, + 'descriptor': {'variant': variant, 'sorted': sorted_keys, + 'threshold': threshold, 'signers': signers, + 'master_blinding_key': master_blinding_key}} + return self._jadeRpc('register_multisig', params) + + def get_receive_address(self, *args, recovery_xpub=None, csv_blocks=0, + variant=None, multisig_name=None, confidential=None): + """ + RPC call to generate, show, and return an address for the given path. + The call has three forms. + + Parameters + ---------- + network: str + Network to which the address should apply - eg. 'mainnet', 'liquid', 'testnet', etc. + + Then either: + + 1. Blockstream Green (multisig shield) addresses + subaccount : int + Blockstream Green subaccount + + branch : int + Blockstream Green derivation branch + + pointer : int + Blockstream Green address pointer + + recovery_xpub : str, optional + xpub of recovery key for 2of3 subaccounts. Otherwise should be omitted. + Defaults to None (ie. not a 2of3 subaccount). + + csv_blocks : int, optional + Number of blocks to include in csv redeem script, if this is a csv-enabled account. + Otherwise should be omitted. + Defaults to 0 (ie. does not apply/not a csv-enabled account.) + + 2. Generic single-sig addresses + path: [int] + bip32 path for which the xpub should be generated. + + variant: str + The script type - one of 'pkh(k)', 'wpkh(k)', 'sh(wpkh(k))' + + 3. Generic multisig addresses + paths: [[int]] + bip32 path suffixes, one for each signer, applied as a suffix to the registered + signer path. Usually these path suffixes will all be identical. + + multisig_name : str + The name of the registered multisig wallet record used to generate the address. + + Returns + ------- + str + The address generated for the given parameters. + + """ + if multisig_name is not None: + assert len(args) == 2 + keys = ['network', 'paths', 'multisig_name'] + args += (multisig_name,) + elif variant is not None: + assert len(args) == 2 + keys = ['network', 'path', 'variant'] + args += (variant,) + else: + assert len(args) == 4 + keys = ['network', 'subaccount', 'branch', 'pointer', 'recovery_xpub', 'csv_blocks'] + args += (recovery_xpub, csv_blocks) + + params = dict(zip(keys, args)) + if confidential is not None: + params['confidential'] = confidential + + return self._jadeRpc('get_receive_address', params) + + def sign_message(self, path, message, use_ae_signatures=False, + ae_host_commitment=None, ae_host_entropy=None): + """ + RPC call to format and sign the given message, using the given bip32 path. + Supports RFC6979 and anti-exfil signatures. + + Parameters + ---------- + path : [int] + bip32 path for which the signature should be generated. + + message : str + Message string to format and sign. + + ae_host_commitment : 32-bytes, optional + The host-commitment to use for Antil-Exfil signatures + + ae_host_entropy : 32-bytes, optional + The host-entropy to use for Antil-Exfil signatures + + Returns + ------- + 1. Legacy/RFC6979 signatures + str + base64-encoded signature + + 2. Anti-exfil signatures + (bytes, str) + signer-commitment, base64-encoded signature + """ + if use_ae_signatures: + # Anti-exfil protocol: + # We send the signing request and receive the signer-commitment in + # reply once the user confirms. + # We can then request the actual signature passing the ae-entropy. + params = {'path': path, 'message': message, 'ae_host_commitment': ae_host_commitment} + signer_commitment = self._jadeRpc('sign_message', params) + params = {'ae_host_entropy': ae_host_entropy} + signature = self._jadeRpc('get_signature', params) + return signer_commitment, signature + else: + # Standard EC signature, simple case + params = {'path': path, 'message': message} + return self._jadeRpc('sign_message', params) + + def get_identity_pubkey(self, identity, curve, key_type, index=0): + """ + RPC call to fetch a pubkey for the given identity (slip13/slip17). + NOTE: this api returns an uncompressed public key + + Parameters + ---------- + identity : str + Identity string to format and sign. For example ssh://satoshi@bitcoin.org + + curve : str + Name of curve to use - currently only 'nist256p1' is supported + + key_type : str + Key derivation type - must be either 'slip-0013' for an identity pubkey, or 'slip-0017' + for an ecdh pubkey. + + index : int, optional + Index number (if require multiple keys/sigs per identity) + Defaults to 0 + + Returns + ------- + 65-bytes + Uncompressed public key for the given identity and index. + Consistent with 'sign_identity' or 'get_identity_shared_key', depending on the + 'key_type'. + + """ + params = {'identity': identity, 'curve': curve, 'type': key_type, 'index': index} + return self._jadeRpc('get_identity_pubkey', params) + + def get_identity_shared_key(self, identity, curve, their_pubkey, index=0): + """ + RPC call to fetch a SLIP-0017 shared ecdh key for the identity and counterparty public key. + NOTE: this api takes an uncompressed public key + + Parameters + ---------- + identity : str + Identity string to format and sign. For example ssh://satoshi@bitcoin.org + + curve : str + Name of curve to use - currently only 'nist256p1' is supported + + their_pubkey : 65-bytes + The counterparty's uncompressed public key + + index : int, optional + Index number (if require multiple keys/sigs per identity) + Defaults to 0 + + Returns + ------- + 32-bytes + The shared ecdh key for the given identity and cpty public key + Consistent with 'get_identity_pubkey' with 'key_type=slip-0017' + """ + params = {'identity': identity, 'curve': curve, 'index': index, + 'their_pubkey': their_pubkey} + return self._jadeRpc('get_identity_shared_key', params) + + def sign_identity(self, identity, curve, challenge, index=0): + """ + RPC call to authenticate the given identity through a challenge. + Supports RFC6979. + Returns the signature and the associated SLIP-0013 pubkey + NOTE: this api returns an uncompressed public key + + Parameters + ---------- + identity : str + Identity string to format and sign. For example ssh://satoshi@bitcoin.org + + curve : str + Name of curve to use - currently only 'nist256p1' is supported + + challenge : bytes + Challenge bytes to sign + + index : int, optional + Index number (if require multiple keys/sigs per identity) + Defaults to 0 + + Returns + ------- + dict + Contains keys: + pubkey - 65-bytes, the uncompressed SLIP-0013 public key, consistent with + 'get_identity_pubkey' with 'key_type=slip-0013' + signature - 65-bytes, RFC6979 deterministic signature, prefixed with 0x00 + """ + params = {'identity': identity, 'curve': curve, 'index': index, 'challenge': challenge} + return self._jadeRpc('sign_identity', params) + + def get_master_blinding_key(self): + """ + RPC call to fetch the master (SLIP-077) blinding key for the hw signer. + NOTE: the master blinding key of any registered multisig wallets can be obtained from + the result of `get_registered_multisigs()`. + + Returns + ------- + 32-bytes + SLIP-077 master blinding key + """ + return self._jadeRpc('get_master_blinding_key') + + def get_blinding_key(self, script, multisig_name=None): + """ + RPC call to fetch the public blinding key for the hw signer. + + Parameters + ---------- + script : bytes + The script for which the public blinding key is required. + + multisig_name : str, optional + The name of any registered multisig wallet for which to fetch the blinding key. + Defaults to None + + Returns + ------- + 33-bytes + Public blinding key for the passed script. + """ + params = {'script': script, 'multisig_name': multisig_name} + return self._jadeRpc('get_blinding_key', params) + + def get_shared_nonce(self, script, their_pubkey, include_pubkey=False, multisig_name=None): + """ + RPC call to get the shared secret to unblind a tx, given the receiving script and + the pubkey of the sender (sometimes called "blinding nonce" in Liquid). + Optionally fetch the hw signer's public blinding key also. + + Parameters + ---------- + script : bytes + The script for which the blinding nonce is required. + + their_pubkey : 33-bytes + The counterparty public key. + + include_pubkey : bool, optional + Whether to also return the wallet's public blinding key. + Defaults to False. + + multisig_name : str, optional + The name of any registered multisig wallet for which to fetch the blinding nonce. + Defaults to None + + Returns + ------- + 1. include_pubkey is False + 33-bytes + Public blinding nonce for the passed script and counterparty public key. + + 2. include_pubkey is True + dict + Contains keys: + shared_nonce - 32-bytes, public blinding nonce for the passed script as above. + blinding_key - 33-bytes, public blinding key for the passed script. + """ + params = {'script': script, 'their_pubkey': their_pubkey, + 'include_pubkey': include_pubkey, 'multisig_name': multisig_name} + return self._jadeRpc('get_shared_nonce', params) + + def get_blinding_factor(self, hash_prevouts, output_index, bftype, multisig_name=None): + """ + RPC call to get a deterministic "trusted" blinding factor to blind an output. + Normally the blinding factors are generated and returned in the `get_commitments` call, + but for the last output the vbf must be generated on the host, so this call allows the + host to get a valid abf to compute the generator and then the "final" vbf. + Nonetheless, this call is kept generic, and can also generate vbfs, hence the "bftype" + parameter. + + Parameters + ---------- + + hash_prevouts : 32-bytes + This value is computed as specified in bip143. + It is verified immediately since at this point Jade doesn't have the tx in question. + It will be checked later during `sign_liquid_tx()`. + + output_index : int + The index of the output we are trying to blind + + bftype : str + Can be eitehr "ASSET" or "VALUE", to generate abfs or vbfs. + + multisig_name : str, optional + The name of any registered multisig wallet for which to fetch the blinding factor. + Defaults to None + + Returns + ------- + 32-bytes + The requested blinding factor + """ + params = {'hash_prevouts': hash_prevouts, + 'output_index': output_index, + 'type': bftype, + 'multisig_name': multisig_name} + return self._jadeRpc('get_blinding_factor', params) + + def get_commitments(self, + asset_id, + value, + hash_prevouts, + output_index, + vbf=None, + multisig_name=None): + """ + RPC call to generate deterministic blinding factors and commitments for a given output. + Can optionally get a "custom" VBF, normally used for the last input where the vbf is not + computed here, but generated on the host according to all the other values. + The commitments generated here should be passed back into `sign_liquid_tx()`. + + Parameters + ---------- + asset_id : 32-bytes + asset_id as usually displayed - ie. reversed compared to network/consensus order + + value : int + value in 'satoshi' or equivalent atomic integral unit + + hash_prevouts : 32-bytes + This value is computed as specified in bip143. + It is verified immediately since at this point Jade doesn't have the tx in question. + It will be checked later during `sign_liquid_tx()`. + + output_index : int + The index of the output we are trying to blind + + vbf : 32-bytes, optional + The vbf to use, in preference to deterministically generating one in this call. + + multisig_name : str, optional + The name of any registered multisig wallet for which to fetch the blinding factor. + Defaults to None + + Returns + ------- + dict + Containing the following the blinding factors and output commitments. + """ + params = {'asset_id': asset_id, + 'value': value, + 'hash_prevouts': hash_prevouts, + 'output_index': output_index, + 'vbf': vbf, + 'multisig_name': multisig_name} + return self._jadeRpc('get_commitments', params) + + def _send_tx_inputs(self, base_id, inputs, use_ae_signatures): + """ + Helper call to send the tx inputs to Jade for signing. + Handles legacy RFC6979 signatures, as well as the Anti-Exfil protocol. + + Parameters + ---------- + base_id : int + The ids of the messages sent will be increments from this base id. + + inputs : [dict] + The tx inputs - see `sign_tx()` / `sign_liquid_tx()` for details. + + use_ae_signatures : bool + Whether to use the anti-exfil protocol to generate the signatures + + Returns + ------- + 1. if use_ae_signatures is False + [bytes] + An array of signatures corresponding to the array of inputs passed. + The signatures are in DER format with the sighash appended. + 'None' placeholder elements are used for inputs not requiring a signature. + + 2. if use_ae_signatures is True + [(32-bytes, bytes)] + An array of pairs of signer-commitments and signatures corresponding to the inputs. + The signatures are in DER format with the sighash appended. + (None, None) placeholder elements are used for inputs not requiring a signature. + """ + if use_ae_signatures: + # Anti-exfil protocol: + # We send one message per input (which includes host-commitment *but + # not* the host entropy) and receive the signer-commitment in reply. + # Once all n input messages are sent, we can request the actual signatures + # (as the user has a chance to confirm/cancel at this point). + # We request the signatures passing the ae-entropy for each one. + # Send inputs one at a time, receiving 'signer-commitment' in reply + signer_commitments = [] + host_ae_entropy_values = [] + for txinput in inputs: + # ae-protocol - do not send the host entropy immediately + txinput = txinput.copy() # shallow copy + host_ae_entropy_values.append(txinput.pop('ae_host_entropy', None)) + + base_id += 1 + input_id = str(base_id) + reply = self._jadeRpc('tx_input', txinput, input_id) + signer_commitments.append(reply) + + # Request the signatures one at a time, sending the entropy + signatures = [] + for (i, host_ae_entropy) in enumerate(host_ae_entropy_values, 1): + base_id += 1 + sig_id = str(base_id) + params = {'ae_host_entropy': host_ae_entropy} + reply = self._jadeRpc('get_signature', params, sig_id) + signatures.append(reply) + + assert len(signatures) == len(inputs) + return list(zip(signer_commitments, signatures)) + else: + # Legacy protocol: + # We send one message per input - without expecting replies. + # Once all n input messages are sent, the hw then sends all n replies + # (as the user has a chance to confirm/cancel at this point). + # Then receive all n replies for the n signatures. + # NOTE: *NOT* a sequence of n blocking rpc calls. + # NOTE: at some point this flow should be removed in favour of the one + # above, albeit without passing anti-exfil entropy or commitment data. + + # Send all n inputs + requests = [] + for txinput in inputs: + base_id += 1 + msg_id = str(base_id) + request = self.jade.build_request(msg_id, 'tx_input', txinput) + self.jade.write_request(request) + requests.append(request) + time.sleep(0.1) + + # Receive all n signatures + signatures = [] + for request in requests: + reply = self.jade.read_response() + self.jade.validate_reply(request, reply) + signature = self._get_result_or_raise_error(reply) + signatures.append(signature) + + assert len(signatures) == len(inputs) + return signatures + + def sign_liquid_tx(self, network, txn, inputs, commitments, change, use_ae_signatures=False, + asset_info=None): + """ + RPC call to sign a liquid transaction. + + Parameters + ---------- + network : str + Network to which the address should apply - eg. 'liquid', 'liquid-testnet', etc. + + txn : bytes + The transaction to sign + + inputs : [dict] + The tx inputs. Should contain keys: + is_witness, bool - whether this is a segwit input + value_commitment, 33-bytes - The value commitment of ths input + + These are only required if signing this input: + script, bytes- the redeem script + path, [int] - the bip32 path to sign with + + These are only required for Anti-Exfil signatures: + ae_host_commitment, 32-bytes - The host-commitment for Anti-Exfil signatures + ae_host_entropy, 32-bytes - The host-entropy for Anti-Exfil signatures + + commitments : [dict] + An array sized for the number of outputs. + Unblinded outputs should have a 'null' placeholder element. + The commitments as retrieved from `get_commitments()`, with the addition of: + 'blinding_key', - the output's public blinding key + (as retrieved from `get_blinding_key()`) + + change : [dict] + An array sized for the number of outputs. + Outputs which are not change should have a 'null' placeholder element. + Change elements with data will be automatically verified by Jade, and not by the user. + Populated elements should contain sufficient data to generate the change address. + See `get_receive_address()` + + use_ae_signatures : bool, optional + Whether to use the anti-exfil protocol to generate the signatures. + Defaults to False. + + asset_info : [dict] + Any asset-registry data relevant to the assets being transacted, such that Jade can + display a meaningful name, issuer, ticker etc. rather than just asset-id. + At the very least must contain 'asset_id', 'contract' and 'issuance_prevout' items, + exactly as in the registry data. NOTE: asset_info for the network policy-asset is + not required. + Defaults to None. + + Returns + ------- + 1. if use_ae_signatures is False + [bytes] + An array of signatures corresponding to the array of inputs passed. + The signatures are in DER format with the sighash appended. + 'None' placeholder elements are used for inputs not requiring a signature. + + 2. if use_ae_signatures is True + [(32-bytes, bytes)] + An array of pairs of signer-commitments and signatures corresponding to the inputs. + The signatures are in DER format with the sighash appended. + (None, None) placeholder elements are used for inputs not requiring a signature. + """ + # 1st message contains txn and number of inputs we are going to send. + # Reply ok if that corresponds to the expected number of inputs (n). + base_id = 100 * random.randint(1000, 9999) + params = {'network': network, + 'txn': txn, + 'num_inputs': len(inputs), + 'trusted_commitments': commitments, + 'use_ae_signatures': use_ae_signatures, + 'change': change, + 'asset_info': asset_info} + + reply = self._jadeRpc('sign_liquid_tx', params, str(base_id)) + assert reply + + # Send inputs and receive signatures + return self._send_tx_inputs(base_id, inputs, use_ae_signatures) + + def sign_tx(self, network, txn, inputs, change, use_ae_signatures=False): + """ + RPC call to sign a btc transaction. + + Parameters + ---------- + network : str + Network to which the address should apply - eg. 'mainnet', 'testnet', etc. + + txn : bytes + The transaction to sign + + inputs : [dict] + The tx inputs. Should contain keys: + is_witness, bool - whether this is a segwit input + + These are only required if signing this input: + script, bytes- the redeem script + path, [int] - the bip32 path to sign with + + One of these is required: + input_tx, bytes - The prior transaction which created the utxo of this input + satoshi, int - The satoshi amount of this input - can be used in place of + 'input_tx' for a tx with a single segwit input + + These are only required for Anti-Exfil signatures: + ae_host_commitment, 32-bytes - The host-commitment for Anti-Exfil signatures + ae_host_entropy, 32-bytes - The host-entropy for Anti-Exfil signatures + + change : [dict] + An array sized for the number of outputs. + Outputs which are not change should have a 'null' placeholder element. + Change elements with data will be automatically verified by Jade, and not by the user. + Populated elements should contain sufficient data to generate the change address. + See `get_receive_address()` + + use_ae_signatures : bool + Whether to use the anti-exfil protocol to generate the signatures + + Returns + ------- + 1. if use_ae_signatures is False + [bytes] + An array of signatures corresponding to the array of inputs passed. + The signatures are in DER format with the sighash appended. + 'None' placeholder elements are used for inputs not requiring a signature. + + 2. if use_ae_signatures is True + [(32-bytes, bytes)] + An array of pairs of signer-commitments and signatures corresponding to the inputs. + The signatures are in DER format with the sighash appended. + (None, None) placeholder elements are used for inputs not requiring a signature. + """ + # 1st message contains txn and number of inputs we are going to send. + # Reply ok if that corresponds to the expected number of inputs (n). + base_id = 100 * random.randint(1000, 9999) + params = {'network': network, + 'txn': txn, + 'num_inputs': len(inputs), + 'use_ae_signatures': use_ae_signatures, + 'change': change} + + reply = self._jadeRpc('sign_tx', params, str(base_id)) + assert reply + + # Send inputs and receive signatures + return self._send_tx_inputs(base_id, inputs, use_ae_signatures) + + +class JadeInterface: + """ + Mid-level interface to Jade + Wraps either a serial or a ble connection + Calls to send and receive bytes and cbor messages over the interface. + + Either: + a) use wrapped with JadeAPI + (recommended) + or: + b) use with JadeInterface.create_[serial|ble]() as jade: + ... + or: + c) use JadeInterface.create_[serial|ble], then call connect() before + using, and disconnect() when finished + (caveat cranium) + or: + d) use ctor to wrap existing low-level implementation instance + (caveat cranium) + """ + + def __init__(self, impl): + assert impl is not None + self.impl = impl + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc, tb): + if (exc_type): + logger.info("Exception causing JadeInterface context exit.") + logger.info(exc_type) + logger.info(exc) + traceback.print_tb(tb) + self.disconnect(exc_type is not None) + + @staticmethod + def create_serial(device=None, baud=None, timeout=None): + """ + Create a JadeInterface object using the serial interface described. + + Parameters + ---------- + device : str, optional + The device identifier for the serial device. + Underlying implementation will default (to /dev/ttyUSB0) + + baud : int, optional + The communication baud rate. + Underlying implementation will default (to 115200) + + timeout : int, optional + The serial read timeout when awaiting messages. + Underlying implementation will default (to 120s) + + Returns + ------- + JadeInterface + Inerface object configured to use given serial parameters. + NOTE: the instance has not yet tried to contact the hw + - caller must call 'connect()' before trying to use the Jade. + """ + if device and JadeTCPImpl.isSupportedDevice(device): + impl = JadeTCPImpl(device, timeout or DEFAULT_SERIAL_TIMEOUT) + else: + impl = JadeSerialImpl(device or DEFAULT_SERIAL_DEVICE, + baud or DEFAULT_BAUD_RATE, + timeout or DEFAULT_SERIAL_TIMEOUT) + return JadeInterface(impl) + + @staticmethod + def create_ble(device_name=None, serial_number=None, + scan_timeout=None, loop=None): + """ + Create a JadeInterface object using the BLE interface described. + NOTE: raises JadeError if BLE dependencies not installed. + + Parameters + ---------- + device_name : str, optional + The device name of the desired BLE device. + Underlying implementation will default (to 'Jade') + + serial_number : int, optional + The serial number of the desired BLE device + - used to disambiguate multiple beacons with the same 'device name' + Underlying implementation will connect to the first beacon it scans + with the matching 'device name'. + + scan_timeout : int, optional + The timeout when scanning for devices which match the device name/serial number. + Underlying implementation will default (to 60s) + + loop : optional + The asynchio event loop to use, if required. + Underlying implementation will default (to asyncio.get_event_loop()) + + Returns + ------- + JadeInterface + Inerface object configured to use given BLE parameters. + NOTE: the instance has not yet tried to contact the hw + - caller must call 'connect()' before trying to use the Jade. + + Raises + ------ + JadeError if BLE backend not available (ie. BLE dependencies not installed) + """ + this_module = sys.modules[__name__] + if not hasattr(this_module, "JadeBleImpl"): + raise JadeError(1, "BLE support not installed", None) + + impl = JadeBleImpl(device_name or DEFAULT_BLE_DEVICE_NAME, + serial_number or DEFAULT_BLE_SERIAL_NUMBER, + scan_timeout or DEFAULT_BLE_SCAN_TIMEOUT, + loop=loop) + return JadeInterface(impl) + + def connect(self): + """ + Try to connect the underlying transport interface (eg. serial, ble, etc.) + Raises an exception on failure. + """ + self.impl.connect() + + def disconnect(self, drain=False): + """ + Disconnect the underlying transport (eg. serial, ble, etc.) + + Parameters + ---------- + drain : bool, optional + When true log any/all remaining messages/data, otherwise silently discard. + NOTE: can prevent disconnection if data is arriving constantly. + Defaults to False. + """ + if drain: + self.drain() + + self.impl.disconnect() + + def drain(self): + """ + Log any/all outstanding messages/data. + NOTE: can run indefinitely if data is arriving constantly. + """ + logger.warn("Draining interface...") + drained = bytearray() + finished = False + + while not finished: + byte_ = self.impl.read(1) + drained.extend(byte_) + finished = byte_ == b'' + + if finished or byte_ == b'\n' or len(drained) > 256: + try: + device_logger.warn(drained.decode('utf-8')) + except Exception as e: + # Dump the bytes raw and as hex if decoding as utf-8 failed + device_logger.warn("Raw:") + device_logger.warn(drained) + device_logger.warn("----") + device_logger.warn("Hex dump:") + device_logger.warn(drained.hex()) + + # Clear and loop to continue collecting + drained.clear() + + @staticmethod + def build_request(input_id, method, params=None): + """ + Build a request dict from passed parameters + + Parameters + ---------- + input_id : str + The id of the request message to construct + + method : str + rpc method to invoke + + params : dict, optional + any parameters to pass to the rpc method + Defaults to None. + + Returns + ------- + dict + The request object as a dict + """ + request = {"method": method, "id": input_id} + if params is not None: + request["params"] = params + return request + + @staticmethod + def serialise_cbor_request(request): + """ + Method to format a request dict as a cbor message + + Parameters + ---------- + request : dict + The request dict + + Returns + ------- + bytes + The request formatted as cbor message bytes + """ + dump = cbor.dumps(request) + len_dump = len(dump) + if 'method' in request and 'ota_data' in request['method']: + msg = 'Sending ota_data message {} as cbor of size {}'.format(request['id'], len_dump) + logger.info(msg) + else: + logger.info('Sending: {} as cbor of size {}'.format(_hexlify(request), len_dump)) + return dump + + def write(self, bytes_): + """ + Write bytes over the underlying interface + + Parameters + ---------- + bytes_ : bytes + The bytes to write + + Returns + ------- + int + The number of bytes written + """ + logger.debug("Sending: {} bytes".format(len(bytes_))) + wrote = self.impl.write(bytes_) + logger.debug("Sent: {} bytes".format(len(bytes_))) + return wrote + + def write_request(self, request): + """ + Write a request dict over the underlying interface, formatted as cbor. + + Parameters + ---------- + request : dict + The request dict to write + """ + msg = self.serialise_cbor_request(request) + written = 0 + while written < len(msg): + written += self.write(msg[written:]) + + def read(self, n): + """ + Try to read bytes from the underlying interface. + + Returns + ------- + bytes + The bytes received + """ + logger.debug("Reading {} bytes...".format(n)) + bytes_ = self.impl.read(n) + logger.debug("Received: {} bytes".format(len(bytes_))) + return bytes_ + + def read_cbor_message(self): + """ + Try to read a single cbor (response) message from the underlying interface. + Respects the any read timeout. + If any 'log' messages are received, logs them locally at the nearest corresponding level + and awaits the next message. Returns when it receives what appears to be a reply message. + + Returns + ------- + dict + The message received, as a dict + """ + while True: + # 'self' is sufficiently 'file-like' to act as a load source. + # Throws EOFError on end of stream/timeout/lost-connection etc. + message = cbor.load(self) + + if isinstance(message, collections.abc.Mapping): + # A message response (to a prior request) + if 'id' in message: + logger.info("Received msg: {}".format(_hexlify(message))) + return message + + # A log message - handle as normal + if 'log' in message: + response = message['log'] + log_method = device_logger.error + try: + response = message['log'].decode("utf-8") + log_methods = { + 'E': device_logger.error, + 'W': device_logger.warn, + 'I': device_logger.info, + 'D': device_logger.debug, + 'V': device_logger.debug, + } + if len(response) > 1 and response[1] == ' ': + lvl = response[0] + log_method = log_methods.get(lvl, device_logger.error) + except Exception as e: + logger.error('Error processing log message: {}'.format(e)) + log_method('>> {}'.format(response)) + continue + + # Unknown/unhandled/unexpected message + logger.error("Unhandled message received") + device_logger.error(message) + + def read_response(self, long_timeout=False): + """ + Try to read a single cbor (response) message from the underlying interface. + If any 'log' messages are received, logs them locally at the nearest corresponding level + and awaits the next message. Returns when it receives what appears to be a reply message. + If `long_timeout` is false, any read-timeout is respected. If True, the call will block + indefinitely awaiting a response message. + + Parameters + ---------- + long_timeout : bool + Whether to wait indefinitely for the next (response) message. + + Returns + ------- + dict + The message received, as a dict + """ + while True: + try: + return self.read_cbor_message() + except EOFError as e: + if not long_timeout: + raise + + @staticmethod + def validate_reply(request, reply): + """ + Helper to minimally validate a reply message, in the context of a request. + Asserts if the reply does contain the expected minimal fields. + """ + assert isinstance(reply, dict) and 'id' in reply + assert ('result' in reply) != ('error' in reply) + assert reply['id'] == request['id'] or \ + reply['id'] == '00' and 'error' in reply + + def make_rpc_call(self, request, long_timeout=False): + """ + Method to send a request over the underlying interface, and await a response. + The request is minimally validated before it is sent, and the response is simialrly + validated before being returned. + Any read-timeout is respected unless 'long_timeout' is passed, in which case the call + blocks indefinitely awaiting a response. + + Parameters + ---------- + long_timeout : bool + Whether to wait indefinitely for the response. + + Returns + ------- + dict + The (minimally validated) response message received, as a dict + """ + # Write outgoing request message + assert isinstance(request, dict) + assert 'id' in request and len(request['id']) > 0 + assert 'method' in request and len(request['method']) > 0 + assert len(request['id']) < 16 and len(request['method']) < 32 + self.write_request(request) + + # Read and validate incoming message + reply = self.read_response(long_timeout) + self.validate_reply(request, reply) + + return reply diff --git a/hwilib/devices/jadepy/jade_error.py b/hwilib/devices/jadepy/jade_error.py new file mode 100644 index 000000000..980ae5d90 --- /dev/null +++ b/hwilib/devices/jadepy/jade_error.py @@ -0,0 +1,24 @@ +class JadeError(Exception): + # RPC error codes + INVALID_REQUEST = -32600 + UNKNOWN_METHOD = -32601 + BAD_PARAMETERS = -32602 + INTERNAL_ERROR = -32603 + + # Implementation specific error codes: -32000 to -32099 + USER_CANCELLED = -32000 + PROTOCOL_ERROR = -32001 + HW_LOCKED = -32002 + NETWORK_MISMATCH = -32003 + + def __init__(self, code, message, data): + self.code = code + self.message = message + self.data = data + + def __repr__(self): + return "JadeError: " + str(self.code) + " - " + self.message \ + + " (Data: " + repr(self.data) + ")" + + def __str__(self): + return repr(self) diff --git a/hwilib/devices/jadepy/jade_serial.py b/hwilib/devices/jadepy/jade_serial.py new file mode 100644 index 000000000..0a6fc2be5 --- /dev/null +++ b/hwilib/devices/jadepy/jade_serial.py @@ -0,0 +1,64 @@ +import serial +import logging + + +logger = logging.getLogger('jade.serial') + + +# +# Low-level Serial backend interface to Jade +# Calls to send and receive bytes over the interface. +# Intended for use via JadeInterface wrapper. +# +# Either: +# a) use via JadeInterface.create_serial() (see JadeInterface) +# (recommended) +# or: +# b) use JadeSerialImpl() directly, and call connect() before +# using, and disconnect() when finished, +# (caveat cranium) +# +class JadeSerialImpl: + def __init__(self, device, baud, timeout): + self.device = device + self.baud = baud + self.timeout = timeout + self.ser = None + + def connect(self): + assert self.ser is None + + logger.info('Connecting to {} at {}'.format(self.device, self.baud)) + self.ser = serial.Serial(self.device, self.baud, + timeout=self.timeout, + write_timeout=self.timeout) + assert self.ser is not None + + if not self.ser.is_open: + self.ser.open() + + # Ensure RTS and DTR are not set (as this can cause the hw to reboot) + self.ser.setRTS(False) + self.ser.setDTR(False) + + logger.info('Connected') + + def disconnect(self): + assert self.ser is not None + + # Ensure RTS and DTR are not set (as this can cause the hw to reboot) + # and then close the connection + self.ser.setRTS(False) + self.ser.setDTR(False) + self.ser.close() + + # Reset state + self.ser = None + + def write(self, bytes_): + assert self.ser is not None + return self.ser.write(bytes_) + + def read(self, n): + assert self.ser is not None + return self.ser.read(n) diff --git a/hwilib/devices/jadepy/jade_tcp.py b/hwilib/devices/jadepy/jade_tcp.py new file mode 100644 index 000000000..a76d5001d --- /dev/null +++ b/hwilib/devices/jadepy/jade_tcp.py @@ -0,0 +1,62 @@ +import socket +import logging + + +logger = logging.getLogger('jade.tcp') + + +# +# Low-level Serial-via-TCP backend interface to Jade +# Calls to send and receive bytes over the interface. +# Intended for use via JadeInterface wrapper. +# +# Either: +# a) use via JadeInterface.create_serial() (see JadeInterface) +# (recommended) +# or: +# b) use JadeTCPImpl() directly, and call connect() before +# using, and disconnect() when finished, +# (caveat cranium) +# +class JadeTCPImpl: + PROTOCOL_PREFIX = 'tcp:' + + @classmethod + def isSupportedDevice(cls, device): + return device is not None and device.startswith(cls.PROTOCOL_PREFIX) + + def __init__(self, device, timeout): + assert self.isSupportedDevice(device) + self.device = device + self.timeout = timeout + self.tcp_sock = None + + def connect(self): + assert self.isSupportedDevice(self.device) + assert self.tcp_sock is None + + logger.info('Connecting to {}'.format(self.device)) + self.tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.tcp_sock.settimeout(self.timeout) + + url = self.device[len(self.PROTOCOL_PREFIX):].split(':') + self.tcp_sock.connect((url[0], int(url[1]))) + assert self.tcp_sock is not None + + self.tcp_sock.__enter__() + logger.info('Connected') + + def disconnect(self): + assert self.tcp_sock is not None + self.tcp_sock.__exit__() + + # Reset state + self.tcp_sock = None + + def write(self, bytes_): + assert self.tcp_sock is not None + return self.tcp_sock.send(bytes_) + + def read(self, n): + assert self.tcp_sock is not None + return self.tcp_sock.recv(n) diff --git a/hwilib/devices/keepkey.py b/hwilib/devices/keepkey.py index 6de1ccc36..894dca685 100644 --- a/hwilib/devices/keepkey.py +++ b/hwilib/devices/keepkey.py @@ -1,21 +1,183 @@ -# KeepKey interaction script +""" +Keepkey +******* +""" -from ..errors import DEVICE_NOT_INITIALIZED, DeviceNotReadyError, common_err_msgs, handle_errors -from .trezorlib.transport import enumerate_devices -from .trezor import TrezorClient -from ..base58 import get_xpub_fingerprint_hex +from ..common import Chain +from ..errors import ( + DEVICE_NOT_INITIALIZED, + DeviceNotReadyError, + common_err_msgs, + handle_errors, +) +from .trezorlib import protobuf +from .trezorlib.transport import ( + hid, + udp, + webusb, +) +from .trezor import TrezorClient, HID_IDS, WEBUSB_IDS +from .trezorlib.mapping import DEFAULT_MAPPING +from .trezorlib.messages import ( + DebugLinkState, + Features, + ResetDevice, +) +from .trezorlib.models import TrezorModel + +from typing import ( + Any, + Dict, + List, + Optional, +) py_enumerate = enumerate # Need to use the enumerate built-in but there's another function already named that +KEEPKEY_HID_IDS = {(0x2B24, 0x0001)} +KEEPKEY_WEBUSB_IDS = {(0x2B24, 0x0002)} +KEEPKEY_SIMULATOR_PATH = '127.0.0.1:11044' + +HID_IDS.update(KEEPKEY_HID_IDS) +WEBUSB_IDS.update(KEEPKEY_WEBUSB_IDS) + + +class KeepkeyFeatures(Features): # type: ignore + MESSAGE_WIRE_TYPE = 17 + FIELDS = { + 1: protobuf.Field("vendor", "string", repeated=False, required=False), + 2: protobuf.Field("major_version", "uint32", repeated=False, required=True), + 3: protobuf.Field("minor_version", "uint32", repeated=False, required=True), + 4: protobuf.Field("patch_version", "uint32", repeated=False, required=True), + 5: protobuf.Field("bootloader_mode", "bool", repeated=False, required=False), + 6: protobuf.Field("device_id", "string", repeated=False, required=False), + 7: protobuf.Field("pin_protection", "bool", repeated=False, required=False), + 8: protobuf.Field("passphrase_protection", "bool", repeated=False, required=False), + 9: protobuf.Field("language", "string", repeated=False, required=False), + 10: protobuf.Field("label", "string", repeated=False, required=False), + 12: protobuf.Field("initialized", "bool", repeated=False, required=False), + 13: protobuf.Field("revision", "bytes", repeated=False, required=False), + 14: protobuf.Field("bootloader_hash", "bytes", repeated=False, required=False), + 15: protobuf.Field("imported", "bool", repeated=False, required=False), + 16: protobuf.Field("unlocked", "bool", repeated=False, required=False), + 17: protobuf.Field("passphrase_cached", "bool", repeated=False, required=False), + 21: protobuf.Field("model", "string", repeated=False, required=False), + 22: protobuf.Field("firmware_variant", "string", repeated=False, required=False), + 23: protobuf.Field("firmware_hash", "bytes", repeated=False, required=False), + 24: protobuf.Field("no_backup", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + firmware_variant: Optional[str] = None, + firmware_hash: Optional[bytes] = None, + passphrase_cached: Optional[bool] = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.passphrase_cached = passphrase_cached + self.firmware_variant = firmware_variant + self.firmware_hash = firmware_hash + + +class KeepkeyResetDevice(ResetDevice): # type: ignore + MESSAGE_WIRE_TYPE = 14 + FIELDS = { + 1: protobuf.Field("display_random", "bool", repeated=False, required=False), + 2: protobuf.Field("strength", "uint32", repeated=False, required=False), + 3: protobuf.Field("passphrase_protection", "bool", repeated=False, required=False), + 4: protobuf.Field("pin_protection", "bool", repeated=False, required=False), + 5: protobuf.Field("language", "string", repeated=False, required=False), + 6: protobuf.Field("label", "string", repeated=False, required=False), + 7: protobuf.Field("no_backup", "bool", repeated=False, required=False), + 8: protobuf.Field("auto_lock_delay_ms", "uint32", repeated=False, required=False), + 9: protobuf.Field("u2f_counter", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + auto_lock_delay_ms: Optional[int] = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.auto_lock_delay_ms = auto_lock_delay_ms + + +class KeepkeyDebugLinkState(DebugLinkState): # type: ignore + MESSAGE_WIRE_TYPE = 102 + FIELDS = { + 1: protobuf.Field("layout", "bytes", repeated=False, required=False), + 2: protobuf.Field("pin", "string", repeated=False, required=False), + 3: protobuf.Field("matrix", "string", repeated=False, required=False), + 4: protobuf.Field("mnemonic_secret", "bytes", repeated=False, required=False), + 5: protobuf.Field("node", "HDNodeType", repeated=False, required=False), + 6: protobuf.Field("passphrase_protection", "bool", repeated=False, required=False), + 7: protobuf.Field("reset_word", "string", repeated=False, required=False), + 8: protobuf.Field("reset_entropy", "bytes", repeated=False, required=False), + 9: protobuf.Field("recovery_fake_word", "string", repeated=False, required=False), + 10: protobuf.Field("recovery_word_pos", "uint32", repeated=False, required=False), + 11: protobuf.Field("recovery_cipher", "string", repeated=False, required=False), + 12: protobuf.Field("recovery_auto_completed_word", "string", repeated=False, required=False), + 13: protobuf.Field("firmware_hash", "bytes", repeated=False, required=False), + 14: protobuf.Field("storage_hash", "bytes", repeated=False, required=False), + } + + def __init__( + self, + *, + recovery_cipher: Optional[str] = None, + recovery_auto_completed_word: Optional[str] = None, + firmware_hash: Optional[bytes] = None, + storage_hash: Optional[bytes] = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.recovery_cipher = recovery_cipher + self.recovery_auto_completed_word = recovery_auto_completed_word + self.firmware_hash = firmware_hash + self.storage_hash = storage_hash + + class KeepkeyClient(TrezorClient): - def __init__(self, path, password=''): - super(KeepkeyClient, self).__init__(path, password) + def __init__(self, path: str, password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> None: + """ + The `KeepkeyClient` is a `HardwareWalletClient` for interacting with the Keepkey. + + As Keepkeys are clones of the Trezor 1, please refer to `TrezorClient` for documentation. + """ + model = TrezorModel( + name="K1-14M", + minimum_version=(0, 0, 0), + vendors=("keepkey.com"), + usb_ids=(), # unused + default_mapping=DEFAULT_MAPPING, + ) + model.default_mapping.register(KeepkeyFeatures) + model.default_mapping.register(KeepkeyResetDevice) + if path.startswith("udp"): + model.default_mapping.register(KeepkeyDebugLinkState) + + super(KeepkeyClient, self).__init__(path, password, expert, chain, KEEPKEY_HID_IDS, KEEPKEY_WEBUSB_IDS, KEEPKEY_SIMULATOR_PATH, model) self.type = 'Keepkey' -def enumerate(password=''): + def can_sign_taproot(self) -> bool: + """ + The KeepKey does not support Taproot yet. + + :returns: False, always + """ + return False + + +def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> List[Dict[str, Any]]: results = [] - for dev in enumerate_devices(): - d_data = {} + devs = hid.HidTransport.enumerate(usb_ids=KEEPKEY_HID_IDS) + devs.extend(webusb.WebUsbTransport.enumerate(usb_ids=KEEPKEY_WEBUSB_IDS)) + devs.extend(udp.UdpTransport.enumerate(KEEPKEY_SIMULATOR_PATH)) + for dev in devs: + d_data: Dict[str, Any] = {} d_data['type'] = 'keepkey' d_data['model'] = 'keepkey' @@ -25,22 +187,25 @@ def enumerate(password=''): with handle_errors(common_err_msgs["enumerate"], d_data): client = KeepkeyClient(d_data['path'], password) - client.client.init_device() + try: + client.client.refresh_features() + except TypeError: + continue if 'keepkey' not in client.client.features.vendor: continue - if d_data['path'] == 'udp:127.0.0.1:21324': + d_data['label'] = client.client.features.label + if d_data['path'].startswith('udp:'): d_data['model'] += '_simulator' - d_data['needs_pin_sent'] = client.client.features.pin_protection and not client.client.features.pin_cached + d_data['needs_pin_sent'] = client.client.features.pin_protection and not client.client.features.unlocked d_data['needs_passphrase_sent'] = client.client.features.passphrase_protection # always need the passphrase sent for Keepkey if it has passphrase protection enabled if d_data['needs_pin_sent']: raise DeviceNotReadyError('Keepkey is locked. Unlock by using \'promptpin\' and then \'sendpin\'.') - if d_data['needs_passphrase_sent'] and not password: + if d_data['needs_passphrase_sent'] and password is None: raise DeviceNotReadyError("Passphrase needs to be specified before the fingerprint information can be retrieved") if client.client.features.initialized: - master_xpub = client.get_pubkey_at_path('m/0h')['xpub'] - d_data['fingerprint'] = get_xpub_fingerprint_hex(master_xpub) + d_data['fingerprint'] = client.get_master_fingerprint().hex() d_data['needs_passphrase_sent'] = False # Passphrase is always needed for the above to have worked, so it's already sent else: d_data['error'] = 'Not initialized' diff --git a/hwilib/devices/ledger.py b/hwilib/devices/ledger.py index 26edcddf7..49fb1389b 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -1,29 +1,89 @@ -# Ledger interaction script - +""" +Ledger Devices +************** +""" + +from functools import wraps +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Union, +) + +from ..descriptor import ( + MultisigDescriptor, + PubkeyProvider, +) from ..hwwclient import HardwareWalletClient -from ..errors import ActionCanceledError, BadArgumentError, DeviceConnectionError, DeviceFailureError, UnavailableActionError, common_err_msgs, handle_errors -from .btchip.bitcoinTransaction import bitcoinTransaction -from .btchip.btchip import btchip -from .btchip.btchipComm import HIDDongleHIDAPI -from .btchip.btchipException import BTChipException -from .btchip.btchipUtils import compress_public_key -import base64 +from ..errors import ( + ActionCanceledError, + BadArgumentError, + DeviceConnectionError, + DeviceFailureError, + UnavailableActionError, + UnknownDeviceError, + common_err_msgs, + handle_errors, +) +from ..common import ( + AddressType, + Chain, +) +from .ledger_bitcoin.client import ( + createClient, + NewClient, + LegacyClient, + TransportClient, +) +from .ledger_bitcoin.exception import NotSupportedError +from .ledger_bitcoin.wallet import ( + MultisigWallet, + WalletPolicy, +) +from .ledger_bitcoin.btchip.btchipException import BTChipException + +import builtins +import copy import hid -import struct -from .. import base58 -from ..base58 import get_xpub_fingerprint_hex -from ..serializations import hash256, hash160, CTransaction + +from ..key import ( + ExtendedKey, + get_bip44_purpose, + get_bip44_chain, + H_, + is_standard_path, + KeyOriginInfo, + parse_path, +) +from .._script import ( + is_p2sh, + is_p2wsh, + is_witness, + parse_multisig, +) +from ..psbt import PSBT import logging import re +SIMULATOR_PATH = 'tcp:127.0.0.1:9999' + LEDGER_VENDOR_ID = 0x2c97 -LEDGER_DEVICE_IDS = [ - 0x0001, # Ledger Nano S - 0x0004, # Ledger Nano X -] +LEDGER_MODEL_IDS = { + 0x10: "ledger_nano_s", + 0x40: "ledger_nano_x", + 0x50: "ledger_nano_s_plus" +} +LEDGER_LEGACY_PRODUCT_IDS = { + 0x0001: "ledger_nano_s", + 0x0004: "ledger_nano_x" +} # minimal checking of string keypath -def check_keypath(key_path): +def check_keypath(key_path: str) -> bool: parts = re.split("/", key_path) if parts[0] != "m": return False @@ -48,8 +108,19 @@ def check_keypath(key_path): 0x6985, # BTCHIP_SW_CONDITIONS_OF_USE_NOT_SATISFIED ] -def ledger_exception(f): - def func(*args, **kwargs): +# The priority of address types we want for signing. +# We want to do Taproot first, then segwit, then legacy +# Higher number is lower priority so that sort does not require reversing. +signing_priority = { + AddressType.TAP: 0, + AddressType.WIT: 1, + AddressType.SH_WIT: 2, + AddressType.LEGACY: 3, +} + +def ledger_exception(f: Callable[..., Any]) -> Any: + @wraps(f) + def func(*args: Any, **kwargs: Any) -> Any: try: return f(*args, **kwargs) except ValueError as e: @@ -70,296 +141,451 @@ def func(*args, **kwargs): # This class extends the HardwareWalletClient for Ledger Nano S and Nano X specific things class LedgerClient(HardwareWalletClient): - def __init__(self, path, password=''): - super(LedgerClient, self).__init__(path, password) - device = hid.device() - device.open_path(path.encode()) - device.set_nonblocking(True) - - self.dongle = HIDDongleHIDAPI(device, True, logging.getLogger().getEffectiveLevel() == logging.DEBUG) - self.app = btchip(self.dongle) - - # Must return a dict with the xpub - # Retrieves the public key at the specified BIP 32 derivation path - @ledger_exception - def get_pubkey_at_path(self, path): - if not check_keypath(path): - raise BadArgumentError("Invalid keypath") - path = path[2:] - path = path.replace('h', '\'') - path = path.replace('H', '\'') - # This call returns raw uncompressed pubkey, chaincode - pubkey = self.app.getWalletPublicKey(path) - if path != "": - parent_path = "" - for ind in path.split("/")[:-1]: - parent_path += ind + "/" - parent_path = parent_path[:-1] - - # Get parent key fingerprint - parent = self.app.getWalletPublicKey(parent_path) - fpr = hash160(compress_public_key(parent["publicKey"]))[:4] - - # Compute child info - childstr = path.split("/")[-1] - hard = 0 - if childstr[-1] == "'" or childstr[-1] == "h" or childstr[-1] == "H": - childstr = childstr[:-1] - hard = 0x80000000 - child = struct.pack(">I", int(childstr) + hard) - # Special case for m - else: - child = bytearray.fromhex("00000000") - fpr = child + def __init__(self, path: str, password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> None: + super(LedgerClient, self).__init__(path, password, expert, chain) - chainCode = pubkey["chainCode"] - publicKey = compress_public_key(pubkey["publicKey"]) + is_debug = logging.getLogger().getEffectiveLevel() == logging.DEBUG - depth = len(path.split("/")) if len(path) > 0 else 0 - depth = struct.pack("B", depth) + try: + if path.startswith('tcp'): + split_path = path.split(':') + server = split_path[1] + port = int(split_path[2]) + self.transport_client = TransportClient(interface="tcp", server=server, port=port, debug=is_debug) + else: + self.transport_client = TransportClient(interface="hid", debug=is_debug, path=path.encode()) - if self.is_testnet: - version = bytearray.fromhex("043587CF") - else: - version = bytearray.fromhex("0488B21E") - extkey = version + depth + fpr + child + chainCode + publicKey - checksum = hash256(extkey)[:4] + self.client = createClient(self.transport_client, chain=self.chain, debug=is_debug) + except NotSupportedError as e: + raise DeviceConnectionError(e.args[2]) - return {"xpub": base58.encode(extkey + checksum)} + @ledger_exception + def get_master_fingerprint(self) -> bytes: + return self.client.get_master_fingerprint() - # Must return a hex string with the signed transaction - # The tx must be in the combined unsigned transaction format - # Current only supports segwit signing @ledger_exception - def sign_tx(self, tx): - c_tx = CTransaction(tx.tx) - tx_bytes = c_tx.serialize_with_witness() - - # Master key fingerprint - master_fpr = hash160(compress_public_key(self.app.getWalletPublicKey('')["publicKey"]))[:4] - # An entry per input, each with 0 to many keys to sign with - all_signature_attempts = [[]] * len(c_tx.vin) - - # NOTE: We only support signing Segwit inputs, where we can skip over non-segwit - # inputs, or non-segwit inputs, where *all* inputs are non-segwit. This is due - # to Ledger's mutually exclusive signing steps for each type. - segwit_inputs = [] - # Legacy style inputs - legacy_inputs = [] - - has_segwit = False - has_legacy = False - - script_codes = [[]] * len(c_tx.vin) - - # Detect changepath, (p2sh-)p2(w)pkh only - change_path = '' - for txout, i_num in zip(c_tx.vout, range(len(c_tx.vout))): - # Find which wallet key could be change based on hdsplit: m/.../1/k - # Wallets shouldn't be sending to change address as user action - # otherwise this will get confused - for pubkey, path in tx.outputs[i_num].hd_keypaths.items(): - if struct.pack(" 2 and path[-2] == 1: - # For possible matches, check if pubkey matches possible template - if hash160(pubkey) in txout.scriptPubKey or hash160(bytearray.fromhex("0014") + hash160(pubkey)) in txout.scriptPubKey: - change_path = '' - for index in path[1:]: - change_path += str(index) + "/" - change_path = change_path[:-1] - - for txin, psbt_in, i_num in zip(c_tx.vin, tx.inputs, range(len(c_tx.vin))): - - seq = format(txin.nSequence, 'x') - seq = seq.zfill(8) - seq = bytearray.fromhex(seq) - seq.reverse() - seq_hex = ''.join('{:02x}'.format(x) for x in seq) + def get_pubkey_at_path(self, path: str) -> ExtendedKey: + path = path.replace("h", "'") + path = path.replace("H", "'") + try: + xpub_str = self.client.get_extended_pubkey(path=path, display=False) + except NotSupportedError: + # We will get not supported for non-standard paths + # If so, try again but with display=True + xpub_str = self.client.get_extended_pubkey(path=path, display=True) + return ExtendedKey.deserialize(xpub_str) + @ledger_exception + def sign_tx(self, tx: PSBT) -> PSBT: + """ + Sign a transaction with a Ledger device. Not all transactions can be signed by a Ledger. + + The scripts supported depend on the version of the Bitcoin Application installed on the Ledger. + + For application versions 1.x and 2.0.x: + + - Transactions containing both segwit and non-segwit inputs are not entirely supported; only the segwit inputs will be signed in this case. + + For application versions 2.1.x and above: + + - Only keys derived with standard BIP 44, 49, 84, and 86 derivation paths are supported for single signature addresses. + """ + master_fp = self.get_master_fingerprint() + + def legacy_sign_tx() -> PSBT: + client = self.client + if not isinstance(client, LegacyClient): + client = LegacyClient(self.transport_client, self.chain) + wallet = WalletPolicy("", "wpkh(@0/**)", [""]) + legacy_input_sigs = client.sign_psbt(tx, wallet, None) + + for idx, pubkey, sig in legacy_input_sigs: + psbt_in = tx.inputs[idx] + psbt_in.partial_sigs[pubkey] = sig + return tx + + if isinstance(self.client, LegacyClient): + return legacy_sign_tx() + + # Make a deepcopy of this psbt. We will need to modify it to get signing to work, + # which will affect the caller's detection for whether signing occured. + psbt2 = copy.deepcopy(tx) + if tx.version != 2: + psbt2.convert_to_v2() + + # Figure out which wallets are signing + wallets: Dict[bytes, Tuple[int, AddressType, WalletPolicy, Optional[bytes]]] = {} + pubkeys: Dict[int, bytes] = {} + for input_num, psbt_in in builtins.enumerate(psbt2.inputs): + utxo = None + scriptcode = b"" + if psbt_in.witness_utxo: + utxo = psbt_in.witness_utxo if psbt_in.non_witness_utxo: - segwit_inputs.append({"value": txin.prevout.serialize() + struct.pack(" 0: - # p2wpkh - scriptCode += b"\x76\xa9\x14" - scriptCode += witness_program[2:] - scriptCode += b"\x88\xac" - elif len(witness_program) == 0: - if len(redeemscript) > 0: - scriptCode = redeemscript + if psbt_in.prev_txid != psbt_in.non_witness_utxo.hash: + raise BadArgumentError(f"Input {input_num} has a non_witness_utxo with the wrong hash") + assert psbt_in.prev_out is not None + utxo = psbt_in.non_witness_utxo.vout[psbt_in.prev_out] + psbt_in.witness_utxo = utxo # Make sure that all inputs have witness_utxo too as signing mixed will fail + if utxo is None: + continue + scriptcode = utxo.scriptPubKey + + p2sh = False + if is_p2sh(scriptcode): + if len(psbt_in.redeem_script) == 0: + continue + scriptcode = psbt_in.redeem_script + p2sh = True + + is_wit, wit_ver, _ = is_witness(scriptcode) + + script_addrtype = AddressType.LEGACY + if is_wit: + if p2sh: + if wit_ver == 0: + script_addrtype = AddressType.SH_WIT + else: + raise BadArgumentError("Cannot have witness v1+ in p2sh") else: - scriptCode = psbt_in.non_witness_utxo.vout[txin.prevout.n].scriptPubKey - - # Save scriptcode for later signing - script_codes[i_num] = scriptCode - - # Find which pubkeys could sign this input (should be all?) - for pubkey in psbt_in.hd_keypaths.keys(): - if hash160(pubkey) in scriptCode or pubkey in scriptCode: - pubkeys.append(pubkey) - - # Figure out which keys in inputs are from our wallet - for pubkey in pubkeys: - keypath = psbt_in.hd_keypaths[pubkey] - if master_fpr == struct.pack(" None: + if not is_standard_path(origin.path, script_addrtype, self.chain): + # TODO: Deal with non-default wallets + return + policy = self._get_singlesig_default_wallet_policy(script_addrtype, origin.path[2]) + wallets[policy.id] = ( + signing_priority[script_addrtype], + script_addrtype, + self._get_singlesig_default_wallet_policy(script_addrtype, origin.path[2]), + None, # Wallet hmac + ) + + for key, origin in psbt_in.hd_keypaths.items(): + if origin.fingerprint == master_fp: + if not multisig: + process_origin(origin) + pubkeys[input_num] = key + + for key, (leaf_hashes, origin) in psbt_in.tap_bip32_paths.items(): + # TODO: Support script path signing + if key == psbt_in.tap_internal_key and origin.fingerprint == master_fp: + process_origin(origin) + pubkeys[input_num] = key + + # For each wallet, sign + for _, (_, addrtype, wallet, wallet_hmac) in sorted(wallets.items(), key=lambda y: y[1][0]): + if addrtype == AddressType.LEGACY: + # We need to remove witness_utxo for legacy inputs when signing with legacy otherwise signing will fail + for psbt_in in psbt2.inputs: + utxo = None + if psbt_in.witness_utxo: + utxo = psbt_in.witness_utxo + if utxo is None: + continue + is_wit, _, _ = is_witness(utxo.scriptPubKey) + if not is_wit: + psbt_in.witness_utxo = None + + input_sigs = self.client.sign_psbt(psbt2, wallet, wallet_hmac) + for idx, partial_sig in input_sigs: + pubkey = partial_sig.pubkey + sig = partial_sig.signature + psbt_in = psbt2.inputs[idx] + + utxo = None + if psbt_in.witness_utxo: + utxo = psbt_in.witness_utxo + if psbt_in.non_witness_utxo: + assert psbt_in.prev_out is not None + utxo = psbt_in.non_witness_utxo.vout[psbt_in.prev_out] + assert utxo is not None + + is_wit, wit_ver, _ = utxo.is_witness() + + if is_wit and wit_ver >= 1: + # TODO: Deal with script path signatures + # For now, assume key path signature + psbt_in.tap_key_sig = sig + else: + psbt_in.partial_sigs[pubkey] = sig + + # Extract the sigs from psbt2 and put them into tx + for sig_in, psbt_in in zip(psbt2.inputs, tx.inputs): + psbt_in.partial_sigs.update(sig_in.partial_sigs) + psbt_in.tap_script_sigs.update(sig_in.tap_script_sigs) + if len(sig_in.tap_key_sig) != 0 and len(psbt_in.tap_key_sig) == 0: + psbt_in.tap_key_sig = sig_in.tap_key_sig + + return tx + @ledger_exception - def sign_message(self, message, keypath): + def sign_message(self, message: Union[str, bytes], keypath: str) -> str: if not check_keypath(keypath): - raise BadArgumentError("Invalid keypath") - message = bytearray(message, 'utf-8') - keypath = keypath[2:] - # First display on screen what address you're signing for - self.app.getWalletPublicKey(keypath, True) - self.app.signMessagePrepare(keypath, message) - signature = self.app.signMessageSign() - - # Make signature into standard bitcoin format - rLength = signature[3] - r = signature[4: 4 + rLength] - sLength = signature[4 + rLength + 1] - s = signature[4 + rLength + 2:] - if rLength == 33: - r = r[1:] - if sLength == 33: - s = s[1:] - - sig = bytearray(chr(27 + 4 + (signature[0] & 0x01)), 'utf8') + r + s - - return {"signature": base64.b64encode(sig).decode('utf-8')} + raise ValueError("Invalid keypath") + + # Ledger requires the character "'" for hardened derivations since version 2.0.0 + keypath = keypath.replace("h", "'") + + return self.client.sign_message(message, keypath) + + def _get_singlesig_default_wallet_policy(self, addr_type: AddressType, account: int) -> WalletPolicy: + if addr_type == AddressType.LEGACY: + template = "pkh(@0/**)" + elif addr_type == AddressType.WIT: + template = "wpkh(@0/**)" + elif addr_type == AddressType.SH_WIT: + template = "sh(wpkh(@0/**))" + elif addr_type == AddressType.TAP: + template = "tr(@0/**)" + else: + BadArgumentError("Unknown address type") + + path = [H_(get_bip44_purpose(addr_type)), H_(get_bip44_chain(self.chain)), H_(account)] + + # Build a PubkeyProvider for the key we're going to use + origin = KeyOriginInfo(self.get_master_fingerprint(), path) + pk_prov = PubkeyProvider(origin, self.get_pubkey_at_path(f"m{origin._path_string()}").to_string(), None) + key_str = pk_prov.to_string(hardened_char="'") + + # Make the Wallet object + return WalletPolicy(name="", descriptor_template=template, keys_info=[key_str]) @ledger_exception - def display_address(self, keypath, p2sh_p2wpkh, bech32): - if not check_keypath(keypath): - raise BadArgumentError("Invalid keypath") - output = self.app.getWalletPublicKey(keypath[2:], True, (p2sh_p2wpkh or bech32), bech32) - return {'address': output['address'][12:-2]} # HACK: A bug in getWalletPublicKey results in the address being returned as the string "bytearray(b'
')". This extracts the actual address to work around this. + def display_singlesig_address( + self, + keypath: str, + addr_type: AddressType, + ) -> str: + path = parse_path(keypath) + + if isinstance(self.client, LegacyClient): + if addr_type == AddressType.LEGACY: + template = "pkh(@0/**)" + elif addr_type == AddressType.WIT: + template = "wpkh(@0/**)" + elif addr_type == AddressType.SH_WIT: + template = "sh(wpkh(@0/**))" + elif addr_type == AddressType.TAP: + BadArgumentError("Taproot is not supported by this version of the Bitcoin App") + else: + BadArgumentError("Unknown address type") + + origin = KeyOriginInfo(self.get_master_fingerprint(), path) + wallet = WalletPolicy(name="", descriptor_template=template, keys_info=["[{}]".format(origin.to_string(hardened_char="'"))]) + else: + if not is_standard_path(path, addr_type, self.chain): + raise BadArgumentError("Ledger requires BIP 44 standard paths") + + wallet = self._get_singlesig_default_wallet_policy(addr_type, path[2]) + + return self.client.get_wallet_address(wallet, None, path[-2], path[-1], True) + + @ledger_exception + def display_multisig_address( + self, + addr_type: AddressType, + multisig: MultisigDescriptor, + ) -> str: + if isinstance(self.client, LegacyClient): + raise BadArgumentError("Displaying multisignature addresses is not supported by this version of the Bitcoin App") + + def is_valid_der_path(path: Optional[str]) -> bool: + if path is None: + return False + path_parts = path.split("/") + return len(path_parts) == 3 and path_parts[1] in ["0", "1"] and path_parts[2].isdigit() and 0 <= int(path_parts[2]) <= 0x7fffffff + + if any(not is_valid_der_path(pk.deriv_path) for pk in multisig.pubkeys): + raise BadArgumentError("Ledger Bitcoin app requires derivation paths ending with /0/* or /1/* for multisig") + + if not (all(pk.deriv_path == multisig.pubkeys[0].deriv_path for pk in multisig.pubkeys)): + raise BadArgumentError("Ledger Bitcoin app requires all derivation paths to end with /0/*, or all with /1/* for multisig") - # Setup a new device - def setup_device(self, label='', passphrase=''): + if any(pk.origin is not None and len(pk.origin.path) > 4 for pk in multisig.pubkeys): + raise BadArgumentError("Ledger Bitcoin app requires extended keys with derivation length at most 4") + + def format_key_info(pubkey: PubkeyProvider) -> str: + assert pubkey.origin is not None and pubkey.extkey is not None + hardened_char = "'" + return f"[{pubkey.origin.to_string(hardened_char=hardened_char)}]{pubkey.extkey.to_string()}" + + keys_info = [format_key_info(pk) for pk in multisig.pubkeys] + + multisig_wallet = MultisigWallet(f"{multisig.thresh} of {len(keys_info)} Multisig", addr_type, multisig.thresh, keys_info=keys_info, sorted=multisig.is_sorted) + _, registered_hmac = self.client.register_wallet(multisig_wallet) + + assert multisig.pubkeys[0].deriv_path is not None # already checked above with is_valid_der_path + change = 0 if multisig.pubkeys[0].deriv_path[:3] == "/0/" else 1 + address_index = int(multisig.pubkeys[0].deriv_path.split("/")[2]) + + return self.client.get_wallet_address(multisig_wallet, registered_hmac, change, address_index, True) + + def setup_device(self, label: str = "", passphrase: str = "") -> bool: + """ + Ledgers do not support setup via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Ledger Nano S and X do not support software setup') - # Wipe this device - def wipe_device(self): + def wipe_device(self) -> bool: + """ + Ledgers do not support wiping via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Ledger Nano S and X do not support wiping via software') - # Restore device from mnemonic or xprv - def restore_device(self, label=''): + def restore_device(self, label: str = "", word_count: int = 24) -> bool: + """ + Ledgers do not support restoring via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Ledger Nano S and X do not support restoring via software') - # Begin backup process - def backup_device(self, label='', passphrase=''): + def backup_device(self, label: str = "", passphrase: str = "") -> bool: + """ + Ledgers do not support backing up via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Ledger Nano S and X do not support creating a backup via software') - # Close the device - def close(self): - self.dongle.close() + def close(self) -> None: + self.client.stop() - # Prompt pin - def prompt_pin(self): + def prompt_pin(self) -> bool: + """ + Ledgers do not need a PIN sent from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Ledger Nano S and X do not need a PIN sent from the host') - # Send pin - def send_pin(self, pin): + def send_pin(self, pin: str) -> bool: + """ + Ledgers do not need a PIN sent from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The Ledger Nano S and X do not need a PIN sent from the host') -def enumerate(password=''): + def toggle_passphrase(self) -> bool: + """ + Ledgers do not support toggling passphrase from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError('The Ledger Nano S and X do not support toggling passphrase from the host') + + @ledger_exception + def can_sign_taproot(self) -> bool: + """ + Ledgers support Taproot if the Bitcoin App version greater than 2.0.0; support here is for versions 2.1.0 and above. + + :returns: True if Bitcoin App version is greater than or equal to 2.1.0, and not the "Legacy" release. False otherwise. + """ + return isinstance(self.client, NewClient) + + +def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> List[Dict[str, Any]]: results = [] - for device_id in LEDGER_DEVICE_IDS: - for d in hid.enumerate(LEDGER_VENDOR_ID, device_id): - if ('interface_number' in d and d['interface_number'] == 0 - or ('usage_page' in d and d['usage_page'] == 0xffa0)): - d_data = {} - - path = d['path'].decode() - d_data['type'] = 'ledger' - d_data['model'] = 'ledger_nano_x' if device_id == 0x0004 else 'ledger_nano_s' - d_data['path'] = path - - client = None - with handle_errors(common_err_msgs["enumerate"], d_data): + devices = [] + devices.extend(hid.enumerate(LEDGER_VENDOR_ID, 0)) + devices.append({'path': SIMULATOR_PATH.encode(), 'interface_number': 0, 'product_id': 0x1000}) + + for d in devices: + if ('interface_number' in d and d['interface_number'] == 0 + or ('usage_page' in d and d['usage_page'] == 0xffa0)): + d_data: Dict[str, Any] = {} + + path = d['path'].decode() + d_data['type'] = 'ledger' + model = d['product_id'] >> 8 + if model in LEDGER_MODEL_IDS.keys(): + d_data['model'] = LEDGER_MODEL_IDS[model] + elif d['product_id'] in LEDGER_LEGACY_PRODUCT_IDS.keys(): + d_data['model'] = LEDGER_LEGACY_PRODUCT_IDS[d['product_id']] + else: + continue + d_data['label'] = None + d_data['path'] = path + + if path == SIMULATOR_PATH: + d_data['model'] += '_simulator' + + client = None + with handle_errors(common_err_msgs["enumerate"], d_data): + try: client = LedgerClient(path, password) - master_xpub = client.get_pubkey_at_path('m/0h')['xpub'] - d_data['fingerprint'] = get_xpub_fingerprint_hex(master_xpub) + d_data['fingerprint'] = client.get_master_fingerprint().hex() d_data['needs_pin_sent'] = False d_data['needs_passphrase_sent'] = False + except (BTChipException, ConnectionRefusedError): + # Ignore simulator if there's an exception, means it isn't there + if path == SIMULATOR_PATH: + continue + else: + raise + except UnknownDeviceError: + # This only happens if the ledger is not in the Bitcoin app, so skip it + continue + + if client: + client.close() - if client: - client.close() + results.append(d_data) - results.append(d_data) return results diff --git a/hwilib/devices/ledger_bitcoin/README.md b/hwilib/devices/ledger_bitcoin/README.md new file mode 100644 index 000000000..e3cc0ee60 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/README.md @@ -0,0 +1,19 @@ +# Ledger Bitcoin application client + +This is a stripped down version of the client provided at https://github.com/LedgerHQ/app-bitcoin-new/tree/master/bitcoin_client. + +This stripped down version was made at commit [4e82e44ecfe4ba358da9848087e7e597309abc53](https://github.com/LedgerHQ/app-bitcoin-new/commit/4e82e44ecfe4ba358da9848087e7e597309abc53) + +## Changes + +As the library originally copied several functions and classes from HWI, the majority of changes were to use relative imports to access those things rather than copying them and duplicating code. + +* Relative imports to our `common.py`, `key.py`, `_serialize.py`, `psbt.py`, `_script.py`, `base58.py`, `descriptor.py`, and `tx.py`. +* `write_varint` is an alias for `..._serialize.ser_compact_size` rather than a separate function. +* Inline `serialize_str` function into `wallet.py`. +* Inline `bip32_path_from_string` function into `command_builder.py`. +* Inline `ByteStreamparser` class into `client_command.py`. +* Removed `btchip/btchipComm.py` as it is not doing anything. +* Remove usage of `typing.Literal` (needed for Python 3.6 support) +* Removed the `sign_message` function (to be reintroduced when message signing is available for both legacy and new clients). +* Use `ledgercomm` relative import diff --git a/hwilib/devices/ledger_bitcoin/__init__.py b/hwilib/devices/ledger_bitcoin/__init__.py new file mode 100644 index 000000000..fda61c517 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/__init__.py @@ -0,0 +1,22 @@ + +"""Ledger Nano Syscoin app client""" + +from .client_base import Client, TransportClient, PartialSignature +from .client import createClient +from ...common import Chain + +from .wallet import AddressType, WalletPolicy, MultisigWallet, WalletType + +__version__ = '0.2.0' + +__all__ = [ + "Client", + "TransportClient", + "PartialSignature", + "createClient", + "Chain", + "AddressType", + "WalletPolicy", + "MultisigWallet", + "WalletType" +] diff --git a/hwilib/devices/ledger_bitcoin/_base58.py b/hwilib/devices/ledger_bitcoin/_base58.py new file mode 100644 index 000000000..2644eb877 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/_base58.py @@ -0,0 +1,152 @@ +""" +Base 58 conversion utilities +**************************** +""" + +# +# base58.py +# +# Revision here from https://github.com/bitcoin-core/HWI/blob/3fe369d0379212fae1c72729a179d133b0adc872/hwilib/_base58.py +# +# Original source: git://github.com/joric/brutus.git +# which was forked from git://github.com/samrushing/caesure.git +# +# Distributed under the MIT/X11 software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +# + +from binascii import hexlify, unhexlify +from typing import List + +from ...common import hash256 +from .errors import BadArgumentError + + +b58_digits: str = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + + +def encode(b: bytes) -> str: + """ + Encode bytes to a base58-encoded string + :param b: Bytes to encode + :return: Base58 encoded string of ``b`` + """ + + # Convert big-endian bytes to integer + n: int = int('0x0' + hexlify(b).decode('utf8'), 16) + + # Divide that integer into base58 + temp: List[str] = [] + while n > 0: + n, r = divmod(n, 58) + temp.append(b58_digits[r]) + res: str = ''.join(temp[::-1]) + + # Encode leading zeros as base58 zeros + czero: int = 0 + pad: int = 0 + for c in b: + if c == czero: + pad += 1 + else: + break + return b58_digits[0] * pad + res + +def decode(s: str) -> bytes: + """ + Decode a base58-encoding string, returning bytes + :param s: Base48 string to decode + :return: Bytes encoded by ``s`` + """ + if not s: + return b'' + + # Convert the string to an integer + n: int = 0 + for c in s: + n *= 58 + if c not in b58_digits: + raise BadArgumentError('Character %r is not a valid base58 character' % c) + digit = b58_digits.index(c) + n += digit + + # Convert the integer to bytes + h: str = '%x' % n + if len(h) % 2: + h = '0' + h + res = unhexlify(h.encode('utf8')) + + # Add padding back. + pad = 0 + for c in s[:-1]: + if c == b58_digits[0]: + pad += 1 + else: + break + return b'\x00' * pad + res + +def get_xpub_fingerprint(s: str) -> bytes: + """ + Get the parent fingerprint from an extended public key + :param s: The extended pubkey + :return: The parent fingerprint bytes + """ + data = decode(s) + fingerprint = data[5:9] + return fingerprint + +def get_xpub_fingerprint_hex(xpub: str) -> str: + """ + Get the parent fingerprint as a hex string from an extended public key + :param s: The extended pubkey + :return: The parent fingerprint as a hex string + """ + data = decode(xpub) + fingerprint = data[5:9] + return hexlify(fingerprint).decode() + +def to_address(b: bytes, version: bytes) -> str: + """ + Base58 Check Encode the data with the version number. + Used to encode legacy style addresses. + :param b: The data to encode + :param version: The version number to encode with + :return: The Base58 Check Encoded string + """ + data = version + b + checksum = hash256(data)[0:4] + data += checksum + return encode(data) + +def xpub_to_pub_hex(xpub: str) -> str: + """ + Get the public key as a string from the extended public key. + :param xpub: The extended pubkey + :return: The pubkey hex string + """ + data = decode(xpub) + pubkey = data[-37:-4] + return hexlify(pubkey).decode() + + +def xpub_to_xonly_pub_hex(xpub: str) -> str: + """ + Get the public key as a string from the extended public key. + :param xpub: The extended pubkey + :return: The pubkey hex string + """ + data = decode(xpub) + pubkey = data[-36:-4] + return hexlify(pubkey).decode() + + +def xpub_main_2_test(xpub: str) -> str: + """ + Convert an extended pubkey from mainnet version to testnet version. + :param xpub: The extended pubkey + :return: The extended pubkey re-encoded using testnet version bytes + """ + data = decode(xpub) + test_data = b'\x04\x35\x87\xCF' + data[4:-4] + checksum = hash256(test_data)[0:4] + return encode(test_data + checksum) diff --git a/hwilib/devices/ledger_bitcoin/_script.py b/hwilib/devices/ledger_bitcoin/_script.py new file mode 100644 index 000000000..f4a645ba3 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/_script.py @@ -0,0 +1,154 @@ +""" +Original version: https://raw.githubusercontent.com/bitcoin-core/HWI/3fe369d0379212fae1c72729a179d133b0adc872/hwilib/_script.py +Distributed under the MIT License. + +Bitcoin Script utilities +************************ +""" + +from typing import ( + Optional, + Sequence, + Tuple, +) + + +def is_opreturn(script: bytes) -> bool: + """ + Determine whether a script is an OP_RETURN output script. + + :param script: The script + :returns: Whether the script is an OP_RETURN output script + """ + return script[0] == 0x6a + + +def is_p2sh(script: bytes) -> bool: + """ + Determine whether a script is a P2SH output script. + + :param script: The script + :returns: Whether the script is a P2SH output script + """ + return len(script) == 23 and script[0] == 0xa9 and script[1] == 0x14 and script[22] == 0x87 + + +def is_p2pkh(script: bytes) -> bool: + """ + Determine whether a script is a P2PKH output script. + + :param script: The script + :returns: Whether the script is a P2PKH output script + """ + return len(script) == 25 and script[0] == 0x76 and script[1] == 0xa9 and script[2] == 0x14 and script[23] == 0x88 and script[24] == 0xac + + +def is_p2pk(script: bytes) -> bool: + """ + Determine whether a script is a P2PK output script. + + :param script: The script + :returns: Whether the script is a P2PK output script + """ + return (len(script) == 35 or len(script) == 67) and (script[0] == 0x21 or script[0] == 0x41) and script[-1] == 0xac + + +def is_p2tr(script: bytes) -> bool: + """ + Determine whether a script is a P2TR output script. + + :param script: The script + :returns: Whether the script is a P2TR output script + """ + return len(script) == 34 and script[0] == 0x51 and script[1] == 0x20 + + +def is_witness(script: bytes) -> Tuple[bool, int, bytes]: + """ + Determine whether a script is a segwit output script. + If so, also returns the witness version and witness program. + + :param script: The script + :returns: A tuple of a bool indicating whether the script is a segwit output script, + an int representing the witness version, + and the bytes of the witness program. + """ + if len(script) < 4 or len(script) > 42: + return (False, 0, b"") + + if script[0] != 0 and (script[0] < 81 or script[0] > 96): + return (False, 0, b"") + + if script[1] + 2 == len(script): + return (True, script[0] - 0x50 if script[0] else 0, script[2:]) + + return (False, 0, b"") + + +def is_p2wpkh(script: bytes) -> bool: + """ + Determine whether a script is a P2WPKH output script. + + :param script: The script + :returns: Whether the script is a P2WPKH output script + """ + is_wit, wit_ver, wit_prog = is_witness(script) + if not is_wit: + return False + elif wit_ver != 0: + return False + return len(wit_prog) == 20 + + +def is_p2wsh(script: bytes) -> bool: + """ + Determine whether a script is a P2WSH output script. + + :param script: The script + :returns: Whether the script is a P2WSH output script + """ + is_wit, wit_ver, wit_prog = is_witness(script) + if not is_wit: + return False + elif wit_ver != 0: + return False + return len(wit_prog) == 32 + + +# Only handles up to 15 of 15. Returns None if this script is not a +# multisig script. Returns (m, pubkeys) otherwise. +def parse_multisig(script: bytes) -> Optional[Tuple[int, Sequence[bytes]]]: + """ + Determine whether a script is a multisig script. If so, determine the parameters of that multisig. + + :param script: The script + :returns: ``None`` if the script is not multisig. + If multisig, returns a tuple of the number of signers required, + and a sequence of public key bytes. + """ + # Get m + m = script[0] - 80 + if m < 1 or m > 15: + return None + + # Get pubkeys + pubkeys = [] + offset = 1 + while True: + pubkey_len = script[offset] + if pubkey_len != 33: + break + offset += 1 + pubkeys.append(script[offset:offset + 33]) + offset += 33 + + # Check things at the end + n = script[offset] - 80 + if n != len(pubkeys): + return None + offset += 1 + op_cms = script[offset] + if op_cms != 174: + return None + + return (m, pubkeys) diff --git a/hwilib/devices/ledger_bitcoin/_serialize.py b/hwilib/devices/ledger_bitcoin/_serialize.py new file mode 100644 index 000000000..2aa25eb22 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/_serialize.py @@ -0,0 +1,260 @@ +# Original version: https://raw.githubusercontent.com/bitcoin-core/HWI/3fe369d0379212fae1c72729a179d133b0adc872/hwilib/_serialize.py +# +#!/usr/bin/env python3 +# Copyright (c) 2010 ArtForz -- public domain half-a-node +# Copyright (c) 2012 Jeff Garzik +# Copyright (c) 2010-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. +""" +Bitcoin Object Python Serializations +************************************ + +Modified from the test/test_framework/mininode.py file from the +Bitcoin repository +""" + +import struct + +from typing import ( + List, + Sequence, + TypeVar, + Callable, +) +from typing_extensions import Protocol + +class Readable(Protocol): + def read(self, n: int = -1) -> bytes: + ... + +class Deserializable(Protocol): + def deserialize(self, f: Readable) -> None: + ... + +class Serializable(Protocol): + def serialize(self) -> bytes: + ... + + +# Serialization/deserialization tools +def ser_compact_size(size: int) -> bytes: + """ + Serialize an integer using Bitcoin's compact size unsigned integer serialization. + + :param size: The int to serialize + :returns: The int serialized as a compact size unsigned integer + """ + r = b"" + if size < 253: + r = struct.pack("B", size) + elif size < 0x10000: + r = struct.pack(" int: + """ + Deserialize a compact size unsigned integer from the beginning of the byte stream. + + :param f: The byte stream + :returns: The integer that was serialized + """ + nit: int = struct.unpack(" bytes: + """ + Deserialize a variable length byte string serialized with Bitcoin's variable length string serialization from a byte stream. + + :param f: The byte stream + :returns: The byte string that was serialized + """ + nit = deser_compact_size(f) + return f.read(nit) + +def ser_string(s: bytes) -> bytes: + """ + Serialize a byte string with Bitcoin's variable length string serialization. + + :param s: The byte string to be serialized + :returns: The serialized byte string + """ + return ser_compact_size(len(s)) + s + +def deser_uint256(f: Readable) -> int: + """ + Deserialize a 256 bit integer serialized with Bitcoin's 256 bit integer serialization from a byte stream. + + :param f: The byte stream. + :returns: The integer that was serialized + """ + r = 0 + for i in range(8): + t = struct.unpack(" bytes: + """ + Serialize a 256 bit integer with Bitcoin's 256 bit integer serialization. + + :param u: The integer to serialize + :returns: The serialized 256 bit integer + """ + rs = b"" + for _ in range(8): + rs += struct.pack(">= 32 + return rs + + +def uint256_from_str(s: bytes) -> int: + """ + Deserialize a 256 bit integer serialized with Bitcoin's 256 bit integer serialization from a byte string. + + :param s: The byte string + :returns: The integer that was serialized + """ + r = 0 + t = struct.unpack(" List[D]: + """ + Deserialize a vector of objects with Bitcoin's object vector serialization from a byte stream. + + :param f: The byte stream + :param c: The class of object to deserialize for each object in the vector + :returns: A list of objects that were serialized + """ + nit = deser_compact_size(f) + r = [] + for _ in range(nit): + t = c() + t.deserialize(f) + r.append(t) + return r + + +def ser_vector(v: Sequence[Serializable]) -> bytes: + """ + Serialize a vector of objects with Bitcoin's object vector serialzation. + + :param v: The list of objects to serialize + :returns: The serialized objects + """ + r = ser_compact_size(len(v)) + for i in v: + r += i.serialize() + return r + + +def deser_string_vector(f: Readable) -> List[bytes]: + """ + Deserialize a vector of byte strings from a byte stream. + + :param f: The byte stream + :returns: The list of byte strings that were serialized + """ + nit = deser_compact_size(f) + r = [] + for _ in range(nit): + t = deser_string(f) + r.append(t) + return r + + +def ser_string_vector(v: List[bytes]) -> bytes: + """ + Serialize a list of byte strings as a vector of byte strings. + + :param v: The list of byte strings to serialize + :returns: The serialized list of byte strings + """ + r = ser_compact_size(len(v)) + for sv in v: + r += ser_string(sv) + return r + +def ser_sig_der(r: bytes, s: bytes) -> bytes: + """ + Serialize the ``r`` and ``s`` values of an ECDSA signature using DER. + + :param r: The ``r`` value bytes + :param s: The ``s`` value bytes + :returns: The DER encoded signature + """ + sig = b"\x30" + + # Make r and s as short as possible + ri = 0 + for b in r: + if b == 0: + ri += 1 + else: + break + r = r[ri:] + si = 0 + for b in s: + if b == 0: + si += 1 + else: + break + s = s[si:] + + # Make positive of neg + first = r[0] + if first & (1 << 7) != 0: + r = b"\x00" + r + first = s[0] + if first & (1 << 7) != 0: + s = b"\x00" + s + + # Write total length + total_len = len(r) + len(s) + 4 + sig += struct.pack("B", total_len) + + # write r + sig += b"\x02" + sig += struct.pack("B", len(r)) + sig += r + + # write s + sig += b"\x02" + sig += struct.pack("B", len(s)) + sig += s + + sig += b"\x01" + return sig + +def ser_sig_compact(r: bytes, s: bytes, recid: bytes) -> bytes: + """ + Serialize the ``r`` and ``s`` values of an ECDSA signature using the compact signature serialization scheme. + + :param r: The ``r`` value bytes + :param s: The ``s`` value bytes + :returns: The compact signature + """ + rec = struct.unpack("B", recid)[0] + prefix = struct.pack("B", 27 + 4 + rec) + + sig = b"" + sig += prefix + sig += r + s + + return sig diff --git a/hwilib/devices/ledger_bitcoin/btchip/README.md b/hwilib/devices/ledger_bitcoin/btchip/README.md new file mode 100644 index 000000000..43c7d7053 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/btchip/README.md @@ -0,0 +1,11 @@ +# Ledger Nano S and Nano X Library + +This is a stripped down and modified version of the official [btchip-python](https://github.com/LedgerHQ/btchip-python) library. + +This stripped down version was made at commit [17f27c1996c75145b8eb5d16583bddcb6e2bf691](https://github.com/LedgerHQ/btchip-python/tree/17f27c1996c75145b8eb5d16583bddcb6e2bf691). + +## Changes + +- Removed support for Ledger HW.1 and other unused things + +See c141b6effa78fb7a3ed52acbe17314078fe93c86 for the specific changes. diff --git a/hwilib/devices/btchip/__init__.py b/hwilib/devices/ledger_bitcoin/btchip/__init__.py similarity index 97% rename from hwilib/devices/btchip/__init__.py rename to hwilib/devices/ledger_bitcoin/btchip/__init__.py index 598eef9b8..2e30a630d 100644 --- a/hwilib/devices/btchip/__init__.py +++ b/hwilib/devices/ledger_bitcoin/btchip/__init__.py @@ -16,4 +16,5 @@ * limitations under the License. ******************************************************************************** """ +__version__ = "0.1.31" diff --git a/hwilib/devices/btchip/bitcoinTransaction.py b/hwilib/devices/ledger_bitcoin/btchip/bitcoinTransaction.py similarity index 100% rename from hwilib/devices/btchip/bitcoinTransaction.py rename to hwilib/devices/ledger_bitcoin/btchip/bitcoinTransaction.py diff --git a/hwilib/devices/btchip/bitcoinVarint.py b/hwilib/devices/ledger_bitcoin/btchip/bitcoinVarint.py similarity index 100% rename from hwilib/devices/btchip/bitcoinVarint.py rename to hwilib/devices/ledger_bitcoin/btchip/bitcoinVarint.py diff --git a/hwilib/devices/btchip/btchip.py b/hwilib/devices/ledger_bitcoin/btchip/btchip.py similarity index 88% rename from hwilib/devices/btchip/btchip.py rename to hwilib/devices/ledger_bitcoin/btchip/btchip.py index 3627f665e..65a54c682 100644 --- a/hwilib/devices/btchip/btchip.py +++ b/hwilib/devices/ledger_bitcoin/btchip/btchip.py @@ -26,8 +26,10 @@ class btchip: BTCHIP_CLA = 0xe0 + BTCHIP_CLA_COMMON_SDK = 0xb0 BTCHIP_JC_EXT_CLA = 0xf0 + BTCHIP_INS_GET_APP_NAME_AND_VERSION = 0x01 BTCHIP_INS_SET_ALTERNATE_COIN_VERSION = 0x14 BTCHIP_INS_SETUP = 0x20 BTCHIP_INS_VERIFY_PIN = 0x22 @@ -84,8 +86,8 @@ def __init__(self, dongle): self.scriptBlockLength = 50 else: self.scriptBlockLength = 255 - except: - pass + except Exception: + pass def getWalletPublicKey(self, path, showOnScreen=False, segwit=False, segwitNative=False, cashAddr=False): result = {} @@ -172,7 +174,7 @@ def getTrustedInput(self, transaction, index): result['value'] = response return result - def startUntrustedTransaction(self, newTransaction, inputIndex, outputList, redeemScript, version=0x01, cashAddr=False): + def startUntrustedTransaction(self, newTransaction, inputIndex, outputList, redeemScript, version=0x01, cashAddr=False, continueSegwit=False): # Start building a fake transaction with the passed inputs segwit = False if newTransaction: @@ -186,7 +188,7 @@ def startUntrustedTransaction(self, newTransaction, inputIndex, outputList, rede else: p2 = 0x00 else: - p2 = 0x80 + p2 = 0x10 if continueSegwit else 0x80 apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_HASH_INPUT_START, 0x00, p2 ] params = bytearray([version, 0x00, 0x00, 0x00]) writeVarint(len(outputList), params) @@ -203,10 +205,10 @@ def startUntrustedTransaction(self, newTransaction, inputIndex, outputList, rede apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_HASH_INPUT_START, 0x80, 0x00 ] params = [] script = bytearray(redeemScript) - if ('witness' in passedOutput) and passedOutput['witness']: - params.append(0x02) - elif ('trustedInput' in passedOutput) and passedOutput['trustedInput']: + if ('trustedInput' in passedOutput) and passedOutput['trustedInput']: params.append(0x01) + elif ('witness' in passedOutput) and passedOutput['witness']: + params.append(0x02) else: params.append(0x00) if ('trustedInput' in passedOutput) and passedOutput['trustedInput']: @@ -215,8 +217,6 @@ def startUntrustedTransaction(self, newTransaction, inputIndex, outputList, rede if currentIndex != inputIndex: script = bytearray() writeVarint(len(script), params) - if len(script) == 0: - params.extend(sequence) apdu.append(len(params)) apdu.extend(params) self.dongle.exchange(bytearray(apdu)) @@ -234,6 +234,10 @@ def startUntrustedTransaction(self, newTransaction, inputIndex, outputList, rede apdu.extend(params) self.dongle.exchange(bytearray(apdu)) offset += blockLength + if len(script) == 0: + apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_HASH_INPUT_START, 0x80, 0x00, len(sequence) ] + apdu.extend(sequence) + self.dongle.exchange(bytearray(apdu)) currentIndex += 1 def finalizeInput(self, outputAddress, amount, fees, changePath, rawTx=None): @@ -269,7 +273,7 @@ def finalizeInput(self, outputAddress, amount, fees, changePath, rawTx=None): response = self.dongle.exchange(bytearray(apdu)) offset += dataLength alternateEncoding = True - except: + except Exception: pass if not alternateEncoding: apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_HASH_INPUT_FINALIZE, 0x02, 0x00 ] @@ -322,6 +326,27 @@ def untrustedHashSign(self, path, pin="", lockTime=0, sighashType=0x01): result[0] = 0x30 return result + def signMessagePrepareV1(self, path, message): + donglePath = parse_bip32_path(path) + if self.needKeyCache: + self.resolvePublicKeysInPath(path) + result = {} + apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_SIGN_MESSAGE, 0x00, 0x00 ] + params = [] + params.extend(donglePath) + params.append(len(message)) + params.extend(bytearray(message)) + apdu.append(len(params)) + apdu.extend(params) + response = self.dongle.exchange(bytearray(apdu)) + result['confirmationNeeded'] = response[0] != 0x00 + result['confirmationType'] = response[0] + if result['confirmationType'] == 0x02: + result['keycardData'] = response[1:] + if result['confirmationType'] == 0x03: + result['secureScreenData'] = response[1:] + return result + def signMessagePrepareV2(self, path, message): donglePath = parse_bip32_path(path) if self.needKeyCache: @@ -384,6 +409,23 @@ def signMessageSign(self, pin=""): response = self.dongle.exchange(bytearray(apdu)) return response + def getAppName(self): + apdu = [ self.BTCHIP_CLA_COMMON_SDK, self.BTCHIP_INS_GET_APP_NAME_AND_VERSION, 0x00, 0x00, 0x00 ] + try: + response = self.dongle.exchange(bytearray(apdu)) + name_len = response[1] + name = response[2:][:name_len] + if b'OLOS' not in name: + return name.decode('ascii') + except BTChipException as e: + if e.sw == 0x6faa: + # ins not implemented" + return None + if e.sw == 0x6d00: + # Not in an app, return just a string saying that + return "not in an app" + raise + def getFirmwareVersion(self): result = {} apdu = [ self.BTCHIP_CLA, self.BTCHIP_INS_GET_FIRMWARE_VERSION, 0x00, 0x00, 0x00 ] @@ -397,5 +439,8 @@ def getFirmwareVersion(self): raise result['compressedKeys'] = (response[0] == 0x01) result['version'] = "%d.%d.%d" % (response[2], response[3], response[4]) + result['major_version'] = response[2] + result['minor_version'] = response[3] + result['patch_version'] = response[4] result['specialVersion'] = response[1] return result diff --git a/hwilib/devices/btchip/btchipComm.py b/hwilib/devices/ledger_bitcoin/btchip/btchipComm.py similarity index 80% rename from hwilib/devices/btchip/btchipComm.py rename to hwilib/devices/ledger_bitcoin/btchip/btchipComm.py index bc878b8c2..012fbb891 100644 --- a/hwilib/devices/btchip/btchipComm.py +++ b/hwilib/devices/ledger_bitcoin/btchip/btchipComm.py @@ -142,6 +142,38 @@ def close(self): if self.opened: try: self.device.close() - except: + except Exception: pass self.opened = False + +class DongleServer(Dongle): + + def __init__(self, server, port, debug=False): + self.server = server + self.port = port + self.debug = debug + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.socket.connect((self.server, self.port)) + except Exception: + raise BTChipException("Proxy connection failed") + + def exchange(self, apdu, timeout=20000): + if self.debug: + print("=> %s" % hexlify(apdu)) + self.socket.send(struct.pack(">I", len(apdu))) + self.socket.send(apdu) + size = struct.unpack(">I", self.socket.recv(4))[0] + response = self.socket.recv(size) + sw = struct.unpack(">H", self.socket.recv(2))[0] + if self.debug: + print("<= %s%.2x" % (hexlify(response), sw)) + if sw != 0x9000: + raise BTChipException("Invalid status %04x" % sw, sw) + return bytearray(response) + + def close(self): + try: + self.socket.close() + except Exception: + pass diff --git a/hwilib/devices/btchip/btchipException.py b/hwilib/devices/ledger_bitcoin/btchip/btchipException.py similarity index 100% rename from hwilib/devices/btchip/btchipException.py rename to hwilib/devices/ledger_bitcoin/btchip/btchipException.py diff --git a/hwilib/devices/btchip/btchipHelpers.py b/hwilib/devices/ledger_bitcoin/btchip/btchipHelpers.py similarity index 100% rename from hwilib/devices/btchip/btchipHelpers.py rename to hwilib/devices/ledger_bitcoin/btchip/btchipHelpers.py diff --git a/hwilib/devices/btchip/btchipUtils.py b/hwilib/devices/ledger_bitcoin/btchip/btchipUtils.py similarity index 100% rename from hwilib/devices/btchip/btchipUtils.py rename to hwilib/devices/ledger_bitcoin/btchip/btchipUtils.py diff --git a/hwilib/devices/btchip/ledgerWrapper.py b/hwilib/devices/ledger_bitcoin/btchip/ledgerWrapper.py similarity index 100% rename from hwilib/devices/btchip/ledgerWrapper.py rename to hwilib/devices/ledger_bitcoin/btchip/ledgerWrapper.py diff --git a/hwilib/devices/ledger_bitcoin/client.py b/hwilib/devices/ledger_bitcoin/client.py new file mode 100644 index 000000000..c7691840f --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/client.py @@ -0,0 +1,270 @@ +from packaging.version import parse as parse_version +from typing import Tuple, List, Mapping, Optional, Union +import base64 +from io import BytesIO, BufferedReader + +from .errors import UnknownDeviceError + +from .command_builder import BitcoinCommandBuilder, BitcoinInsType +from ...common import Chain +from .common import read_uint, read_varint +from .client_command import ClientCommandInterpreter +from .client_base import Client, TransportClient, PartialSignature +from .client_legacy import LegacyClient +from .exception import DeviceException +from .merkle import get_merkleized_map_commitment +from .wallet import WalletPolicy, WalletType +from .psbt import PSBT, normalize_psbt +from ._serialize import deser_string + + +def parse_stream_to_map(f: BufferedReader) -> Mapping[bytes, bytes]: + result = {} + while True: + try: + key = deser_string(f) + except Exception: + break + + # Check for separator + if len(key) == 0: + break + + value = deser_string(f) + + result[key] = value + return result + + +def _make_partial_signature(pubkey_augm: bytes, signature: bytes) -> PartialSignature: + if len(pubkey_augm) == 64: + # tapscript spend: pubkey_augm is the concatenation of: + # - a 32-byte x-only pubkey + # - the 32-byte tapleaf_hash + return PartialSignature(signature=signature, pubkey=pubkey_augm[0:32], tapleaf_hash=pubkey_augm[32:]) + + else: + # either legacy, segwit or taproot keypath spend + # pubkey must be 32 (taproot x-only pubkey) or 33 bytes (compressed pubkey) + + if len(pubkey_augm) not in [32, 33]: + raise UnknownDeviceError(f"Invalid pubkey length returned: {len(pubkey_augm)}") + + return PartialSignature(signature=signature, pubkey=pubkey_augm) + + +class NewClient(Client): + # internal use for testing: if set to True, sign_psbt will not clone the psbt before converting to psbt version 2 + _no_clone_psbt: bool = False + + def __init__(self, comm_client: TransportClient, chain: Chain = Chain.MAIN, debug: bool = False) -> None: + super().__init__(comm_client, chain, debug) + self.builder = BitcoinCommandBuilder() + + # Modifies the behavior of the base method by taking care of SW_INTERRUPTED_EXECUTION responses + def _make_request( + self, apdu: dict, client_intepreter: ClientCommandInterpreter = None + ) -> Tuple[int, bytes]: + sw, response = self._apdu_exchange(apdu) + + while sw == 0xE000: + if not client_intepreter: + raise RuntimeError("Unexpected SW_INTERRUPTED_EXECUTION received.") + + command_response = client_intepreter.execute(response) + sw, response = self._apdu_exchange( + self.builder.continue_interrupted(command_response) + ) + + return sw, response + + def get_extended_pubkey(self, path: str, display: bool = False) -> str: + sw, response = self._make_request(self.builder.get_extended_pubkey(path, display)) + + if sw != 0x9000: + raise DeviceException(error_code=sw, ins=BitcoinInsType.GET_EXTENDED_PUBKEY) + + return response.decode() + + def register_wallet(self, wallet: WalletPolicy) -> Tuple[bytes, bytes]: + if wallet.version not in [WalletType.WALLET_POLICY_V1, WalletType.WALLET_POLICY_V2]: + raise ValueError("invalid wallet policy version") + + client_intepreter = ClientCommandInterpreter() + client_intepreter.add_known_preimage(wallet.serialize()) + client_intepreter.add_known_list([k.encode() for k in wallet.keys_info]) + + # necessary for version 1 of the protocol (introduced in version 2.1.0) + client_intepreter.add_known_preimage(wallet.descriptor_template.encode()) + + sw, response = self._make_request( + self.builder.register_wallet(wallet), client_intepreter + ) + + if sw != 0x9000: + raise DeviceException(error_code=sw, ins=BitcoinInsType.REGISTER_WALLET) + + if len(response) != 64: + raise RuntimeError(f"Invalid response length: {len(response)}") + + wallet_id = response[0:32] + wallet_hmac = response[32:64] + + return wallet_id, wallet_hmac + + def get_wallet_address( + self, + wallet: WalletPolicy, + wallet_hmac: Optional[bytes], + change: int, + address_index: int, + display: bool, + ) -> str: + + if not isinstance(wallet, WalletPolicy) or wallet.version not in [WalletType.WALLET_POLICY_V1, WalletType.WALLET_POLICY_V2]: + raise ValueError("wallet type must be WalletPolicy, with version either WALLET_POLICY_V1 or WALLET_POLICY_V2") + + if change != 0 and change != 1: + raise ValueError("Invalid change") + + client_intepreter = ClientCommandInterpreter() + client_intepreter.add_known_list([k.encode() for k in wallet.keys_info]) + client_intepreter.add_known_preimage(wallet.serialize()) + + # necessary for version 1 of the protocol (introduced in version 2.1.0) + client_intepreter.add_known_preimage(wallet.descriptor_template.encode()) + + sw, response = self._make_request( + self.builder.get_wallet_address( + wallet, wallet_hmac, address_index, change, display + ), + client_intepreter, + ) + + if sw != 0x9000: + raise DeviceException(error_code=sw, ins=BitcoinInsType.GET_WALLET_ADDRESS) + + return response.decode() + + def sign_psbt(self, psbt: Union[PSBT, bytes, str], wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, PartialSignature]]: + psbt = normalize_psbt(psbt) + + if psbt.version != 2: + if self._no_clone_psbt: + psbt.convert_to_v2() + psbt_v2 = psbt + else: + psbt_v2 = PSBT() + psbt_v2.deserialize(psbt.serialize()) # clone psbt + psbt_v2.convert_to_v2() + else: + psbt_v2 = psbt + + psbt_bytes = base64.b64decode(psbt_v2.serialize()) + f = BytesIO(psbt_bytes) + + # We parse the individual maps (global map, each input map, and each output map) from the psbt serialized as a + # sequence of bytes, in order to produce the serialized Merkleized map commitments. Moreover, we prepare the + # client interpreter to respond on queries on all the relevant Merkle trees and pre-images in the psbt. + + assert f.read(5) == b"psbt\xff" + + client_intepreter = ClientCommandInterpreter() + client_intepreter.add_known_list([k.encode() for k in wallet.keys_info]) + client_intepreter.add_known_preimage(wallet.serialize()) + + # necessary for version 1 of the protocol (introduced in version 2.1.0) + client_intepreter.add_known_preimage(wallet.descriptor_template.encode()) + + global_map: Mapping[bytes, bytes] = parse_stream_to_map(f) + client_intepreter.add_known_mapping(global_map) + + input_maps: List[Mapping[bytes, bytes]] = [] + for _ in range(len(psbt_v2.inputs)): + input_maps.append(parse_stream_to_map(f)) + for m in input_maps: + client_intepreter.add_known_mapping(m) + + output_maps: List[Mapping[bytes, bytes]] = [] + for _ in range(len(psbt_v2.outputs)): + output_maps.append(parse_stream_to_map(f)) + for m in output_maps: + client_intepreter.add_known_mapping(m) + + # We also add the Merkle tree of the input (resp. output) map commitments as a known tree + input_commitments = [get_merkleized_map_commitment(m_in) for m_in in input_maps] + output_commitments = [get_merkleized_map_commitment(m_out) for m_out in output_maps] + + client_intepreter.add_known_list(input_commitments) + client_intepreter.add_known_list(output_commitments) + + sw, _ = self._make_request( + self.builder.sign_psbt( + global_map, input_maps, output_maps, wallet, wallet_hmac + ), + client_intepreter, + ) + + if sw != 0x9000: + raise DeviceException(error_code=sw, ins=BitcoinInsType.SIGN_PSBT) + + # parse results and return a structured version instead + results = client_intepreter.yielded + + if any(len(x) <= 1 for x in results): + raise RuntimeError("Invalid response") + + results_list: List[Tuple[int, PartialSignature]] = [] + for res in results: + res_buffer = BytesIO(res) + input_index = read_varint(res_buffer) + + pubkey_augm_len = read_uint(res_buffer, 8) + pubkey_augm = res_buffer.read(pubkey_augm_len) + + signature = res_buffer.read() + + results_list.append((input_index, _make_partial_signature(pubkey_augm, signature))) + + return results_list + + def get_master_fingerprint(self) -> bytes: + sw, response = self._make_request(self.builder.get_master_fingerprint()) + + if sw != 0x9000: + raise DeviceException(error_code=sw, ins=BitcoinInsType.GET_EXTENDED_PUBKEY) + + return response + + def sign_message(self, message: Union[str, bytes], bip32_path: str) -> str: + if isinstance(message, str): + message_bytes = message.encode("utf-8") + else: + message_bytes = message + + chunks = [message_bytes[64 * i: 64 * i + 64] for i in range((len(message_bytes) + 63) // 64)] + + client_intepreter = ClientCommandInterpreter() + client_intepreter.add_known_list(chunks) + + sw, response = self._make_request(self.builder.sign_message(message_bytes, bip32_path), client_intepreter) + + if sw != 0x9000: + raise DeviceException(error_code=sw, ins=BitcoinInsType.SIGN_MESSAGE) + + return base64.b64encode(response).decode('utf-8') + + +def createClient(comm_client: Optional[TransportClient] = None, chain: Chain = Chain.MAIN, debug: bool = False) -> Union[LegacyClient, NewClient]: + if comm_client is None: + comm_client = TransportClient("hid") + + base_client = Client(comm_client, chain, debug) + app_name, app_version, _ = base_client.get_version() + + version = parse_version(app_version) + + # Use the legacy client if either: + # - the name of the app is "Syscoin Legacy" or "Syscoin Test Legacy" (regardless of the version) + # - the version is strictly less than 2.1 + return NewClient(comm_client, chain, debug); diff --git a/hwilib/devices/ledger_bitcoin/client_base.py b/hwilib/devices/ledger_bitcoin/client_base.py new file mode 100644 index 000000000..61efd360a --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/client_base.py @@ -0,0 +1,271 @@ +from dataclasses import dataclass +from typing import List, Tuple, Optional, Union, Literal +from io import BytesIO + +from ledgercomm.interfaces.hid_device import HID + +from .transport import Transport + +from ...common import Chain + +from .command_builder import DefaultInsType +from .exception import DeviceException + +from .wallet import WalletPolicy +from .psbt import PSBT +from ._serialize import deser_string + +try: + from speculos.client import ApduException +except ImportError: + # Speculos package not available, we use our own class + class ApduException(Exception): + def __init__(self, sw: int, data: bytes) -> None: + super().__init__(f"Exception: invalid status 0x{sw:x}") + self.sw = sw + self.data = data + + +class TransportClient: + def __init__(self, interface: Literal['hid', 'tcp'] = "tcp", *, server: str = "127.0.0.1", port: int = 9999, path: Optional[str] = None, hid: Optional[HID] = None, debug: bool = False): + self.transport = Transport('hid', path=path, hid=hid, debug=debug) if interface == 'hid' else Transport(interface, server=server, port=port, debug=debug) + + def apdu_exchange( + self, cla: int, ins: int, data: bytes = b"", p1: int = 0, p2: int = 0 + ) -> bytes: + sw, data = self.transport.exchange(cla, ins, p1, p2, None, data) + + if sw != 0x9000: + raise ApduException(sw, data) + + return data + + def apdu_exchange_nowait( + self, cla: int, ins: int, data: bytes = b"", p1: int = 0, p2: int = 0 + ): + raise NotImplementedError() + + def stop(self) -> None: + self.transport.close() + + +def print_apdu(apdu_dict: dict) -> None: + serialized_apdu = b''.join([ + apdu_dict["cla"].to_bytes(1, byteorder='big'), + apdu_dict["ins"].to_bytes(1, byteorder='big'), + apdu_dict["p1"].to_bytes(1, byteorder='big'), + apdu_dict["p2"].to_bytes(1, byteorder='big'), + len(apdu_dict["data"]).to_bytes(1, byteorder='big'), + apdu_dict["data"] + ]) + print(f"=> {serialized_apdu.hex()}") + + +def print_response(sw: int, data: bytes) -> None: + print(f"<= {data.hex()}{sw.to_bytes(2, byteorder='big').hex()}") + + +@dataclass(frozen=True) +class PartialSignature: + """Represents a partial signature returned by sign_psbt. + + It always contains a pubkey and a signature. + The pubkey + + The tapleaf_hash is also filled if signing a for a tapscript. + """ + pubkey: bytes + signature: bytes + tapleaf_hash: Optional[bytes] = None + + +class Client: + def __init__(self, transport_client: TransportClient, chain: Chain = Chain.MAIN, debug: bool = False) -> None: + self.transport_client = transport_client + self.chain = chain + self.debug = debug + + def _apdu_exchange(self, apdu: dict) -> Tuple[int, bytes]: + try: + if self.debug: + print_apdu(apdu) + + response = self.transport_client.apdu_exchange(**apdu) + if self.debug: + print_response(0x9000, response) + + return 0x9000, response + except ApduException as e: + if self.debug: + print_response(e.sw, e.data) + + return e.sw, e.data + + def _make_request(self, apdu: dict) -> Tuple[int, bytes]: + return self._apdu_exchange(apdu) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.transport_client.stop() + + def stop(self) -> None: + """Stops the transport_client.""" + + self.transport_client.stop() + + def get_version(self) -> Tuple[str, str, bytes]: + """Queries the hardware wallet for the currently running app's name, version and state flags. + + Returns + ------- + Tuple[str, str, bytes] + The first element is the app's name, as a short string. + The second element is the app's version. + The third element is a binary string representing the platform's global state (pin lock etc). + """ + + sw, response = self._make_request( + {"cla": 0xB0, "ins": DefaultInsType.GET_VERSION, "p1": 0, "p2": 0, "data": b''}) + + if sw != 0x9000: + raise DeviceException( + error_code=sw, ins=DefaultInsType.GET_VERSION) + + r = BytesIO(response) + + format = r.read(1) + + app_name = deser_string(r) + app_version = deser_string(r) + app_flags = deser_string(r) + + if format != b'\1' or app_name == b'' or app_version == b'' or app_flags == b'': + raise DeviceException(error_code=sw, ins=DefaultInsType.GET_VERSION, + message="Invalid format returned by GET_VERSION") + + return app_name.decode(), app_version.decode(), app_flags + + def get_extended_pubkey(self, path: str, display: bool = False) -> str: + """Gets the serialized extended public key for certain BIP32 path. Optionally, validate with the user. + + Parameters + ---------- + path : str + BIP32 path of the public key you want. + display : bool + Whether you want to display address and ask confirmation on the device. + + Returns + ------- + str + The requested serialized extended public key. + """ + + raise NotImplementedError + + def register_wallet(self, wallet: WalletPolicy) -> Tuple[bytes, bytes]: + """Registers a wallet policy with the user. After approval returns the wallet id and hmac to be stored on the client. + + Parameters + ---------- + wallet : WalletPolicy + The Wallet policy to register on the device. + + Returns + ------- + Tuple[bytes, bytes] + The first element the tuple is the 32-bytes wallet id. + The second element is the hmac. + """ + + raise NotImplementedError + + def get_wallet_address( + self, + wallet: WalletPolicy, + wallet_hmac: Optional[bytes], + change: int, + address_index: int, + display: bool, + ) -> str: + """For a given wallet that was already registered on the device (or a standard wallet that does not need registration), + returns the address for a certain `change`/`address_index` combination. + + Parameters + ---------- + wallet : WalletPolicy + The registered wallet policy, or a standard wallet policy. + + wallet_hmac: Optional[bytes] + For a registered wallet, the hmac obtained at wallet registration. `None` for a standard wallet policy. + + change: int + 0 for a standard receive address, 1 for a change address. Other values are invalid. + + address_index: int + The address index in the last step of the BIP32 derivation. + + display: bool + Whether you want to display address and ask confirmation on the device. + + Returns + ------- + str + The requested address. + """ + + raise NotImplementedError + + def sign_psbt(self, psbt: Union[PSBT, bytes, str], wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, PartialSignature]]: + """Signs a PSBT using a registered wallet (or a standard wallet that does not need registration). + + Signature requires explicit approval from the user. + + Parameters + ---------- + psbt : PSBT | bytes | str + A PSBT of version 0 or 2, with all the necessary information to sign the inputs already filled in; what the + required fields changes depending on the type of input. + The non-witness UTXO must be present for both legacy and SegWit inputs, or the hardware wallet will reject + signing (this will change for Taproot inputs). + The argument can be either a `PSBT` object, or `bytes`, or a base64-encoded `str`. + + wallet : WalletPolicy + The registered wallet policy, or a standard wallet policy. + + wallet_hmac: Optional[bytes] + For a registered wallet, the hmac obtained at wallet registration. `None` for a standard wallet policy. + + Returns + ------- + List[Tuple[int, PartialSignature]] + A list of tuples returned by the hardware wallets, where each element is a tuple of: + - an integer, the index of the input being signed; + - an instance of `PartialSignature`. + """ + + raise NotImplementedError + + def get_master_fingerprint(self) -> bytes: + """Gets the fingerprint of the master public key, as per BIP-32. + + Returns + ------- + bytes + The fingerprint of the master public key, as an array of 4 bytes. + """ + + raise NotImplementedError + + def sign_message(self, message: Union[str, bytes], bip32_path: str) -> str: + """ + Sign a message (bitcoin message signing). + Signs a message using the legacy Bitcoin Core signed message format. + The message is signed with the key at the given path. + :param message: The message to be signed. First encoded as bytes if not already. + :param bip32_path: The BIP 32 derivation for the key to sign the message with. + :return: The signature + """ + raise NotImplementedError diff --git a/hwilib/devices/ledger_bitcoin/client_command.py b/hwilib/devices/ledger_bitcoin/client_command.py new file mode 100644 index 000000000..7deb8ed37 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/client_command.py @@ -0,0 +1,336 @@ +from enum import IntEnum +from typing import List, Mapping +from collections import deque +from hashlib import sha256 + +from .common import ByteStreamParser +from ...common import sha256 +from ..._serialize import ser_compact_size +from .merkle import MerkleTree, element_hash + + +class ClientCommandCode(IntEnum): + YIELD = 0x10 + GET_PREIMAGE = 0x40 + GET_MERKLE_LEAF_PROOF = 0x41 + GET_MERKLE_LEAF_INDEX = 0x42 + GET_MORE_ELEMENTS = 0xA0 + + +class ClientCommand: + def execute(self, request: bytes) -> bytes: + raise NotImplementedError("Subclasses should implement this method.") + + @property + def code(self) -> int: + raise NotImplementedError("Subclasses should implement this method.") + + +class YieldCommand(ClientCommand): + def __init__(self, results: List[bytes]): + self.results = results + + @property + def code(self) -> int: + return ClientCommandCode.YIELD + + def execute(self, request: bytes) -> bytes: + self.results.append(request[1:]) # only skip the first byte (command code) + return b"" + + +class GetPreimageCommand(ClientCommand): + def __init__(self, known_preimages: Mapping[bytes, bytes], queue: "deque[bytes]"): + self.queue = queue + self.known_preimages = known_preimages + + @property + def code(self) -> int: + return ClientCommandCode.GET_PREIMAGE + + def execute(self, request: bytes) -> bytes: + req = ByteStreamParser(request[1:]) + + if req.read_bytes(1) != b'\0': + raise RuntimeError(f"Unsupported request: the first byte should be 0") + + req_hash = req.read_bytes(32) + req.assert_empty() + + if req_hash in self.known_preimages: + known_preimage = self.known_preimages[req_hash] + + preimage_len_out = ser_compact_size(len(known_preimage)) + + # We can send at most 255 - len(preimage_len_out) - 1 bytes in a single message; + # the rest will be stored for GET_MORE_ELEMENTS + + max_payload_size = 255 - len(preimage_len_out) - 1 + + payload_size = min(max_payload_size, len(known_preimage)) + + if payload_size < len(known_preimage): + # split into list of length-1 bytes elements + extra_elements = [ + known_preimage[i: i + 1] + for i in range(payload_size, len(known_preimage)) + ] + # add to the queue any remaining extra bytes + self.queue.extend(extra_elements) + + return ( + preimage_len_out + + payload_size.to_bytes(1, byteorder="big") + + known_preimage[:payload_size] + ) + + # not found + raise RuntimeError(f"Requested unknown preimage for: {req_hash.hex()}") + + +class GetMerkleLeafProofCommand(ClientCommand): + def __init__(self, known_trees: Mapping[bytes, MerkleTree], queue: "deque[bytes]"): + self.queue = queue + self.known_trees = known_trees + + @property + def code(self) -> int: + return ClientCommandCode.GET_MERKLE_LEAF_PROOF + + def execute(self, request: bytes) -> bytes: + req = ByteStreamParser(request[1:]) + + root = req.read_bytes(32) + tree_size = req.read_varint() + leaf_index = req.read_varint() + req.assert_empty() + + if not root in self.known_trees: + raise ValueError(f"Unknown Merkle root: {root.hex()}.") + + mt: MerkleTree = self.known_trees[root] + + if leaf_index >= tree_size or len(mt) != tree_size: + raise ValueError(f"Invalid index or tree size.") + + if len(self.queue) != 0: + raise RuntimeError( + "This command should not execute when the queue is not empty." + ) + + proof = mt.prove_leaf(leaf_index) + + # Compute how many elements we can fit in 255 - 32 - 1 - 1 = 221 bytes + n_response_elements = min((255 - 32 - 1 - 1) // 32, len(proof)) + n_leftover_elements = len(proof) - n_response_elements + + # Add to the queue any proof elements that do not fit the response + if (n_leftover_elements > 0): + self.queue.extend(proof[-n_leftover_elements:]) + + return b"".join( + [ + mt.get(leaf_index), + len(proof).to_bytes(1, byteorder="big"), + n_response_elements.to_bytes(1, byteorder="big"), + *proof[:n_response_elements], + ] + ) + + +class GetMerkleLeafIndexCommand(ClientCommand): + def __init__(self, known_trees: Mapping[bytes, MerkleTree]): + self.known_trees = known_trees + + @property + def code(self) -> int: + return ClientCommandCode.GET_MERKLE_LEAF_INDEX + + def execute(self, request: bytes) -> bytes: + req = ByteStreamParser(request[1:]) + + root = req.read_bytes(32) + leaf_hash = req.read_bytes(32) + req.assert_empty() + + if root not in self.known_trees: + raise ValueError(f"Unknown Merkle root: {root.hex()}.") + + try: + leaf_index = self.known_trees[root].leaf_index(leaf_hash) + found = 1 + except ValueError: + leaf_index = 0 + found = 0 + + return found.to_bytes(1, byteorder="big") + ser_compact_size(leaf_index) + + +class GetMoreElementsCommand(ClientCommand): + def __init__(self, queue: "deque[bytes]"): + self.queue = queue + + @property + def code(self) -> int: + return ClientCommandCode.GET_MORE_ELEMENTS + + def execute(self, request: bytes) -> bytes: + if len(request) != 1: + raise ValueError("Wrong request length.") + + if len(self.queue) == 0: + raise ValueError("No elements to get.") + + element_len = len(self.queue[0]) + if any(len(el) != element_len for el in self.queue): + raise ValueError( + "The queue contains elements of different byte length, which is not expected." + ) + + # pop from the queue, keeping the total response length at most 255 + + response_elements = bytearray() + + n_added_elements = 0 + while len(self.queue) > 0 and len(response_elements) + element_len <= 253: + response_elements.extend(self.queue.popleft()) + n_added_elements += 1 + + return b"".join( + [ + n_added_elements.to_bytes(1, byteorder="big"), + element_len.to_bytes(1, byteorder="big"), + bytes(response_elements), + ] + ) + + +class ClientCommandInterpreter: + """Interpreter for the client-side commands. + + This class keeps has methods to keep track of: + - known preimages + - known Merkle trees from lists of elements + + Moreover, it containes the state that is relevant for the interpreted client side commands: + - a queue of bytes that contains any bytes that could not fit in a response from the + GET_PREIMAGE client command (when a preimage is too long to fit in a single message) or the + GET_MERKLE_LEAF_PROOF command (which returns a Merkle proof, which might be too long to fit + in a single message). The data in the queue is returned in one (or more) successive + GET_MORE_ELEMENTS commands from the hardware wallet. + + Finally, it keeps track of the yielded values (that is, the values sent from the hardware + wallet with a YIELD client command). + + Attributes + ---------- + yielded: list[bytes] + A list of all the value sent by the Hardware Wallet with a YIELD client command during thw + processing of an APDU. + """ + + def __init__(self): + self.known_preimages: Mapping[bytes, bytes] = {} + self.known_trees: Mapping[bytes, MerkleTree] = {} + + self.yielded: List[bytes] = [] + + queue = deque() + + commands = [ + YieldCommand(self.yielded), + GetPreimageCommand(self.known_preimages, queue), + GetMerkleLeafIndexCommand(self.known_trees), + GetMerkleLeafProofCommand(self.known_trees, queue), + GetMoreElementsCommand(queue), + ] + + self.commands = {cmd.code: cmd for cmd in commands} + + def execute(self, hw_response: bytes) -> bytes: + """Interprets the client command requested by the hardware wallet, returning the appropriate + response and updating the client interpreter's internal state if needed. + + Parameters + ---------- + hw_response : bytes + The data content of the SW_INTERRUPTED_EXECUTION sent by the hardware wallet. + + Returns + ------- + bytes + The result of the execution of the appropriate client side command, containing the response + to be sent via INS_CONTINUE. + """ + + if len(hw_response) == 0: + raise RuntimeError( + "Unexpected empty SW_INTERRUPTED_EXECUTION response from hardware wallet." + ) + + cmd_code = hw_response[0] + if cmd_code not in self.commands: + raise RuntimeError( + "Unexpected command code: 0x{:02X}".format(cmd_code) + ) + + return self.commands[cmd_code].execute(hw_response) + + def add_known_preimage(self, element: bytes) -> None: + """Adds a preimage to the list of known preimages. + + The client must respond with `element` when a GET_PREIMAGE command is sent with + `sha256(element)` in its request. + + Parameters + ---------- + element : bytes + An array of bytes whose preimage must be known to the client during an APDU execution. + """ + + self.known_preimages[sha256(element)] = element + + def add_known_list(self, elements: List[bytes]) -> None: + """Adds a known Merkleized list. + + Builds the Merkle tree of `elements`, and adds it to the Merkle trees known to the client + (mapped by Merkle root `mt_root`). + moreover, adds all the leafs (after adding the b'\0' prefix) to the list of known preimages. + + If `el` is one of `elements`, the client must respond with b'\0' + `el` when a GET_PREIMAGE + client command is sent with `sha256(b'\0' + el)`. + Moreover, the commands GET_MERKLE_LEAF_INDEX and GET_MERKLE_LEAF_PROOF must correctly answer + queries relative to the Merkle whose root is `mt_root`. + + Parameters + ---------- + elements : List[bytes] + A list of `bytes` corresponding to the leafs of the Merkle tree. + """ + + for el in elements: + self.add_known_preimage(b"\x00" + el) + + mt = MerkleTree(element_hash(el) for el in elements) + + self.known_trees[mt.root] = mt + + def add_known_mapping(self, mapping: Mapping[bytes, bytes]) -> None: + """Adds the Merkle trees of keys, and the Merkle tree of values (ordered by key) + of a mapping of bytes to bytes. + + Adds the Merkle tree of the list of keys, and the Merkle tree of the list of corresponding + values, with the same semantics as the `add_known_list` applied separately to the two lists. + + Parameters + ---------- + mapping : Mapping[bytes, bytes] + A mapping whose keys and values are `bytes`. + """ + + items_sorted = list(sorted(mapping.items())) + + keys = [i[0] for i in items_sorted] + values = [i[1] for i in items_sorted] + self.add_known_list(keys) + self.add_known_list(values) diff --git a/hwilib/devices/ledger_bitcoin/client_legacy.py b/hwilib/devices/ledger_bitcoin/client_legacy.py new file mode 100644 index 000000000..a3c3e5f3a --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/client_legacy.py @@ -0,0 +1,356 @@ +""" +This module provides a compatibility layer between the python client of the Ledger Nano Syscoin app v2 and the v1.6.5, +by translating client requests to the API of the app v1.6.5. + +The bulk of the code is taken from bitcoin-core/HWI, with the necessary adaptations. +https://github.com/bitcoin-core/HWI/tree/a109bcd53d24a52e72f26af3ecbabb64b292ff0c, +""" + +import struct +import re +import base64 + +from .client_base import PartialSignature +from .client import Client, TransportClient + +from typing import List, Tuple, Optional, Union + +from ...common import AddressType, Chain, hash160 +from .key import ExtendedKey, parse_path +from .psbt import PSBT, normalize_psbt +from .wallet import WalletPolicy + +from ._script import is_p2sh, is_witness, is_p2wpkh, is_p2wsh + +from .btchip.btchip import btchip +from .btchip.btchipUtils import compress_public_key +from .btchip.bitcoinTransaction import bitcoinTransaction + + +def get_address_type_for_policy(policy: WalletPolicy) -> AddressType: + if policy.descriptor_template in ["pkh(@0/**)", "pkh(@0/<0;1>/*)"]: + return AddressType.LEGACY + elif policy.descriptor_template in ["wpkh(@0/**)", "wpkh(@0/<0:1>/*)"]: + return AddressType.WIT + elif policy.descriptor_template in ["sh(wpkh(@0/**))", "sh(wpkh(@0/<0;1>/*))"]: + return AddressType.SH_WIT + else: + raise ValueError("Invalid or unsupported policy") + + +# minimal checking of string keypath +# taken from HWI +def check_keypath(key_path: str) -> bool: + parts = re.split("/", key_path) + if parts[0] != "m": + return False + # strip hardening chars + for index in parts[1:]: + index_int = re.sub('[hH\']', '', index) + if not index_int.isdigit(): + return False + if int(index_int) > 0x80000000: + return False + return True + + +class DongleAdaptor: + # TODO: type for comm_client + def __init__(self, comm_client): + self.comm_client = comm_client + + def exchange(self, apdu: Union[bytes, bytearray]) -> bytearray: + cla = apdu[0] + ins = apdu[1] + p1 = apdu[2] + p2 = apdu[3] + lc = apdu[4] + data = apdu[5:] + assert len(data) == lc + return bytearray(self.comm_client.apdu_exchange(cla, ins, data, p1, p2)) + +class LegacyClient(Client): + """Wrapper for Ledger Syscoin app before version 2.0.0.""" + + def __init__(self, comm_client: TransportClient, chain: Chain = Chain.MAIN, debug: bool = False): + super().__init__(comm_client, chain, debug) + + self.app = btchip(DongleAdaptor(comm_client)) + + if self.app.getAppName() not in ["Syscoin", "Syscoin Legacy", "Syscoin Test", "Syscoin Test Legacy", "app"]: + raise ValueError("Ledger is not in either the Syscoin or Syscoin Testnet app") + + def get_extended_pubkey(self, path: str, display: bool = False) -> str: + # mostly taken from HWI + + path = path[2:] + path = path.replace('h', '\'') + path = path.replace('H', '\'') + + # This call returns raw uncompressed pubkey, chaincode + pubkey = self.app.getWalletPublicKey(path, display) + int_path = parse_path(path) + if len(path) > 0: + parent_path = "" + for ind in path.split("/")[:-1]: + parent_path += ind + "/" + parent_path = parent_path[:-1] + + # Get parent key fingerprint + parent = self.app.getWalletPublicKey(parent_path) + fpr = hash160(compress_public_key(parent["publicKey"]))[:4] + + child = int_path[-1] + # Special case for m + else: + child = 0 + fpr = b"\x00\x00\x00\x00" + + xpub = ExtendedKey( + version=ExtendedKey.MAINNET_PUBLIC if self.chain == Chain.MAIN else ExtendedKey.TESTNET_PUBLIC, + depth=len(path.split("/")) if len(path) > 0 else 0, + parent_fingerprint=fpr, + child_num=child, + chaincode=pubkey["chainCode"], + privkey=None, + pubkey=compress_public_key(pubkey["publicKey"]), + ) + return xpub.to_string() + + def register_wallet(self, wallet: WalletPolicy) -> Tuple[bytes, bytes]: + raise NotImplementedError # legacy app does not have this functionality + + def get_wallet_address( + self, + wallet: WalletPolicy, + wallet_hmac: Optional[bytes], + change: int, + address_index: int, + display: bool, + ) -> str: + # TODO: check keypath + + if wallet_hmac is not None or wallet.n_keys != 1: + raise NotImplementedError("Policy wallets are only supported from version 2.0.0. Please update your Ledger hardware wallet") + + if not isinstance(wallet, WalletPolicy): + raise ValueError("Invalid wallet policy type, it must be WalletPolicy") + + key_info = wallet.keys_info[0] + try: + first_slash_pos = key_info.index("/") + key_origin_end = key_info.index("]") + except ValueError: + raise ValueError("Could not extract key origin information") + + if key_info[0] != '[': + raise ValueError("Key must have key origin information") + + key_origin_path = key_info[first_slash_pos + 1: key_origin_end] + + addr_type = get_address_type_for_policy(wallet) + + p2sh_p2wpkh = addr_type == AddressType.SH_WIT + bech32 = addr_type == AddressType.WIT + output = self.app.getWalletPublicKey(f"{key_origin_path}/{change}/{address_index}", display, p2sh_p2wpkh or bech32, bech32) + assert isinstance(output["address"], str) + return output['address'][12:-2] # HACK: A bug in getWalletPublicKey results in the address being returned as the string "bytearray(b'
')". This extracts the actual address to work around this. + + def sign_psbt(self, psbt: Union[PSBT, bytes, str], wallet: WalletPolicy, wallet_hmac: Optional[bytes]) -> List[Tuple[int, PartialSignature]]: + if wallet_hmac is not None or wallet.n_keys != 1: + raise NotImplementedError("Policy wallets are only supported from version 2.0.0. Please update your Ledger hardware wallet") + + if not isinstance(wallet, WalletPolicy): + raise ValueError("Invalid wallet policy type, it must be WalletPolicy") + + if wallet.descriptor_template not in ["pkh(@0/**)", "pkh(@0/<0;1>/*)", "wpkh(@0/**)", "wpkh(@0/<0;1>/*)", "sh(wpkh(@0/**))", "sh(wpkh(@0/<0;1>/*))"]: + raise NotImplementedError("Unsupported policy") + + psbt = normalize_psbt(psbt) + + # the rest of the code is basically the HWI code, and it ignores wallet + + tx = psbt + + #c_tx = tx.get_unsigned_tx() + c_tx = tx.tx + tx_bytes = c_tx.serialize_with_witness() + + # Master key fingerprint + master_fpr = hash160(compress_public_key(self.app.getWalletPublicKey('')["publicKey"]))[:4] + # An entry per input, each with 0 to many keys to sign with + all_signature_attempts: List[List[Tuple[str, bytes]]] = [[]] * len(c_tx.vin) + + # Get the app version to determine whether to use Trusted Input for segwit + version = self.app.getFirmwareVersion() + use_trusted_segwit = (version['major_version'] == 1 and version['minor_version'] >= 4) or version['major_version'] > 1 + + # NOTE: We only support signing Segwit inputs, where we can skip over non-segwit + # inputs, or non-segwit inputs, where *all* inputs are non-segwit. This is due + # to Ledger's mutually exclusive signing steps for each type. + segwit_inputs = [] + # Legacy style inputs + legacy_inputs = [] + + has_segwit = False + has_legacy = False + + script_codes: List[bytes] = [b""] * len(c_tx.vin) + + # Detect changepath, (p2sh-)p2(w)pkh only + change_path = '' + for txout, i_num in zip(c_tx.vout, range(len(c_tx.vout))): + # Find which wallet key could be change based on hdsplit: m/.../1/k + # Wallets shouldn't be sending to change address as user action + # otherwise this will get confused + for pubkey, origin in tx.outputs[i_num].hd_keypaths.items(): + if origin.fingerprint == master_fpr and len(origin.path) > 1 and origin.path[-2] == 1: + # For possible matches, check if pubkey matches possible template + if hash160(pubkey) in txout.scriptPubKey or hash160(bytearray.fromhex("0014") + hash160(pubkey)) in txout.scriptPubKey: + change_path = '' + for index in origin.path: + change_path += str(index) + "/" + change_path = change_path[:-1] + + for txin, psbt_in, i_num in zip(c_tx.vin, tx.inputs, range(len(c_tx.vin))): + + seq_hex = txin.nSequence.to_bytes(4, byteorder="little").hex() + + scriptcode = b"" + utxo = None + if psbt_in.witness_utxo: + utxo = psbt_in.witness_utxo + if psbt_in.non_witness_utxo: + if txin.prevout.hash != psbt_in.non_witness_utxo.sha256: + raise ValueError('Input {} has a non_witness_utxo with the wrong hash'.format(i_num)) + utxo = psbt_in.non_witness_utxo.vout[txin.prevout.n] + if utxo is None: + raise Exception("PSBT is missing input utxo information, cannot sign") + scriptcode = utxo.scriptPubKey + + if is_p2sh(scriptcode): + if len(psbt_in.redeem_script) == 0: + continue + scriptcode = psbt_in.redeem_script + + is_wit, _, _ = is_witness(scriptcode) + + segwit_inputs.append({"value": txin.prevout.serialize() + struct.pack(" bytes: + master_pubkey = self.app.getWalletPublicKey("") + return hash160(compress_public_key(master_pubkey["publicKey"]))[:4] + + def sign_message(self, message: Union[str, bytes], keypath: str) -> str: + # copied verbatim from HWI + + if not check_keypath(keypath): + raise ValueError("Invalid keypath") + if isinstance(message, str): + message = bytearray(message, 'utf-8') + else: + message = bytearray(message) + keypath = keypath[2:] + # First display on screen what address you're signing for + self.app.getWalletPublicKey(keypath, True) + self.app.signMessagePrepare(keypath, message) + signature = self.app.signMessageSign() + + # Make signature into standard bitcoin format + rLength = signature[3] + r = int.from_bytes(signature[4: 4 + rLength], byteorder="big", signed=True) + s = int.from_bytes(signature[4 + rLength + 2:], byteorder="big", signed=True) + + sig = bytearray(chr(27 + 4 + (signature[0] & 0x01)), 'utf8') + r.to_bytes(32, byteorder="big", signed=False) + s.to_bytes(32, byteorder="big", signed=False) + + return base64.b64encode(sig).decode('utf-8') diff --git a/hwilib/devices/ledger_bitcoin/command_builder.py b/hwilib/devices/ledger_bitcoin/command_builder.py new file mode 100644 index 000000000..e31a42032 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/command_builder.py @@ -0,0 +1,221 @@ +import enum +from typing import List, Tuple, Mapping, Union, Iterator, Optional + +from .merkle import get_merkleized_map_commitment, MerkleTree, element_hash +from .wallet import WalletPolicy +from ..._serialize import ser_compact_size + +def bip32_path_from_string(path: str) -> List[bytes]: + splitted_path: List[str] = path.split("/") + + if not splitted_path: + raise Exception(f"BIP32 path format error: '{path}'") + + if "m" in splitted_path and splitted_path[0] == "m": + splitted_path = splitted_path[1:] + + return [int(p).to_bytes(4, byteorder="big") if "'" not in p + else (0x80000000 | int(p[:-1])).to_bytes(4, byteorder="big") + for p in splitted_path] + +# p2 encodes the protocol version implemented +CURRENT_PROTOCOL_VERSION = 1 + +def chunkify(data: bytes, chunk_len: int) -> Iterator[Tuple[bool, bytes]]: + size: int = len(data) + + if size <= chunk_len: + yield True, data + return + + chunk: int = size // chunk_len + remaining: int = size % chunk_len + offset: int = 0 + + for i in range(chunk): + yield False, data[offset: offset + chunk_len] + offset += chunk_len + + if remaining: + yield True, data[offset:] + + +class DefaultInsType(enum.IntEnum): + GET_VERSION = 0x01 + +class BitcoinInsType(enum.IntEnum): + GET_EXTENDED_PUBKEY = 0x00 + REGISTER_WALLET = 0x02 + GET_WALLET_ADDRESS = 0x03 + SIGN_PSBT = 0x04 + GET_MASTER_FINGERPRINT = 0x05 + SIGN_MESSAGE = 0x10 + +class FrameworkInsType(enum.IntEnum): + CONTINUE_INTERRUPTED = 0x01 + + +class BitcoinCommandBuilder: + """APDU command builder for the Bitcoin application.""" + + CLA_DEFAULT: int = 0xB0 + CLA_BITCOIN: int = 0xE1 + CLA_FRAMEWORK: int = 0xF8 + + def serialize( + self, + cla: int, + ins: Union[int, enum.IntEnum], + p1: int = 0, + p2: int = CURRENT_PROTOCOL_VERSION, + cdata: bytes = b"", + ) -> dict: + """Serialize the whole APDU command (header + data). + + Parameters + ---------- + cla : int + Instruction class: CLA (1 byte) + ins : Union[int, IntEnum] + Instruction code: INS (1 byte) + p1 : int + Instruction parameter 1: P1 (1 byte). + p2 : int + Instruction parameter 2: P2 (1 byte). + cdata : bytes + Bytes of command data. + + Returns + ------- + dict + Dictionary representing the APDU message. + + """ + + return {"cla": cla, "ins": ins, "p1": p1, "p2": p2, "data": cdata} + + def get_extended_pubkey(self, bip32_path: str, display: bool = False): + bip32_path: List[bytes] = bip32_path_from_string(bip32_path) + + cdata: bytes = b"".join([ + b'\1' if display else b'\0', + len(bip32_path).to_bytes(1, byteorder="big"), + *bip32_path + ]) + + return self.serialize( + cla=self.CLA_BITCOIN, + ins=BitcoinInsType.GET_EXTENDED_PUBKEY, + cdata=cdata, + ) + + def register_wallet(self, wallet: WalletPolicy): + wallet_bytes = wallet.serialize() + + return self.serialize( + cla=self.CLA_BITCOIN, + ins=BitcoinInsType.REGISTER_WALLET, + cdata=ser_compact_size(len(wallet_bytes)) + wallet_bytes, + ) + + def get_wallet_address( + self, + wallet: WalletPolicy, + wallet_hmac: Optional[bytes], + address_index: int, + change: bool, + display: bool, + ): + cdata: bytes = b"".join( + [ + b'\1' if display else b'\0', # 1 byte + wallet.id, # 32 bytes + wallet_hmac if wallet_hmac is not None else b'\0' * 32, # 32 bytes + b"\1" if change else b"\0", # 1 byte + address_index.to_bytes(4, byteorder="big"), # 4 bytes + ] + ) + + return self.serialize( + cla=self.CLA_BITCOIN, + ins=BitcoinInsType.GET_WALLET_ADDRESS, + cdata=cdata, + ) + + def sign_psbt( + self, + global_mapping: Mapping[bytes, bytes], + input_mappings: List[Mapping[bytes, bytes]], + output_mappings: List[Mapping[bytes, bytes]], + wallet: WalletPolicy, + wallet_hmac: Optional[bytes], + ): + + cdata = bytearray() + cdata += get_merkleized_map_commitment(global_mapping) + + cdata += ser_compact_size(len(input_mappings)) + cdata += MerkleTree( + [ + element_hash(get_merkleized_map_commitment(m_in)) + for m_in in input_mappings + ] + ).root + + cdata += ser_compact_size(len(output_mappings)) + cdata += MerkleTree( + [ + element_hash(get_merkleized_map_commitment(m_out)) + for m_out in output_mappings + ] + ).root + + cdata += wallet.id + cdata += wallet_hmac if wallet_hmac is not None else b'\0' * 32 + + return self.serialize( + cla=self.CLA_BITCOIN, ins=BitcoinInsType.SIGN_PSBT, cdata=bytes(cdata) + ) + + def get_master_fingerprint(self): + return self.serialize( + cla=self.CLA_BITCOIN, + ins=BitcoinInsType.GET_MASTER_FINGERPRINT + ) + + def sign_message(self, message: bytes, bip32_path: str): + cdata = bytearray() + + bip32_path: List[bytes] = bip32_path_from_string(bip32_path) + + # split message in 64-byte chunks (last chunk can be smaller) + n_chunks = (len(message) + 63) // 64 + chunks = [message[64 * i: 64 * i + 64] for i in range(n_chunks)] + + cdata += len(bip32_path).to_bytes(1, byteorder="big") + cdata += b''.join(bip32_path) + + cdata += ser_compact_size(len(message)) + + cdata += MerkleTree(element_hash(c) for c in chunks).root + + return self.serialize( + cla=self.CLA_BITCOIN, + ins=BitcoinInsType.SIGN_MESSAGE, + cdata=bytes(cdata) + ) + + def continue_interrupted(self, cdata: bytes): + """Command builder for CONTINUE. + + Returns + ------- + bytes + APDU command for CONTINUE. + + """ + return self.serialize( + cla=self.CLA_FRAMEWORK, + ins=FrameworkInsType.CONTINUE_INTERRUPTED, + cdata=cdata, + ) diff --git a/hwilib/devices/ledger_bitcoin/common.py b/hwilib/devices/ledger_bitcoin/common.py new file mode 100644 index 000000000..b540a52fe --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/common.py @@ -0,0 +1,68 @@ +from io import BytesIO +from typing import List, Optional, Literal +from enum import Enum +from typing import Union + +import hashlib + +UINT64_MAX: int = 18446744073709551615 +UINT32_MAX: int = 4294967295 +UINT16_MAX: int = 65535 + + +def read_varint(buf: BytesIO, + prefix: Optional[bytes] = None) -> int: + b: bytes = prefix if prefix else buf.read(1) + + if not b: + raise ValueError(f"Can't read prefix: '{b}'!") + + n: int = {b"\xfd": 2, b"\xfe": 4, b"\xff": 8}.get(b, 1) # default to 1 + + b = buf.read(n) if n > 1 else b + + if len(b) != n: + raise ValueError("Can't read varint!") + + return int.from_bytes(b, byteorder="little") + + +def read_uint(buf: BytesIO, + bit_len: int, + byteorder: Literal['big', 'little'] = 'little') -> int: + size: int = bit_len // 8 + b: bytes = buf.read(size) + + if len(b) < size: + raise ValueError(f"Can't read u{bit_len} in buffer!") + + return int.from_bytes(b, byteorder) + +class ByteStreamParser: + def __init__(self, input: bytes): + self.stream = BytesIO(input) + + def assert_empty(self) -> bytes: + if self.stream.read(1) != b'': + raise ValueError("Byte stream was expected to be empty") + + def read_bytes(self, n: int) -> bytes: + result = self.stream.read(n) + if len(result) < n: + raise ValueError("Byte stream exhausted") + return result + + def read_uint(self, n: int, byteorder: Literal['big', 'little'] = "big") -> int: + return int.from_bytes(self.read_bytes(n), byteorder) + + def read_varint(self) -> int: + prefix = self.read_uint(1) + + if prefix == 253: + return self.read_uint(2, 'little') + elif prefix == 254: + return self.read_uint(4, 'little') + elif prefix == 255: + return self.read_uint(8, 'little') + else: + return prefix diff --git a/hwilib/devices/ledger_bitcoin/errors.py b/hwilib/devices/ledger_bitcoin/errors.py new file mode 100644 index 000000000..cd32c7c10 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/errors.py @@ -0,0 +1,265 @@ +""" +Original version: https://github.com/bitcoin-core/HWI +Distributed under the MIT License. + + +Errors and Error Codes +********************** + +HWI has several possible Exceptions with corresponding error codes. + +:class:`~hwilib.hwwclient.HardwareWalletClient` functions and :mod:`~hwilib.commands` functions will generally raise an exception that is a subclass of :class:`HWWError`. +The HWI command line tool will convert these exceptions into a dictionary containing the error message and error code. +These look like ``{"error": "", "code": }``. +""" + +from typing import Any, Dict, Iterator, Optional +from contextlib import contextmanager + +# Error codes +NO_DEVICE_TYPE = -1 #: Device type was not specified +MISSING_ARGUMENTS = -2 #: Arguments are missing +DEVICE_CONN_ERROR = -3 #: Error connecting to the device +UNKNWON_DEVICE_TYPE = -4 #: Device type is unknown +INVALID_TX = -5 #: Transaction is invalid +NO_PASSWORD = -6 #: No password provided, but one is needed +BAD_ARGUMENT = -7 #: Bad, malformed, or conflicting argument was provided +NOT_IMPLEMENTED = -8 #: Function is not implemented +UNAVAILABLE_ACTION = -9 #: Function is not available for this device +DEVICE_ALREADY_INIT = -10 #: Device is already initialized +DEVICE_ALREADY_UNLOCKED = -11 #: Device is already unlocked +DEVICE_NOT_READY = -12 #: Device is not ready +UNKNOWN_ERROR = -13 #: An unknown error occurred +ACTION_CANCELED = -14 #: Action was canceled by the user +DEVICE_BUSY = -15 #: Device is busy +NEED_TO_BE_ROOT = -16 #: User needs to be root to perform action +HELP_TEXT = -17 #: Help text was requested by the user +DEVICE_NOT_INITIALIZED = -18 #: Device is not initialized + +# Exceptions +class HWWError(Exception): + """ + Generic exception type produced by HWI + Subclassed by specific Errors to have Exceptions that have specific error codes. + + Contains a message and error code. + """ + + def __init__(self, msg: str, code: int) -> None: + """ + Create an exception with the message and error code + + :param msg: The error message + :param code: The error code + """ + Exception.__init__(self) + self.code = code + self.msg = msg + + def get_code(self) -> int: + """ + Get the error code for this Error + + :return: The error code + """ + return self.code + + def get_msg(self) -> str: + """ + Get the error message for this Error + + :return: The error message + """ + return self.msg + + def __str__(self) -> str: + return self.msg + +class NoPasswordError(HWWError): + """ + :class:`HWWError` for :data:`NO_PASSWORD` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, NO_PASSWORD) + +class UnavailableActionError(HWWError): + """ + :class:`HWWError` for :data:`UNAVAILABLE_ACTION` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, UNAVAILABLE_ACTION) + +class DeviceAlreadyInitError(HWWError): + """ + :class:`HWWError` for :data:`DEVICE_ALREADY_INIT` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, DEVICE_ALREADY_INIT) + +class DeviceNotReadyError(HWWError): + """ + :class:`HWWError` for :data:`DEVICE_NOT_READY` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, DEVICE_NOT_READY) + +class DeviceAlreadyUnlockedError(HWWError): + """ + :class:`HWWError` for :data:`DEVICE_ALREADY_UNLOCKED` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, DEVICE_ALREADY_UNLOCKED) + +class UnknownDeviceError(HWWError): + """ + :class:`HWWError` for :data:`DEVICE_TYPE` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, UNKNWON_DEVICE_TYPE) + +class NotImplementedError(HWWError): + """ + :class:`HWWError` for :data:`NOT_IMPLEMENTED` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, NOT_IMPLEMENTED) + +class PSBTSerializationError(HWWError): + """ + :class:`HWWError` for :data:`INVALID_TX` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, INVALID_TX) + +class BadArgumentError(HWWError): + """ + :class:`HWWError` for :data:`BAD_ARGUMENT` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, BAD_ARGUMENT) + +class DeviceFailureError(HWWError): + """ + :class:`HWWError` for :data:`UNKNOWN_ERROR` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, UNKNOWN_ERROR) + +class ActionCanceledError(HWWError): + """ + :class:`HWWError` for :data:`ACTION_CANCELED` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, ACTION_CANCELED) + +class DeviceConnectionError(HWWError): + """ + :class:`HWWError` for :data:`DEVICE_CONN_ERROR` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, DEVICE_CONN_ERROR) + +class DeviceBusyError(HWWError): + """ + :class:`HWWError` for :data:`DEVICE_BUSY` + """ + + def __init__(self, msg: str): + """ + :param msg: The error message + """ + HWWError.__init__(self, msg, DEVICE_BUSY) + +class NeedsRootError(HWWError): + def __init__(self, msg: str): + HWWError.__init__(self, msg, NEED_TO_BE_ROOT) + +@contextmanager +def handle_errors( + msg: Optional[str] = None, + result: Optional[Dict[str, Any]] = None, + code: int = UNKNOWN_ERROR, + debug: bool = False, +) -> Iterator[None]: + """ + Context manager to catch all Exceptions and HWWErrors to return them as dictionaries containing the error message and code. + + :param msg: Error message prefix. Attached to the beginning of each error message + :param result: The dictionary to put the resulting error in + :param code: The default error code to use for Exceptions + :param debug: Whether to also print out the traceback for debugging purposes + """ + if result is None: + result = {} + + if msg is None: + msg = "" + else: + msg = msg + " " + + try: + yield + + except HWWError as e: + result['error'] = msg + e.get_msg() + result['code'] = e.get_code() + except Exception as e: + result['error'] = msg + str(e) + result['code'] = code + if debug: + import traceback + traceback.print_exc() + return result + + +common_err_msgs = { + "enumerate": "Could not open client or get fingerprint information:" +} diff --git a/hwilib/devices/ledger_bitcoin/exception/__init__.py b/hwilib/devices/ledger_bitcoin/exception/__init__.py new file mode 100644 index 000000000..7a0571c95 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/exception/__init__.py @@ -0,0 +1,27 @@ +from .device_exception import DeviceException +from .errors import (UnknownDeviceError, + DenyError, + IncorrectDataError, + NotSupportedError, + WrongP1P2Error, + WrongDataLengthError, + InsNotSupportedError, + ClaNotSupportedError, + WrongResponseLengthError, + BadStateError, + SignatureFailError) + +__all__ = [ + "DeviceException", + "DenyError", + "IncorrectDataError", + "NotSupportedError", + "UnknownDeviceError", + "WrongP1P2Error", + "WrongDataLengthError", + "InsNotSupportedError", + "ClaNotSupportedError", + "WrongResponseLengthError", + "BadStateError", + "SignatureFailError" +] diff --git a/hwilib/devices/ledger_bitcoin/exception/device_exception.py b/hwilib/devices/ledger_bitcoin/exception/device_exception.py new file mode 100644 index 000000000..b20a57cc7 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/exception/device_exception.py @@ -0,0 +1,36 @@ +import enum +from typing import Dict, Any, Union + +from .errors import * + + +class DeviceException(Exception): # pylint: disable=too-few-public-methods + exc: Dict[int, Any] = { + 0x6985: DenyError, + 0x6982: SecurityStatusNotSatisfiedError, + 0x6A80: IncorrectDataError, + 0x6A82: NotSupportedError, + 0x6A86: WrongP1P2Error, + 0x6A87: WrongDataLengthError, + 0x6D00: InsNotSupportedError, + 0x6E00: ClaNotSupportedError, + 0xB000: WrongResponseLengthError, + 0xB007: BadStateError, + 0xB008: SignatureFailError, + 0xE000: InterruptedExecution, # not an error + } + + def __new__(cls, + error_code: int, + ins: Union[int, enum.IntEnum, None] = None, + message: str = "" + ) -> Any: + error_message: str = (f"Error in {ins!r} command" + if ins else "Error in command") + + if error_code in DeviceException.exc: + return DeviceException.exc[error_code](hex(error_code), + error_message, + message) + + return UnknownDeviceError(hex(error_code), error_message, message) diff --git a/hwilib/devices/ledger_bitcoin/exception/errors.py b/hwilib/devices/ledger_bitcoin/exception/errors.py new file mode 100644 index 000000000..e875d08bb --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/exception/errors.py @@ -0,0 +1,51 @@ +class UnknownDeviceError(Exception): + pass + + +class SecurityStatusNotSatisfiedError(Exception): + pass + + +class DenyError(Exception): + pass + + +class IncorrectDataError(Exception): + pass + + +class NotSupportedError(Exception): + pass + + +class WrongP1P2Error(Exception): + pass + + +class WrongDataLengthError(Exception): + pass + + +class InsNotSupportedError(Exception): + pass + + +class ClaNotSupportedError(Exception): + pass + + +class WrongResponseLengthError(Exception): + pass + + +class BadStateError(Exception): + pass + + +class SignatureFailError(Exception): + pass + + +# Not really an error +class InterruptedExecution(Exception): + pass diff --git a/hwilib/devices/ledger_bitcoin/key.py b/hwilib/devices/ledger_bitcoin/key.py new file mode 100644 index 000000000..c2edbc8d9 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/key.py @@ -0,0 +1,485 @@ +# Original version: https://github.com/bitcoin-core/HWI/blob/3fe369d0379212fae1c72729a179d133b0adc872/hwilib/key.py + +#!/usr/bin/env python3 +# Copyright (c) 2020 The HWI developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +""" +Key Classes and Utilities +************************* + +Classes and utilities for working with extended public keys, key origins, and other key related things. +""" + +from . import _base58 as base58 +from ...common import ( + AddressType, + Chain, + hash256, + hash160, +) +from .errors import BadArgumentError + +import binascii +import hmac +import hashlib +import struct +from typing import ( + Dict, + List, + Optional, + Sequence, + Tuple, +) + + +HARDENED_FLAG = 1 << 31 + +p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F +n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8) + +Point = Optional[Tuple[int, int]] + +def H_(x: int) -> int: + """ + Shortcut function that "hardens" a number in a BIP44 path. + """ + return x | HARDENED_FLAG + +def is_hardened(i: int) -> bool: + """ + Returns whether an index is hardened + """ + return i & HARDENED_FLAG != 0 + + +def point_add(p1: Point, p2: Point) -> Point: + if (p1 is None): + return p2 + if (p2 is None): + return p1 + if (p1[0] == p2[0] and p1[1] != p2[1]): + return None + if (p1 == p2): + lam = (3 * p1[0] * p1[0] * pow(2 * p1[1], p - 2, p)) % p + else: + lam = ((p2[1] - p1[1]) * pow(p2[0] - p1[0], p - 2, p)) % p + x3 = (lam * lam - p1[0] - p2[0]) % p + return (x3, (lam * (p1[0] - x3) - p1[1]) % p) + + +def point_mul(p: Point, n: int) -> Point: + r = None + for i in range(256): + if ((n >> i) & 1): + r = point_add(r, p) + p = point_add(p, p) + return r + + +def deserialize_point(b: bytes) -> Point: + x = int.from_bytes(b[1:], byteorder="big") + y = pow((x * x * x + 7) % p, (p + 1) // 4, p) + if (y & 1 != b[0] & 1): + y = p - y + return (x, y) + + +def bytes_to_point(point_bytes: bytes) -> Point: + header = point_bytes[0] + if header == 4: + x = point_bytes = point_bytes[1:33] + y = point_bytes = point_bytes[33:65] + return (int(binascii.hexlify(x), 16), int(binascii.hexlify(y), 16)) + return deserialize_point(point_bytes) + +def point_to_bytes(p: Point) -> bytes: + if p is None: + raise ValueError("Cannot convert None to bytes") + return (b'\x03' if p[1] & 1 else b'\x02') + p[0].to_bytes(32, byteorder="big") + +def int_from_bytes(b: bytes) -> int: + return int(binascii.hexlify(b), 16) + +def lift_x(x: int) -> 'Point': + c = (pow(x, 3, p) + 7) % p + y = pow(c, (p + 1) // 4, p) + + assert(c == y * y % p) + + return (x, p - y if y & 1 else y) + +def tagged_hash(tag: str, data: bytes) -> bytes: + hashtag = hashlib.sha256(tag.encode()).digest() + return hashlib.sha256(hashtag + hashtag + data).digest() + + +def taproot_tweak_pubkey(pubkey: bytes, h: bytes) -> Tuple[int, bytes]: + t = int_from_bytes(tagged_hash("TapTweak", pubkey + h)) + if t >= p: + raise ValueError + Q = point_add(lift_x(int_from_bytes(pubkey)), point_mul(G, t)) + return 0 if Q[1] & 1 == 0 else 1, Q[0].to_bytes(32, byteorder="big") + + +def get_taproot_output_key(derived_key: bytes) -> bytes: + assert(len(derived_key) == 33) + p = derived_key[1:] + _, key = taproot_tweak_pubkey(p, b'') + return key + + +# An extended public key (xpub) or private key (xprv). Just a data container for now. +# Only handles deserialization of extended keys into component data to be handled by something else +class ExtendedKey(object): + """ + A BIP 32 extended public key. + """ + + MAINNET_PUBLIC = b'\x04\x88\xB2\x1E' + MAINNET_PRIVATE = b'\x04\x88\xAD\xE4' + TESTNET_PUBLIC = b'\x04\x35\x87\xCF' + TESTNET_PRIVATE = b'\x04\x35\x83\x94' + + def __init__(self, version: bytes, depth: int, parent_fingerprint: bytes, child_num: int, chaincode: bytes, privkey: Optional[bytes], pubkey: bytes) -> None: + """ + :param version: The version bytes for this xpub + :param depth: The depth of this xpub as defined in BIP 32 + :param parent_fingerprint: The 4 byte fingerprint of the parent xpub as defined in BIP 32 + :param child_num: The number of this xpub as defined in BIP 32 + :param chaincode: The chaincode of this xpub as defined in BIP 32 + :param privkey: The private key for this xpub if available + :param pubkey: The public key for this xpub + """ + self.version: bytes = version + self.is_testnet: bool = version == ExtendedKey.TESTNET_PUBLIC or version == ExtendedKey.TESTNET_PRIVATE + self.is_private: bool = version == ExtendedKey.MAINNET_PRIVATE or version == ExtendedKey.TESTNET_PRIVATE + self.depth: int = depth + self.parent_fingerprint: bytes = parent_fingerprint + self.child_num: int = child_num + self.chaincode: bytes = chaincode + self.pubkey: bytes = pubkey + self.privkey: Optional[bytes] = privkey + + @classmethod + def deserialize(cls, xpub: str) -> 'ExtendedKey': + """ + Create an :class:`~ExtendedKey` from a Base58 check encoded xpub + + :param xpub: The Base58 check encoded xpub + """ + data = base58.decode(xpub)[:-4] # Decoded xpub without checksum + return cls.from_bytes(data) + + @classmethod + def from_bytes(cls, data: bytes) -> 'ExtendedKey': + """ + Create an :class:`~ExtendedKey` from a serialized xpub + + :param xpub: The serialized xpub + """ + + version = data[0:4] + if version not in [ExtendedKey.MAINNET_PRIVATE, ExtendedKey.MAINNET_PUBLIC, ExtendedKey.TESTNET_PRIVATE, ExtendedKey.TESTNET_PUBLIC]: + raise BadArgumentError(f"Extended key magic of {version.hex()} is invalid") + is_private = version == ExtendedKey.MAINNET_PRIVATE or version == ExtendedKey.TESTNET_PRIVATE + depth = data[4] + parent_fingerprint = data[5:9] + child_num = struct.unpack('>I', data[9:13])[0] + chaincode = data[13:45] + + if is_private: + privkey = data[46:] + pubkey = point_to_bytes(point_mul(G, int.from_bytes(privkey, byteorder="big"))) + return cls(version, depth, parent_fingerprint, child_num, chaincode, privkey, pubkey) + else: + pubkey = data[45:78] + return cls(version, depth, parent_fingerprint, child_num, chaincode, None, pubkey) + + def serialize(self) -> bytes: + """ + Serialize the ExtendedKey with the serialization format described in BIP 32. + Does not create an xpub string, but the bytes serialized here can be Base58 check encoded into one. + + :return: BIP 32 serialized extended key + """ + r = self.version + struct.pack('B', self.depth) + self.parent_fingerprint + struct.pack('>I', self.child_num) + self.chaincode + if self.is_private: + if self.privkey is None: + raise ValueError("Somehow we are private but don't have a privkey") + r += b"\x00" + self.privkey + else: + r += self.pubkey + return r + + def to_string(self) -> str: + """ + Serialize the ExtendedKey as a Base58 check encoded xpub string + + :return: Base58 check encoded xpub + """ + data = self.serialize() + checksum = hash256(data)[0:4] + return base58.encode(data + checksum) + + def get_printable_dict(self) -> Dict[str, object]: + """ + Get the attributes of this ExtendedKey as a dictionary that can be printed + + :return: Dictionary containing ExtendedKey information that can be printed + """ + d: Dict[str, object] = {} + d['testnet'] = self.is_testnet + d['private'] = self.is_private + d['depth'] = self.depth + d['parent_fingerprint'] = binascii.hexlify(self.parent_fingerprint).decode() + d['child_num'] = self.child_num + d['chaincode'] = binascii.hexlify(self.chaincode).decode() + if self.is_private and isinstance(self.privkey, bytes): + d['privkey'] = binascii.hexlify(self.privkey).decode() + d['pubkey'] = binascii.hexlify(self.pubkey).decode() + return d + + def derive_priv(self, i: int) -> 'ExtendedKey': + """ + Derive the private key at the given child index. + + :param i: The child index of the pubkey to derive + """ + if not self.privkey: + raise ValueError("Can only derive a private key from an extended private key") + + # Data to HMAC + if is_hardened(i): + data = b'\0' + self.privkey + struct.pack(">L", i) + else: + data = self.pubkey + struct.pack(">L", i) + + # Get HMAC of data + Ihmac = hmac.new(self.chaincode, data, hashlib.sha512).digest() + Il = Ihmac[:32] + Ir = Ihmac[32:] + + # Construct new key material from Il and current private key + Il_int = int.from_bytes(Il, byteorder="big") + if Il_int > n: + return None + + privkey_int = int.from_bytes(self.privkey, byteorder="big") + k_int = (Il_int + privkey_int) % n + if (k_int == 0): + return None + + privkey = k_int.to_bytes(32, byteorder="big") + pubkey = point_to_bytes(point_mul(G, k_int)) + + chaincode = Ir + fingerprint = hash160(self.pubkey)[0:4] + return ExtendedKey(ExtendedKey.TESTNET_PRIVATE if self.is_testnet else ExtendedKey.MAINNET_PRIVATE, self.depth + 1, fingerprint, i, chaincode, privkey, pubkey) + + def derive_pub(self, i: int) -> 'ExtendedKey': + """ + Derive the public key at the given child index. + + :param i: The child index of the pubkey to derive + """ + if is_hardened(i): + raise ValueError("Index cannot be larger than 2^31") + + # Data to HMAC. Same as CKDpriv() for public child key. + data = self.pubkey + struct.pack(">L", i) + + # Get HMAC of data + Ihmac = hmac.new(self.chaincode, data, hashlib.sha512).digest() + Il = Ihmac[:32] + Ir = Ihmac[32:] + + # Construct curve point Il*G+K + Il_int = int(binascii.hexlify(Il), 16) + child_pubkey = point_add(point_mul(G, Il_int), bytes_to_point(self.pubkey)) + + # Construct and return a new BIP32Key + pubkey = point_to_bytes(child_pubkey) + chaincode = Ir + fingerprint = hash160(self.pubkey)[0:4] + return ExtendedKey(ExtendedKey.TESTNET_PUBLIC if self.is_testnet else ExtendedKey.MAINNET_PUBLIC, self.depth + 1, fingerprint, i, chaincode, None, pubkey) + + def derive_priv_path(self, path: Sequence[int]) -> 'ExtendedKey': + """ + Derive the private key at the given path + + :param path: Sequence of integers for the path of the key to derive + """ + key = self + for i in path: + key = key.derive_priv(i) + return key + + def derive_pub_path(self, path: Sequence[int]) -> 'ExtendedKey': + """ + Derive the public key at the given path + + :param path: Sequence of integers for the path of the pubkey to derive + """ + key = self + for i in path: + key = key.derive_pub(i) + return key + + def neutered(self) -> 'ExtendedKey': + """ + Returns the public key corresponding to this private key. + """ + if not self.is_private: + raise ValueError("It is already a public key") + + return ExtendedKey(ExtendedKey.TESTNET_PUBLIC if self.is_testnet else ExtendedKey.MAINNET_PUBLIC, self.depth, self.parent_fingerprint, self.child_num, self.chaincode, None, self.pubkey) + + +class KeyOriginInfo(object): + """ + Object representing the origin of a key. + """ + + def __init__(self, fingerprint: bytes, path: Sequence[int]) -> None: + """ + :param fingerprint: The 4 byte BIP 32 fingerprint of a parent key from which this key is derived from + :param path: The derivation path to reach this key from the key at ``fingerprint`` + """ + self.fingerprint: bytes = fingerprint + self.path: Sequence[int] = path + + @classmethod + def deserialize(cls, s: bytes) -> 'KeyOriginInfo': + """ + Deserialize a serialized KeyOriginInfo. + They will be serialized in the same way that PSBTs serialize derivation paths + """ + fingerprint = s[0:4] + s = s[4:] + path = list(struct.unpack("<" + "I" * (len(s) // 4), s)) + return cls(fingerprint, path) + + def serialize(self) -> bytes: + """ + Serializes the KeyOriginInfo in the same way that derivation paths are stored in PSBTs + """ + r = self.fingerprint + r += struct.pack("<" + "I" * len(self.path), *self.path) + return r + + def _path_string(self) -> str: + s = "" + for i in self.path: + hardened = is_hardened(i) + i &= ~HARDENED_FLAG + s += "/" + str(i) + if hardened: + s += "h" + return s + + def to_string(self) -> str: + """ + Return the KeyOriginInfo as a string in the form ///... + This is the same way that KeyOriginInfo is shown in descriptors + """ + s = binascii.hexlify(self.fingerprint).decode() + s += self._path_string() + return s + + @classmethod + def from_string(cls, s: str) -> 'KeyOriginInfo': + """ + Create a KeyOriginInfo from the string + + :param s: The string to parse + """ + s = s.lower() + entries = s.split("/") + fingerprint = binascii.unhexlify(s[0:8]) + path: Sequence[int] = [] + if len(entries) > 1: + path = parse_path(s[9:]) + return cls(fingerprint, path) + + def get_derivation_path(self) -> str: + """ + Return the string for just the path + """ + return "m" + self._path_string() + + def get_full_int_list(self) -> List[int]: + """ + Return a list of ints representing this KeyOriginInfo. + The first int is the fingerprint, followed by the path + """ + xfp = [struct.unpack(" List[int]: + """ + Convert BIP32 path string to list of uint32 integers with hardened flags. + Several conventions are supported to set the hardened flag: -1, 1', 1h + + e.g.: "0/1h/1" -> [0, 0x80000001, 1] + + :param nstr: path string + :return: list of integers + """ + if not nstr: + return [] + + n = nstr.split("/") + + # m/a/b/c => a/b/c + if n[0] == "m": + n = n[1:] + + def str_to_harden(x: str) -> int: + if x.startswith("-"): + return H_(abs(int(x))) + elif x.endswith(("h", "'")): + return H_(int(x[:-1])) + else: + return int(x) + + try: + return [str_to_harden(x) for x in n] + except Exception: + raise ValueError("Invalid BIP32 path", nstr) + + +def get_bip44_purpose(addrtype: AddressType) -> int: + """ + Determine the BIP 44 purpose based on the given :class:`~hwilib...common.AddressType`. + + :param addrtype: The address type + """ + if addrtype == AddressType.LEGACY: + return 44 + elif addrtype == AddressType.SH_WIT: + return 49 + elif addrtype == AddressType.WIT: + return 84 + elif addrtype == AddressType.TAP: + return 86 + else: + raise ValueError("Unknown address type") + + +def get_bip44_chain(chain: Chain) -> int: + """ + Determine the BIP 44 coin type based on the Bitcoin chain type. + + For the Bitcoin mainnet chain, this returns 0. For the other chains, this returns 1. + + :param chain: The chain + """ + if chain == Chain.MAIN: + return 0 + else: + return 1 diff --git a/hwilib/devices/ledger_bitcoin/ledgercomm/README.md b/hwilib/devices/ledger_bitcoin/ledgercomm/README.md new file mode 100644 index 000000000..1fa4589f1 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/ledgercomm/README.md @@ -0,0 +1,12 @@ +# LedgerCOMM + +Modified version of the [ledgercomm library](https://github.com/LedgerHQ/ledgercomm) made at commit [83148aa4a7a0a6bcf0eefa6db240e3c011a2a6b5](https://github.com/LedgerHQ/ledgercomm/commit/83148aa4a7a0a6bcf0eefa6db240e3c011a2a6b5) + +# Changes + +The changes are only to make it work on Python 3.6 as the official library requires 3.8+ + +* Remove usage of `typing.Literal` +* Removed the cli +* Change to using relative imports instead of `ledgercomm` +* Add `hid_path` to `Transport` so that the HID path can be given instead of automatically choosing the first device diff --git a/hwilib/devices/ledger_bitcoin/ledgercomm/__init__.py b/hwilib/devices/ledger_bitcoin/ledgercomm/__init__.py new file mode 100644 index 000000000..09bcf5b37 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/ledgercomm/__init__.py @@ -0,0 +1,7 @@ +"""ledgercomm module.""" + +from .transport import Transport + +__version__ = "1.1.0" + +__all__ = ["Transport"] diff --git a/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/__init__.py b/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/__init__.py new file mode 100644 index 000000000..a7ecb3d8b --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/__init__.py @@ -0,0 +1 @@ +"""ledgercomm.interfaces module.""" diff --git a/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/comm.py b/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/comm.py new file mode 100644 index 000000000..d9f23400a --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/comm.py @@ -0,0 +1,33 @@ +"""ledgercomm.comm module.""" + +from abc import ABCMeta, abstractmethod +from typing import Tuple + + +class Comm(metaclass=ABCMeta): + """Abstract class for communication interface.""" + + @abstractmethod + def open(self) -> None: + """Just open the interface.""" + raise NotImplementedError + + @abstractmethod + def send(self, data: bytes) -> int: + """Allow to send raw bytes from the interface.""" + raise NotImplementedError + + @abstractmethod + def recv(self) -> Tuple[int, bytes]: + """Allow to receive raw bytes from the interface.""" + raise NotImplementedError + + @abstractmethod + def exchange(self, data: bytes) -> Tuple[int, bytes]: + """Allow to send and receive raw bytes from the interface.""" + raise NotImplementedError + + @abstractmethod + def close(self) -> None: + """Just close the interface.""" + raise NotImplementedError diff --git a/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/hid_device.py b/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/hid_device.py new file mode 100644 index 000000000..c9842b2ab --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/hid_device.py @@ -0,0 +1,188 @@ +"""ledgercomm.interfaces.hid_device module.""" + +from typing import List, Tuple, Optional + +try: + import hid +except ImportError: + hid = None + +from .comm import Comm +from ..log import LOG + + +class HID(Comm): + """HID class. + + Mainly used to communicate with Nano S/X through USB. + + Parameters + ---------- + vendor_id: int + Vendor ID of the device. Default to Ledger Vendor ID 0x2C97. + + Attributes + ---------- + device : hid.device + HID device connection. + path : Optional[bytes] + Path of the HID device. + __opened : bool + Whether the connection to the HID device is opened or not. + + """ + + def __init__(self, vendor_id: int = 0x2C97, hid_path: Optional[bytes] = None) -> None: + """Init constructor of HID.""" + if hid is None: + raise ImportError("hidapi is not installed, try: " + "'pip install ledgercomm[hid]'") + + self.device = hid.device() + self.path: Optional[bytes] = hid_path + self.__opened: bool = False + self.vendor_id: int = vendor_id + + def open(self) -> None: + """Open connection to the HID device. + + Returns + ------- + None + + """ + if not self.__opened: + if self.path is None: + self.path = HID.enumerate_devices(self.vendor_id)[0] + self.device.open_path(self.path) + self.device.set_nonblocking(True) + self.__opened = True + + @staticmethod + def enumerate_devices(vendor_id: int = 0x2C97) -> List[bytes]: + """Enumerate HID devices to find Nano S/X. + + Parameters + ---------- + vendor_id: int + Vendor ID of the device. Default to Ledger Vendor ID 0x2C97. + + Returns + ------- + List[bytes] + List of paths to HID devices which should be Nano S or Nano X. + + """ + devices: List[bytes] = [] + + for hid_device in hid.enumerate(vendor_id, 0): + if (hid_device.get("interface_number") == 0 or + # MacOS specific + hid_device.get("usage_page") == 0xffa0): + devices.append(hid_device["path"]) + + assert len(devices) != 0, ( + f"Can't find Ledger device with vendor_id {hex(vendor_id)}") + + return devices + + def send(self, data: bytes) -> int: + """Send `data` through HID device `self.device`. + + Parameters + ---------- + data : bytes + Bytes of data to send. + + Returns + ------- + int + Total length of data sent to the device. + + """ + if not data: + raise Exception("Can't send empty data!") + + LOG.debug("=> %s", data.hex()) + + data = int.to_bytes(len(data), 2, byteorder="big") + data + offset: int = 0 + seq_idx: int = 0 + length: int = 0 + + while offset < len(data): + # Header: channel (0x0101), tag (0x05), sequence index + header: bytes = b"\x01\x01\x05" + seq_idx.to_bytes(2, byteorder="big") + data_chunk: bytes = (header + + data[offset:offset + 64 - len(header)]) + + self.device.write(b"\x00" + data_chunk) + length += len(data_chunk) + 1 + offset += 64 - len(header) + seq_idx += 1 + + return length + + def recv(self) -> Tuple[int, bytes]: + """Receive data through HID device `self.device`. + + Blocking IO. + + Returns + ------- + Tuple[int, bytes] + A pair (sw, rdata) containing the status word and response data. + + """ + seq_idx: int = 0 + self.device.set_nonblocking(False) + data_chunk: bytes = bytes(self.device.read(64 + 1)) + self.device.set_nonblocking(True) + + assert data_chunk[:2] == b"\x01\x01" + assert data_chunk[2] == 5 + assert data_chunk[3:5] == seq_idx.to_bytes(2, byteorder="big") + + data_len: int = int.from_bytes(data_chunk[5:7], byteorder="big") + data: bytes = data_chunk[7:] + + while len(data) < data_len: + read_bytes = bytes(self.device.read(64 + 1, timeout_ms=1000)) + data += read_bytes[5:] + + sw: int = int.from_bytes(data[data_len - 2:data_len], byteorder="big") + rdata: bytes = data[:data_len - 2] + + LOG.debug("<= %s %s", rdata.hex(), hex(sw)[2:]) + + return sw, rdata + + def exchange(self, data: bytes) -> Tuple[int, bytes]: + """Exchange (send + receive) with `self.device`. + + Parameters + ---------- + data : bytes + Bytes with `data` to send. + + Returns + ------- + Tuple[int, bytes] + A pair (sw, rdata) containing the status word and reponse data. + + """ + self.send(data) + + return self.recv() # blocking IO + + def close(self) -> None: + """Close connection to HID device `self.device`. + + Returns + ------- + None + + """ + if self.__opened: + self.device.close() + self.__opened = False diff --git a/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/tcp_client.py b/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/tcp_client.py new file mode 100644 index 000000000..cc7082e54 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/ledgercomm/interfaces/tcp_client.py @@ -0,0 +1,123 @@ +"""ledgercomm.interfaces.tcp_client module.""" + +import socket +from typing import Tuple + +from .comm import Comm +from ..log import LOG + + +class TCPClient(Comm): + """TCPClient class. + + Mainly used to connect to the TCP server of the Speculos emulator. + + Parameters + ---------- + server : str + IP address of the TCP server. + port : int + Port of the TCP server. + + Attributes + ---------- + server : str + IP address of the TCP server. + port : int + Port of the TCP server. + socket : socket.socket + TCP socket to communicate with the server. + __opened : bool + Whether the TCP socket is opened or not. + + """ + + def __init__(self, server: str, port: int) -> None: + """Init constructor of TCPClient.""" + self.server: str = server + self.port: int = port + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.__opened: bool = False + + def open(self) -> None: + """Open connection to TCP socket with `self.server` and `self.port`. + + Returns + ------- + None + + """ + if not self.__opened: + self.socket.connect((self.server, self.port)) + self.__opened = True + + def send(self, data: bytes) -> int: + """Send `data` through TCP socket `self.socket`. + + Parameters + ---------- + data : bytes + Bytes of data to send. + + Returns + ------- + int + Total lenght of data sent through TCP socket. + + """ + if not data: + raise Exception("Can't send empty data!") + + LOG.debug("=> %s", data.hex()) + data_len: bytes = int.to_bytes(len(data), 4, byteorder="big") + + return self.socket.send(data_len + data) + + def recv(self) -> Tuple[int, bytes]: + """Receive data through TCP socket `self.socket`. + + Blocking IO. + + Returns + ------- + Tuple[int, bytes] + A pair (sw, rdata) containing the status word and response data. + + """ + length: int = int.from_bytes(self.socket.recv(4), byteorder="big") + rdata: bytes = self.socket.recv(length) + sw: int = int.from_bytes(self.socket.recv(2), byteorder="big") + + LOG.debug("<= %s %s", rdata.hex(), hex(sw)[2:]) + + return sw, rdata + + def exchange(self, data: bytes) -> Tuple[int, bytes]: + """Exchange (send + receive) with `self.socket`. + + Parameters + ---------- + data : bytes + Bytes with `data` to send. + + Returns + ------- + Tuple[int, bytes] + A pair (sw, rdata) containing the status word and response data. + + """ + self.send(data) + + return self.recv() # blocking IO + + def close(self) -> None: + """Close connection to TCP socket `self.socket`. + + Returns + ------- + None + + """ + if self.__opened: + self.socket.close() + self.__opened = False diff --git a/hwilib/devices/ledger_bitcoin/ledgercomm/log.py b/hwilib/devices/ledger_bitcoin/ledgercomm/log.py new file mode 100644 index 000000000..ecfe18127 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/ledgercomm/log.py @@ -0,0 +1,5 @@ +"""ledgercomm.log module.""" + +import logging + +LOG = logging.getLogger("ledgercomm") diff --git a/hwilib/devices/ledger_bitcoin/merkle.py b/hwilib/devices/ledger_bitcoin/merkle.py new file mode 100644 index 000000000..1ce722c1b --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/merkle.py @@ -0,0 +1,262 @@ +from typing import List, Iterable, Mapping + +from ...common import sha256 + +from ..._serialize import ser_compact_size + +NIL = bytes([0] * 32) + + +def floor_lg(n: int) -> int: + """Return floor(log_2(n)) for a positive integer `n`""" + + assert n > 0 + + r = 0 + t = 1 + while 2 * t <= n: + t = 2 * t + r = r + 1 + return r + + +def ceil_lg(n: int) -> int: + """Return ceiling(log_2(n)) for a positive integer `n`.""" + + assert n > 0 + + r = 0 + t = 1 + while t < n: + t = 2 * t + r = r + 1 + return r + + +def is_power_of_2(n: int) -> bool: + """For a positive integer `n`, returns `True` is `n` is a perfect power of 2, `False` otherwise.""" + + assert n >= 1 + + return n & (n - 1) == 0 + + +def largest_power_of_2_less_than(n: int) -> int: + """For an integer `n` which is at least 2, returns the largest exact power of 2 that is strictly less than `n`.""" + + assert n > 1 + + if is_power_of_2(n): + return n // 2 + else: + return 1 << floor_lg(n) + + +def element_hash(element_preimage: bytes) -> bytes: + """Computes the hash of an element to be stored in the Merkle tree.""" + + return sha256(b'\x00' + element_preimage) + + +def combine_hashes(left: bytes, right: bytes) -> bytes: + if len(left) != 32 or len(right) != 32: + raise ValueError("The elements must be 32-bytes sha256 outputs.") + + return sha256(b'\x01' + left + right) + + +# root is the only node with parent == None +# leaves have left == right == None +class Node: + def __init__(self, left, right, parent, value: bytes): + self.left = left + self.right = right + self.parent = parent + self.value = value + + def recompute_value(self): + assert self.left is not None + assert self.right is not None + self.value = combine_hashes(self.left.value, self.right.value) + + def sibling(self): + if self.parent is None: + raise IndexError("The root does not have a sibling.") + + if self.parent.left == self: + return self.parent.right + elif self.parent.right == self: + return self.parent.left + else: + raise IndexError("Invalid state: not a child of his parent.") + + +def make_tree(leaves: List[Node], begin: int, size: int) -> Node: + """Given a list of nodes, builds the left-complete Merkle tree on top of it. + The nodes in `leaves` are modified by setting their `parent` field appropriately. + It returns the root of the newly built tree. + """ + + if size == 0: + return [] + if size == 1: + return leaves[begin] + + lchild_size = largest_power_of_2_less_than(size) + + lchild = make_tree(leaves, begin, lchild_size) + rchild = make_tree(leaves, begin + lchild_size, size - lchild_size) + root = Node(lchild, rchild, None, None) + root.recompute_value() + lchild.parent = rchild.parent = root + return root + + +class MerkleTree: + """ + Maintains a dynamic vector of values and the Merkle tree built on top of it. The elements of the vector are stored + as the leaves of a binary tree. It is possible to add a new element to the vector, or change an existing element; + the hashes in the Merkle tree will be recomputed after each operation in O(log n) time, for a vector with n + elements. + The value of each internal node is the hash of the concatenation of: + - a single byte 0x01; + - the values of the left child; + - the value of the right child. + + The binary tree has the following properties (assuming the vector contains n leaves): + - There are always n - 1 internal nodes; all the internal nodes have exactly two children. + - If a subtree has n > 1 leaves, then the left subchild is a complete subtree with p leaves, where p is the largest + power of 2 smaller than n. + """ + + def __init__(self, elements: Iterable[bytes] = []): + self.leaves = [Node(None, None, None, el) for el in elements] + n_elements = len(self.leaves) + if n_elements > 0: + self.root_node = make_tree(self.leaves, 0, n_elements) + self.depth = ceil_lg(n_elements) + else: + self.root_node = None + self.depth = None + + def __len__(self) -> int: + """Return the total number of leaves in the tree.""" + return len(self.leaves) + + @property + def root(self) -> bytes: + """Return the Merkle root, or None if the tree is empty.""" + return NIL if self.root_node is None else self.root_node.value + + def copy(self): + """Return an identical copy of this Merkle tree.""" + return MerkleTree([leaf.value for leaf in self.leaves]) + + def add(self, x: bytes) -> None: + """Add an element as new leaf, and recompute the tree accordingly. Cost O(log n).""" + + if len(x) != 32: + raise ValueError("Inserted elements must be exactly 32 bytes long") + + new_leaf = Node(None, None, None, x) + self.leaves.append(new_leaf) + if len(self.leaves) == 1: + self.root_node = new_leaf + self.depth = 0 + return + + # add a new leaf + if self.depth == 0: + ltree_size = 0 + else: + # number of leaves of the left subtree of cur_root + ltree_size = 1 << (self.depth - 1) + + cur_root = self.root_node + cur_root_size = len(self.leaves) - 1 + + while not is_power_of_2(cur_root_size): + cur_root = cur_root.right + cur_root_size -= ltree_size + ltree_size /= 2 + + # node value will be computed later + new_node = Node(cur_root, new_leaf, cur_root.parent, None) + if cur_root.parent is None: + # replacing the root + self.depth += 1 + self.root_node = new_node + else: + assert cur_root.parent.right == cur_root + cur_root.parent.right = new_node + cur_root.parent = new_node + new_leaf.parent = new_node + + self.fix_up(new_node) + + def set(self, index: int, x: bytes) -> None: + """ + Set the value of the leaf at position `index` to `x`, recomputing the tree accordingly. + If `index` equals the current number of leaves, then it is equivalent to `add(x)`. + + Cost: Worst case O(log n). + """ + assert 0 <= index <= len(self.leaves) + + if not (0 <= index <= len(self.leaves)): + raise ValueError( + "The index must be at least 0, and at most the current number of leaves.") + + if len(x) != 32: + raise ValueError("Inserted elements must be exactly 32 bytes long.") + + if index == len(self.leaves): + self.add(x) + else: + self.leaves[index].value = x + self.fix_up(self.leaves[index].parent) + + def fix_up(self, node: Node): + while node is not None: + node.recompute_value() + node = node.parent + + def get(self, i: int) -> bytes: + """Return the value of the leaf with index `i`, where 0 <= i < len(self).""" + return self.leaves[i].value + + def leaf_index(self, x: bytes) -> int: + """Return the index of the leaf with hash `x`. Raises `ValueError` if not found.""" + idx = 0 + while idx < len(self): + if self.leaves[idx].value == x: + return idx + idx += 1 + raise ValueError("Leaf not found") + + def prove_leaf(self, index: int) -> List[bytes]: + """Produce the Merkle proof of membership for the leaf with the given index where 0 <= index < len(self).""" + node = self.leaves[index] + proof = [] + while node.parent is not None: + sibling = node.sibling() + assert sibling is not None + + proof.append(sibling.value) + + node = node.parent + + return proof + + +def get_merkleized_map_commitment(mapping: Mapping[bytes, bytes]) -> bytes: + """Returns a serialized Merkleized map commitment, encoded as the concatenation of: + - the number of key/value pairs, as a Bitcoin-style varint; + - the root of the Merkle tree of the keys + - the root of the Merkle tree of the values. + """ + + items_sorted = list(sorted(mapping.items())) + keys_hashes = [element_hash(i[0]) for i in items_sorted] + values_hashes = [element_hash(i[1]) for i in items_sorted] + return ser_compact_size(len(mapping)) + MerkleTree(keys_hashes).root + MerkleTree(values_hashes).root diff --git a/hwilib/devices/ledger_bitcoin/psbt.py b/hwilib/devices/ledger_bitcoin/psbt.py new file mode 100644 index 000000000..16de47d23 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/psbt.py @@ -0,0 +1,1099 @@ +# Original version: https://github.com/bitcoin-core/HWI/blob/3fe369d0379212fae1c72729a179d133b0adc872/hwilib/key.py +# Distributed under the MIT License. + +""" +PSBT Classes and Utilities +************************** +""" + +import base64 +import struct + +from io import BytesIO, BufferedReader +from typing import ( + Dict, + List, + Mapping, + MutableMapping, + Optional, + Sequence, + Set, + Tuple, + Union, +) + +from .key import KeyOriginInfo +from .errors import PSBTSerializationError +from .tx import ( + COutPoint, + CTransaction, + CTxIn, + CTxInWitness, + CTxOut, +) +from ._serialize import ( + deser_compact_size, + deser_string, + Readable, + ser_compact_size, + ser_string, + ser_uint256, + uint256_from_str, +) + +def DeserializeHDKeypath( + f: Readable, + key: bytes, + hd_keypaths: MutableMapping[bytes, KeyOriginInfo], + expected_sizes: Sequence[int], +) -> None: + """ + :meta private: + + Deserialize a serialized PSBT public key and keypath key-value pair. + + :param f: The byte stream to read the value from. + :param key: The bytes of the key of the key-value pair. + :param hd_keypaths: Dictionary of public key bytes to their :class:`~hwilib.key.KeyOriginInfo`. + :param expected_sizes: List of key lengths expected for the keypair being deserialized. + """ + if len(key) not in expected_sizes: + raise PSBTSerializationError("Size of key was not the expected size for the type partial signature pubkey. Length: {}".format(len(key))) + pubkey = key[1:] + if pubkey in hd_keypaths: + raise PSBTSerializationError("Duplicate key, input partial signature for pubkey already provided") + + hd_keypaths[pubkey] = KeyOriginInfo.deserialize(deser_string(f)) + +def SerializeHDKeypath(hd_keypaths: Mapping[bytes, KeyOriginInfo], type: bytes) -> bytes: + """ + :meta private: + + Serialize a public key to :class:`~hwilib.key.KeyOriginInfo` mapping as a PSBT key-value pair. + + :param hd_keypaths: The mapping of public key to keypath + :param type: The PSBT type bytes to use + :returns: The serialized keypaths + """ + r = b"" + for pubkey, path in sorted(hd_keypaths.items()): + r += ser_string(type + pubkey) + packed = path.serialize() + r += ser_string(packed) + return r + +class PartiallySignedInput: + """ + An object for a PSBT input map. + """ + + PSBT_IN_NON_WITNESS_UTXO = 0x00 + PSBT_IN_WITNESS_UTXO = 0x01 + PSBT_IN_PARTIAL_SIG = 0x02 + PSBT_IN_SIGHASH_TYPE = 0x03 + PSBT_IN_REDEEM_SCRIPT = 0x04 + PSBT_IN_WITNESS_SCRIPT = 0x05 + PSBT_IN_BIP32_DERIVATION = 0x06 + PSBT_IN_FINAL_SCRIPTSIG = 0x07 + PSBT_IN_FINAL_SCRIPTWITNESS = 0x08 + PSBT_IN_PREVIOUS_TXID = 0x0e + PSBT_IN_OUTPUT_INDEX = 0x0f + PSBT_IN_SEQUENCE = 0x10 + PSBT_IN_REQUIRED_TIME_LOCKTIME = 0x11 + PSBT_IN_REQUIRED_HEIGHT_LOCKTIME = 0x12 + PSBT_IN_TAP_KEY_SIG = 0x13 + PSBT_IN_TAP_SCRIPT_SIG = 0x14 + PSBT_IN_TAP_LEAF_SCRIPT = 0x15 + PSBT_IN_TAP_BIP32_DERIVATION = 0x16 + PSBT_IN_TAP_INTERNAL_KEY = 0x17 + PSBT_IN_TAP_MERKLE_ROOT = 0x18 + + def __init__(self, version: int) -> None: + self.non_witness_utxo: Optional[CTransaction] = None + self.witness_utxo: Optional[CTxOut] = None + self.partial_sigs: Dict[bytes, bytes] = {} + self.sighash: Optional[int] = None + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths: Dict[bytes, KeyOriginInfo] = {} + self.final_script_sig = b"" + self.final_script_witness = CTxInWitness() + self.prev_txid = b"" + self.prev_out: Optional[int] = None + self.sequence: Optional[int] = None + self.time_locktime: Optional[int] = None + self.height_locktime: Optional[int] = None + self.tap_key_sig = b"" + self.tap_script_sigs: Dict[Tuple[bytes, bytes], bytes] = {} + self.tap_scripts: Dict[Tuple[bytes, int], Set[bytes]] = {} + self.tap_bip32_paths: Dict[bytes, Tuple[Set[bytes], KeyOriginInfo]] = {} + self.tap_internal_key = b"" + self.tap_merkle_root = b"" + self.unknown: Dict[bytes, bytes] = {} + + self.version: int = version + + def set_null(self) -> None: + """ + Clear all values in this PSBT input map. + """ + self.non_witness_utxo = None + self.witness_utxo = None + self.partial_sigs.clear() + self.sighash = None + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths.clear() + self.final_script_sig = b"" + self.final_script_witness = CTxInWitness() + self.tap_key_sig = b"" + self.tap_script_sigs.clear() + self.tap_scripts.clear() + self.tap_bip32_paths.clear() + self.tap_internal_key = b"" + self.tap_merkle_root = b"" + self.prev_txid = b"" + self.prev_out = None + self.sequence = None + self.time_locktime = None + self.height_locktime = None + self.unknown.clear() + + def deserialize(self, f: Readable) -> None: + """ + Deserialize a serialized PSBT input. + + :param f: A byte stream containing the serialized PSBT input + """ + key_lookup: Set[bytes] = set() + + while True: + # read the key + try: + key = deser_string(f) + except Exception: + break + + # Check for separator + if len(key) == 0: + break + + # First byte of key is the type + key_type = deser_compact_size(BytesIO(key)) + + if key_type == PartiallySignedInput.PSBT_IN_NON_WITNESS_UTXO: + if key in key_lookup: + raise PSBTSerializationError("Duplicate Key, input non witness utxo already provided") + elif len(key) != 1: + raise PSBTSerializationError("non witness utxo key is more than one byte type") + self.non_witness_utxo = CTransaction() + utxo_bytes = BufferedReader(BytesIO(deser_string(f))) # type: ignore + self.non_witness_utxo.deserialize(utxo_bytes) + self.non_witness_utxo.rehash() + elif key_type == PartiallySignedInput.PSBT_IN_WITNESS_UTXO: + if key in key_lookup: + raise PSBTSerializationError("Duplicate Key, input witness utxo already provided") + elif len(key) != 1: + raise PSBTSerializationError("witness utxo key is more than one byte type") + self.witness_utxo = CTxOut() + tx_out_bytes = BufferedReader(BytesIO(deser_string(f))) # type: ignore + self.witness_utxo.deserialize(tx_out_bytes) + elif key_type == PartiallySignedInput.PSBT_IN_PARTIAL_SIG: + if len(key) != 34 and len(key) != 66: + raise PSBTSerializationError("Size of key was not the expected size for the type partial signature pubkey") + pubkey = key[1:] + if pubkey in self.partial_sigs: + raise PSBTSerializationError("Duplicate key, input partial signature for pubkey already provided") + + sig = deser_string(f) + self.partial_sigs[pubkey] = sig + elif key_type == PartiallySignedInput.PSBT_IN_SIGHASH_TYPE: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input sighash type already provided") + elif len(key) != 1: + raise PSBTSerializationError("sighash key is more than one byte type") + sighash_bytes = deser_string(f) + self.sighash = struct.unpack(" 65: + raise PSBTSerializationError("Input Taproot key path signature is longer than 65 bytes") + elif key_type == PartiallySignedInput.PSBT_IN_TAP_SCRIPT_SIG: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Taproot script signature already provided") + elif len(key) != 65: + raise PSBTSerializationError("Input Taproot script signature key is not 65 bytes") + xonly = key[1:33] + script_hash = key[33:65] + sig = deser_string(f) + if len(sig) < 64: + raise PSBTSerializationError("Input Taproot script path signature is shorter than 64 bytes") + elif len(sig) > 65: + raise PSBTSerializationError("Input Taproot script path signature is longer than 65 bytes") + self.tap_script_sigs[(xonly, script_hash)] = sig + elif key_type == PartiallySignedInput.PSBT_IN_TAP_LEAF_SCRIPT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Taproot leaf script already provided") + elif len(key) < 34: + raise PSBTSerializationError("Input Taproot leaf script key is not at least 34 bytes") + elif (len(key) - 2) % 32 != 0: + raise PSBTSerializationError("Input Taproot leaf script key's control block is not valid") + script = deser_string(f) + if len(script) == 0: + raise PSBTSerializationError("Input Taproot leaf script cannot be empty") + leaf_script = (script[:-1], int(script[-1])) + if leaf_script not in self.tap_scripts: + self.tap_scripts[leaf_script] = set() + self.tap_scripts[(script[:-1], int(script[-1]))].add(key[1:]) + elif key_type == PartiallySignedInput.PSBT_IN_TAP_BIP32_DERIVATION: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Taproot BIP 32 keypath already provided") + elif len(key) != 33: + raise PSBTSerializationError("Input Taproot BIP 32 keypath key is not 33 bytes") + xonly = key[1:33] + value = deser_string(f) + vs = BytesIO(value) + num_hashes = deser_compact_size(vs) + leaf_hashes = set() + for i in range(0, num_hashes): + leaf_hashes.add(vs.read(32)) + self.tap_bip32_paths[xonly] = (leaf_hashes, KeyOriginInfo.deserialize(vs.read())) + elif key_type == PartiallySignedInput.PSBT_IN_TAP_INTERNAL_KEY: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Taproot internal key already provided") + elif len(key) != 1: + raise PSBTSerializationError("Input Taproot internal key key is more than one byte type") + self.tap_internal_key = deser_string(f) + if len(self.tap_internal_key) != 32: + raise PSBTSerializationError("Input Taproot internal key is not 32 bytes") + elif key_type == PartiallySignedInput.PSBT_IN_TAP_MERKLE_ROOT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Taproot merkle root already provided") + elif len(key) != 1: + raise PSBTSerializationError("Input Taproot merkle root key is more than one byte type") + self.tap_merkle_root = deser_string(f) + if len(self.tap_merkle_root) != 32: + raise PSBTSerializationError("Input Taproot merkle root is not 32 bytes") + else: + if key in self.unknown: + raise PSBTSerializationError("Duplicate key, key for unknown value already provided") + unknown_bytes = deser_string(f) + self.unknown[key] = unknown_bytes + + key_lookup.add(key) + + # Make sure required PSBTv2 fields are present + if self.version >= 2: + if len(self.prev_txid) == 0: + raise PSBTSerializationError("Previous TXID is required in PSBTv2") + if self.prev_out is None: + raise PSBTSerializationError("Previous output's index is required in PSBTv2") + + def serialize(self) -> bytes: + """ + Serialize this PSBT input + + :returns: The serialized PSBT input + """ + r = b"" + + if self.non_witness_utxo: + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_NON_WITNESS_UTXO)) + tx = self.non_witness_utxo.serialize_with_witness() + r += ser_string(tx) + + if self.witness_utxo: + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_WITNESS_UTXO)) + tx = self.witness_utxo.serialize() + r += ser_string(tx) + + if len(self.final_script_sig) == 0 and self.final_script_witness.is_null(): + for pubkey, sig in sorted(self.partial_sigs.items()): + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_PARTIAL_SIG) + pubkey) + r += ser_string(sig) + + if self.sighash is not None: + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_SIGHASH_TYPE)) + r += ser_string(struct.pack("= 2: + if len(self.prev_txid) != 0: + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_PREVIOUS_TXID)) + r += ser_string(self.prev_txid) + + if self.prev_out is not None: + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_OUTPUT_INDEX)) + r += ser_string(struct.pack(" None: + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths: Dict[bytes, KeyOriginInfo] = {} + self.amount: Optional[int] = None + self.script = b"" + self.tap_internal_key = b"" + self.tap_tree = b"" + self.tap_bip32_paths: Dict[bytes, Tuple[Set[bytes], KeyOriginInfo]] = {} + self.unknown: Dict[bytes, bytes] = {} + + self.version: int = version + + def set_null(self) -> None: + """ + Clear this PSBT output map + """ + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths.clear() + self.tap_internal_key = b"" + self.tap_tree = b"" + self.tap_bip32_paths.clear() + self.amount = None + self.script = b"" + self.unknown.clear() + + def deserialize(self, f: Readable) -> None: + """ + Deserialize a serialized PSBT output map + + :param f: A byte stream containing the serialized PSBT output + """ + key_lookup: Set[bytes] = set() + + while True: + # read the key + try: + key = deser_string(f) + except Exception: + break + + # Check for separator + if len(key) == 0: + break + + # First byte of key is the type + key_type = deser_compact_size(BytesIO(key)) + + if key_type == PartiallySignedOutput.PSBT_OUT_REDEEM_SCRIPT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, output redeemScript already provided") + elif len(key) != 1: + raise PSBTSerializationError("Output redeemScript key is more than one byte type") + self.redeem_script = deser_string(f) + elif key_type == PartiallySignedOutput.PSBT_OUT_WITNESS_SCRIPT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, output witnessScript already provided") + elif len(key) != 1: + raise PSBTSerializationError("Output witnessScript key is more than one byte type") + self.witness_script = deser_string(f) + elif key_type == PartiallySignedOutput.PSBT_OUT_BIP32_DERIVATION: + DeserializeHDKeypath(f, key, self.hd_keypaths, [34, 66]) + elif key_type == PartiallySignedOutput.PSBT_OUT_AMOUNT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, output amount already provided") + elif len(key) != 1: + raise PSBTSerializationError("Output amount key is more than one byte type") + v = deser_string(f) + if len(v) != 8: + raise PSBTSerializationError("Output amount is not 8 bytes") + self.amount = struct.unpack("= 2: + if self.amount is None: + raise PSBTSerializationError("PSBT_OUTPUT_AMOUNT is required in PSBTv2") + if len(self.script) == 0: + raise PSBTSerializationError("PSBT_OUTPUT_SCRIPT is required in PSBTv2") + + def serialize(self) -> bytes: + """ + Serialize this PSBT output + + :returns: The serialized PSBT output + """ + r = b"" + if len(self.redeem_script) != 0: + r += ser_string(ser_compact_size(PartiallySignedOutput.PSBT_OUT_REDEEM_SCRIPT)) + r += ser_string(self.redeem_script) + + if len(self.witness_script) != 0: + r += ser_string(ser_compact_size(PartiallySignedOutput.PSBT_OUT_WITNESS_SCRIPT)) + r += ser_string(self.witness_script) + + r += SerializeHDKeypath(self.hd_keypaths, ser_compact_size(PartiallySignedOutput.PSBT_OUT_BIP32_DERIVATION)) + + if self.version >= 2: + if self.amount is not None: + r += ser_string(ser_compact_size(PartiallySignedOutput.PSBT_OUT_AMOUNT)) + r += ser_string(struct.pack(" CTxOut: + """ + Creates a CTxOut for this output + + :returns: The CTxOut + """ + assert self.amount is not None + assert len(self.script) != 0 + return CTxOut(self.amount, self.script) + +class PSBT(object): + """ + A class representing a PSBT + """ + + PSBT_GLOBAL_UNSIGNED_TX = 0x00 + PSBT_GLOBAL_XPUB = 0x01 + PSBT_GLOBAL_TX_VERSION = 0x02 + PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03 + PSBT_GLOBAL_INPUT_COUNT = 0x04 + PSBT_GLOBAL_OUTPUT_COUNT = 0x05 + PSBT_GLOBAL_TX_MODIFIABLE = 0x06 + PSBT_GLOBAL_VERSION = 0xFB + + def __init__(self, tx: Optional[CTransaction] = None) -> None: + """ + :param tx: A Bitcoin transaction that specifies the inputs and outputs to use + """ + if tx: + self.tx = tx + else: + self.tx = CTransaction() + self.inputs: List[PartiallySignedInput] = [] + self.outputs: List[PartiallySignedOutput] = [] + self.unknown: Dict[bytes, bytes] = {} + self.xpub: Dict[bytes, KeyOriginInfo] = {} + self.tx_version: Optional[int] = None + self.fallback_locktime: Optional[int] = None + self.tx_modifiable: Optional[int] = None + + # Assume version 0 PSBT + self.version = 0 + self.explicit_version = False + + def deserialize(self, psbt: str) -> None: + """ + Deserialize a base 64 encoded PSBT. + + :param psbt: A base 64 PSBT. + """ + psbt_bytes = base64.b64decode(psbt.strip()) + f = BufferedReader(BytesIO(psbt_bytes)) # type: ignore + end = len(psbt_bytes) + + # Read the magic bytes + magic = f.read(5) + if magic != b"psbt\xff": + raise PSBTSerializationError("invalid magic") + + key_lookup: Set[bytes] = set() + + input_count = None + output_count = None + + # Read loop + while True: + # read the key + try: + key = deser_string(f) + except Exception: + break + + # Check for separator + if len(key) == 0: + break + + # First byte of key is the type + key_type = deser_compact_size(BytesIO(key)) + + # Do stuff based on type + if key_type == PSBT.PSBT_GLOBAL_UNSIGNED_TX: + # Checks for correctness + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, unsigned tx already provided") + elif len(key) > 1: + raise PSBTSerializationError("Global unsigned tx key is more than one byte type") + + # read in value + tx_bytes = BufferedReader(BytesIO(deser_string(f))) # type: ignore + self.tx.deserialize(tx_bytes) + + # Make sure that all scriptSigs and scriptWitnesses are empty + for txin in self.tx.vin: + if len(txin.scriptSig) != 0 or not self.tx.wit.is_null(): + raise PSBTSerializationError("Unsigned tx does not have empty scriptSigs and scriptWitnesses") + elif key_type == PSBT.PSBT_GLOBAL_XPUB: + DeserializeHDKeypath(f, key, self.xpub, [79]) + elif key_type == PSBT.PSBT_GLOBAL_TX_VERSION: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, global transaction version is already provided") + elif len(key) > 1: + raise PSBTSerializationError("Global transaction version key is more than one byte type") + v = deser_string(f) + if len(v) != 4: + raise PSBTSerializationError("Global transaction version is not 4 bytes") + self.tx_version = struct.unpack(" 1: + raise PSBTSerializationError("Global fallback locktime key is more than one byte type") + v = deser_string(f) + if len(v) != 4: + raise PSBTSerializationError("Global fallback locktime is not 4 bytes") + self.fallback_locktime = struct.unpack(" 1: + raise PSBTSerializationError("Global input count key is more than one byte type") + _ = deser_compact_size(f) # Value length, we can ignore this + input_count = deser_compact_size(f) + elif key_type == PSBT.PSBT_GLOBAL_OUTPUT_COUNT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, global output count is already provided") + elif len(key) > 1: + raise PSBTSerializationError("Global output count key is more than one byte type") + _ = deser_compact_size(f) # Value length, we can ignore this + output_count = deser_compact_size(f) + elif key_type == PSBT.PSBT_GLOBAL_TX_MODIFIABLE: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, global tx modifiable flags is already provided") + elif len(key) > 1: + raise PSBTSerializationError("Global tx modifiable flags key is more than one byte type") + v = deser_string(f) + if len(v) != 1: + raise PSBTSerializationError("Global tx modifiable flags is not 1 bytes") + self.tx_modifiable = struct.unpack(" 1: + raise PSBTSerializationError("Global PSBT version key is more than one byte type") + v = deser_string(f) + if len(v) != 4: + raise PSBTSerializationError("Global PSBT version is not 1 bytes") + self.version = struct.unpack("= 2: + # Tx version, input, and output counts are required + if self.tx_version is None: + raise PSBTSerializationError("PSBT_GLOBAL_TX_VERSION is required in PSBTv2") + if input_count is None: + raise PSBTSerializationError("PSBT_GLOBAL_INPUT_COUNT is required in PSBTv2") + if output_count is None: + raise PSBTSerializationError("PSBT_GLOBAL_OUTPUT_COUNT is required in PSBTv2") + # Unsigned tx is disallowed + if not self.tx.is_null(): + raise PSBTSerializationError("PSBT_GLOBAL_UNSIGNED_TX is not allowed in PSBTv2") + + # Read input data + if input_count is None: + input_count = len(self.tx.vin) + for i in range(input_count): + if f.tell() == end: + break + psbt_in = PartiallySignedInput(self.version) + psbt_in.deserialize(f) + self.inputs.append(psbt_in) + + if self.version >= 2: + prev_txid = psbt_in.prev_txid + else: + prev_txid = ser_uint256(self.tx.vin[i].prevout.hash) + + if psbt_in.non_witness_utxo: + psbt_in.non_witness_utxo.rehash() + if psbt_in.non_witness_utxo.hash != prev_txid: + raise PSBTSerializationError("Non-witness UTXO does not match outpoint hash") + + if (len(self.inputs) != input_count): + raise PSBTSerializationError("Inputs provided does not match the number of inputs in transaction") + + # Read output data + if output_count is None: + output_count = len(self.tx.vout) + for i in range(output_count): + if f.tell() == end: + break + output = PartiallySignedOutput(self.version) + output.deserialize(f) + self.outputs.append(output) + + if len(self.outputs) != output_count: + raise PSBTSerializationError("Outputs provided does not match the number of outputs in transaction") + + self.cache_unsigned_tx_pieces() + + def serialize(self) -> str: + """ + Serialize the PSBT as a base 64 encoded string. + + :returns: The base 64 encoded string. + """ + r = b"" + + # magic bytes + r += b"psbt\xff" + + if self.version == 0: + # unsigned tx flag + r += ser_string(ser_compact_size(PSBT.PSBT_GLOBAL_UNSIGNED_TX)) + + # write serialized tx + tx = self.tx.serialize_with_witness() + r += ser_compact_size(len(tx)) + r += tx + + # write xpubs + r += SerializeHDKeypath(self.xpub, ser_compact_size(PSBT.PSBT_GLOBAL_XPUB)) + + if self.version >= 2: + assert self.tx_version is not None + r += ser_string(ser_compact_size(PSBT.PSBT_GLOBAL_TX_VERSION)) + r += ser_string(struct.pack(" 0 or self.explicit_version: + r += ser_string(ser_compact_size(PSBT.PSBT_GLOBAL_VERSION)) + r += ser_string(struct.pack(" None: + """ + If this PSBT is v0, then the global unsigned transaction will be used to fill in the PSBTv2 + fields so that all users of the PSBT classes can use the same PSBTv2 interface regardless + of PSBT version. + + Does nothing if the PSBT is already v2. + """ + # To make things easier, we split up the global transaction + # and use the PSBTv2 fields for PSBTv0 + if self.tx is not None: + self.setup_from_tx(self.tx) + + def setup_from_tx(self, tx: CTransaction): + """ + Fills in the PSBTv2 fields for this PSBT given a transaction + + :param tx: The CTransaction to fill from + """ + self.tx_version = tx.nVersion + self.fallback_locktime = tx.nLockTime + + for i, txin in enumerate(tx.vin): + psbt_in = self.inputs[i] + + psbt_in.prev_txid = ser_uint256(txin.prevout.hash) + psbt_in.prev_out = txin.prevout.n + psbt_in.sequence = txin.nSequence + + for i, txout in enumerate(tx.vout): + psbt_out = self.outputs[i] + + psbt_out.amount = txout.nValue + psbt_out.script = txout.scriptPubKey + + def compute_lock_time(self) -> int: + """ + Computes the lock time for this transaction + + :returns: The lock time + """ + time_lock: Optional[int] = 0 + height_lock: Optional[int] = 0 + + for psbt_in in self.inputs: + if psbt_in.time_locktime is not None and psbt_in.height_locktime is None: + height_lock = None + if time_lock is None: + raise PSBTSerializationError("Cannot require both time and height locktimes") + elif psbt_in.time_locktime is None and psbt_in.height_locktime is not None: + time_lock = None + if height_lock is None: + raise PSBTSerializationError("Cannot require both time and height locktimes") + + if psbt_in.time_locktime is not None and time_lock is not None: + time_lock = max(time_lock, psbt_in.time_locktime) + if psbt_in.height_locktime is not None and height_lock is not None: + height_lock = max(height_lock, psbt_in.height_locktime) + + if height_lock is not None and height_lock > 0: + return height_lock + if time_lock is not None and time_lock > 0: + return time_lock + if self.fallback_locktime is not None: + return self.fallback_locktime + return 0 + + def get_unsigned_tx(self) -> CTransaction: + """ + Get the unsigned transaction represented by this PSBT + + :return: A CTransaction + """ + if not self.tx.is_null(): + return self.tx + + assert self.tx_version is not None + + tx = CTransaction() + tx.nVersion = self.tx_version + self.nLockTime = self.compute_lock_time() + + for psbt_in in self.inputs: + assert psbt_in.prev_txid is not None + assert psbt_in.prev_out is not None + assert psbt_in.sequence is not None + + txin = CTxIn(COutPoint(uint256_from_str(psbt_in.prev_txid), psbt_in.prev_out), b"", psbt_in.sequence) + tx.vin.append(txin) + + for psbt_out in self.outputs: + assert psbt_out.amount is not None + + txout = CTxOut(psbt_out.amount, psbt_out.script) + tx.vout.append(txout) + + tx.rehash() + return tx + + def _convert_version(self, version) -> None: + self.version = version + for psbt_in in self.inputs: + psbt_in.version = version + for psbt_out in self.outputs: + psbt_out.version = version + + def convert_to_v2(self) -> None: + """ + Sets this PSBT to version 2 + """ + + if self.version == 0: + # make sure fields that PSBT version 0 fields are not present + self.setup_from_tx(self.tx) + + self._convert_version(2) + + def convert_to_v0(self) -> None: + """ + Sets this PSBT to version 0 + """ + + if self.version == 2: + # strip PSBT version 2 fields + self.tx_version = None + self.fallback_locktime = None + self.tx_modifiable = None + + self._convert_version(0) + self.tx = self.get_unsigned_tx() + self.explicit_version = False + + +def normalize_psbt(psbt: Union[PSBT, bytes, str]) -> PSBT: + """ + Deserializes a psbt given as an argument from a string or a byte array, if necessary. + + :param psbt: Either an instance of PSBT, or binary-encoded psbt as `bytes`, or a base64-encoded psbt as a `str`. + :returns: the deserialized PSBT object. If `psbt` was already a `PSBT`, it is returned directly (without cloning). + """ + if isinstance(psbt, bytes): + psbt = base64.b64encode(psbt).decode() + + if isinstance(psbt, str): + psbt_obj = PSBT() + psbt_obj.deserialize(psbt) + psbt = psbt_obj + return psbt diff --git a/hwilib/devices/ledger_bitcoin/ripemd.py b/hwilib/devices/ledger_bitcoin/ripemd.py new file mode 100644 index 000000000..ee08cc387 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/ripemd.py @@ -0,0 +1,115 @@ +# Copyright (c) 2021 Pieter Wuille +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +# +# Taken from https://github.com/bitcoin/bitcoin/blob/124e75a41ea0f3f0e90b63b0c41813184ddce2ab/test/functional/test_framework/ripemd160.py + +""" +Pure Python RIPEMD160 implementation. + +WARNING: This implementation is NOT constant-time. +Do not use without understanding the implications. +""" + +# Message schedule indexes for the left path. +ML = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + 7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8, + 3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12, + 1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2, + 4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13 +] + +# Message schedule indexes for the right path. +MR = [ + 5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12, + 6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2, + 15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13, + 8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14, + 12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11 +] + +# Rotation counts for the left path. +RL = [ + 11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8, + 7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12, + 11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5, + 11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12, + 9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6 +] + +# Rotation counts for the right path. +RR = [ + 8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6, + 9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11, + 9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5, + 15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8, + 8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11 +] + +# K constants for the left path. +KL = [0, 0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xa953fd4e] + +# K constants for the right path. +KR = [0x50a28be6, 0x5c4dd124, 0x6d703ef3, 0x7a6d76e9, 0] + + +def fi(x, y, z, i): + """The f1, f2, f3, f4, and f5 functions from the specification.""" + if i == 0: + return x ^ y ^ z + elif i == 1: + return (x & y) | (~x & z) + elif i == 2: + return (x | ~y) ^ z + elif i == 3: + return (x & z) | (y & ~z) + elif i == 4: + return x ^ (y | ~z) + else: + assert False + + +def rol(x, i): + """Rotate the bottom 32 bits of x left by i bits.""" + return ((x << i) | ((x & 0xffffffff) >> (32 - i))) & 0xffffffff + + +def compress(h0, h1, h2, h3, h4, block): + """Compress state (h0, h1, h2, h3, h4) with block.""" + # Left path variables. + al, bl, cl, dl, el = h0, h1, h2, h3, h4 + # Right path variables. + ar, br, cr, dr, er = h0, h1, h2, h3, h4 + # Message variables. + x = [int.from_bytes(block[4*i:4*(i+1)], 'little') for i in range(16)] + + # Iterate over the 80 rounds of the compression. + for j in range(80): + rnd = j >> 4 + # Perform left side of the transformation. + al = rol(al + fi(bl, cl, dl, rnd) + x[ML[j]] + KL[rnd], RL[j]) + el + al, bl, cl, dl, el = el, al, bl, rol(cl, 10), dl + # Perform right side of the transformation. + ar = rol(ar + fi(br, cr, dr, 4 - rnd) + x[MR[j]] + KR[rnd], RR[j]) + er + ar, br, cr, dr, er = er, ar, br, rol(cr, 10), dr + + # Compose old state, left transform, and right transform into new state. + return h1 + cl + dr, h2 + dl + er, h3 + el + ar, h4 + al + br, h0 + bl + cr + + +def ripemd160(data): + """Compute the RIPEMD-160 hash of data.""" + # Initialize state. + state = (0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0) + # Process full 64-byte blocks in the input. + for b in range(len(data) >> 6): + state = compress(*state, data[64*b:64*(b+1)]) + # Construct final blocks (with padding and size). + pad = b"\x80" + b"\x00" * ((119 - len(data)) & 63) + fin = data[len(data) & ~63:] + pad + (8 * len(data)).to_bytes(8, 'little') + # Process final blocks. + for b in range(len(fin) >> 6): + state = compress(*state, fin[64*b:64*(b+1)]) + # Produce output. + return b"".join((h & 0xffffffff).to_bytes(4, 'little') for h in state) diff --git a/hwilib/devices/ledger_bitcoin/transport.py b/hwilib/devices/ledger_bitcoin/transport.py new file mode 100644 index 000000000..ed0b9bf43 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/transport.py @@ -0,0 +1,263 @@ +# extracted from ledgercomm in order to add the `path` parameter to the constructor. + +import enum +import logging +import struct +from typing import Union, Tuple, Optional, Literal, cast + +from ledgercomm.interfaces.tcp_client import TCPClient +from ledgercomm.interfaces.hid_device import HID +from ledgercomm.log import LOG + + +class TransportType(enum.Enum): + """Type of interface available.""" + + HID = 1 + TCP = 2 + + +class Transport: + """Transport class to send APDUs. + + Allow to communicate using HID device such as Nano S/X or through TCP + socket with the Speculos emulator. + + Parameters + ---------- + interface : str + Either "hid" or "tcp" for the underlying communication interface. + server : str + IP address of the TCP server if interface is "tcp". + port : int + Port of the TCP server if interface is "tcp". + path : Optional[str] + The path to use with HID if interface is "hid"; defaults to `None`. + hid : Optional[HID] + The HID instance to use if interface is "hid"; defaults to `None`. + If not None, the instance is already presumed open. + debug : bool + Whether you want debug logs or not. + + Attributes + ---------- + interface : TransportType + Either TransportType.HID or TransportType.TCP. + com : Union[TCPClient, HID] + Communication interface to send/receive APDUs. + + """ + + def __init__(self, + interface: Literal["hid", "tcp"] = "tcp", + server: str = "127.0.0.1", + port: int = 9999, + path: Optional[str] = None, + hid: Optional[HID] = None, + debug: bool = False) -> None: + """Init constructor of Transport.""" + if debug: + LOG.setLevel(logging.DEBUG) + + self.inferface: TransportType + + try: + self.interface = TransportType[interface.upper()] + except KeyError as exc: + raise Exception(f"Unknown interface '{interface}'!") from exc + + if self.interface == TransportType.TCP: + self.com = TCPClient( + server=server, port=port) + self.com.open() + else: + if hid is not None: + self.com = hid + # we assume the instance is already open, when the `hid` parameter is given + else: + self.com = HID() + self.com.path = path + self.com.open() + + @staticmethod + def apdu_header(cla: int, + ins: Union[int, enum.IntEnum], + p1: int = 0, + p2: int = 0, + opt: Optional[int] = None, + lc: int = 0) -> bytes: + """Pack the APDU header as bytes. + + Parameters + ---------- + cla : int + Instruction class: CLA (1 byte) + ins : Union[int, IntEnum] + Instruction code: INS (1 byte) + p1 : int + Instruction parameter: P1 (1 byte). + p2 : int + Instruction parameter: P2 (1 byte). + opt : Optional[int] + Optional parameter: Opt (1 byte). + lc : int + Number of bytes in the payload: Lc (1 byte). + + Returns + ------- + bytes + APDU header packed with parameters. + + """ + ins = cast(int, ins.value) if isinstance( + ins, enum.IntEnum) else cast(int, ins) + + if opt: + return struct.pack("BBBBBB", + cla, + ins, + p1, + p2, + 1 + lc, # add option to length + opt) + + return struct.pack("BBBBB", + cla, + ins, + p1, + p2, + lc) + + def send(self, + cla: int, + ins: Union[int, enum.IntEnum], + p1: int = 0, + p2: int = 0, + option: Optional[int] = None, + cdata: bytes = b"") -> int: + """Send structured APDUs through `self.com`. + + Parameters + ---------- + cla : int + Instruction class: CLA (1 byte) + ins : Union[int, IntEnum] + Instruction code: INS (1 byte) + p1 : int + Instruction parameter: P1 (1 byte). + p2 : int + Instruction parameter: P2 (1 byte). + option : Optional[int] + Optional parameter: Opt (1 byte). + cdata : bytes + Command data (variable length). + + Returns + ------- + int + Total lenght of the APDU sent. + + """ + header: bytes = Transport.apdu_header( + cla, ins, p1, p2, option, len(cdata)) + + return self.com.send(header + cdata) + + def send_raw(self, apdu: Union[str, bytes]) -> int: + """Send raw bytes `apdu` through `self.com`. + + Parameters + ---------- + apdu : Union[str, bytes] + Hexstring or bytes within APDU to be sent through `self.com`. + + Returns + ------- + Optional[int] + Total lenght of APDU sent if any. + + """ + if isinstance(apdu, str): + apdu = bytes.fromhex(apdu) + + return self.com.send(apdu) + + def recv(self) -> Tuple[int, bytes]: + """Receive data from `self.com`. + + Blocking IO. + + Returns + ------- + Tuple[int, bytes] + A pair (sw, rdata) for the status word (2 bytes represented + as int) and the reponse data (variable lenght). + + """ + return self.com.recv() + + def exchange(self, + cla: int, + ins: Union[int, enum.IntEnum], + p1: int = 0, + p2: int = 0, + option: Optional[int] = None, + cdata: bytes = b"") -> Tuple[int, bytes]: + """Send structured APDUs and wait to receive datas from `self.com`. + + Parameters + ---------- + cla : int + Instruction class: CLA (1 byte) + ins : Union[int, IntEnum] + Instruction code: INS (1 byte) + p1 : int + Instruction parameter: P1 (1 byte). + p2 : int + Instruction parameter: P2 (1 byte). + option : Optional[int] + Optional parameter: Opt (1 byte). + cdata : bytes + Command data (variable length). + + Returns + ------- + Tuple[int, bytes] + A pair (sw, rdata) for the status word (2 bytes represented + as int) and the reponse data (bytes of variable lenght). + + """ + header: bytes = Transport.apdu_header( + cla, ins, p1, p2, option, len(cdata)) + + return self.com.exchange(header + cdata) + + def exchange_raw(self, apdu: Union[str, bytes]) -> Tuple[int, bytes]: + """Send raw bytes `apdu` and wait to receive datas from `self.com`. + + Parameters + ---------- + apdu : Union[str, bytes] + Hexstring or bytes within APDU to send through `self.com`. + + Returns + ------- + Tuple[int, bytes] + A pair (sw, rdata) for the status word (2 bytes represented + as int) and the reponse (bytes of variable lenght). + + """ + if isinstance(apdu, str): + apdu = bytes.fromhex(apdu) + + return self.com.exchange(apdu) + + def close(self) -> None: + """Close `self.com` interface. + + Returns + ------- + None + + """ + self.com.close() diff --git a/hwilib/devices/ledger_bitcoin/tx.py b/hwilib/devices/ledger_bitcoin/tx.py new file mode 100644 index 000000000..10bb0b3f7 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/tx.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# Copyright (c) 2010 ArtForz -- public domain half-a-node +# Copyright (c) 2012 Jeff Garzik +# Copyright (c) 2010-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. +"""Bitcoin Object Python Serializations + +Modified from the test/test_framework/mininode.py file from the +Bitcoin repository + +CTransaction,CTxIn, CTxOut, etc....: + data structures that should map to corresponding structures in + bitcoin/primitives for transactions only +""" + +import copy +import struct + +from ...common import ( + hash256, +) +from ._script import ( + is_opreturn, + is_p2sh, + is_p2pkh, + is_p2pk, + is_witness, + is_p2wsh, +) +from ._serialize import ( + deser_uint256, + deser_string, + deser_string_vector, + deser_vector, + Readable, + ser_uint256, + ser_string, + ser_string_vector, + ser_vector, + uint256_from_str, +) + +from typing import ( + List, + Optional, + Tuple, +) + +# Objects that map to bitcoind objects, which can be serialized/deserialized + +MSG_WITNESS_FLAG = 1 << 30 + +class COutPoint(object): + def __init__(self, hash: int = 0, n: int = 0xffffffff): + self.hash = hash + self.n = n + + def deserialize(self, f: Readable) -> None: + self.hash = deser_uint256(f) + self.n = struct.unpack(" bytes: + r = b"" + r += ser_uint256(self.hash) + r += struct.pack(" str: + return "COutPoint(hash=%064x n=%i)" % (self.hash, self.n) + + +class CTxIn(object): + def __init__( + self, + outpoint: Optional[COutPoint] = None, + scriptSig: bytes = b"", + nSequence: int = 0, + ): + if outpoint is None: + self.prevout = COutPoint() + else: + self.prevout = outpoint + self.scriptSig = scriptSig + self.nSequence = nSequence + + def deserialize(self, f: Readable) -> None: + self.prevout = COutPoint() + self.prevout.deserialize(f) + self.scriptSig = deser_string(f) + self.nSequence = struct.unpack(" bytes: + r = b"" + r += self.prevout.serialize() + r += ser_string(self.scriptSig) + r += struct.pack(" str: + return "CTxIn(prevout=%s scriptSig=%s nSequence=%i)" \ + % (repr(self.prevout), self.scriptSig.hex(), + self.nSequence) + + +class CTxOut(object): + def __init__(self, nValue: int = 0, scriptPubKey: bytes = b""): + self.nValue = nValue + self.scriptPubKey = scriptPubKey + + def deserialize(self, f: Readable) -> None: + self.nValue = struct.unpack(" bytes: + r = b"" + r += struct.pack(" bool: + return is_opreturn(self.scriptPubKey) + + def is_p2sh(self) -> bool: + return is_p2sh(self.scriptPubKey) + + def is_p2wsh(self) -> bool: + return is_p2wsh(self.scriptPubKey) + + def is_p2pkh(self) -> bool: + return is_p2pkh(self.scriptPubKey) + + def is_p2pk(self) -> bool: + return is_p2pk(self.scriptPubKey) + + def is_witness(self) -> Tuple[bool, int, bytes]: + return is_witness(self.scriptPubKey) + + def __repr__(self) -> str: + return "CTxOut(nValue=%i.%08i scriptPubKey=%s)" \ + % (self.nValue // 100_000_000, self.nValue % 100_000_000, self.scriptPubKey.hex()) + + +class CScriptWitness(object): + def __init__(self) -> None: + # stack is a vector of strings + self.stack: List[bytes] = [] + + def __repr__(self) -> str: + return "CScriptWitness(%s)" % \ + (",".join([x.hex() for x in self.stack])) + + def is_null(self) -> bool: + if self.stack: + return False + return True + + +class CTxInWitness(object): + def __init__(self) -> None: + self.scriptWitness = CScriptWitness() + + def deserialize(self, f: Readable) -> None: + self.scriptWitness.stack = deser_string_vector(f) + + def serialize(self) -> bytes: + return ser_string_vector(self.scriptWitness.stack) + + def __repr__(self) -> str: + return repr(self.scriptWitness) + + def is_null(self) -> bool: + return self.scriptWitness.is_null() + + +class CTxWitness(object): + def __init__(self) -> None: + self.vtxinwit: List[CTxInWitness] = [] + + def deserialize(self, f: Readable) -> None: + for i in range(len(self.vtxinwit)): + self.vtxinwit[i].deserialize(f) + + def serialize(self) -> bytes: + r = b"" + # This is different than the usual vector serialization -- + # we omit the length of the vector, which is required to be + # the same length as the transaction's vin vector. + for x in self.vtxinwit: + r += x.serialize() + return r + + def __repr__(self) -> str: + return "CTxWitness(%s)" % \ + (';'.join([repr(x) for x in self.vtxinwit])) + + def is_null(self) -> bool: + for x in self.vtxinwit: + if not x.is_null(): + return False + return True + + +class CTransaction(object): + def __init__(self, tx: Optional['CTransaction'] = None) -> None: + if tx is None: + self.nVersion = 1 + self.vin: List[CTxIn] = [] + self.vout: List[CTxOut] = [] + self.wit = CTxWitness() + self.nLockTime = 0 + self.sha256: Optional[int] = None + self.hash: Optional[bytes] = None + else: + self.nVersion = tx.nVersion + self.vin = copy.deepcopy(tx.vin) + self.vout = copy.deepcopy(tx.vout) + self.nLockTime = tx.nLockTime + self.sha256 = tx.sha256 + self.hash = tx.hash + self.wit = copy.deepcopy(tx.wit) + + def deserialize(self, f: Readable) -> None: + self.nVersion = struct.unpack(" bytes: + r = b"" + r += struct.pack(" bytes: + flags = 0 + if not self.wit.is_null(): + flags |= 1 + r = b"" + r += struct.pack(" bytes: + return self.serialize_without_witness() + + # Recalculate the txid (transaction hash without witness) + def rehash(self) -> None: + self.sha256 = None + self.calc_sha256() + + # We will only cache the serialization without witness in + # self.sha256 and self.hash -- those are expected to be the txid. + def calc_sha256(self, with_witness: bool = False) -> Optional[int]: + if with_witness: + # Don't cache the result, just return it + return uint256_from_str(hash256(self.serialize_with_witness())) + + if self.sha256 is None: + self.sha256 = uint256_from_str(hash256(self.serialize_without_witness())) + self.hash = hash256(self.serialize()) + return None + + def is_null(self) -> bool: + return len(self.vin) == 0 and len(self.vout) == 0 + + def __repr__(self) -> str: + return "CTransaction(nVersion=%i vin=%s vout=%s wit=%s nLockTime=%i)" \ + % (self.nVersion, repr(self.vin), repr(self.vout), repr(self.wit), self.nLockTime) diff --git a/hwilib/devices/ledger_bitcoin/wallet.py b/hwilib/devices/ledger_bitcoin/wallet.py new file mode 100644 index 000000000..9036ed097 --- /dev/null +++ b/hwilib/devices/ledger_bitcoin/wallet.py @@ -0,0 +1,126 @@ +import re + +from enum import IntEnum +from typing import List + +from hashlib import sha256 + +from ...common import AddressType +from ..._serialize import ser_compact_size +from .merkle import MerkleTree, element_hash + +def serialize_str(value: str) -> bytes: + return len(value.encode()).to_bytes(1, byteorder="big") + value.encode() + + +class WalletType(IntEnum): + WALLET_POLICY_V1 = 1 + WALLET_POLICY_V2 = 2 + + +# should not be instantiated directly +class WalletPolicyBase: + def __init__(self, name: str, version: WalletType) -> None: + self.name = name + self.version = version + + if (version != WalletType.WALLET_POLICY_V1 and version != WalletType.WALLET_POLICY_V2): + raise ValueError("Invalid wallet policy version") + + def serialize(self) -> bytes: + return b"".join([ + self.version.value.to_bytes(1, byteorder="big"), + serialize_str(self.name) + ]) + + @property + def id(self) -> bytes: + return sha256(self.serialize()).digest() + + +class WalletPolicy(WalletPolicyBase): + """ + Represents a wallet stored with a wallet policy. + For version V2, the wallet is serialized as follows: + - 1 byte : wallet version + - 1 byte : length of the wallet name (max 64) + - (var) : wallet name (ASCII string) + - (varint) : length of the descriptor template + - 32-bytes : sha256 hash of the descriptor template + - (varint) : number of keys (not larger than 252) + - 32-bytes : root of the Merkle tree of all the keys information. + + The specific format of the keys is deferred to subclasses. + """ + + def __init__(self, name: str, descriptor_template: str, keys_info: List[str], version: WalletType = WalletType.WALLET_POLICY_V2): + super().__init__(name, version) + self.descriptor_template = descriptor_template + self.keys_info = keys_info + + @property + def n_keys(self) -> int: + return len(self.keys_info) + + def serialize(self) -> bytes: + keys_info_hashes = map(lambda k: element_hash(k.encode()), self.keys_info) + + descriptor_template_sha256 = sha256(self.descriptor_template.encode()).digest() + + return b"".join([ + super().serialize(), + ser_compact_size(len(self.descriptor_template.encode())), + self.descriptor_template.encode() if self.version == WalletType.WALLET_POLICY_V1 else descriptor_template_sha256, + ser_compact_size(len(self.keys_info)), + MerkleTree(keys_info_hashes).root + ]) + + def get_descriptor(self, change: bool) -> str: + desc = self.descriptor_template + for i in reversed(range(self.n_keys)): + key = self.keys_info[i] + desc = desc.replace(f"@{i}", key) + + # in V1, /** is part of the key; in V2, it's part of the policy map. This handles either + desc = desc.replace("/**", f"/{1 if change else 0}/*") + + if self.version == WalletType.WALLET_POLICY_V2: + # V2, the / syntax is supported. Replace with M if not change, or with N if change + regex = r"/<(\d+);(\d+)>" + desc = re.sub(regex, "/\\2" if change else "/\\1", desc) + + return desc + +class MultisigWallet(WalletPolicy): + def __init__(self, name: str, address_type: AddressType, threshold: int, keys_info: List[str], sorted: bool = True, version: WalletType = WalletType.WALLET_POLICY_V2) -> None: + n_keys = len(keys_info) + + if not (1 <= threshold <= n_keys <= 16): + raise ValueError("Invalid threshold or number of keys") + + multisig_op = "sortedmulti" if sorted else "multi" + + if (address_type == AddressType.LEGACY): + policy_prefix = f"sh({multisig_op}(" + policy_suffix = f"))" + elif address_type == AddressType.WIT: + policy_prefix = f"wsh({multisig_op}(" + policy_suffix = f"))" + elif address_type == AddressType.SH_WIT: + policy_prefix = f"sh(wsh({multisig_op}(" + policy_suffix = f")))" + else: + raise ValueError(f"Unexpected address type: {address_type}") + + key_placeholder_suffix = "/**" if version == WalletType.WALLET_POLICY_V2 else "" + + descriptor_template = "".join([ + policy_prefix, + str(threshold) + ",", + ",".join("@" + str(l) + key_placeholder_suffix for l in range(n_keys)), + policy_suffix + ]) + + super().__init__(name, descriptor_template, keys_info, version) + + self.threshold = threshold diff --git a/hwilib/devices/trezor.py b/hwilib/devices/trezor.py index a2a4b983d..d8cae99da 100644 --- a/hwilib/devices/trezor.py +++ b/hwilib/devices/trezor.py @@ -1,28 +1,123 @@ -# Trezor interaction script - +""" +Trezor Devices +************** +""" + +from functools import wraps +from typing import ( + Any, + Callable, + Dict, + List, + NoReturn, + Optional, + Sequence, + Set, + Tuple, + Union, +) +from ..descriptor import MultisigDescriptor from ..hwwclient import HardwareWalletClient -from ..errors import ActionCanceledError, BadArgumentError, DeviceAlreadyInitError, DeviceAlreadyUnlockedError, DeviceConnectionError, DEVICE_NOT_INITIALIZED, DeviceNotReadyError, UnavailableActionError, common_err_msgs, handle_errors -from .trezorlib.client import TrezorClient as Trezor +from ..errors import ( + ActionCanceledError, + BadArgumentError, + DeviceAlreadyInitError, + DeviceAlreadyUnlockedError, + DeviceConnectionError, + DEVICE_NOT_INITIALIZED, + DeviceNotReadyError, + NoPasswordError, + UnavailableActionError, + common_err_msgs, + handle_errors, +) +from .trezorlib.client import TrezorClient as Trezor, PASSPHRASE_ON_DEVICE from .trezorlib.debuglink import TrezorClientDebugLink -from .trezorlib.exceptions import Cancelled -from .trezorlib.transport import enumerate_devices, get_transport -from .trezorlib.ui import echo, PassphraseUI, mnemonic_words, PIN_CURRENT, PIN_NEW, PIN_CONFIRM, PIN_MATRIX_DESCRIPTION, prompt -from .trezorlib import tools, btc, device -from .trezorlib import messages as proto -from ..base58 import get_xpub_fingerprint, to_address, xpub_main_2_test, get_xpub_fingerprint_hex -from ..serializations import CTxOut, ser_uint256 -from .. import bech32 +from .trezorlib.exceptions import Cancelled, TrezorFailure +from .trezorlib.models import TrezorModel +from .trezorlib.transport import ( + DEV_TREZOR1, + TREZORS, + hid, + udp, + webusb, +) +from .trezorlib import ( + btc, + device, +) +from .trezorlib import messages +from .._base58 import ( + get_xpub_fingerprint, + to_address, +) +from .. import _base58 as base58 + +from ..key import ( + ExtendedKey, + parse_path, +) +from .._script import ( + is_p2pkh, + is_p2sh, + is_p2wsh, + is_witness, +) +from ..psbt import ( + PSBT, + PartiallySignedInput, + PartiallySignedOutput, + KeyOriginInfo, +) +from ..tx import ( + CTxOut, +) +from .._serialize import ( + ser_uint256, +) +from ..common import ( + AddressType, + Chain, + hash256, +) +from .. import _bech32 as bech32 +from mnemonic import Mnemonic from usb1 import USBErrorNoDevice from types import MethodType import base64 +import builtins +import getpass import logging import sys py_enumerate = enumerate # Need to use the enumerate built-in but there's another function already named that +PIN_MATRIX_DESCRIPTION = """ +Use the numeric keypad to describe number positions. The layout is: + 7 8 9 + 4 5 6 + 1 2 3 +""".strip() + +Device = Union[hid.HidTransport, webusb.WebUsbTransport, udp.UdpTransport] + +ECDSA_SCRIPT_TYPES = [ + messages.InputScriptType.SPENDADDRESS, + messages.InputScriptType.SPENDMULTISIG, + messages.InputScriptType.SPENDWITNESS, + messages.InputScriptType.SPENDP2SHWITNESS, +] +SCHNORR_SCRIPT_TYPES = [ + messages.InputScriptType.SPENDTAPROOT, +] + + # Only handles up to 15 of 15 -def parse_multisig(script): +def parse_multisig(script: bytes, tx_xpubs: Dict[bytes, KeyOriginInfo], psbt_scope: Union[PartiallySignedInput, PartiallySignedOutput]) -> Tuple[bool, Optional[messages.MultisigRedeemScriptType]]: + # at least OP_M pub OP_N OP_CHECKMULTISIG + if len(script) < 37: + return (False, None) # Get m m = script[0] - 80 if m < 1 or m > 15: @@ -39,8 +134,8 @@ def parse_multisig(script): key = script[offset:offset + 33] offset += 33 - hd_node = proto.HDNodeType(depth=0, fingerprint=0, child_num=0, chain_code=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', public_key=key) - pubkeys.append(proto.HDNodePathType(node=hd_node, address_n=[])) + hd_node = messages.HDNodeType(depth=0, fingerprint=0, child_num=0, chain_code=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', public_key=key) + pubkeys.append(messages.HDNodePathType(node=hd_node, address_n=[])) # Check things at the end n = script[offset] - 80 @@ -51,12 +146,27 @@ def parse_multisig(script): if op_cms != 174: return (False, None) + # check if we know corresponding xpubs from global scope + for pub in pubkeys: + if pub.node.public_key in psbt_scope.hd_keypaths: + derivation = psbt_scope.hd_keypaths[pub.node.public_key] + for xpub in tx_xpubs: + hd = ExtendedKey.deserialize(base58.encode(xpub + hash256(xpub)[:4])) + origin = tx_xpubs[xpub] + # check fingerprint and derivation + if (origin.fingerprint == derivation.fingerprint) and (origin.path == derivation.path[:len(origin.path)]): + # all good - populate node and break + pub.address_n = list(derivation.path[len(origin.path):]) + pub.node = messages.HDNodeType(depth=hd.depth, fingerprint=int.from_bytes(hd.parent_fingerprint, 'big'), child_num=hd.child_num, chain_code=hd.chaincode, public_key=hd.pubkey) + break # Build MultisigRedeemScriptType and return it - multisig = proto.MultisigRedeemScriptType(m=m, signatures=[b''] * n, pubkeys=pubkeys) + multisig = messages.MultisigRedeemScriptType(m=m, signatures=[b''] * n, pubkeys=pubkeys) return (True, multisig) -def trezor_exception(f): - def func(*args, **kwargs): + +def trezor_exception(f: Callable[..., Any]) -> Any: + @wraps(f) + def func(*args: Any, **kwargs: Any) -> Any: try: return f(*args, **kwargs) except ValueError as e: @@ -67,39 +177,131 @@ def func(*args, **kwargs): raise DeviceConnectionError('Device disconnected') return func -def interactive_get_pin(self, code=None): - if code == PIN_CURRENT: + +def interactive_get_pin(self: object, code: Optional[int] = None) -> str: + if code == messages.PinMatrixRequestType.Current: desc = "current PIN" - elif code == PIN_NEW: + elif code == messages.PinMatrixRequestType.NewFirst: desc = "new PIN" - elif code == PIN_CONFIRM: + elif code == messages.PinMatrixRequestType.NewSecond: desc = "new PIN again" else: desc = "PIN" - echo(PIN_MATRIX_DESCRIPTION) + print(PIN_MATRIX_DESCRIPTION, file=sys.stderr) while True: - pin = prompt("Please enter {}".format(desc), hide_input=True) + pin = getpass.getpass(f"Please entire {desc}:\n") if not pin.isdigit(): - echo("Non-numerical PIN provided, please try again") + print("Non-numerical PIN provided, please try again", file=sys.stderr) else: return pin + +def mnemonic_words(expand: bool = False, language: str = "english") -> Callable[[Any], str]: + wordlist: Sequence[str] = [] + if expand: + wordlist = Mnemonic(language).wordlist + + def expand_word(word: str) -> str: + if not expand: + return word + if word in wordlist: + return word + matches = [w for w in wordlist if w.startswith(word)] + if len(matches) == 1: + return matches[0] + print("Choose one of: " + ", ".join(matches), file=sys.stderr) + raise KeyError(word) + + def get_word(type: messages.WordRequestType) -> str: + assert type == messages.WordRequestType.Plain + while True: + try: + word = input("Enter one word of mnemonic:\n") + return expand_word(word) + except KeyError: + pass + except Exception: + raise Cancelled from None + + return get_word + + +class PassphraseUI: + def __init__(self, passphrase: str) -> None: + self.passphrase = passphrase + self.pinmatrix_shown = False + self.prompt_shown = False + self.always_prompt = False + self.return_passphrase = True + + def button_request(self, code: Optional[int]) -> None: + if not self.prompt_shown: + print("Please confirm action on your Trezor device", file=sys.stderr) + if not self.always_prompt: + self.prompt_shown = True + + def get_pin(self, code: Optional[int] = None) -> NoReturn: + raise NotImplementedError('get_pin is not needed') + + def disallow_passphrase(self) -> None: + self.return_passphrase = False + + def get_passphrase(self, available_on_device: bool) -> object: + if available_on_device: + return PASSPHRASE_ON_DEVICE + if self.return_passphrase: + return self.passphrase + raise ValueError('Passphrase from Host is not allowed for Trezor T') + + +HID_IDS = {DEV_TREZOR1} +WEBUSB_IDS = TREZORS.copy() +SIMULATOR_PATH = "127.0.0.1:21324" + + +def get_path_transport( + path: str, + hid_ids: Set[Tuple[int, int]], + webusb_ids: Set[Tuple[int, int]], + sim_path: str +) -> Device: + devs = hid.HidTransport.enumerate(usb_ids=hid_ids) + devs.extend(webusb.WebUsbTransport.enumerate(usb_ids=webusb_ids)) + devs.extend(udp.UdpTransport.enumerate(sim_path)) + for dev in devs: + if path == dev.get_path(): + return dev + raise BadArgumentError(f"Could not find device by path: {path}") + + # This class extends the HardwareWalletClient for Trezor specific things class TrezorClient(HardwareWalletClient): - def __init__(self, path, password=''): - super(TrezorClient, self).__init__(path, password) + def __init__( + self, + path: str, + password: Optional[str] = None, + expert: bool = False, + chain: Chain = Chain.MAIN, + hid_ids: Set[Tuple[int, int]] = HID_IDS, + webusb_ids: Set[Tuple[int, int]] = WEBUSB_IDS, + sim_path: str = SIMULATOR_PATH, + model: Optional[TrezorModel] = None + ) -> None: + if password is None: + password = "" + super(TrezorClient, self).__init__(path, password, expert, chain) self.simulator = False + transport = get_path_transport(path, hid_ids, webusb_ids, sim_path) if path.startswith('udp'): logging.debug('Simulator found, using DebugLink') - transport = get_transport(path) - self.client = TrezorClientDebugLink(transport=transport) + self.client = TrezorClientDebugLink(transport=transport, model=model, _init_device=False) self.simulator = True - self.client.set_passphrase(password) + self.client.use_passphrase(password) else: - self.client = Trezor(transport=get_transport(path), ui=PassphraseUI(password)) + self.client = Trezor(transport=transport, ui=PassphraseUI(password), model=model, _init_device=False) # if it wasn't able to find a client, throw an error if not self.client: @@ -108,34 +310,64 @@ def __init__(self, path, password=''): self.password = password self.type = 'Trezor' - def _check_unlocked(self): - self.client.init_device() - if self.client.features.pin_protection and not self.client.features.pin_cached: + def _prepare_device(self) -> None: + self.coin_name = 'Bitcoin' if self.chain == Chain.MAIN else 'Testnet' + resp = self.client.refresh_features() + # If this is a Trezor One or Keepkey, do Initialize + if resp.model == '1' or resp.model == 'K1-14AM': + self.client.init_device() + # For the T, we need to check if a passphrase needs to be entered + elif resp.model == 'T': + try: + self.client.ensure_unlocked() + except TrezorFailure: + self.client.init_device() + + def _check_unlocked(self) -> None: + self._prepare_device() + if self.client.features.model == 'T' and isinstance(self.client.ui, PassphraseUI): + self.client.ui.disallow_passphrase() + if self.client.features.pin_protection and not self.client.features.unlocked: raise DeviceNotReadyError('{} is locked. Unlock by using \'promptpin\' and then \'sendpin\'.'.format(self.type)) + if self.client.features.passphrase_protection and self.password is None: + raise NoPasswordError("Passphrase protection is enabled, passphrase must be provided") + + def _supports_external(self) -> bool: + if self.client.features.model == "1" and self.client.version <= (1, 10, 5): + return True + if self.client.features.model == "T" and self.client.version <= (2, 4, 3): + return True + if self.client.features.model == "K1-14AM": + return True + return False - # Must return a dict with the xpub - # Retrieves the public key at the specified BIP 32 derivation path @trezor_exception - def get_pubkey_at_path(self, path): + def get_pubkey_at_path(self, path: str) -> ExtendedKey: self._check_unlocked() try: - expanded_path = tools.parse_path(path) + expanded_path = parse_path(path) except ValueError as e: raise BadArgumentError(str(e)) - output = btc.get_public_node(self.client, expanded_path) - if self.is_testnet: - return {'xpub': xpub_main_2_test(output.xpub)} - else: - return {'xpub': output.xpub} + output = btc.get_public_node(self.client, expanded_path, coin_name=self.coin_name) + xpub = ExtendedKey.deserialize(output.xpub) + if self.chain != Chain.MAIN: + xpub.version = ExtendedKey.TESTNET_PUBLIC + return xpub - # Must return a hex string with the signed transaction - # The tx must be in the psbt format @trezor_exception - def sign_tx(self, tx): + def sign_tx(self, tx: PSBT) -> PSBT: + """ + Sign a transaction with the Trezor. There are some limitations to what transactions can be signed. + + - Multisig inputs are limited to at most n-of-15 multisigs. This is a firmware limitation. + - Transactions with arbitrary input scripts (scriptPubKey, redeemScript, or witnessScript) and arbitrary output scripts cannot be signed. This is a firmware limitation. + - Send-to-self transactions will result in no prompt for outputs as all outputs will be detected as change. + - Transactions containing Taproot inputs cannot have external inputs. + """ self._check_unlocked() # Get this devices master key fingerprint - master_key = btc.get_public_node(self.client, [0]) + master_key = btc.get_public_node(self.client, [0x80000000], coin_name='Bitcoin') master_fp = get_xpub_fingerprint(master_key.xpub) # Do multiple passes for multisig @@ -146,90 +378,147 @@ def sign_tx(self, tx): # Prepare inputs inputs = [] to_ignore = [] # Note down which inputs whose signatures we're going to ignore - for input_num, (psbt_in, txin) in py_enumerate(list(zip(tx.inputs, tx.tx.vin))): - txinputtype = proto.TxInputType() + for input_num, psbt_in in builtins.enumerate(tx.inputs): + assert psbt_in.prev_txid is not None + assert psbt_in.prev_out is not None + assert psbt_in.sequence is not None - # Set the input stuff - txinputtype.prev_hash = ser_uint256(txin.prevout.hash)[::-1] - txinputtype.prev_index = txin.prevout.n - txinputtype.sequence = txin.nSequence + txinputtype = messages.TxInputType( + prev_hash=psbt_in.prev_txid[::-1], + prev_index=psbt_in.prev_out, + sequence=psbt_in.sequence, + ) # Detrermine spend type scriptcode = b'' - if psbt_in.non_witness_utxo: - utxo = psbt_in.non_witness_utxo.vout[txin.prevout.n] - txinputtype.script_type = proto.InputScriptType.SPENDADDRESS - scriptcode = utxo.scriptPubKey - txinputtype.amount = psbt_in.non_witness_utxo.vout[txin.prevout.n].nValue - elif psbt_in.witness_utxo: + utxo = None + if psbt_in.witness_utxo: utxo = psbt_in.witness_utxo - # Check if the output is p2sh - if psbt_in.witness_utxo.is_p2sh(): - txinputtype.script_type = proto.InputScriptType.SPENDP2SHWITNESS - else: - txinputtype.script_type = proto.InputScriptType.SPENDWITNESS - scriptcode = psbt_in.witness_utxo.scriptPubKey - txinputtype.amount = psbt_in.witness_utxo.nValue - - # Set the script - if psbt_in.witness_script: - scriptcode = psbt_in.witness_script - elif psbt_in.redeem_script: + if psbt_in.non_witness_utxo: + if psbt_in.prev_txid != psbt_in.non_witness_utxo.hash: + raise BadArgumentError('Input {} has a non_witness_utxo with the wrong hash'.format(input_num)) + utxo = psbt_in.non_witness_utxo.vout[psbt_in.prev_out] + if utxo is None: + continue + scriptcode = utxo.scriptPubKey + + # Check if P2SH + p2sh = False + if is_p2sh(scriptcode): + # Look up redeemscript + if len(psbt_in.redeem_script) == 0: + continue scriptcode = psbt_in.redeem_script + p2sh = True + + # Check segwit + is_wit, wit_ver, _ = is_witness(scriptcode) + + if is_wit: + if wit_ver == 0: + if p2sh: + txinputtype.script_type = messages.InputScriptType.SPENDP2SHWITNESS + else: + txinputtype.script_type = messages.InputScriptType.SPENDWITNESS + elif wit_ver == 1: + txinputtype.script_type = messages.InputScriptType.SPENDTAPROOT + else: + txinputtype.script_type = messages.InputScriptType.SPENDADDRESS + txinputtype.amount = utxo.nValue + + # Check if P2WSH + p2wsh = False + if is_p2wsh(scriptcode): + # Look up witnessscript + if len(psbt_in.witness_script) == 0: + continue + scriptcode = psbt_in.witness_script + p2wsh = True - def ignore_input(): - txinputtype.address_n = [0x80000000] + def ignore_input() -> None: + txinputtype.address_n = [0x80000000 | 84, 0x80000000 | (0 if self.chain == Chain.MAIN else 1), 0x80000000, 0, 0] txinputtype.multisig = None - txinputtype.script_type = proto.InputScriptType.SPENDWITNESS + txinputtype.script_type = messages.InputScriptType.SPENDWITNESS inputs.append(txinputtype) to_ignore.append(input_num) # Check for multisig - is_ms, multisig = parse_multisig(scriptcode) + is_ms, multisig = parse_multisig(scriptcode, tx.xpub, psbt_in) if is_ms: # Add to txinputtype txinputtype.multisig = multisig - if psbt_in.non_witness_utxo: - if utxo.is_p2sh: - txinputtype.script_type = proto.InputScriptType.SPENDMULTISIG + if not is_wit: + if utxo.is_p2sh(): + txinputtype.script_type = messages.InputScriptType.SPENDMULTISIG else: # Cannot sign bare multisig, ignore it + if not self._supports_external(): + raise BadArgumentError("Cannot sign bare multisig") ignore_input() continue - elif not is_ms and psbt_in.non_witness_utxo and not utxo.is_p2pkh: + elif not is_ms and not is_wit and not is_p2pkh(scriptcode): # Cannot sign unknown spk, ignore it + if not self._supports_external(): + raise BadArgumentError("Cannot sign unknown scripts") ignore_input() continue - elif not is_ms and psbt_in.witness_utxo and psbt_in.witness_script: + elif not is_ms and is_wit and p2wsh: # Cannot sign unknown witness script, ignore it + if not self._supports_external(): + raise BadArgumentError("Cannot sign unknown witness versions") ignore_input() continue # Find key to sign with - found = False + found = False # Whether we have found a key to sign with + found_in_sigs = False # Whether we have found one of our keys in the signatures our_keys = 0 - for key in psbt_in.hd_keypaths.keys(): - keypath = psbt_in.hd_keypaths[key] - if keypath[0] == master_fp and key not in psbt_in.partial_sigs: - if not found: - txinputtype.address_n = keypath[1:] + path_last_ours = None # The path of the last key that is ours. We will use this if we need to ignore this input because it is already signed. + if txinputtype.script_type in ECDSA_SCRIPT_TYPES: + for key in psbt_in.hd_keypaths.keys(): + keypath = psbt_in.hd_keypaths[key] + if keypath.fingerprint == master_fp: + path_last_ours = keypath.path + if key in psbt_in.partial_sigs: # This key already has a signature + found_in_sigs = True + continue + if not found: # This key does not have a signature and we don't have a key to sign with yet + txinputtype.address_n = keypath.path + found = True + our_keys += 1 + elif txinputtype.script_type in SCHNORR_SCRIPT_TYPES: + found_in_sigs = len(psbt_in.tap_key_sig) > 0 + for key, (leaf_hashes, origin) in psbt_in.tap_bip32_paths.items(): + # TODO: Support script path signing + if key == psbt_in.tap_internal_key and origin.fingerprint == master_fp: + path_last_ours = origin.path + txinputtype.address_n = origin.path found = True - our_keys += 1 + our_keys += 1 + break # Determine if we need to do more passes to sign everything if our_keys > passes: passes = our_keys - if not found: + if not found and not found_in_sigs: # None of our keys were in hd_keypaths or in partial_sigs # This input is not one of ours + if not self._supports_external(): + raise BadArgumentError("Cannot sign external inputs") ignore_input() continue + elif not found and found_in_sigs: + # All of our keys are in partial_sigs, pick the first key that is ours, sign with it, + # and ignore whatever signature is produced for this input + assert path_last_ours is not None + txinputtype.address_n = path_last_ours + to_ignore.append(input_num) # append to inputs inputs.append(txinputtype) # address version byte - if self.is_testnet: + if self.chain != Chain.MAIN: p2pkh_version = b'\x6f' p2sh_version = b'\xc4' bech32_hrp = 'tb' @@ -240,40 +529,60 @@ def ignore_input(): # prepare outputs outputs = [] - for i, out in py_enumerate(tx.tx.vout): - txoutput = proto.TxOutputType() - txoutput.amount = out.nValue - txoutput.script_type = proto.OutputScriptType.PAYTOADDRESS - if out.is_p2pkh(): + for psbt_out in tx.outputs: + out = psbt_out.get_txout() + txoutput = messages.TxOutputType(amount=out.nValue) + txoutput.script_type = messages.OutputScriptType.PAYTOADDRESS + wit, ver, prog = out.is_witness() + if wit: + txoutput.address = bech32.encode(bech32_hrp, ver, prog) + elif out.is_p2pkh(): txoutput.address = to_address(out.scriptPubKey[3:23], p2pkh_version) elif out.is_p2sh(): txoutput.address = to_address(out.scriptPubKey[2:22], p2sh_version) + elif out.is_opreturn(): + txoutput.script_type = messages.OutputScriptType.PAYTOOPRETURN + txoutput.op_return_data = out.scriptPubKey[2:] else: - wit, ver, prog = out.is_witness() - if wit: - txoutput.address = bech32.encode(bech32_hrp, ver, prog) - else: - raise BadArgumentError("Output is not an address") - - # Add the derivation path for change, but only if there is exactly one derivation path - psbt_out = tx.outputs[i] - if len(psbt_out.hd_keypaths) == 1: - _, keypath = next(iter(psbt_out.hd_keypaths.items())) - if keypath[0] == master_fp: + raise BadArgumentError("Output is not an address") + + # Add the derivation path for change + if not wit or (wit and ver == 0): + for _, keypath in psbt_out.hd_keypaths.items(): + if keypath.fingerprint != master_fp: + continue wit, ver, prog = out.is_witness() if out.is_p2pkh(): - txoutput.address_n = keypath[1:] + txoutput.address_n = keypath.path txoutput.address = None elif wit: - txoutput.script_type = proto.OutputScriptType.PAYTOWITNESS - txoutput.address_n = keypath[1:] + txoutput.script_type = messages.OutputScriptType.PAYTOWITNESS + txoutput.address_n = keypath.path txoutput.address = None elif out.is_p2sh() and psbt_out.redeem_script: wit, ver, prog = CTxOut(0, psbt_out.redeem_script).is_witness() - if wit and len(prog) == 20: - txoutput.script_type = proto.OutputScriptType.PAYTOP2SHWITNESS - txoutput.address_n = keypath[1:] + if wit and len(prog) in [20, 32]: + txoutput.script_type = messages.OutputScriptType.PAYTOP2SHWITNESS + txoutput.address_n = keypath.path txoutput.address = None + elif wit and ver == 1: + for key, (leaf_hashes, origin) in psbt_out.tap_bip32_paths.items(): + # TODO: Support script path change + if key == psbt_out.tap_internal_key and origin.fingerprint == master_fp: + txoutput.address_n = origin.path + txoutput.script_type = messages.OutputScriptType.PAYTOTAPROOT + txoutput.address = None + break + + # add multisig info + if psbt_out.witness_script or psbt_out.redeem_script: + is_ms, multisig = parse_multisig( + psbt_out.witness_script or psbt_out.redeem_script, + tx.xpub, psbt_out) + if is_ms: + txoutput.multisig = multisig + if not wit: + txoutput.script_type = messages.OutputScriptType.PAYTOMULTISIG # append to outputs outputs.append(txoutput) @@ -284,147 +593,269 @@ def ignore_input(): if psbt_in.non_witness_utxo: prev = psbt_in.non_witness_utxo - t = proto.TransactionType() + t = messages.TransactionType() t.version = prev.nVersion t.lock_time = prev.nLockTime for vin in prev.vin: - i = proto.TxInputType() - i.prev_hash = ser_uint256(vin.prevout.hash)[::-1] - i.prev_index = vin.prevout.n - i.script_sig = vin.scriptSig - i.sequence = vin.nSequence + i = messages.TxInputType( + prev_hash=ser_uint256(vin.prevout.hash)[::-1], + prev_index=vin.prevout.n, + script_sig=vin.scriptSig, + sequence=vin.nSequence, + ) t.inputs.append(i) for vout in prev.vout: - o = proto.TxOutputBinType() - o.amount = vout.nValue - o.script_pubkey = vout.scriptPubKey + o = messages.TxOutputBinType( + amount=vout.nValue, + script_pubkey=vout.scriptPubKey, + ) t.bin_outputs.append(o) - logging.debug(psbt_in.non_witness_utxo.hash) + assert(psbt_in.non_witness_utxo.hash is not None) + logging.debug(psbt_in.non_witness_utxo.hash.hex()) + assert psbt_in.non_witness_utxo.sha256 is not None prevtxs[ser_uint256(psbt_in.non_witness_utxo.sha256)[::-1]] = t # Sign the transaction - tx_details = proto.SignTx() - tx_details.version = tx.tx.nVersion - tx_details.lock_time = tx.tx.nLockTime - if self.is_testnet: - signed_tx = btc.sign_tx(self.client, "Testnet", inputs, outputs, tx_details, prevtxs) - else: - signed_tx = btc.sign_tx(self.client, "Bitcoin", inputs, outputs, tx_details, prevtxs) + assert tx.tx_version is not None + signed_tx = btc.sign_tx( + client=self.client, + coin_name=self.coin_name, + inputs=inputs, + outputs=outputs, + prev_txes=prevtxs, + version=tx.tx_version, + lock_time=tx.compute_lock_time(), + serialize=False, + ) # Each input has one signature for input_num, (psbt_in, sig) in py_enumerate(list(zip(tx.inputs, signed_tx[0]))): if input_num in to_ignore: continue for pubkey in psbt_in.hd_keypaths.keys(): - fp = psbt_in.hd_keypaths[pubkey][0] + fp = psbt_in.hd_keypaths[pubkey].fingerprint if fp == master_fp and pubkey not in psbt_in.partial_sigs: psbt_in.partial_sigs[pubkey] = sig + b'\x01' break + if len(psbt_in.tap_internal_key) > 0 and len(psbt_in.tap_key_sig) == 0: + # Assume key path sig + # TODO: Deal with script path sig + psbt_in.tap_key_sig = sig p += 1 - return {'psbt': tx.serialize()} + return tx + + @trezor_exception + def sign_message(self, message: Union[str, bytes], keypath: str) -> str: + self._check_unlocked() + path = parse_path(keypath) + result = btc.sign_message(self.client, self.coin_name, path, message) + return base64.b64encode(result.signature).decode('utf-8') - # Must return a base64 encoded string with the signed message - # The message can be any string @trezor_exception - def sign_message(self, message, keypath): + def display_singlesig_address( + self, + keypath: str, + addr_type: AddressType, + ) -> str: self._check_unlocked() - path = tools.parse_path(keypath) - result = btc.sign_message(self.client, 'Bitcoin', path, message) - return {'signature': base64.b64encode(result.signature).decode('utf-8')} - # Display address of specified type on the device. Only supports single-key based addresses. + # Script type + if addr_type == AddressType.SH_WIT: + script_type = messages.InputScriptType.SPENDP2SHWITNESS + elif addr_type == AddressType.WIT: + script_type = messages.InputScriptType.SPENDWITNESS + elif addr_type == AddressType.LEGACY: + script_type = messages.InputScriptType.SPENDADDRESS + elif addr_type == AddressType.TAP: + if not self.can_sign_taproot(): + raise UnavailableActionError("This device does not support displaying Taproot addresses") + script_type = messages.InputScriptType.SPENDTAPROOT + else: + raise BadArgumentError("Unknown address type") + + expanded_path = parse_path(keypath) + + try: + address = btc.get_address( + self.client, + self.coin_name, + expanded_path, + show_display=True, + script_type=script_type, + multisig=None, + ) + assert isinstance(address, str) + return address + except Exception: + pass + + raise BadArgumentError("No path supplied matched device keys") + @trezor_exception - def display_address(self, keypath, p2sh_p2wpkh, bech32): + def display_multisig_address( + self, + addr_type: AddressType, + multisig: MultisigDescriptor, + ) -> str: self._check_unlocked() - expanded_path = tools.parse_path(keypath) - address = btc.get_address( - self.client, - "Testnet" if self.is_testnet else "Bitcoin", - expanded_path, - show_display=True, - script_type=proto.InputScriptType.SPENDWITNESS if bech32 else (proto.InputScriptType.SPENDP2SHWITNESS if p2sh_p2wpkh else proto.InputScriptType.SPENDADDRESS) - ) - return {'address': address} - - # Setup a new device + + der_pks = list(zip([p.get_pubkey_bytes(0) for p in multisig.pubkeys], multisig.pubkeys)) + if multisig.is_sorted: + der_pks = sorted(der_pks) + + pubkey_objs = [] + for pk, p in der_pks: + if p.extkey is not None: + xpub = p.extkey + hd_node = messages.HDNodeType(depth=xpub.depth, fingerprint=int.from_bytes(xpub.parent_fingerprint, 'big'), child_num=xpub.child_num, chain_code=xpub.chaincode, public_key=xpub.pubkey) + pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=parse_path("m" + p.deriv_path if p.deriv_path is not None else ""))) + else: + hd_node = messages.HDNodeType(depth=0, fingerprint=0, child_num=0, chain_code=b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', public_key=pk) + pubkey_objs.append(messages.HDNodePathType(node=hd_node, address_n=[])) + + trezor_ms = messages.MultisigRedeemScriptType(m=multisig.thresh, signatures=[b''] * len(pubkey_objs), pubkeys=pubkey_objs) + + # Script type + if addr_type == AddressType.SH_WIT: + script_type = messages.InputScriptType.SPENDP2SHWITNESS + elif addr_type == AddressType.WIT: + script_type = messages.InputScriptType.SPENDWITNESS + elif addr_type == AddressType.LEGACY: + script_type = messages.InputScriptType.SPENDMULTISIG + else: + raise BadArgumentError("Unknown address type") + + for p in multisig.pubkeys: + keypath = p.origin.get_derivation_path() if p.origin is not None else "m/" + keypath += p.deriv_path if p.deriv_path is not None else "" + path = parse_path(keypath) + try: + address = btc.get_address( + self.client, + self.coin_name, + path, + show_display=True, + script_type=script_type, + multisig=trezor_ms, + ) + assert isinstance(address, str) + return address + except Exception: + pass + + raise BadArgumentError("No path supplied matched device keys") + @trezor_exception - def setup_device(self, label='', passphrase=''): - self.client.init_device() + def setup_device(self, label: str = "", passphrase: str = "") -> bool: + self._prepare_device() if not self.simulator: # Use interactive_get_pin self.client.ui.get_pin = MethodType(interactive_get_pin, self.client.ui) if self.client.features.initialized: raise DeviceAlreadyInitError('Device is already initialized. Use wipe first and try again') - device.reset(self.client, passphrase_protection=bool(self.password)) - return {'success': True} + device.reset(self.client, label=label or None, passphrase_protection=bool(self.password)) + return True - # Wipe this device @trezor_exception - def wipe_device(self): + def wipe_device(self) -> bool: self._check_unlocked() device.wipe(self.client) - return {'success': True} + return True - # Restore device from mnemonic or xprv @trezor_exception - def restore_device(self, label=''): - self.client.init_device() + def restore_device(self, label: str = "", word_count: int = 24) -> bool: + self._prepare_device() if not self.simulator: # Use interactive_get_pin self.client.ui.get_pin = MethodType(interactive_get_pin, self.client.ui) - device.recover(self.client, label=label, input_callback=mnemonic_words(), passphrase_protection=bool(self.password)) - return {'success': True} + device.recover(self.client, word_count=word_count, label=label or None, input_callback=mnemonic_words(), passphrase_protection=bool(self.password)) + return True + + def backup_device(self, label: str = "", passphrase: str = "") -> bool: + """ + Trezor devices do not support backing up via software. - # Begin backup process - def backup_device(self, label='', passphrase=''): + :raises UnavailableActionError: Always, this function is unavailable + """ raise UnavailableActionError('The {} does not support creating a backup via software'.format(self.type)) - # Close the device @trezor_exception - def close(self): + def close(self) -> None: self.client.close() - # Prompt for a pin on device @trezor_exception - def prompt_pin(self): + def prompt_pin(self) -> bool: + self.coin_name = 'Bitcoin' if self.chain == Chain.MAIN else 'Testnet' self.client.open() - self.client.init_device() + self._prepare_device() if not self.client.features.pin_protection: raise DeviceAlreadyUnlockedError('This device does not need a PIN') - if self.client.features.pin_cached: + if self.client.features.unlocked: raise DeviceAlreadyUnlockedError('The PIN has already been sent to this device') print('Use \'sendpin\' to provide the number positions for the PIN as displayed on your device\'s screen', file=sys.stderr) print(PIN_MATRIX_DESCRIPTION, file=sys.stderr) - self.client.call_raw(proto.Ping(message=b'ping', button_protection=False, pin_protection=True, passphrase_protection=False)) - return {'success': True} + self.client.call_raw(messages.GetPublicKey(address_n=[0x8000002c, 0x80000001, 0x80000000], ecdsa_curve_name=None, show_display=False, coin_name=self.coin_name, script_type=messages.InputScriptType.SPENDADDRESS)) + return True - # Send the pin @trezor_exception - def send_pin(self, pin): + def send_pin(self, pin: str) -> bool: self.client.open() if not pin.isdigit(): raise BadArgumentError("Non-numeric PIN provided") - resp = self.client.call_raw(proto.PinMatrixAck(pin=pin)) - if isinstance(resp, proto.Failure): - self.client.features = self.client.call_raw(proto.GetFeatures()) - if isinstance(self.client.features, proto.Features): + resp = self.client.call_raw(messages.PinMatrixAck(pin=pin)) + if isinstance(resp, messages.Failure): + self.client.features = self.client.call_raw(messages.GetFeatures()) + if isinstance(self.client.features, messages.Features): if not self.client.features.pin_protection: raise DeviceAlreadyUnlockedError('This device does not need a PIN') - if self.client.features.pin_cached: + if self.client.features.unlocked: raise DeviceAlreadyUnlockedError('The PIN has already been sent to this device') - return {'success': False} - return {'success': True} + return False + elif isinstance(resp, messages.PassphraseRequest): + pass_resp = self.client.call(messages.PassphraseAck(passphrase=self.client.ui.get_passphrase(available_on_device=False), on_device=False), check_fw=False) + if isinstance(pass_resp, messages.Deprecated_PassphraseStateRequest): + self.client.call_raw(messages.Deprecated_PassphraseStateAck()) + return True + + @trezor_exception + def toggle_passphrase(self) -> bool: + self._check_unlocked() + try: + device.apply_settings(self.client, use_passphrase=not self.client.features.passphrase_protection) + except Exception: + if self.type == 'Keepkey': + print('Confirm the action by entering your PIN', file=sys.stderr) + print('Use \'sendpin\' to provide the number positions for the PIN as displayed on your device\'s screen', file=sys.stderr) + print(PIN_MATRIX_DESCRIPTION, file=sys.stderr) + return True + + @trezor_exception + def can_sign_taproot(self) -> bool: + """ + Trezor T supports Taproot since firmware version 2.4.3. + Trezor One supports Taproot since firmware version 1.10.4. -def enumerate(password=''): + :returns: False, always. + """ + self._prepare_device() + if self.client.features.model == "T": + return bool(self.client.version >= (2, 4, 3)) + return bool(self.client.version >= (1, 10, 4)) + + +def enumerate(password: Optional[str] = None, expert: bool = False, chain: Chain = Chain.MAIN) -> List[Dict[str, Any]]: results = [] - for dev in enumerate_devices(): - d_data = {} + devs = hid.HidTransport.enumerate() + devs.extend(webusb.WebUsbTransport.enumerate()) + devs.extend(udp.UdpTransport.enumerate()) + for dev in devs: + d_data: Dict[str, Any] = {} d_data['type'] = 'trezor' d_data['path'] = dev.get_path() @@ -432,26 +863,29 @@ def enumerate(password=''): client = None with handle_errors(common_err_msgs["enumerate"], d_data): client = TrezorClient(d_data['path'], password) - client.client.init_device() + try: + client._prepare_device() + except TypeError: + continue if 'trezor' not in client.client.features.vendor: continue + d_data['label'] = client.client.features.label d_data['model'] = 'trezor_' + client.client.features.model.lower() - if d_data['path'] == 'udp:127.0.0.1:21324': + if d_data['path'].startswith('udp:'): d_data['model'] += '_simulator' - d_data['needs_pin_sent'] = client.client.features.pin_protection and not client.client.features.pin_cached + d_data['needs_pin_sent'] = client.client.features.pin_protection and not client.client.features.unlocked if client.client.features.model == '1': d_data['needs_passphrase_sent'] = client.client.features.passphrase_protection # always need the passphrase sent for Trezor One if it has passphrase protection enabled else: - d_data['needs_passphrase_sent'] = client.client.features.passphrase_protection and not client.client.features.passphrase_cached + d_data['needs_passphrase_sent'] = False if d_data['needs_pin_sent']: raise DeviceNotReadyError('Trezor is locked. Unlock by using \'promptpin\' and then \'sendpin\'.') - if d_data['needs_passphrase_sent'] and not password: - raise DeviceNotReadyError("Passphrase needs to be specified before the fingerprint information can be retrieved") + if d_data['needs_passphrase_sent'] and password is None: + d_data["warnings"] = [["Passphrase protection enabled but passphrase was not provided. Using default passphrase of the empty string (\"\")"]] if client.client.features.initialized: - master_xpub = client.get_pubkey_at_path('m/0h')['xpub'] - d_data['fingerprint'] = get_xpub_fingerprint_hex(master_xpub) + d_data['fingerprint'] = client.get_master_fingerprint().hex() d_data['needs_passphrase_sent'] = False # Passphrase is always needed for the above to have worked, so it's already sent else: d_data['error'] = 'Not initialized' diff --git a/hwilib/devices/trezorlib/README.md b/hwilib/devices/trezorlib/README.md index 8b5d295b0..9dee3037c 100644 --- a/hwilib/devices/trezorlib/README.md +++ b/hwilib/devices/trezorlib/README.md @@ -1,13 +1,14 @@ # Python Trezor Library -This is a stripped down version of the official [python-trezor](https://github.com/trezor/python-trezor) library. +This is a stripped down version of the official [python-trezor](https://github.com/trezor/trezor-firmware/tree/master/python) library. -This stripped down version was made at commit [d5c2636f0d1b7da3cb94a4eff6169d77f58cefc1](https://github.com/trezor/python-trezor/tree/d5c2636f0d1b7da3cb94a4eff6169d77f58cefc1). +This stripped down version was made at commit [3ed92a72bb2f4c923bd826ffc959e2f1660e75cd](https://github.com/trezor/trezor-firmware/commit/3ed92a72bb2f4c923bd826ffc959e2f1660e75cd). ## Changes - Removed altcoin support -- Include the compiled protobuf definitions instead of making them on install - Removed functions that HWI does not use or plan to use -- Changed `TrezorClient` from calling `init_device()` (HWI needs this behavior and doing it in the library makes this simpler) -- Add Keepkey support. Some fields of some messages had to be removed to support both the Keepkey and the Trezor in the same library +- Optionally disable firmware version check in `TrezorClient.call` +- Remove `_MessageTypeMeta` init override + +See commit 83d17621d9c61636ccfe8cbf026ba2ed180fac86 for the modifications made. diff --git a/hwilib/devices/trezorlib/__init__.py b/hwilib/devices/trezorlib/__init__.py index 029f0032e..7173647ad 100644 --- a/hwilib/devices/trezorlib/__init__.py +++ b/hwilib/devices/trezorlib/__init__.py @@ -1,9 +1,17 @@ -__version__ = "0.11.1" +# This file is part of the Trezor project. +# +# Copyright (C) 2012-2022 SatoshiLabs and contributors +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the License along with this library. +# If not, see . -# fmt: off -MINIMUM_FIRMWARE_VERSION = { - "1": (1, 6, 1), - "T": (2, 0, 10), - "K1-14AM": (0, 0, 0) -} -# fmt: on +__version__ = "0.13.1" diff --git a/hwilib/devices/trezorlib/btc.py b/hwilib/devices/trezorlib/btc.py index f9c56cf0a..1dff24880 100644 --- a/hwilib/devices/trezorlib/btc.py +++ b/hwilib/devices/trezorlib/btc.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2022 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -14,19 +14,100 @@ # You should have received a copy of the License along with this library. # If not, see . -from . import messages -from .tools import CallException, expect, normalize_nfc, session +import warnings +from copy import copy +from decimal import Decimal +from typing import TYPE_CHECKING, Any, AnyStr, Dict, List, Optional, Sequence, Tuple + +# TypedDict is not available in typing for python < 3.8 +from typing_extensions import TypedDict + +from . import exceptions, messages +from .tools import expect, normalize_nfc, session + +if TYPE_CHECKING: + from .client import TrezorClient + from .tools import Address + from .protobuf import MessageType + + class ScriptSig(TypedDict): + asm: str + hex: str + + class ScriptPubKey(TypedDict): + asm: str + hex: str + type: str + reqSigs: int + addresses: List[str] + + class Vin(TypedDict): + txid: str + vout: int + sequence: int + coinbase: str + scriptSig: "ScriptSig" + txinwitness: List[str] + + class Vout(TypedDict): + value: float + int: int + scriptPubKey: "ScriptPubKey" + + class Transaction(TypedDict): + txid: str + hash: str + version: int + size: int + vsize: int + weight: int + locktime: int + vin: List[Vin] + vout: List[Vout] + + +def from_json(json_dict: "Transaction") -> messages.TransactionType: + def make_input(vin: "Vin") -> messages.TxInputType: + if "coinbase" in vin: + return messages.TxInputType( + prev_hash=b"\0" * 32, + prev_index=0xFFFFFFFF, # signed int -1 + script_sig=bytes.fromhex(vin["coinbase"]), + sequence=vin["sequence"], + ) + + else: + return messages.TxInputType( + prev_hash=bytes.fromhex(vin["txid"]), + prev_index=vin["vout"], + script_sig=bytes.fromhex(vin["scriptSig"]["hex"]), + sequence=vin["sequence"], + ) + + def make_bin_output(vout: "Vout") -> messages.TxOutputBinType: + return messages.TxOutputBinType( + amount=int(Decimal(vout["value"]) * (10 ** 8)), + script_pubkey=bytes.fromhex(vout["scriptPubKey"]["hex"]), + ) + + return messages.TransactionType( + version=json_dict["version"], + lock_time=json_dict.get("locktime", 0), + inputs=[make_input(vin) for vin in json_dict["vin"]], + bin_outputs=[make_bin_output(vout) for vout in json_dict["vout"]], + ) @expect(messages.PublicKey) def get_public_node( - client, - n, - ecdsa_curve_name=None, - show_display=False, - coin_name=None, - script_type=messages.InputScriptType.SPENDADDRESS, -): + client: "TrezorClient", + n: "Address", + ecdsa_curve_name: Optional[str] = None, + show_display: bool = False, + coin_name: Optional[str] = None, + script_type: messages.InputScriptType = messages.InputScriptType.SPENDADDRESS, + ignore_xpub_magic: bool = False, +) -> "MessageType": return client.call( messages.GetPublicKey( address_n=n, @@ -34,19 +115,21 @@ def get_public_node( show_display=show_display, coin_name=coin_name, script_type=script_type, + ignore_xpub_magic=ignore_xpub_magic, ) ) -@expect(messages.Address, field="address") +@expect(messages.Address, field="address", ret_type=str) def get_address( - client, - coin_name, - n, - show_display=False, - multisig=None, - script_type=messages.InputScriptType.SPENDADDRESS, -): + client: "TrezorClient", + coin_name: str, + n: "Address", + show_display: bool = False, + multisig: Optional[messages.MultisigRedeemScriptType] = None, + script_type: messages.InputScriptType = messages.InputScriptType.SPENDADDRESS, + ignore_xpub_magic: bool = False, +) -> "MessageType": return client.call( messages.GetAddress( address_n=n, @@ -54,58 +137,163 @@ def get_address( show_display=show_display, multisig=multisig, script_type=script_type, + ignore_xpub_magic=ignore_xpub_magic, + ) + ) + + +@expect(messages.OwnershipId, field="ownership_id", ret_type=bytes) +def get_ownership_id( + client: "TrezorClient", + coin_name: str, + n: "Address", + multisig: Optional[messages.MultisigRedeemScriptType] = None, + script_type: messages.InputScriptType = messages.InputScriptType.SPENDADDRESS, +) -> "MessageType": + return client.call( + messages.GetOwnershipId( + address_n=n, + coin_name=coin_name, + multisig=multisig, + script_type=script_type, ) ) +def get_ownership_proof( + client: "TrezorClient", + coin_name: str, + n: "Address", + multisig: Optional[messages.MultisigRedeemScriptType] = None, + script_type: messages.InputScriptType = messages.InputScriptType.SPENDADDRESS, + user_confirmation: bool = False, + ownership_ids: Optional[List[bytes]] = None, + commitment_data: Optional[bytes] = None, + preauthorized: bool = False, +) -> Tuple[bytes, bytes]: + if preauthorized: + res = client.call(messages.DoPreauthorized()) + if not isinstance(res, messages.PreauthorizedRequest): + raise exceptions.TrezorException("Unexpected message") + + res = client.call( + messages.GetOwnershipProof( + address_n=n, + coin_name=coin_name, + script_type=script_type, + multisig=multisig, + user_confirmation=user_confirmation, + ownership_ids=ownership_ids, + commitment_data=commitment_data, + ) + ) + + if not isinstance(res, messages.OwnershipProof): + raise exceptions.TrezorException("Unexpected message") + + return res.ownership_proof, res.signature + + @expect(messages.MessageSignature) def sign_message( - client, coin_name, n, message, script_type=messages.InputScriptType.SPENDADDRESS -): - message = normalize_nfc(message) + client: "TrezorClient", + coin_name: str, + n: "Address", + message: AnyStr, + script_type: messages.InputScriptType = messages.InputScriptType.SPENDADDRESS, + no_script_type: bool = False, +) -> "MessageType": return client.call( messages.SignMessage( - coin_name=coin_name, address_n=n, message=message, script_type=script_type + coin_name=coin_name, + address_n=n, + message=normalize_nfc(message), + script_type=script_type, + no_script_type=no_script_type, ) ) + +def verify_message( + client: "TrezorClient", + coin_name: str, + address: str, + signature: bytes, + message: AnyStr, +) -> bool: + try: + resp = client.call( + messages.VerifyMessage( + address=address, + signature=signature, + message=normalize_nfc(message), + coin_name=coin_name, + ) + ) + except exceptions.TrezorFailure: + return False + return isinstance(resp, messages.Success) + + @session -def sign_tx(client, coin_name, inputs, outputs, details=None, prev_txes=None): - # set up a transactions dict - txes = {None: messages.TransactionType(inputs=inputs, outputs=outputs)} - # preload all relevant transactions ahead of time - for inp in inputs: - if inp.script_type not in ( - messages.InputScriptType.SPENDP2SHWITNESS, - messages.InputScriptType.SPENDWITNESS, - messages.InputScriptType.EXTERNAL, - ): - try: - prev_tx = prev_txes[inp.prev_hash] - except Exception as e: - raise ValueError("Could not retrieve prev_tx") from e - if not isinstance(prev_tx, messages.TransactionType): - raise ValueError("Invalid value for prev_tx") from None - txes[inp.prev_hash] = prev_tx - - if details is None: - signtx = messages.SignTx() - else: +def sign_tx( + client: "TrezorClient", + coin_name: str, + inputs: Sequence[messages.TxInputType], + outputs: Sequence[messages.TxOutputType], + details: Optional[messages.SignTx] = None, + prev_txes: Optional[Dict[bytes, messages.TransactionType]] = None, + preauthorized: bool = False, + **kwargs: Any, +) -> Tuple[Sequence[Optional[bytes]], bytes]: + """Sign a Bitcoin-like transaction. + + Returns a list of signatures (one for each provided input) and the + network-serialized transaction. + + In addition to the required arguments, it is possible to specify additional + transaction properties (version, lock time, expiry...). Each additional argument + must correspond to a field in the `SignTx` data type. Note that some fields + (`inputs_count`, `outputs_count`, `coin_name`) will be inferred from the arguments + and cannot be overriden by kwargs. + """ + if prev_txes is None: + prev_txes = {} + + if details is not None: + warnings.warn( + "'details' argument is deprecated, use kwargs instead", + DeprecationWarning, + stacklevel=2, + ) signtx = details + signtx.coin_name = coin_name + signtx.inputs_count = len(inputs) + signtx.outputs_count = len(outputs) + + else: + signtx = messages.SignTx( + coin_name=coin_name, + inputs_count=len(inputs), + outputs_count=len(outputs), + ) + for name, value in kwargs.items(): + if hasattr(signtx, name): + setattr(signtx, name, value) - signtx.coin_name = coin_name - signtx.inputs_count = len(inputs) - signtx.outputs_count = len(outputs) + if preauthorized: + res = client.call(messages.DoPreauthorized()) + if not isinstance(res, messages.PreauthorizedRequest): + raise exceptions.TrezorException("Unexpected message") res = client.call(signtx) # Prepare structure for signatures - signatures = [None] * len(inputs) + signatures: List[Optional[bytes]] = [None] * len(inputs) serialized_tx = b"" - def copy_tx_meta(tx): - tx_copy = messages.TransactionType() - tx_copy.CopyFrom(tx) + def copy_tx_meta(tx: messages.TransactionType) -> messages.TransactionType: + tx_copy = copy(tx) # clear fields tx_copy.inputs_cnt = len(tx.inputs) tx_copy.inputs = [] @@ -116,6 +304,15 @@ def copy_tx_meta(tx): tx_copy.extra_data = None return tx_copy + this_tx = messages.TransactionType( + inputs=inputs, + outputs=outputs, + inputs_cnt=len(inputs), + outputs_cnt=len(outputs), + # pick either kw-provided or default value from the SignTx request + version=signtx.version, + ) + R = messages.RequestType while isinstance(res, messages.TxRequest): # If there's some part of signed transaction, let's add it @@ -127,46 +324,80 @@ def copy_tx_meta(tx): idx = res.serialized.signature_index sig = res.serialized.signature if signatures[idx] is not None: - raise ValueError("Signature for index %d already filled" % idx) + raise ValueError(f"Signature for index {idx} already filled") signatures[idx] = sig if res.request_type == R.TXFINISHED: break + assert res.details is not None, "device did not provide details" + # Device asked for one more information, let's process it. - current_tx = txes[res.details.tx_hash] + if res.details.tx_hash is not None: + if res.details.tx_hash not in prev_txes: + raise ValueError( + f"Previous transaction {res.details.tx_hash.hex()} not available" + ) + current_tx = prev_txes[res.details.tx_hash] + else: + current_tx = this_tx + + msg = messages.TransactionType() if res.request_type == R.TXMETA: msg = copy_tx_meta(current_tx) - res = client.call(messages.TxAck(tx=msg)) - - elif res.request_type == R.TXINPUT: - msg = messages.TransactionType() + elif res.request_type in (R.TXINPUT, R.TXORIGINPUT): + assert res.details.request_index is not None msg.inputs = [current_tx.inputs[res.details.request_index]] - res = client.call(messages.TxAck(tx=msg)) - elif res.request_type == R.TXOUTPUT: - msg = messages.TransactionType() + assert res.details.request_index is not None if res.details.tx_hash: msg.bin_outputs = [current_tx.bin_outputs[res.details.request_index]] else: msg.outputs = [current_tx.outputs[res.details.request_index]] - - res = client.call(messages.TxAck(tx=msg)) - + elif res.request_type == R.TXORIGOUTPUT: + assert res.details.request_index is not None + msg.outputs = [current_tx.outputs[res.details.request_index]] elif res.request_type == R.TXEXTRADATA: + assert res.details.extra_data_offset is not None + assert res.details.extra_data_len is not None + assert current_tx.extra_data is not None o, l = res.details.extra_data_offset, res.details.extra_data_len - msg = messages.TransactionType() msg.extra_data = current_tx.extra_data[o : o + l] - res = client.call(messages.TxAck(tx=msg)) + else: + raise exceptions.TrezorException( + f"Unknown request type - {res.request_type}." + ) - if isinstance(res, messages.Failure): - raise CallException("Signing failed") + res = client.call(messages.TxAck(tx=msg)) if not isinstance(res, messages.TxRequest): - raise CallException("Unexpected message") + raise exceptions.TrezorException("Unexpected message") - if None in signatures: - raise RuntimeError("Some signatures are missing!") + for i, sig in zip(inputs, signatures): + if i.script_type != messages.InputScriptType.EXTERNAL and sig is None: + raise exceptions.TrezorException("Some signatures are missing!") return signatures, serialized_tx + + +@expect(messages.Success, field="message", ret_type=str) +def authorize_coinjoin( + client: "TrezorClient", + coordinator: str, + max_total_fee: int, + n: "Address", + coin_name: str, + fee_per_anonymity: Optional[int] = None, + script_type: messages.InputScriptType = messages.InputScriptType.SPENDADDRESS, +) -> "MessageType": + return client.call( + messages.AuthorizeCoinJoin( + coordinator=coordinator, + max_total_fee=max_total_fee, + address_n=n, + coin_name=coin_name, + fee_per_anonymity=fee_per_anonymity, + script_type=script_type, + ) + ) diff --git a/hwilib/devices/trezorlib/client.py b/hwilib/devices/trezorlib/client.py index 75125b492..5048340dd 100644 --- a/hwilib/devices/trezorlib/client.py +++ b/hwilib/devices/trezorlib/client.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2022 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -15,41 +15,60 @@ # If not, see . import logging -import sys +import os import warnings +from typing import TYPE_CHECKING, Any, Optional from mnemonic import Mnemonic -from . import MINIMUM_FIRMWARE_VERSION, exceptions, messages, tools +from . import exceptions, mapping, messages, models +from .log import DUMP_BYTES +from .messages import Capability +from .tools import expect, parse_path, session -if sys.version_info.major < 3: - raise Exception("Trezorlib does not support Python 2 anymore.") +if TYPE_CHECKING: + from .protobuf import MessageType + from .ui import TrezorClientUI + from .transport import Transport LOG = logging.getLogger(__name__) -VENDORS = ("bitcointrezor.com", "trezor.io", "keepkey.com") MAX_PASSPHRASE_LENGTH = 50 +MAX_PIN_LENGTH = 50 -DEPRECATION_ERROR = """ -Incompatible Trezor library detected. - -(Original error: {}) -""".strip() +PASSPHRASE_ON_DEVICE = object() +PASSPHRASE_TEST_PATH = parse_path("44h/1h/0h/0/0") OUTDATED_FIRMWARE_ERROR = """ Your Trezor firmware is out of date. Update it with the following command: trezorctl firmware-update -Or visit https://wallet.trezor.io/ +Or visit https://suite.trezor.io/ """.strip() -def get_buttonrequest_value(code): - # Converts integer code to its string representation of ButtonRequestType - return [ - k - for k in dir(messages.ButtonRequestType) - if getattr(messages.ButtonRequestType, k) == code - ][0] +def get_default_client( + path: Optional[str] = None, ui: Optional["TrezorClientUI"] = None, **kwargs: Any +) -> "TrezorClient": + """Get a client for a connected Trezor device. + + Returns a TrezorClient instance with minimum fuss. + + If path is specified, does a prefix-search for the specified device. Otherwise, uses + the value of TREZOR_PATH env variable, or finds first connected Trezor. + If no UI is supplied, instantiates the default CLI UI. + """ + from .transport import get_transport + from .ui import ClickUI + + if path is None: + path = os.getenv("TREZOR_PATH") + + transport = get_transport(path, prefix_search=True) + if ui is None: + ui = ClickUI() + + return TrezorClient(transport, ui, **kwargs) + class TrezorClient: """Trezor client, a connection to a Trezor device. @@ -57,67 +76,113 @@ class TrezorClient: This class allows you to manage connection state, send and receive protobuf messages, handle user interactions, and perform some generic tasks (send a cancel message, initialize or clear a session, ping the device). - - You have to provide a transport, i.e., a raw connection to the device. You can use - `trezorlib.transport.get_transport` to find one. - - You have to provide an UI implementation for the three kinds of interaction: - - button request (notify the user that their interaction is needed) - - PIN request (on T1, ask the user to input numbers for a PIN matrix) - - passphrase request (ask the user to enter a passphrase) - See `trezorlib.ui` for details. - - You can supply a `state` you saved in the previous session. If you do, - the user might not need to enter their passphrase again. """ - def __init__(self, transport, ui=None, state=None): - LOG.info("creating client instance for device: {}".format(transport.get_path())) + def __init__( + self, + transport: "Transport", + ui: "TrezorClientUI", + session_id: Optional[bytes] = None, + derive_cardano: Optional[bool] = None, + model: Optional[models.TrezorModel] = None, + _init_device: bool = True, + ) -> None: + """Create a TrezorClient instance. + + You have to provide a `transport`, i.e., a raw connection to the device. You can + use `trezorlib.transport.get_transport` to find one. + + You have to provide an UI implementation for the three kinds of interaction: + - button request (notify the user that their interaction is needed) + - PIN request (on T1, ask the user to input numbers for a PIN matrix) + - passphrase request (ask the user to enter a passphrase) See `trezorlib.ui` for + details. + + You can supply a `session_id` you might have saved in the previous session. If + you do, the user might not need to enter their passphrase again. + + You can provide Trezor model information. If not provided, it is detected from + the model name reported at initialization time. + + By default, the instance will open a connection to the Trezor device, send an + `Initialize` message, set up the `features` field from the response, and connect + to a session. By specifying `_init_device=False`, this step is skipped. Notably, + this means that `client.features` is unset. Use `client.init_device()` or + `client.refresh_features()` to fix that, otherwise A LOT OF THINGS will break. + Only use this if you are _sure_ that you know what you are doing. This feature + might be removed at any time. + """ + LOG.info(f"creating client instance for device: {transport.get_path()}") + self.model = model + if self.model: + self.mapping = self.model.default_mapping + else: + self.mapping = mapping.DEFAULT_MAPPING self.transport = transport self.ui = ui - self.state = state - - if ui is None: - warnings.warn("UI class not supplied. This will probably crash soon.") - self.session_counter = 0 + self.session_id = session_id + if _init_device: + self.init_device(session_id=session_id, derive_cardano=derive_cardano) - def open(self): + def open(self) -> None: if self.session_counter == 0: self.transport.begin_session() self.session_counter += 1 - def close(self): - if self.session_counter == 1: + def close(self) -> None: + self.session_counter = max(self.session_counter - 1, 0) + if self.session_counter == 0: + # TODO call EndSession here? self.transport.end_session() - self.session_counter -= 1 - def cancel(self): + def cancel(self) -> None: self._raw_write(messages.Cancel()) - def call_raw(self, msg): + def call_raw(self, msg: "MessageType") -> "MessageType": __tracebackhide__ = True # for pytest # pylint: disable=W0612 self._raw_write(msg) return self._raw_read() - def _raw_write(self, msg): + def _raw_write(self, msg: "MessageType") -> None: __tracebackhide__ = True # for pytest # pylint: disable=W0612 - self.transport.write(msg) + LOG.debug( + f"sending message: {msg.__class__.__name__}", + extra={"protobuf": msg}, + ) + msg_type, msg_bytes = self.mapping.encode(msg) + LOG.log( + DUMP_BYTES, + f"encoded as type {msg_type} ({len(msg_bytes)} bytes): {msg_bytes.hex()}", + ) + self.transport.write(msg_type, msg_bytes) - def _raw_read(self): + def _raw_read(self) -> "MessageType": __tracebackhide__ = True # for pytest # pylint: disable=W0612 - return self.transport.read() + msg_type, msg_bytes = self.transport.read() + LOG.log( + DUMP_BYTES, + f"received type {msg_type} ({len(msg_bytes)} bytes): {msg_bytes.hex()}", + ) + msg = self.mapping.decode(msg_type, msg_bytes) + LOG.debug( + f"received message: {msg.__class__.__name__}", + extra={"protobuf": msg}, + ) + return msg - def _callback_pin(self, msg): + def _callback_pin(self, msg: messages.PinMatrixRequest) -> "MessageType": try: pin = self.ui.get_pin(msg.type) except exceptions.Cancelled: self.call_raw(messages.Cancel()) raise - if not pin.isdigit(): + if any(d not in "123456789" for d in pin) or not ( + 1 <= len(pin) <= MAX_PIN_LENGTH + ): self.call_raw(messages.Cancel()) - raise ValueError("Non-numeric PIN provided") + raise ValueError("Invalid PIN provided") resp = self.call_raw(messages.PinMatrixAck(pin=pin)) if isinstance(resp, messages.Failure) and resp.code in ( @@ -129,38 +194,57 @@ def _callback_pin(self, msg): else: return resp - def _callback_passphrase(self, msg): - if msg.on_device: - passphrase = None - else: - try: - passphrase = self.ui.get_passphrase() - except exceptions.Cancelled: - self.call_raw(messages.Cancel()) - raise + def _callback_passphrase(self, msg: messages.PassphraseRequest) -> "MessageType": + available_on_device = Capability.PassphraseEntry in self.features.capabilities + + def send_passphrase( + passphrase: Optional[str] = None, on_device: Optional[bool] = None + ) -> "MessageType": + msg = messages.PassphraseAck(passphrase=passphrase, on_device=on_device) + resp = self.call_raw(msg) + if isinstance(resp, messages.Deprecated_PassphraseStateRequest): + self.session_id = resp.state + resp = self.call_raw(messages.Deprecated_PassphraseStateAck()) + return resp + + # short-circuit old style entry + if msg._on_device is True: + return send_passphrase(None, None) + + try: + passphrase = self.ui.get_passphrase(available_on_device=available_on_device) + except exceptions.Cancelled: + self.call_raw(messages.Cancel()) + raise - passphrase = Mnemonic.normalize_string(passphrase) - if len(passphrase) > MAX_PASSPHRASE_LENGTH: + if passphrase is PASSPHRASE_ON_DEVICE: + if not available_on_device: self.call_raw(messages.Cancel()) - raise ValueError("Passphrase too long") + raise RuntimeError("Device is not capable of entering passphrase") + else: + return send_passphrase(on_device=True) - resp = self.call_raw(messages.PassphraseAck(passphrase=passphrase)) - if isinstance(resp, messages.PassphraseStateRequest): - self.state = resp.state - return self.call_raw(messages.PassphraseStateAck()) - else: - return resp + # else process host-entered passphrase + if not isinstance(passphrase, str): + raise RuntimeError("Passphrase must be a str") + passphrase = Mnemonic.normalize_string(passphrase) + if len(passphrase) > MAX_PASSPHRASE_LENGTH: + self.call_raw(messages.Cancel()) + raise ValueError("Passphrase too long") + + return send_passphrase(passphrase, on_device=False) - def _callback_button(self, msg): + def _callback_button(self, msg: messages.ButtonRequest) -> "MessageType": __tracebackhide__ = True # for pytest # pylint: disable=W0612 # do this raw - send ButtonAck first, notify UI later self._raw_write(messages.ButtonAck()) - self.ui.button_request(msg.code) + self.ui.button_request(msg) return self._raw_read() - @tools.session - def call(self, msg): - self.check_firmware_version() + @session + def call(self, msg: "MessageType", check_fw: bool = True) -> "MessageType": + if check_fw: + self.check_firmware_version() resp = self.call_raw(msg) while True: if isinstance(resp, messages.PinMatrixRequest): @@ -176,79 +260,220 @@ def call(self, msg): else: return resp - @tools.session - def init_device(self): - resp = self.call_raw(messages.GetFeatures()) - # If GetFeatures fails, try initializing and clearing inconsistent state on the device - if isinstance(resp, messages.Failure): - resp = self.call_raw(messages.Initialize()) - if not isinstance(resp, messages.Features): - raise exceptions.TrezorException("Unexpected initial response") - else: - # If this is a Trezor One or Keepkey, do Initialize - if resp.model == '1' or resp.model == 'K1-14AM': - resp = self.call_raw(messages.Initialize()) - if not isinstance(resp, messages.Features): - raise exceptions.TrezorException("Unexpected initial response") - self.features = resp - if self.features.vendor not in VENDORS: + def _refresh_features(self, features: messages.Features) -> None: + """Update internal fields based on passed-in Features message.""" + + if not self.model: + # Trezor Model One bootloader 1.8.0 or older does not send model name + self.model = models.by_name(features.model or "1") + if self.model is None: + raise RuntimeError("Unsupported Trezor model") + + if features.vendor not in self.model.vendors: raise RuntimeError("Unsupported device") - # A side-effect of this is a sanity check for broken protobuf definitions. - # If the `vendor` field doesn't exist, you probably have a mismatched - # checkout of trezor-common. + + self.features = features self.version = ( self.features.major_version, self.features.minor_version, self.features.patch_version, ) self.check_firmware_version(warn_only=True) + if self.features.session_id is not None: + self.session_id = self.features.session_id + self.features.session_id = None + + @session + def refresh_features(self) -> messages.Features: + """Reload features from the device. - def is_outdated(self): + Should be called after changing settings or performing operations that affect + device state. + """ + resp = self.call_raw(messages.GetFeatures()) + if not isinstance(resp, messages.Features): + raise exceptions.TrezorException("Unexpected response to GetFeatures") + self._refresh_features(resp) + return resp + + @session + def init_device( + self, + *, + session_id: Optional[bytes] = None, + new_session: bool = False, + derive_cardano: Optional[bool] = None, + ) -> Optional[bytes]: + """Initialize the device and return a session ID. + + You can optionally specify a session ID. If the session still exists on the + device, the same session ID will be returned and the session is resumed. + Otherwise a different session ID is returned. + + Specify `new_session=True` to open a fresh session. Since firmware version + 1.9.0/2.3.0, the previous session will remain cached on the device, and can be + resumed by calling `init_device` again with the appropriate session ID. + + If neither `new_session` nor `session_id` is specified, the current session ID + will be reused. If no session ID was cached, a new session ID will be allocated + and returned. + + # Version notes: + + Trezor One older than 1.9.0 does not have session management. Optional arguments + have no effect and the function returns None + + Trezor T older than 2.3.0 does not have session cache. Requesting a new session + will overwrite the old one. In addition, this function will always return None. + A valid session_id can be obtained from the `session_id` attribute, but only + after a passphrase-protected call is performed. You can use the following code: + + >>> client.init_device() + >>> client.ensure_unlocked() + >>> valid_session_id = client.session_id + """ + if new_session: + self.session_id = None + elif session_id is not None: + self.session_id = session_id + + resp = self.call_raw( + messages.Initialize( + session_id=self.session_id, + derive_cardano=derive_cardano, + ) + ) + if isinstance(resp, messages.Failure): + # can happen if `derive_cardano` does not match the current session + raise exceptions.TrezorFailure(resp) + if not isinstance(resp, messages.Features): + raise exceptions.TrezorException("Unexpected response to Initialize") + + if self.session_id is not None and resp.session_id == self.session_id: + LOG.info("Successfully resumed session") + elif session_id is not None: + LOG.info("Failed to resume session") + + # TT < 2.3.0 compatibility: + # _refresh_features will clear out the session_id field. We want this function + # to return its value, so that callers can rely on it being either a valid + # session_id, or None if we can't do that. + # Older TT FW does not report session_id in Features and self.session_id might + # be invalid because TT will not allocate a session_id until a passphrase + # exchange happens. + reported_session_id = resp.session_id + self._refresh_features(resp) + return reported_session_id + + def is_outdated(self) -> bool: if self.features.bootloader_mode: return False - model = self.features.model or "1" - required_version = MINIMUM_FIRMWARE_VERSION[model] - return self.version < required_version - def check_firmware_version(self, warn_only=False): + assert self.model is not None # should happen in _refresh_features + return self.version < self.model.minimum_version + + def check_firmware_version(self, warn_only: bool = False) -> None: if self.is_outdated(): if warn_only: - warnings.warn(OUTDATED_FIRMWARE_ERROR, stacklevel=2) + warnings.warn("Firmware is out of date", stacklevel=2) else: raise exceptions.OutdatedFirmwareError(OUTDATED_FIRMWARE_ERROR) - @tools.expect(messages.Success, field="message") + @expect(messages.Success, field="message", ret_type=str) def ping( self, - msg, - button_protection=False, - pin_protection=False, - passphrase_protection=False, - ): + msg: str, + button_protection: bool = False, + ) -> "MessageType": # We would like ping to work on any valid TrezorClient instance, but # due to the protection modes, we need to go through self.call, and that will # raise an exception if the firmware is too old. # So we short-circuit the simplest variant of ping with call_raw. - if not button_protection and not pin_protection and not passphrase_protection: + if not button_protection: # XXX this should be: `with self:` try: self.open() - return self.call_raw(messages.Ping(message=msg)) + resp = self.call_raw(messages.Ping(message=msg)) + if isinstance(resp, messages.ButtonRequest): + # device is PIN-locked. + # respond and hope for the best + resp = self._callback_button(resp) + return resp finally: self.close() - msg = messages.Ping( - message=msg, - button_protection=button_protection, - pin_protection=pin_protection, - passphrase_protection=passphrase_protection, + return self.call( + messages.Ping(message=msg, button_protection=button_protection) ) - return self.call(msg) - def get_device_id(self): + def get_device_id(self) -> Optional[str]: return self.features.device_id - @tools.expect(messages.Success, field="message") - @tools.session - def clear_session(self): - return self.call_raw(messages.ClearSession()) + @session + def lock(self, *, _refresh_features: bool = True) -> None: + """Lock the device. + + If the device does not have a PIN configured, this will do nothing. + Otherwise, a lock screen will be shown and the device will prompt for PIN + before further actions. + + This call does _not_ invalidate passphrase cache. If passphrase is in use, + the device will not prompt for it after unlocking. + + To invalidate passphrase cache, use `end_session()`. To lock _and_ invalidate + passphrase cache, use `clear_session()`. + """ + # Private argument _refresh_features can be used internally to avoid + # refreshing in cases where we will refresh soon anyway. This is used + # in TrezorClient.clear_session() + self.call(messages.LockDevice()) + if _refresh_features: + self.refresh_features() + + @session + def ensure_unlocked(self) -> None: + """Ensure the device is unlocked and a passphrase is cached. + + If the device is locked, this will prompt for PIN. If passphrase is enabled + and no passphrase is cached for the current session, the device will also + prompt for passphrase. + + After calling this method, further actions on the device will not prompt for + PIN or passphrase until the device is locked or the session becomes invalid. + """ + from .btc import get_address + + get_address(self, "Testnet", PASSPHRASE_TEST_PATH) + self.refresh_features() + + def end_session(self) -> None: + """Close the current session and clear cached passphrase. + + The session will become invalid until `init_device()` is called again. + If passphrase is enabled, further actions will prompt for it again. + + This is a no-op in bootloader mode, as it does not support session management. + """ + # since: 2.3.4, 1.9.4 + try: + if not self.features.bootloader_mode: + self.call(messages.EndSession()) + except exceptions.TrezorFailure: + # A failure most likely means that the FW version does not support + # the EndSession call. We ignore the failure and clear the local session_id. + # The client-side end result is identical. + pass + self.session_id = None + + @session + def clear_session(self) -> None: + """Lock the device and present a fresh session. + + The current session will be invalidated and a new one will be started. If the + device has PIN enabled, it will become locked. + + Equivalent to calling `lock()`, `end_session()` and `init_device()`. + """ + self.lock(_refresh_features=False) + self.end_session() + self.init_device(new_session=True) diff --git a/hwilib/devices/trezorlib/debuglink.py b/hwilib/devices/trezorlib/debuglink.py index 9a94bfbb1..fec103433 100644 --- a/hwilib/devices/trezorlib/debuglink.py +++ b/hwilib/devices/trezorlib/debuglink.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2022 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -14,173 +14,377 @@ # You should have received a copy of the License along with this library. # If not, see . +import logging +import textwrap +from collections import namedtuple from copy import deepcopy +from enum import IntEnum +from itertools import zip_longest +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Generator, + Iterable, + Iterator, + List, + Optional, + Sequence, + Tuple, + Type, + Union, +) from mnemonic import Mnemonic -from . import messages as proto, protobuf, tools +from . import mapping, messages, protobuf from .client import TrezorClient +from .exceptions import TrezorFailure +from .log import DUMP_BYTES +from .models import TrezorModel from .tools import expect +if TYPE_CHECKING: + from .transport import Transport + from .messages import PinMatrixRequestType + + ExpectedMessage = Union[ + protobuf.MessageType, Type[protobuf.MessageType], "MessageFilter" + ] + EXPECTED_RESPONSES_CONTEXT_LINES = 3 +LayoutLines = namedtuple("LayoutLines", "lines text") + +LOG = logging.getLogger(__name__) + + +def layout_lines(lines: Sequence[str]) -> LayoutLines: + return LayoutLines(lines, " ".join(lines)) + class DebugLink: - def __init__(self, transport, auto_interact=True): + def __init__(self, transport: "Transport", auto_interact: bool = True) -> None: self.transport = transport self.allow_interactions = auto_interact + self.mapping = mapping.DEFAULT_MAPPING - def open(self): + def open(self) -> None: self.transport.begin_session() - def close(self): + def close(self) -> None: self.transport.end_session() - def _call(self, msg, nowait=False): - self.transport.write(msg) + def _call(self, msg: protobuf.MessageType, nowait: bool = False) -> Any: + LOG.debug( + f"sending message: {msg.__class__.__name__}", + extra={"protobuf": msg}, + ) + msg_type, msg_bytes = self.mapping.encode(msg) + LOG.log( + DUMP_BYTES, + f"encoded as type {msg_type} ({len(msg_bytes)} bytes): {msg_bytes.hex()}", + ) + self.transport.write(msg_type, msg_bytes) if nowait: return None - ret = self.transport.read() - return ret - def state(self): - return self._call(proto.DebugLinkGetState()) + ret_type, ret_bytes = self.transport.read() + LOG.log( + DUMP_BYTES, + f"received type {msg_type} ({len(msg_bytes)} bytes): {msg_bytes.hex()}", + ) + msg = self.mapping.decode(ret_type, ret_bytes) + LOG.debug( + f"received message: {msg.__class__.__name__}", + extra={"protobuf": msg}, + ) + return msg - def read_pin(self): - state = self.state() - return state.pin, state.matrix + def state(self) -> messages.DebugLinkState: + return self._call(messages.DebugLinkGetState()) + + def read_layout(self) -> LayoutLines: + return layout_lines(self.state().layout_lines) + + def wait_layout(self) -> LayoutLines: + obj = self._call(messages.DebugLinkGetState(wait_layout=True)) + if isinstance(obj, messages.Failure): + raise TrezorFailure(obj) + return layout_lines(obj.layout_lines) + + def watch_layout(self, watch: bool) -> None: + """Enable or disable watching layouts. + If disabled, wait_layout will not work. - def read_pin_encoded(self): - return self.encode_pin(*self.read_pin()) + The message is missing on T1. Use `TrezorClientDebugLink.watch_layout` for + cross-version compatibility. + """ + self._call(messages.DebugLinkWatchLayout(watch=watch)) - def encode_pin(self, pin, matrix=None): + def encode_pin(self, pin: str, matrix: Optional[str] = None) -> str: """Transform correct PIN according to the displayed matrix.""" if matrix is None: - _, matrix = self.read_pin() + matrix = self.state().matrix + if matrix is None: + # we are on trezor-core + return pin + return "".join([str(matrix.index(p) + 1) for p in pin]) - def read_layout(self): - obj = self._call(proto.DebugLinkGetState()) - return obj.layout + def read_recovery_word(self) -> Tuple[Optional[str], Optional[int]]: + state = self.state() + return (state.recovery_fake_word, state.recovery_word_pos) + + def read_reset_word(self) -> str: + state = self._call(messages.DebugLinkGetState(wait_word_list=True)) + return state.reset_word + + def read_reset_word_pos(self) -> int: + state = self._call(messages.DebugLinkGetState(wait_word_pos=True)) + return state.reset_word_pos + + def input( + self, + word: Optional[str] = None, + button: Optional[bool] = None, + swipe: Optional[messages.DebugSwipeDirection] = None, + x: Optional[int] = None, + y: Optional[int] = None, + wait: Optional[bool] = None, + hold_ms: Optional[int] = None, + ) -> Optional[LayoutLines]: + if not self.allow_interactions: + return None - def read_mnemonic(self): - obj = self._call(proto.DebugLinkGetState()) - return obj.mnemonic + args = sum(a is not None for a in (word, button, swipe, x)) + if args != 1: + raise ValueError("Invalid input - must use one of word, button, swipe") - def read_recovery_word(self): - obj = self._call(proto.DebugLinkGetState()) - return (obj.recovery_fake_word, obj.recovery_word_pos) + decision = messages.DebugLinkDecision( + yes_no=button, swipe=swipe, input=word, x=x, y=y, wait=wait, hold_ms=hold_ms + ) + ret = self._call(decision, nowait=not wait) + if ret is not None: + return layout_lines(ret.lines) + + return None - def read_reset_word(self): - obj = self._call(proto.DebugLinkGetState()) - return obj.reset_word + def click( + self, click: Tuple[int, int], wait: bool = False + ) -> Optional[LayoutLines]: + x, y = click + return self.input(x=x, y=y, wait=wait) - def read_reset_word_pos(self): - obj = self._call(proto.DebugLinkGetState()) - return obj.reset_word_pos + def press_yes(self) -> None: + self.input(button=True) - def read_reset_entropy(self): - obj = self._call(proto.DebugLinkGetState()) - return obj.reset_entropy + def press_no(self) -> None: + self.input(button=False) - def read_passphrase_protection(self): - obj = self._call(proto.DebugLinkGetState()) - return obj.passphrase_protection + def swipe_up(self, wait: bool = False) -> None: + self.input(swipe=messages.DebugSwipeDirection.UP, wait=wait) - def input(self, word=None, button=None, swipe=None): - if not self.allow_interactions: - return - decision = proto.DebugLinkDecision() - if button is not None: - decision.yes_no = button - elif word is not None: - decision.input = word - elif swipe is not None: - decision.up_down = swipe - else: - raise ValueError("You need to provide input data.") - self._call(decision, nowait=True) + def swipe_down(self) -> None: + self.input(swipe=messages.DebugSwipeDirection.DOWN) - def press_button(self, yes_no): - self._call(proto.DebugLinkDecision(yes_no=yes_no), nowait=True) + def swipe_right(self) -> None: + self.input(swipe=messages.DebugSwipeDirection.RIGHT) - def press_yes(self): - self.input(button=True) + def swipe_left(self) -> None: + self.input(swipe=messages.DebugSwipeDirection.LEFT) - def press_no(self): - self.input(button=False) + def stop(self) -> None: + self._call(messages.DebugLinkStop(), nowait=True) - def swipe_up(self): - self.input(swipe=True) + def reseed(self, value: int) -> protobuf.MessageType: + return self._call(messages.DebugLinkReseedRandom(value=value)) - def swipe_down(self): - self.input(swipe=False) + def start_recording(self, directory: str) -> None: + self._call(messages.DebugLinkRecordScreen(target_directory=directory)) - def stop(self): - self._call(proto.DebugLinkStop(), nowait=True) + def stop_recording(self) -> None: + self._call(messages.DebugLinkRecordScreen(target_directory=None)) - @expect(proto.DebugLinkMemory, field="memory") - def memory_read(self, address, length): - return self._call(proto.DebugLinkMemoryRead(address=address, length=length)) + @expect(messages.DebugLinkMemory, field="memory", ret_type=bytes) + def memory_read(self, address: int, length: int) -> protobuf.MessageType: + return self._call(messages.DebugLinkMemoryRead(address=address, length=length)) - def memory_write(self, address, memory, flash=False): + def memory_write(self, address: int, memory: bytes, flash: bool = False) -> None: self._call( - proto.DebugLinkMemoryWrite(address=address, memory=memory, flash=flash), + messages.DebugLinkMemoryWrite(address=address, memory=memory, flash=flash), nowait=True, ) - def flash_erase(self, sector): - self._call(proto.DebugLinkFlashErase(sector=sector), nowait=True) + def flash_erase(self, sector: int) -> None: + self._call(messages.DebugLinkFlashErase(sector=sector), nowait=True) + + @expect(messages.Success) + def erase_sd_card(self, format: bool = True) -> messages.Success: + return self._call(messages.DebugLinkEraseSdCard(format=format)) class NullDebugLink(DebugLink): - def __init__(self): - super().__init__(None) + def __init__(self) -> None: + # Ignoring type error as self.transport will not be touched while using NullDebugLink + super().__init__(None) # type: ignore ["None" cannot be assigned to parameter of type "Transport"] - def open(self): + def open(self) -> None: pass - def close(self): + def close(self) -> None: pass - def _call(self, msg, nowait=False): + def _call( + self, msg: protobuf.MessageType, nowait: bool = False + ) -> Optional[messages.DebugLinkState]: if not nowait: - if isinstance(msg, proto.DebugLinkGetState): - return proto.DebugLinkState() + if isinstance(msg, messages.DebugLinkGetState): + return messages.DebugLinkState() else: raise RuntimeError("unexpected call to a fake debuglink") + return None + class DebugUI: INPUT_FLOW_DONE = object() - def __init__(self, debuglink: DebugLink): + def __init__(self, debuglink: DebugLink) -> None: self.debuglink = debuglink - self.pin = None - self.passphrase = "sphinx of black quartz, judge my wov" - self.input_flow = None + self.clear() + + def clear(self) -> None: + self.pins: Optional[Iterator[str]] = None + self.passphrase = "" + self.input_flow: Union[ + Generator[None, messages.ButtonRequest, None], object, None + ] = None - def button_request(self, code): + def button_request(self, br: messages.ButtonRequest) -> None: if self.input_flow is None: - self.debuglink.press_yes() + if br.code == messages.ButtonRequestType.PinEntry: + self.debuglink.input(self.get_pin()) + else: + if br.pages is not None: + for _ in range(br.pages - 1): + self.debuglink.swipe_up(wait=True) + self.debuglink.press_yes() elif self.input_flow is self.INPUT_FLOW_DONE: raise AssertionError("input flow ended prematurely") else: try: - self.input_flow.send(code) + assert isinstance(self.input_flow, Generator) + self.input_flow.send(br) except StopIteration: self.input_flow = self.INPUT_FLOW_DONE - def get_pin(self, code=None): - if self.pin: - return self.pin - else: - return self.debuglink.read_pin_encoded() + def get_pin(self, code: Optional["PinMatrixRequestType"] = None) -> str: + if self.pins is None: + raise RuntimeError("PIN requested but no sequence was configured") - def get_passphrase(self): + try: + return self.debuglink.encode_pin(next(self.pins)) + except StopIteration: + raise AssertionError("PIN sequence ended prematurely") + + def get_passphrase(self, available_on_device: bool) -> str: return self.passphrase +class MessageFilter: + def __init__(self, message_type: Type[protobuf.MessageType], **fields: Any) -> None: + self.message_type = message_type + self.fields: Dict[str, Any] = {} + self.update_fields(**fields) + + def update_fields(self, **fields: Any) -> "MessageFilter": + for name, value in fields.items(): + try: + self.fields[name] = self.from_message_or_type(value) + except TypeError: + self.fields[name] = value + + return self + + @classmethod + def from_message_or_type( + cls, message_or_type: "ExpectedMessage" + ) -> "MessageFilter": + if isinstance(message_or_type, cls): + return message_or_type + if isinstance(message_or_type, protobuf.MessageType): + return cls.from_message(message_or_type) + if isinstance(message_or_type, type) and issubclass( + message_or_type, protobuf.MessageType + ): + return cls(message_or_type) + raise TypeError("Invalid kind of expected response") + + @classmethod + def from_message(cls, message: protobuf.MessageType) -> "MessageFilter": + fields = {} + for field in message.FIELDS.values(): + value = getattr(message, field.name) + if value in (None, [], protobuf.REQUIRED_FIELD_PLACEHOLDER): + continue + fields[field.name] = value + return cls(type(message), **fields) + + def match(self, message: protobuf.MessageType) -> bool: + if type(message) != self.message_type: + return False + + for field, expected_value in self.fields.items(): + actual_value = getattr(message, field, None) + if isinstance(expected_value, MessageFilter): + if actual_value is None or not expected_value.match(actual_value): + return False + elif expected_value != actual_value: + return False + + return True + + def to_string(self, maxwidth: int = 80) -> str: + fields: List[Tuple[str, str]] = [] + for field in self.message_type.FIELDS.values(): + if field.name not in self.fields: + continue + value = self.fields[field.name] + if isinstance(value, IntEnum): + field_str = value.name + elif isinstance(value, MessageFilter): + field_str = value.to_string(maxwidth - 4) + elif isinstance(value, protobuf.MessageType): + field_str = protobuf.format_message(value) + else: + field_str = repr(value) + field_str = textwrap.indent(field_str, " ").lstrip() + fields.append((field.name, field_str)) + + pairs = [f"{k}={v}" for k, v in fields] + oneline_str = ", ".join(pairs) + if len(oneline_str) < maxwidth: + return f"{self.message_type.__name__}({oneline_str})" + else: + item: List[str] = [] + item.append(f"{self.message_type.__name__}(") + for pair in pairs: + item.append(f" {pair}") + item.append(")") + return "\n".join(item) + + +class MessageFilterGenerator: + def __getattr__(self, key: str) -> Callable[..., "MessageFilter"]: + message_type = getattr(messages, key) + return MessageFilter(message_type).update_fields + + +message_filters = MessageFilterGenerator() + + class TrezorClientDebugLink(TrezorClient): # This class implements automatic responses # and other functionality for unit tests @@ -192,46 +396,67 @@ class TrezorClientDebugLink(TrezorClient): # without special DebugLink interface provided # by the device. - def __init__(self, transport, auto_interact=True): + def __init__(self, transport: "Transport", auto_interact: bool = True, model: Optional[TrezorModel] = None, _init_device: bool = False) -> None: try: debug_transport = transport.find_debug() self.debug = DebugLink(debug_transport, auto_interact) + # try to open debuglink, see if it works + self.debug.open() + self.debug.close() except Exception: if not auto_interact: self.debug = NullDebugLink() else: raise - self.ui = DebugUI(self.debug) - - self.in_with_statement = 0 - self.screenshot_id = 0 - - self.filters = {} + self.reset_debug_features() - # Always press Yes and provide correct pin - self.setup_debuglink(True, True) + super().__init__(transport, ui=self.ui, model=model, _init_device=_init_device) - # Do not expect any specific response from device - self.expected_responses = None - self.current_response = None + def reset_debug_features(self) -> None: + """Prepare the debugging client for a new testcase. - # Use blank passphrase - self.set_passphrase("") - super().__init__(transport, ui=self.ui) + Clears all debugging state that might have been modified by a testcase. + """ + self.ui: DebugUI = DebugUI(self.debug) + self.in_with_statement = False + self.expected_responses: Optional[List[MessageFilter]] = None + self.actual_responses: Optional[List[protobuf.MessageType]] = None + self.filters: Dict[ + Type[protobuf.MessageType], + Callable[[protobuf.MessageType], protobuf.MessageType], + ] = {} - def open(self): + def open(self) -> None: super().open() - self.debug.open() + if self.session_counter == 1: + self.debug.open() - def close(self): - self.debug.close() + def close(self) -> None: + if self.session_counter == 1: + self.debug.close() super().close() - def set_filter(self, message_type, callback): + def set_filter( + self, + message_type: Type[protobuf.MessageType], + callback: Callable[[protobuf.MessageType], protobuf.MessageType], + ) -> None: + """Configure a filter function for a specified message type. + + The `callback` must be a function that accepts a protobuf message, and returns + a (possibly modified) protobuf message of the same type. Whenever a message + is sent or received that matches `message_type`, `callback` is invoked on the + message and its result is substituted for the original. + + Useful for test scenarios with an active malicious actor on the wire. + """ + if not self.in_with_statement: + raise RuntimeError("Must be called inside 'with' statement") + self.filters[message_type] = callback - def _filter_message(self, msg): + def _filter_message(self, msg: protobuf.MessageType) -> protobuf.MessageType: message_type = msg.__class__ callback = self.filters.get(message_type) if callable(callback): @@ -239,190 +464,224 @@ def _filter_message(self, msg): else: return msg - def set_input_flow(self, input_flow): - if input_flow is None: - self.ui.input_flow = None - return + def set_input_flow( + self, input_flow: Generator[None, Optional[messages.ButtonRequest], None] + ) -> None: + """Configure a sequence of input events for the current with-block. + + The `input_flow` must be a generator function. A `yield` statement in the + input flow function waits for a ButtonRequest from the device, and returns + its code. + + Example usage: + + >>> def input_flow(): + >>> # wait for first button prompt + >>> code = yield + >>> assert code == ButtonRequestType.Other + >>> # press No + >>> client.debug.press_no() + >>> + >>> # wait for second button prompt + >>> yield + >>> # press Yes + >>> client.debug.press_yes() + >>> + >>> with client: + >>> client.set_input_flow(input_flow) + >>> some_call(client) + """ + if not self.in_with_statement: + raise RuntimeError("Must be called inside 'with' statement") if callable(input_flow): input_flow = input_flow() if not hasattr(input_flow, "send"): raise RuntimeError("input_flow should be a generator function") self.ui.input_flow = input_flow - next(input_flow) # can't send before first yield - - def __enter__(self): + input_flow.send(None) # start the generator + + def watch_layout(self, watch: bool = True) -> None: + """Enable or disable watching layout changes. + + Since trezor-core v2.3.2, it is necessary to call `watch_layout()` before + using `debug.wait_layout()`, otherwise layout changes are not reported. + """ + if self.version >= (2, 3, 2): + # version check is necessary because otherwise we cannot reliably detect + # whether and where to wait for reply: + # - T1 reports unknown debuglink messages on the wirelink + # - TT < 2.3.0 does not reply to unknown debuglink messages due to a bug + self.debug.watch_layout(watch) + + def __enter__(self) -> "TrezorClientDebugLink": # For usage in with/expected_responses - self.in_with_statement += 1 + if self.in_with_statement: + raise RuntimeError("Do not nest!") + self.in_with_statement = True return self - def __exit__(self, _type, value, traceback): - self.in_with_statement -= 1 - - if _type is not None: - # Another exception raised - return False - - if self.expected_responses is None: - # no need to check anything else - return False - - # return isinstance(value, TypeError) - # Evaluate missed responses in 'with' statement - if self.current_response < len(self.expected_responses): - self._raise_unexpected_response(None) - - # Cleanup - self.expected_responses = None - self.current_response = None - return False + def __exit__(self, exc_type: Any, value: Any, traceback: Any) -> None: + __tracebackhide__ = True # for pytest # pylint: disable=W0612 - def set_expected_responses(self, expected): + self.watch_layout(False) + # copy expected/actual responses before clearing them + expected_responses = self.expected_responses + actual_responses = self.actual_responses + self.reset_debug_features() + + if exc_type is None: + # If no other exception was raised, evaluate missed responses + # (raises AssertionError on mismatch) + self._verify_responses(expected_responses, actual_responses) + + def set_expected_responses( + self, expected: List[Union["ExpectedMessage", Tuple[bool, "ExpectedMessage"]]] + ) -> None: + """Set a sequence of expected responses to client calls. + + Within a given with-block, the list of received responses from device must + match the list of expected responses, otherwise an AssertionError is raised. + + If an expected response is given a field value other than None, that field value + must exactly match the received field value. If a given field is None + (or unspecified) in the expected response, the received field value is not + checked. + + Each expected response can also be a tuple (bool, message). In that case, the + expected response is only evaluated if the first field is True. + This is useful for differentiating sequences between Trezor models: + + >>> trezor_one = client.features.model == "1" + >>> client.set_expected_responses([ + >>> messages.ButtonRequest(code=ConfirmOutput), + >>> (trezor_one, messages.ButtonRequest(code=ConfirmOutput)), + >>> messages.Success(), + >>> ]) + """ if not self.in_with_statement: raise RuntimeError("Must be called inside 'with' statement") - self.expected_responses = expected - self.current_response = 0 - def setup_debuglink(self, button, pin_correct): - # self.button = button # True -> YES button, False -> NO button - if pin_correct: - self.ui.pin = None - else: - self.ui.pin = "444222" + # make sure all items are (bool, message) tuples + expected_with_validity = ( + e if isinstance(e, tuple) else (True, e) for e in expected + ) - def set_passphrase(self, passphrase): + # only apply those items that are (True, message) + self.expected_responses = [ + MessageFilter.from_message_or_type(expected) + for valid, expected in expected_with_validity + if valid + ] + self.actual_responses = [] + + def use_pin_sequence(self, pins: Iterable[str]) -> None: + """Respond to PIN prompts from device with the provided PINs. + The sequence must be at least as long as the expected number of PIN prompts. + """ + self.ui.pins = iter(pins) + + def use_passphrase(self, passphrase: str) -> None: + """Respond to passphrase prompts from device with the provided passphrase.""" self.ui.passphrase = Mnemonic.normalize_string(passphrase) - def set_mnemonic(self, mnemonic): + def use_mnemonic(self, mnemonic: str) -> None: + """Use the provided mnemonic to respond to device. + Only applies to T1, where device prompts the host for mnemonic words.""" self.mnemonic = Mnemonic.normalize_string(mnemonic).split(" ") - def _raw_read(self): + def _raw_read(self) -> protobuf.MessageType: __tracebackhide__ = True # for pytest # pylint: disable=W0612 - # if SCREENSHOT and self.debug: - # from PIL import Image - - # layout = self.debug.state().layout - # im = Image.new("RGB", (128, 64)) - # pix = im.load() - # for x in range(128): - # for y in range(64): - # rx, ry = 127 - x, 63 - y - # if (ord(layout[rx + (ry / 8) * 128]) & (1 << (ry % 8))) > 0: - # pix[x, y] = (255, 255, 255) - # im.save("scr%05d.png" % self.screenshot_id) - # self.screenshot_id += 1 - resp = super()._raw_read() resp = self._filter_message(resp) - self._check_request(resp) + if self.actual_responses is not None: + self.actual_responses.append(resp) return resp - def _raw_write(self, msg): + def _raw_write(self, msg: protobuf.MessageType) -> None: return super()._raw_write(self._filter_message(msg)) - def _raise_unexpected_response(self, msg): - __tracebackhide__ = True # for pytest # pylint: disable=W0612 - - start_at = max(self.current_response - EXPECTED_RESPONSES_CONTEXT_LINES, 0) - stop_at = min( - self.current_response + EXPECTED_RESPONSES_CONTEXT_LINES + 1, - len(self.expected_responses), - ) - output = [] + @staticmethod + def _expectation_lines(expected: List[MessageFilter], current: int) -> List[str]: + start_at = max(current - EXPECTED_RESPONSES_CONTEXT_LINES, 0) + stop_at = min(current + EXPECTED_RESPONSES_CONTEXT_LINES + 1, len(expected)) + output: List[str] = [] output.append("Expected responses:") if start_at > 0: - output.append(" (...{} previous responses omitted)".format(start_at)) + output.append(f" (...{start_at} previous responses omitted)") for i in range(start_at, stop_at): - exp = self.expected_responses[i] - prefix = " " if i != self.current_response else ">>> " - set_fields = { - key: value - for key, value in exp.__dict__.items() - if value is not None and value != [] - } - oneline_str = ", ".join("{}={!r}".format(*i) for i in set_fields.items()) - if len(oneline_str) < 60: - output.append( - "{}{}({})".format(prefix, exp.__class__.__name__, oneline_str) - ) - else: - item = [] - item.append("{}{}(".format(prefix, exp.__class__.__name__)) - for key, value in set_fields.items(): - item.append("{} {}={!r}".format(prefix, key, value)) - item.append("{})".format(prefix)) - output.append("\n".join(item)) - if stop_at < len(self.expected_responses): - omitted = len(self.expected_responses) - stop_at - output.append(" (...{} following responses omitted)".format(omitted)) + exp = expected[i] + prefix = " " if i != current else ">>> " + output.append(textwrap.indent(exp.to_string(), prefix)) + if stop_at < len(expected): + omitted = len(expected) - stop_at + output.append(f" (...{omitted} following responses omitted)") output.append("") - if msg is not None: - output.append("Actually received:") - output.append(protobuf.format_message(msg)) - else: - output.append("This message was never received.") - raise AssertionError("\n".join(output)) - - def _check_request(self, msg): + return output + + @classmethod + def _verify_responses( + cls, + expected: Optional[List[MessageFilter]], + actual: Optional[List[protobuf.MessageType]], + ) -> None: __tracebackhide__ = True # for pytest # pylint: disable=W0612 - if self.expected_responses is None: - return - - if self.current_response >= len(self.expected_responses): - raise AssertionError( - "No more messages were expected, but we got:\n" - + protobuf.format_message(msg) - ) - expected = self.expected_responses[self.current_response] - - if msg.__class__ != expected.__class__: - self._raise_unexpected_response(msg) - - for field, value in expected.__dict__.items(): - if value is None or value == []: - continue - if getattr(msg, field) != value: - self._raise_unexpected_response(msg) - - self.current_response += 1 + if expected is None and actual is None: + return - def mnemonic_callback(self, _): + assert expected is not None + assert actual is not None + + for i, (exp, act) in enumerate(zip_longest(expected, actual)): + if exp is None: + output = cls._expectation_lines(expected, i) + output.append("No more messages were expected, but we got:") + for resp in actual[i:]: + output.append( + textwrap.indent(protobuf.format_message(resp), " ") + ) + raise AssertionError("\n".join(output)) + + if act is None: + output = cls._expectation_lines(expected, i) + output.append("This and the following message was not received.") + raise AssertionError("\n".join(output)) + + if not exp.match(act): + output = cls._expectation_lines(expected, i) + output.append("Actually received:") + output.append(textwrap.indent(protobuf.format_message(act), " ")) + raise AssertionError("\n".join(output)) + + def mnemonic_callback(self, _) -> str: word, pos = self.debug.read_recovery_word() - if word != "": + if word: return word - if pos != 0: + if pos: return self.mnemonic[pos - 1] raise RuntimeError("Unexpected call") -@expect(proto.Success, field="message") -def load_device_by_mnemonic( - client, - mnemonic, - pin, - passphrase_protection, - label, - language="english", - skip_checksum=False, - expand=False, -): - # Convert mnemonic to UTF8 NKFD - mnemonic = Mnemonic.normalize_string(mnemonic) - - # Convert mnemonic to ASCII stream - mnemonic = mnemonic.encode() - - m = Mnemonic("english") +@expect(messages.Success, field="message", ret_type=str) +def load_device( + client: "TrezorClient", + mnemonic: Union[str, Iterable[str]], + pin: Optional[str], + passphrase_protection: bool, + label: Optional[str], + language: str = "en-US", + skip_checksum: bool = False, + needs_backup: bool = False, + no_backup: bool = False, +) -> protobuf.MessageType: + if isinstance(mnemonic, str): + mnemonic = [mnemonic] - if expand: - mnemonic = m.expand(mnemonic) - - if not skip_checksum and not m.check(mnemonic): - raise ValueError("Invalid mnemonic checksum") + mnemonics = [Mnemonic.normalize_string(m) for m in mnemonic] if client.features.initialized: raise RuntimeError( @@ -430,76 +689,32 @@ def load_device_by_mnemonic( ) resp = client.call( - proto.LoadDevice( - mnemonic=mnemonic, + messages.LoadDevice( + mnemonics=mnemonics, pin=pin, passphrase_protection=passphrase_protection, language=language, label=label, skip_checksum=skip_checksum, + needs_backup=needs_backup, + no_backup=no_backup, ) ) client.init_device() return resp -@expect(proto.Success, field="message") -def load_device_by_xprv(client, xprv, pin, passphrase_protection, label, language): - if client.features.initialized: - raise RuntimeError( - "Device is initialized already. Call wipe_device() and try again." - ) - - if xprv[0:4] not in ("xprv", "tprv"): - raise ValueError("Unknown type of xprv") - - if not 100 < len(xprv) < 112: # yes this is correct in Python - raise ValueError("Invalid length of xprv") - - node = proto.HDNodeType() - data = tools.b58decode(xprv, None).hex() - - if data[90:92] != "00": - raise ValueError("Contain invalid private key") - - checksum = (tools.btc_hash(bytes.fromhex(data[:156]))[:4]).hex() - if checksum != data[156:]: - raise ValueError("Checksum doesn't match") - - # version 0488ade4 - # depth 00 - # fingerprint 00000000 - # child_num 00000000 - # chaincode 873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508 - # privkey 00e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35 - # checksum e77e9d71 - - node.depth = int(data[8:10], 16) - node.fingerprint = int(data[10:18], 16) - node.child_num = int(data[18:26], 16) - node.chain_code = bytes.fromhex(data[26:90]) - node.private_key = bytes.fromhex(data[92:156]) # skip 0x00 indicating privkey - - resp = client.call( - proto.LoadDevice( - node=node, - pin=pin, - passphrase_protection=passphrase_protection, - language=language, - label=label, - ) - ) - client.init_device() - return resp +# keep the old name for compatibility +load_device_by_mnemonic = load_device -@expect(proto.Success, field="message") -def self_test(client): +@expect(messages.Success, field="message", ret_type=str) +def self_test(client: "TrezorClient") -> protobuf.MessageType: if client.features.bootloader_mode is not True: raise RuntimeError("Device must be in bootloader mode") return client.call( - proto.SelfTest( + messages.SelfTest( payload=b"\x00\xFF\x55\xAA\x66\x99\x33\xCCABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!\x00\xFF\x55\xAA\x66\x99\x33\xCC" ) ) diff --git a/hwilib/devices/trezorlib/device.py b/hwilib/devices/trezorlib/device.py index 7773f0f2a..6f632d1af 100644 --- a/hwilib/devices/trezorlib/device.py +++ b/hwilib/devices/trezorlib/device.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2022 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -16,97 +16,106 @@ import os import time -import warnings +from typing import TYPE_CHECKING, Callable, Optional -from . import messages as proto +from . import messages from .exceptions import Cancelled from .tools import expect, session -from .transport import enumerate_devices, get_transport - -RECOVERY_BACK = "\x08" # backspace character, sent literally +if TYPE_CHECKING: + from .client import TrezorClient + from .protobuf import MessageType -class TrezorDevice: - """ - This class is deprecated. (There is no reason for it to exist in the first - place, it is nothing but a collection of two functions.) - Instead, please use functions from the ``trezorlib.transport`` module. - """ - @classmethod - def enumerate(cls): - warnings.warn("TrezorDevice is deprecated.", DeprecationWarning) - return enumerate_devices() - - @classmethod - def find_by_path(cls, path): - warnings.warn("TrezorDevice is deprecated.", DeprecationWarning) - return get_transport(path, prefix_search=False) +RECOVERY_BACK = "\x08" # backspace character, sent literally -@expect(proto.Success, field="message") +@expect(messages.Success, field="message", ret_type=str) +@session def apply_settings( - client, - label=None, - language=None, - use_passphrase=None, - homescreen=None, - passphrase_source=None, - auto_lock_delay_ms=None, -): - settings = proto.ApplySettings() - if label is not None: - settings.label = label - if language: - settings.language = language - if use_passphrase is not None: - settings.use_passphrase = use_passphrase - if homescreen is not None: - settings.homescreen = homescreen - if passphrase_source is not None: - settings.passphrase_source = passphrase_source - if auto_lock_delay_ms is not None: - settings.auto_lock_delay_ms = auto_lock_delay_ms + client: "TrezorClient", + label: Optional[str] = None, + language: Optional[str] = None, + use_passphrase: Optional[bool] = None, + homescreen: Optional[bytes] = None, + passphrase_always_on_device: Optional[bool] = None, + auto_lock_delay_ms: Optional[int] = None, + display_rotation: Optional[int] = None, + safety_checks: Optional[messages.SafetyCheckLevel] = None, + experimental_features: Optional[bool] = None, +) -> "MessageType": + settings = messages.ApplySettings( + label=label, + language=language, + use_passphrase=use_passphrase, + homescreen=homescreen, + passphrase_always_on_device=passphrase_always_on_device, + auto_lock_delay_ms=auto_lock_delay_ms, + display_rotation=display_rotation, + safety_checks=safety_checks, + experimental_features=experimental_features, + ) out = client.call(settings) - client.init_device() # Reload Features + client.refresh_features() return out -@expect(proto.Success, field="message") -def apply_flags(client, flags): - out = client.call(proto.ApplyFlags(flags=flags)) - client.init_device() # Reload Features +@expect(messages.Success, field="message", ret_type=str) +@session +def apply_flags(client: "TrezorClient", flags: int) -> "MessageType": + out = client.call(messages.ApplyFlags(flags=flags)) + client.refresh_features() return out -@expect(proto.Success, field="message") -def change_pin(client, remove=False): - ret = client.call(proto.ChangePin(remove=remove)) - client.init_device() # Re-read features +@expect(messages.Success, field="message", ret_type=str) +@session +def change_pin(client: "TrezorClient", remove: bool = False) -> "MessageType": + ret = client.call(messages.ChangePin(remove=remove)) + client.refresh_features() return ret -@expect(proto.Success, field="message") -def wipe(client): - ret = client.call(proto.WipeDevice()) +@expect(messages.Success, field="message", ret_type=str) +@session +def change_wipe_code(client: "TrezorClient", remove: bool = False) -> "MessageType": + ret = client.call(messages.ChangeWipeCode(remove=remove)) + client.refresh_features() + return ret + + +@expect(messages.Success, field="message", ret_type=str) +@session +def sd_protect( + client: "TrezorClient", operation: messages.SdProtectOperationType +) -> "MessageType": + ret = client.call(messages.SdProtect(operation=operation)) + client.refresh_features() + return ret + + +@expect(messages.Success, field="message", ret_type=str) +@session +def wipe(client: "TrezorClient") -> "MessageType": + ret = client.call(messages.WipeDevice()) client.init_device() return ret -@expect(proto.Success, field="message") +@session def recover( - client, - word_count=24, - passphrase_protection=False, - pin_protection=True, - label=None, - language="english", - input_callback=None, - type=proto.RecoveryDeviceType.ScrambledWords, - dry_run=False, - u2f_counter=None, -): + client: "TrezorClient", + word_count: int = 24, + passphrase_protection: bool = False, + pin_protection: bool = True, + label: Optional[str] = None, + language: str = "en-US", + input_callback: Optional[Callable] = None, + type: messages.RecoveryDeviceType = messages.RecoveryDeviceType.ScrambledWords, + dry_run: bool = False, + u2f_counter: Optional[int] = None, +) -> "MessageType": if client.features.model == "1" and input_callback is None: raise RuntimeError("Input callback required for Trezor One") @@ -121,45 +130,47 @@ def recover( if u2f_counter is None: u2f_counter = int(time.time()) - res = client.call( - proto.RecoveryDevice( - word_count=word_count, - passphrase_protection=bool(passphrase_protection), - pin_protection=bool(pin_protection), - label=label, - language=language, - enforce_wordlist=True, - type=type, - dry_run=dry_run, - u2f_counter=u2f_counter, - ) + msg = messages.RecoveryDevice( + word_count=word_count, enforce_wordlist=True, type=type, dry_run=dry_run ) - while isinstance(res, proto.WordRequest): + if not dry_run: + # set additional parameters + msg.passphrase_protection = passphrase_protection + msg.pin_protection = pin_protection + msg.label = label + msg.language = language + msg.u2f_counter = u2f_counter + + res = client.call(msg) + + while isinstance(res, messages.WordRequest): try: + assert input_callback is not None inp = input_callback(res.type) - res = client.call(proto.WordAck(word=inp)) + res = client.call(messages.WordAck(word=inp)) except Cancelled: - res = client.call(proto.Cancel()) + res = client.call(messages.Cancel()) client.init_device() return res -@expect(proto.Success, field="message") +@expect(messages.Success, field="message", ret_type=str) @session def reset( - client, - display_random=False, - strength=None, - passphrase_protection=False, - pin_protection=True, - label=None, - language="english", - # u2f_counter=0, - # skip_backup=False, - # no_backup=False, -): + client: "TrezorClient", + display_random: bool = False, + strength: Optional[int] = None, + passphrase_protection: bool = False, + pin_protection: bool = True, + label: Optional[str] = None, + language: str = "en-US", + u2f_counter: int = 0, + skip_backup: bool = False, + no_backup: bool = False, + backup_type: messages.BackupType = messages.BackupType.Bip39, +) -> "MessageType": if client.features.initialized: raise RuntimeError( "Device is initialized already. Call wipe_device() and try again." @@ -172,30 +183,44 @@ def reset( strength = 128 # Begin with device reset workflow - msg = proto.ResetDevice( + msg = messages.ResetDevice( display_random=bool(display_random), strength=strength, passphrase_protection=bool(passphrase_protection), pin_protection=bool(pin_protection), language=language, label=label, - # u2f_counter=u2f_counter, - # skip_backup=bool(skip_backup), - # no_backup=bool(no_backup), + u2f_counter=u2f_counter, + skip_backup=bool(skip_backup), + no_backup=bool(no_backup), + backup_type=backup_type, ) resp = client.call(msg) - if not isinstance(resp, proto.EntropyRequest): + if not isinstance(resp, messages.EntropyRequest): raise RuntimeError("Invalid response, expected EntropyRequest") external_entropy = os.urandom(32) # LOG.debug("Computer generated entropy: " + external_entropy.hex()) - ret = client.call(proto.EntropyAck(entropy=external_entropy)) + ret = client.call(messages.EntropyAck(entropy=external_entropy)) client.init_device() return ret -@expect(proto.Success, field="message") -def backup(client): - ret = client.call(proto.BackupDevice()) +@expect(messages.Success, field="message", ret_type=str) +@session +def backup(client: "TrezorClient") -> "MessageType": + ret = client.call(messages.BackupDevice()) + client.refresh_features() return ret + + +@expect(messages.Success, field="message", ret_type=str) +def cancel_authorization(client: "TrezorClient") -> "MessageType": + return client.call(messages.CancelAuthorization()) + + +@session +@expect(messages.Success, field="message", ret_type=str) +def reboot_to_bootloader(client: "TrezorClient") -> "MessageType": + return client.call(messages.RebootToBootloader()) diff --git a/hwilib/devices/trezorlib/exceptions.py b/hwilib/devices/trezorlib/exceptions.py index f95271eef..fd7133d12 100644 --- a/hwilib/devices/trezorlib/exceptions.py +++ b/hwilib/devices/trezorlib/exceptions.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2022 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -14,18 +14,24 @@ # You should have received a copy of the License along with this library. # If not, see . +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .messages import Failure + class TrezorException(Exception): pass class TrezorFailure(TrezorException): - def __init__(self, failure): + def __init__(self, failure: "Failure") -> None: self.failure = failure - # TODO: this is backwards compatibility with tests. it should be changed - super().__init__(self.failure.code, self.failure.message) + self.code = failure.code + self.message = failure.message + super().__init__(self.code, self.message, self.failure) - def __str__(self): + def __str__(self) -> str: from .messages import FailureType types = { @@ -33,8 +39,8 @@ def __str__(self): for name in dir(FailureType) if not name.startswith("_") } - if self.failure.message is not None: - return "{}: {}".format(types[self.failure.code], self.failure.message) + if self.message is not None: + return f"{types[self.code]}: {self.message}" else: return types[self.failure.code] diff --git a/hwilib/devices/trezorlib/firmware.py b/hwilib/devices/trezorlib/firmware.py index e80a5af55..161faf828 100644 --- a/hwilib/devices/trezorlib/firmware.py +++ b/hwilib/devices/trezorlib/firmware.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2022 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -16,31 +16,60 @@ import hashlib from enum import Enum -from typing import NewType, Tuple +from hashlib import blake2s +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple import construct as c import ecdsa -import pyblake2 -from . import cosi, messages, tools +from . import cosi, messages +from .tools import session + +if TYPE_CHECKING: + from .client import TrezorClient V1_SIGNATURE_SLOTS = 3 -V1_BOOTLOADER_KEYS = { - 1: "04d571b7f148c5e4232c3814f777d8faeaf1a84216c78d569b71041ffc768a5b2d810fc3bb134dd026b57e65005275aedef43e155f48fc11a32ec790a93312bd58", - 2: "0463279c0c0866e50c05c799d32bd6bab0188b6de06536d1109d2ed9ce76cb335c490e55aee10cc901215132e853097d5432eda06b792073bd7740c94ce4516cb1", - 3: "0443aedbb6f7e71c563f8ed2ef64ec9981482519e7ef4f4aa98b27854e8c49126d4956d300ab45fdc34cd26bc8710de0a31dbdf6de7435fd0b492be70ac75fde58", - 4: "04877c39fd7c62237e038235e9c075dab261630f78eeb8edb92487159fffedfdf6046c6f8b881fa407c4a4ce6c28de0b19c1f4e29f1fcbc5a58ffd1432a3e0938a", - 5: "047384c51ae81add0a523adbb186c91b906ffb64c2c765802bf26dbd13bdf12c319e80c2213a136c8ee03d7874fd22b70d68e7dee469decfbbb510ee9a460cda45", -} +V1_BOOTLOADER_KEYS = [ + bytes.fromhex(key) + for key in ( + "04d571b7f148c5e4232c3814f777d8faeaf1a84216c78d569b71041ffc768a5b2d810fc3bb134dd026b57e65005275aedef43e155f48fc11a32ec790a93312bd58", + "0463279c0c0866e50c05c799d32bd6bab0188b6de06536d1109d2ed9ce76cb335c490e55aee10cc901215132e853097d5432eda06b792073bd7740c94ce4516cb1", + "0443aedbb6f7e71c563f8ed2ef64ec9981482519e7ef4f4aa98b27854e8c49126d4956d300ab45fdc34cd26bc8710de0a31dbdf6de7435fd0b492be70ac75fde58", + "04877c39fd7c62237e038235e9c075dab261630f78eeb8edb92487159fffedfdf6046c6f8b881fa407c4a4ce6c28de0b19c1f4e29f1fcbc5a58ffd1432a3e0938a", + "047384c51ae81add0a523adbb186c91b906ffb64c2c765802bf26dbd13bdf12c319e80c2213a136c8ee03d7874fd22b70d68e7dee469decfbbb510ee9a460cda45", + ) +] + +V2_BOARDLOADER_KEYS = [ + bytes.fromhex(key) + for key in ( + "0eb9856be9ba7e972c7f34eac1ed9b6fd0efd172ec00faf0c589759da4ddfba0", + "ac8ab40b32c98655798fd5da5e192be27a22306ea05c6d277cdff4a3f4125cd8", + "ce0fcd12543ef5936cf2804982136707863d17295faced72af171d6e6513ff06", + ) +] + +V2_BOARDLOADER_DEV_KEYS = [ + bytes.fromhex(key) + for key in ( + "db995fe25169d141cab9bbba92baa01f9f2e1ece7df4cb2ac05190f37fcc1f9d", + "2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12", + "22fc297792f0b6ffc0bfcfdb7edb0c0aa14e025a365ec0e342e86e3829cb74b6", + ) +] V2_BOOTLOADER_KEYS = [ - bytes.fromhex("c2c87a49c5a3460977fbb2ec9dfe60f06bd694db8244bd4981fe3b7a26307f3f"), - bytes.fromhex("80d036b08739b846f4cb77593078deb25dc9487aedcf52e30b4fb7cd7024178a"), - bytes.fromhex("b8307a71f552c60a4cbb317ff48b82cdbf6b6bb5f04c920fec7badf017883751"), + bytes.fromhex(key) + for key in ( + "c2c87a49c5a3460977fbb2ec9dfe60f06bd694db8244bd4981fe3b7a26307f3f", + "80d036b08739b846f4cb77593078deb25dc9487aedcf52e30b4fb7cd7024178a", + "b8307a71f552c60a4cbb317ff48b82cdbf6b6bb5f04c920fec7badf017883751", + ) ] -V2_BOOTLOADER_M = 2 -V2_BOOTLOADER_N = 3 +V2_SIGS_REQUIRED = 2 + +ONEV2_CHUNK_SIZE = 1024 * 64 V2_CHUNK_SIZE = 1024 * 128 @@ -57,10 +86,47 @@ def _transform_vendor_trust(data: bytes) -> bytes: return bytes(~b & 0xFF for b in data)[::-1] +class FirmwareIntegrityError(Exception): + pass + + +class InvalidSignatureError(FirmwareIntegrityError): + pass + + +class Unsigned(FirmwareIntegrityError): + pass + + +class ToifMode(Enum): + full_color = b"f" + grayscale = b"g" + + +class HeaderType(Enum): + FIRMWARE = b"TRZF" + BOOTLOADER = b"TRZB" + + +class EnumAdapter(c.Adapter): + def __init__(self, subcon: Any, enum: Any) -> None: + self.enum = enum + super().__init__(subcon) + + def _encode(self, obj: Any, ctx: Any, path: Any): + return obj.value + + def _decode(self, obj: Any, ctx: Any, path: Any): + try: + return self.enum(obj) + except ValueError: + return obj + + # fmt: off Toif = c.Struct( "magic" / c.Const(b"TOI"), - "format" / c.Enum(c.Byte, full_color=b"f", grayscale=b"g"), + "format" / EnumAdapter(c.Bytes(1), ToifMode), "width" / c.Int16ul, "height" / c.Int16ul, "data" / c.Prefixed(c.Int32ul, c.GreedyBytes), @@ -68,7 +134,7 @@ def _transform_vendor_trust(data: bytes) -> bytes: VendorTrust = c.Transformed(c.BitStruct( - "reserved" / c.Default(c.BitsInteger(9), 0), + "_reserved" / c.Default(c.BitsInteger(9), 0), "show_vendor_string" / c.Flag, "require_user_click" / c.Flag, "red_background" / c.Flag, @@ -79,30 +145,27 @@ def _transform_vendor_trust(data: bytes) -> bytes: VendorHeader = c.Struct( "_start_offset" / c.Tell, "magic" / c.Const(b"TRZV"), - "_header_len" / c.Padding(4), + "header_len" / c.Int32ul, "expiry" / c.Int32ul, "version" / c.Struct( "major" / c.Int8ul, "minor" / c.Int8ul, ), - "vendor_sigs_required" / c.Int8ul, - "vendor_sigs_n" / c.Rebuild(c.Int8ul, c.len_(c.this.pubkeys)), - "vendor_trust" / VendorTrust, - "reserved" / c.Padding(14), - "pubkeys" / c.Bytes(32)[c.this.vendor_sigs_n], - "vendor_string" / c.Aligned(4, c.PascalString(c.Int8ul, "utf-8")), - "vendor_image" / Toif, - "_data_end_offset" / c.Tell, - - c.Padding(-(c.this._data_end_offset + 65) % 512), + "sig_m" / c.Int8ul, + "sig_n" / c.Rebuild(c.Int8ul, c.len_(c.this.pubkeys)), + "trust" / VendorTrust, + "_reserved" / c.Padding(14), + "pubkeys" / c.Bytes(32)[c.this.sig_n], + "text" / c.Aligned(4, c.PascalString(c.Int8ul, "utf-8")), + "image" / Toif, + "_end_offset" / c.Tell, + + "_min_header_len" / c.Check(c.this.header_len > (c.this._end_offset - c.this._start_offset) + 65), + "_header_len_aligned" / c.Check(c.this.header_len % 512 == 0), + + c.Padding(c.this.header_len - c.this._end_offset + c.this._start_offset - 65), "sigmask" / c.Byte, "signature" / c.Bytes(64), - - "_end_offset" / c.Tell, - "header_len" / c.Pointer( - c.this._start_offset + 4, - c.Rebuild(c.Int32ul, c.this._end_offset - c.this._start_offset) - ), ) @@ -116,8 +179,8 @@ def _transform_vendor_trust(data: bytes) -> bytes: FirmwareHeader = c.Struct( "_start_offset" / c.Tell, - "magic" / c.Const(b"TRZF"), - "_header_len" / c.Padding(4), + "magic" / EnumAdapter(c.Bytes(4), HeaderType), + "header_len" / c.Int32ul, "expiry" / c.Int32ul, "code_length" / c.Rebuild( c.Int32ul, @@ -127,31 +190,59 @@ def _transform_vendor_trust(data: bytes) -> bytes: ), "version" / VersionLong, "fix_version" / VersionLong, - "reserved" / c.Padding(8), + "_reserved" / c.Padding(8), "hashes" / c.Bytes(32)[16], - "reserved" / c.Padding(415), + "v1_signatures" / c.Bytes(64)[V1_SIGNATURE_SLOTS], + "v1_key_indexes" / c.Int8ul[V1_SIGNATURE_SLOTS], # pylint: disable=E1136 + + "_reserved" / c.Padding(220), "sigmask" / c.Byte, "signature" / c.Bytes(64), "_end_offset" / c.Tell, - "header_len" / c.Pointer( - c.this._start_offset + 4, - c.Rebuild(c.Int32ul, c.this._end_offset - c.this._start_offset) + + "_rebuild_header_len" / c.If( + c.this.version.major > 1, + c.Pointer( + c.this._start_offset + 4, + c.Rebuild(c.Int32ul, c.this._end_offset - c.this._start_offset) + ), ), ) -Firmware = c.Struct( - "vendor_header" / VendorHeader, - "firmware_header" / FirmwareHeader, +"""Raw firmware image. + +Consists of firmware header and code block. +This is the expected format of firmware binaries for Trezor One, or bootloader images +for Trezor T.""" +FirmwareImage = c.Struct( + "header" / FirmwareHeader, "_code_offset" / c.Tell, - "code" / c.Bytes(c.this.firmware_header.code_length), + "code" / c.Bytes(c.this.header.code_length), c.Terminated, ) -FirmwareV1 = c.Struct( +"""Firmware image prefixed by a vendor header. + +This is the expected format of firmware binaries for Trezor T.""" +VendorFirmware = c.Struct( + "vendor_header" / VendorHeader, + "image" / FirmwareImage, + c.Terminated, +) + + +"""Legacy firmware image. +Consists of a custom header and code block. +This is the expected format of firmware binaries for Trezor One pre-1.8.0. + +The code block can optionally be interpreted as a new-style firmware image. That is the +expected format of firmware binary for Trezor One version 1.8.0, which can be installed +by both the older and the newer bootloader.""" +LegacyFirmware = c.Struct( "magic" / c.Const(b"TRZR"), "code_length" / c.Rebuild(c.Int32ul, c.len_(c.this.code)), "key_indexes" / c.Int8ul[V1_SIGNATURE_SLOTS], # pylint: disable=E1136 @@ -159,10 +250,12 @@ def _transform_vendor_trust(data: bytes) -> bytes: c.Padding(7), "restore_storage" / c.Flag, ), - "reserved" / c.Padding(52), + "_reserved" / c.Padding(52), "signatures" / c.Bytes(64)[V1_SIGNATURE_SLOTS], "code" / c.Bytes(c.this.code_length), c.Terminated, + + "embedded_onev2" / c.RestreamData(c.this.code, c.Optional(FirmwareImage)), ) # fmt: on @@ -171,86 +264,176 @@ def _transform_vendor_trust(data: bytes) -> bytes: class FirmwareFormat(Enum): TREZOR_ONE = 1 TREZOR_T = 2 + TREZOR_ONE_V2 = 3 -FirmwareType = NewType("FirmwareType", c.Container) -ParsedFirmware = Tuple[FirmwareFormat, FirmwareType] +ParsedFirmware = Tuple[FirmwareFormat, c.Container] def parse(data: bytes) -> ParsedFirmware: if data[:4] == b"TRZR": version = FirmwareFormat.TREZOR_ONE - cls = FirmwareV1 + cls = LegacyFirmware elif data[:4] == b"TRZV": version = FirmwareFormat.TREZOR_T - cls = Firmware + cls = VendorFirmware + elif data[:4] == b"TRZF": + version = FirmwareFormat.TREZOR_ONE_V2 + cls = FirmwareImage else: raise ValueError("Unrecognized firmware image type") try: fw = cls.parse(data) except Exception as e: - raise ValueError("Invalid firmware image") from e - return version, FirmwareType(fw) + raise FirmwareIntegrityError("Invalid firmware image") from e + return version, fw -def digest_v1(fw: FirmwareType) -> bytes: +def digest_onev1(fw: c.Container) -> bytes: return hashlib.sha256(fw.code).digest() -def check_sig_v1(fw: FirmwareType, idx: int) -> bool: - key_idx = fw.key_indexes[idx] - signature = fw.signatures[idx] +def check_sig_v1( + digest: bytes, key_indexes: List[int], signatures: List[bytes] +) -> None: + distinct_key_indexes = set(i for i in key_indexes if i != 0) + if not distinct_key_indexes: + raise Unsigned - if key_idx == 0: - # no signature = invalid signature - return False + if len(distinct_key_indexes) < len(key_indexes): + raise InvalidSignatureError( + f"Not enough distinct signatures (found {len(distinct_key_indexes)}, need {len(key_indexes)})" + ) - if key_idx not in V1_BOOTLOADER_KEYS: - # unknown pubkey - return False + for i in range(len(key_indexes)): + key_idx = key_indexes[i] - 1 + signature = signatures[i] - pubkey = bytes.fromhex(V1_BOOTLOADER_KEYS[key_idx])[1:] - verify = ecdsa.VerifyingKey.from_string( - pubkey, curve=ecdsa.curves.SECP256k1, hashfunc=hashlib.sha256 - ) - try: - verify.verify(signature, fw.code) - return True - except ecdsa.BadSignatureError: - return False + if key_idx >= len(V1_BOOTLOADER_KEYS): + # unknown pubkey + raise InvalidSignatureError(f"Unknown key in slot {i}") + + pubkey = V1_BOOTLOADER_KEYS[key_idx][1:] + verify = ecdsa.VerifyingKey.from_string(pubkey, curve=ecdsa.curves.SECP256k1) + try: + verify.verify_digest(signature, digest) + except ecdsa.BadSignatureError as e: + raise InvalidSignatureError(f"Invalid signature in slot {i}") from e -def _header_digest(header: c.Container, header_type: c.Construct) -> bytes: +def header_digest(header: c.Container, hash_function: Callable = blake2s) -> bytes: stripped_header = header.copy() stripped_header.sigmask = 0 stripped_header.signature = b"\0" * 64 + stripped_header.v1_key_indexes = [0, 0, 0] + stripped_header.v1_signatures = [b"\0" * 64] * 3 + if header.magic == b"TRZV": + header_type = VendorHeader + else: + header_type = FirmwareHeader header_bytes = header_type.build(stripped_header) - return pyblake2.blake2s(header_bytes).digest() + return hash_function(header_bytes).digest() + + +def digest_v2(fw: c.Container) -> bytes: + return header_digest(fw.image.header, blake2s) + + +def digest_onev2(fw: c.Container) -> bytes: + return header_digest(fw.header, hashlib.sha256) + + +def calculate_code_hashes( + code: bytes, + code_offset: int, + hash_function: Callable = blake2s, + chunk_size: int = V2_CHUNK_SIZE, + padding_byte: Optional[bytes] = None, +) -> List[bytes]: + hashes = [] + # End offset for each chunk. Normally this would be (i+1)*chunk_size for i-th chunk, + # but the first chunk is shorter by code_offset, so all end offsets are shifted. + ends = [(i + 1) * chunk_size - code_offset for i in range(16)] + start = 0 + for end in ends: + chunk = code[start:end] + # padding for last non-empty chunk + if padding_byte is not None and start < len(code) and end > len(code): + chunk += padding_byte[0:1] * (end - start - len(chunk)) + + if not chunk: + hashes.append(b"\0" * 32) + else: + hashes.append(hash_function(chunk).digest()) + start = end -def digest(fw: FirmwareType) -> bytes: - return _header_digest(fw.firmware_header, FirmwareHeader) + return hashes -def validate(fw: FirmwareType, skip_vendor_header=False) -> bool: - vendor_fingerprint = _header_digest(fw.vendor_header, VendorHeader) - fingerprint = digest(fw) +def validate_code_hashes(fw: c.Container, version: FirmwareFormat) -> None: + hash_function: Callable + padding_byte: Optional[bytes] + if version == FirmwareFormat.TREZOR_ONE_V2: + image = fw + hash_function = hashlib.sha256 + chunk_size = ONEV2_CHUNK_SIZE + padding_byte = b"\xff" + else: + image = fw.image + hash_function = blake2s + chunk_size = V2_CHUNK_SIZE + padding_byte = None + + expected_hashes = calculate_code_hashes( + image.code, image._code_offset, hash_function, chunk_size, padding_byte + ) + if expected_hashes != image.header.hashes: + raise FirmwareIntegrityError("Invalid firmware data.") + + +def validate_onev2(fw: c.Container, allow_unsigned: bool = False) -> None: + try: + check_sig_v1( + digest_onev2(fw), + fw.header.v1_key_indexes, + fw.header.v1_signatures, + ) + except Unsigned: + if not allow_unsigned: + raise + + validate_code_hashes(fw, FirmwareFormat.TREZOR_ONE_V2) + + +def validate_onev1(fw: c.Container, allow_unsigned: bool = False) -> None: + try: + check_sig_v1(digest_onev1(fw), fw.key_indexes, fw.signatures) + except Unsigned: + if not allow_unsigned: + raise + if fw.embedded_onev2: + validate_onev2(fw.embedded_onev2, allow_unsigned) + + +def validate_v2(fw: c.Container, skip_vendor_header: bool = False) -> None: + vendor_fingerprint = header_digest(fw.vendor_header) + fingerprint = digest_v2(fw) if not skip_vendor_header: try: # if you want to validate a custom vendor header, you can modify # the global variables to match your keys and m-of-n scheme - cosi.verify_m_of_n( + cosi.verify( fw.vendor_header.signature, vendor_fingerprint, - V2_BOOTLOADER_M, - V2_BOOTLOADER_N, - fw.vendor_header.sigmask, + V2_SIGS_REQUIRED, V2_BOOTLOADER_KEYS, + fw.vendor_header.sigmask, ) except Exception: - raise ValueError("Invalid vendor header signature.") + raise InvalidSignatureError("Invalid vendor header signature.") # XXX expiry is not used now # now = time.gmtime() @@ -258,44 +441,55 @@ def validate(fw: FirmwareType, skip_vendor_header=False) -> bool: # raise ValueError("Vendor header expired.") try: - cosi.verify_m_of_n( - fw.firmware_header.signature, + cosi.verify( + fw.image.header.signature, fingerprint, - fw.vendor_header.vendor_sigs_required, - fw.vendor_header.vendor_sigs_n, - fw.firmware_header.sigmask, + fw.vendor_header.sig_m, fw.vendor_header.pubkeys, + fw.image.header.sigmask, ) except Exception: - raise ValueError("Invalid firmware signature.") + raise InvalidSignatureError("Invalid firmware signature.") # XXX expiry is not used now - # if time.gmtime(fw.firmware_header.expiry) < now: + # if time.gmtime(fw.image.header.expiry) < now: # raise ValueError("Firmware header expired.") + validate_code_hashes(fw, FirmwareFormat.TREZOR_T) - for i, expected_hash in enumerate(fw.firmware_header.hashes): - if i == 0: - # Because first chunk is sent along with headers, there is less code in it. - chunk = fw.code[: V2_CHUNK_SIZE - fw._code_offset] - else: - # Subsequent chunks are shifted by the "missing header" size. - ptr = i * V2_CHUNK_SIZE - fw._code_offset - chunk = fw.code[ptr : ptr + V2_CHUNK_SIZE] - - if not chunk and expected_hash == b"\0" * 32: - continue - chunk_hash = pyblake2.blake2s(chunk).digest() - if chunk_hash != expected_hash: - raise ValueError("Invalid firmware data.") - return True +def digest(version: FirmwareFormat, fw: c.Container) -> bytes: + if version == FirmwareFormat.TREZOR_ONE: + return digest_onev1(fw) + elif version == FirmwareFormat.TREZOR_ONE_V2: + return digest_onev2(fw) + elif version == FirmwareFormat.TREZOR_T: + return digest_v2(fw) + else: + raise ValueError("Unrecognized firmware version") + + +def validate( + version: FirmwareFormat, fw: c.Container, allow_unsigned: bool = False +) -> None: + if version == FirmwareFormat.TREZOR_ONE: + return validate_onev1(fw, allow_unsigned) + elif version == FirmwareFormat.TREZOR_ONE_V2: + return validate_onev2(fw, allow_unsigned) + elif version == FirmwareFormat.TREZOR_T: + return validate_v2(fw) + else: + raise ValueError("Unrecognized firmware version") # ====== Client functions ====== # -@tools.session -def update(client, data): +@session +def update( + client: "TrezorClient", + data: bytes, + progress_update: Callable[[int], Any] = lambda _: None, +): if client.features.bootloader_mode is False: raise RuntimeError("Device must be in bootloader mode") @@ -304,18 +498,23 @@ def update(client, data): # TREZORv1 method if isinstance(resp, messages.Success): resp = client.call(messages.FirmwareUpload(payload=data)) + progress_update(len(data)) if isinstance(resp, messages.Success): return else: - raise RuntimeError("Unexpected result %s" % resp) + raise RuntimeError(f"Unexpected result {resp}") # TREZORv2 method while isinstance(resp, messages.FirmwareRequest): - payload = data[resp.offset : resp.offset + resp.length] - digest = pyblake2.blake2s(payload).digest() + assert resp.offset is not None + assert resp.length is not None + length = resp.length + payload = data[resp.offset : resp.offset + length] + digest = blake2s(payload).digest() resp = client.call(messages.FirmwareUpload(payload=payload, hash=digest)) + progress_update(length) if isinstance(resp, messages.Success): return else: - raise RuntimeError("Unexpected message %s" % resp) + raise RuntimeError(f"Unexpected message {resp}") diff --git a/hwilib/devices/trezorlib/log.py b/hwilib/devices/trezorlib/log.py index 50f778a12..9cc4a6b9a 100644 --- a/hwilib/devices/trezorlib/log.py +++ b/hwilib/devices/trezorlib/log.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2022 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -17,9 +17,23 @@ import logging from typing import Optional, Set, Type +from typing_extensions import Protocol, runtime_checkable + from . import protobuf -OMITTED_MESSAGES = set() # type: Set[Type[protobuf.MessageType]] + +@runtime_checkable +class HasProtobuf(Protocol): + protobuf: protobuf.MessageType + + +OMITTED_MESSAGES: Set[Type[protobuf.MessageType]] = set() + +DUMP_BYTES = 5 +DUMP_PACKETS = 4 + +logging.addLevelName(DUMP_BYTES, "BYTES") +logging.addLevelName(DUMP_PACKETS, "PACKETS") class PrettyProtobufFormatter(logging.Formatter): @@ -31,21 +45,31 @@ def format(self, record: logging.LogRecord) -> str: source=record.name, msg=super().format(record), ) - if hasattr(record, "protobuf"): + if isinstance(record, HasProtobuf): if type(record.protobuf) in OMITTED_MESSAGES: - message += " ({} bytes)".format(record.protobuf.ByteSize()) + message += f" ({record.protobuf.ByteSize()} bytes)" else: message += "\n" + protobuf.format_message(record.protobuf) return message -def enable_debug_output(handler: Optional[logging.Handler] = None): +def enable_debug_output( + verbosity: int = 1, handler: Optional[logging.Handler] = None +) -> None: if handler is None: handler = logging.StreamHandler() formatter = PrettyProtobufFormatter() handler.setFormatter(formatter) + level = logging.NOTSET + if verbosity > 0: + level = logging.DEBUG + if verbosity > 1: + level = DUMP_BYTES + if verbosity > 2: + level = DUMP_PACKETS + logger = logging.getLogger("trezorlib") - logger.setLevel(logging.DEBUG) + logger.setLevel(level) logger.addHandler(handler) diff --git a/hwilib/devices/trezorlib/mapping.py b/hwilib/devices/trezorlib/mapping.py index 11c94cb06..e8fb60edb 100644 --- a/hwilib/devices/trezorlib/mapping.py +++ b/hwilib/devices/trezorlib/mapping.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2022 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -14,49 +14,86 @@ # You should have received a copy of the License along with this library. # If not, see . -from . import messages +import io +from types import ModuleType +from typing import Dict, Optional, Tuple, Type, TypeVar -map_type_to_class = {} -map_class_to_type = {} +from . import messages, protobuf +T = TypeVar("T") -def build_map(): - for msg_name in dir(messages.MessageType): - if msg_name.startswith("__"): - continue - try: - msg_class = getattr(messages, msg_name) - except AttributeError: - raise ValueError( - "Implementation of protobuf message '%s' is missing" % msg_name - ) +class ProtobufMapping: + """Mapping of protobuf classes to Python classes""" - if msg_class.MESSAGE_WIRE_TYPE != getattr(messages.MessageType, msg_name): - raise ValueError( - "Inconsistent wire type and MessageType record for '%s'" % msg_class - ) + def __init__(self) -> None: + self.type_to_class: Dict[int, Type[protobuf.MessageType]] = {} + self.class_to_type_override: Dict[Type[protobuf.MessageType], int] = {} - register_message(msg_class) + def register( + self, + msg_class: Type[protobuf.MessageType], + msg_wire_type: Optional[int] = None, + ) -> None: + """Register a Python class as a protobuf type. + If `msg_wire_type` is specified, it is used instead of the internal value in + `msg_class`. -def register_message(msg_class): - if msg_class.MESSAGE_WIRE_TYPE in map_type_to_class: - raise Exception( - "Message for wire type %s is already registered by %s" - % (msg_class.MESSAGE_WIRE_TYPE, get_class(msg_class.MESSAGE_WIRE_TYPE)) - ) + Any existing registrations are overwritten. + """ + if msg_wire_type is not None: + self.class_to_type_override[msg_class] = msg_wire_type + elif msg_class.MESSAGE_WIRE_TYPE is None: + raise ValueError("Cannot register class without wire type") + else: + msg_wire_type = msg_class.MESSAGE_WIRE_TYPE - map_class_to_type[msg_class] = msg_class.MESSAGE_WIRE_TYPE - map_type_to_class[msg_class.MESSAGE_WIRE_TYPE] = msg_class + self.type_to_class[msg_wire_type] = msg_class + def encode(self, msg: protobuf.MessageType) -> Tuple[int, bytes]: + """Serialize a Python protobuf class. -def get_type(msg): - return map_class_to_type[msg.__class__] + Returns the message wire type and a byte representation of the protobuf message. + """ + wire_type = self.class_to_type_override.get(type(msg), msg.MESSAGE_WIRE_TYPE) + if wire_type is None: + raise ValueError("Cannot encode class without wire type") + buf = io.BytesIO() + protobuf.dump_message(buf, msg) + return wire_type, buf.getvalue() -def get_class(t): - return map_type_to_class[t] + def decode(self, msg_wire_type: int, msg_bytes: bytes) -> protobuf.MessageType: + """Deserialize a protobuf message into a Python class.""" + cls = self.type_to_class[msg_wire_type] + buf = io.BytesIO(msg_bytes) + return protobuf.load_message(buf, cls) + @classmethod + def from_module(cls: Type[T], module: ModuleType) -> T: + """Generate a mapping from a module. -build_map() + The module must have a `MessageType` enum that specifies individual wire types. + """ + mapping = cls() + + message_types = getattr(module, "MessageType") + for entry in message_types: + msg_class = getattr(module, entry.name, None) + if msg_class is None: + raise ValueError( + f"Implementation of protobuf message '{entry.name}' is missing" + ) + + if msg_class.MESSAGE_WIRE_TYPE != entry.value: + raise ValueError( + f"Inconsistent wire type and MessageType record for '{entry.name}'" + ) + + mapping.register(msg_class) + + return mapping + + +DEFAULT_MAPPING = ProtobufMapping.from_module(messages) diff --git a/hwilib/devices/trezorlib/messages.py b/hwilib/devices/trezorlib/messages.py new file mode 100644 index 000000000..32ff489ad --- /dev/null +++ b/hwilib/devices/trezorlib/messages.py @@ -0,0 +1,2169 @@ +# Automatically generated by pb2py +# fmt: off +# isort:skip_file + +from enum import IntEnum +from typing import List, Optional + +from . import protobuf + + +class MessageType(IntEnum): + Initialize = 0 + Ping = 1 + Success = 2 + Failure = 3 + ChangePin = 4 + WipeDevice = 5 + GetEntropy = 9 + Entropy = 10 + LoadDevice = 13 + ResetDevice = 14 + Features = 17 + PinMatrixRequest = 18 + PinMatrixAck = 19 + Cancel = 20 + LockDevice = 24 + ApplySettings = 25 + ButtonRequest = 26 + ButtonAck = 27 + ApplyFlags = 28 + BackupDevice = 34 + EntropyRequest = 35 + EntropyAck = 36 + PassphraseRequest = 41 + PassphraseAck = 42 + RecoveryDevice = 45 + WordRequest = 46 + WordAck = 47 + GetFeatures = 55 + SdProtect = 79 + ChangeWipeCode = 82 + EndSession = 83 + DoPreauthorized = 84 + PreauthorizedRequest = 85 + CancelAuthorization = 86 + RebootToBootloader = 87 + Deprecated_PassphraseStateRequest = 77 + Deprecated_PassphraseStateAck = 78 + FirmwareErase = 6 + FirmwareUpload = 7 + FirmwareRequest = 8 + SelfTest = 32 + GetPublicKey = 11 + PublicKey = 12 + SignTx = 15 + TxRequest = 21 + TxAck = 22 + GetAddress = 29 + Address = 30 + SignMessage = 38 + VerifyMessage = 39 + MessageSignature = 40 + GetOwnershipId = 43 + OwnershipId = 44 + GetOwnershipProof = 49 + OwnershipProof = 50 + AuthorizeCoinJoin = 51 + DebugLinkDecision = 100 + DebugLinkGetState = 101 + DebugLinkState = 102 + DebugLinkStop = 103 + DebugLinkLog = 104 + DebugLinkMemoryRead = 110 + DebugLinkMemory = 111 + DebugLinkMemoryWrite = 112 + DebugLinkFlashErase = 113 + DebugLinkLayout = 9001 + DebugLinkReseedRandom = 9002 + DebugLinkRecordScreen = 9003 + DebugLinkEraseSdCard = 9005 + DebugLinkWatchLayout = 9006 + + +class FailureType(IntEnum): + UnexpectedMessage = 1 + ButtonExpected = 2 + DataError = 3 + ActionCancelled = 4 + PinExpected = 5 + PinCancelled = 6 + PinInvalid = 7 + InvalidSignature = 8 + ProcessError = 9 + NotEnoughFunds = 10 + NotInitialized = 11 + PinMismatch = 12 + WipeCodeMismatch = 13 + InvalidSession = 14 + FirmwareError = 99 + + +class ButtonRequestType(IntEnum): + Other = 1 + FeeOverThreshold = 2 + ConfirmOutput = 3 + ResetDevice = 4 + ConfirmWord = 5 + WipeDevice = 6 + ProtectCall = 7 + SignTx = 8 + FirmwareCheck = 9 + Address = 10 + PublicKey = 11 + MnemonicWordCount = 12 + MnemonicInput = 13 + _Deprecated_ButtonRequest_PassphraseType = 14 + UnknownDerivationPath = 15 + RecoveryHomepage = 16 + Success = 17 + Warning = 18 + PassphraseEntry = 19 + PinEntry = 20 + + +class PinMatrixRequestType(IntEnum): + Current = 1 + NewFirst = 2 + NewSecond = 3 + WipeCodeFirst = 4 + WipeCodeSecond = 5 + + +class InputScriptType(IntEnum): + SPENDADDRESS = 0 + SPENDMULTISIG = 1 + EXTERNAL = 2 + SPENDWITNESS = 3 + SPENDP2SHWITNESS = 4 + SPENDTAPROOT = 5 + + +class OutputScriptType(IntEnum): + PAYTOADDRESS = 0 + PAYTOSCRIPTHASH = 1 + PAYTOMULTISIG = 2 + PAYTOOPRETURN = 3 + PAYTOWITNESS = 4 + PAYTOP2SHWITNESS = 5 + PAYTOTAPROOT = 6 + + +class DecredStakingSpendType(IntEnum): + SSGen = 0 + SSRTX = 1 + + +class AmountUnit(IntEnum): + BITCOIN = 0 + MILLIBITCOIN = 1 + MICROBITCOIN = 2 + SATOSHI = 3 + + +class RequestType(IntEnum): + TXINPUT = 0 + TXOUTPUT = 1 + TXMETA = 2 + TXFINISHED = 3 + TXEXTRADATA = 4 + TXORIGINPUT = 5 + TXORIGOUTPUT = 6 + + +class BackupType(IntEnum): + Bip39 = 0 + Slip39_Basic = 1 + Slip39_Advanced = 2 + + +class SafetyCheckLevel(IntEnum): + Strict = 0 + PromptAlways = 1 + PromptTemporarily = 2 + + +class Capability(IntEnum): + Bitcoin = 1 + Bitcoin_like = 2 + Binance = 3 + Cardano = 4 + Crypto = 5 + EOS = 6 + Ethereum = 7 + Lisk = 8 + Monero = 9 + NEM = 10 + Ripple = 11 + Stellar = 12 + Tezos = 13 + U2F = 14 + Shamir = 15 + ShamirGroups = 16 + PassphraseEntry = 17 + + +class SdProtectOperationType(IntEnum): + DISABLE = 0 + ENABLE = 1 + REFRESH = 2 + + +class RecoveryDeviceType(IntEnum): + ScrambledWords = 0 + Matrix = 1 + + +class WordRequestType(IntEnum): + Plain = 0 + Matrix9 = 1 + Matrix6 = 2 + + +class DebugSwipeDirection(IntEnum): + UP = 0 + DOWN = 1 + LEFT = 2 + RIGHT = 3 + + +class Success(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 2 + FIELDS = { + 1: protobuf.Field("message", "string", repeated=False, required=False), + } + + def __init__( + self, + *, + message: Optional["str"] = '', + ) -> None: + self.message = message + + +class Failure(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 3 + FIELDS = { + 1: protobuf.Field("code", "FailureType", repeated=False, required=False), + 2: protobuf.Field("message", "string", repeated=False, required=False), + } + + def __init__( + self, + *, + code: Optional["FailureType"] = None, + message: Optional["str"] = None, + ) -> None: + self.code = code + self.message = message + + +class ButtonRequest(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 26 + FIELDS = { + 1: protobuf.Field("code", "ButtonRequestType", repeated=False, required=False), + 2: protobuf.Field("pages", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + code: Optional["ButtonRequestType"] = None, + pages: Optional["int"] = None, + ) -> None: + self.code = code + self.pages = pages + + +class ButtonAck(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 27 + + +class PinMatrixRequest(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 18 + FIELDS = { + 1: protobuf.Field("type", "PinMatrixRequestType", repeated=False, required=False), + } + + def __init__( + self, + *, + type: Optional["PinMatrixRequestType"] = None, + ) -> None: + self.type = type + + +class PinMatrixAck(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 19 + FIELDS = { + 1: protobuf.Field("pin", "string", repeated=False, required=True), + } + + def __init__( + self, + *, + pin: "str", + ) -> None: + self.pin = pin + + +class PassphraseRequest(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 41 + FIELDS = { + 1: protobuf.Field("_on_device", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + _on_device: Optional["bool"] = None, + ) -> None: + self._on_device = _on_device + + +class PassphraseAck(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 42 + FIELDS = { + 1: protobuf.Field("passphrase", "string", repeated=False, required=False), + 2: protobuf.Field("_state", "bytes", repeated=False, required=False), + 3: protobuf.Field("on_device", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + passphrase: Optional["str"] = None, + _state: Optional["bytes"] = None, + on_device: Optional["bool"] = None, + ) -> None: + self.passphrase = passphrase + self._state = _state + self.on_device = on_device + + +class Deprecated_PassphraseStateRequest(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 77 + FIELDS = { + 1: protobuf.Field("state", "bytes", repeated=False, required=False), + } + + def __init__( + self, + *, + state: Optional["bytes"] = None, + ) -> None: + self.state = state + + +class Deprecated_PassphraseStateAck(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 78 + + +class HDNodeType(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("depth", "uint32", repeated=False, required=True), + 2: protobuf.Field("fingerprint", "uint32", repeated=False, required=True), + 3: protobuf.Field("child_num", "uint32", repeated=False, required=True), + 4: protobuf.Field("chain_code", "bytes", repeated=False, required=True), + 5: protobuf.Field("private_key", "bytes", repeated=False, required=False), + 6: protobuf.Field("public_key", "bytes", repeated=False, required=True), + } + + def __init__( + self, + *, + depth: "int", + fingerprint: "int", + child_num: "int", + chain_code: "bytes", + public_key: "bytes", + private_key: Optional["bytes"] = None, + ) -> None: + self.depth = depth + self.fingerprint = fingerprint + self.child_num = child_num + self.chain_code = chain_code + self.public_key = public_key + self.private_key = private_key + + +class MultisigRedeemScriptType(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("pubkeys", "HDNodePathType", repeated=True, required=False), + 2: protobuf.Field("signatures", "bytes", repeated=True, required=False), + 3: protobuf.Field("m", "uint32", repeated=False, required=True), + 4: protobuf.Field("nodes", "HDNodeType", repeated=True, required=False), + 5: protobuf.Field("address_n", "uint32", repeated=True, required=False), + } + + def __init__( + self, + *, + m: "int", + pubkeys: Optional[List["HDNodePathType"]] = None, + signatures: Optional[List["bytes"]] = None, + nodes: Optional[List["HDNodeType"]] = None, + address_n: Optional[List["int"]] = None, + ) -> None: + self.pubkeys = pubkeys if pubkeys is not None else [] + self.signatures = signatures if signatures is not None else [] + self.nodes = nodes if nodes is not None else [] + self.address_n = address_n if address_n is not None else [] + self.m = m + + +class GetPublicKey(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 11 + FIELDS = { + 1: protobuf.Field("address_n", "uint32", repeated=True, required=False), + 2: protobuf.Field("ecdsa_curve_name", "string", repeated=False, required=False), + 3: protobuf.Field("show_display", "bool", repeated=False, required=False), + 4: protobuf.Field("coin_name", "string", repeated=False, required=False), + 5: protobuf.Field("script_type", "InputScriptType", repeated=False, required=False), + 6: protobuf.Field("ignore_xpub_magic", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + address_n: Optional[List["int"]] = None, + ecdsa_curve_name: Optional["str"] = None, + show_display: Optional["bool"] = None, + coin_name: Optional["str"] = 'Bitcoin', + script_type: Optional["InputScriptType"] = InputScriptType.SPENDADDRESS, + ignore_xpub_magic: Optional["bool"] = None, + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.ecdsa_curve_name = ecdsa_curve_name + self.show_display = show_display + self.coin_name = coin_name + self.script_type = script_type + self.ignore_xpub_magic = ignore_xpub_magic + + +class PublicKey(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 12 + FIELDS = { + 1: protobuf.Field("node", "HDNodeType", repeated=False, required=True), + 2: protobuf.Field("xpub", "string", repeated=False, required=True), + 3: protobuf.Field("root_fingerprint", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + node: "HDNodeType", + xpub: "str", + root_fingerprint: Optional["int"] = None, + ) -> None: + self.node = node + self.xpub = xpub + self.root_fingerprint = root_fingerprint + + +class GetAddress(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 29 + FIELDS = { + 1: protobuf.Field("address_n", "uint32", repeated=True, required=False), + 2: protobuf.Field("coin_name", "string", repeated=False, required=False), + 3: protobuf.Field("show_display", "bool", repeated=False, required=False), + 4: protobuf.Field("multisig", "MultisigRedeemScriptType", repeated=False, required=False), + 5: protobuf.Field("script_type", "InputScriptType", repeated=False, required=False), + 6: protobuf.Field("ignore_xpub_magic", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + address_n: Optional[List["int"]] = None, + coin_name: Optional["str"] = 'Bitcoin', + show_display: Optional["bool"] = None, + multisig: Optional["MultisigRedeemScriptType"] = None, + script_type: Optional["InputScriptType"] = InputScriptType.SPENDADDRESS, + ignore_xpub_magic: Optional["bool"] = None, + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.coin_name = coin_name + self.show_display = show_display + self.multisig = multisig + self.script_type = script_type + self.ignore_xpub_magic = ignore_xpub_magic + + +class Address(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 30 + FIELDS = { + 1: protobuf.Field("address", "string", repeated=False, required=True), + } + + def __init__( + self, + *, + address: "str", + ) -> None: + self.address = address + + +class GetOwnershipId(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 43 + FIELDS = { + 1: protobuf.Field("address_n", "uint32", repeated=True, required=False), + 2: protobuf.Field("coin_name", "string", repeated=False, required=False), + 3: protobuf.Field("multisig", "MultisigRedeemScriptType", repeated=False, required=False), + 4: protobuf.Field("script_type", "InputScriptType", repeated=False, required=False), + } + + def __init__( + self, + *, + address_n: Optional[List["int"]] = None, + coin_name: Optional["str"] = 'Bitcoin', + multisig: Optional["MultisigRedeemScriptType"] = None, + script_type: Optional["InputScriptType"] = InputScriptType.SPENDADDRESS, + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.coin_name = coin_name + self.multisig = multisig + self.script_type = script_type + + +class OwnershipId(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 44 + FIELDS = { + 1: protobuf.Field("ownership_id", "bytes", repeated=False, required=True), + } + + def __init__( + self, + *, + ownership_id: "bytes", + ) -> None: + self.ownership_id = ownership_id + + +class SignMessage(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 38 + FIELDS = { + 1: protobuf.Field("address_n", "uint32", repeated=True, required=False), + 2: protobuf.Field("message", "bytes", repeated=False, required=True), + 3: protobuf.Field("coin_name", "string", repeated=False, required=False), + 4: protobuf.Field("script_type", "InputScriptType", repeated=False, required=False), + 5: protobuf.Field("no_script_type", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + message: "bytes", + address_n: Optional[List["int"]] = None, + coin_name: Optional["str"] = 'Bitcoin', + script_type: Optional["InputScriptType"] = InputScriptType.SPENDADDRESS, + no_script_type: Optional["bool"] = None, + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.message = message + self.coin_name = coin_name + self.script_type = script_type + self.no_script_type = no_script_type + + +class MessageSignature(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 40 + FIELDS = { + 1: protobuf.Field("address", "string", repeated=False, required=True), + 2: protobuf.Field("signature", "bytes", repeated=False, required=True), + } + + def __init__( + self, + *, + address: "str", + signature: "bytes", + ) -> None: + self.address = address + self.signature = signature + + +class VerifyMessage(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 39 + FIELDS = { + 1: protobuf.Field("address", "string", repeated=False, required=True), + 2: protobuf.Field("signature", "bytes", repeated=False, required=True), + 3: protobuf.Field("message", "bytes", repeated=False, required=True), + 4: protobuf.Field("coin_name", "string", repeated=False, required=False), + } + + def __init__( + self, + *, + address: "str", + signature: "bytes", + message: "bytes", + coin_name: Optional["str"] = 'Bitcoin', + ) -> None: + self.address = address + self.signature = signature + self.message = message + self.coin_name = coin_name + + +class SignTx(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 15 + FIELDS = { + 1: protobuf.Field("outputs_count", "uint32", repeated=False, required=True), + 2: protobuf.Field("inputs_count", "uint32", repeated=False, required=True), + 3: protobuf.Field("coin_name", "string", repeated=False, required=False), + 4: protobuf.Field("version", "uint32", repeated=False, required=False), + 5: protobuf.Field("lock_time", "uint32", repeated=False, required=False), + 6: protobuf.Field("expiry", "uint32", repeated=False, required=False), + 7: protobuf.Field("overwintered", "bool", repeated=False, required=False), + 8: protobuf.Field("version_group_id", "uint32", repeated=False, required=False), + 9: protobuf.Field("timestamp", "uint32", repeated=False, required=False), + 10: protobuf.Field("branch_id", "uint32", repeated=False, required=False), + 11: protobuf.Field("amount_unit", "AmountUnit", repeated=False, required=False), + 12: protobuf.Field("decred_staking_ticket", "bool", repeated=False, required=False), + 13: protobuf.Field("serialize", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + outputs_count: "int", + inputs_count: "int", + coin_name: Optional["str"] = 'Bitcoin', + version: Optional["int"] = 1, + lock_time: Optional["int"] = 0, + expiry: Optional["int"] = None, + overwintered: Optional["bool"] = None, + version_group_id: Optional["int"] = None, + timestamp: Optional["int"] = None, + branch_id: Optional["int"] = None, + amount_unit: Optional["AmountUnit"] = AmountUnit.BITCOIN, + decred_staking_ticket: Optional["bool"] = False, + serialize: Optional["bool"] = True, + ) -> None: + self.outputs_count = outputs_count + self.inputs_count = inputs_count + self.coin_name = coin_name + self.version = version + self.lock_time = lock_time + self.expiry = expiry + self.overwintered = overwintered + self.version_group_id = version_group_id + self.timestamp = timestamp + self.branch_id = branch_id + self.amount_unit = amount_unit + self.decred_staking_ticket = decred_staking_ticket + self.serialize = serialize + + +class TxRequest(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 21 + FIELDS = { + 1: protobuf.Field("request_type", "RequestType", repeated=False, required=False), + 2: protobuf.Field("details", "TxRequestDetailsType", repeated=False, required=False), + 3: protobuf.Field("serialized", "TxRequestSerializedType", repeated=False, required=False), + } + + def __init__( + self, + *, + request_type: Optional["RequestType"] = None, + details: Optional["TxRequestDetailsType"] = None, + serialized: Optional["TxRequestSerializedType"] = None, + ) -> None: + self.request_type = request_type + self.details = details + self.serialized = serialized + + +class TxAck(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 22 + FIELDS = { + 1: protobuf.Field("tx", "TransactionType", repeated=False, required=False), + } + + def __init__( + self, + *, + tx: Optional["TransactionType"] = None, + ) -> None: + self.tx = tx + + +class TxInput(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("address_n", "uint32", repeated=True, required=False), + 2: protobuf.Field("prev_hash", "bytes", repeated=False, required=True), + 3: protobuf.Field("prev_index", "uint32", repeated=False, required=True), + 4: protobuf.Field("script_sig", "bytes", repeated=False, required=False), + 5: protobuf.Field("sequence", "uint32", repeated=False, required=False), + 6: protobuf.Field("script_type", "InputScriptType", repeated=False, required=False), + 7: protobuf.Field("multisig", "MultisigRedeemScriptType", repeated=False, required=False), + 8: protobuf.Field("amount", "uint64", repeated=False, required=True), + 9: protobuf.Field("decred_tree", "uint32", repeated=False, required=False), + 13: protobuf.Field("witness", "bytes", repeated=False, required=False), + 14: protobuf.Field("ownership_proof", "bytes", repeated=False, required=False), + 15: protobuf.Field("commitment_data", "bytes", repeated=False, required=False), + 16: protobuf.Field("orig_hash", "bytes", repeated=False, required=False), + 17: protobuf.Field("orig_index", "uint32", repeated=False, required=False), + 18: protobuf.Field("decred_staking_spend", "DecredStakingSpendType", repeated=False, required=False), + 19: protobuf.Field("script_pubkey", "bytes", repeated=False, required=False), + } + + def __init__( + self, + *, + prev_hash: "bytes", + prev_index: "int", + amount: "int", + address_n: Optional[List["int"]] = None, + script_sig: Optional["bytes"] = None, + sequence: Optional["int"] = 4294967295, + script_type: Optional["InputScriptType"] = InputScriptType.SPENDADDRESS, + multisig: Optional["MultisigRedeemScriptType"] = None, + decred_tree: Optional["int"] = None, + witness: Optional["bytes"] = None, + ownership_proof: Optional["bytes"] = None, + commitment_data: Optional["bytes"] = None, + orig_hash: Optional["bytes"] = None, + orig_index: Optional["int"] = None, + decred_staking_spend: Optional["DecredStakingSpendType"] = None, + script_pubkey: Optional["bytes"] = None, + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.prev_hash = prev_hash + self.prev_index = prev_index + self.amount = amount + self.script_sig = script_sig + self.sequence = sequence + self.script_type = script_type + self.multisig = multisig + self.decred_tree = decred_tree + self.witness = witness + self.ownership_proof = ownership_proof + self.commitment_data = commitment_data + self.orig_hash = orig_hash + self.orig_index = orig_index + self.decred_staking_spend = decred_staking_spend + self.script_pubkey = script_pubkey + + +class TxOutput(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("address", "string", repeated=False, required=False), + 2: protobuf.Field("address_n", "uint32", repeated=True, required=False), + 3: protobuf.Field("amount", "uint64", repeated=False, required=True), + 4: protobuf.Field("script_type", "OutputScriptType", repeated=False, required=False), + 5: protobuf.Field("multisig", "MultisigRedeemScriptType", repeated=False, required=False), + 6: protobuf.Field("op_return_data", "bytes", repeated=False, required=False), + 10: protobuf.Field("orig_hash", "bytes", repeated=False, required=False), + 11: protobuf.Field("orig_index", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + amount: "int", + address_n: Optional[List["int"]] = None, + address: Optional["str"] = None, + script_type: Optional["OutputScriptType"] = OutputScriptType.PAYTOADDRESS, + multisig: Optional["MultisigRedeemScriptType"] = None, + op_return_data: Optional["bytes"] = None, + orig_hash: Optional["bytes"] = None, + orig_index: Optional["int"] = None, + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.amount = amount + self.address = address + self.script_type = script_type + self.multisig = multisig + self.op_return_data = op_return_data + self.orig_hash = orig_hash + self.orig_index = orig_index + + +class PrevTx(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("version", "uint32", repeated=False, required=True), + 4: protobuf.Field("lock_time", "uint32", repeated=False, required=True), + 6: protobuf.Field("inputs_count", "uint32", repeated=False, required=True), + 7: protobuf.Field("outputs_count", "uint32", repeated=False, required=True), + 9: protobuf.Field("extra_data_len", "uint32", repeated=False, required=False), + 10: protobuf.Field("expiry", "uint32", repeated=False, required=False), + 12: protobuf.Field("version_group_id", "uint32", repeated=False, required=False), + 13: protobuf.Field("timestamp", "uint32", repeated=False, required=False), + 14: protobuf.Field("branch_id", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + version: "int", + lock_time: "int", + inputs_count: "int", + outputs_count: "int", + extra_data_len: Optional["int"] = 0, + expiry: Optional["int"] = None, + version_group_id: Optional["int"] = None, + timestamp: Optional["int"] = None, + branch_id: Optional["int"] = None, + ) -> None: + self.version = version + self.lock_time = lock_time + self.inputs_count = inputs_count + self.outputs_count = outputs_count + self.extra_data_len = extra_data_len + self.expiry = expiry + self.version_group_id = version_group_id + self.timestamp = timestamp + self.branch_id = branch_id + + +class PrevInput(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 2: protobuf.Field("prev_hash", "bytes", repeated=False, required=True), + 3: protobuf.Field("prev_index", "uint32", repeated=False, required=True), + 4: protobuf.Field("script_sig", "bytes", repeated=False, required=True), + 5: protobuf.Field("sequence", "uint32", repeated=False, required=True), + 9: protobuf.Field("decred_tree", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + prev_hash: "bytes", + prev_index: "int", + script_sig: "bytes", + sequence: "int", + decred_tree: Optional["int"] = None, + ) -> None: + self.prev_hash = prev_hash + self.prev_index = prev_index + self.script_sig = script_sig + self.sequence = sequence + self.decred_tree = decred_tree + + +class PrevOutput(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("amount", "uint64", repeated=False, required=True), + 2: protobuf.Field("script_pubkey", "bytes", repeated=False, required=True), + 3: protobuf.Field("decred_script_version", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + amount: "int", + script_pubkey: "bytes", + decred_script_version: Optional["int"] = None, + ) -> None: + self.amount = amount + self.script_pubkey = script_pubkey + self.decred_script_version = decred_script_version + + +class TxAckInput(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 22 + FIELDS = { + 1: protobuf.Field("tx", "TxAckInputWrapper", repeated=False, required=True), + } + + def __init__( + self, + *, + tx: "TxAckInputWrapper", + ) -> None: + self.tx = tx + + +class TxAckOutput(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 22 + FIELDS = { + 1: protobuf.Field("tx", "TxAckOutputWrapper", repeated=False, required=True), + } + + def __init__( + self, + *, + tx: "TxAckOutputWrapper", + ) -> None: + self.tx = tx + + +class TxAckPrevMeta(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 22 + FIELDS = { + 1: protobuf.Field("tx", "PrevTx", repeated=False, required=True), + } + + def __init__( + self, + *, + tx: "PrevTx", + ) -> None: + self.tx = tx + + +class TxAckPrevInput(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 22 + FIELDS = { + 1: protobuf.Field("tx", "TxAckPrevInputWrapper", repeated=False, required=True), + } + + def __init__( + self, + *, + tx: "TxAckPrevInputWrapper", + ) -> None: + self.tx = tx + + +class TxAckPrevOutput(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 22 + FIELDS = { + 1: protobuf.Field("tx", "TxAckPrevOutputWrapper", repeated=False, required=True), + } + + def __init__( + self, + *, + tx: "TxAckPrevOutputWrapper", + ) -> None: + self.tx = tx + + +class TxAckPrevExtraData(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 22 + FIELDS = { + 1: protobuf.Field("tx", "TxAckPrevExtraDataWrapper", repeated=False, required=True), + } + + def __init__( + self, + *, + tx: "TxAckPrevExtraDataWrapper", + ) -> None: + self.tx = tx + + +class GetOwnershipProof(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 49 + FIELDS = { + 1: protobuf.Field("address_n", "uint32", repeated=True, required=False), + 2: protobuf.Field("coin_name", "string", repeated=False, required=False), + 3: protobuf.Field("script_type", "InputScriptType", repeated=False, required=False), + 4: protobuf.Field("multisig", "MultisigRedeemScriptType", repeated=False, required=False), + 5: protobuf.Field("user_confirmation", "bool", repeated=False, required=False), + 6: protobuf.Field("ownership_ids", "bytes", repeated=True, required=False), + 7: protobuf.Field("commitment_data", "bytes", repeated=False, required=False), + } + + def __init__( + self, + *, + address_n: Optional[List["int"]] = None, + ownership_ids: Optional[List["bytes"]] = None, + coin_name: Optional["str"] = 'Bitcoin', + script_type: Optional["InputScriptType"] = InputScriptType.SPENDWITNESS, + multisig: Optional["MultisigRedeemScriptType"] = None, + user_confirmation: Optional["bool"] = False, + commitment_data: Optional["bytes"] = b'', + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.ownership_ids = ownership_ids if ownership_ids is not None else [] + self.coin_name = coin_name + self.script_type = script_type + self.multisig = multisig + self.user_confirmation = user_confirmation + self.commitment_data = commitment_data + + +class OwnershipProof(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 50 + FIELDS = { + 1: protobuf.Field("ownership_proof", "bytes", repeated=False, required=True), + 2: protobuf.Field("signature", "bytes", repeated=False, required=True), + } + + def __init__( + self, + *, + ownership_proof: "bytes", + signature: "bytes", + ) -> None: + self.ownership_proof = ownership_proof + self.signature = signature + + +class AuthorizeCoinJoin(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 51 + FIELDS = { + 1: protobuf.Field("coordinator", "string", repeated=False, required=True), + 2: protobuf.Field("max_total_fee", "uint64", repeated=False, required=True), + 3: protobuf.Field("fee_per_anonymity", "uint32", repeated=False, required=False), + 4: protobuf.Field("address_n", "uint32", repeated=True, required=False), + 5: protobuf.Field("coin_name", "string", repeated=False, required=False), + 6: protobuf.Field("script_type", "InputScriptType", repeated=False, required=False), + 11: protobuf.Field("amount_unit", "AmountUnit", repeated=False, required=False), + } + + def __init__( + self, + *, + coordinator: "str", + max_total_fee: "int", + address_n: Optional[List["int"]] = None, + fee_per_anonymity: Optional["int"] = 0, + coin_name: Optional["str"] = 'Bitcoin', + script_type: Optional["InputScriptType"] = InputScriptType.SPENDADDRESS, + amount_unit: Optional["AmountUnit"] = AmountUnit.BITCOIN, + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.coordinator = coordinator + self.max_total_fee = max_total_fee + self.fee_per_anonymity = fee_per_anonymity + self.coin_name = coin_name + self.script_type = script_type + self.amount_unit = amount_unit + + +class HDNodePathType(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("node", "HDNodeType", repeated=False, required=True), + 2: protobuf.Field("address_n", "uint32", repeated=True, required=False), + } + + def __init__( + self, + *, + node: "HDNodeType", + address_n: Optional[List["int"]] = None, + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.node = node + + +class TxRequestDetailsType(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("request_index", "uint32", repeated=False, required=False), + 2: protobuf.Field("tx_hash", "bytes", repeated=False, required=False), + 3: protobuf.Field("extra_data_len", "uint32", repeated=False, required=False), + 4: protobuf.Field("extra_data_offset", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + request_index: Optional["int"] = None, + tx_hash: Optional["bytes"] = None, + extra_data_len: Optional["int"] = None, + extra_data_offset: Optional["int"] = None, + ) -> None: + self.request_index = request_index + self.tx_hash = tx_hash + self.extra_data_len = extra_data_len + self.extra_data_offset = extra_data_offset + + +class TxRequestSerializedType(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("signature_index", "uint32", repeated=False, required=False), + 2: protobuf.Field("signature", "bytes", repeated=False, required=False), + 3: protobuf.Field("serialized_tx", "bytes", repeated=False, required=False), + } + + def __init__( + self, + *, + signature_index: Optional["int"] = None, + signature: Optional["bytes"] = None, + serialized_tx: Optional["bytes"] = None, + ) -> None: + self.signature_index = signature_index + self.signature = signature + self.serialized_tx = serialized_tx + + +class TransactionType(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("version", "uint32", repeated=False, required=False), + 2: protobuf.Field("inputs", "TxInputType", repeated=True, required=False), + 3: protobuf.Field("bin_outputs", "TxOutputBinType", repeated=True, required=False), + 4: protobuf.Field("lock_time", "uint32", repeated=False, required=False), + 5: protobuf.Field("outputs", "TxOutputType", repeated=True, required=False), + 6: protobuf.Field("inputs_cnt", "uint32", repeated=False, required=False), + 7: protobuf.Field("outputs_cnt", "uint32", repeated=False, required=False), + 8: protobuf.Field("extra_data", "bytes", repeated=False, required=False), + 9: protobuf.Field("extra_data_len", "uint32", repeated=False, required=False), + 10: protobuf.Field("expiry", "uint32", repeated=False, required=False), + 11: protobuf.Field("overwintered", "bool", repeated=False, required=False), + 12: protobuf.Field("version_group_id", "uint32", repeated=False, required=False), + 13: protobuf.Field("timestamp", "uint32", repeated=False, required=False), + 14: protobuf.Field("branch_id", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + inputs: Optional[List["TxInputType"]] = None, + bin_outputs: Optional[List["TxOutputBinType"]] = None, + outputs: Optional[List["TxOutputType"]] = None, + version: Optional["int"] = None, + lock_time: Optional["int"] = None, + inputs_cnt: Optional["int"] = None, + outputs_cnt: Optional["int"] = None, + extra_data: Optional["bytes"] = None, + extra_data_len: Optional["int"] = None, + expiry: Optional["int"] = None, + overwintered: Optional["bool"] = None, + version_group_id: Optional["int"] = None, + timestamp: Optional["int"] = None, + branch_id: Optional["int"] = None, + ) -> None: + self.inputs = inputs if inputs is not None else [] + self.bin_outputs = bin_outputs if bin_outputs is not None else [] + self.outputs = outputs if outputs is not None else [] + self.version = version + self.lock_time = lock_time + self.inputs_cnt = inputs_cnt + self.outputs_cnt = outputs_cnt + self.extra_data = extra_data + self.extra_data_len = extra_data_len + self.expiry = expiry + self.overwintered = overwintered + self.version_group_id = version_group_id + self.timestamp = timestamp + self.branch_id = branch_id + + +class TxInputType(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("address_n", "uint32", repeated=True, required=False), + 2: protobuf.Field("prev_hash", "bytes", repeated=False, required=True), + 3: protobuf.Field("prev_index", "uint32", repeated=False, required=True), + 4: protobuf.Field("script_sig", "bytes", repeated=False, required=False), + 5: protobuf.Field("sequence", "uint32", repeated=False, required=False), + 6: protobuf.Field("script_type", "InputScriptType", repeated=False, required=False), + 7: protobuf.Field("multisig", "MultisigRedeemScriptType", repeated=False, required=False), + 8: protobuf.Field("amount", "uint64", repeated=False, required=False), + 9: protobuf.Field("decred_tree", "uint32", repeated=False, required=False), + 13: protobuf.Field("witness", "bytes", repeated=False, required=False), + 14: protobuf.Field("ownership_proof", "bytes", repeated=False, required=False), + 15: protobuf.Field("commitment_data", "bytes", repeated=False, required=False), + 16: protobuf.Field("orig_hash", "bytes", repeated=False, required=False), + 17: protobuf.Field("orig_index", "uint32", repeated=False, required=False), + 18: protobuf.Field("decred_staking_spend", "DecredStakingSpendType", repeated=False, required=False), + 19: protobuf.Field("script_pubkey", "bytes", repeated=False, required=False), + } + + def __init__( + self, + *, + prev_hash: "bytes", + prev_index: "int", + address_n: Optional[List["int"]] = None, + script_sig: Optional["bytes"] = None, + sequence: Optional["int"] = 4294967295, + script_type: Optional["InputScriptType"] = InputScriptType.SPENDADDRESS, + multisig: Optional["MultisigRedeemScriptType"] = None, + amount: Optional["int"] = None, + decred_tree: Optional["int"] = None, + witness: Optional["bytes"] = None, + ownership_proof: Optional["bytes"] = None, + commitment_data: Optional["bytes"] = None, + orig_hash: Optional["bytes"] = None, + orig_index: Optional["int"] = None, + decred_staking_spend: Optional["DecredStakingSpendType"] = None, + script_pubkey: Optional["bytes"] = None, + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.prev_hash = prev_hash + self.prev_index = prev_index + self.script_sig = script_sig + self.sequence = sequence + self.script_type = script_type + self.multisig = multisig + self.amount = amount + self.decred_tree = decred_tree + self.witness = witness + self.ownership_proof = ownership_proof + self.commitment_data = commitment_data + self.orig_hash = orig_hash + self.orig_index = orig_index + self.decred_staking_spend = decred_staking_spend + self.script_pubkey = script_pubkey + + +class TxOutputBinType(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("amount", "uint64", repeated=False, required=True), + 2: protobuf.Field("script_pubkey", "bytes", repeated=False, required=True), + 3: protobuf.Field("decred_script_version", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + amount: "int", + script_pubkey: "bytes", + decred_script_version: Optional["int"] = None, + ) -> None: + self.amount = amount + self.script_pubkey = script_pubkey + self.decred_script_version = decred_script_version + + +class TxOutputType(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("address", "string", repeated=False, required=False), + 2: protobuf.Field("address_n", "uint32", repeated=True, required=False), + 3: protobuf.Field("amount", "uint64", repeated=False, required=True), + 4: protobuf.Field("script_type", "OutputScriptType", repeated=False, required=False), + 5: protobuf.Field("multisig", "MultisigRedeemScriptType", repeated=False, required=False), + 6: protobuf.Field("op_return_data", "bytes", repeated=False, required=False), + 10: protobuf.Field("orig_hash", "bytes", repeated=False, required=False), + 11: protobuf.Field("orig_index", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + amount: "int", + address_n: Optional[List["int"]] = None, + address: Optional["str"] = None, + script_type: Optional["OutputScriptType"] = OutputScriptType.PAYTOADDRESS, + multisig: Optional["MultisigRedeemScriptType"] = None, + op_return_data: Optional["bytes"] = None, + orig_hash: Optional["bytes"] = None, + orig_index: Optional["int"] = None, + ) -> None: + self.address_n = address_n if address_n is not None else [] + self.amount = amount + self.address = address + self.script_type = script_type + self.multisig = multisig + self.op_return_data = op_return_data + self.orig_hash = orig_hash + self.orig_index = orig_index + + +class TxAckInputWrapper(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 2: protobuf.Field("input", "TxInput", repeated=False, required=True), + } + + def __init__( + self, + *, + input: "TxInput", + ) -> None: + self.input = input + + +class TxAckOutputWrapper(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 5: protobuf.Field("output", "TxOutput", repeated=False, required=True), + } + + def __init__( + self, + *, + output: "TxOutput", + ) -> None: + self.output = output + + +class TxAckPrevInputWrapper(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 2: protobuf.Field("input", "PrevInput", repeated=False, required=True), + } + + def __init__( + self, + *, + input: "PrevInput", + ) -> None: + self.input = input + + +class TxAckPrevOutputWrapper(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 3: protobuf.Field("output", "PrevOutput", repeated=False, required=True), + } + + def __init__( + self, + *, + output: "PrevOutput", + ) -> None: + self.output = output + + +class TxAckPrevExtraDataWrapper(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 8: protobuf.Field("extra_data_chunk", "bytes", repeated=False, required=True), + } + + def __init__( + self, + *, + extra_data_chunk: "bytes", + ) -> None: + self.extra_data_chunk = extra_data_chunk + + +class FirmwareErase(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 6 + FIELDS = { + 1: protobuf.Field("length", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + length: Optional["int"] = None, + ) -> None: + self.length = length + + +class FirmwareRequest(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 8 + FIELDS = { + 1: protobuf.Field("offset", "uint32", repeated=False, required=False), + 2: protobuf.Field("length", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + offset: Optional["int"] = None, + length: Optional["int"] = None, + ) -> None: + self.offset = offset + self.length = length + + +class FirmwareUpload(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 7 + FIELDS = { + 1: protobuf.Field("payload", "bytes", repeated=False, required=True), + 2: protobuf.Field("hash", "bytes", repeated=False, required=False), + } + + def __init__( + self, + *, + payload: "bytes", + hash: Optional["bytes"] = None, + ) -> None: + self.payload = payload + self.hash = hash + + +class SelfTest(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 32 + FIELDS = { + 1: protobuf.Field("payload", "bytes", repeated=False, required=False), + } + + def __init__( + self, + *, + payload: Optional["bytes"] = None, + ) -> None: + self.payload = payload + + +class IdentityType(protobuf.MessageType): + MESSAGE_WIRE_TYPE = None + FIELDS = { + 1: protobuf.Field("proto", "string", repeated=False, required=False), + 2: protobuf.Field("user", "string", repeated=False, required=False), + 3: protobuf.Field("host", "string", repeated=False, required=False), + 4: protobuf.Field("port", "string", repeated=False, required=False), + 5: protobuf.Field("path", "string", repeated=False, required=False), + 6: protobuf.Field("index", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + proto: Optional["str"] = None, + user: Optional["str"] = None, + host: Optional["str"] = None, + port: Optional["str"] = None, + path: Optional["str"] = None, + index: Optional["int"] = 0, + ) -> None: + self.proto = proto + self.user = user + self.host = host + self.port = port + self.path = path + self.index = index + + +class Initialize(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 0 + FIELDS = { + 1: protobuf.Field("session_id", "bytes", repeated=False, required=False), + 3: protobuf.Field("derive_cardano", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + session_id: Optional["bytes"] = None, + derive_cardano: Optional["bool"] = None, + ) -> None: + self.session_id = session_id + self.derive_cardano = derive_cardano + + +class GetFeatures(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 55 + + +class Features(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 17 + FIELDS = { + 1: protobuf.Field("vendor", "string", repeated=False, required=False), + 2: protobuf.Field("major_version", "uint32", repeated=False, required=True), + 3: protobuf.Field("minor_version", "uint32", repeated=False, required=True), + 4: protobuf.Field("patch_version", "uint32", repeated=False, required=True), + 5: protobuf.Field("bootloader_mode", "bool", repeated=False, required=False), + 6: protobuf.Field("device_id", "string", repeated=False, required=False), + 7: protobuf.Field("pin_protection", "bool", repeated=False, required=False), + 8: protobuf.Field("passphrase_protection", "bool", repeated=False, required=False), + 9: protobuf.Field("language", "string", repeated=False, required=False), + 10: protobuf.Field("label", "string", repeated=False, required=False), + 12: protobuf.Field("initialized", "bool", repeated=False, required=False), + 13: protobuf.Field("revision", "bytes", repeated=False, required=False), + 14: protobuf.Field("bootloader_hash", "bytes", repeated=False, required=False), + 15: protobuf.Field("imported", "bool", repeated=False, required=False), + 16: protobuf.Field("unlocked", "bool", repeated=False, required=False), + 18: protobuf.Field("firmware_present", "bool", repeated=False, required=False), + 19: protobuf.Field("needs_backup", "bool", repeated=False, required=False), + 20: protobuf.Field("flags", "uint32", repeated=False, required=False), + 21: protobuf.Field("model", "string", repeated=False, required=False), + 22: protobuf.Field("fw_major", "uint32", repeated=False, required=False), + 23: protobuf.Field("fw_minor", "uint32", repeated=False, required=False), + 24: protobuf.Field("fw_patch", "uint32", repeated=False, required=False), + 25: protobuf.Field("fw_vendor", "string", repeated=False, required=False), + 26: protobuf.Field("fw_vendor_keys", "bytes", repeated=False, required=False), + 27: protobuf.Field("unfinished_backup", "bool", repeated=False, required=False), + 28: protobuf.Field("no_backup", "bool", repeated=False, required=False), + 29: protobuf.Field("recovery_mode", "bool", repeated=False, required=False), + 30: protobuf.Field("capabilities", "Capability", repeated=True, required=False), + 31: protobuf.Field("backup_type", "BackupType", repeated=False, required=False), + 32: protobuf.Field("sd_card_present", "bool", repeated=False, required=False), + 33: protobuf.Field("sd_protection", "bool", repeated=False, required=False), + 34: protobuf.Field("wipe_code_protection", "bool", repeated=False, required=False), + 35: protobuf.Field("session_id", "bytes", repeated=False, required=False), + 36: protobuf.Field("passphrase_always_on_device", "bool", repeated=False, required=False), + 37: protobuf.Field("safety_checks", "SafetyCheckLevel", repeated=False, required=False), + 38: protobuf.Field("auto_lock_delay_ms", "uint32", repeated=False, required=False), + 39: protobuf.Field("display_rotation", "uint32", repeated=False, required=False), + 40: protobuf.Field("experimental_features", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + major_version: "int", + minor_version: "int", + patch_version: "int", + capabilities: Optional[List["Capability"]] = None, + vendor: Optional["str"] = None, + bootloader_mode: Optional["bool"] = None, + device_id: Optional["str"] = None, + pin_protection: Optional["bool"] = None, + passphrase_protection: Optional["bool"] = None, + language: Optional["str"] = None, + label: Optional["str"] = None, + initialized: Optional["bool"] = None, + revision: Optional["bytes"] = None, + bootloader_hash: Optional["bytes"] = None, + imported: Optional["bool"] = None, + unlocked: Optional["bool"] = None, + firmware_present: Optional["bool"] = None, + needs_backup: Optional["bool"] = None, + flags: Optional["int"] = None, + model: Optional["str"] = None, + fw_major: Optional["int"] = None, + fw_minor: Optional["int"] = None, + fw_patch: Optional["int"] = None, + fw_vendor: Optional["str"] = None, + fw_vendor_keys: Optional["bytes"] = None, + unfinished_backup: Optional["bool"] = None, + no_backup: Optional["bool"] = None, + recovery_mode: Optional["bool"] = None, + backup_type: Optional["BackupType"] = None, + sd_card_present: Optional["bool"] = None, + sd_protection: Optional["bool"] = None, + wipe_code_protection: Optional["bool"] = None, + session_id: Optional["bytes"] = None, + passphrase_always_on_device: Optional["bool"] = None, + safety_checks: Optional["SafetyCheckLevel"] = None, + auto_lock_delay_ms: Optional["int"] = None, + display_rotation: Optional["int"] = None, + experimental_features: Optional["bool"] = None, + ) -> None: + self.capabilities = capabilities if capabilities is not None else [] + self.major_version = major_version + self.minor_version = minor_version + self.patch_version = patch_version + self.vendor = vendor + self.bootloader_mode = bootloader_mode + self.device_id = device_id + self.pin_protection = pin_protection + self.passphrase_protection = passphrase_protection + self.language = language + self.label = label + self.initialized = initialized + self.revision = revision + self.bootloader_hash = bootloader_hash + self.imported = imported + self.unlocked = unlocked + self.firmware_present = firmware_present + self.needs_backup = needs_backup + self.flags = flags + self.model = model + self.fw_major = fw_major + self.fw_minor = fw_minor + self.fw_patch = fw_patch + self.fw_vendor = fw_vendor + self.fw_vendor_keys = fw_vendor_keys + self.unfinished_backup = unfinished_backup + self.no_backup = no_backup + self.recovery_mode = recovery_mode + self.backup_type = backup_type + self.sd_card_present = sd_card_present + self.sd_protection = sd_protection + self.wipe_code_protection = wipe_code_protection + self.session_id = session_id + self.passphrase_always_on_device = passphrase_always_on_device + self.safety_checks = safety_checks + self.auto_lock_delay_ms = auto_lock_delay_ms + self.display_rotation = display_rotation + self.experimental_features = experimental_features + + +class LockDevice(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 24 + + +class EndSession(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 83 + + +class ApplySettings(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 25 + FIELDS = { + 1: protobuf.Field("language", "string", repeated=False, required=False), + 2: protobuf.Field("label", "string", repeated=False, required=False), + 3: protobuf.Field("use_passphrase", "bool", repeated=False, required=False), + 4: protobuf.Field("homescreen", "bytes", repeated=False, required=False), + 6: protobuf.Field("auto_lock_delay_ms", "uint32", repeated=False, required=False), + 7: protobuf.Field("display_rotation", "uint32", repeated=False, required=False), + 8: protobuf.Field("passphrase_always_on_device", "bool", repeated=False, required=False), + 9: protobuf.Field("safety_checks", "SafetyCheckLevel", repeated=False, required=False), + 10: protobuf.Field("experimental_features", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + language: Optional["str"] = None, + label: Optional["str"] = None, + use_passphrase: Optional["bool"] = None, + homescreen: Optional["bytes"] = None, + auto_lock_delay_ms: Optional["int"] = None, + display_rotation: Optional["int"] = None, + passphrase_always_on_device: Optional["bool"] = None, + safety_checks: Optional["SafetyCheckLevel"] = None, + experimental_features: Optional["bool"] = None, + ) -> None: + self.language = language + self.label = label + self.use_passphrase = use_passphrase + self.homescreen = homescreen + self.auto_lock_delay_ms = auto_lock_delay_ms + self.display_rotation = display_rotation + self.passphrase_always_on_device = passphrase_always_on_device + self.safety_checks = safety_checks + self.experimental_features = experimental_features + + +class ApplyFlags(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 28 + FIELDS = { + 1: protobuf.Field("flags", "uint32", repeated=False, required=True), + } + + def __init__( + self, + *, + flags: "int", + ) -> None: + self.flags = flags + + +class ChangePin(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 4 + FIELDS = { + 1: protobuf.Field("remove", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + remove: Optional["bool"] = None, + ) -> None: + self.remove = remove + + +class ChangeWipeCode(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 82 + FIELDS = { + 1: protobuf.Field("remove", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + remove: Optional["bool"] = None, + ) -> None: + self.remove = remove + + +class SdProtect(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 79 + FIELDS = { + 1: protobuf.Field("operation", "SdProtectOperationType", repeated=False, required=True), + } + + def __init__( + self, + *, + operation: "SdProtectOperationType", + ) -> None: + self.operation = operation + + +class Ping(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 1 + FIELDS = { + 1: protobuf.Field("message", "string", repeated=False, required=False), + 2: protobuf.Field("button_protection", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + message: Optional["str"] = '', + button_protection: Optional["bool"] = None, + ) -> None: + self.message = message + self.button_protection = button_protection + + +class Cancel(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 20 + + +class GetEntropy(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 9 + FIELDS = { + 1: protobuf.Field("size", "uint32", repeated=False, required=True), + } + + def __init__( + self, + *, + size: "int", + ) -> None: + self.size = size + + +class Entropy(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 10 + FIELDS = { + 1: protobuf.Field("entropy", "bytes", repeated=False, required=True), + } + + def __init__( + self, + *, + entropy: "bytes", + ) -> None: + self.entropy = entropy + + +class WipeDevice(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 5 + + +class LoadDevice(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 13 + FIELDS = { + 1: protobuf.Field("mnemonics", "string", repeated=True, required=False), + 3: protobuf.Field("pin", "string", repeated=False, required=False), + 4: protobuf.Field("passphrase_protection", "bool", repeated=False, required=False), + 5: protobuf.Field("language", "string", repeated=False, required=False), + 6: protobuf.Field("label", "string", repeated=False, required=False), + 7: protobuf.Field("skip_checksum", "bool", repeated=False, required=False), + 8: protobuf.Field("u2f_counter", "uint32", repeated=False, required=False), + 9: protobuf.Field("needs_backup", "bool", repeated=False, required=False), + 10: protobuf.Field("no_backup", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + mnemonics: Optional[List["str"]] = None, + pin: Optional["str"] = None, + passphrase_protection: Optional["bool"] = None, + language: Optional["str"] = 'en-US', + label: Optional["str"] = None, + skip_checksum: Optional["bool"] = None, + u2f_counter: Optional["int"] = None, + needs_backup: Optional["bool"] = None, + no_backup: Optional["bool"] = None, + ) -> None: + self.mnemonics = mnemonics if mnemonics is not None else [] + self.pin = pin + self.passphrase_protection = passphrase_protection + self.language = language + self.label = label + self.skip_checksum = skip_checksum + self.u2f_counter = u2f_counter + self.needs_backup = needs_backup + self.no_backup = no_backup + + +class ResetDevice(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 14 + FIELDS = { + 1: protobuf.Field("display_random", "bool", repeated=False, required=False), + 2: protobuf.Field("strength", "uint32", repeated=False, required=False), + 3: protobuf.Field("passphrase_protection", "bool", repeated=False, required=False), + 4: protobuf.Field("pin_protection", "bool", repeated=False, required=False), + 5: protobuf.Field("language", "string", repeated=False, required=False), + 6: protobuf.Field("label", "string", repeated=False, required=False), + 7: protobuf.Field("u2f_counter", "uint32", repeated=False, required=False), + 8: protobuf.Field("skip_backup", "bool", repeated=False, required=False), + 9: protobuf.Field("no_backup", "bool", repeated=False, required=False), + 10: protobuf.Field("backup_type", "BackupType", repeated=False, required=False), + } + + def __init__( + self, + *, + display_random: Optional["bool"] = None, + strength: Optional["int"] = 256, + passphrase_protection: Optional["bool"] = None, + pin_protection: Optional["bool"] = None, + language: Optional["str"] = 'en-US', + label: Optional["str"] = None, + u2f_counter: Optional["int"] = None, + skip_backup: Optional["bool"] = None, + no_backup: Optional["bool"] = None, + backup_type: Optional["BackupType"] = BackupType.Bip39, + ) -> None: + self.display_random = display_random + self.strength = strength + self.passphrase_protection = passphrase_protection + self.pin_protection = pin_protection + self.language = language + self.label = label + self.u2f_counter = u2f_counter + self.skip_backup = skip_backup + self.no_backup = no_backup + self.backup_type = backup_type + + +class BackupDevice(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 34 + + +class EntropyRequest(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 35 + + +class EntropyAck(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 36 + FIELDS = { + 1: protobuf.Field("entropy", "bytes", repeated=False, required=True), + } + + def __init__( + self, + *, + entropy: "bytes", + ) -> None: + self.entropy = entropy + + +class RecoveryDevice(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 45 + FIELDS = { + 1: protobuf.Field("word_count", "uint32", repeated=False, required=False), + 2: protobuf.Field("passphrase_protection", "bool", repeated=False, required=False), + 3: protobuf.Field("pin_protection", "bool", repeated=False, required=False), + 4: protobuf.Field("language", "string", repeated=False, required=False), + 5: protobuf.Field("label", "string", repeated=False, required=False), + 6: protobuf.Field("enforce_wordlist", "bool", repeated=False, required=False), + 8: protobuf.Field("type", "RecoveryDeviceType", repeated=False, required=False), + 9: protobuf.Field("u2f_counter", "uint32", repeated=False, required=False), + 10: protobuf.Field("dry_run", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + word_count: Optional["int"] = None, + passphrase_protection: Optional["bool"] = None, + pin_protection: Optional["bool"] = None, + language: Optional["str"] = None, + label: Optional["str"] = None, + enforce_wordlist: Optional["bool"] = None, + type: Optional["RecoveryDeviceType"] = None, + u2f_counter: Optional["int"] = None, + dry_run: Optional["bool"] = None, + ) -> None: + self.word_count = word_count + self.passphrase_protection = passphrase_protection + self.pin_protection = pin_protection + self.language = language + self.label = label + self.enforce_wordlist = enforce_wordlist + self.type = type + self.u2f_counter = u2f_counter + self.dry_run = dry_run + + +class WordRequest(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 46 + FIELDS = { + 1: protobuf.Field("type", "WordRequestType", repeated=False, required=True), + } + + def __init__( + self, + *, + type: "WordRequestType", + ) -> None: + self.type = type + + +class WordAck(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 47 + FIELDS = { + 1: protobuf.Field("word", "string", repeated=False, required=True), + } + + def __init__( + self, + *, + word: "str", + ) -> None: + self.word = word + + +class DoPreauthorized(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 84 + + +class PreauthorizedRequest(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 85 + + +class CancelAuthorization(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 86 + + +class RebootToBootloader(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 87 + + +class DebugLinkDecision(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 100 + FIELDS = { + 1: protobuf.Field("yes_no", "bool", repeated=False, required=False), + 2: protobuf.Field("swipe", "DebugSwipeDirection", repeated=False, required=False), + 3: protobuf.Field("input", "string", repeated=False, required=False), + 4: protobuf.Field("x", "uint32", repeated=False, required=False), + 5: protobuf.Field("y", "uint32", repeated=False, required=False), + 6: protobuf.Field("wait", "bool", repeated=False, required=False), + 7: protobuf.Field("hold_ms", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + yes_no: Optional["bool"] = None, + swipe: Optional["DebugSwipeDirection"] = None, + input: Optional["str"] = None, + x: Optional["int"] = None, + y: Optional["int"] = None, + wait: Optional["bool"] = None, + hold_ms: Optional["int"] = None, + ) -> None: + self.yes_no = yes_no + self.swipe = swipe + self.input = input + self.x = x + self.y = y + self.wait = wait + self.hold_ms = hold_ms + + +class DebugLinkLayout(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 9001 + FIELDS = { + 1: protobuf.Field("lines", "string", repeated=True, required=False), + } + + def __init__( + self, + *, + lines: Optional[List["str"]] = None, + ) -> None: + self.lines = lines if lines is not None else [] + + +class DebugLinkReseedRandom(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 9002 + FIELDS = { + 1: protobuf.Field("value", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + value: Optional["int"] = None, + ) -> None: + self.value = value + + +class DebugLinkRecordScreen(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 9003 + FIELDS = { + 1: protobuf.Field("target_directory", "string", repeated=False, required=False), + } + + def __init__( + self, + *, + target_directory: Optional["str"] = None, + ) -> None: + self.target_directory = target_directory + + +class DebugLinkGetState(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 101 + FIELDS = { + 1: protobuf.Field("wait_word_list", "bool", repeated=False, required=False), + 2: protobuf.Field("wait_word_pos", "bool", repeated=False, required=False), + 3: protobuf.Field("wait_layout", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + wait_word_list: Optional["bool"] = None, + wait_word_pos: Optional["bool"] = None, + wait_layout: Optional["bool"] = None, + ) -> None: + self.wait_word_list = wait_word_list + self.wait_word_pos = wait_word_pos + self.wait_layout = wait_layout + + +class DebugLinkState(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 102 + FIELDS = { + 1: protobuf.Field("layout", "bytes", repeated=False, required=False), + 2: protobuf.Field("pin", "string", repeated=False, required=False), + 3: protobuf.Field("matrix", "string", repeated=False, required=False), + 4: protobuf.Field("mnemonic_secret", "bytes", repeated=False, required=False), + 5: protobuf.Field("node", "HDNodeType", repeated=False, required=False), + 6: protobuf.Field("passphrase_protection", "bool", repeated=False, required=False), + 7: protobuf.Field("reset_word", "string", repeated=False, required=False), + 8: protobuf.Field("reset_entropy", "bytes", repeated=False, required=False), + 9: protobuf.Field("recovery_fake_word", "string", repeated=False, required=False), + 10: protobuf.Field("recovery_word_pos", "uint32", repeated=False, required=False), + 11: protobuf.Field("reset_word_pos", "uint32", repeated=False, required=False), + 12: protobuf.Field("mnemonic_type", "BackupType", repeated=False, required=False), + 13: protobuf.Field("layout_lines", "string", repeated=True, required=False), + } + + def __init__( + self, + *, + layout_lines: Optional[List["str"]] = None, + layout: Optional["bytes"] = None, + pin: Optional["str"] = None, + matrix: Optional["str"] = None, + mnemonic_secret: Optional["bytes"] = None, + node: Optional["HDNodeType"] = None, + passphrase_protection: Optional["bool"] = None, + reset_word: Optional["str"] = None, + reset_entropy: Optional["bytes"] = None, + recovery_fake_word: Optional["str"] = None, + recovery_word_pos: Optional["int"] = None, + reset_word_pos: Optional["int"] = None, + mnemonic_type: Optional["BackupType"] = None, + ) -> None: + self.layout_lines = layout_lines if layout_lines is not None else [] + self.layout = layout + self.pin = pin + self.matrix = matrix + self.mnemonic_secret = mnemonic_secret + self.node = node + self.passphrase_protection = passphrase_protection + self.reset_word = reset_word + self.reset_entropy = reset_entropy + self.recovery_fake_word = recovery_fake_word + self.recovery_word_pos = recovery_word_pos + self.reset_word_pos = reset_word_pos + self.mnemonic_type = mnemonic_type + + +class DebugLinkStop(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 103 + + +class DebugLinkLog(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 104 + FIELDS = { + 1: protobuf.Field("level", "uint32", repeated=False, required=False), + 2: protobuf.Field("bucket", "string", repeated=False, required=False), + 3: protobuf.Field("text", "string", repeated=False, required=False), + } + + def __init__( + self, + *, + level: Optional["int"] = None, + bucket: Optional["str"] = None, + text: Optional["str"] = None, + ) -> None: + self.level = level + self.bucket = bucket + self.text = text + + +class DebugLinkMemoryRead(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 110 + FIELDS = { + 1: protobuf.Field("address", "uint32", repeated=False, required=False), + 2: protobuf.Field("length", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + address: Optional["int"] = None, + length: Optional["int"] = None, + ) -> None: + self.address = address + self.length = length + + +class DebugLinkMemory(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 111 + FIELDS = { + 1: protobuf.Field("memory", "bytes", repeated=False, required=False), + } + + def __init__( + self, + *, + memory: Optional["bytes"] = None, + ) -> None: + self.memory = memory + + +class DebugLinkMemoryWrite(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 112 + FIELDS = { + 1: protobuf.Field("address", "uint32", repeated=False, required=False), + 2: protobuf.Field("memory", "bytes", repeated=False, required=False), + 3: protobuf.Field("flash", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + address: Optional["int"] = None, + memory: Optional["bytes"] = None, + flash: Optional["bool"] = None, + ) -> None: + self.address = address + self.memory = memory + self.flash = flash + + +class DebugLinkFlashErase(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 113 + FIELDS = { + 1: protobuf.Field("sector", "uint32", repeated=False, required=False), + } + + def __init__( + self, + *, + sector: Optional["int"] = None, + ) -> None: + self.sector = sector + + +class DebugLinkEraseSdCard(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 9005 + FIELDS = { + 1: protobuf.Field("format", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + format: Optional["bool"] = None, + ) -> None: + self.format = format + + +class DebugLinkWatchLayout(protobuf.MessageType): + MESSAGE_WIRE_TYPE = 9006 + FIELDS = { + 1: protobuf.Field("watch", "bool", repeated=False, required=False), + } + + def __init__( + self, + *, + watch: Optional["bool"] = None, + ) -> None: + self.watch = watch diff --git a/hwilib/devices/trezorlib/messages/Address.py b/hwilib/devices/trezorlib/messages/Address.py deleted file mode 100644 index 8db78f462..000000000 --- a/hwilib/devices/trezorlib/messages/Address.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class Address(p.MessageType): - MESSAGE_WIRE_TYPE = 30 - - def __init__( - self, - address: str = None, - ) -> None: - self.address = address - - @classmethod - def get_fields(cls): - return { - 1: ('address', p.UnicodeType, 0), # required - } diff --git a/hwilib/devices/trezorlib/messages/ApplyFlags.py b/hwilib/devices/trezorlib/messages/ApplyFlags.py deleted file mode 100644 index 775e6dbfc..000000000 --- a/hwilib/devices/trezorlib/messages/ApplyFlags.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class ApplyFlags(p.MessageType): - MESSAGE_WIRE_TYPE = 28 - - def __init__( - self, - flags: int = None, - ) -> None: - self.flags = flags - - @classmethod - def get_fields(cls): - return { - 1: ('flags', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/ApplySettings.py b/hwilib/devices/trezorlib/messages/ApplySettings.py deleted file mode 100644 index 3330a3136..000000000 --- a/hwilib/devices/trezorlib/messages/ApplySettings.py +++ /dev/null @@ -1,34 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class ApplySettings(p.MessageType): - MESSAGE_WIRE_TYPE = 25 - - def __init__( - self, - language: str = None, - label: str = None, - use_passphrase: bool = None, - homescreen: bytes = None, - passphrase_source: int = None, - auto_lock_delay_ms: int = None, - ) -> None: - self.language = language - self.label = label - self.use_passphrase = use_passphrase - self.homescreen = homescreen - self.passphrase_source = passphrase_source - self.auto_lock_delay_ms = auto_lock_delay_ms - - @classmethod - def get_fields(cls): - return { - 1: ('language', p.UnicodeType, 0), - 2: ('label', p.UnicodeType, 0), - 3: ('use_passphrase', p.BoolType, 0), - 4: ('homescreen', p.BytesType, 0), - 5: ('passphrase_source', p.UVarintType, 0), - 6: ('auto_lock_delay_ms', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/BackupDevice.py b/hwilib/devices/trezorlib/messages/BackupDevice.py deleted file mode 100644 index 5acb2a6af..000000000 --- a/hwilib/devices/trezorlib/messages/BackupDevice.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class BackupDevice(p.MessageType): - MESSAGE_WIRE_TYPE = 34 diff --git a/hwilib/devices/trezorlib/messages/ButtonAck.py b/hwilib/devices/trezorlib/messages/ButtonAck.py deleted file mode 100644 index cb6e17067..000000000 --- a/hwilib/devices/trezorlib/messages/ButtonAck.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class ButtonAck(p.MessageType): - MESSAGE_WIRE_TYPE = 27 diff --git a/hwilib/devices/trezorlib/messages/ButtonRequest.py b/hwilib/devices/trezorlib/messages/ButtonRequest.py deleted file mode 100644 index ac53cae35..000000000 --- a/hwilib/devices/trezorlib/messages/ButtonRequest.py +++ /dev/null @@ -1,22 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class ButtonRequest(p.MessageType): - MESSAGE_WIRE_TYPE = 26 - - def __init__( - self, - code: int = None, - data: str = None, - ) -> None: - self.code = code - self.data = data - - @classmethod - def get_fields(cls): - return { - 1: ('code', p.UVarintType, 0), - 2: ('data', p.UnicodeType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/ButtonRequestType.py b/hwilib/devices/trezorlib/messages/ButtonRequestType.py deleted file mode 100644 index 5f8decd08..000000000 --- a/hwilib/devices/trezorlib/messages/ButtonRequestType.py +++ /dev/null @@ -1,17 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -Other = 1 -FeeOverThreshold = 2 -ConfirmOutput = 3 -ResetDevice = 4 -ConfirmWord = 5 -WipeDevice = 6 -ProtectCall = 7 -SignTx = 8 -FirmwareCheck = 9 -Address = 10 -PublicKey = 11 -MnemonicWordCount = 12 -MnemonicInput = 13 -PassphraseType = 14 -UnknownDerivationPath = 15 diff --git a/hwilib/devices/trezorlib/messages/Cancel.py b/hwilib/devices/trezorlib/messages/Cancel.py deleted file mode 100644 index 29eb2ee2a..000000000 --- a/hwilib/devices/trezorlib/messages/Cancel.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class Cancel(p.MessageType): - MESSAGE_WIRE_TYPE = 20 diff --git a/hwilib/devices/trezorlib/messages/ChangePin.py b/hwilib/devices/trezorlib/messages/ChangePin.py deleted file mode 100644 index 8b2e82ea1..000000000 --- a/hwilib/devices/trezorlib/messages/ChangePin.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class ChangePin(p.MessageType): - MESSAGE_WIRE_TYPE = 4 - - def __init__( - self, - remove: bool = None, - ) -> None: - self.remove = remove - - @classmethod - def get_fields(cls): - return { - 1: ('remove', p.BoolType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/ClearSession.py b/hwilib/devices/trezorlib/messages/ClearSession.py deleted file mode 100644 index b929c2ac0..000000000 --- a/hwilib/devices/trezorlib/messages/ClearSession.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class ClearSession(p.MessageType): - MESSAGE_WIRE_TYPE = 24 diff --git a/hwilib/devices/trezorlib/messages/DebugLinkDecision.py b/hwilib/devices/trezorlib/messages/DebugLinkDecision.py deleted file mode 100644 index 57be789a3..000000000 --- a/hwilib/devices/trezorlib/messages/DebugLinkDecision.py +++ /dev/null @@ -1,25 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class DebugLinkDecision(p.MessageType): - MESSAGE_WIRE_TYPE = 100 - - def __init__( - self, - yes_no: bool = None, - up_down: bool = None, - input: str = None, - ) -> None: - self.yes_no = yes_no - self.up_down = up_down - self.input = input - - @classmethod - def get_fields(cls): - return { - 1: ('yes_no', p.BoolType, 0), - 2: ('up_down', p.BoolType, 0), - 3: ('input', p.UnicodeType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/DebugLinkFlashErase.py b/hwilib/devices/trezorlib/messages/DebugLinkFlashErase.py deleted file mode 100644 index 36507b7fa..000000000 --- a/hwilib/devices/trezorlib/messages/DebugLinkFlashErase.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class DebugLinkFlashErase(p.MessageType): - MESSAGE_WIRE_TYPE = 113 - - def __init__( - self, - sector: int = None, - ) -> None: - self.sector = sector - - @classmethod - def get_fields(cls): - return { - 1: ('sector', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/DebugLinkGetState.py b/hwilib/devices/trezorlib/messages/DebugLinkGetState.py deleted file mode 100644 index 7a089359d..000000000 --- a/hwilib/devices/trezorlib/messages/DebugLinkGetState.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class DebugLinkGetState(p.MessageType): - MESSAGE_WIRE_TYPE = 101 diff --git a/hwilib/devices/trezorlib/messages/DebugLinkLog.py b/hwilib/devices/trezorlib/messages/DebugLinkLog.py deleted file mode 100644 index b22782986..000000000 --- a/hwilib/devices/trezorlib/messages/DebugLinkLog.py +++ /dev/null @@ -1,25 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class DebugLinkLog(p.MessageType): - MESSAGE_WIRE_TYPE = 104 - - def __init__( - self, - level: int = None, - bucket: str = None, - text: str = None, - ) -> None: - self.level = level - self.bucket = bucket - self.text = text - - @classmethod - def get_fields(cls): - return { - 1: ('level', p.UVarintType, 0), - 2: ('bucket', p.UnicodeType, 0), - 3: ('text', p.UnicodeType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/DebugLinkMemory.py b/hwilib/devices/trezorlib/messages/DebugLinkMemory.py deleted file mode 100644 index 0b5f4c5af..000000000 --- a/hwilib/devices/trezorlib/messages/DebugLinkMemory.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class DebugLinkMemory(p.MessageType): - MESSAGE_WIRE_TYPE = 111 - - def __init__( - self, - memory: bytes = None, - ) -> None: - self.memory = memory - - @classmethod - def get_fields(cls): - return { - 1: ('memory', p.BytesType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/DebugLinkMemoryRead.py b/hwilib/devices/trezorlib/messages/DebugLinkMemoryRead.py deleted file mode 100644 index 36560c40a..000000000 --- a/hwilib/devices/trezorlib/messages/DebugLinkMemoryRead.py +++ /dev/null @@ -1,22 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class DebugLinkMemoryRead(p.MessageType): - MESSAGE_WIRE_TYPE = 110 - - def __init__( - self, - address: int = None, - length: int = None, - ) -> None: - self.address = address - self.length = length - - @classmethod - def get_fields(cls): - return { - 1: ('address', p.UVarintType, 0), - 2: ('length', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/DebugLinkMemoryWrite.py b/hwilib/devices/trezorlib/messages/DebugLinkMemoryWrite.py deleted file mode 100644 index d939f533a..000000000 --- a/hwilib/devices/trezorlib/messages/DebugLinkMemoryWrite.py +++ /dev/null @@ -1,25 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class DebugLinkMemoryWrite(p.MessageType): - MESSAGE_WIRE_TYPE = 112 - - def __init__( - self, - address: int = None, - memory: bytes = None, - flash: bool = None, - ) -> None: - self.address = address - self.memory = memory - self.flash = flash - - @classmethod - def get_fields(cls): - return { - 1: ('address', p.UVarintType, 0), - 2: ('memory', p.BytesType, 0), - 3: ('flash', p.BoolType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/DebugLinkState.py b/hwilib/devices/trezorlib/messages/DebugLinkState.py deleted file mode 100644 index 5729102a2..000000000 --- a/hwilib/devices/trezorlib/messages/DebugLinkState.py +++ /dev/null @@ -1,51 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .HDNodeType import HDNodeType - - -class DebugLinkState(p.MessageType): - MESSAGE_WIRE_TYPE = 102 - - def __init__( - self, - layout: bytes = None, - pin: str = None, - matrix: str = None, - mnemonic: str = None, - node: HDNodeType = None, - passphrase_protection: bool = None, - reset_word: str = None, - reset_entropy: bytes = None, - recovery_fake_word: str = None, - recovery_word_pos: int = None, - # reset_word_pos: int = None, - ) -> None: - self.layout = layout - self.pin = pin - self.matrix = matrix - self.mnemonic = mnemonic - self.node = node - self.passphrase_protection = passphrase_protection - self.reset_word = reset_word - self.reset_entropy = reset_entropy - self.recovery_fake_word = recovery_fake_word - self.recovery_word_pos = recovery_word_pos - # self.reset_word_pos = reset_word_pos - - @classmethod - def get_fields(cls): - return { - 1: ('layout', p.BytesType, 0), - 2: ('pin', p.UnicodeType, 0), - 3: ('matrix', p.UnicodeType, 0), - 4: ('mnemonic', p.UnicodeType, 0), - 5: ('node', HDNodeType, 0), - 6: ('passphrase_protection', p.BoolType, 0), - 7: ('reset_word', p.UnicodeType, 0), - 8: ('reset_entropy', p.BytesType, 0), - 9: ('recovery_fake_word', p.UnicodeType, 0), - 10: ('recovery_word_pos', p.UVarintType, 0), - # 11: ('reset_word_pos', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/DebugLinkStop.py b/hwilib/devices/trezorlib/messages/DebugLinkStop.py deleted file mode 100644 index 3a63a1020..000000000 --- a/hwilib/devices/trezorlib/messages/DebugLinkStop.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class DebugLinkStop(p.MessageType): - MESSAGE_WIRE_TYPE = 103 diff --git a/hwilib/devices/trezorlib/messages/Entropy.py b/hwilib/devices/trezorlib/messages/Entropy.py deleted file mode 100644 index e11f389bf..000000000 --- a/hwilib/devices/trezorlib/messages/Entropy.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class Entropy(p.MessageType): - MESSAGE_WIRE_TYPE = 10 - - def __init__( - self, - entropy: bytes = None, - ) -> None: - self.entropy = entropy - - @classmethod - def get_fields(cls): - return { - 1: ('entropy', p.BytesType, 0), # required - } diff --git a/hwilib/devices/trezorlib/messages/EntropyAck.py b/hwilib/devices/trezorlib/messages/EntropyAck.py deleted file mode 100644 index c6a08eaaa..000000000 --- a/hwilib/devices/trezorlib/messages/EntropyAck.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class EntropyAck(p.MessageType): - MESSAGE_WIRE_TYPE = 36 - - def __init__( - self, - entropy: bytes = None, - ) -> None: - self.entropy = entropy - - @classmethod - def get_fields(cls): - return { - 1: ('entropy', p.BytesType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/EntropyRequest.py b/hwilib/devices/trezorlib/messages/EntropyRequest.py deleted file mode 100644 index 418668670..000000000 --- a/hwilib/devices/trezorlib/messages/EntropyRequest.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class EntropyRequest(p.MessageType): - MESSAGE_WIRE_TYPE = 35 diff --git a/hwilib/devices/trezorlib/messages/Failure.py b/hwilib/devices/trezorlib/messages/Failure.py deleted file mode 100644 index 2753763c7..000000000 --- a/hwilib/devices/trezorlib/messages/Failure.py +++ /dev/null @@ -1,22 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class Failure(p.MessageType): - MESSAGE_WIRE_TYPE = 3 - - def __init__( - self, - code: int = None, - message: str = None, - ) -> None: - self.code = code - self.message = message - - @classmethod - def get_fields(cls): - return { - 1: ('code', p.UVarintType, 0), - 2: ('message', p.UnicodeType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/FailureType.py b/hwilib/devices/trezorlib/messages/FailureType.py deleted file mode 100644 index 6f5cffe18..000000000 --- a/hwilib/devices/trezorlib/messages/FailureType.py +++ /dev/null @@ -1,15 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -UnexpectedMessage = 1 -ButtonExpected = 2 -DataError = 3 -ActionCancelled = 4 -PinExpected = 5 -PinCancelled = 6 -PinInvalid = 7 -InvalidSignature = 8 -ProcessError = 9 -NotEnoughFunds = 10 -NotInitialized = 11 -PinMismatch = 12 -FirmwareError = 99 diff --git a/hwilib/devices/trezorlib/messages/Features.py b/hwilib/devices/trezorlib/messages/Features.py deleted file mode 100644 index c6aff5902..000000000 --- a/hwilib/devices/trezorlib/messages/Features.py +++ /dev/null @@ -1,97 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class Features(p.MessageType): - MESSAGE_WIRE_TYPE = 17 - - def __init__( - self, - vendor: str = None, - major_version: int = None, - minor_version: int = None, - patch_version: int = None, - bootloader_mode: bool = None, - device_id: str = None, - pin_protection: bool = None, - passphrase_protection: bool = None, - language: str = None, - label: str = None, - initialized: bool = None, - revision: bytes = None, - bootloader_hash: bytes = None, - imported: bool = None, - pin_cached: bool = None, - passphrase_cached: bool = None, - firmware_present: bool = None, - needs_backup: bool = None, - flags: int = None, - model: str = None, - fw_major: int = None, - fw_minor: int = None, - fw_patch: int = None, - fw_vendor: str = None, - fw_vendor_keys: bytes = None, - unfinished_backup: bool = None, - no_backup: bool = None, - ) -> None: - self.vendor = vendor - self.major_version = major_version - self.minor_version = minor_version - self.patch_version = patch_version - self.bootloader_mode = bootloader_mode - self.device_id = device_id - self.pin_protection = pin_protection - self.passphrase_protection = passphrase_protection - self.language = language - self.label = label - self.initialized = initialized - self.revision = revision - self.bootloader_hash = bootloader_hash - self.imported = imported - self.pin_cached = pin_cached - self.passphrase_cached = passphrase_cached - self.firmware_present = firmware_present - self.needs_backup = needs_backup - self.flags = flags - self.model = model - self.fw_major = fw_major - self.fw_minor = fw_minor - self.fw_patch = fw_patch - self.fw_vendor = fw_vendor - self.fw_vendor_keys = fw_vendor_keys - self.unfinished_backup = unfinished_backup - self.no_backup = no_backup - - @classmethod - def get_fields(cls): - return { - 1: ('vendor', p.UnicodeType, 0), - 2: ('major_version', p.UVarintType, 0), - 3: ('minor_version', p.UVarintType, 0), - 4: ('patch_version', p.UVarintType, 0), - 5: ('bootloader_mode', p.BoolType, 0), - 6: ('device_id', p.UnicodeType, 0), - 7: ('pin_protection', p.BoolType, 0), - 8: ('passphrase_protection', p.BoolType, 0), - 9: ('language', p.UnicodeType, 0), - 10: ('label', p.UnicodeType, 0), - 12: ('initialized', p.BoolType, 0), - 13: ('revision', p.BytesType, 0), - 14: ('bootloader_hash', p.BytesType, 0), - 15: ('imported', p.BoolType, 0), - 16: ('pin_cached', p.BoolType, 0), - 17: ('passphrase_cached', p.BoolType, 0), - # 18: ('firmware_present', p.BoolType, 0), - # 19: ('needs_backup', p.BoolType, 0), - # 20: ('flags', p.UVarintType, 0), - 21: ('model', p.UnicodeType, 0), - # 22: ('fw_major', p.UVarintType, 0), - # 23: ('fw_minor', p.UVarintType, 0), - # 24: ('fw_patch', p.UVarintType, 0), - # 25: ('fw_vendor', p.UnicodeType, 0), - # 26: ('fw_vendor_keys', p.BytesType, 0), - # 27: ('unfinished_backup', p.BoolType, 0), - # 28: ('no_backup', p.BoolType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/FirmwareErase.py b/hwilib/devices/trezorlib/messages/FirmwareErase.py deleted file mode 100644 index c07c2f910..000000000 --- a/hwilib/devices/trezorlib/messages/FirmwareErase.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class FirmwareErase(p.MessageType): - MESSAGE_WIRE_TYPE = 6 - - def __init__( - self, - length: int = None, - ) -> None: - self.length = length - - @classmethod - def get_fields(cls): - return { - 1: ('length', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/FirmwareRequest.py b/hwilib/devices/trezorlib/messages/FirmwareRequest.py deleted file mode 100644 index 13ba2ce1b..000000000 --- a/hwilib/devices/trezorlib/messages/FirmwareRequest.py +++ /dev/null @@ -1,22 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class FirmwareRequest(p.MessageType): - MESSAGE_WIRE_TYPE = 8 - - def __init__( - self, - offset: int = None, - length: int = None, - ) -> None: - self.offset = offset - self.length = length - - @classmethod - def get_fields(cls): - return { - 1: ('offset', p.UVarintType, 0), - 2: ('length', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/FirmwareUpload.py b/hwilib/devices/trezorlib/messages/FirmwareUpload.py deleted file mode 100644 index 217273ed6..000000000 --- a/hwilib/devices/trezorlib/messages/FirmwareUpload.py +++ /dev/null @@ -1,22 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class FirmwareUpload(p.MessageType): - MESSAGE_WIRE_TYPE = 7 - - def __init__( - self, - payload: bytes = None, - hash: bytes = None, - ) -> None: - self.payload = payload - self.hash = hash - - @classmethod - def get_fields(cls): - return { - 1: ('payload', p.BytesType, 0), # required - 2: ('hash', p.BytesType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/GetAddress.py b/hwilib/devices/trezorlib/messages/GetAddress.py deleted file mode 100644 index ce876512a..000000000 --- a/hwilib/devices/trezorlib/messages/GetAddress.py +++ /dev/null @@ -1,39 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .MultisigRedeemScriptType import MultisigRedeemScriptType - -if __debug__: - try: - from typing import List - except ImportError: - List = None # type: ignore - - -class GetAddress(p.MessageType): - MESSAGE_WIRE_TYPE = 29 - - def __init__( - self, - address_n: List[int] = None, - coin_name: str = None, - show_display: bool = None, - multisig: MultisigRedeemScriptType = None, - script_type: int = None, - ) -> None: - self.address_n = address_n if address_n is not None else [] - self.coin_name = coin_name - self.show_display = show_display - self.multisig = multisig - self.script_type = script_type - - @classmethod - def get_fields(cls): - return { - 1: ('address_n', p.UVarintType, p.FLAG_REPEATED), - 2: ('coin_name', p.UnicodeType, 0), # default=Bitcoin - 3: ('show_display', p.BoolType, 0), - 4: ('multisig', MultisigRedeemScriptType, 0), - 5: ('script_type', p.UVarintType, 0), # default=SPENDADDRESS - } diff --git a/hwilib/devices/trezorlib/messages/GetEntropy.py b/hwilib/devices/trezorlib/messages/GetEntropy.py deleted file mode 100644 index 0a606c7d8..000000000 --- a/hwilib/devices/trezorlib/messages/GetEntropy.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class GetEntropy(p.MessageType): - MESSAGE_WIRE_TYPE = 9 - - def __init__( - self, - size: int = None, - ) -> None: - self.size = size - - @classmethod - def get_fields(cls): - return { - 1: ('size', p.UVarintType, 0), # required - } diff --git a/hwilib/devices/trezorlib/messages/GetFeatures.py b/hwilib/devices/trezorlib/messages/GetFeatures.py deleted file mode 100644 index bdbcab35b..000000000 --- a/hwilib/devices/trezorlib/messages/GetFeatures.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class GetFeatures(p.MessageType): - MESSAGE_WIRE_TYPE = 55 diff --git a/hwilib/devices/trezorlib/messages/GetPublicKey.py b/hwilib/devices/trezorlib/messages/GetPublicKey.py deleted file mode 100644 index f488eb662..000000000 --- a/hwilib/devices/trezorlib/messages/GetPublicKey.py +++ /dev/null @@ -1,37 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -if __debug__: - try: - from typing import List - except ImportError: - List = None # type: ignore - - -class GetPublicKey(p.MessageType): - MESSAGE_WIRE_TYPE = 11 - - def __init__( - self, - address_n: List[int] = None, - ecdsa_curve_name: str = None, - show_display: bool = None, - coin_name: str = None, - script_type: int = None, - ) -> None: - self.address_n = address_n if address_n is not None else [] - self.ecdsa_curve_name = ecdsa_curve_name - self.show_display = show_display - self.coin_name = coin_name - self.script_type = script_type - - @classmethod - def get_fields(cls): - return { - 1: ('address_n', p.UVarintType, p.FLAG_REPEATED), - 2: ('ecdsa_curve_name', p.UnicodeType, 0), - 3: ('show_display', p.BoolType, 0), - 4: ('coin_name', p.UnicodeType, 0), # default=Bitcoin - 5: ('script_type', p.UVarintType, 0), # default=SPENDADDRESS - } diff --git a/hwilib/devices/trezorlib/messages/HDNodePathType.py b/hwilib/devices/trezorlib/messages/HDNodePathType.py deleted file mode 100644 index 3e275acb4..000000000 --- a/hwilib/devices/trezorlib/messages/HDNodePathType.py +++ /dev/null @@ -1,29 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .HDNodeType import HDNodeType - -if __debug__: - try: - from typing import List - except ImportError: - List = None # type: ignore - - -class HDNodePathType(p.MessageType): - - def __init__( - self, - node: HDNodeType = None, - address_n: List[int] = None, - ) -> None: - self.node = node - self.address_n = address_n if address_n is not None else [] - - @classmethod - def get_fields(cls): - return { - 1: ('node', HDNodeType, 0), # required - 2: ('address_n', p.UVarintType, p.FLAG_REPEATED), - } diff --git a/hwilib/devices/trezorlib/messages/HDNodeType.py b/hwilib/devices/trezorlib/messages/HDNodeType.py deleted file mode 100644 index 4577532b7..000000000 --- a/hwilib/devices/trezorlib/messages/HDNodeType.py +++ /dev/null @@ -1,33 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class HDNodeType(p.MessageType): - - def __init__( - self, - depth: int = None, - fingerprint: int = None, - child_num: int = None, - chain_code: bytes = None, - private_key: bytes = None, - public_key: bytes = None, - ) -> None: - self.depth = depth - self.fingerprint = fingerprint - self.child_num = child_num - self.chain_code = chain_code - self.private_key = private_key - self.public_key = public_key - - @classmethod - def get_fields(cls): - return { - 1: ('depth', p.UVarintType, 0), # required - 2: ('fingerprint', p.UVarintType, 0), # required - 3: ('child_num', p.UVarintType, 0), # required - 4: ('chain_code', p.BytesType, 0), # required - 5: ('private_key', p.BytesType, 0), - 6: ('public_key', p.BytesType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/IdentityType.py b/hwilib/devices/trezorlib/messages/IdentityType.py deleted file mode 100644 index 1e5125b84..000000000 --- a/hwilib/devices/trezorlib/messages/IdentityType.py +++ /dev/null @@ -1,33 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class IdentityType(p.MessageType): - - def __init__( - self, - proto: str = None, - user: str = None, - host: str = None, - port: str = None, - path: str = None, - index: int = None, - ) -> None: - self.proto = proto - self.user = user - self.host = host - self.port = port - self.path = path - self.index = index - - @classmethod - def get_fields(cls): - return { - 1: ('proto', p.UnicodeType, 0), - 2: ('user', p.UnicodeType, 0), - 3: ('host', p.UnicodeType, 0), - 4: ('port', p.UnicodeType, 0), - 5: ('path', p.UnicodeType, 0), - 6: ('index', p.UVarintType, 0), # default=0 - } diff --git a/hwilib/devices/trezorlib/messages/Initialize.py b/hwilib/devices/trezorlib/messages/Initialize.py deleted file mode 100644 index 01663d74a..000000000 --- a/hwilib/devices/trezorlib/messages/Initialize.py +++ /dev/null @@ -1,22 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class Initialize(p.MessageType): - MESSAGE_WIRE_TYPE = 0 - - def __init__( - self, - state: bytes = None, - skip_passphrase: bool = None, - ) -> None: - self.state = state - self.skip_passphrase = skip_passphrase - - @classmethod - def get_fields(cls): - return { - 1: ('state', p.BytesType, 0), - 2: ('skip_passphrase', p.BoolType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/InputScriptType.py b/hwilib/devices/trezorlib/messages/InputScriptType.py deleted file mode 100644 index 09b8332cb..000000000 --- a/hwilib/devices/trezorlib/messages/InputScriptType.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -SPENDADDRESS = 0 -SPENDMULTISIG = 1 -EXTERNAL = 2 -SPENDWITNESS = 3 -SPENDP2SHWITNESS = 4 diff --git a/hwilib/devices/trezorlib/messages/LoadDevice.py b/hwilib/devices/trezorlib/messages/LoadDevice.py deleted file mode 100644 index 534563cb0..000000000 --- a/hwilib/devices/trezorlib/messages/LoadDevice.py +++ /dev/null @@ -1,42 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .HDNodeType import HDNodeType - - -class LoadDevice(p.MessageType): - MESSAGE_WIRE_TYPE = 13 - - def __init__( - self, - mnemonic: str = None, - node: HDNodeType = None, - pin: str = None, - passphrase_protection: bool = None, - language: str = None, - label: str = None, - skip_checksum: bool = None, - u2f_counter: int = None, - ) -> None: - self.mnemonic = mnemonic - self.node = node - self.pin = pin - self.passphrase_protection = passphrase_protection - self.language = language - self.label = label - self.skip_checksum = skip_checksum - self.u2f_counter = u2f_counter - - @classmethod - def get_fields(cls): - return { - 1: ('mnemonic', p.UnicodeType, 0), - 2: ('node', HDNodeType, 0), - 3: ('pin', p.UnicodeType, 0), - 4: ('passphrase_protection', p.BoolType, 0), - 5: ('language', p.UnicodeType, 0), # default=english - 6: ('label', p.UnicodeType, 0), - 7: ('skip_checksum', p.BoolType, 0), - 8: ('u2f_counter', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/MessageSignature.py b/hwilib/devices/trezorlib/messages/MessageSignature.py deleted file mode 100644 index 70aa8416b..000000000 --- a/hwilib/devices/trezorlib/messages/MessageSignature.py +++ /dev/null @@ -1,22 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class MessageSignature(p.MessageType): - MESSAGE_WIRE_TYPE = 40 - - def __init__( - self, - address: str = None, - signature: bytes = None, - ) -> None: - self.address = address - self.signature = signature - - @classmethod - def get_fields(cls): - return { - 1: ('address', p.UnicodeType, 0), - 2: ('signature', p.BytesType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/MessageType.py b/hwilib/devices/trezorlib/messages/MessageType.py deleted file mode 100644 index 852ba8c21..000000000 --- a/hwilib/devices/trezorlib/messages/MessageType.py +++ /dev/null @@ -1,57 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -Initialize = 0 -Ping = 1 -Success = 2 -Failure = 3 -ChangePin = 4 -WipeDevice = 5 -GetEntropy = 9 -Entropy = 10 -LoadDevice = 13 -ResetDevice = 14 -Features = 17 -PinMatrixRequest = 18 -PinMatrixAck = 19 -Cancel = 20 -ClearSession = 24 -ApplySettings = 25 -ButtonRequest = 26 -ButtonAck = 27 -ApplyFlags = 28 -BackupDevice = 34 -EntropyRequest = 35 -EntropyAck = 36 -PassphraseRequest = 41 -PassphraseAck = 42 -PassphraseStateRequest = 77 -PassphraseStateAck = 78 -RecoveryDevice = 45 -WordRequest = 46 -WordAck = 47 -GetFeatures = 55 -FirmwareErase = 6 -FirmwareUpload = 7 -FirmwareRequest = 8 -SelfTest = 32 -GetPublicKey = 11 -PublicKey = 12 -SignTx = 15 -TxRequest = 21 -TxAck = 22 -GetAddress = 29 -Address = 30 -SignMessage = 38 -VerifyMessage = 39 -MessageSignature = 40 -SignIdentity = 53 -SignedIdentity = 54 -DebugLinkDecision = 100 -DebugLinkGetState = 101 -DebugLinkState = 102 -DebugLinkStop = 103 -DebugLinkLog = 104 -DebugLinkMemoryRead = 110 -DebugLinkMemory = 111 -DebugLinkMemoryWrite = 112 -DebugLinkFlashErase = 113 diff --git a/hwilib/devices/trezorlib/messages/MultisigRedeemScriptType.py b/hwilib/devices/trezorlib/messages/MultisigRedeemScriptType.py deleted file mode 100644 index 705cfe16c..000000000 --- a/hwilib/devices/trezorlib/messages/MultisigRedeemScriptType.py +++ /dev/null @@ -1,32 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .HDNodePathType import HDNodePathType - -if __debug__: - try: - from typing import List - except ImportError: - List = None # type: ignore - - -class MultisigRedeemScriptType(p.MessageType): - - def __init__( - self, - pubkeys: List[HDNodePathType] = None, - signatures: List[bytes] = None, - m: int = None, - ) -> None: - self.pubkeys = pubkeys if pubkeys is not None else [] - self.signatures = signatures if signatures is not None else [] - self.m = m - - @classmethod - def get_fields(cls): - return { - 1: ('pubkeys', HDNodePathType, p.FLAG_REPEATED), - 2: ('signatures', p.BytesType, p.FLAG_REPEATED), - 3: ('m', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/OutputScriptType.py b/hwilib/devices/trezorlib/messages/OutputScriptType.py deleted file mode 100644 index 6a9b7eb39..000000000 --- a/hwilib/devices/trezorlib/messages/OutputScriptType.py +++ /dev/null @@ -1,8 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -PAYTOADDRESS = 0 -PAYTOSCRIPTHASH = 1 -PAYTOMULTISIG = 2 -PAYTOOPRETURN = 3 -PAYTOWITNESS = 4 -PAYTOP2SHWITNESS = 5 diff --git a/hwilib/devices/trezorlib/messages/PassphraseAck.py b/hwilib/devices/trezorlib/messages/PassphraseAck.py deleted file mode 100644 index 8f49ce239..000000000 --- a/hwilib/devices/trezorlib/messages/PassphraseAck.py +++ /dev/null @@ -1,22 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class PassphraseAck(p.MessageType): - MESSAGE_WIRE_TYPE = 42 - - def __init__( - self, - passphrase: str = None, - state: bytes = None, - ) -> None: - self.passphrase = passphrase - self.state = state - - @classmethod - def get_fields(cls): - return { - 1: ('passphrase', p.UnicodeType, 0), - 2: ('state', p.BytesType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/PassphraseRequest.py b/hwilib/devices/trezorlib/messages/PassphraseRequest.py deleted file mode 100644 index 919fee9c3..000000000 --- a/hwilib/devices/trezorlib/messages/PassphraseRequest.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class PassphraseRequest(p.MessageType): - MESSAGE_WIRE_TYPE = 41 - - def __init__( - self, - on_device: bool = None, - ) -> None: - self.on_device = on_device - - @classmethod - def get_fields(cls): - return { - 1: ('on_device', p.BoolType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/PassphraseSourceType.py b/hwilib/devices/trezorlib/messages/PassphraseSourceType.py deleted file mode 100644 index 3bce46d10..000000000 --- a/hwilib/devices/trezorlib/messages/PassphraseSourceType.py +++ /dev/null @@ -1,5 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -ASK = 0 -DEVICE = 1 -HOST = 2 diff --git a/hwilib/devices/trezorlib/messages/PassphraseStateAck.py b/hwilib/devices/trezorlib/messages/PassphraseStateAck.py deleted file mode 100644 index 7563e61ed..000000000 --- a/hwilib/devices/trezorlib/messages/PassphraseStateAck.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class PassphraseStateAck(p.MessageType): - MESSAGE_WIRE_TYPE = 78 diff --git a/hwilib/devices/trezorlib/messages/PassphraseStateRequest.py b/hwilib/devices/trezorlib/messages/PassphraseStateRequest.py deleted file mode 100644 index 92dd4e9da..000000000 --- a/hwilib/devices/trezorlib/messages/PassphraseStateRequest.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class PassphraseStateRequest(p.MessageType): - MESSAGE_WIRE_TYPE = 77 - - def __init__( - self, - state: bytes = None, - ) -> None: - self.state = state - - @classmethod - def get_fields(cls): - return { - 1: ('state', p.BytesType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/PinMatrixAck.py b/hwilib/devices/trezorlib/messages/PinMatrixAck.py deleted file mode 100644 index be2115585..000000000 --- a/hwilib/devices/trezorlib/messages/PinMatrixAck.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class PinMatrixAck(p.MessageType): - MESSAGE_WIRE_TYPE = 19 - - def __init__( - self, - pin: str = None, - ) -> None: - self.pin = pin - - @classmethod - def get_fields(cls): - return { - 1: ('pin', p.UnicodeType, 0), # required - } diff --git a/hwilib/devices/trezorlib/messages/PinMatrixRequest.py b/hwilib/devices/trezorlib/messages/PinMatrixRequest.py deleted file mode 100644 index db4433b35..000000000 --- a/hwilib/devices/trezorlib/messages/PinMatrixRequest.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class PinMatrixRequest(p.MessageType): - MESSAGE_WIRE_TYPE = 18 - - def __init__( - self, - type: int = None, - ) -> None: - self.type = type - - @classmethod - def get_fields(cls): - return { - 1: ('type', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/PinMatrixRequestType.py b/hwilib/devices/trezorlib/messages/PinMatrixRequestType.py deleted file mode 100644 index 2010103cb..000000000 --- a/hwilib/devices/trezorlib/messages/PinMatrixRequestType.py +++ /dev/null @@ -1,5 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -Current = 1 -NewFirst = 2 -NewSecond = 3 diff --git a/hwilib/devices/trezorlib/messages/Ping.py b/hwilib/devices/trezorlib/messages/Ping.py deleted file mode 100644 index cd8c71bea..000000000 --- a/hwilib/devices/trezorlib/messages/Ping.py +++ /dev/null @@ -1,28 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class Ping(p.MessageType): - MESSAGE_WIRE_TYPE = 1 - - def __init__( - self, - message: str = None, - button_protection: bool = None, - pin_protection: bool = None, - passphrase_protection: bool = None, - ) -> None: - self.message = message - self.button_protection = button_protection - self.pin_protection = pin_protection - self.passphrase_protection = passphrase_protection - - @classmethod - def get_fields(cls): - return { - 1: ('message', p.UnicodeType, 0), - 2: ('button_protection', p.BoolType, 0), - 3: ('pin_protection', p.BoolType, 0), - 4: ('passphrase_protection', p.BoolType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/PublicKey.py b/hwilib/devices/trezorlib/messages/PublicKey.py deleted file mode 100644 index 08061feb4..000000000 --- a/hwilib/devices/trezorlib/messages/PublicKey.py +++ /dev/null @@ -1,24 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .HDNodeType import HDNodeType - - -class PublicKey(p.MessageType): - MESSAGE_WIRE_TYPE = 12 - - def __init__( - self, - node: HDNodeType = None, - xpub: str = None, - ) -> None: - self.node = node - self.xpub = xpub - - @classmethod - def get_fields(cls): - return { - 1: ('node', HDNodeType, 0), # required - 2: ('xpub', p.UnicodeType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/RecoveryDevice.py b/hwilib/devices/trezorlib/messages/RecoveryDevice.py deleted file mode 100644 index 5b6bb8e97..000000000 --- a/hwilib/devices/trezorlib/messages/RecoveryDevice.py +++ /dev/null @@ -1,43 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class RecoveryDevice(p.MessageType): - MESSAGE_WIRE_TYPE = 45 - - def __init__( - self, - word_count: int = None, - passphrase_protection: bool = None, - pin_protection: bool = None, - language: str = None, - label: str = None, - enforce_wordlist: bool = None, - type: int = None, - u2f_counter: int = None, - dry_run: bool = None, - ) -> None: - self.word_count = word_count - self.passphrase_protection = passphrase_protection - self.pin_protection = pin_protection - self.language = language - self.label = label - self.enforce_wordlist = enforce_wordlist - self.type = type - self.u2f_counter = u2f_counter - self.dry_run = dry_run - - @classmethod - def get_fields(cls): - return { - 1: ('word_count', p.UVarintType, 0), - 2: ('passphrase_protection', p.BoolType, 0), - 3: ('pin_protection', p.BoolType, 0), - 4: ('language', p.UnicodeType, 0), # default=english - 5: ('label', p.UnicodeType, 0), - 6: ('enforce_wordlist', p.BoolType, 0), - 8: ('type', p.UVarintType, 0), - 9: ('u2f_counter', p.UVarintType, 0), - 10: ('dry_run', p.BoolType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/RecoveryDeviceType.py b/hwilib/devices/trezorlib/messages/RecoveryDeviceType.py deleted file mode 100644 index f6f643ac7..000000000 --- a/hwilib/devices/trezorlib/messages/RecoveryDeviceType.py +++ /dev/null @@ -1,4 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -ScrambledWords = 0 -Matrix = 1 diff --git a/hwilib/devices/trezorlib/messages/RequestType.py b/hwilib/devices/trezorlib/messages/RequestType.py deleted file mode 100644 index 4c122c944..000000000 --- a/hwilib/devices/trezorlib/messages/RequestType.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -TXINPUT = 0 -TXOUTPUT = 1 -TXMETA = 2 -TXFINISHED = 3 -TXEXTRADATA = 4 diff --git a/hwilib/devices/trezorlib/messages/ResetDevice.py b/hwilib/devices/trezorlib/messages/ResetDevice.py deleted file mode 100644 index 2087fa24c..000000000 --- a/hwilib/devices/trezorlib/messages/ResetDevice.py +++ /dev/null @@ -1,43 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class ResetDevice(p.MessageType): - MESSAGE_WIRE_TYPE = 14 - - def __init__( - self, - display_random: bool = None, - strength: int = None, - passphrase_protection: bool = None, - pin_protection: bool = None, - language: str = None, - label: str = None, - # u2f_counter: int = None, - # skip_backup: bool = None, - # no_backup: bool = None, - ) -> None: - self.display_random = display_random - self.strength = strength - self.passphrase_protection = passphrase_protection - self.pin_protection = pin_protection - self.language = language - self.label = label - # self.u2f_counter = u2f_counter - # self.skip_backup = skip_backup - # self.no_backup = no_backup - - @classmethod - def get_fields(cls): - return { - 1: ('display_random', p.BoolType, 0), - 2: ('strength', p.UVarintType, 0), # default=256 - 3: ('passphrase_protection', p.BoolType, 0), - 4: ('pin_protection', p.BoolType, 0), - 5: ('language', p.UnicodeType, 0), # default=english - 6: ('label', p.UnicodeType, 0), - # 7: ('u2f_counter', p.UVarintType, 0), - # 8: ('skip_backup', p.BoolType, 0), - # 9: ('no_backup', p.BoolType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/SelfTest.py b/hwilib/devices/trezorlib/messages/SelfTest.py deleted file mode 100644 index 4894ed541..000000000 --- a/hwilib/devices/trezorlib/messages/SelfTest.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class SelfTest(p.MessageType): - MESSAGE_WIRE_TYPE = 32 - - def __init__( - self, - payload: bytes = None, - ) -> None: - self.payload = payload - - @classmethod - def get_fields(cls): - return { - 1: ('payload', p.BytesType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/SignIdentity.py b/hwilib/devices/trezorlib/messages/SignIdentity.py deleted file mode 100644 index 0d4579a35..000000000 --- a/hwilib/devices/trezorlib/messages/SignIdentity.py +++ /dev/null @@ -1,30 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .IdentityType import IdentityType - - -class SignIdentity(p.MessageType): - MESSAGE_WIRE_TYPE = 53 - - def __init__( - self, - identity: IdentityType = None, - challenge_hidden: bytes = None, - challenge_visual: str = None, - ecdsa_curve_name: str = None, - ) -> None: - self.identity = identity - self.challenge_hidden = challenge_hidden - self.challenge_visual = challenge_visual - self.ecdsa_curve_name = ecdsa_curve_name - - @classmethod - def get_fields(cls): - return { - 1: ('identity', IdentityType, 0), - 2: ('challenge_hidden', p.BytesType, 0), - 3: ('challenge_visual', p.UnicodeType, 0), - 4: ('ecdsa_curve_name', p.UnicodeType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/SignMessage.py b/hwilib/devices/trezorlib/messages/SignMessage.py deleted file mode 100644 index 95cbb1595..000000000 --- a/hwilib/devices/trezorlib/messages/SignMessage.py +++ /dev/null @@ -1,34 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -if __debug__: - try: - from typing import List - except ImportError: - List = None # type: ignore - - -class SignMessage(p.MessageType): - MESSAGE_WIRE_TYPE = 38 - - def __init__( - self, - address_n: List[int] = None, - message: bytes = None, - coin_name: str = None, - script_type: int = None, - ) -> None: - self.address_n = address_n if address_n is not None else [] - self.message = message - self.coin_name = coin_name - self.script_type = script_type - - @classmethod - def get_fields(cls): - return { - 1: ('address_n', p.UVarintType, p.FLAG_REPEATED), - 2: ('message', p.BytesType, 0), # required - 3: ('coin_name', p.UnicodeType, 0), # default=Bitcoin - 4: ('script_type', p.UVarintType, 0), # default=SPENDADDRESS - } diff --git a/hwilib/devices/trezorlib/messages/SignTx.py b/hwilib/devices/trezorlib/messages/SignTx.py deleted file mode 100644 index db637f1d1..000000000 --- a/hwilib/devices/trezorlib/messages/SignTx.py +++ /dev/null @@ -1,43 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class SignTx(p.MessageType): - MESSAGE_WIRE_TYPE = 15 - - def __init__( - self, - outputs_count: int = None, - inputs_count: int = None, - coin_name: str = None, - version: int = None, - lock_time: int = None, - expiry: int = None, - overwintered: bool = None, - version_group_id: int = None, - timestamp: int = None, - ) -> None: - self.outputs_count = outputs_count - self.inputs_count = inputs_count - self.coin_name = coin_name - self.version = version - self.lock_time = lock_time - self.expiry = expiry - self.overwintered = overwintered - self.version_group_id = version_group_id - self.timestamp = timestamp - - @classmethod - def get_fields(cls): - return { - 1: ('outputs_count', p.UVarintType, 0), # required - 2: ('inputs_count', p.UVarintType, 0), # required - 3: ('coin_name', p.UnicodeType, 0), # default=Bitcoin - 4: ('version', p.UVarintType, 0), # default=1 - 5: ('lock_time', p.UVarintType, 0), # default=0 - 6: ('expiry', p.UVarintType, 0), - 7: ('overwintered', p.BoolType, 0), - 8: ('version_group_id', p.UVarintType, 0), - 9: ('timestamp', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/SignedIdentity.py b/hwilib/devices/trezorlib/messages/SignedIdentity.py deleted file mode 100644 index 25bc39277..000000000 --- a/hwilib/devices/trezorlib/messages/SignedIdentity.py +++ /dev/null @@ -1,25 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class SignedIdentity(p.MessageType): - MESSAGE_WIRE_TYPE = 54 - - def __init__( - self, - address: str = None, - public_key: bytes = None, - signature: bytes = None, - ) -> None: - self.address = address - self.public_key = public_key - self.signature = signature - - @classmethod - def get_fields(cls): - return { - 1: ('address', p.UnicodeType, 0), - 2: ('public_key', p.BytesType, 0), - 3: ('signature', p.BytesType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/Success.py b/hwilib/devices/trezorlib/messages/Success.py deleted file mode 100644 index ec91c8217..000000000 --- a/hwilib/devices/trezorlib/messages/Success.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class Success(p.MessageType): - MESSAGE_WIRE_TYPE = 2 - - def __init__( - self, - message: str = None, - ) -> None: - self.message = message - - @classmethod - def get_fields(cls): - return { - 1: ('message', p.UnicodeType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/TransactionType.py b/hwilib/devices/trezorlib/messages/TransactionType.py deleted file mode 100644 index 96d25aa41..000000000 --- a/hwilib/devices/trezorlib/messages/TransactionType.py +++ /dev/null @@ -1,64 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .TxInputType import TxInputType -from .TxOutputBinType import TxOutputBinType -from .TxOutputType import TxOutputType - -if __debug__: - try: - from typing import List - except ImportError: - List = None # type: ignore - - -class TransactionType(p.MessageType): - - def __init__( - self, - version: int = None, - inputs: List[TxInputType] = None, - bin_outputs: List[TxOutputBinType] = None, - lock_time: int = None, - outputs: List[TxOutputType] = None, - inputs_cnt: int = None, - outputs_cnt: int = None, - extra_data: bytes = None, - extra_data_len: int = None, - expiry: int = None, - overwintered: bool = None, - version_group_id: int = None, - timestamp: int = None, - ) -> None: - self.version = version - self.inputs = inputs if inputs is not None else [] - self.bin_outputs = bin_outputs if bin_outputs is not None else [] - self.lock_time = lock_time - self.outputs = outputs if outputs is not None else [] - self.inputs_cnt = inputs_cnt - self.outputs_cnt = outputs_cnt - self.extra_data = extra_data - self.extra_data_len = extra_data_len - self.expiry = expiry - self.overwintered = overwintered - self.version_group_id = version_group_id - self.timestamp = timestamp - - @classmethod - def get_fields(cls): - return { - 1: ('version', p.UVarintType, 0), - 2: ('inputs', TxInputType, p.FLAG_REPEATED), - 3: ('bin_outputs', TxOutputBinType, p.FLAG_REPEATED), - 4: ('lock_time', p.UVarintType, 0), - 5: ('outputs', TxOutputType, p.FLAG_REPEATED), - 6: ('inputs_cnt', p.UVarintType, 0), - 7: ('outputs_cnt', p.UVarintType, 0), - 8: ('extra_data', p.BytesType, 0), - 9: ('extra_data_len', p.UVarintType, 0), - 10: ('expiry', p.UVarintType, 0), - 11: ('overwintered', p.BoolType, 0), - 12: ('version_group_id', p.UVarintType, 0), - 13: ('timestamp', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/TxAck.py b/hwilib/devices/trezorlib/messages/TxAck.py deleted file mode 100644 index 8594b77c1..000000000 --- a/hwilib/devices/trezorlib/messages/TxAck.py +++ /dev/null @@ -1,21 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .TransactionType import TransactionType - - -class TxAck(p.MessageType): - MESSAGE_WIRE_TYPE = 22 - - def __init__( - self, - tx: TransactionType = None, - ) -> None: - self.tx = tx - - @classmethod - def get_fields(cls): - return { - 1: ('tx', TransactionType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/TxInputType.py b/hwilib/devices/trezorlib/messages/TxInputType.py deleted file mode 100644 index 0f1c97d25..000000000 --- a/hwilib/devices/trezorlib/messages/TxInputType.py +++ /dev/null @@ -1,59 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .MultisigRedeemScriptType import MultisigRedeemScriptType - -if __debug__: - try: - from typing import List - except ImportError: - List = None # type: ignore - - -class TxInputType(p.MessageType): - - def __init__( - self, - address_n: List[int] = None, - prev_hash: bytes = None, - prev_index: int = None, - script_sig: bytes = None, - sequence: int = None, - script_type: int = None, - multisig: MultisigRedeemScriptType = None, - amount: int = None, - decred_tree: int = None, - decred_script_version: int = None, - prev_block_hash_bip115: bytes = None, - prev_block_height_bip115: int = None, - ) -> None: - self.address_n = address_n if address_n is not None else [] - self.prev_hash = prev_hash - self.prev_index = prev_index - self.script_sig = script_sig - self.sequence = sequence - self.script_type = script_type - self.multisig = multisig - self.amount = amount - self.decred_tree = decred_tree - self.decred_script_version = decred_script_version - self.prev_block_hash_bip115 = prev_block_hash_bip115 - self.prev_block_height_bip115 = prev_block_height_bip115 - - @classmethod - def get_fields(cls): - return { - 1: ('address_n', p.UVarintType, p.FLAG_REPEATED), - 2: ('prev_hash', p.BytesType, 0), # required - 3: ('prev_index', p.UVarintType, 0), # required - 4: ('script_sig', p.BytesType, 0), - 5: ('sequence', p.UVarintType, 0), # default=4294967295 - 6: ('script_type', p.UVarintType, 0), # default=SPENDADDRESS - 7: ('multisig', MultisigRedeemScriptType, 0), - 8: ('amount', p.UVarintType, 0), - 9: ('decred_tree', p.UVarintType, 0), - 10: ('decred_script_version', p.UVarintType, 0), - 11: ('prev_block_hash_bip115', p.BytesType, 0), - 12: ('prev_block_height_bip115', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/TxOutputBinType.py b/hwilib/devices/trezorlib/messages/TxOutputBinType.py deleted file mode 100644 index c979abdfc..000000000 --- a/hwilib/devices/trezorlib/messages/TxOutputBinType.py +++ /dev/null @@ -1,24 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class TxOutputBinType(p.MessageType): - - def __init__( - self, - amount: int = None, - script_pubkey: bytes = None, - decred_script_version: int = None, - ) -> None: - self.amount = amount - self.script_pubkey = script_pubkey - self.decred_script_version = decred_script_version - - @classmethod - def get_fields(cls): - return { - 1: ('amount', p.UVarintType, 0), # required - 2: ('script_pubkey', p.BytesType, 0), # required - 3: ('decred_script_version', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/TxOutputType.py b/hwilib/devices/trezorlib/messages/TxOutputType.py deleted file mode 100644 index fdc226e85..000000000 --- a/hwilib/devices/trezorlib/messages/TxOutputType.py +++ /dev/null @@ -1,50 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .MultisigRedeemScriptType import MultisigRedeemScriptType - -if __debug__: - try: - from typing import List - except ImportError: - List = None # type: ignore - - -class TxOutputType(p.MessageType): - - def __init__( - self, - address: str = None, - address_n: List[int] = None, - amount: int = None, - script_type: int = None, - multisig: MultisigRedeemScriptType = None, - op_return_data: bytes = None, - decred_script_version: int = None, - block_hash_bip115: bytes = None, - block_height_bip115: int = None, - ) -> None: - self.address = address - self.address_n = address_n if address_n is not None else [] - self.amount = amount - self.script_type = script_type - self.multisig = multisig - self.op_return_data = op_return_data - self.decred_script_version = decred_script_version - self.block_hash_bip115 = block_hash_bip115 - self.block_height_bip115 = block_height_bip115 - - @classmethod - def get_fields(cls): - return { - 1: ('address', p.UnicodeType, 0), - 2: ('address_n', p.UVarintType, p.FLAG_REPEATED), - 3: ('amount', p.UVarintType, 0), # required - 4: ('script_type', p.UVarintType, 0), # required - 5: ('multisig', MultisigRedeemScriptType, 0), - 6: ('op_return_data', p.BytesType, 0), - 7: ('decred_script_version', p.UVarintType, 0), - 8: ('block_hash_bip115', p.BytesType, 0), - 9: ('block_height_bip115', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/TxRequest.py b/hwilib/devices/trezorlib/messages/TxRequest.py deleted file mode 100644 index b619882a9..000000000 --- a/hwilib/devices/trezorlib/messages/TxRequest.py +++ /dev/null @@ -1,28 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - -from .TxRequestDetailsType import TxRequestDetailsType -from .TxRequestSerializedType import TxRequestSerializedType - - -class TxRequest(p.MessageType): - MESSAGE_WIRE_TYPE = 21 - - def __init__( - self, - request_type: int = None, - details: TxRequestDetailsType = None, - serialized: TxRequestSerializedType = None, - ) -> None: - self.request_type = request_type - self.details = details - self.serialized = serialized - - @classmethod - def get_fields(cls): - return { - 1: ('request_type', p.UVarintType, 0), - 2: ('details', TxRequestDetailsType, 0), - 3: ('serialized', TxRequestSerializedType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/TxRequestDetailsType.py b/hwilib/devices/trezorlib/messages/TxRequestDetailsType.py deleted file mode 100644 index 6407b48f8..000000000 --- a/hwilib/devices/trezorlib/messages/TxRequestDetailsType.py +++ /dev/null @@ -1,27 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class TxRequestDetailsType(p.MessageType): - - def __init__( - self, - request_index: int = None, - tx_hash: bytes = None, - extra_data_len: int = None, - extra_data_offset: int = None, - ) -> None: - self.request_index = request_index - self.tx_hash = tx_hash - self.extra_data_len = extra_data_len - self.extra_data_offset = extra_data_offset - - @classmethod - def get_fields(cls): - return { - 1: ('request_index', p.UVarintType, 0), - 2: ('tx_hash', p.BytesType, 0), - 3: ('extra_data_len', p.UVarintType, 0), - 4: ('extra_data_offset', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/TxRequestSerializedType.py b/hwilib/devices/trezorlib/messages/TxRequestSerializedType.py deleted file mode 100644 index c09b496ff..000000000 --- a/hwilib/devices/trezorlib/messages/TxRequestSerializedType.py +++ /dev/null @@ -1,24 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class TxRequestSerializedType(p.MessageType): - - def __init__( - self, - signature_index: int = None, - signature: bytes = None, - serialized_tx: bytes = None, - ) -> None: - self.signature_index = signature_index - self.signature = signature - self.serialized_tx = serialized_tx - - @classmethod - def get_fields(cls): - return { - 1: ('signature_index', p.UVarintType, 0), - 2: ('signature', p.BytesType, 0), - 3: ('serialized_tx', p.BytesType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/VerifyMessage.py b/hwilib/devices/trezorlib/messages/VerifyMessage.py deleted file mode 100644 index 58447dac3..000000000 --- a/hwilib/devices/trezorlib/messages/VerifyMessage.py +++ /dev/null @@ -1,28 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class VerifyMessage(p.MessageType): - MESSAGE_WIRE_TYPE = 39 - - def __init__( - self, - address: str = None, - signature: bytes = None, - message: bytes = None, - coin_name: str = None, - ) -> None: - self.address = address - self.signature = signature - self.message = message - self.coin_name = coin_name - - @classmethod - def get_fields(cls): - return { - 1: ('address', p.UnicodeType, 0), - 2: ('signature', p.BytesType, 0), - 3: ('message', p.BytesType, 0), - 4: ('coin_name', p.UnicodeType, 0), # default=Bitcoin - } diff --git a/hwilib/devices/trezorlib/messages/WipeDevice.py b/hwilib/devices/trezorlib/messages/WipeDevice.py deleted file mode 100644 index f695def18..000000000 --- a/hwilib/devices/trezorlib/messages/WipeDevice.py +++ /dev/null @@ -1,7 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class WipeDevice(p.MessageType): - MESSAGE_WIRE_TYPE = 5 diff --git a/hwilib/devices/trezorlib/messages/WordAck.py b/hwilib/devices/trezorlib/messages/WordAck.py deleted file mode 100644 index 1ef36958a..000000000 --- a/hwilib/devices/trezorlib/messages/WordAck.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class WordAck(p.MessageType): - MESSAGE_WIRE_TYPE = 47 - - def __init__( - self, - word: str = None, - ) -> None: - self.word = word - - @classmethod - def get_fields(cls): - return { - 1: ('word', p.UnicodeType, 0), # required - } diff --git a/hwilib/devices/trezorlib/messages/WordRequest.py b/hwilib/devices/trezorlib/messages/WordRequest.py deleted file mode 100644 index 7c14830db..000000000 --- a/hwilib/devices/trezorlib/messages/WordRequest.py +++ /dev/null @@ -1,19 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -from .. import protobuf as p - - -class WordRequest(p.MessageType): - MESSAGE_WIRE_TYPE = 46 - - def __init__( - self, - type: int = None, - ) -> None: - self.type = type - - @classmethod - def get_fields(cls): - return { - 1: ('type', p.UVarintType, 0), - } diff --git a/hwilib/devices/trezorlib/messages/WordRequestType.py b/hwilib/devices/trezorlib/messages/WordRequestType.py deleted file mode 100644 index a284fac9c..000000000 --- a/hwilib/devices/trezorlib/messages/WordRequestType.py +++ /dev/null @@ -1,5 +0,0 @@ -# Automatically generated by pb2py -# fmt: off -Plain = 0 -Matrix9 = 1 -Matrix6 = 2 diff --git a/hwilib/devices/trezorlib/messages/__init__.py b/hwilib/devices/trezorlib/messages/__init__.py deleted file mode 100644 index d01eb4f7f..000000000 --- a/hwilib/devices/trezorlib/messages/__init__.py +++ /dev/null @@ -1,78 +0,0 @@ -# Automatically generated by pb2py -# fmt: off - -from .Address import Address -from .ApplyFlags import ApplyFlags -from .ApplySettings import ApplySettings -from .BackupDevice import BackupDevice -from .ButtonAck import ButtonAck -from .ButtonRequest import ButtonRequest -from .Cancel import Cancel -from .ChangePin import ChangePin -from .ClearSession import ClearSession -from .DebugLinkDecision import DebugLinkDecision -from .DebugLinkFlashErase import DebugLinkFlashErase -from .DebugLinkGetState import DebugLinkGetState -from .DebugLinkLog import DebugLinkLog -from .DebugLinkMemory import DebugLinkMemory -from .DebugLinkMemoryRead import DebugLinkMemoryRead -from .DebugLinkMemoryWrite import DebugLinkMemoryWrite -from .DebugLinkState import DebugLinkState -from .DebugLinkStop import DebugLinkStop -from .Entropy import Entropy -from .EntropyAck import EntropyAck -from .EntropyRequest import EntropyRequest -from .Failure import Failure -from .Features import Features -from .FirmwareErase import FirmwareErase -from .FirmwareRequest import FirmwareRequest -from .FirmwareUpload import FirmwareUpload -from .GetAddress import GetAddress -from .GetEntropy import GetEntropy -from .GetFeatures import GetFeatures -from .GetPublicKey import GetPublicKey -from .HDNodePathType import HDNodePathType -from .HDNodeType import HDNodeType -from .IdentityType import IdentityType -from .Initialize import Initialize -from .LoadDevice import LoadDevice -from .MessageSignature import MessageSignature -from .MultisigRedeemScriptType import MultisigRedeemScriptType -from .PassphraseAck import PassphraseAck -from .PassphraseRequest import PassphraseRequest -from .PassphraseStateAck import PassphraseStateAck -from .PassphraseStateRequest import PassphraseStateRequest -from .PinMatrixAck import PinMatrixAck -from .PinMatrixRequest import PinMatrixRequest -from .Ping import Ping -from .PublicKey import PublicKey -from .RecoveryDevice import RecoveryDevice -from .ResetDevice import ResetDevice -from .SelfTest import SelfTest -from .SignIdentity import SignIdentity -from .SignMessage import SignMessage -from .SignTx import SignTx -from .SignedIdentity import SignedIdentity -from .Success import Success -from .TransactionType import TransactionType -from .TxAck import TxAck -from .TxInputType import TxInputType -from .TxOutputBinType import TxOutputBinType -from .TxOutputType import TxOutputType -from .TxRequest import TxRequest -from .TxRequestDetailsType import TxRequestDetailsType -from .TxRequestSerializedType import TxRequestSerializedType -from .VerifyMessage import VerifyMessage -from .WipeDevice import WipeDevice -from .WordAck import WordAck -from .WordRequest import WordRequest -from . import ButtonRequestType -from . import FailureType -from . import InputScriptType -from . import MessageType -from . import OutputScriptType -from . import PassphraseSourceType -from . import PinMatrixRequestType -from . import RecoveryDeviceType -from . import RequestType -from . import WordRequestType diff --git a/hwilib/devices/trezorlib/models.py b/hwilib/devices/trezorlib/models.py new file mode 100644 index 000000000..183dfb3eb --- /dev/null +++ b/hwilib/devices/trezorlib/models.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from typing import Collection, Optional, Tuple + +from . import mapping + +UsbId = Tuple[int, int] + +VENDORS = ("bitcointrezor.com", "trezor.io") + + +@dataclass(eq=True, frozen=True) +class TrezorModel: + name: str + minimum_version: Tuple[int, int, int] + vendors: Collection[str] + usb_ids: Collection[UsbId] + default_mapping: mapping.ProtobufMapping + + +TREZOR_ONE = TrezorModel( + name="1", + minimum_version=(1, 8, 0), + vendors=VENDORS, + usb_ids=((0x534C, 0x0001),), + default_mapping=mapping.DEFAULT_MAPPING, +) + +TREZOR_T = TrezorModel( + name="T", + minimum_version=(2, 1, 0), + vendors=VENDORS, + usb_ids=((0x1209, 0x53C1), (0x1209, 0x53C0)), + default_mapping=mapping.DEFAULT_MAPPING, +) + +TREZORS = {TREZOR_ONE, TREZOR_T} + + +def by_name(name: str) -> Optional[TrezorModel]: + for model in TREZORS: + if model.name == name: + return model + return None diff --git a/hwilib/devices/trezorlib/protobuf.py b/hwilib/devices/trezorlib/protobuf.py index c082e41af..ea8215134 100644 --- a/hwilib/devices/trezorlib/protobuf.py +++ b/hwilib/devices/trezorlib/protobuf.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2022 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -14,54 +14,78 @@ # You should have received a copy of the License along with this library. # If not, see . -''' -Extremely minimal streaming codec for a subset of protobuf. Supports uint32, -bytes, string, embedded message and repeated fields. +""" +Extremely minimal streaming codec for a subset of protobuf. +Supports uint32, bytes, string, embedded message and repeated fields. -For de-sererializing (loading) protobuf types, object with `Reader` -interface is required: +For de-serializing (loading) protobuf types, object with `Reader` interface is required. +For serializing (dumping) protobuf types, object with `Writer` interface is required. +""" ->>> class Reader: ->>> def readinto(self, buffer): ->>> """ ->>> Reads `len(buffer)` bytes into `buffer`, or raises `EOFError`. ->>> """ +import logging +import warnings +from dataclasses import dataclass +from enum import IntEnum +from io import BytesIO +from itertools import zip_longest +from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union -For serializing (dumping) protobuf types, object with `Writer` interface is -required: +from typing_extensions import Protocol, TypeGuard ->>> class Writer: ->>> def write(self, buffer): ->>> """ ->>> Writes all bytes from `buffer`, or raises `EOFError`. ->>> """ -''' +T = TypeVar("T", bound=type) +MT = TypeVar("MT", bound="MessageType") + + +class Reader(Protocol): + def readinto(self, __buf: bytearray) -> int: + """ + Reads exactly `len(buffer)` bytes into `buffer`. Returns number of bytes read, + or 0 if it cannot read that much. + """ + ... + + +class Writer(Protocol): + def write(self, __buf: bytes) -> int: + """ + Writes all bytes from `buffer`, or raises `EOFError` + """ + ... -from io import BytesIO -from typing import Any, Optional _UVARINT_BUFFER = bytearray(1) +LOG = logging.getLogger(__name__) + + +def safe_issubclass(value: Any, cls: Union[T, Tuple[T, ...]]) -> TypeGuard[T]: + return isinstance(value, type) and issubclass(value, cls) -def load_uvarint(reader): + +def load_uvarint(reader: Reader) -> int: buffer = _UVARINT_BUFFER result = 0 shift = 0 byte = 0x80 + bytes_read = 0 while byte & 0x80: if reader.readinto(buffer) == 0: - raise EOFError + if bytes_read > 0: + raise IOError("Interrupted UVarint") + else: + raise EOFError + bytes_read += 1 byte = buffer[0] result += (byte & 0x7F) << shift shift += 7 return result -def dump_uvarint(writer, n): +def dump_uvarint(writer: Writer, n: int) -> None: if n < 0: raise ValueError("Cannot dump signed value, convert it to unsigned first.") buffer = _UVARINT_BUFFER - shifted = True + shifted = 1 while shifted: shifted = n >> 7 buffer[0] = (n & 0x7F) | (0x80 if shifted else 0x00) @@ -89,14 +113,14 @@ def dump_uvarint(writer, n): # So we have to branch on whether the number is negative. -def sint_to_uint(sint): +def sint_to_uint(sint: int) -> int: res = sint << 1 if sint < 0: res = ~res return res -def uint_to_sint(uint): +def uint_to_sint(uint: int) -> int: sign = uint & 1 res = uint >> 1 if sign: @@ -104,84 +128,136 @@ def uint_to_sint(uint): return res -class UVarintType: - WIRE_TYPE = 0 +WIRE_TYPE_INT = 0 +WIRE_TYPE_LENGTH = 2 +WIRE_TYPES = { + "uint32": WIRE_TYPE_INT, + "uint64": WIRE_TYPE_INT, + "sint32": WIRE_TYPE_INT, + "sint64": WIRE_TYPE_INT, + "bool": WIRE_TYPE_INT, + "bytes": WIRE_TYPE_LENGTH, + "string": WIRE_TYPE_LENGTH, +} -class SVarintType: - WIRE_TYPE = 0 +REQUIRED_FIELD_PLACEHOLDER = object() -class BoolType: - WIRE_TYPE = 0 +@dataclass +class Field: + name: str + type: str + repeated: bool = False + required: bool = False + default: object = None + @property + def wire_type(self) -> int: + if self.type in WIRE_TYPES: + return WIRE_TYPES[self.type] -class BytesType: - WIRE_TYPE = 2 + field_type_object = get_field_type_object(self) + if safe_issubclass(field_type_object, MessageType): + return WIRE_TYPE_LENGTH + if safe_issubclass(field_type_object, IntEnum): + return WIRE_TYPE_INT -class UnicodeType: - WIRE_TYPE = 2 + raise ValueError(f"Unrecognized type for field {self.name}") + def value_fits(self, value: int) -> bool: + if self.type == "uint32": + return 0 <= value < 2 ** 32 + if self.type == "uint64": + return 0 <= value < 2 ** 64 + if self.type == "sint32": + return -(2 ** 31) <= value < 2 ** 31 + if self.type == "sint64": + return -(2 ** 63) <= value < 2 ** 63 -class MessageType: - WIRE_TYPE = 2 + raise ValueError(f"Cannot check range bounds for {self.type}") - @classmethod - def get_fields(cls): - return {} - def __init__(self, **kwargs): - for kw in kwargs: - setattr(self, kw, kwargs[kw]) - self._fill_missing() +class _MessageTypeMeta(type): + def __init__(cls, name: str, bases: tuple, d: dict) -> None: + super().__init__(name, bases, d) # type: ignore [Expected 1 positional] + #if name != "MessageType": + # cls.__init__ = MessageType.__init__ # type: ignore [Cannot assign member "__init__" for type "_MessageTypeMeta"] + + +class MessageType(metaclass=_MessageTypeMeta): + MESSAGE_WIRE_TYPE: Optional[int] = None + UNSTABLE: bool = False + + FIELDS: Dict[int, Field] = {} + + @classmethod + def get_field(cls, name: str) -> Optional[Field]: + return next((f for f in cls.FIELDS.values() if f.name == name), None) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + if args: + warnings.warn( + "Positional arguments for MessageType are deprecated", + DeprecationWarning, + stacklevel=2, + ) + # process fields one by one + MISSING = object() + for field, val in zip_longest(self.FIELDS.values(), args, fillvalue=MISSING): + if field is MISSING: + raise TypeError("too many positional arguments") + if field.name in kwargs and val is not MISSING: + # both *args and **kwargs specify the same thing + raise TypeError(f"got multiple values for argument '{field.name}'") + elif field.name in kwargs: + # set in kwargs but not in args + setattr(self, field.name, kwargs[field.name]) + elif val is not MISSING: + # set in args but not in kwargs + setattr(self, field.name, val) + else: + default: Any + # not set at all, pick a default + if field.repeated: + default = [] + elif field.required: + warnings.warn( + f"Value of required field '{field.name}' must be provided in constructor", + DeprecationWarning, + stacklevel=2, + ) + default = REQUIRED_FIELD_PLACEHOLDER + else: + default = field.default + setattr(self, field.name, default) - def __eq__(self, rhs): + def __eq__(self, rhs: Any) -> bool: return self.__class__ is rhs.__class__ and self.__dict__ == rhs.__dict__ - def __repr__(self): + def __repr__(self) -> str: d = {} for key, value in self.__dict__.items(): if value is None or value == []: continue d[key] = value - return "<%s: %s>" % (self.__class__.__name__, d) - - def __iter__(self): - return iter(self.keys()) - - def keys(self): - return (name for name, _, _ in self.get_fields().values()) + return f"<{self.__class__.__name__}: {d}>" - def __getitem__(self, key): - return getattr(self, key) - - def _fill_missing(self): - # fill missing fields - for fname, ftype, fflags in self.get_fields().values(): - if not hasattr(self, fname): - if fflags & FLAG_REPEATED: - setattr(self, fname, []) - else: - setattr(self, fname, None) - - def CopyFrom(self, obj): - self.__dict__ = obj.__dict__.copy() - - def ByteSize(self): + def ByteSize(self) -> int: data = BytesIO() dump_message(data, self) return len(data.getvalue()) class LimitedReader: - def __init__(self, reader, limit): + def __init__(self, reader: Reader, limit: int) -> None: self.reader = reader self.limit = limit - def readinto(self, buf): + def readinto(self, buf: bytearray) -> int: if self.limit < len(buf): - raise EOFError + return 0 else: nread = self.reader.readinto(buf) self.limit -= nread @@ -189,21 +265,102 @@ def readinto(self, buf): class CountingWriter: - def __init__(self): + def __init__(self) -> None: self.size = 0 - def write(self, buf): + def write(self, buf: bytes) -> int: nwritten = len(buf) self.size += nwritten return nwritten -FLAG_REPEATED = 1 +def get_field_type_object( + field: Field, +) -> Optional[Union[Type[MessageType], Type[IntEnum]]]: + from . import messages + + field_type_object = getattr(messages, field.type, None) + if not safe_issubclass(field_type_object, (IntEnum, MessageType)): + return None + return field_type_object + + +def decode_packed_array_field(field: Field, reader: Reader) -> List[Any]: + assert field.repeated, "Not decoding packed array into non-repeated field" + length = load_uvarint(reader) + packed_reader = LimitedReader(reader, length) + values = [] + try: + while True: + values.append(decode_varint_field(field, packed_reader)) + except EOFError: + pass + return values + + +def decode_varint_field(field: Field, reader: Reader) -> Union[int, bool, IntEnum]: + assert field.wire_type == WIRE_TYPE_INT, f"Field {field.name} is not varint-encoded" + value = load_uvarint(reader) + + field_type_object = get_field_type_object(field) + if safe_issubclass(field_type_object, IntEnum): + try: + return field_type_object(value) + except ValueError as e: + # treat enum errors as warnings + LOG.info(f"On field {field.name}: {e}") + return value + + if field.type.startswith("uint"): + if not field.value_fits(value): + LOG.info( + f"On field {field.name}: value {value} out of range for {field.type}" + ) + return value + + if field.type.startswith("sint"): + value = uint_to_sint(value) + if not field.value_fits(value): + LOG.info( + f"On field {field.name}: value {value} out of range for {field.type}" + ) + return value + + if field.type == "bool": + return bool(value) + + raise TypeError # not a varint field or unknown type + + +def decode_length_delimited_field( + field: Field, reader: Reader +) -> Union[bytes, str, MessageType]: + value = load_uvarint(reader) + if field.type == "bytes": + buf = bytearray(value) + reader.readinto(buf) + return bytes(buf) + + if field.type == "string": + buf = bytearray(value) + reader.readinto(buf) + return buf.decode() + + field_type_object = get_field_type_object(field) + if safe_issubclass(field_type_object, MessageType): + return load_message(LimitedReader(reader, value), field_type_object) + raise TypeError # field type is unknown -def load_message(reader, msg_type): - fields = msg_type.get_fields() - msg = msg_type() + +def load_message(reader: Reader, msg_type: Type[MT]) -> MT: + msg_dict: Dict[str, Any] = {} + # pre-seed the dict + for field in msg_type.FIELDS.values(): + if field.repeated: + msg_dict[field.name] = [] + elif not field.required: + msg_dict[field.name] = field.default while True: try: @@ -214,138 +371,163 @@ def load_message(reader, msg_type): ftag = fkey >> 3 wtype = fkey & 7 - field = fields.get(ftag, None) - - if field is None: # unknown field, skip it - if wtype == 0: + if ftag not in msg_type.FIELDS: # unknown field, skip it + if wtype == WIRE_TYPE_INT: load_uvarint(reader) - elif wtype == 2: + elif wtype == WIRE_TYPE_LENGTH: ivalue = load_uvarint(reader) reader.readinto(bytearray(ivalue)) else: raise ValueError continue - fname, ftype, fflags = field - if wtype != ftype.WIRE_TYPE: - raise TypeError # parsed wire type differs from the schema - - ivalue = load_uvarint(reader) - - if ftype is UVarintType: - fvalue = ivalue - elif ftype is SVarintType: - fvalue = uint_to_sint(ivalue) - elif ftype is BoolType: - fvalue = bool(ivalue) - elif ftype is BytesType: - buf = bytearray(ivalue) - reader.readinto(buf) - fvalue = bytes(buf) - elif ftype is UnicodeType: - buf = bytearray(ivalue) - reader.readinto(buf) - fvalue = buf.decode() - elif issubclass(ftype, MessageType): - fvalue = load_message(LimitedReader(reader, ivalue), ftype) + field = msg_type.FIELDS[ftag] + + if ( + wtype == WIRE_TYPE_LENGTH + and field.wire_type == WIRE_TYPE_INT + and field.repeated + ): + # packed array + fvalues = decode_packed_array_field(field, reader) + + elif wtype != field.wire_type: + raise ValueError(f"Field {field.name} received value does not match schema") + + elif wtype == WIRE_TYPE_LENGTH: + fvalues = [decode_length_delimited_field(field, reader)] + + elif wtype == WIRE_TYPE_INT: + fvalues = [decode_varint_field(field, reader)] + else: - raise TypeError # field type is unknown + raise TypeError # unknown wire type - if fflags & FLAG_REPEATED: - pvalue = getattr(msg, fname) - pvalue.append(fvalue) - fvalue = pvalue - setattr(msg, fname, fvalue) + if field.repeated: + msg_dict[field.name].extend(fvalues) + elif len(fvalues) != 1: + raise ValueError("Unexpected multiple values in non-repeating field") + else: + msg_dict[field.name] = fvalues[0] - return msg + for field in msg_type.FIELDS.values(): + if field.required and field.name not in msg_dict: + raise ValueError(f"Did not receive value for field {field.name}") + return msg_type(**msg_dict) -def dump_message(writer, msg): +def dump_message(writer: Writer, msg: "MessageType") -> None: repvalue = [0] mtype = msg.__class__ - fields = mtype.get_fields() - for ftag in fields: - fname, ftype, fflags = fields[ftag] + for ftag, field in mtype.FIELDS.items(): + fvalue = getattr(msg, field.name, None) + + if fvalue is REQUIRED_FIELD_PLACEHOLDER: + raise ValueError(f"Required value of field {field.name} was not provided") - fvalue = getattr(msg, fname, None) if fvalue is None: + # not sending empty values continue - fkey = (ftag << 3) | ftype.WIRE_TYPE + fkey = (ftag << 3) | field.wire_type - if not fflags & FLAG_REPEATED: + if not field.repeated: repvalue[0] = fvalue fvalue = repvalue for svalue in fvalue: dump_uvarint(writer, fkey) - if ftype is UVarintType: + field_type_object = get_field_type_object(field) + if safe_issubclass(field_type_object, MessageType): + if not isinstance(svalue, field_type_object): + raise ValueError( + f"Value {svalue} in field {field.name} is not {field_type_object.__name__}" + ) + counter = CountingWriter() + dump_message(counter, svalue) + dump_uvarint(writer, counter.size) + dump_message(writer, svalue) + + elif safe_issubclass(field_type_object, IntEnum): + if svalue not in field_type_object.__members__.values(): + raise ValueError( + f"Value {svalue} in field {field.name} unknown for {field.type}" + ) dump_uvarint(writer, svalue) - elif ftype is SVarintType: + elif field.type.startswith("uint"): + if not field.value_fits(svalue): + raise ValueError( + f"Value {svalue} in field {field.name} does not fit into {field.type}" + ) + dump_uvarint(writer, svalue) + + elif field.type.startswith("sint"): + if not field.value_fits(svalue): + raise ValueError( + f"Value {svalue} in field {field.name} does not fit into {field.type}" + ) dump_uvarint(writer, sint_to_uint(svalue)) - elif ftype is BoolType: + elif field.type == "bool": dump_uvarint(writer, int(svalue)) - elif ftype is BytesType: - dump_uvarint(writer, len(svalue)) - writer.write(svalue) - - elif ftype is UnicodeType: - if not isinstance(svalue, bytes): - svalue = svalue.encode() - + elif field.type == "bytes": + assert isinstance(svalue, (bytes, bytearray)) dump_uvarint(writer, len(svalue)) writer.write(svalue) - elif issubclass(ftype, MessageType): - counter = CountingWriter() - dump_message(counter, svalue) - dump_uvarint(writer, counter.size) - dump_message(writer, svalue) + elif field.type == "string": + assert isinstance(svalue, str) + svalue_bytes = svalue.encode() + dump_uvarint(writer, len(svalue_bytes)) + writer.write(svalue_bytes) else: raise TypeError def format_message( - pb: MessageType, + pb: "MessageType", indent: int = 0, sep: str = " " * 4, truncate_after: Optional[int] = 256, truncate_to: Optional[int] = 64, ) -> str: - def mostly_printable(bytes): + def mostly_printable(bytes: bytes) -> bool: if not bytes: return True printable = sum(1 for byte in bytes if 0x20 <= byte <= 0x7E) return printable / len(bytes) > 0.8 - def pformat_value(value: Any, indent: int) -> str: + def pformat(name: str, value: Any, indent: int) -> str: level = sep * indent leadin = sep * (indent + 1) + if isinstance(value, MessageType): return format_message(value, indent, sep) + if isinstance(value, list): # short list of simple values - if not value or not isinstance(value[0], MessageType): + if not value or all(isinstance(x, int) for x in value): return repr(value) # long list, one line per entry lines = ["[", level + "]"] - lines[1:1] = [leadin + pformat_value(x, indent + 1) + "," for x in value] + lines[1:1] = [leadin + pformat(name, x, indent + 1) + "," for x in value] return "\n".join(lines) + if isinstance(value, dict): lines = ["{"] for key, val in sorted(value.items()): if val is None or val == []: continue - lines.append(leadin + key + ": " + pformat_value(val, indent + 1) + ",") + lines.append(leadin + key + ": " + pformat(key, val, indent + 1) + ",") lines.append(level + "}") return "\n".join(lines) + if isinstance(value, (bytes, bytearray)): length = len(value) suffix = "" @@ -356,70 +538,98 @@ def pformat_value(value: Any, indent: int) -> str: output = repr(value) else: output = "0x" + value.hex() - return "{} bytes {}{}".format(length, output, suffix) + return f"{length} bytes {output}{suffix}" + + field = pb.get_field(name) + if field is not None: + if isinstance(value, int) and safe_issubclass(field.type, IntEnum): + try: + return f"{field.type(value).name} ({value})" + except ValueError: + return str(value) return repr(value) return "{name} ({size} bytes) {content}".format( name=pb.__class__.__name__, size=pb.ByteSize(), - content=pformat_value(pb.__dict__, indent), + content=pformat("", pb.__dict__, indent), ) -def value_to_proto(ftype, value): - if issubclass(ftype, MessageType): +def value_to_proto(field: Field, value: Any) -> Any: + field_type_object = get_field_type_object(field) + if safe_issubclass(field_type_object, MessageType): raise TypeError("value_to_proto only converts simple values") - if ftype in (UVarintType, SVarintType): + if safe_issubclass(field_type_object, IntEnum): + if isinstance(value, str): + return field_type_object.__members__[value] + else: + try: + return field_type_object(value) + except ValueError as e: + LOG.info(f"On field {field.name}: {e}") + return int(value) + + if "int" in field.type: return int(value) - if ftype is BoolType: + if field.type == "bool": return bool(value) - if ftype is UnicodeType: + if field.type == "string": return str(value) - if ftype is BytesType: + if field.type == "bytes": if isinstance(value, str): return bytes.fromhex(value) elif isinstance(value, bytes): return value else: - raise TypeError("can't convert {} value to bytes".format(type(value))) + raise TypeError(f"can't convert {type(value)} value to bytes") -def dict_to_proto(message_type, d): +def dict_to_proto(message_type: Type[MT], d: Dict[str, Any]) -> MT: params = {} - for fname, ftype, fflags in message_type.get_fields().values(): - repeated = fflags & FLAG_REPEATED - value = d.get(fname) + for field in message_type.FIELDS.values(): + value = d.get(field.name) if value is None: continue - if not repeated: + if not field.repeated: value = [value] - if issubclass(ftype, MessageType): - function = dict_to_proto + field_type_object = get_field_type_object(field) + if safe_issubclass(field_type_object, MessageType): + newvalue = [dict_to_proto(field_type_object, v) for v in value] else: - function = value_to_proto + newvalue = [value_to_proto(field, v) for v in value] - newvalue = [function(ftype, v) for v in value] - - if not repeated: + if not field.repeated: newvalue = newvalue[0] - params[fname] = newvalue + params[field.name] = newvalue return message_type(**params) -def to_dict(msg): +def to_dict(msg: "MessageType", hexlify_bytes: bool = True) -> Dict[str, Any]: + def convert_value(value: Any) -> Any: + if hexlify_bytes and isinstance(value, bytes): + return value.hex() + elif isinstance(value, MessageType): + return to_dict(value, hexlify_bytes) + elif isinstance(value, list): + return [convert_value(v) for v in value] + elif isinstance(value, IntEnum): + return value.name + else: + return value + res = {} for key, value in msg.__dict__.items(): if value is None or value == []: continue - if isinstance(value, MessageType): - value = to_dict(value) - res[key] = value + res[key] = convert_value(value) + return res diff --git a/hwilib/devices/trezorlib/tools.py b/hwilib/devices/trezorlib/tools.py index dca461e0b..6680d6a99 100644 --- a/hwilib/devices/trezorlib/tools.py +++ b/hwilib/devices/trezorlib/tools.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2022 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -19,11 +19,32 @@ import re import struct import unicodedata -from typing import List, NewType - -from .exceptions import TrezorFailure - -CallException = TrezorFailure +from typing import ( + TYPE_CHECKING, + Any, + AnyStr, + Callable, + Dict, + List, + NewType, + Optional, + Type, + Union, + overload, +) + +if TYPE_CHECKING: + from .client import TrezorClient + from .protobuf import MessageType + + # Needed to enforce a return value from decorators + # More details: https://www.python.org/dev/peps/pep-0612/ + from typing import TypeVar + from typing_extensions import ParamSpec, Concatenate + + MT = TypeVar("MT", bound=MessageType) + P = ParamSpec("P") + R = TypeVar("R") HARDENED_FLAG = 1 << 31 @@ -37,33 +58,43 @@ def H_(x: int) -> int: return x | HARDENED_FLAG -def btc_hash(data): +def btc_hash(data: bytes) -> bytes: """ Double-SHA256 hash as used in BTC """ return hashlib.sha256(hashlib.sha256(data).digest()).digest() -def hash_160(public_key): +def tx_hash(data: bytes) -> bytes: + """Calculate and return double-SHA256 hash in reverse order. + + This is what Bitcoin uses as txids. + """ + return btc_hash(data)[::-1] + + +def hash_160(public_key: bytes) -> bytes: md = hashlib.new("ripemd160") md.update(hashlib.sha256(public_key).digest()) return md.digest() -def hash_160_to_bc_address(h160, address_type): +def hash_160_to_bc_address(h160: bytes, address_type: int) -> str: vh160 = struct.pack(" bytes: if public_key[0] == 4: return bytes((public_key[64] & 1) + 2) + public_key[1:33] raise ValueError("Pubkey is already compressed") -def public_key_to_bc_address(public_key, address_type, compress=True): +def public_key_to_bc_address( + public_key: bytes, address_type: int, compress: bool = True +) -> str: if public_key[0] == "\x04" and compress: public_key = compress_pubkey(public_key) @@ -75,7 +106,7 @@ def public_key_to_bc_address(public_key, address_type, compress=True): __b58base = len(__b58chars) -def b58encode(v): +def b58encode(v: bytes) -> str: """ encode v, which is a string of bytes, to base58.""" long_value = 0 @@ -101,17 +132,16 @@ def b58encode(v): return (__b58chars[0] * nPad) + result -def b58decode(v, length=None): +def b58decode(v: AnyStr, length: Optional[int] = None) -> bytes: """ decode v into a string of len bytes.""" - if isinstance(v, bytes): - v = v.decode() + str_v = v.decode() if isinstance(v, bytes) else v - for c in v: + for c in str_v: if c not in __b58chars: raise ValueError("invalid Base58 string") long_value = 0 - for (i, c) in enumerate(v[::-1]): + for (i, c) in enumerate(str_v[::-1]): long_value += __b58chars.find(c) * (__b58base ** i) result = b"" @@ -122,7 +152,7 @@ def b58decode(v, length=None): result = struct.pack("B", long_value) + result nPad = 0 - for c in v: + for c in str_v: if c == __b58chars[0]: nPad += 1 else: @@ -130,17 +160,17 @@ def b58decode(v, length=None): result = b"\x00" * nPad + result if length is not None and len(result) != length: - return None + raise ValueError("Result length does not match expected_length") return result -def b58check_encode(v): +def b58check_encode(v: bytes) -> str: checksum = btc_hash(v)[:4] return b58encode(v + checksum) -def b58check_decode(v, length=None): +def b58check_decode(v: AnyStr, length: Optional[int] = None) -> bytes: dec = b58decode(v, length) data, checksum = dec[:-4], dec[-4:] if btc_hash(data)[:4] != checksum: @@ -159,7 +189,7 @@ def parse_path(nstr: str) -> Address: :return: list of integers """ if not nstr: - return [] + return Address([]) n = nstr.split("/") @@ -176,51 +206,80 @@ def str_to_harden(x: str) -> int: return int(x) try: - return [str_to_harden(x) for x in n] - except Exception: - raise ValueError("Invalid BIP32 path", nstr) + return Address([str_to_harden(x) for x in n]) + except Exception as e: + raise ValueError("Invalid BIP32 path", nstr) from e -def normalize_nfc(txt): +def normalize_nfc(txt: AnyStr) -> bytes: """ Normalize message to NFC and return bytes suitable for protobuf. This seems to be bitcoin-qt standard of doing things. """ - if isinstance(txt, bytes): - txt = txt.decode() - return unicodedata.normalize("NFC", txt).encode() + str_txt = txt.decode() if isinstance(txt, bytes) else txt + return unicodedata.normalize("NFC", str_txt).encode() + + +# NOTE for type tests (mypy/pyright): +# Overloads below have a goal of enforcing the return value +# that should be returned from the original function being decorated +# while still preserving the function signature (the inputted arguments +# are going to be type-checked). +# Currently (November 2021) mypy does not support "ParamSpec" typing +# construct, so it will not understand it and will complain about +# definitions below. -class expect: - # Decorator checks if the method - # returned one of expected protobuf messages - # or raises an exception - def __init__(self, expected, field=None): - self.expected = expected - self.field = field +@overload +def expect( + expected: "Type[MT]", +) -> "Callable[[Callable[P, MessageType]], Callable[P, MT]]": + ... - def __call__(self, f): + +@overload +def expect( + expected: "Type[MT]", *, field: str, ret_type: "Type[R]" +) -> "Callable[[Callable[P, MessageType]], Callable[P, R]]": + ... + + +def expect( + expected: "Type[MT]", + *, + field: Optional[str] = None, + ret_type: "Optional[Type[R]]" = None, +) -> "Callable[[Callable[P, MessageType]], Callable[P, Union[MT, R]]]": + """ + Decorator checks if the method + returned one of expected protobuf messages + or raises an exception + """ + + def decorator(f: "Callable[P, MessageType]") -> "Callable[P, Union[MT, R]]": @functools.wraps(f) - def wrapped_f(*args, **kwargs): + def wrapped_f(*args: "P.args", **kwargs: "P.kwargs") -> "Union[MT, R]": __tracebackhide__ = True # for pytest # pylint: disable=W0612 ret = f(*args, **kwargs) - if not isinstance(ret, self.expected): - raise RuntimeError( - "Got %s, expected %s" % (ret.__class__, self.expected) - ) - if self.field is not None: - return getattr(ret, self.field) + if not isinstance(ret, expected): + raise RuntimeError(f"Got {ret.__class__}, expected {expected}") + if field is not None: + return getattr(ret, field) else: return ret return wrapped_f + return decorator + -def session(f): +def session( + f: "Callable[Concatenate[TrezorClient, P], R]", +) -> "Callable[Concatenate[TrezorClient, P], R]": # Decorator wraps a BaseClient method # with session activation / deactivation @functools.wraps(f) - def wrapped_f(client, *args, **kwargs): + def wrapped_f(client: "TrezorClient", *args: "P.args", **kwargs: "P.kwargs") -> "R": __tracebackhide__ = True # for pytest # pylint: disable=W0612 client.open() try: @@ -238,19 +297,19 @@ def wrapped_f(client, *args, **kwargs): ALL_CAP_RE = re.compile("([a-z0-9])([A-Z])") -def from_camelcase(s): +def from_camelcase(s: str) -> str: s = FIRST_CAP_RE.sub(r"\1_\2", s) return ALL_CAP_RE.sub(r"\1_\2", s).lower() -def dict_from_camelcase(d, renames=None): +def dict_from_camelcase(d: Any, renames: Optional[dict] = None) -> dict: if not isinstance(d, dict): return d if renames is None: renames = {} - res = {} + res: Dict[str, Any] = {} for key, value in d.items(): newkey = from_camelcase(key) renamed_key = renames.get(newkey) or renames.get(key) @@ -263,3 +322,51 @@ def dict_from_camelcase(d, renames=None): res[newkey] = dict_from_camelcase(value, renames) return res + + +# adapted from https://github.com/bitcoin-core/HWI/blob/master/hwilib/descriptor.py + + +def descriptor_checksum(desc: str) -> str: + def _polymod(c: int, val: int) -> int: + c0 = c >> 35 + c = ((c & 0x7FFFFFFFF) << 5) ^ val + if c0 & 1: + c ^= 0xF5DEE51989 + if c0 & 2: + c ^= 0xA9FDCA3312 + if c0 & 4: + c ^= 0x1BAB10E32D + if c0 & 8: + c ^= 0x3706B1677A + if c0 & 16: + c ^= 0x644D626FFD + return c + + INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " + CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + c = 1 + cls = 0 + clscount = 0 + for ch in desc: + pos = INPUT_CHARSET.find(ch) + if pos == -1: + return "" + c = _polymod(c, pos & 31) + cls = cls * 3 + (pos >> 5) + clscount += 1 + if clscount == 3: + c = _polymod(c, cls) + cls = 0 + clscount = 0 + if clscount > 0: + c = _polymod(c, cls) + for j in range(0, 8): + c = _polymod(c, 0) + c ^= 1 + + ret = [""] * 8 + for j in range(0, 8): + ret[j] = CHECKSUM_CHARSET[(c >> (5 * (7 - j))) & 31] + return "".join(ret) diff --git a/hwilib/devices/trezorlib/transport/__init__.py b/hwilib/devices/trezorlib/transport/__init__.py index b2d19feae..6e1e6f178 100644 --- a/hwilib/devices/trezorlib/transport/__init__.py +++ b/hwilib/devices/trezorlib/transport/__init__.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2019 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -15,10 +15,9 @@ # If not, see . import logging -from typing import Iterable, List, Type +from typing import Iterable, List, Tuple, Type from ..exceptions import TrezorException -from ..protobuf import MessageType LOG = logging.getLogger(__name__) @@ -26,10 +25,8 @@ DEV_TREZOR1 = (0x534C, 0x0001) DEV_TREZOR2 = (0x1209, 0x53C1) DEV_TREZOR2_BL = (0x1209, 0x53C0) -DEV_KEEPKEY = (0x2B24, 0x0001) -DEV_KEEPKEY_WEBUSB = (0x2B24, 0x0002) -TREZORS = {DEV_TREZOR1, DEV_TREZOR2, DEV_TREZOR2_BL, DEV_KEEPKEY, DEV_KEEPKEY_WEBUSB} +TREZORS = {DEV_TREZOR1, DEV_TREZOR2, DEV_TREZOR2_BL} UDEV_RULES_STR = """ Do you have udev rules installed? @@ -37,6 +34,9 @@ """.strip() +MessagePayload = Tuple[int, bytes] + + class TransportException(TrezorException): pass @@ -44,7 +44,7 @@ class TransportException(TrezorException): class Transport: """Raw connection to a Trezor device. - Transport subclass represents a kind of communication link: WebUSB + Transport subclass represents a kind of communication link: Trezor Bridge, WebUSB or USB-HID connection, or UDP socket of listening emulator(s). It can also enumerate devices available over this communication link, and return them as instances. @@ -58,7 +58,7 @@ class Transport: a Trezor device to a computer. """ - PATH_PREFIX = None # type: str + PATH_PREFIX: str = None ENABLED = False def __str__(self) -> str: @@ -73,10 +73,10 @@ def begin_session(self) -> None: def end_session(self) -> None: raise NotImplementedError - def read(self) -> MessageType: + def read(self) -> MessagePayload: raise NotImplementedError - def write(self, message: MessageType) -> None: + def write(self, message_type: int, message_data: bytes) -> None: raise NotImplementedError @classmethod @@ -99,19 +99,20 @@ def find_by_path(cls, path: str, prefix_search: bool = False) -> "Transport": def all_transports() -> Iterable[Type[Transport]]: + from .bridge import BridgeTransport from .hid import HidTransport from .udp import UdpTransport from .webusb import WebUsbTransport return set( cls - for cls in (HidTransport, UdpTransport, WebUsbTransport) + for cls in (BridgeTransport, HidTransport, UdpTransport, WebUsbTransport) if cls.ENABLED ) def enumerate_devices() -> Iterable[Transport]: - devices = [] # type: List[Transport] + devices: List[Transport] = [] for transport in all_transports(): name = transport.__name__ try: @@ -131,7 +132,7 @@ def get_transport(path: str = None, prefix_search: bool = False) -> Transport: try: return next(iter(enumerate_devices())) except StopIteration: - raise TransportException("No TREZOR device found") from None + raise TransportException("No Trezor device found") from None # Find whether B is prefix of A (transport name is part of the path) # or A is prefix of B (path is a prefix, or a name, of transport). diff --git a/hwilib/devices/trezorlib/transport/hid.py b/hwilib/devices/trezorlib/transport/hid.py index 68849ddbb..29bb61e06 100644 --- a/hwilib/devices/trezorlib/transport/hid.py +++ b/hwilib/devices/trezorlib/transport/hid.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2019 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -17,9 +17,10 @@ import logging import sys import time -from typing import Any, Dict, Iterable +from typing import Any, Dict, Iterable, Optional, Set, Tuple -from . import DEV_TREZOR1, DEV_KEEPKEY, UDEV_RULES_STR, TransportException +from ..log import DUMP_PACKETS +from . import DEV_TREZOR1, UDEV_RULES_STR, TransportException from .protocol import ProtocolBasedTransport, ProtocolV1 LOG = logging.getLogger(__name__) @@ -41,7 +42,7 @@ def __init__( ) -> None: self.path = path self.serial = serial - self.handle = None # type: HidDeviceHandle + self.handle: HidDeviceHandle = None self.hid_version = None if probe_hid_version else 2 def open(self) -> None: @@ -82,17 +83,21 @@ def write_chunk(self, chunk: bytes) -> None: raise TransportException("Unexpected chunk size: %d" % len(chunk)) if self.hid_version == 2: - self.handle.write(b"\0" + bytearray(chunk)) - else: - self.handle.write(chunk) + chunk = b"\x00" + chunk + + LOG.log(DUMP_PACKETS, "writing packet: {}".format(chunk.hex())) + self.handle.write(chunk) def read_chunk(self) -> bytes: while True: - chunk = self.handle.read(64) + # hidapi seems to return lists of ints instead of bytes + chunk = bytes(self.handle.read(64)) if chunk: break else: time.sleep(0.001) + + LOG.log(DUMP_PACKETS, "read packet: {}".format(chunk.hex())) if len(chunk) != 64: raise TransportException("Unexpected chunk size: %d" % len(chunk)) return bytes(chunk) @@ -119,18 +124,17 @@ def __init__(self, device: HidDevice) -> None: self.device = device self.handle = HidHandle(device["path"], device["serial_number"]) - protocol = ProtocolV1(self.handle) - super().__init__(protocol=protocol) + super().__init__(protocol=ProtocolV1(self.handle)) def get_path(self) -> str: return "%s:%s" % (self.PATH_PREFIX, self.device["path"].decode()) @classmethod - def enumerate(cls, debug: bool = False) -> Iterable["HidTransport"]: + def enumerate(cls, debug: bool = False, usb_ids: Set[Tuple[int, int]] = {DEV_TREZOR1}) -> Iterable["HidTransport"]: devices = [] for dev in hid.enumerate(0, 0): usb_id = (dev["vendor_id"], dev["product_id"]) - if usb_id != DEV_TREZOR1 and usb_id != DEV_KEEPKEY: + if usb_id not in usb_ids: continue if debug: if not is_debuglink(dev): @@ -142,15 +146,11 @@ def enumerate(cls, debug: bool = False) -> Iterable["HidTransport"]: return devices def find_debug(self) -> "HidTransport": - if self.protocol.VERSION >= 2: - # use the same device - return self - else: - # For v1 protocol, find debug USB interface for the same serial number - for debug in HidTransport.enumerate(debug=True): - if debug.device["serial_number"] == self.device["serial_number"]: - return debug - raise TransportException("Debug HID device not found") + # For v1 protocol, find debug USB interface for the same serial number + for debug in HidTransport.enumerate(debug=True): + if debug.device["serial_number"] == self.device["serial_number"]: + return debug + raise TransportException("Debug HID device not found") def is_wirelink(dev: HidDevice) -> bool: diff --git a/hwilib/devices/trezorlib/transport/protocol.py b/hwilib/devices/trezorlib/transport/protocol.py index 00a066f88..da3806cd7 100644 --- a/hwilib/devices/trezorlib/transport/protocol.py +++ b/hwilib/devices/trezorlib/transport/protocol.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2019 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -15,15 +15,12 @@ # If not, see . import logging -import os import struct -from io import BytesIO from typing import Tuple from typing_extensions import Protocol as StructuralType -from . import Transport -from .. import mapping, protobuf +from . import MessagePayload, Transport REPLEN = 64 @@ -71,7 +68,6 @@ class Protocol: - open and close physical connections, - and send and receive binary chunks. - We declare a protocol version (we have implementations of v1 and v2). For now, the class also handles session counting and opening the underlying Handle. This will probably be removed in the future. @@ -79,8 +75,6 @@ class Protocol: its messages. """ - VERSION = None # type: int - def __init__(self, handle: Handle) -> None: self.handle = handle self.session_counter = 0 @@ -92,14 +86,14 @@ def begin_session(self) -> None: self.session_counter += 1 def end_session(self) -> None: - if self.session_counter == 1: + self.session_counter = max(self.session_counter - 1, 0) + if self.session_counter == 0: self.handle.close() - self.session_counter -= 1 - def read(self) -> protobuf.MessageType: + def read(self) -> MessagePayload: raise NotImplementedError - def write(self, message: protobuf.MessageType) -> None: + def write(self, message_type: int, message_data: bytes) -> None: raise NotImplementedError @@ -113,10 +107,10 @@ class ProtocolBasedTransport(Transport): def __init__(self, protocol: Protocol) -> None: self.protocol = protocol - def write(self, message: protobuf.MessageType) -> None: - self.protocol.write(message) + def write(self, message_type: int, message_data: bytes) -> None: + self.protocol.write(message_type, message_data) - def read(self) -> protobuf.MessageType: + def read(self) -> MessagePayload: return self.protocol.read() def begin_session(self) -> None: @@ -131,18 +125,11 @@ class ProtocolV1(Protocol): Does not understand sessions. """ - VERSION = 1 + HEADER_LEN = struct.calcsize(">HL") - def write(self, msg: protobuf.MessageType) -> None: - LOG.debug( - "sending message: {}".format(msg.__class__.__name__), - extra={"protobuf": msg}, - ) - data = BytesIO() - protobuf.dump_message(data, msg) - ser = data.getvalue() - header = struct.pack(">HL", mapping.get_type(msg), len(ser)) - buffer = bytearray(b"##" + header + ser) + def write(self, message_type: int, message_data: bytes) -> None: + header = struct.pack(">HL", message_type, len(message_data)) + buffer = bytearray(b"##" + header + message_data) while buffer: # Report ID, data padded to 63 bytes @@ -151,7 +138,7 @@ def write(self, msg: protobuf.MessageType) -> None: self.handle.write_chunk(chunk) buffer = buffer[63:] - def read(self) -> protobuf.MessageType: + def read(self) -> MessagePayload: buffer = bytearray() # Read header with first part of message data msg_type, datalen, first_chunk = self.read_first() @@ -161,28 +148,18 @@ def read(self) -> protobuf.MessageType: while len(buffer) < datalen: buffer.extend(self.read_next()) - # Strip padding - data = BytesIO(buffer[:datalen]) - - # Parse to protobuf - msg = protobuf.load_message(data, mapping.get_class(msg_type)) - LOG.debug( - "received message: {}".format(msg.__class__.__name__), - extra={"protobuf": msg}, - ) - return msg + return msg_type, buffer[:datalen] def read_first(self) -> Tuple[int, int, bytes]: chunk = self.handle.read_chunk() if chunk[:3] != b"?##": raise RuntimeError("Unexpected magic characters") try: - headerlen = struct.calcsize(">HL") - msg_type, datalen = struct.unpack(">HL", chunk[3 : 3 + headerlen]) + msg_type, datalen = struct.unpack(">HL", chunk[3 : 3 + self.HEADER_LEN]) except Exception: raise RuntimeError("Cannot parse header") - data = chunk[3 + headerlen :] + data = chunk[3 + self.HEADER_LEN :] return msg_type, datalen, data def read_next(self) -> bytes: @@ -190,17 +167,3 @@ def read_next(self) -> bytes: if chunk[:1] != b"?": raise RuntimeError("Unexpected magic characters") return chunk[1:] - - -def get_protocol(handle: Handle, want_v2: bool) -> Protocol: - """Make a Protocol instance for the given handle. - - Each transport can have a preference for using a particular protocol version. - This preference is overridable through `TREZOR_PROTOCOL_V1` environment variable, - which forces the library to use V1 anyways. - - As of 11/2018, no devices support V2, so we enforce V1 here. It is still possible - to set `TREZOR_PROTOCOL_V1=0` and thus enable V2 protocol for transports that ask - for it (i.e., USB transports for Trezor T). - """ - return ProtocolV1(handle) diff --git a/hwilib/devices/trezorlib/transport/udp.py b/hwilib/devices/trezorlib/transport/udp.py index 53a8b0ac9..1dd8c77e1 100644 --- a/hwilib/devices/trezorlib/transport/udp.py +++ b/hwilib/devices/trezorlib/transport/udp.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2019 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -14,11 +14,18 @@ # You should have received a copy of the License along with this library. # If not, see . +import logging import socket +import time from typing import Iterable, Optional, cast +from ..log import DUMP_PACKETS from . import TransportException -from .protocol import ProtocolBasedTransport, get_protocol +from .protocol import ProtocolBasedTransport, ProtocolV1 + +SOCKET_TIMEOUT = 10 + +LOG = logging.getLogger(__name__) class UdpTransport(ProtocolBasedTransport): @@ -37,10 +44,9 @@ def __init__(self, device: str = None) -> None: host = devparts[0] port = int(devparts[1]) if len(devparts) > 1 else UdpTransport.DEFAULT_PORT self.device = (host, port) - self.socket = None # type: Optional[socket.socket] + self.socket: Optional[socket.socket] = None - protocol = get_protocol(self, want_v2=False) - super().__init__(protocol=protocol) + super().__init__(protocol=ProtocolV1(self)) def get_path(self) -> str: return "{}:{}:{}".format(self.PATH_PREFIX, *self.device) @@ -58,16 +64,17 @@ def _try_path(cls, path: str) -> "UdpTransport": return d else: raise TransportException( - "No TREZOR device found at address {}".format(path) + "No Trezor device found at address {}".format(d.get_path()) ) finally: d.close() @classmethod - def enumerate(cls) -> Iterable["UdpTransport"]: - default_path = "{}:{}".format(cls.DEFAULT_HOST, cls.DEFAULT_PORT) + def enumerate(cls, path: Optional[str] = None) -> Iterable["UdpTransport"]: + if path is None: + path = "{}:{}".format(cls.DEFAULT_HOST, cls.DEFAULT_PORT) try: - return [cls._try_path(default_path)] + return [cls._try_path(path)] except TransportException: return [] @@ -82,10 +89,26 @@ def find_by_path(cls, path: str, prefix_search: bool = False) -> "UdpTransport": path = path.replace("{}:".format(cls.PATH_PREFIX), "") return cls._try_path(path) + def wait_until_ready(self, timeout: float = 10) -> None: + try: + self.open() + self.socket.settimeout(0) + start = time.monotonic() + while True: + if self._ping(): + break + elapsed = time.monotonic() - start + if elapsed >= timeout: + raise TransportException("Timed out waiting for connection.") + + time.sleep(0.05) + finally: + self.close() + def open(self) -> None: self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket.connect(self.device) - self.socket.settimeout(10) + self.socket.settimeout(SOCKET_TIMEOUT) def close(self) -> None: if self.socket is not None: @@ -107,6 +130,7 @@ def write_chunk(self, chunk: bytes) -> None: assert self.socket is not None if len(chunk) != 64: raise TransportException("Unexpected data length") + LOG.log(DUMP_PACKETS, "sending packet: {}".format(chunk.hex())) self.socket.sendall(chunk) def read_chunk(self) -> bytes: @@ -117,6 +141,7 @@ def read_chunk(self) -> bytes: break except socket.timeout: continue + LOG.log(DUMP_PACKETS, "received packet: {}".format(chunk.hex())) if len(chunk) != 64: raise TransportException("Unexpected chunk size: %d" % len(chunk)) return bytearray(chunk) diff --git a/hwilib/devices/trezorlib/transport/webusb.py b/hwilib/devices/trezorlib/transport/webusb.py index 61d14e4a2..af7a914ef 100644 --- a/hwilib/devices/trezorlib/transport/webusb.py +++ b/hwilib/devices/trezorlib/transport/webusb.py @@ -1,6 +1,6 @@ # This file is part of the Trezor project. # -# Copyright (C) 2012-2018 SatoshiLabs and contributors +# Copyright (C) 2012-2019 SatoshiLabs and contributors # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License version 3 @@ -18,8 +18,9 @@ import logging import sys import time -from typing import Iterable, Optional +from typing import Iterable, Optional, Set, Tuple +from ..log import DUMP_PACKETS from . import TREZORS, UDEV_RULES_STR, TransportException from .protocol import ProtocolBasedTransport, ProtocolV1 @@ -43,7 +44,7 @@ def __init__(self, device: "usb1.USBDevice", debug: bool = False) -> None: self.interface = DEBUG_INTERFACE if debug else INTERFACE self.endpoint = DEBUG_ENDPOINT if debug else ENDPOINT self.count = 0 - self.handle = None # type: Optional[usb1.USBDeviceHandle] + self.handle: Optional[usb1.USBDeviceHandle] = None def open(self) -> None: self.handle = self.device.open() @@ -65,6 +66,7 @@ def write_chunk(self, chunk: bytes) -> None: assert self.handle is not None if len(chunk) != 64: raise TransportException("Unexpected chunk size: %d" % len(chunk)) + LOG.log(DUMP_PACKETS, "writing packet: {}".format(chunk.hex())) self.handle.interruptWrite(self.endpoint, chunk) def read_chunk(self) -> bytes: @@ -76,6 +78,7 @@ def read_chunk(self) -> bytes: break else: time.sleep(0.001) + LOG.log(DUMP_PACKETS, "read packet: {}".format(chunk.hex())) if len(chunk) != 64: raise TransportException("Unexpected chunk size: %d" % len(chunk)) return chunk @@ -106,7 +109,7 @@ def get_path(self) -> str: return "%s:%s" % (self.PATH_PREFIX, dev_to_str(self.device)) @classmethod - def enumerate(cls) -> Iterable["WebUsbTransport"]: + def enumerate(cls, usb_reset=False, usb_ids: Set[Tuple[int, int]] = TREZORS) -> Iterable["WebUsbTransport"]: if cls.context is None: cls.context = usb1.USBContext() cls.context.open() @@ -114,7 +117,7 @@ def enumerate(cls) -> Iterable["WebUsbTransport"]: devices = [] for dev in cls.context.getDeviceIterator(skip_on_error=True): usb_id = (dev.getVendorID(), dev.getProductID()) - if usb_id not in TREZORS: + if usb_id not in usb_ids: continue if not is_vendor_class(dev): continue @@ -126,19 +129,18 @@ def enumerate(cls) -> Iterable["WebUsbTransport"]: # non-functional. dev.getProduct() devices.append(WebUsbTransport(dev)) - except: + except usb1.USBErrorNotSupported: pass + except usb1.USBErrorPipe: + if usb_reset: + handle = dev.open() + handle.resetDevice() + handle.close() return devices def find_debug(self) -> "WebUsbTransport": - if self.protocol.VERSION >= 2: - # TODO test this - # XXX this is broken right now because sessions don't really work - # For v2 protocol, use the same WebUSB interface with a different session - return WebUsbTransport(self.device, self.handle) - else: - # For v1 protocol, find debug USB interface for the same serial number - return WebUsbTransport(self.device, debug=True) + # For v1 protocol, find debug USB interface for the same serial number + return WebUsbTransport(self.device, debug=True) def is_vendor_class(dev: "usb1.USBDevice") -> bool: diff --git a/hwilib/devices/trezorlib/ui.py b/hwilib/devices/trezorlib/ui.py deleted file mode 100644 index 23f83f7ab..000000000 --- a/hwilib/devices/trezorlib/ui.py +++ /dev/null @@ -1,105 +0,0 @@ -# This file is part of the Trezor project. -# -# Copyright (C) 2012-2018 SatoshiLabs and contributors -# -# This library is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License version 3 -# as published by the Free Software Foundation. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the License along with this library. -# If not, see . - -import os -import sys - -from mnemonic import Mnemonic - -from . import device -from .exceptions import Cancelled -from .messages import PinMatrixRequestType, WordRequestType - -PIN_MATRIX_DESCRIPTION = """ -Use the numeric keypad to describe number positions. The layout is: - 7 8 9 - 4 5 6 - 1 2 3 -""".strip() - -RECOVERY_MATRIX_DESCRIPTION = """ -Use the numeric keypad to describe positions. -For the word list use only left and right keys. -Use backspace to correct an entry. - -The keypad layout is: - 7 8 9 7 | 9 - 4 5 6 4 | 6 - 1 2 3 1 | 3 -""".strip() - -PIN_GENERIC = None -PIN_CURRENT = PinMatrixRequestType.Current -PIN_NEW = PinMatrixRequestType.NewFirst -PIN_CONFIRM = PinMatrixRequestType.NewSecond - - -def echo(msg): - print(msg, file=sys.stderr) - -def prompt(msg, hide_input=False): - if hide_input: - import getpass - return getpass.getpass(msg + ' :\n') - else: - return input(msg + ':\n') - -class PassphraseUI: - def __init__(self, passphrase): - self.passphrase = passphrase - self.pinmatrix_shown = False - self.prompt_shown = False - self.always_prompt = False - - def button_request(self, code): - if not self.prompt_shown: - echo("Please confirm action on your Trezor device") - if not self.always_prompt: - self.prompt_shown = True - - def get_pin(self, code=None): - raise NotImplementedError('get_pin is not needed') - - def get_passphrase(self): - return self.passphrase - -def mnemonic_words(expand=False, language="english"): - if expand: - wordlist = Mnemonic(language).wordlist - else: - wordlist = set() - - def expand_word(word): - if not expand: - return word - if word in wordlist: - return word - matches = [w for w in wordlist if w.startswith(word)] - if len(matches) == 1: - return word - echo("Choose one of: " + ", ".join(matches)) - raise KeyError(word) - - def get_word(type): - assert type == WordRequestType.Plain - while True: - try: - word = prompt("Enter one word of mnemonic") - return expand_word(word) - except KeyError: - pass - - return get_word diff --git a/hwilib/errors.py b/hwilib/errors.py index 6240c99e6..1f8c9195d 100644 --- a/hwilib/errors.py +++ b/hwilib/errors.py @@ -1,97 +1,224 @@ -# Defines errors and error codes +""" +Errors and Error Codes +********************** +HWI has several possible Exceptions with corresponding error codes. + +:class:`~hwilib.hwwclient.HardwareWalletClient` functions and :mod:`~hwilib.commands` functions will generally raise an exception that is a subclass of :class:`HWWError`. +The HWI command line tool will convert these exceptions into a dictionary containing the error message and error code. +These look like ``{"error": "", "code": }``. +""" + +from typing import Any, Dict, Iterator, Optional from contextlib import contextmanager # Error codes -NO_DEVICE_TYPE = -1 -MISSING_ARGUMENTS = -2 -DEVICE_CONN_ERROR = -3 -UNKNWON_DEVICE_TYPE = -4 -INVALID_TX = -5 -NO_PASSWORD = -6 -BAD_ARGUMENT = -7 -NOT_IMPLEMENTED = -8 -UNAVAILABLE_ACTION = -9 -DEVICE_ALREADY_INIT = -10 -DEVICE_ALREADY_UNLOCKED = -11 -DEVICE_NOT_READY = -12 -UNKNOWN_ERROR = -13 -ACTION_CANCELED = -14 -DEVICE_BUSY = -15 -NEED_TO_BE_ROOT = -16 -HELP_TEXT = -17 -DEVICE_NOT_INITIALIZED = -18 +NO_DEVICE_TYPE = -1 #: Device type was not specified +MISSING_ARGUMENTS = -2 #: Arguments are missing +DEVICE_CONN_ERROR = -3 #: Error connecting to the device +UNKNWON_DEVICE_TYPE = -4 #: Device type is unknown +INVALID_TX = -5 #: Transaction is invalid +NO_PASSWORD = -6 #: No password provided, but one is needed +BAD_ARGUMENT = -7 #: Bad, malformed, or conflicting argument was provided +NOT_IMPLEMENTED = -8 #: Function is not implemented +UNAVAILABLE_ACTION = -9 #: Function is not available for this device +DEVICE_ALREADY_INIT = -10 #: Device is already initialized +DEVICE_ALREADY_UNLOCKED = -11 #: Device is already unlocked +DEVICE_NOT_READY = -12 #: Device is not ready +UNKNOWN_ERROR = -13 #: An unknown error occurred +ACTION_CANCELED = -14 #: Action was canceled by the user +DEVICE_BUSY = -15 #: Device is busy +NEED_TO_BE_ROOT = -16 #: User needs to be root to perform action +HELP_TEXT = -17 #: Help text was requested by the user +DEVICE_NOT_INITIALIZED = -18 #: Device is not initialized # Exceptions class HWWError(Exception): - def __init__(self, msg, code): + """ + Generic exception type produced by HWI + Subclassed by specific Errors to have Exceptions that have specific error codes. + + Contains a message and error code. + """ + def __init__(self, msg: str, code: int) -> None: + """ + Create an exception with the message and error code + + :param msg: The error message + :param code: The error code + """ Exception.__init__(self) self.code = code self.msg = msg - def get_code(self): + def get_code(self) -> int: + """ + Get the error code for this Error + + :return: The error code + """ return self.code - def get_msg(self): + def get_msg(self) -> str: + """ + Get the error message for this Error + + :return: The error message + """ return self.msg - def __str__(self): + def __str__(self) -> str: return self.msg class NoPasswordError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`NO_PASSWORD` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, NO_PASSWORD) class UnavailableActionError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`UNAVAILABLE_ACTION` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, UNAVAILABLE_ACTION) class DeviceAlreadyInitError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`DEVICE_ALREADY_INIT` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, DEVICE_ALREADY_INIT) class DeviceNotReadyError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`DEVICE_NOT_READY` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, DEVICE_NOT_READY) class DeviceAlreadyUnlockedError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`DEVICE_ALREADY_UNLOCKED` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, DEVICE_ALREADY_UNLOCKED) class UnknownDeviceError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`DEVICE_TYPE` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, UNKNWON_DEVICE_TYPE) class NotImplementedError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`NOT_IMPLEMENTED` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, NOT_IMPLEMENTED) class PSBTSerializationError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`INVALID_TX` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, INVALID_TX) class BadArgumentError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`BAD_ARGUMENT` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, BAD_ARGUMENT) class DeviceFailureError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`UNKNOWN_ERROR` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, UNKNOWN_ERROR) class ActionCanceledError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`ACTION_CANCELED` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, ACTION_CANCELED) class DeviceConnectionError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`DEVICE_CONN_ERROR` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, DEVICE_CONN_ERROR) class DeviceBusyError(HWWError): - def __init__(self, msg): + """ + :class:`HWWError` for :data:`DEVICE_BUSY` + """ + def __init__(self, msg: str): + """ + :param msg: The error message + """ HWWError.__init__(self, msg, DEVICE_BUSY) +class NeedsRootError(HWWError): + def __init__(self, msg: str): + HWWError.__init__(self, msg, NEED_TO_BE_ROOT) + @contextmanager -def handle_errors(msg=None, result=None, code=UNKNOWN_ERROR, debug=False): +def handle_errors( + msg: Optional[str] = None, + result: Optional[Dict[str, Any]] = None, + code: int = UNKNOWN_ERROR, + debug: bool = False, +) -> Iterator[None]: + """ + Context manager to catch all Exceptions and HWWErrors to return them as dictionaries containing the error message and code. + + :param msg: Error message prefix. Attached to the beginning of each error message + :param result: The dictionary to put the resulting error in + :param code: The default error code to use for Exceptions + :param debug: Whether to also print out the traceback for debugging purposes + """ if result is None: result = {} diff --git a/hwilib/hwwclient.py b/hwilib/hwwclient.py index e36e905e6..565afcf44 100644 --- a/hwilib/hwwclient.py +++ b/hwilib/hwwclient.py @@ -1,65 +1,239 @@ -# This is an abstract class that defines all of the methods that each Hardware -# wallet subclass must implement. +""" +Hardware Wallet Client Interface +******************************** + +The :class:`HardwareWalletClient` is the class which all of the specific device implementations subclass. +""" + +from typing import ( + Dict, + Optional, + Union, +) +from .descriptor import MultisigDescriptor +from .key import ( + ExtendedKey, + get_bip44_purpose, + get_bip44_chain, +) +from .psbt import PSBT +from .common import AddressType, Chain + + class HardwareWalletClient(object): + """Create a client for a HID device that has already been opened. + + This abstract class defines the methods + that hardware wallet subclasses should implement. + """ - # device is an HID device that has already been opened. - def __init__(self, path, password): + def __init__(self, path: str, password: Optional[str], expert: bool, chain: Chain = Chain.MAIN) -> None: + """ + :param path: Path to the device as returned by :func:`~hwilib.commands.enumerate` + :param password: A password/passphrase to use with the device. + Typically a BIP 39 passphrase, but not always. + See device specific documentation for further details. + :param expert: Whether to return additional information intended for experts. + """ self.path = path self.password = password self.message_magic = b"\x18Bitcoin Signed Message:\n" - self.is_testnet = False - self.fingerprint = None - self.xpub_cache = {} - - # Get the master BIP 44 pubkey - def get_master_xpub(self): - return self.get_pubkey_at_path('m/44\'/0\'/0\'') - - # Must return a dict with the xpub - # Retrieves the public key at the specified BIP 32 derivation path - def get_pubkey_at_path(self, path): - raise NotImplementedError('The HardwareWalletClient base class does not ' - 'implement this method') - - # Must return a hex string with the signed transaction - # The tx must be in the combined unsigned transaction format - def sign_tx(self, tx): - raise NotImplementedError('The HardwareWalletClient base class does not ' - 'implement this method') - - # Must return a base64 encoded string with the signed message - # The message can be any string. keypath is the bip 32 derivation path for the key to sign with - def sign_message(self, message, keypath): - raise NotImplementedError('The HardwareWalletClient base class does not ' - 'implement this method') - - # Setup a new device - def setup_device(self, label='', passphrase=''): - raise NotImplementedError('The HardwareWalletClient base class does not ' - 'implement this method') - - # Wipe this device - def wipe_device(self): - raise NotImplementedError('The HardwareWalletClient base class does not ' - 'implement this method') - - # Restore device from mnemonic or xprv - def restore_device(self, label=''): - raise NotImplementedError('The HardwareWalletClient base class does not implement this method') - - # Begin backup process - def backup_device(self, label='', passphrase=''): - raise NotImplementedError('The HardwareWalletClient base class does not implement this method') - - # Close the device - def close(self): - raise NotImplementedError('The HardwareWalletClient base class does not ' - 'implement this method') - - # Prompt pin - def prompt_pin(self): - raise NotImplementedError('The HardwareWalletClient base class does not implement this method') - - # Send pin - def send_pin(self): - raise NotImplementedError('The HardwareWalletClient base class does not implement this method') + self.chain = chain + self.fingerprint: Optional[str] = None + # {bip32_path: } + self.xpub_cache: Dict[str, str] = {} + self.expert = expert + + def get_master_xpub(self, addrtype: AddressType = AddressType.WIT, account: int = 0) -> ExtendedKey: + """ + Retrieves a BIP 44 master public key + + Get the extended public key used to derive receiving and change addresses with the BIP 44 derivation path scheme. + The returned xpub will be dependent on the address type requested, the chain type, and the BIP 44 account number. + + :return: The extended public key + """ + path = f"m/{get_bip44_purpose(addrtype)}h/{get_bip44_chain(self.chain)}h/{account}h" + return self.get_pubkey_at_path(path) + + def get_master_fingerprint(self) -> bytes: + """ + Get the master public key fingerprint as bytes. + + Retrieves the fingerprint of the master public key of a device. + Typically implemented by fetching the extended public key at "m/0h" + and extracting the parent fingerprint from it. + + :return: The fingerprint as bytes + """ + return self.get_pubkey_at_path("m/0h").parent_fingerprint + + def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey: + """ + Get the public key at the BIP 32 derivation path. + + :param bip32_path: The BIP 32 derivation path + :return: The extended public key + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def sign_tx(self, psbt: PSBT) -> PSBT: + """ + Sign a partially signed bitcoin transaction (PSBT). + + :param psbt: The PSBT to sign + :return: The PSBT after being processed by the hardware wallet + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def sign_message( + self, message: Union[str, bytes], bip32_path: str + ) -> str: + """ + Sign a message (bitcoin message signing). + + Signs a message using the legacy Bitcoin Core signed message format. + The message is signed with the key at the given path. + + :param message: The message to be signed. First encoded as bytes if not already. + :param bip32_path: The BIP 32 derivation for the key to sign the message with. + :return: The signature + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def display_singlesig_address( + self, + bip32_path: str, + addr_type: AddressType, + ) -> str: + """ + Display and return the single sig address of specified type + at the given derivation path. + + :param bip32_path: The BIP 32 derivation path to get the address for + :param addr_type: The address type + :return: The retrieved address also being shown by the device + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def display_multisig_address( + self, + addr_type: AddressType, + multisig: MultisigDescriptor, + ) -> str: + """ + Display and return the multisig address of specified type given the descriptor. + + :param addr_type: The address type + :param multisig: A :class:`~hwilib.descriptor.MultisigDescriptor` that describes the multisig to display. + :return: The retrieved address also being shown by the device + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def wipe_device(self) -> bool: + """ + Wipe the device. + + :return: Whether the wipe was successful + :raises UnavailableActionError: if appropriate for the device. + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def setup_device( + self, label: str = "", passphrase: str = "" + ) -> bool: + """ + Setup the device. + + :param label: A label to apply to the device. + See device specific documentation for details as to what this actually does. + :param passphrase: A passphrase to apply to the device. + Typically a BIP 39 passphrase. + See device specific documentation for details as to what this actually does. + :return: Whether the setup was successful + :raises UnavailableActionError: if appropriate for the device. + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def restore_device( + self, label: str = "", word_count: int = 24 + ) -> bool: + """ + Restore the device. + + :param label: A label to apply to the device. + See device specific documentation for details as to what this actually does. + :param word_count: The number of BIP 39 mnemonic words. + :return: Whether the restore was successful + :raises UnavailableActionError: if appropriate for the device. + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def backup_device( + self, label: str = "", passphrase: str = "" + ) -> bool: + """ + Backup the device. + + :param label: A label to apply to the backup. + See device specific documentation for details as to what this actually does. + :param passphrase: A passphrase to apply to the backup. + See device specific documentation for details as to what this actually does. + :return: Whether the backup was successful + :raises UnavailableActionError: if appropriate for the device. + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def close(self) -> None: + "Close the HID device." + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def prompt_pin(self) -> bool: + """ + Prompt for PIN. + + :return: Whether the PIN prompt was successful + :raises UnavailableActionError: if appropriate for the device. + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def send_pin(self, pin: str) -> bool: + """ + Send PIN. + + :param pin: The PIN + :return: Whether the PIN successfully unlocked the device + :raises UnavailableActionError: if appropriate for the device. + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def toggle_passphrase(self) -> bool: + """ + Toggle passphrase. + + :return: Whether the passphrase was successfully toggled + :raises UnavailableActionError: if appropriate for the device. + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") + + def can_sign_taproot(self) -> bool: + """ + Whether the device has a version that can sign for Taproot inputs + + :return: Whether Taproot is supported + """ + raise NotImplementedError("The HardwareWalletClient base class " + "does not implement this method") diff --git a/hwilib/key.py b/hwilib/key.py new file mode 100644 index 000000000..299ff544b --- /dev/null +++ b/hwilib/key.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +# Copyright (c) 2020 The HWI developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +""" +Key Classes and Utilities +************************* + +Classes and utilities for working with extended public keys, key origins, and other key related things. +""" + +from . import _base58 as base58 +from .common import ( + AddressType, + Chain, + hash256, + hash160, +) +from .errors import BadArgumentError + +import binascii +import hmac +import hashlib +import struct +from typing import ( + Dict, + List, + Optional, + Sequence, + Tuple, +) + + +HARDENED_FLAG = 1 << 31 + +p = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F +n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8) + +Point = Optional[Tuple[int, int]] + +def H_(x: int) -> int: + """ + Shortcut function that "hardens" a number in a BIP44 path. + """ + return x | HARDENED_FLAG + +def is_hardened(i: int) -> bool: + """ + Returns whether an index is hardened + """ + return i & HARDENED_FLAG != 0 + + +def point_add(p1: Point, p2: Point) -> Point: + if (p1 is None): + return p2 + if (p2 is None): + return p1 + if (p1[0] == p2[0] and p1[1] != p2[1]): + return None + if (p1 == p2): + lam = (3 * p1[0] * p1[0] * pow(2 * p1[1], p - 2, p)) % p + else: + lam = ((p2[1] - p1[1]) * pow(p2[0] - p1[0], p - 2, p)) % p + x3 = (lam * lam - p1[0] - p2[0]) % p + return (x3, (lam * (p1[0] - x3) - p1[1]) % p) + + +def point_mul(p: Point, n: int) -> Point: + r = None + for i in range(256): + if ((n >> i) & 1): + r = point_add(r, p) + p = point_add(p, p) + return r + + +def deserialize_point(b: bytes) -> Point: + x = int.from_bytes(b[1:], byteorder="big") + y = pow((x * x * x + 7) % p, (p + 1) // 4, p) + if (y & 1 != b[0] & 1): + y = p - y + return (x, y) + + +def bytes_to_point(point_bytes: bytes) -> Point: + header = point_bytes[0] + if header == 4: + x = point_bytes = point_bytes[1:33] + y = point_bytes = point_bytes[33:65] + return (int(binascii.hexlify(x), 16), int(binascii.hexlify(y), 16)) + return deserialize_point(point_bytes) + +def point_to_bytes(p: Point) -> bytes: + if p is None: + raise ValueError("Cannot convert None to bytes") + return (b'\x03' if p[1] & 1 else b'\x02') + p[0].to_bytes(32, byteorder="big") + + +# An extended public key (xpub) or private key (xprv). Just a data container for now. +# Only handles deserialization of extended keys into component data to be handled by something else +class ExtendedKey(object): + """ + A BIP 32 extended public key. + """ + + MAINNET_PUBLIC = b'\x04\x88\xB2\x1E' + MAINNET_PRIVATE = b'\x04\x88\xAD\xE4' + TESTNET_PUBLIC = b'\x04\x35\x87\xCF' + TESTNET_PRIVATE = b'\x04\x35\x83\x94' + + def __init__(self, version: bytes, depth: int, parent_fingerprint: bytes, child_num: int, chaincode: bytes, privkey: Optional[bytes], pubkey: bytes) -> None: + """ + :param version: The version bytes for this xpub + :param depth: The depth of this xpub as defined in BIP 32 + :param parent_fingerprint: The 4 byte fingerprint of the parent xpub as defined in BIP 32 + :param child_num: The number of this xpub as defined in BIP 32 + :param chaincode: The chaincode of this xpub as defined in BIP 32 + :param privkey: The private key for this xpub if available + :param pubkey: The public key for this xpub + """ + self.version: bytes = version + self.is_testnet: bool = version == ExtendedKey.TESTNET_PUBLIC or version == ExtendedKey.TESTNET_PRIVATE + self.is_private: bool = version == ExtendedKey.MAINNET_PRIVATE or version == ExtendedKey.TESTNET_PRIVATE + self.depth: int = depth + self.parent_fingerprint: bytes = parent_fingerprint + self.child_num: int = child_num + self.chaincode: bytes = chaincode + self.pubkey: bytes = pubkey + self.privkey: Optional[bytes] = privkey + + @classmethod + def deserialize(cls, xpub: str) -> 'ExtendedKey': + """ + Create an :class:`~ExtendedKey` from a Base58 check encoded xpub + + :param xpub: The Base58 check encoded xpub + """ + data = base58.decode(xpub)[:-4] # Decoded xpub without checksum + return cls.from_bytes(data) + + @classmethod + def from_bytes(cls, data: bytes) -> 'ExtendedKey': + """ + Create an :class:`~ExtendedKey` from a serialized xpub + + :param xpub: The serialized xpub + """ + + version = data[0:4] + if version not in [ExtendedKey.MAINNET_PRIVATE, ExtendedKey.MAINNET_PUBLIC, ExtendedKey.TESTNET_PRIVATE, ExtendedKey.TESTNET_PUBLIC]: + raise BadArgumentError(f"Extended key magic of {version.hex()} is invalid") + is_private = version == ExtendedKey.MAINNET_PRIVATE or version == ExtendedKey.TESTNET_PRIVATE + depth = data[4] + parent_fingerprint = data[5:9] + child_num = struct.unpack('>I', data[9:13])[0] + chaincode = data[13:45] + + if is_private: + privkey = data[46:] + pubkey = point_to_bytes(point_mul(G, int.from_bytes(privkey, byteorder="big"))) + return cls(version, depth, parent_fingerprint, child_num, chaincode, privkey, pubkey) + else: + pubkey = data[45:78] + return cls(version, depth, parent_fingerprint, child_num, chaincode, None, pubkey) + + def serialize(self) -> bytes: + """ + Serialize the ExtendedKey with the serialization format described in BIP 32. + Does not create an xpub string, but the bytes serialized here can be Base58 check encoded into one. + + :return: BIP 32 serialized extended key + """ + r = self.version + struct.pack('B', self.depth) + self.parent_fingerprint + struct.pack('>I', self.child_num) + self.chaincode + if self.is_private: + if self.privkey is None: + raise ValueError("Somehow we are private but don't have a privkey") + r += b"\x00" + self.privkey + else: + r += self.pubkey + return r + + def to_string(self) -> str: + """ + Serialize the ExtendedKey as a Base58 check encoded xpub string + + :return: Base58 check encoded xpub + """ + data = self.serialize() + checksum = hash256(data)[0:4] + return base58.encode(data + checksum) + + def get_printable_dict(self) -> Dict[str, object]: + """ + Get the attributes of this ExtendedKey as a dictionary that can be printed + + :return: Dictionary containing ExtendedKey information that can be printed + """ + d: Dict[str, object] = {} + d['testnet'] = self.is_testnet + d['private'] = self.is_private + d['depth'] = self.depth + d['parent_fingerprint'] = binascii.hexlify(self.parent_fingerprint).decode() + d['child_num'] = self.child_num + d['chaincode'] = binascii.hexlify(self.chaincode).decode() + if self.is_private and isinstance(self.privkey, bytes): + d['privkey'] = binascii.hexlify(self.privkey).decode() + d['pubkey'] = binascii.hexlify(self.pubkey).decode() + return d + + def derive_pub(self, i: int) -> 'ExtendedKey': + """ + Derive the public key at the given child index. + + :param i: The child index of the pubkey to derive + """ + if is_hardened(i): + raise ValueError("Index cannot be larger than 2^31") + + # Data to HMAC. Same as CKDpriv() for public child key. + data = self.pubkey + struct.pack(">L", i) + + # Get HMAC of data + Ihmac = hmac.new(self.chaincode, data, hashlib.sha512).digest() + Il = Ihmac[:32] + Ir = Ihmac[32:] + + # Construct curve point Il*G+K + Il_int = int(binascii.hexlify(Il), 16) + child_pubkey = point_add(point_mul(G, Il_int), bytes_to_point(self.pubkey)) + + # Construct and return a new BIP32Key + pubkey = point_to_bytes(child_pubkey) + chaincode = Ir + fingerprint = hash160(self.pubkey)[0:4] + return ExtendedKey(ExtendedKey.TESTNET_PUBLIC if self.is_testnet else ExtendedKey.MAINNET_PUBLIC, self.depth + 1, fingerprint, i, chaincode, None, pubkey) + + def derive_pub_path(self, path: Sequence[int]) -> 'ExtendedKey': + """ + Derive the public key at the given path + + :param path: Sequence of integers for the path of the pubkey to derive + """ + key = self + for i in path: + key = key.derive_pub(i) + return key + + +class KeyOriginInfo(object): + """ + Object representing the origin of a key. + """ + def __init__(self, fingerprint: bytes, path: Sequence[int]) -> None: + """ + :param fingerprint: The 4 byte BIP 32 fingerprint of a parent key from which this key is derived from + :param path: The derivation path to reach this key from the key at ``fingerprint`` + """ + self.fingerprint: bytes = fingerprint + self.path: Sequence[int] = path + + @classmethod + def deserialize(cls, s: bytes) -> 'KeyOriginInfo': + """ + Deserialize a serialized KeyOriginInfo. + They will be serialized in the same way that PSBTs serialize derivation paths + """ + fingerprint = s[0:4] + s = s[4:] + path = list(struct.unpack("<" + "I" * (len(s) // 4), s)) + return cls(fingerprint, path) + + def serialize(self) -> bytes: + """ + Serializes the KeyOriginInfo in the same way that derivation paths are stored in PSBTs + """ + r = self.fingerprint + r += struct.pack("<" + "I" * len(self.path), *self.path) + return r + + def _path_string(self, hardened_char: str = "h") -> str: + s = "" + for i in self.path: + hardened = is_hardened(i) + i &= ~HARDENED_FLAG + s += "/" + str(i) + if hardened: + s += hardened_char + return s + + def to_string(self, hardened_char: str = "h") -> str: + """ + Return the KeyOriginInfo as a string in the form ///... + This is the same way that KeyOriginInfo is shown in descriptors + """ + s = binascii.hexlify(self.fingerprint).decode() + s += self._path_string(hardened_char) + return s + + @classmethod + def from_string(cls, s: str) -> 'KeyOriginInfo': + """ + Create a KeyOriginInfo from the string + + :param s: The string to parse + """ + s = s.lower() + entries = s.split("/") + fingerprint = binascii.unhexlify(s[0:8]) + path: Sequence[int] = [] + if len(entries) > 1: + path = parse_path(s[9:]) + return cls(fingerprint, path) + + def get_derivation_path(self) -> str: + """ + Return the string for just the path + """ + return "m" + self._path_string() + + def get_full_int_list(self) -> List[int]: + """ + Return a list of ints representing this KeyOriginInfo. + The first int is the fingerprint, followed by the path + """ + xfp = [struct.unpack(" List[int]: + """ + Convert BIP32 path string to list of uint32 integers with hardened flags. + Several conventions are supported to set the hardened flag: -1, 1', 1h + + e.g.: "0/1h/1" -> [0, 0x80000001, 1] + + :param nstr: path string + :return: list of integers + """ + if not nstr: + return [] + + n = nstr.split("/") + + # m/a/b/c => a/b/c + if n[0] == "m": + n = n[1:] + + def str_to_harden(x: str) -> int: + if x.startswith("-"): + return H_(abs(int(x))) + elif x.endswith(("h", "'")): + return H_(int(x[:-1])) + else: + return int(x) + + try: + return [str_to_harden(x) for x in n] + except Exception: + raise ValueError("Invalid BIP32 path", nstr) + + +def get_bip44_purpose(addrtype: AddressType) -> int: + """ + Determine the BIP 44 purpose based on the given :class:`~hwilib.common.AddressType`. + + :param addrtype: The address type + """ + if addrtype == AddressType.LEGACY: + return 44 + elif addrtype == AddressType.SH_WIT: + return 49 + elif addrtype == AddressType.WIT: + return 84 + elif addrtype == AddressType.TAP: + return 86 + else: + raise ValueError("Unknown address type") + + +def get_bip44_chain(chain: Chain) -> int: + """ + Determine the BIP 44 coin type based on the Bitcoin chain type. + + For the Bitcoin mainnet chain, this returns 0. For the other chains, this returns 1. + + :param chain: The chain + """ + if chain == Chain.MAIN: + return 57 + else: + return 1 + +def get_addrtype_from_bip44_purpose(index: int) -> Optional[AddressType]: + purpose = index & ~HARDENED_FLAG + + if purpose == 44: + return AddressType.LEGACY + elif purpose == 49: + return AddressType.SH_WIT + elif purpose == 84: + return AddressType.WIT + elif purpose == 86: + return AddressType.TAP + else: + return None + +def is_standard_path( + path: Sequence[int], + addrtype: AddressType, + chain: Chain, +) -> bool: + if len(path) != 5: + return False + if not is_hardened(path[0]) or not is_hardened(path[1]) or not is_hardened(path[2]): + return False + if is_hardened(path[3]) or is_hardened(path[4]): + return False + computed_addrtype = get_addrtype_from_bip44_purpose(path[0]) + if computed_addrtype is None: + return False + if computed_addrtype != addrtype: + return False + if path[1] != H_(get_bip44_chain(chain)): + return False + if path[3] not in [0, 1]: + return False + return True diff --git a/hwilib/psbt.py b/hwilib/psbt.py new file mode 100644 index 000000000..f5ab31b3c --- /dev/null +++ b/hwilib/psbt.py @@ -0,0 +1,1066 @@ +""" +PSBT Classes and Utilities +************************** +""" + +import base64 +import struct + +from io import BytesIO, BufferedReader +from typing import ( + Dict, + List, + Mapping, + MutableMapping, + Optional, + Sequence, + Set, + Tuple, +) + +from .key import KeyOriginInfo +from .errors import PSBTSerializationError +from .tx import ( + COutPoint, + CTransaction, + CTxIn, + CTxInWitness, + CTxOut, +) +from ._serialize import ( + deser_compact_size, + deser_string, + Readable, + ser_compact_size, + ser_string, + ser_uint256, + uint256_from_str, +) + +def DeserializeHDKeypath( + f: Readable, + key: bytes, + hd_keypaths: MutableMapping[bytes, KeyOriginInfo], + expected_sizes: Sequence[int], +) -> None: + """ + :meta private: + + Deserialize a serialized PSBT public key and keypath key-value pair. + + :param f: The byte stream to read the value from. + :param key: The bytes of the key of the key-value pair. + :param hd_keypaths: Dictionary of public key bytes to their :class:`~hwilib.key.KeyOriginInfo`. + :param expected_sizes: List of key lengths expected for the keypair being deserialized. + """ + if len(key) not in expected_sizes: + raise PSBTSerializationError("Size of key was not the expected size for the type partial signature pubkey. Length: {}".format(len(key))) + pubkey = key[1:] + if pubkey in hd_keypaths: + raise PSBTSerializationError("Duplicate key, input partial signature for pubkey already provided") + + hd_keypaths[pubkey] = KeyOriginInfo.deserialize(deser_string(f)) + +def SerializeHDKeypath(hd_keypaths: Mapping[bytes, KeyOriginInfo], type: bytes) -> bytes: + """ + :meta private: + + Serialize a public key to :class:`~hwilib.key.KeyOriginInfo` mapping as a PSBT key-value pair. + + :param hd_keypaths: The mapping of public key to keypath + :param type: The PSBT type bytes to use + :returns: The serialized keypaths + """ + r = b"" + for pubkey, path in sorted(hd_keypaths.items()): + r += ser_string(type + pubkey) + packed = path.serialize() + r += ser_string(packed) + return r + +class PartiallySignedInput: + """ + An object for a PSBT input map. + """ + + PSBT_IN_NON_WITNESS_UTXO = 0x00 + PSBT_IN_WITNESS_UTXO = 0x01 + PSBT_IN_PARTIAL_SIG = 0x02 + PSBT_IN_SIGHASH_TYPE = 0x03 + PSBT_IN_REDEEM_SCRIPT = 0x04 + PSBT_IN_WITNESS_SCRIPT = 0x05 + PSBT_IN_BIP32_DERIVATION = 0x06 + PSBT_IN_FINAL_SCRIPTSIG = 0x07 + PSBT_IN_FINAL_SCRIPTWITNESS = 0x08 + PSBT_IN_PREVIOUS_TXID = 0x0e + PSBT_IN_OUTPUT_INDEX = 0x0f + PSBT_IN_SEQUENCE = 0x10 + PSBT_IN_REQUIRED_TIME_LOCKTIME = 0x11 + PSBT_IN_REQUIRED_HEIGHT_LOCKTIME = 0x12 + PSBT_IN_TAP_KEY_SIG = 0x13 + PSBT_IN_TAP_SCRIPT_SIG = 0x14 + PSBT_IN_TAP_LEAF_SCRIPT = 0x15 + PSBT_IN_TAP_BIP32_DERIVATION = 0x16 + PSBT_IN_TAP_INTERNAL_KEY = 0x17 + PSBT_IN_TAP_MERKLE_ROOT = 0x18 + + def __init__(self, version: int) -> None: + self.non_witness_utxo: Optional[CTransaction] = None + self.witness_utxo: Optional[CTxOut] = None + self.partial_sigs: Dict[bytes, bytes] = {} + self.sighash: Optional[int] = None + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths: Dict[bytes, KeyOriginInfo] = {} + self.final_script_sig = b"" + self.final_script_witness = CTxInWitness() + self.prev_txid = b"" + self.prev_out: Optional[int] = None + self.sequence: Optional[int] = None + self.time_locktime: Optional[int] = None + self.height_locktime: Optional[int] = None + self.tap_key_sig = b"" + self.tap_script_sigs: Dict[Tuple[bytes, bytes], bytes] = {} + self.tap_scripts: Dict[Tuple[bytes, int], Set[bytes]] = {} + self.tap_bip32_paths: Dict[bytes, Tuple[Set[bytes], KeyOriginInfo]] = {} + self.tap_internal_key = b"" + self.tap_merkle_root = b"" + self.unknown: Dict[bytes, bytes] = {} + + self.version: int = version + + def set_null(self) -> None: + """ + Clear all values in this PSBT input map. + """ + self.non_witness_utxo = None + self.witness_utxo = None + self.partial_sigs.clear() + self.sighash = None + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths.clear() + self.final_script_sig = b"" + self.final_script_witness = CTxInWitness() + self.tap_key_sig = b"" + self.tap_script_sigs.clear() + self.tap_scripts.clear() + self.tap_bip32_paths.clear() + self.tap_internal_key = b"" + self.tap_merkle_root = b"" + self.prev_txid = b"" + self.prev_out = None + self.sequence = None + self.time_locktime = None + self.height_locktime = None + self.unknown.clear() + + def deserialize(self, f: Readable) -> None: + """ + Deserialize a serialized PSBT input. + + :param f: A byte stream containing the serialized PSBT input + """ + key_lookup: Set[bytes] = set() + + while True: + # read the key + try: + key = deser_string(f) + except Exception: + break + + # Check for separator + if len(key) == 0: + break + + # First byte of key is the type + key_type = deser_compact_size(BytesIO(key)) + + if key_type == PartiallySignedInput.PSBT_IN_NON_WITNESS_UTXO: + if key in key_lookup: + raise PSBTSerializationError("Duplicate Key, input non witness utxo already provided") + elif len(key) != 1: + raise PSBTSerializationError("non witness utxo key is more than one byte type") + self.non_witness_utxo = CTransaction() + utxo_bytes = BufferedReader(BytesIO(deser_string(f))) # type: ignore + self.non_witness_utxo.deserialize(utxo_bytes) + self.non_witness_utxo.rehash() + elif key_type == PartiallySignedInput.PSBT_IN_WITNESS_UTXO: + if key in key_lookup: + raise PSBTSerializationError("Duplicate Key, input witness utxo already provided") + elif len(key) != 1: + raise PSBTSerializationError("witness utxo key is more than one byte type") + self.witness_utxo = CTxOut() + tx_out_bytes = BufferedReader(BytesIO(deser_string(f))) # type: ignore + self.witness_utxo.deserialize(tx_out_bytes) + elif key_type == PartiallySignedInput.PSBT_IN_PARTIAL_SIG: + if len(key) != 34 and len(key) != 66: + raise PSBTSerializationError("Size of key was not the expected size for the type partial signature pubkey") + pubkey = key[1:] + if pubkey in self.partial_sigs: + raise PSBTSerializationError("Duplicate key, input partial signature for pubkey already provided") + + sig = deser_string(f) + self.partial_sigs[pubkey] = sig + elif key_type == PartiallySignedInput.PSBT_IN_SIGHASH_TYPE: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input sighash type already provided") + elif len(key) != 1: + raise PSBTSerializationError("sighash key is more than one byte type") + sighash_bytes = deser_string(f) + self.sighash = struct.unpack(" 65: + raise PSBTSerializationError("Input Taproot key path signature is longer than 65 bytes") + elif key_type == PartiallySignedInput.PSBT_IN_TAP_SCRIPT_SIG: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Taproot script signature already provided") + elif len(key) != 65: + raise PSBTSerializationError("Input Taproot script signature key is not 65 bytes") + xonly = key[1:33] + script_hash = key[33:65] + sig = deser_string(f) + if len(sig) < 64: + raise PSBTSerializationError("Input Taproot script path signature is shorter than 64 bytes") + elif len(sig) > 65: + raise PSBTSerializationError("Input Taproot script path signature is longer than 65 bytes") + self.tap_script_sigs[(xonly, script_hash)] = sig + elif key_type == PartiallySignedInput.PSBT_IN_TAP_LEAF_SCRIPT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Taproot leaf script already provided") + elif len(key) < 34: + raise PSBTSerializationError("Input Taproot leaf script key is not at least 34 bytes") + elif (len(key) - 2) % 32 != 0: + raise PSBTSerializationError("Input Taproot leaf script key's control block is not valid") + script = deser_string(f) + if len(script) == 0: + raise PSBTSerializationError("Input Taproot leaf script cannot be empty") + leaf_script = (script[:-1], int(script[-1])) + if leaf_script not in self.tap_scripts: + self.tap_scripts[leaf_script] = set() + self.tap_scripts[(script[:-1], int(script[-1]))].add(key[1:]) + elif key_type == PartiallySignedInput.PSBT_IN_TAP_BIP32_DERIVATION: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Taproot BIP 32 keypath already provided") + elif len(key) != 33: + raise PSBTSerializationError("Input Taproot BIP 32 keypath key is not 33 bytes") + xonly = key[1:33] + value = deser_string(f) + vs = BytesIO(value) + num_hashes = deser_compact_size(vs) + leaf_hashes = set() + for i in range(0, num_hashes): + leaf_hashes.add(vs.read(32)) + self.tap_bip32_paths[xonly] = (leaf_hashes, KeyOriginInfo.deserialize(vs.read())) + elif key_type == PartiallySignedInput.PSBT_IN_TAP_INTERNAL_KEY: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Taproot internal key already provided") + elif len(key) != 1: + raise PSBTSerializationError("Input Taproot internal key key is more than one byte type") + self.tap_internal_key = deser_string(f) + if len(self.tap_internal_key) != 32: + raise PSBTSerializationError("Input Taproot internal key is not 32 bytes") + elif key_type == PartiallySignedInput.PSBT_IN_TAP_MERKLE_ROOT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, input Taproot merkle root already provided") + elif len(key) != 1: + raise PSBTSerializationError("Input Taproot merkle root key is more than one byte type") + self.tap_merkle_root = deser_string(f) + if len(self.tap_merkle_root) != 32: + raise PSBTSerializationError("Input Taproot merkle root is not 32 bytes") + else: + if key in self.unknown: + raise PSBTSerializationError("Duplicate key, key for unknown value already provided") + unknown_bytes = deser_string(f) + self.unknown[key] = unknown_bytes + + key_lookup.add(key) + + # Make sure required PSBTv2 fields are present + if self.version >= 2: + if len(self.prev_txid) == 0: + raise PSBTSerializationError("Previous TXID is required in PSBTv2") + if self.prev_out is None: + raise PSBTSerializationError("Previous output's index is required in PSBTv2") + + def serialize(self) -> bytes: + """ + Serialize this PSBT input + + :returns: The serialized PSBT input + """ + r = b"" + + if self.non_witness_utxo: + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_NON_WITNESS_UTXO)) + tx = self.non_witness_utxo.serialize_with_witness() + r += ser_string(tx) + + if self.witness_utxo: + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_WITNESS_UTXO)) + tx = self.witness_utxo.serialize() + r += ser_string(tx) + + if len(self.final_script_sig) == 0 and self.final_script_witness.is_null(): + for pubkey, sig in sorted(self.partial_sigs.items()): + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_PARTIAL_SIG) + pubkey) + r += ser_string(sig) + + if self.sighash is not None: + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_SIGHASH_TYPE)) + r += ser_string(struct.pack("= 2: + if len(self.prev_txid) != 0: + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_PREVIOUS_TXID)) + r += ser_string(self.prev_txid) + + if self.prev_out is not None: + r += ser_string(ser_compact_size(PartiallySignedInput.PSBT_IN_OUTPUT_INDEX)) + r += ser_string(struct.pack(" None: + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths: Dict[bytes, KeyOriginInfo] = {} + self.amount: Optional[int] = None + self.script = b"" + self.tap_internal_key = b"" + self.tap_tree = b"" + self.tap_bip32_paths: Dict[bytes, Tuple[Set[bytes], KeyOriginInfo]] = {} + self.unknown: Dict[bytes, bytes] = {} + + self.version: int = version + + def set_null(self) -> None: + """ + Clear this PSBT output map + """ + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths.clear() + self.tap_internal_key = b"" + self.tap_tree = b"" + self.tap_bip32_paths.clear() + self.amount = None + self.script = b"" + self.unknown.clear() + + def deserialize(self, f: Readable) -> None: + """ + Deserialize a serialized PSBT output map + + :param f: A byte stream containing the serialized PSBT output + """ + key_lookup: Set[bytes] = set() + + while True: + # read the key + try: + key = deser_string(f) + except Exception: + break + + # Check for separator + if len(key) == 0: + break + + # First byte of key is the type + key_type = deser_compact_size(BytesIO(key)) + + if key_type == PartiallySignedOutput.PSBT_OUT_REDEEM_SCRIPT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, output redeemScript already provided") + elif len(key) != 1: + raise PSBTSerializationError("Output redeemScript key is more than one byte type") + self.redeem_script = deser_string(f) + elif key_type == PartiallySignedOutput.PSBT_OUT_WITNESS_SCRIPT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, output witnessScript already provided") + elif len(key) != 1: + raise PSBTSerializationError("Output witnessScript key is more than one byte type") + self.witness_script = deser_string(f) + elif key_type == PartiallySignedOutput.PSBT_OUT_BIP32_DERIVATION: + DeserializeHDKeypath(f, key, self.hd_keypaths, [34, 66]) + elif key_type == PartiallySignedOutput.PSBT_OUT_AMOUNT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, output amount already provided") + elif len(key) != 1: + raise PSBTSerializationError("Output amount key is more than one byte type") + v = deser_string(f) + if len(v) != 8: + raise PSBTSerializationError("Output amount is not 8 bytes") + self.amount = struct.unpack("= 2: + if self.amount is None: + raise PSBTSerializationError("PSBT_OUTPUT_AMOUNT is required in PSBTv2") + if len(self.script) == 0: + raise PSBTSerializationError("PSBT_OUTPUT_SCRIPT is required in PSBTv2") + + def serialize(self) -> bytes: + """ + Serialize this PSBT output + + :returns: The serialized PSBT output + """ + r = b"" + if len(self.redeem_script) != 0: + r += ser_string(ser_compact_size(PartiallySignedOutput.PSBT_OUT_REDEEM_SCRIPT)) + r += ser_string(self.redeem_script) + + if len(self.witness_script) != 0: + r += ser_string(ser_compact_size(PartiallySignedOutput.PSBT_OUT_WITNESS_SCRIPT)) + r += ser_string(self.witness_script) + + r += SerializeHDKeypath(self.hd_keypaths, ser_compact_size(PartiallySignedOutput.PSBT_OUT_BIP32_DERIVATION)) + + if self.version >= 2: + if self.amount is not None: + r += ser_string(ser_compact_size(PartiallySignedOutput.PSBT_OUT_AMOUNT)) + r += ser_string(struct.pack(" CTxOut: + """ + Creates a CTxOut for this output + + :returns: The CTxOut + """ + assert self.amount is not None + assert len(self.script) != 0 + return CTxOut(self.amount, self.script) + +class PSBT(object): + """ + A class representing a PSBT + """ + + PSBT_GLOBAL_UNSIGNED_TX = 0x00 + PSBT_GLOBAL_XPUB = 0x01 + PSBT_GLOBAL_TX_VERSION = 0x02 + PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03 + PSBT_GLOBAL_INPUT_COUNT = 0x04 + PSBT_GLOBAL_OUTPUT_COUNT = 0x05 + PSBT_GLOBAL_TX_MODIFIABLE = 0x06 + PSBT_GLOBAL_VERSION = 0xFB + + def __init__(self, tx: Optional[CTransaction] = None) -> None: + """ + :param tx: A Bitcoin transaction that specifies the inputs and outputs to use + """ + if tx: + self.tx = tx + else: + self.tx = CTransaction() + self.inputs: List[PartiallySignedInput] = [] + self.outputs: List[PartiallySignedOutput] = [] + self.unknown: Dict[bytes, bytes] = {} + self.xpub: Dict[bytes, KeyOriginInfo] = {} + self.tx_version: Optional[int] = None + self.fallback_locktime: Optional[int] = None + self.tx_modifiable: Optional[int] = None + + # Assume version 0 PSBT + self.version = 0 + self.explicit_version = False + + def deserialize(self, psbt: str) -> None: + """ + Deserialize a base 64 encoded PSBT. + + :param psbt: A base 64 PSBT. + """ + psbt_bytes = base64.b64decode(psbt.strip()) + f = BufferedReader(BytesIO(psbt_bytes)) # type: ignore + end = len(psbt_bytes) + + # Read the magic bytes + magic = f.read(5) + if magic != b"psbt\xff": + raise PSBTSerializationError("invalid magic") + + key_lookup: Set[bytes] = set() + + input_count = None + output_count = None + + # Read loop + while True: + # read the key + try: + key = deser_string(f) + except Exception: + break + + # Check for separator + if len(key) == 0: + break + + # First byte of key is the type + key_type = deser_compact_size(BytesIO(key)) + + # Do stuff based on type + if key_type == PSBT.PSBT_GLOBAL_UNSIGNED_TX: + # Checks for correctness + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, unsigned tx already provided") + elif len(key) > 1: + raise PSBTSerializationError("Global unsigned tx key is more than one byte type") + + # read in value + tx_bytes = BufferedReader(BytesIO(deser_string(f))) # type: ignore + self.tx.deserialize(tx_bytes) + + # Make sure that all scriptSigs and scriptWitnesses are empty + for txin in self.tx.vin: + if len(txin.scriptSig) != 0 or not self.tx.wit.is_null(): + raise PSBTSerializationError("Unsigned tx does not have empty scriptSigs and scriptWitnesses") + elif key_type == PSBT.PSBT_GLOBAL_XPUB: + DeserializeHDKeypath(f, key, self.xpub, [79]) + elif key_type == PSBT.PSBT_GLOBAL_TX_VERSION: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, global transaction version is already provided") + elif len(key) > 1: + raise PSBTSerializationError("Global transaction version key is more than one byte type") + v = deser_string(f) + if len(v) != 4: + raise PSBTSerializationError("Global transaction version is not 4 bytes") + self.tx_version = struct.unpack(" 1: + raise PSBTSerializationError("Global fallback locktime key is more than one byte type") + v = deser_string(f) + if len(v) != 4: + raise PSBTSerializationError("Global fallback locktime is not 4 bytes") + self.fallback_locktime = struct.unpack(" 1: + raise PSBTSerializationError("Global input count key is more than one byte type") + _ = deser_compact_size(f) # Value length, we can ignore this + input_count = deser_compact_size(f) + elif key_type == PSBT.PSBT_GLOBAL_OUTPUT_COUNT: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, global output count is already provided") + elif len(key) > 1: + raise PSBTSerializationError("Global output count key is more than one byte type") + _ = deser_compact_size(f) # Value length, we can ignore this + output_count = deser_compact_size(f) + elif key_type == PSBT.PSBT_GLOBAL_TX_MODIFIABLE: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, global tx modifiable flags is already provided") + elif len(key) > 1: + raise PSBTSerializationError("Global tx modifiable flags key is more than one byte type") + v = deser_string(f) + if len(v) != 1: + raise PSBTSerializationError("Global tx modifiable flags is not 1 bytes") + self.tx_modifiable = struct.unpack(" 1: + raise PSBTSerializationError("Global PSBT version key is more than one byte type") + v = deser_string(f) + if len(v) != 4: + raise PSBTSerializationError("Global PSBT version is not 1 bytes") + self.version = struct.unpack("= 2: + # Tx version, input, and output counts are required + if self.tx_version is None: + raise PSBTSerializationError("PSBT_GLOBAL_TX_VERSION is required in PSBTv2") + if input_count is None: + raise PSBTSerializationError("PSBT_GLOBAL_INPUT_COUNT is required in PSBTv2") + if output_count is None: + raise PSBTSerializationError("PSBT_GLOBAL_OUTPUT_COUNT is required in PSBTv2") + # Unsigned tx is disallowed + if not self.tx.is_null(): + raise PSBTSerializationError("PSBT_GLOBAL_UNSIGNED_TX is not allowed in PSBTv2") + + # Read input data + if input_count is None: + input_count = len(self.tx.vin) + for i in range(input_count): + if f.tell() == end: + break + psbt_in = PartiallySignedInput(self.version) + psbt_in.deserialize(f) + self.inputs.append(psbt_in) + + if self.version >= 2: + prev_txid = psbt_in.prev_txid + else: + prev_txid = ser_uint256(self.tx.vin[i].prevout.hash) + + if psbt_in.non_witness_utxo: + psbt_in.non_witness_utxo.rehash() + if psbt_in.non_witness_utxo.hash != prev_txid: + raise PSBTSerializationError("Non-witness UTXO does not match outpoint hash") + + if (len(self.inputs) != input_count): + raise PSBTSerializationError("Inputs provided does not match the number of inputs in transaction") + + # Read output data + if output_count is None: + output_count = len(self.tx.vout) + for i in range(output_count): + if f.tell() == end: + break + output = PartiallySignedOutput(self.version) + output.deserialize(f) + self.outputs.append(output) + + if len(self.outputs) != output_count: + raise PSBTSerializationError("Outputs provided does not match the number of outputs in transaction") + + self.cache_unsigned_tx_pieces() + + def serialize(self) -> str: + """ + Serialize the PSBT as a base 64 encoded string. + + :returns: The base 64 encoded string. + """ + r = b"" + + # magic bytes + r += b"psbt\xff" + + if self.version == 0: + # unsigned tx flag + r += ser_string(ser_compact_size(PSBT.PSBT_GLOBAL_UNSIGNED_TX)) + + # write serialized tx + tx = self.tx.serialize_with_witness() + r += ser_compact_size(len(tx)) + r += tx + + # write xpubs + r += SerializeHDKeypath(self.xpub, ser_compact_size(PSBT.PSBT_GLOBAL_XPUB)) + + if self.version >= 2: + assert self.tx_version is not None + r += ser_string(ser_compact_size(PSBT.PSBT_GLOBAL_TX_VERSION)) + r += ser_string(struct.pack(" 0 or self.explicit_version: + r += ser_string(ser_compact_size(PSBT.PSBT_GLOBAL_VERSION)) + r += ser_string(struct.pack(" None: + """ + If this PSBT is v0, then the global unsigned transaction will be used to fill in the PSBTv2 + fields so that all users of the PSBT classes can use the same PSBTv2 interface regardless + of PSBT version. + + Does nothing if the PSBT is already v2. + """ + # To make things easier, we split up the global transaction + # and use the PSBTv2 fields for PSBTv0 + if self.tx is not None: + self.setup_from_tx(self.tx) + + def setup_from_tx(self, tx: CTransaction): + """ + Fills in the PSBTv2 fields for this PSBT given a transaction + + :param tx: The CTransaction to fill from + """ + self.tx_version = tx.nVersion + self.fallback_locktime = tx.nLockTime + + for i, txin in enumerate(tx.vin): + psbt_in = self.inputs[i] + + psbt_in.prev_txid = ser_uint256(txin.prevout.hash) + psbt_in.prev_out = txin.prevout.n + psbt_in.sequence = txin.nSequence + + for i, txout in enumerate(tx.vout): + psbt_out = self.outputs[i] + + psbt_out.amount = txout.nValue + psbt_out.script = txout.scriptPubKey + + def compute_lock_time(self) -> int: + """ + Computes the lock time for this transaction + + :returns: The lock time + """ + time_lock: Optional[int] = 0 + height_lock: Optional[int] = 0 + + for psbt_in in self.inputs: + if psbt_in.time_locktime is not None and psbt_in.height_locktime is None: + height_lock = None + if time_lock is None: + raise PSBTSerializationError("Cannot require both time and height locktimes") + elif psbt_in.time_locktime is None and psbt_in.height_locktime is not None: + time_lock = None + if height_lock is None: + raise PSBTSerializationError("Cannot require both time and height locktimes") + + if psbt_in.time_locktime is not None and time_lock is not None: + time_lock = max(time_lock, psbt_in.time_locktime) + if psbt_in.height_locktime is not None and height_lock is not None: + height_lock = max(height_lock, psbt_in.height_locktime) + + if height_lock is not None and height_lock > 0: + return height_lock + if time_lock is not None and time_lock > 0: + return time_lock + if self.fallback_locktime is not None: + return self.fallback_locktime + return 0 + + def get_unsigned_tx(self) -> CTransaction: + """ + Get the unsigned transaction represented by this PSBT + + :return: A CTransaction + """ + if not self.tx.is_null(): + return self.tx + + assert self.tx_version is not None + + tx = CTransaction() + tx.nVersion = self.tx_version + self.nLockTime = self.compute_lock_time() + + for psbt_in in self.inputs: + assert psbt_in.prev_txid is not None + assert psbt_in.prev_out is not None + assert psbt_in.sequence is not None + + txin = CTxIn(COutPoint(uint256_from_str(psbt_in.prev_txid), psbt_in.prev_out), b"", psbt_in.sequence) + tx.vin.append(txin) + + for psbt_out in self.outputs: + assert psbt_out.amount is not None + + txout = CTxOut(psbt_out.amount, psbt_out.script) + tx.vout.append(txout) + + tx.rehash() + return tx + + def _convert_version(self, version) -> None: + self.version = version + for psbt_in in self.inputs: + psbt_in.version = version + for psbt_out in self.outputs: + psbt_out.version = version + + def convert_to_v2(self) -> None: + """ + Sets this PSBT to version 2 + """ + self._convert_version(2) + + def convert_to_v0(self) -> None: + """ + Sets this PSBT to version 0 + """ + self._convert_version(0) + self.tx = self.get_unsigned_tx() + self.explicit_version = False diff --git a/hwilib/devices/trezorlib/messages/.keep b/hwilib/py.typed similarity index 100% rename from hwilib/devices/trezorlib/messages/.keep rename to hwilib/py.typed diff --git a/hwilib/serializations.py b/hwilib/serializations.py deleted file mode 100644 index 63fc0c878..000000000 --- a/hwilib/serializations.py +++ /dev/null @@ -1,835 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2010 ArtForz -- public domain half-a-node -# Copyright (c) 2012 Jeff Garzik -# Copyright (c) 2010-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. -"""Bitcoin Object Python Serializations - -Modified from the test/test_framework/mininode.py file from the -Bitcoin repository - -CTransaction,CTxIn, CTxOut, etc....: - data structures that should map to corresponding structures in - bitcoin/primitives for transactions only -ser_*, deser_*: functions that handle serialization/deserialization -""" - -from io import BytesIO, BufferedReader -from codecs import encode -from .errors import PSBTSerializationError -import struct -import binascii -import hashlib -import copy -import base64 - -def sha256(s): - return hashlib.new('sha256', s).digest() - -def ripemd160(s): - return hashlib.new('ripemd160', s).digest() - -def hash256(s): - return sha256(sha256(s)) - -def hash160(s): - return ripemd160(sha256(s)) - - -# Serialization/deserialization tools -def ser_compact_size(l): - r = b"" - if l < 253: - r = struct.pack("B", l) - elif l < 0x10000: - r = struct.pack(">= 32 - return rs - - -def uint256_from_str(s): - r = 0 - t = struct.unpack(" 42: - return (False, None, None) - - if self.scriptPubKey[0] != 0 and (self.scriptPubKey[0] < 81 or self.scriptPubKey[0] > 96): - return (False, None, None) - - if self.scriptPubKey[1] + 2 == len(self.scriptPubKey): - return (True, self.scriptPubKey[0] - 0x50 if self.scriptPubKey[0] else 0, self.scriptPubKey[2:]) - - return (False, None, None) - - def __repr__(self): - return "CTxOut(nValue=%i.%08i scriptPubKey=%s)" \ - % (self.nValue, self.nValue, binascii.hexlify(self.scriptPubKey)) - - -class CScriptWitness(object): - def __init__(self): - # stack is a vector of strings - self.stack = [] - - def __repr__(self): - return "CScriptWitness(%s)" % \ - (",".join([x.hex() for x in self.stack])) - - def is_null(self): - if self.stack: - return False - return True - - -class CTxInWitness(object): - def __init__(self): - self.scriptWitness = CScriptWitness() - - def deserialize(self, f): - self.scriptWitness.stack = deser_string_vector(f) - - def serialize(self): - return ser_string_vector(self.scriptWitness.stack) - - def __repr__(self): - return repr(self.scriptWitness) - - def is_null(self): - return self.scriptWitness.is_null() - - -class CTxWitness(object): - def __init__(self): - self.vtxinwit = [] - - def deserialize(self, f): - for i in range(len(self.vtxinwit)): - self.vtxinwit[i].deserialize(f) - - def serialize(self): - r = b"" - # This is different than the usual vector serialization -- - # we omit the length of the vector, which is required to be - # the same length as the transaction's vin vector. - for x in self.vtxinwit: - r += x.serialize() - return r - - def __repr__(self): - return "CTxWitness(%s)" % \ - (';'.join([repr(x) for x in self.vtxinwit])) - - def is_null(self): - for x in self.vtxinwit: - if not x.is_null(): - return False - return True - - -class CTransaction(object): - def __init__(self, tx=None): - if tx is None: - self.nVersion = 1 - self.vin = [] - self.vout = [] - self.wit = CTxWitness() - self.nLockTime = 0 - self.sha256 = None - self.hash = None - else: - self.nVersion = tx.nVersion - self.vin = copy.deepcopy(tx.vin) - self.vout = copy.deepcopy(tx.vout) - self.nLockTime = tx.nLockTime - self.sha256 = tx.sha256 - self.hash = tx.hash - self.wit = copy.deepcopy(tx.wit) - - def deserialize(self, f): - self.nVersion = struct.unpack(" 0: - raise PSBTSerializationError("Duplicate key, input sighash type already provided") - elif len(key) != 1: - raise PSBTSerializationError("sighash key is more than one byte type") - value = deser_string(f) - self.sighash = struct.unpack(" 0: - r += ser_string(b"\x03") - r += ser_string(struct.pack(" 1: - raise PSBTSerializationError("Global unsigned tx key is more than one byte type") - - # read in value - value = BufferedReader(BytesIO(deser_string(f))) - self.tx.deserialize(value) - - # Make sure that all scriptSigs and scriptWitnesses are empty - for txin in self.tx.vin: - if len(txin.scriptSig) != 0 or not self.tx.wit.is_null(): - raise PSBTSerializationError("Unsigned tx does not have empty scriptSigs and scriptWitnesses") - - else: - if key in self.unknown: - raise PSBTSerializationError("Duplicate key, key for unknown value already provided") - value = deser_string(f) - self.unknown[key] = value - - # make sure that we got an unsigned tx - if self.tx.is_null(): - raise PSBTSerializationError("No unsigned trasaction was provided") - - # Read input data - for txin in self.tx.vin: - if f.tell() == end: - break - input = PartiallySignedInput() - input.deserialize(f) - self.inputs.append(input) - - if input.non_witness_utxo and input.non_witness_utxo.rehash() and input.non_witness_utxo.sha256 != txin.prevout.sha256: - raise PSBTSerializationError("Non-witness UTXO does not match outpoint hash") - - if (len(self.inputs) != len(self.tx.vin)): - raise PSBTSerializationError("Inputs provided does not match the number of inputs in transaction") - - # Read output data - for txout in self.tx.vout: - if f.tell() == end: - break - output = PartiallySignedOutput() - output.deserialize(f) - self.outputs.append(output) - - if len(self.outputs) != len(self.tx.vout): - raise PSBTSerializationError("Outputs provided does not match the number of outputs in transaction") - - if not self.is_sane(): - raise PSBTSerializationError("PSBT is not sane") - - def serialize(self): - r = b"" - - # magic bytes - r += b"psbt\xff" - - # unsigned tx flag - r += b"\x01\x00" - - # write serialized tx - tx = self.tx.serialize_with_witness() - r += ser_compact_size(len(tx)) - r += tx - - # unknowns - for key, value in sorted(self.unknown.items()): - r += ser_string(key) - r += ser_string(value) - - # separator - r += b"\x00" - - # inputs - for input in self.inputs: - r += input.serialize() - - # outputs - for output in self.outputs: - r += output.serialize() - - # return hex string - return HexToBase64(binascii.hexlify(r)).decode() - - def is_sane(self): - for input in self.inputs: - if not input.is_sane(): - return False - return True diff --git a/hwilib/tx.py b/hwilib/tx.py new file mode 100644 index 000000000..91b1215ff --- /dev/null +++ b/hwilib/tx.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# Copyright (c) 2010 ArtForz -- public domain half-a-node +# Copyright (c) 2012 Jeff Garzik +# Copyright (c) 2010-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. +"""Bitcoin Object Python Serializations + +Modified from the test/test_framework/mininode.py file from the +Bitcoin repository + +CTransaction,CTxIn, CTxOut, etc....: + data structures that should map to corresponding structures in + bitcoin/primitives for transactions only +""" + +import copy +import struct + +from .common import ( + hash256, +) +from ._script import ( + is_opreturn, + is_p2sh, + is_p2pkh, + is_p2pk, + is_witness, + is_p2wsh, +) +from ._serialize import ( + deser_uint256, + deser_string, + deser_string_vector, + deser_vector, + Readable, + ser_uint256, + ser_string, + ser_string_vector, + ser_vector, + uint256_from_str, +) + +from typing import ( + List, + Optional, + Tuple, +) + +# Objects that map to bitcoind objects, which can be serialized/deserialized + +MSG_WITNESS_FLAG = 1 << 30 + +class COutPoint(object): + def __init__(self, hash: int = 0, n: int = 0xffffffff): + self.hash = hash + self.n = n + + def deserialize(self, f: Readable) -> None: + self.hash = deser_uint256(f) + self.n = struct.unpack(" bytes: + r = b"" + r += ser_uint256(self.hash) + r += struct.pack(" str: + return "COutPoint(hash=%064x n=%i)" % (self.hash, self.n) + + +class CTxIn(object): + def __init__( + self, + outpoint: Optional[COutPoint] = None, + scriptSig: bytes = b"", + nSequence: int = 0, + ): + if outpoint is None: + self.prevout = COutPoint() + else: + self.prevout = outpoint + self.scriptSig = scriptSig + self.nSequence = nSequence + + def deserialize(self, f: Readable) -> None: + self.prevout = COutPoint() + self.prevout.deserialize(f) + self.scriptSig = deser_string(f) + self.nSequence = struct.unpack(" bytes: + r = b"" + r += self.prevout.serialize() + r += ser_string(self.scriptSig) + r += struct.pack(" str: + return "CTxIn(prevout=%s scriptSig=%s nSequence=%i)" \ + % (repr(self.prevout), self.scriptSig.hex(), + self.nSequence) + + +class CTxOut(object): + def __init__(self, nValue: int = 0, scriptPubKey: bytes = b""): + self.nValue = nValue + self.scriptPubKey = scriptPubKey + + def deserialize(self, f: Readable) -> None: + self.nValue = struct.unpack(" bytes: + r = b"" + r += struct.pack(" bool: + return is_opreturn(self.scriptPubKey) + + def is_p2sh(self) -> bool: + return is_p2sh(self.scriptPubKey) + + def is_p2wsh(self) -> bool: + return is_p2wsh(self.scriptPubKey) + + def is_p2pkh(self) -> bool: + return is_p2pkh(self.scriptPubKey) + + def is_p2pk(self) -> bool: + return is_p2pk(self.scriptPubKey) + + def is_witness(self) -> Tuple[bool, int, bytes]: + return is_witness(self.scriptPubKey) + + def __repr__(self) -> str: + return "CTxOut(nValue=%i.%08i scriptPubKey=%s)" \ + % (self.nValue // 100_000_000, self.nValue % 100_000_000, self.scriptPubKey.hex()) + + +class CScriptWitness(object): + def __init__(self) -> None: + # stack is a vector of strings + self.stack: List[bytes] = [] + + def __repr__(self) -> str: + return "CScriptWitness(%s)" % \ + (",".join([x.hex() for x in self.stack])) + + def is_null(self) -> bool: + if self.stack: + return False + return True + + +class CTxInWitness(object): + def __init__(self) -> None: + self.scriptWitness = CScriptWitness() + + def deserialize(self, f: Readable) -> None: + self.scriptWitness.stack = deser_string_vector(f) + + def serialize(self) -> bytes: + return ser_string_vector(self.scriptWitness.stack) + + def __repr__(self) -> str: + return repr(self.scriptWitness) + + def is_null(self) -> bool: + return self.scriptWitness.is_null() + + +class CTxWitness(object): + def __init__(self) -> None: + self.vtxinwit: List[CTxInWitness] = [] + + def deserialize(self, f: Readable) -> None: + for i in range(len(self.vtxinwit)): + self.vtxinwit[i].deserialize(f) + + def serialize(self) -> bytes: + r = b"" + # This is different than the usual vector serialization -- + # we omit the length of the vector, which is required to be + # the same length as the transaction's vin vector. + for x in self.vtxinwit: + r += x.serialize() + return r + + def __repr__(self) -> str: + return "CTxWitness(%s)" % \ + (';'.join([repr(x) for x in self.vtxinwit])) + + def is_null(self) -> bool: + for x in self.vtxinwit: + if not x.is_null(): + return False + return True + + +class CTransaction(object): + def __init__(self, tx: Optional['CTransaction'] = None) -> None: + if tx is None: + self.nVersion = 1 + self.vin: List[CTxIn] = [] + self.vout: List[CTxOut] = [] + self.wit = CTxWitness() + self.nLockTime = 0 + self.sha256: Optional[int] = None + self.hash: Optional[bytes] = None + else: + self.nVersion = tx.nVersion + self.vin = copy.deepcopy(tx.vin) + self.vout = copy.deepcopy(tx.vout) + self.nLockTime = tx.nLockTime + self.sha256 = tx.sha256 + self.hash = tx.hash + self.wit = copy.deepcopy(tx.wit) + + def deserialize(self, f: Readable) -> None: + self.nVersion = struct.unpack(" bytes: + r = b"" + r += struct.pack(" bytes: + flags = 0 + if not self.wit.is_null(): + flags |= 1 + r = b"" + r += struct.pack(" bytes: + return self.serialize_without_witness() + + # Recalculate the txid (transaction hash without witness) + def rehash(self) -> None: + self.sha256 = None + self.calc_sha256() + + # We will only cache the serialization without witness in + # self.sha256 and self.hash -- those are expected to be the txid. + def calc_sha256(self, with_witness: bool = False) -> Optional[int]: + if with_witness: + # Don't cache the result, just return it + return uint256_from_str(hash256(self.serialize_with_witness())) + + if self.sha256 is None: + self.sha256 = uint256_from_str(hash256(self.serialize_without_witness())) + self.hash = hash256(self.serialize()) + return None + + def is_null(self) -> bool: + return len(self.vin) == 0 and len(self.vout) == 0 + + def __repr__(self) -> str: + return "CTransaction(nVersion=%i vin=%s vout=%s wit=%s nLockTime=%i)" \ + % (self.nVersion, repr(self.vin), repr(self.vout), repr(self.wit), self.nLockTime) diff --git a/hwilib/udev/20-hw1.rules b/hwilib/udev/20-hw1.rules index 1fd2c66b9..5928fd429 100644 --- a/hwilib/udev/20-hw1.rules +++ b/hwilib/udev/20-hw1.rules @@ -1,9 +1,14 @@ -SUBSYSTEMS=="usb", ATTRS{idVendor}=="2581", ATTRS{idProduct}=="1b7c", MODE="0660", GROUP="plugdev" -SUBSYSTEMS=="usb", ATTRS{idVendor}=="2581", ATTRS{idProduct}=="2b7c", MODE="0660", GROUP="plugdev" -SUBSYSTEMS=="usb", ATTRS{idVendor}=="2581", ATTRS{idProduct}=="3b7c", MODE="0660", GROUP="plugdev" -SUBSYSTEMS=="usb", ATTRS{idVendor}=="2581", ATTRS{idProduct}=="4b7c", MODE="0660", GROUP="plugdev" -SUBSYSTEMS=="usb", ATTRS{idVendor}=="2581", ATTRS{idProduct}=="1807", MODE="0660", GROUP="plugdev" -SUBSYSTEMS=="usb", ATTRS{idVendor}=="2581", ATTRS{idProduct}=="1808", MODE="0660", GROUP="plugdev" -SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0000", MODE="0660", GROUP="plugdev" -SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0001", MODE="0660", GROUP="plugdev" -SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0004", MODE="0660", GROUP="plugdev" \ No newline at end of file +# HW.1 / Nano +SUBSYSTEMS=="usb", ATTRS{idVendor}=="2581", ATTRS{idProduct}=="1b7c|2b7c|3b7c|4b7c", TAG+="uaccess", TAG+="udev-acl" +# Blue +SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0000|0000|0001|0002|0003|0004|0005|0006|0007|0008|0009|000a|000b|000c|000d|000e|000f|0010|0011|0012|0013|0014|0015|0016|0017|0018|0019|001a|001b|001c|001d|001e|001f", TAG+="uaccess", TAG+="udev-acl" +# Nano S +SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0001|1000|1001|1002|1003|1004|1005|1006|1007|1008|1009|100a|100b|100c|100d|100e|100f|1010|1011|1012|1013|1014|1015|1016|1017|1018|1019|101a|101b|101c|101d|101e|101f", TAG+="uaccess", TAG+="udev-acl" +# Aramis +SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0002|2000|2001|2002|2003|2004|2005|2006|2007|2008|2009|200a|200b|200c|200d|200e|200f|2010|2011|2012|2013|2014|2015|2016|2017|2018|2019|201a|201b|201c|201d|201e|201f", TAG+="uaccess", TAG+="udev-acl" +# HW2 +SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0003|3000|3001|3002|3003|3004|3005|3006|3007|3008|3009|300a|300b|300c|300d|300e|300f|3010|3011|3012|3013|3014|3015|3016|3017|3018|3019|301a|301b|301c|301d|301e|301f", TAG+="uaccess", TAG+="udev-acl" +# Nano X +SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0004|4000|4001|4002|4003|4004|4005|4006|4007|4008|4009|400a|400b|400c|400d|400e|400f|4010|4011|4012|4013|4014|4015|4016|4017|4018|4019|401a|401b|401c|401d|401e|401f", TAG+="uaccess", TAG+="udev-acl" +# Ledger Test +SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0005|5000|5001|5002|5003|5004|5005|5006|5007|5008|5009|500a|500b|500c|500d|500e|500f|5010|5011|5012|5013|5014|5015|5016|5017|5018|5019|501a|501b|501c|501d|501e|501f", TAG+="uaccess", TAG+="udev-acl" \ No newline at end of file diff --git a/hwilib/udev/53-hid-bitbox02.rules b/hwilib/udev/53-hid-bitbox02.rules new file mode 100644 index 000000000..2daffc03b --- /dev/null +++ b/hwilib/udev/53-hid-bitbox02.rules @@ -0,0 +1 @@ +SUBSYSTEM=="usb", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="bitbox02_%n", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403" diff --git a/hwilib/udev/54-hid-bitbox02.rules b/hwilib/udev/54-hid-bitbox02.rules new file mode 100644 index 000000000..1b74e4774 --- /dev/null +++ b/hwilib/udev/54-hid-bitbox02.rules @@ -0,0 +1 @@ +KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="03eb", ATTRS{idProduct}=="2403", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="bitbox02-%n" diff --git a/hwilib/udev/55-usb-jade.rules b/hwilib/udev/55-usb-jade.rules new file mode 100644 index 000000000..0e4dfeaa1 --- /dev/null +++ b/hwilib/udev/55-usb-jade.rules @@ -0,0 +1,2 @@ +KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="jade%n" +KERNEL=="ttyACM*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d4", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="jade%n" diff --git a/hwilib/udev/README.md b/hwilib/udev/README.md index aea809b70..b3078d78f 100644 --- a/hwilib/udev/README.md +++ b/hwilib/udev/README.md @@ -3,22 +3,23 @@ This directory contains all of the udev rules for the supported devices as retrieved from vendor websites and repositories. These are necessary for the devices to be reachable on linux environments. -`20-hw1.rules` (Ledger): https://github.com/LedgerHQ/udev-rules/blob/master/20-hw1.rules -`51-coinkite.rules` (Coldcard): https://github.com/Coldcard/ckcc-protocol/blob/master/51-coinkite.rules -`51-hid-digitalbitbox.rules`, `52-hid-digitalbitbox.rules` (Digital Bitbox): https://shiftcrypto.ch/start_linux -`51-trezor.rules` (Trezor): https://github.com/trezor/trezor-common/blob/master/udev/51-trezor.rules -`51-usb-keepkey.rules` (Keepkey): https://github.com/keepkey/udev-rules/blob/master/51-usb-keepkey.rules + - `20-hw1.rules` (Ledger): https://github.com/LedgerHQ/udev-rules/blob/master/20-hw1.rules + - `51-coinkite.rules` (Coldcard): https://github.com/Coldcard/ckcc-protocol/blob/master/51-coinkite.rules + - `51-hid-digitalbitbox.rules`, `52-hid-digitalbitbox.rules` (Digital Bitbox): https://shiftcrypto.ch/start_linux + - `51-trezor.rules` (Trezor): https://github.com/trezor/trezor-common/blob/master/udev/51-trezor.rules + - `51-usb-keepkey.rules` (Keepkey): https://github.com/keepkey/udev-rules/blob/master/51-usb-keepkey.rules # Usage Apply these rules by copying them to `/etc/udev/rules.d/` and notifying `udevadm`. Your user will need to be added to the `plugdev` group, which needs to be created if it does not already exist. +```Shell +cd hwilib/; \ + sudo cp udev/*.rules /etc/udev/rules.d/ && \ + sudo udevadm trigger && \ + sudo udevadm control --reload-rules && \ + sudo groupadd plugdev && \ + sudo usermod -aG plugdev `whoami` ``` -$ cd hwilib/ -$ sudo cp udev/*.rules /etc/udev/rules.d/ -$ sudo udevadm trigger -$ sudo udevadm control --reload-rules -$ sudo groupadd plugdev -$ sudo usermod -aG plugdev `whoami` -``` + diff --git a/hwilib/udevinstaller.py b/hwilib/udevinstaller.py index cd9f5ae69..b0c994e42 100644 --- a/hwilib/udevinstaller.py +++ b/hwilib/udevinstaller.py @@ -1,11 +1,31 @@ +""" +UDev Rules Installer +******************** + +Classes and utilities for installing device udev rules. +""" + +from .errors import NeedsRootError + from subprocess import check_call, CalledProcessError, DEVNULL -from .errors import NEED_TO_BE_ROOT -from shutil import copy -from os import path, listdir, getlogin, geteuid +from shutil import copy, which +from os import path, listdir, getlogin, geteuid, chmod class UDevInstaller(object): + """ + Installs the udev rules + """ @staticmethod - def install(source, location): + def install(source: str, location: str) -> bool: + """ + Install the udev rules from source into location. + This will also reload and trigger udevadm so that devices matching the new rules will be detected. + The user will be added to the ``plugdev`` group. If the group doesn't exist, the user will be added to it. + + :param source: The path to the source directory containing the rules + :param location: The path to the directory to copy the rules to + :return: Whether the install was successful + """ try: udev_installer = UDevInstaller() udev_installer.copy_udev_rule_files(source, location) @@ -14,45 +34,65 @@ def install(source, location): udev_installer.add_user_plugdev_group() except CalledProcessError: if geteuid() != 0: - return {'error': 'Need to be root.', 'code': NEED_TO_BE_ROOT} + raise NeedsRootError("Need to be root.") raise - return {"success": True} + return True - def __init__(self): - self._udevadm = '/sbin/udevadm' - self._groupadd = '/usr/sbin/groupadd' - self._usermod = '/usr/sbin/usermod' + def __init__(self) -> None: + self._udevadm = which('udevadm') + self._groupadd = which('groupadd') + self._usermod = which('usermod') - def _execute(self, command, *args): - command = [command] + list(args) + def _execute(self, cmd: str, *args: str) -> None: + command = [cmd] + list(args) check_call(command, stderr=DEVNULL, stdout=DEVNULL) - def trigger(self): + def trigger(self) -> None: + """ + Run ``udevadm trigger`` + """ + assert self._udevadm self._execute(self._udevadm, 'trigger') - def reload_rules(self): + def reload_rules(self) -> None: + """ + Run ``udevadm control --reload-rules`` + """ + assert self._udevadm self._execute(self._udevadm, 'control', '--reload-rules') - def add_user_plugdev_group(self): + def add_user_plugdev_group(self) -> None: + """ + Add the user to the ``plugdev`` group + """ self._create_group('plugdev') self._add_user_to_group(getlogin(), 'plugdev') - def _create_group(self, name): + def _create_group(self, name: str) -> None: + assert self._groupadd try: self._execute(self._groupadd, name) except CalledProcessError as e: if e.returncode != 9: # group already exists raise - def _add_user_to_group(self, user, group): + def _add_user_to_group(self, user: str, group: str) -> None: + assert self._usermod self._execute(self._usermod, '-aG', group, user) - def copy_udev_rule_files(self, source, location): + def copy_udev_rule_files(self, source: str, location: str) -> None: + """ + Copy the udev rules from source to location + + :param source: The path to the source directory containing the rules + :param location: The path to the directory to copy the rules to + """ src_dir_path = source for rules_file_name in listdir(_resource_path(src_dir_path)): if '.rules' in rules_file_name: rules_file_path = _resource_path(path.join(src_dir_path, rules_file_name)) copy(rules_file_path, location) + chmod(path.join(location, rules_file_name), 0o644) -def _resource_path(relative_path): +def _resource_path(relative_path: str) -> str: return path.join(path.dirname(__file__), relative_path) diff --git a/hwilib/ui/bitbox02pairing.ui b/hwilib/ui/bitbox02pairing.ui new file mode 100644 index 000000000..6b1a1997a --- /dev/null +++ b/hwilib/ui/bitbox02pairing.ui @@ -0,0 +1,120 @@ + + + BitBox02PairingDialog + + + Qt::WindowModal + + + + 0 + 0 + 400 + 209 + + + + Dialog + + + + + 30 + 160 + 341 + 32 + + + + Qt::Horizontal + + + QDialogButtonBox::No|QDialogButtonBox::Yes + + + + + + 20 + 80 + 331 + 61 + + + + + DejaVu Sans Mono + 15 + 75 + true + + + + + + + Qt::RichText + + + Qt::AlignCenter + + + + + + 20 + 10 + 351 + 61 + + + + + 11 + + + + Please verify the pairing code matches what is +shown on your BitBox02. + + + Qt::PlainText + + + + + + + buttonBox + accepted() + BitBox02PairingDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + BitBox02PairingDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/hwilib/ui/displayaddressdialog.ui b/hwilib/ui/displayaddressdialog.ui new file mode 100644 index 000000000..3fbbd2608 --- /dev/null +++ b/hwilib/ui/displayaddressdialog.ui @@ -0,0 +1,219 @@ + + + DisplayAddressDialog + + + + 0 + 0 + 500 + 200 + + + + Dialog + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + 0 + 0 + + + + + 300 + 50 + + + + + 500 + 50 + + + + + + + + 5 + + + + + P2SH-P2WPKH + + + true + + + + + + + P2WPKH + + + + + + + P2PKH + + + + + + + + + + Address + + + + + + + + 100 + 30 + + + + true + + + + + + + + 200 + 30 + + + + + + + + + 0 + 0 + + + + + 40 + 40 + + + + + 50 + 40 + + + + Go + + + false + + + true + + + + + + + + 0 + 25 + + + + + 80 + 25 + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + 100 + 16777215 + + + + Derivation Path + + + + + + + + + buttonBox + accepted() + DisplayAddressDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + DisplayAddressDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/hwilib/ui/getkeypooloptionsdialog.ui b/hwilib/ui/getkeypooloptionsdialog.ui new file mode 100644 index 000000000..7ccb5bb36 --- /dev/null +++ b/hwilib/ui/getkeypooloptionsdialog.ui @@ -0,0 +1,281 @@ + + + GetKeypoolOptionsDialog + + + + 0 + 0 + 440 + 224 + + + + Dialog + + + + + 80 + 180 + 341 + 32 + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + 20 + 20 + 41 + 18 + + + + Start + + + + + + 20 + 60 + 31 + 18 + + + + End + + + + + + 80 + 10 + 161 + 32 + + + + 2147483647 + + + + + + 80 + 50 + 161 + 32 + + + + 2147483647 + + + 1000 + + + + + + 280 + 10 + 88 + 22 + + + + Internal + + + + + + 280 + 40 + 88 + 22 + + + + keypool + + + true + + + + + + 280 + 70 + 141 + 101 + + + + + + + + + 10 + 10 + 121 + 22 + + + + P2SH-P2WPKH + + + true + + + + + + 10 + 40 + 105 + 22 + + + + P2WPKH + + + + + + 10 + 70 + 105 + 22 + + + + P2PKH + + + + + + + 10 + 90 + 231 + 91 + + + + + + + + + 100 + 10 + 111 + 32 + + + + 2147483647 + + + 0 + + + + + + 10 + 10 + 81 + 22 + + + + Account + + + true + + + + + + 10 + 50 + 61 + 22 + + + + Path + + + + + false + + + + 80 + 50 + 141 + 32 + + + + m/0'/0'/* + + + + + + + + buttonBox + accepted() + GetKeypoolOptionsDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + GetKeypoolOptionsDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/hwilib/ui/getxpubdialog.ui b/hwilib/ui/getxpubdialog.ui new file mode 100644 index 000000000..162e984b5 --- /dev/null +++ b/hwilib/ui/getxpubdialog.ui @@ -0,0 +1,117 @@ + + + GetXpubDialog + + + + 0 + 0 + 1050 + 125 + + + + Dialog + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + 200 + 16777215 + + + + Qt::NoFocus + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + Derivation Path + + + + + + + + 0 + 30 + + + + + 16777215 + 30 + + + + QTextEdit::NoWrap + + + true + + + + + + + + 200 + 30 + + + + + + + + + 200 + 16777215 + + + + Get xpub + + + + + + + xpub + + + Qt::AlignCenter + + + + + + + + diff --git a/hwilib/ui/hwiqt.pyproject b/hwilib/ui/hwiqt.pyproject new file mode 100644 index 000000000..60fccf589 --- /dev/null +++ b/hwilib/ui/hwiqt.pyproject @@ -0,0 +1,3 @@ +{ + "files": ["signpsbtdialog.ui","mainwindow.ui","sendpindialog.ui","getxpubdialog.ui","signmessagedialog.ui","displayaddressdialog.ui","setpassphrasedialog.ui","getkeypooloptionsdialog.ui"] +} diff --git a/hwilib/ui/mainwindow.ui b/hwilib/ui/mainwindow.ui new file mode 100644 index 000000000..f47526d94 --- /dev/null +++ b/hwilib/ui/mainwindow.ui @@ -0,0 +1,268 @@ + + + MainWindow + + + + 0 + 0 + 650 + 400 + + + + MainWindow + + + + + + + + + + 0 + 0 + + + + + 130 + 20 + + + + + 200 + 20 + + + + Connected devices + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + 100 + 30 + + + + Refresh + + + + + + + + + Qt::Horizontal + + + + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + + + Set Passphrase + + + + + + + false + + + Get an xpub + + + + + + + false + + + Sign Message + + + + + + + false + + + Sign PSBT + + + + + + + false + + + Change the options used for getkeypool + + + Change getkeypool options + + + + + + + false + + + Send Pin + + + + + + + false + + + Toggle Passphrase + + + + + + + false + + + Display Address + + + + + + + true + + + + 0 + 0 + + + + Actions + + + Qt::AlignCenter + + + + + + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + + + Keypool + + + Qt::AlignCenter + + + + + + + true + + + + + + + Descriptors + + + Qt::AlignCenter + + + + + + + true + + + + + + + + + + + diff --git a/hwilib/ui/sendpindialog.ui b/hwilib/ui/sendpindialog.ui new file mode 100644 index 000000000..3030a4749 --- /dev/null +++ b/hwilib/ui/sendpindialog.ui @@ -0,0 +1,180 @@ + + + SendPinDialog + + + + 0 + 0 + 250 + 250 + + + + Dialog + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + 0 + 30 + + + + + 0 + 30 + + + + false + + + + + + + 5 + + + 5 + + + 5 + + + 0 + + + 20 + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + SendPinDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SendPinDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/hwilib/ui/setpassphrasedialog.ui b/hwilib/ui/setpassphrasedialog.ui new file mode 100644 index 000000000..c2cb40ffa --- /dev/null +++ b/hwilib/ui/setpassphrasedialog.ui @@ -0,0 +1,92 @@ + + + SetPassphraseDialog + + + + 0 + 0 + 400 + 100 + + + + Dialog + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + 0 + 30 + + + + false + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + SetPassphraseDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SetPassphraseDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/hwilib/ui/signmessagedialog.ui b/hwilib/ui/signmessagedialog.ui new file mode 100644 index 000000000..9ca09dc71 --- /dev/null +++ b/hwilib/ui/signmessagedialog.ui @@ -0,0 +1,182 @@ + + + SignMessageDialog + + + + 0 + 0 + 600 + 260 + + + + Dialog + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + 0 + 50 + + + + + 16777215 + 100 + + + + + + + + + 300 + 30 + + + + + 16777215 + 30 + + + + + + + + Signature + + + + + + + Message + + + + + + + + 150 + 16777215 + + + + Sign Message + + + false + + + true + + + + + + + + 0 + 30 + + + + + 16777215 + 50 + + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + Derivation Path + + + + + + + + 80 + 0 + + + + + 80 + 16777215 + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + + buttonBox + accepted() + SignMessageDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SignMessageDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/hwilib/ui/signpsbtdialog.ui b/hwilib/ui/signpsbtdialog.ui new file mode 100644 index 000000000..fbdeab4f7 --- /dev/null +++ b/hwilib/ui/signpsbtdialog.ui @@ -0,0 +1,225 @@ + + + SignPSBTDialog + + + + 0 + 0 + 650 + 400 + + + + Dialog + + + + 10 + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + PSBT To Sign + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + PSBT Result + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + 300 + 120 + + + + + + + + + 300 + 120 + + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Sign PSBT + + + false + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + + buttonBox + accepted() + SignPSBTDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SignPSBTDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 000000000..459e5c338 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,45 @@ +[mypy] +warn_unused_configs = True +disallow_any_generics = True +disallow_subclassing_any = True +disallow_untyped_calls = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_untyped_decorators = True +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_return_any = True +implicit_reexport = True +strict_equality = True + +[mypy-hwilib.devices.ckcc.*] +follow_imports = skip + +[mypy-hwilib.devices.trezorlib.*] +follow_imports = skip + +[mypy-hwilib.devices.ledger_bitcoin.btchip.*] +follow_imports = skip + +[mypy-hwilib.devices.jadepy.*] +follow_imports = skip + +[mypy-serial.tools] +ignore_missing_imports = True + +[mypy-hid] +ignore_missing_imports = True + +[mypy-pyaes] +ignore_missing_imports = True + +[mypy-semver] +ignore_missing_imports = True + +[mypy-usb1] +ignore_missing_imports = True + +[mypy-mnemonic] +ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock index 56754c461..7dc238071 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,217 +1,1304 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + [[package]] +name = "alabaster" +version = "0.7.13" +description = "A configurable sidebar-enabled Sphinx theme" category = "dev" -description = "Python graph (network) package" +optional = false +python-versions = ">=3.6" +files = [ + {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, + {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, +] + +[[package]] name = "altgraph" +version = "0.17.3" +description = "Python graph (network) package" +category = "dev" optional = false python-versions = "*" -version = "0.16.1" +files = [ + {file = "altgraph-0.17.3-py2.py3-none-any.whl", hash = "sha256:c8ac1ca6772207179ed8003ce7687757c04b0b71536f81e2ac5755c6226458fe"}, + {file = "altgraph-0.17.3.tar.gz", hash = "sha256:ad33358114df7c9416cdb8fa1eaa5852166c505118717021c6a8c7c7abbd03dd"}, +] [[package]] -category = "dev" -description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" name = "autopep8" +version = "1.5.7" +description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" +category = "dev" optional = false python-versions = "*" -version = "1.4.4" +files = [ + {file = "autopep8-1.5.7-py2.py3-none-any.whl", hash = "sha256:aa213493c30dcdac99537249ee65b24af0b2c29f2e83cd8b3f68760441ed0db9"}, + {file = "autopep8-1.5.7.tar.gz", hash = "sha256:276ced7e9e3cb22e5d7c14748384a5cf5d9002257c0ed50c0e075b68011bb6d0"}, +] [package.dependencies] -pycodestyle = ">=2.4.0" +pycodestyle = ">=2.7.0" +toml = "*" [[package]] +name = "babel" +version = "2.11.0" +description = "Internationalization utilities" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"}, + {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"}, +] + +[package.dependencies] +pytz = ">=2015.7" + +[[package]] +name = "base58" +version = "2.1.1" +description = "Base58 and Base58Check implementation." +category = "main" +optional = false +python-versions = ">=3.5" +files = [ + {file = "base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2"}, + {file = "base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c"}, +] + +[package.extras] +tests = ["PyHamcrest (>=2.0.2)", "mypy", "pytest (>=4.6)", "pytest-benchmark", "pytest-cov", "pytest-flake8"] + +[[package]] +name = "bitbox02" +version = "6.1.1" +description = "Python library for bitbox02 communication" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "bitbox02-6.1.1-py3-none-any.whl", hash = "sha256:f37f1e571f06aa0a4005441ca1948b53e68e3bef0b642963303b42810e2b1486"}, + {file = "bitbox02-6.1.1.tar.gz", hash = "sha256:99503409d6c61899f8e11eb11e7a29866b2754cb02c03acf34cdef99755aedd8"}, +] + +[package.dependencies] +base58 = ">=2.0.0" +ecdsa = ">=0.14" +hidapi = ">=0.7.99.post21" +noiseprotocol = ">=0.3" +protobuf = ">=3.20" +semver = ">=2.8.1" +typing-extensions = ">=3.7.4" + +[[package]] +name = "cbor" +version = "1.0.0" +description = "RFC 7049 - Concise Binary Object Representation" category = "main" -description = "ECDSA cryptographic signature library (pure python)" -name = "ecdsa" optional = false python-versions = "*" -version = "0.13.2" +files = [ + {file = "cbor-1.0.0.tar.gz", hash = "sha256:13225a262ddf5615cbd9fd55a76a0d53069d18b07d2e9f19c39e6acb8609bbb6"}, +] [[package]] +name = "certifi" +version = "2022.12.7" +description = "Python package for providing Mozilla's CA Bundle." category = "dev" -description = "Discover and load entry points from installed packages." -name = "entrypoints" optional = false -python-versions = ">=2.7" -version = "0.3" +python-versions = ">=3.6" +files = [ + {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, + {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, +] + +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, + {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, + {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, + {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, + {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, + {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, + {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, + {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, + {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, + {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, + {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, + {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, + {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, + {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, + {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, + {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, + {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, + {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, + {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, + {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, + {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, + {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, + {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, + {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, + {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, + {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, + {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, + {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, + {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, + {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, + {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, + {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, + {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, +] + +[package.dependencies] +pycparser = "*" [[package]] +name = "charset-normalizer" +version = "3.0.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "dev" -description = "the modular source code checker: pep8, pyflakes and co" -name = "flake8" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.7.8" +python-versions = "*" +files = [ + {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"}, + {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"}, + {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"}, + {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"}, + {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"}, + {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"}, + {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"}, + {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cryptography" +version = "39.0.1" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:6687ef6d0a6497e2b58e7c5b852b53f62142cfa7cd1555795758934da363a965"}, + {file = "cryptography-39.0.1-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:706843b48f9a3f9b9911979761c91541e3d90db1ca905fd63fee540a217698bc"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:5d2d8b87a490bfcd407ed9d49093793d0f75198a35e6eb1a923ce1ee86c62b41"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e17b26de248c33f3acffb922748151d71827d6021d98c70e6c1a25ddd78505"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e124352fd3db36a9d4a21c1aa27fd5d051e621845cb87fb851c08f4f75ce8be6"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:5aa67414fcdfa22cf052e640cb5ddc461924a045cacf325cd164e65312d99502"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:35f7c7d015d474f4011e859e93e789c87d21f6f4880ebdc29896a60403328f1f"}, + {file = "cryptography-39.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f24077a3b5298a5a06a8e0536e3ea9ec60e4c7ac486755e5fb6e6ea9b3500106"}, + {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c64d1bd842ca2633e74a1a28033d139368ad959872533b1bab8c80e8240a0c"}, + {file = "cryptography-39.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0f8da300b5c8af9f98111ffd512910bc792b4c77392a9523624680f7956a99d4"}, + {file = "cryptography-39.0.1-cp36-abi3-win32.whl", hash = "sha256:fe913f20024eb2cb2f323e42a64bdf2911bb9738a15dba7d3cce48151034e3a8"}, + {file = "cryptography-39.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ced4e447ae29ca194449a3f1ce132ded8fcab06971ef5f618605aacaa612beac"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:807ce09d4434881ca3a7594733669bd834f5b2c6d5c7e36f8c00f691887042ad"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c5caeb8188c24888c90b5108a441c106f7faa4c4c075a2bcae438c6e8ca73cef"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4789d1e3e257965e960232345002262ede4d094d1a19f4d3b52e48d4d8f3b885"}, + {file = "cryptography-39.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:96f1157a7c08b5b189b16b47bc9db2332269d6680a196341bf30046330d15388"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e422abdec8b5fa8462aa016786680720d78bdce7a30c652b7fadf83a4ba35336"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:b0afd054cd42f3d213bf82c629efb1ee5f22eba35bf0eec88ea9ea7304f511a2"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:6f8ba7f0328b79f08bdacc3e4e66fb4d7aab0c3584e0bd41328dce5262e26b2e"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ef8b72fa70b348724ff1218267e7f7375b8de4e8194d1636ee60510aae104cd0"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:aec5a6c9864be7df2240c382740fcf3b96928c46604eaa7f3091f58b878c0bb6"}, + {file = "cryptography-39.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fdd188c8a6ef8769f148f88f859884507b954cc64db6b52f66ef199bb9ad660a"}, + {file = "cryptography-39.0.1.tar.gz", hash = "sha256:d1f6198ee6d9148405e49887803907fe8962a23e6c6f83ea7d98f1c0de375695"}, +] [package.dependencies] -entrypoints = ">=0.3.0,<0.4.0" -mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.5.0,<2.6.0" -pyflakes = ">=2.1.0,<2.2.0" +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +pep8test = ["black", "check-manifest", "mypy", "ruff", "types-pytz", "types-requests"] +sdist = ["setuptools-rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-shard (>=0.1.2)", "pytest-subtests", "pytest-xdist", "pytz"] +test-randomorder = ["pytest-randomly"] +tox = ["tox"] [[package]] +name = "docutils" +version = "0.18.1" +description = "Docutils -- Python Documentation Utilities" category = "dev" -description = "Clean single-source support for Python 3 and 2" -marker = "sys_platform == \"win32\"" -name = "future" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "0.17.1" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"}, + {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"}, +] [[package]] +name = "ecdsa" +version = "0.18.0" +description = "ECDSA cryptographic signature library (pure python)" category = "main" -description = "A Cython interface to the hidapi from https://github.com/signal11/hidapi" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"}, + {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"}, +] + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + +[[package]] +name = "flake8" +version = "3.9.2" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +files = [ + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, +] + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.7.0,<2.8.0" +pyflakes = ">=2.3.0,<2.4.0" + +[[package]] name = "hidapi" +version = "0.13.1" +description = "A Cython interface to the hidapi from https://github.com/libusb/hidapi" +category = "main" optional = false python-versions = "*" -version = "0.7.99.post21" +files = [ + {file = "hidapi-0.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0e245b5177fc59548e4f76400a07a7882fca48a1c650f7c5d902afe519869bd0"}, + {file = "hidapi-0.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec97dcb6697659be0878aac14143ba00ab43e86a6bbec2fa6af46f833ab69743"}, + {file = "hidapi-0.13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a48a0c3f1113ac05d389ae4eff00485a253c63dc6e7c7c17ae3026d063ca809a"}, + {file = "hidapi-0.13.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b2421ace55d1cca96b8d5c09f918e7662b6ac7b7dbb0f624814e831dab5062e"}, + {file = "hidapi-0.13.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:571e3a07bcbdc13c2d2af9bd09839e6f45cfbac8d5e2d28b7e1b844427813162"}, + {file = "hidapi-0.13.1-cp310-cp310-win32.whl", hash = "sha256:6c0a285b3720d1277f318b574d987483d6127b75d7d76decdd6c85c724af8ef7"}, + {file = "hidapi-0.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:da551cdf88122e9d7dbb93557efa69d35fb1b1fea0510a5bd461af331ad92892"}, + {file = "hidapi-0.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6490021f7d8227fb6277355f143539404c1b9074d77d64febbe0d4a629f0835b"}, + {file = "hidapi-0.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ebffcf48798bbf2018a86f1465eeabce0da98c2066c99a121db97dd397b798b3"}, + {file = "hidapi-0.13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c6ae8a0b69ca9ae0ad3f948e1174231a9b781a70ff3642e6b110dfec770cf59"}, + {file = "hidapi-0.13.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93b95d8bb6c87f6db03b6eb9faffb2fe977ac788791898651826c1b61a4734c1"}, + {file = "hidapi-0.13.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aca9899e7fccc6f9e6a909681c5d97cc2ba224683d886c96bcdca3165454d875"}, + {file = "hidapi-0.13.1-cp311-cp311-win32.whl", hash = "sha256:93dfc2739e0d3f773ae148fed9c0f020c13e17d9d512cf73b54a5e60f0b28c68"}, + {file = "hidapi-0.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:68b9281c22e7832e1d140d3eb65718486b61ac24701837c0ff211f2195bedfb8"}, + {file = "hidapi-0.13.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3c190d4f8be403c8ee543684013e85494ac690da2d636a03a4f7f5b75b41bba5"}, + {file = "hidapi-0.13.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cb651134546cabc1e515b38626732770ec3ab0157b33dc132fc2f4607858658"}, + {file = "hidapi-0.13.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ab4dd2e19918beff50638be89b9626191df4514456fd7388611762cdbbd46cc"}, + {file = "hidapi-0.13.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:871808f5b4c8049f758b94375e24bfe87c8a58f91f7c2ca4c482ab003c9af9a7"}, + {file = "hidapi-0.13.1-cp36-cp36m-win32.whl", hash = "sha256:5a51521200fecd677695d7bd12a6efdccf315a16929a65b836fb7ec8596a24f4"}, + {file = "hidapi-0.13.1-cp36-cp36m-win_amd64.whl", hash = "sha256:d1e91ef0cca8185a858491d9e253f585e58888c5350013ab9305caf4b7f563ba"}, + {file = "hidapi-0.13.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8a735ea49c68d6008b4e07b3de5b4c211f875c262dd14d6669a3629bc482a375"}, + {file = "hidapi-0.13.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8647eeb286d1b46104a297258b4af15e7878b3dbdb9951ba01cf233b2ff513a"}, + {file = "hidapi-0.13.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf6a90f988c3a1b1910f441c29e4c8c21150c6cb0ed5a8be3bea715aed02002d"}, + {file = "hidapi-0.13.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58580360dc69206a54efccc6d234baa6dbfa56394386ff77bdd67228b27c9759"}, + {file = "hidapi-0.13.1-cp37-cp37m-win32.whl", hash = "sha256:1eb1247930ebf45275937db011856880dc6e3c961290dc8783835230312250b6"}, + {file = "hidapi-0.13.1-cp37-cp37m-win_amd64.whl", hash = "sha256:155bce0c0741ee3b4cf93ac53d02526ef16008a1ef3d9071cd67dd4e190c02d8"}, + {file = "hidapi-0.13.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2f013d7e107aef90a4c79802f7e44f5280f9fd62d3d43e307dd7d108c95277cd"}, + {file = "hidapi-0.13.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:afc7991c12235f3392b5f744b75878429dbf5a3a4a0d51ca18b1e82ebcce7033"}, + {file = "hidapi-0.13.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3fcfe6e4a75651baf704001ddf765326ddd64b55b1ba6cd0e2e0851409c27c4"}, + {file = "hidapi-0.13.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64ec0dcce28232354a91d4575a74eab77fcbb4379fa2f4650e9eaadd7561d887"}, + {file = "hidapi-0.13.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8174e095386a37eb3d8eb08e942f3aa7e6bd703c296dbb6c45cbca40862a7253"}, + {file = "hidapi-0.13.1-cp38-cp38-win32.whl", hash = "sha256:2dcaf4b9df91a10c0e2904f175c0bfed53d51a3fe73d2aba7061ddc360249f86"}, + {file = "hidapi-0.13.1-cp38-cp38-win_amd64.whl", hash = "sha256:f5a16a8a97ac944775369713b35bb47a7cb6b3091458e7cfe4fdec3f7ac9c29e"}, + {file = "hidapi-0.13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0933fef6e1599c38093bec22a3dded359fb0a9ce57ab43db8947eca7308fe0a8"}, + {file = "hidapi-0.13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:20c06a2256696e83c5f2789dcd9a8109009af5f3e75af0082934867fc6de5283"}, + {file = "hidapi-0.13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b59778cec7ef9f6151778bf65a1b1dd164014238d7d4345c8e376c4a4baac0e"}, + {file = "hidapi-0.13.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22d5af6f776fffce8078d6d3785dc8c1647688183c1dbda24acf6dd841d76ac3"}, + {file = "hidapi-0.13.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ed536627429bc299b28188a28d2fe4230b472d737ee9dc5c8376fd66c418674"}, + {file = "hidapi-0.13.1-cp39-cp39-win32.whl", hash = "sha256:63494ccce46bf60491d7e44f1371443a178e21afa287b341288057bfbefbe872"}, + {file = "hidapi-0.13.1-cp39-cp39-win_amd64.whl", hash = "sha256:5696e1479c203e6123db5f6c5effe6fdca3a5da332bd9ba559687056af7b1d3c"}, + {file = "hidapi-0.13.1.tar.gz", hash = "sha256:99b18b28ec414ef9b604ddaed08182e486a400486f31ca56f61d537eed1d17cf"}, +] [package.dependencies] setuptools = ">=19.0" [[package]] -category = "main" -description = "Pure-python wrapper for libusb-1.0" +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] + +[[package]] +name = "importlib-metadata" +version = "6.0.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"}, + {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"}, +] + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] name = "libusb1" +version = "2.0.1" +description = "Pure-python wrapper for libusb-1.0" +category = "main" optional = false python-versions = "*" -version = "1.7.1" +files = [ + {file = "libusb1-2.0.1-py3-none-any.whl", hash = "sha256:81381ce1d8852a4d4345b2ee8218971d35865b5f025fef96b43ee082757099cd"}, + {file = "libusb1-2.0.1-py3-none-win32.whl", hash = "sha256:9fda3055c98ab043cfb3beac93ef1de2900ad11d949c694f58bdf414ce2bd03c"}, + {file = "libusb1-2.0.1-py3-none-win_amd64.whl", hash = "sha256:a97bcb90f589d863c5e971b013c8cf7e1915680a951e66c4222a2c5bb64b7153"}, + {file = "libusb1-2.0.1.tar.gz", hash = "sha256:d3ba82ecf7ab6a48d21dac6697e26504670cc3522b8e5941bd28fb56cf3f6c46"}, +] [[package]] -category = "dev" -description = "Mach-O header analysis and editing" -marker = "sys_platform == \"darwin\"" name = "macholib" +version = "1.16.2" +description = "Mach-O header analysis and editing" +category = "dev" optional = false python-versions = "*" -version = "1.11" +files = [ + {file = "macholib-1.16.2-py2.py3-none-any.whl", hash = "sha256:44c40f2cd7d6726af8fa6fe22549178d3a4dfecc35a9cd15ea916d9c83a688e0"}, + {file = "macholib-1.16.2.tar.gz", hash = "sha256:557bbfa1bb255c20e9abafe7ed6cd8046b48d9525db2f9b77d3122a63a2a8bf8"}, +] [package.dependencies] -altgraph = ">=0.15" +altgraph = ">=0.17" [[package]] +name = "markupsafe" +version = "2.1.2" +description = "Safely add untrusted strings to HTML/XML markup." category = "dev" -description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"}, + {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"}, + {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"}, + {file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"}, + {file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"}, + {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"}, + {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, +] + +[[package]] name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" optional = false python-versions = "*" -version = "0.6.1" +files = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] [[package]] -category = "main" -description = "Implementation of Bitcoin BIP-0039" name = "mnemonic" +version = "0.20" +description = "Implementation of Bitcoin BIP-0039" +category = "main" optional = false -python-versions = "*" -version = "0.18" +python-versions = ">=3.5" +files = [ + {file = "mnemonic-0.20-py3-none-any.whl", hash = "sha256:acd2168872d0379e7a10873bb3e12bf6c91b35de758135c4fbd1015ef18fafc5"}, + {file = "mnemonic-0.20.tar.gz", hash = "sha256:7c6fb5639d779388027a77944680aee4870f0fcd09b1e42a5525ee2ce4c625f6"}, +] + +[[package]] +name = "mypy" +version = "0.991" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mypy-0.991-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab"}, + {file = "mypy-0.991-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d"}, + {file = "mypy-0.991-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6"}, + {file = "mypy-0.991-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb"}, + {file = "mypy-0.991-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305"}, + {file = "mypy-0.991-cp310-cp310-win_amd64.whl", hash = "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c"}, + {file = "mypy-0.991-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372"}, + {file = "mypy-0.991-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f"}, + {file = "mypy-0.991-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33"}, + {file = "mypy-0.991-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05"}, + {file = "mypy-0.991-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad"}, + {file = "mypy-0.991-cp311-cp311-win_amd64.whl", hash = "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297"}, + {file = "mypy-0.991-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813"}, + {file = "mypy-0.991-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711"}, + {file = "mypy-0.991-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd"}, + {file = "mypy-0.991-cp37-cp37m-win_amd64.whl", hash = "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef"}, + {file = "mypy-0.991-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a"}, + {file = "mypy-0.991-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93"}, + {file = "mypy-0.991-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf"}, + {file = "mypy-0.991-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135"}, + {file = "mypy-0.991-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70"}, + {file = "mypy-0.991-cp38-cp38-win_amd64.whl", hash = "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243"}, + {file = "mypy-0.991-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d"}, + {file = "mypy-0.991-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5"}, + {file = "mypy-0.991-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3"}, + {file = "mypy-0.991-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648"}, + {file = "mypy-0.991-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476"}, + {file = "mypy-0.991-cp39-cp39-win_amd64.whl", hash = "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461"}, + {file = "mypy-0.991-py3-none-any.whl", hash = "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb"}, + {file = "mypy-0.991.tar.gz", hash = "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06"}, +] [package.dependencies] -pbkdf2 = "*" +mypy-extensions = ">=0.4.3" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] [[package]] +name = "noiseprotocol" +version = "0.3.1" +description = "Implementation of Noise Protocol Framework" category = "main" -description = "PKCS#5 v2.0 PBKDF2 Module" -name = "pbkdf2" optional = false -python-versions = "*" -version = "1.3" +python-versions = "~=3.5" +files = [ + {file = "noiseprotocol-0.3.1-py3-none-any.whl", hash = "sha256:2e1a603a38439636cf0ffd8b3e8b12cee27d368a28b41be7dbe568b2abb23111"}, + {file = "noiseprotocol-0.3.1.tar.gz", hash = "sha256:b092a871b60f6a8f07f17950dc9f7098c8fe7d715b049bd4c24ee3752b90d645"}, +] + +[package.dependencies] +cryptography = ">=2.8" [[package]] +name = "packaging" +version = "23.0" +description = "Core utilities for Python packages" category = "dev" -description = "Python PE parsing module" -marker = "sys_platform == \"win32\"" -name = "pefile" optional = false -python-versions = "*" -version = "2019.4.18" +python-versions = ">=3.7" +files = [ + {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, + {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, +] -[package.dependencies] -future = "*" +[[package]] +name = "pefile" +version = "2023.2.7" +description = "Python PE parsing module" +category = "dev" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"}, + {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"}, +] [[package]] +name = "protobuf" +version = "4.22.0" +description = "" category = "main" -description = "Pure-Python Implementation of the AES block-cipher and common modes of operation" +optional = false +python-versions = ">=3.7" +files = [ + {file = "protobuf-4.22.0-cp310-abi3-win32.whl", hash = "sha256:b2fea9dc8e3c0f32c38124790ef16cba2ee0628fe2022a52e435e1117bfef9b1"}, + {file = "protobuf-4.22.0-cp310-abi3-win_amd64.whl", hash = "sha256:a33a273d21852f911b8bda47f39f4383fe7c061eb1814db2c76c9875c89c2491"}, + {file = "protobuf-4.22.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:e894e9ae603e963f0842498c4cd5d39c6a60f0d7e4c103df50ee939564298658"}, + {file = "protobuf-4.22.0-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:7c535d126e7dcc714105ab20b418c4fedbd28f8b8afc42b7350b1e317bbbcc71"}, + {file = "protobuf-4.22.0-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:86c3d20428b007537ba6792b475c0853bba7f66b1f60e610d913b77d94b486e4"}, + {file = "protobuf-4.22.0-cp37-cp37m-win32.whl", hash = "sha256:1669cb7524221a8e2d9008d0842453dbefdd0fcdd64d67672f657244867635fb"}, + {file = "protobuf-4.22.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ab4d043865dd04e6b09386981fe8f80b39a1e46139fb4a3c206229d6b9f36ff6"}, + {file = "protobuf-4.22.0-cp38-cp38-win32.whl", hash = "sha256:29288813aacaa302afa2381db1d6e0482165737b0afdf2811df5fa99185c457b"}, + {file = "protobuf-4.22.0-cp38-cp38-win_amd64.whl", hash = "sha256:e474b63bab0a2ea32a7b26a4d8eec59e33e709321e5e16fb66e766b61b82a95e"}, + {file = "protobuf-4.22.0-cp39-cp39-win32.whl", hash = "sha256:47d31bdf58222dd296976aa1646c68c6ee80b96d22e0a3c336c9174e253fd35e"}, + {file = "protobuf-4.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:c27f371f0159feb70e6ea52ed7e768b3f3a4c5676c1900a7e51a24740381650e"}, + {file = "protobuf-4.22.0-py3-none-any.whl", hash = "sha256:c3325803095fb4c2a48649c321d2fbde59f8fbfcb9bfc7a86df27d112831c571"}, + {file = "protobuf-4.22.0.tar.gz", hash = "sha256:652d8dfece122a24d98eebfef30e31e455d300efa41999d1182e015984ac5930"}, +] + +[[package]] name = "pyaes" +version = "1.6.1" +description = "Pure-Python Implementation of the AES block-cipher and common modes of operation" +category = "main" optional = false python-versions = "*" -version = "1.6.1" +files = [ + {file = "pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f"}, +] [[package]] -category = "dev" -description = "Python style guide checker" name = "pycodestyle" +version = "2.7.0" +description = "Python style guide checker" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.5.0" +files = [ + {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, + {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, +] + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] [[package]] -category = "dev" -description = "passive checker of Python programs" name = "pyflakes" +version = "2.3.1" +description = "passive checker of Python programs" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.1.1" +files = [ + {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, + {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, +] [[package]] +name = "pygments" +version = "2.14.0" +description = "Pygments is a syntax highlighting package written in Python." category = "dev" -description = "PyInstaller bundles a Python application and all its dependencies into a single package." +optional = false +python-versions = ">=3.6" +files = [ + {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, + {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] name = "pyinstaller" +version = "5.8.0" +description = "PyInstaller bundles a Python application and all its dependencies into a single package." +category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.5" +python-versions = "<3.12,>=3.7" +files = [ + {file = "pyinstaller-5.8.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:502a2166165a8e8c3d99c19272e923d2548bac2132424d78910ef9dd8bb11705"}, + {file = "pyinstaller-5.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:bf1f7b7e88b467d7aefcdb2bc9cbd2e856ca88c5ab232c0efe0848f146d3bd5f"}, + {file = "pyinstaller-5.8.0-py3-none-manylinux2014_i686.whl", hash = "sha256:a62ee598b137202ef2e99d8dbaee6bc7379a6565c3ddf0331decb41b98eff1a2"}, + {file = "pyinstaller-5.8.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e68bcadf32edc1171ccb06117699a6a4f8e924b7c2c8812cfa00fd0186ade4ee"}, + {file = "pyinstaller-5.8.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ded780f0d3642d7bfc21d97b98d4ec4b41d2fe70c3f5c5d243868612f536e011"}, + {file = "pyinstaller-5.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f9361eff44c7108c2312f39d85ed768c4ada7e0aa729046bbcef3ef3c1577d18"}, + {file = "pyinstaller-5.8.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5c9632a20faecd6d79f0124afb31e6557414d19be271e572765b474f860f8d76"}, + {file = "pyinstaller-5.8.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8d004699c5d71c704c14a5f81eec233faa4f87a3bf0ae68e222b87d63f5dd17e"}, + {file = "pyinstaller-5.8.0-py3-none-win32.whl", hash = "sha256:3b74f50a57b1413047042e47033480b7324b091f23dff790a4494af32b377d94"}, + {file = "pyinstaller-5.8.0-py3-none-win_amd64.whl", hash = "sha256:4f4d818588e2d8de4bf24ed018056c3de0c95898ad25719e12d68626161b4933"}, + {file = "pyinstaller-5.8.0-py3-none-win_arm64.whl", hash = "sha256:bacf236b5c2f8f674723a39daca399646dceb470881f842f52e393b9a67ff2f8"}, + {file = "pyinstaller-5.8.0.tar.gz", hash = "sha256:314fb883caf3cbf06adbea2b77671bb73c3481568e994af0467ea7e47eb64755"}, +] [package.dependencies] altgraph = "*" -setuptools = "*" +importlib-metadata = {version = ">=1.4", markers = "python_version < \"3.8\""} +macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""} +pyinstaller-hooks-contrib = ">=2021.4" +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +setuptools = ">=42.0.0" + +[package.extras] +encryption = ["tinyaes (>=1.0.0)"] +hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] +name = "pyinstaller-hooks-contrib" +version = "2023.0" +description = "Community maintained hooks for PyInstaller" category = "dev" -description = "" -marker = "sys_platform == \"win32\"" -name = "pywin32-ctypes" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyinstaller-hooks-contrib-2023.0.tar.gz", hash = "sha256:bd578781cd6a33ef713584bf3726f7cd60a3e656ec08a6cc7971e39990808cc0"}, + {file = "pyinstaller_hooks_contrib-2023.0-py2.py3-none-any.whl", hash = "sha256:29d052eb73e0ab8f137f11df8e73d464c1c6d4c3044d9dc8df2af44639d8bfbf"}, +] + +[[package]] +name = "pyserial" +version = "3.5" +description = "Python Serial Port Extension" +category = "main" optional = false python-versions = "*" -version = "0.2.0" +files = [ + {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"}, + {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, +] + +[package.extras] +cp2110 = ["hidapi"] [[package]] +name = "pyside2" +version = "5.15.2.1" +description = "Python bindings for the Qt cross-platform application and UI framework" category = "main" -description = "Type Hints for Python" -name = "typing" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <3.11" +files = [ + {file = "PySide2-5.15.2.1-5.15.2-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:b5e1d92f26b0bbaefff67727ccbb2e1b577f2c0164b349b3d6e80febb4c5bde2"}, + {file = "PySide2-5.15.2.1-5.15.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:235240b6ec8206d9fdf0232472c6ef3241783d480425e5b54796f06e39ed23da"}, + {file = "PySide2-5.15.2.1-5.15.2-cp35.cp36.cp37.cp38.cp39.cp310-abi3-macosx_10_13_intel.whl", hash = "sha256:a9e2e6bbcb5d2ebb421e46e72244a0f4fe0943b2288115f80a863aacc1de1f06"}, + {file = "PySide2-5.15.2.1-5.15.2-cp35.cp36.cp37.cp38.cp39.cp310-abi3-manylinux1_x86_64.whl", hash = "sha256:23886c6391ebd916e835fa1b5ae66938048504fd3a2934ae3189a96cd5ac0b46"}, + {file = "PySide2-5.15.2.1-5.15.2-cp35.cp36.cp37.cp38.cp39.cp310-none-win32.whl", hash = "sha256:439509e53cfe05abbf9a99422a2cbad086408b0f9bf5e6f642ff1b13b1f8b055"}, + {file = "PySide2-5.15.2.1-5.15.2-cp35.cp36.cp37.cp38.cp39.cp310-none-win_amd64.whl", hash = "sha256:af6b263fe63ba6dea7eaebae80aa7b291491fe66f4f0057c0aafe780cc83da9d"}, +] + +[package.dependencies] +shiboken2 = "5.15.2.1" + +[[package]] +name = "pytz" +version = "2022.7.1" +description = "World timezone definitions, modern and historical" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"}, + {file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"}, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.0" +description = "" +category = "dev" optional = false python-versions = "*" -version = "3.7.4.1" +files = [ + {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"}, + {file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"}, +] + +[[package]] +name = "requests" +version = "2.28.2" +description = "Python HTTP for Humans." +category = "dev" +optional = false +python-versions = ">=3.7, <4" +files = [ + {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, + {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] +name = "semver" +version = "2.13.0" +description = "Python helper for Semantic Versioning (http://semver.org/)" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4"}, + {file = "semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f"}, +] + +[[package]] +name = "setuptools" +version = "67.3.3" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "setuptools-67.3.3-py3-none-any.whl", hash = "sha256:9d3de8591bd6f6522594406fa46a6418eabd0562dacb267f8556675762801514"}, + {file = "setuptools-67.3.3.tar.gz", hash = "sha256:ed4e75fafe103c79b692f217158ba87edf38d31004b9dbc1913debb48793c828"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "shiboken2" +version = "5.15.2.1" +description = "Python / C++ bindings helper module" +category = "main" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <3.11" +files = [ + {file = "shiboken2-5.15.2.1-5.15.2-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:f890f5611ab8f48b88cfecb716da2ac55aef99e2923198cefcf781842888ea65"}, + {file = "shiboken2-5.15.2.1-5.15.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:87079c07587859a525b9800d60b1be971338ce9b371d6ead81f15ee5a46d448b"}, + {file = "shiboken2-5.15.2.1-5.15.2-cp35.cp36.cp37.cp38.cp39.cp310-abi3-macosx_10_13_intel.whl", hash = "sha256:ffd3d0ec3d508e592d7ee3885d27fee1f279a49989f734eb130f46d9501273a9"}, + {file = "shiboken2-5.15.2.1-5.15.2-cp35.cp36.cp37.cp38.cp39.cp310-abi3-manylinux1_x86_64.whl", hash = "sha256:63debfcc531b6a2b4985aa9b71433d2ad3bac542acffc729cc0ecaa3854390c0"}, + {file = "shiboken2-5.15.2.1-5.15.2-cp35.cp36.cp37.cp38.cp39.cp310-none-win32.whl", hash = "sha256:eb0da44b6fa60c6bd317b8f219e500595e94e0322b33ec5b4e9f406bedaee555"}, + {file = "shiboken2-5.15.2.1-5.15.2-cp35.cp36.cp37.cp38.cp39.cp310-none-win_amd64.whl", hash = "sha256:a0d0fdeb12b72c8af349b9642ccc67afd783dca449309f45e78cda50272fd6b7"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" category = "main" -description = "Backported and Experimental Type Hints for Python 3.5+" -name = "typing-extensions" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "dev" optional = false python-versions = "*" -version = "3.7.4" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "sphinx" +version = "5.3.0" +description = "Python documentation generator" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "Sphinx-5.3.0.tar.gz", hash = "sha256:51026de0a9ff9fc13c05d74913ad66047e104f56a129ff73e174eb5c3ee794b5"}, + {file = "sphinx-5.3.0-py3-none-any.whl", hash = "sha256:060ca5c9f7ba57a08a1219e547b269fadf125ae25b06b9fa7f66768efb652d6d"}, +] [package.dependencies] -typing = ">=3.7.4" +alabaster = ">=0.7,<0.8" +babel = ">=2.9" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.14,<0.20" +imagesize = ">=1.3" +importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} +Jinja2 = ">=3.0" +packaging = ">=21.0" +Pygments = ">=2.12" +requests = ">=2.5.0" +snowballstemmer = ">=2.0" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.5" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-bugbear", "flake8-comprehensions", "flake8-simplify", "isort", "mypy (>=0.981)", "sphinx-lint", "types-requests", "types-typed-ast"] +test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] + +[[package]] +name = "sphinx-rtd-theme" +version = "1.2.0" +description = "Read the Docs theme for Sphinx" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +files = [ + {file = "sphinx_rtd_theme-1.2.0-py2.py3-none-any.whl", hash = "sha256:f823f7e71890abe0ac6aaa6013361ea2696fc8d3e1fa798f463e82bdb77eeff2"}, + {file = "sphinx_rtd_theme-1.2.0.tar.gz", hash = "sha256:a0d8bd1a2ed52e0b338cbe19c4b2eef3c5e7a048769753dac6a9f059c7b641b8"}, +] + +[package.dependencies] +docutils = "<0.19" +sphinx = ">=1.6,<7" +sphinxcontrib-jquery = {version = ">=2.0.0,<3.0.0 || >3.0.0", markers = "python_version > \"3\""} + +[package.extras] +dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.2" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, + {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-autoprogram" +version = "0.1.8" +description = "Documenting CLI programs" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sphinxcontrib-autoprogram-0.1.8.tar.gz", hash = "sha256:5a69729db9d283e0e4c6d349bd60e62a4b8ebd2c07c0ab634b82d08a4121f10a"}, + {file = "sphinxcontrib_autoprogram-0.1.8-py2.py3-none-any.whl", hash = "sha256:222b029217b05cb22a6c72a473bafd0e57bbe666420be636d91e5ea71b704610"}, +] + +[package.dependencies] +six = "*" +Sphinx = ">=1.2" + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.0" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, + {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["html5lib", "pytest"] + +[[package]] +name = "sphinxcontrib-jquery" +version = "2.0.0" +description = "Extension to include jQuery on newer Sphinx releases" +category = "dev" +optional = false +python-versions = ">=2.7" +files = [ + {file = "sphinxcontrib-jquery-2.0.0.tar.gz", hash = "sha256:8fb65f6dba84bf7bcd1aea1f02ab3955ac34611d838bcc95d4983b805b234daa"}, + {file = "sphinxcontrib_jquery-2.0.0-py3-none-any.whl", hash = "sha256:ed47fa425c338ffebe3c37e1cdb56e30eb806116b85f01055b158c7057fdb995"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[package.extras] +test = ["flake8", "mypy", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.5" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, +] + +[package.extras] +lint = ["docutils-stubs", "flake8", "mypy"] +test = ["pytest"] + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typed-ast" +version = "1.5.4" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, + {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"}, + {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"}, + {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"}, + {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"}, + {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"}, + {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"}, + {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"}, + {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"}, + {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"}, + {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"}, + {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"}, + {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"}, + {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"}, + {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"}, + {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"}, + {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"}, + {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"}, + {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"}, + {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"}, + {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"}, + {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"}, + {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, + {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, +] + +[[package]] +name = "typing-extensions" +version = "4.5.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, + {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, +] + +[[package]] +name = "urllib3" +version = "1.26.14" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +files = [ + {file = "urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1"}, + {file = "urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "zipp" +version = "3.14.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "zipp-3.14.0-py3-none-any.whl", hash = "sha256:188834565033387710d046e3fe96acfc9b5e86cbca7f39ff69cf21a4128198b7"}, + {file = "zipp-3.14.0.tar.gz", hash = "sha256:9e5421e176ef5ab4c0ad896624e87a7b2f07aca746c9b2aa305952800cb8eecb"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + +[extras] +qt = ["pyside2"] [metadata] -content-hash = "efb94ab72596b3a8e2c057d24c9ff91efcbe40bfed6b7ca1ed3de909745a72c6" -python-versions = "^3.6" - -[metadata.hashes] -altgraph = ["d6814989f242b2b43025cba7161fc1b8fb487a62cd49c49245d6fd01c18ac997", "ddf5320017147ba7b810198e0b6619bd7b5563aa034da388cea8546b877f9b0c"] -autopep8 = ["4d8eec30cc81bc5617dbf1218201d770dc35629363547f17577c61683ccfb3ee"] -ecdsa = ["20c17e527e75acad8f402290e158a6ac178b91b881f941fc6ea305bfdfb9657c", "5c034ffa23413ac923541ceb3ac14ec15a0d2530690413bff58c12b80e56d884"] -entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] -flake8 = ["19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548", "8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"] -future = ["67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8"] -hidapi = ["1ac170f4d601c340f2cd52fd06e85c5e77bad7ceac811a7bb54b529f7dc28c24", "6424ad75da0021ce8c1bcd78056a04adada303eff3c561f8d132b85d0a914cb3", "8d3be666f464347022e2b47caf9132287885d9eacc7895314fc8fefcb4e42946", "92878bad7324dee619b7832fbfc60b5360d378aa7c5addbfef0a410d8fd342c7", "b4b1f6aff0192e9be153fe07c1b7576cb7a1ff52e78e3f76d867be95301a8e87", "bf03f06f586ce7d8aeb697a94b7dba12dc9271aae92d7a8d4486360ff711a660", "c76de162937326fcd57aa399f94939ce726242323e65c15c67e183da1f6c26f7", "d4ad1e46aef98783a9e6274d523b8b1e766acfc3d72828cd44a337564d984cfa", "d4b5787a04613503357606bb10e59c3e2c1114fa00ee328b838dd257f41cbd7b", "e0be1aa6566979266a8fc845ab0e18613f4918cf2c977fe67050f5dc7e2a9a97", "edfb16b16a298717cf05b8c8a9ad1828b6ff3de5e93048ceccd74e6ae4ff0922"] -libusb1 = ["adf64a4f3f5c94643a1286f8153bcf4bc787c348b38934aacd7fe17fbeebc571"] -macholib = ["ac02d29898cf66f27510d8f39e9112ae00590adb4a48ec57b25028d6962b1ae1", "c4180ffc6f909bf8db6cd81cff4b6f601d575568f4d5dee148c830e9851eb9db"] -mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] -mnemonic = ["02a7306a792370f4a0c106c2cf1ce5a0c84b9dbd7e71c6792fdb9ad88a727f1d"] -pbkdf2 = ["ac6397369f128212c43064a2b4878038dab78dab41875364554aaf2a684e6979"] -pefile = ["a5d6e8305c6b210849b47a6174ddf9c452b2888340b8177874b862ba6c207645"] -pyaes = ["02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f"] -pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] -pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"] -pyinstaller = ["ee7504022d1332a3324250faf2135ea56ac71fdb6309cff8cd235de26b1d0a96"] -pywin32-ctypes = ["24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942", "9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"] -typing = ["91dfe6f3f706ee8cc32d38edbbf304e9b7583fb37108fef38229617f8b3eba23", "c8cabb5ab8945cd2f54917be357d134db9cc1eb039e59d1606dc1e60cb1d9d36", "f38d83c5a7a7086543a0f649564d661859c5146a85775ab90c0d2f93ffaa9714"] -typing-extensions = ["2ed632b30bb54fc3941c382decfd0ee4148f5c591651c9272473fea2c6397d95", "b1edbbf0652660e32ae780ac9433f4231e7339c7f9a8057d0f042fcbcea49b87", "d8179012ec2c620d3791ca6fe2bf7979d979acdbef1fca0bc56b37411db682ed"] +lock-version = "2.0" +python-versions = "^3.7,<3.12" +content-hash = "379a1a94b266321aa1b2b60fc4367e2ec37f34a6b99cdd031fe73d0fb45c2ed5" diff --git a/pyproject.toml b/pyproject.toml index 808217d5a..31510130f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hwi" -version = "1.0.2" +version = "2.2.1" description = "A library for working with Bitcoin hardware wallets" authors = ["Andrew Chow "] license = "MIT" @@ -8,31 +8,42 @@ readme = "README.md" repository = "https://github.com/bitcoin-core/HWI" homepage = "https://github.com/bitcoin-core/HWI" exclude = ["docs/", "test/"] -include = ["hwilib/**/*.py", "udev/"] +include = ["hwilib/**/*.py", "udev/", "hwilib/py.typed"] packages = [ { include = "hwi.py" }, + { include = "hwi-qt.py" }, { include = "hwilib" }, ] [tool.poetry.dependencies] -python = "^3.6" -hidapi = "^0.7.99" -ecdsa = "^0.13.0" +python = "^3.7,<3.12" +hidapi = "~0" +ecdsa = "~0" pyaes = "^1.6" -mnemonic = "^0.18.0" -typing-extensions = "^3.7" -libusb1 = "^1.7" +mnemonic = "~0" +typing-extensions = "^4.4" +libusb1 = ">=1.7,<3" +pyside2 = { version = "^5.14.0", optional = true, python = "<3.10" } +bitbox02 = ">=6.1.0,<7.0.0" +cbor = "^1.0.0" +pyserial = "^3.5" +dataclasses = {version = "^0.8", python = ">=3.6,<3.7"} + +[tool.poetry.extras] +qt = ["pyside2"] [tool.poetry.dev-dependencies] -pyinstaller = "^3.4" -pywin32-ctypes = {version = "^0.2.0",platform = "win32"} -pefile = {version = "^2019.4",platform = "win32"} -macholib = {version = "^1.11",platform = "darwin"} -autopep8 = "^1.4" -flake8 = "^3.7" +pyinstaller = "^5.3" +autopep8 = "~1" +flake8 = ">=3" +mypy = "~0" +sphinx = ">=4" +sphinx-rtd-theme = "~1" +sphinxcontrib-autoprogram = "~0" [tool.poetry.scripts] -hwi = 'hwilib.cli:main' +hwi = 'hwilib._cli:main' +hwi-qt = 'hwilib._gui:main' [build-system] requires = ["poetry>=0.12"] diff --git a/setup.py b/setup.py index 966379724..8b4ad287c 100644 --- a/setup.py +++ b/setup.py @@ -1,45 +1,60 @@ # -*- coding: utf-8 -*- -from distutils.core import setup +from setuptools import setup packages = \ ['hwilib', 'hwilib.devices', - 'hwilib.devices.btchip', 'hwilib.devices.ckcc', + 'hwilib.devices.jadepy', + 'hwilib.devices.ledger_bitcoin', + 'hwilib.devices.ledger_bitcoin.btchip', + 'hwilib.devices.ledger_bitcoin.exception', + 'hwilib.devices.ledger_bitcoin.ledgercomm', + 'hwilib.devices.ledger_bitcoin.ledgercomm.interfaces', 'hwilib.devices.trezorlib', - 'hwilib.devices.trezorlib.messages', - 'hwilib.devices.trezorlib.transport'] + 'hwilib.devices.trezorlib.transport', + 'hwilib.ui'] package_data = \ {'': ['*'], 'hwilib': ['udev/*']} modules = \ -['hwi'] +['hwi', 'hwi-qt'] install_requires = \ -['ecdsa>=0.13.0,<0.14.0', - 'hidapi>=0.7.99,<0.8.0', - 'libusb1>=1.7,<2.0', - 'mnemonic>=0.18.0,<0.19.0', +['bitbox02>=6.1.0,<7.0.0', + 'cbor>=1.0.0,<2.0.0', + 'ecdsa>=0,<1', + 'hidapi>=0,<1', + 'libusb1>=1.7,<3', + 'mnemonic>=0,<1', 'pyaes>=1.6,<2.0', - 'typing-extensions>=3.7,<4.0'] + 'pyserial>=3.5,<4.0', + 'typing-extensions>=4.4,<5.0'] + +extras_require = \ +{':python_version >= "3.6" and python_version < "3.7"': ['dataclasses>=0.8,<0.9'], + 'qt:python_version < "3.10"': ['pyside2>=5.14.0,<6.0.0']} entry_points = \ -{'console_scripts': ['hwi = hwilib.cli:main']} +{'console_scripts': ['hwi = hwilib._cli:main', 'hwi-qt = hwilib._gui:main']} setup_kwargs = { 'name': 'hwi', - 'version': '1.0.2', + 'version': '2.2.1', 'description': 'A library for working with Bitcoin hardware wallets', - 'long_description': "# Bitcoin Hardware Wallet Interface\n\n[![Build Status](https://travis-ci.org/bitcoin-core/HWI.svg?branch=master)](https://travis-ci.org/bitcoin-core/HWI)\n\nThe Bitcoin Hardware Wallet Interface is a Python library and command line tool for interacting with hardware wallets.\nIt provides a standard way for software to work with hardware wallets without needing to implement device specific drivers.\nPython software can use the provided library (`hwilib`). Software in other languages can execute the `hwi` tool.\n\n## Prerequisites\n\nPython 3 is required. The libraries and [udev rules](hwilib/udev/README.md) for each device must also be installed. Some libraries will need to be installed\n\nFor Ubuntu/Debian:\n```\nsudo apt install libusb-1.0-0-dev libudev-dev\n```\n\nFor macOS:\n```\nbrew install libusb\n```\n\nThis project uses the [Poetry](https://github.com/sdispater/poetry) dependency manager.\nOnce HWI's source has been downloaded with git clone, it and its dependencies can be installed via poetry by execting the following in the root source directory:\n\n```\npoetry install\n```\n\nPip can also be used to install all of the dependencies (in virtualenv or system) required for operation and development. See `pyproject.toml` for all dependencies. Dependencies under `[tool.poetry.dependecies]` are user dependencies, and `[tool.poetry.dev-dependencies]` for development based dependencies.\n\n## Install\n\n```\ngit clone https://github.com/bitcoin-core/HWI.git\ncd HWI\n```\n\n## Usage\n\nTo use, first enumerate all devices and find the one that you want to use with\n\n```\n./hwi.py enumerate\n```\n\nOnce the device type and device path is known, issue commands to it like so:\n\n```\n./hwi.py -t -d \n```\n\nAll output will be in JSON form and sent to `stdout`.\nAdditional information or prompts will be sent to `stderr` and will not necessarily be in JSON.\nThis additional information is for debugging purposes.\n\n## Device Support\n\nThe below table lists what devices and features are supported for each device.\n\nPlease also see [docs](docs/) for additional information about each device.\n\n| Feature \\ Device | Ledger Nano X | Ledger Nano S | Trezor One | Trezor Model T | Digital BitBox | KeepKey | Coldcard |\n|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|\n| Support Planned | Yes | Yes | Yes | Yes | Yes | Yes | Yes |\n| Implemented | Yes | Yes | Yes | Yes | Yes | Yes | Yes |\n| xpub retrieval | Yes | Yes | Yes | Yes | Yes | Yes | Yes |\n| Message Signing | Yes | Yes | Yes | Yes | Yes | Yes | Yes |\n| Device Setup | N/A | N/A | Yes | Yes | Yes | Yes | N/A |\n| Device Wipe | N/A | N/A | Yes | Yes | Yes | Yes | N/A |\n| Device Recovery | N/A | N/A | Yes | Yes | N/A | Yes | N/A |\n| Device Backup | N/A | N/A | N/A | N/A | Yes | N/A | Yes |\n| P2PKH Inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes |\n| P2SH-P2WPKH Inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes |\n| P2WPKH Inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes |\n| P2SH Multisig Inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes |\n| P2SH-P2WSH Multisig Inputs | Yes | Yes | Yes | Yes | Yes | No | Yes |\n| P2WSH Multisig Inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes |\n| Bare Multisig Inputs | Yes | Yes | N/A | N/A | Yes | N/A | N/A |\n| Arbitrary scriptPubKey Inputs | Yes | Yes | N/A | N/A | Yes | N/A | N/A |\n| Arbitrary redeemScript Inputs | Yes | Yes | N/A | N/A | Yes | N/A | N/A |\n| Arbitrary witnessScript Inputs | Yes | Yes | N/A | N/A | Yes | N/A | N/A |\n| Non-wallet inputs | Yes | Yes | Yes | Yes | Yes | Yes | Yes |\n| Mixed Segwit and Non-Segwit Inputs | N/A | N/A | Yes | N/A | Yes | Yes | Yes |\n| Display on device screen | Yes | Yes | Yes | Yes | N/A | Yes | Yes |\n\n## Using with Bitcoin Core\n\nSee [Using Bitcoin Core with Hardware Wallets](docs/bitcoin-core-usage.md).\n\n## License\n\nThis project is available under the MIT License, Copyright Andrew Chow.\n", + 'long_description': "# Bitcoin Hardware Wallet Interface\n\n[![Build Status](https://api.cirrus-ci.com/github/bitcoin-core/HWI.svg)](https://cirrus-ci.com/github/bitcoin-core/HWI)\n[![Documentation Status](https://readthedocs.org/projects/hwi/badge/?version=latest)](https://hwi.readthedocs.io/en/latest/?badge=latest)\n\nThe Bitcoin Hardware Wallet Interface is a Python library and command line tool for interacting with hardware wallets.\nIt provides a standard way for software to work with hardware wallets without needing to implement device specific drivers.\nPython software can use the provided library (`hwilib`). Software in other languages can execute the `hwi` tool.\n\nCaveat emptor: Inclusion of a specific hardware wallet vendor does not imply any endorsement of quality or security.\n\n## Prerequisites\n\nPython 3 is required. The libraries and [udev rules](hwilib/udev/README.md) for each device must also be installed. Some libraries will need to be installed\n\nFor Ubuntu/Debian:\n```\nsudo apt install libusb-1.0-0-dev libudev-dev python3-dev\n```\n\nFor Centos:\n```\nsudo yum -y install python3-devel libusbx-devel systemd-devel\n```\n\nFor macOS:\n```\nbrew install libusb\n```\n\n## Install\n\n```\ngit clone https://github.com/bitcoin-core/HWI.git\ncd HWI\npoetry install # or 'pip3 install .' or 'python3 setup.py install'\n```\n\nThis project uses the [Poetry](https://github.com/sdispater/poetry) dependency manager. HWI and its dependencies can be installed via poetry by executing the following in the root source directory:\n\n```\npoetry install\n```\n\nPip can also be used to automatically install HWI and its dependencies using the `setup.py` file (which is usually in sync with `pyproject.toml`):\n\n```\npip3 install .\n```\n\nThe `setup.py` file can be used to install HWI and its dependencies so long as `setuptools` is also installed:\n\n```\npip3 install -U setuptools\npython3 setup.py install\n```\n\n## Dependencies\n\nSee `pyproject.toml` for all dependencies. Dependencies under `[tool.poetry.dependencies]` are user dependencies, and `[tool.poetry.dev-dependencies]` for development based dependencies. These dependencies will be installed with any of the three above installation methods.\n\n## Usage\n\nTo use, first enumerate all devices and find the one that you want to use with\n\n```\n./hwi.py enumerate\n```\n\nOnce the device type and device path is known, issue commands to it like so:\n\n```\n./hwi.py -t -d \n```\n\nAll output will be in JSON form and sent to `stdout`.\nAdditional information or prompts will be sent to `stderr` and will not necessarily be in JSON.\nThis additional information is for debugging purposes.\n\nTo see a complete list of available commands and global parameters, run\n`./hwi.py --help`. To see options specific to a particular command,\npass the `--help` parameter after the command name; for example:\n\n```\n./hwi.py getdescriptors --help\n```\n\n## Documentation\n\nDocumentation for HWI can be found on [readthedocs.io](https://hwi.readthedocs.io/).\n\n### Device Support\n\nFor documentation on devices supported and how they are supported, please check the [device support page](https://hwi.readthedocs.io/en/latest/devices/index.html#support-matrix)\n\n### Using with Bitcoin Core\n\nSee [Using Bitcoin Core with Hardware Wallets](https://hwi.readthedocs.io/en/latest/examples/bitcoin-core-usage.html).\n\n## License\n\nThis project is available under the MIT License, Copyright Andrew Chow.\n", 'author': 'Andrew Chow', 'author_email': 'andrew@achow101.com', + 'maintainer': 'None', + 'maintainer_email': 'None', 'url': 'https://github.com/bitcoin-core/HWI', 'packages': packages, 'package_data': package_data, 'py_modules': modules, 'install_requires': install_requires, + 'extras_require': extras_require, 'entry_points': entry_points, - 'python_requires': '>=3.6,<4.0', + 'python_requires': '>=3.7,<3.12', } diff --git a/test/README.md b/test/README.md index 51a90ee56..9e98a95dc 100644 --- a/test/README.md +++ b/test/README.md @@ -9,7 +9,7 @@ This is taken directly from the [python reference implementation](https://github - `test_psbt.py` tests the psbt serialization. It implements all of the [BIP 174 serialization test vectors](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki#Test_Vectors). - `test_trezor.py` tests the command line interface and the Trezor implementation. -It uses the [Trezor One firmware emulator](https://github.com/trezor/trezor-mcu/#building-for-development). +It uses the [Trezor One firmware emulator](https://github.com/trezor/trezor-firmware/blob/master/docs/legacy/index.md#local-development-build) and [Trezor Model T firmware emulator](https://github.com/trezor/trezor-firmware/blob/master/docs/core/emulator/index.md). It also tests usage with `bitcoind`. - `test_keepkey.py` tests the command line interface and the Keepkey implementation. It uses the [Keepkey firmware emulator](https://github.com/keepkey/keepkey-firmware/blob/master/docs/Build.md). @@ -17,15 +17,21 @@ It also tests usage with `bitcoind`. - `test_coldcard.py` tests the command line interface and Coldcard implementation. It uses the [Coldcard simulator](https://github.com/Coldcard/firmware/tree/master/unix#coldcard-desktop-simulator). It also tests usage with `bitcoind`. +- `test_jade.py` tests the command line interface and Blockstream Jade implementation. +It uses the [Espressif fork of the Qemu emulator](https://github.com/espressif/qemu.git). +It also tests usage with `bitcoind`. -`setup_environment.sh` will build the Trezor emulator, the Coldcard simulator, the Keepkey emulator, the Digital Bitbox simulator, and `bitcoind`. -if run in the `test/` directory, these will be built in `work/test/trezor-mcu`, `work/test/firmware`, `work/test/keepkey-firmware`, `work/test/mcu`, and `work/test/bitcoin` respectively. +`setup_environment.sh` will build the Trezor emulator, the Coldcard simulator, the Keepkey emulator, the Digital Bitbox simulator, the Jade emulator, and `bitcoind`. +if run in the `test/` directory, these will be built in `work/test/trezor-firmware`, `work/test/firmware`, `work/test/keepkey-firmware`, `work/test/mcu`, and `work/test/bitcoin` respectively. +In order to build each simulator/emulator, you will need to use command line arguments. +These are `--trezor-1`, `--trezor-t`, `--coldcard`, `--keepkey`, `--bitbox01`, `--jade`, and `--bitcoind`. +If an environment variable is not present or not set, then the simulator/emulator or bitcoind that it guards will not be built. -`run_tests.py` runs the tests. If run from the `test/` directory, it will be able to find the Trezor emulator, Coldcard simulator, Keepkey emulator, Digital Bitbox simulator, and bitcoind. +`run_tests.py` runs the tests. If run from the `test/` directory, it will be able to find the Trezor emulator, Coldcard simulator, Keepkey emulator, Digital Bitbox simulator, Jade emulator, and bitcoind. Otherwise the paths to those will need to be specified on the command line. -test_trezor.py`, `test_coldcard.py`, `test_keepkey.py`, and `test/test_digitalbitbox.py` can be disabled. +`test_trezor.py`, `test_coldcard.py`, `test_keepkey.py`, `test_jade.py`, and `test/test_digitalbitbox.py` can be disabled. -If you are building the Trezor emulator, the Coldcard simulator, the Keepkey emulator, the Digital Bitbox simulator, and `bitcoind` without `setup_environment.sh`, then you will need to make `work/` inside of `test/`. +If you are building the Trezor emulator, the Coldcard simulator, the Keepkey emulator, the Jade emulator, the Digital Bitbox simulator, and `bitcoind` without `setup_environment.sh`, then you will need to make `work/` inside of `test/`. ``` $ cd test @@ -54,13 +60,13 @@ pip install pipenv Clone the repository: ``` -$ git clone https://github.com/trezor/trezor-mcu/ +$ git clone https://github.com/trezor/trezor-firmware/ ``` Build the emulator in headless mode: ``` -$ cd trezor-mcu +$ cd trezor-firmware/legacy $ export EMULATOR=1 TREZOR_TRANSPORT_V1=1 DEBUG_LINK=1 HEADLESS=1 $ script/setup $ pipenv install @@ -161,6 +167,116 @@ $ cmake -C cmake/caches/emulator.cmake . -DNANOPB_DIR=nanopb/ -DKK_HAVE_STRLCAT= $ make kkemu ``` +## Jade emulator + +### Dependencies + +In order to build the Jade emulator, the following packages will need to be installed: + +``` +build-essential git cmake ninja-build libusb-1.0-0 libglib2.0-dev libpixman-1-dev libgcrypt20-dev +``` + +### Building + +Building the jade firmware and emulator can be a bit involved. See `setup_environment.sh`. + +NOTE: the branch and commit of the esp-idf toolchain and the qemu emulator required are best extracted +from the Jade Dockerfile at the Jade commit being built. + +Clone the jade repository and extract the branches and commits of the dependencies: + +``` +$ mkdir jade +$ git clone --recursive --branch master https://github.com/Blockstream/Jade.git ./jade +$ ESP_IDF_BRANCH=$(grep "ARG ESP_IDF_BRANCH=" Dockerfile | cut -d\= -f2) +$ ESP_IDF_COMMIT=$(grep "ARG ESP_IDF_COMMIT=" Dockerfile | cut -d\= -f2) +$ ESP_QEMU_BRANCH=$(grep "ARG ESP_QEMU_BRANCH=" Dockerfile | cut -d\= -f2) +$ ESP_QEMU_COMMIT=$(grep "ARG ESP_QEMU_COMMIT=" Dockerfile | cut -d\= -f2) +``` + +Clone and build the qemu emulator: +``` +$ mkdir qemu +$ git clone --depth 1 --branch ${ESP_QEMU_BRANCH} --single-branch --recursive https://github.com/espressif/qemu.git ./qemu +$ cd qemu && checkout ${ESP_QEMU_COMMIT} +$ ./configure \ + --target-list=xtensa-softmmu \ + --enable-gcrypt \ + --enable-sanitizers \ + --disable-user \ + --disable-opengl \ + --disable-curses \ + --disable-capstone \ + --disable-vnc \ + --disable-parallels \ + --disable-qed \ + --disable-vvfat \ + --disable-vdi \ + --disable-qcow1 \ + --disable-dmg \ + --disable-cloop \ + --disable-bochs \ + --disable-replication \ + --disable-live-block-migration \ + --disable-keyring \ + --disable-containers \ + --disable-docs \ + --disable-libssh \ + --disable-xen \ + --disable-tools \ + --disable-zlib-test \ + --disable-sdl \ + --disable-gtk \ + --disable-vhost-scsi \ + --disable-qom-cast-debug \ + --disable-tpm +$ ninja -C build +$ cd .. +``` + +Clone and install the relevant version of the esp-idf libraries and toolchain: +``` +$ mkdir ./esp && cd ./esp +$ export IDF_TOOLS_PATH="$(pwd)/esp-idf-tools" +$ git clone --quiet --depth=1 --branch ${ESP_IDF_BRANCH} --single-branch --recursive https://github.com/espressif/esp-idf.git +$ cd esp-idf && git checkout ${ESP_IDF_COMMIT} +$ ./install.sh esp32 +$ . ./export.sh +$ cd ../.. +``` +(Note: once the tools are installed, any new shell only needs to source the `./export.sh` file.) + +Build the Jade fw configured for the emulator: +``` +$ cd jade +$ rm -f sdkconfig +$ cp configs/sdkconfig_qemu.defaults sdkconfig.defaults +$ idf.py all +``` + +Create an emulator rom image: +``` +$ esptool.py --chip esp32 merge_bin --fill-flash-size 4MB -o main/qemu/flash_image.bin \ +$ --flash_mode dio --flash_freq 40m --flash_size 4MB \ +$ 0x9000 build/partition_table/partition-table.bin \ +$ 0xe000 build/ota_data_initial.bin \ +$ 0x1000 build/bootloader/bootloader.bin \ +$ 0x10000 build/jade.bin +$ cd .. +``` + +Extract the minimal artifacts required to run the emulator +``` +$ rm -fr simulator +$ mkdir simulator +$ cp qemu/build/qemu-system-xtensa simulator/ +$ cp -R qemu/pc-bios simulator/ +$ cp jade/main/qemu/flash_image.bin simulator/ +$ cp jade/main/qemu/qemu_efuse.bin simulator/ +$ cd .. +``` + ## Bitcoin Core In order to build `bitcoind`, see [Bitcoin Core's build documentation](https://github.com/bitcoin/bitcoin/blob/master/doc/build-unix.md#linux-distribution-specific-instructions) to get all of the dependencies installed and for instructions on how to build. diff --git a/test/data/bip32_vectors.json b/test/data/bip32_vectors.json index 79c43c937..816531bf9 100644 --- a/test/data/bip32_vectors.json +++ b/test/data/bip32_vectors.json @@ -1,54 +1,336 @@ -[ - { - "xprv": "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi", - "master_xpub": "xpub6CDEarkRoiwWPj3n3gYygGwgoGchxYg3g6Zs5L2nB4B6wdojzcWCKKHMu9XuY1GyYygRfrVembjAko1T5xTsxj7ecKXxEPzDxx7nCK8Dxtx", - "vectors" : [ - { - "path" : "m/0h", - "xpub" : "xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw" - }, - { - "path" : "m/0h/1", - "xpub" : "xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ" - }, - { - "path" : "m/0h/1/2h", - "xpub" : "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5" - }, - { - "path" : "m/0h/1/2h/2", - "xpub" : "xpub6FHa3pjLCk84BayeJxFW2SP4XRrFd1JYnxeLeU8EqN3vDfZmbqBqaGJAyiLjTAwm6ZLRQUMv1ZACTj37sR62cfN7fe5JnJ7dh8zL4fiyLHV" - }, - { - "path" : "m/0h/1/2h/2/1000000000", - "xpub" : "xpub6H1LXWLaKsWFhvm6RVpEL9P4KfRZSW7abD2ttkWP3SSQvnyA8FSVqNTEcYFgJS2UaFcxupHiYkro49S8yGasTvXEYBVPamhGW6cFJodrTHy" - } - ] - }, - { - "xprv": "xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U", - "master_xpub": "xpub6DAiPJAHXi5oZE6cXrSgsWdMGKtHW6wCaWsGuYL1Wx9qMtRgJn2VekPQeZc1WwAoeuoytGozkCQnToL2PMw4deyhWGEu7Xou6gPYc1KqYuj", - "vectors" : [ - { - "path" : "m/0", - "xpub" : "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH" - }, - { - "path" : "m/0/2147483647h", - "xpub" : "xpub6ASAVgeehLbnwdqV6UKMHVzgqAG8Gr6riv3Fxxpj8ksbH9ebxaEyBLZ85ySDhKiLDBrQSARLq1uNRts8RuJiHjaDMBU4Zn9h8LZNnBC5y4a" - }, - { - "path" : "m/0/2147483647h/1", - "xpub" : "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon" - }, - { - "path" : "m/0/2147483647h/1/2147483646h", - "xpub" : "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL" - }, - { - "path" : "m/0/2147483647h/1/2147483646h/2", - "xpub" : "xpub6FnCn6nSzZAw5Tw7cgR9bi15UV96gLZhjDstkXXxvCLsUXBGXPdSnLFbdpq8p9HmGsApME5hQTZ3emM2rnY5agb9rXpVGyy3bdW6EEgAtqt" - } - ] - } -] \ No newline at end of file +[{"master_xpub": "xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "vectors": [{"path": "m/0h", "xpub": "xpub68jrRzQopSUQm76hJ6TNtiJMJfhj38u1X12xCzExrw388hcN443UVnYpswdUkV7vPJ3KayiCdp3Q5E23s4wvkucohVTh7eSstJdBFyn2DMx"}, + {"path": "m/0h/1", "xpub": "xpub6A7PsGUCo9qsp8t5feeCx8AqLJ1w5dECaBAgNjmrhGVPWiPymXbtrBPzbzXVdyHjYxjwbhnM5L3W1368TPXeHkqEszytXPQEk4JjWePv6kT"}, + {"path": "m/0h/1/2h", "xpub": "xpub6BxRX2Zy9Cg6rd9M7a5maB2SPGtctzhrD5HTaqQbHgrQw7mgXHrYNvenb253xoqr2ce64Lwhhfyjd9DuP2AUsE1AmQN9Sy4cTv2ZPypYvWB"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6Dq24c677Ht3MQfNSxPGEC3Uev8gM7ZLZmdTKYqjrJznbNkpmTcU2vYoahnuDKRnuVD63WLJCpzDxnnSQW2tqo2x57aEum6JueVTUCBssPi"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6GyTPqD671LbX1J967NaX2WCVhmCcrvW9QBkgUjMSNgXUa1fT7pkaP6SNcNjb3ywSdiG6v951CxrtAseYTrJMr7zoeSvCuFVoFCCbFh9XTe"}, + {"path": "m/0", "xpub": "xpub68jrRzQfUmwSaf5Y37Yd5uwfnMRxiR14M3HBonDr91GB7GKEh7R9Mvu2UeCtbASfXZ9FdNo9FwFx6a37HNXUDiXVQFXuadXmevRBa3y7rL8"}, + {"path": "m/0/2147483647h", "xpub": "xpub6APw4Jtp5eRFbisWf11y1WNpCKzG1Q2f4YKk41fgQukyojzrxrEA1wjbQLWhCgcyXHghV8vBYjTQdVR15Ze7WmR5qWRJeH3gJjjbjTgcJTp"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6D4eabteW24dae6vNnvrk1g1PwDiayDaXuESsJRackzADWP7hPiScC42sYgXtxLtKQyWfbNPFv9W7JD6W4WbcM8iDTAgKyRgBq2io25QV6f"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6DvRNUMiiAW57fRscyXwYckXz6BfS25oLMXiVUy9rqNANEkhanmcmqhXPfGHipmQSZwmTJL5tJ7WXmzQCnRDbLPmrELF2i18e7t7jzFnY4T"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6FbZDrzMe2d5uKL6NKxDrc6jpxUtS2MMB25ouXqYdyLMW7NE6ZhrxKhXiB56ucNReFbhFri2XG3ptCUXVuhTrHCUa1QU6KV4z65BLzLcp4D"}], + "xprv": "xprv9s21ZrQH143K3GJpoapnV8SFfukcVBSfeCficPSGfubmSFDxo1kuHnLisriDvSnRRuL2Qrg5ggqHKNVpxR86QEC8w35uxmGoggxtQTPvfUu", + "xpub": "xpub661MyMwAqRbcFkPHucMnrGNzDwb6teAX1RbKQmqtEF8kK3Z7LZ59qafCjB9eCRLiTVG3uxBxgKvRgbubRhqSKXnGGb1aoaqLrpMBDrVxga8"}, + {"master_xpub": "xpub6DRjAgkh3vGTWDcEmDp4TPwy48Nu8yrp6swCEdCCLL615CgnZon7r3vXYr8LYibMLJh5DriGSito1FRBwVoBkjD1ZWG4dmgiC935wLj3nQC", + "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow", + "vectors": [{"path": "m/0h", "xpub": "xpub69F7Wq4sNAW3SdJULVAKvemtL7MKkqrWAz8C77TDjGUU7eWtCNNifNFd5odLDZK14NMuZnM1QWmgSx1v44dRCJycFh7JkAbCG4tgxa8aCYL"}, + {"path": "m/0h/1", "xpub": "xpub6BF6djsxYrwkhBEJLDBnTGD2hbVpVGUCrAChbYJEhUXsv5inTEs6NeJ55ND56fWnJtQJfUPfkRYV888FYau7xCd6FaTbkgrab88pWRVfgZR"}, + {"path": "m/0h/1/2h", "xpub": "xpub6DG12XunmXbG2KYRqfHpHz2pUPg1GtKz6uAB6o1Ezk7gtLRhhUiRHtxscXUus6tu42XQGQVdRnT3G5suQDqMcfxooZLPLZJUJpLnS7BmGfk"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6DhiedLdjyidqZT7VQSPkhkRMMvuCv68gR85kycWxCUr5F9PTnEssKzRjAbextSmqsnrF9fLG1Y4Stda4oRu1eBZy9x3vznfjPNRjZRSRjJ"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6HCZaRQCgokS56S5Us1wj2NGEbadD9x1qvvwrMK7YKQScqXtwoQ488WUb3sPUYTgG92a9PHhUHGzNarPXWSHADkUkSo7cUk57Leg6c9Vp1a"}, + {"path": "m/0", "xpub": "xpub69F7Wq4j2Vy5EU72B6ECD3V7mJ7ZAENCCr1PX9Huh4xeAgFuEJwywmSXFYiWVBkRt8fcxSawAEvSXYCktx6uysNRzpkbSHsAtiqdPVaMC1k"}, + {"path": "m/0/2147483647h", "xpub": "xpub6AGQpdx1jrUa1JTafcVKDEvC4n9Y2vKw23ucW5kupoY194paKrKqKLYXLetpgcT2tVjBwAtJ1ejYoPXjjZAAKa2mNmioYYDgXuuGyuVUFRH"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6C7BcdKuQZjW86diTf72ApeXAPprujd431tM6psHJXo149VvWGgSiWt95Uonn7AvTWMk2DycXi7UPvniCrM3gBPthyvVSTRrsLScWqNHEmy"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6DboHJhmaasg89i5oit9FeGHS9fgmKyZuLwpjidbk7fn6D6KwR31CPT1HnRbUhmuSsGBBbMGPDYMRs4nFN8oamB1oqAPibCH9iTC7PNzFKh"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6H68PpSLpozG3q1enQtyjaMRBycHyntpHzkT4X1Z4bFj5ZErm6xzgZTehSbmBYUFq2jNs1scVxVTdaGSfFkiUJ6onbnezSkxuaDb91wNUAY"}], + "xprv": "xprv9s21ZrQH143K2x4gnzRB1eZDq92Uuvy9CXbvgQGdvykXZ9mkkot6LBjzDpgaAfvzkuxJe9JKJXQ38VoPutxvACA5MsyoBs5UyQ4HZKGshGs", + "xpub": "xpub661MyMwAqRbcFS99u1xBNnVxPAryKPgzZkXXUngFVKHWRx6uJMCLsz4U56FN7PxTSeVqL8tPJpiCrs1KZh1dV2Bh6QyAbmNmjFRPnkrZP52"}, + {"master_xpub": "xpub6Ckc5ZiE7H6pKsFstoAWHrwwVwCJYnM2B4CzuZNdRvoYQwAZ5bSDQbpqiHp93xKB5UBtqkgJ2sfKr8Qk5ZFWh8cSqMoEtRhpy3LhA9F7x7R", + "mnemonic": "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + "vectors": [{"path": "m/0h", "xpub": "xpub68BiZSLmmvzsJfbCGaXt4pg3xo7wLv6amhfJ1aTiSovaRhabPsMjGkN5NKNYXSLdsptof2xvHwhsGDFL9nBJZrJRpk6JsuesibhT8JQ1NBj"}, + {"path": "m/0h/1", "xpub": "xpub6AzSyuvxXVih9XsNvLm1WJz8ebfEpucgCpiGNGTnA4EA68DFziPfhQjqyVPxapPGd9rCd3witPjpct95omrLu3rBPHjPvkWVxHEZiQUbfV7"}, + {"path": "m/0h/1/2h", "xpub": "xpub6D1MFnN84tjVtxUkgEmvSBo7sRjUZJqfb58981kAMVUMeYRSUY6ET47uS76ov1ydwSGELUj4T8qhyhLnLTxfRy68k41GQaFqheWdaNMscUR"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6DYXefDszjJdQE7e1NVkzNxU6k82dViyxJmBj8Exhp4KQDunm9mPnkiGxX8BAUGxsTxSVVrxyGHd6Y8MS6LmYV21NiJFrkbhKzFAZQ2yoHD"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6GN8yrQZXy8Pq18XVNK6fMBk74TmhVyFfpbFDzG2ijRjFcRXsMEZSyR3WY1kxmvd3zHr6TrWaK69tHYowenBfB6muuWxZRYDyTNjJTEr116"}, + {"path": "m/0", "xpub": "xpub68BiZSLdSGTu99pyC4ZhP1XdyacF1QTQZBuz5NxY4bkE6MGnBNfVAjg616C2RHrmRS5exA35skNSqcQWvJkH6TNUiXC5BHTPVxHXNmV5tKT"}, + {"path": "m/0/2147483647h", "xpub": "xpub6APBrKxW2PjksG33iyWpMU5QGtLM1VT7VLsdPr2szxat9QL4U5pnEWeT2BtVSb2rmG8iHEekeSjshNMVQvMxGpjsHuT9VrtnUHjoU9ZdeSZ"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6D81VTvVgRCFc6FTAhzTEu4BHi3YAVnVirRtjYDkyoqLEqg2RQBYsdTjRQH6tdnaYXCFPj8m7dcSgiwthptYKXy1W1roNoZPAcUG2kqTyss"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6ERnHPxT6n55aQFumEgxKoB5k9BWubR54ZGAo2k9Gr152Upw9tAkZo5mczLBi5D7wZKgnf9TwsP7S7Q484nwWKMRPwK8VhTsWjbSHHqQvWW"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6H2VVMnzdBe61mjbKGsykDniysoQEJTi2gn9Hpah4L4QqZY4JXueF91SLc7psDhv3tDGkfZDoACGrqn5AATsJra8dMse8KaoxTN2x5Fdg4Z"}], + "xprv": "xprv9s21ZrQH143K44Ed4QRTf937zLBakERjDRwqdMirEN9K5GjGedDM4zZSzCTuKSfTRw6b9c6AfNpnLi5ZA6ZWpQ1cmmt86pq8AE2yqeTB6Xm", + "xpub": "xpub661MyMwAqRbcGYK6ARxU2GyrYN259h9aaesSRk8TnhgHx54RCAXbcnsvqVPbTcfcR96ucYohYYsu7j3GrowCcrtQ7EjoUDPGjmj4apw3wCk"}, + {"master_xpub": "xpub6CfuVE8s2cAQijg7nqYKFoEu7AqkAfMNNMufV7utCmDjMjQZwM9RtN9PHxvBK4gkLWRyu8Xs6jh4TwRz8EYiFjWb8bxDMynAwyHZFxwzvkZ", + "mnemonic": "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + "vectors": [{"path": "m/0h", "xpub": "xpub68MXAZN5xcN1p1hWsyDQWztygZ984csLX6AcscDxSt9dthQ9yMyPSToYdJ24jCS5jaVMGSiLeGuP2cWvgKKYQsNXyg988XGGQYgk1FjDv4P"}, + {"path": "m/0h/1", "xpub": "xpub6BKgNwigYsSzHy7igPkEEbXwgnfW2VhxSCeSxLCoQWSpVbTC97u95kQh5Fewvsq1aKV6Y2jg2hYPpPPwnxm1QiCuVnB8pqzPpxbmYWknMHW"}, + {"path": "m/0h/1/2h", "xpub": "xpub6BsKz3uLo3H97eXPQakTxZybNeWjLoc82SYvoJ17qp7XZnnueFcgDftybfSHbUrpmGqFPzUbvaeDWZ64Kr6jbBBWHEqWGQyFpRfH5NzyMWw"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6Dy96x2yusjWMUFQ1MHwRsSisaUTf8vos23agwYus4N3ExDRSps44upCLvXv6qAgN8Wun671qXFvfsxhbwXtNLtr8ck8rgmggyGhYcX2dKN"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6GNcnHNdB8BammjG4kKhbWdSFLrWPrHu5rL4bqyFZJXmGNzU4omnYWShdeBHr8mUrG6D6CKJEQCFFzCuCX8jeVx2nYZ7gvNiYz5kdnvLXeV"}, + {"path": "m/0", "xpub": "xpub68MXAZMwcwq3bay2X32y6r7JoqtV6uqtZWKCr3tVyKom1MiyF5vDSiH5UUymFQnBfY3YDgrBAWY8zxM64PBczZbrSUdTvTrCdD46Dai2WFq"}, + {"path": "m/0/2147483647h", "xpub": "xpub69vwAQBFwVLgA5z9Yt9V2Ch85o3C3LSgWCD2GrjRSPqWTsdA7M3qvFG5r4Qas3nYj6ZmgYPjkjy3oNvKavLYz5kF4KhNHSmsqGpmfpjKgqg"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6Cz6mPcGVi3Dcd1et2xRMkReZksdHCJkHp6rjhWZh4TMqjP6RL9GQWDprjk5aUiZ6QmHCA4KniaWXZGqUR8YRZPH1ZNsGFzFsYqM7JFfzSz"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6EsdcpaDmDSLzTmt2zJnPa31xpw3w746uncjsw6VFSQfo9hsT6YW65WzCv7zDC6E3RAKe1ukWXzbv232SVvcdYncHbnjTf5cdTNtWRfCxN8"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6FRqFiqh4uk5FP7zMp4aWv2hGbSkcjDMjn1LQrSeMzXBfxz2GjQfpEeaYXM6oi9us3AidGzXehnxuUJyz5oVbugPvbjJ4DSNiB2bfca77Yi"}], + "xprv": "xprv9s21ZrQH143K2PfMvkNViFc1fgumGqBew45JD8SxA59Jc5M66n3diqb92JjvaR61zT9P89Grys12kdtV4EFVo6tMwER7U2hcUmZ9VfMYPLC", + "xpub": "xpub661MyMwAqRbcEsjq2muW5PYkDikFgHuWJGzu1WrZiQgHUsgEeKMtGducsZe1iRsGAGNGDzmWYDM69ya24LMyR7mDhtzqQsc286XEQfM2kkV"}, + {"master_xpub": "xpub6CVHrvPpGM6pXKHRebPy2rzwSz5Nsa7kpQdszzaKJwGBKEP7eZm4EYbw8nNvhfY15Y31LXVzpxGBtmL5GK4PDZ8enSp21buTfm3VH8RPJ8y", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent", + "vectors": [{"path": "m/0h", "xpub": "xpub69YDwAs4jk2rtBkvXFjfjauxnbickyq72tByQTmxrmqb1urdstKWCtYXWzRf5ggxEWk758oSUbkY62fCvyd8ZNMgHyVkRjSuEyQEZ8C9JZP"}, + {"path": "m/0h/1", "xpub": "xpub69mwpRPMZmRJCyVSevwpEYtBohmK3BKWvFA6PJqQxSRA9JSYR89wyhsvCyeMh89BoAm3RwHznuQ12Z5nu7BS4jGnn6h5dLZs3LiDBMs6SgP"}, + {"path": "m/0h/1/2h", "xpub": "xpub6CRadSA2fFMaCU5dfbkLqPFbgve9aPmTXaGcqN2JeA51drpatX9xrEj9DPN4NpPf8mrJ991Ah5aqv519ZNK1GcP6a6DJFt1KcvH4DJMuFw5"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6EgtSRe3mqSiBFXJuYboCJgLJvzqZvZBiaCCxBTfo8DbhaYBeGvGxyQJEskEEd7EsGMDNuPP28MayK3piaVJKPN14pfRXeexZcPNx6KDRgB"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6H4xNPZa1kMWkPLG6GLpVD9CTwS8KtsrL1PgcYLRhmUFoECvQyt9YYDR7GqJ9jdz6kooCPeu3FBG9mkgN7VcQ3pLmkjMWzHKqVpvRjtRB2u"}, + {"path": "m/0", "xpub": "xpub69YDwArvQ5VtiBG8ZEARjdD5dns3hzDygsbkPVdscZDK4veoshaKsGNsV4Yy1FHMWBXUpdeQPcdAqJVc2snK9AWpKrRaRixQ3gpDbc1KgYg"}, + {"path": "m/0/2147483647h", "xpub": "xpub6BTdMX3yNKnvMqVQUMBaWmwbjTDveo5vYeVJK6irCVVgL1iigyWQCRhksAyw8a7cwqPqjGSzj7RdEJHeoVoWNVE9okYh5Qwobcr9uSSZCht"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6BxAFpunBggt5biYU1oQpbvbtJEuBuwnw1jgAgpUVf39Zg7vg7aTZjFVfu1gUCJ1CCgUQjzS7cZv62pGyFY1gtg5Sh5FaMbbyzMuhxMnV9x"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6F7JAY7Jmo1qShtqWzkF24UsFxoReADUeGsHVG44oDRmNLiQB1j7u3M54MTvtpndA8Mcjp8V2NWDPidHomWiZBsjgxtT2oyve7baR97H2CW"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GectTmvQh1wY1ViMkLLFupp8Td8ovhEesBLZaLSmBPHp8EDcvCczYfqoybxHpUnrxGjQQKJgQZbcNgjY6VgMnuX3XmvqroyF5MqrU4Q4yx"}], + "xprv": "xprv9s21ZrQH143K48UaviLRexBBQSB7EmGa5J9hjUf91xP9RSr2e9BrBZ67ks4jLKuXXSzw1gYXmX9vtbYBcANxztsb2yWCQXYyEBtrv88EXDw", + "xpub": "xpub661MyMwAqRbcGcZ42jsS267uxU1beDzRSX5JXs4kaHv8JFBBBgW6jMQbcA3dnJTGvNS9An4TiW3ibxajo7NKuyNA6AzaFTaQge5cwRuDuWj"}, + {"master_xpub": "xpub6D1rk4onoToBnivyrZ3NNgeP5Ac6xx7HrmcY3f5QvxA8Q8NyUdiaS4wLZgDF7Mt7oFuH9GdWaSGJNg3nrz3GugiGdYGCbadbKAerYERLLyr", + "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will", + "vectors": [{"path": "m/0h", "xpub": "xpub68ieXU9iMD11kBmBkerf8djWzkxqYpqWZ6TMpjWW5nUcWsvYBxQraMyUJ9UPwoFSgdXt6JRYsqu3Ja6jbK3r8s5tqr9vPM6KGrPQRvHut7i"}, + {"path": "m/0h/1", "xpub": "xpub6AuPGgif6dvARqtrFjc54Mnidgnti9ts7PKwUL3rgtxoAEtaNahX2VKmnvUgUJtWqn48J5UV56UnZwVxMPQe4xtHnTaf1ZKpNHe3H9LG3ZF"}, + {"path": "m/0h/1/2h", "xpub": "xpub6Bnt5a7cGnTqLBLjMcDzgiHvwHRHEeXWy8zSGuC6VaXgmAi3ub2v5pUrfEWjDdy7J2KhdCPraJ9MYZ4zpjnMBBw3mgiAkNQz2KDK9dJ8j44"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6FMk3wFnq5956hw9iQBWpZijVwCx5BzWY8iAMa6sdmNPaA18EPDEUYT9dZ7zrQjArgUvSJyqw1cnMySCrMfc2n5NjKBfeyzsY4JL9mQRzaM"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6GjyN9p6KoDxnCYCHizMG6phSW68RWESrVjAbZKgiuqezuvXzqAhRU6N6fu8X22fRiE7g6Nemu5PjVirBLNEyKCv2GSi9JxWST9xEr31jCx"}, + {"path": "m/0", "xpub": "xpub68ieXU9a1YU3av8i9x6ir2C25xHApFxeqFRJcWVqsZvwigQiFW9TPu7KU5Gv3K4f9K41qdFEt9ZMZYh1TEebGn4PdSxEtKpoAMqx1XvLgYX"}, + {"path": "m/0/2147483647h", "xpub": "xpub6ARzsJuMKDRpWG9d1Bz2of2sEBF3eq5Jhw5Lx5FxdRHSvda6QVbd63Gch4tprZcW5gP1ArUncL23giQRrVDuv26TFuaKvsoWDrBchzWZ4Qi"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6DToMx5kYRPBD5BqhBbbLX1ZxzUSeNiDLMuCzZVJxywutoxyuFyeVfUPmnDbQxgfagDYDsXG8xCycvrEWMtsSoXRTiNkA7stFyDCYWq4gP3"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6E1GdYieSZQcxdVE2TLbuybuSr61XFM77kEVVDYdAZ5wvCZETbxtG8BfuzQnQM8MW6yQZTh9ziRhwoBJej59gRzyvQr5wY1U9ibJd1XSAir"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GM2usoytQ6owMbYhpKQGGK5fchX55HvFxTXVLZDtdjPgaowWndfpvoaF7eh8LTpURog6ZWx6PWRHdQD3THzaTKdferoxyG8MH2asWoXxi7"}], + "xprv": "xprv9s21ZrQH143K4Vgw4mbHCunAxHVRuR1irqSiqXVGpSuca7rczLU2w6hiPkA1HW7GEUZTNtsFJrwggFJZNGRhmRP1e6y8BYH98xMnYDavzB4", + "xpub": "xpub661MyMwAqRbcGymQAo8Ha3iuWKKvJsjaE4NKduttNnSbSvBmXsnHUu2CF3fEkY5W2gsWK3BJbzFWZMK5o8evYXiBxfaKpRS3y1WSy2ViSUM"}, + {"master_xpub": "xpub6DQ4aXnceoS6huw5bff3QGk8dfscDQEYEENkeyWA4mLoTBjGAjjbtEZZvvjuD6SGiwDe6VDYhqYWaopJhacCSJ78w1mmX4GTQ3EjJ8Aumqf", + "mnemonic": "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always", + "vectors": [{"path": "m/0h", "xpub": "xpub69kYY6qqs9DTh5576CcvDN47G9as43mWYxw72Cx4b5nrrFdh168LaarSwPimYFLeCpBSkYAvdyyPfz1cH7CXe2ibghMkSeaQFdzw2ZBd1VB"}, + {"path": "m/0h/1", "xpub": "xpub6AiUYSn4gb9eN5jq4t5un9DqrEWPU22NkNAiFehFosqdvtXgS44dDoCZBEwZ3Qwnti5fr6kwkTxWxKbX12BgbYivySigUBSEoqjJjTQ2Q4z"}, + {"path": "m/0h/1/2h", "xpub": "xpub6DSRTTZWnJwhrV81gdedgeZeVBuTKVhVdeAG1Hp29R3Dndu6RFCtRq8XtJEevvUbbgL24NcTJrsKCttt7wXnR48J3DG884N1DxzRW1EipYB"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6ETzG65RMvfSzzWT9htP2zsMZaL9KXyixfUVRmjCYtzVJA3Kb8mTHEURiFGw6oHrtUib7UtcYxDtuEsA5KDQeZnsP5y3rMKVFEyUwrumPiC"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6HGtCMuuJgZZ9raLqV9nPWyPppBsNev7bWZgauW9QeiwsiZ2g2hCVjHpDCvecvif43vAawLv62MxMBEJqVquRNv5Q91TTBakFVWATU5cQTG"}, + {"path": "m/0", "xpub": "xpub69kYY6qhXUgVWSMLGB82SGUS53n9vqX4MQcEzrrvEB31DrHk7wuJ3n3q8aCoZx9Qd96ji7os5UrxT1uoh12Ji7fvMeCL71hdVn3vqHFvpQL"}, + {"path": "m/0/2147483647h", "xpub": "xpub69xpXiFXyY33NKP7epeRqdHtdw3KXeQnUPy1LdSonFGdhXP7P7s6u9XhJpkRbWMfM3gk6r89DFxyQuTfuWgG98LW4S871o87CKYQ4rTrqwd"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6DSUQysV4JdrzTj4bMdBgZDwfLDsJbH4XWVXT6boNSK74fUbfmsvHMNYjwWqJSVcih5AaA4nKDN7urYxKsLdG63dCqgYyXcMgqrPnyieL4u"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6EDUfvX98yhnev1QaXfryJemwu6NKQjFBx1qv1D6AEG9jP8HUshudjtgBTiA1BtjX8Er4Yayhvz84dhPqZKubmovjkVo84N76go31uDGtTy"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GHwhtrc3aR8ebmYYbV4YuekQd442zwLkY6tH2gg9TNVQT2GeAVhLZTz3vbAkxu25nUbt3fdXb8BMTnaQxc348uaP15sTCTVeWG6y6ZNdHe"}], + "xprv": "xprv9s21ZrQH143K2Z2MAexszKEd4vm8bHh9vdxreb2KFq2deUv57Xd8winRtRaRFAvbixr43BhviFF9mSQ2TyGHuwKKRAmeVc17wxFKEbrvKh2", + "xpub": "xpub661MyMwAqRbcF36pGgVtMTBMcxbczkR1HrtTSyRvpAZcXHFDf4wPVX6ujiC1qwz7iG4pUpLy3FMh7H2oVQLTqdNSQjzxhBnJgVdrn5mXP7j"}, + {"master_xpub": "xpub6Ce9N25NJgK748emHH27bLsgnUMmmfFkGchDoZvNedp99M3JuSb5TEGZVt2Q2pYGCWGQf9qYaEeChbnNktuRBGUT6LoAGX2DquuNdh6uK6x", + "mnemonic": "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when", + "vectors": [{"path": "m/0h", "xpub": "xpub69PxTAMrS9njGezy6bQzTCx9kY5d6gyW6ZGJF79vn8yaJZevmF3ZGZZREG3thR8YKZPVkPBCr8nemnrNZ4J3RdNhCAQVEBuNRfMoLXB5gin"}, + {"path": "m/0h/1", "xpub": "xpub6AMf3EGnY7NjjP8TsZb4crqPcU9QnjDG4btba5j9KuW7uUTrQxQcYiQHzUMEmZ1WSNqvpfiBymDd1CvGgWfLePjffBvSJzapXaiXJWfkdm3"}, + {"path": "m/0h/1/2h", "xpub": "xpub6DJMsJZRTsu9uGjTu7vGAmVFzFeK7DYVt4dU2FGH49LdcWNtfLEAFdaSpYhfnStEKDCi6fUTgXikdngQUWMaYADrm3eL6P6hjzkBrNZUNm4"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6EWpdKAu45CcF3KGaTJZrztfUgHpuHepGdJaHsRmmXZHeN6zkn26gPavmMazW6mQmmv9PSV3iBB4Mk2TQim83FBZFzcxD5tTZ4qVZbTA4YL"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6H81njH6LSoN4DKqDEdqTaeG6zdtQR8K7XyeaaBzvJhH9Dd13AUxnnj4hfT23TJCxD9FjKATA5CS8uqmEgif63ihMPvP2GxT3EDpZHDcAXY"}, + {"path": "m/0", "xpub": "xpub69PxTAMi6VFm4uyeW4AsFRubqxpdz6gdiAUQgC3Q3AJweNQRrm3whTe4RZENEipuNCVBUgGAGt8TLvvqHCtgMEjuH5BNXV4fsVDf5VtrCuA"}, + {"path": "m/0/2147483647h", "xpub": "xpub6BGLDPDW9yuAt6HSUzrmFU5QmQgHb1vfisnFZ1YQSLppPry46RajmbXw8rhQdE1J38ATadeX6HizX1bG7z1BxTYrpA6TYWfKxtMyZ3U6T9v"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6D9U8wUwhgexaN99MZBn3EafCPMSoF4VWDJjA42Qn2fWEHa3VugRP17JfCH8TyusMMzh2nbYUf61gqSgtEijDhar9yNZDRGQR9h5eYZgU5K"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6EGfzg4fA5qPwxkAoqQM46b6wwRJpSMGUfz89rvgbnsRUSsThMdH42ovvnrxSJac4R2PYTyNvqz7HW4smMnwErZ1BGq81a9jhLwxV1GCh4D"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GCvi6wxjvTSYVRx65AqLrqdmdVFk6t4ndoxZKfspwmg3d1vccNe8AU4o3V4AaBiFkz83Mxq9nkrJkMRn5nkdiSUepUfsU9kmvafTcx7GJM"}], + "xprv": "xprv9s21ZrQH143K4MVHZvjndvSoH1tHuM6Uf6BTiPMX9Ni2Dj5iqxtMGycnUi9T9tUUckPJnuwGrRf3MVVD5cvSF4svqfHNdKg1SSkvjSMBgxb", + "xpub": "xpub661MyMwAqRbcGqZkfxGo14PXq3inJopL2K74Wmm8hiF16XQsPWCbpmwGKyn3JpCmsQZNUBH5Bf5x5np3SX39PtxuQ1GrQvo1o6H2y3g29MT"}, + {"master_xpub": "xpub6CDwootAjK1YycduSmTrAAXjfW9A8bPhVamZeofd8wX6rvGm2vLz6qtnqx4FagbFeXJwFThkzDPGkErrjFpLpr1wsj7NXgHHkevPUxHYjUP", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + "vectors": [{"path": "m/0h", "xpub": "xpub68WQ7UiFxwyyJahGvBZ1cffn8vMHn6Vti4fb62GSmErn17B8SJHqJPjAdTwbECo864SjV6PJVqYoAuNf4hYaTebBWZqjxZV7LR1r4aVkPsU"}, + {"path": "m/0h/1", "xpub": "xpub69wDXVVjVqKxuC3nP35gtBVh3VxH9gDKTbS15vqyegs4quxRdVVUopA5E7C96Kpdx5dTeP7CreqYhPR9uCKMvwXykDwt4duPJjDiSZgsAL5"}, + {"path": "m/0h/1/2h", "xpub": "xpub6DPsSwT5tTazDN8bMHseCxBAJE5iVvU5U3trCa9mdXiCmDmuAW9sKeJWGmhmsWchzCCHTUahMcG3Qyrz7SUbJzs9tXuZagq4btoctF43Tae"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6EKnpBv8UJeJiqWWYaRKYjz3R6DT2X8Q18JurLXY4gqHqMpdRK7ghQWkg77Sv1bJT7gpHhMssu7uyUWx1iFtQWGZjqBta3xqhQ45QiDHQaM"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6FR3eUHArz4PWkHuchTK819Eaht2QYPQyTcETBrrtMoCTQGm9oC38YgpncvkPHHobQ5KLLJa6GP6AcaYbkVGK49RRaecjxEdVHnqgYnu6Nr"}, + {"path": "m/0", "xpub": "xpub68WQ7Ui7dHT181UsjwnViF47KcBN6cVToNcW3fpWzqZYq38UEAZcSmB9BBmSMLSQmx7NthScekEfksmo5ycduFLKTzjiQFz7FyEKTb2JGfx"}, + {"path": "m/0/2147483647h", "xpub": "xpub6APRMNXW9vXympmgVsbXCaVknpBB41oiZ6mqJvHHbKB7yi57FYXXkNcf8q3Jqjctnzz5JJPgFPSm92QXtSAAwQWC5XjXksUy9JDuZTs9ZdP"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6D3kpdeFpzS3GEPNwFQuPtaisUe8JNMNfQJ6141Kt6KaEnRS46pA36CXWQi7u4Vcmrkij5mP5gFvLzWnUxWLRYxjiw8YSzokLwLLoyR2z46"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6EmF1TYv3orHinGZnV1FPEySvrNdZdZnk2Qwqfvqh58vgtWaUVCNFSiEi77QEHkvy9tKZdV6Yk3SxB3g9kMAcCB1fpNGXEU2avYpWoz2iEL"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GbiWsPRyHpiZPGDDb4YiraiBSroQuiEER47exQXiLhtRftWRDAnYGLGLuhP18Jrhp3ENnAXk64WWL4DWpsJdRsN44UTuoKRNjTgYucz4tR"}], + "xprv": "xprv9s21ZrQH143K4VHfAaPWRTm4aoHAZhJHunsZZTQptR82FSTZRjBGXBP8kQKHrUVUE8vMM2Z3h7UoG9x9XCt9FHQ1t1nHU7zQDqrEszAg28q", + "xpub": "xpub661MyMwAqRbcGyN8GbvWnbho8q7eyA29H1oAMqpSSkf18EnhyGVX4yhcbfjXa8j9KW7APXiBmXXpfseCZy4whWaFo1xsQoTxLPYJH17sBeA"}, + {"master_xpub": "xpub6CAzShWx2MuZwVxHdXQhSbyGb3DM6vLeNeqNVqFJBpbioYB2NytmBPSL4BKWpvSnKz2T36obQMghidDz4MnicBTuecFTiSQ1CJM9xkfFiAw", + "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title", + "vectors": [{"path": "m/0h", "xpub": "xpub67zHQDXa7ubWKhyALcRGfe1P6w1w2eJbLLuULbEJiouPBjm7t2JxJzbXzyf3YyEtuN5PD6wDTWse7s2nEKV8pKUK52Kd3Hi8CjjQAadycsp"}, + {"path": "m/0h/1", "xpub": "xpub69rqrQkTqAodF1UbKhULUgze3bNJ5sMvZxiwhFFrbxRKWxxR6oVbXG8f2fWZCSxhhQeACMugNstEng2W6FMhFrTwa5H9pPa3GJkxQZTGDTa"}, + {"path": "m/0h/1/2h", "xpub": "xpub6DVfjXkXtWaLKdj5KMi23W5zcNooeEYKCPdUWyCQ1pSjxwPLYLv6mMu48xaYU7PSEYr2b6EmBZg6PeuSbxZZbyzc21wAfVZGrfjHtPiEznf"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6EEfUFTFfMn6a7iR72gyFwiFaeibasdWPVFo8iie9GkiaRXYtfhZKDbtzD2aCDNoaPTDcwkp88Ry9LTZU42CXw8hd32DkPxpCTdLcjsyws2"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6FvEpkg5gL3CFMTjJytEc8cqai4ohXqgzY7WATvPn7kyLXEQqugrQP3dTQxMA947vJ9uC4ZLENKPnfEuAhSy46zSdwQu9zEBSsCPas1coiP"}, + {"path": "m/0", "xpub": "xpub67zHQDXRnF4YAJJmT45Pz8a7vAkfrJxvfDSjzQYeijn1jcBKE1pDtEDW8guidCJoAsSkH7tvy4kJUezKvyEJJrsrAyXTjJwjK8dSJE8BaR6"}, + {"path": "m/0/2147483647h", "xpub": "xpub6AgcuGdNhgvettDobCw91mASBnnMZj2GHf1aCxUknhcnc7QWTaig4qMZHZAS3j5nUQ19EU6sQN5oKY491Yq8yNtockuRPT4vTHszSfZHneR"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6CQUHifvdxd2wCCtNrGAsUETFUuVhBWvFGkoJKAYNFaNPWRT4NdA5Antc35bwftPa6Kndp2eEweERcgYKzei3GL9hSmGSCjVuZjkzBrntrm"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6Ex9nqA2k9wjpzSBRd1cp8NTzmTWkJ8AupdcaAuSH9GZ9m9KcN5SnXgVg9hzYMgUm9rHV9qvyST8kw3RKfanAE4FUutp6RKS9xJduznWg8J"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6H7zAFbUB4bssAQJeGsyhcx7mQvsYE6wkt3nHVGreLeY6dqPsEUzLhDmnKSHAcuD2giVut1wYPHiZdRast2BA67X86i9bk25Sb2ZNtmk2Yw"}], + "xprv": "xprv9s21ZrQH143K4Az8C4dShiQm8vTkWHw4M1FmzeW7zJWWiXGbXbt4AFCMnPpoZL3r1MbMJX269yKRzd1uRb6j8Vxq8BFiCWssGtspW7Yxz7J", + "xpub": "xpub661MyMwAqRbcGf4bJ6AT4rMVgxJEukeuiEBNo2ujYe3VbKbk59CJi3WqdeEtrKeMeaZUa8sGnC3bDSia2hUTuwTamHvJe5Avm6fYp1EUwJo"}, + {"master_xpub": "xpub6CyF2xxfekkigMca7q3WPBzxGwbvapFgL4sqFPiQDPUokukwQwGfDxUhS6sdqs8byESa3iJkosDgc5JqzkkXX23smnuJ8w2q3yzbWWscxqk", + "mnemonic": "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless", + "vectors": [{"path": "m/0h", "xpub": "xpub68cCaJLdRwjMfKDSB8DEQKucKBEM6jSHSGmcZpHkbBoLrdpRxvoxefqkitVNV2yoXzzMrmsMdJHR2LTKk6DHfe2WkLLR611PKvXGv7oqa1L"}, + {"path": "m/0h/1", "xpub": "xpub6BCBqUcUBhJfhJ4pSAngJFKXCHq1PvixKm7saV9B3mwRkpw3ndbDAwyrYwX5QrS7ZdAmRg83qddJtoXz4ZfbTQjwQPEpHtEW2RLG3ood7WH"}, + {"path": "m/0h/1/2h", "xpub": "xpub6BnHwvrJ4FQG63FsD3f5uHd8RU6WQJGb3W8USKxcUCiy3P3j2J6Mfvjovx5hFrDdPj8WZWcF26CwB9UkvLwgbJosGuCySHMweVejbvZjPLP"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6DuRBCr26G5sa6ENyFUfzKy8ZzR22Nxyka7oXm9g6jy3iMfVxEa2MRXg638uXJY6RgGBKCGH17rRgVSwCXsvVaqis6pSsjVvuKiqoKEhoK1"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6FjeyDpVqLome5KYaVio8LCZPEZo1NjEDcE8agAwSUsmH62h2oJKuxc38iYaRCRYmqbtUDtMm5tSYM5ew77GxoGwxsFn5K2n3MKUZyGEJ7o"}, + {"path": "m/0", "xpub": "xpub68cCaJLV6HCPU98ycKUHUhXw6x1eLg1phTUCiq5aWJk9R5QABVXqYyM2uap7AETiMdkKRQ43FrHgtWytpjWpPjyW8F1xdtNQaWEHURfqMBU"}, + {"path": "m/0/2147483647h", "xpub": "xpub69qnUJVJ4g2xN6odY65QWZP5J1DtjLFJecSG2EsQXZxDok6tRFZKKT7GuwaAvudzaVsW1jPUQ1KgJmTYSvbxHcvr3icsKY2Jd8v6SqrqsEb"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6CBLRYToDorih1aN21kREiJtg3Nj8a7qcYPYNuQa52hWhijXCPvRH6d6WE8BcNxAiY6pywDA5sqQ9T4ipZJ3tkczGqgcZpAayPLpxdbG4qc"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6FLsMnQwYDW5duWWAdP3ZudUniGmUn7irqQPJwcRBaTxi7Xc2E2YvxknMW1BuGnDXSvrBZeWfhDfTF8MPLLma2cheGKipKjZAH8odve7GVk"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GbAenqCmDbxi9ga8ondQ8nhPRykwmAbLQPHoR3AQU3T4Cqb3Ap7oDbcgtTEMbSXJbwbNqUgwD87Q4soz1JV7uefYxDr2SqRank2QhTLP1e"}], + "xprv": "xprv9s21ZrQH143K3iJNbWM7JeraBZf6a4zC99owVcZKFRAq6kVKcpg2q29TXcpMeiyxSRkNwFxGdku1A5TmWZMr71Dp6rs4NYPwvVZWJmnhXZQ", + "xpub": "xpub661MyMwAqRbcGCNqhXt7fnoJjbVayXi3WNjYHzxvokhoyYpUAMzHNpTwNrGY2Xm2eKBDzVoiEWLkz1FyvrJ4XWtSg8qxAedywhuWEaMwzM5"}, + {"master_xpub": "xpub6CNmKkwmGwGoxCCfMWLS7dEvmouvs7i9HjxhTaYbo3Yrjhevrvr5FHDzbwKaYorWJP9JpnYS3wREtgXD6gxqmJT2KDXj32ynyqawCRMqBRo", + "mnemonic": "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", + "vectors": [{"path": "m/0h", "xpub": "xpub689yKeTwX2tatRMe2zqpWsHp2h4Eaj65BThH7o3mnRJ8kP97VgRjm1Kx6vvnd54RG3Xy4z9iA24U8gh9dNRGdki5gg9LZM2XCau5W6xQpEG"}, + {"path": "m/0h/1", "xpub": "xpub6BMxSsC8ztda7dBaDshtNC3DWM251H7UJZFKrVsEeBQ3crtnmgdYuVzwc4Eut19HEBgmG5afN33KH2Yi74C2xHDqdw3WvbG8MAnK4xzZLhV"}, + {"path": "m/0h/1/2h", "xpub": "xpub6Bz8U5aS7thg7ittXpYKCzJWqgNRNe5N6pbByaemnmXjF7Nc2mqoXNtr2dKvCcMYkMUJozgAat2EdvjaEgwsA5sHQtmq5XpYM5VZTWSihwE"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6ExhJFrqEgJQauXWh2tyPutVTnQdnvfQC92AfDTDTHV48N48S1bKs65qmifyAXoASSCX5WgrG3voDHWohfmVied1EEde8kmteYxBRGSc8r5"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6HHZwQuCC9dKoNhUrdgUsCUCRXb8qyzhBEeuuck6mBCaL23wZbS1qnmcjjqr4Y7TPLEWz9Zjvb9M4LzqAJ8QikZpRCmkBZ6U4mVLSpKrqTe"}, + {"path": "m/0", "xpub": "xpub689yKeToBNMciR8vWQrPjoxjLRaugSqvF56BTLt2Q3VFzsHUiRGC3TXbziMWf9m1pxKL7SnXfdDqLejk8w3YPVpFuJxBr4n6k3eeVfVcpnu"}, + {"path": "m/0/2147483647h", "xpub": "xpub6BCxyodJpNRqGKbFboQiDYRAx6yB8XX4rSv1NngsNKDCtfqG5hviDSw7nopUTReebJV5F2vo4eVEtSgoy8ExgBGsbYCegCPVHpvL5wous4J"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6CK3i9KFfaxuhY2T2wtsrEfhes1DWxLaJZe7Ts6qE6nD73G8QxC6o4emwWbbugbeyk4fmYVToVTQ9hY15npZU83MgKVAr6iRDBLXy2CQGQQ"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6DgQSMt8yURyZsGRXVAofcbCpYgMAXmTDwdhMP7sSqeD8nbo6tzCEL1sfsnRjvt7u4WnPAuUsMDH4exswqz4PuwE7JFyQtVe3LdyFSga5pL"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6FZRf7y9VTYGLoJToAxVYBsK3GP4PbDKxFqXPe1ps2BHZWj2tnVHb5twZH9GYcnP99BsSohEs9z9cVgvNg5hZBbMyJjwan4spZ4Jn9Kt4wT"}], + "xprv": "xprv9s21ZrQH143K2GsJn6WxP71Mnxxf4X48Sjwzyb6KqjGzp3CyHaQxEFjdukez1nhkf6U6zc4GgJVHUHJ5Jn2F8JLCdQCnjSS8XwMDqm4npwa", + "xpub": "xpub661MyMwAqRbcEkwmt83xkEx6Lzo9TymyoxsbmyVwQ4oygqY7q7jCn447m2kp2JWAayzQLTSLUpSRdTWsTexdRM6bw6ufMHHCfxBMP6dcFrQ"}, + {"master_xpub": "xpub6DLxm1owyvTGWvffXks1PsLWvSRG8DiBEQLySoajwst7CVReP25eJZ7DkMX7mQxuntT8RPaRxccnoQWSm6PMJW5XZoWpnqeGJ4w9KsvzjdT", + "mnemonic": "ozone drill grab fiber curtain grace pudding thank cruise elder eight picnic", + "vectors": [{"path": "m/0h", "xpub": "xpub694v6EJvGZgd6JE6pAamYSEGP74pbnkWTRscaA8gie2EmnnPTjhxPx8XuNnGsFcZoER58YfjGKhLbdYfB8MSWJmquRAosmXhgFbdSXTRYkh"}, + {"path": "m/0h/1", "xpub": "xpub6Axtfr9F1d2scttMN4Er7qdRJMwQ4zWm6USc5njAM3upxJc8DYEJPMvo5zCoEG1iBtaDDNVXJgnEKDBsk96pff4LXAbG4JfSSbLwLqq2rwH"}, + {"path": "m/0h/1/2h", "xpub": "xpub6CutqkSAAWUFGx8KBF1WmVVZbjEVyFBD9VmKk5hiREdKfztQVwN5pDjJWyxJRSiZkXoCMh7wF4PSeGLqfarFJ5UioDFjb7H1g8fymWNR7ma"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6FBJGp4h5teU4sP9Zv2tqTiLpA3N2t2iN5WKoY8CYHHiy91P6V3UPfrUdRPHFpaQ7ZrB25w9Npqhh9t2M7QAR3tG3pLz6jSKxyQQiwBTiMd"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6FsrxCEXju6dPEpVwWTzTpESR9GhNERi3wpcqcWTm6rkUMikiuSV99U93uXX9okNmBvf6e9UWCVCcdtbv2JyovqzKdJ3X5i4BrFbCpDhtBJ"}, + {"path": "m/0", "xpub": "xpub694v6EJmvu9euRDUzuTxnSrhcYg1jn8zNS9FqgLMVqPLAZCkjhtKAnhfcqbjhU21FB8CGEKDFjqmHonCH5d9pja4jBFUwyk4ZwEjE8YBihb"}, + {"path": "m/0/2147483647h", "xpub": "xpub6BEAciQZRVAZD6cfkUTpJ9s81rxEVY9jSunrReUUA2kFuu4RoELX41pyvW9YeuqfsDP855QSawJZvd3bNMpVUvRUGyeqRsnxt6DbUi3dKDE"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6CTijsNJuFFWveG8n8FVM5RSzbQMgxBN5HDqXgWox4rNQEv18GZNBmLJUrbwKGiJZxaPKr39MiUvbb8vhsGPLXwyRfuX6SAFS3HgA7KxRPU"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6E4dpYDTXPpn5X5j3wmgX8ASu6RHYuoHfzqMGrLPYa2NvpJQZywURJqpzGwaJkj8EscK7UmN2mCw74zYJSU2YiiJ3SjTPe8hYEGKABmjHtk"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GmwSYecVubnsoWLUJuwu5vHfkqsVqGcr6MdEWfoTHhmLeK5xzyHLKfG8HiaJGD6cpRhmz569bUvHeuvmCvugc1CTpQPddvuruJgp3QeHdV"}], + "xprv": "xprv9s21ZrQH143K2vwt95G9vjWpa1o7izT4HqJFiKSdSeUAaYGz6zfW28ipAPB5eJqKccqFHU2BxGCvhQkVY7hMRdwDrHvsqe1GbkMCWPB9GGm", + "xpub": "xpub661MyMwAqRbcFR2MF6oAHsTZ83dc8TAuf4DrWhrEzz19TLc8eXykZw3J1gnvht82BHy8cuQWBz1HMxz9qUwLLRKH8BvpdJ1EZFZFAvzgRT3"}, + {"master_xpub": "xpub6DTf6J4bPq9gLPAqGBH7fD3yPEVdiQkbdu8husPVJHYed2AqPfBGkdbG3tuTQcrgCmYG88gcLxQ4WqpPgywtmJ5T1rSTWKGvJBjxuSx8zfy", + "mnemonic": "gravity machine north sort system female filter attitude volume fold club stay feature office ecology stable narrow fog", + "vectors": [{"path": "m/0h", "xpub": "xpub68sytstXdaAtSuvFAXZZYx2TmDHbaUdZaQTi4NBqCXz4e9hwe9fc3HgC2MWH8H6RFkDgt1v2kmow2oUXpTgXpba8sanfarn2g7PNA6cwbKZ"}, + {"path": "m/0h/1", "xpub": "xpub6B4gDtfxymZfxjCH7sr6AkGT93LkP5bStfx7y1pM4yCmNeR9wCvoRn2hJzzzBboL4Guj3D6QgFJgEinVXnzSe3jysszwYBVz2QhPGoyYmuQ"}, + {"path": "m/0h/1/2h", "xpub": "xpub6BwdYPgmtNfwd71AypQJgqkSxRVvRp6yyDZZMjsi7bU8CB2mFbny5eP844C8Jf8aGdJsggoPUFH3RFjLdjyzrLahsi6fy8D2BMMA1uXGWeQ"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6EJ82W5gfyjbzmqXPczXizqM8xkA68vjqq4nFy9DitsKjk1gHX9kpscXLThjwpmM4TBJNX2yoV8uBMYoZ8i4v7VXHDDrSZCZTyKRer2m1Dh"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6FriRDVikCXnDtNpyQUokFo7KT1yvwtjpXTRXzdVNsLEZK21T8kEAmFsHfFY3Yv5VDG8zNU8WxdixLaws639wg6dt64To1imRDMfuk2r7Jz"}, + {"path": "m/0", "xpub": "xpub68sytstPHudvGMjchVVaVr7p9WQR9gzNUyqRCcYqSo6dNKrPKGC4hCshcdevitcjshCS2pzrP86E7crfarwC7h5EtriB3cfpQTfg1FrHStD"}, + {"path": "m/0/2147483647h", "xpub": "xpub6AE9fy9Hbu13beBiHdbvdfFU2MnmTWbNMHiZrCaAzz5tffYZywCvwFvNHxPbT8aZWPs3mZ5vAx6PphKfC5wDNbmd63EVk3KA1QGEVGMZ84B"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6CFpQ9g2sVsj7bVsEkVjKXYF7ZPwyYjzxvf75WRNwgTJHLh9aMUVwPbFGmfShiTig9xVixn8qZMpwqme5PE4TAHXiHApXCWWQkXNs4mjb6R"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6F3QyGYTUJkK2cP6YkRQ7hpXBPrKX6ysHZtj97t18SSpFyF3cUB9tqErjJPRGL84g7BZYNBAK9MTW8MRhqZ1yo9K9F59wfVAMXF9gXUeEqc"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6HH1bEMC3ZiESZkZRE3vvpqmSsc44LaA29GULMf9HJX44FGKKpxqKXhfAEBfHdTKxU5rJXWvPuKFZi6E57GgZhuw28dx69ZkNxvxT12Bx4U"}], + "xprv": "xprv9s21ZrQH143K3iQQGmeRFG1W51Z3Ay5Rre84yapj8yuFRJYDUfD5HBWTFqV3p1kgkHR2u8opVj8AtZtgm6SBLnSLXiAAaUfcR7zjSj59eT3", + "xpub": "xpub661MyMwAqRbcGCUsNoBRcPxEd3PXaRoHDs3fmyELhKSEJ6sN2CXKpypw78TDYZrDDuXS72QXFLz33cxXMfaX95zoKdLGjjFkzj3erDT2gCd"}, + {"master_xpub": "xpub6DG4hM28j884yDYHGFU7G1omzrsszg2kqZ254MDDQ3Q6Kuwqtvbr7iJK4eXxEvHattZ4psWJVMVjyCo89ySNtfPfJdHhP7gQu4hWCZJyumk", + "mnemonic": "hamster diagram private dutch cause delay private meat slide toddler razor book happy fancy gospel tennis maple dilemma loan word shrug inflict delay length", + "vectors": [{"path": "m/0h", "xpub": "xpub68S7hrU38ptexjCsHhEuzT9oFB5zwoUXkk7s6RXqWnLxBeUqv9qgPV1ecdu2Du1t3LEKr7DoRFvwD2G5CHxQDBsA9zejWZQcewkhBsb2mHD"}, + {"path": "m/0h/1", "xpub": "xpub69tSRVcgC3FGL7SSWHWizAWE8mtNNJeCSqFi7pPVs8LpnG9zmias6yKgJ3sBmouD2CWh3ccnWQ8KAgpfDqD8mxSp5XzimvwUU6CSMfsbU8P"}, + {"path": "m/0h/1/2h", "xpub": "xpub6CwCqYcgG2kJyq5cUDzKH6K2J2Pah1P7oWqtpLJweHiN8oqt9mcbGNhmWsAnFDCxbRneJgJHz3z8qiPCS53vm8umPfFAEguBzY95dEiFirT"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6DYKYivYmAsUmQUG1dsLrwxv7Qt8wwXfMUd994m1w7UWQb8gDZhNn3MKYchYxXHtiS4suXQGLog6AfXQXF2X35dpvgBNhmdvZTLLp6uWRiq"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6GTGKqBj1ShFYsWNfmekv7RT2yxgjGL17BMyGYDvbKtJVgh8aJJ2KriWTTRL17VF2MDaz4dRDA1ozFjb4uLARE8KpkhNFwryMtqZkvg9hXA"}, + {"path": "m/0", "xpub": "xpub68S7hrTtoAMgmWwGTmV3sGh5FW1CAUTPEKZw4PticWtttty9MJydSY7vZdLLiF7v7BxCMi8xeqVNH2ccdVYYEkHcethLuUbck8ktt2sy25E"}, + {"path": "m/0/2147483647h", "xpub": "xpub6Be6KtuzpE7iN49NdVdTPMP16j8S7s8KmSuZPC6pepX8tDL1RFyFxxnmXi2u7iELDCgDsHbcV7fUWBVbaWTMQLXDdEm1cLF8ENLsfNf3xdx"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6D17UtussxBXWFUcfSWifGMnfn4VF8dpD2svPzkVNRGC4tDaJSodAcsgdK1WAQi7QMxMWdURFQZmbBKKv4Ktze2vSVXaX8mF3NruhR3Qfij"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6FQmyux3ETbNSXTHLYDNGrkF58e99AiXaFJag5rDxAzR9V99WsW94krCpuAAdQBrrDwdZwHyACFcpm2RRQnSfzhkVbyAydxwuxPRoqD7A2x"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GyS9Kpxgbk1FfWaEdrzYuPkrzRX92CvAg1WD478CjfVCEpCNG9jUumq1TVyNrt51Ji68rc5hxPNcdxVYHshDqewMnRDfD5emuTxF43XcPG"}], + "xprv": "xprv9s21ZrQH143K2GkCPw791LqAr4t86cdAMr5KBLZpYFJHVwwfGaJFw9SZmcwB1yRa4frhhNUGhz3LXJeRVjPamiN4o25ij4tmwVBWXtA6MTx", + "xpub": "xpub661MyMwAqRbcEkpfVxe9NUmuQ6icW5M1j4zuyiyS6aqGNkGop7cWUwm3cwMCKRSyDj4vYcPREc6bpXc8R4zoBa2wkF7MEdEUa8EUQzWeMNc"}, + {"master_xpub": "xpub6BnoewRSzkrESpkG9hYgxGNrcj9cUEMC3pFmuzRzfUiWsAgzdsgXK7LgtR9e3XzY2Y4dXHy1o2mejaGHDkhjucRZNQb2gG376ST8D9ffDhb", + "mnemonic": "scheme spot photo card baby mountain device kick cradle pact join borrow", + "vectors": [{"path": "m/0h", "xpub": "xpub69Eg2d9znKavWXBbsGpTYRZLfjEjba9M6f1UamqN7B6XC9t9tnes5zN77LzUQje5dCNCFbuUn7edmvnPTLgrCx72mFqPWPsLy7hwQqyP3UT"}, + {"path": "m/0h/1", "xpub": "xpub6A8KKcvTKEDQTQgY9XMhjAvCNBDx6nSkE71VXgaJZPnXFNUX6ELp8X8PYuwmmP83mTNMfMMHZko7TCcaUot94dx3MfgwjdUhsf9GzucP9Tv"}, + {"path": "m/0h/1/2h", "xpub": "xpub6CaNpwAB8nx8XAr5kveCzAqkd4hRT2oXcNVycgoZfprJWM5jjygYEaSTtK3ASZfBENYPrKY481To7J9cUri96FES9N9BTn9TN9fHZkTJ3DZ"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6DfikXpTp7iTHPYXrGeCMqrorH4U4urRzmVwmb3jLDsLbywPw2vP21AdE7cqQM8gGFjc2oVnVb6JTAUsZ8DHMdXCYX7sMef4J1cz2gtMQE6"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6HELrtBuV9wp8qFp85jLxVg67FZf1ncSmwYqifBWt2oJPW8AtQaJpfmipmkLwv1jJViDfx3mJupj6oyjcK3xZMDQXgcxJGn8rwSkYzBQoJz"}, + {"path": "m/0", "xpub": "xpub69Eg2d9rSf3xM2wWpKdNLfoeTfp6SGUVFWqQPiZjiwabcdta1VTeZi8wju7HgGq2jykoF35G1sWo6GTEpKEWrEiJZniywdJj4dsSVeou616"}, + {"path": "m/0/2147483647h", "xpub": "xpub6ARap7ALftcWLUtkhUTw3dZzig9QmoHN95pW2NPnno8ayMNiy6tcskkiG9cmQviE2MfZf55EbAck34KQYohdPggQx1u3q1Pk6vsYhoQSd2X"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6DRsUpAkN8r3wKqz98tp9fohKnBHS6yWecB8Nq2VexnWcP23qdaP8Nh51VTYH8jeq1vZmjBDV7uxSMvBeyd8sz41rHigdboPUZX51NtYKFs"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6Di9ARPQCsdBYnDQRGaENtT7Pr1L9furawbookQroSEWu4j94MAcpp63jLhn8YMGhgo1sDih5ToDWyiLsepnG8ZB2xSzdj43zfnxyyP3rt1"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6FZBbkq9qkM73TjP8jihhBtW2PZYcfCnSCNiZnWVGSE9qrTSAx18ka5ZNYHT4DcX8tyRbVxGpFY52kndeXr2ofV1gE5qL5NYYvTrmFguyQm"}], + "xprv": "xprv9s21ZrQH143K2suBbFp7kn3RMQmGZjUqePpEd5Byx1DqFJRWNdqFrXMJGRcBtxu9zYw974W3d8YbHUPGFfnFghaY3cgXMM1bgkqrSpUpnFy", + "xpub": "xpub661MyMwAqRbcFMyehHM87uz9uSbkyCCh1cjqRTbbWLkp86kevB9WQKfn7jPQ1GtP67BL5CsFsVviprV4j1rcWJhADEZ8dTwFssydWEpi1vn"}, + {"master_xpub": "xpub6D1NEHYCQwSkVgiQbChnrNH2HKrzNExP9coHogfU3wejAJCt2FpmaYu7RgZwWd5ZqX2L7AfDByKdVRP4opc4D6nFrsr1k84uz144GtMsy5H", + "mnemonic": "horn tenant knee talent sponsor spell gate clip pulse soap slush warm silver nephew swap uncle crack brave", + "vectors": [{"path": "m/0h", "xpub": "xpub68c3ytKDVQAAGGPyiLwGHH5b5FTetouwKvmET3HVxywFLr91UfdZvswJYNbNnKfMdKb7CH5uoPV6Kew9rExKhSh5ixck8GUindBiKHVmdDi"}, + {"path": "m/0h/1", "xpub": "xpub6BdAYKv5PhVyQ6ebCY39SqWmQnjUPbAmc5ZEV4uZS7B3YdaagiYqUbpNx9MPn6qufzVdxFoitbx2qsBLr65cyvZNqD2qFNTQZZzA7WLcYou"}, + {"path": "m/0h/1/2h", "xpub": "xpub6CPMXpWRcsZ87XtSBDMPQGn5NKagAqMPiqMgMnGYdSGcyPHVRkx2kzXoSUBt7nBQwVYDQP6R1ApnNC3kEbh2DM4iAuukgr4cNMtDtEsVZEz"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6Dm5SDi63dVvd7R2toTYyQJcCvfV8xLs9ckH3n6cvikaJD24z8gNgUH4ib8p8g368t5tXEqvLXA6nwnGFihf3EtSsj17uQyh3yXbiEFSdmo"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6FnV2hN7fEsADHMAber5YH97dEhwRogkWHN3mkt11XmBUQ2ymz96zpKm8pnwv9y4G7NRn5VhNL5XceBhrbPrjBaRV1Du9wP9w3ESzeFroT7"}, + {"path": "m/0", "xpub": "xpub68c3ytK59jdC5ixRhs6pVgQdQBMcVeRhRA9AXUKbekbJveQ7TaG3DmLU18Z1Ehdvsx4Q38GLARAqPXNf3JG61Bi8vVtTeSnc3RhX8WnxSph"}, + {"path": "m/0/2147483647h", "xpub": "xpub6AzG2bJfPEHPAdMwrrwBj7cpxVCZV3L2PjY819qtxVxny9pmog8BxsDn4z7FUtiNBH8RgZSHVn2FpoyQNC9LtHxDJwCR3BUDdGtF3Sqsafj"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6CdcHiixMpMhXipXG9C3YSEhvDmGiptR7694PQA8Gv2VB5pUPdGWgiTxnakXsjqjhS2yoRPcJuBUzpuYZNo8vLTAiXQVKvZZWduHKCz7Cts"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6EkFyDYX4Xjnshe2SnpahMNHPDjniD6g1rw7L1K8WphiCkNqZNvAtb89r2NL1cBvFRQakrWYRAxZHkpawGyvVWgVLKhvvNhZjGrbA68J18U"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GTmGRJM7pbm7pAC5RMvpsj3q5G5fg4dKaWeaAJMA7rTpQv9ayHNtHkAidVyE7Nav4Y6ox5TJ7EQXkvjQWH6cb3vWEDUY2W7iwBGiL8NAne"}], + "xprv": "xprv9s21ZrQH143K3Rq4g5KhyZyKBhg9sWbhvDQmYXpFnB5Lq4zNiQVDexEoNAVuMhHBuF6JwaQeFs2YxvudoYhcgPMTixq63g6ukUcgTbM9y83", + "xpub": "xpub661MyMwAqRbcFuuXn6riLhv3jjWeGyKZHSLNLvDsLWcKhsKXFwoUCkZHDS1xEW91qFG2eh5jkZTzEkQc4uJM28Cx1JyrD3A7SM1ZzqYD5rp"}, + {"master_xpub": "xpub6BuDPjYMa6VWu4d8ysHJCTqGARk85g4UkxTyTwU7PWzohoeWcdUeX7gCpwwUQ3EF68bWaHBivffzdchv74oPp3BN2eZqYGDQzSXYYnVMgks", + "mnemonic": "panda eyebrow bullet gorilla call smoke muffin taste mesh discover soft ostrich alcohol speed nation flash devote level hobby quick inner drive ghost inside", + "vectors": [{"path": "m/0h", "xpub": "xpub68uW5iyXtqyqpsyXwLRLC1nQfSV3WN6wXLdbCfMDnYHRvotSE3dC79d4a4SHcCG8HUbik2uCf1jGhK29mGQQc3wuJY4cR1g4vQ8BuxKxqdy"}, + {"path": "m/0h/1", "xpub": "xpub6Bc6zisUnKTvSqoYE788ovZiR8eTM1CxAHrXyV8n4vogzkcfqQaTQdvonmMfe5PgcZtBBmW47juwQCqZqLUHdwaxZEcFJdaUVL54PSd1zKY"}, + {"path": "m/0h/1/2h", "xpub": "xpub6CXccXFhWKgKMRPmPvNCrynm6eQFoYM8kC712j2yydgxgA6DE8x83RrRuWAYkmkiJJDNWu683d2qQcSicrSBgUKc8VW5m4kqXMVMF1ZsRxt"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6FD94cTDGSZ87LRCmLBNHDagGFBUJKe7RaAoWvHxpxhhgjAm62uGuyEZrtFxSCkhao59DHGM3bVkTchzHCT3wwRX478WdrN6VvWM76fZMFy"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6G29zDYRbJ3QVVo719P8FkN22BMhPR5ouBtP6DfKHzTkiszVCpXj6nnHE4LnEMH657J8JgxCghjYhs75i48UsjDRWKGcPypfKAQPQy6PNua"}, + {"path": "m/0", "xpub": "xpub68uW5iyPZBSsednaJHbRHdpjQ3thcAmq9XJiQXiUx5CP1zwUautB8CJNm21AsyJSTbfEvTrWCooxF1PQ67zZJED43Y7pHwN136nznHx715o"}, + {"path": "m/0/2147483647h", "xpub": "xpub6BHpcjfZNXFFLTWPTjMvt7sJEy8J38JhuiY9u2R9YKxQ2ddoxemv8BVqrb4UBPw8prvtHxrSNSfJrwURduH3KfEdw1J12LABoA12UcB1vAU"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6CoVEP4JNY1BLKV2gYRHo7BcFGhTz6zpguww6pN1L33NxUV9VJEgecX2Cbv7ssuGd9bAR4YRgAvFhJDFH9o16o2FMxpPWtGfKCSyiMnxgZT"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6EGocS7sqNGqiTy2p6rg4mQ3cn2QV4eyJkGByaGrmxCo1it2M9B2w9PYGQmn8v2fhFgkQyQG5aYsRyrKuj9G8zzo48LzCaBzEJvQ1RNZoCu"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6HFByTLakDVbWGiLjnkYbWtigb9VZzBsjzuDdjxH5McRJBA79weEt8fkTRKPVbn89EW5YQtzZEP6Fc7CXvJ1zisU3BZYz8aFzMgSQ8dBQRn"}], + "xprv": "xprv9s21ZrQH143K3gwvGvpNybNnKgucYunYCveFYLo6VSZAXnddMzk4FGmGt9eT8YyQ841i7Vt6VSYotTtby5QuB2QJraw2ta1GSaaZFWGizET", + "xpub": "xpub661MyMwAqRbcGB2PNxMPLjKWsik6xNWPa9ZrLjCi3n69QaxmuY4Jo55kjTu8X6tNUT1dra7VPBJ6XWgW1DeJ2Eh7d9vmcZeFAkZfWauMAtT"}, + {"master_xpub": "xpub6CQwAScCGPWPGNRrHJekRwCX73VTs6heupyRkmKgxzAwWcJW1AT3g3y6CrfwgVWLqmyxFR6QAhRoeZcr1XpNgi859LtzeWSLbkdGMN8Jpt4", + "mnemonic": "cat swing flag economy stadium alone churn speed unique patch report train", + "vectors": [{"path": "m/0h", "xpub": "xpub69WmLayNmhGX8F7ZmDJDwckniLVqGx9aKBYXwPEoXSSiuBRMc17Bgv1sfxNcVfu5CMiRSBRsxAqEFTyh761WSsStbhyiY5mkZtxrqFF4C6e"}, + {"path": "m/0h/1", "xpub": "xpub6Af6E9chBdS3XWnR7RSSoAVfjhRAgZMsbVkuv5kYfZSXMPnVtEN3whzeBpzfug6yfBNUx9f79T6FvT1Qix7HxDCyc1zwtggaTLWHUJVxuKF"}, + {"path": "m/0h/1/2h", "xpub": "xpub6Cby5tczeFh4EogxVmZgyQcHaVWyQr4jpK7XbTb5njrGr64CNFNSnaMHMf7Z4k4LtqeiHKQEHo46EVYQpaVB4YwvY1vh7huCLuvWPn8G5pQ"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6EHTdZ2Yc635arz2xSUn7HtdR9gh6xLS9hxPDbFFca2vMYgnhpqCxf1PvgCHmUZDKEW1DrFfSh1EhvdcGRWZSZRwEXK3ZaSwBrnt6y4cepT"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6HChqHNzpu6ciBbkgchLDqJibeu7yN5APjfo311izJBNZpsx7RgAWPtKh6fTocFmzebgEnuZUfffBeRPArqMWb7ww8KfpNV2ia76qAj3wba"}, + {"path": "m/0", "xpub": "xpub69WmLayES2jYy6srezQ2Lzqq937QPUMZH9oBKAEtRTYwhtbpv6aiANFWVGNYYVyMe6TcxUrLmz6L898FRpC2KKBG3dmKNt8SEuNsQ2CK6jw"}, + {"path": "m/0/2147483647h", "xpub": "xpub69yVugcVmf2oCKR2iMceXThBY95M43PNYyeWiP85GFtXtPwjwwLsVs5etAwKg2nQE5dqfLkuisK6zB9cWRX1VbZvnX7XYEoKYBwPb8VKMzR"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6CvzXaQdm57yrxRsZto4RNmMQJvgiVx8TsTzZgfQz5w8JsSaawKXXCyVGvjannDc1bDqP2SkUF88Yy1aess5btmaxVuWj3EV59jpwB1B8zF"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6DuEQ3Thg3UPbg4fkevwAJ5wFi49YhkayumHXgHTAtqAWWZwzPRPJZAGR8jcvgD2fSmMFUQEwczP5ySCJAK7g6haG2kjFVS9nj1d6kwGLhj"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6H297CE2gjDy2doiDihd5qnvmog3LHv2tvo524nvoATjCJkZGPwWaCYpUtNpWwn1XQ2XWkMbDSrch7eDWnuU9LjiS8CTnzEaPjvmqdqtFXU"}], + "xprv": "xprv9s21ZrQH143K2AtEH5vXQTtJQdUVC1mwSKcGt3BMvKQDV5vgWNjAHYc9Kt7qw5CZCX2AXzprnVXurCCqPRiDkCamD4feECNCiYT4FNNmWBk", + "xpub": "xpub661MyMwAqRbcEexhP7TXmbq2xfJybUVnoYXsgRayUewCMtFq3v3QqLvdBBp19mJSGk7t735oQH622cd2ZkykhdESxxBwYoYTBS7wkwjuFTA"}, + {"master_xpub": "xpub6CrVe4dGQS48yjcEHT9hF2FtuegWpsmDnDThoRSpYvoH4es1bH9SoqFC3WPfYSFjpMA1K1VwDKNLK7edp4whfpJ3kDJKeHFMKcubtnnumX7", + "mnemonic": "light rule cinnamon wrap drastic word pride squirrel upgrade then income fatal apart sustain crack supply proud access", + "vectors": [{"path": "m/0h", "xpub": "xpub69Qd43sFY9Uc1Kr1nXsyXbFU8ab1y9ysmD9cWcAeYLBemX88gUDw564fakABvfa4k1jvueQGx7wT3cmf9Ms6eFnZq1Mo3snGueQ65cuvbWH"}, + {"path": "m/0h/1", "xpub": "xpub6AjBfojrY6hQpqEbvnoDmqFsrrUbSPpQS27ty5TNDMEqoBZB6UqXnRwTcBWigduVMZ2Qd79Hx7TBSTWdNJWNbo63enFHZMDWewTKZbtqbUG"}, + {"path": "m/0h/1/2h", "xpub": "xpub6D21mwsKcVo5pVqN2jVwped7dbCjLd8nJK1fiLqQDw8zZMdHKPVXuwepJPUP7w97pRhD2TPYkyKMzj56uLJ4FWtfbBh34Px4NM3sedi31sn"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6DzQ2poTbeh8r7eu8ubD7XjmrmttLnr8Jnjas9WMUMTBonfQpLZqwoxHyTofPz28D4UwXE5BbwUSKqWRcnWHCwcUxy5hkdeFeLtJ4HobPLs"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6GYLXbKqZubF3gbrU1pZ9GL2GbUkVDtPja9ycva8dyhMYVJ7zvcV5aXNtYGsSGLCW3UnFXx9SSEX9kARwL5Vcek8CGdX52vdAcWPQcD9kVi"}, + {"path": "m/0", "xpub": "xpub69Qd43s7CUwdrLE9vCg3SY9kSwCHs2Rd3TLKEhcoxD4kak8vdwbT4FSLbzsfZx2h81hFsg7cUYCFrvSDMBCKGtvsNaSSRmDVS1tih8H3MVd"}, + {"path": "m/0/2147483647h", "xpub": "xpub6Aun4LKeJsDwD3sKkEar57EC4oGfLj9Rc4VGYBBFkSvbpdCkCagb6fZcvpX26xz42uyfjggwo4DaNDtZd7AB1QpjvHjsizXYNcmQFQrU1Jv"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6DCTck92JeFMBUtSyuvfdrtwToVQbQbYq4v9NhJEojaW96ywhgrjdp9SNwMCASbHdfKw9k4pZPWhRoh3vLbR747ZDDECAV7Wgtw4YgrmXo9"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6Dj88k2Kg3cC7wbsdkCQADgDqt5x1qqccDjd2xE7sseb5HaXk4Fq7kpWpcEimrFyeh43n45YpR6w3Gx33TeFfNQZZfa9KdmuYsAKTNhDzn6"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6Gjqq27mfZBYuPejqaVrkEkDiYCYP9giPiGHFsZQ95tmrCm9sqBgGCUiwerm7MesUEwnouh5sMyzBMYsT32LL8r84BP1ZKYKEsrDWMBb8f3"}], + "xprv": "xprv9s21ZrQH143K38sXphyFNo98r6a7W6qQXzm7e2k1Xk8ttAcPxvFd8WDyM2WMqabevL3fHke8MrpKXDJS412sh3AzK3YGpxKeu2aLki4VT7c", + "xpub": "xpub661MyMwAqRbcFcwzvjWFjw5sQ8QbuZZFuDgiSR9d65fskxwYWTZsgJYTCKXDxZYhs6CYGJGAZu4UsdCdmtgT9oEZXjSL6hSF6JxqNGyFc4B"}, + {"master_xpub": "xpub6CjeCajsEoYVr8C5F7iQust2nZoA3q7cHWaSAKZqDuQtbmtMuZphhKHgsVGfjG9JXG5WJzB169vmLTMqdbddexrJ4DX8vkgG51Ax8WHQcPy", + "mnemonic": "all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform", + "vectors": [{"path": "m/0h", "xpub": "xpub68BjfutLj9SN8y2vKJkorGGxuWdzrVpnNUsKEmnCQmTdkc2MteUwoNsGWqhkUJzMDMkQW3Xie4HZBCbAcyK1ZP9d4UhhFXBZPMZeJpgJBa7"}, + {"path": "m/0h/1", "xpub": "xpub69swybbmnF39xshPftQ9nRacSEo75vRiawUJzWFjpXnEQyUJuotedTbgYt6gLcHZ669vopo6ZeDMecpf7ELXZtNnQ8QJWzYFvqwojCaxpQL"}, + {"path": "m/0h/1/2h", "xpub": "xpub6Bj8p9GMXBoE1n5U5uFwEYx1ahX591Rixijnr1Sc32QDptfvGNDZiRix4P2Hw2yv4aJRyHuUp9pA1WLVBRckv4jHCSPbmSpwZaJSmTnUwAN"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6FQgi6EqZVmEfyZ8HaMAF91n23kYA6sV8hAn5Tgko4C8FMTJ3k7RjHqZLXUQfW35S7LB8p7rJqHu4bkx9p67bTPzaZrV8ETM7zjoaaTqyde"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6G5huJS7Gf3Y2KKYmWi2y8xCvK11a5XHnKBfkti3zcSbACZXZovd13BnSB3n8KFKKLgXCYQLFN9Y7gnFDFFEJXiTnFNYH5pt8MNGtoBz8ms"}, + {"path": "m/0", "xpub": "xpub68BjfutCPUuPxWwGcrvkJgXDTaK4y3xZpLwvXnxMAcqbK6e6LBVbZHYU4HCoK1XYtmXiJXdnsJKr6qRvRza9h8bUgk4A85VNZXjVqfD3yxs"}, + {"path": "m/0/2147483647h", "xpub": "xpub6AkFTbmz5k3tKE9V2M3wttbVTAA1QC5r7uBWbL7FLVxXwaxgLbpRgGSkyhjzQrBZqWjoNg39wognbEQanEkDKX4SuzEBDpG6ptchrVVVpYn"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6DAM9JVXUtEFudCcsq9UcvyyyaZdNrXYVnD8QQVo2ucan1zAjddbSuBoDnyT7YSy2Gr6WxV32sMrLz18n2rceCoHPo9zttCXUYbFa5cSpoo"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6FF3Podcbq7zmwHoWt9s8RsG69ZqjpRoBAUPRF4kJyR7P65Cd6twZNTWsqRqBsXaDX57JfjEKFsL2jRb6U29R4AqJX9iDcpXguHwLQGYxKc"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GazAxvg88uQUfLm3e2yDwA9C6hosFWoynUNVXMvcUMzpVjrLZ9xcQ7wtJDLjg9NLaZYdwuNytEeP4oTXe5qA6kHu9UsMUcdorKaiPswzSq"}], + "xprv": "xprv9s21ZrQH143K2Jkd9X2AWpbhNL6yLzVmDxr8F24GXpxAGnkFELJduv1KinexqDctfGBmf6gwrejTQ4QzYZGw6o9RRjpBveREQvytc4JSkPk", + "xpub": "xpub661MyMwAqRbcEnq6FYZAsxYRvMwTkTDcbBmj3QTt6AV99b5PmsctTiKoa4tf3rUQMR16kVYr3mDbnUsFaAuf1qXYPgiSP6ZvZ8wbiFmEVmJ"}, + {"master_xpub": "xpub6CzoMCg4goLhyKXiPWLqpUzGQbfSWvVnnDUC8TuDdSNeJhA6JkXRicACEnTg8rRiwLdkLH83S2jduHJgXnvjecJ4jS1iotZVMm2dwTnvTjY", + "mnemonic": "vessel ladder alter error federal sibling chat ability sun glass valve picture", + "vectors": [{"path": "m/0h", "xpub": "xpub69GiX9v5Cudsh69nhkYaFU2wKKWf7xreUVyxhS6DjnVqYBtbD27t4Cc3oEymbU24QAiVfVt7KGWZRXwhQBuFkV2Cuorzx3m8iW5eEisnp3Y"}, + {"path": "m/0h/1", "xpub": "xpub6BBsjHVgb7pMrC9SqtT229bJwc4dboeTboy2xsyBfKhJ4CM7CJx58XahxnrshiWNVFUMvMNZ6jfVRc5dwUDrAWU8dYvwTsb5tP3BaQSWaWy"}, + {"path": "m/0h/1/2h", "xpub": "xpub6Crwinm3mHBrMcQKJ4SuVKdo7J4Prse6gvsBwMgFNKcpRBxayBJXF62U6u6sf2bchrwXcBPNP1T88cwstPxVxXp6H56iFraNbqZycgeyHzq"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6Ds1AYMa1JDQsZZSqDThuiXxV4ALscHbG1yuYUzqccpph1HxUwew42aSqPrWYL7t67WPngdvmwzV1FW35TdQs33B7AX1a5RGyn4w3kKQnuk"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6Fukgg2TFvaTV56ahWppUfitXytLXcgPSVUXtpUBH1aCfnM4nGBJe5xrpvR2m8cxU4gR8YeiTVawY6NNzMCGhvbXcUjctFee7cVP75oADEK"}, + {"path": "m/0", "xpub": "xpub69GiX9uvsF6uWzE7YPVCzo6SikJyPgyf3ZAxWiRVusDh8QUvYcCxQwEkBkpbftdTBmbJzhHVPDsys4mjPLPGLh9vnHVvrRL6xybY1r3qqSv"}, + {"path": "m/0/2147483647h", "xpub": "xpub6AgEaEngfgfe1zMx2gamRiU1aymh8Q8iQ6W3civ23WUKWQijs7Ubd1YrxzkdtJ9Y9KoWwboGH1qNyzJFNsHZx1zujgGzb37mJ9TuV3LCDri"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6D4LGGCFHCYiGuoKrWzyvsf62uo6CibBYZUqNMEsNTSZ2ka7SQv9UP2BREuxG93LpDQNn2CxBzDJzksAgAagBZJR1eUBPfmFXaQZGuT8HL2"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6ExhbmAYyQkkZ7tiZux1XxpxPFar8SWnZEdHuWoLX27mdYLXTRd685sAENV1UeDpeTVHGFJa9Jx4tEYKyPGGfyXqZTqeDyHi47rSsRMonVN"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6G27GctfQAGGAAKQjbWTnUgBzdrKB4i7jCbH1chcRAucSYS1gaXP8EGS76fKoKdKmpNvb93cpKBS7ABp7aXYr53i2YqtmTogNiYMU5ndCVg"}], + "xprv": "xprv9s21ZrQH143K4HQ2EdzttnLzhVt7gkEHcrBWw8WZBiCuXzp37KcocvU9uKazS2qSqSVDp1e9d5F1PMyMs5dTGxSamJWXeMfEmyKGxtMGzKU", + "xpub": "xpub661MyMwAqRbcGmUVLfXuFvHjFXic6Cx8z577jWvAk3jtQo9Berw4AindkcW3Nqf5xVQ4G9ksujKjRmNdkz5YcvKyPAqqRuRyM1749LQpEdQ"}, + {"master_xpub": "xpub6CARLEj2PqtS8oMnvJ2vaFzRc3j8PJvuAZcXW11HJssQGxnGNFnQtPiwLvYFzqhSa8vyUNgURT17idGv8Co4qSbDzsxkv8accDZCiWit2Rd", + "mnemonic": "scissors invite lock maple supreme raw rapid void congress muscle digital elegant little brisk hair mango congress clump", + "vectors": [{"path": "m/0h", "xpub": "xpub68CGNoi8Pn2PPHEBfjLrBcgQjrx1tm3vV1HDub8ougSACNrWCuLQYdVfcAViSVrikzVKVfHu4oqG2oWcpmi3hYVw2MJCtTjKmKpzuKBmC8J"}, + {"path": "m/0h/1", "xpub": "xpub6BLjroUgrqRhzkMszxub2x6ZmvvCrbmSgkoQkrAMn5SbjmhbEJKxsnpSLXDNJz4UPTMot9pKHD9Dp7zgG72ZzToQasbF9HkqJZjsLHdhbBj"}, + {"path": "m/0h/1/2h", "xpub": "xpub6CWVUWLrdisahEBPJHcUzxGwJL5ca1miwZDPgyjPfspxCJAmKvqojYgFYmVmUBJxz3nFVeQsq3T58wjC6ae4adhr2DdBwGSdy2qarKgX8Yc"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6EF24pP2b5aTpHwmHit21ozZ5NYaaa92CmZg44aajekmxvcS2ccMkSUu54kU1Wp2u4Umq2QMgWFRTtqJsfwe39TnVJDjHzVT3F9VqtdY7xz"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6H1ZX1HRWeZ8i8QunX9ziNF2ehdaaA9CpqjZPk53b4WUVzd4zGSnRZPkofYcfHaDb4PnnMeUNXpKFzSP29ZpXkSuuXRqRxNJkvJKkGiZR9b"}, + {"path": "m/0", "xpub": "xpub68CGNohz47VRBnTrxFPnSn8fvKtCAbhR7R42R7bfBwP3sLx5rGEYhjyECfauXpuNnx4FFcratrmwo7XAC9fzKxRhFs9oULH3XMQXfKoSy42"}, + {"path": "m/0/2147483647h", "xpub": "xpub6A5ru4XhfrfsFBFHA5TbPvsNUTHtggySZTXCW6qR7jbLwXBhwJsCo9NxbCyEBnVnDSbL3PHyAAMv9vHUDY2q8dHpwAGW46uybHwHNBMmjHq"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6DRrDcxV7isD1F6hU1XWkdazniJXaxZZdzprN4WJS3romDiEdwhFPk1jt4i9sT7S6jJv2gzPkxgweNejUSgWtHfd1Dcx3YFrNysPMV5NSS7"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6DwRebtJfjEzbG5nPeANfmn5H9PBok1bnsJwTZEARFyZyhd95zgt3MNYdm43dz3J6Zv6EvZNBFct93vYip7VkRuwR5hvgGAjnA3DrBYuHyP"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6Fxs9sPKtAd3gU4yJ9TVz959FArAi7gGDj85eRQCueiTPMnwtfroMNuAjkXLLfy6bN2JQP5ZeZPNKBFP7acUwKEWCGZaJ3NFjF33Mi4Brd4"}], + "xprv": "xprv9s21ZrQH143K4ZQvNzA7Loca2W4jZJEp4y17EDDLY59hYBrK51KrGW3sPtC1BHKgyfqjR3JoY8cX3VMtHZAcjhzE1HB13zmyR2Vs7xXaq2T", + "xpub": "xpub661MyMwAqRbcH3VPV1h7hwZJaXuDxkxfSBvi2bcx6QggQzBTcYe6pJNMFAr4NJt68cwGraTCtbWbvcY7WAc2DaXUqSHNhDxUg4Mesv1XnVy"}, + {"master_xpub": "xpub6C32v2sJCVcgyZHU5kSBnnRokjcC66pmTeCjXJLxiLUWdD7NZmz4R8EmpCP24opNWFb8W2eZwbA235pdT2WEgQJqnQtY5K9JjQh5BJf6tkK", + "mnemonic": "void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold", + "vectors": [{"path": "m/0h", "xpub": "xpub69h9z6Y8DYUPVkNXbZs9ATK1ZLuicA7gitrZPExJdCDA9u2MJfkQridgXniA9wU74EjEQgzDnfF9e11Uwe5EcKnCwbQKGk7eit594cQ8dKB"}, + {"path": "m/0h/1", "xpub": "xpub69uiXN5JDEpHQ8VDFZM57Cse9pUo7R4oZscUEuVw4XvM7ocbhemcUZFXFNeeB53Mtn34wcg2thRw46pGR6CjQ4eiqb1qZHwVs5a7wUuqbxw"}, + {"path": "m/0h/1/2h", "xpub": "xpub6Cn3HJHDbZAakPpNoHADNF2ytYekNkRPGsDTkCY75SrKaQwR2Dgjh2rzTz3ASnGDJMCT5NY1m6vAcV4WTWW5oqGBUjwGvz8Eb6Z8wT9Smtu"}, + {"path": "m/0h/1/2h/2", "xpub": "xpub6EfqgCMa7eCDC9Sm4yMnRxWRwJiG8DPfpCpHUt9EnPGUbcpfzGNAG2sbJDSJYd1Er84A9VB1MDTfUn1CLcXbyqLD17gfYGokRqeNjsUjuKH"}, + {"path": "m/0h/1/2h/2/1000000000", "xpub": "xpub6H75RNPgHAcKkbyskTtYz91qZCxrrh1KjwBfUBC3oUgm9KpZTaEMoUBwPZs92UPCmgfN33HnZwWhfQ26HUSVqiph7sPuyY3zyoFDsrkVnUo"}, + {"path": "m/0", "xpub": "xpub69h9z6XysswRK7xm3NKdhqsgJSD4z5uwoh9UtRocymL7eH6PU3EJ13oGLSbnBLCz5KfpguHmsdNNGrHKBz11GnDpFho8zDaBDMZRd2qpy83"}, + {"path": "m/0/2147483647h", "xpub": "xpub6AkxvSSVjiMixBodUarR7W9Qgs9szQJLahiR3yuwoMh4pbkFEcrMXBrjZtLVAWn6u1wxAHMPdMT5U9vgHpQHyFovBNSGxQxh5aUfEPqRSTa"}, + {"path": "m/0/2147483647h/1", "xpub": "xpub6CTbKFLR2G63VAeTcC7kUGv9s2crQuoPL2EoxLCeHqzjbDZZSSBTJ5nGHGHV1Jq6TD51GibFRRRrDiq4MBKbPPH9iJFZ6qHv3w9nfT4f2By"}, + {"path": "m/0/2147483647h/1/2147483646h", "xpub": "xpub6EXmH5Tm3jv7DbX2hZXem45ZMixddQzQokmvDpqEkVxByidzCWbZHik6FwuptgsBxXrNgDgrwucay1BmunYA8dStWRB3M18tjwUgc5Hdyo8"}, + {"path": "m/0/2147483647h/1/2147483646h/2", "xpub": "xpub6GBKNafDD3DNusJEJbwZt8xUThnP6gwagtaMUkpsUEzx2HTZL2mPUrsr9Cfs6n3PBWZvHNGieTMrx8iJVkei9114H1cRQsdUXAYbXazEJ7U"}], + "xprv": "xprv9s21ZrQH143K3vkVeVcLG5PeVoexN6hpu9r4mS2j3uVeZo7vBrRNGHENDZXwYBgbQ5eMvHCX9YRL8V7aykC7a4UNkvJCuBacLRHwsdMGhNF", + "xpub": "xpub661MyMwAqRbcGQpxkX9LdDLP3qVSmZRgGNmfZpSLcF2dSbT4jPjcp5Yr4pYCzdVYPKEjCUdyU1oAQaUJhdaHUi6QyxXAL23cEpSBxXXDZtr"}] \ No newline at end of file diff --git a/test/data/coldcard-linux-sock.patch b/test/data/coldcard-linux-sock.patch deleted file mode 100644 index b87c0324c..000000000 --- a/test/data/coldcard-linux-sock.patch +++ /dev/null @@ -1,30 +0,0 @@ -From d1a3a1cef890ebe4ff72a8f89cd6c56dca89747e Mon Sep 17 00:00:00 2001 -From: Andrew Chow -Date: Tue, 27 Nov 2018 17:32:44 -0500 -Subject: [PATCH] Use linux unix socket address format - ---- - unix/frozen-modules/pyb.py | 6 +++--- - 1 file changed, 3 insertions(+), 3 deletions(-) - -diff --git a/unix/frozen-modules/pyb.py b/unix/frozen-modules/pyb.py -index 39778a2..0108516 100644 ---- a/unix/frozen-modules/pyb.py -+++ b/unix/frozen-modules/pyb.py -@@ -23,10 +23,10 @@ class USB_HID: - import usocket as socket - fn = b'/tmp/ckcc-simulator.sock' - self.pipe = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) -- addr = bytes([len(fn)+2, socket.AF_UNIX] + list(fn)) -+ # addr = bytes([len(fn)+2, socket.AF_UNIX] + list(fn)) - # If on linux, try uncommenting the following two lines -- #import struct -- #addr = struct.pack('H108s', socket.AF_UNIX, fn) -+ import struct -+ addr = struct.pack('H108s', socket.AF_UNIX, fn) - while 1: - try: - self.pipe.bind(addr) --- -2.11.0 - diff --git a/test/data/coldcard-multisig.patch b/test/data/coldcard-multisig.patch new file mode 100644 index 000000000..23038b66f --- /dev/null +++ b/test/data/coldcard-multisig.patch @@ -0,0 +1,145 @@ +From c2e4c24c226555903705aae0386aefe0e15bf873 Mon Sep 17 00:00:00 2001 +From: Andrew Chow +Date: Tue, 27 Nov 2018 17:32:44 -0500 +Subject: [PATCH 1/3] Use linux unix socket address format + +--- + unix/variant/pyb.py | 6 +++--- + 1 file changed, 3 insertions(+), 3 deletions(-) + +diff --git a/unix/variant/pyb.py b/unix/variant/pyb.py +index d22bb1b..fe8e7ca 100644 +--- a/unix/variant/pyb.py ++++ b/unix/variant/pyb.py +@@ -36,10 +36,10 @@ class USB_HID: + import usocket as socket + self.pipe = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + # If on linux, try commenting the following line +- addr = bytes([len(self.fn)+2, socket.AF_UNIX] + list(self.fn)) ++ # addr = bytes([len(self.fn)+2, socket.AF_UNIX] + list(self.fn)) + # If on linux, try uncommenting the following two lines +- #import struct +- #addr = struct.pack('H108s', socket.AF_UNIX, self.fn) ++ import struct ++ addr = struct.pack('H108s', socket.AF_UNIX, self.fn) + while 1: + try: + self.pipe.bind(addr) +-- +2.38.1 + + +From fd51e85693e0d66129133b1f195134aead1cf7d0 Mon Sep 17 00:00:00 2001 +From: Andrew Chow +Date: Tue, 17 Dec 2019 17:56:05 -0500 +Subject: [PATCH 2/3] Change default simulator multisig + +--- + unix/variant/sim_settings.py | 6 +++++- + 1 file changed, 5 insertions(+), 1 deletion(-) + +diff --git a/unix/variant/sim_settings.py b/unix/variant/sim_settings.py +index 2706fb4..f9b533d 100644 +--- a/unix/variant/sim_settings.py ++++ b/unix/variant/sim_settings.py +@@ -71,7 +71,11 @@ if '--ms' in sys.argv: + sim_defaults['multisig'] = [["CC-2-of-4", [2, 4], [[1130956047, "tpubDF2rnouQaaYrUEy2JM1YD3RFzew4onawGM4X2Re67gguTf5CbHonBRiFGe3Xjz7DK88dxBFGf2i7K1hef3PM4cFKyUjcbJXddaY9F5tJBoP"], [3503269483, "tpubDFcrvj5n7gyatVbr8dHCUfHT4CGvL8hREBjtxc4ge7HZgqNuPhFimPRtVg6fRRwfXiQthV9EBjNbwbpgV2VoQeL1ZNXoAWXxP2L9vMtRjax"], [2389277556, "tpubDExj5FnaUnPAjjgzELoSiNRkuXJG8Cm1pbdiA4Hc5vkAZHphibeVcUp6mqH5LuNVKbtLVZxVSzyja5X26Cfmx6pzRH6gXBUJAH7MiqwNyuM"], [3190206587, "tpubDFiuHYSJhNbHaGtB5skiuDLg12tRboh2uVZ6KGXxr8WVr28pLcS7F3gv8SsHFa2tm1jtx3VAuw56YfgRkdo6DXyfp51oygTKY3nJFT5jBMt"]], {"pp": "48'/1'/0'/1'", "ch": "XTN", "ft": 26}]] + else: + # P2SH: 2of4 using BIP39 passwords: "Me", "Myself", "and I", and (empty string) on simulator +- sim_defaults['multisig'] = [['MeMyself', [2, 4], [[3503269483, 'tpubD9429UXFGCTKJ9NdiNK4rC5ygqSUkginycYHccqSg5gkmyQ7PZRHNjk99M6a6Y3NY8ctEUUJvCu6iCCui8Ju3xrHRu3Ez1CKB4ZFoRZDdP9'], [2389277556, 'tpubD97nVL37v5tWyMf9ofh5rznwhh1593WMRg6FT4o6MRJkKWANtwAMHYLrcJFsFmPfYbY1TE1LLQ4KBb84LBPt1ubvFwoosvMkcWJtMwvXgSc'], [3190206587, 'tpubD9ArfXowvGHnuECKdGXVKDMfZVGdephVWg8fWGWStH3VKHzT4ph3A4ZcgXWqFu1F5xGTfxncmrnf3sLC86dup2a8Kx7z3xQ3AgeNTQeFxPa'], [1130956047, 'tpubD8NXmKsmWp3a3DXhbihAYbYLGaRNVdTnr6JoSxxfXYQcmwVtW2hv8QoDwng6JtEonmJoL3cNEwfd2cLXMpGezwZ2vL2dQ7259bueNKj9C8n']], {'ch': 'XTN', 'pp': "45'"}]] ++ sim_defaults['multisig'] = [ ++ ['mstest', [2, 3], [[1130956047, 0, 'tpubDF2rnouQaaYrR9x68P5Jm8WjhCE4atyGiPviFA9ve5iMnYbkTjof2HjzejcQcD7getPusDLPsWJLN2UttzK3pyVgBkRs52MiRZM7ZJ8TrEq'], [1130956047, 1, 'tpubDETRnZNJAqXiVeiL8UMDzCTBAoh3JvZkgXLdb1K2xzpJLepuJ6ka8jnVyRSkVh8Nbbo8u8dobZCsNENmRKipLzHNsS5mccjKSpXgSgavTQe'], [1130956047, 2, 'tpubDF3hdPQ5oDhtYjjaC596pboPii7UZmjqZcBPBRAbb6Bgn9hKoFxb8zWsBfdiCnTq3htUs2Yi2reeG3kMqHzZGZykJQAB5aKrJ8UfiXjmaLD']], {'ft': 8, 'ch': 'XTN', "d": ["48'/1'/0'/0'", "48'/1'/1'/0'", "48'/1'/2'/0'"]}], ++ ['mstest1', [2, 3], [[1130956047, 0, 'tpubDF2rnouQaaYrUEy2JM1YD3RFzew4onawGM4X2Re67gguTf5CbHonBRiFGe3Xjz7DK88dxBFGf2i7K1hef3PM4cFKyUjcbJXddaY9F5tJBoP'], [1130956047, 1, 'tpubDETRnZNJAqXiVeiL8UMDzCTBAoh3JvZkgXLdb1K2xzpJLepuJ6ka8jnVyRSkVh8Nbbo8u8dobZCsNENmRKipLzHNsS5mccjKSpXgSgavTQe'], [1130956047, 2, 'tpubDF3hdPQ5oDhtYjjaC596pboPii7UZmjqZcBPBRAbb6Bgn9hKoFxb8zWsBfdiCnTq3htUs2Yi2reeG3kMqHzZGZykJQAB5aKrJ8UfiXjmaLD']], {'ft': 14, 'ch': 'XTN', "d": ["48'/1'/0'/0'", "48'/1'/1'/0'", "48'/1'/2'/0'"]}], ++ ['mstest2', [2, 3], [[1130956047, 0, 'tpubDF2rnouQaaYrXF4noGTv6rQYmx87cQ4GrUdhpvXkhtChwQPbdGTi8GA88NUaSrwZBwNsTkC9bFkkC8vDyGBVVAQTZ2AS6gs68RQXtXcCvkP'], [1130956047, 1, 'tpubDETRnZNJAqXiVeiL8UMDzCTBAoh3JvZkgXLdb1K2xzpJLepuJ6ka8jnVyRSkVh8Nbbo8u8dobZCsNENmRKipLzHNsS5mccjKSpXgSgavTQe'], [1130956047, 2, 'tpubDF3hdPQ5oDhtYjjaC596pboPii7UZmjqZcBPBRAbb6Bgn9hKoFxb8zWsBfdiCnTq3htUs2Yi2reeG3kMqHzZGZykJQAB5aKrJ8UfiXjmaLD']], {'ft': 26, 'ch': 'XTN', "d": ["48'/1'/0'/0'", "48'/1'/1'/0'", "48'/1'/2'/0'"]}], ++ ] + sim_defaults['fee_limit'] = -1 + + if '--xfp' in sys.argv: +-- +2.38.1 + + +From 04aecb1005d997783e63bd6cd830c4a98f099cb8 Mon Sep 17 00:00:00 2001 +From: Andrew Chow +Date: Wed, 27 Jan 2021 21:50:22 -0500 +Subject: [PATCH 3/3] Allow multisigs to share master fingerprint + +--- + shared/multisig.py | 40 +++++++++++++++++++++++++--------------- + 1 file changed, 25 insertions(+), 15 deletions(-) + +diff --git a/shared/multisig.py b/shared/multisig.py +index 6f26644..6e190b5 100644 +--- a/shared/multisig.py ++++ b/shared/multisig.py +@@ -144,9 +144,9 @@ class MultisigWallet: + # calc useful cache value: numeric xfp+subpath, with lookup + self.xfp_paths = {} + for xfp, deriv, xpub in self.xpubs: +- self.xfp_paths[xfp] = str_to_keypath(xfp, deriv) ++ self.xfp_paths.setdefault(xfp, list()).append(str_to_keypath(xfp, deriv)) + +- assert len(self.xfp_paths) == self.N, 'dup XFP' # not supported ++ assert len(self.xpubs) == self.N, 'Number of pubkeys does not match N' + + @classmethod + def render_addr_fmt(cls, addr_fmt): +@@ -245,7 +245,11 @@ class MultisigWallet: + + def get_xfp_paths(self): + # return list of lists [xfp, *deriv] +- return list(self.xfp_paths.values()) ++ ret = [] ++ for paths_list in self.xfp_paths: ++ for xfp_path in paths_list: ++ ret.append(xfp_path) ++ return ret + + @classmethod + def find_match(cls, M, N, xfp_paths, addr_fmt=None): +@@ -280,24 +284,30 @@ class MultisigWallet: + # the same prefix path per-each xfp, as indicated + # xfp_paths (unordered)? + # - could also check non-prefix part is all non-hardened +- if len(xfp_paths) != len(self.xfp_paths): ++ if len(xfp_paths) != sum([len(x) for _, x in self.xfp_paths.items()]): + # cannot be the same if len(w0.N) != len(w1.N) + # maybe check duplicates first? + return False + for x in xfp_paths: + if x[0] not in self.xfp_paths: + return False +- prefix = self.xfp_paths[x[0]] +- +- if len(x) < len(prefix): +- # PSBT specs a path shorter than wallet's xpub +- #print('path len: %d vs %d' % (len(prefix), len(x))) +- return False +- +- comm = len(prefix) +- if tuple(prefix[:comm]) != tuple(x[:comm]): +- # xfp => maps to wrong path +- #print('path mismatch:\n%r\n%r\ncomm=%d' % (prefix[:comm], x[:comm], comm)) ++ for prefix in self.xfp_paths[x[0]]: ++ if len(x) < len(prefix): ++ # PSBT specs a path shorter than wallet's xpub ++ #print('path len: %d vs %d' % (len(prefix), len(x))) ++ return False ++ ++ comm = len(prefix) ++ if tuple(prefix[:comm]) != tuple(x[:comm]): ++ # xfp => maps to wrong path ++ # But maybe there is another path that does match, so keep going ++ #print('path mismatch:\n%r\n%r\ncomm=%d' % (prefix[:comm], x[:comm], comm)) ++ continue ++ else: ++ # Found a match, cleanly exit ++ break ++ else: ++ # No match was found + return False + + return True +-- +2.38.1 + diff --git a/test/data/keepkey-build.patch b/test/data/keepkey-build.patch new file mode 100644 index 000000000..3e68ad60f --- /dev/null +++ b/test/data/keepkey-build.patch @@ -0,0 +1,38 @@ +From 5657d7a0465cea36e840853f350f907e9301a451 Mon Sep 17 00:00:00 2001 +From: Andrew Chow +Date: Tue, 31 Aug 2021 17:29:07 -0400 +Subject: [PATCH] include stdio and remove extra __stack_chk_guard + +--- + lib/board/keepkey_board.c | 2 +- + tools/emulator/main.cpp | 1 + + 2 files changed, 2 insertions(+), 1 deletion(-) + +diff --git a/lib/board/keepkey_board.c b/lib/board/keepkey_board.c +index cfc957a6..0e67623c 100644 +--- a/lib/board/keepkey_board.c ++++ b/lib/board/keepkey_board.c +@@ -36,7 +36,7 @@ + #include + + /* Stack smashing protector (SSP) canary value storage */ +-uintptr_t __stack_chk_guard; ++// uintptr_t __stack_chk_guard; + + #ifdef EMULATOR + /** +diff --git a/tools/emulator/main.cpp b/tools/emulator/main.cpp +index b6aa9ee6..7b3976f8 100644 +--- a/tools/emulator/main.cpp ++++ b/tools/emulator/main.cpp +@@ -37,6 +37,7 @@ extern "C" { + #include + #include + #include ++#include + + #define APP_VERSIONS \ + "VERSION" VERSION_STR(MAJOR_VERSION) "." VERSION_STR( \ +-- +2.33.0 + diff --git a/test/data/speculos-automation.json b/test/data/speculos-automation.json new file mode 100644 index 000000000..e9c96dce4 --- /dev/null +++ b/test/data/speculos-automation.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "rules": [ + { + "regexp": "^(Address|Review|Amount|Fee|Confirm|The derivation|Derivation path|Reject if you're|The change path|Change path|external inputs|Register wallet|Policy map|Key|Path|Public key|Spend from|Wallet name|Wallet policy).*", + "actions": [ + [ "button", 2, true ], + [ "button", 2, false ] + ] + }, + { + "regexp": "^(Accept|Approve|Continue).*", + "actions": [ + [ "button", 1, true ], + [ "button", 2, true ], + [ "button", 1, false ], + [ "button", 2, false ] + ] + }, + { + "regexp": "^Message hash.*", + "actions": [ + [ "button", 2, true ], + [ "button", 2, false ], + [ "setbool", "seen_msg_hash", true ] + ] + }, + { + "text": "message", + "conditions": [ + [ "seen_msg_hash", false ] + ], + "actions": [ + [ "button", 2, true ], + [ "button", 2, false ], + [ "setbool", "seen_msg_hash", true ] + ] + }, + { + "text": "message", + "conditions": [ + [ "seen_msg_hash", true ] + ], + "actions": [ + [ "button", 1, true ], + [ "button", 2, true ], + [ "button", 1, false ], + [ "button", 2, false ], + [ "setbool", "seen_msg_hash", false ] + ] + }, + { + "regexp": "^(Cancel|Reject).*", + "actions": [ + [ "button", 1, true ], + [ "button", 1, false ] + ] + } + ] +} diff --git a/test/data/test_bip32.json b/test/data/test_bip32.json new file mode 100644 index 000000000..1e2488be4 --- /dev/null +++ b/test/data/test_bip32.json @@ -0,0 +1,260 @@ +{ + "serialization": [ + { + "xpub": "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8", + "xprv": "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 0, + "parent_fingerprint": "00000000", + "child_num": 0, + "chaincode": "873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508", + "pubkey": "0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2", + "privkey": "e8f32e723decf4051aefac8e2c93c9c5b214313817cdb01a1494b917c8436b35" + } + }, + { + "xpub": "xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw", + "xprv": "xprv9uHRZZhk6KAJC1avXpDAp4MDc3sQKNxDiPvvkX8Br5ngLNv1TxvUxt4cV1rGL5hj6KCesnDYUhd7oWgT11eZG7XnxHrnYeSvkzY7d2bhkJ7", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 1, + "parent_fingerprint": "3442193e", + "child_num": 2147483648, + "chaincode": "47fdacbd0f1097043b78c63c20c34ef4ed9a111d980047ad16282c7ae6236141", + "pubkey": "035a784662a4a20a65bf6aab9ae98a6c068a81c52e4b032c0fb5400c706cfccc56", + "privkey": "edb2e14f9ee77d26dd93b4ecede8d16ed408ce149b6cd80b0715a2d911a0afea" + } + }, + { + "xpub": "xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ", + "xprv": "xprv9wTYmMFdV23N2TdNG573QoEsfRrWKQgWeibmLntzniatZvR9BmLnvSxqu53Kw1UmYPxLgboyZQaXwTCg8MSY3H2EU4pWcQDnRnrVA1xe8fs", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 2, + "parent_fingerprint": "5c1bd648", + "child_num": 1, + "chaincode": "2a7857631386ba23dacac34180dd1983734e444fdbf774041578e9b6adb37c19", + "pubkey": "03501e454bf00751f24b1b489aa925215d66af2234e3891c3b21a52bedb3cd711c", + "privkey": "3c6cb8d0f6a264c91ea8b5030fadaa8e538b020f0a387421a12de9319dc93368" + } + }, + { + "xpub": "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5", + "xprv": "xprv9z4pot5VBttmtdRTWfWQmoH1taj2axGVzFqSb8C9xaxKymcFzXBDptWmT7FwuEzG3ryjH4ktypQSAewRiNMjANTtpgP4mLTj34bhnZX7UiM", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 3, + "parent_fingerprint": "bef5a2f9", + "child_num": 2147483650, + "chaincode": "04466b9cc8e161e966409ca52986c584f07e9dc81f735db683c3ff6ec7b1503f", + "pubkey": "0357bfe1e341d01c69fe5654309956cbea516822fba8a601743a012a7896ee8dc2", + "privkey": "cbce0d719ecf7431d88e6a89fa1483e02e35092af60c042b1df2ff59fa424dca" + } + }, + { + "xpub": "xpub6FHa3pjLCk84BayeJxFW2SP4XRrFd1JYnxeLeU8EqN3vDfZmbqBqaGJAyiLjTAwm6ZLRQUMv1ZACTj37sR62cfN7fe5JnJ7dh8zL4fiyLHV", + "xprv": "xprvA2JDeKCSNNZky6uBCviVfJSKyQ1mDYahRjijr5idH2WwLsEd4Hsb2Tyh8RfQMuPh7f7RtyzTtdrbdqqsunu5Mm3wDvUAKRHSC34sJ7in334", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 4, + "parent_fingerprint": "ee7ab90c", + "child_num": 2, + "chaincode": "cfb71883f01676f587d023cc53a35bc7f88f724b1f8c2892ac1275ac822a3edd", + "pubkey": "02e8445082a72f29b75ca48748a914df60622a609cacfce8ed0e35804560741d29", + "privkey": "0f479245fb19a38a1954c5c7c0ebab2f9bdfd96a17563ef28a6a4b1a2a764ef4" + } + }, + { + "xpub": "xpub6H1LXWLaKsWFhvm6RVpEL9P4KfRZSW7abD2ttkWP3SSQvnyA8FSVqNTEcYFgJS2UaFcxupHiYkro49S8yGasTvXEYBVPamhGW6cFJodrTHy", + "xprv": "xprvA41z7zogVVwxVSgdKUHDy1SKmdb533PjDz7J6N6mV6uS3ze1ai8FHa8kmHScGpWmj4WggLyQjgPie1rFSruoUihUZREPSL39UNdE3BBDu76", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 5, + "parent_fingerprint": "d880d7d8", + "child_num": 1000000000, + "chaincode": "c783e67b921d2beb8f6b389cc646d7263b4145701dadd2161548a8b078e65e9e", + "pubkey": "022a471424da5e657499d1ff51cb43c47481a03b1e77f951fe64cec9f5a48f7011", + "privkey": "471b76e389e528d6de6d816857e012c5455051cad6660850e58372a6c3e6e7c8" + } + }, + { + "xpub": "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB", + "xprv": "xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 0, + "parent_fingerprint": "00000000", + "child_num": 0, + "chaincode": "60499f801b896d83179a4374aeb7822aaeaceaa0db1f85ee3e904c4defbd9689", + "pubkey": "03cbcaa9c98c877a26977d00825c956a238e8dddfbd322cce4f74b0b5bd6ace4a7", + "privkey": "4b03d6fc340455b363f51020ad3ecca4f0850280cf436c70c727923f6db46c3e" + } + }, + { + "xpub": "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH", + "xprv": "xprv9vHkqa6EV4sPZHYqZznhT2NPtPCjKuDKGY38FBWLvgaDx45zo9WQRUT3dKYnjwih2yJD9mkrocEZXo1ex8G81dwSM1fwqWpWkeS3v86pgKt", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 1, + "parent_fingerprint": "bd16bee5", + "child_num": 0, + "chaincode": "f0909affaa7ee7abe5dd4e100598d4dc53cd709d5a5c2cac40e7412f232f7c9c", + "pubkey": "02fc9e5af0ac8d9b3cecfe2a888e2117ba3d089d8585886c9c826b6b22a98d12ea", + "privkey": "abe74a98f6c7eabee0428f53798f0ab8aa1bd37873999041703c742f15ac7e1e" + } + }, + { + "xpub": "xpub6ASAVgeehLbnwdqV6UKMHVzgqAG8Gr6riv3Fxxpj8ksbH9ebxaEyBLZ85ySDhKiLDBrQSARLq1uNRts8RuJiHjaDMBU4Zn9h8LZNnBC5y4a", + "xprv": "xprv9wSp6B7kry3Vj9m1zSnLvN3xH8RdsPP1Mh7fAaR7aRLcQMKTR2vidYEeEg2mUCTAwCd6vnxVrcjfy2kRgVsFawNzmjuHc2YmYRmagcEPdU9", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 2, + "parent_fingerprint": "5a61ff8e", + "child_num": 4294967295, + "chaincode": "be17a268474a6bb9c61e1d720cf6215e2a88c5406c4aee7b38547f585c9a37d9", + "pubkey": "03c01e7425647bdefa82b12d9bad5e3e6865bee0502694b94ca58b666abc0a5c3b", + "privkey": "877c779ad9687164e9c2f4f0f4ff0340814392330693ce95a58fe18fd52e6e93" + } + }, + { + "xpub": "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon", + "xprv": "xprv9zFnWC6h2cLgpmSA46vutJzBcfJ8yaJGg8cX1e5StJh45BBciYTRXSd25UEPVuesF9yog62tGAQtHjXajPPdbRCHuWS6T8XA2ECKADdw4Ef", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 3, + "parent_fingerprint": "d8ab4937", + "child_num": 1, + "chaincode": "f366f48f1ea9f2d1d3fe958c95ca84ea18e4c4ddb9366c336c927eb246fb38cb", + "pubkey": "03a7d1d856deb74c508e05031f9895dab54626251b3806e16b4bd12e781a7df5b9", + "privkey": "704addf544a06e5ee4bea37098463c23613da32020d604506da8c0518e1da4b7" + } + }, + { + "xpub": "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL", + "xprv": "xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 4, + "parent_fingerprint": "78412e3a", + "child_num": 4294967294, + "chaincode": "637807030d55d01f9a0cb3a7839515d796bd07706386a6eddf06cc29a65a0e29", + "pubkey": "02d2b36900396c9282fa14628566582f206a5dd0bcc8d5e892611806cafb0301f0", + "privkey": "f1c7c871a54a804afe328b4c83a1c33b8e5ff48f5087273f04efa83b247d6a2d" + } + }, + { + "xpub": "xpub6FnCn6nSzZAw5Tw7cgR9bi15UV96gLZhjDstkXXxvCLsUXBGXPdSnLFbdpq8p9HmGsApME5hQTZ3emM2rnY5agb9rXpVGyy3bdW6EEgAtqt", + "xprv": "xprvA2nrNbFZABcdryreWet9Ea4LvTJcGsqrMzxHx98MMrotbir7yrKCEXw7nadnHM8Dq38EGfSh6dqA9QWTyefMLEcBYJUuekgW4BYPJcr9E7j", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 5, + "parent_fingerprint": "31a507b8", + "child_num": 2, + "chaincode": "9452b549be8cea3ecb7a84bec10dcfd94afe4d129ebfd3b3cb58eedf394ed271", + "pubkey": "024d902e1a2fc7a8755ab5b694c575fce742c48d9ff192e63df5193e4c7afe1f9c", + "privkey": "bb7d39bdb83ecf58f2fd82b6d918341cbef428661ef01ab97c28a4842125ac23" + } + }, + { + "xpub": "xpub661MyMwAqRbcEZVB4dScxMAdx6d4nFc9nvyvH3v4gJL378CSRZiYmhRoP7mBy6gSPSCYk6SzXPTf3ND1cZAceL7SfJ1Z3GC8vBgp2epUt13", + "xprv": "xprv9s21ZrQH143K25QhxbucbDDuQ4naNntJRi4KUfWT7xo4EKsHt2QJDu7KXp1A3u7Bi1j8ph3EGsZ9Xvz9dGuVrtHHs7pXeTzjuxBrCmmhgC6", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 0, + "parent_fingerprint": "00000000", + "child_num": 0, + "chaincode": "01d28a3e53cffa419ec122c968b3259e16b65076495494d97cae10bbfec3c36f", + "pubkey": "03683af1ba5743bdfc798cf814efeeab2735ec52d95eced528e692b8e34c4e5669", + "privkey": "00ddb80b067e0d4993197fe10f2657a844a384589847602d56f0c629c81aae32" + } + }, + { + "xpub": "xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y", + "xprv": "xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L", + "deser": { + "pub_version": "0488B21E", + "priv_version": "0488ADE4", + "is_testnet": false, + "depth": 1, + "parent_fingerprint": "41d63b50", + "child_num": 2147483648, + "chaincode": "e5fea12a97b927fc9dc3d2cb0d1ea1cf50aa5a1fdc1f933e8906bb38df3377bd", + "pubkey": "026557fdda1d5d43d79611f784780471f086d58e8126b8c40acb82272a7712e7f2", + "privkey": "491f7a2eebc7b57028e0d3faa0acda02e75c33b03c48fb288c41e2ea44e1daef" + } + } + ], + "deriv" : [ + { + "parent_xpub": "xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw", + "parent_xprv": "xprv9uHRZZhk6KAJC1avXpDAp4MDc3sQKNxDiPvvkX8Br5ngLNv1TxvUxt4cV1rGL5hj6KCesnDYUhd7oWgT11eZG7XnxHrnYeSvkzY7d2bhkJ7", + "child_xpub": "xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ", + "index": 1 + }, + { + "parent_xpub": "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5", + "parent_xprv": "xprv9z4pot5VBttmtdRTWfWQmoH1taj2axGVzFqSb8C9xaxKymcFzXBDptWmT7FwuEzG3ryjH4ktypQSAewRiNMjANTtpgP4mLTj34bhnZX7UiM", + "child_xpub": "xpub6FHa3pjLCk84BayeJxFW2SP4XRrFd1JYnxeLeU8EqN3vDfZmbqBqaGJAyiLjTAwm6ZLRQUMv1ZACTj37sR62cfN7fe5JnJ7dh8zL4fiyLHV", + "index": 2 + }, + { + "parent_xpub": "xpub6FHa3pjLCk84BayeJxFW2SP4XRrFd1JYnxeLeU8EqN3vDfZmbqBqaGJAyiLjTAwm6ZLRQUMv1ZACTj37sR62cfN7fe5JnJ7dh8zL4fiyLHV", + "parent_xprv": "xprvA2JDeKCSNNZky6uBCviVfJSKyQ1mDYahRjijr5idH2WwLsEd4Hsb2Tyh8RfQMuPh7f7RtyzTtdrbdqqsunu5Mm3wDvUAKRHSC34sJ7in334", + "child_xpub": "xpub6H1LXWLaKsWFhvm6RVpEL9P4KfRZSW7abD2ttkWP3SSQvnyA8FSVqNTEcYFgJS2UaFcxupHiYkro49S8yGasTvXEYBVPamhGW6cFJodrTHy", + "index": 1000000000 + }, + { + "parent_xpub": "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB", + "parent_xprv": "xprv9s21ZrQH143K31xYSDQpPDxsXRTUcvj2iNHm5NUtrGiGG5e2DtALGdso3pGz6ssrdK4PFmM8NSpSBHNqPqm55Qn3LqFtT2emdEXVYsCzC2U", + "child_xpub": "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH", + "index": 0 + }, + { + "parent_xpub": "xpub6ASAVgeehLbnwdqV6UKMHVzgqAG8Gr6riv3Fxxpj8ksbH9ebxaEyBLZ85ySDhKiLDBrQSARLq1uNRts8RuJiHjaDMBU4Zn9h8LZNnBC5y4a", + "parent_xprv": "xprv9wSp6B7kry3Vj9m1zSnLvN3xH8RdsPP1Mh7fAaR7aRLcQMKTR2vidYEeEg2mUCTAwCd6vnxVrcjfy2kRgVsFawNzmjuHc2YmYRmagcEPdU9", + "child_xpub": "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon", + "index": 1 + }, + { + "parent_xpub": "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL", + "parent_xprv": "xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc", + "child_xpub": "xpub6FnCn6nSzZAw5Tw7cgR9bi15UV96gLZhjDstkXXxvCLsUXBGXPdSnLFbdpq8p9HmGsApME5hQTZ3emM2rnY5agb9rXpVGyy3bdW6EEgAtqt", + "index": 2 + } + ], + "deriv_path": [ + { + "parent_xpub": "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5", + "parent_xprv": "xprv9z4pot5VBttmtdRTWfWQmoH1taj2axGVzFqSb8C9xaxKymcFzXBDptWmT7FwuEzG3ryjH4ktypQSAewRiNMjANTtpgP4mLTj34bhnZX7UiM", + "child_xpub": "xpub6H1LXWLaKsWFhvm6RVpEL9P4KfRZSW7abD2ttkWP3SSQvnyA8FSVqNTEcYFgJS2UaFcxupHiYkro49S8yGasTvXEYBVPamhGW6cFJodrTHy", + "path": "m/2/1000000000" + } + ] +} diff --git a/test/data/test_psbt.json b/test/data/test_psbt.json index 4a3d47fd7..3651f1984 100644 --- a/test/data/test_psbt.json +++ b/test/data/test_psbt.json @@ -17,7 +17,19 @@ "cHNidP8BAJoCAAAAAljoeiG1ba8MI76OcHBFbDNvfLqlyHV5JPVFiHuyq911AAAAAAD/////g40EJ9DsZQpoqka7CwmK6kQiwHGyyng1Kgd5WdB86h0BAAAAAP////8CcKrwCAAAAAAWABTYXCtx0AYLCcmIauuBXlCZHdoSTQDh9QUAAAAAFgAUAK6pouXw+HaliN9VRuh0LR2HAI8AAAAAAAEAuwIAAAABqtc5MQGL0l+ErkALaISL4J23BurCrBgpi6vucatlb4sAAAAASEcwRAIgWPb8fGoz4bMVSNSByCbAFb0wE1qtQs1neQ2rZtKtJDsCIEoc7SYExnNbY5PltBaR3XiwDwxZQvufdRhW+qk4FX26Af7///8CgPD6AgAAAAAXqRQPuUY0IWlrgsgzryQceMF9295JNIfQ8gonAQAAABepFCnKdPigj4GZlCgYXJe12FLkBj9hh2UAAAABB9oARzBEAiB0AYrUGACXuHMyPAAVcgs2hMyBI4kQSOfbzZtVrWecmQIgc9Npt0Dj61Pc76M4I8gHBRTKVafdlUTxV8FnkTJhEYwBSDBFAiEA9hA4swjcHahlo0hSdG8BV3KTQgjG0kRUOTzZm98iF3cCIAVuZ1pnWm0KArhbFOXikHTYolqbV2C+ooFvZhkQoAbqAUdSIQKVg785rgpgl0etGZrd1jT6YQhVnWxc05tMIYPxq5bgfyEC2rYf9JoU22p9ArDNH7t4/EsYMStbTlTa5Nui+/71NtdSrgABASAAwusLAAAAABepFLf1+vQOPUClpFmx2zU18rcvqSHohwEHIyIAIIwjUxc3Q7WV37Sge3K6jkLjeX2nTof+fZ10l+OyAokDAQjaBABHMEQCIGLrelVhB6fHP0WsSrWh3d9vcHX7EnWWmn84Pv/3hLyyAiAMBdu3Rw2/LwhVfdNWxzJcHtMJE+mWzThAlF2xIijaXwFHMEQCIGX0W6WZi1mif/4ae+0BavHx+Q1Us6qPdFCqX1aiUQO9AiB/ckcDrR7blmgLKEtW1P/LiPf7dZ6rvgiqMPKbhROD0gFHUiEDCJ3BDHrG21T5EymvYXMz2ziM6tDCMfcjN50bmQMLAtwhAjrdkE89bc9Z3bkGsN7iNSm3/7ntUOXoYVGSaGAiHw5zUq4AIQIDqaTDf1mW06ol26xrVwrwZQOUSSlCRgs1R1PtnuylhxDZDGpPAAAAgAAAAIAEAACAACICAn9jmXV9Lv9VoTatAsaEsYOLZVbl8bazQoKpS2tQBRCWENkMak8AAACAAAAAgAUAAIAA", "cHNidP8BAHMCAAAAATAa6YblFqHsisW0vGVz0y+DtGXiOtdhZ9aLOOcwtNvbAAAAAAD/////AnR7AQAAAAAAF6kUA6oXrogrXQ1Usl1jEE5P/s57nqKHYEOZOwAAAAAXqRS5IbG6b3IuS/qDtlV6MTmYakLsg4cAAAAAAAEBHwDKmjsAAAAAFgAU0tlLZK4IWH7vyO6xh8YB6Tn5A3wCAwABAAAAAAEAFgAUYunpgv/zTdgjlhAxawkM0qO3R8sAAQAiACCHa62DLx0WgBXtQSMqnqZaGBXZ7xPA74dZ9ktbKyeKZQEBJVEhA7fOI6AcW0vwCmQlN836uzFbZoMyhnR471EwnSvVf4qHUa4A", "cHNidP8BAHMCAAAAATAa6YblFqHsisW0vGVz0y+DtGXiOtdhZ9aLOOcwtNvbAAAAAAD/////AnR7AQAAAAAAF6kUA6oXrogrXQ1Usl1jEE5P/s57nqKHYEOZOwAAAAAXqRS5IbG6b3IuS/qDtlV6MTmYakLsg4cAAAAAAAEBHwDKmjsAAAAAFgAU0tlLZK4IWH7vyO6xh8YB6Tn5A3wAAgAAFgAUYunpgv/zTdgjlhAxawkM0qO3R8sAAQAiACCHa62DLx0WgBXtQSMqnqZaGBXZ7xPA74dZ9ktbKyeKZQEBJVEhA7fOI6AcW0vwCmQlN836uzFbZoMyhnR471EwnSvVf4qHUa4A", - "cHNidP8BAHMCAAAAATAa6YblFqHsisW0vGVz0y+DtGXiOtdhZ9aLOOcwtNvbAAAAAAD/////AnR7AQAAAAAAF6kUA6oXrogrXQ1Usl1jEE5P/s57nqKHYEOZOwAAAAAXqRS5IbG6b3IuS/qDtlV6MTmYakLsg4cAAAAAAAEBHwDKmjsAAAAAFgAU0tlLZK4IWH7vyO6xh8YB6Tn5A3wAAQAWABRi6emC//NN2COWEDFrCQzSo7dHywABACIAIIdrrYMvHRaAFe1BIyqeploYFdnvE8Dvh1n2S1srJ4plIQEAJVEhA7fOI6AcW0vwCmQlN836uzFbZoMyhnR471EwnSvVf4qHUa4A" + "cHNidP8BAHMCAAAAATAa6YblFqHsisW0vGVz0y+DtGXiOtdhZ9aLOOcwtNvbAAAAAAD/////AnR7AQAAAAAAF6kUA6oXrogrXQ1Usl1jEE5P/s57nqKHYEOZOwAAAAAXqRS5IbG6b3IuS/qDtlV6MTmYakLsg4cAAAAAAAEBHwDKmjsAAAAAFgAU0tlLZK4IWH7vyO6xh8YB6Tn5A3wAAQAWABRi6emC//NN2COWEDFrCQzSo7dHywABACIAIIdrrYMvHRaAFe1BIyqeploYFdnvE8Dvh1n2S1srJ4plIQEAJVEhA7fOI6AcW0vwCmQlN836uzFbZoMyhnR471EwnSvVf4qHUa4A", + "cHNidP8BAHMCAAAAAY43bdygsmO9x9a2AyzUpshOgpVx6re/d/vuYAfvGEfIAQAAAAD9////AvgRAAAAAAAAF6kUA9NxbxOrP4pij9OIuyjnY/dAp4mHMP0TAAAAAAAXqRS59fWkR2DcQ+z1w7bOwilZXcDwFIfz6BwATgECQonvBBKS8Q2AAAABsd4yUFWH7gQx+vPwZENJfFff0NAzfMTVnhtoUnv62W0CeZO1Ujk7zdBOIWWQgJS+fJISyuy5qfSr9f9gxRCgaxQZVC6wMAAAgAEAAIAAAACAAQAAgE8BAkKJ7wRppKYYgAAAAXa9y9oj7zcS7452oc2h4ZNxRhq/QTLlKS560A1atY4fAnASZtL9Rsu9GJjX80COOwGkUYCLPpLFnjpzogzw1Ma9FOgaV0QwAACAAQAAgAAAAIABAACAAAEA9wIAAAAAAQHizx3xPxzn6CVTzYoRvZ6glR+I5NCz3EUfy2KosxPV6gAAAAAXFgAUPjTLr1dUB4jjlBZT/oesgrjIXbf+////AmrMfggBAAAAF6kUhI2CBLIHFddfYSZ40rXWFajy2LqHPxAUAAAAAAAXqRT0k6FCcz5GDJmyHUiK4sGv60T7XIcCRzBEAiB5tPi/H34vGKryr5oqITYpRpnrxuOBv2FPwsdnUD9nmQIgQD47b/bMepSLpin3KNzTxNJifEcu+B6XV+L4xwWBgeQBIQOzL6gF2ObchppgdS08ShOuOJOdevhWJonswr02kL0WQ/PoHAAiAgP3DH3/zjXEaELmksr3MxkKoG63lA+KdIJUrSu9S7rz3EcwRAIgAuMNHJ/F1oiUSy/TZcmKUyxkjHfZ4FcafzaYl1FL66wCIApaVukx8bCYartDMOHlwn99Mq4hYOx8lvXEzNVc6NyIAQEEIgAgtUUwTMw06MhQH9f9ltFa1H2OoZOByEzcQSNlUHvjlIUBBUdSIQOin1plRrlkmYGt/MCIFdz2yh3yg5ATXZKXThCaPZNMJyED9wx9/841xGhC5pLK9zMZCqBut5QPinSCVK0rvUu689xSriIGA6KfWmVGuWSZga38wIgV3PbKHfKDkBNdkpdOEJo9k0wnHOgaV0QwAACAAQAAgAAAAIABAACAAAAAAAAAAAAiBgP3DH3/zjXEaELmksr3MxkKoG63lA+KdIJUrSu9S7rz3BwZVC6wMAAAgAEAAIAAAACAAQAAgAAAAAAAAAAAAAEAIgAgBfoZdJ+MY8g0ZypFFXPF9yQpYIJMRqQ+T0JA5OPfKWkBAUdSIQKPQaPd43GOhCOoprKnaAeE+jB+MXTu9GU5rXGhfFBVfSECm2qtddhDhdeQz3rmBk3VNi2e6U5oWX+Dz+HQDS7GXNJSriICAo9Bo93jcY6EI6imsqdoB4T6MH4xdO70ZTmtcaF8UFV9HBlULrAwAACAAQAAgAAAAIABAACAAQAAAAAAAAAiAgKbaq112EOF15DPeuYGTdU2LZ7pTmhZf4PP4dANLsZc0hzoGldEMAAAgAEAAIAAAACAAQAAgAEAAAAAAAAAAAEAIgAgbgVZZTDEmbGsvV/lW1JP9KDQaH+EZFRo8yboIGMjCGQBAUdSIQMjZva1A9ooUCsZsKCi9LkJkliAjk+KiZoDM40/JE6x6yEDy3BsaBCpLLwNmkaNlHfmIl6sBS5oZ3YMMqKXJD0VNPhSriICAyNm9rUD2ihQKxmwoKL0uQmSWICOT4qJmgMzjT8kTrHrHBlULrAwAACAAQAAgAAAAIABAACAAAAAAAEAAAAiAgPLcGxoEKksvA2aRo2Ud+YiXqwFLmhndgwyopckPRU0+BzoGldEMAAAgAEAAIAAAACAAQAAgAAAAAABAAAAAA==", + "cHNidP8BAHECAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Anh8AQAAAAAAFgAUg6fjS9mf8DpJYu+KGhAbspVGHs5gawQqAQAAABYAFHrDad8bIOAz1hFmI5V7CsSfPFLoAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXARchAv40kGTJjW4qhT+jybEr2LMEoZwZXGDvp+4jkwRtP6IyAAAA", + "cHNidP8BAHECAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Anh8AQAAAAAAFgAUg6fjS9mf8DpJYu+KGhAbspVGHs5gawQqAQAAABYAFHrDad8bIOAz1hFmI5V7CsSfPFLoAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXARM/Fzuz02wHSvtxb+xjB6BpouRQuZXzyCeFlFq43w4kJg3NcDsMvzTeOZGEqUgawrNYbbZgHwJqd/fkk4SBvDR1AAAA", + "cHNidP8BAHECAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Anh8AQAAAAAAFgAUg6fjS9mf8DpJYu+KGhAbspVGHs5gawQqAQAAABYAFHrDad8bIOAz1hFmI5V7CsSfPFLoAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXARNCFzuz02wHSvtxb+xjB6BpouRQuZXzyCeFlFq43w4kJg3NcDsMvzTeOZGEqUgawrNYbbZgHwJqd/fkk4SBvDR1FwGqAAAA", + "cHNidP8BAHECAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Anh8AQAAAAAAFgAUg6fjS9mf8DpJYu+KGhAbspVGHs5gawQqAQAAABYAFHrDad8bIOAz1hFmI5V7CsSfPFLoAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXIhYC/jSQZMmNbiqFP6PJsSvYswShnBlcYO+n7iOTBG0/ojIZAHcrLadWAACAAQAAgAAAAIABAAAAAAAAAAAAAA==", + "cHNidP8BAH0CAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Aoh7AQAAAAAAFgAUI4KHHH6EIaAAk/dU2RKB5nWHS59gawQqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXAAABBSEC/jSQZMmNbiqFP6PJsSvYswShnBlcYO+n7iOTBG0/ojIA", + "cHNidP8BAH0CAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////Aoh7AQAAAAAAFgAUI4KHHH6EIaAAk/dU2RKB5nWHS59gawQqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXAAAAAAABASsA8gUqAQAAACJRIFosLPW1LPMfg60ujaY/8DGD7Nj2CcdRCuikjgORCgdXAAAiBwL+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMhkAdystp1YAAIABAACAAAAAgAEAAAAAAAAAAA==", + "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJCFAIssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20s2XDhX1P8DIL5UP1WD/qRm3YXK+AXNoqJkTrwdPQAsJQIl1aqNznMxonsD886NgvjLMC1mxbpOh6LtGBXJrLKej/3BsQXZkljKyzGjh+RK4pXjjcZzncQiFx6lm9JvNQ8sAAA==", + "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJBFCyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwlCiXVqo3OczGiewPzzo2C+MswLWbFuk6Hou0YFcmssp6P/cGxBdmSWMrLMaOH5ErileONxnOdxCIXHqWb0m81DywEBAAA=", + "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJBFCyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwk5iXVqo3OczGiewPzzo2C+MswLWbFuk6Hou0YFcmssp6P/cGxBdmSWMrLMaOH5ErileONxnOdxCIXHqWb0m81DywAA", + "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJjFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4fgAIyAssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20qzAAAA=", + "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgAw2k/OT32yjCyylRYx4ANxOFZZf+ljiCy1AOaBEsymMAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJhFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4SMgLLE6xoJI3oBqpqNlnPPAPraCHQnIEUpOho/r3oZbttKswAAA" ], "valid" : [ "cHNidP8BAHUCAAAAASaBcTce3/KF6Tet7qSze3gADAVmy7OtZGQXE8pCFxv2AAAAAAD+////AtPf9QUAAAAAGXapFNDFmQPFusKGh2DpD9UhpGZap2UgiKwA4fUFAAAAABepFDVF5uM7gyxHBQ8k0+65PJwDlIvHh7MuEwAAAQD9pQEBAAAAAAECiaPHHqtNIOA3G7ukzGmPopXJRjr6Ljl/hTPMti+VZ+UBAAAAFxYAFL4Y0VKpsBIDna89p95PUzSe7LmF/////4b4qkOnHf8USIk6UwpyN+9rRgi7st0tAXHmOuxqSJC0AQAAABcWABT+Pp7xp0XpdNkCxDVZQ6vLNL1TU/////8CAMLrCwAAAAAZdqkUhc/xCX/Z4Ai7NK9wnGIZeziXikiIrHL++E4sAAAAF6kUM5cluiHv1irHU6m80GfWx6ajnQWHAkcwRAIgJxK+IuAnDzlPVoMR3HyppolwuAJf3TskAinwf4pfOiQCIAGLONfc0xTnNMkna9b7QPZzMlvEuqFEyADS8vAtsnZcASED0uFWdJQbrUqZY3LLh+GFbTZSYG2YVi/jnF6efkE/IQUCSDBFAiEA0SuFLYXc2WHS9fSrZgZU327tzHlMDDPOXMMJ/7X85Y0CIGczio4OFyXBl/saiK9Z9R5E5CVbIBZ8hoQDHAXR8lkqASECI7cr7vCWXRC+B3jv7NYfysb3mk6haTkzgHNEZPhPKrMAAAAAAAAA", @@ -25,7 +37,15 @@ "cHNidP8BAHUCAAAAASaBcTce3/KF6Tet7qSze3gADAVmy7OtZGQXE8pCFxv2AAAAAAD+////AtPf9QUAAAAAGXapFNDFmQPFusKGh2DpD9UhpGZap2UgiKwA4fUFAAAAABepFDVF5uM7gyxHBQ8k0+65PJwDlIvHh7MuEwAAAQD9pQEBAAAAAAECiaPHHqtNIOA3G7ukzGmPopXJRjr6Ljl/hTPMti+VZ+UBAAAAFxYAFL4Y0VKpsBIDna89p95PUzSe7LmF/////4b4qkOnHf8USIk6UwpyN+9rRgi7st0tAXHmOuxqSJC0AQAAABcWABT+Pp7xp0XpdNkCxDVZQ6vLNL1TU/////8CAMLrCwAAAAAZdqkUhc/xCX/Z4Ai7NK9wnGIZeziXikiIrHL++E4sAAAAF6kUM5cluiHv1irHU6m80GfWx6ajnQWHAkcwRAIgJxK+IuAnDzlPVoMR3HyppolwuAJf3TskAinwf4pfOiQCIAGLONfc0xTnNMkna9b7QPZzMlvEuqFEyADS8vAtsnZcASED0uFWdJQbrUqZY3LLh+GFbTZSYG2YVi/jnF6efkE/IQUCSDBFAiEA0SuFLYXc2WHS9fSrZgZU327tzHlMDDPOXMMJ/7X85Y0CIGczio4OFyXBl/saiK9Z9R5E5CVbIBZ8hoQDHAXR8lkqASECI7cr7vCWXRC+B3jv7NYfysb3mk6haTkzgHNEZPhPKrMAAAAAAQMEAQAAAAAAAA==", "cHNidP8BAKACAAAAAqsJSaCMWvfEm4IS9Bfi8Vqz9cM9zxU4IagTn4d6W3vkAAAAAAD+////qwlJoIxa98SbghL0F+LxWrP1wz3PFTghqBOfh3pbe+QBAAAAAP7///8CYDvqCwAAAAAZdqkUdopAu9dAy+gdmI5x3ipNXHE5ax2IrI4kAAAAAAAAGXapFG9GILVT+glechue4O/p+gOcykWXiKwAAAAAAAEA3wIAAAABJoFxNx7f8oXpN63upLN7eAAMBWbLs61kZBcTykIXG/YAAAAAakcwRAIgcLIkUSPmv0dNYMW1DAQ9TGkaXSQ18Jo0p2YqncJReQoCIAEynKnazygL3zB0DsA5BCJCLIHLRYOUV663b8Eu3ZWzASECZX0RjTNXuOD0ws1G23s59tnDjZpwq8ubLeXcjb/kzjH+////AtPf9QUAAAAAGXapFNDFmQPFusKGh2DpD9UhpGZap2UgiKwA4fUFAAAAABepFDVF5uM7gyxHBQ8k0+65PJwDlIvHh7MuEwAAAQEgAOH1BQAAAAAXqRQ1RebjO4MsRwUPJNPuuTycA5SLx4cBBBYAFIXRNTfy4mVAWjTbr6nj3aAfuCMIACICAurVlmh8qAYEPtw94RbN8p1eklfBls0FXPaYyNAr8k6ZELSmumcAAACAAAAAgAIAAIAAIgIDlPYr6d8ZlSxVh3aK63aYBhrSxKJciU9H2MFitNchPQUQtKa6ZwAAAIABAACAAgAAgAA=", "cHNidP8BAFUCAAAAASeaIyOl37UfxF8iD6WLD8E+HjNCeSqF1+Ns1jM7XLw5AAAAAAD/////AaBa6gsAAAAAGXapFP/pwAYQl8w7Y28ssEYPpPxCfStFiKwAAAAAAAEBIJVe6gsAAAAAF6kUY0UgD2jRieGtwN8cTRbqjxTA2+uHIgIDsTQcy6doO2r08SOM1ul+cWfVafrEfx5I1HVBhENVvUZGMEMCIAQktY7/qqaU4VWepck7v9SokGQiQFXN8HC2dxRpRC0HAh9cjrD+plFtYLisszrWTt5g6Hhb+zqpS5m9+GFR25qaAQEEIgAgdx/RitRZZm3Unz1WTj28QvTIR3TjYK2haBao7UiNVoEBBUdSIQOxNBzLp2g7avTxI4zW6X5xZ9Vp+sR/HkjUdUGEQ1W9RiED3lXR4drIBeP4pYwfv5uUwC89uq/hJ/78pJlfJvggg71SriIGA7E0HMunaDtq9PEjjNbpfnFn1Wn6xH8eSNR1QYRDVb1GELSmumcAAACAAAAAgAQAAIAiBgPeVdHh2sgF4/iljB+/m5TALz26r+En/vykmV8m+CCDvRC0prpnAAAAgAAAAIAFAACAAAA=", - "cHNidP8BAH0CAAAAAXTJ5KdKezIfJIIqamLIyQxivxrXhgf0hPJdvDIqZe6ZAAAAAAD/////AgDzKwQAAAAAIgAgOAAOiTkWbVRkLTkK6SJLaZ12Qg/sYIwNOSsiBsnDiXWAlpgAAAAAABYAFG/wUHGvbEs/3RQIpNhYdh/09gwmAAAAAE8BBDWHzwRj1QHLgAAAAmDUcFrNsZhPXsreojbjRfHxKRktQR/bg0UG9IFxkSkqA5dwhHV4cNGNLjhFGEjc/IvZYHqamzEDsDWj18pA3Ys9FPeeyRCAAAAwgAAAAYAAAACAAAACTwEENYfPBMAmm9aAAAACdEGWiAl3lI+b68dxXnedY+qqqBs7PJpP4u/AI1jBMB4De/ZrB9O5eDy4bBkjuYINiEa2E87TrKU1T7gCJcRPsQkUfBbvIIAAADCAAAABgAAAAIAAAAIAAQErpJfEBAAAAAAiACA4AA6JORZtVGQtOQrpIktpnXZCD+xgjA05KyIGycOJdQEFR1IhAsdc2uyHckrqUdmo8qRSAyeTIpeSBycQjK7AO6wCKSR/IQO947/flOxdVTqxIznQ6CBY/drvmcvQOSz5iJM1VR+5PlKuIgYCx1za7IdySupR2ajypFIDJ5Mil5IHJxCMrsA7rAIpJH8cfBbvIDAAAIABAACAAAAAgAIAAIABAAAAAAAAACIGA73jv9+U7F1VOrEjOdDoIFj92u+Zy9A5LPmIkzVVH7k+HPeeyRAwAACAAQAAgAAAAIACAACAAQAAAAAAAAAAAQFHUiECx1za7IdySupR2ajypFIDJ5Mil5IHJxCMrsA7rAIpJH8hA73jv9+U7F1VOrEjOdDoIFj92u+Zy9A5LPmIkzVVH7k+Uq4iAgLHXNrsh3JK6lHZqPKkUgMnkyKXkgcnEIyuwDusAikkfxx8Fu8gMAAAgAEAAIAAAACAAgAAgAEAAAAAAAAAIgIDveO/35TsXVU6sSM50OggWP3a75nL0Dks+YiTNVUfuT4c957JEDAAAIABAACAAAAAgAIAAIABAAAAAAAAAAAA" + "cHNidP8BAH0CAAAAAXTJ5KdKezIfJIIqamLIyQxivxrXhgf0hPJdvDIqZe6ZAAAAAAD/////AgDzKwQAAAAAIgAgOAAOiTkWbVRkLTkK6SJLaZ12Qg/sYIwNOSsiBsnDiXWAlpgAAAAAABYAFG/wUHGvbEs/3RQIpNhYdh/09gwmAAAAAE8BBDWHzwRj1QHLgAAAAmDUcFrNsZhPXsreojbjRfHxKRktQR/bg0UG9IFxkSkqA5dwhHV4cNGNLjhFGEjc/IvZYHqamzEDsDWj18pA3Ys9FPeeyRCAAAAwgAAAAYAAAACAAAACTwEENYfPBMAmm9aAAAACdEGWiAl3lI+b68dxXnedY+qqqBs7PJpP4u/AI1jBMB4De/ZrB9O5eDy4bBkjuYINiEa2E87TrKU1T7gCJcRPsQkUfBbvIIAAADCAAAABgAAAAIAAAAIAAQErpJfEBAAAAAAiACA4AA6JORZtVGQtOQrpIktpnXZCD+xgjA05KyIGycOJdQEFR1IhAsdc2uyHckrqUdmo8qRSAyeTIpeSBycQjK7AO6wCKSR/IQO947/flOxdVTqxIznQ6CBY/drvmcvQOSz5iJM1VR+5PlKuIgYCx1za7IdySupR2ajypFIDJ5Mil5IHJxCMrsA7rAIpJH8cfBbvIDAAAIABAACAAAAAgAIAAIABAAAAAAAAACIGA73jv9+U7F1VOrEjOdDoIFj92u+Zy9A5LPmIkzVVH7k+HPeeyRAwAACAAQAAgAAAAIACAACAAQAAAAAAAAAAAQFHUiECx1za7IdySupR2ajypFIDJ5Mil5IHJxCMrsA7rAIpJH8hA73jv9+U7F1VOrEjOdDoIFj92u+Zy9A5LPmIkzVVH7k+Uq4iAgLHXNrsh3JK6lHZqPKkUgMnkyKXkgcnEIyuwDusAikkfxx8Fu8gMAAAgAEAAIAAAACAAgAAgAEAAAAAAAAAIgIDveO/35TsXVU6sSM50OggWP3a75nL0Dks+YiTNVUfuT4c957JEDAAAIABAACAAAAAgAIAAIABAAAAAAAAAAAA", + "cHNidP8BAJoBAAAAAnw6Vs9tiL+SSPtWMGR48n3fL/dKdP42f7CTNbnqW2w8AAAAAAD/////ritQQHoovDeJPhX2UjoZZD64lpjCRlHZspnwq4qicj0BAAAAAP////8CSKEDAAAAAAAWABSqcG1CpXVTtmijORx2w3i4mh24hWU/ewAAAAAAFgAUnyqotEHFWLijEmNVaE1w5qPVpooAAAAAAAEBH1qJfQAAAAAAFgAUM6hkOMepv5eEM3tQYDb/qusWBSYiBgJl8UgW/0nmJvTxwWQx4zwJHETwaYx7hVv6Th6D34TIRxiv2IqeVAAAgAAAAIAAAACAAQAAAAgAAAAAAQEf48wBAAAAAAAWABRP1x7OvdEQaDmaCmcMRAcE+vdCVQEIawJHMEQCIFOxn3+ED5icBRBb8zXCy5LHHWTesGdmR0KLacF+C9w/AiBQ3eY/LbEvGnkSvE4sWCDl0Db3IM+omE9i6ekTYK8apgEhAoDbrhqspo2K9Ph39LPjcLbGAUSGgyTg8LL5QKOmYoQlAAAiAgL2zgv5Vwk6ARpIdvBdV9vIxZnW+5V8cc6lf2a5dKFO0hiv2IqeVAAAgAAAAIAAAACAAQAAAAQAAAAA", + "cHNidP8BAHMCAAAAAY43bdygsmO9x9a2AyzUpshOgpVx6re/d/vuYAfvGEfIAQAAAAD9////AvgRAAAAAAAAF6kUA9NxbxOrP4pij9OIuyjnY/dAp4mHMP0TAAAAAAAXqRS59fWkR2DcQ+z1w7bOwilZXcDwFIfz6BwATwECQonvBBKS8Q2AAAABsd4yUFWH7gQx+vPwZENJfFff0NAzfMTVnhtoUnv62W0CeZO1Ujk7zdBOIWWQgJS+fJISyuy5qfSr9f9gxRCga2MUGVQusDAAAIABAACAAAAAgAEAAIBPAQJCie8EaaSmGIAAAAF2vcvaI+83Eu+OdqHNoeGTcUYav0Ey5SkuetANWrWOHwJwEmbS/UbLvRiY1/NAjjsBpFGAiz6SxZ46c6IM8NTGvRToGldEMAAAgAEAAIAAAACAAQAAgAABAPcCAAAAAAEB4s8d8T8c5+glU82KEb2eoJUfiOTQs9xFH8tiqLMT1eoAAAAAFxYAFD40y69XVAeI45QWU/6HrIK4yF23/v///wJqzH4IAQAAABepFISNggSyBxXXX2EmeNK11hWo8ti6hz8QFAAAAAAAF6kU9JOhQnM+RgyZsh1IiuLBr+tE+1yHAkcwRAIgebT4vx9+Lxiq8q+aKiE2KUaZ68bjgb9hT8LHZ1A/Z5kCIEA+O2/2zHqUi6Yp9yjc08TSYnxHLvgel1fi+McFgYHkASEDsy+oBdjm3IaaYHUtPEoTrjiTnXr4ViaJ7MK9NpC9FkPz6BwAIgID9wx9/841xGhC5pLK9zMZCqBut5QPinSCVK0rvUu689xHMEQCIALjDRyfxdaIlEsv02XJilMsZIx32eBXGn82mJdRS+usAiAKWlbpMfGwmGq7QzDh5cJ/fTKuIWDsfJb1xMzVXOjciAEBBCIAILVFMEzMNOjIUB/X/ZbRWtR9jqGTgchM3EEjZVB745SFAQVHUiEDop9aZUa5ZJmBrfzAiBXc9sod8oOQE12Sl04Qmj2TTCchA/cMff/ONcRoQuaSyvczGQqgbreUD4p0glStK71LuvPcUq4iBgOin1plRrlkmYGt/MCIFdz2yh3yg5ATXZKXThCaPZNMJxzoGldEMAAAgAEAAIAAAACAAQAAgAAAAAAAAAAAIgYD9wx9/841xGhC5pLK9zMZCqBut5QPinSCVK0rvUu689wcGVQusDAAAIABAACAAAAAgAEAAIAAAAAAAAAAAAABACIAIAX6GXSfjGPINGcqRRVzxfckKWCCTEakPk9CQOTj3ylpAQFHUiECj0Gj3eNxjoQjqKayp2gHhPowfjF07vRlOa1xoXxQVX0hAptqrXXYQ4XXkM965gZN1TYtnulOaFl/g8/h0A0uxlzSUq4iAgKPQaPd43GOhCOoprKnaAeE+jB+MXTu9GU5rXGhfFBVfRwZVC6wMAAAgAEAAIAAAACAAQAAgAEAAAAAAAAAIgICm2qtddhDhdeQz3rmBk3VNi2e6U5oWX+Dz+HQDS7GXNIc6BpXRDAAAIABAACAAAAAgAEAAIABAAAAAAAAAAABACIAIG4FWWUwxJmxrL1f5VtST/Sg0Gh/hGRUaPMm6CBjIwhkAQFHUiEDI2b2tQPaKFArGbCgovS5CZJYgI5PiomaAzONPyROseshA8twbGgQqSy8DZpGjZR35iJerAUuaGd2DDKilyQ9FTT4Uq4iAgMjZva1A9ooUCsZsKCi9LkJkliAjk+KiZoDM40/JE6x6xwZVC6wMAAAgAEAAIAAAACAAQAAgAAAAAABAAAAIgIDy3BsaBCpLLwNmkaNlHfmIl6sBS5oZ3YMMqKXJD0VNPgc6BpXRDAAAIABAACAAAAAgAEAAIAAAAAAAQAAAAA=", + "cHNidP8BAFICAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////AUjmBSoBAAAAFgAUdo4e60z0IIZgM/gKzv8PlyB0SWkAAAAAAAEBKwDyBSoBAAAAIlEgWiws9bUs8x+DrS6Npj/wMYPs2PYJx1EK6KSOA5EKB1chFv40kGTJjW4qhT+jybEr2LMEoZwZXGDvp+4jkwRtP6IyGQB3Ky2nVgAAgAEAAIAAAACAAQAAAAAAAAABFyD+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMgAiAgNrdyptt02HU8mKgnlY3mx4qzMSEJ830+AwRIQkLs5z2Bh3Ky2nVAAAgAEAAIAAAACAAAAAAAAAAAAA", + "cHNidP8BAFICAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////AUjmBSoBAAAAFgAUdo4e60z0IIZgM/gKzv8PlyB0SWkAAAAAAAEBKwDyBSoBAAAAIlEgWiws9bUs8x+DrS6Npj/wMYPs2PYJx1EK6KSOA5EKB1cBE0C7U+yRe62dkGrxuocYHEi4as5aritTYFpyXKdGJWMUdvxvW67a9PLuD0d/NvWPOXDVuCc7fkl7l68uPxJcl680IRb+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMhkAdystp1YAAIABAACAAAAAgAEAAAAAAAAAARcg/jSQZMmNbiqFP6PJsSvYswShnBlcYO+n7iOTBG0/ojIAIgIDa3cqbbdNh1PJioJ5WN5seKszEhCfN9PgMESEJC7Oc9gYdystp1QAAIABAACAAAAAgAAAAAAAAAAAAA==", + "cHNidP8BAF4CAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////AUjmBSoBAAAAIlEgg2mORYxmZOFZXXXaJZfeHiLul9eY5wbEwKS1qYI810MAAAAAAAEBKwDyBSoBAAAAIlEgWiws9bUs8x+DrS6Npj/wMYPs2PYJx1EK6KSOA5EKB1chFv40kGTJjW4qhT+jybEr2LMEoZwZXGDvp+4jkwRtP6IyGQB3Ky2nVgAAgAEAAIAAAACAAQAAAAAAAAABFyD+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMgABBSARJNp67JLM0GyVRWJkf0N7E4uVchqEvivyJ2u92rPmcSEHESTaeuySzNBslUViZH9DexOLlXIahL4r8idrvdqz5nEZAHcrLadWAACAAQAAgAAAAIAAAAAABQAAAAA=", + "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgg2mORYxmZOFZXXXaJZfeHiLul9eY5wbEwKS1qYI810MAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJiFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4fgjICyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSrMBCFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wJfG5v6l/3FP9XJEmZkIEOQG6YqhD1v35fZ4S8HQqabOIyBDILC/FvARtT6nvmFZJKp/J+XSmtIOoRVdhIZ2w7rRsqzAYhXBUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsDNlw4V9T/AyC+VD9Vg/6kZt2FyvgFzaKiZE68HT0ALCRFfLkkK98xFxPeFEfNgV85cWlxWMlop+0TfwgPzVuH4IyD6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqazAIRYssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20jkBzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwl3Ky2nVgAAgAEAAIACAACAAAAAAAAAAAAhFkMgsL8W8BG1Pqe+YVkkqn8n5dKa0g6hFV2EhnbDutGyOQERXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+HcrLadWAACAAQAAgAEAAIAAAAAAAAAAACEWUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAFAHxGHl0hFvoPejzvOx0MCmzn0m4XraCy5cktGe+tSLQYWcuKRRypOQFvfWIFnpSXoaSiZ1admHbaYBAa/zjjUpubk5zn+RrpcHcrLadWAACAAQAAgAMAAIAAAAAAAAAAAAEXIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAARgg8DYuL3Wm9CClvePrIh2WrmcgzyX4GJDJWx13WstRXmUAAQUgESTaeuySzNBslUViZH9DexOLlXIahL4r8idrvdqz5nEhBxEk2nrskszQbJVFYmR/Q3sTi5VyGoS+K/Ina73as+ZxGQB3Ky2nVgAAgAEAAIAAAACAAAAAAAUAAAAA", + "cHNidP8BAF4CAAAAASd0Srq/MCf+DWzyOpbu4u+xiO9SMBlUWFiD5ptmJLJCAAAAAAD/////AUjmBSoBAAAAIlEgCoy9yG3hzhwPnK6yLW33ztNoP+Qj4F0eQCqHk0HW9vUAAAAAAAEBKwDyBSoBAAAAIlEgWiws9bUs8x+DrS6Npj/wMYPs2PYJx1EK6KSOA5EKB1chFv40kGTJjW4qhT+jybEr2LMEoZwZXGDvp+4jkwRtP6IyGQB3Ky2nVgAAgAEAAIAAAACAAQAAAAAAAAABFyD+NJBkyY1uKoU/o8mxK9izBKGcGVxg76fuI5MEbT+iMgABBSBQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAEGbwLAIiBzblcpAP4SUliaIUPI88efcaBBLSNTr3VelwHHgmlKAqwCwCIgYxxfO1gyuPvev7GXBM7rMjwh9A96JPQ9aO8MwmsSWWmsAcAiIET6pJoDON5IjI3//s37bzKfOAvVZu8gyN9tgT6rHEJzrCEHRPqkmgM43kiMjf/+zftvMp84C9Vm7yDI322BPqscQnM5AfBreYuSoQ7ZqdC7/Trxc6U7FhfaOkFZygCCFs2Fay4Odystp1YAAIABAACAAQAAgAAAAAADAAAAIQdQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wAUAfEYeXSEHYxxfO1gyuPvev7GXBM7rMjwh9A96JPQ9aO8MwmsSWWk5ARis5AmIl4Xg6nDO67jhyokqenjq7eDy4pbPQ1lhqPTKdystp1YAAIABAACAAgAAgAAAAAADAAAAIQdzblcpAP4SUliaIUPI88efcaBBLSNTr3VelwHHgmlKAjkBKaW0kVCQFi11mv0/4Pk/ozJgVtC0CIy5M8rngmy42Cx3Ky2nVgAAgAEAAIADAACAAAAAAAMAAAAA", + "cHNidP8BAF4CAAAAAZvUh2UjC/mnLmYgAflyVW5U8Mb5f+tWvLVgDYF/aZUmAQAAAAD/////AUjmBSoBAAAAIlEgg2mORYxmZOFZXXXaJZfeHiLul9eY5wbEwKS1qYI810MAAAAAAAEBKwDyBSoBAAAAIlEgwiR++/2SrEf29AuNQtFpF1oZ+p+hDkol1/NetN2FtpJBFCyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwlAv4GNl1fW/+tTi6BX+0wfxOD17xhudlvrVkeR4Cr1/T1eJVHU404z2G8na4LJnHmu0/A5Wgge/NLMLGXdfmk9eUEUQyCwvxbwEbU+p75hWSSqfyfl0prSDqEVXYSGdsO60bIRXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+EDh8atvq/omsjbyGDNxncHUKKt2jYD5H5mI2KvvR7+4Y7sfKlKfdowV8AzjTsKDzcB+iPhCi+KPbvZAQ8MpEYEaQRT6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqW99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwQOwfA3kgZGHIM0IoVCMyZwirAx8NpKJT7kWq+luMkgNNi2BUkPjNE+APmJmJuX4hX6o28S3uNpPS2szzeBwXV/ZiFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wG99YgWelJehpKJnVp2YdtpgEBr/OONSm5uTnOf5GulwEV8uSQr3zEXE94UR82BXzlxaXFYyWin7RN/CA/NW4fgjICyxOsaCSN6AaqajZZzzwD62gh0JyBFKToaP696GW7bSrMBCFcFQkpt0waBJVLeLS2A16XpeB4paDyjsltVHv+6azoA6wJfG5v6l/3FP9XJEmZkIEOQG6YqhD1v35fZ4S8HQqabOIyBDILC/FvARtT6nvmFZJKp/J+XSmtIOoRVdhIZ2w7rRsqzAYhXBUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsDNlw4V9T/AyC+VD9Vg/6kZt2FyvgFzaKiZE68HT0ALCRFfLkkK98xFxPeFEfNgV85cWlxWMlop+0TfwgPzVuH4IyD6D3o87zsdDAps59JuF62gsuXJLRnvrUi0GFnLikUcqazAIRYssTrGgkjegGqmo2Wc88A+toIdCcgRSk6Gj+vehlu20jkBzZcOFfU/wMgvlQ/VYP+pGbdhcr4Bc2iomROvB09ACwl3Ky2nVgAAgAEAAIACAACAAAAAAAAAAAAhFkMgsL8W8BG1Pqe+YVkkqn8n5dKa0g6hFV2EhnbDutGyOQERXy5JCvfMRcT3hRHzYFfOXFpcVjJaKftE38ID81bh+HcrLadWAACAAQAAgAEAAIAAAAAAAAAAACEWUJKbdMGgSVS3i0tgNel6XgeKWg8o7JbVR7/ums6AOsAFAHxGHl0hFvoPejzvOx0MCmzn0m4XraCy5cktGe+tSLQYWcuKRRypOQFvfWIFnpSXoaSiZ1admHbaYBAa/zjjUpubk5zn+RrpcHcrLadWAACAAQAAgAMAAIAAAAAAAAAAAAEXIFCSm3TBoElUt4tLYDXpel4HiloPKOyW1Ue/7prOgDrAARgg8DYuL3Wm9CClvePrIh2WrmcgzyX4GJDJWx13WstRXmUAAQUgESTaeuySzNBslUViZH9DexOLlXIahL4r8idrvdqz5nEhBxEk2nrskszQbJVFYmR/Q3sTi5VyGoS+K/Ina73as+ZxGQB3Ky2nVgAAgAEAAIAAAACAAAAAAAUAAAAA" ], "creator" : [ { @@ -96,4 +116,4 @@ "result" : "0200000000010258e87a21b56daf0c23be8e7070456c336f7cbaa5c8757924f545887bb2abdd7500000000da00473044022074018ad4180097b873323c0015720b3684cc8123891048e7dbcd9b55ad679c99022073d369b740e3eb53dcefa33823c8070514ca55a7dd9544f157c167913261118c01483045022100f61038b308dc1da865a34852746f015772934208c6d24454393cd99bdf2217770220056e675a675a6d0a02b85b14e5e29074d8a25a9b5760bea2816f661910a006ea01475221029583bf39ae0a609747ad199addd634fa6108559d6c5cd39b4c2183f1ab96e07f2102dab61ff49a14db6a7d02b0cd1fbb78fc4b18312b5b4e54dae4dba2fbfef536d752aeffffffff838d0427d0ec650a68aa46bb0b098aea4422c071b2ca78352a077959d07cea1d01000000232200208c2353173743b595dfb4a07b72ba8e42e3797da74e87fe7d9d7497e3b2028903ffffffff0270aaf00800000000160014d85c2b71d0060b09c9886aeb815e50991dda124d00e1f5050000000016001400aea9a2e5f0f876a588df5546e8742d1d87008f000400473044022062eb7a556107a7c73f45ac4ab5a1dddf6f7075fb1275969a7f383efff784bcb202200c05dbb7470dbf2f08557dd356c7325c1ed30913e996cd3840945db12228da5f01473044022065f45ba5998b59a27ffe1a7bed016af1f1f90d54b3aa8f7450aa5f56a25103bd02207f724703ad1edb96680b284b56d4ffcb88f7fb759eabbe08aa30f29b851383d20147522103089dc10c7ac6db54f91329af617333db388cead0c231f723379d1b99030b02dc21023add904f3d6dcf59ddb906b0dee23529b7ffb9ed50e5e86151926860221f0e7352ae00000000" } ] -} \ No newline at end of file +} diff --git a/test/run_tests.py b/test/run_tests.py index 87cccca52..cde4b65dc 100755 --- a/test/run_tests.py +++ b/test/run_tests.py @@ -6,20 +6,22 @@ from test_base58 import TestBase58 from test_bech32 import TestSegwitAddress +from test_bip32 import TestBIP32 from test_coldcard import coldcard_test_suite from test_descriptor import TestDescriptor -from test_device import start_bitcoind +from test_device import Bitcoind from test_psbt import TestPSBT from test_trezor import trezor_test_suite from test_ledger import ledger_test_suite from test_digitalbitbox import digitalbitbox_test_suite from test_keepkey import keepkey_test_suite +from test_jade import jade_test_suite from test_udevrules import TestUdevRulesInstaller parser = argparse.ArgumentParser(description='Setup the testing environment and run automated tests') trezor_group = parser.add_mutually_exclusive_group() -trezor_group.add_argument('--no-trezor', dest='trezor', help='Do not run Trezor test with emulator', action='store_false') -trezor_group.add_argument('--trezor', dest='trezor', help='Run Trezor test with emulator', action='store_true') +trezor_group.add_argument('--no-trezor-1', dest='trezor_1', help='Do not run Trezor test with emulator', action='store_false') +trezor_group.add_argument('--trezor-1', dest='trezor_1', help='Run Trezor test with emulator', action='store_true') trezor_t_group = parser.add_mutually_exclusive_group() trezor_t_group.add_argument('--no-trezor-t', dest='trezor_t', help='Do not run Trezor T test with emulator', action='store_false') @@ -29,67 +31,97 @@ coldcard_group.add_argument('--no-coldcard', dest='coldcard', help='Do not run Coldcard test with simulator', action='store_false') coldcard_group.add_argument('--coldcard', dest='coldcard', help='Run Coldcard test with simulator', action='store_true') -ledger_s_group = parser.add_mutually_exclusive_group() -ledger_s_group.add_argument('--ledger-s', help='Run physical Ledger Nano S tests.', action='store_true') +ledger_group = parser.add_mutually_exclusive_group() +ledger_group.add_argument('--no-ledger', dest='ledger', help='Do not run Ledger test with emulator', action='store_false') +ledger_group.add_argument('--ledger', dest='ledger', help='Run Ledger test with emulator', action='store_true') -ledger_x_group = parser.add_mutually_exclusive_group() -ledger_x_group.add_argument('--ledger-x', help='Run physical Ledger Nano X tests.', action='store_true') +ledger_legacy_group = parser.add_mutually_exclusive_group() +ledger_legacy_group.add_argument('--no-ledger-legacy', dest='ledger_legacy', help='Do not run Ledger App legacy test with emulator', action='store_false') +ledger_legacy_group.add_argument('--ledger-legacy', dest='ledger_legacy', help='Run Ledger App legacy test with emulator', action='store_true') keepkey_group = parser.add_mutually_exclusive_group() keepkey_group.add_argument('--no-keepkey', dest='keepkey', help='Do not run Keepkey test with emulator', action='store_false') keepkey_group.add_argument('--keepkey', dest='keepkey', help='Run Keepkey test with emulator', action='store_true') +jade_group = parser.add_mutually_exclusive_group() +jade_group.add_argument('--no-jade', dest='jade', help='Do not run Jade test with emulator', action='store_false') +jade_group.add_argument('--jade', dest='jade', help='Run Jade test with emulator', action='store_true') + dbb_group = parser.add_mutually_exclusive_group() -dbb_group.add_argument('--no_bitbox', dest='bitbox', help='Do not run Digital Bitbox test with simulator', action='store_false') -dbb_group.add_argument('--bitbox', dest='bitbox', help='Run Digital Bitbox test with simulator', action='store_true') +dbb_group.add_argument('--no_bitbox01', dest='bitbox01', help='Do not run Digital Bitbox test with simulator', action='store_false') +dbb_group.add_argument('--bitbox01', dest='bitbox01', help='Run Digital Bitbox test with simulator', action='store_true') -parser.add_argument('--trezor-path', dest='trezor_path', help='Path to Trezor emulator', default='work/trezor-firmware/legacy/firmware/trezor.elf') +parser.add_argument('--trezor-1-path', dest='trezor_1_path', help='Path to Trezor 1 emulator', default='work/trezor-firmware/legacy/firmware/trezor.elf') parser.add_argument('--trezor-t-path', dest='trezor_t_path', help='Path to Trezor T emulator', default='work/trezor-firmware/core/emu.sh') parser.add_argument('--coldcard-path', dest='coldcard_path', help='Path to Coldcar simulator', default='work/firmware/unix/headless.py') parser.add_argument('--keepkey-path', dest='keepkey_path', help='Path to Keepkey emulator', default='work/keepkey-firmware/bin/kkemu') -parser.add_argument('--bitbox-path', dest='bitbox_path', help='Path to Digital Bitbox simulator', default='work/mcu/build/bin/simulator') +parser.add_argument('--bitbox01-path', dest='bitbox01_path', help='Path to Digital Bitbox simulator', default='work/mcu/build/bin/simulator') +parser.add_argument('--ledger-path', dest='ledger_path', help='Path to Ledger emulator', default='work/speculos/speculos.py') +parser.add_argument('--jade-path', dest='jade_path', help='Path to Jade qemu emulator', default='work/jade/simulator') parser.add_argument('--all', help='Run tests on all existing simulators', default=False, action='store_true') parser.add_argument('--bitcoind', help='Path to bitcoind', default='work/bitcoin/src/bitcoind') parser.add_argument('--interface', help='Which interface to send commands over', choices=['library', 'cli', 'bindist', 'stdin'], default='library') -parser.set_defaults(trezor=False, trezor_t=False, coldcard=False, keepkey=False, bitbox=False) +parser.add_argument("--device-only", help="Only run device tests", action="store_true") + +parser.set_defaults(trezor_1=None, trezor_t=None, coldcard=None, keepkey=None, bitbox01=None, ledger=None, ledger_legacy=None, jade=None) + args = parser.parse_args() # Run tests +success = True suite = unittest.TestSuite() -suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestDescriptor)) -suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestSegwitAddress)) -suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestPSBT)) -suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestBase58)) -if sys.platform.startswith("linux"): - suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestUdevRulesInstaller)) +if not args.device_only: + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestDescriptor)) + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestSegwitAddress)) + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestPSBT)) + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestBase58)) + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestBIP32)) + if sys.platform.startswith("linux"): + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestUdevRulesInstaller)) + success = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite).wasSuccessful() if args.all: - args.trezor = True - args.trezor_t = True - args.coldcard = True - args.keepkey = True - args.bitbox = True - -if args.trezor or args.trezor_t or args.coldcard or args.ledger_s or args.ledger_x or args.keepkey or args.bitbox: + # Default all true unless overridden + args.trezor_1 = True if args.trezor_1 is None else args.trezor_1 + args.trezor_t = True if args.trezor_t is None else args.trezor_t + args.coldcard = True if args.coldcard is None else args.coldcard + args.keepkey = True if args.keepkey is None else args.keepkey + args.bitbox01 = True if args.bitbox01 is None else args.bitbox01 + args.ledger = True if args.ledger is None else args.ledger + args.ledger_legacy = True if args.ledger_legacy is None else args.ledger_legacy + args.jade = True if args.jade is None else args.jade +else: + # Default all false unless overridden + args.trezor_1 = False if args.trezor_1 is None else args.trezor_1 + args.trezor_t = False if args.trezor_t is None else args.trezor_t + args.coldcard = False if args.coldcard is None else args.coldcard + args.keepkey = False if args.keepkey is None else args.keepkey + args.bitbox01 = False if args.bitbox01 is None else args.bitbox01 + args.ledger = False if args.ledger is None else args.ledger + args.ledger_legacy = False if args.ledger_legacy is None else args.ledger_legacy + args.jade = False if args.jade is None else args.jade + +if args.trezor_1 or args.trezor_t or args.coldcard or args.ledger or args.ledger_legacy or args.keepkey or args.bitbox01 or args.jade: # Start bitcoind - rpc, userpass = start_bitcoind(args.bitcoind) - - if args.bitbox: - suite.addTest(digitalbitbox_test_suite(args.bitbox_path, rpc, userpass, args.interface)) - if args.coldcard: - suite.addTest(coldcard_test_suite(args.coldcard_path, rpc, userpass, args.interface)) - if args.trezor: - suite.addTest(trezor_test_suite(args.trezor_path, rpc, userpass, args.interface)) - if args.trezor_t: - suite.addTest(trezor_test_suite(args.trezor_t_path, rpc, userpass, args.interface, True)) - if args.keepkey: - suite.addTest(keepkey_test_suite(args.keepkey_path, rpc, userpass, args.interface)) - if args.ledger_s: - suite.addTest(ledger_test_suite("ledger_nano_s", rpc, userpass, args.interface)) - if args.ledger_x: - suite.addTest(ledger_test_suite("ledger_nano_x", rpc, userpass, args.interface)) - -result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) -sys.exit(not result.wasSuccessful()) + bitcoind = Bitcoind.create(args.bitcoind) + + if success and args.bitbox01: + success &= digitalbitbox_test_suite(args.bitbox01_path, bitcoind, args.interface) + if success and args.coldcard: + success &= coldcard_test_suite(args.coldcard_path, bitcoind, args.interface) + if success and args.trezor_1: + success &= trezor_test_suite(args.trezor_1_path, bitcoind, args.interface, '1') + if success and args.trezor_t: + success &= trezor_test_suite(args.trezor_t_path, bitcoind, args.interface, 't') + if success and args.keepkey: + success &= keepkey_test_suite(args.keepkey_path, bitcoind, args.interface) + if success and args.ledger: + success &= ledger_test_suite(args.ledger_path, bitcoind, args.interface, False) + if success and args.ledger_legacy: + success &= ledger_test_suite(args.ledger_path, bitcoind, args.interface, True) + if success and args.jade: + success &= jade_test_suite(args.jade_path, bitcoind, args.interface) + +sys.exit(not success) diff --git a/test/setup_environment.sh b/test/setup_environment.sh index 0d7967629..fa0910399 100755 --- a/test/setup_environment.sh +++ b/test/setup_environment.sh @@ -1,194 +1,464 @@ #! /usr/bin/env bash +while [[ $# -gt 0 ]]; do + case $1 in + --trezor-1) + build_trezor_1=1 + shift + ;; + --trezor-t) + build_trezor_t=1 + shift + ;; + --coldcard) + build_coldcard=1 + shift + ;; + --bitbox01) + build_bitbox01=1 + shift + ;; + --ledger) + build_ledger=1 + shift + ;; + --ledger-legacy) + build_ledger=1 + shift + ;; + --keepkey) + build_keepkey=1 + shift + ;; + --jade) + build_jade=1 + shift + ;; + --bitcoind) + build_bitcoind=1 + shift + ;; + --all) + build_trezor_1=1 + build_trezor_t=1 + build_coldcard=1 + build_bitbox01=1 + build_ledger=1 + build_keepkey=1 + build_jade=1 + build_bitcoind=1 + shift + ;; + esac +done + # Makes debugging easier -set -x +set -ex # Go into the working directory mkdir -p work cd work -# Clone trezor-mcu if it doesn't exist, or update it if it does -trezor_setup_needed=false -if [ ! -d "trezor-firmware" ]; then - git clone --recursive https://github.com/trezor/trezor-firmware.git - cd trezor-firmware - trezor_setup_needed=true -else - cd trezor-firmware - git fetch - - # Determine if we need to pull. From https://stackoverflow.com/a/3278427 - UPSTREAM=${1:-'@{u}'} - LOCAL=$(git rev-parse @) - REMOTE=$(git rev-parse "$UPSTREAM") - BASE=$(git merge-base @ "$UPSTREAM") - - if [ $LOCAL = $REMOTE ]; then - echo "Up-to-date" - elif [ $LOCAL = $BASE ]; then - git pull - trezor_setup_needed=true +if [[ -n ${build_trezor_1} || -n ${build_trezor_t} ]]; then + # Clone trezor-firmware if it doesn't exist, or update it if it does + if [ ! -d "trezor-firmware" ]; then + git clone --recursive https://github.com/trezor/trezor-firmware.git + cd trezor-firmware + else + cd trezor-firmware + git fetch + + # Determine if we need to pull. From https://stackoverflow.com/a/3278427 + UPSTREAM=${1:-'@{u}'} + LOCAL=$(git rev-parse @) + REMOTE=$(git rev-parse "$UPSTREAM") + BASE=$(git merge-base @ "$UPSTREAM") + + if [ $LOCAL = $REMOTE ]; then + echo "Up-to-date" + elif [ $LOCAL = $BASE ]; then + git pull + fi fi -fi -# Build trezor one emulator. This is pretty fast, so rebuilding every time is ok -# But there should be some caching that makes this faster -cd legacy -export EMULATOR=1 TREZOR_TRANSPORT_V1=1 DEBUG_LINK=1 HEADLESS=1 -if [ "$trezor_setup_needed" == true ] ; then - script/setup - pipenv install -fi -pipenv run script/cibuild -# Delete any emulator.img file -find . -name "emulator.img" -exec rm {} \; -cd .. - -# Build trezor t emulator. This is pretty fast, so rebuilding every time is ok -# But there should be some caching that makes this faster -cd core -if [ "$trezor_setup_needed" == true ] ; then - make vendor + # Remove .venv so that poetry can symlink everything correctly + find . -type d -name ".venv" -exec rm -rf {} + + + if [[ -n ${build_trezor_1} ]]; then + # Build trezor one emulator. This is pretty fast, so rebuilding every time is ok + # But there should be some caching that makes this faster + poetry install + cd legacy + export EMULATOR=1 TREZOR_TRANSPORT_V1=1 DEBUG_LINK=1 HEADLESS=1 PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python + poetry run pip install -U protobuf + poetry run script/setup + poetry run script/cibuild + # Delete any emulator.img file + find . -name "emulator.img" -exec rm {} \; + cd .. + fi + + if [[ -n ${build_trezor_t} ]]; then + rustup update + rustup toolchain uninstall nightly + rustup toolchain install nightly + rustup default nightly + # Build trezor t emulator. This is pretty fast, so rebuilding every time is ok + # But there should be some caching that makes this faster + poetry install + cd core + poetry run make build_unix + # Delete any emulator.img file + find . -name "trezor.flash" -exec rm {} \; + cd .. + fi + cd .. fi -make build_unix -# Delete any emulator.img file -rm /var/tmp/trezor.flash -cd ../.. - -# Clone coldcard firmware if it doesn't exist, or update it if it does -coldcard_setup_needed=false -if [ ! -d "firmware" ]; then - git clone --recursive https://github.com/Coldcard/firmware.git - cd firmware - coldcard_setup_needed=true -else - cd firmware - git reset --hard HEAD^ # Undo git-am for checking and updating - git fetch - - # Determine if we need to pull. From https://stackoverflow.com/a/3278427 - UPSTREAM=${1:-'@{u}'} - LOCAL=$(git rev-parse @) - REMOTE=$(git rev-parse "$UPSTREAM") - BASE=$(git merge-base @ "$UPSTREAM") - - if [ $LOCAL = $REMOTE ]; then - echo "Up-to-date" - elif [ $LOCAL = $BASE ]; then - git pull + +if [[ -n ${build_coldcard} ]]; then + # Clone coldcard firmware if it doesn't exist, or update it if it does + coldcard_setup_needed=false + if [ ! -d "firmware" ]; then + git clone --recursive https://github.com/Coldcard/firmware.git + cd firmware coldcard_setup_needed=true + else + cd firmware + git reset --hard HEAD~3 # Undo git-am for checking and updating + git fetch + + # Determine if we need to pull. From https://stackoverflow.com/a/3278427 + UPSTREAM=${1:-'@{u}'} + LOCAL=$(git rev-parse @) + REMOTE=$(git rev-parse "$UPSTREAM") + BASE=$(git merge-base @ "$UPSTREAM") + + if [ $LOCAL = $REMOTE ]; then + echo "Up-to-date" + elif [ $LOCAL = $BASE ]; then + git pull + coldcard_setup_needed=true + fi fi -fi -# Apply patch to make simulator work in linux environments -git am ../../data/coldcard-linux-sock.patch + # Apply patch to make simulator work in linux environments + git am ../../data/coldcard-multisig.patch -# Build the simulator. This is cached, but it is also fast -cd unix -if [ "$coldcard_setup_needed" == true ] ; then - make setup + # Build the simulator. This is cached, but it is also fast + poetry run pip install -r requirements.txt + pip install -r requirements.txt + cd unix + if [ "$coldcard_setup_needed" == true ] ; then + pushd ../external/micropython/mpy-cross/ + make + popd + make setup + make ngu-setup + fi + make + cd ../.. fi -make -j$(nproc) -cd ../.. - -# Clone digital bitbox firmware if it doesn't exist, or update it if it does -dbb_setup_needed=false -if [ ! -d "mcu" ]; then - git clone --recursive https://github.com/digitalbitbox/mcu.git - cd mcu - dbb_setup_needed=true -else - cd mcu - git fetch - - # Determine if we need to pull. From https://stackoverflow.com/a/3278427 - UPSTREAM=${1:-'@{u}'} - LOCAL=$(git rev-parse @) - REMOTE=$(git rev-parse "$UPSTREAM") - BASE=$(git merge-base @ "$UPSTREAM") - - if [ $LOCAL = $REMOTE ]; then - echo "Up-to-date" - elif [ $LOCAL = $BASE ]; then - git pull - coldcard_setup_needed=true + +if [[ -n ${build_bitbox01} ]]; then + # Clone digital bitbox firmware if it doesn't exist, or update it if it does + if [ ! -d "mcu" ]; then + git clone --recursive https://github.com/digitalbitbox/mcu.git + cd mcu + else + cd mcu + git fetch + + # Determine if we need to pull. From https://stackoverflow.com/a/3278427 + UPSTREAM=${1:-'@{u}'} + LOCAL=$(git rev-parse @) + REMOTE=$(git rev-parse "$UPSTREAM") + BASE=$(git merge-base @ "$UPSTREAM") + + if [ $LOCAL = $REMOTE ]; then + echo "Up-to-date" + elif [ $LOCAL = $BASE ]; then + git pull + fi fi + + # Build the simulator. This is cached, but it is also fast + mkdir -p build && cd build + cmake .. -DBUILD_TYPE=simulator -DCMAKE_C_FLAGS="-Wno-format-truncation -Wno-array-parameter" + make + cd ../.. fi -# Build the simulator. This is cached, but it is also fast -mkdir -p build && cd build -cmake .. -DBUILD_TYPE=simulator -make -j$(nproc) -cd ../.. - -# Clone keepkey firmware if it doesn't exist, or update it if it does -keepkey_setup_needed=false -if [ ! -d "keepkey-firmware" ]; then - git clone --recursive https://github.com/keepkey/keepkey-firmware.git - cd keepkey-firmware - keepkey_setup_needed=true -else - cd keepkey-firmware - git fetch - - # Determine if we need to pull. From https://stackoverflow.com/a/3278427 - UPSTREAM=${1:-'@{u}'} - LOCAL=$(git rev-parse @) - REMOTE=$(git rev-parse "$UPSTREAM") - BASE=$(git merge-base @ "$UPSTREAM") - - if [ $LOCAL = $REMOTE ]; then - echo "Up-to-date" - elif [ $LOCAL = $BASE ]; then - git pull +if [[ -n ${build_keepkey} ]]; then + poetry run pip install protobuf + pip install protobuf + # Clone keepkey firmware if it doesn't exist, or update it if it does + keepkey_setup_needed=false + if [ ! -d "keepkey-firmware" ]; then + git clone --recursive https://github.com/keepkey/keepkey-firmware.git + cd keepkey-firmware keepkey_setup_needed=true + else + cd keepkey-firmware + git reset --hard HEAD~1 # Undo git-am for checking and updating + git fetch + + # Determine if we need to pull. From https://stackoverflow.com/a/3278427 + UPSTREAM=${1:-'@{u}'} + LOCAL=$(git rev-parse @) + REMOTE=$(git rev-parse "$UPSTREAM") + BASE=$(git merge-base @ "$UPSTREAM") + + if [ $LOCAL = $REMOTE ]; then + echo "Up-to-date" + elif [ $LOCAL = $BASE ]; then + git pull + keepkey_setup_needed=true + fi + fi + # Apply patch to make simulator build + git am ../../data/keepkey-build.patch + + # Build the simulator. This is cached, but it is also fast + if [ "$keepkey_setup_needed" == true ] ; then + git clean -ffdx + git clone https://github.com/nanopb/nanopb.git -b nanopb-0.3.9.4 fi + cd nanopb/generator/proto + make + cd ../../../ + export PATH=$PATH:`pwd`/nanopb/generator + cmake -C cmake/caches/emulator.cmake . -DNANOPB_DIR=nanopb/ -DPROTOC_BINARY=/usr/local/bin/protoc + make + # Delete any emulator.img file + find . -name "emulator.img" -exec rm {} \; + cd .. fi -# Build the simulator. This is cached, but it is also fast -if [ "$keepkey_setup_needed" == true ] ; then - git clone https://github.com/nanopb/nanopb.git -b nanopb-0.2.9.2 +if [[ -n ${build_ledger} ]]; then + speculos_packages="construct flask-restful jsonschema mnemonic pyelftools pillow requests" + poetry run pip install ${speculos_packages} + pip install ${speculos_packages} + # Clone ledger simulator Speculos if it doesn't exist, or update it if it does + if [ ! -d "speculos" ]; then + git clone --recursive https://github.com/LedgerHQ/speculos.git + cd speculos + else + cd speculos + git fetch + + # Determine if we need to pull. From https://stackoverflow.com/a/3278427 + UPSTREAM=${1:-'@{u}'} + LOCAL=$(git rev-parse @) + REMOTE=$(git rev-parse "$UPSTREAM") + BASE=$(git merge-base @ "$UPSTREAM") + + if [ $LOCAL = $REMOTE ]; then + echo "Up-to-date" + elif [ $LOCAL = $BASE ]; then + git pull + fi + fi + + # Build the simulator. This is cached, but it is also fast + mkdir -p build + cmake -Bbuild -H. + make -C build/ emu launcher copy-launcher + cd .. fi -# This needs py2, so make a pipenv -export PIPENV_IGNORE_VIRTUALENVS=1 -pipenv --python 2.7 -pipenv install protobuf -cd nanopb/generator/proto -pipenv run make -cd ../../../ -export PATH=$PATH:`pwd`/nanopb/generator -pipenv run cmake -C cmake/caches/emulator.cmake . -DNANOPB_DIR=nanopb/ -DKK_HAVE_STRLCAT=OFF -DKK_HAVE_STRLCPY=OFF -pipenv run make -j$(nproc) kkemu -# Delete any emulator.img file -find . -name "emulator.img" -exec rm {} \; -cd .. - -# Clone bitcoind if it doesn't exist, or update it if it does -bitcoind_setup_needed=false -if [ ! -d "bitcoin" ]; then - git clone https://github.com/bitcoin/bitcoin.git - cd bitcoin - bitcoind_setup_needed=true -else - cd bitcoin - git fetch - - # Determine if we need to pull. From https://stackoverflow.com/a/3278427 - UPSTREAM=${1:-'@{u}'} - LOCAL=$(git rev-parse @) - REMOTE=$(git rev-parse "$UPSTREAM") - BASE=$(git merge-base @ "$UPSTREAM") - - if [ $LOCAL = $REMOTE ]; then - echo "Up-to-date" - elif [ $LOCAL = $BASE ]; then - git pull - bitcoind_setup_needed=true + +if [[ -n ${build_jade} ]]; then + mkdir -p jade + cd jade + + # Clone Blockstream Jade firmware if it doesn't exist, or update it if it does + if [ ! -d "jade" ]; then + git clone --recursive --branch master https://github.com/Blockstream/Jade.git ./jade + cd jade + else + cd jade + git fetch --tags --recurse-submodules + + # Determine if we need to pull. From https://stackoverflow.com/a/3278427 + UPSTREAM=${1:-'@{u}'} + LOCAL=$(git rev-parse @) + REMOTE=$(git rev-parse "$UPSTREAM") + BASE=$(git merge-base @ "$UPSTREAM") + + if [ $LOCAL = $REMOTE ]; then + echo "Jade master up-to-date" + elif [ $LOCAL = $BASE ]; then + git pull + fi + git submodule update --recursive --init + fi + + # Deduce the relevant versions of esp-idf and qemu to use + ESP_IDF_BRANCH=$(grep "ARG ESP_IDF_BRANCH=" Dockerfile | cut -d\= -f2) + ESP_IDF_COMMIT=$(grep "ARG ESP_IDF_COMMIT=" Dockerfile | cut -d\= -f2) + ESP_QEMU_BRANCH=$(grep "ARG ESP_QEMU_BRANCH=" Dockerfile | cut -d\= -f2) + ESP_QEMU_COMMIT=$(grep "ARG ESP_QEMU_COMMIT=" Dockerfile | cut -d\= -f2) + cd .. + + # Build the qemu emulator if required + + # If the directory exists, see if it is at the expected commit + # If not, remove the entire directory (it will be re-cloned below) + if [ -d "qemu" ]; then + cd qemu + LOCAL=$(git rev-parse @) + if [ $LOCAL = $ESP_QEMU_COMMIT ]; then + echo "esp-qemu up-to-date" + cd .. + else + cd .. + rm -fr qemu + fi fi + + # Clone the upstream if the directory does not exist + # Then build the emulator + if [ ! -d "qemu" ]; then + git clone --depth 1 --branch ${ESP_QEMU_BRANCH} --single-branch --recursive https://github.com/espressif/qemu.git ./qemu + cd qemu + + git checkout ${ESP_QEMU_COMMIT} + git submodule update --recursive --init + ./configure \ + --target-list=xtensa-softmmu \ + --enable-gcrypt \ + --disable-user \ + --disable-opengl \ + --disable-curses \ + --disable-capstone \ + --disable-vnc \ + --disable-parallels \ + --disable-qed \ + --disable-vvfat \ + --disable-vdi \ + --disable-qcow1 \ + --disable-dmg \ + --disable-cloop \ + --disable-bochs \ + --disable-replication \ + --disable-live-block-migration \ + --disable-keyring \ + --disable-containers \ + --disable-docs \ + --disable-libssh \ + --disable-xen \ + --disable-tools \ + --disable-zlib-test \ + --disable-sdl \ + --disable-gtk \ + --disable-vhost-scsi \ + --disable-qom-cast-debug \ + --disable-tpm \ + --extra-cflags=-Wno-array-parameter + ninja -C build + cd .. + fi + + # Build the esp-idf toolchain if required + + # We will install the esp-idf tools in a given location (otherwise defaults to user home dir) + export IDF_TOOLS_PATH="$(pwd)/esp-idf-tools" + + # If the directory exists, see if it is at the expected commit + # If not, remove the entire directory (it will be re-cloned below) + if [ -d "esp-idf" ]; then + cd esp-idf + LOCAL=$(git rev-parse @) + if [ $LOCAL = $ESP_IDF_COMMIT ]; then + echo "esp-idf up-to-date" + cd .. + else + cd .. + rm -fr esp-idf + fi + fi + + # Clone the upstream if the directory does not exist + # Then build and install the tools + if [ ! -d "esp-idf" ]; then + git clone --depth=1 --branch ${ESP_IDF_BRANCH} --single-branch --recursive https://github.com/espressif/esp-idf.git ./esp-idf + cd esp-idf + + git checkout ${ESP_IDF_COMMIT} + git submodule update --recursive --init + cd .. + fi + + # Install the tools every run regardless + # (Otherwise a cached CI run which skips the above esp-idf clone does not + # always seem to pick up the locally installed python virtualenv, and instead uses + # the system python/no-virtualenv which fails ...) + # Only install the tools we need (ie. esp32) + rm -fr "${IDF_TOOLS_PATH}" + cd esp-idf + ./install.sh esp32 + cd .. + + # Export the tools + . ./esp-idf/export.sh + + # Build Blockstream Jade firmware configured for the emulator + cd jade + rm -fr sdkconfig + cp configs/sdkconfig_qemu.defaults sdkconfig.defaults + idf.py fullclean all + + # Make the qemu flash image + esptool.py --chip esp32 merge_bin --fill-flash-size 4MB -o main/qemu/flash_image.bin \ + --flash_mode dio --flash_freq 40m --flash_size 4MB \ + 0x9000 build/partition_table/partition-table.bin \ + 0xe000 build/ota_data_initial.bin \ + 0x1000 build/bootloader/bootloader.bin \ + 0x10000 build/jade.bin + cd .. + + # Extract the minimal artifacts required to run the emulator + rm -fr simulator + mkdir simulator + cp qemu/build/qemu-system-xtensa simulator/ + cp -R qemu/pc-bios simulator/ + cp jade/main/qemu/flash_image.bin simulator/ + cp jade/main/qemu/qemu_efuse.bin simulator/ + + cd .. fi -# Build bitcoind. This is super slow, but it is cached so it runs fairly quickly. -if [ "$bitcoind_setup_needed" == true ] ; then +if [[ -n ${build_bitcoind} ]]; then + # Clone bitcoind if it doesn't exist, or update it if it does + bitcoind_setup_needed=false + if [ ! -d "bitcoin" ]; then + git clone https://github.com/bitcoin/bitcoin.git + cd bitcoin + bitcoind_setup_needed=true + else + cd bitcoin + git reset --hard origin/master + git fetch + + # Determine if we need to pull. From https://stackoverflow.com/a/3278427 + UPSTREAM=${1:-'@{u}'} + LOCAL=$(git rev-parse @) + REMOTE=$(git rev-parse "$UPSTREAM") + BASE=$(git merge-base @ "$UPSTREAM") + + if [ $LOCAL = $REMOTE ]; then + echo "Up-to-date" + elif [ $LOCAL = $BASE ]; then + git pull + bitcoind_setup_needed=true + fi + fi + + # Build bitcoind. This is super slow, but it is cached so it runs fairly quickly. + pushd depends + make NO_QT=1 NO_QR=1 NO_ZMQ=1 NO_UPNP=1 NO_NATPMP=1 + popd + + # Do the build ./autogen.sh - ./configure --with-incompatible-bdb --with-miniupnpc=no --without-gui --disable-zmq --disable-tests --disable-bench --with-libs=no --with-utils=no + CONFIG_SITE=$PWD/depends/x86_64-pc-linux-gnu/share/config.site ./configure --with-incompatible-bdb --with-miniupnpc=no --without-gui --disable-zmq --disable-tests --disable-bench --with-libs=no --with-utils=no + make src/bitcoind fi -make -j$(nproc) src/bitcoind diff --git a/test/test_base58.py b/test/test_base58.py index a988dce50..aff01dc45 100755 --- a/test/test_base58.py +++ b/test/test_base58.py @@ -5,7 +5,7 @@ from binascii import unhexlify from typing import List, Tuple import unittest -import hwilib.base58 as base58 +import hwilib._base58 as base58 # Taken from Bitcoin Core # https://github.com/bitcoin/bitcoin/blob/master/src/test/data/base58_encode_decode.json diff --git a/test/test_bech32.py b/test/test_bech32.py index 6f0dff0d7..cc0a9c37b 100755 --- a/test/test_bech32.py +++ b/test/test_bech32.py @@ -21,59 +21,117 @@ # THE SOFTWARE. -"""Reference tests for segwit adresses""" +"""Reference tests for segwit addresses""" import binascii import unittest -import hwilib.bech32 as segwit_addr +import hwilib._bech32 as segwit_addr def segwit_scriptpubkey(witver, witprog): """Construct a Segwit scriptPubKey for a given witness program.""" return bytes([witver + 0x50 if witver else 0, len(witprog)] + witprog) -VALID_CHECKSUM = [ +VALID_BECH32 = [ "A12UEL5L", + "a12uel5l", "an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", "abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", "11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", "split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", + "?1ezyfcl", ] -INVALID_CHECKSUM = [ - " 1nwldj5", - "\x7F" + "1axkwrx", +VALID_BECH32M = [ + "A1LQFN3A", + "a1lqfn3a", + "an83characterlonghumanreadablepartthatcontainsthetheexcludedcharactersbioandnumber11sg7hg6", + "abcdef1l7aum6echk45nj3s0wdvt2fg8x9yrzpqzd3ryx", + "11llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllludsr8", + "split1checkupstagehandshakeupstreamerranterredcaperredlc445v", + "?1v759aa", +] + +INVALID_BECH32 = [ + " 1nwldj5", # HRP character out of range + "\x7F" + "1axkwrx", # HRP character out of range + "\x80" + "1eym55h", # HRP character out of range + # overall max length exceeded "an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", - "pzry9x0s0muk", - "1pzry9x0s0muk", - "x1b4n0q5v", - "li1dgmt3", - "de1lg7wt\xff", + "pzry9x0s0muk", # No separator character + "1pzry9x0s0muk", # Empty HRP + "x1b4n0q5v", # Invalid data character + "li1dgmt3", # Too short checksum + "de1lg7wt" + "\xFF", # Invalid character in checksum + "A1G7SGD8", # checksum calculated with uppercase form of HRP + "10a06t8", # empty HRP + "1qzzfhee", # empty HRP +] + +INVALID_BECH32M = [ + " 1xj0phk", # HRP character out of range + "\x7F" + "1g6xzxy", # HRP character out of range + "\x80" + "1vctc34", # HRP character out of range + # overall max length exceeded + "an84characterslonghumanreadablepartthatcontainsthetheexcludedcharactersbioandnumber11d6pts4", + "qyrz8wqd2c9m", # No separator character + "1qyrz8wqd2c9m", # Empty HRP + "y1b0jsk6g", # Invalid data character + "lt1igcx5c0", # Invalid data character + "in1muywd", # Too short checksum + "mm1crxm3i", # Invalid character in checksum + "au1s5cgom", # Invalid character in checksum + "M1VUXWEZ", # Checksum calculated with uppercase form of HRP + "16plkw9", # Empty HRP + "1p2gdwpf", # Empty HRP ] VALID_ADDRESS = [ ["BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4", "0014751e76e8199196d454941c45d1b3a323f1433bd6"], ["tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", "00201863143c14c5166804bd19203356da136c985678cd4d27a1b8c6329604903262"], - ["bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7k7grplx", + ["bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y", "5128751e76e8199196d454941c45d1b3a323f1433bd6751e76e8199196d454941c45d1b3a323f1433bd6"], - ["BC1SW50QA3JX3S", "6002751e"], - ["bc1zw508d6qejxtdg4y5r3zarvaryvg6kdaj", "5210751e76e8199196d454941c45d1b3a323"], + ["BC1SW50QGDZ25J", "6002751e"], + ["bc1zw508d6qejxtdg4y5r3zarvaryvaxxpcs", "5210751e76e8199196d454941c45d1b3a323"], ["tb1qqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesrxh6hy", "0020000000c4a5cad46221b2a187905e5266362b99d5e91c6ce24d165dab93e86433"], + ["tb1pqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesf3hn0c", + "5120000000c4a5cad46221b2a187905e5266362b99d5e91c6ce24d165dab93e86433"], + ["bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", + "512079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"], ] INVALID_ADDRESS = [ - "tc1qw508d6qejxtdg4y5r3zarvary0c5xw7kg3g4ty", - "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5", - "BC13W508D6QEJXTDG4Y5R3ZARVARY0C5XW7KN40WF2", - "bc1rw5uspcuh", - "bc10w508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kw5rljs90", + # Invalid HRP + "tc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq5zuyut", + # Invalid checksum algorithm (bech32 instead of bech32m) + "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqh2y7hd", + # Invalid checksum algorithm (bech32 instead of bech32m) + "tb1z0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqglt7rf", + # Invalid checksum algorithm (bech32 instead of bech32m) + "BC1S0XLXVLHEMJA6C4DQV22UAPCTQUPFHLXM9H8Z3K2E72Q4K9HCZ7VQ54WELL", + # Invalid checksum algorithm (bech32m instead of bech32) + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh", + # Invalid checksum algorithm (bech32m instead of bech32) + "tb1q0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq24jc47", + # Invalid character in checksum + "bc1p38j9r5y49hruaue7wxjce0updqjuyyx0kh56v8s25huc6995vvpql3jow4", + # Invalid witness version + "BC130XLXVLHEMJA6C4DQV22UAPCTQUPFHLXM9H8Z3K2E72Q4K9HCZ7VQ7ZWS8R", + # Invalid program length (1 byte) + "bc1pw5dgrnzv", + # Invalid program length (41 bytes) + "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7v8n0nx0muaewav253zgeav", + # Invalid program length for witness version 0 (per BIP141) "BC1QR508D6QEJXTDG4Y5R3ZARVARYV98GJ9P", - "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sL5k7", - "bc1zw508d6qejxtdg4y5r3zarvaryvqyzf3du", - "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3pjxtptv", + # Mixed case + "tb1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vq47Zagq", + # More than 4 padding bits + "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7v07qwwzcrf", + # Non-zero padding in 8-to-5 conversion + "tb1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vpggkg4j", + # Empty data section "bc1gmk9yu", - ] INVALID_ADDRESS_ENC = [ @@ -85,23 +143,27 @@ def segwit_scriptpubkey(witver, witprog): ] class TestSegwitAddress(unittest.TestCase): - """Unit test class for segwit addressess.""" + """Unit test class for segwit addresses.""" def test_valid_checksum(self): """Test checksum creation and validation.""" - for test in VALID_CHECKSUM: - hrp, _ = segwit_addr.bech32_decode(test) - self.assertIsNotNone(hrp) - pos = test.rfind('1') - test = test[:pos + 1] + chr(ord(test[pos + 1]) ^ 1) + test[pos + 2:] - hrp, _ = segwit_addr.bech32_decode(test) - self.assertIsNone(hrp) + for encoding in segwit_addr.Encoding: + tests = VALID_BECH32 if encoding == segwit_addr.Encoding.BECH32 else VALID_BECH32M + for test in tests: + dencoding, hrp, _ = segwit_addr.bech32_decode(test) + self.assertTrue(hrp is not None and dencoding == encoding) + pos = test.rfind('1') + test = test[:pos + 1] + chr(ord(test[pos + 1]) ^ 1) + test[pos + 2:] + decoding, hrp, _ = segwit_addr.bech32_decode(test) + self.assertIsNone(hrp) def test_invalid_checksum(self): """Test validation of invalid checksums.""" - for test in INVALID_CHECKSUM: - hrp, _ = segwit_addr.bech32_decode(test) - self.assertIsNone(hrp) + for encoding in segwit_addr.Encoding: + tests = INVALID_BECH32 if encoding == segwit_addr.Encoding.BECH32 else INVALID_BECH32M + for test in tests: + dencoding, hrp, _ = segwit_addr.bech32_decode(test) + self.assertTrue(hrp is None or dencoding != encoding) def test_valid_address(self): """Test whether valid addresses decode to the correct output.""" @@ -111,7 +173,7 @@ def test_valid_address(self): if witver is None: hrp = "tb" witver, witprog = segwit_addr.decode(hrp, address) - self.assertIsNotNone(witver) + self.assertIsNotNone(witver, address) scriptpubkey = segwit_scriptpubkey(witver, witprog) self.assertEqual(scriptpubkey, binascii.unhexlify(hexscript)) addr = segwit_addr.encode(hrp, witver, witprog) diff --git a/test/test_bip32.py b/test/test_bip32.py new file mode 100755 index 000000000..5a88d8243 --- /dev/null +++ b/test/test_bip32.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# Copyright (c) 2020 The HWI developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from hwilib.key import ( + ExtendedKey, + parse_path, +) + +import binascii +import json +import os +import unittest + +class TestBIP32(unittest.TestCase): + @classmethod + def setUpClass(cls): + with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "data/test_bip32.json"), encoding="utf-8") as f: + cls.data = json.load(f) + for key in cls.data["serialization"]: + deser = key["deser"] + deser["pub_version"] = binascii.unhexlify(deser["pub_version"]) + deser["priv_version"] = binascii.unhexlify(deser["priv_version"]) + deser["hex_parent_fingerprint"] = deser["parent_fingerprint"] + deser["parent_fingerprint"] = binascii.unhexlify(deser["parent_fingerprint"]) + deser["hex_chaincode"] = deser["chaincode"] + deser["chaincode"] = binascii.unhexlify(deser["chaincode"]) + deser["hex_pubkey"] = deser["pubkey"] + deser["pubkey"] = binascii.unhexlify(deser["pubkey"]) + deser["hex_privkey"] = deser["privkey"] + deser["privkey"] = binascii.unhexlify(deser["privkey"]) + + def test_serialization(self): + for key in self.data["serialization"]: + xpub = key["xpub"] + xprv = key["xprv"] + deser = key["deser"] + with self.subTest(key=key): + key_pub = ExtendedKey.deserialize(xpub) + key_prv = ExtendedKey.deserialize(xprv) + + # Make sure they roundtrip + self.assertEqual(key_pub.to_string(), xpub) + self.assertEqual(key_prv.to_string(), xprv) + + # Make sure they agree + self.assertEqual(key_pub.is_testnet, key_prv.is_testnet) + self.assertEqual(key_pub.depth, key_prv.depth) + self.assertEqual(key_pub.parent_fingerprint, key_prv.parent_fingerprint) + self.assertEqual(key_pub.child_num, key_prv.child_num) + self.assertEqual(key_pub.chaincode, key_prv.chaincode) + self.assertEqual(key_pub.pubkey, key_prv.pubkey) + + # Make sure they are correct + self.assertEqual(key_pub.version, deser["pub_version"]) + self.assertEqual(key_pub.is_testnet, deser["is_testnet"]) + self.assertEqual(key_pub.is_private, False) + self.assertEqual(key_pub.depth, deser["depth"]) + self.assertEqual(key_pub.parent_fingerprint, deser["parent_fingerprint"]) + self.assertEqual(key_pub.child_num, deser["child_num"]) + self.assertEqual(key_pub.chaincode, deser["chaincode"]) + self.assertEqual(key_pub.pubkey, deser["pubkey"]) + self.assertEqual(key_prv.version, deser["priv_version"]) + self.assertEqual(key_prv.is_testnet, deser["is_testnet"]) + self.assertEqual(key_prv.is_private, True) + self.assertEqual(key_prv.depth, deser["depth"]) + self.assertEqual(key_prv.parent_fingerprint, deser["parent_fingerprint"]) + self.assertEqual(key_prv.child_num, deser["child_num"]) + self.assertEqual(key_prv.chaincode, deser["chaincode"]) + self.assertEqual(key_prv.pubkey, deser["pubkey"]) + self.assertEqual(key_prv.privkey, deser["privkey"]) + + # Make sure the printable dict is right + key_dict = key_pub.get_printable_dict() + self.assertEqual(key_dict["testnet"], deser["is_testnet"]) + self.assertEqual(key_dict["private"], False) + self.assertEqual(key_dict["depth"], deser["depth"]) + self.assertEqual(key_dict["parent_fingerprint"], deser["hex_parent_fingerprint"]) + self.assertEqual(key_dict["child_num"], deser["child_num"]) + self.assertEqual(key_dict["chaincode"], deser["hex_chaincode"]) + self.assertEqual(key_dict["pubkey"], deser["hex_pubkey"]) + key_dict = key_prv.get_printable_dict() + self.assertEqual(key_dict["testnet"], deser["is_testnet"]) + self.assertEqual(key_dict["private"], True) + self.assertEqual(key_dict["depth"], deser["depth"]) + self.assertEqual(key_dict["parent_fingerprint"], deser["hex_parent_fingerprint"]) + self.assertEqual(key_dict["child_num"], deser["child_num"]) + self.assertEqual(key_dict["chaincode"], deser["hex_chaincode"]) + self.assertEqual(key_dict["pubkey"], deser["hex_pubkey"]) + self.assertEqual(key_dict["privkey"], deser["hex_privkey"]) + + def test_deriv(self): + for test in self.data["deriv"]: + with self.subTest(test=test): + # Deser + par_xpub = ExtendedKey.deserialize(test["parent_xpub"]) + par_xprv = ExtendedKey.deserialize(test["parent_xprv"]) + + # Derive + i = test["index"] + child_xpub = test["child_xpub"] + xpub_der = par_xpub.derive_pub(i) + self.assertEqual(xpub_der.to_string(), child_xpub) + xprv_der = par_xprv.derive_pub(i) + self.assertEqual(xprv_der.to_string(), child_xpub) + + def test_deriv_path(self): + for test in self.data["deriv_path"]: + with self.subTest(test=test): + # Deser + par_xpub = ExtendedKey.deserialize(test["parent_xpub"]) + par_xprv = ExtendedKey.deserialize(test["parent_xprv"]) + + # Parse the path + path = parse_path(test["path"]) + + # Derive + child_xpub = test["child_xpub"] + xpub_der = par_xpub.derive_pub_path(path) + self.assertEqual(xpub_der.to_string(), child_xpub) + xprv_der = par_xprv.derive_pub_path(path) + self.assertEqual(xprv_der.to_string(), child_xpub) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_coldcard.py b/test/test_coldcard.py index d2997f582..d049647b3 100755 --- a/test/test_coldcard.py +++ b/test/test_coldcard.py @@ -2,89 +2,163 @@ import argparse import atexit +import glob import os +import signal import subprocess +import sys import time import unittest -from hwilib.cli import process_commands -from hwilib.devices.ckcc.protocol import CCProtocolPacker -from hwilib.devices.ckcc.client import ColdcardDevice -from test_device import DeviceTestCase, start_bitcoind, TestDeviceConnect, TestDisplayAddress, TestGetKeypool, TestGetDescriptors, TestSignMessage, TestSignTx - -def coldcard_test_suite(simulator, rpc, userpass, interface): - # Start the Coldcard simulator - subprocess.Popen(['python3', os.path.basename(simulator)], cwd=os.path.dirname(simulator), stdout=subprocess.DEVNULL) - # Wait for simulator to be up - while True: - enum_res = process_commands(['enumerate']) - found = False - for dev in enum_res: - if dev['type'] == 'coldcard' and 'error' not in dev: - found = True - break - if found: - break - time.sleep(0.5) - # Cleanup - - def cleanup_simulator(): - dev = ColdcardDevice(sn='/tmp/ckcc-simulator.sock') - dev.send_recv(CCProtocolPacker.logout()) - atexit.register(cleanup_simulator) - - # Coldcard specific management command tests - class TestColdcardManCommands(DeviceTestCase): - def test_setup(self): - result = self.do_command(self.dev_args + ['-i', 'setup']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Coldcard does not support software setup') - self.assertEqual(result['code'], -9) - - def test_wipe(self): - result = self.do_command(self.dev_args + ['wipe']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Coldcard does not support wiping via software') - self.assertEqual(result['code'], -9) - - def test_restore(self): - result = self.do_command(self.dev_args + ['-i', 'restore']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Coldcard does not support restoring via software') - self.assertEqual(result['code'], -9) - - def test_backup(self): - result = self.do_command(self.dev_args + ['backup']) - self.assertTrue(result['success']) - self.assertIn('The backup has been written to', result['message']) - - def test_pin(self): - result = self.do_command(self.dev_args + ['promptpin']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Coldcard does not need a PIN sent from the host') - self.assertEqual(result['code'], -9) - - result = self.do_command(self.dev_args + ['sendpin', '1234']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Coldcard does not need a PIN sent from the host') - self.assertEqual(result['code'], -9) +from hwilib._cli import process_commands +from test_device import ( + Bitcoind, + DeviceEmulator, + DeviceTestCase, + TestDeviceConnect, + TestDisplayAddress, + TestGetKeypool, + TestGetDescriptors, + TestSignMessage, + TestSignTx, +) + +class ColdcardSimulator(DeviceEmulator): + def __init__(self, simulator): + try: + os.unlink("coldcard-emulator.stdout") + except FileNotFoundError: + pass + self.simulator = simulator + self.coldcard_log = None + self.coldcard_proc = None + self.type = "coldcard" + self.path = "/tmp/ckcc-simulator.sock" + self.fingerprint = "0f056943" + self.master_xpub = "tpubDCiHGUNYdRRBPNYm7CqeeLwPWfeb2ZT2rPsk4aEW3eUoJM93jbBa7hPpB1T9YKtigmjpxHrB1522kSsTxGm9V6cqKqrp1EDaYaeJZqcirYB" + self.password = "" + self.supports_ms_display = True + self.supports_xpub_ms_display = False + self.supports_unsorted_ms = False + self.supports_taproot = False + self.strict_bip48 = False + self.include_xpubs = False + self.supports_device_multiple_multisig = True + + def start(self): + super().start() + self.coldcard_log = open("coldcard-emulator.stdout", "a") + # Start the Coldcard simulator + self.coldcard_proc = subprocess.Popen( + [ + "python3", + os.path.basename(self.simulator), "--ms" + ], + cwd=os.path.dirname(self.simulator), + stdout=self.coldcard_log, + preexec_fn=os.setsid + ) + # Wait for simulator to be up + while True: + try: + enum_res = process_commands(["enumerate"]) + found = False + for dev in enum_res: + if dev["type"] == "coldcard" and "error" not in dev: + found = True + break + if found: + break + except Exception: + pass + time.sleep(0.5) + atexit.register(self.stop) + + def stop(self): + super().stop() + if self.coldcard_proc.poll() is None: + os.killpg(os.getpgid(self.coldcard_proc.pid), signal.SIGTERM) + os.waitpid(os.getpgid(self.coldcard_proc.pid), 0) + self.coldcard_log.close() + atexit.unregister(self.stop) + +# Coldcard specific management command tests +class TestColdcardManCommands(DeviceTestCase): + def test_setup(self): + result = self.do_command(self.dev_args + ['-i', 'setup']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Coldcard does not support software setup') + self.assertEqual(result['code'], -9) + + def test_wipe(self): + result = self.do_command(self.dev_args + ['wipe']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Coldcard does not support wiping via software') + self.assertEqual(result['code'], -9) + + def test_restore(self): + result = self.do_command(self.dev_args + ['-i', 'restore']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Coldcard does not support restoring via software') + self.assertEqual(result['code'], -9) + + def test_backup(self): + result = self.do_command(self.dev_args + ['backup']) + self.assertTrue(result['success']) + for filename in glob.glob("backup-*.7z"): + os.remove(filename) + + def test_pin(self): + result = self.do_command(self.dev_args + ['promptpin']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Coldcard does not need a PIN sent from the host') + self.assertEqual(result['code'], -9) + + result = self.do_command(self.dev_args + ['sendpin', '1234']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Coldcard does not need a PIN sent from the host') + self.assertEqual(result['code'], -9) + +class TestColdcardGetXpub(DeviceTestCase): + def test_getxpub(self): + result = self.do_command(self.dev_args + ['--expert', 'getxpub', 'm/44h/0h/0h/3']) + self.assertEqual(result['xpub'], 'tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty') + self.assertTrue(result['testnet']) + self.assertFalse(result['private']) + self.assertEqual(result['depth'], 4) + self.assertEqual(result['parent_fingerprint'], 'bc123c3e') + self.assertEqual(result['child_num'], 3) + self.assertEqual(result['chaincode'], '806b26507824f73bc331494afe122f428ef30dde80b2c1ce025d2d03aff411e7') + self.assertEqual(result['pubkey'], '0368000bdff5e0b71421c37b8514de8acd4d98ba9908d183d9da56d02ca4fcfd08') + +def coldcard_test_suite(simulator, bitcoind, interface): + dev_emulator = ColdcardSimulator(simulator) + + signtx_cases = [ + (["legacy"], ["legacy"], True, False), + (["segwit"], ["segwit"], True, False), + (["legacy", "segwit"], ["legacy", "segwit"], True, False), + ] # Generic device tests suite = unittest.TestSuite() - suite.addTest(DeviceTestCase.parameterize(TestColdcardManCommands, rpc, userpass, 'coldcard', 'coldcard', '/tmp/ckcc-simulator.sock', '0f056943', '', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, 'coldcard', 'coldcard', '/tmp/ckcc-simulator.sock', '0f056943', 'tpubDDpWvmUrPZrhSPmUzCMBHffvC3HyMAPnWDSAQNBTnj1iZeJa7BZQEttFiP4DS4GCcXQHezdXhn86Hj6LHX5EDstXPWrMaSneRWM8yUf6NFd', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, 'coldcard_simulator', 'coldcard', '/tmp/ckcc-simulator.sock', '0f056943', 'tpubDDpWvmUrPZrhSPmUzCMBHffvC3HyMAPnWDSAQNBTnj1iZeJa7BZQEttFiP4DS4GCcXQHezdXhn86Hj6LHX5EDstXPWrMaSneRWM8yUf6NFd', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, rpc, userpass, 'coldcard', 'coldcard', '/tmp/ckcc-simulator.sock', '0f056943', 'tpubDDpWvmUrPZrhSPmUzCMBHffvC3HyMAPnWDSAQNBTnj1iZeJa7BZQEttFiP4DS4GCcXQHezdXhn86Hj6LHX5EDstXPWrMaSneRWM8yUf6NFd', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, rpc, userpass, 'coldcard', 'coldcard', '/tmp/ckcc-simulator.sock', '0f056943', 'tpubDDpWvmUrPZrhSPmUzCMBHffvC3HyMAPnWDSAQNBTnj1iZeJa7BZQEttFiP4DS4GCcXQHezdXhn86Hj6LHX5EDstXPWrMaSneRWM8yUf6NFd', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, rpc, userpass, 'coldcard', 'coldcard', '/tmp/ckcc-simulator.sock', '0f056943', 'tpubDDpWvmUrPZrhSPmUzCMBHffvC3HyMAPnWDSAQNBTnj1iZeJa7BZQEttFiP4DS4GCcXQHezdXhn86Hj6LHX5EDstXPWrMaSneRWM8yUf6NFd', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestSignMessage, rpc, userpass, 'coldcard', 'coldcard', '/tmp/ckcc-simulator.sock', '0f056943', 'tpubDDpWvmUrPZrhSPmUzCMBHffvC3HyMAPnWDSAQNBTnj1iZeJa7BZQEttFiP4DS4GCcXQHezdXhn86Hj6LHX5EDstXPWrMaSneRWM8yUf6NFd', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestSignTx, rpc, userpass, 'coldcard', 'coldcard', '/tmp/ckcc-simulator.sock', '0f056943', 'tpubDDpWvmUrPZrhSPmUzCMBHffvC3HyMAPnWDSAQNBTnj1iZeJa7BZQEttFiP4DS4GCcXQHezdXhn86Hj6LHX5EDstXPWrMaSneRWM8yUf6NFd', interface=interface)) - return suite + suite.addTest(DeviceTestCase.parameterize(TestColdcardManCommands, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestColdcardGetXpub, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type="coldcard")) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type="coldcard")) + suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) + + result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + return result.wasSuccessful() if __name__ == '__main__': parser = argparse.ArgumentParser(description='Test Coldcard implementation') @@ -94,7 +168,6 @@ def test_pin(self): args = parser.parse_args() # Start bitcoind - rpc, userpass = start_bitcoind(args.bitcoind) + bitcoind = Bitcoind.create(args.bitcoind) - suite = coldcard_test_suite(args.simulator, rpc, userpass, args.interface) - unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(not coldcard_test_suite(args.simulator, bitcoind, args.interface)) diff --git a/test/test_descriptor.py b/test/test_descriptor.py index a479c5962..4df2c0899 100755 --- a/test/test_descriptor.py +++ b/test/test_descriptor.py @@ -1,102 +1,209 @@ #! /usr/bin/env python3 -from hwilib.descriptor import Descriptor +from hwilib.descriptor import ( + parse_descriptor, + MultisigDescriptor, + SHDescriptor, + TRDescriptor, + PKHDescriptor, + WPKHDescriptor, + WSHDescriptor, +) + +from binascii import unhexlify + import unittest class TestDescriptor(unittest.TestCase): def test_parse_descriptor_with_origin(self): - desc = Descriptor.parse("wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", True) - self.assertIsNotNone(desc) - self.assertEqual(desc.wpkh, True) - self.assertEqual(desc.sh_wpkh, None) - self.assertEqual(desc.origin_fingerprint, "00000001") - self.assertEqual(desc.origin_path, "/84'/1'/0'") - self.assertEqual(desc.base_key, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.path_suffix, "/0/0") - self.assertEqual(desc.testnet, True) - self.assertEqual(desc.m_path, "m/84'/1'/0'/0/0") + d = "wpkh([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand(0) + self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) + + def test_parse_multisig_descriptor_with_origin(self): + d = "wsh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0))" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WSHDescriptor)) + self.assertTrue(isinstance(desc.subdescriptors[0], MultisigDescriptor)) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + + self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand(0) + self.assertEqual(e.output_script, unhexlify("002084b64b2b8651df8fd3e9735f6269edbf9e03abf619ae0788be9f17bf18e83d59")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, unhexlify("522102c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c721033a4f18d2b498273ed7439c59f6d8a673d5b9c67a03163d530e12c941ca22be3352ae")) + + d = "sh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0))" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, SHDescriptor)) + self.assertTrue(isinstance(desc.subdescriptors[0], MultisigDescriptor)) + self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + + self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") + self.assertEqual(desc.subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand(0) + self.assertEqual(e.output_script, unhexlify("a91495ee6326805b1586bb821fc3c0eeab2c68441b4187")) + self.assertEqual(e.redeem_script, unhexlify("522102c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c721033a4f18d2b498273ed7439c59f6d8a673d5b9c67a03163d530e12c941ca22be3352ae")) + self.assertEqual(e.witness_script, None) + + d = "sh(wsh(multi(2,[00000001/48h/0h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/0h/0h/2h]tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty/0/0)))" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, SHDescriptor)) + self.assertTrue(isinstance(desc.subdescriptors[0], WSHDescriptor)) + self.assertTrue(isinstance(desc.subdescriptors[0].subdescriptors[0], MultisigDescriptor)) + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[0].deriv_path, "/0/0") + + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.fingerprint.hex(), "00000002") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].origin.get_derivation_path(), "m/48h/0h/0h/2h") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].pubkey, "tpubDFHiBJDeNvqPWNJbzzxqDVXmJZoNn2GEtoVcFhMjXipQiorGUmps3e5ieDGbRrBPTFTh9TXEKJCwbAGW9uZnfrVPbMxxbFohuFzfT6VThty") + self.assertEqual(desc.subdescriptors[0].subdescriptors[0].pubkeys[1].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand(0) + self.assertEqual(e.output_script, unhexlify("a914779ae0f6958e98b997cc177f9b554289905fbb5587")) + self.assertEqual(e.redeem_script, unhexlify("002084b64b2b8651df8fd3e9735f6269edbf9e03abf619ae0788be9f17bf18e83d59")) + self.assertEqual(e.witness_script, unhexlify("522102c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c721033a4f18d2b498273ed7439c59f6d8a673d5b9c67a03163d530e12c941ca22be3352ae")) def test_parse_descriptor_without_origin(self): - desc = Descriptor.parse("wpkh(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", True) - self.assertIsNotNone(desc) - self.assertEqual(desc.wpkh, True) - self.assertEqual(desc.sh_wpkh, None) - self.assertEqual(desc.origin_fingerprint, None) - self.assertEqual(desc.origin_path, None) - self.assertEqual(desc.base_key, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") - self.assertEqual(desc.path_suffix, "/0/0") - self.assertEqual(desc.testnet, True) - self.assertEqual(desc.m_path, None) + d = "wpkh(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin, None) + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand(0) + self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) + + def test_parse_descriptor_with_origin_fingerprint_only(self): + d = "wpkh([00000001]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(len(desc.pubkeys[0].origin.path), 0) + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand(0) + self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) def test_parse_descriptor_with_key_at_end_with_origin(self): - desc = Descriptor.parse("wpkh([00000001/84'/1'/0'/0/0]0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)", True) - self.assertIsNotNone(desc) - self.assertEqual(desc.wpkh, True) - self.assertEqual(desc.sh_wpkh, None) - self.assertEqual(desc.origin_fingerprint, "00000001") - self.assertEqual(desc.origin_path, "/84'/1'/0'/0/0") - self.assertEqual(desc.base_key, "0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") - self.assertEqual(desc.path_suffix, None) - self.assertEqual(desc.testnet, True) - self.assertEqual(desc.m_path, "m/84'/1'/0'/0/0") + d = "wpkh([00000001/84h/1h/0h/0/0]02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h/0/0") + self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") + self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand(0) + self.assertEqual(e.output_script, unhexlify("0014d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) + + d = "pkh([00000001/84h/1h/0h/0/0]02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, PKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h/0/0") + self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") + self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.to_string_no_checksum(), d) + e = desc.expand(0) + self.assertEqual(e.output_script, unhexlify("76a914d95fc47eada9e4c3cf59a2cbf9e96517c3ba2efa88ac")) + self.assertEqual(e.redeem_script, None) + self.assertEqual(e.witness_script, None) def test_parse_descriptor_with_key_at_end_without_origin(self): - desc = Descriptor.parse("wpkh(0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)", True) - self.assertIsNotNone(desc) - self.assertEqual(desc.wpkh, True) - self.assertEqual(desc.sh_wpkh, None) - self.assertEqual(desc.origin_fingerprint, None) - self.assertEqual(desc.origin_path, None) - self.assertEqual(desc.base_key, "0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") - self.assertEqual(desc.path_suffix, None) - self.assertEqual(desc.testnet, True) - self.assertEqual(desc.m_path, None) + d = "wpkh(02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, WPKHDescriptor)) + self.assertEqual(desc.pubkeys[0].origin, None) + self.assertEqual(desc.pubkeys[0].pubkey, "02c97dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7") + self.assertEqual(desc.pubkeys[0].deriv_path, None) + self.assertEqual(desc.to_string_no_checksum(), d) def test_parse_empty_descriptor(self): - desc = Descriptor.parse("", True) - self.assertIsNone(desc) + self.assertRaises(ValueError, parse_descriptor, "") def test_parse_descriptor_replace_h(self): - desc = Descriptor.parse("wpkh([00000001/84h/1h/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)", True) + d = "wpkh([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" + desc = parse_descriptor(d) self.assertIsNotNone(desc) - self.assertEqual(desc.origin_path, "/84'/1'/0'") - - def test_serialize_descriptor_with_origin(self): - descriptor = "wpkh([00000001/84'/1'/0']tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)#mz20k55p" - desc = Descriptor.parse(descriptor, True) - self.assertEqual(desc.serialize(), descriptor) - - def test_serialize_descriptor_without_origin(self): - descriptor = "wpkh(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)#ac0p4yhq" - desc = Descriptor.parse(descriptor, True) - self.assertEqual(desc.serialize(), descriptor) - - def test_serialize_descriptor_with_key_at_end_with_origin(self): - descriptor = "wpkh([00000001/84'/1'/0'/0/0]0297dc3f4420402e01a113984311bf4a1b8de376cac0bdcfaf1b3ac81f13433c7)#rh7p6vk2" - desc = Descriptor.parse(descriptor, True) - self.assertEqual(desc.serialize(), descriptor) + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") def test_checksums(self): - with self.subTest(msg='Valid checksum'): - self.assertIsNotNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxfy")) - self.assertIsNotNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5t")) - self.assertIsNotNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))")) - self.assertIsNotNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))")) + with self.subTest(msg="Valid checksum"): + self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#5js07kwj")) + self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#hgmsckna")) + self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))")) + self.assertIsNotNone(parse_descriptor("sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))")) with self.subTest(msg="Empty Checksum"): - self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#")) - self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#")) + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#") + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#") with self.subTest(msg="Too long Checksum"): - self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxfyq")) - self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5tq")) + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#5js07kwjq") + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#hgmscknaq") with self.subTest(msg="Too Short Checksum"): - self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxf")) - self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5")) + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#5js07kw") + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#hgmsckn") with self.subTest(msg="Error in Payload"): - self.assertIsNone(Descriptor.parse("sh(multi(3,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxfy")) - self.assertIsNone(Descriptor.parse("sh(multi(3,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5t")) + self.assertRaises(ValueError, parse_descriptor, "sh(multi(3,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggrsrxf") + self.assertRaises(ValueError, parse_descriptor, "sh(multi(3,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09x5") with self.subTest(msg="Error in Checksum"): - self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#ggssrxfy")) - self.assertIsNone(Descriptor.parse("sh(multi(2,[00000000/111'/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjq09x4t")) + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xprvA1RpRA33e1JQ7ifknakTFpgNXPmW2YvmhqLQYMmrj4xJXXWYpDPS3xz7iAxn8L39njGVyuoseXzU6rcxFLJ8HFsTjSyQbLYnMpCqE2VbFWc,xprv9uPDJpEQgRQfDcW7BkF7eTya6RPxXeJCqCJGHuCJ4GiRVLzkTXBAJMu2qaMWPrS7AANYqdq6vcBcBUdJCVVFceUvJFjaPdGZ2y9WACViL4L/0))#5js07kej") + self.assertRaises(ValueError, parse_descriptor, "sh(multi(2,[00000000/111h/222]xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL,xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y/0))#tjg09y5") + + def test_tr_descriptor(self): + d = "tr([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0)" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, TRDescriptor)) + self.assertEqual(len(desc.pubkeys), 1) + self.assertEqual(len(desc.subdescriptors), 0) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.to_string_no_checksum(), d) + + d = "tr([00000001/84h/1h/0h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,{pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B),{{pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B),pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B)},pk(tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B)}})" + desc = parse_descriptor(d) + self.assertTrue(isinstance(desc, TRDescriptor)) + self.assertEqual(len(desc.subdescriptors), 4) + self.assertEqual(desc.pubkeys[0].origin.fingerprint.hex(), "00000001") + self.assertEqual(desc.pubkeys[0].origin.get_derivation_path(), "m/84h/1h/0h") + self.assertEqual(desc.pubkeys[0].pubkey, "tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B") + self.assertEqual(desc.pubkeys[0].deriv_path, "/0/0") + self.assertEqual(desc.depths, [1, 3, 3, 2]) + self.assertEqual(desc.to_string_no_checksum(), d) if __name__ == "__main__": unittest.main() diff --git a/test/test_device.py b/test/test_device.py index bfb408711..8f64f6f71 100644 --- a/test/test_device.py +++ b/test/test_device.py @@ -5,81 +5,150 @@ import os import shlex import shutil +import socket import subprocess import tempfile import time import unittest +from typing import Dict + from authproxy import AuthServiceProxy, JSONRPCException -from hwilib.base58 import xpub_to_pub_hex -from hwilib.cli import process_commands -from hwilib.serializations import PSBT +from hwilib._base58 import xpub_to_pub_hex, to_address, decode +from hwilib._cli import process_commands +from hwilib.descriptor import AddChecksum, parse_descriptor, PubkeyProvider +from hwilib.key import ExtendedKey, KeyOriginInfo +from hwilib.psbt import PSBT # Class for emulator control class DeviceEmulator(): + def __init__(self): + self.type = None + self.path = None + self.fingerprint = None + self.master_xpub = None + self.password = None + self.supports_ms_display = None + self.supports_xpub_ms_display = None + self.supports_unsorted_ms = None + self.supports_taproot = None + self.strict_bip48 = None + self.include_xpubs = None + self.supports_device_multiple_multisig = None + def start(self): - pass + assert self.type is not None + assert self.path is not None + assert self.fingerprint is not None + assert self.master_xpub is not None + assert self.password is not None + assert self.supports_ms_display is not None + assert self.supports_xpub_ms_display is not None + assert self.supports_unsorted_ms is not None + assert self.strict_bip48 is not None + assert self.include_xpubs is not None + assert self.supports_device_multiple_multisig is not None def stop(self): pass -def start_bitcoind(bitcoind_path): - datadir = tempfile.mkdtemp() - bitcoind_proc = subprocess.Popen([bitcoind_path, '-regtest', '-datadir=' + datadir, '-noprinttoconsole', '-fallbackfee=0.0002']) - - def cleanup_bitcoind(): - bitcoind_proc.kill() - shutil.rmtree(datadir) - atexit.register(cleanup_bitcoind) - # Wait for cookie file to be created - while not os.path.exists(datadir + '/regtest/.cookie'): - time.sleep(0.5) - # Read .cookie file to get user and pass - with open(datadir + '/regtest/.cookie') as f: - userpass = f.readline().lstrip().rstrip() - rpc = AuthServiceProxy('http://{}@127.0.0.1:18443'.format(userpass)) - - # Wait for bitcoind to be ready - ready = False - while not ready: - try: - rpc.getblockchaininfo() - ready = True - except JSONRPCException: +# Class for bitcoind control and RPC +class Bitcoind(): + def __init__(self, bitcoind_path): + self.bitcoind_path = bitcoind_path + self.datadir = tempfile.mkdtemp() + self.rpc = None + self.bitcoind_proc = None + self.userpass = None + + def start(self): + + def get_free_port(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("", 0)) + s.listen(1) + port = s.getsockname()[1] + s.close() + return port + + self.p2p_port = get_free_port() + self.rpc_port = get_free_port() + + self.bitcoind_proc = subprocess.Popen( + [ + self.bitcoind_path, + "-regtest", + f"-datadir={self.datadir}", + "-noprinttoconsole", + "-fallbackfee=0.0002", + "-keypool=1", + f"-port={self.p2p_port}", + f"-rpcport={self.rpc_port}" + ] + ) + + atexit.register(self.cleanup) + + # Wait for cookie file to be created + cookie_path = os.path.join(self.datadir, "regtest", ".cookie") + while not os.path.exists(cookie_path): time.sleep(0.5) - pass + # Read .cookie file to get user and pass + with open(cookie_path) as f: + self.userpass = f.readline().lstrip().rstrip() + self.rpc_url = f"http://{self.userpass}@127.0.0.1:{self.rpc_port}" + self.rpc = AuthServiceProxy(self.rpc_url) + + # Wait for bitcoind to be ready + ready = False + while not ready: + try: + self.rpc.getblockchaininfo() + ready = True + except JSONRPCException: + time.sleep(0.5) + pass + + # Make sure there are blocks and coins available + self.rpc.createwallet(wallet_name="supply") + self.wrpc = self.get_wallet_rpc("supply") + self.wrpc.generatetoaddress(101, self.wrpc.getnewaddress()) - # Make sure there are blocks and coins available - rpc.generatetoaddress(101, rpc.getnewaddress()) - return (rpc, userpass) + def get_wallet_rpc(self, wallet): + url = self.rpc_url + f"/wallet/{wallet}" + return AuthServiceProxy(url) + + def cleanup(self): + if self.bitcoind_proc is not None and self.bitcoind_proc.poll() is None: + self.bitcoind_proc.kill() + shutil.rmtree(self.datadir) + + @staticmethod + def create(*args, **kwargs): + c = Bitcoind(*args, **kwargs) + c.start() + return c class DeviceTestCase(unittest.TestCase): - def __init__(self, rpc, rpc_userpass, type, full_type, path, fingerprint, master_xpub, password='', emulator=None, interface='library', methodName='runTest'): + def __init__(self, bitcoind, emulator=None, interface='library', methodName='runTest'): super(DeviceTestCase, self).__init__(methodName) - self.rpc = rpc - self.rpc_userpass = rpc_userpass - self.type = type - self.full_type = full_type - self.path = path - self.fingerprint = fingerprint - self.master_xpub = master_xpub - self.password = password - self.dev_args = ['-t', self.type, '-d', self.path] - if emulator: - self.emulator = emulator - else: - self.emulator = DeviceEmulator() - if password: - self.dev_args.extend(['-p', password]) + self.bitcoind = bitcoind + self.rpc = bitcoind.rpc + self.emulator = emulator + + self.dev_args = ['-t', self.emulator.type, '-d', self.emulator.path, '--chain', 'test'] + if self.emulator.password is not None: + self.dev_args.extend(['-p', self.emulator.password]) + self.interface = interface @staticmethod - def parameterize(testclass, rpc, rpc_userpass, type, full_type, path, fingerprint, master_xpub, password='', interface='library', emulator=None): + def parameterize(testclass, bitcoind, emulator, interface='library', *args, **kwargs): testloader = unittest.TestLoader() testnames = testloader.getTestCaseNames(testclass) suite = unittest.TestSuite() for name in testnames: - suite.addTest(testclass(rpc, rpc_userpass, type, full_type, path, fingerprint, master_xpub, password, emulator, interface, name)) + suite.addTest(testclass(bitcoind, emulator, interface, name, *args, **kwargs)) return suite def do_command(self, args): @@ -91,151 +160,145 @@ def do_command(self, args): result = proc.communicate() return json.loads(result[0].decode()) elif self.interface == 'bindist': - proc = subprocess.Popen(['../dist/hwi ' + ' '.join(cli_args)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, shell=True) + proc = subprocess.Popen(['../dist/hwi ' + ' '.join(cli_args)], stdout=subprocess.PIPE, shell=True) result = proc.communicate() return json.loads(result[0].decode()) elif self.interface == 'stdin': + args = [f'"{arg}"' for arg in args] input_str = '\n'.join(args) + '\n' - proc = subprocess.Popen(['hwi', '--stdin'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + proc = subprocess.Popen(['hwi', '--stdin'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) result = proc.communicate(input_str.encode()) return json.loads(result[0].decode()) else: return process_commands(args) def get_password_args(self): - if self.password: - return ['-p', self.password] + if self.emulator.password is not None: + return ['-p', self.emulator.password] return [] def __str__(self): - return '{}: {}'.format(self.full_type, super().__str__()) + return '{}: {}'.format(self.emulator.type, super().__str__()) def __repr__(self): - return '{}: {}'.format(self.full_type, super().__repr__()) + return '{}: {}'.format(self.emulator.type, super().__repr__()) + + def setup_wallets(self): + wallet_name = '{}_{}_test'.format(self.emulator.type, self.id()) + self.rpc.createwallet(wallet_name=wallet_name, disable_private_keys=True, descriptors=True) + self.wrpc = self.bitcoind.get_wallet_rpc(wallet_name) + self.wpk_rpc = self.bitcoind.get_wallet_rpc("supply") -class TestDeviceConnect(DeviceTestCase): def setUp(self): self.emulator.start() def tearDown(self): self.emulator.stop() +class TestDeviceConnect(DeviceTestCase): + def __init__(self, *args, detect_type, **kwargs): + super(TestDeviceConnect, self).__init__(*args, **kwargs) + self.detect_type = detect_type + def test_enumerate(self): enum_res = self.do_command(self.get_password_args() + ['enumerate']) found = False for device in enum_res: - if (device['type'] == self.type or device['model'] == self.type) and device['path'] == self.path and device['fingerprint'] == self.fingerprint: + if (device['type'] == self.detect_type or device['model'] == self.detect_type) and device['path'] == self.emulator.path and device['fingerprint'] == self.emulator.fingerprint: + self.assertIn('type', device) + self.assertIn('model', device) + self.assertIn('path', device) + self.assertIn('needs_pin_sent', device) + self.assertIn('needs_passphrase_sent', device) self.assertNotIn('error', device) + self.assertNotIn('code', device) found = True self.assertTrue(found) def test_no_type(self): - gmxp_res = self.do_command(['getmasterxpub']) + gmxp_res = self.do_command(["--chain", "test", 'getmasterxpub', "--addr-type", "legacy"]) self.assertIn('error', gmxp_res) self.assertEqual(gmxp_res['error'], 'You must specify a device type or fingerprint for all commands except enumerate') self.assertIn('code', gmxp_res) self.assertEqual(gmxp_res['code'], -1) def test_path_type(self): - gmxp_res = self.do_command(self.get_password_args() + ['-t', self.type, '-d', self.path, 'getmasterxpub']) - self.assertEqual(gmxp_res['xpub'], self.master_xpub) + gmxp_res = self.do_command(self.get_password_args() + ['-t', self.detect_type, '-d', self.emulator.path, "--chain", "test", 'getmasterxpub', "--addr-type", "legacy"]) + self.assertEqual(gmxp_res['xpub'], self.emulator.master_xpub) def test_fingerprint_autodetect(self): - gmxp_res = self.do_command(self.get_password_args() + ['-f', self.fingerprint, 'getmasterxpub']) - self.assertEqual(gmxp_res['xpub'], self.master_xpub) + gmxp_res = self.do_command(self.get_password_args() + ['-f', self.emulator.fingerprint, "--chain", "test", 'getmasterxpub', "--addr-type", "legacy"]) + self.assertEqual(gmxp_res['xpub'], self.emulator.master_xpub) # Nonexistent fingerprint - gmxp_res = self.do_command(self.get_password_args() + ['-f', '0000ffff', 'getmasterxpub']) + gmxp_res = self.do_command(self.get_password_args() + ['-f', '0000ffff', "--chain", "test", 'getmasterxpub', "--addr-type", "legacy"]) self.assertEqual(gmxp_res['error'], 'Could not find device with specified fingerprint') self.assertEqual(gmxp_res['code'], -3) def test_type_only_autodetect(self): - gmxp_res = self.do_command(self.get_password_args() + ['-t', self.type, 'getmasterxpub']) - self.assertEqual(gmxp_res['xpub'], self.master_xpub) + gmxp_res = self.do_command(self.get_password_args() + ['-t', self.detect_type, "--chain", "test", 'getmasterxpub', "--addr-type", "legacy"]) + self.assertEqual(gmxp_res['xpub'], self.emulator.master_xpub) # Unknown device type - gmxp_res = self.do_command(['-t', 'fakedev', '-d', 'fakepath', 'getmasterxpub']) + gmxp_res = self.do_command(['-t', 'fakedev', '-d', 'fakepath', "--chain", "test", 'getmasterxpub', "--addr-type", "legacy"]) self.assertEqual(gmxp_res['error'], 'Unknown device type specified') self.assertEqual(gmxp_res['code'], -4) class TestGetKeypool(DeviceTestCase): def setUp(self): - self.rpc = AuthServiceProxy('http://{}@127.0.0.1:18443'.format(self.rpc_userpass)) - if '{}_test'.format(self.full_type) not in self.rpc.listwallets(): - self.rpc.createwallet('{}_test'.format(self.full_type), True) - self.wrpc = AuthServiceProxy('http://{}@127.0.0.1:18443/wallet/{}_test'.format(self.rpc_userpass, self.full_type)) - self.wpk_rpc = AuthServiceProxy('http://{}@127.0.0.1:18443/wallet/'.format(self.rpc_userpass)) - if '--testnet' not in self.dev_args: - self.dev_args.append('--testnet') - self.emulator.start() - - def tearDown(self): - self.emulator.stop() - - def test_getkeypool_bad_args(self): - result = self.do_command(self.dev_args + ['getkeypool', '--sh_wpkh', '--wpkh', '0', '20']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['code'], -7) + super().setUp() + self.setup_wallets() def test_getkeypool(self): - non_keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--nokeypool', '0', '20']) - import_result = self.wpk_rpc.importmulti(non_keypool_desc) - self.assertTrue(import_result[0]['success']) - - keypool_desc = self.do_command(self.dev_args + ['getkeypool', '0', '20']) - import_result = self.wpk_rpc.importmulti(keypool_desc) - self.assertFalse(import_result[0]['success']) - import_result = self.wrpc.importmulti(keypool_desc) - self.assertTrue(import_result[0]['success']) - for i in range(0, 21): - addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/44'/1'/0'/0/{}".format(i)) - addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/44'/1'/0'/1/{}".format(i)) - - keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--sh_wpkh', '0', '20']) - import_result = self.wrpc.importmulti(keypool_desc) - self.assertTrue(import_result[0]['success']) - for i in range(0, 21): - addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/49'/1'/0'/0/{}".format(i)) - addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/49'/1'/0'/1/{}".format(i)) - - keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--wpkh', '0', '20']) - import_result = self.wrpc.importmulti(keypool_desc) - self.assertTrue(import_result[0]['success']) - for i in range(0, 21): - addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/84'/1'/0'/0/{}".format(i)) - addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/84'/1'/0'/1/{}".format(i)) - - keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--sh_wpkh', '--account', '3', '0', '20']) - import_result = self.wrpc.importmulti(keypool_desc) + getkeypool_args = [ + ("legacy", 44, "legacy"), + ("wit", 84, "bech32"), + ("sh_wit", 49, "p2sh-segwit"), + ] + if self.emulator.supports_taproot: + getkeypool_args.append(("tap", 86, "bech32m")) + + descs = [] + for arg in getkeypool_args: + with self.subTest(addrtype=arg[0]): + desc = self.do_command(self.dev_args + ["getkeypool", "--addr-type", arg[0], "0", "20"]) + import_result = self.wrpc.importdescriptors(desc) + self.assertTrue(import_result[0]["success"]) + for _ in range(0, 21): + addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress("", arg[2])) + self.assertTrue(addr_info["hdkeypath"].startswith(f"m/{arg[1]}'/1'/0'/0/")) + addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress(arg[2])) + self.assertTrue(addr_info["hdkeypath"].startswith(f"m/{arg[1]}'/1'/0'/1/")) + descs.extend(desc) + + # Test that `--all` option gives the "concatenation" of previous four calls + all_keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--all', '0', '20']) + self.assertEqual(all_keypool_desc, descs) + + keypool_desc = self.do_command(self.dev_args + ['getkeypool', "--addr-type", "sh_wit", '--account', '3', '0', '20']) + import_result = self.wrpc.importdescriptors(keypool_desc) self.assertTrue(import_result[0]['success']) - for i in range(0, 21): - addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/49'/1'/3'/0/{}".format(i)) - addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/49'/1'/3'/1/{}".format(i)) - keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--wpkh', '--account', '3', '0', '20']) - import_result = self.wrpc.importmulti(keypool_desc) + for _ in range(0, 21): + addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress('', 'p2sh-segwit')) + self.assertTrue(addr_info['hdkeypath'].startswith("m/49'/1'/3'/0/")) + addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress('p2sh-segwit')) + self.assertTrue(addr_info['hdkeypath'].startswith("m/49'/1'/3'/1/")) + keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--account', '3', '0', '20']) + import_result = self.wrpc.importdescriptors(keypool_desc) self.assertTrue(import_result[0]['success']) - for i in range(0, 21): - addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/84'/1'/3'/0/{}".format(i)) - addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/84'/1'/3'/1/{}".format(i)) + for _ in range(0, 21): + addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress('', 'bech32')) + self.assertTrue(addr_info['hdkeypath'].startswith("m/84'/1'/3'/0/")) + addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress('bech32')) + self.assertTrue(addr_info['hdkeypath'].startswith("m/84'/1'/3'/1/")) keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--path', 'm/0h/0h/4h/*', '0', '20']) - import_result = self.wrpc.importmulti(keypool_desc) + import_result = self.wrpc.importdescriptors(keypool_desc) self.assertTrue(import_result[0]['success']) - for i in range(0, 21): - addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress()) - self.assertEqual(addr_info['hdkeypath'], "m/0'/0'/4'/{}".format(i)) + for _ in range(0, 21): + addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress('', 'bech32')) + self.assertTrue(addr_info['hdkeypath'].startswith("m/0'/0'/4'/")) keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--path', '/0h/0h/4h/*', '0', '20']) self.assertEqual(keypool_desc['error'], 'Path must start with m/') @@ -245,12 +308,6 @@ def test_getkeypool(self): self.assertEqual(keypool_desc['code'], -7) class TestGetDescriptors(DeviceTestCase): - def setUp(self): - self.rpc = AuthServiceProxy('http://{}@127.0.0.1:18443'.format(self.rpc_userpass)) - if '--testnet' not in self.dev_args: - self.dev_args.append('--testnet') - self.emulator.start() - def tearDown(self): self.emulator.stop() @@ -259,38 +316,42 @@ def test_getdescriptors(self): self.assertIn('receive', descriptors) self.assertIn('internal', descriptors) - self.assertEqual(len(descriptors['receive']), 3) - self.assertEqual(len(descriptors['internal']), 3) + self.assertEqual(len(descriptors['receive']), 4 if self.emulator.supports_taproot else 3) + self.assertEqual(len(descriptors['internal']), 4 if self.emulator.supports_taproot else 3) for descriptor in descriptors['receive']: + self.assertNotIn("'", descriptor) info_result = self.rpc.getdescriptorinfo(descriptor) self.assertTrue(info_result['isrange']) self.assertTrue(info_result['issolvable']) for descriptor in descriptors['internal']: + self.assertNotIn("'", descriptor) info_result = self.rpc.getdescriptorinfo(descriptor) self.assertTrue(info_result['isrange']) self.assertTrue(info_result['issolvable']) class TestSignTx(DeviceTestCase): - def setUp(self): - self.rpc = AuthServiceProxy('http://{}@127.0.0.1:18443'.format(self.rpc_userpass)) - if '{}_test'.format(self.full_type) not in self.rpc.listwallets(): - self.rpc.createwallet('{}_test'.format(self.full_type), True) - self.wrpc = AuthServiceProxy('http://{}@127.0.0.1:18443/wallet/{}_test'.format(self.rpc_userpass, self.full_type)) - self.wpk_rpc = AuthServiceProxy('http://{}@127.0.0.1:18443/wallet/'.format(self.rpc_userpass)) - if '--testnet' not in self.dev_args: - self.dev_args.append('--testnet') - self.emulator.start() + def __init__(self, *args, signtx_cases, **kwargs): + super(TestSignTx, self).__init__(*args, **kwargs) + self.signtx_cases = signtx_cases - def tearDown(self): - self.emulator.stop() + def setUp(self): + super().setUp() + self.setup_wallets() def _generate_and_finalize(self, unknown_inputs, psbt): + if not self.emulator.supports_device_multiple_multisig: + # We will need Core to sign so that the multisig is complete + core_sign_res = self.wpk_rpc.walletprocesspsbt(psbt=psbt, finalize=False) + psbt = core_sign_res["psbt"] + if not unknown_inputs: # Just do the normal signing process to test "all inputs" case - sign_res = self.do_command(self.dev_args + ['signtx', psbt['psbt']]) + sign_res = self.do_command(self.dev_args + ['signtx', psbt]) finalize_res = self.wrpc.finalizepsbt(sign_res['psbt']) + self.assertTrue(sign_res["signed"]) + self.assertTrue(finalize_res["complete"]) else: # Sign only input one on first pass # then rest on second pass to test ability to successfully @@ -298,17 +359,21 @@ def _generate_and_finalize(self, unknown_inputs, psbt): # signing passes to ensure they are actually properly being # partially signed at each step. first_psbt = PSBT() - first_psbt.deserialize(psbt['psbt']) + first_psbt.deserialize(psbt) second_psbt = PSBT() - second_psbt.deserialize(psbt['psbt']) + second_psbt.deserialize(psbt) # Blank master fingerprint to make hww fail to sign # Single input PSBTs will be fully signed by first signer for psbt_input in first_psbt.inputs[1:]: for pubkey, path in psbt_input.hd_keypaths.items(): - psbt_input.hd_keypaths[pubkey] = (0,) + path[1:] + psbt_input.hd_keypaths[pubkey] = KeyOriginInfo(b"\x00\x00\x00\x01", path.path) + for pubkey, (leaves, origin) in psbt_input.tap_bip32_paths.items(): + psbt_input.tap_bip32_paths[pubkey] = (leaves, KeyOriginInfo(b"\x00\x00\x00\x01", origin.path)) for pubkey, path in second_psbt.inputs[0].hd_keypaths.items(): - second_psbt.inputs[0].hd_keypaths[pubkey] = (0,) + path[1:] + second_psbt.inputs[0].hd_keypaths[pubkey] = KeyOriginInfo(b"\x00\x00\x00\x01", path.path) + for pubkey, (leaves, origin) in second_psbt.inputs[0].tap_bip32_paths.items(): + second_psbt.inputs[0].tap_bip32_paths[pubkey] = (leaves, KeyOriginInfo(b"\x00\x00\x00\x01", origin.path)) single_input = len(first_psbt.inputs) == 1 @@ -318,11 +383,16 @@ def _generate_and_finalize(self, unknown_inputs, psbt): # First will always have something to sign first_sign_res = self.do_command(self.dev_args + ['signtx', first_psbt]) + self.assertTrue(first_sign_res["signed"]) self.assertTrue(single_input == self.wrpc.finalizepsbt(first_sign_res['psbt'])['complete']) # Second may have nothing to sign (1 input case) # and also may throw an error(e.g., ColdCard) second_sign_res = self.do_command(self.dev_args + ['signtx', second_psbt]) if 'psbt' in second_sign_res: + if single_input: + self.assertFalse(second_sign_res["signed"]) + else: + self.assertTrue(second_sign_res["signed"]) self.assertTrue(not self.wrpc.finalizepsbt(second_sign_res['psbt'])['complete']) combined_psbt = self.wrpc.combinepsbt([first_sign_res['psbt'], second_sign_res['psbt']]) @@ -335,81 +405,158 @@ def _generate_and_finalize(self, unknown_inputs, psbt): self.assertTrue(self.wrpc.testmempoolaccept([finalize_res['hex']])[0]["allowed"]) return finalize_res['hex'] - def _test_signtx(self, input_type, multisig): + def _make_multisig(self, addrtype): + if addrtype == "legacy": + coin_type = 0 + desc_prefix = "sh(" + desc_suffix = ")" + elif addrtype == "p2sh-segwit": + coin_type = 1 if self.emulator.strict_bip48 else 0 + desc_prefix = "sh(wsh(" + desc_suffix = "))" + elif addrtype == "bech32": + coin_type = 2 if self.emulator.strict_bip48 else 0 + desc_prefix = "wsh(" + desc_suffix = ")" + else: + self.fail(f"Unknown address type {addrtype}") + + desc_pubkeys = [] + xpubs: Dict[bytes, KeyOriginInfo] = {} + for account in range(0, 3 if self.emulator.supports_device_multiple_multisig else 1): + path = f"/48h/1h/{account}h/{coin_type}h" + origin = '{}{}'.format(self.emulator.fingerprint, path) + xpub = self.do_command(self.dev_args + ["getxpub", "m{}".format(path)]) + desc_pubkeys.append("[{}]{}/0/0".format(origin, xpub["xpub"])) + if self.emulator.include_xpubs: + extkey = ExtendedKey.deserialize(xpub["xpub"]) + xpubs[extkey.serialize()] = KeyOriginInfo.from_string(origin) + + if not self.emulator.supports_device_multiple_multisig: + # If the device does not support itself in the multisig more than once, + # we need to fetch a key from Core, and use another key that will not be signed with + counter_descs = self.wpk_rpc.listdescriptors()["descriptors"] + desc = parse_descriptor(counter_descs[0]["desc"]) + pubkey_prov = None + while pubkey_prov is None: + if len(desc.pubkeys) > 0: + pubkey_prov = desc.pubkeys[0] + else: + desc = desc.subdescriptors[0] + assert pubkey_prov.extkey is not None + assert pubkey_prov.origin is not None + pubkey_prov.deriv_path = "/0/0" + desc_pubkeys.append(pubkey_prov.to_string()) + if self.emulator.include_xpubs: + xpubs[pubkey_prov.extkey.serialize()] = pubkey_prov.origin + + # A fixed key + fixed_extkey = ExtendedKey.deserialize("tpubDCBWBScQPGv4Xk3JSbhw6wYYpayMjb2eAYyArpbSqQTbLDpphHGAetB6VQgVeftLML8vDSUEWcC2xDi3qJJ3YCDChJDvqVzpgoYSuT52MhJ") + fixed_origin = KeyOriginInfo(b"\xde\xad\xbe\xef", [0x80000000]) + desc_pubkeys.append(PubkeyProvider(fixed_origin, fixed_extkey.to_string(), "/0/0").to_string()) + if self.emulator.include_xpubs: + xpubs[fixed_extkey.serialize()] = fixed_origin + + desc = AddChecksum(f"{desc_prefix}sortedmulti(2,{desc_pubkeys[0]},{desc_pubkeys[1]},{desc_pubkeys[2]}){desc_suffix}") + + return desc, self.rpc.deriveaddresses(desc)[0], xpubs + + def _test_signtx(self, input_types, multisig_types, external, op_return: bool): # Import some keys to the watch only wallet and send coins to them - keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--sh_wpkh', '30', '40']) - import_result = self.wrpc.importmulti(keypool_desc) - self.assertTrue(import_result[0]['success']) - keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--sh_wpkh', '--internal', '30', '40']) - import_result = self.wrpc.importmulti(keypool_desc) + keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--all', '30', '50']) + import_result = self.wrpc.importdescriptors(keypool_desc) self.assertTrue(import_result[0]['success']) sh_wpkh_addr = self.wrpc.getnewaddress('', 'p2sh-segwit') wpkh_addr = self.wrpc.getnewaddress('', 'bech32') pkh_addr = self.wrpc.getnewaddress('', 'legacy') - self.wrpc.importaddress(wpkh_addr) - self.wrpc.importaddress(pkh_addr) - - # pubkeys to construct 2-of-3 multisig descriptors for import - sh_wpkh_info = self.wrpc.getaddressinfo(sh_wpkh_addr) - wpkh_info = self.wrpc.getaddressinfo(wpkh_addr) - pkh_info = self.wrpc.getaddressinfo(pkh_addr) - - # Get origin info/key pair so wallet doesn't forget how to - # sign with keys post-import - pubkeys = [sh_wpkh_info['desc'][8:-11], - wpkh_info['desc'][5:-10], - pkh_info['desc'][4:-10]] - - # Get the descriptors with their checksums - sh_multi_desc = self.wrpc.getdescriptorinfo('sh(multi(2,' + pubkeys[0] + ',' + pubkeys[1] + ',' + pubkeys[2] + '))')['descriptor'] - sh_wsh_multi_desc = self.wrpc.getdescriptorinfo('sh(wsh(multi(2,' + pubkeys[0] + ',' + pubkeys[1] + ',' + pubkeys[2] + ')))')['descriptor'] - wsh_multi_desc = self.wrpc.getdescriptorinfo('wsh(multi(2,' + pubkeys[2] + ',' + pubkeys[1] + ',' + pubkeys[0] + '))')['descriptor'] - - sh_multi_import = {'desc': sh_multi_desc, "timestamp": "now", "label": "shmulti"} - sh_wsh_multi_import = {'desc': sh_wsh_multi_desc, "timestamp": "now", "label": "shwshmulti"} - # re-order pubkeys to allow import without "already have private keys" error - wsh_multi_import = {'desc': wsh_multi_desc, "timestamp": "now", "label": "wshmulti"} - multi_result = self.wrpc.importmulti([sh_multi_import, sh_wsh_multi_import, wsh_multi_import]) - self.assertTrue(multi_result[0]['success']) - self.assertTrue(multi_result[1]['success']) - self.assertTrue(multi_result[2]['success']) - - sh_multi_addr = self.wrpc.getaddressesbylabel("shmulti").popitem()[0] - sh_wsh_multi_addr = self.wrpc.getaddressesbylabel("shwshmulti").popitem()[0] - wsh_multi_addr = self.wrpc.getaddressesbylabel("wshmulti").popitem()[0] - - in_amt = 3 - out_amt = in_amt // 3 + tr_addr = None + if "tap" in input_types: + tr_addr = self.wrpc.getnewaddress("", "bech32m") + + in_amt = 1 number_inputs = 0 # Single-sig - if input_type == 'segwit' or input_type == 'all': + if "segwit" in input_types: self.wpk_rpc.sendtoaddress(sh_wpkh_addr, in_amt) self.wpk_rpc.sendtoaddress(wpkh_addr, in_amt) number_inputs += 2 - if input_type == 'legacy' or input_type == 'all': + if "legacy" in input_types: self.wpk_rpc.sendtoaddress(pkh_addr, in_amt) number_inputs += 1 + if "tap" in input_types: + assert tr_addr is not None + self.wpk_rpc.sendtoaddress(tr_addr, in_amt) + number_inputs += 1 # Now do segwit/legacy multisig - if multisig: - if input_type == 'legacy' or input_type == 'all': - self.wpk_rpc.sendtoaddress(sh_multi_addr, in_amt) - number_inputs += 1 - if input_type == 'segwit' or input_type == 'all': - self.wpk_rpc.sendtoaddress(wsh_multi_addr, in_amt) - self.wpk_rpc.sendtoaddress(sh_wsh_multi_addr, in_amt) - number_inputs += 2 + xpubs: Dict[bytes, KeyOriginInfo] = {} + if "legacy" in multisig_types: + sh_multi_desc, sh_multi_addr, sh_multi_xpubs = self._make_multisig("legacy") + + xpubs.update(sh_multi_xpubs) + + sh_multi_import = {'desc': sh_multi_desc, "timestamp": "now", "label": "shmulti"} + multi_result = self.wrpc.importdescriptors([sh_multi_import]) + self.assertTrue(multi_result[0]['success']) + + self.wpk_rpc.sendtoaddress(sh_multi_addr, in_amt) + number_inputs += 1 + if "segwit" in multisig_types: + sh_wsh_multi_desc, sh_wsh_multi_addr, sh_wsh_xpubs = self._make_multisig("p2sh-segwit") + wsh_multi_desc, wsh_multi_addr, wsh_xpubs = self._make_multisig("bech32") + + xpubs.update(sh_wsh_xpubs) + xpubs.update(wsh_xpubs) + + sh_wsh_multi_import = {'desc': sh_wsh_multi_desc, "timestamp": "now", "label": "shwshmulti"} + wsh_multi_import = {'desc': wsh_multi_desc, "timestamp": "now", "label": "wshmulti"} + + multi_result = self.wrpc.importdescriptors([sh_wsh_multi_import, wsh_multi_import]) + self.assertTrue(multi_result[0]['success']) + self.assertTrue(multi_result[1]['success']) + + self.wpk_rpc.sendtoaddress(wsh_multi_addr, in_amt) + self.wpk_rpc.sendtoaddress(sh_wsh_multi_addr, in_amt) + number_inputs += 2 self.wpk_rpc.generatetoaddress(6, self.wpk_rpc.getnewaddress()) - # Spend different amounts, requiring 1 to 3 inputs - for i in range(number_inputs): + # Spend different amounts, with increasing number of inputs until the wallet is swept + utxos = self.wrpc.listunspent() + for i in range(1, number_inputs + 1): # Create a psbt spending the above - if i == number_inputs - 1: - self.assertTrue((i + 1) * in_amt == self.wrpc.getbalance("*", 0, True)) - psbt = self.wrpc.walletcreatefundedpsbt([], [{self.wpk_rpc.getnewaddress('', 'legacy'): (i + 1) * out_amt}, {self.wpk_rpc.getnewaddress('', 'p2sh-segwit'): (i + 1) * out_amt}, {self.wpk_rpc.getnewaddress('', 'bech32'): (i + 1) * out_amt}], 0, {'includeWatching': True, 'subtractFeeFromOutputs': [0, 1, 2]}, True) - - # Sign with unknown inputs in two steps - self._generate_and_finalize(True, psbt) + change_addr = self.wpk_rpc.getrawchangeaddress() + + out_val = i / 4 + outputs = [ + {self.wpk_rpc.getnewaddress('', 'legacy'): out_val}, + {self.wpk_rpc.getnewaddress('', 'p2sh-segwit'): out_val}, + {self.wpk_rpc.getnewaddress('', 'bech32'): out_val} + ] + if self.emulator.supports_taproot: + outputs.append({self.wpk_rpc.getnewaddress("", "bech32m"): out_val}) + if op_return: + outputs.append({"data": "000102030405060708090a0b0c0d0e0f10111213141516171819101a1b1c1d1e1f"}) + psbt = self.wrpc.walletcreatefundedpsbt( + utxos[:i], + outputs, + 0, + { + "includeWatching": True, + "changeAddress": change_addr, + "subtractFeeFromOutputs": [0, 1, 2], + }, + True + )["psbt"] + + # We need to modify the psbt to include our xpubs as Core does not include xpubs + psbt_obj = PSBT() + psbt_obj.deserialize(psbt) + psbt_obj.xpub = xpubs + psbt = psbt_obj.serialize() + + if external: + # Sign with unknown inputs in two steps + self._generate_and_finalize(True, psbt) # Sign all inputs all at once final_tx = self._generate_and_finalize(False, psbt) @@ -418,36 +565,34 @@ def _test_signtx(self, input_type, multisig): # Test wrapper to avoid mixed-inputs signing for Ledger def test_signtx(self): - supports_mixed = {'coldcard', 'trezor_1', 'digitalbitbox', 'keepkey'} - supports_multisig = {'ledger', 'trezor_1', 'digitalbitbox', 'keepkey'} - if self.full_type not in supports_mixed: - self._test_signtx("legacy", self.full_type in supports_multisig) - self._test_signtx("segwit", self.full_type in supports_multisig) - else: - self._test_signtx("all", self.full_type in supports_multisig) + + for addrtypes, multisig_types, external, op_return in self.signtx_cases: + with self.subTest(addrtypes=addrtypes, multisig_types=multisig_types, external=external, op_return=op_return): + self._test_signtx(addrtypes, multisig_types, external, op_return) # Make a huge transaction which might cause some problems with different interfaces def test_big_tx(self): - # make a huge transaction that is unrelated to the hardware wallet + # make a huge transaction + keypool_desc = self.do_command(self.dev_args + ["getkeypool", "--account", "10", "--addr-type", "legacy", "0", "100"]) + import_result = self.wrpc.importdescriptors(keypool_desc) + self.assertTrue(import_result[0]['success']) outputs = [] num_inputs = 60 for i in range(0, num_inputs): - outputs.append({self.wpk_rpc.getnewaddress('', 'legacy'): 0.001}) + outputs.append({self.wrpc.getnewaddress('', 'legacy'): 0.001}) + outputs.append({self.wrpc.getnewaddress("", "legacy"): 10}) psbt = self.wpk_rpc.walletcreatefundedpsbt([], outputs, 0, {}, True)['psbt'] psbt = self.wpk_rpc.walletprocesspsbt(psbt)['psbt'] tx = self.wpk_rpc.finalizepsbt(psbt)['hex'] - txid = self.wpk_rpc.sendrawtransaction(tx) - inputs = [] - for i in range(0, num_inputs): - inputs.append({'txid': txid, 'vout': i}) - psbt = self.wpk_rpc.walletcreatefundedpsbt(inputs, [{self.wpk_rpc.getnewaddress('', 'legacy'): 0.001 * num_inputs}], 0, {'subtractFeeFromOutputs': [0]}, True)['psbt'] + self.wpk_rpc.sendrawtransaction(tx) + self.wpk_rpc.generatetoaddress(10, self.wpk_rpc.getnewaddress()) + inputs = self.wrpc.listunspent() + psbt = self.wrpc.walletcreatefundedpsbt(inputs, [{self.wpk_rpc.getnewaddress('', 'legacy'): 0.001 * num_inputs}])['psbt'] # For cli, this should throw an exception try: result = self.do_command(self.dev_args + ['signtx', psbt]) if self.interface == 'cli': self.fail('Big tx did not cause CLI to error') - if self.type == 'coldcard': - self.assertEqual(result['code'], -7) else: self.assertNotIn('code', result) self.assertNotIn('error', result) @@ -456,30 +601,18 @@ def test_big_tx(self): pass class TestDisplayAddress(DeviceTestCase): - def setUp(self): - self.emulator.start() - - def tearDown(self): - self.emulator.stop() - - def test_display_address_bad_args(self): - result = self.do_command(self.dev_args + ['displayaddress', '--sh_wpkh', '--wpkh', '--path', 'm/49h/1h/0h/0/0']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['code'], -7) - def test_display_address_path(self): - result = self.do_command(self.dev_args + ['displayaddress', '--path', 'm/44h/1h/0h/0/0']) + result = self.do_command(self.dev_args + ['displayaddress', "--addr-type", "legacy", '--path', 'm/44h/1h/0h/0/0']) self.assertNotIn('error', result) self.assertNotIn('code', result) self.assertIn('address', result) - result = self.do_command(self.dev_args + ['displayaddress', '--sh_wpkh', '--path', 'm/49h/1h/0h/0/0']) + result = self.do_command(self.dev_args + ['displayaddress', "--addr-type", "sh_wit", '--path', 'm/49h/1h/0h/0/0']) self.assertNotIn('error', result) self.assertNotIn('code', result) self.assertIn('address', result) - result = self.do_command(self.dev_args + ['displayaddress', '--wpkh', '--path', 'm/84h/1h/0h/0/0']) + result = self.do_command(self.dev_args + ['displayaddress', "--addr-type", "wit", '--path', 'm/84h/1h/0h/0/0']) self.assertNotIn('error', result) self.assertNotIn('code', result) self.assertIn('address', result) @@ -494,37 +627,37 @@ def test_display_address_descriptor(self): legacy_account_xpub = self.do_command(self.dev_args + ['getxpub', 'm/44h/1h/0h'])['xpub'] # Native SegWit address using xpub: - result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.fingerprint + '/84h/1h/0h]' + account_xpub + '/0/0)']) + result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.emulator.fingerprint + '/84h/1h/0h]' + account_xpub + '/0/0)']) self.assertNotIn('error', result) self.assertNotIn('code', result) self.assertIn('address', result) # Native SegWit address using hex encoded pubkey: - result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.fingerprint + '/84h/1h/0h]' + xpub_to_pub_hex(account_xpub) + '/0/0)']) + result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.emulator.fingerprint + '/84h/1h/0h]' + xpub_to_pub_hex(account_xpub) + '/0/0)']) self.assertNotIn('error', result) self.assertNotIn('code', result) self.assertIn('address', result) # P2SH wrapped SegWit address using xpub: - result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'sh(wpkh([' + self.fingerprint + '/49h/1h/0h]' + p2sh_segwit_account_xpub + '/0/0))']) + result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'sh(wpkh([' + self.emulator.fingerprint + '/49h/1h/0h]' + p2sh_segwit_account_xpub + '/0/0))']) self.assertNotIn('error', result) self.assertNotIn('code', result) self.assertIn('address', result) # Legacy address - result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'pkh([' + self.fingerprint + '/44h/1h/0h]' + legacy_account_xpub + '/0/0)']) + result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'pkh([' + self.emulator.fingerprint + '/44h/1h/0h]' + legacy_account_xpub + '/0/0)']) self.assertNotIn('error', result) self.assertNotIn('code', result) self.assertIn('address', result) # Should check xpub - result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.fingerprint + '/84h/1h/0h]' + "not_and_xpub" + '/0/0)']) + result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.emulator.fingerprint + '/84h/1h/0h]' + "not_and_xpub" + '/0/0)']) self.assertIn('error', result) self.assertIn('code', result) self.assertEqual(result['code'], -7) # Should check hex pub - result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.fingerprint + '/84h/1h/0h]' + "not_and_xpub" + '/0/0)']) + result = self.do_command(self.dev_args + ['displayaddress', '--desc', 'wpkh([' + self.emulator.fingerprint + '/84h/1h/0h]' + "not_and_xpub" + '/0/0)']) self.assertIn('error', result) self.assertIn('code', result) self.assertEqual(result['code'], -7) @@ -535,16 +668,79 @@ def test_display_address_descriptor(self): self.assertIn('code', result) self.assertEqual(result['code'], -7) + def _make_single_multisig(self, addrtype, sort, use_xpub): + desc_pubkeys = [] + for i in range(0, 3): + path = "/48h/1h/{}h/0h/0".format(i) + if not use_xpub: + path += "/0" + origin = '{}{}'.format(self.emulator.fingerprint, path) + xpub = self.do_command(self.dev_args + ["--expert", "getxpub", "m{}".format(path)]) + desc_pubkeys.append("[{}]{}{}".format(origin, xpub["xpub"] if use_xpub else xpub["pubkey"], "/0" if use_xpub else "")) + + desc_func = "sortedmulti" if sort else "multi" + + if addrtype == "pkh": + desc = AddChecksum("sh({}(2,{},{},{}))".format(desc_func, desc_pubkeys[0], desc_pubkeys[1], desc_pubkeys[2])) + addr = self.rpc.deriveaddresses(desc)[0] + elif addrtype == "sh_wpkh": + desc = AddChecksum("sh(wsh({}(2,{},{},{})))".format(desc_func, desc_pubkeys[1], desc_pubkeys[2], desc_pubkeys[0])) + addr = self.rpc.deriveaddresses(desc)[0] + elif addrtype == "wpkh": + desc = AddChecksum("wsh({}(2,{},{},{}))".format(desc_func, desc_pubkeys[2], desc_pubkeys[1], desc_pubkeys[0])) + addr = self.rpc.deriveaddresses(desc)[0] + else: + self.fail("Oops the test is broken") + + return addr, desc + + def test_display_address_multisig(self): + if not self.emulator.supports_ms_display and not self.emulator.supports_xpub_ms_display: + raise unittest.SkipTest("{} does not support multisig display".format(self.emulator.type)) + + for addrtype in ["pkh", "sh_wpkh", "wpkh"]: + for sort in [True, False]: + for derive in [True, False]: + with self.subTest(addrtype=addrtype): + if not sort and not self.emulator.supports_unsorted_ms: + raise unittest.SkipTest("{} does not support unsorted multisigs".format(self.emulator.type)) + if derive and not self.emulator.supports_xpub_ms_display: + raise unittest.SkipTest("{} does not support multisig display with xpubs".format(self.emulator.type)) + + addr, desc = self._make_single_multisig(addrtype, sort, derive) + + args = ['displayaddress', '--desc', desc] + + result = self.do_command(self.dev_args + args) + self.assertNotIn('error', result) + self.assertNotIn('code', result) + self.assertIn('address', result) + + if addrtype == "wpkh": + # removes prefix and checksum since regtest gives + # prefix `bcrt` on Bitcoin Core while wallets return testnet `tb` prefix + self.assertEqual(addr[4:58], result['address'][2:56]) + else: + self.assertEqual(addr, result['address']) + class TestSignMessage(DeviceTestCase): - def setUp(self): - self.emulator.start() + def _check_sign_msg(self, msg): + addr_path = "m/44h/1h/0h/0/0" + sign_res = self.do_command(self.dev_args + ['signmessage', msg, addr_path]) + self.assertNotIn("error", sign_res) + self.assertNotIn("code", sign_res) + self.assertIn("signature", sign_res) + sig = sign_res["signature"] - def tearDown(self): - self.emulator.stop() + addr = self.do_command(self.dev_args + ['displayaddress', "--addr-type", "legacy", '--path', addr_path])["address"] + addr = to_address(decode(addr)[1:-4], b"\x6F") + + self.assertTrue(self.rpc.verifymessage(addr, sig, msg)) def test_sign_msg(self): - self.do_command(self.dev_args + ['signmessage', '"Message signing test"', 'm/44h/1h/0h/0/0']) + self._check_sign_msg("Message signing test") + self._check_sign_msg("285") # Specific test case for Ledger shorter S def test_bad_path(self): - result = self.do_command(self.dev_args + ['signmessage', '"Message signing test"', 'f']) + result = self.do_command(self.dev_args + ['signmessage', "Message signing test", 'f']) self.assertEquals(result['code'], -7) diff --git a/test/test_digitalbitbox.py b/test/test_digitalbitbox.py index 204ffb0af..f0dd9ec3c 100755 --- a/test/test_digitalbitbox.py +++ b/test/test_digitalbitbox.py @@ -5,136 +5,197 @@ import json import os import subprocess +import sys import time import unittest -from test_device import DeviceTestCase, start_bitcoind, TestDeviceConnect, TestGetKeypool, TestGetDescriptors, TestSignTx, TestSignMessage +from test_device import ( + Bitcoind, + DeviceEmulator, + DeviceTestCase, + TestDeviceConnect, + TestGetKeypool, + TestGetDescriptors, + TestSignTx, +) from hwilib.devices.digitalbitbox import BitboxSimulator, send_plain, send_encrypt -def digitalbitbox_test_suite(simulator, rpc, userpass, interface): - # Start the Digital bitbox simulator - simulator_proc = subprocess.Popen(['./' + os.path.basename(simulator), '../../tests/sd_files/'], cwd=os.path.dirname(simulator), stderr=subprocess.DEVNULL) - # Wait for simulator to be up - while True: +class BitBox01Emulator(DeviceEmulator): + def __init__(self, simulator): try: - dev = BitboxSimulator('127.0.0.1', 35345) - reply = send_plain(b'{"password":"0000"}', dev) - if 'error' not in reply: - break - except: + os.unlink('bitbox-emulator.stderr') + except FileNotFoundError: pass - time.sleep(0.5) - # Cleanup - - def cleanup_simulator(): - simulator_proc.kill() - simulator_proc.wait() - atexit.register(cleanup_simulator) - - # Set password and load from backup - send_encrypt(json.dumps({"seed": {"source": "backup", "filename": "test_backup.pdf", "key": "key"}}), '0000', dev) - - # params - type = 'digitalbitbox' - full_type = 'digitalbitbox' - path = 'udp:127.0.0.1:35345' - fingerprint = 'a31b978a' - master_xpub = 'xpub6BsWJiRvbzQJg3J6tgUKmHWYbHJSj41EjAAje6LuDwnYLqLiNSWK4N7rCXwiUmNJTBrKL8AEH3LBzhJdgdxoy4T9aMPLCWAa6eWKGCFjQhq' - - # DigitalBitbox specific management command tests - class TestDBBManCommands(DeviceTestCase): - def test_restore(self): - result = self.do_command(self.dev_args + ['-i', 'restore']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Digital Bitbox does not support restoring via software') - self.assertEqual(result['code'], -9) - - def test_pin(self): - result = self.do_command(self.dev_args + ['promptpin']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Digital Bitbox does not need a PIN sent from the host') - self.assertEqual(result['code'], -9) - - result = self.do_command(self.dev_args + ['sendpin', '1234']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Digital Bitbox does not need a PIN sent from the host') - self.assertEqual(result['code'], -9) - - def test_display(self): - result = self.do_command(self.dev_args + ['displayaddress', '--path', 'm/0h']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Digital Bitbox does not have a screen to display addresses on') - self.assertEqual(result['code'], -9) - - def test_setup_wipe(self): - # Device is init, setup should fail - result = self.do_command(self.dev_args + ['-i', 'setup', '--label', 'setup_test', '--backup_passphrase', 'testpass']) - self.assertEquals(result['code'], -10) - self.assertEquals(result['error'], 'Device is already initialized. Use wipe first and try again') - - # Wipe - result = self.do_command(self.dev_args + ['wipe']) - self.assertTrue(result['success']) - - # Check arguments - result = self.do_command(self.dev_args + ['-i', 'setup', '--label', 'setup_test']) - self.assertEquals(result['code'], -7) - self.assertEquals(result['error'], 'The label and backup passphrase for a new Digital Bitbox wallet must be specified and cannot be empty') - result = self.do_command(self.dev_args + ['-i', 'setup', '--backup_passphrase', 'testpass']) - self.assertEquals(result['code'], -7) - self.assertEquals(result['error'], 'The label and backup passphrase for a new Digital Bitbox wallet must be specified and cannot be empty') - - # Setup - result = self.do_command(self.dev_args + ['-i', 'setup', '--label', 'setup_test', '--backup_passphrase', 'testpass']) - self.assertTrue(result['success']) - - # Reset back to original - result = self.do_command(self.dev_args + ['wipe']) - self.assertTrue(result['success']) - send_plain(b'{"password":"0000"}', dev) - send_encrypt(json.dumps({"seed": {"source": "backup", "filename": "test_backup.pdf", "key": "key"}}), '0000', dev) - - # Make sure device is init, setup should fail - result = self.do_command(self.dev_args + ['-i', 'setup', '--label', 'setup_test', '--backup_passphrase', 'testpass']) - self.assertEquals(result['code'], -10) - self.assertEquals(result['error'], 'Device is already initialized. Use wipe first and try again') - - def test_backup(self): - # Check arguments - result = self.do_command(self.dev_args + ['backup', '--label', 'backup_test']) - self.assertEquals(result['code'], -7) - self.assertEquals(result['error'], 'The label and backup passphrase for a Digital Bitbox backup must be specified and cannot be empty') - result = self.do_command(self.dev_args + ['backup', '--backup_passphrase', 'key']) - self.assertEquals(result['code'], -7) - self.assertEquals(result['error'], 'The label and backup passphrase for a Digital Bitbox backup must be specified and cannot be empty') - - # Wipe - result = self.do_command(self.dev_args + ['wipe']) - self.assertTrue(result['success']) - - # Setup - result = self.do_command(self.dev_args + ['-i', 'setup', '--label', 'backup_test', '--backup_passphrase', 'testpass']) - self.assertTrue(result['success']) - - # make the backup - result = self.do_command(self.dev_args + ['backup', '--label', 'backup_test_backup', '--backup_passphrase', 'testpass']) - self.assertTrue(result['success']) + self.simulator = simulator + self.bitbox_log = None + self.simulator_proc = None + self.type = 'digitalbitbox' + self.path = 'udp:127.0.0.1:35345' + self.fingerprint = 'a31b978a' + self.master_xpub = "tpubDCjZ76WbqdyGWi7NaFLuhWL8GX7NK5gCGB7ApynxUHGkgvBVCtpXX1i6Uj88rL9WKM7vimN8QZRjowSX4g2uPxjnuie1Kg7XK8pvNGZznQi" + self.password = "0000" + self.supports_ms_display = False + self.supports_xpub_ms_display = False + self.supports_unsorted_ms = False + self.supports_taproot = False + self.strict_bip48 = False + self.include_xpubs = False + self.supports_device_multiple_multisig = True + + def start(self): + super().start() + self.bitbox_log = open('bitbox-emulator.stderr', 'a') + # Start the Digital bitbox simulator + self.simulator_proc = subprocess.Popen( + [ + './' + os.path.basename(self.simulator), + '../../tests/sd_files/' + ], + cwd=os.path.dirname(self.simulator), + stderr=self.bitbox_log + ) + # Wait for simulator to be up + while True: + try: + self.dev = BitboxSimulator('127.0.0.1', 35345) + reply = send_plain(b'{"password":"0000"}', self.dev) + if 'error' not in reply: + break + except Exception: + pass + time.sleep(0.5) + + # Set password and load from backup + send_encrypt(json.dumps({"seed": {"source": "backup", "filename": "test_backup.pdf", "key": "key"}}), '0000', self.dev) + + atexit.register(self.stop) + + def stop(self): + super().stop() + self.simulator_proc.terminate() + self.simulator_proc.wait() + self.bitbox_log.close() + atexit.unregister(self.stop) + +# DigitalBitbox specific management command tests +class TestDBBManCommands(DeviceTestCase): + def test_restore(self): + result = self.do_command(self.dev_args + ['-i', 'restore']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Digital Bitbox does not support restoring via software') + self.assertEqual(result['code'], -9) + + def test_pin(self): + result = self.do_command(self.dev_args + ['promptpin']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Digital Bitbox does not need a PIN sent from the host') + self.assertEqual(result['code'], -9) + + result = self.do_command(self.dev_args + ['sendpin', '1234']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Digital Bitbox does not need a PIN sent from the host') + self.assertEqual(result['code'], -9) + + def test_display(self): + result = self.do_command(self.dev_args + ['displayaddress', '--path', 'm/0h']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Digital Bitbox does not have a screen to display addresses on') + self.assertEqual(result['code'], -9) + + def test_setup_wipe(self): + # Device is init, setup should fail + result = self.do_command(self.dev_args + ['-i', 'setup', '--label', 'setup_test', '--backup_passphrase', 'testpass']) + self.assertEquals(result['code'], -10) + self.assertEquals(result['error'], 'Device is already initialized. Use wipe first and try again') + + # Wipe + result = self.do_command(self.dev_args + ['wipe']) + self.assertTrue(result['success']) + + # Check arguments + result = self.do_command(self.dev_args + ['-i', 'setup', '--label', 'setup_test']) + self.assertEquals(result['code'], -7) + self.assertEquals(result['error'], 'The label and backup passphrase for a new Digital Bitbox wallet must be specified and cannot be empty') + result = self.do_command(self.dev_args + ['-i', 'setup', '--backup_passphrase', 'testpass']) + self.assertEquals(result['code'], -7) + self.assertEquals(result['error'], 'The label and backup passphrase for a new Digital Bitbox wallet must be specified and cannot be empty') + + # Setup + result = self.do_command(self.dev_args + ['-i', 'setup', '--label', 'setup_test', '--backup_passphrase', 'testpass']) + self.assertTrue(result['success']) + + # Reset back to original + result = self.do_command(self.dev_args + ['wipe']) + self.assertTrue(result['success']) + send_plain(b'{"password":"0000"}', self.emulator.dev) + send_encrypt(json.dumps({"seed": {"source": "backup", "filename": "test_backup.pdf", "key": "key"}}), '0000', self.emulator.dev) + + # Make sure device is init, setup should fail + result = self.do_command(self.dev_args + ['-i', 'setup', '--label', 'setup_test', '--backup_passphrase', 'testpass']) + self.assertEquals(result['code'], -10) + self.assertEquals(result['error'], 'Device is already initialized. Use wipe first and try again') + + def test_backup(self): + # Check arguments + result = self.do_command(self.dev_args + ['backup', '--label', 'backup_test']) + self.assertEquals(result['code'], -7) + self.assertEquals(result['error'], 'The label and backup passphrase for a Digital Bitbox backup must be specified and cannot be empty') + result = self.do_command(self.dev_args + ['backup', '--backup_passphrase', 'key']) + self.assertEquals(result['code'], -7) + self.assertEquals(result['error'], 'The label and backup passphrase for a Digital Bitbox backup must be specified and cannot be empty') + + # Wipe + result = self.do_command(self.dev_args + ['wipe']) + self.assertTrue(result['success']) + + # Setup + result = self.do_command(self.dev_args + ['-i', 'setup', '--label', 'backup_test', '--backup_passphrase', 'testpass']) + self.assertTrue(result['success']) + + # make the backup + result = self.do_command(self.dev_args + ['backup', '--label', 'backup_test_backup', '--backup_passphrase', 'testpass']) + self.assertTrue(result['success']) + +class TestBitboxGetXpub(DeviceTestCase): + def test_getxpub(self): + self.dev_args.remove('--chain') + self.dev_args.remove('test') + result = self.do_command(self.dev_args + ['--expert', 'getxpub', 'm/44h/0h/0h/3']) + self.assertEqual(result['xpub'], 'xpub6Du9e5Cz1NZWz3dvsvM21tsj4xEdbAb7AcbysFL42Y3yr8PLMnsaxhetHxurTpX5Rp5RbnFFwP1wct8K3gErCUSwcxFhxThsMBSxdmkhTNf') + self.assertFalse(result['testnet']) + self.assertFalse(result['private']) + self.assertEqual(result['depth'], 4) + self.assertEqual(result['parent_fingerprint'], '31d5e5ea') + self.assertEqual(result['child_num'], 3) + self.assertEqual(result['chaincode'], '7062818c752f878bf96ca668f77630452c3fa033b7415eed3ff568e04ada8104') + self.assertEqual(result['pubkey'], '029078c9ad8421afd958d7bc054a0952874923e2586fc9375604f0479a354ea193') + +def digitalbitbox_test_suite(simulator, bitcoind, interface): + dev_emulator = BitBox01Emulator(simulator) + + signtx_cases = [ + (["legacy"], ["legacy"], True, True), + (["segwit"], ["segwit"], True, True), + (["legacy", "segwit"], ["legacy", "segwit"], True, True), + ] # Generic Device tests suite = unittest.TestSuite() - suite.addTest(DeviceTestCase.parameterize(TestDBBManCommands, rpc, userpass, type, full_type, path, fingerprint, master_xpub, '0000', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, type, full_type, path, fingerprint, master_xpub, '0000', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, 'digitalbitbox_01_simulator', full_type, path, fingerprint, master_xpub, '0000', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, rpc, userpass, type, full_type, path, fingerprint, master_xpub, '0000', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, rpc, userpass, type, full_type, path, fingerprint, master_xpub, '0000', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestSignTx, rpc, userpass, type, full_type, path, fingerprint, master_xpub, '0000', interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestSignMessage, rpc, userpass, type, full_type, path, fingerprint, master_xpub, '0000', interface=interface)) - return suite + suite.addTest(DeviceTestCase.parameterize(TestDBBManCommands, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestBitboxGetXpub, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type="digitalbitbox")) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type="digitalbitbox_01_simulator")) + suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) + + result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + return result.wasSuccessful() if __name__ == '__main__': parser = argparse.ArgumentParser(description='Test Digital Bitbox implementation') @@ -144,7 +205,6 @@ def test_backup(self): args = parser.parse_args() # Start bitcoind - rpc, userpass = start_bitcoind(args.bitcoind) + bitcoind = Bitcoind.create(args.bitcoind) - suite = digitalbitbox_test_suite(args.simulator, rpc, userpass, args.interface) - unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(not digitalbitbox_test_suite(args.simulator, bitcoind, args.interface)) diff --git a/test/test_jade.py b/test/test_jade.py new file mode 100755 index 000000000..78030115f --- /dev/null +++ b/test/test_jade.py @@ -0,0 +1,251 @@ +#! /usr/bin/env python3 + +import argparse +import atexit +import os +import subprocess +import logging +import signal +import sys +import time +import unittest + +from test_device import ( + Bitcoind, + DeviceEmulator, + DeviceTestCase, + TestDeviceConnect, + TestDisplayAddress, + TestGetKeypool, + TestGetDescriptors, + TestSignMessage, + TestSignTx, +) +from hwilib.devices.jadepy.jade import JadeAPI + +USE_SIMULATOR = True +JADE_PATH = 'tcp:127.0.0.1:30121' if USE_SIMULATOR else '/dev/ttyUSB0' +TEST_SEED = bytes.fromhex('b90e532426d0dc20fffe01037048c018e940300038b165c211915c672e07762c') + +LOGGING = None # logging.INFO + +# Enable jade logging +if LOGGING: + logger = logging.getLogger('jade') + logger.setLevel(LOGGING) + device_logger = logging.getLogger('jade-device') + device_logger.setLevel(LOGGING) + +class JadeEmulator(DeviceEmulator): + def __init__(self, jade_qemu_emulator_path): + self.emulator_path = jade_qemu_emulator_path + self.emulator_proc = None + self.type = "jade" + self.path = JADE_PATH + self.master_xpub = "tpubDCgUYU13ZZ2ES5mTZNfR93i2hGLKyCkGcbJtUJ2U1Lt9qQdNTSG7kQ4r6WK3mY7HVVKxyVpkbK6Hrdo1FLRHFRL4RP68eLmoySecZLLX5tW" + self.fingerprint = '1273da33' + self.password = "" + self.supports_ms_display = False + self.supports_xpub_ms_display = False + self.supports_unsorted_ms = False + self.supports_taproot = False + self.strict_bip48 = False + self.include_xpubs = False + self.supports_device_multiple_multisig = True + + def start(self): + super().start() + if USE_SIMULATOR: + # Start the qemu emulator + self.emulator_stdout_log = open("jade-emulator.stdout", "a") + self.emulator_stderr_log = open("jade-emulator.stderr", "a") + self.emulator_proc = subprocess.Popen( + [ + './qemu-system-xtensa', + '-nographic', + '-machine', 'esp32', + '-m', '4M', + '-drive', 'file=flash_image.bin,if=mtd,format=raw', + '-nic', 'user,model=open_eth,id=lo0,hostfwd=tcp:0.0.0.0:30121-:30121', + '-drive', 'file=qemu_efuse.bin,if=none,format=raw,id=efuse', + '-global', 'driver=nvram.esp32.efuse,property=drive,value=efuse', + '-serial', 'pty' + ], + cwd=self.emulator_path, + preexec_fn=os.setsid, + stdout=self.emulator_stdout_log, + stderr=self.emulator_stderr_log, + ) + time.sleep(5) + + # Wait for emulator to be up + while True: + time.sleep(1) + try: + # Try to connect and set the test seed + with JadeAPI.create_serial(JADE_PATH, timeout=5) as jade: + if jade.set_seed(TEST_SEED): + break + + except Exception as e: + print(str(e)) + atexit.register(self.stop) + + def stop(self): + super().stop() + if USE_SIMULATOR: + if self.emulator_proc.poll() is None: + os.killpg(os.getpgid(self.emulator_proc.pid), signal.SIGTERM) + os.waitpid(self.emulator_proc.pid, 0) + self.emulator_stdout_log.close() + self.emulator_stderr_log.close() + atexit.unregister(self.stop) + +# Jade specific disabled command tests +class TestJadeDisabledCommands(DeviceTestCase): + def test_pin(self): + result = self.do_command(self.dev_args + ['promptpin']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'Blockstream Jade does not need a PIN sent from the host') + self.assertEqual(result['code'], -9) + + result = self.do_command(self.dev_args + ['sendpin', '1234']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'Blockstream Jade does not need a PIN sent from the host') + self.assertEqual(result['code'], -9) + + def test_setup(self): + result = self.do_command(self.dev_args + ['-i', 'setup']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'Blockstream Jade does not support software setup') + self.assertEqual(result['code'], -9) + + def test_wipe(self): + result = self.do_command(self.dev_args + ['wipe']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'Blockstream Jade does not support wiping via software') + self.assertEqual(result['code'], -9) + + def test_restore(self): + result = self.do_command(self.dev_args + ['-i', 'restore']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'Blockstream Jade does not support restoring via software') + self.assertEqual(result['code'], -9) + + def test_backup(self): + result = self.do_command(self.dev_args + ['backup']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'Blockstream Jade does not support creating a backup via software') + self.assertEqual(result['code'], -9) + +class TestJadeGetXpub(DeviceTestCase): + def test_getexpertxpub(self): + self.dev_args.remove("--chain") + self.dev_args.remove("test") + result = self.do_command(self.dev_args + ['--expert', 'getxpub', 'm/44h/0h/0h/3']) + self.assertEqual(result['xpub'], 'xpub6EZPQwwr93eGRt5uAN8fqNpLWtgoWM4Cn96Y7XERhRBaXus5FjuTpgGBWuvuAXp1PhYBfp7h7C7HPyuRvCyyc6wBAK7PC1Z1JVEGBnrZUXi') + self.assertFalse(result['testnet']) + self.assertFalse(result['private']) + self.assertEqual(result['depth'], 4) + self.assertEqual(result['parent_fingerprint'], '8b878d56') + self.assertEqual(result['child_num'], 3) + self.assertEqual(result['chaincode'], '7f6e61a651f74388da6a741be38aaf223e849ab5a677220dee113c34c51028b3') + self.assertEqual(result['pubkey'], '03f99c7114dd0418434585410e11648ec202817dcba5551d7a5ab1d3f93a2aad2e') + +# Because Jade has some restrictions about what multisigs it supports, we run +# explicit multisig-address tests, rather than using the standard/provided ones. +class TestJadeGetMultisigAddresses(DeviceTestCase): + # NOTE: These ones are present in Jade's own unit tests + # Jade test case: test_data/multisig_reg_ss_p2sh.json + def test_getp2sh(self): + descriptor_param = '--desc=sh(multi(2,[1273da33/44/0h/0h]tpubDDCNstnPhbdd4vwbw5UWK3vRQSF1WXQkvBHpNXpKJAkwFYjwu735EH3GVf53qwbWimzewDUv68MUmRDgYtQ1AU8FRCPkazfuaBp7LaEaohG/3/1/11/12,[e3ebcc79/3h/1h/1]tpubDDExQpZg2tziZ7ACSBCYsY3rYxAZtTRBgWwioRLYqgNBguH6rMHN1D8epTxUQUB5kM5nxkEtr2SNic6PJLPubcGMR6S2fmDZTzL9dHpU7ka/1/3/4/5))' + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], '2N2K6xGHeEYNaKEHZSBVy33g2GfUdFwJr2A') + + # Jade test case: test_data/multisig_reg_ss_wsh_sorted.json + def test_get_sorted_p2wsh(self): + descriptor_param = '--desc=wsh(sortedmulti(2,[1273da33/44/0h/0h]tpubDDCNstnPhbdd4vwbw5UWK3vRQSF1WXQkvBHpNXpKJAkwFYjwu735EH3GVf53qwbWimzewDUv68MUmRDgYtQ1AU8FRCPkazfuaBp7LaEaohG/3/1/0/1,[e3ebcc79/3h/1h/1]tpubDDExQpZg2tziZ7ACSBCYsY3rYxAZtTRBgWwioRLYqgNBguH6rMHN1D8epTxUQUB5kM5nxkEtr2SNic6PJLPubcGMR6S2fmDZTzL9dHpU7ka/1/0/16))' + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], 'tb1qx474um8lr97sn46wz9v90u6qs49ttrhdze2cfxa5x4tajvl62avshcqval') + + # Jade test case: test_data/multisig_reg_ss_matches_ga_2of3.json + def test_getp2shwsh(self): + descriptor_param = '--desc=sh(wsh(multi(2,[b5281696/3]tpubECMbgHMZm4QymrFbuEntaWbSsaaKvJVpoR6rhVwxGUrT9qf6WLfC88piWYVsQDV86PUKx3QsXYiCqugWX1oz8ekucUdFUdupuNCgjf17EYK/13,[e3ebcc79/3h/2h/1]tpubDD8fpYqWy6DEvbqdj9CVWptA3gd3xqarNN6wCAjfDs1sFnd8zfb9SeDzRAXA3S4eeeYvo2sz6mbyS3KaXuDe5PcWy94PqShTpBjiJN198A6/13,[1273da33/1]tpubD8PzcLmm1rVeUpEjmd2kQD6a9DXy6dwVVgE14mrh1zc6L98nmNqmDaApAbEcbrJ1iPBpo2gnEniSpVXHgovU8ecWwfHVP113DK2bWEMPpEs/13)))' + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], '2NFgE1k4EpyiBTN4SBkGamFU9E1DwLLajLo') + + # NOTE: these ones are used by the sign-tx tests - using them here gets + # them 'registered' on the Jade hw - This is not mandatory but means in + # sign-tx we should be using them to auto-validate the change outputs. + SIGN_TX_MULTI_DESCRIPTOR = 'multi(2,[1273da33/48h/1h/2h/0h]tpubDE5KhdeLh956ERiopzHRskaJ3huWXLUPKiQUSkR3R3nTsr4SQfVVU6DbA9E66BZYwTk87hwE7wn1175WqBzMsbkFErGt3ATJm2xaisCPUmn/0/1,[1273da33/48h/1h/0h/0h]tpubDEAjmvwVDj4aNW8D1KX39VmMW1ZUX8BNgVEyD6tUVshZYCJQvbp9LSqvihiJa4tGZUisf6XpyZHg76dDBxNZLHTf6xYwgbio4Xnj6i21JgN/0/1,[1273da33/48h/1h/1h/0h]tpubDERHGgfviqDnoRSykG1YBBfhFbgNPuTeWvjwJBXM36d5wzFwkQpWFXHC76XW99hMgd1NkR6A3rRHM93Njqdx2X3KoUebekokUPsAvmeC4NE/0/1)' + SIGN_TX_SORTEDMULTI_DESCRIPTOR = f'sorted{SIGN_TX_MULTI_DESCRIPTOR}' + + def test_get_signing_p2sh(self): + descriptor_param = f'--desc=sh({self.SIGN_TX_MULTI_DESCRIPTOR})' + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], '2N4v6yct4NHy7FLex1wgwksLG97dXSTEs9x', result) + + descriptor_param = f'--desc=sh({self.SIGN_TX_SORTEDMULTI_DESCRIPTOR})' + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], '2MwZVj4Sbjn3ewD87ZoDgWrKFNjav4uPJm9', result) + + def test_get_signing_p2wsh(self): + descriptor_param = f'--desc=wsh({self.SIGN_TX_MULTI_DESCRIPTOR})' + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], 'tb1qxjxvxk69yedt49u2djh8mu9lsmw6tf4n2pwuqend5tjlqrumuq2skh7qzc', result) + + descriptor_param = f'--desc=wsh({self.SIGN_TX_SORTEDMULTI_DESCRIPTOR})' + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], 'tb1qeuh6d9eg28aqy5ckrqxn868saapcr6kg968prm6999gclkr4ewqsv22prt', result) + + def test_get_signing_p2shwsh(self): + descriptor_param = f'--desc=sh(wsh({self.SIGN_TX_MULTI_DESCRIPTOR}))' + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], '2N6wdpQsBUvT3nAp8hGaXEFpfvzerqaHFVC', result) + + descriptor_param = f'--desc=sh(wsh({self.SIGN_TX_SORTEDMULTI_DESCRIPTOR}))' + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], '2NAXBEePa5ebo1zTDrtQ9C21QDkkamwczfQ', result) + +def jade_test_suite(emulator, bitcoind, interface): + dev_emulator = JadeEmulator(emulator) + + signtx_cases = [ + (["legacy"], ["legacy"], True, True), + (["segwit"], ["segwit"], True, True), + (["legacy", "segwit"], ["legacy", "segwit"], True, True), + ] + + # Generic Device tests + suite = unittest.TestSuite() + suite.addTest(DeviceTestCase.parameterize(TestJadeDisabledCommands, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type="jade")) + suite.addTest(DeviceTestCase.parameterize(TestJadeGetXpub, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestJadeGetMultisigAddresses, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) + + result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + return result.wasSuccessful() + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Test Jade implementation') + parser.add_argument('emulator', help='Docker image name of the jade emulator') + parser.add_argument('bitcoind', help='Path to bitcoind binary') + parser.add_argument('--interface', help='Which interface to send commands over', choices=['library', 'cli', 'bindist'], default='library') + + args = parser.parse_args() + + # Start bitcoind + bitcoind = Bitcoind.create(args.bitcoind) + + sys.exit(not jade_test_suite(args.emulator, bitcoind, args.interface)) diff --git a/test/test_keepkey.py b/test/test_keepkey.py index 43058f7f1..9e74a0cb4 100755 --- a/test/test_keepkey.py +++ b/test/test_keepkey.py @@ -1,6 +1,7 @@ #! /usr/bin/env python3 import argparse +import atexit import json import os import shlex @@ -10,15 +11,30 @@ import time import unittest -from hwilib.devices.trezorlib.transport import enumerate_devices from hwilib.devices.trezorlib.transport.udp import UdpTransport -from hwilib.devices.trezorlib.debuglink import TrezorClientDebugLink, load_device_by_mnemonic, load_device_by_xprv +from hwilib.devices.trezorlib.debuglink import TrezorClientDebugLink, load_device_by_mnemonic from hwilib.devices.trezorlib import device, messages -from test_device import DeviceEmulator, DeviceTestCase, start_bitcoind, TestDeviceConnect, TestDisplayAddress, TestGetKeypool, TestGetDescriptors, TestSignMessage, TestSignTx - -from hwilib.cli import process_commands -from hwilib.devices.keepkey import KeepkeyClient - +from hwilib.devices.trezorlib.mapping import DEFAULT_MAPPING +from hwilib.devices.trezorlib.models import TrezorModel +from test_device import ( + Bitcoind, + DeviceEmulator, + DeviceTestCase, + TestDeviceConnect, + TestDisplayAddress, + TestGetKeypool, + TestGetDescriptors, + TestSignMessage, + TestSignTx, +) + +from hwilib._cli import process_commands +from hwilib.devices.keepkey import ( + KeepkeyClient, + KeepkeyDebugLinkState, + KeepkeyFeatures, + KeepkeyResetDevice, +) from types import MethodType def get_pin(self, code=None): @@ -31,14 +47,33 @@ class KeepkeyEmulator(DeviceEmulator): def __init__(self, path): self.emulator_path = path self.emulator_proc = None + self.keepkey_log = None + try: + os.unlink('keepkey-emulator.stdout') + except FileNotFoundError: + pass + self.type = 'keepkey' + self.path = 'udp:127.0.0.1:11044' + self.fingerprint = '95d8f670' + self.master_xpub = "tpubDCknDegFqAdP4V2AhHhs635DPe8N1aTjfKE9m2UFbdej8zmeNbtqDzK59SxnsYSRSx5uS3AujbwgANUiAk4oHmDNUKoGGkWWUY6c48WgjEx" + self.password = "" + self.supports_ms_display = True + self.supports_xpub_ms_display = False + self.supports_unsorted_ms = False + self.supports_taproot = False + self.strict_bip48 = False + self.include_xpubs = False + self.supports_device_multiple_multisig = True def start(self): + super().start() + self.keepkey_log = open('keepkey-emulator.stdout', 'a') # Start the Keepkey emulator - self.emulator_proc = subprocess.Popen(['./' + os.path.basename(self.emulator_path)], cwd=os.path.dirname(self.emulator_path), stdout=subprocess.DEVNULL) + self.emulator_proc = subprocess.Popen(['./' + os.path.basename(self.emulator_path)], cwd=os.path.dirname(self.emulator_path), stdout=self.keepkey_log) # Wait for emulator to be up - # From https://github.com/trezor/trezor-mcu/blob/master/script/wait_for_emulator.py + # From https://github.com/trezor/trezor-firmware/blob/master/legacy/script/wait_for_emulator.py sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.connect(('127.0.0.1', 21324)) + sock.connect(('127.0.0.1', 11044)) sock.settimeout(0) while True: try: @@ -50,19 +85,27 @@ def start(self): time.sleep(0.05) # Setup the emulator - for dev in enumerate_devices(): - # Find the udp transport, that's the emulator - if isinstance(dev, UdpTransport): - wirelink = dev - break - client = TrezorClientDebugLink(wirelink) + model = TrezorModel( + name="K1-14M", + minimum_version=(0, 0, 0), + vendors=("keepkey.com"), + usb_ids=(), # unused + default_mapping=DEFAULT_MAPPING, + ) + model.default_mapping.register(KeepkeyFeatures) + model.default_mapping.register(KeepkeyResetDevice) + model.default_mapping.register(KeepkeyDebugLinkState) + wirelink = UdpTransport.enumerate("127.0.0.1:11044")[0] + client = TrezorClientDebugLink(wirelink, model=model) client.init_device() device.wipe(client) load_device_by_mnemonic(client=client, mnemonic='alcohol woman abuse must during monitor noble actual mixed trade anger aisle', pin='', passphrase_protection=False, label='test') # From Trezor device tests + atexit.register(self.stop) return client def stop(self): - self.emulator_proc.kill() + super().stop() + self.emulator_proc.terminate() self.emulator_proc.wait() # Clean up emulator image @@ -70,6 +113,14 @@ def stop(self): if os.path.isfile(emulator_img): os.unlink(emulator_img) + if self.keepkey_log is not None: + self.keepkey_log.close() + + # Wait a second for everything to be cleaned up before going to the next test + time.sleep(1) + + atexit.unregister(self.stop) + class KeepkeyTestCase(unittest.TestCase): def __init__(self, emulator, interface='library', methodName='runTest'): super(KeepkeyTestCase, self).__init__(methodName) @@ -111,40 +162,48 @@ def __str__(self): def __repr__(self): return 'keepkey: {}'.format(super().__repr__()) -# Keepkey specific getxpub test because this requires device specific thing to set xprvs -class TestKeepkeyGetxpub(KeepkeyTestCase): def setUp(self): self.client = self.emulator.start() def tearDown(self): self.emulator.stop() +# Keepkey specific getxpub test because this requires device specific thing to set xprvs +class TestKeepkeyGetxpub(KeepkeyTestCase): def test_getxpub(self): with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data/bip32_vectors.json'), encoding='utf-8') as f: vectors = json.load(f) for vec in vectors: with self.subTest(vector=vec): - # Setup with xprv + # Setup with mnemonic device.wipe(self.client) - load_device_by_xprv(client=self.client, xprv=vec['xprv'], pin='', passphrase_protection=False, label='test', language='english') + load_device_by_mnemonic(client=self.client, mnemonic=vec['mnemonic'], pin='', passphrase_protection=False, label='test', language='english') # Test getmasterxpub - gmxp_res = self.do_command(['-t', 'keepkey', '-d', 'udp:127.0.0.1:21324', 'getmasterxpub']) + gmxp_res = self.do_command(['-t', 'keepkey', '-d', 'udp:127.0.0.1:11044', 'getmasterxpub', "--addr-type", "legacy"]) self.assertEqual(gmxp_res['xpub'], vec['master_xpub']) # Test the path derivs for path_vec in vec['vectors']: - gxp_res = self.do_command(['-t', 'keepkey', '-d', 'udp:127.0.0.1:21324', 'getxpub', path_vec['path']]) + gxp_res = self.do_command(['-t', 'keepkey', '-d', 'udp:127.0.0.1:11044', 'getxpub', path_vec['path']]) self.assertEqual(gxp_res['xpub'], path_vec['xpub']) + def test_expert_getxpub(self): + result = self.do_command(['-t', 'keepkey', '-d', 'udp:127.0.0.1:11044', '--expert', 'getxpub', 'm/44h/0h/0h/3']) + self.assertEqual(result['xpub'], 'xpub6FMafWAi3n3ET2rU5yQr16UhRD1Zx4dELmcEw3NaYeBaNnipcr2zjzYp1sNdwR3aTN37hxAqRWQ13AWUZr6L9jc617mU6EvgYXyBjXrEhgr') + self.assertFalse(result['testnet']) + self.assertFalse(result['private']) + self.assertEqual(result['depth'], 4) + self.assertEqual(result['parent_fingerprint'], 'f7e318db') + self.assertEqual(result['child_num'], 3) + self.assertEqual(result['chaincode'], '95a7fb33c4f0896f66045cd7f45ed49a9e72372d2aed204ad0149c39b7b17905') + self.assertEqual(result['pubkey'], '022e6d9c18e5a837e802fb09abe00f787c8ccb0fc489c6ec5dc2613d930efd7eae') + # Keepkey specific management (setup, wipe, restore, backup, promptpin, sendpin) command tests class TestKeepkeyManCommands(KeepkeyTestCase): def setUp(self): self.client = self.emulator.start() - self.dev_args = ['-t', 'keepkey', '-d', 'udp:127.0.0.1:21324'] - - def tearDown(self): - self.emulator.stop() + self.dev_args = ['-t', 'keepkey', '-d', 'udp:127.0.0.1:11044'] def test_setup_wipe(self): # Device is init, setup should fail @@ -157,17 +216,35 @@ def test_setup_wipe(self): self.assertTrue(result['success']) # Setup - t_client = KeepkeyClient('udp:127.0.0.1:21324', 'test') + t_client = KeepkeyClient('udp:127.0.0.1:11044', 'test') t_client.client.ui.get_pin = MethodType(get_pin, t_client.client.ui) t_client.client.ui.pin = '1234' - result = t_client.setup_device() - self.assertTrue(result['success']) + result = t_client.setup_device(label='HWI Keepkey') + self.assertTrue(result) # Make sure device is init, setup should fail result = self.do_command(self.dev_args + ['-i', 'setup']) self.assertEquals(result['code'], -10) self.assertEquals(result['error'], 'Device is already initialized. Use wipe first and try again') + def test_label(self): + result = self.do_command(self.dev_args + ['wipe']) + self.assertTrue(result['success']) + + t_client = KeepkeyClient('udp:127.0.0.1:11044', 'test') + t_client.client.ui.get_pin = MethodType(get_pin, t_client.client.ui) + t_client.client.ui.pin = '1234' + result = t_client.setup_device(label='HWI Keepkey') + self.assertTrue(result) + + result = self.do_command(self.dev_args + ['enumerate']) + for dev in result: + if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:11044': + self.assertEqual(dev['label'], 'HWI Keepkey') + break + else: + self.fail("Did not enumerate device") + def test_backup(self): result = self.do_command(self.dev_args + ['backup']) self.assertIn('error', result) @@ -185,17 +262,23 @@ def test_pins(self): self.assertEqual(result['code'], -11) result = self.do_command(self.dev_args + ['enumerate']) for dev in result: - if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': + if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:11044': self.assertFalse(dev['needs_pin_sent']) + break + else: + self.fail("Did not enumerate device") # Set a PIN device.wipe(self.client) - load_device_by_mnemonic(client=self.client, mnemonic='alcohol woman abuse must during monitor noble actual mixed trade anger aisle', pin='1234', passphrase_protection=False, label='test') - self.client.call(messages.ClearSession()) + load_device_by_mnemonic(client=self.client, mnemonic='alcohol woman abuse must during monitor noble actual mixed trade anger aisle', pin='1234', passphrase_protection=True, label='test') + self.client.call(messages.LockDevice()) result = self.do_command(self.dev_args + ['enumerate']) for dev in result: - if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': + if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:11044': self.assertTrue(dev['needs_pin_sent']) + break + else: + self.fail("Did not enumerate device") result = self.do_command(self.dev_args + ['promptpin']) self.assertTrue(result['success']) @@ -205,7 +288,7 @@ def test_pins(self): self.assertEqual(result['code'], -7) result = self.do_command(self.dev_args + ['sendpin', '00000']) - self.assertFalse(result['success']) + self.assertFalse(result["success"]) # Make sure we get a needs pin message result = self.do_command(self.dev_args + ['getxpub', 'm/0h']) @@ -213,20 +296,23 @@ def test_pins(self): self.assertEqual(result['error'], 'Keepkey is locked. Unlock by using \'promptpin\' and then \'sendpin\'.') # Prompt pin - self.client.call(messages.ClearSession()) + self.client.call(messages.LockDevice()) result = self.do_command(self.dev_args + ['promptpin']) self.assertTrue(result['success']) # Send the PIN self.client.open() pin = self.client.debug.encode_pin('1234') - result = self.do_command(self.dev_args + ['sendpin', pin]) + result = self.do_command(self.dev_args + ["-p", "test", 'sendpin', pin]) self.assertTrue(result['success']) result = self.do_command(self.dev_args + ['enumerate']) for dev in result: - if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': + if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:11044': self.assertFalse(dev['needs_pin_sent']) + break + else: + self.fail("Did not enumerate device") # Sending PIN after unlock result = self.do_command(self.dev_args + ['promptpin']) @@ -237,74 +323,95 @@ def test_pins(self): self.assertEqual(result['code'], -11) def test_passphrase(self): - # There's no passphrase - result = self.do_command(self.dev_args + ['enumerate']) - for dev in result: - if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:21324': - self.assertFalse(dev['needs_passphrase_sent']) - self.assertEquals(dev['fingerprint'], '95d8f670') - # Setting a passphrase won't change the fingerprint - result = self.do_command(self.dev_args + ['-p', 'pass', 'enumerate']) - for dev in result: - if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:21324': - self.assertFalse(dev['needs_passphrase_sent']) - self.assertEquals(dev['fingerprint'], '95d8f670') - - # Set a passphrase - device.wipe(self.client) - self.client.set_passphrase('pass') - load_device_by_mnemonic(client=self.client, mnemonic='alcohol woman abuse must during monitor noble actual mixed trade anger aisle', pin='', passphrase_protection=True, label='test') - self.client.call(messages.ClearSession()) + # Enable passphrase + self.do_command(self.dev_args + ['togglepassphrase']) # A passphrase will need to be sent result = self.do_command(self.dev_args + ['enumerate']) for dev in result: - if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:21324': + if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:11044': self.assertTrue(dev['needs_passphrase_sent']) + break + else: + self.fail("Did not enumerate device") result = self.do_command(self.dev_args + ['-p', 'pass', 'enumerate']) for dev in result: - if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:21324': + if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:11044': self.assertFalse(dev['needs_passphrase_sent']) fpr = dev['fingerprint'] + break + else: + self.fail("Did not enumerate device") # A different passphrase will change the fingerprint result = self.do_command(self.dev_args + ['-p', 'pass2', 'enumerate']) for dev in result: - if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:21324': + if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:11044': self.assertFalse(dev['needs_passphrase_sent']) self.assertNotEqual(dev['fingerprint'], fpr) + break + else: + self.fail("Did not enumerate device") # Clearing the session and starting a new one with a new passphrase should change the passphrase - self.client.call(messages.ClearSession()) + self.client.call(messages.LockDevice()) result = self.do_command(self.dev_args + ['-p', 'pass3', 'enumerate']) for dev in result: - if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:21324': + if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:11044': self.assertFalse(dev['needs_passphrase_sent']) self.assertNotEqual(dev['fingerprint'], fpr) + break + else: + self.fail("Did not enumerate device") + + # Disable passphrase + self.do_command(self.dev_args + ['togglepassphrase']) -def keepkey_test_suite(emulator, rpc, userpass, interface): + # There's no passphrase + result = self.do_command(self.dev_args + ['enumerate']) + for dev in result: + if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:11044': + self.assertFalse(dev['needs_passphrase_sent']) + self.assertEquals(dev['fingerprint'], '95d8f670') + break + else: + self.fail("Did not enumerate device") + # Setting a passphrase won't change the fingerprint + result = self.do_command(self.dev_args + ['-p', 'pass', 'enumerate']) + for dev in result: + if dev['type'] == 'keepkey' and dev['path'] == 'udp:127.0.0.1:11044': + self.assertFalse(dev['needs_passphrase_sent']) + self.assertEquals(dev['fingerprint'], '95d8f670') + break + else: + self.fail("Did not enumerate device") + +def keepkey_test_suite(emulator, bitcoind, interface): # Redirect stderr to /dev/null as it's super spammy sys.stderr = open(os.devnull, 'w') - # Device info for tests - type = 'keepkey' - full_type = 'keepkey' - path = 'udp:127.0.0.1:21324' - fingerprint = '95d8f670' - master_xpub = 'xpub6D1weXBcFAo8CqBbpP4TbH5sxQH8ZkqC5pDEvJ95rNNBZC9zrKmZP2fXMuve7ZRBe18pWQQsGg68jkq24mZchHwYENd8cCiSb71u3KD4AFH' dev_emulator = KeepkeyEmulator(emulator) + signtx_cases = [ + (["legacy"], ["legacy"], True, True), + (["segwit"], ["segwit"], True, True), + (["legacy", "segwit"], ["legacy", "segwit"], True, True), + ] + # Generic Device tests suite = unittest.TestSuite() - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, 'keepkey_simulator', full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestSignTx, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestSignMessage, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type="keepkey")) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type="keepkey_simulator")) + suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) + suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) suite.addTest(KeepkeyTestCase.parameterize(TestKeepkeyGetxpub, emulator=dev_emulator, interface=interface)) suite.addTest(KeepkeyTestCase.parameterize(TestKeepkeyManCommands, emulator=dev_emulator, interface=interface)) - return suite + + result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + sys.stderr = sys.__stderr__ + return result.wasSuccessful() if __name__ == '__main__': parser = argparse.ArgumentParser(description='Test Keepkey implementation') @@ -314,7 +421,6 @@ def keepkey_test_suite(emulator, rpc, userpass, interface): args = parser.parse_args() # Start bitcoind - rpc, userpass = start_bitcoind(args.bitcoind) + bitcoind = Bitcoind.create(args.bitcoind) - suite = keepkey_test_suite(args.emulator, rpc, userpass, args.interface) - unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + sys.exit(not keepkey_test_suite(args.emulator, bitcoind, args.interface)) diff --git a/test/test_ledger.py b/test/test_ledger.py index 3f8e3b638..f82c7eec4 100755 --- a/test/test_ledger.py +++ b/test/test_ledger.py @@ -1,91 +1,201 @@ #! /usr/bin/env python3 import argparse +import atexit +import os +import subprocess +import signal +import sys +import time import unittest -from test_device import DeviceTestCase, start_bitcoind, TestDeviceConnect, TestDisplayAddress, TestGetKeypool, TestGetDescriptors, TestSignMessage, TestSignTx - -from hwilib.cli import process_commands - -def ledger_test_suite(device_model, rpc, userpass, interface): - # Look for real ledger using HWI API(self-referential, but no other way) - enum_res = process_commands(['enumerate']) - path = None - master_xpub = None - fingerprint = None - for device in enum_res: - if device['type'] == 'ledger': - fingerprint = device['fingerprint'] - path = device['path'] - master_xpub = process_commands(['-f', fingerprint, 'getmasterxpub'])['xpub'] - break - assert(path is not None and master_xpub is not None and fingerprint is not None) - - # Ledger specific disabled command tests - class TestLedgerDisabledCommands(DeviceTestCase): - def test_pin(self): - result = self.do_command(self.dev_args + ['promptpin']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Ledger Nano S and X do not need a PIN sent from the host') - self.assertEqual(result['code'], -9) - - result = self.do_command(self.dev_args + ['sendpin', '1234']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Ledger Nano S and X do not need a PIN sent from the host') - self.assertEqual(result['code'], -9) - - def test_setup(self): - result = self.do_command(self.dev_args + ['-i', 'setup']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Ledger Nano S and X do not support software setup') - self.assertEqual(result['code'], -9) - - def test_wipe(self): - result = self.do_command(self.dev_args + ['wipe']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Ledger Nano S and X do not support wiping via software') - self.assertEqual(result['code'], -9) - - def test_restore(self): - result = self.do_command(self.dev_args + ['-i', 'restore']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Ledger Nano S and X do not support restoring via software') - self.assertEqual(result['code'], -9) - - def test_backup(self): - result = self.do_command(self.dev_args + ['backup']) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'The Ledger Nano S and X do not support creating a backup via software') - self.assertEqual(result['code'], -9) +from test_device import ( + Bitcoind, + DeviceEmulator, + DeviceTestCase, + TestDeviceConnect, + TestDisplayAddress, + TestGetKeypool, + TestGetDescriptors, + TestSignMessage, + TestSignTx, +) + +from hwilib._cli import process_commands + +class LedgerEmulator(DeviceEmulator): + def __init__(self, path, legacy=False): + self.emulator_path = path + self.emulator_proc = None + self.emulator_stderr = None + self.emulator_stdout = None + self.legacy = legacy + try: + os.unlink('ledger-emulator.stderr') + except FileNotFoundError: + pass + self.type = "ledger" + self.path = 'tcp:127.0.0.1:9999' + self.fingerprint = 'f5acc2fd' + self.master_xpub = 'tpubDCwYjpDhUdPGP5rS3wgNg13mTrrjBuG8V9VpWbyptX6TRPbNoZVXsoVUSkCjmQ8jJycjuDKBb9eataSymXakTTaGifxR6kmVsfFehH1ZgJT' + self.password = "" + self.supports_ms_display = False # Legacy does not multisig address display; tests not updated for new app + self.supports_xpub_ms_display = False # Legacy does not multisig address display; tests not updated for new app + self.supports_unsorted_ms = False # Legacy does not support unsorted multisig; tests not updated for new app + self.supports_taproot = not legacy # Legacy does not support Taproot + self.strict_bip48 = True + self.include_xpubs = True + self.supports_device_multiple_multisig = True + + def start(self): + super().start() + automation_path = os.path.abspath("data/speculos-automation.json") + app_path = "./apps/nanos#btc#2.0#ce796c1b.elf" if self.legacy else "./apps/btc-test.elf" + os.environ["SPECULOS_APPNAME"] = "Bitcoin Test:1.6.0" if self.legacy else "Bitcoin Test:2.1.0" + + self.emulator_stderr = open('ledger-emulator.stderr', 'a') + # Start the emulator + self.emulator_proc = subprocess.Popen( + [ + 'python3', + './' + os.path.basename(self.emulator_path), + '--display', + 'headless', + '--automation', + 'file:{}'.format(automation_path), + '--log-level', + 'automation:DEBUG', + '--log-level', + 'seproxyhal:DEBUG', + '--api-port', + '0', + app_path + ], + cwd=os.path.dirname(self.emulator_path), + stderr=self.emulator_stderr, + preexec_fn=os.setsid, + ) + # Wait for simulator to be up + while True: + try: + enum_res = process_commands(['enumerate']) + found = False + for dev in enum_res: + if dev['type'] == 'ledger' and 'error' not in dev: + found = True + break + if found: + break + except Exception as e: + print(str(e)) + pass + time.sleep(0.5) + atexit.register(self.stop) + + def stop(self): + super().stop() + if self.emulator_proc.poll() is None: + os.killpg(os.getpgid(self.emulator_proc.pid), signal.SIGTERM) + os.waitpid(self.emulator_proc.pid, 0) + if self.emulator_stderr is not None: + self.emulator_stderr.close() + if self.emulator_stdout is not None: + self.emulator_stdout.close() + atexit.unregister(self.stop) + +# Ledger specific disabled command tests +class TestLedgerDisabledCommands(DeviceTestCase): + def test_pin(self): + result = self.do_command(self.dev_args + ['promptpin']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Ledger Nano S and X do not need a PIN sent from the host') + self.assertEqual(result['code'], -9) + + result = self.do_command(self.dev_args + ['sendpin', '1234']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Ledger Nano S and X do not need a PIN sent from the host') + self.assertEqual(result['code'], -9) + + def test_setup(self): + result = self.do_command(self.dev_args + ['-i', 'setup']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Ledger Nano S and X do not support software setup') + self.assertEqual(result['code'], -9) + + def test_wipe(self): + result = self.do_command(self.dev_args + ['wipe']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Ledger Nano S and X do not support wiping via software') + self.assertEqual(result['code'], -9) + + def test_restore(self): + result = self.do_command(self.dev_args + ['-i', 'restore']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Ledger Nano S and X do not support restoring via software') + self.assertEqual(result['code'], -9) + + def test_backup(self): + result = self.do_command(self.dev_args + ['backup']) + self.assertIn('error', result) + self.assertIn('code', result) + self.assertEqual(result['error'], 'The Ledger Nano S and X do not support creating a backup via software') + self.assertEqual(result['code'], -9) + +class TestLedgerGetXpub(DeviceTestCase): + def test_getxpub(self): + result = self.do_command(self.dev_args + ['--expert', 'getxpub', 'm/44h/1h/0h/0/3']) + self.assertEqual(result['xpub'], "tpubDHcN44A4UHqdR5iJduo8FWiWtJNcY7MPUEe1Dmpo4sv1R93k6mrWxAVNmjFAsW4e9gC14yTfkHFzBTQUjnkdijZVLmmiJdueMgLPczBRBSL") + self.assertTrue(result['testnet']) + self.assertFalse(result['private']) + self.assertEqual(result['depth'], 5) + self.assertEqual(result['parent_fingerprint'], "f7ed8b7e") + self.assertEqual(result['child_num'], 3) + self.assertEqual(result['chaincode'], "1067f2a53975faf7ac265be505c1c50ef80a0dcbe1f53f50497c5618e8888dbd") + self.assertEqual(result['pubkey'], "035879ca173a9c1b3f300ec587fb4cc6d54d618e30584e425c1b53b98828708f1d") + +def ledger_test_suite(emulator, bitcoind, interface, legacy=False): + dev_emulator = LedgerEmulator(emulator, legacy) + + signtx_cases = [ + (["legacy"], ["legacy"], True, legacy), + (["segwit"], ["segwit"], True, legacy), + ] + if not legacy: + signtx_cases.extend([ + (["tap"], [], True, legacy), + (["legacy", "segwit"], ["legacy", "segwit"], True, legacy), + (["legacy", "segwit", "tap"], ["legacy", "segwit"], True, legacy), + ]) # Generic Device tests suite = unittest.TestSuite() - suite.addTest(DeviceTestCase.parameterize(TestLedgerDisabledCommands, rpc, userpass, device_model, 'ledger', path, fingerprint, master_xpub, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, device_model, 'ledger', path, fingerprint, master_xpub, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, device_model, 'ledger', path, fingerprint, master_xpub, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, rpc, userpass, device_model, 'ledger', path, fingerprint, master_xpub, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, rpc, userpass, device_model, 'ledger', path, fingerprint, master_xpub, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestSignTx, rpc, userpass, device_model, 'ledger', path, fingerprint, master_xpub, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, rpc, userpass, device_model, 'ledger', path, fingerprint, master_xpub, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestSignMessage, rpc, userpass, device_model, 'ledger', path, fingerprint, master_xpub, interface=interface)) - return suite + suite.addTest(DeviceTestCase.parameterize(TestLedgerDisabledCommands, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestLedgerGetXpub, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type=dev_emulator.type)) + suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) + + result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + return result.wasSuccessful() if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Test Ledger implementation on physical device') + parser = argparse.ArgumentParser(description='Test Ledger implementation') + parser.add_argument('emulator', help='Path to the ledger emulator') parser.add_argument('bitcoind', help='Path to bitcoind binary') - parser.add_argument('device_model', help='Device model', choices=['ledger_nano_s', 'ledger_nano_x']) parser.add_argument('--interface', help='Which interface to send commands over', choices=['library', 'cli', 'bindist'], default='library') + parser.add_argument("--legacy", action="store_true", help="Use the v1 app and test the legacy API") args = parser.parse_args() # Start bitcoind - rpc, userpass = start_bitcoind(args.bitcoind) + bitcoind = Bitcoind.create(args.bitcoind) - suite = ledger_test_suite(args.device_model, rpc, userpass, args.interface) - unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(not ledger_test_suite(args.emulator, bitcoind, args.interface, args.legacy)) diff --git a/test/test_psbt.py b/test/test_psbt.py index 7dacfa7c0..3a585b060 100755 --- a/test/test_psbt.py +++ b/test/test_psbt.py @@ -1,6 +1,6 @@ #! /usr/bin/env python3 -from hwilib.serializations import PSBT +from hwilib.psbt import PSBT from hwilib.errors import PSBTSerializationError import json import os diff --git a/test/test_trezor.py b/test/test_trezor.py index d688cf5d6..87fdb251c 100755 --- a/test/test_trezor.py +++ b/test/test_trezor.py @@ -1,6 +1,7 @@ #! /usr/bin/env python3 import argparse +import atexit import json import os import shlex @@ -11,17 +12,28 @@ import time import unittest -from hwilib.devices.trezorlib.transport import enumerate_devices from hwilib.devices.trezorlib.transport.udp import UdpTransport -from hwilib.devices.trezorlib.debuglink import TrezorClientDebugLink, load_device_by_mnemonic, load_device_by_xprv +from hwilib.devices.trezorlib.debuglink import TrezorClientDebugLink, load_device_by_mnemonic from hwilib.devices.trezorlib import device, messages -from test_device import DeviceEmulator, DeviceTestCase, start_bitcoind, TestDeviceConnect, TestDisplayAddress, TestGetKeypool, TestGetDescriptors, TestSignMessage, TestSignTx - -from hwilib.cli import process_commands +from test_device import ( + Bitcoind, + DeviceEmulator, + DeviceTestCase, + TestDeviceConnect, + TestDisplayAddress, + TestGetKeypool, + TestGetDescriptors, + TestSignMessage, + TestSignTx, +) + +from hwilib._cli import process_commands from hwilib.devices.trezor import TrezorClient from types import MethodType +TREZOR_MODELS = {'1', 't'} + def get_pin(self, code=None): if self.pin: return self.debuglink.encode_pin(self.pin) @@ -29,16 +41,36 @@ def get_pin(self, code=None): return self.debuglink.read_pin_encoded() class TrezorEmulator(DeviceEmulator): - def __init__(self, path, model_t): + def __init__(self, path, model): + assert model in TREZOR_MODELS self.emulator_path = path self.emulator_proc = None - self.model_t = model_t + self.model = model + self.emulator_log = None + try: + os.unlink('trezor-{}-emulator.stdout'.format(self.model)) + except FileNotFoundError: + pass + self.type = f"trezor_{model}" + self.path = 'udp:127.0.0.1:21324' + self.fingerprint = '95d8f670' + self.master_xpub = "tpubDCknDegFqAdP4V2AhHhs635DPe8N1aTjfKE9m2UFbdej8zmeNbtqDzK59SxnsYSRSx5uS3AujbwgANUiAk4oHmDNUKoGGkWWUY6c48WgjEx" + self.password = "" + self.supports_ms_display = True + self.supports_xpub_ms_display = True + self.supports_unsorted_ms = True + self.supports_taproot = True + self.strict_bip48 = True + self.include_xpubs = False + self.supports_device_multiple_multisig = True def start(self): + super().start() + self.emulator_log = open('trezor-{}-emulator.stdout'.format(self.model), 'a') # Start the Trezor emulator - self.emulator_proc = subprocess.Popen(['./' + os.path.basename(self.emulator_path)], cwd=os.path.dirname(self.emulator_path), stdout=subprocess.DEVNULL, env={'SDL_VIDEODRIVER': 'dummy', 'PYOPT': '0'}, shell=True, preexec_fn=os.setsid) + self.emulator_proc = subprocess.Popen(['./' + os.path.basename(self.emulator_path)], cwd=os.path.dirname(self.emulator_path), stdout=self.emulator_log, env={'SDL_VIDEODRIVER': 'dummy', 'PYOPT': '0'}, shell=True, preexec_fn=os.setsid) # Wait for emulator to be up - # From https://github.com/trezor/trezor-mcu/blob/master/script/wait_for_emulator.py + # From https://github.com/trezor/trezor-firmware/blob/master/legacy/script/wait_for_emulator.py sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.connect(('127.0.0.1', 21324)) sock.settimeout(0) @@ -52,30 +84,38 @@ def start(self): time.sleep(0.05) # Setup the emulator - for dev in enumerate_devices(): - # Find the udp transport, that's the emulator - if isinstance(dev, UdpTransport): - wirelink = dev - break + wirelink = UdpTransport.enumerate()[0] client = TrezorClientDebugLink(wirelink) client.init_device() device.wipe(client) load_device_by_mnemonic(client=client, mnemonic='alcohol woman abuse must during monitor noble actual mixed trade anger aisle', pin='', passphrase_protection=False, label='test') # From Trezor device tests + atexit.register(self.stop) return client def stop(self): - os.killpg(os.getpgid(self.emulator_proc.pid), signal.SIGINT) - os.waitpid(self.emulator_proc.pid, 0) + super().stop() + if self.emulator_proc.poll() is None: + os.killpg(os.getpgid(self.emulator_proc.pid), signal.SIGTERM) + os.waitpid(self.emulator_proc.pid, 0) # Clean up emulator image - if self.model_t: + if self.model == 't': emulator_img = "/var/tmp/trezor.flash" - else: + else: # self.model == '1' emulator_img = os.path.dirname(self.emulator_path) + "/emulator.img" if os.path.isfile(emulator_img): os.unlink(emulator_img) + if self.emulator_log is not None: + self.emulator_log.close() + self.emulator_log = None + + # Wait a second for everything to be cleaned up before going to the next test + time.sleep(1) + + atexit.unregister(self.stop) + class TrezorTestCase(unittest.TestCase): def __init__(self, emulator, interface='library', methodName='runTest'): super(TrezorTestCase, self).__init__(methodName) @@ -112,30 +152,30 @@ def do_command(self, args): return process_commands(args) def __str__(self): - return 'trezor 1: {}'.format(super().__str__()) + return 'trezor_{}: {}'.format(self.emulator.model, super().__str__()) def __repr__(self): - return 'trezor 1: {}'.format(super().__repr__()) + return 'trezor_{}: {}'.format(self.emulator.model, super().__repr__()) -# Trezor specific getxpub test because this requires device specific thing to set xprvs -class TestTrezorGetxpub(TrezorTestCase): def setUp(self): self.client = self.emulator.start() def tearDown(self): self.emulator.stop() +# Trezor specific getxpub test because this requires device specific thing to set xprvs +class TestTrezorGetxpub(TrezorTestCase): def test_getxpub(self): with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'data/bip32_vectors.json'), encoding='utf-8') as f: vectors = json.load(f) for vec in vectors: with self.subTest(vector=vec): - # Setup with xprv + # Setup with mnemonic device.wipe(self.client) - load_device_by_xprv(client=self.client, xprv=vec['xprv'], pin='', passphrase_protection=False, label='test', language='english') + load_device_by_mnemonic(client=self.client, mnemonic=vec['mnemonic'], pin='', passphrase_protection=False, label='test', language='english') # Test getmasterxpub - gmxp_res = self.do_command(['-t', 'trezor', '-d', 'udp:127.0.0.1:21324', 'getmasterxpub']) + gmxp_res = self.do_command(['-t', 'trezor', '-d', 'udp:127.0.0.1:21324', 'getmasterxpub', "--addr-type", "legacy"]) self.assertEqual(gmxp_res['xpub'], vec['master_xpub']) # Test the path derivs @@ -143,15 +183,37 @@ def test_getxpub(self): gxp_res = self.do_command(['-t', 'trezor', '-d', 'udp:127.0.0.1:21324', 'getxpub', path_vec['path']]) self.assertEqual(gxp_res['xpub'], path_vec['xpub']) + def test_expert_getxpub(self): + result = self.do_command(['-t', 'trezor', '-d', 'udp:127.0.0.1:21324', '--expert', 'getxpub', 'm/44h/0h/0h/3']) + self.assertEqual(result['xpub'], 'xpub6FMafWAi3n3ET2rU5yQr16UhRD1Zx4dELmcEw3NaYeBaNnipcr2zjzYp1sNdwR3aTN37hxAqRWQ13AWUZr6L9jc617mU6EvgYXyBjXrEhgr') + self.assertFalse(result['testnet']) + self.assertFalse(result['private']) + self.assertEqual(result['depth'], 4) + self.assertEqual(result['parent_fingerprint'], 'f7e318db') + self.assertEqual(result['child_num'], 3) + self.assertEqual(result['chaincode'], '95a7fb33c4f0896f66045cd7f45ed49a9e72372d2aed204ad0149c39b7b17905') + self.assertEqual(result['pubkey'], '022e6d9c18e5a837e802fb09abe00f787c8ccb0fc489c6ec5dc2613d930efd7eae') + +class TestTrezorLabel(TrezorTestCase): + def setUp(self): + self.client = self.emulator.start() + self.dev_args = ['-t', 'trezor', '-d', 'udp:127.0.0.1:21324'] + + def test_label(self): + result = self.do_command(self.dev_args + ['enumerate']) + for dev in result: + if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': + self.assertEqual(dev['label'], 'test') + break + else: + self.fail("Did not enumerate device") + # Trezor specific management (setup, wipe, restore, backup, promptpin, sendpin) command tests class TestTrezorManCommands(TrezorTestCase): def setUp(self): self.client = self.emulator.start() self.dev_args = ['-t', 'trezor', '-d', 'udp:127.0.0.1:21324'] - def tearDown(self): - self.emulator.stop() - def test_setup_wipe(self): # Device is init, setup should fail result = self.do_command(self.dev_args + ['-i', 'setup']) @@ -166,14 +228,32 @@ def test_setup_wipe(self): t_client = TrezorClient('udp:127.0.0.1:21324', 'test') t_client.client.ui.get_pin = MethodType(get_pin, t_client.client.ui) t_client.client.ui.pin = '1234' - result = t_client.setup_device() - self.assertTrue(result['success']) + result = t_client.setup_device(label='HWI Trezor') + self.assertTrue(result) # Make sure device is init, setup should fail result = self.do_command(self.dev_args + ['-i', 'setup']) self.assertEquals(result['code'], -10) self.assertEquals(result['error'], 'Device is already initialized. Use wipe first and try again') + def test_label(self): + result = self.do_command(self.dev_args + ['wipe']) + self.assertTrue(result['success']) + + t_client = TrezorClient('udp:127.0.0.1:21324', 'test') + t_client.client.ui.get_pin = MethodType(get_pin, t_client.client.ui) + t_client.client.ui.pin = '1234' + result = t_client.setup_device(label='HWI Trezor') + self.assertTrue(result) + + result = self.do_command(self.dev_args + ['enumerate']) + for dev in result: + if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': + self.assertEqual(dev['label'], 'HWI Trezor') + break + else: + self.fail("Did not enumerate device") + def test_backup(self): result = self.do_command(self.dev_args + ['backup']) self.assertIn('error', result) @@ -193,15 +273,22 @@ def test_pins(self): for dev in result: if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': self.assertFalse(dev['needs_pin_sent']) + break + else: + self.fail("Did not enumerate device") # Set a PIN device.wipe(self.client) - load_device_by_mnemonic(client=self.client, mnemonic='alcohol woman abuse must during monitor noble actual mixed trade anger aisle', pin='1234', passphrase_protection=False, label='test') - self.client.call(messages.ClearSession()) + load_device_by_mnemonic(client=self.client, mnemonic='alcohol woman abuse must during monitor noble actual mixed trade anger aisle', pin='1234', passphrase_protection=True, label='test') + self.client.lock(_refresh_features=False) + self.client.end_session() result = self.do_command(self.dev_args + ['enumerate']) for dev in result: if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': self.assertTrue(dev['needs_pin_sent']) + break + else: + self.fail("Did not enumerate device") result = self.do_command(self.dev_args + ['promptpin']) self.assertTrue(result['success']) @@ -210,7 +297,7 @@ def test_pins(self): self.assertEqual(result['error'], 'Non-numeric PIN provided') self.assertEqual(result['code'], -7) - result = self.do_command(self.dev_args + ['sendpin', '00000']) + result = self.do_command(self.dev_args + ['sendpin', '1111']) self.assertFalse(result['success']) # Make sure we get a needs pin message @@ -219,20 +306,23 @@ def test_pins(self): self.assertEqual(result['error'], 'Trezor is locked. Unlock by using \'promptpin\' and then \'sendpin\'.') # Prompt pin - self.client.call(messages.ClearSession()) + self.client.call(messages.EndSession()) result = self.do_command(self.dev_args + ['promptpin']) self.assertTrue(result['success']) # Send the PIN self.client.open() pin = self.client.debug.encode_pin('1234') - result = self.do_command(self.dev_args + ['sendpin', pin]) + result = self.do_command(self.dev_args + ["-p", "asdf", 'sendpin', pin]) self.assertTrue(result['success']) result = self.do_command(self.dev_args + ['enumerate']) for dev in result: if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': self.assertFalse(dev['needs_pin_sent']) + break + else: + self.fail("Did not enumerate device") # Sending PIN after unlock result = self.do_command(self.dev_args + ['promptpin']) @@ -243,42 +333,44 @@ def test_pins(self): self.assertEqual(result['code'], -11) def test_passphrase(self): - # There's no passphrase + # Enable passphrase + self.do_command(self.dev_args + ['togglepassphrase']) + + # A passphrase will need to be sent result = self.do_command(self.dev_args + ['enumerate']) for dev in result: if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': - self.assertFalse(dev['needs_passphrase_sent']) - self.assertEquals(dev['fingerprint'], '95d8f670') - # Setting a passphrase won't change the fingerprint + self.assertIn("warnings", dev) + break + else: + self.fail("Did not enumerate device") result = self.do_command(self.dev_args + ['-p', 'pass', 'enumerate']) for dev in result: if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': self.assertFalse(dev['needs_passphrase_sent']) - self.assertEquals(dev['fingerprint'], '95d8f670') - - # Set a passphrase - device.wipe(self.client) - load_device_by_mnemonic(client=self.client, mnemonic='alcohol woman abuse must during monitor noble actual mixed trade anger aisle', pin='', passphrase_protection=True, label='test') - self.client.call(messages.ClearSession()) - - # A passphrase will need to be sent - result = self.do_command(self.dev_args + ['enumerate']) - for dev in result: - if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': - self.assertTrue(dev['needs_passphrase_sent']) - result = self.do_command(self.dev_args + ['-p', 'pass', 'enumerate']) + fpr = dev['fingerprint'] + break + else: + self.fail("Did not enumerate device") + result = self.do_command(self.dev_args + ['-p', '\"\"', 'enumerate']) for dev in result: if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': self.assertFalse(dev['needs_passphrase_sent']) fpr = dev['fingerprint'] + break + else: + self.fail("Did not enumerate device") - if self.emulator.model_t: + if self.emulator.model == 't': # Trezor T: A different passphrase would not change the fingerprint result = self.do_command(self.dev_args + ['-p', 'pass2', 'enumerate']) for dev in result: if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': self.assertFalse(dev['needs_passphrase_sent']) self.assertEqual(dev['fingerprint'], fpr) + break + else: + self.fail("Did not enumerate device") else: # Trezor 1: A different passphrase will change the fingerprint result = self.do_command(self.dev_args + ['-p', 'pass2', 'enumerate']) @@ -286,6 +378,9 @@ def test_passphrase(self): if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': self.assertFalse(dev['needs_passphrase_sent']) self.assertNotEqual(dev['fingerprint'], fpr) + break + else: + self.fail("Did not enumerate device") # Clearing the session and starting a new one with a new passphrase should change the passphrase self.client.call(messages.Initialize()) @@ -294,49 +389,76 @@ def test_passphrase(self): if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': self.assertFalse(dev['needs_passphrase_sent']) self.assertNotEqual(dev['fingerprint'], fpr) + break + else: + self.fail("Did not enumerate device") + + # Disable passphrase + self.do_command(self.dev_args + ['togglepassphrase']) + + # There's no passphrase + result = self.do_command(self.dev_args + ['enumerate']) + for dev in result: + if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': + self.assertFalse(dev['needs_passphrase_sent']) + self.assertEquals(dev['fingerprint'], '95d8f670') + break + else: + self.fail("Did not enumerate device") + # Setting a passphrase won't change the fingerprint + result = self.do_command(self.dev_args + ['-p', 'pass', 'enumerate']) + for dev in result: + if dev['type'] == 'trezor' and dev['path'] == 'udp:127.0.0.1:21324': + self.assertFalse(dev['needs_passphrase_sent']) + self.assertEquals(dev['fingerprint'], '95d8f670') + break + else: + self.fail("Did not enumerate device") -def trezor_test_suite(emulator, rpc, userpass, interface, model_t=False): +def trezor_test_suite(emulator, bitcoind, interface, model): + assert model in TREZOR_MODELS # Redirect stderr to /dev/null as it's super spammy sys.stderr = open(os.devnull, 'w') - # Device info for tests - type = 'trezor' - path = 'udp:127.0.0.1:21324' - fingerprint = '95d8f670' - master_xpub = 'xpub6D1weXBcFAo8CqBbpP4TbH5sxQH8ZkqC5pDEvJ95rNNBZC9zrKmZP2fXMuve7ZRBe18pWQQsGg68jkq24mZchHwYENd8cCiSb71u3KD4AFH' - dev_emulator = TrezorEmulator(emulator, model_t) + dev_emulator = TrezorEmulator(emulator, model) - if model_t: - full_type = 'trezor_t' - else: - full_type = 'trezor_1' + signtx_cases = [ + (["legacy"], ["legacy"], False, True), + (["segwit"], ["segwit"], False, True), + (["tap"], [], False, True), + (["legacy", "segwit"], ["legacy", "segwit"], False, True), + (["legacy", "segwit", "tap"], ["legacy", "segwit"], False, True), + ] # Generic Device tests suite = unittest.TestSuite() - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestSignTx, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(DeviceTestCase.parameterize(TestSignMessage, rpc, userpass, type, full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - if not model_t: - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, 'trezor_1_simulator', full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - suite.addTest(TrezorTestCase.parameterize(TestTrezorGetxpub, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type="trezor")) + suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignTx, bitcoind, emulator=dev_emulator, interface=interface, signtx_cases=signtx_cases)) + suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, bitcoind, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignMessage, bitcoind, emulator=dev_emulator, interface=interface)) + if model != 't': suite.addTest(TrezorTestCase.parameterize(TestTrezorManCommands, emulator=dev_emulator, interface=interface)) - else: - suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, 'trezor_t_simulator', full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) - return suite + suite.addTest(TrezorTestCase.parameterize(TestTrezorLabel, emulator=dev_emulator, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, bitcoind, emulator=dev_emulator, interface=interface, detect_type=f"trezor_{model}_simulator")) + suite.addTest(TrezorTestCase.parameterize(TestTrezorGetxpub, emulator=dev_emulator, interface=interface)) + + result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + sys.stderr = sys.__stderr__ + return result.wasSuccessful() if __name__ == '__main__': parser = argparse.ArgumentParser(description='Test Trezor implementation') parser.add_argument('emulator', help='Path to the Trezor emulator') parser.add_argument('bitcoind', help='Path to bitcoind binary') parser.add_argument('--interface', help='Which interface to send commands over', choices=['library', 'cli', 'bindist'], default='library') - parser.add_argument('--model_t', help='The emulator is for the Trezor T', action='store_true') + group = parser.add_argument_group() + group.add_argument('--model_1', help='The emulator is for the Trezor One', action='store_const', const='1', dest='model') + group.add_argument('--model_t', help='The emulator is for the Trezor T', action='store_const', const='t', dest='model') args = parser.parse_args() # Start bitcoind - rpc, userpass = start_bitcoind(args.bitcoind) + bitcoind = Bitcoind.create(args.bitcoind) - suite = trezor_test_suite(args.emulator, rpc, userpass, args.interface, args.model_t) - unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + sys.exit(not trezor_test_suite(args.emulator, bitcoind, args.interface, args.model)) diff --git a/test/test_udevrules.py b/test/test_udevrules.py index 07e88d2de..110b2422e 100755 --- a/test/test_udevrules.py +++ b/test/test_udevrules.py @@ -3,7 +3,7 @@ import unittest import filecmp from os import makedirs, remove, removedirs, walk, path -from hwilib.cli import process_commands +from hwilib._cli import process_commands class TestUdevRulesInstaller(unittest.TestCase): INSTALLATION_FOLDER = 'rules.d' @@ -16,19 +16,15 @@ def setUpClass(cls): @classmethod def tearDownClass(self): - for root, dirs, files in walk(self.INSTALLATION_FOLDER, topdown=False): + for root, _, files in walk(self.INSTALLATION_FOLDER, topdown=False): for name in files: remove(path.join(root, name)) removedirs(self.INSTALLATION_FOLDER) def test_rules_file_are_copied(self): - result = process_commands(['installudevrules', '--location', self.INSTALLATION_FOLDER]) - self.assertIn('error', result) - self.assertIn('code', result) - self.assertEqual(result['error'], 'Need to be root.') - self.assertEqual(result['code'], -16) + process_commands(['installudevrules', '--location', self.INSTALLATION_FOLDER]) # Assert files wre copied - for root, dirs, files in walk(self.INSTALLATION_FOLDER, topdown=False): + for _, _, files in walk(self.INSTALLATION_FOLDER, topdown=False): for file_name in files: src = path.join(self.SOURCE_FOLDER, file_name) tgt = path.join(self.INSTALLATION_FOLDER, file_name)