diff --git a/.cirrus.yml b/.cirrus.yml new file mode 100644 index 000000000..e6ce5cc66 --- /dev/null +++ b/.cirrus.yml @@ -0,0 +1,302 @@ +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 + - syscoind_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/syscoind_builder/syscoin/syscoin.tar.gz" + - tar -xvf "syscoin.tar.gz" + - env: + DEVICE: --trezor-t + depends_on: + - Trezor T Sim Builder + - dist_builder + - syscoind_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/syscoind_builder/syscoin/syscoin.tar.gz" + - tar -xvf "syscoin.tar.gz" + - env: + DEVICE: --coldcard + depends_on: + - Coldcard Sim Builder + - dist_builder + - syscoind_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/syscoind_builder/syscoin/syscoin.tar.gz" + - tar -xvf "syscoin.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 + - syscoind_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/syscoind_builder/syscoin/syscoin.tar.gz" + - tar -xvf "syscoin.tar.gz" + - env: + DEVICE: --jade + depends_on: + - Jade Sim Builder + - dist_builder + - syscoind_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/syscoind_builder/syscoin/syscoin.tar.gz" + - tar -xvf "syscoin.tar.gz" + - env: + DEVICE: --ledger + depends_on: + - Ledger Sim Builder + - dist_builder + - syscoind_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/syscoind_builder/syscoin/syscoin.tar.gz" + - tar -xvf "syscoin.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 + - syscoind_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/syscoind_builder/syscoin/syscoin.tar.gz" + - tar -xvf "syscoin.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 {} \; + +syscoind_builder_task: + syscoind_cache: + folder: test/work/syscoin + ccache_cache: + folder: /root/.ccache + env: + BUILD_SYSCOIND: 1 + build_script: + - cd test; ./setup_environment.sh --syscoind; cd .. + - tar -czf syscoin.tar.gz test/work/syscoin + syscoin_artifacts: + path: "syscoin.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/*" + +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.6 + - container: + dockerfile: ci/py37.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..42c2c2bc5 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/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 ef633664a..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 syscoind - - 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 b61b04bd5..90b658a12 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,26 @@ # Syscoin Hardware Wallet Interface -[![Build Status](https://travis-ci.org/syscoin/HWI.svg?branch=master)](https://travis-ci.org/syscoin/HWI) +[![Build Status](https://api.cirrus-ci.com/github/syscoin/HWI.svg)](https://cirrus-ci.com/github/syscoin/HWI) +[![Documentation Status](https://readthedocs.org/projects/hwi/badge/?version=latest)](https://hwi.readthedocs.io/en/latest/?badge=latest) The Syscoin 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/syscoin/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/syscoin/HWI.git -cd HWI +pip3 install -U setuptools +python3 setup.py install ``` +## Dependencies + +See `pyproject.toml` for all dependencies. Dependencies under `[tool.poetry.dependecies]` 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,37 +77,23 @@ 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 Syscoin Core +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 Syscoin Core See [Using Syscoin Core with Hardware Wallets](docs/syscoin-core-usage.md). diff --git a/ci/cirrus.Dockerfile b/ci/cirrus.Dockerfile new file mode 100644 index 000000000..ca06db1ec --- /dev/null +++ b/ci/cirrus.Dockerfile @@ -0,0 +1,87 @@ +FROM python:3.6 + +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 \ + libsdl2-dev \ + libsdl2-image-dev \ + libssl-dev \ + libtool \ + libudev-dev \ + libusb-1.0-0-dev \ + ninja-build \ + pkg-config \ + protobuf-compiler \ + qemu-user-static + +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" + +#################### +# 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 --syscoind +# +## 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/py310.Dockerfile b/ci/py310.Dockerfile new file mode 100644 index 000000000..10fa63d01 --- /dev/null +++ b/ci/py310.Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.10 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update +RUN apt-get install -y \ + cython3 \ + git \ + libsdl2-dev \ + libsdl2-image-dev \ + libudev-dev \ + libusb-1.0-0-dev \ + qemu-user-static + +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/py37.Dockerfile b/ci/py37.Dockerfile new file mode 100644 index 000000000..0006a7c2e --- /dev/null +++ b/ci/py37.Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.7 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update +RUN apt-get install -y \ + cython3 \ + git \ + libsdl2-dev \ + libsdl2-image-dev \ + libudev-dev \ + libusb-1.0-0-dev \ + qemu-user-static + +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..2436a6147 --- /dev/null +++ b/ci/py38.Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.8 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update +RUN apt-get install -y \ + cython3 \ + git \ + libsdl2-dev \ + libsdl2-image-dev \ + libudev-dev \ + libusb-1.0-0-dev \ + qemu-user-static + +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..a4946b434 --- /dev/null +++ b/ci/py39.Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.9 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update +RUN apt-get install -y \ + cython3 \ + git \ + libsdl2-dev \ + libsdl2-image-dev \ + libudev-dev \ + libusb-1.0-0-dev \ + qemu-user-static + +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..f060ef002 100755 --- a/contrib/build_bin.sh +++ b/contrib/build_bin.sh @@ -1,28 +1,25 @@ #! /bin/bash # Script for building standalone binary releases deterministically -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 - -# 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 '{}' \; -fi +poetry install -E qt # 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 +poetry run contrib/generate-ui.sh +poetry run pyinstaller hwi-qt.spec unset PYTHONHASHSEED # Make the final compressed package @@ -32,5 +29,13 @@ 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" +tar -czf $target_tarfile hwi hwi-qt + +# Copy the binaries to subdir for shasum +target_dir="$target_tarfile.dir" +mkdir $target_dir +mv hwi $target_dir +mv hwi-qt $target_dir + popd diff --git a/contrib/build_dist.sh b/contrib/build_dist.sh index b6b7d6b75..6edc4681f 100755 --- a/contrib/build_dist.sh +++ b/contrib/build_dist.sh @@ -1,13 +1,15 @@ #! /bin/bash # Script for building pypi distribution archives deterministically -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 +poetry install -E qt # 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/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..6fe1dbad8 --- /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.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 `_ 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..911bd1415 --- /dev/null +++ b/docs/devices/index.rst @@ -0,0 +1,103 @@ +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 | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ | ++------------------------------------+---------------+---------------+------------+----------------+----------+----------+---------+----------+------------------+ +| Arbitrary scriptPubKey Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ | ++------------------------------------+---------------+---------------+------------+----------------+----------+----------+---------+----------+------------------+ +| Arbitrary redeemScript Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ | ++------------------------------------+---------------+---------------+------------+----------------+----------+----------+---------+----------+------------------+ +| Arbitrary witnessScript Inputs | ✓ | ✓ | ― | ― | ✓ | ― | ― | ― | ✓ | ++------------------------------------+---------------+---------------+------------+----------------+----------+----------+---------+----------+------------------+ +| Non-wallet inputs | ✓ | ✓ | ✓ | ✓ | ✓ | ― | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+------------+----------------+----------+----------+---------+----------+------------------+ +| Mixed Segwit and Non-Segwit Inputs | ― | ― | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+------------+----------------+----------+----------+---------+----------+------------------+ +| Display on device screen | ✓ | ✓ | ✓ | ✓ | ― | ✓ | ✓ | ✓ | ✓ | ++------------------------------------+---------------+---------------+------------+----------------+----------+----------+---------+----------+------------------+ + +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/examples.md b/docs/examples.md deleted file mode 100644 index f364edd12..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 - -Syscoin 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/syscoin/syscoin/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/57h/0h | /0/* and /1/* | pkh() | -| BIP49 | m/49h/57h/0h | /0/* and /1/* | sh(wpkh()) | -| BIP84 | m/84h/57h/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/syscoin/bips/blob/master/bip-0044.mediawiki) - -1. To obtain the xpub relative to the last hardened level (m/44h/57h/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/57h/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 Syscoin Core v0.17.0. - -``` -syscoin-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/syscoin/bips/blob/master/bip-0049.mediawiki) - -1. To obtain the xpub relative to the last hardened level (m/49h/57h/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/57h/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 Syscoin Core v0.17.0. - -``` -syscoin-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/syscoin/bips/blob/master/bip-0084.mediawiki) - -1. To obtain the xpub relative to the last hardened level (m/84h/57h/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/57h/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 Syscoin Core v0.17.0. - -``` -syscoin-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..e5d92a92f --- /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 +================ + +Syscoin 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/57h/0h | /0/* and /1/* | pkh() | ++-------------+---------------+--------------------+-------------+ +| BIP49 | m/49h/57h/0h | /0/* and /1/* | sh(wpkh()) | ++-------------+---------------+--------------------+-------------+ +| BIP84 | m/84h/57h/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/57h/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/57h/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 Syscoin Core v0.17.0. + +:: + + syscoin-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/57h/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/57h/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 Syscoin Core v0.17.0. + +:: + + syscoin-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/57h/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/57h/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 Syscoin Core v0.17.0. + +:: + + syscoin-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..c42d36dac --- /dev/null +++ b/docs/examples/index.rst @@ -0,0 +1,9 @@ +Example Usage +============= + +.. toctree:: + :maxdepth: 1 + + sysoin-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..5a8ef3efe --- /dev/null +++ b/docs/examples/syscoin-core-usage.rst @@ -0,0 +1,286 @@ +Using Syscoin Core with Hardware Wallets +**************************************** + +This approach is fairly manual, requires the command line, and Syscoin Core >=4.3.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 +-------- + +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.1.0. +However this guide will require Syscoin Core >=4.3.0 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": "coldcard", "model": "coldcard", "path": "0003:0005:00", "needs_pin_sent": false, "needs_passphrase_sent": false, "fingerprint": "e5dbc9cb"}] + +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 ``e5dbc9cb``. 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 e5dbc9cb getkeypool 0 1000 + [{"desc": "wpkh([e5dbc9cb/84'/57'/0']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/0/*)#cwyap6p3", "range": [0, 1000], "timestamp": "now", "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([e5dbc9cb/84'/57'/0']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/1/*)#f6puu03f", "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=hwicoldcard disable_private_keys=true descriptors=true + { + "name": "hwicoldcard", + "warning": "Wallet is an experimental descriptor wallet" + } + $ ../syscoin/src/syscoin-cli -rpcwallet=hwicoldcard importdescriptors '[{"desc": "wpkh([e5dbc9cb/84\'/57\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/0/*)#cwyap6p3", "range": [0, 1000], "timestamp": "now", "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([e5dbc9cb/84\'/57\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/1/*)#f6puu03f", "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=hwicoldcard importdescriptors '[{"desc": "wpkh([e5dbc9cb/84\'/57\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/0/*)#cwyap6p3", "range": [0, 1000], "timestamp": , "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([e5dbc9cb/84\'/57\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/1/*)#f6puu03f", "range": [0, 1000], "timestamp": , "internal": true, "keypool": true, "active": true, "watchonly": true}]' + $ ../syscoin/src/syscoin-cli -rpcwallet=hwicoldcard importdescriptors '[{"desc": "wpkh([e5dbc9cb/84\'/57\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/0/*)#cwyap6p3", "range": [0, 1000], "timestamp": 500000, "internal": false, "keypool": true, "active": true, "watchonly": true}, {"desc": "wpkh([e5dbc9cb/84\'/57\'/0\']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/1/*)#f6puu03f", "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=hwicoldcard getnewaddress + bc1q2xsn08w749d2tfm7qrkvztlxfmq2564sly4dwl + +This address belongs to your hardware wallet. You can check this by doing ``getaddressinfo``:: + + $ src/syscoin-cli -rpcwallet=hwicoldcard getaddressinfo bc1q2xsn08w749d2tfm7qrkvztlxfmq2564sly4dwl + { + "address": "bc1q2xsn08w749d2tfm7qrkvztlxfmq2564sly4dwl", + "scriptPubKey": "001451a1379ddea95aa5a77e00ecc12fe64ec0aa6ab0", + "ismine": true, + "solvable": true, + "desc": "wpkh([e5dbc9cb/84'/57'/0'/0/0]0325ccb1f60a3d0640cbc3bfa1cefc34512d50c32d0e7c102b62e18f23ab69fbc5)#je3ch2kg", + "parent_desc": "wpkh([e5dbc9cb/84'/57'/0']xpub6CbtS57jivMSuzcvp5YZxp6JhUU8YWup2axi2xkQRVHY8w4otp8YkEvfWBHgE5rA2AJYNHquuRoLFFdWeSi1UgVohcUeM7SkE9c8NftRwRJ/0/*)#cwyap6p3", + "iswatchonly": false, + "isscript": false, + "iswitness": true, + "witness_version": 0, + "witness_program": "51a1379ddea95aa5a77e00ecc12fe64ec0aa6ab0", + "pubkey": "0325ccb1f60a3d0640cbc3bfa1cefc34512d50c32d0e7c102b62e18f23ab69fbc5", + "ischange": false, + "timestamp": 1614190663, + "hdkeypath": "m/84'/57'/0'/0/0", + "hdseedid": "0000000000000000000000000000000000000000", + "hdmasterfingerprint": "e5dbc9cb", + "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 +======= + +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 1 BTC to bc1q257z5t76hedc36wmmzva05890ny3kxd7xfwrgy. First I create a funded psbt with BIP 32 derivation paths to be included:: + + $ src/syscoin-cli -rpcwallet=hwicoldcard 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/syscoin-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 e5dbc9cb --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 Syscoin Core + +:: + + $ cd ../syscoin + $ src/syscoin-cli finalizepsbt cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgICIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9RIMEUCIQDMECVXsrFK5XbMQn5yVCvm3zWF1kdCgepf3RSqFDDmAAIgQtty07rN4zBWMjd1qVOtkgOHBAlGaO2Se3LkiNsABYcBAQMEAQAAACIGAiMg8c9y57os72vjLXSTzjvUxqJXX+Uc4mA3etwWVgPUGIA47NlUAACAAQAAgAAAAIAAAAAAAAAAAAAAIgID9BzENiuvd8wl0wrnQVM3pg4cS5hRhEzpwFe74A89q/UYgDjs2VQAAIABAACAAAAAgAEAAAAAAAAAAA== + { + "hex": "020000000001014f0a5a4094ec7e0c6018991e62decba15abd5dcbdf12dfcb8b55119a2e6f1fb60000000000feffffff0200e1f50500000000160014553c2a2fdabe5b88e9dbd899d7d0e57cc91b19befc78d71700000000160014b1ee5f7591b8fb37ca97903b388dc39a859411fc02483045022100cc102557b2b14ae576cc427e72542be6df3585d6474281ea5fdd14aa1430e600022042db72d3bacde33056323775a953ad92038704094668ed927b72e488db0005870121022320f1cf72e7ba2cef6be32d7493ce3bd4c6a2575fe51ce260377adc165603d400000000", + "complete": true + } + $ src/syscoin-cli sendrawtransaction 020000000001014f0a5a4094ec7e0c6018991e62decba15abd5dcbdf12dfcb8b55119a2e6f1fb60000000000feffffff0200e1f50500000000160014553c2a2fdabe5b88e9dbd899d7d0e57cc91b19befc78d71700000000160014b1ee5f7591b8fb37ca97903b388dc39a859411fc02483045022100cc102557b2b14ae576cc427e72542be6df3585d6474281ea5fdd14aa1430e600022042db72d3bacde33056323775a953ad92038704094668ed927b72e488db0005870121022320f1cf72e7ba2cef6be32d7493ce3bd4c6a2575fe51ce260377adc165603d400000000 + e51392c82e13bbfe714c73361aff14ac1a1637abf37587a562844ae5a4265adf + +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..5cb69d420 --- /dev/null +++ b/docs/examples/walkthrough/walkthrough.rst @@ -0,0 +1,188 @@ +Walkthrough: Using a Hardware Wallet with Syscoin 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 Syscoin Core Wallet. As hardware wallet example we have used a Trezor. + +Create a watch-only Syscoin Core wallet for Trezor +================================================== + +Create your watch-only Syscoin Core Wallet as described in `Using Syscoin Core with Hardware Wallets <../syscoin-core-usage.rst>`_. +You find all the details well described in this link. But in summary, one opens a terminal and runs ``syscoind``. E.g. + +:: + + syscoind -testnet -datadir=$HOME/.syscoin-testnet + +for a testnet ``syscoind`` daemon, or + +:: + + syscoind + +for a mainnet, i.e. regular, ``syscoind`` 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/57h/0h/1/* + DERIVATIONPATH_MAINNET=0 # mainnet uses derivation paths like m/84h/57h/0h/0/* and m/84h/57h/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/57h/0/* --keypool 0 1000) + chg=$(hwi --testnet -f $FINGERPRINT_TESTNET getkeypool --addr-type wit --path m/84h/${DERIVATIONPATH_TESTNET}h/57h/1/* --keypool --internal 0 1000) + syscoin-cli -testnet createwallet "$wallet" true + syscoin-cli -testnet -rpcwallet="$wallet" importmulti "$rec" + syscoin-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." + syscoin-cli -testnet -rpcwallet="$wallet" rescanblockchain # full rescan + # after rescan unload wallet + syscoin-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 Syscoin Core wallet has been created, stop ``syscoind`` with control-C. We are ready to use the wallet. + +Send funds with Syscoin Core and Trezor using HWI +================================================= + +* our example does everything on the Syscoin 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 Syscoin Core wallet, e.g. ``syscoin-qt -testnet`` (or ``syscoin-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/57h/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/57h/0h/1/0" (testnet derivation path) (or "m/84h/57h/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 Syscoin Core Wallet + you can rest assured that the wallet has been created correctly and + that the Syscoin Core wallet corresponds to your Trezor device. + +* Now let us **send funds**. + +* CORE: To send funds, open the "Send" tab in Syscoin Core Wallet, + then select input, amount, fees, etc. Once satisifed, click "Create Unsigned", + verify any displayed information, then click "Create Unsigned" again. + The PSBT (Partially Signed Syscoin 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 + Syscoin 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 Syscoin 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 Syscoin 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 Janary 2021 +* HWI version 2.0.0-dev +* Syscoin 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/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/syscoin-core-usage.md b/docs/syscoin-core-usage.md deleted file mode 100644 index 98a19e7f3..000000000 --- a/docs/syscoin-core-usage.md +++ /dev/null @@ -1,294 +0,0 @@ -# Using Syscoin Core with Hardware Wallets - -This approach is fairly manual, requires the command line, and Syscoin Core >=4.1.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 - -### 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 as of commit [c576979b78b541bf3b4a7cbeee989b55d268e3e1](https://github.com/syscoin/syscoin/commit/c576979b78b541bf3b4a7cbeee989b55d268e3e1). It is usable with Syscoin Core >=4.1.0. - -## 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 -[{"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/57h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/0/*)#36sal9a4", "internal": false, "range": [0, 1000], "timestamp": "now", "keypool": true, "watchonly": true}, {"desc": "wpkh([8038ecd9/84h/57h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/1/*)#nl2rc26w", "internal": true, "range": [0, 1000], "timestamp": "now", "keypool": true, "watchonly": true}] -``` - -We now create a new Syscoin Core 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 createwallet "coldcard" true -{ - "name": "coldcard", - "warning": "" -} -$ ../syscoin/src/syscoin-cli -rpcwallet=coldcard importmulti '[{"desc": "wpkh([8038ecd9/84h/57h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/0/*)#36sal9a4", "internal": false, "range": [0, 1000], "timestamp": "now", "keypool": true, "watchonly": true}, {"desc": "wpkh([8038ecd9/84h/57h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/1/*)#nl2rc26w", "internal": true, "range": [0, 1000], "timestamp": "now", "keypool": 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 `importmulti` 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=coldcard importmulti '[{"internal": true, "timestamp": , "desc": "wpkh([8038ecd9/84h/57h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/1/*)#qw4uzsdd", "keypool": true, "range": [0, 1000], "watchonly": true}]' -$ ../syscoin/src/syscoin-cli -rpcwallet=coldcard importmulti '[{"internal": true, "timestamp": 500000, "desc": "wpkh([8038ecd9/84h/57h/0h]xpub6DR4rqx16YnCcfwFqgwvJdKiWrjDRzqxYTY44aoyHwZDSeSB5n2tqt42aYr9qPKhSKUdftPdTjhHrKKD6WGKVbuyhMvGH76VyKKZubg8o4P/1/*)#qw4uzsdd", "keypool": true, "range": [0, 1000], "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=coldcard getnewaddress -scrt1qu8qe24zq5e2ahh4nkl6g5ysxlpn3nyf0xt026s -``` - -This address belongs to your hardware wallet. You can check this by doing `getaddressinfo`: - -``` -$ src/syscoin-cli -rpcwallet=coldcard getaddressinfo scrt1qu8qe24zq5e2ahh4nkl6g5ysxlpn3nyf0xt026s -{ - "address": "scrt1qu8qe24zq5e2ahh4nkl6g5ysxlpn3nyf0xt026s", - "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 Syscoin Core wallet as watch-only. - -## Sending - -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 1 SYS to sys1q257z5t76hedc36wmmzva05890ny3kxd7xfwrgy. First I create a funded psbt with BIP 32 derivation paths to be included: -``` -$ src/syscoin-cli -rpcwallet=coldcard walletcreatefundedpsbt '[]' '[{"sys1q257z5t76hedc36wmmzva05890ny3kxd7xfwrgy":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/syscoin-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": [ - "sys1q257z5t76hedc36wmmzva05890ny3kxd7xfwrgy" - ] - } - }, - { - "value": 3.99997180, - "n": 1, - "scriptPubKey": { - "asm": "0 b1ee5f7591b8fb37ca97903b388dc39a859411fc", - "hex": "0014b1ee5f7591b8fb37ca97903b388dc39a859411fc", - "reqSigs": 1, - "type": "witness_v0_keyhash", - "addresses": [ - "sys1qk8h97av3hran0j5hjqan3rwrn2zegy0unusy49" - ] - } - } - ] - }, - "unknown": { - }, - "inputs": [ - { - "witness_utxo": { - "amount": 5.00000000, - "scriptPubKey": { - "asm": "0 e1c1955440a655dbdeb3b7f48a1206f86719912f", - "hex": "0014e1c1955440a655dbdeb3b7f48a1206f86719912f", - "type": "witness_v0_keyhash", - "address": "sys1qu8qe24zq5e2ahh4nkl6g5ysxlpn3nyf0wyd5k2" - } - }, - "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 Syscoin Core - -``` -$ cd ../syscoin -$ src/syscoin-cli finalizepsbt cHNidP8BAHECAAAAAU8KWkCU7H4MYBiZHmLey6FavV3L3xLfy4tVEZoubx+2AAAAAAD+////AgDh9QUAAAAAFgAUVTwqL9q+W4jp29iZ19DlfMkbGb78eNcXAAAAABYAFLHuX3WRuPs3ypeQOziNw5qFlBH8AAAAAAABAR8AZc0dAAAAABYAFOHBlVRAplXb3rO39IoSBvhnGZEvIgICIyDxz3Lnuizva+MtdJPOO9TGoldf5RziYDd63BZWA9RIMEUCIQDMECVXsrFK5XbMQn5yVCvm3zWF1kdCgepf3RSqFDDmAAIgQtty07rN4zBWMjd1qVOtkgOHBAlGaO2Se3LkiNsABYcBAQMEAQAAACIGAiMg8c9y57os72vjLXSTzjvUxqJXX+Uc4mA3etwWVgPUGIA47NlUAACAAQAAgAAAAIAAAAAAAAAAAAAAIgID9BzENiuvd8wl0wrnQVM3pg4cS5hRhEzpwFe74A89q/UYgDjs2VQAAIABAACAAAAAgAEAAAAAAAAAAA== -{ - "hex": "020000000001014f0a5a4094ec7e0c6018991e62decba15abd5dcbdf12dfcb8b55119a2e6f1fb60000000000feffffff0200e1f50500000000160014553c2a2fdabe5b88e9dbd899d7d0e57cc91b19befc78d71700000000160014b1ee5f7591b8fb37ca97903b388dc39a859411fc02483045022100cc102557b2b14ae576cc427e72542be6df3585d6474281ea5fdd14aa1430e600022042db72d3bacde33056323775a953ad92038704094668ed927b72e488db0005870121022320f1cf72e7ba2cef6be32d7493ce3bd4c6a2575fe51ce260377adc165603d400000000", - "complete": true -} -$ src/syscoin-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/57h/0h/0` for normal receiving keys and `m/44h/57h/0h/1` for change keys. -Using the `--wpkh` option will result in 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 `sh_wpkh` 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/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..840d686d4 --- /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 availabe 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..0309ae290 100644 --- a/hwilib/__init__.py +++ b/hwilib/__init__.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = "2.0.2" 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 debfdbd92..8243328d0 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): +def enumerate_handler(args: argparse.Namespace) -> List[Dict[str, Any]]: return enumerate(password=args.password) -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('--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/57h/0h/[0,1]/*)') - getkeypool_parser.add_argument('--wpkh', action='store_true', help='Generate bech32 addresses (default path: m/84h/57h/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/57h/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/57h/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/57h/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/57h/0h]xpub.../0/0), where 00000000 must match --fingerprint and xpub can be obtained with getxpub. See doc/descriptors.md in Syscoin 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/57h/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/57h/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,21 @@ 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) 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) 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} + + client.chain = args.chain # Do the commands with handle_errors(result=result, debug=args.debug): @@ -250,6 +302,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..c79233904 --- /dev/null +++ b/hwilib/_gui.py @@ -0,0 +1,498 @@ +#! /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.client.chain = 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 initalized 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..b6bc8315b --- /dev/null +++ b/hwilib/_script.py @@ -0,0 +1,141 @@ +""" +Syscoin 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 + + +# 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..baac56a59 --- /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 Syscoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +""" +Syscoin Object Python Serializations +************************************ + +Modified from the test/test_framework/mininode.py file from the +Syscoin 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 Syscoin'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 Syscoin'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 Syscoin'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 Syscoin'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 Syscoin'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 Syscoin'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 Syscoin'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 Syscoin'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 5668b1202..9f3b00bdc 100644 --- a/hwilib/commands.py +++ b/hwilib/commands.py @@ -1,27 +1,96 @@ #! /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, +) +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: str = "", expert: bool = False) -> 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) + :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) except ImportError: if client: client.close() @@ -30,166 +99,332 @@ 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: str = "") -> 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)) # type: ignore + 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: str = "", + device_type: Optional[str] = None, + fingerprint: Optional[str] = None, + expert: bool = False, +) -> 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) + :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) + 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 Syscoin 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 Syscoin signed message standard used by Syscoin 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 += "57'/" # Syscoin SLIP-0044 + 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 Syscoin 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 Syscoin 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 +432,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..3f4ab59db --- /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 #: Syscoin Main network + TEST = 1 #: Syscoin Test network + REGTEST = 2 #: Syscoin Core Regression Test network + SIGNET = 3 #: Syscoin 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 6bcf24d5b..a013bd7a1 100644 --- a/hwilib/descriptor.py +++ b/hwilib/descriptor.py @@ -1,8 +1,39 @@ -import re +""" +Output Script Descriptors +************************* -# From: https://github.com/syscoin/syscoin/blob/master/src/script/descriptor.cpp +HWI has a more limited implementation of descriptors. +See `Syscoin 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/syscoin/syscoin/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) -> 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()) + 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) -> str: + """ + Serializes the descriptor as a string without the descriptor checksum + + :return: The descriptor string + """ + return "{}({}{})".format( + self.name, + ",".join([p.to_string() for p in self.pubkeys]), + self.subdescriptors[0].to_string_no_checksum() if len(self.subdescriptors) > 0 else "" + ) + + def to_string(self) -> str: + """ + Serializes the descriptor as a string wtih the checksum + + :return: The descriptor with a checksum + """ + return AddChecksum(self.to_string_no_checksum()) + + 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) -> str: + return "{}({},{})".format(self.name, self.thresh, ",".join([p.to_string() 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: + """ + :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) -> str: + r = f"{self.name}({self.pubkeys[0].to_string()}" + 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() + 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() suports 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..02860e8b3 --- /dev/null +++ b/hwilib/devices/bitbox02.py @@ -0,0 +1,878 @@ +""" +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, + 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/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: str = "") -> List[Dict[str, object]]: + """ + 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: str = "", expert: bool = False) -> None: + """ + Initializes a new BitBox02 client instance. + """ + super().__init__(path, password=password, expert=expert) + if password: + 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." + ) + + 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: + 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/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, + ) -> 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: + raise UnavailableActionError("BitBox02 does not support displaying Taproot addresses yet") + 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. + + he 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), + ) + # 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, tx_in) in builtins.enumerate( + zip(psbt.inputs, psbt.tx.vin) + ): + 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 for now requires the prevtx, at least until Taproot activates. + + if psbt_in.non_witness_utxo: + if tx_in.prevout.hash != psbt_in.non_witness_utxo.sha256: + raise BadArgumentError( + "Input {} has a non_witness_utxo with the wrong hash".format( + input_index + ) + ) + utxo = psbt_in.non_witness_utxo.vout[tx_in.prevout.n] + 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)) + if prevtx is None: + raise BadArgumentError( + "Previous transaction missing for input {}".format(input_index) + ) + + found_pubkey, keypath = find_our_key(psbt_in.hd_keypaths) + 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": ser_uint256(tx_in.prevout.hash), + "prev_out_index": tx_in.prevout.n, + "prev_out_value": utxo.nValue, + "sequence": tx_in.nSequence, + "keypath": keypath, + "script_config_index": script_config_index, + "prev_tx": { + "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, tx_out) in builtins.enumerate( + zip(psbt.outputs, psbt.tx.vout) + ): + _, keypath = find_our_key(psbt_out.hd_keypaths) + 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_hash = tx_out.scriptPubKey[3:23] + elif is_p2wpkh(tx_out.scriptPubKey): + output_type = bitbox02.btc.P2WPKH + output_hash = tx_out.scriptPubKey[2:] + elif tx_out.is_p2sh(): + output_type = bitbox02.btc.P2SH + output_hash = tx_out.scriptPubKey[2:22] + elif is_p2wsh(tx_out.scriptPubKey): + output_type = bitbox02.btc.P2WSH + output_hash = 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_hash=output_hash, + 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 + ) + + sigs = self.init().btc_sign( + self._get_coin(), + script_configs, + inputs=inputs, + outputs=outputs, + locktime=psbt.tx.nLockTime, + version=psbt.tx.nVersion, + ) + + for (_, sig), pubkey, psbt_in in zip(sigs, found_pubkeys, psbt.inputs): + r, s = sig[:32], sig[32:64] + # 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 index c2eeb3f1f..43c7d7053 100644 --- a/hwilib/devices/btchip/README.md +++ b/hwilib/devices/btchip/README.md @@ -2,8 +2,10 @@ 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). +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/btchip/__init__.py index ee8532adf..da7ed44d7 100644 --- a/hwilib/devices/btchip/__init__.py +++ b/hwilib/devices/btchip/__init__.py @@ -16,4 +16,5 @@ * limitations under the License. ******************************************************************************** """ +__version__ = "0.1.31" diff --git a/hwilib/devices/btchip/btchip.py b/hwilib/devices/btchip/btchip.py index caa1fc0ce..80c301d28 100644 --- a/hwilib/devices/btchip/btchip.py +++ b/hwilib/devices/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/btchip/btchipComm.py index 4042c173b..afb82b997 100644 --- a/hwilib/devices/btchip/btchipComm.py +++ b/hwilib/devices/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/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 ff702079f..80a48e73d 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 a92587a8d..23d896a48 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/84'/57'/23/23 @@ -107,8 +134,36 @@ def get_xpub(subpath='m'): def show_address(subpath, addr_fmt=AF_CLASSIC): # takes a string, like: m/84'/57'/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..8d163c7fb 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,20 @@ import sys import time import struct -from binascii import hexlify + +from binascii import b2a_hex +from typing import ( + Any, + Callable, +) 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 +89,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: str = "", expert: bool = False) -> None: + super(ColdcardClient, self).__init__(path, password, expert) # Simulator hard coded pipe socket if path == CC_SIMULATOR_SOCK: self.device = ColdcardDevice(sn=path) @@ -45,86 +99,112 @@ 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 Syscoin Core using the `sortedmulti()` descriptor, available in Syscoin 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 + 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 +220,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 +359,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: str = "") -> 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..45c169702 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,56 @@ import socket import sys import time - +from functools import wraps +from typing import ( + Any, + Callable, + Dict, + List, + 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 ..tx import ( + CTransaction, +) +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 +80,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 +99,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 +128,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 +167,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 +220,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 +259,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 +276,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 +291,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 +300,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 +329,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) + def __init__(self, path: str, password: str, expert: bool = False) -> 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. + """ + super(DigitalbitboxClient, self).__init__(path, password, expert) if not password: raise NoPasswordError('Password must be supplied for digital BitBox') 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 - # 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) # 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 +445,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 +462,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,7 +473,7 @@ 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,7 +495,7 @@ def sign_tx(self, tx): # Return early if nothing to do if len(sighash_tuples) == 0: - return {'psbt': tx.serialize()} + return tx # Sign the sighashes to_send = '{"sign":{"data":[' @@ -466,16 +534,17 @@ def sign_tx(self, tx): for tup, sig in zip(sighash_tuples, der_sigs): tx.inputs[tup[2]].partial_sigs[tup[3]] = sig - return {'psbt': tx.serialize()} + return tx - # 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 +571,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. + + :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. - # 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') - # 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 +601,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 +637,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: str = "") -> List[Dict[str, Any]]: results = [] devices = hid.enumerate(DBB_VENDOR_ID, DBB_DEVICE_ID) # Try connecting to simulator @@ -581,16 +687,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 @@ -601,12 +708,11 @@ def enumerate(password=''): # Check initialized reply = send_encrypt('{"device" : "info"}', password, client.device) - if 'error' in reply and reply['error']['code'] == 101: + 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..35914942c --- /dev/null +++ b/hwilib/devices/jade.py @@ -0,0 +1,539 @@ +""" +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 ..tx import ( + CTransaction +) +from .._script import ( + is_p2sh, + is_p2wpkh, + is_p2wsh, + is_witness, + parse_multisig +) + +import logging +import os + +# The test emulator port +SIMULATOR_PATH = 'tcp:127.0.0.1:2222' + +JADE_DEVICE_IDS = [(0x10c4, 0xea60)] +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): + + 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)))'} + + @staticmethod + def _convertAddrType(addrType: AddressType, multisig: bool) -> str: + return JadeClient.MULTI_ADDRTYPES[addrType] if multisig else JadeClient.ADDRTYPES[addrType] + + # Derive a deterministic name for a multisig registration record + @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 + summary = type + '|' + str(threshold) + '|' + for fingerprint, path in 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: str = '', expert: bool = False, timeout: Optional[int] = None) -> None: + super(JadeClient, self).__init__(path, password, expert) + 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'] + + 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 'Emergency Restore' feature to enter mnemonic on Jade hw + raise DeviceNotReadyError('Use "Emergency Restore" feature on Jade hw to enter wallet mnemonic') + + # 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 = CTransaction(tx.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, (txin, psbtin) in py_enumerate(zip(c_txn.vin, 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 txin.prevout.hash != psbtin.non_witness_utxo.sha256: + raise BadArgumentError(f'Input {n_vin} has a non_witness_utxo with the wrong hash') + utxo = psbtin.non_witness_utxo.vout[txin.prevout.n] + input_txn_bytes = psbtin.non_witness_utxo.serialize_without_witness() + if utxo is None: + raise Exception('PSBT is missing input utxo information, cannot sign') + 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, 'input_tx': input_txn_bytes, 'script': scriptcode, 'path': path, + '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 ouptut. 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) + + # 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 + candidate_multisigs = {} + 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) == (script_variant, threshold, signers) + if matched_multisig: + 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 = [] + if multisig.is_sorted: + raise BadArgumentError('Blockstream Jade can not generate addresses for sorted multisigs') + 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 origin derivation path information') + + # 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)) + + # Get a deterministic name for this multisig wallet + 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.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: str = '') -> 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, 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..65af9a6cd --- /dev/null +++ b/hwilib/devices/jadepy/README.md @@ -0,0 +1,10 @@ +# 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 commit [5d6c1ff2bb134261ccb7f939c3cea5f051945ab8](https://github.com/Blockstream/Jade/commit/5d6c1ff2bb134261ccb7f939c3cea5f051945ab8) + +## 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..5079bd9b7 --- /dev/null +++ b/hwilib/devices/jadepy/__init__.py @@ -0,0 +1,4 @@ +from .jade import JadeAPI +from .jade_error import JadeError + +__version__ = "0.0.1" diff --git a/hwilib/devices/jadepy/jade.py b/hwilib/devices/jadepy/jade.py new file mode 100644 index 000000000..0e6d0933b --- /dev/null +++ b/hwilib/devices/jadepy/jade.py @@ -0,0 +1,640 @@ +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 + +# Not used in HWI +# Removed to reduce transitive dependencies +# from .jade_ble import JadeBleImpl + + +# 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 + +# 'jade' logger +logger = logging.getLogger('jade') +device_logger = logging.getLogger('jade-device') + + +# 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. +try: + import requests + + def _http_request(params): + 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.warn(e) + logger.warn('Default _http_requests() function will not be available') + + +# +# 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) +# +class JadeAPI: + 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.error("Exception causing JadeAPI context exit.") + logger.error(exc_type) + logger.error(exc) + traceback.print_tb(tb) + self.disconnect(exc_type is not None) + + @staticmethod + def create_serial(device=None, baud=None, timeout=None): + 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): + impl = JadeInterface.create_ble(device_name, serial_number, + scan_timeout, loop) + return JadeAPI(impl) + + # Connect underlying interface + def connect(self): + self.jade.connect() + + # Disconnect underlying interface + def disconnect(self, drain=False): + self.jade.disconnect(drain) + + # Drain all output from the interface + def drain(self): + self.jade.drain() + + # Raise any returned error as an exception + @staticmethod + def _get_result_or_raise_error(reply): + if 'error' in reply: + e = reply['error'] + raise JadeError(e.get('code'), e.get('message'), e.get('data')) + + return reply['result'] + + # Helper to call wrapper interface rpc invoker + def _jadeRpc(self, method, params=None, inputid=None, http_request_fn=None, long_timeout=False): + 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 + + # Get version information from the hw + def get_version_info(self): + return self._jadeRpc('get_version_info') + + # Add client entropy to the hw rng + def add_entropy(self, entropy): + params = {'entropy': entropy} + return self._jadeRpc('add_entropy', params) + + # OTA new firmware + def ota_update(self, fwcmp, fwlen, chunksize, cb): + + cmphasher = hashlib.sha256() + cmphasher.update(fwcmp) + cmphash = cmphasher.digest() + cmplen = len(fwcmp) + + # Initiate OTA + params = {'fwsize': fwlen, + 'cmpsize': cmplen, + 'cmphash': cmphash} + + result = self._jadeRpc('ota', 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') + + # Run (debug) healthcheck on the hw + def run_remote_selfcheck(self): + return self._jadeRpc('debug_selfcheck', long_timeout=True) + + # Set the (debug) mnemonic + def set_mnemonic(self, mnemonic, passphrase=None, temporary_wallet=False): + params = {'mnemonic': mnemonic, 'passphrase': passphrase, + 'temporary_wallet': temporary_wallet} + return self._jadeRpc('debug_set_mnemonic', params) + + # Set the (debug) seed + def set_seed(self, seed, temporary_wallet=False): + params = {'seed': seed, 'temporary_wallet': temporary_wallet} + return self._jadeRpc('debug_set_mnemonic', params) + + # Override the pinserver details on the hww + def set_pinserver(self, urlA=None, urlB=None, pubkey=None, cert=None): + 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) + + # Reset the pinserver details on the hww to their defaults + def reset_pinserver(self, reset_details, reset_certificate): + params = {'reset_details': reset_details, + 'reset_certificate': reset_certificate} + return self._jadeRpc('update_pinserver', params) + + # Trigger user authentication on the hw + # Involves pinserver handshake + def auth_user(self, network, http_request_fn=None): + params = {'network': network} + return self._jadeRpc('auth_user', params, + http_request_fn=http_request_fn, + long_timeout=True) + + # Get xpub given a path + def get_xpub(self, network, path): + params = {'network': network, 'path': path} + return self._jadeRpc('get_xpub', params) + + # Get registered multisig wallets + def get_registered_multisigs(self): + return self._jadeRpc('get_registered_multisigs') + + # Register a multisig wallet + def register_multisig(self, network, multisig_name, variant, threshold, signers): + params = {'network': network, 'multisig_name': multisig_name, + 'descriptor': {'variant': variant, 'threshold': threshold, 'signers': signers}} + return self._jadeRpc('register_multisig', params) + + # Get receive-address for parameters + def get_receive_address(self, *args, recovery_xpub=None, csv_blocks=0, + variant=None, multisig_name=None): + 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) + return self._jadeRpc('get_receive_address', dict(zip(keys, args))) + + # Sign a message + def sign_message(self, path, message, use_ae_signatures=False, + ae_host_commitment=None, ae_host_entropy=None): + 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) + + # Get a Liquid master blinding key + def get_master_blinding_key(self): + return self._jadeRpc('get_master_blinding_key') + + # Get a Liquid public blinding key for a given script + def get_blinding_key(self, script): + params = {'script': script} + return self._jadeRpc('get_blinding_key', params) + + # Get the shared secret to unblind a tx, given the receiving script on + # our side and the pubkey of the sender (sometimes called "nonce" in + # Liquid) + def get_shared_nonce(self, script, their_pubkey): + params = {'script': script, 'their_pubkey': their_pubkey} + return self._jadeRpc('get_shared_nonce', params) + + # Get a "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 side, 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, thus the "type" parameter. + # `hash_prevouts` is computed as specified in BIP143 (double SHA of all + # the outpoints being spent as input. It's not checked right away since + # at this point Jade doesn't know anything about the tx we are referring + # to. It will be checked later during `sign_liquid_tx`. + # `output_index` is the output we are trying to blind. + # `type` can either be "ASSET" or "VALUE" to generate ABFs or VBFs. + def get_blinding_factor(self, hash_prevouts, output_index, type): + params = {'hash_prevouts': hash_prevouts, + 'output_index': output_index, + 'type': type} + return self._jadeRpc('get_blinding_factor', params) + + # Generate the 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 random, but generated accordingly to + # all the others. + # `hash_prevouts` and `output_index` have the same meaning as in + # the `get_blinding_factor` call. + # NOTE: the `asset_id` should be passed as it is normally displayed, so + # reversed compared to the "consensus" representation. + def get_commitments(self, + asset_id, + value, + hash_prevouts, + output_index, + vbf=None): + params = {'asset_id': asset_id, + 'value': value, + 'hash_prevouts': hash_prevouts, + 'output_index': output_index} + if vbf is not None: + params['vbf'] = vbf + return self._jadeRpc('get_commitments', params) + + # Common code for sending btc- and liquid- tx-inputs and receiving the + # signatures. Handles standard EC and AE signing schemes. + def _send_tx_inputs(self, base_id, inputs, use_ae_signatures): + 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 + + # Sign a Liquid txn + def sign_liquid_tx(self, network, txn, inputs, commitments, change, use_ae_signatures=False): + # 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} + + 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) + + # Sign a txn + def sign_tx(self, network, txn, inputs, change, use_ae_signatures=False): + # 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) + + +# +# 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) +# +class JadeInterface: + 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.error("Exception causing JadeInterface context exit.") + logger.error(exc_type) + logger.error(exc) + traceback.print_tb(tb) + self.disconnect(exc_type is not None) + + @staticmethod + def create_serial(device=None, baud=None, timeout=None): + if device and JadeTCPImpl.isSupportedDevice(device): + impl = JadeTCPImpl(device) + 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): + 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): + self.impl.connect() + + def disconnect(self, drain=False): + if drain: + self.drain() + + self.impl.disconnect() + + def drain(self): + 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): + request = {"method": method, "id": input_id} + if params is not None: + request["params"] = params + return request + + @staticmethod + def serialise_cbor_request(request): + 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(request, len_dump)) + return dump + + def write(self, bytes_): + 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): + msg = self.serialise_cbor_request(request) + written = 0 + while written < len(msg): + written += self.write(msg[written:]) + + def read(self, n): + 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): + 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) + + # A message response (to a prior request) + if 'id' in message: + logger.info("Received msg: {}".format(message)) + return message + + # A log message - handle as normal + if 'log' in message: + 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, + } + log_method = device_logger.error + if len(response) > 1 and response[1] == ' ': + lvl = response[0] + log_method = log_methods.get(lvl, device_logger.error) + + log_method('>> {}'.format(response)) + else: + # Unknown/unhandled/unexpected message + logger.error("Unhandled message received") + device_logger.error(message) + + def read_response(self, long_timeout=False): + while True: + try: + return self.read_cbor_message() + except EOFError as e: + if not long_timeout: + raise + + @staticmethod + def validate_reply(request, reply): + 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): + # 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..64fe134c5 --- /dev/null +++ b/hwilib/devices/jadepy/jade_serial.py @@ -0,0 +1,52 @@ +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 + self.ser.__enter__() + logger.info('Connected') + + def disconnect(self): + assert self.ser is not None + self.ser.__exit__() + + # 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..0909b8bfa --- /dev/null +++ b/hwilib/devices/jadepy/jade_tcp.py @@ -0,0 +1,60 @@ +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): + assert self.isSupportedDevice(device) + self.device = device + 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) + + 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..36b44e288 100644 --- a/hwilib/devices/keepkey.py +++ b/hwilib/devices/keepkey.py @@ -1,21 +1,182 @@ -# 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 ..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: str = "", expert: bool = False) -> 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, 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: str = "") -> 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 +186,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: 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 723be55c3..f8e2f1707 100644 --- a/hwilib/devices/ledger.py +++ b/hwilib/devices/ledger.py @@ -1,29 +1,78 @@ -# Ledger interaction script - +""" +Ledger Devices +************** +""" + +from functools import wraps +from typing import ( + Any, + Callable, + Dict, + List, + Union, + Tuple, +) + +from ..descriptor import MultisigDescriptor from ..hwwclient import HardwareWalletClient -from ..errors import ActionCanceledError, BadArgumentError, DeviceConnectionError, DeviceFailureError, UnavailableActionError, common_err_msgs, handle_errors +from ..errors import ( + ActionCanceledError, + BadArgumentError, + DeviceConnectionError, + DeviceFailureError, + UnavailableActionError, + UnknownDeviceError, + common_err_msgs, + handle_errors, +) +from ..common import ( + AddressType, + Chain, + hash160, +) from .btchip.syscoinTransaction import syscoinTransaction from .btchip.btchip import btchip -from .btchip.btchipComm import HIDDongleHIDAPI +from .btchip.btchipComm import ( + DongleServer, + HIDDongleHIDAPI, +) from .btchip.btchipException import BTChipException from .btchip.btchipUtils import compress_public_key import base64 import hid import struct -from .. import base58 -from ..base58 import get_xpub_fingerprint_hex -from ..serializations import hash256, hash160, CTransaction + +from ..key import ( + ExtendedKey, + parse_path, +) +from .._script import ( + is_p2sh, + is_p2wpkh, + is_p2wsh, + is_witness, +) +from ..psbt import PSBT +from ..tx import ( + CTransaction, +) 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" +} +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 +97,9 @@ def check_keypath(key_path): 0x6985, # BTCHIP_SW_CONDITIONS_OF_USE_NOT_SATISFIED ] -def ledger_exception(f): - def func(*args, **kwargs): +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,19 +120,28 @@ 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) + def __init__(self, path: str, password: str = "", expert: bool = False) -> None: + super(LedgerClient, self).__init__(path, password, expert) + + if path.startswith('tcp'): + split_path = path.split(':') + server = split_path[1] + port = int(split_path[2]) + self.dongle = DongleServer(server, port, logging.getLogger().getEffectiveLevel() == logging.DEBUG) + else: + device = hid.device() + device.open_path(path.encode()) + device.set_nonblocking(True) + + self.dongle = HIDDongleHIDAPI(device, True, logging.getLogger().getEffectiveLevel() == logging.DEBUG) - 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 + if self.app.getAppName() not in ["Syscoin", "Syscoin Test", "app"]: + raise UnknownDeviceError("Ledger is not in either the Syscoin or Syscoin Testnet app") + @ledger_exception - def get_pubkey_at_path(self, path): + def get_pubkey_at_path(self, path: str) -> ExtendedKey: if not check_keypath(path): raise BadArgumentError("Invalid keypath") path = path[2:] @@ -90,7 +149,8 @@ def get_pubkey_at_path(self, path): path = path.replace('H', '\'') # This call returns raw uncompressed pubkey, chaincode pubkey = self.app.getWalletPublicKey(path) - if path != "": + int_path = parse_path(path) + if len(path) > 0: parent_path = "" for ind in path.split("/")[:-1]: parent_path += ind + "/" @@ -100,45 +160,41 @@ def get_pubkey_at_path(self, path): 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) + child = int_path[-1] # Special case for m else: - child = bytearray.fromhex("00000000") - fpr = child - - chainCode = pubkey["chainCode"] - publicKey = compress_public_key(pubkey["publicKey"]) - - depth = len(path.split("/")) if len(path) > 0 else 0 - depth = struct.pack("B", depth) - - if self.is_testnet: - version = bytearray.fromhex("043587CF") - else: - version = bytearray.fromhex("0488B21E") - extkey = version + depth + fpr + child + chainCode + publicKey - checksum = hash256(extkey)[:4] - - return {"xpub": base58.encode(extkey + checksum)} + 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 - # 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): + def sign_tx(self, tx: PSBT) -> PSBT: + """ + Sign a transaction with a Ledger device. Not all transactiosn can be signed by a Ledger. + + - Transactions containing both segwit and non-segwit inputs are not entirely supported; only the segwit inputs wil lbe signed in this case. + """ 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) + 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 @@ -150,7 +206,7 @@ def sign_tx(self, tx): has_segwit = False has_legacy = False - script_codes = [[]] * len(c_tx.vin) + script_codes: List[bytes] = [b""] * len(c_tx.vin) # Detect changepath, (p2sh-)p2(w)pkh only change_path = '' @@ -158,88 +214,81 @@ def sign_tx(self, tx): # 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 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 path[1:]: + 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 = format(txin.nSequence, 'x') - seq = seq.zfill(8) - seq = bytearray.fromhex(seq) - seq.reverse() - seq_hex = ''.join('{:02x}'.format(x) for x in seq) + 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: - 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 - else: - scriptCode = psbt_in.non_witness_utxo.vout[txin.prevout.n].scriptPubKey - # Save scriptcode for later signing - script_codes[i_num] = scriptCode + 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: + 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(" str: if not check_keypath(keypath): raise BadArgumentError("Invalid keypath") - message = bytearray(message, 'utf-8') + 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) @@ -290,76 +337,161 @@ def sign_message(self, message, keypath): # Make signature into standard syscoin 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:] + 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 + s + 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 {"signature": base64.b64encode(sig).decode('utf-8')} + return base64.b64encode(sig).decode('utf-8') @ledger_exception - def display_address(self, keypath, p2sh_p2wpkh, bech32): + def display_singlesig_address( + self, + keypath: str, + addr_type: AddressType, + ) -> str: 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. + bech32 = False + p2sh_p2wpkh = False + if addr_type == AddressType.SH_WIT: + p2sh_p2wpkh = True + elif addr_type == AddressType.WIT: + bech32 = True + elif addr_type == AddressType.LEGACY: + pass + elif addr_type == AddressType.TAP: + raise UnavailableActionError("Ledger does not support displaying Taproot addresses yet") + else: + raise BadArgumentError("Unknown address type") + output = self.app.getWalletPublicKey(keypath[2:], True, 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. - # Setup a new device - def setup_device(self, label='', passphrase=''): + @ledger_exception + def display_multisig_address( + self, + addr_type: AddressType, + multisig: MultisigDescriptor, + ) -> str: + raise BadArgumentError("The Ledger Nano S and X do not support P2SH address display") + + 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 Ledger Nano S and X do 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 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: + """ + The Coldcard does 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: + """ + The Coldcard does 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): + def close(self) -> None: self.dongle.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 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: + """ + The Coldcard does 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: + """ + The Coldcard does 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 Syscoin App version greater than 2.0.0. + However HWI does not implement Taproot support for the Ledger yet. + + :returns: False, always + """ + return False + + +def enumerate(password: str = '') -> 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: + # 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 Syscoin 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/trezor.py b/hwilib/devices/trezor.py index 91741508a..99ba02015 100644 --- a/hwilib/devices/trezor.py +++ b/hwilib/devices/trezor.py @@ -1,28 +1,111 @@ -# 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, + 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, syscoin, 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 ( + syscoin, + 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 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] + + # 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 +122,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 +134,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 +165,128 @@ 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.Currrent: 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: str = "", + expert: bool = False, + 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: + super(TrezorClient, self).__init__(path, password, expert) 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 +295,52 @@ 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 = 'Syscoin' 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)) - # 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 = syscoin.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 = syscoin.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. + """ self._check_unlocked() # Get this devices master key fingerprint - master_key = syscoin.get_public_node(self.client, [0]) + master_key = syscoin.get_public_node(self.client, [0x80000000], coin_name='Syscoin') master_fp = get_xpub_fingerprint(master_key.xpub) # Do multiple passes for multisig @@ -147,72 +352,95 @@ def sign_tx(self, tx): 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() - - # 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=ser_uint256(txin.prevout.hash)[::-1], + prev_index=txin.prevout.n, + sequence=txin.nSequence, + ) # Detrermine spend type 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 BadArgumentError('Input {} has a non_witness_utxo with the wrong hash'.format(input_num)) 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 = 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 + 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 - # Set the script - if psbt_in.witness_script: + # Check segwit + is_wit, _, _ = is_witness(scriptcode) + + if is_wit: + if p2sh: + txinputtype.script_type = messages.InputScriptType.SPENDP2SHWITNESS + else: + txinputtype.script_type = messages.InputScriptType.SPENDWITNESS + 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 - elif psbt_in.redeem_script: - scriptcode = psbt_in.redeem_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 not is_wit: if utxo.is_p2sh: - txinputtype.script_type = proto.InputScriptType.SPENDMULTISIG + txinputtype.script_type = messages.InputScriptType.SPENDMULTISIG else: # Cannot sign bare multisig, ignore it 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 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 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:] + if keypath.fingerprint == master_fp: + 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 @@ -220,16 +448,19 @@ def ignore_input(): 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 ignore_input() continue + elif not found and found_in_sigs: # All of our keys are in partial_sigs, ignore whatever signature is produced for this input + ignore_input() + continue # append to inputs inputs.append(txinputtype) # address version byte - if self.is_testnet: + if self.chain != Chain.MAIN: p2pkh_version = b'\x41' p2sh_version = b'\xc4' bech32_hrp = 'tsys' @@ -241,13 +472,15 @@ 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 + txoutput = messages.TxOutputType(amount=out.nValue) + txoutput.script_type = messages.OutputScriptType.PAYTOADDRESS if 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: @@ -255,25 +488,33 @@ def ignore_input(): else: raise BadArgumentError("Output is not an address") - # Add the derivation path for change, but only if there is exactly one derivation path + # Add the derivation path for change 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: - wit, ver, prog = out.is_witness() - if out.is_p2pkh(): - txoutput.address_n = keypath[1:] - txoutput.address = None - elif wit: - txoutput.script_type = proto.OutputScriptType.PAYTOWITNESS - txoutput.address_n = keypath[1:] + 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.path + txoutput.address = None + elif wit: + 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) in [20, 32]: + txoutput.script_type = messages.OutputScriptType.PAYTOP2SHWITNESS + 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:] - txoutput.address = None + + # 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 # append to outputs outputs.append(txoutput) @@ -284,147 +525,258 @@ 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.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 = syscoin.sign_tx(self.client, "Testnet", inputs, outputs, tx_details, prevtxs) - else: - signed_tx = syscoin.sign_tx(self.client, "Syscoin", inputs, outputs, tx_details, prevtxs) + signed_tx = syscoin.sign_tx( + client=self.client, + coin_name=self.coin_name, + inputs=inputs, + outputs=outputs, + prev_txes=prevtxs, + version=tx.tx.nVersion, + lock_time=tx.tx.nLockTime, + ) # 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 p += 1 - return {'psbt': tx.serialize()} + return tx - # 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 sign_message(self, message: Union[str, bytes], keypath: str) -> str: self._check_unlocked() - path = tools.parse_path(keypath) - result = syscoin.sign_message(self.client, 'Syscoin', path, message) - return {'signature': base64.b64encode(result.signature).decode('utf-8')} + path = parse_path(keypath) + result = syscoin.sign_message(self.client, self.coin_name, path, message) + return base64.b64encode(result.signature).decode('utf-8') - # Display address of specified type on the device. Only supports single-key based addresses. @trezor_exception - def display_address(self, keypath, p2sh_p2wpkh, bech32): + def display_singlesig_address( + self, + keypath: str, + addr_type: AddressType, + ) -> str: self._check_unlocked() - expanded_path = tools.parse_path(keypath) - address = syscoin.get_address( - self.client, - "Testnet" if self.is_testnet else "Syscoin", - 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 + + # 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: + raise UnavailableActionError("Trezor does not support displaying Taproot addresses yet") + 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 setup_device(self, label='', passphrase=''): - self.client.init_device() + def display_multisig_address( + self, + addr_type: AddressType, + multisig: MultisigDescriptor, + ) -> str: + self._check_unlocked() + + 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: 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 = 'Syscoin' 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 in firmware versions greater than (not including) 2.4.2. + Trezor One supports Taproot in firmware versions greater than (not including) 1.10.3. + However HWI does not implement Taproot support for any Trezor devices yet. -def enumerate(password=''): + :returns: False, always. + """ + return False + + +def enumerate(password: str = "") -> 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 +784,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 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/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..40fc1fef0 --- /dev/null +++ b/hwilib/devices/trezorlib/messages.py @@ -0,0 +1,2166 @@ +# 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): + SYSCOIN = 0 + MILLISYSCOIN = 1 + MICROSYSCOIN = 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): + Syscoin = 1 + Syscoin_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"] = 'Syscoin', + 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"] = 'Syscoin', + 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"] = 'Syscoin', + 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"] = 'Syscoin', + 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"] = 'Syscoin', + ) -> 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), + } + + def __init__( + self, + *, + outputs_count: "int", + inputs_count: "int", + coin_name: Optional["str"] = 'Syscoin', + 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.SYSCOIN, + decred_staking_ticket: Optional["bool"] = False, + ) -> 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 + + +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"] = 'Syscoin', + 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"] = 'Syscoin', + script_type: Optional["InputScriptType"] = InputScriptType.SPENDADDRESS, + amount_unit: Optional["AmountUnit"] = AmountUnit.SYSCOIN, + ) -> 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/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/syscoin.py b/hwilib/devices/trezorlib/syscoin.py index f9c56cf0a..2b1b82613 100644 --- a/hwilib/devices/trezorlib/syscoin.py +++ b/hwilib/devices/trezorlib/syscoin.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 Syscoin-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/tools.py b/hwilib/devices/trezorlib/tools.py index 07e5e2477..ad522b38d 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 SYS """ 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 Syscoin 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 syscoin-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/syscoin-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 25ad0b196..2ca816675 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: str, expert: bool) -> 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"\x18Syscoin 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.MAIN + 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 syscoin 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 (syscoin message signing). + + Signs a message using the legacy Syscoin 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..d9dd527f2 --- /dev/null +++ b/hwilib/key.py @@ -0,0 +1,386 @@ +#!/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 + + 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) -> 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 Syscoin chain type. + + For the Syscoin mainnet chain, this returns 0. For the other chains, this returns 1. + + :param chain: The chain + """ + if chain == 57: + return 0 + else: + return 1 diff --git a/hwilib/psbt.py b/hwilib/psbt.py new file mode 100644 index 000000000..947573f9f --- /dev/null +++ b/hwilib/psbt.py @@ -0,0 +1,670 @@ +""" +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 ( + CTransaction, + CTxInWitness, + CTxOut, +) +from ._serialize import ( + deser_compact_size, + deser_string, + Readable, + ser_compact_size, + ser_string, +) + +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_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) -> 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.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] = {} + + 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.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 = struct.unpack("b", bytearray([key[0]]))[0] + + 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("Intput 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) + + 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(" None: + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths: Dict[bytes, KeyOriginInfo] = {} + self.tap_internal_key = b"" + self.tap_tree = b"" + self.tap_bip32_paths: Dict[bytes, Tuple[Set[bytes], KeyOriginInfo]] = {} + self.unknown: Dict[bytes, bytes] = {} + + 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.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 = struct.unpack("b", bytearray([key[0]]))[0] + + 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_TAP_INTERNAL_KEY: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, output Taproot internal key already provided") + elif len(key) != 1: + raise PSBTSerializationError("Output 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("Output Taproot internal key is not 32 bytes") + elif key_type == PartiallySignedOutput.PSBT_OUT_TAP_TREE: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, output Taproot tree already provided") + elif len(key) != 1: + raise PSBTSerializationError("Output Taproot tree key is more than one byte type") + self.tap_tree = deser_string(f) + elif key_type == PartiallySignedOutput.PSBT_OUT_TAP_BIP32_DERIVATION: + if key in key_lookup: + raise PSBTSerializationError("Duplicate key, output Taproot BIP 32 keypath already provided") + elif len(key) != 33: + raise PSBTSerializationError("Output 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())) + else: + if key in self.unknown: + raise PSBTSerializationError("Duplicate key, key for unknown value already provided") + value = deser_string(f) + self.unknown[key] = value + + key_lookup.add(key) + + 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 len(self.tap_internal_key) != 0: + r += ser_string(ser_compact_size(PartiallySignedOutput.PSBT_OUT_TAP_INTERNAL_KEY)) + r += ser_string(self.tap_internal_key) + + if len(self.tap_tree) != 0: + r += ser_string(ser_compact_size(PartiallySignedOutput.PSBT_OUT_TAP_TREE)) + r += ser_string(self.tap_tree) + + for xonly, (leaf_hashes, origin) in self.tap_bip32_paths.items(): + r += ser_string(ser_compact_size(PartiallySignedOutput.PSBT_OUT_TAP_BIP32_DERIVATION) + xonly) + value = ser_compact_size(len(leaf_hashes)) + for lh in leaf_hashes: + value += lh + value += origin.serialize() + r += ser_string(value) + + for key, value in sorted(self.unknown.items()): + r += ser_string(key) + r += ser_string(value) + + r += b"\x00" + + return r + +class PSBT(object): + """ + A class representing a PSBT + """ + + PSBT_GLOBAL_UNSIGNED_TX = 0x00 + PSBT_GLOBAL_XPUB = 0x01 + + def __init__(self, tx: Optional[CTransaction] = None) -> None: + """ + :param tx: A Syscoin 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] = {} + + 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() + + # 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 = struct.unpack("b", bytearray([key[0]]))[0] + + # 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]) + 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 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: + input.non_witness_utxo.rehash() + if input.non_witness_utxo.sha256 != txin.prevout.hash: + 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") + + 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" + + # 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)) + + # 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 base64.b64encode(r).decode() diff --git a/hwilib/py.typed b/hwilib/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/hwilib/serializations.py b/hwilib/serializations.py deleted file mode 100644 index c2857f137..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 Syscoin Core developers -# Distributed under the MIT software license, see the accompanying -# file COPYING or http://www.opensource.org/licenses/mit-license.php. -"""Syscoin Object Python Serializations - -Modified from the test/test_framework/mininode.py file from the -Syscoin repository - -CTransaction,CTxIn, CTxOut, etc....: - data structures that should map to corresponding structures in - syscoin/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..1a2d9502d --- /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 Syscoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Syscoin Object Python Serializations + +Modified from the test/test_framework/mininode.py file from the +Syscoin repository + +CTransaction,CTxIn, CTxOut, etc....: + data structures that should map to corresponding structures in + syscoin/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 syscoind 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, self.nValue, 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[str] = 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())[::-1].hex() + 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..235dee75a 100644 --- a/hwilib/udev/20-hw1.rules +++ b/hwilib/udev/20-hw1.rules @@ -1,9 +1,12 @@ -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" 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..406f69e62 --- /dev/null +++ b/hwilib/udev/55-usb-jade.rules @@ -0,0 +1 @@ +KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", 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..e427e7d50 --- /dev/null +++ b/hwilib/ui/displayaddressdialog.ui @@ -0,0 +1,203 @@ + + + DisplayAddressDialog + + + + 0 + 0 + 469 + 196 + + + + Dialog + + + + + 350 + 150 + 101 + 32 + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + 120 + 10 + 331 + 32 + + + + + + + 10 + 20 + 111 + 18 + + + + Derivation Path + + + + + + 410 + 50 + 41 + 41 + + + + Go + + + false + + + true + + + + + + 10 + 120 + 58 + 18 + + + + Address + + + + + + 70 + 110 + 381 + 32 + + + + true + + + + + + 10 + 50 + 381 + 40 + + + + + 0 + 0 + + + + + 0 + 30 + + + + + + + + + 10 + 10 + 121 + 22 + + + + P2SH-P2WPKH + + + true + + + + + + 150 + 10 + 91 + 22 + + + + P2WPKH + + + + + + 260 + 10 + 105 + 22 + + + + P2PKH + + + + + + + + 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..0d6417224 --- /dev/null +++ b/hwilib/ui/getxpubdialog.ui @@ -0,0 +1,115 @@ + + + GetXpubDialog + + + + 0 + 0 + 1205 + 158 + + + + Dialog + + + + + 320 + 20 + 101 + 31 + + + + Derivation Path + + + + + + 430 + 20 + 401 + 32 + + + + + + + 1100 + 110 + 91 + 34 + + + + Qt::NoFocus + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + 840 + 20 + 88 + 34 + + + + Get xpub + + + false + + + true + + + + + + 30 + 70 + 41 + 31 + + + + xpub + + + + + + 70 + 70 + 1121 + 32 + + + + Qt::NoFocus + + + true + + + buttonBox + path_label + path_lineedit + getxpub_button + xpub_label + xpub_lineedit + + + + 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..a27955b4d --- /dev/null +++ b/hwilib/ui/mainwindow.ui @@ -0,0 +1,235 @@ + + + MainWindow + + + + 0 + 0 + 828 + 430 + + + + MainWindow + + + + + + 19 + 29 + 794 + 375 + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 200 + 20 + + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + 100 + 30 + + + + Refresh + + + + + + + + + Qt::Horizontal + + + + + + + + + false + + + Send Pin + + + + + + + Set Passphrase + + + + + + + false + + + Sign PSBT + + + + + + + false + + + Get an xpub + + + + + + + false + + + Sign Message + + + + + + + + 0 + 0 + + + + + 100 + 20 + + + + Actions: + + + + + + + false + + + Change the options used for getkeypool + + + Change getkeypool options + + + + + + + false + + + Display Address + + + + + + + false + + + Toggle Passphrase + + + + + + + + + + + Keypool: + + + + + + + true + + + + + + + + + + + Descriptors: + + + + + + + true + + + + + + + + + + + + + diff --git a/hwilib/ui/sendpindialog.ui b/hwilib/ui/sendpindialog.ui new file mode 100644 index 000000000..83d691723 --- /dev/null +++ b/hwilib/ui/sendpindialog.ui @@ -0,0 +1,156 @@ + + + SendPinDialog + + + + 0 + 0 + 257 + 234 + + + + Dialog + + + + + 60 + 190 + 181 + 32 + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + 10 + 10 + 231 + 32 + + + + false + + + + + + 10 + 50 + 231 + 131 + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + ? + + + + + + + + + + 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..fe26a2639 --- /dev/null +++ b/hwilib/ui/setpassphrasedialog.ui @@ -0,0 +1,81 @@ + + + SetPassphraseDialog + + + + 0 + 0 + 400 + 96 + + + + Dialog + + + + + 40 + 50 + 341 + 32 + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + 30 + 10 + 351 + 32 + + + + false + + + + + + + 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..254bbe35d --- /dev/null +++ b/hwilib/ui/signmessagedialog.ui @@ -0,0 +1,159 @@ + + + SignMessageDialog + + + + 0 + 0 + 957 + 350 + + + + Dialog + + + + + 600 + 300 + 341 + 32 + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + 20 + 70 + 61 + 18 + + + + Message + + + + + + 80 + 20 + 861 + 131 + + + + + + + 20 + 180 + 121 + 18 + + + + Key Derivation Path + + + + + + 150 + 170 + 391 + 32 + + + + + + + 570 + 170 + 101 + 34 + + + + Sign Message + + + false + + + true + + + + + + 20 + 230 + 71 + 18 + + + + Signature + + + + + + 90 + 220 + 851 + 61 + + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + 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..66b4f3b5b --- /dev/null +++ b/hwilib/ui/signpsbtdialog.ui @@ -0,0 +1,142 @@ + + + SignPSBTDialog + + + + 0 + 0 + 987 + 813 + + + + Dialog + + + + + 630 + 760 + 341 + 32 + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + 20 + 180 + 58 + 61 + + + + PSBT To Sign + + + true + + + + + + 90 + 20 + 881 + 321 + + + + + + + 90 + 410 + 881 + 331 + + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + 30 + 530 + 58 + 61 + + + + PSBT Result + + + true + + + + + + 480 + 350 + 88 + 34 + + + + Sign PSBT + + + false + + + true + + + + + + + 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..663c5dbf0 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,42 @@ +[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.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-usb1] +ignore_missing_imports = True + +[mypy-mnemonic] +ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock index 56754c461..300e8fc64 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,217 +1,1170 @@ [[package]] +name = "alabaster" +version = "0.7.12" +description = "A configurable sidebar-enabled Sphinx theme" category = "dev" -description = "Python graph (network) package" -name = "altgraph" optional = false python-versions = "*" -version = "0.16.1" [[package]] +name = "altgraph" +version = "0.17.2" +description = "Python graph (network) package" category = "dev" -description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" +optional = false +python-versions = "*" + +[[package]] name = "autopep8" +version = "1.6.0" +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" [package.dependencies] -pycodestyle = ">=2.4.0" +pycodestyle = ">=2.8.0" +toml = "*" + +[[package]] +name = "babel" +version = "2.9.1" +description = "Internationalization utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[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" + +[package.extras] +tests = ["mypy", "PyHamcrest (>=2.0.2)", "pytest (>=4.6)", "pytest-benchmark", "pytest-cov", "pytest-flake8"] + +[[package]] +name = "bitbox02" +version = "5.3.0" +description = "Python library for bitbox02 communication" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +base58 = ">=2.0.0" +ecdsa = ">=0.14" +hidapi = ">=0.7.99.post21" +noiseprotocol = ">=0.3" +protobuf = ">=3.7" +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" +optional = false +python-versions = "*" + +[[package]] +name = "certifi" +version = "2021.10.8" +description = "Python package for providing Mozilla's CA Bundle." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "cffi" +version = "1.15.0" +description = "Foreign Function Interface for Python calling C code." category = "main" -description = "ECDSA cryptographic signature library (pure python)" -name = "ecdsa" optional = false python-versions = "*" -version = "0.13.2" + +[package.dependencies] +pycparser = "*" [[package]] +name = "charset-normalizer" +version = "2.0.9" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 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.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "cryptography" +version = "36.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools_rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.2.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + +[[package]] +name = "dataclasses" +version = "0.8" +description = "A backport of the dataclasses module for Python 3.6" +category = "main" +optional = false +python-versions = ">=3.6, <3.7" [[package]] +name = "docutils" +version = "0.17.1" +description = "Docutils -- Python Documentation Utilities" category = "dev" -description = "the modular source code checker: pep8, pyflakes and co" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "ecdsa" +version = "0.17.0" +description = "ECDSA cryptographic signature library (pure python)" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + +[[package]] name = "flake8" +version = "4.0.1" +description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.7.8" +python-versions = ">=3.6" [package.dependencies] -entrypoints = ">=0.3.0,<0.4.0" +importlib-metadata = {version = "<4.3", markers = "python_version < \"3.8\""} mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.5.0,<2.6.0" -pyflakes = ">=2.1.0,<2.2.0" +pycodestyle = ">=2.8.0,<2.9.0" +pyflakes = ">=2.4.0,<2.5.0" [[package]] -category = "dev" -description = "Clean single-source support for Python 3 and 2" -marker = "sys_platform == \"win32\"" name = "future" +version = "0.18.2" +description = "Clean single-source support for Python 3 and 2" +category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "0.17.1" [[package]] -category = "main" -description = "A Cython interface to the hidapi from https://github.com/signal11/hidapi" name = "hidapi" +version = "0.11.0.post2" +description = "A Cython interface to the hidapi from https://github.com/libusb/hidapi" +category = "main" optional = false python-versions = "*" -version = "0.7.99.post21" + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "imagesize" +version = "1.3.0" +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.*" + +[[package]] +name = "importlib-metadata" +version = "4.2.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" [package.dependencies] -setuptools = ">=19.0" +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] + +[[package]] +name = "jinja2" +version = "3.0.3" +description = "A very fast and expressive template engine." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] [[package]] -category = "main" -description = "Pure-python wrapper for libusb-1.0" name = "libusb1" +version = "2.0.1" +description = "Pure-python wrapper for libusb-1.0" +category = "main" optional = false python-versions = "*" -version = "1.7.1" [[package]] -category = "dev" -description = "Mach-O header analysis and editing" -marker = "sys_platform == \"darwin\"" name = "macholib" +version = "1.15.2" +description = "Mach-O header analysis and editing" +category = "dev" optional = false python-versions = "*" -version = "1.11" [package.dependencies] altgraph = ">=0.15" [[package]] +name = "markupsafe" +version = "2.0.1" +description = "Safely add untrusted strings to HTML/XML markup." category = "dev" -description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" + +[[package]] name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +category = "dev" optional = false python-versions = "*" -version = "0.6.1" [[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" + +[[package]] +name = "mypy" +version = "0.910" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.5" [package.dependencies] -pbkdf2 = "*" +mypy-extensions = ">=0.4.3,<0.5.0" +toml = "*" +typed-ast = {version = ">=1.4.0,<1.5.0", markers = "python_version < \"3.8\""} +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +python2 = ["typed-ast (>=1.4.0,<1.5.0)"] [[package]] -category = "main" -description = "PKCS#5 v2.0 PBKDF2 Module" -name = "pbkdf2" +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" optional = false python-versions = "*" -version = "1.3" [[package]] +name = "noiseprotocol" +version = "0.3.1" +description = "Implementation of Noise Protocol Framework" +category = "main" +optional = false +python-versions = "~=3.5" + +[package.dependencies] +cryptography = ">=2.8" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" category = "dev" -description = "Python PE parsing module" -marker = "sys_platform == \"win32\"" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] name = "pefile" +version = "2021.9.3" +description = "Python PE parsing module" +category = "dev" optional = false -python-versions = "*" -version = "2019.4.18" +python-versions = ">=3.6.0" [package.dependencies] future = "*" [[package]] +name = "protobuf" +version = "3.19.1" +description = "Protocol Buffers" category = "main" -description = "Pure-Python Implementation of the AES block-cipher and common modes of operation" +optional = false +python-versions = ">=3.5" + +[[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" [[package]] -category = "dev" -description = "Python style guide checker" name = "pycodestyle" +version = "2.8.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[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.*" -version = "2.5.0" [[package]] -category = "dev" -description = "passive checker of Python programs" name = "pyflakes" +version = "2.4.0" +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" [[package]] +name = "pygments" +version = "2.10.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.5" + +[[package]] name = "pyinstaller" +version = "4.5.1" +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.6" [package.dependencies] altgraph = "*" -setuptools = "*" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""} +pefile = {version = ">=2017.8.1", markers = "sys_platform == \"win32\""} +pyinstaller-hooks-contrib = ">=2020.6" +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} + +[package.extras] +encryption = ["tinyaes (>=1.0.0)"] +hook_testing = ["pytest (>=2.7.3)", "execnet (>=1.5.0)", "psutil"] [[package]] +name = "pyinstaller-hooks-contrib" +version = "2021.4" +description = "Community maintained hooks for PyInstaller" category = "dev" -description = "" -marker = "sys_platform == \"win32\"" -name = "pywin32-ctypes" optional = false python-versions = "*" -version = "0.2.0" [[package]] +name = "pyparsing" +version = "3.0.6" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pyserial" +version = "3.5" +description = "Python Serial Port Extension" category = "main" -description = "Type Hints for Python" -name = "typing" optional = false python-versions = "*" -version = "3.7.4.1" + +[package.extras] +cp2110 = ["hidapi"] [[package]] +name = "pyside2" +version = "5.15.2" +description = "Python bindings for the Qt cross-platform application and UI framework" category = "main" -description = "Backported and Experimental Type Hints for Python 3.5+" -name = "typing-extensions" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <3.10" + +[package.dependencies] +shiboken2 = "5.15.2" + +[[package]] +name = "pytz" +version = "2021.3" +description = "World timezone definitions, modern and historical" +category = "dev" optional = false python-versions = "*" -version = "3.7.4" + +[[package]] +name = "pywin32-ctypes" +version = "0.2.0" +description = "" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "requests" +version = "2.26.0" +description = "Python HTTP for Humans." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] -typing = ">=3.7.4" +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[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.*" + +[[package]] +name = "shiboken2" +version = "5.15.2" +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.10" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[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 = "*" + +[[package]] +name = "sphinx" +version = "4.3.1" +description = "Python documentation generator" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +alabaster = ">=0.7,<0.8" +babel = ">=1.3" +colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.14,<0.18" +imagesize = "*" +Jinja2 = ">=2.3" +packaging = "*" +Pygments = ">=2.0" +requests = ">=2.5.0" +snowballstemmer = ">=1.1" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.5" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.900)", "docutils-stubs", "types-typed-ast", "types-pkg-resources", "types-requests"] +test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] + +[[package]] +name = "sphinx-rtd-theme" +version = "1.0.0" +description = "Read the Docs theme for Sphinx" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" + +[package.dependencies] +docutils = "<0.18" +sphinx = ">=1.6" + +[package.extras] +dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"] + +[[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" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-autoprogram" +version = "0.1.7" +description = "Documenting CLI programs" +category = "dev" +optional = false +python-versions = "*" + +[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" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +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" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest", "html5lib"] + +[[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" + +[package.extras] +test = ["pytest", "flake8", "mypy"] + +[[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" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +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" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +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.*" + +[[package]] +name = "typed-ast" +version = "1.4.3" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "typing-extensions" +version = "3.10.0.2" +description = "Backported and Experimental Type Hints for Python 3.5+" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "urllib3" +version = "1.26.7" +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.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "zipp" +version = "3.6.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] + +[extras] +qt = ["pyside2"] [metadata] -content-hash = "efb94ab72596b3a8e2c057d24c9ff91efcbe40bfed6b7ca1ed3de909745a72c6" +lock-version = "1.1" python-versions = "^3.6" +content-hash = "7a7b95bc92f4d767f0e0c151d49a2d80d07bda066ea906e68e2a39bb34c9cf3f" -[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"] +[metadata.files] +alabaster = [ + {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, + {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, +] +altgraph = [ + {file = "altgraph-0.17.2-py2.py3-none-any.whl", hash = "sha256:743628f2ac6a7c26f5d9223c91ed8ecbba535f506f4b6f558885a8a56a105857"}, + {file = "altgraph-0.17.2.tar.gz", hash = "sha256:ebf2269361b47d97b3b88e696439f6e4cbc607c17c51feb1754f90fb79839158"}, +] +autopep8 = [ + {file = "autopep8-1.6.0-py2.py3-none-any.whl", hash = "sha256:ed77137193bbac52d029a52c59bec1b0629b5a186c495f1eb21b126ac466083f"}, + {file = "autopep8-1.6.0.tar.gz", hash = "sha256:44f0932855039d2c15c4510d6df665e4730f2b8582704fa48f9c55bd3e17d979"}, +] +babel = [ + {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, + {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, +] +base58 = [ + {file = "base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2"}, + {file = "base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c"}, +] +bitbox02 = [ + {file = "bitbox02-5.3.0-py3-none-any.whl", hash = "sha256:797e6904d431f6d2ef711f169e7ce8fffc125cc8c5b3efb8187fd451f45635e1"}, + {file = "bitbox02-5.3.0.tar.gz", hash = "sha256:fe0e8aeb9b32fd7d76bb3e9838895973a74dfd532a8fb8ac174a1a60214aee26"}, +] +cbor = [ + {file = "cbor-1.0.0.tar.gz", hash = "sha256:13225a262ddf5615cbd9fd55a76a0d53069d18b07d2e9f19c39e6acb8609bbb6"}, +] +certifi = [ + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, +] +cffi = [ + {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, + {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, + {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, + {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, + {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, + {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, + {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, + {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, + {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, + {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, + {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, + {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, + {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, + {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, + {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, + {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, + {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"}, + {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"}, +] +colorama = [ + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +cryptography = [ + {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:9511416e85e449fe1de73f7f99b21b3aa04fba4c4d335d30c486ba3756e3a2a6"}, + {file = "cryptography-36.0.0-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:97199a13b772e74cdcdb03760c32109c808aff7cd49c29e9cf4b7754bb725d1d"}, + {file = "cryptography-36.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:494106e9cd945c2cadfce5374fa44c94cfadf01d4566a3b13bb487d2e6c7959e"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6fbbbb8aab4053fa018984bb0e95a16faeb051dd8cca15add2a27e267ba02b58"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:684993ff6f67000a56454b41bdc7e015429732d65a52d06385b6e9de6181c71e"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c702855cd3174666ef0d2d13dcc879090aa9c6c38f5578896407a7028f75b9f"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d91bc9f535599bed58f6d2e21a2724cb0c3895bf41c6403fe881391d29096f1d"}, + {file = "cryptography-36.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:b17d83b3d1610e571fedac21b2eb36b816654d6f7496004d6a0d32f99d1d8120"}, + {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8982c19bb90a4fa2aad3d635c6d71814e38b643649b4000a8419f8691f20ac44"}, + {file = "cryptography-36.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:24469d9d33217ffd0ce4582dfcf2a76671af115663a95328f63c99ec7ece61a4"}, + {file = "cryptography-36.0.0-cp36-abi3-win32.whl", hash = "sha256:f6a5a85beb33e57998dc605b9dbe7deaa806385fdf5c4810fb849fcd04640c81"}, + {file = "cryptography-36.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:2deab5ec05d83ddcf9b0916319674d3dae88b0e7ee18f8962642d3cde0496568"}, + {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2049f8b87f449fc6190350de443ee0c1dd631f2ce4fa99efad2984de81031681"}, + {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a776bae1629c8d7198396fd93ec0265f8dd2341c553dc32b976168aaf0e6a636"}, + {file = "cryptography-36.0.0-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:aa94d617a4cd4cdf4af9b5af65100c036bce22280ebb15d8b5262e8273ebc6ba"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5c49c9e8fb26a567a2b3fa0343c89f5d325447956cc2fc7231c943b29a973712"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef216d13ac8d24d9cd851776662f75f8d29c9f2d05cdcc2d34a18d32463a9b0b"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231c4a69b11f6af79c1495a0e5a85909686ea8db946935224b7825cfb53827ed"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:f92556f94e476c1b616e6daec5f7ddded2c082efa7cee7f31c7aeda615906ed8"}, + {file = "cryptography-36.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d73e3a96c38173e0aa5646c31bf8473bc3564837977dd480f5cbeacf1d7ef3a3"}, + {file = "cryptography-36.0.0.tar.gz", hash = "sha256:52f769ecb4ef39865719aedc67b4b7eae167bafa48dbc2a26dd36fa56460507f"}, +] +dataclasses = [ + {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, + {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, +] +docutils = [ + {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, + {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, +] +ecdsa = [ + {file = "ecdsa-0.17.0-py2.py3-none-any.whl", hash = "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676"}, + {file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"}, +] +flake8 = [ + {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, + {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, +] +future = [ + {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, +] +hidapi = [ + {file = "hidapi-0.11.0.post2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6fc745eaec01dcc25d71b02cd868e1e34e0c53c4ac7f0f0edbb3a56dbd5e50f8"}, + {file = "hidapi-0.11.0.post2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:23b54a90af7d4d73e730153099f37869feb27784649d1bf29a52ce9faf3168fb"}, + {file = "hidapi-0.11.0.post2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c91ee15c358787583b8f403f9b7b40d48fa4d3e74544e6313f90de83a7695a96"}, + {file = "hidapi-0.11.0.post2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9a70135e8091c8ae685374fcb148ee81b15423072e2ee32f40f7ad710879d4a"}, + {file = "hidapi-0.11.0.post2-cp310-cp310-win32.whl", hash = "sha256:19ca6866dee8ac7668f3b4f12103c0df55ff65cda5c9c400aefc21fb29512004"}, + {file = "hidapi-0.11.0.post2-cp310-cp310-win_amd64.whl", hash = "sha256:449f6a9f0230ec0af749a4f6057afd81285112d702e2b8b19059a061ec8df591"}, + {file = "hidapi-0.11.0.post2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f30a90663a399f71cd3df75d42422643eb3fc290ca19553eaee88ca352e626ff"}, + {file = "hidapi-0.11.0.post2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e8bd1395e25ae557f129973b2153fdb8a0d6debd0f7411ba72abe7dbe536ebe3"}, + {file = "hidapi-0.11.0.post2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:017f379e03f00a35ba12df9819df9bd219a14dd8444860d093a6f9878a505867"}, + {file = "hidapi-0.11.0.post2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afabcf8fdbdc355b907c03984258d7f9acc01eac5114837364fe227c239d53d7"}, + {file = "hidapi-0.11.0.post2-cp36-cp36m-win32.whl", hash = "sha256:67953056555f48539f268f9326462f4a1d525191712fd6af3666e7787f4a609b"}, + {file = "hidapi-0.11.0.post2-cp36-cp36m-win_amd64.whl", hash = "sha256:3083655c9306ff87c3f790949cdbac933fc8c6912dd7063c0c3e6e199b9e40b8"}, + {file = "hidapi-0.11.0.post2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8f8e67042d5dbf6ecec0f8cbf347d99ad074dcb600754bc60609f3340c746977"}, + {file = "hidapi-0.11.0.post2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:782d0c208d794c540e999b1737ddb9ebc36df604d7739889b9659fcc3819f4ce"}, + {file = "hidapi-0.11.0.post2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a19864e2528fff9c708126f72176790c0bb7cb1e31a904ccadc6b6f9fd29a7a1"}, + {file = "hidapi-0.11.0.post2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:053f1f06a671b1e466339a04ee46d91b86932212aaf3b9160d358a6fae80ef50"}, + {file = "hidapi-0.11.0.post2-cp37-cp37m-win32.whl", hash = "sha256:87cbeee15afe23759a3c8eb07eb0ecf156f5f4db98aa31ff2ceee4b45feac4f9"}, + {file = "hidapi-0.11.0.post2-cp37-cp37m-win_amd64.whl", hash = "sha256:9e82b4aaa9c000c49b6fb1296974713b21d3e8fa3e0c87efab03998e4d6dbeee"}, + {file = "hidapi-0.11.0.post2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1bc4ed3c34e4b0b6be589ef88845d7697bd81acf9079ded9d53ab1ebe874dc01"}, + {file = "hidapi-0.11.0.post2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e7d28a6473626f7ddb83a32349196880e8ff38a809f4eeb221f212264c8c9431"}, + {file = "hidapi-0.11.0.post2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d15e7238bbeab71313b4366e65032e5db6c314a3323729e1d4a4798f6f6c111b"}, + {file = "hidapi-0.11.0.post2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84040de52295f043094ca2b6257c6c1fe390fd8d2623ae000a46122150b15b7"}, + {file = "hidapi-0.11.0.post2-cp38-cp38-win32.whl", hash = "sha256:08da7855400a516a742c4dfd6a3b6d69ee56d281561f7fb284c7a289bb2d5b40"}, + {file = "hidapi-0.11.0.post2-cp38-cp38-win_amd64.whl", hash = "sha256:08f5eb2e3e5451c3c631b6b018b3187b6a2fca9c3c200cb5a17fb69fd7f479bb"}, + {file = "hidapi-0.11.0.post2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:55d1a6cdbefb5de85bb54e6d4d4967554fed7d26b874d3290cb1a9f02dfa5865"}, + {file = "hidapi-0.11.0.post2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c70ec32cdfd0c1303fe4aa22ddf839282bdce279a0c038c6efdddc703342dd6"}, + {file = "hidapi-0.11.0.post2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:991b9c9f5a4400d3493cfba2511b0a7f6199eff3ef0968ca65bace32d0f811bf"}, + {file = "hidapi-0.11.0.post2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad8d5ce93c4ea2a51a10cd8405409252107350b0402e3372d905846c0e7326ae"}, + {file = "hidapi-0.11.0.post2-cp39-cp39-win32.whl", hash = "sha256:89c51889894d389e0de63a2adb7faafd9c8c24928b50848365cd4b4d0d57e10b"}, + {file = "hidapi-0.11.0.post2-cp39-cp39-win_amd64.whl", hash = "sha256:5a64b6509064b683ef8ca9888f71906e3dbc6909ab2e3361cbaf6d25a007ab5f"}, + {file = "hidapi-0.11.0.post2.tar.gz", hash = "sha256:da815e0d1d4b2ef1ebbcc85034572105dca29627eb61881337aa39010f2ef8cb"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +imagesize = [ + {file = "imagesize-1.3.0-py2.py3-none-any.whl", hash = "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c"}, + {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, +] +importlib-metadata = [ + {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, + {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, +] +jinja2 = [ + {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, + {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, +] +libusb1 = [ + {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"}, +] +macholib = [ + {file = "macholib-1.15.2-py2.py3-none-any.whl", hash = "sha256:885613dd02d3e26dbd2b541eb4cc4ce611b841f827c0958ab98656e478b9e6f6"}, + {file = "macholib-1.15.2.tar.gz", hash = "sha256:1542c41da3600509f91c165cb897e7e54c0e74008bd8da5da7ebbee519d593d2"}, +] +markupsafe = [ + {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, + {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mnemonic = [ + {file = "mnemonic-0.20-py3-none-any.whl", hash = "sha256:acd2168872d0379e7a10873bb3e12bf6c91b35de758135c4fbd1015ef18fafc5"}, + {file = "mnemonic-0.20.tar.gz", hash = "sha256:7c6fb5639d779388027a77944680aee4870f0fcd09b1e42a5525ee2ce4c625f6"}, +] +mypy = [ + {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"}, + {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"}, + {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"}, + {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"}, + {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"}, + {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"}, + {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"}, + {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"}, + {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"}, + {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"}, + {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"}, + {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"}, + {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"}, + {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"}, + {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"}, + {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"}, + {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"}, + {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"}, + {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"}, + {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"}, + {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"}, + {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"}, + {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +noiseprotocol = [ + {file = "noiseprotocol-0.3.1-py3-none-any.whl", hash = "sha256:2e1a603a38439636cf0ffd8b3e8b12cee27d368a28b41be7dbe568b2abb23111"}, + {file = "noiseprotocol-0.3.1.tar.gz", hash = "sha256:b092a871b60f6a8f07f17950dc9f7098c8fe7d715b049bd4c24ee3752b90d645"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pefile = [ + {file = "pefile-2021.9.3.tar.gz", hash = "sha256:344a49e40a94e10849f0fe34dddc80f773a12b40675bf2f7be4b8be578bdd94a"}, +] +protobuf = [ + {file = "protobuf-3.19.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d80f80eb175bf5f1169139c2e0c5ada98b1c098e2b3c3736667f28cbbea39fc8"}, + {file = "protobuf-3.19.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a529e7df52204565bcd33738a7a5f288f3d2d37d86caa5d78c458fa5fabbd54d"}, + {file = "protobuf-3.19.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28ccea56d4dc38d35cd70c43c2da2f40ac0be0a355ef882242e8586c6d66666f"}, + {file = "protobuf-3.19.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b30a7de128c46b5ecb343917d9fa737612a6e8280f440874e5cc2ba0d79b8f6"}, + {file = "protobuf-3.19.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5935c8ce02e3d89c7900140a8a42b35bc037ec07a6aeb61cc108be8d3c9438a6"}, + {file = "protobuf-3.19.1-cp36-cp36m-win32.whl", hash = "sha256:74f33edeb4f3b7ed13d567881da8e5a92a72b36495d57d696c2ea1ae0cfee80c"}, + {file = "protobuf-3.19.1-cp36-cp36m-win_amd64.whl", hash = "sha256:038daf4fa38a7e818dd61f51f22588d61755160a98db087a046f80d66b855942"}, + {file = "protobuf-3.19.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e51561d72efd5bd5c91490af1f13e32bcba8dab4643761eb7de3ce18e64a853"}, + {file = "protobuf-3.19.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:6e8ea9173403219239cdfd8d946ed101f2ab6ecc025b0fda0c6c713c35c9981d"}, + {file = "protobuf-3.19.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db3532d9f7a6ebbe2392041350437953b6d7a792de10e629c1e4f5a6b1fe1ac6"}, + {file = "protobuf-3.19.1-cp37-cp37m-win32.whl", hash = "sha256:615b426a177780ce381ecd212edc1e0f70db8557ed72560b82096bd36b01bc04"}, + {file = "protobuf-3.19.1-cp37-cp37m-win_amd64.whl", hash = "sha256:d8919368410110633717c406ab5c97e8df5ce93020cfcf3012834f28b1fab1ea"}, + {file = "protobuf-3.19.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:71b0250b0cfb738442d60cab68abc166de43411f2a4f791d31378590bfb71bd7"}, + {file = "protobuf-3.19.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:3cd0458870ea7d1c58e948ac8078f6ba8a7ecc44a57e03032ed066c5bb318089"}, + {file = "protobuf-3.19.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:655264ed0d0efe47a523e2255fc1106a22f6faab7cc46cfe99b5bae085c2a13e"}, + {file = "protobuf-3.19.1-cp38-cp38-win32.whl", hash = "sha256:b691d996c6d0984947c4cf8b7ae2fe372d99b32821d0584f0b90277aa36982d3"}, + {file = "protobuf-3.19.1-cp38-cp38-win_amd64.whl", hash = "sha256:e7e8d2c20921f8da0dea277dfefc6abac05903ceac8e72839b2da519db69206b"}, + {file = "protobuf-3.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fd390367fc211cc0ffcf3a9e149dfeca78fecc62adb911371db0cec5c8b7472d"}, + {file = "protobuf-3.19.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d83e1ef8cb74009bebee3e61cc84b1c9cd04935b72bca0cbc83217d140424995"}, + {file = "protobuf-3.19.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36d90676d6f426718463fe382ec6274909337ca6319d375eebd2044e6c6ac560"}, + {file = "protobuf-3.19.1-cp39-cp39-win32.whl", hash = "sha256:e7b24c11df36ee8e0c085e5b0dc560289e4b58804746fb487287dda51410f1e2"}, + {file = "protobuf-3.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:77d2fadcf369b3f22859ab25bd12bb8e98fb11e05d9ff9b7cd45b711c719c002"}, + {file = "protobuf-3.19.1-py2.py3-none-any.whl", hash = "sha256:e813b1c9006b6399308e917ac5d298f345d95bb31f46f02b60cd92970a9afa17"}, + {file = "protobuf-3.19.1.tar.gz", hash = "sha256:62a8e4baa9cb9e064eb62d1002eca820857ab2138440cb4b3ea4243830f94ca7"}, +] +pyaes = [ + {file = "pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f"}, +] +pycodestyle = [ + {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, + {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, +] +pycparser = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] +pyflakes = [ + {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, + {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, +] +pygments = [ + {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, + {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, +] +pyinstaller = [ + {file = "pyinstaller-4.5.1-py3-none-macosx_10_13_universal2.whl", hash = "sha256:ecc2baadeeefd2b6fbf39d13c65d4aa603afdda1c6aaaebc4577ba72893fee9e"}, + {file = "pyinstaller-4.5.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4d848cd782ee0893d7ad9fe2bfe535206a79f0b6760cecc5f2add831258b9322"}, + {file = "pyinstaller-4.5.1-py3-none-manylinux2014_i686.whl", hash = "sha256:8f747b190e6ad30e2d2fd5da9a64636f61aac8c038c0b7f685efa92c782ea14f"}, + {file = "pyinstaller-4.5.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c587da8f521a7ce1b9efb4e3d0117cd63c92dc6cedff24590aeef89372f53012"}, + {file = "pyinstaller-4.5.1-py3-none-win32.whl", hash = "sha256:fed9f5e4802769a416a8f2ca171c6be961d1861cc05a0b71d20dfe05423137e9"}, + {file = "pyinstaller-4.5.1-py3-none-win_amd64.whl", hash = "sha256:aae456205c68355f9597411090576bb31b614e53976b4c102d072bbe5db8392a"}, + {file = "pyinstaller-4.5.1.tar.gz", hash = "sha256:30733baaf8971902286a0ddf77e5499ac5f7bf8e7c39163e83d4f8c696ef265e"}, +] +pyinstaller-hooks-contrib = [ + {file = "pyinstaller-hooks-contrib-2021.4.tar.gz", hash = "sha256:775b52200b39e12c95cc24f809eb050a97110fee819d178ebfde214f0f51e5f4"}, + {file = "pyinstaller_hooks_contrib-2021.4-py2.py3-none-any.whl", hash = "sha256:60a57e4057fa2183bbaa81f10401a27eb7dd701ef8a11b287bb6345b571f94e7"}, +] +pyparsing = [ + {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, + {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, +] +pyserial = [ + {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"}, + {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"}, +] +pyside2 = [ + {file = "PySide2-5.15.2-5.15.2-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:4f17a0161995678110447711d685fcd7b15b762810e8f00f6dc239bffb70a32e"}, + {file = "PySide2-5.15.2-5.15.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0558ced3bcd7f9da638fa8b7709dba5dae82a38728e481aac8b9058ea22fcdd9"}, + {file = "PySide2-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-abi3-macosx_10_13_intel.whl", hash = "sha256:976cacf01ef3b397a680f9228af7d3d6273b9254457ad4204731507c1f9e6c3c"}, + {file = "PySide2-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-abi3-manylinux1_x86_64.whl", hash = "sha256:081d8c8a6c65fb1392856a547814c0c014e25ac04b38b987d9a3483e879e9634"}, + {file = "PySide2-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-none-win32.whl", hash = "sha256:087a0b719bb967405ea85fd202757c761f1fc73d0e2397bc3a6a15376782ee75"}, + {file = "PySide2-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-none-win_amd64.whl", hash = "sha256:1316aa22dd330df096daf7b0defe9c00297a66e0b4907f057aaa3e88c53d1aff"}, +] +pytz = [ + {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, + {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, +] +pywin32-ctypes = [ + {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"}, +] +requests = [ + {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, + {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, +] +semver = [ + {file = "semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4"}, + {file = "semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f"}, +] +shiboken2 = [ + {file = "shiboken2-5.15.2-5.15.2-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:03f41b0693b91c7f89627f1085a4ecbe8591c03f904118a034854d935e0e766c"}, + {file = "shiboken2-5.15.2-5.15.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ae8ca41274cfa057106268b6249674ca669c5b21009ec49b16d77665ab9619ed"}, + {file = "shiboken2-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-abi3-macosx_10_13_intel.whl", hash = "sha256:edc12a4df2b5be7ca1e762ab94e331ba9e2fbfe3932c20378d8aa3f73f90e0af"}, + {file = "shiboken2-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-abi3-manylinux1_x86_64.whl", hash = "sha256:4aee1b91e339578f9831e824ce2a1ec3ba3a463f41fda8946b4547c7eb3cba86"}, + {file = "shiboken2-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-none-win32.whl", hash = "sha256:89c157a0e2271909330e1655892e7039249f7b79a64a443d52c512337065cde0"}, + {file = "shiboken2-5.15.2-5.15.2-cp35.cp36.cp37.cp38.cp39-none-win_amd64.whl", hash = "sha256:14a33169cf1bd919e4c4c4408fffbcd424c919a3f702df412b8d72b694e4c1d5"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +snowballstemmer = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] +sphinx = [ + {file = "Sphinx-4.3.1-py3-none-any.whl", hash = "sha256:048dac56039a5713f47a554589dc98a442b39226a2b9ed7f82797fcb2fe9253f"}, + {file = "Sphinx-4.3.1.tar.gz", hash = "sha256:32a5b3e9a1b176cc25ed048557d4d3d01af635e6b76c5bc7a43b0a34447fbd45"}, +] +sphinx-rtd-theme = [ + {file = "sphinx_rtd_theme-1.0.0-py2.py3-none-any.whl", hash = "sha256:4d35a56f4508cfee4c4fb604373ede6feae2a306731d533f409ef5c3496fdbd8"}, + {file = "sphinx_rtd_theme-1.0.0.tar.gz", hash = "sha256:eec6d497e4c2195fa0e8b2016b337532b8a699a68bcb22a512870e16925c6a5c"}, +] +sphinxcontrib-applehelp = [ + {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"}, +] +sphinxcontrib-autoprogram = [ + {file = "sphinxcontrib-autoprogram-0.1.7.tar.gz", hash = "sha256:bc642e3f2817a7539f306e021697f72b225bea5ad23b30dc14a7b9d1408d1f1a"}, + {file = "sphinxcontrib_autoprogram-0.1.7-py2.py3-none-any.whl", hash = "sha256:746adb4214c3d2917af948499b3ed4b7b88b208a48c96368c0cff356474dba42"}, +] +sphinxcontrib-devhelp = [ + {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"}, +] +sphinxcontrib-htmlhelp = [ + {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"}, +] +sphinxcontrib-jsmath = [ + {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"}, +] +sphinxcontrib-qthelp = [ + {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"}, +] +sphinxcontrib-serializinghtml = [ + {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"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +typed-ast = [ + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, + {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, + {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, + {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, + {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, + {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, + {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, + {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, + {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, + {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, + {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, + {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, + {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, + {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, + {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, + {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, + {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, + {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, + {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, + {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, + {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, +] +typing-extensions = [ + {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, + {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, + {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, +] +urllib3 = [ + {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, + {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, +] +zipp = [ + {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, + {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, +] diff --git a/pyproject.toml b/pyproject.toml index f1fcc35b1..33ad42ea9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,38 +1,49 @@ [tool.poetry] name = "hwi" -version = "1.0.2" +version = "2.0.2" description = "A library for working with Syscoin hardware wallets" -authors = ["Andrew Chow "] +authors = ["Jagdeep Sidhu ", "Andrew Chow "] license = "MIT" readme = "README.md" repository = "https://github.com/syscoin/HWI" homepage = "https://github.com/syscoin/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" +hidapi = "~0" +ecdsa = "~0" pyaes = "^1.6" -mnemonic = "^0.18.0" +mnemonic = "~0" typing-extensions = "^3.7" -libusb1 = "^1.7" +libusb1 = ">=1.7,<3" +pyside2 = { version = "^5.14.0", optional = true, python = "<3.10" } +bitbox02 = ">=5.3.0,<6.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 = "^4.0" +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 0777898c8..ce4b0c709 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from distutils.core import setup +from setuptools import setup packages = \ ['hwilib', @@ -11,33 +11,42 @@ 'hwilib.devices.trezorlib.transport'] package_data = \ -{'': ['*'], 'hwilib': ['udev/*']} +{'': ['*'], 'hwilib': ['udev/*', 'ui/*']} 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>=5.3.0,<6.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', + 'pyserial>=3.4.0,<4.0.0', 'typing-extensions>=3.7,<4.0'] +extras_require = \ +{'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.0.2', 'description': 'A library for working with Syscoin hardware wallets', - 'long_description': "# Syscoin Hardware Wallet Interface\n\n[![Build Status](https://travis-ci.org/syscoin/HWI.svg?branch=master)](https://travis-ci.org/syscoin/HWI)\n\nThe Syscoin 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/syscoin/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 Syscoin Core\n\nSee [Using Syscoin Core with Hardware Wallets](docs/syscoin-core-usage.md).\n\n## License\n\nThis project is available under the MIT License, Copyright Andrew Chow.\n", +'long_description': "# Syscoin Hardware Wallet Interface\n\n[![Build Status](https://api.cirrus-ci.com/github/syscoin/HWI.svg)](https://cirrus-ci.com/github/syscoin/HWI)\n[![Documentation Status](https://readthedocs.org/projects/hwi/badge/?version=latest)](https://hwi.readthedocs.io/en/latest/?badge=latest)\n\nThe Syscoin 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/syscoin/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.dependecies]` 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 Syscoin Core\n\nSee [Using Syscoin Core with Hardware Wallets](docs/syscoin-core-usage.md).\n\n## License\n\nThis project is available under the MIT License, Copyright Andrew Chow.\n", 'author': 'Jagdeep Sidhu', 'author_email': 'jsidhu@blockchainfoundry.co', + 'maintainer': None, + 'maintainer_email': None, 'url': 'https://github.com/syscoin/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', } diff --git a/test/README.md b/test/README.md index 84a470338..05acd4ea8 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/syscoin/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 `syscoind`. - `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). @@ -19,11 +19,14 @@ It uses the [Coldcard simulator](https://github.com/Coldcard/firmware/tree/maste It also tests usage with `syscoind`. `setup_environment.sh` will build the Trezor emulator, the Coldcard simulator, the Keepkey emulator, the Digital Bitbox simulator, and `syscoind`. -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/syscoin` respectively. +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/syscoin` 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`, and `--syscoind`. +If an environment variable is not present or not set, then the simulator/emulator or syscoind 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 syscoind. 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`, 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 `syscoind` without `setup_environment.sh`, then you will need to make `work/` inside of `test/`. @@ -54,13 +57,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 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-libngu.patch b/test/data/coldcard-libngu.patch new file mode 100644 index 000000000..a00510b04 --- /dev/null +++ b/test/data/coldcard-libngu.patch @@ -0,0 +1,39 @@ +From ed46f9ae6048aed6e694fd040909a972be4595d1 Mon Sep 17 00:00:00 2001 +From: Andrew Chow +Date: Fri, 27 Aug 2021 22:08:14 -0400 +Subject: [PATCH] Build fixes for libngu on linux + +--- + ngu/hash.c | 2 +- + ngu/random.c | 2 +- + 2 files changed, 2 insertions(+), 2 deletions(-) + +diff --git a/ngu/hash.c b/ngu/hash.c +index 72a16d5..c8e0653 100644 +--- a/ngu/hash.c ++++ b/ngu/hash.c +@@ -286,7 +286,7 @@ void ripemd160(const uint8_t *msg, int msglen, uint8_t digest[20]) + #error "untested; suspect endian challenge here" + #endif + +- if(((uint32_t)digest) & 0x3) { ++ if(((uintptr_t)digest) & 0x3) { + // unaligned case + uint32_t ctx[5]; + +diff --git a/ngu/random.c b/ngu/random.c +index 4bd3d8d..deef1d2 100644 +--- a/ngu/random.c ++++ b/ngu/random.c +@@ -32,7 +32,7 @@ extern uint32_t rng_get(void); + + #ifdef UNIX + # define CHIP_TRNG_SETUP() +-# define CHIP_TRNG_32() arc4random() ++# define CHIP_TRNG_32() random() + #endif + + #ifndef CHIP_TRNG_SETUP +-- +2.33.0 + diff --git a/test/data/coldcard-multisig.patch b/test/data/coldcard-multisig.patch new file mode 100644 index 000000000..4c3b0368c --- /dev/null +++ b/test/data/coldcard-multisig.patch @@ -0,0 +1,135 @@ +From d217046502825fb238cca49546e2b314ccafa8ad 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.33.0 + +From ea0fbe4eb7ba4c68ed60c262af3c1cea993061cb 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 130f20c..4f0c46b 100644 +--- a/unix/variant/sim_settings.py ++++ b/unix/variant/sim_settings.py +@@ -68,7 +68,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.33.0 + +From 9c6a8f180bd2589c3e0700ea0c6724a2430ee713 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 | 38 ++++++++++++++++++++++++-------------- + 1 file changed, 24 insertions(+), 14 deletions(-) + +diff --git a/shared/multisig.py b/shared/multisig.py +index 4ce66e4..a655516 100644 +--- a/shared/multisig.py ++++ b/shared/multisig.py +@@ -142,9 +142,9 @@ class MultisigWallet: + # calc useful cache value: numeric xfp+subpath, with lookup + self.xfp_paths = {} + for xfp, deriv, _ 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): +@@ -243,7 +243,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): +@@ -281,17 +285,23 @@ class MultisigWallet: + 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.33.0 + 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..dd17ecaf3 --- /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).*", + "actions": [ + [ "button", 2, true ], + [ "button", 2, false ] + ] + }, + { + "regexp": "^(Accept|Approve).*", + "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 7f6dfc78e..6941c59be 100755 --- a/test/run_tests.py +++ b/test/run_tests.py @@ -6,6 +6,7 @@ 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_syscoind @@ -14,12 +15,13 @@ 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,89 @@ 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_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_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') 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('--syscoind', help='Path to syscoind', default='work/syscoin/src/syscoind') 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, 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.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.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.keepkey or args.bitbox01 or args.jade: # Start syscoind rpc, userpass = start_syscoind(args.syscoind) - 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()) + if success and args.bitbox01: + success &= digitalbitbox_test_suite(args.bitbox01_path, rpc, userpass, args.interface) + if success and args.coldcard: + success &= coldcard_test_suite(args.coldcard_path, rpc, userpass, args.interface) + if success and args.trezor_1: + success &= trezor_test_suite(args.trezor_1_path, rpc, userpass, args.interface, '1') + if success and args.trezor_t: + success &= trezor_test_suite(args.trezor_t_path, rpc, userpass, args.interface, 't') + if success and args.keepkey: + success &= keepkey_test_suite(args.keepkey_path, rpc, userpass, args.interface) + if success and args.ledger: + success &= ledger_test_suite(args.ledger_path, rpc, userpass, args.interface) + if success and args.jade: + success &= jade_test_suite(args.jade_path, rpc, userpass, args.interface) + +sys.exit(not success) diff --git a/test/setup_environment.sh b/test/setup_environment.sh index d10b16005..0e282a050 100755 --- a/test/setup_environment.sh +++ b/test/setup_environment.sh @@ -1,194 +1,394 @@ #! /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 + ;; + --keepkey) + build_keepkey=1 + shift + ;; + --jade) + build_jade=1 + shift + ;; + --syscoind) + build_syscoind=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_syscoind=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 + 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 toolchain uninstall stable + rustup toolchain install stable + rustup update + # 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 + + # Apply patch to libngu to make it compile + pushd external/libngu + git am ../../../../data/coldcard-libngu.patch + popd -# 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" + 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/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 syscoind if it doesn't exist, or update it if it does -syscoind_setup_needed=false -if [ ! -d "syscoin" ]; then - git clone https://github.com/syscoin/syscoin.git -b dev-4.x - cd syscoin - syscoind_setup_needed=true -else - cd syscoin - 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 - syscoind_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 + + # 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 + 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 [ ! -d "qemu" ]; then + git clone --depth 1 --branch ${ESP_QEMU_BRANCH} --single-branch --recursive https://github.com/espressif/qemu.git ./qemu + cd qemu + ./configure --target-list=xtensa-softmmu \ + --enable-gcrypt \ + --enable-debug --enable-sanitizers \ + --disable-strip --disable-user \ + --disable-capstone --disable-vnc \ + --disable-sdl --disable-gtk + else + cd qemu + git fetch fi + git checkout ${ESP_QEMU_COMMIT} + git submodule update --recursive --init + ninja -C build + cd .. + + # Build the esp-idf toolchain + 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 + else + cd esp-idf + git fetch + fi + git checkout ${ESP_IDF_COMMIT} + git submodule update --recursive --init + + # Install the isp-idf tools in a given location (otherwise defauts to user home dir) + IDF_TOOLS_PATH=$(pwd)/tools + ./install.sh + . ./export.sh + cd .. + + # Build Blockstream Jade firmware configured for the emulator + cd jade + rm -fr sdkconfig + cp configs/sdkconfig_qemu.defaults sdkconfig.defaults + idf.py 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 syscoind. This is super slow, but it is cached so it runs fairly quickly. -if [ "$syscoind_setup_needed" == true ] ; then +if [[ -n ${build_syscoind} ]]; then + # Clone syscoind if it doesn't exist, or update it if it does + syscoind_setup_needed=false + if [ ! -d "syscoin" ]; then + git clone https://github.com/syscoin/syscoin.git + cd syscoin + syscoind_setup_needed=true + else + cd syscoin + 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 + syscoind_setup_needed=true + fi + fi + + # Build syscoind. 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 ./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/syscoind fi -make -j$(nproc) src/syscoind diff --git a/test/test_base58.py b/test/test_base58.py index 571801689..241dc90e6 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 Syscoin Core # https://github.com/syscoin/syscoin/blob/master/src/test/data/base58_encode_decode.json diff --git a/test/test_bech32.py b/test/test_bech32.py index 6f0dff0d7..e39cdc4eb 100755 --- a/test/test_bech32.py +++ b/test/test_bech32.py @@ -25,55 +25,113 @@ 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 = [ @@ -89,19 +147,23 @@ class TestSegwitAddress(unittest.TestCase): 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 aef3e696a..3b42605b1 100755 --- a/test/test_coldcard.py +++ b/test/test_coldcard.py @@ -2,35 +2,46 @@ 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 hwilib._cli import process_commands from test_device import DeviceTestCase, start_syscoind, TestDeviceConnect, TestDisplayAddress, TestGetKeypool, TestGetDescriptors, TestSignMessage, TestSignTx def coldcard_test_suite(simulator, rpc, userpass, interface): + try: + os.unlink('coldcard-emulator.stdout') + except FileNotFoundError: + pass + coldcard_log = open('coldcard-emulator.stdout', 'a') # Start the Coldcard simulator - subprocess.Popen(['python3', os.path.basename(simulator)], cwd=os.path.dirname(simulator), stdout=subprocess.DEVNULL) + coldcard_proc = subprocess.Popen(['python3', os.path.basename(simulator), '--ms'], cwd=os.path.dirname(simulator), stdout=coldcard_log, preexec_fn=os.setsid) # 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 + 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 - if found: - break + except Exception: + pass time.sleep(0.5) # Cleanup def cleanup_simulator(): - dev = ColdcardDevice(sn='/tmp/ckcc-simulator.sock') - dev.send_recv(CCProtocolPacker.logout()) + if coldcard_proc.poll() is None: + os.killpg(os.getpgid(coldcard_proc.pid), signal.SIGTERM) + os.waitpid(os.getpgid(coldcard_proc.pid), 0) + coldcard_log.close() atexit.register(cleanup_simulator) # Coldcard specific management command tests @@ -59,7 +70,8 @@ def test_restore(self): 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']) + for filename in glob.glob("backup-*.7z"): + os.remove(filename) def test_pin(self): result = self.do_command(self.dev_args + ['promptpin']) @@ -74,9 +86,22 @@ def test_pin(self): 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/57h/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') + # 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(TestColdcardGetXpub, rpc, userpass, 'coldcard', 'coldcard', '/tmp/ckcc-simulator.sock', '0f056943', 'tpubDDpWvmUrPZrhSPmUzCMBHffvC3HyMAPnWDSAQNBTnj1iZeJa7BZQEttFiP4DS4GCcXQHezdXhn86Hj6LHX5EDstXPWrMaSneRWM8yUf6NFd', 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)) @@ -84,7 +109,11 @@ def test_pin(self): 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 + + result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + cleanup_simulator() + atexit.unregister(cleanup_simulator) + return result.wasSuccessful() if __name__ == '__main__': parser = argparse.ArgumentParser(description='Test Coldcard implementation') @@ -96,5 +125,4 @@ def test_pin(self): # Start syscoind rpc, userpass = start_syscoind(args.syscoind) - suite = coldcard_test_suite(args.simulator, rpc, userpass, args.interface) - unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(not coldcard_test_suite(args.simulator, rpc, userpass, args.interface)) diff --git a/test/test_descriptor.py b/test/test_descriptor.py index a479c5962..8057155b4 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/57h/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/57h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/57h/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/57h/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/57h/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/57h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/57h/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/57h/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/57h/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/57h/0h/2h]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/0,[00000002/48h/57h/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/57h/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/57h/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/57h/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/57h/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/57h/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/57h/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/57h/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/57h/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/57h/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 4da0efc37..40fa3fc7a 100644 --- a/test/test_device.py +++ b/test/test_device.py @@ -11,9 +11,20 @@ import unittest 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 +from hwilib.key import KeyOriginInfo +from hwilib.psbt import PSBT + +SUPPORTS_MS_DISPLAY = {'trezor_1', 'keepkey', 'coldcard', 'trezor_t'} +SUPPORTS_XPUB_MS_DISPLAY = {'trezor_1', 'trezor_t'} +SUPPORTS_UNSORTED_MS = {"trezor_1", "trezor_t"} +SUPPORTS_MIXED = {'coldcard', 'trezor_1', 'digitalbitbox', 'keepkey', 'trezor_t', 'jade'} +SUPPORTS_MULTISIG = {'ledger', 'trezor_1', 'digitalbitbox', 'keepkey', 'coldcard', 'trezor_t', 'jade'} +SUPPORTS_EXTERNAL = {'ledger', 'trezor_1', 'digitalbitbox', 'keepkey', 'coldcard', 'trezor_t', 'jade'} +SUPPORTS_OP_RETURN = {'ledger', 'digitalbitbox', 'trezor_1', 'trezor_t', 'keepkey', 'jade'} +SUPPORTS_TAPROOT = {} # Class for emulator control class DeviceEmulator(): @@ -25,7 +36,7 @@ def stop(self): def start_syscoind(syscoind_path): datadir = tempfile.mkdtemp() - syscoind_proc = subprocess.Popen([syscoind_path, '-regtest', '-datadir=' + datadir, '-noprinttoconsole', '-fallbackfee=0.0002']) + syscoind_proc = subprocess.Popen([syscoind_path, '-regtest', '-datadir=' + datadir, '-noprinttoconsole', '-fallbackfee=0.0002', '-keypool=1']) def cleanup_syscoind(): syscoind_proc.kill() @@ -50,7 +61,9 @@ def cleanup_syscoind(): pass # Make sure there are blocks and coins available - rpc.generatetoaddress(101, rpc.getnewaddress()) + rpc.createwallet(wallet_name="supply") + wrpc = AuthServiceProxy('http://{}@127.0.0.1:18443/wallet/supply'.format(userpass)) + wrpc.generatetoaddress(101, wrpc.getnewaddress()) return (rpc, userpass) class DeviceTestCase(unittest.TestCase): @@ -64,7 +77,7 @@ def __init__(self, rpc, rpc_userpass, type, full_type, path, fingerprint, master self.fingerprint = fingerprint self.master_xpub = master_xpub self.password = password - self.dev_args = ['-t', self.type, '-d', self.path] + self.dev_args = ['-t', self.type, '-d', self.path, '--chain', 'test'] if emulator: self.emulator = emulator else: @@ -91,12 +104,13 @@ 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: @@ -113,129 +127,118 @@ def __str__(self): def __repr__(self): return '{}: {}'.format(self.full_type, super().__repr__()) -class TestDeviceConnect(DeviceTestCase): + def setup_wallets(self): + wallet_name = '{}_{}_test'.format(self.full_type, self.id()) + self.rpc.createwallet(wallet_name=wallet_name, disable_private_keys=True, descriptors=True) + self.wrpc = AuthServiceProxy('http://{}@127.0.0.1:18443/wallet/{}'.format(self.rpc_userpass, wallet_name)) + self.wpk_rpc = AuthServiceProxy('http://{}@127.0.0.1:18443/wallet/supply'.format(self.rpc_userpass)) + def setUp(self): self.emulator.start() def tearDown(self): self.emulator.stop() +class TestDeviceConnect(DeviceTestCase): 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: + 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(['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']) + gmxp_res = self.do_command(self.get_password_args() + ['-t', self.type, '-d', self.path, 'getmasterxpub', "--addr-type", "legacy"]) self.assertEqual(gmxp_res['xpub'], self.master_xpub) def test_fingerprint_autodetect(self): - gmxp_res = self.do_command(self.get_password_args() + ['-f', self.fingerprint, 'getmasterxpub']) + gmxp_res = self.do_command(self.get_password_args() + ['-f', self.fingerprint, 'getmasterxpub', "--addr-type", "legacy"]) self.assertEqual(gmxp_res['xpub'], self.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', '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']) + gmxp_res = self.do_command(self.get_password_args() + ['-t', self.type, 'getmasterxpub', "--addr-type", "legacy"]) self.assertEqual(gmxp_res['xpub'], self.master_xpub) # Unknown device type - gmxp_res = self.do_command(['-t', 'fakedev', '-d', 'fakepath', 'getmasterxpub']) + gmxp_res = self.do_command(['-t', 'fakedev', '-d', 'fakepath', '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:18470'.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:18470/wallet/{}_test'.format(self.rpc_userpass, self.full_type)) - self.wpk_rpc = AuthServiceProxy('http://{}@127.0.0.1:18470/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) + getkeypool_args = [ + ("legacy", 44, "legacy"), + ("wit", 84, "bech32"), + ("sh_wit", 49, "p2sh-segwit"), + ] + if self.full_type in 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]}'/57'/0'/0/")) + addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress(arg[2])) + self.assertTrue(addr_info["hdkeypath"].startswith(f"m/{arg[1]}'/57'/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'/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) + for _ in range(0, 21): + addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress('', 'p2sh-segwit')) + self.assertTrue(addr_info['hdkeypath'].startswith("m/49'/57'/3'/0/")) + addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress('p2sh-segwit')) + self.assertTrue(addr_info['hdkeypath'].startswith("m/49'/57'/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'/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) + for _ in range(0, 21): + addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress('', 'bech32')) + self.assertTrue(addr_info['hdkeypath'].startswith("m/84'/57'/3'/0/")) + addr_info = self.wrpc.getaddressinfo(self.wrpc.getrawchangeaddress('bech32')) + self.assertTrue(addr_info['hdkeypath'].startswith("m/84'/57'/3'/1/")) + + keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--path', 'm/57h/0h/4h/*', '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) - 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)) - - keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--path', 'm/84h/57h/4h/*', '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'/57'/4'/{}".format(i)) + for _ in range(0, 21): + addr_info = self.wrpc.getaddressinfo(self.wrpc.getnewaddress('', 'bech32')) + self.assertTrue(addr_info['hdkeypath'].startswith("m/57'/0'/4'/")) keypool_desc = self.do_command(self.dev_args + ['getkeypool', '--path', '/84h/57h/4h/*', '0', '20']) self.assertEqual(keypool_desc['error'], 'Path must start with m/') @@ -245,12 +248,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:18470'.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 +256,33 @@ 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.full_type in SUPPORTS_TAPROOT else 3) + self.assertEqual(len(descriptors['internal']), 4 if self.full_type in 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:18470'.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:18470/wallet/{}_test'.format(self.rpc_userpass, self.full_type)) - self.wpk_rpc = AuthServiceProxy('http://{}@127.0.0.1:18470/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() + super().setUp() + self.setup_wallets() def _generate_and_finalize(self, unknown_inputs, 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']]) 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 @@ -306,9 +298,9 @@ def _generate_and_finalize(self, unknown_inputs, psbt): # 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, 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) single_input = len(first_psbt.inputs) == 1 @@ -318,11 +310,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,51 +332,60 @@ 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_multisigs(self): + def get_pubkeys(t): + desc_pubkeys = [] + sorted_pubkeys = [] + for i in range(0, 3): + path = "/48h/1h/{}h/{}h/0/0".format(i, t) + origin = '{}{}'.format(self.fingerprint, path) + xpub = self.do_command(self.dev_args + ["--expert", "getxpub", "m{}".format(path)]) + desc_pubkeys.append("[{}]{}".format(origin, xpub["pubkey"])) + sorted_pubkeys.append(xpub["pubkey"]) + sorted_pubkeys.sort() + return desc_pubkeys, sorted_pubkeys + + desc_pubkeys, sorted_pubkeys = get_pubkeys(0) + sh_desc = AddChecksum("sh(sortedmulti(2,{},{},{}))".format(desc_pubkeys[0], desc_pubkeys[1], desc_pubkeys[2])) + sh_ms_info = self.rpc.createmultisig(2, sorted_pubkeys, "legacy") + self.assertEqual(self.rpc.deriveaddresses(sh_desc)[0], sh_ms_info["address"]) + + # Trezor requires that each address type uses a different derivation path. + # Other devices don't have this requirement, and in the tests involving multiple address types, Coldcard will fail. + # So for those other devices, stick to the 0 path. + desc_pubkeys, sorted_pubkeys = get_pubkeys(1) if self.full_type == "trezor_t" else get_pubkeys(0) + sh_wsh_desc = AddChecksum("sh(wsh(sortedmulti(2,{},{},{})))".format(desc_pubkeys[1], desc_pubkeys[2], desc_pubkeys[0])) + sh_wsh_ms_info = self.rpc.createmultisig(2, sorted_pubkeys, "p2sh-segwit") + self.assertEqual(self.rpc.deriveaddresses(sh_wsh_desc)[0], sh_wsh_ms_info["address"]) + + desc_pubkeys, sorted_pubkeys = get_pubkeys(2) if self.full_type == "trezor_t" else get_pubkeys(0) + wsh_desc = AddChecksum("wsh(sortedmulti(2,{},{},{}))".format(desc_pubkeys[2], desc_pubkeys[1], desc_pubkeys[0])) + wsh_ms_info = self.rpc.createmultisig(2, sorted_pubkeys, "bech32") + self.assertEqual(self.rpc.deriveaddresses(wsh_desc)[0], wsh_ms_info["address"]) + + return sh_desc, sh_ms_info["address"], sh_wsh_desc, sh_wsh_ms_info["address"], wsh_desc, wsh_ms_info["address"] + + def _test_signtx(self, input_type, multisig, 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_desc, sh_multi_addr, sh_wsh_multi_desc, sh_wsh_multi_addr, wsh_multi_desc, wsh_multi_addr = self._make_multisigs() 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]) + multi_result = self.wrpc.importdescriptors([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 + out_amt = in_amt // 3 * 0.9 number_inputs = 0 # Single-sig if input_type == 'segwit' or input_type == 'all': @@ -404,12 +410,23 @@ def _test_signtx(self, input_type, multisig): # Spend different amounts, requiring 1 to 3 inputs for i in range(number_inputs): # Create a psbt spending the above + change_addr = self.wrpc.getrawchangeaddress() 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) + self.assertEqual((i + 1) * in_amt, self.wrpc.getbalance("*", 0, True)) + change_addr = self.wpk_rpc.getrawchangeaddress() + out_val = (i + 1) * out_amt + outputs = [ + {self.wpk_rpc.getnewaddress('', 'legacy'): out_val}, + {self.wpk_rpc.getnewaddress('', 'p2sh-segwit'): out_val}, + {self.wpk_rpc.getnewaddress('', 'bech32'): out_val} + ] + if op_return: + outputs.append({"data": "000102030405060708090a0b0c0d0e0f10111213141516171819101a1b1c1d1e1f"}) + psbt = self.wrpc.walletcreatefundedpsbt([], outputs, 0, {'includeWatching': True, "changePosition": 3, "changeAddress": change_addr}, True) + + 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,13 +435,16 @@ 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) + multisig = self.full_type in SUPPORTS_MULTISIG + external = self.full_type in SUPPORTS_EXTERNAL + op_return = self.full_type in SUPPORTS_OP_RETURN + with self.subTest(addrtype="legacy", multisig=multisig, external=external): + self._test_signtx("legacy", multisig, external, op_return) + with self.subTest(addrtype="segwit", multisig=multisig, external=external): + self._test_signtx("segwit", multisig, external, op_return) + if self.full_type in SUPPORTS_MIXED: + with self.subTest(addrtype="all", multisig=multisig, external=external): + self._test_signtx("all", multisig, external, op_return) # Make a huge transaction which might cause some problems with different interfaces def test_big_tx(self): @@ -456,30 +476,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) @@ -489,62 +497,125 @@ def test_display_address_bad_path(self): self.assertEquals(result['code'], -7) def test_display_address_descriptor(self): - account_xpub = self.do_command(self.dev_args + ['getxpub', 'm/84h/1h/0h'])['xpub'] - p2sh_segwit_account_xpub = self.do_command(self.dev_args + ['getxpub', 'm/49h/1h/0h'])['xpub'] - legacy_account_xpub = self.do_command(self.dev_args + ['getxpub', 'm/44h/1h/0h'])['xpub'] + account_xpub = self.do_command(self.dev_args + ['getxpub', 'm/84h/57h/0h'])['xpub'] + p2sh_segwit_account_xpub = self.do_command(self.dev_args + ['getxpub', 'm/49h/57h/0h'])['xpub'] + legacy_account_xpub = self.do_command(self.dev_args + ['getxpub', 'm/44h/57h/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.fingerprint + '/84h/57h/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.fingerprint + '/84h/57h/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.fingerprint + '/49h/57h/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.fingerprint + '/44h/57h/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.fingerprint + '/84h/57h/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.fingerprint + '/84h/57h/0h]' + "not_and_xpub" + '/0/0)']) self.assertIn('error', result) self.assertIn('code', result) self.assertEqual(result['code'], -7) # Should check fingerprint - self.do_command(self.dev_args + ['displayaddress', '--desc', 'wpkh([00000000/84h/1h/0h]' + account_xpub + '/0/0)']) + self.do_command(self.dev_args + ['displayaddress', '--desc', 'wpkh([00000000/84h/57h/0h]' + account_xpub + '/0/0)']) self.assertIn('error', result) 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.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 self.full_type not in SUPPORTS_MS_DISPLAY and self.full_type not in SUPPORTS_XPUB_MS_DISPLAY: + raise unittest.SkipTest("{} does not support multisig display".format(self.full_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 self.full_type not in SUPPORTS_UNSORTED_MS: + raise unittest.SkipTest("{} does not support unsorted multisigs".format(self.full_type)) + if derive and self.full_type not in SUPPORTS_XPUB_MS_DISPLAY: + raise unittest.SkipTest("{} does not support multisig display with xpubs".format(self.full_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 Syscoin 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 f141437c3..4f2984c90 100755 --- a/test/test_digitalbitbox.py +++ b/test/test_digitalbitbox.py @@ -5,16 +5,22 @@ import json import os import subprocess +import sys import time import unittest -from test_device import DeviceTestCase, start_syscoind, TestDeviceConnect, TestGetKeypool, TestGetDescriptors, TestSignTx, TestSignMessage +from test_device import DeviceTestCase, start_syscoind, TestDeviceConnect, TestGetKeypool, TestGetDescriptors, TestSignTx from hwilib.devices.digitalbitbox import BitboxSimulator, send_plain, send_encrypt def digitalbitbox_test_suite(simulator, rpc, userpass, interface): + try: + os.unlink('bitbox-emulator.stderr') + except FileNotFoundError: + pass + bitbox_log = open('bitbox-emulator.stderr', 'a') # Start the Digital bitbox simulator - simulator_proc = subprocess.Popen(['./' + os.path.basename(simulator), '../../tests/sd_files/'], cwd=os.path.dirname(simulator), stderr=subprocess.DEVNULL) + simulator_proc = subprocess.Popen(['./' + os.path.basename(simulator), '../../tests/sd_files/'], cwd=os.path.dirname(simulator), stderr=bitbox_log) # Wait for simulator to be up while True: try: @@ -22,14 +28,15 @@ def digitalbitbox_test_suite(simulator, rpc, userpass, interface): reply = send_plain(b'{"password":"0000"}', dev) if 'error' not in reply: break - except: + except Exception: pass time.sleep(0.5) # Cleanup def cleanup_simulator(): - simulator_proc.kill() + simulator_proc.terminate() simulator_proc.wait() + bitbox_log.close() atexit.register(cleanup_simulator) # Set password and load from backup @@ -125,16 +132,36 @@ def test_backup(self): result = self.do_command(self.dev_args + ['backup', '--label', 'backup_test_backup', '--backup_passphrase', 'testpass']) self.assertTrue(result['success']) + class TestBitboxGetXpub(DeviceTestCase): + def setUp(self): + self.dev_args.remove('--chain') + self.dev_args.remove('test') + + def test_getxpub(self): + result = self.do_command(self.dev_args + ['--expert', 'getxpub', 'm/44h/57h/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') + # 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(TestBitboxGetXpub, 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 + + result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + cleanup_simulator() + atexit.unregister(cleanup_simulator) + return result.wasSuccessful() if __name__ == '__main__': parser = argparse.ArgumentParser(description='Test Digital Bitbox implementation') @@ -146,5 +173,4 @@ def test_backup(self): # Start syscoind rpc, userpass = start_syscoind(args.syscoind) - suite = digitalbitbox_test_suite(args.simulator, rpc, userpass, args.interface) - unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(not digitalbitbox_test_suite(args.simulator, rpc, userpass, args.interface)) diff --git a/test/test_jade.py b/test/test_jade.py new file mode 100755 index 000000000..8f6b51d59 --- /dev/null +++ b/test/test_jade.py @@ -0,0 +1,210 @@ +#! /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 DeviceEmulator, DeviceTestCase, start_syscoind, TestDeviceConnect, TestDisplayAddress, TestGetKeypool, TestGetDescriptors, TestSignMessage, TestSignTx +from hwilib.devices.jadepy.jade import JadeAPI + +USE_SIMULATOR = True +JADE_PATH = 'tcp:127.0.0.1:2222' if USE_SIMULATOR else '/dev/ttyUSB0' +JADE_MODEL = 'jade_simulator' if USE_SIMULATOR else 'jade' +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 + + def start(self): + if USE_SIMULATOR: + # Start the qemu emulator + print('Starting Jade emulator at:', self.emulator_path) + 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:2222-:2222', + '-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) + 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, temporary_wallet=True): + print('Emulator started and test seed set') + break + + except Exception as e: + print(str(e)) + + def stop(self): + if USE_SIMULATOR: + print('Stopping Jade emulator') + if self.emulator_proc.poll() is None: + os.killpg(os.getpgid(self.emulator_proc.pid), signal.SIGTERM) + os.waitpid(self.emulator_proc.pid, 0) + +def jade_test_suite(emulator, rpc, userpass, interface): + + # 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 setUp(self): + self.dev_args.remove("--chain") + self.dev_args.remove("test") + + def test_getexpertxpub(self): + result = self.do_command(self.dev_args + ['--expert', 'getxpub', 'm/44h/57h/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): + def test_getp2sh(self): + descriptor_param = '--desc=sh(multi(2,[1273da33/44/57h/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') + + def test_getp2wsh(self): + descriptor_param = '--desc=wsh(multi(2,[b5281696/3]tpubECMbgHMZm4QymrFbuEntaWbSsaaKvJVpoR6rhVwxGUrT9qf6WLfC88piWYVsQDV86PUKx3QsXYiCqugWX1oz8ekucUdFUdupuNCgjf17EYK/0/37,[e3ebcc79/3h/2h/1]tpubDD8fpYqWy6DEvbqdj9CVWptA3gd3xqarNN6wCAjfDs1sFnd8zfb9SeDzRAXA3S4eeeYvo2sz6mbyS3KaXuDe5PcWy94PqShTpBjiJN198A6/0/37,[1273da33/1]tpubD8PzcLmm1rVeUpEjmd2kQD6a9DXy6dwVVgE14mrh1zc6L98nmNqmDaApAbEcbrJ1iPBpo2gnEniSpVXHgovU8ecWwfHVP113DK2bWEMPpEs/0/37))' + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], 'tb1qd4zy3l2dckqwjartdq2aj53x2xgesvg530x5569z4lzfrueuhjmsrtcdfp') + + 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)' + + def test_get_signing_p2sh(self): + descriptor_param = '--desc=sh({})'.format(TestJadeGetMultisigAddresses.SIGN_TX_MULTI_DESCRIPTOR) + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], '2N4v6yct4NHy7FLex1wgwksLG97dXSTEs9x', result) + + def test_get_signing_p2wsh(self): + descriptor_param = '--desc=wsh({})'.format(TestJadeGetMultisigAddresses.SIGN_TX_MULTI_DESCRIPTOR) + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], 'tb1qxjxvxk69yedt49u2djh8mu9lsmw6tf4n2pwuqend5tjlqrumuq2skh7qzc', result) + + def test_get_signing_p2shwsh(self): + descriptor_param = '--desc=sh(wsh({}))'.format(TestJadeGetMultisigAddresses.SIGN_TX_MULTI_DESCRIPTOR) + result = self.do_command(self.dev_args + ['displayaddress', descriptor_param]) + self.assertEqual(result['address'], '2N6wdpQsBUvT3nAp8hGaXEFpfvzerqaHFVC', result) + + full_type = 'jade' + device_model = JADE_MODEL + path = JADE_PATH + master_xpub = 'xpub6CYWf8Kf1MXHij4KJjjtkNgQxJufSmAoyrmGuiGWvjXHSpak638GrmgWZqiem339nuHf2xuCmEVmmnXDmskEjB7QdZGW2HdiBUnoEAwV1q2' + fingerprint = '1273da33' + dev_emulator = JadeEmulator(emulator) + dev_emulator.start() + atexit.register(dev_emulator.stop) + + # Generic Device tests + suite = unittest.TestSuite() + suite.addTest(DeviceTestCase.parameterize(TestJadeDisabledCommands, rpc, userpass, device_model, full_type, path, fingerprint, master_xpub, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDeviceConnect, rpc, userpass, device_model, full_type, path, fingerprint, master_xpub, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestJadeGetXpub, rpc, userpass, device_model, full_type, path, fingerprint, master_xpub, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestGetDescriptors, rpc, userpass, device_model, full_type, path, fingerprint, master_xpub, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestGetKeypool, rpc, userpass, device_model, full_type, path, fingerprint, master_xpub, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestDisplayAddress, rpc, userpass, device_model, full_type, path, fingerprint, master_xpub, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestJadeGetMultisigAddresses, rpc, userpass, device_model, full_type, path, fingerprint, master_xpub, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignMessage, rpc, userpass, device_model, full_type, path, fingerprint, master_xpub, interface=interface)) + suite.addTest(DeviceTestCase.parameterize(TestSignTx, rpc, userpass, device_model, full_type, path, fingerprint, master_xpub, interface=interface)) + + result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + dev_emulator.stop() + atexit.unregister(dev_emulator.stop) + 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('syscoind', help='Path to syscoind binary') + parser.add_argument('--interface', help='Which interface to send commands over', choices=['library', 'cli', 'bindist'], default='library') + + args = parser.parse_args() + + # Start syscoind + rpc, userpass = start_syscoind(args.syscoind) + + sys.exit(not jade_test_suite(args.emulator, rpc, userpass, args.interface)) diff --git a/test/test_keepkey.py b/test/test_keepkey.py index a1e543812..cb41541fc 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,20 @@ 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 hwilib.devices.trezorlib.mapping import DEFAULT_MAPPING +from hwilib.devices.trezorlib.models import TrezorModel from test_device import DeviceEmulator, DeviceTestCase, start_syscoind, TestDeviceConnect, TestDisplayAddress, TestGetKeypool, TestGetDescriptors, TestSignMessage, TestSignTx -from hwilib.cli import process_commands -from hwilib.devices.keepkey import KeepkeyClient - +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 +37,20 @@ 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 def start(self): + 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 +62,26 @@ 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() + self.emulator_proc.terminate() self.emulator_proc.wait() # Clean up emulator image @@ -70,6 +89,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 +138,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/57h/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 +192,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 +238,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 +264,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 +272,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,49 +299,67 @@ 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']) + + # 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, rpc, userpass, interface): # Redirect stderr to /dev/null as it's super spammy @@ -288,7 +368,7 @@ def keepkey_test_suite(emulator, rpc, userpass, interface): # Device info for tests type = 'keepkey' full_type = 'keepkey' - path = 'udp:127.0.0.1:21324' + path = 'udp:127.0.0.1:11044' fingerprint = '95d8f670' master_xpub = 'xpub6D1weXBcFAo8CqBbpP4TbH5sxQH8ZkqC5pDEvJ95rNNBZC9zrKmZP2fXMuve7ZRBe18pWQQsGg68jkq24mZchHwYENd8cCiSb71u3KD4AFH' dev_emulator = KeepkeyEmulator(emulator) @@ -304,7 +384,10 @@ def keepkey_test_suite(emulator, rpc, userpass, interface): suite.addTest(DeviceTestCase.parameterize(TestSignMessage, rpc, userpass, type, full_type, path, fingerprint, master_xpub, 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') @@ -316,5 +399,4 @@ def keepkey_test_suite(emulator, rpc, userpass, interface): # Start syscoind rpc, userpass = start_syscoind(args.syscoind) - 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, rpc, userpass, args.interface)) diff --git a/test/test_ledger.py b/test/test_ledger.py index bef7d3dac..57b88b695 100755 --- a/test/test_ledger.py +++ b/test/test_ledger.py @@ -1,25 +1,61 @@ #! /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_syscoind, TestDeviceConnect, TestDisplayAddress, TestGetKeypool, TestGetDescriptors, TestSignMessage, TestSignTx +from test_device import DeviceEmulator, DeviceTestCase, start_syscoind, TestDeviceConnect, TestDisplayAddress, TestGetKeypool, TestGetDescriptors, TestSignMessage, TestSignTx -from hwilib.cli import process_commands +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) +class LedgerEmulator(DeviceEmulator): + def __init__(self, path): + self.emulator_path = path + self.emulator_proc = None + self.emulator_stderr = None + self.emulator_stdout = None + try: + os.unlink('ledger-emulator.stderr') + except FileNotFoundError: + pass + + def start(self): + automation_path = os.path.abspath("data/speculos-automation.json") + + 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', './apps/btc.elf'], 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) + + def stop(self): + 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() + +def ledger_test_suite(emulator, rpc, userpass, interface): # Ledger specific disabled command tests class TestLedgerDisabledCommands(DeviceTestCase): @@ -64,22 +100,50 @@ def test_backup(self): 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 setUp(self): + self.dev_args.remove("--chain") + self.dev_args.remove("test") + + def test_getxpub(self): + result = self.do_command(self.dev_args + ['--expert', 'getxpub', 'm/44h/57h/0h/3']) + self.assertEqual(result['xpub'], 'xpub6DqTtMuqBiBsSPb5UxB1qgJ3ViXuhoyZYhw3zTK4MywLB6psioW4PN1SAbhxVVirKQojnTBsjG5gXiiueRBgWmUuN43dpbMSgMCQHVqx2bR') + self.assertFalse(result['testnet']) + self.assertFalse(result['private']) + self.assertEqual(result['depth'], 4) + self.assertEqual(result['parent_fingerprint'], '2930ce56') + self.assertEqual(result['child_num'], 3) + self.assertEqual(result['chaincode'], 'a3cd503ab3ffd3c31610a84307f141528c7e9b8416e10980ced60d1868b463e2') + self.assertEqual(result['pubkey'], '03d5edb7c091b5577e1e2e6493b34e602b02547518222e26472cfab1745bb5977d') + + device_model = 'ledger_nano_s_simulator' + path = 'tcp:127.0.0.1:9999' + master_xpub = 'xpub6Cak8u8nU1evR4eMoz5UX12bU9Ws5RjEgq2Kq1RKZrsEQF6Cvecoyr19ZYRikWoJo16SXeft5fhkzbXcmuPfCzQKKB9RDPWT8XnUM62ieB9' + fingerprint = 'f5acc2fd' + dev_emulator = LedgerEmulator(emulator) + dev_emulator.start() + atexit.register(dev_emulator.stop) + # 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(TestLedgerGetXpub, 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(TestSignTx, rpc, userpass, device_model, 'ledger', path, fingerprint, master_xpub, interface=interface)) + + result = unittest.TextTestRunner(stream=sys.stdout, verbosity=2).run(suite) + dev_emulator.stop() + atexit.unregister(dev_emulator.stop) + 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('syscoind', help='Path to syscoind 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') args = parser.parse_args() @@ -87,5 +151,4 @@ def test_backup(self): # Start syscoind rpc, userpass = start_syscoind(args.syscoind) - 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, rpc, userpass, args.interface)) 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 be0a84328..0cdd080db 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,18 @@ 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_syscoind, TestDeviceConnect, TestDisplayAddress, TestGetKeypool, TestGetDescriptors, TestSignMessage, TestSignTx -from hwilib.cli import process_commands +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 +31,23 @@ 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 def start(self): + 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 +61,37 @@ 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) + 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 +128,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 +159,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/57h/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 +204,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 +249,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 +273,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 +282,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 +309,36 @@ 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'] == '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 - 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()) + # 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.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'] == '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 +346,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,8 +357,34 @@ 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']) -def trezor_test_suite(emulator, rpc, userpass, interface, model_t=False): + # 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): + assert model in TREZOR_MODELS # Redirect stderr to /dev/null as it's super spammy sys.stderr = open(os.devnull, 'w') @@ -304,12 +393,8 @@ def trezor_test_suite(emulator, rpc, userpass, interface, model_t=False): path = 'udp:127.0.0.1:21324' fingerprint = '95d8f670' master_xpub = 'xpub6D1weXBcFAo8CqBbpP4TbH5sxQH8ZkqC5pDEvJ95rNNBZC9zrKmZP2fXMuve7ZRBe18pWQQsGg68jkq24mZchHwYENd8cCiSb71u3KD4AFH' - dev_emulator = TrezorEmulator(emulator, model_t) - - if model_t: - full_type = 'trezor_t' - else: - full_type = 'trezor_1' + dev_emulator = TrezorEmulator(emulator, model) + full_type = 'trezor_{}'.format(model) # Generic Device tests suite = unittest.TestSuite() @@ -319,24 +404,27 @@ def trezor_test_suite(emulator, rpc, userpass, interface, model_t=False): 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)) + 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, rpc, userpass, 'trezor_{}_simulator'.format(model), full_type, path, fingerprint, master_xpub, emulator=dev_emulator, interface=interface)) + 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('syscoind', help='Path to syscoind 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 syscoind rpc, userpass = start_syscoind(args.syscoind) - 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, rpc, userpass, 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)