diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 0000000..a56144d --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,35 @@ +# https://docs.github.com/en/free-pro-team@latest/actions/guides/building-and-testing-python + +name: Python package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.6, 3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black flake8 pytest mypy + if [ -f requirements-test.txt ]; then pip install -r requirements-test.txt; fi + - name: Lint with black + run: | + black . --diff --check + - name: Lint with flake8 + run: | + flake8 hermit --count --statistics + - name: Test with pytest + run: | + # pytest --cov=hermit --cov-config=test/.coveragerc --ignore=vendor + echo "FIXME" diff --git a/.gitignore b/.gitignore index 228642a..66ee4be 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ dist build.info .coverage .mypy_cache/ -docs/_build \ No newline at end of file +docs/_build +.DS_Store diff --git a/.travis.yml b/.travis.yml index 1585703..ce0613f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,8 +7,12 @@ before_install: install: - pip install wheel - pip install codecov - - pip install -r requirements.frozen.txt + - pip install -r requirements.txt script: - python -m pytest --cov=hermit --cov-config=tests/.coveragerc --ignore=vendor + - python -m black hermit tests scripts *.py + - python -m flake8 hermit tests scripts *.py + - python -m mypy -p hermit + - python -m sphinx -c docs hermit docs/_build after_success: - codecov diff --git a/Jenkinsfile b/Jenkinsfile index 547dc5f..8810531 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -19,8 +19,9 @@ pipeline { sh ''' . .virtualenv/bin/activate python setup.py install - make lint - make test + make check + make test + make docs ''' } } @@ -32,12 +33,10 @@ pipeline { withCredentials([usernamePassword(credentialsId: 'tbd', passwordVarable: 'PYPI_PASSWORD', usernameVariable: 'PYPI_USERNAME')]) { sh ''' . ./environment.sh - make upload + make release ''' } } } } } - - diff --git a/Makefile b/Makefile index 7233e9f..79cc6d2 100644 --- a/Makefile +++ b/Makefile @@ -1,40 +1,47 @@ # -# == Paths & Directories == +# == Environment == # -VENV_DIR := .virtualenv - -PYTHON3 := $(shell command -v python3 2> /dev/null) -PYTHON_REQUIREMENTS := requirements.txt -PYTHON_FROZEN_REQUIREMENTS := requirements.frozen.txt +UNAME := $(shell uname) # -# == Configuration == +# == Files & Directories == # -UNAME := $(shell uname) +PYTHON_REQUIREMENTS := requirements.in +PYTHON_FROZEN_REQUIREMENTS := requirements.txt + +VENV_DIR := .virtualenv +FIXTURES := tests/fixtures +SCRIPTS := scripts +EXAMPLES := examples # # == Commands == # -PIP := $(VENV_DIR)/bin/pip -PYTEST := $(VENV_DIR)/bin/pytest -FLAKE8 := $(VENV_DIR)/bin/flake8 -MYPY := $(VENV_DIR)/bin/mypy -SPHINX_BUILD := $(VENV_DIR)/bin/sphinx-build +CAT := cat +ECHO := echo -# -# == Targets == -# +BREW := brew +APT := apt -default: dependencies +SYSTEM_PYTHON3 := $(shell command -v python3 2> /dev/null) +PYTHON3 := $(VENV_DIR)/bin/python3 +PIP := $(VENV_DIR)/bin/pip -docs: - $(SPHINX_BUILD) -c docs hermit docs/_build +PYTEST := $(VENV_DIR)/bin/pytest +BLACK := $(VENV_DIR)/bin/black +FLAKE8 := $(VENV_DIR)/bin/flake8 +MYPY := $(VENV_DIR)/bin/mypy +SPHINX_BUILD := $(VENV_DIR)/bin/sphinx-build +TWINE := twine -clean: - $(RM) -rf docs/_build/* build/* dist/* hermit.egg-info build.info +default: dependencies + +# +# == Dependencies == +# freeze: $(PIP) freeze -l > $(PYTHON_FROZEN_REQUIREMENTS) @@ -43,12 +50,13 @@ dependencies: system-dependencies python-dependencies system-dependencies: ifeq ($(UNAME),Darwin) - brew ls --versions zbar || brew install zbar + $(BREW) ls --versions zbar || brew install zbar else - sudo apt install libzbar0 + $(APT) install libzbar0 endif python-dependencies: $(VENV_DIR) + $(PYTHON3) -m pip install --upgrade pip $(PIP) install wheel ifdef UNFREEZE $(PIP) install -r $(PYTHON_REQUIREMENTS) @@ -57,24 +65,65 @@ else endif +$(VENV_DIR): + $(SYSTEM_PYTHON3) -m venv --prompt='hermit' $(VENV_DIR) + +# +# == Development == +# + +check: + $(BLACK) --check hermit tests scripts *.py + $(FLAKE8) hermit tests scripts *.py + $(MYPY) -p hermit + +test: + $(PYTEST) --cov=hermit --cov-config=tests/.coveragerc --ignore=vendor + +docs: + HERMIT_LOAD_ALL_IO=true $(SPHINX_BUILD) -c docs hermit docs/_build + +# +# == Examples & Fixtures == +# + +fixtures: $(FIXTURES)/signature_requests/2-of-2.p2sh.testnet.coordinator_signed.gif $(FIXTURES)/signature_requests/2-of-2.p2sh.testnet.gif $(EXAMPLES)/lorem_ipsum.gif $(EXAMPLES)/hello_world.jpg + +$(EXAMPLES)/lorem_ipsum.gif: $(FIXTURES)/lorem_ipsum.txt + $(CAT) $< | $(PYTHON3) $(SCRIPTS)/create_qr_code_animation.py $@ + +$(EXAMPLES)/hello_world.jpg: $(FIXTURES)/hello_world.txt + $(CAT) $< | $(PYTHON3) scripts/create_qr_code_image.py $@ + +%.gif: %.psbt + $(CAT) $< | $(PYTHON3) $(SCRIPTS)/create_qr_code_animation.py $@ true + +%.coordinator_signed.psbt: %.psbt + $(CAT) $< | $(PYTHON3) $(SCRIPTS)/sign_psbt_as_coordinator.py $(FIXTURES)/coordinator.pem > $@ + + +# +# == Release == +# + build.info: - echo ${BUILD_NUMBER} > build.info + $(ECHO) ${BUILD_NUMBER} > build.info package: python-dependencies build.info - python setup.py sdist bdist_wheel + $(PYTHON3) setup.py sdist bdist_wheel -upload: python-dependencies package - #twine upload -u ${PYPI_USERNAME} -p ${PYPI_PASSWORD} --repository-url https://test.pypi.org/legacy/ dist/* - twine upload dist/* +release: package + #$(TWINE) upload -u ${PYPI_USERNAME} -p ${PYPI_PASSWORD} --repository-url https://test.pypi.org/legacy/ dist/* + $(TWINE) upload dist/* -$(VENV_DIR): - $(PYTHON3) -m venv --prompt='hermit' $(VENV_DIR) +.PHONY: docs -test: - $(PYTEST) --cov=hermit --cov-config=tests/.coveragerc --ignore=vendor +# +# == Cleanup == +# -lint: - -$(FLAKE8) hermit --exclude=__init__.py - -$(MYPY) hermit/ +clean: + $(RM) -rf docs/_build/* build/* dist/* hermit.egg-info build.info /tmp/shard_words.bson* -.PHONY: test docs +purge: + $(RM) -rf $(VENV_DIR) diff --git a/README.md b/README.md index 3de9ed1..e256df1 100644 --- a/README.md +++ b/README.md @@ -4,38 +4,109 @@ Hermit [![Travis](https://img.shields.io/travis/unchained-capital/hermit.svg)](https://travis-ci.com/unchained-capital/hermit/) [![Codecov](https://img.shields.io/codecov/c/github/unchained-capital/hermit.svg)](https://codecov.io/gh/unchained-capital/hermit/) -Hermit is a sharded, -[HD](https://en.bitcoin.it/wiki/Deterministic_wallet) command-line -wallet designed for cryptocurrency owners who demand the highest -possible form of security. +Hermit is not like most bitcoin wallets. Hermit doesn't connect to +the Internet and it doesn't know about the state of the bitcoin +blockchain. -Hermit implements the -[SLIP-0039](https://github.com/satoshilabs/slips/blob/master/slip-0039.md) -standard for hierarchical Shamir sharding. +Hermit is a **keystore**: an application that stores private keys and +uses them to sign bitcoin transactions. Hermit focuses on: + +* storing private keys on high-security, airgapped hardware + +* validating unsigned bitcoin transactions across the airgap + +* returning signed bitcoin transactions across the airgap + +Hermit is designed to operate in tandem with a **coordinator**: an +Internet-connected application which does understand the state of the +bitcoin blockchain. The coordinator is responsible for + +* generating unsigned (or partially signed) transactions to pass to + Hermit + +* receiving signed transactions from Hermit + +* constructing fully-signed transactions and broadcasting them to the + bitcoin blockchain + +This separation of concerns between keystores and coordinators allows +Hermit to be used by operating businesses that demand the highest +possible security for private keys. + +Hermit is compatible with the following standards: + +* [SLIP-0039](https://github.com/satoshilabs/slips/blob/master/slip-0039.md) + standard for hierarchical Shamir sharding of private key data + +* [BIP-0032](https://en.bitcoin.it/wiki/BIP_0032) standard for + hierarchical deterministic (HD) wallets + +* [PSBT](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki) + standard data format for bitcoin transactions -Hermit is designed to operate in tandem with an online wallet which -can talk to a blockchain. All communication between the user, Hermit, -and the online wallet is done via QR codes, cameras, screen, and -keyboard. +* [BC-UR](https://github.com/BlockchainCommons/Research/blob/master/papers/bcr-2020-005-ur.md) + standard for transporting data through QR code URIs -This means that a Hermit installation does not require WiFi, -Bluetooth, or any other form of wired or wireless communication. -Hermit can operate in a completely air-gapped environment. +Hermit is compatible with the following coordinator software: -Read on or watch these videos to learn more about Hermit: +* [Unchained Capital](https://unchained.com) +* [Caravan](https://unchained-capital.github.io/caravan/#/) + + + -* [Creating a shard family](https://www.youtube.com/watch?v=tOc0GBjIK8Y&feature=youtu.be) -* [Exporting/importing shards](https://www.youtube.com/watch?v=usBk-X3a4Qo&feature=youtu.be) -* [Export a public key](https://www.youtube.com/watch?v=ut9ALBqjZbg&feature=youtu.be) -* [Sign a bitcoin transaction](https://www.youtube.com/watch?v=NYjJa0fUxQE&feature=youtu.be) +Features +-------- + * **Air-Gapped:** All communication between the user, Hermit, and + the coordinator is done via QR codes, cameras, screen, and + keyboard. This means a Hermit installation does not require WiFi, + Bluetooth, or any other form of wired or wireless + communication. Hermit can operate in a completely air-gapped + environment. + * **Supports Signing Teams:** Private key data is (hierarchically) + sharded into P-of-Q groups with each group requiring m-of-n shards + (with m & n possibly differing among the Q groups). Shards can be + copied, deleted, and re-created. This makes it possible to + operate Hermit using a team of signers with no individual having + unilateral access to the private key. Turnover on signing teams + can be accommodated by rotating shards. + + * **Encrypted:** Private key shards are encrypted and must be + decrypted using shard-specific passwords before they can be + combined. + + * **Requires Unlocking**: The combination of encryption and sharding + means that Hermit's private keys must be "unlocked" before it can + be used. Unlocking requires shard-specific passwords to m-of-n + shards for P-of-Q groups. Hermit will automatically lock itself + after a short time of being unlocked with no user input. Hermit + can also be instantly locked in an emergency. + + * **Can Use Security Modules:** By default, Hermit uses the local + filesystem for all storage of shard data. This can be customized + through configuration to use a trusted platform module (TPM) or + hardware security module (HSM) for shard storage. + + * **Runs on Low-End Hardware:** Hermit is a command-line application + but uses the operating system's windowing system to display QR + code animations and camera previews. Hermit can be configured to + instead operate using ASCII or with direct access to a + framebuffer. This allows installing Hermit on limited hardware + without a graphical system (e.g. terminal only). + + * **Coordinator Authorization:** Transactions received from a + coordinator can be signed with an ECDSA private key (held by the + coordinator) and verified against the corresponding public key + (held in Hermit's configuration). This ensures that Hermit will + sign transactions from an authorized coordinator. Quickstart ---------- ``` -$ pip install hermit # may need 'sudo' for this command +$ pip3 install hermit # may need 'sudo' for this command ... $ hermit # launch hermit ... @@ -46,417 +117,706 @@ wallet> shards # enter shard mode shards> help # see shard commands ... shards> list-shards # see current shards +No shards. +shards> build-family-from-phrase # create new shard family from BIP39 phrase ... -shards> build-family-from-random # create new shard family from random data +How many groups are required unlock (P)? 1 # use a single group ... -shards> list-shards # see newly created shards +What is m of n for Group 1? 2of3 # 2of3 shards +What is m of n for Group 2? # hit ENTER ... + +Enter BIP39 phrase for wallet below (CTRL-D to submit): + +merge alley lucky axis penalty manage # enter BIP39 phrase +latin gasp virus captain wheel deal +chase fragile chapter boss zero dirt +stadium tooth physical valve kid plunge + # CTRL-D to submit + + +Enter at least 256 bits worth of random data. + +Hit CTRL-D when done. + +Collected 0.0 bits>:awef;oaweo;fawe # mash the keyboard +Collected 38.3 bits>:awefa;wef;oawefaawe;faweaw +Collected 102.0 bits>:aw;efjao;ejwf;oaje;fjao;web +Collected 189.3 bits>:bsblsrevhlerferfrefserfuulfli +Collected 333.2 bits>: # CTRL-D to submit + +Family: 515, Group: 1, Shard: 1 +Enter name: alice # name the first shard +new password> ******** # and provide a password + confirm> ******** + +Family: 515, Group: 1, Shard: 2 # repeat for second shard +Enter name: bob +new password> ******** + confirm> ******** + shards> list-shards # see newly created shards + alice (family:515 group:1 member:1) + bob (family:515 group:1 member:2) + shards> write # save newly created shards to disk shards> quit # back to wallet mode -wallet> export-xpub m/45'/0'/0' # export an extended public key +wallet> unlock # unlock the shards we just created +Choose shard +(options: alice, bob or to quit) +> alice # pick the first shard and provide password + +Enter password for shard alice (family:515 group:1 member:1) +password> ******** +Choose shard +(options: bob or to quit) +> bob # repeat for second shard + +Enter password for shard bob (family:515 group:1 member:2) +password> ******** +*wallet> # wallet is now unlocked +*wallet> sign # sign a bitcoin transaction by scanning a QR code + ... -wallet> sign-bitcoin # sign a bitcoin transaction - # see examples/signature_requests/bitcoin_testnet.png +# See tests/fixtures/signature_requests/2-of-2.p2sh.testnet.gif for example transaction request QR code +``` + +Setup +----- + +## Dependencies + +Hermit requires Python > 3.5. + +## Installation + +Installing Hermit can be done via `pip3`: + +``` +$ pip3 install hermit +``` + +If you want to develop against Hermit, see the "Developers" section +below for a different way of installing Hermit. + +## Configuration + +Hermit's default configuration works fine for initial evaluation but +is not designed for production usage. + +For production usage, you'll want to configure Hermit through its +configuration file. + +By default, Hermit looks for a configuration file at +`/etc/hermit.yml`, but you can change this by passing in a different +configuration file path through an environment variable when you +launch `hermit`: + +``` +$ HERMIT_CONFIG=/path/to/hermit.yml hermit +``` + +See the documentation for the `HermitConfig` class for details on +allowed configuration settings. + +Usage +----- + +To start Hermit, just run the `hermit` command. + +``` +$ hermit +``` + +### "Wallet" Functions + +As stated above, Hermit is not a "wallet" but it does perform some key +functions associated with bitcoin wallets: signing bitcoin +transactions and exporting extended public keys. + +#### Signing Transactions + +Assuming Hermit has a private key (see the "Private Key Management" +section below), you can run the following commands to unlock it and +sign a bitcoin transaction: + +``` +wallet> unlock ... +wallet> sign + +# See tests/fixtures/signature_requests/2-of-2.p2sh.testnet.gif for example transaction request ``` -See more details in the "Usage" section below. +If you don't unlock the private key first, Hermit will preview the +transaction for you and abort signing. -Design ------- +Remember: Hermit does not create transactions. An external +coordinator application must pass Hermit an unsigned +[PSBT](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki) +which Hermit interprets. -Hermit follows the following design principles: +You can find examples of such requests in +[tests/fixtures/signature_requests](tests/fixtures/signature_requests). - * Unidirectional information transfer -- information should only move in one direction - * Always-on sharding & encryption -- keys should always be sharded and each shard encrypted - * Open-source everything -- complete control over your software and hardware gives you the best security - * Flexibility for human security -- you can customize the sharding configuration to suit your organization +#### Exporting Extended Public Keys (xpubs) -### Audience +Hermit can export extended public keys (xpubs) derived from a private +key. -Hermit is a difficult to use but highly-secure wallet. +``` +wallet> unlock +wallet> display-xpub m/45'/0'/0' +xpub... +``` -Hermit is **not recommended** for non-technical individuals with a -small amount of cryptocurrency. +Extended public keys are printed to screen and displayed as QR codes. -Hermit is designed for computer-savvy people and organizations to -self-custody significant amounts of cryptocurrency. +### Private key management -### Sharding +Before Hermit can be used for "wallet" functionality, you must first +define some private keys for Hermit to store & protect. -Hermit is different than other wallets you may have used because it -always shards your key. +Hermit stores all private keys in a sharded format. Sharding is done using -[SLIP-39](https://github.com/satoshilabs/slips/blob/master/slip-0039.md) +[SLIP-0039](https://github.com/satoshilabs/slips/blob/master/slip-0039.md) which means there are two levels of structure: -* Groups -- some quorum of *P* of *Q* groups is required to unlock a key +* Groups -- some quorum *P* of *Q* groups is required to unlock a key -* Shards -- some quorum of *n* of *m* shards is required to unlock - each group, with *n* and *m* possibly different for each group +* Shards -- some quorum *m* of *n* shards is required to unlock + each group, with *m* and *n* possibly different for each group This structure creates a lot of flexibility for different scenarios. +For example: + +* With *P=Q=1* and *n=m=1*, SLIP-0039 only a single shard is used, + replicating a singlesig wallet. This is useful for testing but not + the expected usage pattern. + +* With *P=Q=1*, SLIP-0039 reproduces *m* of *n* Shamir sharding with + *n* shards, *m* of which are required to unlock the private key. + +* *P=Q=1*; one 'junior' group with *m=4$ and *n=7*, and another + 'senior' group with *m=2* and *n=3*. This structure requires + **either** 4 of 7 'junior' shards **OR** 2 of 3 'senior' shards to + unlock the private key. Hermit extends the current SLIP-39 proposal by encrypting each shard -with a password. +with a password. This allows each shard to be "assigned" to a given +operator who knows that shard's password. A quorom of such operators +must physically co-locate and decrypt their shards by entering their +passwords before Hermit can unlock the corresponding private key. -Shards in Hermit are generally encrypted by a password. Each shard has -its own password, allowing for teams to operate a key together, each -team member operating a given shard (in some group). However, if the -user explicitly supplies an empty string as the password when either -creating a shard, or changing the password on the shard, the resulting -shard will be unencrypted. While this makes the transport of the shard -less safe, it does make it possible to export the shards to technologies -that support only unencrypted SLIP-39 implementations. +Shard encryption can be skipped by providing an empty string as the +password when creating a shard (or changing the password on the +shard). While this makes the storage of the shard less secure, it +does make it possible to export the shards to technologies that +support only unencrypted SLIP-39 implementations (e.g. Trezor). -#### Compatibility with other wallets +#### Random number generation -If you are using a non-sharded wallet such as a hardware wallet -(Trezor, Ledger, &c.) or Electrum, you can import your key from your -BIP39 "seed phrase" and Hermit will shard it for you. +Secure encryption and sharding requires generating cryptographically +random values. **Hermit does not generate its own random values**. + +Instead, Hermit expects you to choose a trusted source of randomness +and manually enter random values from it: -You may also input a new key using whatever source of randomness you -like. +``` +Enter at least 256 bits worth of random data. -Hermit will **not** export keys as BIP39 phrases; only as encrypted -SLIP39 phrases. This means it is challenging to extract a key from a -Hermit installation for use in, for example, a hardware wallet or -Electrum. This constraint is present by design. +Hit CTRL-D when done. -### Input & Output +Collected 0.0 bits>: +``` -Hermit is designed to be deployed in air-gapped environments. +Two simple ways to do this are to roll fair dice or draw cards from a +well-shuffled deck and transcribe the resulting values. -* The only way data should be able to enter the device Hermit is - running on is via keyboard and camera. +The number of random bits Hermit requires is determined by the action +you are taking. The larger the shard families you're working with, +the more randomness is required. -* The only way data can leave Hermit is via the screen of the device - it is running on. +The characters you enter can be chosen from any character set you +like, as appropriate for your chosen method of generation. If you are +rolling dice, for example, all the characters you enter will be digits +from 1 through 6. Hermit will use the concatenated bytes of the +complete text you enter as its final random value. -Hermit has no idea what is happening on the blockchain and relies on -an external, online wallet application to draft transaction requests. -These transaction requests are passed to Hermit via QR codes. -Signatures produced by Hermit are similarly displayed on screen as QR -codes. +The number of characters you'll need to enter to reach the required +number of random bits will depend on the character set produced by +your chosen random number generator. -The usage of QR codes for transaction requests and signatures creates -a limit to how complex of a transaction Hermit can sign. This -limitation will be addressed by a QR code packeting algorithm one day. +As you enter characters, hermit will estimate the number of bits of +randomness you have entered so far but this estimate **should not be +relied upon** for production usage. It is better to **predetermine** +the number of characters you need to enter based on the number of +random bits Hermit is asking for and size of the character set used by +your chosen random number generator. -### Storage +For example, a single roll of a fair 6-sided die produces +log2(6) ~ 2.58 bits of randomness. This means that ~100 +dice rolls are required to produce 256 bits of randomness. -Hermit uses 3 storage locations: +Note, when testing Hermit it is easiest to simply "mash" on the +keyboard till sufficient "random" bits are detected by Hermit. This +is obviously not a good idea in production! - _________ _____________ ____________ - | | | | | | - | memory | -- write -> | filesystem | -- persist --> | data store | - |_________| |_____________| |____________| +#### Create a shard family for a new private key +A new private key can be created by entering random data through the +keyboard: -When Hermit first boots, shards from the data store or filesystem -(in that order) are loaded into memory. Changes made to shards -are always made *in memory* and will not survive Hermit restarts. +``` +wallet> shards +shards> build-family-from-random +``` -To save data across Hermit restarts, ensure you `write` it to the -filesystem. +You will be prompted for the configuration to use for the shard family: -If your Hermit lives on a read-only filesystem, to save data -across Hermit machine restarts, ensure you `persist` it to the -data store. +``` +How many groups should be required to unlock the wallet (P)? 1 +... +What shard configuration should be used for Group 1? 2of3 +What shard configuration should be used for Group 2? +... +``` -### Modes +and then for sufficient random data to create both the new private key +and the shard family. -Hermit is a modal application with two modes: +You'll then be asked to name each shard and provide a password for it. -* `wallet` mode is where you will spend most of your time, signing transactions and exporting public keys -* `shards` mode is accessed when you need to import keys or shards, export shards, or otherwise change something about how your key is unlocked +``` +Family: 8347, Group: 1, Shard: 1 +Enter name: alice +... +Family: 8347, Group: 1, Shard: 2 +Enter name: bob +... +Family: 8347, Group: 1, Shard: 3 +Enter name: charlie +... +``` -### Bitcoin Only +Finally, don't forget to store this information on disk! -As a sharded, HD wallet, Hermit is a tool that can be used with any -cryptocurrency that operates with the BIP32 standard. +``` +shards> write +``` -But Hermit also ships with a `sign-bitcoin` command that will sign -Bitcoin (BTC) transactions. +#### Create a shard family for a private key imported from a BIP39 phrase -You can extend Hermit for other cryptocurrencies if you need; see the -"Plugins" section below. +If you are using a non-sharded wallet such as a hardware wallet +(Trezor, Ledger, Coldcard, Electrum, &c.), you can import your private +key from your +[BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) +"seed phrase" and Hermit will shard it for you. -Usage ------ +``` +wallet> shards +shards> build-family-from-phrase +``` -### Installation +Similar to creating a shard family from a new private key, you'll be +prompted for the configuration to use for the new shard family. -Installing Hermit can be done via `pip`: +You'll next be prompted to enter the BIP39 phrase: ``` -$ pip install hermit +Enter BIP39 phrase for wallet below (CTRL-D to submit): +merge alley lucky axis penalty manage +latin gasp virus captain wheel deal +chase fragile chapter boss zero dirt +stadium tooth physical valve kid plunge ``` -If you want to develop against Hermit, see the "Developers" section -below for a different way of installing Hermit. +and then prompted to enter random data to create the shard family. +You'll then be asked to name each shard and provide a password. -### Starting Hermit +**Note:** Hermit will **not** export private keys as BIP39 phrases. +It will only export shards as encrypted SLIP39 phrases. This means it +is challenging to extract a key from a Hermit installation for use in, +for example, a hardware wallet or Electrum. This constraint is +present by design. -To start Hermit, just run the `hermit` command. +#### Create a new shard family from an existing shard family + +A private key already stored in Hermit can be resharded into a +different configuration of groups and/or shards. ``` -$ hermit +wallet> shards +shards> build-family-from-family ``` -You can also pass a configuration file +You'll first be asked to unlock the existing shard family: ``` -$ HERMIT_CONFIG=/path/to/hermit.yml hermit +Choose shard +... +> alice +... +> bob ``` -See the "Configuration" section below for more information on how to -configure Hermit. +You'll next be prompted for the configuration to use for the new shard +family and sufficient random data to build it. You'll then be asked +to name each new shard and provide a password. + +### Shard management + +Hermit provides several commands for manipulating individual shards +within shard families. -### Creating a Key +#### Importing & exporting shards -Before you can do much with Hermit you'll need to create or import at -least one key. To do so, first enter shards mode: +Shards can be individually imported & exported, which allows +transferring entire shard families between Hermit installations. + +Transferring can be done through QR codes or through encrypted +SLIP-0039 phrases. + +To transfer the shard `alice` using QR codes, begin on the Hermit +installation that already has the shard: ``` -$ hermit -... wallet> shards +shards> export-shard-as-qr alice ``` -Two `shard` mode commands will let you import a key: +This Hermit installation will display a QR code. On the other Hermit +installation run + +``` +wallet> shards +shards> import-shard-from-qr alice +``` -* `build-family-from-phrase` -- enter a BIP39 phrase. This is useful if you are importing a key from a hardware wallet such as Trezor or Ledger or from another software wallet such as Electrum. -* `build-family-from-random` -- enter random characters. This is useful if you want to generate your own entropy (from, say, rolling dice) +This Hermit installation will open its camera. Scan the QR code from +the first Hermit installation to complete the transfer. -Whichever you choose, you will be prompted to enter a shard configuration. +Note that the **name** of the shard can be changed during transfer but +the shard's data and password are always transferred unchanged. -Creating a secure shard set from a key requires additional randomness -beyond the seed of the key. So even if you choose to -`build-family-from-phrase`, you will still be asked to input random -characters. Ensure you are prepared to do so using a good source of -randomness (such as rolling dice). +To transfer the same shard using SLIP-0039 phrases, again begin on the +Hermit installation that already has the shard: +``` +wallet> shards +shards> export-shard-as-phrase alice -### Exporting Public Keys +Encrypted SLIP39 phrase for shard alice: -Hermit can export public keys (or extended public keys) from the key -it protects. These are useful for other applications which want to -refer to Hermit's key but, obviously, can't be allowed to see its -private contents. +friar merchant academic academic analysis ... +``` -Two `wallet` mode commands are useful for this: +On the second Hermit installation run -* `export-xpub` -- exports an extended public key -* `export-pub` -- exports a public key +``` +wallet> shards +shards> import-shard-from-phrase alice -Each of these commands expects a BIP32 path as an argument and each -will display its data as a QR code. +Enter SLIP39 phrase for shard alice below (CTRL-D to submit): +``` -### Signing Transactions +Type in the SLIP-0039 phrase and hit `CTRL-D` to complete the +transfer. -The whole point of Hermit is to ultimately sign transactions. -Transaction signature requests must be created by an external -application. You can also use a test signature request available at -[examples/signature_requests/bitcoin.png](examples/signature_requests/bitcoin.png). +#### Renaming, copying, and deleting shards -Once you have a signature request, and you're in `wallet` mode, you -can run `sign-bitcoin` to start signing a Bitcoin transaction. +Shards can renamed without knowing their passwords: -### Configuration +``` +shards> list-shards + alpha-alice (family:10014 group:1 member:1) + alpha-bob (family:10014 group:1 member:2) + alpha-charlie (family:10014 group:1 member:3) +shards> rename-shard alpha-bob alpha-bobby +shards> list-shards + alpha-alice (family:10014 group:1 member:1) + alpha-bobby (family:10014 group:1 member:2) + alpha-charlie (family:10014 group:1 member:3) +``` -Hermit looks for a configuration file at `/etc/hermit.yml` by default. -You can change this path by passing the`HERMIT_CONFIG` environment -variable when you start the `hermit` program: +Shards can also be copied: ``` -$ HERMIT_CONFIG=/path/to/hermit.yml hermit +shards> list-shards + alpha-alice (family:10014 group:1 member:1) + alpha-bobby (family:10014 group:1 member:2) + alpha-charlie (family:10014 group:1 member:3) +shards> copy-shard alpha-charlie alpha-dave ``` -The configuration file is a YAML file. See the documentation for the -`HermitConfig` class for details on allowed configuration settings. +**WARNING:** When copying a shard, you'll be asked for the current +password to the existing shard as well as a new password for the new +shard. You'll also be forced to unlock Hermit and you MUST select the +new shard when doing so. Run `help copy-shard` while in `shards` mode +for further information. +Shards can also be deleted, which is useful after copying them: -Developers ----------- +``` +shards> list-shards + alpha-alice (family:10014 group:1 member:1) + alpha-bobby (family:10014 group:1 member:2) + alpha-charlie (family:10014 group:1 member:3) + alpha-dave (family:10014 group:1 member:3) +shards> delete-shard alpha-charlie +Really delete shard alpha-charlie? yes +shards> list-shards + alpha-alice (family:10014 group:1 member:1) + alpha-bobby (family:10014 group:1 member:2) + alpha-dave (family:10014 group:1 member:3) +``` -Developers will want to clone a copy of the Hermit source code: +Anytime you manipulate shards, don't forget to store this information +on disk! ``` -$ git clone --recursive https://github.com/unchained-capital/hermit -$ cd hermit -$ make +shards> write ``` -**NOTE:** To develop using the Hermit code in this directory, run -`source environment.sh`. This applies to all the commands below in -this section. +#### Shard storage + +Hermit uses 3 storage locations for shard data: + + _________ _____________ _________ + | | | | | | + | memory | -- write -> | filesystem | -- persist --> | TPM/HSM | + |_________| |_____________| |_________| + + +Hermit does not assume that the filesystem it is running from is +writeable and so never writes to the filesystem *unless asked*. New +shards or changes made to existing shards are therefore always made +*in memory only* and will not survive Hermit restarts. -### Testing +To save shard data across Hermit restarts, the `write` command must be +run (while in `shards` mode). -Hermit ships with a full [pytest] suite. Run it as follows: +This will cause shard data in memory to be written to the file +`/tmp/shard_words.bson`. This path **should be changed** to an +appropriate value through the following configuration file setting: ``` -$ source environment.sh -$ make test -$ make lint +# in /etc/hermit.yml +shards_file: /home/user/shard_words.bson ``` -Hermit has been tested on the following platforms: +If Hermit is running on a device with a TPM or HSM then shards can be +directly stored in the TPM/HSM. The `persist` command can be run +(while in `shards` mode) to execute shell commands to persist data +from the filesystem to the TPM/HSM. -* OS X High Sierra 10.13.6 -* Linux Ubuntu 18.04 -* Linux Slax 9.6.4 +When Hermit first boots, shards from the TPM/HSM or filesystem (in +that order) are loaded into memory. The `restore` command can be used +to reload shards from the TPM/HSM into memory. + +See the documentation for `HermitConfig.DefaultCommands` for more +details on shard persistence. -There is also a full-flow example script provided at -(tests/test_script.md)[tests/test_script.md] that you can follow to -see and test all functionality. +By default, Hermit "fakes" a TPM/HSM by using the filesystem, +e.g. running `persist` will copy (and compress) the `shards_file` to +the same location with a suffix `.persisted` added. +#### Shard Rotation + +The commands Hermit provides for manipulating shards and shard +families allow for transferring shards between operators. + +##### Recovering from the loss of a shard or operator + +Begin with a key named `cherry` managed by three operators `a`, `b`, +and `c` in a 2-of-3 configuration. + +``` +wallet> shards +shards> list-shards + cherry-a (family:10014 group:1 member:1) + cherry-b (family:10014 group:1 member:2) + cherry-c (family:10014 group:1 member:3) +``` + +Our goal is to replace the operator/shard `c` with a new +operator/shard `d`. + +We begin by building a new shard family: + +``` +shards> build-family-from-family +... +``` -### Developers +which will require unlocking the original family: -#### Integrating +``` +Choose shard +... +> cherry-a +... +> cherry-b +``` -Hermit needs an external, online wallet application in order to work. -This application has a few ways it may need to integrate with -Hermit: +We'll use the same 2-of-3 configuration for the new family as for the old: -* Read a public key displayed by Hermit +``` +How many groups should be required to unlock the wallet (P)? 1 +... +What shard configuration should be used for Group 1? 2of3 +What shard configuration should be used for Group 2? +... +``` -* Generate a signature request for Hermit (see [examples/signature_requests/bitcoin_testnet.json](examples/signature_requests/bitcoin_testnet.json)) +After entering sufficient random data, we'll now be able to define the +new shards. We'll start with shards for the operators `a` and `b` +that are shared between the old and new shard families (these +operators can even use the same passwords for their new shards as they +used for their old shards, if desired): -* Read a signature displayed by Hermit (see [examples/signatures/bitcoin.json](examples/signatures/bitcoin.json)) +``` +Family: 8347, Group: 1, Shard: 1 +Enter name: cherry-a-copy +... +Family: 8347, Group: 1, Shard: 2 +Enter name: cherry-b-copy +... +``` -In all cases, Hermit uses the same scheme to encode/decode data into -QR codes. The pipelines look like this: +The final shard will go to the new operator `d`: - * To create a QR code from a `string`: utf-8 encode -> gzip-compress -> Base32 encode - * To parse a QR code `string`: Base32 decode -> gzip-decompress -> utf-8 decode +``` +Family: 8347, Group: 1, Shard: 3 +Enter name: cherry-d +... +``` -The `string` data may sometimes itself be JSON. +Now there are two simultaneous shard families for the key `cherry`: -#### Plugins +* the original 2-of-3 among `a`, `b`, and `c` +* the new 2-of-3 among `a`, `b`, and `d` -Hermit allows you to write plugins to extend its functionality. This -is chiefly so that you can write `Signer` classes for cryptocurrencies -beyond Bitcoin (BTC). +``` +shards> list-shards + cherry-a (family:10014 group:1 member:1) + cherry-b (family:10014 group:1 member:2) + cherry-c (family:10014 group:1 member:3) + cherry-a-copy (family:8347 group:1 member:1) + cherry-b-copy (family:8347 group:1 member:2) + cherry-d (family:8347 group:1 member:3) +``` -The default directory for plugin code is `/var/lib/hermit`. Any -`*.py` files in this directory will be loaded by Hermit when it boots -(though you can customize this directory; see the "Configuration" -section above). +The old shard family can now be deleted: -An example signer class is below +``` +shards> delete-shard cherry-a +Really delete shard cherry-a? yes +shards> delete-shard cherry-b +Really delete shard cherry-b? yes +shards> delete-shard cherry-c +Really delete shard cherry-c? yes +``` -```python -# -# Example signer class for a putative "MyCoin" currency. -# -# Put in /var/lib/hermit/mycoin_signer.py -# +Which leaves just the new shard family, without operator/shard `c`: -from hermit.errors import InvalidSignatureRequest -from hermit.signer.base import Signer -from hermit.ui.wallet import wallet_command -import hermit.ui.state as state +``` +shards> list-shards + cherry-a-copy (family:8347 group:1 member:1) + cherry-b-copy (family:8347 group:1 member:2) + cherry-d (family:8347 group:1 member:3) +``` -# Some library for MyCoin -from mycoin_lib import sign_mycoin_transaction +The shards for operators `a` and `b` can be renamed if desired: -class MyCoinSigner(Signer): - """Signs MyCoin transactions""" +``` +shards> rename-shard cherry-a-copy cherry-a +shards> rename-shard cherry-b-copy cherry-b +shards> list-shards + cherry-a (family:8347 group:1 member:1) + cherry-b (family:8347 group:1 member:2) + cherry-d (family:8347 group:1 member:3) +``` - def validate_request(self) -> None: - """Validates a MyCoin signature request""" - # This is built into the Signer class - self.validate_bip32_path(request.get('bip32_path')) +Finally, don't forget to store this information on disk! - # this isn't great validation code, but you get the point... - if 'input' not in self.request: - raise InvalidSignatureRequest("The param 'input' is required.") - if 'output' not in self.request: - raise InvalidSignatureRequest("The param 'output' is required.") - if 'amount' not in self.request: - raise InvalidSignatureRequest("The param 'amount' is required.") +``` +shards> write +``` - self.bip32_path = self.request['bip32_path'] - self.input = self.request[input] - self.output = self.request['output'] - self.amount = self.request['amount'] +The procedure above requires all the operators of the new shard family +(`a`, `b`, and `d`) but only a quorum of operators from the original +shard family (`a` and `b`). Crucially, this means the procedure can +be performed **without** the participation of operator being replaced +(`c`). This allows recovering from scenarios where an operator has +forgotten their shard password or has become unavailable or +uncooperative. - def display_request(self) -> None: - """Displays the transaction to be signed""" - print(""" - INPUT: {} - OUTPUT: {} - AMOUNT: {} - SIGNING AS: {} - """.format(self.input, - self.output, - self.amount, - self.bip32_path)) +Because of the SLIP39 sharding, a rogue operator who has exfiltrated +their own decrypted shard cannot use that information to learn +anything about the old or new shard families. - def create_signature(self) -> None: - """Signs a transaction""" - keys = self.generate_child_keys(self.bip32_path) - # Here is the magic of MyCoin... - self.signature = sign_mycoin_transaction(self.input, self.output, self.amount, keys) -@wallet_command('sign-mycoin') -def sign_mycoin(): - """usage: sign-mycoin +Development +----------- - Create a signature for a MyCoin transaction. +### Setup - Hermit will open a QR code reader window and wait for you to scan an - Ethereum transaction signature request. +Hermit has the following development dependencies: - Once scanned, the details of the signature request will be displayed - on screen and you will be prompted whether or not you want to sign - the transaction. +* Python >= 3.5 +* `make` for running development tasks - If you agree, Hermit will open a window displaying the signature as - a QR code. +### Installation - Creating a signature requires unlocking the wallet. +Developers will want to clone a copy of the Hermit source code: - """ - MyCoinSigner(state.Wallet, state.Session).sign() +``` +$ git clone https://github.com/unchained-capital/hermit +$ cd hermit +$ make ``` -#### Contributing to Hermit +### Usage -Unchained Capital welcomes bug reports, new features, and better -documentation for Hermit. To contribute, create a pull request (PR) -on GitHub against the [Unchained Capital fork of -Hermit](https://github.com/unchained-capital/hermit). +**NOTE:** To develop using the Hermit code in this directory, run +`source environment.sh`. This applies to all the commands below in +this section. -Before you submit your PR, make sure to lint your code and run the test suite! +Hermit ships with a full test suite +([pytest](https://docs.pytest.org/en/latest/)). Run it as follows: ``` -$ source environment.sh $ make test -$ make lint ``` -(Linting is done with [flake8] and [mypy].) +The code can also be run through linters ( +([black](https://black.readthedocs.io/en/stable/) & +[flake8](https://flake8.pycqa.org/en/latest/)) and a type-checker +([mypy](http://mypy-lang.org/)). + +``` +$ make check +``` + +Hermit has been tested on the following platforms: -[pytest]: https://docs.pytest.org/en/latest/ -[flake8]: http://flake8.pycqa.org/en/latest/ -[mypy]: http://mypy-lang.org/ -[bip32]: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki +* macOS Big Sur 11.3 +* Linux Ubuntu 18.04 +* Linux Slax 9.6.4 -## Key rotation +### Contributing -Each individual share should be managed by a team. Each team has multiple -copies of the passphrase to decrypt the share. The share only exists on the -Hermit device, and it's encrypted. Rotating out a member is achieved by using -one of the other team members to decrypt the share and then re-encrypting the -share with a new passphrase, thus excluding the previous user. +Unchained Capital welcomes bug reports, new features, and better +documentation for Hermit. To contribute, create a pull request (PR) +on GitHub against the [Unchained Capital fork of +Hermit](https://github.com/unchained-capital/hermit). -## TODO +Before you submit your PR, make sure to run the test suite and static +analysis tools: -* Validate wallet public keys/signatures against the provided redeem script in the bitcoin signer. -* Re-do QR-code protocol details once a [standard emerges](https://www.blockchaincommons.com) +``` +$ source environment.sh +$ make check test +``` diff --git a/bin/hermit b/bin/hermit index 68c5957..a4c0eb3 100755 --- a/bin/hermit +++ b/bin/hermit @@ -1,10 +1,7 @@ #!/usr/bin/env python +from hermit.ui import main + + if __name__ == '__main__': - from hermit.ui import main - try: - main() - except TypeError as e: - # FIXME why does this error even occur? - if 'a coroutine or an awaitable is required' in str(e): pass - else: raise e + main() diff --git a/docs/conf.py b/docs/conf.py index 7364321..d740c6a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,19 +14,20 @@ # import os import sys -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) # -- Project information ----------------------------------------------------- -project = 'Hermit' -copyright = '2019, Unchained Capital' -author = 'Chris Howe, Destry Saul, Dhruv Bansal' +project = "Hermit" +copyright = "2019, Unchained Capital" +author = "Chris Howe, Destry Saul, Dhruv Bansal, Michael Flaxman" # The short X.Y version -version = '0.1' +version = "0.1" # The full version, including alpha/beta/rc tags -release = '0.1.0' +release = "0.1.0" # -- General configuration --------------------------------------------------- @@ -39,26 +40,28 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode', - 'sphinx.ext.githubpages', + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", + "sphinx.ext.githubpages", + "sphinx_autodoc_typehints", + "m2r2", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -#master_doc = os.path.abspath(os.path.join(os.path.dirname(__file__), 'index')) -master_doc = 'hermit' +# master_doc = os.path.abspath(os.path.join(os.path.dirname(__file__), 'index')) +master_doc = "hermit" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -70,7 +73,7 @@ # 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'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = None @@ -81,7 +84,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'alabaster' +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -92,7 +95,7 @@ # 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'] +html_static_path = ["_static"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -108,7 +111,7 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = 'Hermitdoc' +htmlhelp_basename = "Hermitdoc" # -- Options for LaTeX output ------------------------------------------------ @@ -117,15 +120,12 @@ # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # # 'preamble': '', - # Latex figure (float) alignment # # 'figure_align': 'htbp', @@ -135,8 +135,7 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'Hermit.tex', 'Hermit Documentation', - 'Unchained Capital', 'manual'), + (master_doc, "Hermit.tex", "Hermit Documentation", "Unchained Capital", "manual"), ] @@ -144,10 +143,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'hermit', 'Hermit Documentation', - [author], 1) -] +man_pages = [(master_doc, "hermit", "Hermit Documentation", [author], 1)] # -- Options for Texinfo output ---------------------------------------------- @@ -156,9 +152,15 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'Hermit', 'Hermit Documentation', - author, 'Hermit', 'Air-gapped bitcoin wallet.', - 'Miscellaneous'), + ( + master_doc, + "Hermit", + "Hermit Documentation", + author, + "Hermit", + "Air-gapped bitcoin wallet.", + "Miscellaneous", + ), ] @@ -177,7 +179,7 @@ # epub_uid = '' # A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] +epub_exclude_files = ["search.html"] # -- Extension configuration ------------------------------------------------- @@ -185,7 +187,7 @@ # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} +intersphinx_mapping = {"https://docs.python.org/": None} # -- Options for todo extension ---------------------------------------------- diff --git a/docs/hermit.rst b/docs/hermit.rst new file mode 100644 index 0000000..aedf211 --- /dev/null +++ b/docs/hermit.rst @@ -0,0 +1,59 @@ +Hermit API Documentation +======================== + +This document is intended for developers working on Hermit, extending +Hermit, or integrating Hermit into some other program. For end-user +documentation, see Hermit's `README +`_. + +Hermit's codebase is split into three sections: + +* a collection of functions which together constitute an API for + configuration, input, & output + +* classes to model cameras, displays, a bitcoin wallet, and signing + bitcoin transactions + +* a UI in the form of a command-line REPL + +The functional API is documented below, while the classes are +documented on their own pages. + +.. toctree:: + :maxdepth: 2 + + hermit.config + hermit.io + hermit.qr + hermit.keystore + hermit.coordinator + +API +--- + +The following methods are available in the top-level ``hermit`` +namespace. + +Configuration +~~~~~~~~~~~~~ + +.. automodule:: hermit.config + :members: get_config + +Input & Output +~~~~~~~~~~~~~~ + +.. automodule:: hermit.io + :members: get_io, display_data_as_animated_qrs, read_data_from_animated_qrs + +QR Codes +~~~~~~~~ + +.. automodule:: hermit.qr + :members: create_qr, create_qr_sequence, qr_to_image, detect_qrs_in_image + +Randomness +~~~~~~~~~~ + +.. automodule:: hermit.rng + :members: max_entropy_estimate, max_self_entropy, max_kolmogorov_entropy_estimate diff --git a/environment.sh b/environment.sh index 6f9b61f..cb7be18 100644 --- a/environment.sh +++ b/environment.sh @@ -9,7 +9,7 @@ SCRIPT_NAME=$(basename "${0/-/}") SOURCE_NAME=$(basename "$BASH_SOURCE") if [ "$SCRIPT_NAME" = "$SOURCE_NAME" ]; then - echo "ERROR: Do not execute ('bash environment.sh') this script! Source it instead ('source environment.sh')" + echo "ERROR: Do not execute ('bash environment.sh') this script! Source it instead ('source environment.sh')" >&2 exit 1 fi @@ -19,7 +19,6 @@ fi ROOT_DIR=$(pwd) LIB_DIR="${ROOT_DIR}" BIN_DIR="${ROOT_DIR}/bin" -SUBMODULES_DIR="vendor" # # Python virtualenv @@ -28,10 +27,10 @@ SUBMODULES_DIR="vendor" VENV_NAME=".virtualenv" VENV_DIR="${ROOT_DIR}/${VENV_NAME}" if [ -d "$VENV_DIR" ]; then - echo "[virtualenv] Entering Python virtualenv at ${VENV_DIR}" . "${VENV_DIR}/bin/activate" else - echo "ERROR: Python virtualenv directory (${VENV_DIR}) does not exist. Did you run 'make' yet?" + echo "ERROR: Python virtualenv directory (${VENV_DIR}) does not exist. Did you run 'make' yet?" >&2 + exit 2 fi # @@ -41,12 +40,8 @@ fi # if [ -z $(echo "$PYTHONPATH" | grep "$LIB_DIR") ]; then - echo "[pythonpath] Adding $LIB_DIR to PYTHONPATH (${PYTHONPATH})" export PYTHONPATH="${PYTHONPATH}:${LIB_DIR}" -else - echo "[pythonpath] $LIB_DIR already on PYTHONPATH (${PYTHONPATH})" fi -export MYPYPATH=":$(python -m site | grep virtual | sed -e "s/^ *'//g" -e "s/',/\//g")" # # PATH @@ -55,18 +50,5 @@ export MYPYPATH=":$(python -m site | grep virtual | sed -e "s/^ *'//g" -e "s/',/ # if [ -z $(echo "$PATH" | grep "$BIN_DIR") ]; then - echo "[path] Adding $BIN_DIR to PATH (${PATH})" - export PATH="${PATH}:${BIN_DIR}" -else - echo "[path] $BIN_DIR already on PATH (${PATH})" -fi - -# -# Submodules -# - -if [ ! -e "${SUBMODULES_DIR}/pybitcointools/pybitcointools" ]; then - echo 'ERROR: No git submodules. Run `git submodule update --init`' -else - echo "[git] Submodules present" + export PATH="${BIN_DIR}:${PATH}" fi diff --git a/examples/config_files/hermit.ascii.yml b/examples/config_files/hermit.ascii.yml new file mode 100644 index 0000000..52d5acd --- /dev/null +++ b/examples/config_files/hermit.ascii.yml @@ -0,0 +1,13 @@ +--- + +# This configuration file specifies that Hermit should run in the +# ASCII display mode. +# +# Run Hermit using this file via: +# +# $ HERMIT_CONFIG=examples/config_files/hermit.ascii.yml +# + +io: + display: ascii + width: 80 diff --git a/examples/config_files/hermit.coordinator.yml b/examples/config_files/hermit.coordinator.yml new file mode 100644 index 0000000..5e18899 --- /dev/null +++ b/examples/config_files/hermit.coordinator.yml @@ -0,0 +1,23 @@ +--- + +# This configuration file provides Hermit with a example public key +# from an authorized coordinator. +# +# Run Hermit using this file via: +# +# $ HERMIT_CONFIG=examples/config_files/hermit.ascii.yml +# + + +coordinator: + signature_required: true + + # This value is from tests/fixtures/coordinator.pub. The + # corresponding private key is at tests/fixtures/coordinator.pem. + public_key: |- + -----BEGIN PUBLIC KEY----- + MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDm6LRbZLXjha+bODC7SID5SKY + WfhwHNJj9fTI9wxankYzMuSMUiSq29HBmwPkVtFzFdLuYKFnc6+1pIwXHP+Xf7Pc + wO4esRMkzEZUK2IR5BniarMTQ9/sBXf7ttj6Nhp1LTQ2aZ4fjGdI9ZWj/hcJY7wY + 56KIRZHF+/rAzXNFfQIDAQAB + -----END PUBLIC KEY----- diff --git a/examples/config_files/hermit.example.yml b/examples/config_files/hermit.example.yml new file mode 100644 index 0000000..5d30179 --- /dev/null +++ b/examples/config_files/hermit.example.yml @@ -0,0 +1,90 @@ +--- + +# +# Example configuration file for Hermit. All values are set to their +# defaults. +# +# Modify and place the resulting file at /etc/hermit.yaml (or set +# `HERMIT_CONFIG` environment variable). +# + +# Paths used by Hermit. +paths: + # Path to the YAML file used for configuration + config_file: /etc/hermit.yaml + + # Path to the BSON file used to store shards + shards_file: /tmp/shard_words.bson + + # Path to a directory used to load runtime plugins + plugin_dir: /var/lib/hermit + + +# Commands for persistence and backup of data. +# +# Each command will have the string `{0}` interpolated with the path +# to the file being processed. +commands: + # Command used to copy shards from file system to persistent storage + persistShards: "cat {0} | gzip -c - > {0}.persisted" + + # Command used to copy shards from file system to backup storage + backupShards: "cp {0}.persisted {0}.backup" + + # Command used to copy shards from backup storage to file system + restoreBackup: "zcat {0}.backup > {0}" + + # Command used to copy shards from persistent storage to file system + getPersistedShards: "zcat {0}.persisted > {0}" + +# Settings for input camera & output display. +# +# Note: If the HERMIT_DISABLE_IO environment variable is set, all IO +# will be disabled. (This is useful for automated testing.) +io: + # Display mode (`opencv`, `framebuffer`, or `ascii`) + display: "opencv" + + # Camera mode (`opencv` or `imageio`) + camera: "opencv" + + # Time (in milliseconds) between each successive QR code in a sequence + qr_code_sequence_delay: 200 + + # Horizontal position of display on screen + x_position: 100 + + # Vertical position of display on screen + y_position: 100 + + # Height of display on screen + width: 300 + + # Width of display on screen + height: 300 + +# Settings for the coordinator being used with Hermit. +coordinator: + + # Whether a signature from the coordinator is required to sign + signature_required: false + + # An ECDSA public key (in hex) corresponding to the private key used + # to sign by the coordinator + # + # This value is from tests/fixtures/coordinator.pub. The + # corresponding private key is at tests/fixtures/coordinator.pem. + public_key: |- + -----BEGIN PUBLIC KEY----- + MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDm6LRbZLXjha+bODC7SID5SKY + WfhwHNJj9fTI9wxankYzMuSMUiSq29HBmwPkVtFzFdLuYKFnc6+1pIwXHP+Xf7Pc + wO4esRMkzEZUK2IR5BniarMTQ9/sBXf7ttj6Nhp1LTQ2aZ4fjGdI9ZWj/hcJY7wY + 56KIRZHF+/rAzXNFfQIDAQAB + -----END PUBLIC KEY----- + + + # Controls how transactions are displayed during signing. + # + # Options are `old`, `long`, and `short`, with the default being + # `old`. + transaction_display: "old" diff --git a/examples/config_files/hermit.framebuffer.yml b/examples/config_files/hermit.framebuffer.yml new file mode 100644 index 0000000..24a729f --- /dev/null +++ b/examples/config_files/hermit.framebuffer.yml @@ -0,0 +1,12 @@ +--- + +# This configuration file specifies that Hermit should run in the +# framebuffer display mode. +# +# Run Hermit using this file via: +# +# $ HERMIT_CONFIG=examples/config_files/hermit.framebuffer.yml +# + +io: + display: framebuffer diff --git a/examples/config_files/hermit.imageio.yml b/examples/config_files/hermit.imageio.yml new file mode 100644 index 0000000..64ee7b5 --- /dev/null +++ b/examples/config_files/hermit.imageio.yml @@ -0,0 +1,13 @@ +--- + +# This configuration file specifies that Hermit should run in the +# ImageIO camera mode. +# +# Run Hermit using this file via: +# +# $ HERMIT_CONFIG=examples/config_files/hermit.imageio.yml +# + + +io: + camera: imageio diff --git a/examples/hello_world.jpg b/examples/hello_world.jpg new file mode 100644 index 0000000..33c543a Binary files /dev/null and b/examples/hello_world.jpg differ diff --git a/examples/lorem_ipsum.gif b/examples/lorem_ipsum.gif new file mode 100644 index 0000000..bc1ec9b Binary files /dev/null and b/examples/lorem_ipsum.gif differ diff --git a/examples/signature_requests b/examples/signature_requests new file mode 120000 index 0000000..75a061a --- /dev/null +++ b/examples/signature_requests @@ -0,0 +1 @@ +../tests/fixtures/signature_requests \ No newline at end of file diff --git a/examples/signature_requests/bitcoin_testnet.json b/examples/signature_requests/bitcoin_testnet.json deleted file mode 100644 index b03c2e0..0000000 --- a/examples/signature_requests/bitcoin_testnet.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - - "inputs": [ - - [ - - "522102a567420d0ecb8ae1ac3794a6f901c9fdfa63a24476b8889d4c483dc7975f4ab321034a7496c358ee925043f5859bf7a191566fa070e3b75aac3f3bde6a670a8b6a8e2103666946ad4ff2b8c5c1ed6c8f5dd7f28769820cf35061ad5e02a45c5f05d54a1853ae", - "m/45'/1'/120'/20/26", - { - "txid": "6b6ac4fafde29ad0ca65f61f9ea479ecb46a5a0a4195f147d99f164d004aa883", - "index": 0, - "amount": 27194455 - }, - { - "txid": "11b193ba1bf66ed2274cdf4fe93a39968b60a217f8f7c2b0e16d222240e781bb", - "index": 1, - "amount": 9818406 - } - ] - - ], - - "outputs": [ - { - "address": "2NG4oZZZbBcBtUw6bv2KEJmbZgrdLTTf5CC", - "amount": 10000000 - }, - { - "address": "2MuK9EGZeXMLJTqVtv8eKrcbrvYg2pwD1te", - "amount": 27002781 - } - ] - -} diff --git a/examples/signature_requests/bitcoin_testnet.png b/examples/signature_requests/bitcoin_testnet.png deleted file mode 100644 index af0fcd7..0000000 Binary files a/examples/signature_requests/bitcoin_testnet.png and /dev/null differ diff --git a/examples/testnet1/hermit_inputs.json b/examples/testnet1/hermit_inputs.json deleted file mode 100644 index 360a5f3..0000000 --- a/examples/testnet1/hermit_inputs.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "txid": "23a379298f1ba0bacc42dc55af3c72745ec9a5fe23c51abfb4d28895ecd3c0ad", - "n": 1, - "amount": 9468656, - "rawRefTx": "0200000000010112a6ed1776e3e9ce04acb704f0d3cbed2254032d5148c9f18a2a941391e0980c010000001716001413210915b087df1ca0c472013ea245a477fa2f51feffffff02fee6508c6e00000017a914b473b198a9368c93742511530721e6e6a4b3376287f07a90000000000017a91495a5d9c46749c9d6eac6a8cefa0ec96001b3f5cd870247304402206ce7b56ca496b08bc9be39953febbf0a50363a3f3e707846d1a294e7a69d296a02200f91b78d0e44c0f9281d25046414df4f7bf863cd83c58e9e3abe8cc254cdb0ee012103bc5471bd841dc36733989aabcbb846971afbfd49c77c5d71f568f6820c70632bf0051600" - }, - { - "txid": "bda974b84204894eed7c7c9c3c5ce79659745c4acbbc38373f4176a8811c0943", - "n": 1, - "amount": 10287810, - "rawRefTx": "02000000000101db876f667ab5ffc4bba5b1525388e3e3144cb1b3dddc3377ab6c05bb8759addc0000000017160014cf163ff181a380cea4dcd41b686b7c4def38cf41feffffff020dd9014e6600000017a9141ab7ab67aa56bfd4dfbc4db850d53d9fe751968087c2fa9c000000000017a91495a5d9c46749c9d6eac6a8cefa0ec96001b3f5cd8702483045022100a86520272753459fe213338c5e2dc2f6544981d2a052c288b30302a6f4471d3c0220450882d21a877e3bdb47783766b65a286b2938c70c9c0bffc709f4c86b9bdbe1012103ddd8290ed2150b238c97e4787b122bf795237f7f55fc54588319963e29aa1ce48b061600" - } -] diff --git a/examples/testnet1/hermit_outputs.json b/examples/testnet1/hermit_outputs.json deleted file mode 100644 index c75840a..0000000 --- a/examples/testnet1/hermit_outputs.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "address": "2NG4oZZZbBcBtUw6bv2KEJmbZgrdLTTf5CC", - "amount": 10000000 - }, - { - "address": "2MuK9EGZeXMLJTqVtv8eKrcbrvYg2pwD1te", - "amount": 9689266 - } -] diff --git a/examples/testnet1/testnet1.json b/examples/testnet1/testnet1.json deleted file mode 100644 index 4f3aeeb..0000000 --- a/examples/testnet1/testnet1.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - - "redeem_script": "522102a567420d0ecb8ae1ac3794a6f901c9fdfa63a24476b8889d4c483dc7975f4ab321034a7496c358ee925043f5859bf7a191566fa070e3b75aac3f3bde6a670a8b6a8e2103666946ad4ff2b8c5c1ed6c8f5dd7f28769820cf35061ad5e02a45c5f05d54a1853ae", - - "bip32_path": "m/45'/1'/120'/20/26", - - "inputs": [ - { - "address": "2N6tVMNXwt5xD9xYU3TNWP5pvMLF2jyTR6V", - "txid": "23a379298f1ba0bacc42dc55af3c72745ec9a5fe23c51abfb4d28895ecd3c0ad", - "n": 1, - "amount": 9468656 - }, - { - "address": "2N6tVMNXwt5xD9xYU3TNWP5pvMLF2jyTR6V", - "txid": "bda974b84204894eed7c7c9c3c5ce79659745c4acbbc38373f4176a8811c0943", - "n": 1, - "amount": 10287810 - } - ] - , - - "outputs": [ - { - "address": "2NG4oZZZbBcBtUw6bv2KEJmbZgrdLTTf5CC", - "amount": 10000000 - }, - { - "address": "2MuK9EGZeXMLJTqVtv8eKrcbrvYg2pwD1te", - "amount": 9689266 - } - ] - - -} diff --git a/hermit/VERSION b/hermit/VERSION index 7ac4e5e..0ea3a94 100644 --- a/hermit/VERSION +++ b/hermit/VERSION @@ -1 +1 @@ -0.1.13 +0.2.0 diff --git a/hermit/__init__.py b/hermit/__init__.py index 2f711c1..6392b84 100644 --- a/hermit/__init__.py +++ b/hermit/__init__.py @@ -1,12 +1,39 @@ -from .errors import * -from .config import * -from .wallet import * -from .signer import * -from .qrcode import * -from .plugin import * +import os.path +from .errors import ( + HermitError, + InvalidQRCodeSequence, + InvalidPSBT, + InvalidCoordinatorSignature, +) +from .config import get_config +from .wallet import HDWallet +from .signer import Signer +from .qr import ( + create_qr_sequence, + qr_to_image, + detect_qrs_in_image, + GenericReassembler, +) +from .io import ( + display_data_as_animated_qrs, + read_data_from_animated_qrs, +) +from .camera import Camera +from .display import Display +from .rng import ( + max_self_entropy, + max_kolmogorov_entropy_estimate, + max_entropy_estimate, +) +from .plugins import ( + load_plugins, + plugins_loaded, +) -import os +def _get_current_version(): + with open(os.path.join(os.path.dirname(__file__), "VERSION")) as version_file: + return version_file.read().strip() -with open(os.path.join(os.path.dirname(__file__), "VERSION")) as version_file: - __version__ = version_file.read().strip() + +__version__ = _get_current_version() diff --git a/hermit/camera/__init__.py b/hermit/camera/__init__.py new file mode 100644 index 0000000..0617d7b --- /dev/null +++ b/hermit/camera/__init__.py @@ -0,0 +1,17 @@ +from os import environ +from .base import Camera + +# +# Ordinarily we don't want to import these classes because their +# dependencies might not be installed. +# +# The `IO` class handles loading the proper class based on the +# configured camera mode. +# +# This environment variable exists to force load all camera classes so +# they can be included in documentation. +# + +if environ.get("HERMIT_LOAD_ALL_IO"): + from .opencv import OpenCVCamera + from .imageio import ImageIOCamera diff --git a/hermit/camera/base.py b/hermit/camera/base.py new file mode 100644 index 0000000..62b1576 --- /dev/null +++ b/hermit/camera/base.py @@ -0,0 +1,36 @@ +from typing import Optional +from PIL import Image + + +class Camera: + """Abstract base class for cameras. + + Concrete subclasses should implement the ``get_image`` method + which should return the device's camera's current image. + + ``open`` and ``close`` methods are also available to aid with + setup and teardown of resources associated with the device's + camera. + + """ + + def open(self) -> None: + """Starts the camera, initializing any resources required.""" + pass + + def close(self) -> None: + """Stops the camera, releasing any resources required.""" + pass + + def get_image(self) -> Optional[Image.Image]: + """Get the current image from the camera. + + The image should be an instance of the `Image class + `_ + from the `pillow library + <(https://pillow.readthedocs.io/en/stable/>`_. + + If no image is available, return ``None``. + + """ + pass diff --git a/hermit/camera/imageio.py b/hermit/camera/imageio.py new file mode 100644 index 0000000..a3a8d3a --- /dev/null +++ b/hermit/camera/imageio.py @@ -0,0 +1,39 @@ +from typing import Optional +from .base import Camera +from PIL import Image + +try: + from imageio import get_reader +except ModuleNotFoundError: + print("ERROR: imageio library not installed") + + +class ImageIOCamera(Camera): + """Corresponds to camera mode ``imageio``. + + Uses the `ImageIO `_ + library. + + Allows getting high-resolution image data from the web camera + despite not having a graphical environment. + + """ + + def __init__(self): + self.camera = None + + def open(self): + if self.camera is None: + self.camera = get_reader("") + + def get_image(self) -> Optional[Image.Image]: + image = None + if self.camera is not None: + frame = self.camera.get_next_data() + image = Image.fromarray(frame) + return image + + def close(self): + if self.camera is not None: + self.camera.close() + self.camera = None diff --git a/hermit/camera/opencv.py b/hermit/camera/opencv.py new file mode 100644 index 0000000..5a4f156 --- /dev/null +++ b/hermit/camera/opencv.py @@ -0,0 +1,42 @@ +from typing import Optional +from ..errors import HermitError +from .base import Camera +from PIL import Image + +try: + import cv2 +except ModuleNotFoundError: + print("ERROR: cv2 library not installed") + + +class OpenCVCamera(Camera): + """Corresponds to camera mode ``opencv``. + + Uses the `OpenCV `_ library. + + Requires that Hermit is running in a graphical environment. + + """ + + def __init__(self): + self.camera = None + + def open(self): + if self.camera is None: + self.camera = cv2.VideoCapture(0) + if not self.camera.isOpened(): + raise HermitError("Cannot open camera") + + def get_image(self) -> Optional[Image.Image]: + image = None + if self.camera is not None: + ret, frame = self.camera.read() + bgr = Image.fromarray(frame) + b, g, r = bgr.split() + image = Image.merge("RGB", (r, g, b)) + return image + + def close(self): + if self.camera is not None: + self.camera.release() + self.camera = None diff --git a/hermit/config.py b/hermit/config.py index b65fbcd..af69bd4 100644 --- a/hermit/config.py +++ b/hermit/config.py @@ -1,24 +1,66 @@ -import yaml -from os import path, environ -from typing import Dict +__doc__ = """ + +""" + +from yaml import safe_load +import os +from os import environ +from os.path import exists +from typing import Optional, Dict, Union + +_global_config = None + + +def get_config() -> "HermitConfig": + """Return a globally shared and already loaded instance of :class:`HermitConfig`. + + This is the usual way to get configuration values in Hermit + code. :: + + >>> from hermit import get_config + >>> print(get_config().paths["config_file"]) + "/etc/hermit.yaml" + + """ + global _global_config + + if _global_config is None: + _global_config = HermitConfig.load() + + return _global_config class HermitConfig: """Object to hold Hermit configuration - Hermit reads its configuration from a YAML file on disk at - `/etc/hermit.yaml` (by default). + Hermit configuration is split into six sections: - The following settings are supported: + * `paths` -- paths for configuration, shard data, and plugins + * `commands` -- command-lines used to manipulate shard data + * `io` -- settings for input and output + * `coordinator` -- settings for the coordinator + * `disabled_wallet_commands` -- chooses which wallet commands are not allowed + * `disabled_shards_commands` -- chooses which shards commands are not allowed - * `shards_file` -- path to store shards - * `plugin_dir` -- directory containing plugins - * `commands` -- a dictionary of command lines used to manipulate storage, see :attribute:`hermit.HermitConfig.DefaultCommands`. + This class is typically not instantiated directly. Instead, the + :func:`get_config` method is used. """ - #: Default commands for persistence and backup of data. + #: Default paths used by Hermit. #: + #: The following paths are defined: + #: + #: * `config_file` -- path to the YAML file used for configuration + #: * `shards_file` -- path to the BSON file used to store shards + #: * `plugin_dir` -- path to a directory used to load runtime plugins + DefaultPaths: Dict[str, str] = { + "config_file": "/etc/hermit.yaml", + "shards_file": "/tmp/shard_words.bson", + "plugin_dir": "/var/lib/hermit", + } + + #: Default commands for persistence and backup of data. #: #: Each command will have the string `{0}` interpolated with the #: path to the file being processed. @@ -26,57 +68,130 @@ class HermitConfig: #: The following commands are defined: #: #: * `persistShards` -- copy from file system to persistent storage - #: * `getPersistedShards` -- copy from persistent storage to file system #: * `backupShards` -- copy from file system to backup storage #: * `restoreBackup` -- copy from backup storage to file system + #: * `getPersistedShards` -- copy from persistent storage to file system #: - DefaultCommands = { - 'persistShards': "cat {0} | gzip -c - > {0}.persisted", - 'backupShards': "cp {0}.persisted {0}.backup", - 'restoreBackup': "zcat {0}.backup > {0}", - 'getPersistedShards': "zcat {0}.persisted > {0}" + DefaultCommands: Dict[str, str] = { + "persistShards": "cat {0} | gzip -c - > {0}.persisted", + "backupShards": "cp {0}.persisted {0}.backup", + "restoreBackup": "zcat {0}.backup > {0}", + "getPersistedShards": "zcat {0}.persisted > {0}", } - DefaultPaths = { - 'config_file': '/etc/hermit.yaml', - 'shards_file': '/tmp/shard_words.bson', - 'plugin_dir': '/var/lib/hermit', + #: Default settings for input camera & output display. + #: + #: The following settings are defined: + #: + #: * `display` -- display mode (`opencv`, `framebuffer`, or `ascii`) + #: * `camera` -- camera mode (`opencv` or `imageio`) + #: * `qr_code_sequence_delay` -- time (in milliseconds) between each successive QR code in a sequence + #: * `x_position` -- horizontal position of display on screen + #: * `y_position` -- vertical position of display on screen + #: * `width` -- height of display on screen + #: * `height` -- width of display on screen + #: + DefaultIO: Dict[str, Union[str, int]] = { + "display": "opencv", + "camera": "opencv", + "qr_code_sequence_delay": 200, + "x_position": 100, + "y_position": 100, + "width": 300, + "height": 300, } - def __init__(self, config_file: str): - - """ - Initialize Hermit configuration - - :param config_file: the path to the YAML configuration file - """ - - self.config_file = config_file - self.shards_file = self.DefaultPaths['shards_file'] - self.plugin_dir = self.DefaultPaths['plugin_dir'] - self.config: Dict = {} - self.commands: Dict = {} + #: Default settings relevant to the coordinator being used with Hermit. + #: + #: The following settings are defined: + #: + #: * `signature_required` -- whether a signature from the + #: coordinator is required to sign + #: + #: * `public_key` -- an ECDSA public key (in hex) corresponding to + #: the private key used to sign by the coordinator + #: + #: * `transaction_display` -- controls how transactions are displayed + #: during signing. Options are `old`, `long`, and `short`, with the + #: default being `old`. + #: + #: * 'minimize_signed_psbt' -- controls whether or not to remove information + #: from the signed psbt that the coordinator should already be aware of + #: because they should still have a copy of the unsigned psbt that they + #: sent for us to sign. In some cases this can DRAMATICALLY reduce the + #: amount of information that needs to be sent back over the return QR + #: channel. + DefaultCoordinator: Dict[str, Union[str, bool, None, int]] = { + "signature_required": False, + "public_key": None, + "transaction_display": "old", + "relock_timeout": 30, # seconds + "minimize_signed_psbt": False, + } - if path.exists(config_file): - self.config = yaml.safe_load(open(config_file)) + @classmethod + def load(cls): + """Return a properly initialized `HermitConfig` instance.""" + return HermitConfig(config_file=environ.get("HERMIT_CONFIG")) - if 'shards_file' in self.config: - self.shards_file = self.config['shards_file'] - if 'plugin_dir' in self.config: - self.plugin_dir = self.config['plugin_dir'] - if 'commands' in self.config: - self.commands = self.config['commands'] + def __init__(self, config_file: Optional[str] = None): + """Initialize Hermit configuration. - defaults = self.DefaultCommands.copy() + :param config_file: the path to the YAML configuration file (defaults to `/etc/hermit.yaml`). - for key in defaults: - if key not in self.commands: - self.commands[key] = defaults[key] + If the `config_file` does not exist, it will be ignored. - for key in self.commands: - formatted_key = self.commands[key].format(self.shards_file) - self.commands[key] = formatted_key + """ - @classmethod - def load(cls): - return HermitConfig(environ.get("HERMIT_CONFIG", cls.DefaultPaths['config_file'])) + self._load(config_file=config_file) + self._inject_defaults() + self._interpolate_paths() + self._interpolate_commands() + self.paths = self.config["paths"] + self.commands = self.config["commands"] + self.io = self.config["io"] + self.coordinator = self.config["coordinator"] + self.disabled_wallet_commands = self.config["disabled_wallet_commands"] + self.disabled_shards_commands = self.config["disabled_shards_commands"] + + def _load(self, config_file: Optional[str] = None) -> None: + if config_file is None: + config_file = self.DefaultPaths["config_file"] + + if exists(config_file): + self.config = safe_load(open(config_file)) or {} + else: + self.config = {} + + def _inject_defaults(self) -> None: + for section_key, defaults in [ + ("paths", self.DefaultPaths), + ("commands", self.DefaultCommands), + ("io", self.DefaultIO), + ("coordinator", self.DefaultCoordinator), + ]: + if section_key not in self.config: + self.config[section_key] = {} + for config_key, default_value in defaults.items(): # type: ignore + if config_key not in self.config[section_key]: + self.config[section_key][config_key] = default_value + + for section_key in [ + "disabled_wallet_commands", + "disabled_shards_commands", + ]: + if section_key not in self.config: + self.config[section_key] = [] + + def _interpolate_commands(self) -> None: + for config_key in self.config["commands"]: + interpolated_value = self.config["commands"][config_key].format( + self.config["paths"]["shards_file"] + ) + self.config["commands"][config_key] = interpolated_value + + def _interpolate_paths(self) -> None: + self.config["paths"] = { + key: os.path.expandvars(os.path.expanduser(value)) + for key, value in self.config["paths"].items() + } diff --git a/hermit/confirm_transaction.py b/hermit/confirm_transaction.py new file mode 100644 index 0000000..ddaabe0 --- /dev/null +++ b/hermit/confirm_transaction.py @@ -0,0 +1,116 @@ +# from prompt_toolkit.layout import ScrollablePane, HSplit, BufferControl, FormattedTextControl, Window, Container + +from prompt_toolkit.widgets import Label, Button, Dialog, TextArea + +from prompt_toolkit.application.current import get_app + +from prompt_toolkit.key_binding.key_bindings import KeyBindings, merge_key_bindings + +# from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.key_binding.defaults import load_key_bindings + +from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous + +from prompt_toolkit.application import Application +from prompt_toolkit.layout import Layout + +from prompt_toolkit.key_binding.bindings.scroll import ( + scroll_forward, + scroll_backward, + scroll_half_page_up, + scroll_half_page_down, + scroll_one_line_up, + scroll_one_line_down, +) +from prompt_toolkit.styles import Style + +confirm_style = Style.from_dict( + { + "dialog": "bg:#000000", + "dialog frame.label": "bg:#000000 #ffffff", + "dialog.body": "bg:#000000 #ffffff", + "dialog shadow": "bg:#000000", + "text-area": "bg:#888888 #ffffff", + "text-area.prompt": "bg:#888888 #ffffff", + } +) + + +def confirm_transaction_dialog(title=None, transaction=None, style=None): + """ + Display a Yes/No dialog showing the descriptions of the transaction. + + Return a boolean. + """ + style = style or confirm_style + + # Set up handlers for events that signal that we do want to sign the + # transaction. + + def yes_handler(event=None): + get_app().exit(result=True) + + def no_handler(event=None): + get_app().exit(result=False) + + title = title or "Sign this Transaction?" + + if transaction is None: + transaction = "\n".join(f"This is line {i}" for i in range(100)) + + # Display the transaction description in a big, read only TextArea control. + body = TextArea( + text=transaction, + multiline=True, + read_only=True, + scrollbar=True, + ) + + dialog = Dialog( + title=title, + body=body, + buttons=[ + Button( + text="Yes", + handler=yes_handler, + width=8, + # left_symbol='[', right_symbol=']', + ), + Label(" "), + Button( + text="No", + handler=no_handler, + width=8, + # left_symbol='[', right_symbol=']', + ), + ], + with_background=False, + ) + + # Key bindings. + bindings = KeyBindings() + bindings.add("tab")(focus_next) + bindings.add("s-tab")(focus_previous) + bindings.add("pagedown")(scroll_forward) + bindings.add("pageup")(scroll_backward) + bindings.add("up")(scroll_one_line_up) + bindings.add("down")(scroll_one_line_down) + bindings.add("s-up")(scroll_one_line_up) + bindings.add("s-down")(scroll_one_line_down) + bindings.add("c-up")(scroll_half_page_up) + bindings.add("c-down")(scroll_half_page_down) + + bindings.add("y")(yes_handler) + bindings.add("n")(no_handler) + + return Application( + layout=Layout(dialog), + key_bindings=merge_key_bindings([load_key_bindings(), bindings]), + mouse_support=True, + style=style, + full_screen=True, + ) + + +if __name__ == "__main__": + print(confirm_transaction_dialog().run()) diff --git a/hermit/coordinator.py b/hermit/coordinator.py new file mode 100644 index 0000000..37e2f1d --- /dev/null +++ b/hermit/coordinator.py @@ -0,0 +1,129 @@ +from typing import Tuple + +from buidl import PSBT +from Crypto.Hash import SHA256 +from Crypto.Signature import PKCS1_v1_5 +from Crypto.PublicKey import RSA + +from .config import get_config +from .errors import InvalidCoordinatorSignature + +#: The key that holds an optional coordinator signature for a PSBT. +COORDINATOR_SIGNATURE_KEY: bytes = "coordinator_sig".encode("utf8") + + +def validate_coordinator_signature_if_necessary(original_psbt: PSBT) -> None: + """Validates the given PSBT has a valid coordinator signature, if necessary. + + Raises :class:`~hermit.errors.InvalidCoordinatorSignature` if the + signature is invalid. + + If the PSBT lacks a coordinator signature, and one is required + (see :attr:`~hermit.config.HermitConfig.DefaultCoordinator`), will again raise + :class:`~hermit.errors.InvalidCoordinatorSignature` + + If a coordinator signature is not required, PSBTs without one will + be valid. PSBTs with coordinator signatures will still have their + signatures fully-validated in this case. + + Coordinator signatures are RSA signatures verified using a public + key stored in Hermit's configuration (see + :attr:`~hermit.config.HermitConfig.DefaultCoordinator`). + + """ + + signature_required = get_config().coordinator["signature_required"] + if COORDINATOR_SIGNATURE_KEY not in original_psbt.extra_map: + if signature_required: + raise InvalidCoordinatorSignature("Coordinator signature is missing.") + else: + return + + unsigned_psbt_base64_bytes, sig_bytes = extract_rsa_signature_params(original_psbt) + validate_rsa_signature(unsigned_psbt_base64_bytes, sig_bytes) + + +def create_rsa_signature(message: bytes, private_key_path: str) -> bytes: + """Create an RSA signature. + + This function is not called within usual Hermit operation. It is + useful for scripts and tests. + + """ + + with open(private_key_path, mode="r") as private_key_file: + private_key = RSA.importKey(private_key_file.read()) + + digest = SHA256.new() + digest.update(message) + signer = PKCS1_v1_5.new(private_key) + signature = signer.sign(digest) + return signature + + +def validate_rsa_signature(message: bytes, signature: bytes) -> None: + """Validate an RSA signature. + + Uses the public key from Hermit's configuration for verification + (see :attr:`~hermit.config.DefaultCoordinator`). + + Will raise :class:`~hermit.errors.InvalidCoordinatorSignature` if + the public key is missing or invalid or if the signature is + invalid. + + """ + public_key_text = get_config().coordinator.get("public_key") + if public_key_text is None: + raise InvalidCoordinatorSignature( + "Coordinator signature is present but no public key is configured." + ) + + try: + public_key = RSA.importKey(public_key_text) + except Exception: + raise InvalidCoordinatorSignature( + "Coordinator signature is present but coordinator public key is invalid." + ) + + digest = SHA256.new() + digest.update(message) + verifier = PKCS1_v1_5.new(public_key) + if not verifier.verify(digest, signature): # type: ignore + raise InvalidCoordinatorSignature("Coordinator signature is invalid.") + + +def extract_rsa_signature_params(original_psbt: PSBT) -> Tuple[bytes, bytes]: + """Extract RSA signature parameters from a PSBT. + + The value of the :attr:`COORDINATOR_SIGNATURE_KEY` key within the + PSBT's `extra_map` is extracted as the signature bytes. + + This key is then deleted and the PSBT re-serialized to base 64 + bytes. This is the message assumed to be signed by the signature + bytes. + + """ + + sig_bytes = original_psbt.extra_map[COORDINATOR_SIGNATURE_KEY] + + # FIXME how do we make a copy of a PSBT object? + unsigned_psbt = PSBT.parse_base64(original_psbt.serialize_base64()) + del unsigned_psbt.extra_map[COORDINATOR_SIGNATURE_KEY] + unsigned_psbt_base64 = unsigned_psbt.serialize_base64() + unsigned_psbt_base64_bytes = unsigned_psbt_base64.encode("utf8") + + return unsigned_psbt_base64_bytes, sig_bytes + + +def add_rsa_signature(original_psbt: PSBT, private_key_path: str) -> PSBT: + """Add a signature to a PSBT.""" + + psbt_base64 = original_psbt.serialize_base64() + + sig_bytes = create_rsa_signature( + bytes(psbt_base64, "utf-8"), + private_key_path, + ) + + original_psbt.extra_map[COORDINATOR_SIGNATURE_KEY] = sig_bytes + return original_psbt diff --git a/hermit/display/__init__.py b/hermit/display/__init__.py new file mode 100644 index 0000000..dfb8bac --- /dev/null +++ b/hermit/display/__init__.py @@ -0,0 +1,18 @@ +from os import environ +from .base import Display + +# +# Ordinarily we don't want to import these classes because their +# dependencies might not be installed. +# +# The `IO` class handles loading the proper class based on the +# configured display mode. +# +# This environment variable exists to force load all display classes +# so they can be included in documentation. +# + +if environ.get("HERMIT_LOAD_ALL_IO"): + from .ascii import ASCIIDisplay + from .framebuffer import FrameBufferDisplay + from .opencv import OpenCVDisplay diff --git a/hermit/display/ascii.py b/hermit/display/ascii.py new file mode 100644 index 0000000..44ac736 --- /dev/null +++ b/hermit/display/ascii.py @@ -0,0 +1,152 @@ +from typing import List +from io import StringIO +from time import sleep + +from qrcode import QRCode +from prompt_toolkit import print_formatted_text, ANSI +from prompt_toolkit.shortcuts import clear +from PIL import ImageEnhance + +from .base import Display + + +class ANSIColorMap: + """This class has some quick and dirty mappings from 24bit color space + to 16-color ansi terminal space. + + """ + + Reset = "\u001b[0m" + Home = "\u001b[1;1H" + Clear = "\u001b[2J" + + Black = "\u001b[30m" + Red = "\u001b[31m" + Green = "\u001b[32m" + Yellow = "\u001b[33m" + Blue = "\u001b[34m" + Magenta = "\u001b[35m" + Cyan = "\u001b[36m" + White = "\u001b[37m" + BrightBlack = "\u001b[30;1m" + BrightRed = "\u001b[31;1m" + BrightGreen = "\u001b[32;1m" + BrightYellow = "\u001b[33;1m" + BrightBlue = "\u001b[34;1m" + BrightMagenta = "\u001b[35;1m" + BrightCyan = "\u001b[36;1m" + BrightWhite = "\u001b[37;1m" + + RGB2Bit = [ + [ # red 00 + [Black, Black, Blue, Blue], # Green 00 + [Black, Black, Blue, Blue], # Green 01 + [Green, Green, Cyan, Cyan], # Green 10 + [Green, Green, Cyan, Cyan], # Green 11 + ], + [ # red 01 + [Black, Black, Blue, Blue], # Green 00 + [Black, BrightBlack, BrightBlue, BrightBlue], # Green 01 + [Green, BrightGreen, Cyan, Cyan], # Green 10 + [Green, BrightGreen, Cyan, BrightCyan], # Green 11 + ], + [ # red 10 + [Red, Red, Magenta, Magenta], # Green 00 + [Red, Red, Magenta, Magenta], # Green 01 + [Yellow, Yellow, White, White], # Green 10 + [Yellow, Yellow, White, BrightCyan], # Green 11 + ], + [ # red 11 + [Red, Red, Magenta, Magenta], # Green 00 + [Red, BrightRed, BrightMagenta, White], # Green 01 + [Yellow, BrightYellow, White, BrightWhite], # Green 10 + [Yellow, White, BrightWhite, BrightWhite], # Green 11 + ], + ] + + @classmethod + def color(self, char, r, g, b): + r = r // 64 + g = g // 64 + b = b // 64 + return self.RGB2Bit[r][g][b] + char + self.Reset + + +class ASCIIDisplay(Display): + """Corresponds to display mode ``ascii``. + + Displays data through ASCII text suitable for a terminal. + + Images will be approximated as best as possible given the + resolution. + + """ + + DEFAULT_WIDTH = 80 + + def __init__(self, io_config): + Display.__init__(self, io_config) + + # FIXME This is undocumented in config.py + self.height_scale = float(io_config.get("height_scale", 0.55)) + + # + # Displaying QRs + # + + def format_qr(self, qr: QRCode) -> str: + f = StringIO() + qr.print_ascii(f) + return f.getvalue() + + def animate_qrs(self, qrs: List[QRCode]) -> None: + ascii_images = [self.format_qr(qr) for qr in qrs] + + while True: + for index, image in enumerate(ascii_images): + clear() + print_formatted_text(ANSI(image)) + print_formatted_text(ANSI("")) + print_formatted_text(ANSI("Hit CTRL-C once finished")) + print_formatted_text(ANSI("")) + + sleep(self.qr_code_sequence_delay_seconds) + + # + # Camera management + # + + def display_camera_image(self, image): + ascii = self.render(image, width=self.width, height_scale=self.height_scale) + clear() + print_formatted_text(ANSI(ascii)) + print_formatted_text(ANSI("")) + print_formatted_text(ANSI("Hit CTRL-C to cancel")) + print_formatted_text(ANSI("")) + + return True + + def render(self, image, width=80, height_scale=0.55, colorize=True): + org_width, orig_height = image.size + aspect_ratio = orig_height / org_width + new_height = aspect_ratio * width * height_scale + img = image.resize((width, int(new_height))) + img = img.convert("RGBA") + img = ImageEnhance.Sharpness(img).enhance(2.0) + pixels = img.getdata() + + def mapto(r, g, b, alpha): + if alpha == 0.0: + return " " + chars = ["B", "S", "#", "&", "@", "$", "%", "*", ":", ".", " "] + pixel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16 + return ANSIColorMap.color(chars[pixel // 25], r, g, b) + + new_pixels = [mapto(r, g, b, alpha) for r, g, b, alpha in pixels] + new_pixels_count = len(new_pixels) + ascii_image = [ + "".join(new_pixels[index : index + width]) + for index in range(0, new_pixels_count, width) + ] + ascii_image = "\n".join(ascii_image) + return ascii_image diff --git a/hermit/display/base.py b/hermit/display/base.py new file mode 100644 index 0000000..a24077f --- /dev/null +++ b/hermit/display/base.py @@ -0,0 +1,74 @@ +from typing import Optional, List +from qrcode import QRCode +from PIL import Image + + +class Display: + """Abstract base class for displays. + + For displaying sequences of animated QR codes, concrete subclasses + should implement the ``animate_qrs`` method. + + For displaying what the camera sees, concrete subclasses should + implement the ``display_camera_image`` method. + + ``setup_camera_display`` and ``teardown_camera_display`` methods + are also available to aid with setup and teardown of resources + associated with the display of the camera. + + """ + + #: Default delay (in milliseconds) between successive QR codes in + #: a sequence. + DEFAULT_QR_CODE_SEQUENCE_DELAY = 200 + + #: Default horizontal position for display. + DEFAULT_X_POSITION = 100 + + #: Default vertical position for display. + DEFAULT_Y_POSITION = 100 + + #: Default width for display. + DEFAULT_WIDTH = 300 + + #: Default height for display. + DEFAULT_HEIGHT = 300 + + def __init__(self, io_config: dict): + self.x_position = int(io_config.get("x_position", self.DEFAULT_X_POSITION)) + self.y_position = int(io_config.get("y_position", self.DEFAULT_Y_POSITION)) + self.width = int(io_config.get("width", self.DEFAULT_WIDTH)) + self.height = int(io_config.get("height", self.DEFAULT_HEIGHT)) + self.qr_code_sequence_delay_ms = int( + io_config.get("qr_code_sequence_delay", self.DEFAULT_QR_CODE_SEQUENCE_DELAY) + ) + self.qr_code_sequence_delay_seconds = self.qr_code_sequence_delay_ms / 1000 + + # + # QR Code Animation + # + + def animate_qrs(self, qrs: List[QRCode]) -> None: + """Display the list of QR codes as an animated sequence.""" + pass + + # + # Camera + # + + def setup_camera_display(self, title: Optional[str] = None): + """Initialize the display for the camera. + + If possible, the given ``title`` should be used to label the + display (e.g. - the window in a graphical user interface). + + """ + pass + + def teardown_camera_display(self): + """Tear down the display for the camera.""" + pass + + def display_camera_image(self, image: Image.Image): + """Display the given ``image`` for the camera.""" + pass diff --git a/hermit/display/framebuffer.py b/hermit/display/framebuffer.py new file mode 100644 index 0000000..7b312b6 --- /dev/null +++ b/hermit/display/framebuffer.py @@ -0,0 +1,117 @@ +from io import BytesIO +from time import sleep +from typing import Optional, List + +from qrcode import QRCode +from PIL import Image +import FBpyGIF.fb as fb + +from ..qr import qr_to_image +from .base import Display + + +def copy_image_from_fb(x, y, w, h): + (mm, fbw, fbh, bpp) = fb.ready_fb() + bytespp = bpp // 8 + s = w * bytespp + + # Allow negative x and y to place image from the right or bottom + if x < 0: + x = fbw + x - w + + if y < 0: + y = fbh + y - h + + b = BytesIO() + for z in range(h): + fb.mmseekto(fb.vx + x, fb.vy + y + z) + b.write(fb.mm.read(s)) + + return Image.frombytes("RGBA", (w, h), b.getvalue()) + + +def write_image_to_fb(x, y, image): + w = image.width + h = image.height + + (mm, fbw, fbh, bpp) = fb.ready_fb() + bytespp = bpp // 8 + + # Allow negative x and y to place image from the right or bottom + if x < 0: + x = fbw + x - w + + if y < 0: + y = fbh + y - h + + bytespp = bpp // 8 + s = w * bytespp + + b = BytesIO(image.convert("RGBA").tobytes("raw", "RGBA")) + + for z in range(h): + fb.mmseekto(fb.vx + x, fb.vy + y + z) + fb.mm.write(b.read(s)) + + +class FrameBufferDisplay(Display): + """Corresponds to display mode ``framebuffer``. + + Displays data by directly writing to the display's framebuffer. + + This allows displaying images within a terminal. + + """ + + #: Override the default horizontal position for frame buffers. + DEFAULT_X_POSITION = -100 + + # + # Displaying QRs + # + + def format_qr(self, qr: QRCode): + return qr_to_image(qr).convert("RGBA") + + def animate_qrs(self, qrs: List[QRCode]) -> None: + images = [self.format_qr(qr) for qr in qrs] + + if len(images) == 0: + return + + saved = copy_image_from_fb( + self.x_position, self.y_position, images[0].width, images[0].height + ) + + finished = False + try: + while not finished: + for image in images: + write_image_to_fb(self.x_position, self.y_position, image) + sleep(self.qr_code_sequence_delay_seconds) + finally: + write_image_to_fb(self.x_position, self.y_position, saved) + + # + # Camera management + # + + def setup_camera_display(self, title: Optional[str] = None): + self.saved = None + + def teardown_camera_display(self): + if self.saved is not None: + write_image_to_fb(self.x_position, self.y_position, self.saved) + self.saved = None + + def display_camera_image(self, image): + if self.saved is None: + self.saved = copy_image_from_fb( + self.x_position, self.y_position, image.width, image.height + ) + + r, g, b = image.split() + bgr = Image.merge("RGB", (b, g, r)) + + write_image_to_fb(self.x_position, self.y_position, bgr) + return True diff --git a/hermit/display/opencv.py b/hermit/display/opencv.py new file mode 100644 index 0000000..61126ec --- /dev/null +++ b/hermit/display/opencv.py @@ -0,0 +1,152 @@ +from typing import Optional, List +import numpy as np +from qrcode import QRCode +from PIL import Image + +try: + import cv2 +except ModuleNotFoundError: + print("ERROR: cv2 library not installed") + +from ..qr import qr_to_image +from .base import Display + + +def window_is_open(window_name: str, delay: Optional[int] = 1) -> bool: + # If we want the deadman counter to continue to count down while the + # window is displayed, we need to run some kind of 'sleep' inside the + # asycio environment. For now, this is commented out as it tends to + # make scanning a large barcode even more frustrating because the + # wallet could autolock in the process of scanning. + + # asyncio.run(asyncio.sleep(1/1000.0)) + + # + # waitKey returns -1 if *no* keys were pressed during the delay. + # If any key was pressed during the delay, it returns the code of + # that key. + # + # As written, this variable is a boolean. + # + + no_keys_pressed_during_delay = cv2.waitKey(delay) == -1 + + # + # On systems which support window properties (e.g. Qt backends + # such as Linux) this is 0 or 1 (actually floats of those for some + # damn reason). + # + # On a Mac, where window properties are not supported, this comes + # out to -1. + # + + window_is_visible_value = cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) + window_is_visible = window_is_visible_value != 0 + + # + # We want both conditions + # + return no_keys_pressed_during_delay and window_is_visible + + +class OpenCVDisplay(Display): + """Corresponds to display mode ``opencv``. + + Uses the `OpenCV `_ library. + + Requires that Hermit is running in a graphical environment. + + """ + + def __init__(self, io_config): + Display.__init__(self, io_config) + self.qr_window_name = "displayqr" + self.camera_window_name = "readqr" + + # + # Displaying QRs + # + + def format_qr(self, qr: QRCode) -> bytes: + return np.array(qr_to_image(qr).convert("RGB"))[:, :, ::-1] + + def animate_qrs(self, qrs: List[QRCode]) -> None: + # Build images to display before we show the window. + images = [self.format_qr(qr) for qr in qrs] + + self.create_window(self.qr_window_name, preserve_ratio=True) + + finished = False + total = len(images) + try: + while not finished: + for index, image in enumerate(images): + cv2.imshow(self.qr_window_name, image) + cv2.setWindowTitle( + self.qr_window_name, f"QR Code {index+1} of {total}" + ) + + # We need to wait at least 1 tick to give OpenCV time to + # process the directions above. + cv2.waitKey(1) + + # Wait for a keypress... + if not window_is_open( + self.qr_window_name, self.qr_code_sequence_delay_ms + ): + finished = True + break + + finally: + self.destroy_window(self.qr_window_name) + + # + # Camera management + # + + def setup_camera_display(self, title: Optional[str] = None): + self.create_window(self.camera_window_name, title=title) + + def teardown_camera_display(self): + self.destroy_window(self.camera_window_name) + + def display_camera_image(self, image: Image.Image): + cvimg = np.array(image) + # RGB to BGR + cvimg = cvimg[:, :, ::-1].copy() + cv2.imshow(self.camera_window_name, cvimg) + return window_is_open(self.camera_window_name, 100) + + # + # Window management + # + + def create_window( + self, + window_name: str, + preserve_ratio: bool = False, + title: Optional[str] = None, + ): + # The flags prevent the inclusion of a toolbar at the top or a + # status bar at the bottom. + cv2.namedWindow(window_name, cv2.WINDOW_NORMAL | cv2.WINDOW_GUI_NORMAL) + + if preserve_ratio: + cv2.setWindowProperty( + window_name, cv2.WND_PROP_ASPECT_RATIO, cv2.WINDOW_KEEPRATIO + ) + + # Resize & move the window + cv2.resizeWindow(window_name, self.width, self.height) + cv2.moveWindow(window_name, self.x_position, self.y_position) + + # Set the window title + if title: + cv2.setWindowTitle(window_name, title) + + def destroy_window(self, window_name: str): + cv2.destroyWindow(window_name) + + # We need to wait at least 1 tick to give OpenCV time to + # process the destroyWindow direction above. + cv2.waitKey(1) diff --git a/hermit/errors.py b/hermit/errors.py index ccb6921..1186143 100644 --- a/hermit/errors.py +++ b/hermit/errors.py @@ -1,16 +1,22 @@ class HermitError(Exception): - """Generic Hermit Error""" + """Base class for all Hermit errors.""" + + pass + + +class InvalidQRCodeSequence(HermitError): + """Base class for all exceptions in parsing a QR code sequence.""" + pass -class InvalidSignatureRequest(HermitError): - """Signature request was not valid""" +class InvalidPSBT(HermitError): + """Raised to indicate a PSBT was invalid.""" + + pass - def __init__(self, message: str) -> None: - """Initialize a new `InvalidSignatureRequest` - :param message: more details on the error. - """ +class InvalidCoordinatorSignature(HermitError): + """Raised to indicate a signature from a coordinator was invalid.""" - HermitError.__init__(self, - "Invalid signature request: {}.".format(message)) + pass diff --git a/hermit/hermit.config.rst b/hermit/hermit.config.rst new file mode 100644 index 0000000..9171cec --- /dev/null +++ b/hermit/hermit.config.rst @@ -0,0 +1,23 @@ +Config +====== + +Hermit reads configuration data from a YAML configuration file at +startup. + +The default location for this file is `/etc/hermit.yaml` (this path +can be changed by setting the `HERMIT_CONFIG` environment variable +before launching the `hermit` program). + +An example configuration file is reproduced below from the +``examples`` directory of the `Hermit source code +`_: + +.. literalinclude:: ../examples/config_files/hermit.example.yml + :language: yaml + +Default configuration is declared in the `HermitConfig` class. +Configuration from the YAML file (if present) is merged on top of the +default configuration and stored in a shared instance of the +`HermitConfig` class: + +.. autoclass:: hermit.config.HermitConfig diff --git a/hermit/hermit.coordinator.rst b/hermit/hermit.coordinator.rst new file mode 100644 index 0000000..ad3e7eb --- /dev/null +++ b/hermit/hermit.coordinator.rst @@ -0,0 +1,7 @@ +Coordinator +=========== + +.. automodule:: hermit.coordinator + :members: validate_coordinator_signature_if_necessary, COORDINATOR_SIGNATURE_KEY + :undoc-members: + :show-inheritance: diff --git a/hermit/hermit.io.rst b/hermit/hermit.io.rst new file mode 100644 index 0000000..0258c0c --- /dev/null +++ b/hermit/hermit.io.rst @@ -0,0 +1,63 @@ +Input & Output +============== + +In addition to text input and output, Hermit requires input and output +of animated sequences of QR codes. + +Hermit has several different modes for both input (camera) and output +(display) of these sequences. + +Cameras +------- + +Hermit defines an abstract ``Camera`` class which defines an API +for capturing QR code images from the device's camera. + +Different camera modes correspond to ``Camera`` subclasses which +provide specific implementations for this API. + +.. autoclass:: hermit.camera.Camera + :members: + +Camera Modes +~~~~~~~~~~~~ + +Hermit's default camera mode (``opencv``) is implemented by the +``OpenCVCamera`` class: + +.. autoclass:: hermit.camera.OpenCVCamera + +The following ``Camera`` subclasses are also provided: + +.. autoclass:: hermit.camera.ImageIOCamera + + +Displays +-------- + +Hermit defines an abstract ``Display`` class which defines an API for +displaying + +1. a sequence of animated QR code images on the device's screen + +2. a real-time image of what the camera is seeing (for aid in scanning + QR codes). + +Different display modes correspond to ``Display`` subclasses which +provide specific implementations for this API. + +.. autoclass:: hermit.display.Display + :members: + +Display Modes +~~~~~~~~~~~~~ + +Hermit's default display mode (``opencv``) is implemented by the +``OpenCVDisplay`` class: + +.. autoclass:: hermit.display.OpenCVDisplay + +The following ``Display`` subclasses are also provided: + +.. autoclass:: hermit.display.ASCIIDisplay +.. autoclass:: hermit.display.FrameBufferDisplay diff --git a/hermit/hermit.keystore.rst b/hermit/hermit.keystore.rst new file mode 100644 index 0000000..2d36356 --- /dev/null +++ b/hermit/hermit.keystore.rst @@ -0,0 +1,33 @@ +Keystore +======== + +Hermit implements.... + +Shards +------ + +.. automodule:: hermit.shards + :members: Shard, ShardSet, ShardWordUserInterface + :undoc-members: + :show-inheritance: + +.. automodule:: hermit.shamir_share + :members: encrypt_shard, decrypt_shard, encrypt_mnemonic, reencrypt_mnemonic, decrypt_mnemonic + :undoc-members: + :show-inheritance: + +Wallet +------ + +.. automodule:: hermit.wallet + :members: HDWallet + :undoc-members: + :show-inheritance: + +Signing +------- + +.. automodule:: hermit.signer + :members: Signer + :undoc-members: + :show-inheritance: diff --git a/hermit/hermit.qr.rst b/hermit/hermit.qr.rst new file mode 100644 index 0000000..b4d876b --- /dev/null +++ b/hermit/hermit.qr.rst @@ -0,0 +1,13 @@ +QR Codes +======== + +Hermit uses Version 1 of the Blockchain Commons UR protocol for +serializing data into a sequence of packets. + +.. autoclass:: hermit.qr.reassemblers.GenericReassembler + :members: + +.. automodule:: hermit.qr.reassemblers + :members: Reassembler, SingleQRCodeReassembler, BCURSingleReassembler, BCURMultiReassembler, SpecterDesktopReassembler + :undoc-members: + :show-inheritance: diff --git a/hermit/hermit.qrcode.rst b/hermit/hermit.qrcode.rst deleted file mode 100644 index 3554697..0000000 --- a/hermit/hermit.qrcode.rst +++ /dev/null @@ -1,46 +0,0 @@ -hermit.qrcode package -===================== - -Submodules ----------- - -hermit.qrcode.displayer module ------------------------------- - -.. automodule:: hermit.qrcode.displayer - :members: - :undoc-members: - :show-inheritance: - -hermit.qrcode.format module ---------------------------- - -.. automodule:: hermit.qrcode.format - :members: - :undoc-members: - :show-inheritance: - -hermit.qrcode.reader module ---------------------------- - -.. automodule:: hermit.qrcode.reader - :members: - :undoc-members: - :show-inheritance: - -hermit.qrcode.utils module --------------------------- - -.. automodule:: hermit.qrcode.utils - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: hermit.qrcode - :members: - :undoc-members: - :show-inheritance: diff --git a/hermit/hermit.rst b/hermit/hermit.rst index 77c9f48..aedf211 100644 --- a/hermit/hermit.rst +++ b/hermit/hermit.rst @@ -1,64 +1,59 @@ -hermit package -============== +Hermit API Documentation +======================== -Subpackages ------------ +This document is intended for developers working on Hermit, extending +Hermit, or integrating Hermit into some other program. For end-user +documentation, see Hermit's `README +`_. -.. toctree:: +Hermit's codebase is split into three sections: - hermit.qrcode - hermit.shards - hermit.signer - hermit.ui - hermit.wordlists +* a collection of functions which together constitute an API for + configuration, input, & output -Submodules ----------- +* classes to model cameras, displays, a bitcoin wallet, and signing + bitcoin transactions -hermit.config module --------------------- +* a UI in the form of a command-line REPL -.. automodule:: hermit.config - :members: - :undoc-members: - :show-inheritance: +The functional API is documented below, while the classes are +documented on their own pages. -hermit.errors module --------------------- +.. toctree:: + :maxdepth: 2 -.. automodule:: hermit.errors - :members: - :undoc-members: - :show-inheritance: + hermit.config + hermit.io + hermit.qr + hermit.keystore + hermit.coordinator -hermit.rng module ------------------ +API +--- -.. automodule:: hermit.rng - :members: - :undoc-members: - :show-inheritance: +The following methods are available in the top-level ``hermit`` +namespace. -hermit.shamir\_share module ---------------------------- +Configuration +~~~~~~~~~~~~~ -.. automodule:: hermit.shamir_share - :members: - :undoc-members: - :show-inheritance: +.. automodule:: hermit.config + :members: get_config + +Input & Output +~~~~~~~~~~~~~~ -hermit.wallet module --------------------- +.. automodule:: hermit.io + :members: get_io, display_data_as_animated_qrs, read_data_from_animated_qrs -.. automodule:: hermit.wallet - :members: - :undoc-members: - :show-inheritance: +QR Codes +~~~~~~~~ -Module contents ---------------- +.. automodule:: hermit.qr + :members: create_qr, create_qr_sequence, qr_to_image, detect_qrs_in_image -.. automodule:: hermit - :members: - :undoc-members: - :show-inheritance: +Randomness +~~~~~~~~~~ + +.. automodule:: hermit.rng + :members: max_entropy_estimate, max_self_entropy, max_kolmogorov_entropy_estimate diff --git a/hermit/hermit.shards.rst b/hermit/hermit.shards.rst deleted file mode 100644 index 6b25976..0000000 --- a/hermit/hermit.shards.rst +++ /dev/null @@ -1,38 +0,0 @@ -hermit.shards package -===================== - -Submodules ----------- - -hermit.shards.interface module ------------------------------- - -.. automodule:: hermit.shards.interface - :members: - :undoc-members: - :show-inheritance: - -hermit.shards.shard module --------------------------- - -.. automodule:: hermit.shards.shard - :members: - :undoc-members: - :show-inheritance: - -hermit.shards.shard\_set module -------------------------------- - -.. automodule:: hermit.shards.shard_set - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: hermit.shards - :members: - :undoc-members: - :show-inheritance: diff --git a/hermit/hermit.signer.rst b/hermit/hermit.signer.rst deleted file mode 100644 index bc966ee..0000000 --- a/hermit/hermit.signer.rst +++ /dev/null @@ -1,38 +0,0 @@ -hermit.signer package -===================== - -Submodules ----------- - -hermit.signer.base module -------------------------- - -.. automodule:: hermit.signer.base - :members: - :undoc-members: - :show-inheritance: - -hermit.signer.bitcoin\_signer module ------------------------------------- - -.. automodule:: hermit.signer.bitcoin_signer - :members: - :undoc-members: - :show-inheritance: - -hermit.signer.echo\_signer module ---------------------------------- - -.. automodule:: hermit.signer.echo_signer - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: hermit.signer - :members: - :undoc-members: - :show-inheritance: diff --git a/hermit/hermit.ui.rst b/hermit/hermit.ui.rst deleted file mode 100644 index 7a0856c..0000000 --- a/hermit/hermit.ui.rst +++ /dev/null @@ -1,86 +0,0 @@ -hermit.ui package -================= - -Submodules ----------- - -hermit.ui.base module ---------------------- - -.. automodule:: hermit.ui.base - :members: - :undoc-members: - :show-inheritance: - -hermit.ui.common module ------------------------ - -.. automodule:: hermit.ui.common - :members: - :undoc-members: - :show-inheritance: - -hermit.ui.main module ---------------------- - -.. automodule:: hermit.ui.main - :members: - :undoc-members: - :show-inheritance: - -hermit.ui.relocker module -------------------------- - -.. automodule:: hermit.ui.relocker - :members: - :undoc-members: - :show-inheritance: - -hermit.ui.repl module ---------------------- - -.. automodule:: hermit.ui.repl - :members: - :undoc-members: - :show-inheritance: - -hermit.ui.shards module ------------------------ - -.. automodule:: hermit.ui.shards - :members: - :undoc-members: - :show-inheritance: - -hermit.ui.state module ----------------------- - -.. automodule:: hermit.ui.state - :members: - :undoc-members: - :show-inheritance: - -hermit.ui.toolbar module ------------------------- - -.. automodule:: hermit.ui.toolbar - :members: - :undoc-members: - :show-inheritance: - -hermit.ui.wallet module ------------------------ - -.. automodule:: hermit.ui.wallet - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: hermit.ui - :members: - :undoc-members: - :show-inheritance: diff --git a/hermit/hermit.wordlists.rst b/hermit/hermit.wordlists.rst deleted file mode 100644 index ca90aa2..0000000 --- a/hermit/hermit.wordlists.rst +++ /dev/null @@ -1,10 +0,0 @@ -hermit.wordlists package -======================== - -Module contents ---------------- - -.. automodule:: hermit.wordlists - :members: - :undoc-members: - :show-inheritance: diff --git a/hermit/io.py b/hermit/io.py new file mode 100644 index 0000000..bbdd00a --- /dev/null +++ b/hermit/io.py @@ -0,0 +1,198 @@ +__doc__ = """Hermit displays animated QR codes on screen and reads animated QR codes through the camera. + +Hermit can use several "modes" for both input and output of QR codes. + +By default, input uses the device's camera and output uses the +device's screen; both use the the `opencv` library. This requires +Hermit is running inside a graphical environment. + +The input mode can be changed to use the ImageIO camera. + +The output mode can be changed to use an ASCII terminal display or a +framebuffer. + +""" + +from os import environ +from typing import Optional + +from prompt_toolkit.shortcuts.progress_bar.base import ProgressBar, ProgressBarCounter + +from .errors import ( + HermitError, +) +from .config import get_config +from .qr import ( + create_qr_sequence, + detect_qrs_in_image, + GenericReassembler, +) + + +def display_data_as_animated_qrs( + data: Optional[str] = None, base64_data: Optional[str] = None +) -> None: + """Display the given data as an animated QR code sequence. + + Uses the currently configured display mode. + + Example usage: :: + + >>> from hermit import display_data_as_animated_qrs + >>> display_data_as_animated_qrs(data="Hello there") + >>> display_data_as_animated_qrs(base64_data="cHNidP8BA...IBkAACAAQAAAAAAAAAA=") + + """ + if environ.get("HERMIT_DISABLE_IO"): + return + io = get_io() + io.display_data_as_animated_qrs(data=data, base64_data=base64_data) + + +def read_data_from_animated_qrs() -> Optional[str]: + """Read data from an animated QR code sequence. + + Uses the currently configured camera & display modes. + + Example usage: :: + + >>> from hermit import display_data_as_animated_qrs + >>> data = read_data_from_animated_qrs() + >>> print(data) + "..." + + """ + if environ.get("HERMIT_DISABLE_IO"): + return None + io = get_io() + return io.read_data_from_animated_qrs() + + +_io = None + + +def get_io() -> "IO": + """Return a globally shared and already loaded instance of :class:`IO`.""" + global _io + + if _io is None: + io_config = get_config().io + _io = IO(io_config) + + return _io + + +class IO: + def __init__(self, io_config): + + camera_mode = io_config.get("camera", "opencv") + if camera_mode == "opencv": + from .camera.opencv import OpenCVCamera + + self.camera = OpenCVCamera() + elif camera_mode == "imageio": + from .camera.imageio import ImageIOCamera + + self.camera = ImageIOCamera() + else: + raise HermitError( + f"Invalid camera mode '{camera_mode}'. Must be either 'opencv' or 'imageio'." + ) + + display_mode = io_config.get("display", "opencv") + if display_mode == "opencv": + from .display.opencv import OpenCVDisplay + + self.display = OpenCVDisplay(io_config) + elif display_mode == "framebuffer": + from .display.framebuffer import FrameBufferDisplay + + self.display = FrameBufferDisplay(io_config) + elif display_mode == "ascii": + from .display.ascii import ASCIIDisplay + + self.display = ASCIIDisplay(io_config) + else: + raise HermitError( + f"Invalid display mode '{display_mode}'. Must be one of 'opencv', 'framebuffer', or 'ascii'." + ) + + def display_data_as_animated_qrs( + self, data: Optional[str] = None, base64_data: Optional[str] = None + ) -> None: + return self.display.animate_qrs( + create_qr_sequence(data=data, base64_data=base64_data) + ) + + def read_data_from_animated_qrs(self, title: Optional[str] = None) -> Optional[str]: + from .ui.repl import check_timer + + if title is None: + title = "Scanning QR Codes..." + + with ProgressBar(title=title) as progress_bar: + try: + self.camera.open() + self.display.setup_camera_display(title) + self.reassembler = GenericReassembler() + + counter = ReassemblerCounter(progress_bar, self) + progress_bar.counters.append(counter) + + for c in counter: + try: + image = self.camera.get_image() + mirror, data = detect_qrs_in_image(image, box_width=20) + + if not self.display.display_camera_image(mirror): + break + + # Iterate through the identified QR codes and let the + # reassembler collect them. + for data_item in data: + check_timer() + if self.reassembler.collect(data_item): + c.advance() + + except HermitError: + # If for some reason we encountered an error in + # scanning the qr code, parsing its contents or + # consolidating it with the overall reresult, + # we do not want to stop processing. + pass + + finally: + self.display.teardown_camera_display() + self.camera.close() + + return self.reassembler.decode() + + +class ReassemblerCounter(ProgressBarCounter): + def __init__(self, progress_bar, io): + ProgressBarCounter.__init__(self, progress_bar, remove_when_done=True) + self.io = io + + def __next__(self): + if self.io.reassembler.is_complete(): + self.done = True + if self in self.progress_bar.counters: + self.progress_bar.counters.remove(self) + raise StopIteration + else: + return self + + def __iter__(self): + return self + + def advance(self): + self.current += 1 + self.progress_bar.invalidate() + + @property + def total(self): + return self.io.reassembler.total + + @total.setter + def total(self, value): + pass diff --git a/hermit/modules.rst b/hermit/modules.rst deleted file mode 100644 index a4e33f1..0000000 --- a/hermit/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -hermit -====== - -.. toctree:: - :maxdepth: 4 - - hermit diff --git a/hermit/plugin.py b/hermit/plugin.py deleted file mode 100644 index a3a6d80..0000000 --- a/hermit/plugin.py +++ /dev/null @@ -1,15 +0,0 @@ -from os import listdir -from os.path import exists, join - -from hermit.config import HermitConfig - -PluginsLoaded = [] - -_config = HermitConfig.load() -if exists(_config.plugin_dir): - for basename in listdir(_config.plugin_dir): - if basename.endswith('.py'): - # FIXME this doesn't feel right - PluginsLoaded.append(basename) - print("Loading plugin {}".format(basename)) - exec(open(join(_config.plugin_dir, basename), 'r').read()) diff --git a/hermit/plugins.py b/hermit/plugins.py new file mode 100644 index 0000000..d4dc3ca --- /dev/null +++ b/hermit/plugins.py @@ -0,0 +1,23 @@ +from os import listdir +from os.path import exists, join + +from .config import get_config + +_PluginsLoaded: frozenset = frozenset() + + +def load_plugins(): + global _PluginsLoaded + _plugin_dir = get_config().paths["plugin_dir"] + plugins_loaded = [] + if exists(_plugin_dir): + for basename in listdir(_plugin_dir): + if basename.endswith(".py"): + print(f"Loading plugin {basename} ...") + exec(open(join(_plugin_dir, basename), "r").read()) + plugins_loaded.append(basename) + _PluginsLoaded = frozenset(plugins_loaded) + + +def plugins_loaded(): + return _PluginsLoaded diff --git a/hermit/qr/__init__.py b/hermit/qr/__init__.py new file mode 100644 index 0000000..8a14a10 --- /dev/null +++ b/hermit/qr/__init__.py @@ -0,0 +1,26 @@ +__doc__ = """Hermit uses animated QR code sequences for input and output of data. + +The functions in this module are used to: + +* create QR code images from data or to parse data from QR code images + +* split up data into pieces for a sequence of animated QR codes as + well as reassemble pieces together into the original data + +The Blockchain Commons UR protocol is used. + +""" + +from .create import ( + create_qr, + create_qr_sequence, + qr_to_image, +) +from .reassemblers import ( + GenericReassembler, + BCURSingleReassembler, + BCURMultiReassembler, + SingleQRCodeReassembler, +) + +from .detect import detect_qrs_in_image diff --git a/hermit/qr/create.py b/hermit/qr/create.py new file mode 100644 index 0000000..309989d --- /dev/null +++ b/hermit/qr/create.py @@ -0,0 +1,82 @@ +from base64 import b64encode +from typing import Optional, List + +from qrcode import QRCode +from qrcode.constants import ERROR_CORRECT_L +from buidl.bcur import BCURMulti + +from ..errors import HermitError + +# FIXME -- get these from config? +VERSION = 12 +BOX_SIZE = 5 +BORDER = 4 + + +def create_qr_sequence( + data: Optional[str] = None, base64_data: Optional[str] = None +) -> List[QRCode]: + """Returns a BCUR Multi QR code sequence for the given `data` (or `base64_data`). + + If `data` is given, it will be UTF8 & base64 encoded first. If + `base64_data` is given, it will UTF8 encoded and used directly. + + If both `data` and `base64_data` are given, then `base64_data` + will be used. + + Example usage: :: + + >>> from hermit import create_qr_sequence + >>> sequence = create_qr_sequence(data="foo bar") + >>> sequence = create_qr_sequence(base64_data="cHNidP8BA...IBkAACAAQAAAAAAAAAA=") + + """ + if base64_data is None: + if data is None: + raise HermitError("Must provide some `data` to create a QR code sequence.") + else: + base64_data = b64encode(data.encode("utf8")).decode("utf8") + return [ + create_qr(ur) for ur in BCURMulti(text_b64=base64_data).encode(animate=True) + ] + + +def create_qr(data: str) -> QRCode: + """Returns a `QRCode` object representing the given `data`. + + In order to be displayed, this `QRCode` object will need to be + further transformed, depending on the display mode. + + Example usage: :: + + >>> from hermit import create_qr + >>> qr = create_qr("foo bar") + + """ + qr = QRCode( + version=VERSION, + error_correction=ERROR_CORRECT_L, + box_size=BOX_SIZE, + border=BORDER, + ) + qr.add_data(bytes(data, "utf-8")) + + # otherwise gifs are of different sizes + qr.make(fit=True) + return qr + + +def qr_to_image(qr: QRCode): + """Turn the given `QRCode` into an image object, of the type returned + by `PIL.Image.open`. + + Example usage: :: + + >>> from hermit import create_qr, qr_to_image + >>> qr = create_qr("foo bar") + >>> image = qr_to_image(qr) + >>> image.save("/tmp/qr.jpg") + + """ + # FIXME -- get colors from config? + return qr.make_image(fill_color="black", back_color="white") diff --git a/hermit/qr/detect.py b/hermit/qr/detect.py new file mode 100644 index 0000000..be2cb8a --- /dev/null +++ b/hermit/qr/detect.py @@ -0,0 +1,28 @@ +from pyzbar import pyzbar +from PIL import ImageOps +from PIL.ImageDraw import ImageDraw + + +def detect_qrs_in_image(image, box_width=2): + """ + Return frame, [data_str1, data_str2, ...] + + The returned frame is the original frame with a green box + drawn around each of the identified QR codes. + """ + barcodes = pyzbar.decode(image) + + annotate = ImageDraw(image) + + # Mark the qr codes in the image + for barcode in barcodes: + x, y, w, h = barcode.rect + annotate.rectangle([(x, y), (x + w, y + h)], outline="#00FF00", width=box_width) + + # Return the mirror image of the annoted original + mirror = ImageOps.mirror(image) + + # Extract qr code data + results = [barcode.data.decode("utf-8").strip() for barcode in barcodes] + + return mirror, results diff --git a/hermit/qr/reassemblers.py b/hermit/qr/reassemblers.py new file mode 100644 index 0000000..e910c67 --- /dev/null +++ b/hermit/qr/reassemblers.py @@ -0,0 +1,228 @@ +from base64 import b64decode +from typing import Optional, Tuple +import re + +from buidl.bcur import BCURMulti, BCURSingle + +from ..errors import InvalidQRCodeSequence + + +class Reassembler: + """Base class for QR code sequence reassemblers. + + Generalizes the process of reassembling N QR codes' payloads that + may arive out of sequence. + + """ + + RE = re.compile("^.*$", re.MULTILINE) + + def __init__(self): + self.total_items = None + self.segments = None + self.data = None + + @classmethod + def match_data(cls, data: str): + """Tells whether or not the QR payload data matches the regular + expression defining a particular type of QR encoding. + + """ + return cls.RE.match(data) + + def collect(self, data: str) -> bool: + """Collect the given data. + + Will validate the data looks like it's part of the sequence + being reassembled. + + Returns a tuple with the total number of data items in the + sequence and the number collected so far. + + If the item cannot be collected, returns a tuple `(None, + None)`. + + """ + match = self.match_data(data) + if not match: + raise InvalidQRCodeSequence("Data does not match QR code type.") + + total, index, segment = self._get_total_index_segment(match, data) + + return self._store_item(total, index, segment) + + def is_complete(self) -> bool: + return self.total_items is not None and self.total_items == self.segments + + @property + def total(self) -> int: + return self.total_items + + def decode(self) -> Optional[str]: + """Assumbles all of the QR data segments into a the final + payload.""" + + if not self.is_complete(): + raise InvalidQRCodeSequence("Barcode value not complete.") + + return self._decode() + + def _get_total_index_segment(self, match, data: str) -> Tuple[int, int, str]: + raise NotImplementedError( + f"Implement the `_get_total_index_segment` method in {type(self).__name__}" + ) + + def _store_item(self, total: int, index: int, segment: str) -> bool: + """Given the data from the regex match, store information + about the current data item + + Returns a tuple with the total number of data items in the + sequence and the number collected so far. + + If the item cannot be collected, returns a tuple `(None, + None)`. + + """ + if self.total_items is None: + self.total_items = total + self.data = [None] * self.total + self.segments = 0 + + if self.total_items != total: + raise InvalidQRCodeSequence("Mismatched QR sequence.") + + if self.data[index] is None: + self.data[index] = segment + self.segments += 1 + return True + + return False + + def _decode(self) -> Optional[str]: + """Do whatever implementation specific task needed to decode + the multi segment payload data.""" + raise NotImplementedError( + f"Implement the `_decode` method in {type(self).__name__}" + ) + + +class SingleQRCodeReassembler(Reassembler): + """Reassembles data from a single QR code. + + Just grabs whatever data is in the first QR code that we see and + calls it complete. + + """ + + RE = re.compile("^.*$", re.MULTILINE) + TYPE = "QR" + + def _get_total_index_segment(self, match, data: str) -> Tuple[int, int, str]: + return 1, 0, data + + def _decode(self) -> Optional[str]: + return self.data[0] + + +class BCURSingleReassembler(Reassembler): + """Reassembles data from BCUR single QR codes.""" + + RE = re.compile("^ur:bytes/[^/]+/[^/]+$", re.IGNORECASE) + TYPE = "BCUR" + + def _get_total_index_segment(self, match, data) -> Tuple[int, int, str]: + return 1, 0, data + + def _decode(self) -> Optional[str]: + base64_text = BCURSingle.parse(self.data[0]).text_b64 + plain_bytes = b64decode(base64_text) + try: + return plain_bytes.decode("utf8") + except UnicodeDecodeError: + return base64_text + + +class BCURMultiReassembler(Reassembler): + """Reassembles data from BCUR QR code sequences.""" + + RE = re.compile("^ur:bytes/([0-9]+)of([0-9]+)/[^/]+/[^/]+$", re.IGNORECASE) + TYPE = "BCUR*" + + def _get_total_index_segment(self, match, data) -> Tuple[int, int, str]: + return int(match[2]), int(match[1]) - 1, data + + def _decode(self) -> Optional[str]: + # FIXME something strange happening here... + base64_text = BCURMulti.parse(self.data).text_b64 + plain_bytes = b64decode(base64_text) + try: + return plain_bytes.decode("utf8") + except UnicodeDecodeError: + return base64_text + + +class SpecterDesktopReassembler(Reassembler): + """Reassembles data from Specter Desktop QR code sequences.""" + + RE = re.compile("^p([0-9]+)of([0-9]+) (.+)$") + TYPE = "Specter" + + def _get_total_index_segment(self, match, data) -> Tuple[int, int, str]: + return int(match[2]), int(match[1]) - 1, data + + def _decode(self) -> Optional[str]: + return "".join(self.data) + + +class GenericReassembler: + """Reassembles data split into a sequence of QR codes. + + The first payload is used to classify the nature of the QR code + sequence (see :attr:`QRTYPES`). + + There are numerous scenarios where mixing up QR code sequence + types will lead to both detectable and undedectable errors. + + """ + + #: Classes defining QR code sequence types this reassembler + #: understands. + #: + #: See the corresponding class for more information. + REASSEMBLERS = [ + BCURSingleReassembler, + BCURMultiReassembler, + SpecterDesktopReassembler, # Used for both psbts and accountmaps + SingleQRCodeReassembler, # This should always be at the end, because it always matches. + ] + + def __init__(self): + self.reassembler = None + + @property + def total(self) -> str: + return self.reassembler and self.reassembler.total + + @property + def type(self): + return self.reassembler and self.reassembler.TYPE + + def collect(self, data: str) -> bool: + if self.reassembler is None: + for cls in self.REASSEMBLERS: + if cls.match_data(data): + self.reassembler = cls() + break + if self.reassembler is None: + raise InvalidQRCodeSequence("Unrecognized QR code format.") + + return self.reassembler.collect(data) + + def is_complete(self): + return self.reassembler is not None and self.reassembler.is_complete() + + def decode(self) -> Optional[str]: + if self.reassembler is None: + return None + else: + return self.reassembler.decode() diff --git a/hermit/qrcode/__init__.py b/hermit/qrcode/__init__.py deleted file mode 100644 index 426d922..0000000 --- a/hermit/qrcode/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .reader import read_qr_code -from .displayer import display_qr_code, create_qr_code_image -from .format import decode_qr_code_data, encode_qr_code_data diff --git a/hermit/qrcode/displayer.py b/hermit/qrcode/displayer.py deleted file mode 100644 index a19619e..0000000 --- a/hermit/qrcode/displayer.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio - -import cv2 -import numpy as np -import qrcode -from qrcode.image.pil import PilImage - -from .format import encode_qr_code_data -from .utils import window_is_open - - -def display_qr_code(data: str, name: str = "Preview") -> asyncio.Task: - task = _display_qr_code_async(data, name) - return asyncio.get_event_loop().create_task(task) - - -async def _display_qr_code_async(data: str, name: str = "Preview") -> None: - image = create_qr_code_image(data) - - cv2.namedWindow(name) - cv2.imshow(name, np.array(image.convert('RGB'))[:, :, ::-1].copy()) - - while window_is_open(name): - await asyncio.sleep(0.01) - - cv2.destroyWindow(name) - - -def create_qr_code_image(data: str) -> PilImage: - - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(encode_qr_code_data(data)) - qr.make(fit=True) - return qr.make_image(fill_color="black", back_color="white") diff --git a/hermit/qrcode/format.py b/hermit/qrcode/format.py deleted file mode 100644 index 27f6154..0000000 --- a/hermit/qrcode/format.py +++ /dev/null @@ -1,46 +0,0 @@ -from base64 import b32decode, b32encode -from gzip import decompress, compress -from binascii import Error as Base32DecodeError - - -from hermit.errors import InvalidSignatureRequest - - -def decode_qr_code_data(encoded: bytes) -> str: - if not isinstance(encoded, (bytes,)): - raise InvalidSignatureRequest("Can only decode bytes") - if encoded == b'': - raise InvalidSignatureRequest("Cannot decode empty bytes") - try: - compressed_bytes = b32decode(encoded) - try: - decompressed_bytes = decompress(compressed_bytes) - try: - data = decompressed_bytes.decode('utf-8') - return data - except UnicodeError: - raise InvalidSignatureRequest("Not valid UTF-8") - except OSError: - raise InvalidSignatureRequest("Not gzipped") - except (TypeError, Base32DecodeError): - raise InvalidSignatureRequest("Not Base32") - - -def encode_qr_code_data(decoded: str) -> bytes: - if not isinstance(decoded, (str,)): - raise InvalidSignatureRequest("Can only encode strings") - if decoded.strip() == '': - raise InvalidSignatureRequest("Cannot encode empty string") - try: - uncompressed_bytes = decoded.encode('utf-8') - try: - compressed_bytes = compress(uncompressed_bytes) - try: - data = b32encode(compressed_bytes) - return data - except TypeError: - raise InvalidSignatureRequest("Failed to Base32-encode") - except OSError: - raise InvalidSignatureRequest("Failed to gzip") - except UnicodeError: - raise InvalidSignatureRequest("Failed to encode as UTF-8") diff --git a/hermit/qrcode/reader.py b/hermit/qrcode/reader.py deleted file mode 100644 index 2ef627f..0000000 --- a/hermit/qrcode/reader.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import Optional - - -from pyzbar import pyzbar -import asyncio -import cv2 - - -from hermit.errors import InvalidSignatureRequest -from .format import decode_qr_code_data -from .utils import window_is_open - - -def read_qr_code() -> Optional[str]: - task = _capture_qr_code_async() - return asyncio.get_event_loop().run_until_complete(task) - - -async def _capture_qr_code_async() -> Optional[str]: - capture = _start_camera() - preview_dimensions = (640, 480) - decoded_data = None - encoded_data = None - window_name = "Signature Request QR Code Scanner" - cv2.namedWindow(window_name) - - while window_is_open(window_name): - ret, frame = capture.read() - frame = cv2.resize(frame, preview_dimensions) - - for qrcode in pyzbar.decode(frame): - # Extract the position & dimensions of box bounding the QR - # code - (x, y, w, h) = qrcode.rect - # Draw this bounding box on the image - cv2.rectangle( - frame, - (x, y), - (x + w, y + h), - (0, 0, 255), - 2) - - # Decode the QR code data - encoded_data = qrcode.data - try: - decoded_data = decode_qr_code_data(encoded_data) - except InvalidSignatureRequest as e: - print("Invalid signature request: {}".format(str(e))) - - # Use the first QR code found - if decoded_data is not None: - break - - # Preview the (reversed) frame - mirror = cv2.flip(frame, 1) - cv2.imshow(window_name, mirror) - - # Break out of the loop if we found a valid QR code - if decoded_data: - break - - await asyncio.sleep(0.01) - - # Clean up windows before exiting. - capture.release() - cv2.destroyWindow(window_name) - - return decoded_data - - -def _start_camera() -> cv2.VideoCapture: - capture = cv2.VideoCapture(0) - if not capture.isOpened(): - raise IOError("Cannot open webcam") - return capture diff --git a/hermit/qrcode/utils.py b/hermit/qrcode/utils.py deleted file mode 100644 index f6782d2..0000000 --- a/hermit/qrcode/utils.py +++ /dev/null @@ -1,6 +0,0 @@ -import cv2 - - -def window_is_open(window_name): - return (cv2.waitKey(1) and - cv2.getWindowProperty(window_name, cv2.WND_PROP_VISIBLE) == 1.0) diff --git a/hermit/rng.py b/hermit/rng.py index b285e35..a2c539d 100644 --- a/hermit/rng.py +++ b/hermit/rng.py @@ -4,39 +4,82 @@ from prompt_toolkit import prompt, print_formatted_text, HTML from typing import List -def self_entropy(input: str) -> float: - """Measure the self-entropy of a given string - Models the string as produced by a Markov process. See - https://en.wikipedia.org/wiki/Entropy_(information_theory)#Data_as_a_Markov_process +def max_entropy_estimate(input: str) -> float: + """Estimate the entropy of the given string. + + Works conservatively, by choosing the minimum of the self-entropy + and Kolmogorov entropy. + + Example usage: :: + + >>> from hermit import max_entropy_estimate + >>> max_entropy_estimate("abcd") + 8.0 + + """ + return min(max_self_entropy(input), max_kolmogorov_entropy_estimate(input)) + + +def max_self_entropy(input: str) -> float: + """Measure the maximum self-entropy of a given string (in bits). + + Models the string as produced by a `Markov process + `_ + which means the self-entropy has the following properties: + + 1) it is proportional to the length of the string + 2) it is independent of the order of the characters in the string + + Because of (2), it is an upper bound on entropy. + + Example usage: :: + + >>> from hermit import max_self_entropy + >>> max_self_entropy("abcd") + 8.0 """ - inputBytes = input.encode('utf8') - counts = [0]*256 + inputBytes = input.encode("utf8") + counts = [0] * 256 for byte in inputBytes: counts[byte] += 1 total = len(inputBytes) entropy_per_byte = 0.0 - for c in counts: - if c != 0: - entropy_per_byte += (c / total) * math.log(total / c, 2) - return entropy_per_byte * total + for count in counts: + # We can ignore counts of zero since they don't contribute + # any entropy (probability = 0 and the product below + # vanishes). + if count != 0: + probability = float(count) / float(total) + # Taking the log base 2 is what gets us bits. + log2_probability = math.log(probability, 2) + entropy_per_byte += probability * log2_probability + # Log probabilities are negative, so we return the absolute value + # of the result. (We could also have inverted the ratio of the + # value we took the log_2 base of, but this is easier for most + # people to understand IMO). + return abs(entropy_per_byte * total) -def compression_entropy(input: str) -> float: - """Measure the compression entropy of a given string +def max_kolmogorov_entropy_estimate(input: str) -> float: + """Estimates the Kolmogorov entropy of the given string (in bits). - Compresses the string with the zlib algorithm. - """ - return 8 * len(zlib.compress(input.encode('utf-8'), 9)) + Uses the zlib compression algorithm to estimate the total + information in the string. + + This is an upper bound because there certainly exist other + algorithms which could better compress the given string. + Example usage: :: -def entropy(input: str) -> float: - """Return the most conservative measure of entropy for the given string + >>> from hermit import max_kolmogorov_entropy_estimate + >>> max_kolmogorov_entropy_estimate("abcd") + 96.0 - Returns the minimum of the self-entropy and compression entropy. """ - return min(self_entropy(input), compression_entropy(input)) + # Multiply by 8 to turn number of bytes into bits. + return float(8 * len(zlib.compress(input.encode("utf8"), 9))) def enter_randomness(chunks: int) -> bytes: @@ -45,11 +88,17 @@ def enter_randomness(chunks: int) -> bytes: The total number of bits of randomness is equal to `chunks * 256`. """ - print_formatted_text(HTML(""" + print_formatted_text( + HTML( + """ Enter at least {} bits worth of random data. Hit CTRL-D when done. -""".format(chunks * 256))) +""".format( + chunks * 256 + ) + ) + ) lines: List = [] input_entropy = 0.0 @@ -59,18 +108,16 @@ def enter_randomness(chunks: int) -> bytes: while True: try: - prompt_msg = ( - "Collected {0:5.1f} bits>:" - .format(input_entropy)) + prompt_msg = "Collected {0:5.1f} bits>:".format(input_entropy) line = prompt(prompt_msg).strip() except EOFError: break lines += line - input_entropy = entropy(''.join(lines)) + input_entropy = max_entropy_estimate("".join(lines)) if input_entropy > target: - output += hashlib.sha256(''.join(lines).encode('utf-8')).digest() + output += hashlib.sha256("".join(lines).encode("utf-8")).digest() target += 256 return output @@ -85,7 +132,7 @@ class RandomGenerator: """ def __init__(self) -> None: - self.bytes = b'' + self.bytes = b"" self.size = 0 self.active = True @@ -96,7 +143,7 @@ def get_more(self, count) -> None: self.size += len(more) def random(self, size: int) -> bytes: - while(self.size < size): + while self.size < size: self.get_more(size - self.size) out = self.bytes[:size] diff --git a/hermit/shamir_share.py b/hermit/shamir_share.py index f35f6ea..ca512be 100644 --- a/hermit/shamir_share.py +++ b/hermit/shamir_share.py @@ -1,20 +1,28 @@ - # This file was originally substantially copied from the reference implementation for # slip-0039, now it imports that functionality from the pypi module shamir_mnemonic. # # The two main differences are that it provides a means to encrypt the # shard data for each shard, and it exposes a mechanism to export -# shards as byte arrays for efficient storage. +# shards as byte arrays for efficient storage. # # -import hashlib -import hmac -import os - import shamir_mnemonic -from shamir_mnemonic import * -from shamir_mnemonic import _encrypt, _decrypt, _int_from_indices, _int_to_indices +from shamir_mnemonic import ( + _encrypt, + _decrypt, + _int_from_indices, + _int_to_indices, + mnemonic_to_indices, + mnemonic_from_indices, + RADIX_BITS, + bits_to_bytes, + encode_mnemonic, + decode_mnemonic, +) + +# unused but later imported in shard_set.py (FIXME: very hackey!) +from shamir_mnemonic import combine_mnemonics, generate_mnemonics # type: ignore # noqa: F401 # shamir_mnemonic expects us to update its local copy of RANDOM_BYTES in order # to override the random number generator. Instead of exposing this complication @@ -22,57 +30,106 @@ # generator as set_random_bytes. old_rngs = [] + + def set_random_bytes(rng): old_rngs.append(shamir_mnemonic.RANDOM_BYTES) shamir_mnemonic.RANDOM_BYTES = rng -def restore_random_bytes(): - if(length(old_rngs)>0): - shamir_mnemonic.RANDOM_BYTES = old_rngs.pop() def mnemonic_from_bytes(bytes_data): """ Converts a 32-byte array into mnemonics. """ - return mnemonic_from_indices(_int_to_indices(int.from_bytes(bytes_data,'big'), (8*len(bytes_data)) // RADIX_BITS, RADIX_BITS)) + return mnemonic_from_indices( + _int_to_indices( + int.from_bytes(bytes_data, "big"), + (8 * len(bytes_data)) // RADIX_BITS, + RADIX_BITS, + ) + ) def mnemonic_to_bytes(mnemonic): """ Converts a mnemonic into a 32-byte array. """ - wordlength = len(mnemonic.split(' ')) - return _int_from_indices(mnemonic_to_indices(mnemonic)).to_bytes(bits_to_bytes(RADIX_BITS*wordlength) ,'big') + wordlength = len(mnemonic.split(" ")) + return _int_from_indices(mnemonic_to_indices(mnemonic)).to_bytes( + bits_to_bytes(RADIX_BITS * wordlength), "big" + ) + def encrypt_shard(passphrase, unencrypted_shard): - (identifier, iteration_exponent, group_index, group_threshold, groups, member_index, member_threshold, value) = unencrypted_shard + ( + identifier, + iteration_exponent, + group_index, + group_threshold, + groups, + member_index, + member_threshold, + value, + ) = unencrypted_shard encrypted_value = value # If there was not passphrase given, do not actually encrypt anything if passphrase is not None: encrypted_value = _encrypt(value, passphrase, iteration_exponent, identifier) - return (identifier, iteration_exponent, group_index, group_threshold, groups, member_index, member_threshold, encrypted_value) + return ( + identifier, + iteration_exponent, + group_index, + group_threshold, + groups, + member_index, + member_threshold, + encrypted_value, + ) + def decrypt_shard(passphrase, encrypted_shard): - (identifier, iteration_exponent, group_index, group_threshold, groups, member_index, member_threshold, encrypted_value) = encrypted_shard + ( + identifier, + iteration_exponent, + group_index, + group_threshold, + groups, + member_index, + member_threshold, + encrypted_value, + ) = encrypted_shard decrypted_value = encrypted_value # If not passphrase was given, do not actually decrypt anything if passphrase is not None: - decrypted_value = _decrypt(encrypted_value, passphrase, iteration_exponent, identifier) - return (identifier, iteration_exponent, group_index, group_threshold, groups, member_index, member_threshold, decrypted_value) + decrypted_value = _decrypt( + encrypted_value, passphrase, iteration_exponent, identifier + ) + return ( + identifier, + iteration_exponent, + group_index, + group_threshold, + groups, + member_index, + member_threshold, + decrypted_value, + ) + def decrypt_mnemonic(mnemonic, passphrase): decoded = decode_mnemonic(mnemonic) decrypted = decrypt_shard(passphrase, decoded) return encode_mnemonic(*decrypted) + def reencrypt_mnemonic(mnemonic, oldpassphrase, newpassphrase): decoded = decode_mnemonic(mnemonic) decrypted = decrypt_shard(oldpassphrase, decoded) encrypted = encrypt_shard(newpassphrase, decrypted) return encode_mnemonic(*encrypted) + def encrypt_mnemonic(mnemonic, passphrase): decoded = decode_mnemonic(mnemonic) encrypted = encrypt_shard(passphrase, decoded) return encode_mnemonic(*encrypted) - diff --git a/hermit/shards/interface.py b/hermit/shards/interface.py index b4eb8cd..6a4e24a 100644 --- a/hermit/shards/interface.py +++ b/hermit/shards/interface.py @@ -12,6 +12,7 @@ class ShardWordUserInterface(object): This class represents all of the interactions that the shard classes need to have with the user. """ + YesNoCompleter = WordCompleter(["no", "yes"]) WalletWordCompleter = WordCompleter(WalletWords) ShardWordCompleter = WordCompleter(ShardWords) @@ -24,21 +25,20 @@ def get_line_then_clear(self) -> None: prompt(HTML("Hit ENTER to continue...\n")) shortcuts.clear() - def get_password(self, name: str) -> bytes: + def get_password(self, name: str) -> Optional[bytes]: print_formatted_text(HTML("\nEnter password for shard {}".format(name))) - pass_msg = "password> ".format(name) - password = prompt(pass_msg, is_password=True).strip().encode('ascii') + pass_msg = "password> " + password = prompt(pass_msg, is_password=True).strip().encode("ascii") # Empty string means do not encrypt with a password if len(password) == 0: return None - + return password - - def confirm_password(self) -> bytes: - password = prompt("new password> ", - is_password=True).strip().encode('ascii') - confirm = prompt(" confirm> ", is_password=True).strip().encode('ascii') + + def confirm_password(self) -> Optional[bytes]: + password = prompt("new password> ", is_password=True).strip().encode("ascii") + confirm = prompt(" confirm> ", is_password=True).strip().encode("ascii") if password == confirm: # Empty string means do not encrypt @@ -48,42 +48,54 @@ def confirm_password(self) -> bytes: raise HermitError("Passwords do not match.") - def get_change_password(self, name: str) -> Tuple[bytes, bytes]: + def get_change_password(self, name: str) -> Tuple[Optional[bytes], Optional[bytes]]: # promt_toolkit's 'is_password' option # replaces input with '*' characters # while getpass echos nothing. print_formatted_text("\nChange password for shard {}".format(name)) - old_password: bytes = prompt( - "old password> ", is_password=True).strip().encode('ascii') + old_password: Optional[bytes] = ( + prompt("old password> ", is_password=True).strip().encode("ascii") + ) new_password = self.confirm_password() - # Empty string means do not encrypt - if len(old_password) == 0: + # Empty string means do not encrypt + if old_password is not None and len(old_password) == 0: old_password = None - if len(new_password) == 0: + if new_password is not None and len(new_password) == 0: new_password = None return (old_password, new_password) def get_name_for_shard( - self, share_id, group_index, group_threshold, groups, - member_index, member_threshold, shards - ): + self, + share_id, + group_index, + group_threshold, + groups, + member_index, + member_threshold, + shards, + ): print_formatted_text("") - print_formatted_text("Family: {}, Group: {}, Shard: {}".format(share_id, group_index + 1, member_index + 1)) + print_formatted_text( + "Family: {}, Group: {}, Shard: {}".format( + share_id, group_index + 1, member_index + 1 + ) + ) while True: - name = prompt('Enter name: ', **self.options).strip() + name = prompt("Enter name: ", **self.options).strip() if name not in shards: return name - print_formatted_text("Sorry, but a shard with that name already exists. Try again.") + print_formatted_text( + "Sorry, but a shard with that name already exists. Try again." + ) - def choose_shard(self, - shards) -> Optional[str]: + def choose_shard(self, shards) -> Optional[str]: if len(shards) == 0: raise HermitError("Not enough shards to reconstruct secret.") @@ -96,37 +108,51 @@ def choose_shard(self, while True: prompt_string = "Choose shard\n(options: {} or to quit)\n> " prompt_msg = prompt_string.format(", ".join(shardnames)) - shard_name = prompt(prompt_msg, - completer=shardCompleter, - **self.options).strip() + shard_name = prompt( + prompt_msg, completer=shardCompleter, **self.options + ).strip() if shard_name in shardnames: return shard_name - if shard_name == '': + if shard_name == "": return None print("Shard not found.") def confirm_delete_shard(self, shard_name: str) -> bool: - return prompt("Really delete shard {0}? ".format(shard_name), - completer=self.YesNoCompleter) == "yes" + return ( + prompt( + "Really delete shard {0}? ".format(shard_name), + completer=self.YesNoCompleter, + ) + == "yes" + ) def confirm_initialize_file(self) -> bool: - return prompt("Really initialize the shard file? ", - completer=self.YesNoCompleter) == "yes" + return ( + prompt("Really initialize the shard file? ", completer=self.YesNoCompleter) + == "yes" + ) def choose_shard_name(self, number: int) -> str: prompt_msg = "\nEnter name for shard {0}: ".format(number) return prompt(prompt_msg, **self.options).strip() - def enter_shard_words(self, name: str) -> str: - print(("\nEnter SLIP39 phrase for shard {} below (CTRL-D to submit):".format(name))) + def enter_shard_words(self, name: str, prompt_line: str = None) -> str: + + if prompt_line is None: + prompt_line = ( + f"\nEnter SLIP39 phrase for shard {name} below (CTRL-D to submit):" + ) + + print(prompt_line) lines: List = [] while True: try: - line = prompt("", completer=self.ShardWordCompleter, - **self.options).strip() + line = prompt( + "", completer=self.ShardWordCompleter, **self.options + ).strip() except EOFError: break # Test against wordlist @@ -136,18 +162,24 @@ def enter_shard_words(self, name: str) -> str: else: for word in words: if word not in ShardWords: - print(("{} is not a valid shard word, " - + "ignoring last line").format(word)) + print( + ( + "{} is not a valid shard word, " + "ignoring last line" + ).format(word) + ) shortcuts.clear() - return ' '.join(lines) + return " ".join(lines) - def enter_wallet_words(self) -> str: - print("\nEnter BIP39 phrase for wallet below (CTRL-D to submit): ") + def enter_wallet_words(self, prompt_string: Optional[str] = None) -> str: + if prompt_string is None: + prompt_string = "\nEnter BIP39 phrase for wallet below (CTRL-D to submit): " + print_formatted_text(prompt_string) lines: List = [] while True: try: - line = prompt("", completer=self.WalletWordCompleter, - **self.options).strip() + line = prompt( + "", completer=self.WalletWordCompleter, **self.options + ).strip() except EOFError: break @@ -157,39 +189,78 @@ def enter_wallet_words(self) -> str: else: for word in words: if word not in ShardWords: - print(("{} is not a valid wallet word, " - + "ignoring last line").format(word)) + print( + ( + "{} is not a valid wallet word, " + "ignoring last line" + ).format(word) + ) shortcuts.clear() - return ' '.join(lines) + return " ".join(lines) - def enter_group_information(self) -> Tuple[int, List[Tuple[int,int]]]: - print_formatted_text(HTML("""SLIP39 sharding has two levels. + def enter_group_information(self) -> Tuple[int, List[Tuple[int, int]]]: + print_formatted_text( + HTML( + """SLIP39 sharding has two levels. -At the upper level you specify Q groups, P of which are required to -unlock the wallet (P of Q groups). -""")) - group_threshold = int( - prompt(HTML("How many groups should be required to unlock the wallet (P)? "), completer=self.SmallNumberCompleter)) - groups : List[Tuple[int,int]] = [] +At the upper level you specify Q groups, P of which are required to unlock +the wallet (P of Q groups). - print_formatted_text(HTML(""" -Each of the Q groups is itself broken into m shards, n of which are -required to unlock the group (n of m shards). +At the lower level, each of the Q groups is itself broken into n shards, +m of which are required to unlock the group (m of n shards). -Unlocking the wallet requires unlocking P groups and unlocking each -group requires unlocking n shards for that group. +Unlocking the shard family requires unlocking P groups and unlocking each +of those P groups requires unlocking m shards for that group. -You must now specify a shard configuration (such as '2 of 3') -for each of the Q groups. - -Hit Ctrl-D or enter an empty line once you have entered -shard configurations for all Q groups. -""")) - input_error_message = HTML("Please enter a shard configuration in the form 'n of m' where n and m are small integers.") +The shard configurations m and n can be different for each of the Q groups. +""" + ) + ) + input_error_message = HTML( + "Required number of groups P must be a small positive integer." + ) + while True: + try: + group_threshold = int( + prompt( + HTML( + "How many groups are required to unlock (P)? " + ), + completer=self.SmallNumberCompleter, + ) + ) + assert group_threshold >= 1 + break + except (EOFError, ValueError, AssertionError): + print_formatted_text(input_error_message) + continue + + groups: List[Tuple[int, int]] = [] + + print_formatted_text( + HTML( + """ +You must now specify an m of n shard configuration (such as '2 of 3') +for each of the Q groups. You will enter each group's configuration +on its own line. + +Hit CTRL-D or ENTER once you have entered shard configurations for +all Q groups. +""" + ) + ) + input_error_message = HTML( + "Shard configuration must be in the form 'm of n' where m and n are small positive integers and 1≤m≤n." + ) while True: try: group_str = prompt( - HTML("What shard configuration should be used for Group {}? ".format(len(groups) + 1)), completer=self.SmallNumberCompleter) + HTML( + "What is m of n for Group {}? ".format( + len(groups) + 1 + ) + ), + completer=self.SmallNumberCompleter, + ) except EOFError: if group_threshold > len(groups): print_formatted_text(input_error_message) @@ -197,9 +268,9 @@ def enter_group_information(self) -> Tuple[int, List[Tuple[int,int]]]: else: break - if group_str == '' and len(groups) >= group_threshold: + if group_str == "" and len(groups) >= group_threshold: break - match = re.match(r'^\s*(\d+)\s*of\s*(\d+)', group_str) + match = re.match(r"^\s*(\d+)\s*of\s*(\d+)", group_str) if not match: print_formatted_text(input_error_message) else: @@ -207,9 +278,12 @@ def enter_group_information(self) -> Tuple[int, List[Tuple[int,int]]]: n = int(n) m = int(m) - if(n > m): - print_formatted_text(HTML( - "The number of required shards (n) must not be larger than the total number of shards (m)")) + if n > m: + print_formatted_text( + HTML( + "The number of required shards (n) must not be larger than the total number of shards (m)" + ) + ) else: groups.append((n, m)) diff --git a/hermit/shards/shard.py b/hermit/shards/shard.py index 42ace03..f646778 100644 --- a/hermit/shards/shard.py +++ b/hermit/shards/shard.py @@ -3,10 +3,9 @@ from hermit import shamir_share from .interface import ShardWordUserInterface -class Shard(object): - """Represents a single Shamir shard. - """ +class Shard(object): + """Represents a single Shamir shard.""" @property def encrypted_mnemonic(self): @@ -65,11 +64,12 @@ def group_threshold(self): self._unpack_share() return self._group_threshold - def __init__(self, - name: str, - encrypted_mnemonic: Optional[str], - interface: ShardWordUserInterface = None, - ) -> None: + def __init__( + self, + name: str, + encrypted_mnemonic: Optional[str], + interface: ShardWordUserInterface = None, + ) -> None: """Creates a WalletWordsShard instance :param name: the name of the shard @@ -94,30 +94,31 @@ def __init__(self, else: self.interface = interface - def input(self) -> None: + def input(self, prompt=None) -> None: """Input this shard's data from a SLIP39 phrase""" - words = self.interface.enter_shard_words(self.name) + words = self.interface.enter_shard_words(self.name, prompt) shamir_share.decode_mnemonic(words) self.encrypted_mnemonic = words def words(self) -> List[str]: """Returns the (decrypted) SLIP39 phrase for this shard""" - return shamir_share.decrypt_mnemonic(self.encrypted_mnemonic, self._get_password()) + return shamir_share.decrypt_mnemonic( + self.encrypted_mnemonic, self._get_password() + ) def change_password(self): """Decrypt and re-encrypt this shard with a new password""" old_password, new_password = self._get_change_password() self.encrypted_mnemonic = shamir_share.reencrypt_mnemonic( - self.encrypted_mnemonic, old_password, new_password) - self.encrypted_shard = shamir_share.decode_mnemonic( - self.encrypted_mnemonic) + self.encrypted_mnemonic, old_password, new_password + ) + self.encrypted_shard = shamir_share.decode_mnemonic(self.encrypted_mnemonic) def from_bytes(self, bytes_data: bytes) -> None: """Initialize shard from the given bytes""" self.encrypted_mnemonic = shamir_share.mnemonic_from_bytes(bytes_data) - self.encrypted_shard = shamir_share.decode_mnemonic( - self.encrypted_mnemonic) + self.encrypted_shard = shamir_share.decode_mnemonic(self.encrypted_mnemonic) def to_bytes(self) -> bytes: """Serialize this shard to bytes""" @@ -128,18 +129,36 @@ def to_qr_bson(self) -> bytes: return bson.dumps({self.name: self.to_bytes()}) def _unpack_share(self) -> None: - (self._share_id, _, self._group_id, self._group_threshold, _, self._member_id, self._member_threshold, - _) = shamir_share.decode_mnemonic(self.encrypted_mnemonic) + ( + self._share_id, + _, + self._group_id, + self._group_threshold, + _, + self._member_id, + self._member_threshold, + _, + ) = shamir_share.decode_mnemonic(self.encrypted_mnemonic) def to_str(self) -> str: """Return a user friendly string describing this shard and its membership in a group""" - (identifier, _, group_index, _, _, member_identifier, _, - _) = shamir_share.decode_mnemonic(self.encrypted_mnemonic) - return "{0} (family:{1} group:{2} member:{3})".format(self.name, identifier, group_index + 1, member_identifier + 1) - - def _get_password(self) -> bytes: + ( + identifier, + _, + group_index, + _, + _, + member_identifier, + _, + _, + ) = shamir_share.decode_mnemonic(self.encrypted_mnemonic) + return "{0} (family:{1} group:{2} member:{3})".format( + self.name, identifier, group_index + 1, member_identifier + 1 + ) + + def _get_password(self) -> Optional[bytes]: """Prompt the user for this shard's password""" return self.interface.get_password(self.to_str()) - def _get_change_password(self) -> Tuple[bytes, bytes]: + def _get_change_password(self) -> Tuple[Optional[bytes], Optional[bytes]]: return self.interface.get_change_password(self.to_str()) diff --git a/hermit/shards/shard_set.py b/hermit/shards/shard_set.py index 66412c0..69d3d79 100644 --- a/hermit/shards/shard_set.py +++ b/hermit/shards/shard_set.py @@ -3,18 +3,26 @@ import textwrap from hermit import shamir_share from prompt_toolkit import print_formatted_text, HTML -from hermit.config import HermitConfig +from hermit.config import get_config from hermit.errors import HermitError from typing import List, Dict, Optional from mnemonic import Mnemonic from .interface import ShardWordUserInterface from .shard import Shard from hermit.rng import RandomGenerator +import shamir_mnemonic + +# FIXME +# os.system() calls should be removed +# https://docs.python.org/3/library/os.html#os.system +# At a minimum, we should `wait` for results +# Preferably, this whole thing should be re-written in something pythonic RNG = RandomGenerator() shamir_share.set_random_bytes(RNG.random) + def check_satisfaction_criteria(shards): """check_satisfaction_criteria(shards) @@ -32,22 +40,21 @@ def check_satisfaction_criteria(shards): for s in shards: (group_idx, _) = s.shard_id - if not group_idx in groups: + if group_idx not in groups: groups[group_idx] = 0 groups[group_idx] += 1 if groups[group_idx] >= s.member_threshold: filled.add(group_idx) - satisfied = (len(filled) >= s.group_threshold) + satisfied = len(filled) >= s.group_threshold return (satisfied, filled) class ShardSet(object): - def __init__(self, - interface: Optional[ShardWordUserInterface] = None) -> None: + def __init__(self, interface: Optional[ShardWordUserInterface] = None) -> None: self.shards: Dict = {} self._shards_loaded = False - self.config = HermitConfig.load() + self.config = get_config() if interface is None: self.interface = ShardWordUserInterface() else: @@ -60,43 +67,52 @@ def _ensure_shards(self, shards_expected: bool = True) -> None: # If the shards dont exist at the place where they # are expected to by, try to restore them with the externally # configured getPersistedShards command. - if not os.path.exists(self.config.shards_file): + if not os.path.exists(self.config.paths["shards_file"]): try: - os.system(self.config.commands['getPersistedShards']) + os.system(self.config.commands["getPersistedShards"]) except TypeError: pass # If for some reason the persistence layer failed to create the # the shards file, we assume that we just need to initialize # it as an empty bson object. - if not os.path.exists(self.config.shards_file): - with open(self.config.shards_file, 'wb') as f: + if not os.path.exists(self.config.paths["shards_file"]): + with open(self.config.paths["shards_file"], "wb") as f: f.write(bson.dumps({})) - with open(self.config.shards_file, 'rb') as f: - bdata = bson.loads(f.read()) + with open(self.config.paths["shards_file"], "rb") as f: + bbytes = f.read() + if len(bbytes) == 0: + bdata = {} + else: + bdata = bson.loads(bbytes) for name, shard_bytes in bdata.items(): self.shards[name] = Shard( - name, shamir_share.mnemonic_from_bytes(shard_bytes)) + name, shamir_share.mnemonic_from_bytes(shard_bytes) + ) self._shards_loaded = True if len(self.shards) == 0 and shards_expected: - raise HermitError("No shards found. Create some by entering 'shards' mode.") + raise HermitError( + "No shards found. Create some by entering 'shards' mode." + ) def initialize_file(self) -> None: if self.interface.confirm_initialize_file(): - with open(self.config.shards_file, 'wb') as f: + with open(self.config.paths["shards_file"], "wb") as f: f.write(bson.dumps({})) def to_bytes(self): - data = {name: shard.to_bytes() - for (name, shard) in self.shards.items()} + data = {name: shard.to_bytes() for (name, shard) in self.shards.items()} return bson.dumps(data) def save(self) -> None: - with open(self.config.shards_file, 'wb') as f: + # Note: this is called via `write()` method + # FIXME: standardize naming convention on something logical + self._ensure_shards(shards_expected=False) + with open(self.config.paths["shards_file"], "wb") as f: f.write(self.to_bytes()) def _needed_entropy_bytes(self, group_threshold, groups): @@ -125,40 +141,64 @@ def _needed_entropy_bytes(self, group_threshold, groups): # them is actually the group secret, so we dont count it here. degrees_of_freedom += threshold - 1 - return (degrees_of_freedom * 32) - (digests * 4) + (identifiers*2) + return (degrees_of_freedom * 32) - (digests * 4) + (identifiers * 2) def create_share_from_wallet_words(self, wallet_words=None): + self._ensure_shards(shards_expected=False) (group_threshold, groups) = self.interface.enter_group_information() if wallet_words is None: wallet_words = self.interface.enter_wallet_words() - mnemonic = Mnemonic('english') + mnemonic = Mnemonic("english") secret = mnemonic.to_entropy(wallet_words) RNG.ensure_bytes(self._needed_entropy_bytes(group_threshold, groups)) - mnemonics = shamir_share.generate_mnemonics( - group_threshold, groups, secret) + mnemonics = shamir_share.generate_mnemonics(group_threshold, groups, secret) self._import_share_mnemonic_groups(mnemonics) def create_random_share(self): + self._ensure_shards(shards_expected=False) (group_threshold, groups) = self.interface.enter_group_information() - RNG.ensure_bytes(self._needed_entropy_bytes( - group_threshold, groups) + 32) - mnemonics = shamir_share.generate_mnemonics_random( - group_threshold, groups, strength_bits=256) + RNG.ensure_bytes(self._needed_entropy_bytes(group_threshold, groups) + 32) + mnemonics = shamir_mnemonic.generate_mnemonics_random( + group_threshold, groups, strength_bits=256 + ) self._import_share_mnemonic_groups(mnemonics) def _import_share_mnemonic_groups(self, mnemonic_groups: List[List[str]]) -> None: for group in mnemonic_groups: for mnemonic in group: - (share_id, _, group_index, group_threshold, groups, member_identifier, - member_threshold, _) = shamir_share.decode_mnemonic(mnemonic) - name = self.interface.get_name_for_shard( - share_id, group_index, group_threshold, groups, member_identifier, member_threshold, self.shards) - password = self.interface.confirm_password() - shard = Shard(name, shamir_share.encrypt_mnemonic( - mnemonic, password), self.interface) + shard = None + while shard is None: + try: + ( + share_id, + _, + group_index, + group_threshold, + groups, + member_identifier, + member_threshold, + _, + ) = shamir_share.decode_mnemonic(mnemonic) + name = self.interface.get_name_for_shard( + share_id, + group_index, + group_threshold, + groups, + member_identifier, + member_threshold, + self.shards, + ) + password = self.interface.confirm_password() + shard = Shard( + name, + shamir_share.encrypt_mnemonic(mnemonic, password), + self.interface, + ) + except Exception: + print("Somthing went wrong. Try again.") self.shards[name] = shard def wallet_words(self) -> str: @@ -168,41 +208,45 @@ def wallet_words(self) -> str: # wallet words - this is the only way that I can see that the shamir # code is going to be compatible with bip39 wallets. seed = self.secret_seed() - mnemonic = Mnemonic('english') + mnemonic = Mnemonic("english") return mnemonic.to_mnemonic(seed) def secret_seed(self) -> bytes: self._ensure_shards() - selected : Dict[str, Shard]= {} - selected_share_id : Optional[int]= None - selected_shard_ids : set = set() - selected_shards : set = set() + selected: Dict[str, Shard] = {} + selected_share_id: Optional[int] = None + selected_shard_ids: set = set() + selected_shards: set = set() satisfied = False filled_groups = set() while not satisfied: - shards = [shard - for (name, shard) - in self.shards.items() - if (name not in selected) - and (selected_share_id is None or shard.share_id == selected_share_id) - and (shard.shard_id not in selected_shard_ids) - and (shard.group_id not in filled_groups) - ] + shards = [ + shard + for (name, shard) in self.shards.items() + if (name not in selected) + and (selected_share_id is None or shard.share_id == selected_share_id) + and (shard.shard_id not in selected_shard_ids) + and (shard.group_id not in filled_groups) + ] if selected_share_id is not None: - (enough_shards,_) = check_satisfaction_criteria(selected_shards.union(shards)) + (enough_shards, _) = check_satisfaction_criteria( + selected_shards.union(shards) + ) if not enough_shards: print([shard.to_str() for shard in shards]) - raise HermitError("There are not enough shards available to unlock this secret.") + raise HermitError( + "There are not enough shards available to unlock this secret." + ) name = None try: name = self.interface.choose_shard(shards) - except: + except Exception: # catch end-of-input style exceptions pass @@ -226,8 +270,9 @@ def reveal_shard(self, shard_name: str) -> None: self._ensure_shards(shards_expected=True) shard = self.shards[shard_name] words = shard.encrypted_mnemonic - print_formatted_text(HTML( - "Encrypted SLIP39 phrase for shard {}:\n".format(shard_name))) + print_formatted_text( + HTML("Encrypted SLIP39 phrase for shard {}:\n".format(shard_name)) + ) print_formatted_text("\n".join(textwrap.wrap(words, 80)), "\n") self.interface.get_line_then_clear() @@ -236,8 +281,8 @@ def reveal_wallet_words(self) -> None: words = self.wallet_words() print_formatted_text( - "- WARNING -\n" + - "The wallet words for this secret are about to be revealed.\n" + "- WARNING -\n" + + "The wallet words for this secret are about to be revealed.\n" ) self.interface.get_line_then_clear() @@ -254,15 +299,16 @@ def qr_shard(self, shard_name: str) -> bytes: return shard.to_qr_bson() def import_shard_qr(self, name: str, shard_data: bytes) -> None: + self._ensure_shards(shards_expected=False) if name in self.shards: - err_msg = ("Shard exists. If you need to replace it, " - + "delete it first.") + err_msg = "Shard exists. If you need to replace it, " + "delete it first." raise HermitError(err_msg) shard_dict = bson.loads(shard_data) old_name = list(shard_dict.keys())[0] print_formatted_text( - "Importing shard '{}' from qr code as shard '{}'".format(old_name, name)) + "Importing shard '{}' from qr code as shard '{}'".format(old_name, name) + ) shard = Shard(name, None, interface=self.interface) shard.from_bytes(shard_dict[old_name]) @@ -272,8 +318,7 @@ def input_shard_words(self, name) -> None: self._ensure_shards(shards_expected=False) if name in self.shards: - err_msg = ("Shard exists. If you need to replace it, " - + "delete it first.") + err_msg = "Shard exists. If you need to replace it, " + "delete it first." raise HermitError(err_msg) shard = Shard(name, None, interface=self.interface) @@ -281,24 +326,45 @@ def input_shard_words(self, name) -> None: self.shards[name] = shard - def copy_shard(self, original: str, copy: str) -> None: + def copy_shard(self, old: str, new: str) -> None: + self._ensure_shards() + if old not in self.shards: + raise HermitError("Shard {} does not exist.".format(old)) + + if new in self.shards: + err_msg = ( + "Shard {} exists. If you need to replace it, delete it first.".format( + new + ) + ) + raise HermitError(err_msg) + + old_shard = self.shards[old] + new_shard = Shard(new, old_shard.encrypted_mnemonic, interface=self.interface) + new_shard.change_password() + self.shards[new] = new_shard + + def rename_shard(self, old: str, new: str) -> None: self._ensure_shards() - if original not in self.shards: - raise HermitError("Shard {} does not exist.".format(original)) + if old not in self.shards: + raise HermitError("Shard {} does not exist.".format(old)) - if copy in self.shards: + if new in self.shards: err_msg = ( - "Shard {} exists. If you need to replace it, delete it first.".format(copy)) + "Shard {} exists. If you need to replace it, delete it first.".format( + new + ) + ) raise HermitError(err_msg) - original_shard = self.shards[original] - copy_shard = Shard( - copy, original_shard.encrypted_mnemonic, interface=self.interface) - copy_shard.change_password() - self.shards[copy] = copy_shard + shard = self.shards[old] + del self.shards[old] + shard.name = new + self.shards[new] = shard def clear_shards(self) -> None: self.shards = {} + self._shards_loaded = True def wallet_words_shard(self, name: str) -> None: self._ensure_shards() @@ -319,13 +385,13 @@ def list_shards(self) -> None: def persist(self) -> None: # TODO: check to see that everything is saved - os.system(self.config.commands['persistShards']) + os.system(self.config.commands["persistShards"]) def backup(self) -> None: - os.system(self.config.commands['backupShards']) + os.system(self.config.commands["backupShards"]) def restore(self) -> None: - os.system(self.config.commands['restoreBackup']) + os.system(self.config.commands["restoreBackup"]) def reload(self) -> None: self.shards = {} diff --git a/hermit/signer.py b/hermit/signer.py new file mode 100644 index 0000000..cb0a736 --- /dev/null +++ b/hermit/signer.py @@ -0,0 +1,529 @@ +from decimal import Decimal +from typing import Optional, List + +from prompt_toolkit import PromptSession, print_formatted_text, HTML +from buidl import PSBT + +from .errors import HermitError, InvalidPSBT +from .io import ( + display_data_as_animated_qrs, + read_data_from_animated_qrs, +) +from .wallet import HDWallet +from .coordinator import validate_coordinator_signature_if_necessary +from .config import get_config + +_satoshis_per_bitcoin = Decimal(int(pow(10, 8))) + +transaction_display_mode = get_config().coordinator["transaction_display"] +minimize_signed_psbt = get_config().coordinator["minimize_signed_psbt"] +TOPROW = "╔═╗" +MIDDLE = "║ ║" +SECTION = "╠═╣" +SUBSECT = "╟─╢" +BOTTOM = "╚═╝" + + +def format_top(label, width): + label = label.strip().upper() + n = width - 6 - len(label) + return ( + TOPROW[0] + + TOPROW[1] * 2 + + " " + + label.strip() + + " " + + TOPROW[1] * n + + TOPROW[2] + ) + + +def format_section(label, width): + label = label.strip().upper() + n = width - 6 - len(label) + return ( + SECTION[0] + + SECTION[1] * 2 + + " " + + label.strip() + + " " + + SECTION[1] * n + + SECTION[2] + ) + + +def format_subsection(width): + return SUBSECT[0] + SUBSECT[1] * (width - 2) + SUBSECT[2] + + +def format_bottom(width): + return BOTTOM[0] + BOTTOM[1] * (width - 2) + BOTTOM[2] + + +def format_line(line, width): + n = width - len(line) - 2 + return MIDDLE[0] + line + MIDDLE[1] * n + MIDDLE[2] + + +def format_bitcoin(btc): + """Returns a 21 character string that shows the btc value with 8 places after the + decimal aligned for amounts up to the maximum number of bitcoin. + """ + return f"{btc:17.8f} BTC" + + +TX_DETAILS_WIDTH = 80 + + +def format_tx_details(txid, version, lock_time): + txid_line = f"TXID: {txid}" + version_lock_line = f"Version: {version} Lock Time: {lock_time}" + pad = 78 - max(len(txid_line), len(version_lock_line)) + + yield format_top("details", TX_DETAILS_WIDTH) + yield format_line(" " * pad + txid_line, TX_DETAILS_WIDTH) + yield format_line(" " * pad + version_lock_line, TX_DETAILS_WIDTH) + + +def format_input(index, prev_txid, prev_index, amount): + prev = f"{prev_txid}:{prev_index}" + idx = f"{index}:" + + yield format_line(idx + prev.rjust(78 - len(idx)), TX_DETAILS_WIDTH) + yield format_line(" " * 11 + format_bitcoin(amount), TX_DETAILS_WIDTH) + + +def format_inputs(inputs): + yield format_section("inputs", TX_DETAILS_WIDTH) + for index, input_data in enumerate(inputs): + yield from format_input( + index=index + 1, + prev_txid=input_data["prev_txhash"], + prev_index=input_data["prev_idx"], + amount=_sats_to_btc(input_data["sats"]), + ) + if index + 1 < len(inputs): + yield format_subsection(TX_DETAILS_WIDTH) + + +def split_string(s, n): + """Splits s into n nearly equal length substrings.""" + d = len(s) // n + r = len(s) % n + lengths = [d + (i < r) for i in range(n)] + start = 0 + result = [] + for length in lengths: + result.append(s[start : start + length]) + start += length + return result + + +def split_address(a): + """split an address up into multiple lines, of approximately equal length, with no more than + 45 characters per line.""" + if len(a) <= 45: + return [a] + return split_string(a, (len(a) + 44) // 45) + + +def format_output(index, address, change, amount): + parts = split_address(address) + + idx = f"{index}:" + change_label = "CHANGE" if change else "" + + yield format_line( + f"{idx:3} {change_label:6} {format_bitcoin(amount)} {parts[0].rjust(TX_DETAILS_WIDTH - 35)}", + TX_DETAILS_WIDTH, + ) + yield from ( + format_line(part.rjust(TX_DETAILS_WIDTH - 2), TX_DETAILS_WIDTH) + for part in parts[1:] + ) + + +def format_outputs(outputs, top): + if top: + yield format_top("outputs", TX_DETAILS_WIDTH) + else: + yield format_section("outputs", TX_DETAILS_WIDTH) + + for index, output_data in enumerate(outputs): + yield from format_output( + index=index + 1, + address=output_data["addr"], + change=output_data["is_change"], + amount=_sats_to_btc(output_data["sats"]), + ) + if index + 1 < len(outputs): + yield format_subsection(TX_DETAILS_WIDTH) + + +def format_totals(total_in, total_out, fee): + yield format_section("totals", TX_DETAILS_WIDTH) + yield format_line(f" Inputs: {format_bitcoin(total_in)}", TX_DETAILS_WIDTH) + yield format_line( + f" Outputs: {format_bitcoin(total_out)} Fee: {format_bitcoin(fee)}", + TX_DETAILS_WIDTH, + ) + yield format_bottom(TX_DETAILS_WIDTH) + + +def long_format_transaction(transaction_metadata): + yield from format_tx_details( + txid=transaction_metadata["txid"], + version=transaction_metadata["version"], + lock_time=transaction_metadata["locktime"], + ) + yield from format_inputs( + inputs=transaction_metadata["inputs_desc"], + ) + yield from format_outputs( + outputs=transaction_metadata["outputs_desc"], + top=False, + ) + + input_sats = sum(inp["sats"] for inp in transaction_metadata["inputs_desc"]) + output_sats = sum(outp["sats"] for outp in transaction_metadata["outputs_desc"]) + + yield from format_totals( + total_in=_sats_to_btc(input_sats), + total_out=_sats_to_btc(output_sats), + fee=_sats_to_btc(input_sats - output_sats), + ) + + +def short_format_transaction(transaction_metadata): + yield from format_outputs( + outputs=transaction_metadata["outputs_desc"], + top=True, + ) + + input_sats = sum(inp["sats"] for inp in transaction_metadata["inputs_desc"]) + output_sats = sum(outp["sats"] for outp in transaction_metadata["outputs_desc"]) + + yield from format_totals( + total_in=_sats_to_btc(input_sats), + total_out=_sats_to_btc(output_sats), + fee=_sats_to_btc(input_sats - output_sats), + ) + + +# +# ╔══ DETAILS ═══════════════════════════════════════════════════════════════════╗ +# ║ TXID: 3368f33986c888d436d642a3d1f4f3fe9c837493ec6987e450579f1f7c8dcd74║ +# ║ Version: 1 Lock Time: 0 ║ +# ╠══ INPUTS ════════════════════════════════════════════════════════════════════╣ +# ║1: 5e5686f54ae59105a7815e89565d4b6b41e47aca39fc3adfa2198c619dfdf748:1000║ +# ║ 21000000.10000000 BTC ║ +# ╠══ OUTPUTS ═══════════════════════════════════════════════════════════════════╣ +# +# ╔══ OUTPUTS ═══════════════════════════════════════════════════════════════════╗ +# ║1: 0.00100000 BTC 3KBa15krVQCe8M9LS965BuNxHojF1hbGR2║ +# ╟──────────────────────────────────────────────────────────────────────────────╢ +# ║2: 483.37283737 BTC tb1asdfghjqwertyuiopqwertyuiopqwertyuiopqwert║ +# ║ yuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiop║ +# ╟──────────────────────────────────────────────────────────────────────────────╢ +# ║3: CHANGE 0.37283844 BTC 38LtBaM93g6wtCzGGNhrNjuV1QBx9V11Qd║ +# ╠══ TOTALS ════════════════════════════════════════════════════════════════════╣ +# ║ Inputs: #####374.99988750 BTC ║ +# ║ Outputs: #####374.99988750 BTC Fee: 0.00001125 BTC ║ +# ╚══════════════════════════════════════════════════════════════════════════════╝ + + +def _sats_to_btc(sats): + return Decimal(sats) / _satoshis_per_bitcoin + + +def _format_btc(btc): + return f"{btc} BTC" + + +def _format_btc_aligned(*values): + if len(values) == 0: + return [] + + lefts = [] + rights = [] + strings = [str(value) for value in values] + for string in strings: + length = len(string) + try: + left = string.index(".") + if left == length: + # 'M.' + right = 0 + elif left == 0: + # '.N' + right = length - 1 + else: + # 'M.N' + right = length - left - 1 + except ValueError: + # M + left = len(string) + right = 0 + lefts.append(left) + rights.append(right) + + max_left = max(lefts) + max_right = max(rights) + + aligned_strings = [] + for index, string in enumerate(strings): + length = len(string) + left = lefts[index] + right = rights[index] + left_pad = (max_left - left) * " " + right_pad = (max_right - right) * " " + aligned_strings.append(left_pad + string + right_pad + " BTC") + return aligned_strings + + +class Signer(object): + """Signs BTC transactions. + + Accepts transactions in PSBT format read in through the camera or + passed in on instantiation. PSBT data must be encoded as Base64 + text. + + Performs the following validations: + + * The PSBT is syntactically valid. + + * If the transaction is spending multisig inputs: + + * all inputs must all be have the same M-of-N configuration. + + * if the transaction is creating multisig outputs in the same + wallet, the outputs must have the same M-of-N configuration. + + * The PSBT must have an `hd_pubs` dictionary mapping XFPs to the + BIP32 path(s) of the corresponding xpub to use when signing for + seed identified by that XFP. + + If operating Hermit with a coordinator, the coordinator can + additionally inject an RSA signature into the PSBT's `extra_map` + dict (for the + :attr:`~hermit.coordinator.COORDINATOR_SIGNATURE_KEY`). This + signature will be verified against an RSA public key in Hermit's + configuration (see :attr:`~HermitConfig.DefaultCoordinator`). + + """ + + def __init__( + self, + signing_wallet: HDWallet, + session: PromptSession = None, + unsigned_psbt_b64: str = None, + testnet: bool = False, + ) -> None: + self.wallet = signing_wallet + self.session = session + self.unsigned_psbt_b64: Optional[str] = unsigned_psbt_b64 + self.testnet = testnet + self.psbt: Optional[PSBT] = None + self.signed_psbt_b64: Optional[str] = None + + def sign(self) -> None: + """Initiate signing. + + Will wait for a signature request, handle validation, + confirmation, generation, and display of a signature. + """ + + if not self.wallet.unlocked(): + # TODO: add UX flow where the user inspects the TX and can + # then unlock the wallet? + print_formatted_text( + "WARNING: wallet is LOCKED; you cannot sign without first unlocking." + ) + + self.read_signature_request() + self.parse_signature_request() + self.validate_signature_request() + + self.generate_transaction_metadata() + self.print_transaction_description() + + self.wallet.unlock() + + if not self.approve_signature_request(): + print_formatted_text( + "Signature request REJECTED, aborting without attempting to sign." + ) + return + + self.create_signature() + self.show_signature() + + # + # Signature Request + # + + def read_signature_request(self): + if self.unsigned_psbt_b64: + # The PSBT was already passed in as an argument. + return + self.unsigned_psbt_b64 = read_data_from_animated_qrs() + + def parse_signature_request(self) -> None: + print_formatted_text(HTML("Parsing PSBT...")) + if self.unsigned_psbt_b64 is None: + raise HermitError("No PSBT provided.") + + try: + network = "testnet" if self.testnet else "mainnet" + self.psbt = PSBT.parse_base64(self.unsigned_psbt_b64, network=network) + except Exception as e: + err_msg = "Invalid PSBT: {} ({}).".format(e, type(e).__name__) + raise InvalidPSBT(err_msg) + + def validate_signature_request(self) -> None: + print_formatted_text(HTML("Validating PSBT...")) + if self.psbt is None or self.psbt.validate() is not True: + raise InvalidPSBT("Invalid PSBT.") + + validate_coordinator_signature_if_necessary(self.psbt) + + # + # Transaction Description + # + + def generate_transaction_metadata(self) -> None: + print_formatted_text(HTML("Describing signature request...")) + self.transaction_metadata = self.psbt.describe_basic_multisig( # type: ignore + # xfp_for_signing=self.wallet.xfp_hex, + ) + + def print_transaction_description(self): + if transaction_display_mode == "long": + lines = long_format_transaction(self.transaction_metadata) + elif transaction_display_mode == "short": + lines = short_format_transaction(self.transaction_metadata) + else: + lines = self.transaction_description_lines() + + for line in lines: + print_formatted_text(line) + + def transaction_description_lines(self) -> List[str]: + data = self.transaction_metadata + + lines = [] + + lines.extend( + [ + "", + f"TXID: {data['txid']}", + f"Lock Time: {data['locktime']}", + f"Version: {data['version']}", + "", + f"INPUTS ({len(data['inputs_desc'])}):", + ] + ) + + total_input_sats = 0 + for idx, inp in enumerate(data["inputs_desc"]): + lines.extend( + [ + f" Input {idx}:", + f" Prev. TXID: {inp['prev_txhash']}", + f" Prev. Index: {inp['prev_idx']}", + f" Amount: {_format_btc(_sats_to_btc(inp['sats']))}", + "", + ] + ) + total_input_sats += inp["sats"] + + lines.extend( + [ + f"OUTPUTS ({len(data['outputs_desc'])}):", + ] + ) + + total_output_sats = 0 + for idx, output in enumerate(data["outputs_desc"]): + lines.extend( + [ + f" Output {idx}:", + f" Address: {output['addr']}", + f" Amount: {_format_btc(_sats_to_btc(output['sats']))}", + f" Change?: {'Yes' if output['is_change'] else 'No'}", + "", + ] + ) + total_output_sats += output["sats"] + + total_input_btc = _sats_to_btc(total_input_sats) + total_output_btc = _sats_to_btc(total_output_sats) + fee_btc = _sats_to_btc(data["tx_fee_sats"]) + + total_input_btc, total_output_btc, fee_btc = _format_btc_aligned( + total_input_btc, total_output_btc, fee_btc + ) + + lines.extend( + [ + f"Total Input Amount: {total_input_btc}", + f"Total Output Amount: {total_output_btc}", + f"Fee: {fee_btc}", + "", + ] + ) + + return lines + + # + # Signature + # + + def approve_signature_request(self) -> bool: + prompt_msg = "Sign the above transaction? [y/N]" + + if self.session is not None: + response = self.session.prompt(HTML("{} ".format(prompt_msg))) + else: + response = input(prompt_msg) + + return response.strip().lower().startswith("y") + + def create_signature(self) -> None: + """Signs a given transaction""" + + child_private_keys_to_use = [] + for xfp, bip32_paths_for_xfp in self.transaction_metadata["root_paths"].items(): + for bip32_path in bip32_paths_for_xfp: + child_private_keys_to_use.append( + self.wallet.private_key(bip32_path, testnet=self.testnet) + ) + + was_signed = self.psbt is not None and self.psbt.sign_with_private_keys( + private_keys=child_private_keys_to_use + ) + if was_signed is False: + raise HermitError("Failed to sign transaction") + + + if self.psbt is not None: + + if minimize_signed_psbt: + for inp in self.psbt.psbt_ins: + inp.prev_tx = None + inp.redeem_script = None + inp.named_pubs = {} + inp.prev_out = None + inp.witness_script = None + inp.extra_map = {} + self.signed_psbt_b64 = self.psbt.serialize_base64() + + def show_signature(self) -> None: + # TODO: is there a smaller signatures only format for less bandwidth? + if self.signed_psbt_b64 is None: + return + + print_formatted_text(HTML("Signed PSBT: ")) + print_formatted_text(HTML(f"{self.signed_psbt_b64}")) + + display_data_as_animated_qrs(base64_data=self.signed_psbt_b64) diff --git a/hermit/signer/__init__.py b/hermit/signer/__init__.py deleted file mode 100644 index 705d5af..0000000 --- a/hermit/signer/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .base import * -from .bitcoin_signer import * -from .echo_signer import * diff --git a/hermit/signer/base.py b/hermit/signer/base.py deleted file mode 100644 index 21cd844..0000000 --- a/hermit/signer/base.py +++ /dev/null @@ -1,180 +0,0 @@ -import json -import re -from typing import Optional, Dict - -from prompt_toolkit import PromptSession, print_formatted_text, HTML - -import hermit -from hermit.errors import HermitError, InvalidSignatureRequest -from hermit.qrcode import reader, displayer -from hermit.wallet import (compressed_private_key_from_bip32, - compressed_public_key_from_bip32, HDWallet) - - -class Signer(object): - """Abstract class from which to subclass signing classes for specific assets. - - This class implements the basic framework required to receive a - signature request and return a signature. - - Subclasses should implement the following API methods: - - * ``validate_request`` - * ``display_request`` - * ``create_signature`` - - Subclasses will likely require the following API methods to - validate and extract BIP32 nodes with which to sign transactions. - - * ``validate_bip32_path`` - * ``generate_child_keys`` - - """ - - BIP32_PATH_REGEX = "^m(/[0-9]+'?)+$" - BIP32_NODE_MAX_VALUE = 2147483647 - - def __init__(self, - signing_wallet: HDWallet, - session: PromptSession = None) -> None: - self.wallet = signing_wallet - self.session = session - self.signature: Optional[Dict] = None - self.request_data: Optional[str] = None - - def sign(self, testnet: bool = False) -> None: - """Initiate signing. - - Will wait for a signature request, handle validation, - confirmation, generation, and display of a signature. - """ - - self.testnet = testnet - self._wait_for_request() - if self.request_data: - self._parse_request() - self.validate_request() - if self._confirm_create_signature(): - self.create_signature() - self._show_signature() - - def validate_request(self) -> None: - """Validate a signature request. - - Concrete subclasses should override this method. - - The contents of the signature request will already be parsed - from QR code and available as ``self.request``. - - Invalid requests should raise an appropriate error class with - message. - - The presence in the request of a valid path to a BIP32 node to - use when signing is already validated. - - """ - pass - - def validate_bip32_path(self, bip32_path:str) -> None: - """Validate a BIP32 path - - Used by concrete subclasses to validate a BIP32 path in a - signature request. - """ - if not isinstance(bip32_path, (str,)): - raise InvalidSignatureRequest("BIP32 path must be a string") - if not re.match(self.BIP32_PATH_REGEX, bip32_path): - err_msg = "invalid BIP32 path formatting" - raise InvalidSignatureRequest(err_msg) - nodes = bip32_path.split('/')[1:] - node_values = [int(x.replace("'", "")) for x in nodes] - for node_value in node_values: - if node_value > self.BIP32_NODE_MAX_VALUE: - err_msg = "invalid BIP32 path" - raise InvalidSignatureRequest(err_msg) - - def display_request(self) -> None: - """Display a signature request. - - Concrete subclasses should override this method. - - The contents of the signature request will already be parsed - from QR code and available as ``self.request``. - - Use ``print_formatted_text`` to display the signature in a way - that readable on consoles. - - """ - pass - - def create_signature(self) -> None: - """Create a signature. - - Concrete subclasses should override this method. - - The contents of the signature request will already be parsed - from QR code and available as ``self.request``. - - The signature data should be saved as ``self.signature``. - - """ - pass - - def generate_child_keys(self, bip32_path:str) -> Dict: - """Return keys at a given BIP32 path in the current wallet. - - The dictionary returned will contain the following items from - the HD node at the given BIP32 path: - - * ``xprv`` -- the extended private key - * ``xpub`` -- the extended public key - * ``private_key`` -- the private key - * ``public_key`` -- the public key - """ - xprv = self.wallet.extended_private_key(bip32_path) - xpub = self.wallet.extended_public_key(bip32_path) - return dict( - xprv=xprv, - xpub=xpub, - private_key=compressed_private_key_from_bip32(xprv).hex(), - public_key=compressed_public_key_from_bip32(xpub).hex()) - - - def _wait_for_request(self) -> None: - self.request_data = reader.read_qr_code() - - def _parse_request(self) -> None: - if self.request_data is not None: - try: - self.request = json.loads(self.request_data) - except ValueError as e: - err_msg = ("Invalid signature request: {} ({})" - .format(e, type(e).__name__)) - raise HermitError(err_msg) - else: - raise HermitError('No Request Data') - - def _confirm_create_signature(self) -> bool: - self.display_request() - prompt_msg = "Sign the above transaction? [y/N] " - - if self.session is not None: - response = self.session.prompt(HTML("{}".format(prompt_msg))) - else: - response = input(prompt_msg) - - return response.strip().lower().startswith('y') - - - def _show_signature(self) -> None: - name = self._signature_label() - print_formatted_text(HTML("Signature Data: ")) - print(json.dumps(self.signature, indent=2)) - displayer.display_qr_code(self._serialized_signature(), name=name) - - - def _serialized_signature(self) -> str: - return json.dumps(self.signature) - - def _signature_label(self) -> str: - return 'Signature' diff --git a/hermit/signer/bitcoin_signer.py b/hermit/signer/bitcoin_signer.py deleted file mode 100644 index 4332e3c..0000000 --- a/hermit/signer/bitcoin_signer.py +++ /dev/null @@ -1,336 +0,0 @@ -import binascii -from collections import defaultdict -from hashlib import sha256 -from typing import Dict - -from bitcoin import SelectParams -from bitcoin import base58, bech32 -from bitcoin.core import COutPoint, CMutableTxOut, CMutableTxIn, CTransaction -from bitcoin.core.script import SignatureHash, SIGHASH_ALL, CScript -from bitcoin.wallet import (CBitcoinAddress, - CBitcoinAddressError, - P2SHBitcoinAddress) -import bitcoin -import ecdsa - -from hermit.errors import InvalidSignatureRequest -from hermit.signer.base import Signer, print_formatted_text, HTML - - -def generate_multisig_address(redeemscript: str, testnet: bool = False) -> str: - """ - Generates a P2SH-multisig Bitcoin address from a redeem script - - Args: - redeemscript: hex-encoded redeem script - use generate_multisig_redeem_script to create - the redeem script from three compressed public keys - testnet: Should the address be testnet or mainnet? - - Example: - TODO - """ - - if testnet: - bitcoin.SelectParams('testnet') - - redeem_script = CScript(bitcoin.core.x(redeemscript)) - - addr = P2SHBitcoinAddress.from_redeemScript(redeem_script) - - return str(addr) - - -class BitcoinSigner(Signer): - """Signs BTC transactions - - Signature requests must match the following schema: - - { - - "inputs": [ - [ - REDEEM_SCRIPT, - BIP32_PATH, - { - "txid": TXID, - "index": INDEX, - "amount": SATOSHIS - }, - ... - ], - ... - ], - - "outputs": [ - { - "address": ADDRESS, - "amount": SATOSHIS - }, - ... - ] - - } - - See the file ``examples/signature_requests/bitcoin_testnet.json`` - for a more complete example. - - """ - - # - # Validation - # - - def validate_request(self) -> None: - """Validates a signature request - - Validates - - * the redeem script - * inputs & outputs - * fee - """ - if self.testnet: - SelectParams('testnet') - else: - SelectParams('mainnet') - self._validate_input_groups() - self._validate_outputs() - self._validate_fee() - - def _validate_input_groups(self) -> None: - if 'inputs' not in self.request: - raise InvalidSignatureRequest("no input groups") - input_groups = self.request['inputs'] - if not isinstance(input_groups, list): - raise InvalidSignatureRequest("input groups is not an array") - if len(input_groups) == 0: - raise InvalidSignatureRequest("at least one input group is required") - self.inputs = [] - for input_group in input_groups: - self._validate_input_group(input_group) - - def _validate_input_group(self, input_group: list) -> None: - if len(input_group) < 3: - raise InvalidSignatureRequest("input group must include redeem script, BIP32 path, and at least one input") - redeem_script = input_group[0] - self._validate_redeem_script(redeem_script) - bip32_path = input_group[1] - self.validate_bip32_path(bip32_path) - address = generate_multisig_address(redeem_script, self.testnet) - for input in input_group[2:]: - self._validate_input(input) - input['redeem_script'] = redeem_script - input['bip32_path'] = bip32_path - input['address'] = address - self.inputs.append(input) - - def _validate_input(self, input: Dict) -> None: - if 'amount' not in input: - raise InvalidSignatureRequest("no amount in input") - if type(input['amount']) != int: - err_msg = "input amount must be an integer (satoshis)" - raise InvalidSignatureRequest(err_msg) - if input['amount'] <= 0: - raise InvalidSignatureRequest("invalid input amount") - - if 'txid' not in input: - raise InvalidSignatureRequest("no txid in input") - if len(input['txid']) != 64: - raise InvalidSignatureRequest("txid must be 64 characters") - try: - binascii.unhexlify(input['txid'].encode('utf8')) - except ValueError: - err_msg = "input TXIDs must be hexadecimal strings" - raise InvalidSignatureRequest(err_msg) - - if 'index' not in input: - raise InvalidSignatureRequest("no index in input") - if type(input['index']) != int: - err_msg = "input index must be an integer" - raise InvalidSignatureRequest(err_msg) - if input['index'] < 0: - raise InvalidSignatureRequest("invalid input index") - - def _validate_redeem_script(self, redeem_script: bytes) -> None: - try: - binascii.unhexlify(redeem_script.encode('utf8')) - except (ValueError, AttributeError): - raise InvalidSignatureRequest("redeem script is not valid hex") - - def _validate_outputs(self) -> None: - if 'outputs' not in self.request: - raise InvalidSignatureRequest("no outputs") - self.outputs = self.request['outputs'] - if not isinstance(self.outputs, list): - raise InvalidSignatureRequest("outputs is not an array") - if len(self.outputs) == 0: - raise InvalidSignatureRequest("at least one output is required") - for output in self.outputs: - self._validate_output(output) - - def _validate_output(self, output: Dict) -> None: - if 'address' not in output: - raise InvalidSignatureRequest("no address in output") - if not isinstance(output['address'], (str,)): - err_msg = "output addresses must be base58-encoded strings" - raise InvalidSignatureRequest(err_msg) - - if output['address'][:2] in ('bc', 'tb'): - try: - bech32.CBech32Data(output['address']) - except bech32.Bech32Error: - err_msg = "invalid bech32 output address (check mainnet vs. testnet)" - raise InvalidSignatureRequest(err_msg) - else: - try: - base58.CBase58Data(output['address']) - except base58.InvalidBase58Error: - err_msg = "output addresses must be base58-encoded strings" - raise InvalidSignatureRequest(err_msg) - except base58.Base58ChecksumError: - err_msg = "invalid output address checksum" - raise InvalidSignatureRequest(err_msg) - try: - CBitcoinAddress(output['address']) - except CBitcoinAddressError: - err_msg = "invalid output address (check mainnet vs. testnet)" - raise InvalidSignatureRequest(err_msg) - - if 'amount' not in output: - raise InvalidSignatureRequest("no amount in output") - if type(output['amount']) != int: - err_msg = "output amount must be an integer (satoshis)" - raise InvalidSignatureRequest(err_msg) - if output['amount'] <= 0: - raise InvalidSignatureRequest("invalid output amount") - - def _validate_fee(self) -> None: - sum_inputs = sum([input['amount'] for input in self.inputs]) - sum_outputs = sum([output['amount'] for output in self.outputs]) - self.fee = sum_inputs - sum_outputs - if self.fee < 0: - raise InvalidSignatureRequest("fee cannot be negative") - - # - # Display - # - - def display_request(self) -> None: - """Displays the transaction to be signed""" - print_formatted_text(HTML("""INPUTS: -{} - -OUTPUTS: -{} - -FEE: {} BTC -""".format(self._formatted_input_groups(), - self._formatted_outputs(), - self._format_amount(self.fee)))) - - def _formatted_input_groups(self) -> str: - bip32_paths = {} - addresses: Dict = defaultdict(int) - for input in self.inputs: - address = input['address'] - addresses[address] += input['amount'] - bip32_paths[address] = input['bip32_path'] # they're all the same - - lines = [] - for address in addresses: - lines.append(" {}\t{} BTC\tSigning as {}".format( - address, - self._format_amount(addresses[address]), - bip32_paths[address])) - return "\n".join(lines) - - def _formatted_outputs(self) -> str: - formatted_outputs = [self._format_output(output) - for output - in self.outputs] - return "\n".join(formatted_outputs) - - def _format_output(self, output: Dict) -> str: - return " {}\t{} BTC".format( - output['address'], - self._format_amount(output['amount'])) - - def _format_amount(self, amount) -> str: - return "%0.8f" % (amount / pow(10, 8)) - - # - # Signing - # - - def create_signature(self) -> None: - """Signs a given transaction""" - # Keys are derived in base.py - - if self.testnet: - SelectParams('testnet') - else: - SelectParams('mainnet') - - # Construct Inputs - tx_inputs = [] - parsed_redeem_scripts = {} - for input in self.inputs: - if input['redeem_script'] not in parsed_redeem_scripts: - parsed_redeem_scripts[input['redeem_script']] = CScript(bitcoin.core.x(input['redeem_script'])) - - txid = bitcoin.core.lx(input['txid']) - vout = input['index'] - tx_inputs.append(CMutableTxIn(COutPoint(txid, vout))) - - # Construct Outputs - tx_outputs = [] - - for output in self.outputs: - output_script = (CBitcoinAddress(output['address']) - .to_scriptPubKey()) - tx_outputs.append(CMutableTxOut(output['amount'], output_script)) - - # Construct Transaction - tx = CTransaction(tx_inputs, tx_outputs) - - # Construct data for each signature (1 per input) - signature_hashes = [] - keys = {} - for input_index, input in enumerate(self.inputs): - redeem_script = input['redeem_script'] - bip32_path = input['bip32_path'] - - # Signature Hash - signature_hashes.append(SignatureHash( - parsed_redeem_scripts[redeem_script], - tx, input_index, SIGHASH_ALL)) - - # Only need to generate keys once per unique BIP32 path - if keys.get(bip32_path) is None: - keys[bip32_path] = self.generate_child_keys(bip32_path) - keys[bip32_path]['signing_key'] = ecdsa.SigningKey.from_string( - bytes.fromhex(keys[bip32_path]['private_key']), - curve=ecdsa.SECP256k1) - - # Construct signatures (1 per input) - # - # WARNING: We do not append the SIGHASH_ALL byte, - # transaction constructioin should account for that. - # - signatures = [] - for input_index, input in enumerate(self.inputs): - input = self.inputs[input_index] - signature_hash = signature_hashes[input_index] - signing_key = keys[input['bip32_path']]['signing_key'] - signatures.append( - signing_key.sign_digest_deterministic( - signature_hash, - sha256, - sigencode=ecdsa.util.sigencode_der_canonize - ).hex()) - - # Assign result - result = {"signatures": signatures} - - self.signature = result diff --git a/hermit/signer/echo_signer.py b/hermit/signer/echo_signer.py deleted file mode 100644 index a07d426..0000000 --- a/hermit/signer/echo_signer.py +++ /dev/null @@ -1,40 +0,0 @@ -from hermit.qrcode import displayer -from hermit.signer.base import Signer - - -class EchoSigner(Signer): - """Returns the signature request data as a signature. - - This class is useful for debugging signature requests. - """ - - # - # Validation - # - - def validate_request(self) -> None: - """Validate the signature request - - Does nothing :) - """ - pass - - # - # Display - # - - def display_request(self) -> None: - """Prints the signature request""" - print("""QR Code: - {} - """.format(self.request)) - - def create_signature(self) -> None: - """Create a fake signature - - The signature data is just the request data. - """ - self.signature = self.request - - def _signature_label(self) -> str: - return 'Request' diff --git a/hermit/ui/base.py b/hermit/ui/base.py index 471faa0..92e34e8 100644 --- a/hermit/ui/base.py +++ b/hermit/ui/base.py @@ -1,40 +1,59 @@ from typing import Dict from functools import wraps +from ..config import get_config -from prompt_toolkit import HTML, print_formatted_text +# from hermit.errors import HermitError -from hermit.errors import HermitError -from hermit.qrcode import displayer, reader +#: Duration of idle time before the wallet will automatically lock +#: itself. +DeadTime = get_config().coordinator["relock_timeout"] -DeadTime = 30 -def clear_screen(): - print(chr(27) + "[2J") - -# is this even used? -def reset_screen(): +def clear_screen() -> None: + """Clears the screen.""" print(chr(27) + "c") -def command(name, commands:Dict): + +def command(name: str, commands: Dict): + """Decorator for defining a new command.""" + def _command_decorator(f): nonlocal name if name is None: name = f.name if name in commands: - raise Exception('command already defined: '+name) + raise Exception("command already defined: " + name) @wraps(f) def wrapper(*args, **kwargs): - try: - return f(*args, **kwargs) - except TypeError as terr: - raise terr - except Exception as err: - print(err) - raise HermitError("Hmm. Something went wrong.") + return f(*args, **kwargs) commands[name] = wrapper return wrapper return _command_decorator + + +def disabled(*args, **kwargs): + """command disabled""" + print("Command disabled.") + + +def disabled_command(name: str, commands: Dict): + """Decorator for defining a new command.""" + + def _command_decorator(f): + nonlocal name + if name is None: + name = f.name + if name in commands: + raise Exception("command already defined: " + name) + + @wraps(f) + def wrapper(*args, **kwargs): + return disabled(*args, **kwargs) + + return wrapper + + return _command_decorator diff --git a/hermit/ui/common.py b/hermit/ui/common.py index ed0187b..996cf80 100644 --- a/hermit/ui/common.py +++ b/hermit/ui/common.py @@ -1,74 +1,79 @@ -from .base import * +from prompt_toolkit import print_formatted_text + +from ..errors import HermitError +from .base import DeadTime, clear_screen from .wallet import wallet_command from .shards import shard_command + import hermit.ui.state as state -import traceback -import sys -from hermit import __version__; +from hermit import __version__ + -@wallet_command('unlock') -@shard_command('unlock') +@wallet_command("unlock") +@shard_command("unlock") def unlock(): """usage: unlock - Explicity unlock the wallet, prompting for shard passwords. + Explicity unlock the wallet, prompting for shard passwords. - Many commands will do this implicitly. + Many commands will do this implicitly. """ try: - state.Wallet.unlock() + state.Wallet.unlock(testnet=state.Testnet) except HermitError as e: - print_formatted_text("Unable to unlock wallet: ", e) - #traceback.print_exc(file=sys.stdout) + print_formatted_text("Unable to unlock wallet: ", e) if state.Wallet.unlocked: state.Timeout = DeadTime -@wallet_command('lock') -@shard_command('lock') +@wallet_command("lock") +@shard_command("lock") def lock(): """usage: lock - Explicity lock the wallet, requiring passwords to be re-entered as - necessary. + Explicity lock the wallet, requiring passwords to be re-entered as + necessary. - The wallet will automatically lock after 30 seconds. + The wallet will automatically lock after 30 seconds. """ state.Wallet.lock() -@shard_command('clear') -@wallet_command('clear') + +@shard_command("clear") +@wallet_command("clear") def clear(): """usage: clear - Clear screen. + Clear screen. """ clear_screen() -@wallet_command('debug') -@shard_command('debug') + +@wallet_command("debug") +@shard_command("debug") def toggle_debug(): """usage: debug - Toggle debug mode on or off. + Toggle debug mode on or off. - When debug mode is active, more information is displayed about - errors and some additional commands are available. + When debug mode is active, more information is displayed about + errors and some additional commands are available. - The word DEBUG will also appear in Hermit's bottom toolbar. + The word DEBUG will also appear in Hermit's bottom toolbar. """ state.Debug = not state.Debug -@wallet_command('version') -@shard_command('version') -def unlock(): + +@wallet_command("version") +@shard_command("version") +def version(): """usage: version - Print out the version of hermit currently running. - - + Print out the version of hermit currently running. + + """ print_formatted_text(__version__) diff --git a/hermit/ui/main.py b/hermit/ui/main.py index 2aa9626..3e10ede 100644 --- a/hermit/ui/main.py +++ b/hermit/ui/main.py @@ -1,11 +1,12 @@ from prompt_toolkit.eventloop.defaults import use_asyncio_event_loop +from ..plugins import load_plugins +from .wallet import clear_screen, wallet_repl +from .relocker import asyncio, relock_wallet_if_timed_out +from hermit import __version__ -from hermit.plugin import PluginsLoaded +# HACK to initiate all the commands: +from .common import unlock, lock, clear, toggle_debug, version # type: ignore # noqa: F401 -from .wallet import * -from .common import * -from .relocker import * -from hermit import __version__ Banner = r""" _ _ _ _ @@ -18,12 +19,13 @@ You are in WALLET mode. Type 'help' for help. (v{}) """ + def main(): + """Start the Hermit REPL user interface.""" clear_screen() + load_plugins() print(Banner.format(__version__)) - for plugin in PluginsLoaded: - print("Loaded plugin {}".format(plugin)) use_asyncio_event_loop() loop = asyncio.get_event_loop() - deadman_task = loop.create_task(relock_wallet_if_timed_out()) - loop.run_until_complete(wallet_repl()) + loop.create_task(relock_wallet_if_timed_out()) # deadman_task + wallet_repl() diff --git a/hermit/ui/relocker.py b/hermit/ui/relocker.py index a361ba2..902bb50 100644 --- a/hermit/ui/relocker.py +++ b/hermit/ui/relocker.py @@ -1,13 +1,14 @@ import asyncio -from .base import * import hermit.ui.state as state + async def relock_wallet_if_timed_out(): while True: await asyncio.sleep(0.5) await _handle_tick() + async def _handle_tick(): global Timeout global Live @@ -15,7 +16,7 @@ async def _handle_tick(): if state.Live: state.Live = False elif state.Wallet.unlocked() and state.Timeout > 0: - state.Timeout = state.Timeout - 1 + state.Timeout = state.Timeout - 0.5 if state.Timeout <= 0: state.Timeout = 0 state.Wallet.lock() diff --git a/hermit/ui/repl.py b/hermit/ui/repl.py index 43989cf..24b27fe 100644 --- a/hermit/ui/repl.py +++ b/hermit/ui/repl.py @@ -1,4 +1,3 @@ -import asyncio import traceback @@ -8,33 +7,39 @@ from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout -from .base import * -from .toolbar import * +from typing import Dict + +from .base import DeadTime +from .toolbar import bottom_toolbar + +from hermit.errors import HermitError import hermit.ui.state as state Bindings = KeyBindings() -def repl(commands:Dict, mode="", help_command=None): + +def repl(commands: Dict, mode: str = "", help_command=None): + """Start a REPL with the given `commands`, `mode`, and `help_command`.""" commandCompleter = WordCompleter( - [c for c in commands], - sentence=True # allows hyphens + [c for c in commands], sentence=True # allows hyphens ) oldSession = state.Session - state.Session = PromptSession(key_bindings=Bindings, - bottom_toolbar=bottom_toolbar, - refresh_interval=0.1) - state.Wallet.shards.interface.options = {'bottom_toolbar': bottom_toolbar} + state.Session = PromptSession( + key_bindings=Bindings, bottom_toolbar=bottom_toolbar, refresh_interval=0.1 + ) + state.Wallet.shards.interface.options = {"bottom_toolbar": bottom_toolbar} done = None with patch_stdout(): while not done: try: - unlocked = ' ' + unlocked = " " if state.Wallet.unlocked(): - unlocked = '*' - command_line = state.Session.prompt(HTML('{}{}> '.format(unlocked, mode)), - completer=commandCompleter, - ).split() + unlocked = "*" + command_line = state.Session.prompt( + HTML("{}{}> ".format(unlocked, mode)), + completer=commandCompleter, + ).split() if len(command_line) == 0: continue @@ -43,9 +48,11 @@ def repl(commands:Dict, mode="", help_command=None): command_fn = commands[command_line[0]] try: done = command_fn(*(command_line[1:])) - except TypeError as err: - if state.Debug: - raise err + except HermitError as e: + print(e) + # If we get a HermitError here, it generally means that someone + # didnt provide the right kinds of arguments to a command line + # so we should show them some help. if help_command is not None: help_command(command_line[0]) else: @@ -64,7 +71,7 @@ def repl(commands:Dict, mode="", help_command=None): print(err) if state.Debug: traceback.print_exc() - break + continue state.Session = oldSession @@ -76,16 +83,16 @@ def check_timer(): return False -@Bindings.add('', filter=check_timer) +@Bindings.add("", filter=check_timer) def escape_binding(event): pass -@Bindings.add('`', eager=True) +@Bindings.add("`", eager=True) def force_check_timer(event): check_timer() -@Bindings.add('escape', eager=True) +@Bindings.add("escape", eager=True) def force_lock(event): state.Wallet.lock() diff --git a/hermit/ui/shards.py b/hermit/ui/shards.py index 4dc703a..05db09f 100644 --- a/hermit/ui/shards.py +++ b/hermit/ui/shards.py @@ -1,12 +1,28 @@ from base64 import b64encode, b64decode -from .base import * +from prompt_toolkit import print_formatted_text, HTML + +from ..io import ( + read_data_from_animated_qrs, + display_data_as_animated_qrs, +) +from .base import command, clear_screen, disabled_command +from ..config import get_config import hermit.ui.state as state +from typing import Dict, List + + ShardCommands: Dict = {} +DisabledShardsCommands: List[str] = get_config().disabled_shards_commands + + def shard_command(name): - return command(name, ShardCommands) + if name not in DisabledShardsCommands: + return command(name, ShardCommands) + else: + return disabled_command(name, ShardCommands) # @@ -14,61 +30,62 @@ def shard_command(name): # -@shard_command('build-family-from-phrase') +@shard_command("build-family-from-phrase") def build_family_from_phrase(): """usage: build-family-from-phrase - Build a shard family from a BIP39 mnemonic phrase. + Build a shard family from a BIP39 mnemonic phrase. - Hermit will prompt you in turn for a shard configuration, a BIP39 - phrase, and random data from which to build shards. + Hermit will prompt you in turn for a shard configuration, a BIP39 + phrase, and random data from which to build shards. - Once the shards have been built, Hermit will ask you to name each - one and encrypt it with a password. + Once the shards have been built, Hermit will ask you to name each + one and encrypt it with a password. - These shards will be built in memory. You should run `write` to save - the shards to the filesystem. + These shards will be built in memory. You should run `write` to save + the shards to the filesystem. """ state.Wallet.shards.create_share_from_wallet_words() -@shard_command('build-family-from-random') +@shard_command("build-family-from-random") def build_family_from_random(): """usage: build-family-from-random - Build a shard family from random data. + Build a shard family from random data. - Hermit will prompt you for a shard configuration and then for random - data to use in building the shards. + Hermit will prompt you for a shard configuration and then for random + data to use in building the shards. - Once the shards have been built, Hermit will ask you to name each - one and encrypt it with a password. + Once the shards have been built, Hermit will ask you to name each + one and encrypt it with a password. - These shards will be built in memory. You should run `write` to save - the shards to the filesystem. + These shards will be built in memory. You should run `write` to save + the shards to the filesystem. """ state.Wallet.shards.create_random_share() -@shard_command('build-family-from-wallet') -def build_family_from_wallet(): - """usage: build-family-from-wallet +@shard_command("build-family-from-family") +def build_family_from_family(): + """usage: build-family-from-family - Build a new shard family from the current wallet. + Rebuild a shard family from an existing family. - Hermit will prompt you to unlock the wallet then for a shard - configuration and random data to use in building the shards. + Hermit will prompt you to unlock a particular shard family, ask + you to provide configuration for a new family, and random data to + use in building the new shards. - Once the shards have been built, Hermit will ask you to name each - one and encrypt it with a password. + Once the shards have been built, Hermit will ask you to name each + one and encrypt it with a password. - These shards will be built in memory. You should run `write` to save - the shards to the filesystem. + These shards will be built in memory. You should run `write` to save + the shards to the filesystem. - Running this command will first lock the wallet, forcing you to - unlock it again. + Running this command will first lock the wallet, forcing you to + unlock it again. """ state.Wallet.lock() @@ -76,7 +93,7 @@ def build_family_from_wallet(): # This is dangerous? -# +# # @shard_command('export-wallet-as-phrase') # def export_wallet_as_phrase(): # """usage: export-wallet-as-phrase @@ -95,192 +112,247 @@ def build_family_from_wallet(): # Shards # -@shard_command('list-shards') + +@shard_command("list-shards") def list_shards(): """usage: list-shards - List all shards. + List all shards. """ state.Wallet.shards.list_shards() -@shard_command('import-shard-from-phrase') +@shard_command("import-shard-from-phrase") def import_shard_from_phrase(name): """usage: import-shard-from-phrase NAME - Import a shard from an encrypted SLIP39 mnemonic phrase. + Import a shard from an encrypted SLIP39 mnemonic phrase. - The password for the shard will be the one encoded into the phrase. + The password for the shard will be the one encoded into the phrase. - The shard is imported in memory. You must run the `write` command - to save shards to the filesystem. + The shard is imported in memory. You must run the `write` command + to save shards to the filesystem. - Examples: + Examples: - shards> import-shard-from-phrase shard1 - ... - shards> write + shards> import-shard-from-phrase shard1 + ... + shards> write """ state.Wallet.shards.input_shard_words(name) -@shard_command('import-shard-from-qr') +@shard_command("import-shard-from-qr") def import_shard_from_qr(name): """usage import-shard-from-qr NAME - Import a shard from a QR code. + Import a shard from a QR code. - The shard is imported in memory. You must run the `write` command - to save shards to the filesystem. + The shard is imported in memory. You must run the `write` command + to save shards to the filesystem. - Examples: + Examples: - shards> import-shard-from-qr shard1 - ... - shards> write + shards> import-shard-from-qr shard1 + ... + shards> write """ - qr_data = reader.read_qr_code() + qr_data = read_data_from_animated_qrs() shard_data = b64decode(qr_data) state.Wallet.shards.import_shard_qr(name, shard_data) -@shard_command('export-shard-as-phrase') +@shard_command("export-shard-as-phrase") def export_shard_as_phrase(name): """usage: export-shard-as-phrase NAME - Print the encrypted SLIP39 mnemonic phrase for the given shard. + Print the encrypted SLIP39 mnemonic phrase for the given shard. """ state.Wallet.shards.reveal_shard(name) -@shard_command('export-shard-as-qr') + +@shard_command("export-shard-as-qr") def export_shard_as_qr(name): """usage export-shard-as-qr NAME - Display a QR code for the given shard. + Display a QR code for the given shard. """ - shard_data = b64encode(state.Wallet.shards.qr_shard(name)).decode('utf-8') - displayer.display_qr_code(shard_data, name=name) + shard_data = b64encode(state.Wallet.shards.qr_shard(name)).decode("utf-8") + display_data_as_animated_qrs(base64_data=shard_data) -@shard_command('copy-shard') -def copy_shard(original, copy): +@shard_command("copy-shard") +def copy_shard(old, new): """usage: copy-shard OLD NEW - Copy a shard, assigning the new copy its own password. + Copy a shard while assigning it a new password. You will also + have to provide the current password for the original shard. + + The new shard is created in memory. You must run the `write` + command to save the new shard to the filesystem. + + WARNING: The current password for the original shard cannot be + validated in isolation; if you accidentally enter the wrong + password for the existing shard the new shard will be corrupted. + + To avoid this outcome, this command will initiate an immediate + `unlock` of the wallet. When unlocking, you MUST select the newly + created shard as one of the shards you use to unlock the family. + See the example below for details: + + Example: + + shards> list-shards + alice (family:17489 group:1 member:1) + bob (family:17489 group:1 member:2) + shards> copy-shard alice alice-copy + + Change password for shard alice-copy (family:17489 group:1 member:2) + old password> ******** + new password> ******** + confirm> ******** + Choose shard + (options: alice, bob, alice-copy or to quit) + > alice-copy + Enter password for shard foo-2-copy (family:17489 group:1 member:2) + password> ******** + Choose shard + (options: bob or to quit) + > bob + Enter password for shard foo-1 (family:17489 group:1 member:1) + password> ******** + *shards> + shards> list-shards + alice (family:17489 group:1 member:1) + alice-copy (family:17489 group:1 member:1) + bob (family:17489 group:1 member:2) + *shards> write - The new shard is created in memory. You must run the `write` - command to save the new shard to the filesystem. + """ + state.Wallet.shards.copy_shard(old, new) + state.Wallet.lock() + state.Wallet.unlock(testnet=state.Testnet) + + +@shard_command("rename-shard") +def rename_shard(old, new): + """usage: rename-shard OLD NEW + + Rename a shard. This does not change the shard's data or + password. - Examples: + The change is made in memory. You must run the `write` command to + save the change to the filesystem. - shards> copy-shard apple pear - ... - shards> write + Examples: + + shards> rename-shard apple pear + shards> write """ - state.Wallet.shards.copy_shard(original, copy) + state.Wallet.shards.rename_shard(old, new) -@shard_command('delete-shard') +@shard_command("delete-shard") def delete_shard(name): """usage: delete_shard NAME - Delete a shard. + Delete a shard. - Hermit will prompt you to confirm whether or not you really want to - delete the given shard. + Hermit will prompt you to confirm whether or not you really want to + delete the given shard. - If you agree, the shard will be deleted in memory. You must run the - `write` command to delete the shard from the filesystem. + If you agree, the shard will be deleted in memory. You must run the + `write` command to delete the shard from the filesystem. - Examples: + Examples: - shards> delete-shard apple - ... - shards> write + shards> delete-shard apple + ... + shards> write """ state.Wallet.shards.delete_shard(name) + # # Storage # -@shard_command('write') +@shard_command("write") def write(): """usage: write - Write all shards in memory to the filesystem. + Write all shards in memory to the filesystem. - Metadata (number of shards, shard numbers and names) will be written - in plain text but all shard content will be encrypted with each - shard's password. + Metadata (number of shards, shard numbers and names) will be written + in plain text but all shard content will be encrypted with each + shard's password. - You may want to run the `persist` command after running the `write` - command. + You may want to run the `persist` command after running the `write` + command. """ state.Wallet.shards.save() -@shard_command('persist') +@shard_command("persist") def persist(): """usage: persist - Copies shards from the filesystem to persistent storage. + Copies shards from the filesystem to persistent storage. - Persistent storage defaults to the filesystem but can be configured - to live in a higher-security location such as a Trusted Platform - Module (TPM). + Persistent storage defaults to the filesystem but can be configured + to live in a higher-security location such as a Trusted Platform + Module (TPM). """ state.Wallet.shards.persist() -@shard_command('backup') +@shard_command("backup") def backup(): """usage: backup - Copies shards from the filesystem to backup storage. + Copies shards from the filesystem to backup storage. - Backup storage defaults to the filesystem but can be configured as - necessary. + Backup storage defaults to the filesystem but can be configured as + necessary. """ state.Wallet.shards.backup() -@shard_command('restore') +@shard_command("restore") def restore(): """usage: restore - Copies shards from backup storage to the filesystem. + Copies shards from backup storage to the filesystem. """ state.Wallet.shards.restore() -@shard_command('reload') +@shard_command("reload") def reload(): """usage: reload - Reload shards in memory from the filesystem. + Reload shards in memory from the filesystem. - This resets any changes made to shards in memory during the current - session. + This resets any changes made to shards in memory during the current + session. """ state.Wallet.lock() state.Wallet.shards.reload() - # @shard_command('clear') # def clear(): # """usage: clear @@ -294,7 +366,6 @@ def reload(): # state.Wallet.shards.clear_shards() - # @shard_command('initialize') # def initialize(): # """usage: initialize @@ -310,35 +381,39 @@ def reload(): # -@shard_command('quit') +@shard_command("quit") def quit_shards(): """usage: quit -Exit shards mode.""" + Exit shards mode.""" clear_screen() print("You are now in WALLET mode. Type 'help' for help.\n") return True -@shard_command('help') -def shard_help(*args,): +@shard_command("help") +def shard_help( + *args, +): """usage: help [COMMAND] - Prints out helpful information about Hermit's "shards" mode. + Prints out helpful information about Hermit's "shards" mode. - When called with an argument, prints out helpful information about - the command with that name. + When called with an argument, prints out helpful information about + the command with that name. - Examples: + Examples: - shards> help import-shard-from-phrase - shards> help build-shards-from-random + shards> help import-shard-from-phrase + shards> help build-shards-from-random """ if len(args) > 0 and args[0] in ShardCommands: print(ShardCommands[args[0]].__doc__) else: - print_formatted_text(HTML(""" + print_formatted_text( + HTML( + """ You are in SHARDS mode. In this mode, Hermit can create and manipulate shards and interact with storage. @@ -350,8 +425,8 @@ def shard_help(*args,): Create a shard family from a BIP39 mnemonic phrase build-family-from-random Create a shard family from random data - build-family-from-wallet - Create a shard family from the current wallet + build-family-from-family + Create a shard family from an existing family SHARDS list-shards List all existing shards @@ -365,6 +440,8 @@ def shard_help(*args,): Display the given shard as a QR code copy-shard OLD NEW Copy an existing shard with a new password + rename-shard OLD NEW + Rename an existing shard with a new name delete-shard NAME Delete a shard STORAGE @@ -391,4 +468,6 @@ def shard_help(*args,): quit Return to wallet mode - """)) + """ + ) + ) diff --git a/hermit/ui/state.py b/hermit/ui/state.py index a705bf5..8db4f49 100644 --- a/hermit/ui/state.py +++ b/hermit/ui/state.py @@ -1,15 +1,32 @@ +from typing import Optional from os import environ +from prompt_toolkit import PromptSession + from hermit.wallet import HDWallet Timeout = 0 - Live = False -Debug = 'DEBUG' in environ -Testnet = 'TESTNET' in environ +#: Whether the wallet is in DEBUG mode or not. +#: +#: +#: Defaults to ON if the `DEBUG` environment variable is set on Hermit +#: launch. +#: +#: Can be explicitly toggled using the `debug` command. +Debug = "DEBUG" in environ + +#: Whether the wallet is in testnet mode or not. +#: +#: Defaults to testnet if the `TESTNET` environment variable is set on +#: Hermit launch. +#: +#: Can be explicitly toggled using the `testnet` command. +Testnet = "TESTNET" in environ +#: The current wallet instance. Wallet = HDWallet() -Session = None +Session: Optional[PromptSession] = None diff --git a/hermit/ui/toolbar.py b/hermit/ui/toolbar.py index d26289a..283f4f1 100644 --- a/hermit/ui/toolbar.py +++ b/hermit/ui/toolbar.py @@ -1,11 +1,14 @@ -import asyncio - -from .base import * +from .base import DeadTime import hermit.ui.state as state -Bars = '#'*DeadTime + ' '*DeadTime + +bar_len = 15 +chars = ["#", "=", "-", "."] +Bars = [chars[0] * (bar_len - 1) + char + " " * bar_len for char in chars] + def bottom_toolbar(): + """Renders the bottom toolbar.""" debug_status = "" testnet_status = "" wallet_status = "" @@ -16,12 +19,13 @@ def bottom_toolbar(): if state.Testnet: testnet_status = "TESTNET" - b = DeadTime - state.Timeout + b = int(60 * (DeadTime - state.Timeout) / DeadTime) if state.Wallet.unlocked(): - wallet_status = "wallet UNLOCKED " + Bars[b:b+DeadTime] + bar = Bars[b % 4][b // 4 : b // 4 + bar_len] + wallet_status = "wallet UNLOCKED " + bar else: wallet_status = "wallet locked" - return "Hermit --- {0:<16} {1:>10} {2:>8}".format(wallet_status, - testnet_status, - debug_status) + return "Hermit --- {0:<32} {1:>7} {2:>5}".format( + wallet_status, testnet_status, debug_status + ) diff --git a/hermit/ui/wallet.py b/hermit/ui/wallet.py index 2472e54..e3217e7 100644 --- a/hermit/ui/wallet.py +++ b/hermit/ui/wallet.py @@ -1,116 +1,202 @@ -from prompt_toolkit import print_formatted_text -from json import dumps +from typing import Dict, List +from prompt_toolkit import print_formatted_text, HTML -from hermit.signer import (BitcoinSigner, - EchoSigner) +from hermit.signer import Signer -from .base import * +from ..errors import HermitError +from ..io import ( + read_data_from_animated_qrs, + display_data_as_animated_qrs, +) +from .base import command, clear_screen, disabled_command from .repl import repl from .shards import ShardCommands, shard_help import hermit.ui.state as state +from ..config import get_config -WalletCommands: Dict = {} +from buidl.hd import is_valid_bip32_path -def wallet_command(name): - return command(name, WalletCommands) +# from buidl.libsec_status import is_libsec_enabled +WalletCommands: Dict = {} -@wallet_command('sign-echo') -def echo(): - """usage: sign-echo +DisabledWalletCommands: List[str] = get_config().disabled_wallet_commands - Scans, "signs", and then displays a QR code. - Hermit will open a QR code reader window and wait for you to scan a - QR code. +def wallet_command(name): + """Create a new wallet command.""" + if name not in DisabledWalletCommands: + return command(name, WalletCommands) + else: + return disabled_command(name, WalletCommands) - Once scanned, the data in the QR code will be displayed on screen - and you will be prompted whether or not you want to "sign" the - "transaction". - If you agree, Hermit will open a window displaying the original QR - code. +@wallet_command("echo") +def echo(): + """usage: echo - Agreeing to "sign" does not require unlocking the wallet. + Print out the contents of a QR code to screen. """ - EchoSigner(state.Wallet, state.Session).sign(testnet=state.Testnet) - - -@wallet_command('sign-bitcoin') -def sign_bitcoin(): - """usage: sign-bitcoin + data = read_data_from_animated_qrs() + print_formatted_text(data) - Create a signature for a Bitcoin transaction. - Hermit will open a QR code reader window and wait for you to scan a - Bitcoin transaction signature request. +# +# This command is useful when developing on Hermit but it's also +# dangerous, so it's been commented out. +# - Once scanned, the details of the signature request will be displayed - on screen and you will be prompted whether or not you want to sign - the transaction. +# @wallet_command("qr") +# def qr(data=None): +# """usage: qr data - If you agree, Hermit will open a window displaying the signature as - a QR code. +# Display an animated QR code containing the given data. +# """ +# if data is None: +# raise HermitError("Data is required.") +# display_data_as_animated_qrs(data=data) - Creating a signature requires unlocking the wallet. - - """ - BitcoinSigner(state.Wallet, state.Session).sign(testnet=state.Testnet) +@wallet_command("sign") +def sign(unsigned_psbt_b64=None): + """usage: sign [UNSIGNED_PSBT] -@wallet_command('export-xpub') -def export_xpub(path): - """usage: export-xpub BIP32_PATH + Create a signature for a Bitcoin transaction. - Displays the extended public key (xpub) at a given BIP32 path. + Will read a base64-encoded unsigned PSBT from the camera. - Hermit will open a window displaying the extended public key as a QR - code. + Can also pass in the base64-encoded PSBT as a command-line argument. - Exporting an extended public key requires unlocking the wallet. + The details of the signature request will be displayed on screen and + you will be prompted whether or not you want to sign the transaction. - Examples: + If you agree, Hermit will display the signature. - wallet> export-xpub m/45'/0'/0' - wallet> export-xpub m/44'/60'/2' + Note: Creating a signature requires unlocking the wallet. If you + attempt to sign without first unlocking the wallet, Hermit will + later ask you to unlock the wallet. """ - xpub = state.Wallet.extended_public_key(path) - name = "Extended public key for BIP32 path {}:".format(path) - print_formatted_text("\n" + name) - print_formatted_text(xpub) - displayer.display_qr_code(dumps(dict(bip32_path=path, xpub=xpub)), name=name) - + Signer( + state.Wallet, + state.Session, + unsigned_psbt_b64=unsigned_psbt_b64, + testnet=state.Testnet, + ).sign() -@wallet_command('export-pub') -def export_pub(path): - """usage: export-pub BIP32_PATH - Displays the public key at a given BIP32 path. +@wallet_command("display-xpub") +def display_xpub(path=None): + """usage: display-xpub [BIP32_PATH] - Hermit will open a window displaying the public key as a QR code. + Displays the extended public key (xpub) at a given BIP32 path + (defaults to `m/48'/0'/0'/2'` on mainnet and `m/48'/1'/0'/2'` on + testnet). - Exporting a public key requires unlocking the wallet. + Note: Displaying an xpub requires unlocking the wallet. - Examples: + Examples: - wallet> export-pub m/45'/0'/0'/10/20 - wallet> export-pub m/44'/60'/2'/1/12 + wallet> display-xpub + wallet> display-xpub m/45/0'/0' """ - pubkey = state.Wallet.public_key(path) - name = "Public key for BIP32 path {}:".format(path) - print_formatted_text("\n" + name) - print_formatted_text(pubkey) - displayer.display_qr_code(dumps(dict(bip32_path=path, pubkey=pubkey)), name=name) - -@wallet_command('shards') -def shard_mode(): + if path is None: + # Use default paths if none are supplied + if state.Testnet: + path = "m/48'/1'/0'/2'" + else: + path = "m/48'/0'/0'/2'" + print_formatted_text(f"No path supplied, using default path {path}...\n") + + if not is_valid_bip32_path(path): + raise HermitError("Invalid BIP32 path.") + + xpub = state.Wallet.xpub(bip32_path=path, testnet=False, use_slip132=False) + xfp_hex = state.Wallet.xfp_hex + title = f"Extended Public Key Info for Seed ID {xfp_hex}" + xpub_descriptor = f"[{xfp_hex}/{path[2:]}]{xpub}" + print_formatted_text(f"\n{title}:\n{xpub_descriptor}") + display_data_as_animated_qrs(data=xpub_descriptor) + + +# @wallet_command("set-account-map") +# def set_account_map(account_map_str=""): +# """usage: set-account-map + +# Sets the account map (for change and receiving address validation) with the collection of public key records that you get from your Coordinator software + +# The account map can be supplied via CLI, or if left blank the camera will open to scan the account map from you Coordinator software. + +# Examples: + +# wallet> set-account-map +# wallet> set-account-map wsh(sortedmulti(2,[deadbeef/48h/0h/0h/2h]Zpub...)) + +# """ +# # FIXME: this should be persisted, but persistance is currently written using dangerous os.system() calls and only stored at the shards level (not the wallet level) +# # A major persistence refactor is needed + +# if ( +# state.Wallet.set_account_map( +# account_map_str=account_map_str, testnet=state.Testnet +# ) +# is True +# ): +# print_formatted_text("Account map set") +# else: +# print_formatted_text("Account map NOT set") +# # TODO: this is an ugly hack to get around the fact that we store xpriv/tpriv (which has a version byte), but what we really want to store is the secret (mnemonic) +# # Get rid of this in the future when Hermit state overhaul is complete +# state.Wallet.lock() + + +# @wallet_command("display-address") +# def display_address(offset=0, limit=10, is_change=0): +# """usage: display-address + +# Display bitcoin address(es) that belong to your account map. +# By default, this will display the first 10 addresses on the receive branch. + +# You can customize the offset, limit, and the receive/change branch as follows. + +# Examples: + +# wallet> display-address +# wallet> display-address 4 5 1 + +# """ +# if not state.Wallet.quorum_m or not state.Wallet.pubkey_records: +# print_formatted_text( +# "Account map not previously set. Please use set-account-map first to set an account map that we can derive bitcoin addresses from" +# ) +# return + +# # Format params +# offset = int(offset) +# limit = int(limit) +# is_change = bool(is_change) +# testnet = state.Testnet + +# to_print = f"{state.Wallet.quorum_m}-of-{len(state.Wallet.pubkey_records)} Multisig {'Change' if is_change else 'Receive'} Addresses" +# if not is_libsec_enabled(): +# # TODO: use libsec bindings in buidl for 100x performance increase +# to_print += "\n(this is ~100x faster if you install libsec)" +# print_formatted_text(to_print + ":") +# generator = state.Wallet.derive_child_address( +# testnet=testnet, is_change=is_change, offset=offset, limit=limit +# ) +# for cnt, address in enumerate(generator): +# print_formatted_text(f"#{cnt + offset}: {address}") + + +@wallet_command("shards") +def enter_shard_mode(): """usage: shards - Enter shards mode. + Enter shards mode. """ clear_screen() @@ -118,86 +204,89 @@ def shard_mode(): repl(ShardCommands, mode="shards", help_command=shard_help) -@wallet_command('quit') +@wallet_command("quit") def quit_hermit(): """usage: quit - Exit Hermit. + Exit Hermit. """ clear_screen() return True -@wallet_command('testnet') +@wallet_command("testnet") def toggle_testnet(): """usage: testnet - Toggle testnet mode on or off. + Toggle testnet mode on or off. - Being in testnet mode changes the way transactions are signed. + Being in testnet mode changes the way transactions are signed. - When testnet mode is active, the word TESTNET will appear in - Hermit's bottom toolbar. + When testnet mode is active, the word TESTNET will appear in + Hermit's bottom toolbar. """ state.Testnet = not state.Testnet -@wallet_command('help') -def wallet_help(*args,): +@wallet_command("help") +def wallet_help( + *args, +): """usage: help [COMMAND] - Prints out helpful information about Hermit's "wallet" mode (the - default mode). + Prints out helpful information about Hermit's "wallet" mode (the + default mode). - When called with an argument, prints out helpful information about - the command with that name. + When called with an argument, prints out helpful information about + the command with that name. - Examples: + Examples: - wallet> help sign-bitcoin - wallet> help export-xpub + wallet> help sign + wallet> help display-xpub """ if len(args) > 0 and args[0] in WalletCommands: print(WalletCommands[args[0]].__doc__) else: - print_formatted_text(HTML(""" + print_formatted_text( + HTML( + """ You are in WALLET mode. In this mode, Hermit can sign transactions and export public keys. The following commands are supported (try running `help COMMAND` to learn more about each command): - SIGNING - sign-bitcoin + WALLET + sign Produce a signature for a Bitcoin transaction - sign-echo - Echo a signature request back as a signature - KEYS - export-xpub BIP32_PATH + display-xpub [BIP32_PATH] Display the extended public key at the given BIP32 path - export-pub BIP32_PATH - Display the public key at the given BIP32 path - WALLET unlock Explicitly unlock the wallet lock Explictly lock the wallet - MISC + MODES shards Enter shards mode testnet Toggle testnet mode debug Toggle debug mode + MISC clear Clear screen quit Exit Hermit - """)) + """ + ) + ) + def wallet_repl(): - return repl(WalletCommands, mode='wallet', help_command=wallet_help) + """Start a REPL in wallet mode.""" + return repl(WalletCommands, mode="wallet", help_command=wallet_help) diff --git a/hermit/wallet.py b/hermit/wallet.py index 18941d9..501cf5b 100644 --- a/hermit/wallet.py +++ b/hermit/wallet.py @@ -1,56 +1,13 @@ -from re import match -from typing import Tuple - +from typing import Optional +from buidl.hd import ( + HDPrivateKey, + HDPublicKey, +) from mnemonic import Mnemonic -from pybitcointools import (bip32_ckd, - bip32_privtopub, - bip32_master_key, - bip32_deserialize, - bip32_extract_key) - -from hermit import shards +from hermit.shards import ShardSet from hermit.errors import HermitError -def compressed_private_key_from_bip32(bip32_xkey: str) -> bytes: - """Return a compressed private key from the given BIP32 path""" - bip32_args = bip32_deserialize(bip32_xkey) - # cut off 'compressed' byte flag (only for private key!) - return bip32_args[5][:-1] - - -def compressed_public_key_from_bip32(bip32_xkey: str) -> bytes: - """Return a compressed public key from the given BIP32 path""" - bip32_args = bip32_deserialize(bip32_xkey) - return bip32_args[5] - - -def _hardened(id: int) -> int: - hardening_offset = 2 ** 31 - return (hardening_offset + id) - - -def _decode_segment(segment: str) -> int: - if segment.endswith("'"): - return _hardened(int(segment[:-1])) - else: - return int(segment) - - -def bip32_sequence(bip32_path: str) -> Tuple[int, ...]: - """Turn a BIP32 path into a tuple of deriviation points - """ - bip32_path_regex = "^m(/[0-9]+'?)+$" - - if not match(bip32_path_regex, bip32_path): - raise HermitError("Not a valid BIP32 path.") - - return tuple( - _decode_segment(segment) - for segment in bip32_path[2:].split('/') - if len(segment) != 0) - - class HDWallet(object): """Represents a hierarchical deterministic (HD) wallet @@ -59,15 +16,49 @@ class HDWallet(object): """ def __init__(self) -> None: - self.root_priv = None - self.shards = shards.ShardSet() + self._root_xprv: Optional[str] = None + self._root_extended_private_key: Optional[HDPrivateKey] = None + self.xfp_hex = None # root fingerprint in hex + self.shards = ShardSet() self.language = "english" + # quorum m of all the public key records make up the account map (output descriptor) to use for validating a change/receive address: + # self.pubkey_records = [] + self.quorum_m = 0 + # self.hdpubkey_map = {} + + # + # Root xprv & private key + # + + @property + def root_xprv(self) -> Optional[str]: + return self._root_xprv + + @property + def root_extended_private_key(self): + return self._root_extended_private_key + + @root_xprv.setter # type: ignore + def root_xprv(self, xprv: str) -> None: + if xprv is None: + self._root_xprv = None + self._root_extended_private_key = None + else: + self._root_xprv = xprv + self._root_extended_private_key = HDPrivateKey.parse(xprv) + + # + # Locking + # def unlocked(self) -> bool: - return self.root_priv is not None + return self.root_xprv is not None + + def lock(self) -> None: + self.root_xprv = None # type: ignore - def unlock(self, passphrase: str = "") -> None: - if self.root_priv is not None: + def unlock(self, passphrase: str = "", testnet: bool = False) -> None: + if self.root_xprv is not None: return mnemonic = Mnemonic(self.language) @@ -76,25 +67,130 @@ def unlock(self, passphrase: str = "") -> None: words = self.shards.wallet_words() if mnemonic.check(words): seed = Mnemonic.to_seed(words, passphrase=passphrase) - self.root_priv = bip32_master_key(seed) + hd_obj = HDPrivateKey.from_seed(seed) + # Note that xprv conveys network info (xprv vs tprv) and this is only reset on locking/unlocking the wallet + # TODO: a more elegant way to persist this data? + self.root_xprv = hd_obj.xprv() # type: ignore + self.xfp_hex = ( + hd_obj.fingerprint().hex() + ) # later needed to identify us as cosigner else: raise HermitError("Wallet words failed checksum.") - def lock(self) -> None: - self.root_priv = None - - def extended_public_key(self, bip32_path: str) -> str: - self.unlock() - xprv = self.extended_private_key(bip32_path) - return bip32_privtopub(xprv) - - def public_key(self, bip32_path: str) -> str: - xpub = self.extended_public_key(bip32_path) - return bip32_extract_key(xpub) - - def extended_private_key(self, bip32_path: str) -> str: - self.unlock() - xprv = self.root_priv - for child_id in bip32_sequence(bip32_path): - xprv = bip32_ckd(xprv, child_id) - return str(xprv) + # + # Private Keys + # + + def private_key(self, bip32_path: str, testnet: bool = False): + self.unlock(testnet=testnet) + return self.root_extended_private_key.traverse(bip32_path).private_key + + # + # Extended Public Keys + # + + def extended_public_key( + self, bip32_path: str, testnet: bool = False + ) -> HDPublicKey: + # Will use whatever network the xprv/trpv is saved as from the unlock method + self.unlock(testnet=testnet) + return self.root_extended_private_key.traverse(path=bip32_path).pub + + def xpub( + self, bip32_path: str, testnet: bool = False, use_slip132: bool = False + ) -> str: + # hopefully, slip132 will be deprecated one day and this can be removed, BUT + # for now it is the only way to guarantee seemless Specter-Desktop compatibility when uploading an xpub (on setup) from Hermit + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#registered-hd-version-bytes + + # Will use whatever network the xprv/trpv is saved as from the unlock method + self.unlock(testnet=testnet) + hd_pubkey_obj = self.extended_public_key(bip32_path=bip32_path, testnet=testnet) + + if use_slip132: + if testnet is True: + p2wsh_version_byte = "02575483" + else: + p2wsh_version_byte = "02aa7ed3" + version: Optional[bytes] = bytes.fromhex(p2wsh_version_byte) + else: + # This will automatically determine if we are xpub or tpub + version = None + + return hd_pubkey_obj.xpub(version=version) + + # + # Addresses + # + + # def derive_child_address( + # self, + # testnet: bool = False, + # is_change: bool = False, + # offset: int = 0, + # limit: int = 10, + # ): + # return None + # # return generate_wshsortedmulti_address( + # # quorum_m=self.quorum_m, + # # key_records=self.pubkey_records, + # # is_testnet=testnet, + # # is_change=is_change, + # # offset=offset, + # # limit=limit, + # # ) + + # + # Account Map + # + + # def set_account_map(self, account_map_str: str, testnet: bool = False) -> bool: + # """ + # Returns True if we were able to set the account map, False otherwise + + # We would get False if the account map were invalid or our seed was not a part of it + # """ + # # TODO: this should be persisted along with shamir shares, but the way Hermit handles persistance is hackey (os.system() calls) + # # Also, persistance is currently on the shards class and not the Wallet class + + # if not account_map_str: + # # Get unsigned PSBT from webcam (QR gif) if not already passed in as an argument + # account_map_str = reader.read_qr_code("Scan the accountmap.") + + # # Will use whatever network the xprv/trpv is saved as from the unlock method + # wsh_dict = parse_wshsortedmulti(output_record=account_map_str) + + # # Confirm that our key is in this account map + # included = False + # for key_record in wsh_dict["key_records"]: + # # TODO: performance optimize this for large multisigs (99% of multisig re-uses the same paths) + # calculated_xpub = self.extended_public_key( + # bip32_path=key_record["path"], testnet=testnet, use_slip132=False + # ) + # if calculated_xpub == key_record["xpub_parent"]: + # included = True + # break + + # if calculated_xpub[:4] != key_record["xpub_parent"][:4]: + # # TODO: better way to convey this back to user? + # print( + # f"Network mismatch: account map has {key_record['xpub_parent'][:4]} and we are looking for {calculated_xpub[:4]}." + # ) + # print(f"Please toggle testnet and try again.") + # return False + + # if not included: + # return False + + # self.quorum_m = wsh_dict["quorum_m"] + # self.pubkey_records = wsh_dict["key_records"] + # hd_pubkey_map = {} + # for pubkey_record in self.pubkey_records: + # hd_pubkey_map[pubkey_record["xfp"]] = HDPublicKey.parse( + # pubkey_record["xpub_parent"] + # ) + # self.hdpubkey_map = hd_pubkey_map + # return True + + # def has_account_map(self): + # return self.quorum_m > 0 diff --git a/mypy.ini b/mypy.ini index f94a502..2612224 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,10 +2,52 @@ follow_imports = silent -[mypy-cv2] +[mypy-imageio.*] follow_imports = skip +ignore_missing_imports = True -[mypy-numpy] +[mypy-cv2.*] + +ignore_missing_imports = True + +[mypy-numpy.*] + +ignore_missing_imports = True + +[mypy-prompt_toolkit.*] + +ignore_missing_imports = True + +[mypy-bson.*] + +ignore_missing_imports = True + +[mypy-qrcode.*] + +ignore_missing_imports = True + +[mypy-pyzbar.*] + +ignore_missing_imports = True + +[mypy-PIL.*] + +ignore_missing_imports = True + +[mypy-buidl.*] + +ignore_missing_imports = True + +[mypy-mnemonic.*] + +ignore_missing_imports = True + +[mypy-FBpyGIF.*] + +ignore_missing_imports = True + +[mypy-shamir_mnemonic.*] + +ignore_missing_imports = True -follow_imports = skip diff --git a/pybitcointools b/pybitcointools deleted file mode 120000 index 7d6aa42..0000000 --- a/pybitcointools +++ /dev/null @@ -1 +0,0 @@ -vendor/pybitcointools/pybitcointools/ \ No newline at end of file diff --git a/requirements.frozen.txt b/requirements.frozen.txt deleted file mode 100644 index fdf5163..0000000 --- a/requirements.frozen.txt +++ /dev/null @@ -1,68 +0,0 @@ -alabaster==0.7.12 -asn1crypto==0.24.0 -atomicwrites==1.3.0 -attrs==19.1.0 -Babel==2.7.0 -bleach==3.1.2 -bson==0.5.8 -certifi==2019.6.16 -cffi==1.12.3 -chardet==3.0.4 -Click==7.0 -colorama==0.4.1 -coverage==4.5.4 -cryptography==2.7 -docutils==0.15.2 -ecdsa==0.13.3 -entrypoints==0.3 -flake8==3.7.7 -idna==2.8 -imagesize==1.1.0 -importlib-metadata==0.19 -imutils==0.5.1 -Jinja2==2.10.1 -MarkupSafe==1.1.1 -mccabe==0.6.1 -mnemonic==0.18 -more-itertools==7.2.0 -mypy==0.701 -mypy-extensions==0.4.1 -numpy==1.17.0 -opencv-python==3.4.3.18 -packaging==19.1 -pbkdf2==1.3 -Pillow==6.2.0 -pkginfo==1.5.0.1 -pluggy==0.12.0 -prompt-toolkit==2.0.7 -py==1.8.0 -pyAesCrypt==0.4.2 -pycodestyle==2.5.0 -pycparser==2.19 -pyflakes==2.1.1 -Pygments==2.4.2 -pyparsing==2.4.2 -pysha3==1.0.2 -pytest==4.4.0 -pytest-cov==2.6.1 -python-bitcoinlib==0.11.0 -python-dateutil==2.8.0 -pytz==2019.2 -PyYAML==5.1.1 -pyzbar==0.1.7 -qrcode==6.0 -readme-renderer==24.0 -requests==2.22.0 -requests-toolbelt==0.9.1 -shamir-mnemonic==0.1.0 -six==1.12.0 -snowballstemmer==1.9.0 -Sphinx==1.8.5 -sphinxcontrib-websupport==1.1.2 -tqdm==4.32.2 -twine==1.13.0 -typed-ast==1.3.5 -urllib3==1.25.3 -wcwidth==0.1.7 -webencodings==0.5.1 -zipp==0.5.2 diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..b59b3f6 --- /dev/null +++ b/requirements.in @@ -0,0 +1,28 @@ +ansicolors==1.1.8 +bson==0.5.8 +buidl==0.2.34 +imutils==0.5.1 +mnemonic==0.18 +opencv-python==4.5.1.48 +prompt-toolkit==2.0.7 +PyYAML==5.4 +pyzbar==0.1.7 +qrcode[pil]==6.0 +shamir-mnemonic==0.1 +fbpygif==1.0.5 +pycryptodome==3.11.0 + +# dev/test dependencies +sphinx==4.2.0 +sphinx-autodoc-typehints==1.12.0 +pytest==4.4.0 +pytest-cov==2.6.1 +black==21.9b0 +flake8==3.9.2 +mypy==0.800 +twine==1.13.0 +m2r2==0.3.2 + +# from security alerts +urllib3>=1.26.5 +Pillow>=8.3.2 diff --git a/requirements.txt b/requirements.txt index 8448520..a869087 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,75 @@ +alabaster==0.7.12 +ansicolors==1.1.8 +atomicwrites==1.4.0 +attrs==21.2.0 +Babel==2.9.1 +black==21.9b0 +bleach==4.1.0 bson==0.5.8 -ecdsa==0.13.3 -flake8==3.7.7 +buidl==0.2.36 +certifi==2021.10.8 +charset-normalizer==2.0.6 +click==7.1.2 +colorama==0.4.4 +coverage==6.0.1 +docutils==0.17.1 +FBpyGIF==1.0.5 +flake8==3.9.2 +idna==3.2 +imagesize==1.2.0 imutils==0.5.1 +Jinja2==3.0.2 +m2r2==0.3.2 +MarkupSafe==2.0.1 +mccabe==0.6.1 +mistune==0.8.4 mnemonic==0.18 -mypy==0.701 -opencv-python==3.4.3.18 +more-itertools==8.10.0 +mypy==0.800 +mypy-extensions==0.4.3 +numpy==1.21.2 +opencv-python==4.5.1.48 +packaging==21.0 +pathspec==0.9.0 +pbkdf2==1.3 +Pillow==8.3.2 +pkginfo==1.7.1 +platformdirs==2.4.0 +pluggy==1.0.0 prompt-toolkit==2.0.7 -pyAesCrypt==0.4.2 -pysha3==1.0.2 -pytest-cov==2.6.1 +py==1.10.0 +pycodestyle==2.7.0 +pycryptodome==3.11.0 +pyflakes==2.3.1 +Pygments==2.10.0 +pyparsing==2.4.7 pytest==4.4.0 -python-bitcoinlib==0.11.0 -PyYAML==5.1.1 +pytest-cov==2.6.1 +python-dateutil==2.8.2 +pytz==2021.3 +PyYAML==5.4 pyzbar==0.1.7 -qrcode[pil]==6.0 +qrcode==6.0 +readme-renderer==30.0 +regex==2021.10.8 +requests==2.26.0 +requests-toolbelt==0.9.1 +shamir-mnemonic==0.1.0 +six==1.16.0 +snowballstemmer==2.1.0 +Sphinx==4.2.0 +sphinx-autodoc-typehints==1.12.0 +sphinxcontrib-applehelp==1.0.2 +sphinxcontrib-devhelp==1.0.2 +sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==1.0.3 +sphinxcontrib-serializinghtml==1.1.5 +tomli==1.2.1 +tqdm==4.62.3 twine==1.13.0 -sphinx==1.8.5 -shamir-mnemonic==0.1 +typed-ast==1.4.3 +typing-extensions==3.10.0.2 +urllib3==1.26.7 +wcwidth==0.2.5 +webencodings==0.5.1 diff --git a/scripts/create_qr_code.py b/scripts/create_qr_code.py deleted file mode 100644 index a1ea928..0000000 --- a/scripts/create_qr_code.py +++ /dev/null @@ -1,23 +0,0 @@ -from sys import stdin, argv - -from hermit.qrcode.displayer import create_qr_code_image - -HELP = """usage: cat ... | python create_qr_code.py image.png - -This program reads input data over STDIN and transforms it into an -image file containing a Hermit-compatible QR code. - -The data contained in the QR code will be a wrapped & compressed -version of the input data. -""" - -if __name__ == '__main__': - - if ('--help' in argv) or ('-h' in argv) or len(argv) != 2: - print(HELP) - exit(1) - - output_path = argv[1] - data = stdin.read() - image = create_qr_code_image(data) - image.save(open(output_path, 'wb')) diff --git a/scripts/create_qr_code_animation.py b/scripts/create_qr_code_animation.py new file mode 100644 index 0000000..a29312e --- /dev/null +++ b/scripts/create_qr_code_animation.py @@ -0,0 +1,49 @@ +from sys import stdin, argv +from os.path import basename + +from hermit import ( + get_config, + qr_to_image, + create_qr_sequence, +) + +HELP = f"""usage: cat ... | python {basename(__file__)} OUTPUT_PATH [IS_BASE_64] + +This program reads input text over STDIN and transforms it into an +animation of a QR code sequence which it writes to OUTPUT_PATH. + +If IS_BASE_64 is not blank, the input data will be interpreted as an +already base64-encoded string.""" + +if __name__ == "__main__": + + if ("--help" in argv) or ("-h" in argv) or len(argv) not in (2, 3): + print(HELP) + exit(1) + + output_path = argv[1] + data = stdin.read().strip() + is_base64 = len(argv) == 3 + if len(data) == 0: + print("Input data is required.") + exit(2) + + if is_base64: + sequence = create_qr_sequence(base64_data=data) + else: + sequence = create_qr_sequence(data=data) + images = [qr_to_image(image) for image in sequence] + print( + f"Created {len(images)} image QR code sequence from {'base64' if is_base64 else 'plain'} input text." + ) + + qr_code_sequence_delay_ms = get_config().io["qr_code_sequence_delay"] + + images[0].save( + output_path, + save_all=True, + append_images=images[1:], + optimize=False, + duration=qr_code_sequence_delay_ms, + loop=0, + ) # loop forever diff --git a/scripts/create_qr_code_image.py b/scripts/create_qr_code_image.py new file mode 100644 index 0000000..409109a --- /dev/null +++ b/scripts/create_qr_code_image.py @@ -0,0 +1,23 @@ +from sys import stdin, argv +from os.path import basename + +from hermit.qr import qr_to_image, create_qr + +HELP = f"""usage: cat ... | python {basename(__file__)} OUTPUT_PATH + +This program reads input text over STDIN and transforms it into an +image containing a QR code which it writes to OUTPUT_PATH.""" + +if __name__ == "__main__": + + if ("--help" in argv) or ("-h" in argv) or len(argv) != 2: + print(HELP) + exit(1) + + output_path = argv[1] + data = stdin.read().strip() + if len(data) == 0: + print("Input data is required.") + exit(2) + + qr_to_image(create_qr(data)).save(output_path) diff --git a/scripts/generate_fixture_images.py b/scripts/generate_fixture_images.py deleted file mode 100644 index 40ca6b4..0000000 --- a/scripts/generate_fixture_images.py +++ /dev/null @@ -1,46 +0,0 @@ -import gzip - -import numpy -import qrcode -import json -import numpy as np - -import base64 - - -from pyzbar import pyzbar -from PIL import Image - -def generate_fixture_images(json_filename): - filename_base = json_filename.split('.json')[0] - with open(json_filename, 'r') as f: - test_vector = json.load(f) - - data = json.dumps(test_vector['request']) - data = data.encode('utf-8') - data = gzip.compress(data) - - data = base64.b32encode(data) - print(filename_base, "data length: ", len(data), " (must be <= 4296)") # - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4 - ) - qr.add_data(data) - qr.make(fit=True) - image = qr.make_image(fill_color="black", back_color="white") - image.save(filename_base + '.png') - - #loaded_image = Image.open(filename_base + '.png') - #decoded = pyzbar.decode(loaded_image) - - image_array = np.array(image.convert('RGB'))[:, :, ::-1].copy() - numpy.save(filename_base + '.npy',image_array) - -requests = ["tests/fixtures/opensource_bitcoin_test_vector_0.json" - ] - -for request in requests: - generate_fixture_images(request) diff --git a/scripts/generate_large_bitcoin_request.py b/scripts/generate_large_bitcoin_request.py deleted file mode 100644 index 7a49e4a..0000000 --- a/scripts/generate_large_bitcoin_request.py +++ /dev/null @@ -1,52 +0,0 @@ -from sys import argv -from os.path import basename -import string -import random - -# python scripts/generate_large_bitcoin_request.py > tests/fixtures/large_bitcoin_signature_request.json - -def generate_large_bitcoin_request(N): - header = """{ - - "inputs": [ - [ - "522102a567420d0ecb8ae1ac3794a6f901c9fdfa63a24476b8889d4c483dc7975f4ab321034a7496c358ee925043f5859bf7a191566fa070e3b75aac3f3bde6a670a8b6a8e2103666946ad4ff2b8c5c1ed6c8f5dd7f28769820cf35061ad5e02a45c5f05d54a1853ae", - "m/45'/1'/120'/20/26",""" - - footer = """ - ] - ], - - "outputs": [ - { - "address": "2NG4oZZZbBcBtUw6bv2KEJmbZgrdLTTf5CC", - "amount": 10000000 - }, - { - "address": "2MuK9EGZeXMLJTqVtv8eKrcbrvYg2pwD1te", - "amount": 27002781 - } - ] - -}""" - tmp1 = """ - { - "txid": \"""" - tmp2 = """\", - "n": 0, - "amount": """ - tmp3 = """ - }""" - input_array = [(tmp1 - + ''.join([random.choice(string.hexdigits[:16]) for x in range(64)]) - + tmp2 - + ''.join([random.choice(string.digits[1:]) for x in range(7)]) - + tmp3) for x in range(N)] - input_array = ','.join(input_array) - print(header + input_array + footer) - -if __name__ == '__main__': - if len(argv) < 2: - print("usage: {} NUM_INPUTS".format(basename(__file__))) - exit(1) - generate_large_bitcoin_request(int(argv[1])) diff --git a/scripts/read_data_from_animated_qrs.py b/scripts/read_data_from_animated_qrs.py new file mode 100644 index 0000000..54a51d4 --- /dev/null +++ b/scripts/read_data_from_animated_qrs.py @@ -0,0 +1,17 @@ +from os.path import basename +from sys import argv +from hermit import read_data_from_animated_qrs + +HELP = f"""usage: python {basename(__file__)} + +This program reads an animated sequence of QR images from the camera +and prints the data it contains.""" + +if __name__ == "__main__": + + if ("--help" in argv) or ("-h" in argv) or len(argv) != 1: + print(HELP) + exit(1) + + data = read_data_from_animated_qrs() + print(data) diff --git a/scripts/read_qr_code.py b/scripts/read_qr_code.py deleted file mode 100644 index 125e659..0000000 --- a/scripts/read_qr_code.py +++ /dev/null @@ -1,27 +0,0 @@ -from sys import argv - -from pyzbar import pyzbar -from PIL import Image - -from hermit.qrcode.format import decode_qr_code_data - -HELP = """usage: python read_qr_code.py image.png - -This program prints data from an image file containing a -Hermit-compatible QR code. - -The data contained in the QR code will be unwrapped and uncompressed -before printing. -""" - -if __name__ == '__main__': - - if ('--help' in argv) or ('-h' in argv) or len(argv) != 2: - print(HELP) - exit(1) - - input_path = argv[1] - image = Image.open(input_path) - for qrcode in pyzbar.decode(image): - print(decode_qr_code_data(qrcode.data)) - break diff --git a/scripts/sign_psbt_as_coordinator.py b/scripts/sign_psbt_as_coordinator.py new file mode 100644 index 0000000..9ba5542 --- /dev/null +++ b/scripts/sign_psbt_as_coordinator.py @@ -0,0 +1,37 @@ +from sys import stdin, argv +from os.path import basename + +from buidl import PSBT + +from hermit.coordinator import ( + COORDINATOR_SIGNATURE_KEY, + create_rsa_signature, +) + +HELP = f"""usage: cat ... | python {basename(__file__)} PRIVATE_KEY_PATH + +This program reads an unsigned PSBT (in base64) over STDIN, signs that +PSBT as a coordinator, and prints the resulting PSBT.. + +The RSA private key at PRIVATE_KEY_PATH is used for signing.""" + +if __name__ == "__main__": + + if ("--help" in argv) or ("-h" in argv) or len(argv) != 2: + print(HELP) + exit(1) + + private_key_path = argv[1] + + unsigned_psbt_base64 = stdin.read().strip() + if len(unsigned_psbt_base64) == 0: + print("Input PSBT is required.") + exit(2) + psbt = PSBT.parse_base64(unsigned_psbt_base64) + + message = unsigned_psbt_base64.encode("utf8") + signature = create_rsa_signature(message, private_key_path) + + psbt.extra_map[COORDINATOR_SIGNATURE_KEY] = signature + + print(psbt.serialize_base64()) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..41aece7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[flake8] +ignore=E125,E203,E226,E501,W503 +exclude=__init__.py,*/lib/*,*.venv3/* +max-line-length=127 diff --git a/setup.py b/setup.py index db8e6fe..2fa4693 100644 --- a/setup.py +++ b/setup.py @@ -2,15 +2,17 @@ import os import sys -if sys.version_info < (3,7): - sys.exit('Sorry, Python < 3.7 is not supported') + +if sys.version_info < (3, 5): + sys.exit("Sorry, Python < 3.5 is not supported") + def __path(filename): - return os.path.join(os.path.dirname(__file__), - filename) + return os.path.join(os.path.dirname(__file__), filename) -if os.path.exists(__path('build.info')): - build = open(__path('build.info')).read().strip() + +if os.path.exists(__path("build.info")): + build = open(__path("build.info")).read().strip() with open(__path("hermit/VERSION")) as version_file: version = version_file.read().strip() @@ -18,7 +20,7 @@ def __path(filename): with open("README.md", "r") as fh: long_description = fh.read() -requirementPath = __path('requirements.frozen.txt') +requirementPath = __path("requirements.txt") install_requires = [] if os.path.isfile(requirementPath): with open(requirementPath) as f: @@ -27,25 +29,22 @@ def __path(filename): setuptools.setup( name="hermit", version=version, - author="Unchained Capital Engineering", - author_email="engineering@unchained-capital.com", - description="Unchained Capital Hermit", + author="Unchained Capital", + author_email="hello@unchained-capital.com", + description="Air-gapped, sharded keystore for signing bitcoin transactions", long_description=long_description, long_description_content_type="text/markdown", - url="https://github.com/unchained-captial/hermit", + url="https://github.com/unchained-capital/hermit", packages=setuptools.find_packages(), classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ], - scripts=[ - 'bin/hermit' - ], + scripts=["bin/hermit"], install_requires=install_requires, data_files=[ - ('pybitcointools', ['pybitcointools/english.txt']), - ('hermit', ['hermit/wordlists/shard.txt', 'hermit/wordlists/wallet.txt']), + ("hermit", ["hermit/wordlists/shard.txt", "hermit/wordlists/wallet.txt"]), ], include_package_data=True, ) diff --git a/tests/.coveragerc b/tests/.coveragerc index 44dc682..1947f08 100644 --- a/tests/.coveragerc +++ b/tests/.coveragerc @@ -4,8 +4,10 @@ branch = True omit = *__init__.py .virtualenv/* - vendor/* - tests/ + tests/ + hermit/ui/* + hermit/display/* + hermit/camera/* [report] show_missing = True \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 5c54aa7..cf39910 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,34 +1,39 @@ import json -from unittest.mock import patch, create_autospec +from unittest.mock import create_autospec import pytest -from hermit.shards import ShardSet +from hermit.shards import ShardSet, Shard + @pytest.fixture() def trezor_bip39_vectors(): - with open("tests/fixtures/trezor_bip39_vectors.json", 'r') as f: + with open("tests/fixtures/trezor_bip39_vectors.json", "r") as f: vectors = json.load(f) return vectors + @pytest.fixture() def bip32_vectors(): - with open("tests/fixtures/bip32_vectors.json", 'r') as f: + with open("tests/fixtures/bip32_vectors.json", "r") as f: vectors = json.load(f) return vectors + @pytest.fixture() def unchained_vectors(): - with open("tests/fixtures/unchained_vectors.json", 'r') as f: + with open("tests/fixtures/unchained_vectors.json", "r") as f: vectors = json.load(f) return vectors + @pytest.fixture() def bitcoin_testnet_signature_request(): - with open("examples/signature_requests/bitcoin_testnet.json", 'r') as f: + with open("examples/signature_requests/bitcoin_testnet.json", "r") as f: request = json.load(f) return json.dumps(request) + @pytest.fixture() def opensource_wallet_words(): return "merge alley lucky axis penalty manage latin gasp virus captain wheel deal chase fragile chapter boss zero dirt stadium tooth physical valve kid plunge" @@ -36,31 +41,30 @@ def opensource_wallet_words(): @pytest.fixture() def fixture_opensource_shards(opensource_wallet_words): - mock_interface = create_autospec(WalletWordUserInterface) - mock_shard1 = create_autospec(WalletWordsShard) - mock_shard2 = create_autospec(WalletWordsShard) - mock_shard1.name = 'shard1' - mock_shard2.name = 'shard2' + mock_shard1 = create_autospec(Shard) + mock_shard2 = create_autospec(Shard) + mock_shard1.name = "shard1" + mock_shard2.name = "shard2" mock_shard1.number = 1 mock_shard2.number = 2 mock_shard1.count = 2 mock_shard2.count = 2 mock_shard1.words.return_value = opensource_wallet_words.split()[:6] mock_shard2.words.return_value = opensource_wallet_words.split()[6:] - mock_shard1.to_str.return_value = "{0} ({1}/{2})".format(mock_shard1.name, - mock_shard1.number, - mock_shard1.count) - mock_shard2.to_str.return_value = "{0} ({1}/{2})".format(mock_shard2.name, - mock_shard2.number, - mock_shard2.count) - - mock_shard1.to_json.return_value = json.dumps([0,2,'encrypted1','salt1']) - mock_shard2.to_json.return_value = json.dumps([1,2,'encrypted2','salt2']) - - mock_shards = {mock_shard1.name: mock_shard1, - mock_shard2.name: mock_shard2} + mock_shard1.to_str.return_value = "{0} ({1}/{2})".format( + mock_shard1.name, mock_shard1.number, mock_shard1.count + ) + mock_shard2.to_str.return_value = "{0} ({1}/{2})".format( + mock_shard2.name, mock_shard2.number, mock_shard2.count + ) + + mock_shard1.to_json.return_value = json.dumps([0, 2, "encrypted1", "salt1"]) + mock_shard2.to_json.return_value = json.dumps([1, 2, "encrypted2", "salt2"]) + + mock_shards = {mock_shard1.name: mock_shard1, mock_shard2.name: mock_shard2} return mock_shards + @pytest.fixture() def fixture_opensource_shard_set(opensource_wallet_words): mock_shard_set = create_autospec(ShardSet) @@ -69,33 +73,32 @@ def fixture_opensource_shard_set(opensource_wallet_words): def prep_full_vector(filename): - with open(filename, 'r') as f: + with open(filename, "r") as f: test_vector = json.load(f) - test_vector['request_json'] = json.dumps(test_vector['request']) - test_vector['expected_display'] = (test_vector['expected_display'] - + json.dumps(test_vector['expected_signature'], - indent=2) - + "\n") + test_vector["request_json"] = json.dumps(test_vector["request"]) + test_vector["expected_display"] = ( + test_vector["expected_display"] + + json.dumps(test_vector["expected_signature"], indent=2) + + "\n" + ) return test_vector @pytest.fixture def fixture_opensource_bitcoin_vector(): - return prep_full_vector( - "tests/fixtures/opensource_bitcoin_test_vector_0.json") + return prep_full_vector("tests/fixtures/opensource_bitcoin_test_vector_0.json") def fixture_opensource_bitcoin_vector_0(): - return prep_full_vector( - "tests/fixtures/opensource_bitcoin_test_vector_0.json") + return prep_full_vector("tests/fixtures/opensource_bitcoin_test_vector_0.json") def fixture_opensource_bitcoin_vector_1(): - return prep_full_vector( - "tests/fixtures/opensource_bitcoin_test_vector_1.json") + return prep_full_vector("tests/fixtures/opensource_bitcoin_test_vector_1.json") -@pytest.fixture(params=[fixture_opensource_bitcoin_vector_0, - fixture_opensource_bitcoin_vector_1]) +@pytest.fixture( + params=[fixture_opensource_bitcoin_vector_0, fixture_opensource_bitcoin_vector_1] +) def fixture_opensource_bitcoin_vectors(request): return request.param() diff --git a/tests/fixtures/bitcoin_signature_request.json b/tests/fixtures/bitcoin_signature_request.json deleted file mode 100644 index 4be9add..0000000 --- a/tests/fixtures/bitcoin_signature_request.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - - "inputs": [ - [ - "522102a567420d0ecb8ae1ac3794a6f901c9fdfa63a24476b8889d4c483dc7975f4ab321034a7496c358ee925043f5859bf7a191566fa070e3b75aac3f3bde6a670a8b6a8e2103666946ad4ff2b8c5c1ed6c8f5dd7f28769820cf35061ad5e02a45c5f05d54a1853ae", - "m/45'/1'/120'/20/26", - { - "txid": "c3ab435fb94b6b4c9de6450a086b1eea0c5780f6bcbeaee51170b3b380e8c886", - "index": 0, - "amount": 100000000 - } - ] - ], - - "outputs": [ - { - "address": "2NG4oZZZbBcBtUw6bv2KEJmbZgrdLTTf5CC", - "amount": 90000000 - } - ] - -} diff --git a/tests/fixtures/bitcoin_signature_request.png b/tests/fixtures/bitcoin_signature_request.png deleted file mode 100644 index 30494d6..0000000 Binary files a/tests/fixtures/bitcoin_signature_request.png and /dev/null differ diff --git a/tests/fixtures/coordinator.pem b/tests/fixtures/coordinator.pem new file mode 100644 index 0000000..e72fdb1 --- /dev/null +++ b/tests/fixtures/coordinator.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXwIBAAKBgQDDm6LRbZLXjha+bODC7SID5SKYWfhwHNJj9fTI9wxankYzMuSM +UiSq29HBmwPkVtFzFdLuYKFnc6+1pIwXHP+Xf7PcwO4esRMkzEZUK2IR5BniarMT +Q9/sBXf7ttj6Nhp1LTQ2aZ4fjGdI9ZWj/hcJY7wY56KIRZHF+/rAzXNFfQIDAQAB +AoGBAL//3GlE7IW4aoqvxE6RBHpeRv7UEQ+6uqhzm7pHBFFOWgmXQs6ZMnSjH9ix +l7hhn2UfXtOs9cDdxPK+eOOXCyiqUhhVeTdrwYkEI9zfvUX6r5Jo/zfaJtFB9+8g +zJr3ABtV18MlESSFHYoFJ9SjZhsdqRJM7at/zta3g1Zt4tPhAkEA4qYG2qSuRVLb +spd2yuo+dc7SAYDSytYiA40x8kDv/E7sFGHDPiUvrBUC7rfVpNoPtUk3DDJ8nkP3 +Hn0HSA8KGwJBANzwiPvFQ9Rc96vDTMqagVsKq3Q8uQiQYbT2d8kBHkzqscrnrl36 ++/MIgtMPfwe5lzHzN3lxquEsi2CuutJN6EcCQQCn0oX6ubvsyviwmeS1RZOwSc9I +m6n51WrkNFWKarkImyvFv8oBJynQgtJkDq1cXrcI5kijeHK8Adlmsu+EVNaHAkEA +m2/SP6cB2Hbre/jznppipUV1aFqMJv1E8EZx8YUK5zw6hzDF2LKJ7Oqw94IwcaPd +PjQJdDRG7xIioIttPiW3YwJBAJ8AviflkzhUyHnrWoSpLqCTYWn1VAu/z5ByEVkA +W5JebxXwmWzxI4uOaionoNEGs2Zf7XhMIDU6XnAT+Sl17WY= +-----END RSA PRIVATE KEY----- diff --git a/tests/fixtures/coordinator.pub b/tests/fixtures/coordinator.pub new file mode 100644 index 0000000..de404af --- /dev/null +++ b/tests/fixtures/coordinator.pub @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDDm6LRbZLXjha+bODC7SID5SKY +WfhwHNJj9fTI9wxankYzMuSMUiSq29HBmwPkVtFzFdLuYKFnc6+1pIwXHP+Xf7Pc +wO4esRMkzEZUK2IR5BniarMTQ9/sBXf7ttj6Nhp1LTQ2aZ4fjGdI9ZWj/hcJY7wY +56KIRZHF+/rAzXNFfQIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/tests/fixtures/hello_world.txt b/tests/fixtures/hello_world.txt new file mode 100644 index 0000000..af5626b --- /dev/null +++ b/tests/fixtures/hello_world.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/tests/fixtures/largest_bitcoin_signature_request.json b/tests/fixtures/largest_bitcoin_signature_request.json deleted file mode 100644 index 302ca12..0000000 --- a/tests/fixtures/largest_bitcoin_signature_request.json +++ /dev/null @@ -1,271 +0,0 @@ -{ - - "inputs": [ - [ - "522102a567420d0ecb8ae1ac3794a6f901c9fdfa63a24476b8889d4c483dc7975f4ab321034a7496c358ee925043f5859bf7a191566fa070e3b75aac3f3bde6a670a8b6a8e2103666946ad4ff2b8c5c1ed6c8f5dd7f28769820cf35061ad5e02a45c5f05d54a1853ae", - "m/45'/1'/120'/20/26", - { - "txid": "0be9eebe5a7c317073ddabd956639388c6e6e8ec85b23245c9e66449db448a3c", - "n": 0, - "amount": 8485321 - }, - { - "txid": "a9b9a4af7818998d87f2eecd9eb81d69fd8b44f42d4a6af9cdc775352a7998ab", - "n": 0, - "amount": 8849593 - }, - { - "txid": "7740068d0283ed79e642865c51b1828887f834d3b9b486d303c08d680a0223c7", - "n": 0, - "amount": 8118297 - }, - { - "txid": "01bfdbe15869f8784ff884e9f0390046564c6425f6659f5edd3104fa3ec38049", - "n": 0, - "amount": 2649743 - }, - { - "txid": "68200722966e49182af5300ed1b6d4992c7ed665a6f6248159479abe6d341653", - "n": 0, - "amount": 4895281 - }, - { - "txid": "ddd5662a81c1c144a44788a7879a7543a1e97d77f1ee2f2054adbc2a9445f4b0", - "n": 0, - "amount": 6545871 - }, - { - "txid": "74187eaa13badfd04a49fbd5d4ab0b215cba9423b1968b21af8c31db06adbfaa", - "n": 0, - "amount": 7531798 - }, - { - "txid": "03e4fa718c564cba0a72291a282c560c5c8ae7e73df1f4178abbbda99b239d9d", - "n": 0, - "amount": 9372778 - }, - { - "txid": "7a0a4d541edddb3cd910112d2bdf20e871c6728d34de3a18ee8b37081f2b0ba2", - "n": 0, - "amount": 6737176 - }, - { - "txid": "25281e211fc1bd414b60671200794881f4a43fcdd6caa0b18be9fce56ded8fb2", - "n": 0, - "amount": 4838263 - }, - { - "txid": "2628a55ce94b93292e6f13bb154493173b815c930259a6f83d1aaff57a5cc7b4", - "n": 0, - "amount": 2322637 - }, - { - "txid": "a58aedd82d2022cb8a02d0ebd77e366f272247dbfe466fbd5da3a6fabd193952", - "n": 0, - "amount": 3253332 - }, - { - "txid": "9e0068ad3694f3e06a5956609530f3e372b7c39abc91d4b221651838025ee226", - "n": 0, - "amount": 3762758 - }, - { - "txid": "504a7a6712e224a73aee69f5897fa9d7dc3245f48bd8a296696bc353674c6533", - "n": 0, - "amount": 1174433 - }, - { - "txid": "8131dd5475119bd85b43811d9e76c7124898998df734bb813337881da616c5f2", - "n": 0, - "amount": 3985261 - }, - { - "txid": "d8090a73569611d8d6e9b8841394db52b56607e117e7b757ad7fc8ce861e00ea", - "n": 0, - "amount": 1998916 - }, - { - "txid": "1157075caccb338242a768c3efae4f729954bd4fbf535142a4b5696a58bb623a", - "n": 0, - "amount": 3731797 - }, - { - "txid": "6fb787894f2fbcb423cefd5098f1f92628841cccd39312324bb2248dc2c9e7f8", - "n": 0, - "amount": 5516748 - }, - { - "txid": "1030175f03e131acb570c5d280e69496701e01da4367448aa55616ab8c4fc90c", - "n": 0, - "amount": 2197398 - }, - { - "txid": "66be3e173ffadb90ccae320e0e89280decf53398406f59f81afd04681f708c7a", - "n": 0, - "amount": 1146787 - }, - { - "txid": "c131bc1864717a410fadbbc0e07855d88e823ddd6c5cbf4222b6fef888da16e1", - "n": 0, - "amount": 7284111 - }, - { - "txid": "31a07076e73b81c9381003aaaef4aa4428acc9305136aef92c0e02c31d65fbb2", - "n": 0, - "amount": 2399567 - }, - { - "txid": "fd6e4d67305d0e4e27a8073383254063b1603438a16915d6ab98b071b66e2b3d", - "n": 0, - "amount": 9622598 - }, - { - "txid": "dc31e70337930ffb223d6290c232ebb0c8afed9295fef9d663c67d1a08c8935e", - "n": 0, - "amount": 5298997 - }, - { - "txid": "e1cca54965cf4a03b902c55897428db06b37598a3bae9d57901591bcdf31a084", - "n": 0, - "amount": 3817245 - }, - { - "txid": "012ccb7b6b08bb5c7852641225e4c1601ff2110c3c8e7385983fd9ceed528b1d", - "n": 0, - "amount": 5159911 - }, - { - "txid": "318d47c1fa0b42c6b13ff2bf9346c95101ee6c06936d7f7572de2efb491bff62", - "n": 0, - "amount": 6935151 - }, - { - "txid": "6101871bd59d0e3c5728d00163767b3326b3b1d0dcd48cac1c45c27261e0ae6e", - "n": 0, - "amount": 8382959 - }, - { - "txid": "57602a029021fbb9720bc4df836baaf8749aea18e61480bd8c3c6442e26d387f", - "n": 0, - "amount": 8445726 - }, - { - "txid": "13304c2d441c26936af7f31a1dd34a681b870dfc683529f1031c2c35b8d2386b", - "n": 0, - "amount": 5258931 - }, - { - "txid": "debd06166bffd1e9f101f3bf7b14a19a8f7839ab49b59eb4f8be29f61f033427", - "n": 0, - "amount": 5714857 - }, - { - "txid": "40dfdddc20686654eaf0536001694a7ba373145d230948c43e8b7acc7fdb2f7b", - "n": 0, - "amount": 4473219 - }, - { - "txid": "5eeef7fc5af8467678e140cc39e920c31b4d534f16c86cb761d4a02607f3d11f", - "n": 0, - "amount": 6387816 - }, - { - "txid": "e770ce6b7c7a36f4d94993b93a794e2c58a04d84cf6208e165cce8246f4d1bb1", - "n": 0, - "amount": 6267575 - }, - { - "txid": "cc16b97abd27ed17fbbf12ba694c58f1240e43b1aae4274ddb62c17360962c6f", - "n": 0, - "amount": 2773854 - }, - { - "txid": "3746947a0287da0cd1c763b15539b6aba3a042002dd047c025f57ce0e77eea5a", - "n": 0, - "amount": 4964351 - }, - { - "txid": "e211968e8c5c4d8250e1057a79d74710c15e7c1c0c060dd1d0c012104f10104c", - "n": 0, - "amount": 8287963 - }, - { - "txid": "5ccd89cc962e8a4cf192ffb777f3633ef55eda415ec5c70e7a6208c19df13587", - "n": 0, - "amount": 7963447 - }, - { - "txid": "9576f1c3711a7abe77c1a537a3af745db5878a0c92cf223e6eae7d3cc75401f2", - "n": 0, - "amount": 2755469 - }, - { - "txid": "bd84558992c19d6990fa0ea8d6b5ec6fbaabd778e75e072a7e29b97f6d794a28", - "n": 0, - "amount": 3415163 - }, - { - "txid": "4d95e58be50c96ede110d93cccc99c81d7d7a4380c8c774519a3c6fe11dc531d", - "n": 0, - "amount": 5767491 - }, - { - "txid": "079737b6fba0b13926d2de771976623dff992881c1c871c3af764ba27377327e", - "n": 0, - "amount": 3133178 - }, - { - "txid": "648a122e5c3048ecb371cf7ccbe6c9627c19a6344eccdc87f6646ef55cdb1901", - "n": 0, - "amount": 7699929 - }, - { - "txid": "b99ada7dd6a5f9cf82d7a3ba53e7348bbc0c32442d9a8597ceb6deaeef39312b", - "n": 0, - "amount": 7587431 - }, - { - "txid": "23d0692631ef618c0ef959ddd9c588809623800366701f7895679960dcd3984f", - "n": 0, - "amount": 9137596 - }, - { - "txid": "aa96d00c0f3dfe25a7ab48ad11346594dd8c484836080fcc884e1dff4d69fc17", - "n": 0, - "amount": 3699871 - }, - { - "txid": "8ecac1da6260914cd6ca8d57366245b88ab3c99a7f0c219536f6c643144555d3", - "n": 0, - "amount": 9912555 - }, - { - "txid": "b00794f2174ddcf04b86d9dfc0e44c5a317b2e9f6df0e5d2dff1ca8c01178950", - "n": 0, - "amount": 2511387 - }, - { - "txid": "697f458d34dbf68c13d73e45e7fcf712de0aa0b306d885bdfbac40330a043660", - "n": 0, - "amount": 4377913 - }, - { - "txid": "8e77ca9f368928c4fdfdae275ac63d9952a6257902d16fe7f85c4660bfb8bfa7", - "n": 0, - "amount": 5382666 - } - ] - ], - - "outputs": [ - { - "address": "2NG4oZZZbBcBtUw6bv2KEJmbZgrdLTTf5CC", - "amount": 10000000 - }, - { - "address": "2MuK9EGZeXMLJTqVtv8eKrcbrvYg2pwD1te", - "amount": 27002781 - } - ] - -} diff --git a/tests/fixtures/largest_bitcoin_signature_request.png b/tests/fixtures/largest_bitcoin_signature_request.png deleted file mode 100644 index 424f746..0000000 Binary files a/tests/fixtures/largest_bitcoin_signature_request.png and /dev/null differ diff --git a/tests/fixtures/lorem_ipsum.txt b/tests/fixtures/lorem_ipsum.txt new file mode 100644 index 0000000..46a8838 --- /dev/null +++ b/tests/fixtures/lorem_ipsum.txt @@ -0,0 +1 @@ +Sed ut perspiciatis, unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam eaque ipsa, quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt, explicabo. Nemo enim ipsam voluptatem, quia voluptas sit, aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos, qui ratione voluptatem sequi nesciunt, neque porro quisquam est, qui dolorem ipsum, quia dolor sit, amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt, ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit, qui in ea voluptate velit esse, quam nihil molestiae consequatur, vel illum, qui dolorem eum fugiat, quo voluptas nulla pariatur? [33] At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum deleniti atque corrupti, quos dolores et quas molestias excepturi sint, obcaecati cupiditate non provident, similique sunt in culpa, qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio, cumque nihil impedit, quo minus id, quod maxime placeat, facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet, ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. diff --git a/tests/fixtures/qrdata/bcur_6_part.json b/tests/fixtures/qrdata/bcur_6_part.json new file mode 100644 index 0000000..566f4fb --- /dev/null +++ b/tests/fixtures/qrdata/bcur_6_part.json @@ -0,0 +1,11 @@ +{ + "data": " All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\nAll work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.", + "urls": [ + "ur:bytes/1of6/3r5dvxnv36zanwk3ldk8wf9dn6p45yf0agtn4sdqr2ljl23gndusqdy0r6/tyzp2gpqyqszqgpqyqszqgpqyqszqgzpd3kzqam0wf4jqctwvssxumeqwpkxz7fqd4skketnyp9xzcmtypsjqer4d3kzqcn00yhq5gpqyqszqgpqyqszqgpqyqszqgpqg9kxcgrhdaexkgrpdejzqmn0ypcxccteypkkz6m9wvsy5ctrdvsxzgryw4kxcgrzdaujuz3qyqszqgpqypqkcmpqwahhy6eqv9hxggrwdus8qmrp0ysx6cttv4ejqjnpvd4jqcfqv36kcmpqvfhhjts2y", + "ur:bytes/2of6/3r5dvxnv36zanwk3ldk8wf9dn6p45yf0agtn4sdqr2ljl23gndusqdy0r6/qszqstvdss8wmmjdvsxzmnyyphx7grsd3shjgrdv94k2ueqffskx6eqvysxgatvdssxymme9c9zqgpqyqsyzmrvypmk7untypskuepqdehjqurvv9ujqmtpddjhxgz2v93kkgrpypj82mrvyp3x77fwpgszqgpqyqszqgpqyqszqgpqypqkcmpqwahhy6eqv9hxggrwdus8qmrp0ysx6cttv4ejqjnpvd4jqcfqv36kcmpqvfhhjts2yqszqgpqg9kxcgrhdaexkgrpdejzqmn0yp", + "ur:bytes/3of6/3r5dvxnv36zanwk3ldk8wf9dn6p45yf0agtn4sdqr2ljl23gndusqdy0r6/cxccteypkkz6m9wvsy5ctrdvsxzgryw4kxcgrzdaujuz3qyqszqgpqyqszqgzpd3kzqam0wf4jqctwvssxumeqwpkxz7fqd4skketnyp9xzcmtypsjqer4d3kzqcn00yhq5gpqyqszqgpqyqszqgpqyqszqgzpd3kzqam0wf4jqctwvssxumeqwpkxz7fqd4skketnyp9xzcmtypsjqer4d3kzqcn00yhq5gpqyqszqstvdss8wmmjdvsxzmnyyphx7grsd3shjgrdv94k2ueqffs", + "ur:bytes/4of6/3r5dvxnv36zanwk3ldk8wf9dn6p45yf0agtn4sdqr2ljl23gndusqdy0r6/kx6eqvysxgatvdssxymme9c9zqgzpd3kzqam0wf4jqctwvssxumeqwpkxz7fqd4skketnyp9xzcmtypsjqer4d3kzqcn00yhq5gpqypqkcmpqwahhy6eqv9hxggrwdus8qmrp0ysx6cttv4ejqjnpvd4jqcfqv36kcmpqvfhhjts2yqszqgpqyqszqgzpd3kzqam0wf4jqctwvssxumeqwpkxz7fqd4skketnyp9xzcmtypsjqer4d3kzqcn00yhq5gpqyqszqgpqypqkcmpqwahh", + "ur:bytes/5of6/3r5dvxnv36zanwk3ldk8wf9dn6p45yf0agtn4sdqr2ljl23gndusqdy0r6/y6eqv9hxggrwdus8qmrp0ysx6cttv4ejqjnpvd4jqcfqv36kcmpqvfhhjts2yqszqgpqyqszqgpqypqkcmpqwahhy6eqv9hxggrwdus8qmrp0ysx6cttv4ejqjnpvd4jqcfqv36kcmpqvfhhjts2yqszqgzpd3kzqam0wf4jqctwvssxumeqwpkxz7fqd4skketnyp9xzcmtypsjqer4d3kzqcn00yhq5gpqg9kxcgrhdaexkgrpdejzqmn0ypcxccteypkkz6m9wvsy5ctrdvsxz", + "ur:bytes/6of6/3r5dvxnv36zanwk3ldk8wf9dn6p45yf0agtn4sdqr2ljl23gndusqdy0r6/gryw4kxcgrzdaujuzjpd3kzqam0wf4jqctwvssxumeqwpkxz7fqd4skketnyp9xzcmtypsjqer4d3kzqcn00yhq5gpqyqszqgpqyqszqgpqypqkcmpqwahhy6eqv9hxggrwdus8qmrp0ysx6cttv4ejqjnpvd4jqcfqv36kcmpqvfhhjts2yqszqgpqyqszqgpqyqszqgpqg9kxcgrhdaexkgrpdejzqmn0ypcxccteypkkz6m9wvsy5ctrdvsxzgryw4kxcgrzdaujuhyy4qz" + ] +} diff --git a/tests/fixtures/qrdata/bcur_singles.json b/tests/fixtures/qrdata/bcur_singles.json new file mode 100644 index 0000000..5103ef8 --- /dev/null +++ b/tests/fixtures/qrdata/bcur_singles.json @@ -0,0 +1,14 @@ +[ + { + "data": "aaaa", + "urls": [ + "ur:bytes/ysypyck5etagxt08hzn6vcnwam3lgupp0uhcs7n8pg0wmen32p3qate5eg/gd56dxsyew2w5" + ] + }, + { + "data": " All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.", + "urls": [ + "ur:bytes/7cysmg39e8t0ve3g47c27ccpay5pn24m5cd063w98r8u4n9klyls3dgqld/tzwzqgpqypqkcmpqwahhy6eqv9hxggrwdus8qmrp0ysx6cttv4ejqjnpvd4jqcfqv36kcmpqvfhhjts2yqszqgpqyqszqgpqyqszqstvdss8wmmjdvsxzmnyyphx7grsd3shjgrdv94k2ueqffskx6eqvysxgatvdssxymme9c9zqgpqyqszqgpqg9kxcgrhdaexkgrpdejzqmn0ypcxccteypkkz6m9wvsy5ctrdvsxzgryw4kxcgrzdaujufvdec9" + ] + } +] diff --git a/tests/fixtures/qrdata/single_qr.json b/tests/fixtures/qrdata/single_qr.json new file mode 100644 index 0000000..4f11eaa --- /dev/null +++ b/tests/fixtures/qrdata/single_qr.json @@ -0,0 +1,3 @@ +[ + " All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy.\n All work and no play makes Jack a dull boy." +] diff --git a/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.coordinator_signed.gif b/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.coordinator_signed.gif new file mode 100644 index 0000000..030cf4c Binary files /dev/null and b/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.coordinator_signed.gif differ diff --git a/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.coordinator_signed.psbt b/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.coordinator_signed.psbt new file mode 100644 index 0000000..c3236fa --- /dev/null +++ b/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.coordinator_signed.psbt @@ -0,0 +1 @@ +cHNidP8BAMUBAAAAA4RSZmhtXSRz+wmYLHLaDW1msFfD4TputL/aMEB27+dlAQAAAAD/////KgI+xaBWgfS8tWueRYhPYlqWZY4doW+ALhAuMaganq4BAAAAAP////9ErmEIocbg7uZe38fpG3ICYmN2nLh3FKmd1F24+8FD8gAAAAAA/////wIGcwQAAAAAABepFOO6EVG3Xv+/etxGc8g8j+7D3cNnh28dAAAAAAAAF6kUw01jpnIIZgcEkKjLJExr3Hzi+hOHAAAAAE8BBIiyHgO82dPNgAAAZOq+Cad5QN0+ElvoG8JfzwTGEVRPQxlnUxvqgLEvLnLSAtQZwuNweEaK+X4H4kA0On6Gke9dzKn+WafHdNuebE5iEPV+xl0tAACAAQAAgGQAAIBPAQSIsh4D5spSFoAAAGQa3n+dYJmJjZhRrwW0iLlK061PyrqzlwuO6XX7DjPFFwOzDPED9HdcNmzck5TcQrnPqdBesC/Qei+YqLGyLYZ/7BAAAAABLQAAgAEAAIBkAACAD2Nvb3JkaW5hdG9yX3NpZ4CXVPhU+KzWj69tE3ZeZ+YtLqHePoi784qIIgEkpWZ0cqhRbKr7Od+8gngA/zv569IKB0pr1AAjlOfed2iGdGDU38LCr04Oo4xSK6bjrRa6PdPF3hZ8vohQr7P8hsDmXRm549At22paUnlcNZ/AopaSRH+NFKsvurLDHWi/evSFnwABAPcCAAAAAAEBSckS0OXkb275MwOMf7fh1mXbmuVrZ/pX/kw0dqlc+VQAAAAAFxYAFADi94+YelpEk88GKZTb3knQQKki/v///wJjFBgAAAAAABepFMerbRAxgKSBgYR9NXMuk+DOmrBzh6CGAQAAAAAAF6kUhHkHLVpVDuCQC1r35wr1dVJ6h52HAkcwRAIgL1OHUuQItIF+d1HvJD7uZ9IkLKIGHo5snyKHMkfxCo0CIFtGIjFO/XM/EvxlV7wvMj/yy8FgStl6NRgH4b6Ah1vIASEC6SM19uyxhi8O6guZKX8hvbm+uaHo9BETeI9a3TBsqfzumxgAAQRHUiECqFE9mTGJbV06/IBjFI23XYhR/R/EGxCYuipqdm21Y9QhA5ON0Jvz3Snd9B8mSFisz6QLMwyY4O0nyvd3NPrAATm6Uq4iBgKoUT2ZMYltXTr8gGMUjbddiFH9H8QbEJi6Kmp2bbVj1Bj1fsZdLQAAgAEAAIBkAACAAAAAAAAAAAAiBgOTjdCb890p3fQfJkhYrM+kCzMMmODtJ8r3dzT6wAE5uhgAAAABLQAAgAEAAIBkAACAAAAAAAAAAAAAAQD3AgAAAAABAQF0Xh2qKMFwXb9z7dGD5e+RrQkY2XrT4uwsabVICG9NAAAAABcWABQrC1IrqH2xZGiYEYhgRJ/LLGna4/7///8CMpZCAAAAAAAXqRQPiU9+O3C4dB+DDgZrbvUIqfdHnYeghgEAAAAAABepFIR5By1aVQ7gkAta9+cK9XVSeoedhwJHMEQCIC3Ih+XWI72XSWgoXpyBZc+p+s2UPK8PhHLnrO9jL7lDAiBcYENAYeak5FNg07PJAanB3RSLON1sliPNj6JndYfmMgEhAjZlOGkv+5Yi51oF3CAE2F76DrwnuZlh5pTYj57eK1fK5JsYAAEER1IhAqhRPZkxiW1dOvyAYxSNt12IUf0fxBsQmLoqanZttWPUIQOTjdCb890p3fQfJkhYrM+kCzMMmODtJ8r3dzT6wAE5ulKuIgYCqFE9mTGJbV06/IBjFI23XYhR/R/EGxCYuipqdm21Y9QY9X7GXS0AAIABAACAZAAAgAAAAAAAAAAAIgYDk43Qm/PdKd30HyZIWKzPpAszDJjg7SfK93c0+sABOboYAAAAAS0AAIABAACAZAAAgAAAAAAAAAAAAAEA9wIAAAAAAQHl1qD/xfg4epDEY79hSuU2CbcpiMRK/GpXfyJma8lxpwAAAAAXFgAUKDhkidFbHN39JFtQa4/y2QmxjTb+////AqCGAQAAAAAAF6kUhHkHLVpVDuCQC1r35wr1dVJ6h52Hhs4YBQAAAAAXqRTS+wqJWOVdTGw/9Y+XD9u6MAbsB4cCRzBEAiAHpxhuavuT3nSbOpBdHHQ39HD5cJXqQQU4tqwz0VqUeAIgWmYRjH3C4U1zJaEi6wAh9U4dvV37j9VrJT+jeCcWrz0BIQP1lRzMzwCWTVTu+ngoCuCD4PDwzGOC/Sez+/3+2o3Sx7KbGAABBEdSIQKoUT2ZMYltXTr8gGMUjbddiFH9H8QbEJi6Kmp2bbVj1CEDk43Qm/PdKd30HyZIWKzPpAszDJjg7SfK93c0+sABObpSriIGAqhRPZkxiW1dOvyAYxSNt12IUf0fxBsQmLoqanZttWPUGPV+xl0tAACAAQAAgGQAAIAAAAAAAAAAACIGA5ON0Jvz3Snd9B8mSFisz6QLMwyY4O0nyvd3NPrAATm6GAAAAAEtAACAAQAAgGQAAIAAAAAAAAAAAAAAAQBHUiECGgSXRxIDRfqQF/tC2P89T7HS70yAVGhyxdpRO6vVFYUhA6AAld9INn7SHlxu3VCvQ1IxG/Bg6xAEJct69DMaoarQUq4iAgIaBJdHEgNF+pAX+0LY/z1PsdLvTIBUaHLF2lE7q9UVhRgAAAABLQAAgAEAAIBkAACAAQAAAAAAAAAiAgOgAJXfSDZ+0h5cbt1Qr0NSMRvwYOsQBCXLevQzGqGq0Bj1fsZdLQAAgAEAAIBkAACAAQAAAAAAAAAA diff --git a/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.gif b/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.gif new file mode 100644 index 0000000..f972d40 Binary files /dev/null and b/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.gif differ diff --git a/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.psbt b/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.psbt new file mode 100644 index 0000000..df52284 --- /dev/null +++ b/tests/fixtures/signature_requests/2-of-2.p2sh.testnet.psbt @@ -0,0 +1 @@ +cHNidP8BAMUBAAAAA4RSZmhtXSRz+wmYLHLaDW1msFfD4TputL/aMEB27+dlAQAAAAD/////KgI+xaBWgfS8tWueRYhPYlqWZY4doW+ALhAuMaganq4BAAAAAP////9ErmEIocbg7uZe38fpG3ICYmN2nLh3FKmd1F24+8FD8gAAAAAA/////wIGcwQAAAAAABepFOO6EVG3Xv+/etxGc8g8j+7D3cNnh28dAAAAAAAAF6kUw01jpnIIZgcEkKjLJExr3Hzi+hOHAAAAAE8BBDWHzwO82dPNgAAAZOq+Cad5QN0+ElvoG8JfzwTGEVRPQxlnUxvqgLEvLnLSAtQZwuNweEaK+X4H4kA0On6Gke9dzKn+WafHdNuebE5iEPV+xl0tAACAAQAAgGQAAIBPAQQ1h88D5spSFoAAAGQa3n+dYJmJjZhRrwW0iLlK061PyrqzlwuO6XX7DjPFFwOzDPED9HdcNmzck5TcQrnPqdBesC/Qei+YqLGyLYZ/7BAAAAABLQAAgAEAAIBkAACAAAEA9wIAAAAAAQFJyRLQ5eRvbvkzA4x/t+HWZdua5Wtn+lf+TDR2qVz5VAAAAAAXFgAUAOL3j5h6WkSTzwYplNveSdBAqSL+////AmMUGAAAAAAAF6kUx6ttEDGApIGBhH01cy6T4M6asHOHoIYBAAAAAAAXqRSEeQctWlUO4JALWvfnCvV1UnqHnYcCRzBEAiAvU4dS5Ai0gX53Ue8kPu5n0iQsogYejmyfIocyR/EKjQIgW0YiMU79cz8S/GVXvC8yP/LLwWBK2Xo1GAfhvoCHW8gBIQLpIzX27LGGLw7qC5kpfyG9ub65oej0ERN4j1rdMGyp/O6bGAABBEdSIQKoUT2ZMYltXTr8gGMUjbddiFH9H8QbEJi6Kmp2bbVj1CEDk43Qm/PdKd30HyZIWKzPpAszDJjg7SfK93c0+sABObpSriIGAqhRPZkxiW1dOvyAYxSNt12IUf0fxBsQmLoqanZttWPUGPV+xl0tAACAAQAAgGQAAIAAAAAAAAAAACIGA5ON0Jvz3Snd9B8mSFisz6QLMwyY4O0nyvd3NPrAATm6GAAAAAEtAACAAQAAgGQAAIAAAAAAAAAAAAABAPcCAAAAAAEBAXReHaoowXBdv3Pt0YPl75GtCRjZetPi7CxptUgIb00AAAAAFxYAFCsLUiuofbFkaJgRiGBEn8ssadrj/v///wIylkIAAAAAABepFA+JT347cLh0H4MOBmtu9Qip90edh6CGAQAAAAAAF6kUhHkHLVpVDuCQC1r35wr1dVJ6h52HAkcwRAIgLciH5dYjvZdJaChenIFlz6n6zZQ8rw+Ecues72MvuUMCIFxgQ0Bh5qTkU2DTs8kBqcHdFIs43WyWI82Pomd1h+YyASECNmU4aS/7liLnWgXcIATYXvoOvCe5mWHmlNiPnt4rV8rkmxgAAQRHUiECqFE9mTGJbV06/IBjFI23XYhR/R/EGxCYuipqdm21Y9QhA5ON0Jvz3Snd9B8mSFisz6QLMwyY4O0nyvd3NPrAATm6Uq4iBgKoUT2ZMYltXTr8gGMUjbddiFH9H8QbEJi6Kmp2bbVj1Bj1fsZdLQAAgAEAAIBkAACAAAAAAAAAAAAiBgOTjdCb890p3fQfJkhYrM+kCzMMmODtJ8r3dzT6wAE5uhgAAAABLQAAgAEAAIBkAACAAAAAAAAAAAAAAQD3AgAAAAABAeXWoP/F+Dh6kMRjv2FK5TYJtymIxEr8ald/ImZryXGnAAAAABcWABQoOGSJ0Vsc3f0kW1Brj/LZCbGNNv7///8CoIYBAAAAAAAXqRSEeQctWlUO4JALWvfnCvV1UnqHnYeGzhgFAAAAABepFNL7ColY5V1MbD/1j5cP27owBuwHhwJHMEQCIAenGG5q+5PedJs6kF0cdDf0cPlwlepBBTi2rDPRWpR4AiBaZhGMfcLhTXMloSLrACH1Th29XfuP1WslP6N4JxavPQEhA/WVHMzPAJZNVO76eCgK4IPg8PDMY4L9J7P7/f7ajdLHspsYAAEER1IhAqhRPZkxiW1dOvyAYxSNt12IUf0fxBsQmLoqanZttWPUIQOTjdCb890p3fQfJkhYrM+kCzMMmODtJ8r3dzT6wAE5ulKuIgYCqFE9mTGJbV06/IBjFI23XYhR/R/EGxCYuipqdm21Y9QY9X7GXS0AAIABAACAZAAAgAAAAAAAAAAAIgYDk43Qm/PdKd30HyZIWKzPpAszDJjg7SfK93c0+sABOboYAAAAAS0AAIABAACAZAAAgAAAAAAAAAAAAAABAEdSIQIaBJdHEgNF+pAX+0LY/z1PsdLvTIBUaHLF2lE7q9UVhSEDoACV30g2ftIeXG7dUK9DUjEb8GDrEAQly3r0MxqhqtBSriICAhoEl0cSA0X6kBf7Qtj/PU+x0u9MgFRocsXaUTur1RWFGAAAAAEtAACAAQAAgGQAAIABAAAAAAAAACICA6AAld9INn7SHlxu3VCvQ1IxG/Bg6xAEJct69DMaoarQGPV+xl0tAACAAQAAgGQAAIABAAAAAAAAAAA= \ No newline at end of file diff --git a/tests/fixtures/too_large_bitcoin_signature_request.json b/tests/fixtures/too_large_bitcoin_signature_request.json deleted file mode 100644 index a7f828b..0000000 --- a/tests/fixtures/too_large_bitcoin_signature_request.json +++ /dev/null @@ -1,296 +0,0 @@ -{ - - "inputs": [ - [ - "522102a567420d0ecb8ae1ac3794a6f901c9fdfa63a24476b8889d4c483dc7975f4ab321034a7496c358ee925043f5859bf7a191566fa070e3b75aac3f3bde6a670a8b6a8e2103666946ad4ff2b8c5c1ed6c8f5dd7f28769820cf35061ad5e02a45c5f05d54a1853ae", - "m/45'/1'/120'/20/26", - { - "txid": "f9509f7042d57382f4b6620044252764ec63f4e7b3434ee4dcde3b98ff2568ce", - "n": 0, - "amount": 6276637 - }, - { - "txid": "5484d9a1bb1ce316cf5fa312abb4a4a641109565eb9571203bc6bfb69457fb7b", - "n": 0, - "amount": 1329626 - }, - { - "txid": "7b04b8ca2ee8b14e1359c1316335a2408a7785d855ac42b775d91727b363fe08", - "n": 0, - "amount": 1656813 - }, - { - "txid": "36f0093a44ff507fc38cfc5e7d4ae908d6e103a7d13749030322801e10db08fd", - "n": 0, - "amount": 5227676 - }, - { - "txid": "eebf72d682d1e2bc8dc3d15c75a3d3c7fc531cdf1fc19d9323fa8babc3e42de3", - "n": 0, - "amount": 6645553 - }, - { - "txid": "8385fedc790c64ba034b83a1dcde734b69ccce764483e2a76fcea76e7c74544e", - "n": 0, - "amount": 1897736 - }, - { - "txid": "6f7c9c104b77eabb1601a910df76649a96244b659e57d1587c1ac287c8999bf9", - "n": 0, - "amount": 4444865 - }, - { - "txid": "b1f4bfd2ed4abb63c0665f0529bb101c22873101bdb1ee9d1aed89f7fd6e7de2", - "n": 0, - "amount": 3569794 - }, - { - "txid": "b8ad8cc93ef15443b493c49fc9a5f0e970641a88a3d6cec1c8cf03b1134d420f", - "n": 0, - "amount": 9746665 - }, - { - "txid": "1f635c9629e81eee54333fa16122d1aedcdac0c6fa966427535211912bc00196", - "n": 0, - "amount": 8791954 - }, - { - "txid": "0761f4b417ac94117f281a810960d26aa1339b9772bdbb6fc3836bd8cdff894b", - "n": 0, - "amount": 8373929 - }, - { - "txid": "cd53c5018ff9fc28d0a28dacc0d6b2b5114a61fb7fdb2d439291dfc14fb574e4", - "n": 0, - "amount": 6773774 - }, - { - "txid": "e02ee433cd42148fbafa92247ac1128836590f20b4253b1032b4e6e253324d7e", - "n": 0, - "amount": 2974185 - }, - { - "txid": "2c9145297392202ce2ce318943005660c9a09553c7540d707baa9546cc78c28d", - "n": 0, - "amount": 1945398 - }, - { - "txid": "6495e4f392bb3ca7c4704b5b655f5a7fa31f37f5bdf36848ad1cb4cee1a0ff0c", - "n": 0, - "amount": 9365399 - }, - { - "txid": "c67973efc68ada65276671261a9db8b7e041b251d4379117401a24f6d21722a4", - "n": 0, - "amount": 3262742 - }, - { - "txid": "1795c91d04bd7f31c5725de9f7a63b7bfb59735e6ee330ace86966a146192163", - "n": 0, - "amount": 1282281 - }, - { - "txid": "9d42ad2cf1379fd04969e582c51cdc9504a11790bd03816c0b42f03817dd605b", - "n": 0, - "amount": 9872959 - }, - { - "txid": "329cd7a3a1b55cc7c3ece6640a1f39d4dade5c9680cea87d99f5a5dd37e81932", - "n": 0, - "amount": 1232944 - }, - { - "txid": "5e2314bd6f076a73536c129fd8ec04db411a6c192cb7b8213f836d1ad2b157d8", - "n": 0, - "amount": 3887595 - }, - { - "txid": "864e3efe4aa7b82974fe45ab73afc920ffc814478b7a09e58f75ce948d3ac882", - "n": 0, - "amount": 8872318 - }, - { - "txid": "46505b16bd1b5bde52d5d0d29dd2ebb62efc4180cb966770fcc5eec6e21a2e42", - "n": 0, - "amount": 9984793 - }, - { - "txid": "e131f344aade81a5a821d791150d39e341bd46bbdb68f838a2728e7bf0a7790e", - "n": 0, - "amount": 4368518 - }, - { - "txid": "ecf858df3a4d86c54d84d0c8f406c6e2f944605613c32d031f0f20cbacf9d2e9", - "n": 0, - "amount": 5131326 - }, - { - "txid": "b59a605276e5b5724b86e2988e76298a34e8918a1e2e785514b3b650f27c2285", - "n": 0, - "amount": 9461942 - }, - { - "txid": "2dc3403fdcfd1167477958aae7bfa9e383ffaa4dd8bf49d3037e291468ff91bd", - "n": 0, - "amount": 6716896 - }, - { - "txid": "009ce6feb00e39c11a049d30ce8f1e3ed8ab1b6f744ef03e9c03bd516f15a482", - "n": 0, - "amount": 6444359 - }, - { - "txid": "25be7dc13d02bf482501a2a58ee1085e77607485c624994a417a0f32360baf86", - "n": 0, - "amount": 2995278 - }, - { - "txid": "c7002b4299bf035637cdd29daa1df4ff5c4101401214a42bca697a6a90343ecb", - "n": 0, - "amount": 2943528 - }, - { - "txid": "12030f627b26631584bb94e92e4af9cb7bc38637f3d45f7ba22b37ba97542fd4", - "n": 0, - "amount": 9732471 - }, - { - "txid": "97924acb73db3c8064f9e6edb96ce479e9bf2d7b4359f36da9699199b0f8cfb5", - "n": 0, - "amount": 1368747 - }, - { - "txid": "bdd26a7a9abe039e721a5dc97de2e21deb943bfd5a643743b82932f858d0026b", - "n": 0, - "amount": 7955232 - }, - { - "txid": "25bd9baa0d629dd9c98c1ab26db4020c7f361c8747f0202e0da5c48fca1b6662", - "n": 0, - "amount": 2669122 - }, - { - "txid": "5440e6215f6d0da2e9d3b42a07ff1f8e3bc27dbac337d968c65aa32a465c2a73", - "n": 0, - "amount": 7278141 - }, - { - "txid": "bf2ed264634361fd68d739c497e87d6e50a2058e92dd5604c8ee9cd038584122", - "n": 0, - "amount": 5717797 - }, - { - "txid": "75d4129b921ba98896910705bacb0f63ed8c35c0aca41824843b3e7651e40d2a", - "n": 0, - "amount": 4649526 - }, - { - "txid": "b0ed853b83153a585f49f66258f27cad9821d2a829e3cac1223598e1f177924e", - "n": 0, - "amount": 5826116 - }, - { - "txid": "75195540557600636bcdb267b45cafa1bd3057be77b127a23512b0f745041f2d", - "n": 0, - "amount": 6979218 - }, - { - "txid": "9575a22f3e38f00ea5da55e941880befd1427e5c8d278def89d8f1f75890cd84", - "n": 0, - "amount": 6964343 - }, - { - "txid": "acbe6b7758ca93a673613ac647120ca480997b9ca14ae004d93712a928f434b0", - "n": 0, - "amount": 6622876 - }, - { - "txid": "cf476447e4ad6c0cba4dac981d97b8b25e75e3163e192407df25a6440352f009", - "n": 0, - "amount": 9734128 - }, - { - "txid": "8a42cc5a02100f21dde2bfbbadfc48cdc3144fe246a06c870b87cfc551cbc03f", - "n": 0, - "amount": 5347837 - }, - { - "txid": "7f6397ef2617560cb57bc093c67936f2a079ea72b3727c7f740842c72f4cf6a6", - "n": 0, - "amount": 6724418 - }, - { - "txid": "4552c6faaea206199cbc967fbb029d462cf767d21d2c7161842033205bd9550a", - "n": 0, - "amount": 6365919 - }, - { - "txid": "d285cd9d6f1c800dfea19c2d99d8f00fc47e4882661f86763c6b597b2b160e62", - "n": 0, - "amount": 9275429 - }, - { - "txid": "82d2d573e54289015c9b73c94f2a9132b0f0b65550681ae9827d5f656ec8b836", - "n": 0, - "amount": 9668847 - }, - { - "txid": "7ace9ff301e70c154d51616c4e203db1eb665eb897d5fa0389f08c1221196f20", - "n": 0, - "amount": 7241143 - }, - { - "txid": "bbc413c44d22d692322b438586276f97fadc64e67382d45d7f1f37c6a2da6f2a", - "n": 0, - "amount": 8693825 - }, - { - "txid": "030969d9f6381a48c06f8fe974b6399ef02cc87ed3669a9faf3f17f9e2cf9f1f", - "n": 0, - "amount": 1673337 - }, - { - "txid": "12f090e8b12aebe7a5635a3dca5c53d1107f482ae054c2bc6937944e3562eb85", - "n": 0, - "amount": 3356322 - }, - { - "txid": "00925fd8e22882cbbd48d1bc53a2cfe75c588a375c50654c0816cef11aa0dfae", - "n": 0, - "amount": 3413929 - }, - { - "txid": "25d3461cd6a93be839d890d9a158e07e0333fd2c26af2bffa337b95a3cbab802", - "n": 0, - "amount": 5333558 - }, - { - "txid": "7192fded7696394c1ef34bc4231a80faf7dfa4175d5b951e3a8efc0ff3a9d23b", - "n": 0, - "amount": 1347295 - }, - { - "txid": "f298d74dfc768d52e8b98bd87cda16552f7fdf87f3ae049febff9ac3de007ad3", - "n": 0, - "amount": 4474448 - }, - { - "txid": "d0845811ff1ae0dfdcf019348a0e23363073e5554e9237d8181810ddb561f974", - "n": 0, - "amount": 6688958 - } - ] - ], - - "outputs": [ - { - "address": "2NG4oZZZbBcBtUw6bv2KEJmbZgrdLTTf5CC", - "amount": 10000000 - }, - { - "address": "2MuK9EGZeXMLJTqVtv8eKrcbrvYg2pwD1te", - "amount": 27002781 - } - ] - -} diff --git a/tests/qr/test_create.py b/tests/qr/test_create.py new file mode 100644 index 0000000..a702d15 --- /dev/null +++ b/tests/qr/test_create.py @@ -0,0 +1,53 @@ +from base64 import b64encode +from unittest.mock import Mock +from pytest import raises + +from qrcode import QRCode + +from hermit import qr_to_image, create_qr_sequence, HermitError +from hermit.qr import create_qr + + +class TestCreateQRSequence(object): + def setup(self): + self.data = open("tests/fixtures/lorem_ipsum.txt", "r").read() + self.base64_data = b64encode(self.data.encode("utf8")).decode("utf8") + + def test_with_no_arguments(self): + with raises(HermitError) as e: + create_qr_sequence() + assert "Must provide" in str(e) + + def test_with_data(self): + sequence = create_qr_sequence(data=self.data) + assert len(sequence) == 10 + for qr in sequence: + assert isinstance(qr, QRCode) + + def test_with_base64_data(self): + sequence = create_qr_sequence(base64_data=self.base64_data) + assert len(sequence) == 10 + for qr in sequence: + assert isinstance(qr, QRCode) + + def test_with_both(self): + sequence = create_qr_sequence(data=self.data, base64_data=self.base64_data) + assert len(sequence) == 10 + for qr in sequence: + assert isinstance(qr, QRCode) + + +def test_qr_to_image(): + qr = Mock() + mock_make_image = Mock() + qr.make_image = mock_make_image + mock_image = Mock() + mock_make_image.return_value = mock_image + assert qr_to_image(qr) == mock_image + mock_make_image.assert_called_once_with(fill_color="black", back_color="white") + + +def test_create_qr(): + data = "data" + qr = create_qr(data) + assert isinstance(qr, QRCode) diff --git a/tests/qr/test_detect.py b/tests/qr/test_detect.py new file mode 100644 index 0000000..644c598 --- /dev/null +++ b/tests/qr/test_detect.py @@ -0,0 +1,11 @@ +from PIL import Image + +from hermit import detect_qrs_in_image + + +def test_detect_qrs_in_image(): + image = Image.open("examples/hello_world.jpg") + mirror, results = detect_qrs_in_image(image) + assert mirror is not None + assert len(results) == 1 + assert results[0] == "Hello, world!" diff --git a/tests/qr/test_full_circle.py b/tests/qr/test_full_circle.py new file mode 100644 index 0000000..39fa258 --- /dev/null +++ b/tests/qr/test_full_circle.py @@ -0,0 +1,38 @@ +from base64 import b64encode +from hermit.qr import ( + create_qr_sequence, + GenericReassembler, + qr_to_image, + detect_qrs_in_image, +) + + +class TestQRFullCircle(object): + def test_plain_text(self): + data = "foobar" + sequence = create_qr_sequence(data) + reassembler = GenericReassembler() + for qr in sequence: + image = qr_to_image(qr) + image = image.convert("RGBA") + mirror, payloads = detect_qrs_in_image(image) + for payload in payloads: + reassembler.collect(payload) + assert reassembler.total == len(sequence) + assert reassembler.is_complete() + assert reassembler.decode() == data + + def test_base64_text(self): + data = "foobar" + base64_data = b64encode(data.encode("utf8")).decode("utf8") + sequence = create_qr_sequence(base64_data=base64_data) + reassembler = GenericReassembler() + for qr in sequence: + image = qr_to_image(qr) + image = image.convert("RGBA") + mirror, payloads = detect_qrs_in_image(image) + for payload in payloads: + reassembler.collect(payload) + assert reassembler.total == len(sequence) + assert reassembler.is_complete() + assert reassembler.decode() == data diff --git a/tests/qr/test_reassemblers.py b/tests/qr/test_reassemblers.py new file mode 100644 index 0000000..2830589 --- /dev/null +++ b/tests/qr/test_reassemblers.py @@ -0,0 +1,62 @@ +import pytest +import json + +from hermit.qr import ( + GenericReassembler, + BCURSingleReassembler, + BCURMultiReassembler, + SingleQRCodeReassembler, +) + + +@pytest.fixture() +def bcur_multi(): + with open("tests/fixtures/qrdata/bcur_6_part.json", "r") as f: + vector = json.load(f) + return vector + + +@pytest.fixture() +def bcur_singles(): + with open("tests/fixtures/qrdata/bcur_singles.json", "r") as f: + vector = json.load(f) + return vector + + +@pytest.fixture() +def single_qr(): + with open("tests/fixtures/qrdata/single_qr.json", "r") as f: + vector = json.load(f) + return vector + + +class TestGenericReassembler(object): + def test_bcur_singles(self, bcur_singles): + for bcur_single in bcur_singles: + self.reassemble( + BCURSingleReassembler.TYPE, bcur_single["urls"], bcur_single["data"] + ) + + def test_bcur_multi(self, bcur_multi): + self.reassemble( + BCURMultiReassembler.TYPE, bcur_multi["urls"], bcur_multi["data"] + ) + + def test_single_qr(self, single_qr): + data = single_qr[0] + self.reassemble(SingleQRCodeReassembler.TYPE, [data], data) + + def reassemble(self, type, payloads, expected): + reassembler = GenericReassembler() + + assert reassembler.total is None + assert reassembler.type is None + + for payload in payloads: + assert not reassembler.is_complete() + assert reassembler.collect(payload) is True + assert reassembler.type == type + assert reassembler.total == len(payloads) + + assert reassembler.is_complete() is True + assert reassembler.decode() == expected diff --git a/tests/qrcode/test_displayer.py b/tests/qrcode/test_displayer.py deleted file mode 100644 index 9f54dc5..0000000 --- a/tests/qrcode/test_displayer.py +++ /dev/null @@ -1,86 +0,0 @@ -import asyncio -import json -from unittest.mock import patch - -import numpy as np -import pytest -import qrcode - -import hermit - - -@pytest.fixture() -def too_large_bitcoin_signature_request(): - filename = "tests/fixtures/too_large_bitcoin_signature_request.json" - with open(filename, 'r') as f: - request = json.load(f) - return json.dumps(request) - - -@pytest.fixture() -def largest_bitcoin_signature_request(): - filename = "tests/fixtures/largest_bitcoin_signature_request.json" - with open(filename, 'r') as f: - request = json.load(f) - return json.dumps(request) - -@pytest.fixture() -def opensource_bitcoin_vector_0_array(): - return np.load('tests/fixtures/opensource_bitcoin_test_vector_0.npy') - - -class TestDisplayQRCode(object): - - @patch('hermit.qrcode.displayer.cv2') - @patch('hermit.qrcode.displayer.window_is_open') - async def test_valid_qr_code(self, - mock_window_is_open, - mock_cv2, - fixture_opensource_bitcoin_vectors, - opensource_bitcoin_vector_0_array): - request_json = fixture_opensource_bitcoin_vectors['request_json'] - mock_window_is_open.return_value = True - future = hermit.qrcode.display_qr_code(request_json) - await future - mock_cv2.imshow.assert_called_once() - call_args = mock_cv2.imshow.call_args - assert call_args[0][0] == 'Preview' - expected_arg = opensource_bitcoin_vector_0_array - assert np.array_equal(call_args[0][1], expected_arg) - - # @patch('hermit.qrcode.displayer.cv2') - # @patch('hermit.qrcode.displayer.window_is_open') - # def test_task_ends_when_window_closed(self, - # mock_window_is_open, - # mock_cv2, - # fixture_opensource_bitcoin_vector_0): - # request_json = fixture_opensource_bitcoin_vector_0['request_json'] - # mock_window_is_open.return_value = True - # future = hermit.qrcode.display_qr_code(request_json) - - # mock_cv2.destroyWindow.assert_called_once() - - # @patch('hermit.qrcode.displayer.cv2') - # def test_task_ends_when_q_is_pressed(self, - # mock_cv2, - # fixture_opensource_bitcoin_vector_0): - # request_json = fixture_opensource_bitcoin_vector_0['request_json'] - # mock_cv2.waitKey.side_effect = [-1, 113] - # future = hermit.qrcode.display_qr_code(request_json) - # asyncio.get_event_loop().run_until_complete(future) - # mock_cv2.destroyWindow.assert_called_once() - - -class TestCreateQRCode(object): - - # using gzip, we can store between 50-55 inputs in one qrcode - # - # See Version 40: - # http://www.qrcode.com/en/about/version.html - def test_fifty_five_inputs_fails(self, too_large_bitcoin_signature_request): - with pytest.raises(qrcode.exceptions.DataOverflowError): - hermit.qrcode.create_qr_code_image(too_large_bitcoin_signature_request) - - def test_fifty_inputs_passes(self, largest_bitcoin_signature_request): - hermit.qrcode.create_qr_code_image(largest_bitcoin_signature_request) - assert True diff --git a/tests/qrcode/test_format.py b/tests/qrcode/test_format.py deleted file mode 100644 index 2d5fce6..0000000 --- a/tests/qrcode/test_format.py +++ /dev/null @@ -1,48 +0,0 @@ -import pytest -import base64 -import gzip - -import hermit - -_DECODED = 'foobar' -_ENCODED = b'D6FQQACPVZOV2AX7JPF46T2KFQBABFI762PAMAAAAA======' - -@pytest.mark.qrcode -class TestDecodeQRCodeData(object): - - def test_valid_format(self): - assert _DECODED == hermit.decode_qr_code_data(_ENCODED) - - def test_empty_bytes(self): - with pytest.raises(hermit.InvalidSignatureRequest): - hermit.decode_qr_code_data(b'') - - def test_improper_base32(self): - with pytest.raises(hermit.InvalidSignatureRequest): - hermit.decode_qr_code_data(_ENCODED[:-1]) - - def test_base64(self): - with pytest.raises(hermit.InvalidSignatureRequest): - hermit.decode_qr_code_data(base64.b64encode(gzip.compress(_DECODED.encode('utf-8')))) - - def test_uncompressed(self): - with pytest.raises(hermit.InvalidSignatureRequest): - hermit.decode_qr_code_data(base64.b32encode(_DECODED.encode('utf-8'))) - - def test_not_utf8(self): - with pytest.raises(hermit.InvalidSignatureRequest): - hermit.decode_qr_code_data(base64.b32encode(gzip.compress(_DECODED.encode('utf-16')))) - -@pytest.mark.qrcode -class TestEncodeQRCodeData(object): - - def test_recoverability(self): - assert _DECODED == hermit.decode_qr_code_data(hermit.encode_qr_code_data(_DECODED)) - - def test_empty_string(self): - with pytest.raises(hermit.InvalidSignatureRequest): - hermit.encode_qr_code_data('') - - def test_bytes(self): - with pytest.raises(hermit.InvalidSignatureRequest): - hermit.encode_qr_code_data(_DECODED.encode('utf-8')) diff --git a/tests/qrcode/test_limits.py b/tests/qrcode/test_limits.py deleted file mode 100644 index 9128f73..0000000 --- a/tests/qrcode/test_limits.py +++ /dev/null @@ -1,163 +0,0 @@ -import base64 -import gzip -import random -import string - -from numpy import array -import cv2 -import pytest -import qrcode - - -# -# Version 40 QRcode Storage Limits with Low Error Correction -# Mixed Bytes: 23,648 (tests show this library is limited to 23,549) -# Numeric: 7,089 -# Alphanumeric: 4,296 -# Binary: 2,953 -# - -@pytest.mark.qrcode -class TestQRCodeStorage(object): - - - def test_numeric_maximum(self): - N = 7089 - data = ''.join([ - random.choice(string.digits) - for _ in range(N)]) - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(data) - qr.make() - assert True - - def test_numeric_overflow(self): - N = 7090 - data = ''.join([ - random.choice(string.digits) - for _ in range(N)]) - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(data) - with pytest.raises(qrcode.exceptions.DataOverflowError): - qr.make() - assert True - - def test_alphanumeric_maximum(self): - N = 4296 - data = ''.join([ - random.choice(string.digits + string.ascii_uppercase) - for _ in range(N)]) - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(data) - qr.make() - assert True - - def test_alphanumeric_overflow(self): - N = 4297 - data = ''.join([ - random.choice(string.digits + string.ascii_uppercase) - for _ in range(N)]) - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(data, optimize=0) - with pytest.raises(qrcode.exceptions.DataOverflowError): - qr.make() - assert True - - - def test_binary_maximum(self): - N = 2953 - data = bytes(bytearray((random.getrandbits(8) for i in range(N)))) - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(data) - qr.make() - assert True - - def test_binary_overflow(self): - N = 2954 - data = bytes(bytearray((random.getrandbits(8) for i in range(N)))) - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(data, optimize=0) - with pytest.raises(qrcode.exceptions.DataOverflowError): - qr.make() - assert True - - def test_bits_maximum(self): - N = 23549 - data = 1 << N - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(data) - qr.make() - assert True - - def test_bits_overflow(self): - N = 23550 - data = 1 << N - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(data) - with pytest.raises(qrcode.exceptions.DataOverflowError): - qr.make() - assert True - - def test_alphanumeric_compression_maximum(self): - M = 4 - N = 2000 - data = ''.join([ - random.choice(string.digits + string.ascii_letters) * M - for _ in range(N)]) - data = data.encode('utf-8') - print(len(data)) # 10000 - data = gzip.compress(data) - data = base64.b32encode(data) - - print(len(data)) - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) - qr.add_data(data) - qr.make() - assert len(data) < 4296 - assert True - diff --git a/tests/qrcode/test_reader.py b/tests/qrcode/test_reader.py deleted file mode 100644 index b64150a..0000000 --- a/tests/qrcode/test_reader.py +++ /dev/null @@ -1,60 +0,0 @@ -from unittest.mock import patch -import unittest - -from PIL import Image -import numpy as np -import pytest - -import hermit - - -@pytest.fixture() -def opensource_bitcoin_vector_array(): - return np.load('tests/fixtures/opensource_bitcoin_test_vector_0.npy') - - -@pytest.fixture() -def opensource_bitcoin_vector_0_image(): - return Image.open('tests/fixtures/opensource_bitcoin_test_vector_0.png') - - -class TestReadQRCode(object): - - @patch('hermit.qrcode.reader.cv2') - def test_cannot_open_camera_is_error(self, mock_cv2): - mock_cv2.VideoCapture().isOpened.return_value = False - with pytest.raises(IOError) as e_info: - hermit.qrcode.reader._start_camera() - assert str(e_info.value) == "Cannot open webcam" - - @patch('hermit.qrcode.reader.cv2') - @patch('hermit.qrcode.reader.window_is_open') - # @unittest.skip("TODO: fix this broken test!") - def test_valid_qr_code(self, - mock_window_is_open, - mock_cv2, - fixture_opensource_bitcoin_vector, - opensource_bitcoin_vector_0_image): - request_json = fixture_opensource_bitcoin_vector['request_json'] - mock_cv2.VideoCapture().read.return_value = ( - None, np.array(opensource_bitcoin_vector_0_image)) - mock_cv2.resize.return_value = opensource_bitcoin_vector_0_image - mock_window_is_open.return_value = True - data = hermit.qrcode.read_qr_code() - assert data == request_json - mock_cv2.imshow.assert_called_once() - mock_cv2.namedWindow.assert_called_once() - mock_cv2.destroyWindow.assert_called_once() - assert True - - # @patch('hermit.qrcode.reader.cv2') - # def test_q_breaks_reader_after_loop(self, - # mock_cv2): - # mock_cv2.VideoCapture().read.return_value = (None, None) - # mock_cv2.waitKey.side_effect = [0, 113] - # mock_cv2.resize.return_value = np.zeros((800, 800)) - # hermit.qrcode.read_qr_code() - # assert mock_cv2.imshow.call_count == 2 - # mock_cv2.namedWindow.assert_called_once() - # mock_cv2.destroyWindow.assert_called_once() - # assert True diff --git a/tests/shards/conftest.py b/tests/shards/conftest.py index d5b9b6b..cd71d49 100644 --- a/tests/shards/conftest.py +++ b/tests/shards/conftest.py @@ -1,38 +1,46 @@ import pytest + @pytest.fixture() # This set of wallet words corresponds to a 256-bit entropy value with all zeros def zero_wallet_words(): - return 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art' + return "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" + @pytest.fixture() def zero_bytes(): - return b'\x00' * 32 + return b"\x00" * 32 + @pytest.fixture() def unencrypted_mnemonic_1(): - return 'crucial extend category march apart overall expect wrap teaspoon rich finance hazard lunch painting domain fortune hand bracelet ruler humidity painting exercise necklace ordinary wildlife family quiet findings luck founder kidney flip champion' + return "crucial extend category march apart overall expect wrap teaspoon rich finance hazard lunch painting domain fortune hand bracelet ruler humidity painting exercise necklace ordinary wildlife family quiet findings luck founder kidney flip champion" + @pytest.fixture() def encrypted_mnemonic_1(): - return 'crucial extend category march apart express scholar strike cards evening laden domain firm program aunt founder prize firm glimpse swimming fishing easel explain document stay injury charity step lobe both theory alto knit' + return "crucial extend category march apart express scholar strike cards evening laden domain firm program aunt founder prize firm glimpse swimming fishing easel explain document stay injury charity step lobe both theory alto knit" + @pytest.fixture() def password_1(): - return b'password' + return b"password" + @pytest.fixture() def password_2(): - return b'drowssap' + return b"drowssap" + @pytest.fixture() def shamir_mnemonic_2_of_2(): return [ - [ - 'dismiss stay academic acid afraid lift aspect hanger armed intimate rumor depend grill curly class antenna username twice rhythm credit require thumb family surface lying cricket wolf lamp album license enforce true hunting', - 'dismiss stay academic agency august mandate space recover argue miracle ambition tension home arena marvel document move simple stick friendly fiber grief envelope blessing wits width elevator taught terminal focus false thumb hesitate' + [ + "dismiss stay academic acid afraid lift aspect hanger armed intimate rumor depend grill curly class antenna username twice rhythm credit require thumb family surface lying cricket wolf lamp album license enforce true hunting", + "dismiss stay academic agency august mandate space recover argue miracle ambition tension home arena marvel document move simple stick friendly fiber grief envelope blessing wits width elevator taught terminal focus false thumb hesitate", + ] ] -] + @pytest.fixture() def shamir_2_of_2_secret(): diff --git a/tests/shards/test_shard.py b/tests/shards/test_shard.py index 09be7a3..bf20ea0 100644 --- a/tests/shards/test_shard.py +++ b/tests/shards/test_shard.py @@ -1,15 +1,14 @@ -import unittest from unittest.mock import Mock from hermit.shards import Shard -class TestShard(object): +class TestShard(object): def setup(self): self.interface = Mock() def test_create_shard(self, encrypted_mnemonic_1): - shard_name = 'foo' + shard_name = "foo" shard = Shard(shard_name, encrypted_mnemonic_1, self.interface) @@ -17,25 +16,31 @@ def test_create_shard(self, encrypted_mnemonic_1): assert shard.encrypted_mnemonic == encrypted_mnemonic_1 def test_null_decrypt_shard(self, unencrypted_mnemonic_1): - shard = Shard('foo', unencrypted_mnemonic_1, self.interface) + shard = Shard("foo", unencrypted_mnemonic_1, self.interface) self.interface.get_password.return_value = None assert shard.words() == unencrypted_mnemonic_1 - - def test_decrypt_shard(self, encrypted_mnemonic_1, password_1, unencrypted_mnemonic_1): - shard = Shard('foo', encrypted_mnemonic_1, self.interface) + def test_decrypt_shard( + self, encrypted_mnemonic_1, password_1, unencrypted_mnemonic_1 + ): + shard = Shard("foo", encrypted_mnemonic_1, self.interface) self.interface.get_password.return_value = password_1 assert shard.words() == unencrypted_mnemonic_1 - def test_reencrypt_shard(self, encrypted_mnemonic_1, password_1, password_2, unencrypted_mnemonic_1): - shard = Shard('foo', encrypted_mnemonic_1, self.interface) + def test_reencrypt_shard( + self, encrypted_mnemonic_1, password_1, password_2, unencrypted_mnemonic_1 + ): + shard = Shard("foo", encrypted_mnemonic_1, self.interface) # set up the user inteface for two calls to 'get_change_password'. Once # to change from PASSWORD_1 to PASSWORD_2, and the second time to change # back. - self.interface.get_change_password.side_effect = [(password_1, password_2), (password_2, password_1)] + self.interface.get_change_password.side_effect = [ + (password_1, password_2), + (password_2, password_1), + ] shard.change_password() mnemonic = shard.encrypted_mnemonic @@ -48,14 +53,18 @@ def test_reencrypt_shard(self, encrypted_mnemonic_1, password_1, password_2, une assert mnemonic != encrypted_mnemonic_1 assert mnemonic != unencrypted_mnemonic_1 - - def test_none_reencrypt_shard(self, encrypted_mnemonic_1, password_1, unencrypted_mnemonic_1): - shard = Shard('foo', encrypted_mnemonic_1, self.interface) + def test_none_reencrypt_shard( + self, encrypted_mnemonic_1, password_1, unencrypted_mnemonic_1 + ): + shard = Shard("foo", encrypted_mnemonic_1, self.interface) # set up the user inteface for two calls to 'get_change_password'. Once # to change from PASSWORD_1 to PASSWORD_2, and the second time to change # back. - self.interface.get_change_password.side_effect = [(password_1, None), (None, password_1)] + self.interface.get_change_password.side_effect = [ + (password_1, None), + (None, password_1), + ] shard.change_password() mnemonic = shard.encrypted_mnemonic @@ -68,7 +77,7 @@ def test_none_reencrypt_shard(self, encrypted_mnemonic_1, password_1, unencrypte assert mnemonic == unencrypted_mnemonic_1 def test_shard_to_bytes(self, encrypted_mnemonic_1): - shard = Shard('foo', encrypted_mnemonic_1, self.interface) + shard = Shard("foo", encrypted_mnemonic_1, self.interface) byte_data = shard.to_bytes() # The byte representation of a shamir share varies a little based on the # payload, but for us, we are using 256-bit secrets, so this should give @@ -78,15 +87,15 @@ def test_shard_to_bytes(self, encrypted_mnemonic_1): assert len(byte_data) == 42 def test_shard_from_bytes(self, encrypted_mnemonic_1): - shard = Shard('foo', encrypted_mnemonic_1, self.interface) + shard = Shard("foo", encrypted_mnemonic_1, self.interface) byte_data = shard.to_bytes() - shard2 = Shard('a', None, self.interface) + shard2 = Shard("a", None, self.interface) shard2.from_bytes(byte_data) - assert shard2.name == 'a' + assert shard2.name == "a" assert shard2.encrypted_mnemonic == shard.encrypted_mnemonic def test_shard_to_str(self, encrypted_mnemonic_1): - shard = Shard('NAME', encrypted_mnemonic_1, self.interface) + shard = Shard("NAME", encrypted_mnemonic_1, self.interface) string_value = shard.to_str() - assert string_value == 'NAME (family:5610 group:3 member:4)' + assert string_value == "NAME (family:5610 group:3 member:4)" diff --git a/tests/shards/test_shard_set.py b/tests/shards/test_shard_set.py index d24a7da..e9c8d32 100644 --- a/tests/shards/test_shard_set.py +++ b/tests/shards/test_shard_set.py @@ -1,17 +1,20 @@ import bson import unittest -from unittest.mock import Mock, create_autospec, mock_open, call, patch +from unittest.mock import Mock, mock_open, call, patch import hermit from hermit.shards import ShardSet +from hermit.config import HermitConfig import shamir_mnemonic -class TestShardSet(object): +class TestShardSet(object): def setup(self): self.interface = Mock() - self.config = create_autospec(hermit.config.HermitConfig) - config_patch = patch('hermit.shards.shard_set.HermitConfig.load', return_value=self.config) + self.config = HermitConfig() + config_patch = patch( + "hermit.shards.shard_set.get_config", return_value=self.config + ) self.config_patch = config_patch.start() @@ -27,7 +30,7 @@ def setup(self): def mock_random(count): self.random_requested += count - return b'0'*count + return b"0" * count def mock_ensure(count): self.random_provisioned += count @@ -39,45 +42,41 @@ def teardown(self): # Clean up random number mocking hermit.shards.shard_set.RNG = self.old_rg shamir_mnemonic.RANDOM_BYTES = self.old_rg.random - + self.config_patch.stop() # Ensure that the random number ledgers are balanced. assert self.random_requested == self.random_provisioned - - def test_init(self): shard_set = ShardSet(self.interface) assert shard_set.shards == {} assert shard_set.config == self.config assert shard_set.interface == self.interface - @unittest.skip('test not implemented') + @unittest.skip("test not implemented") def test_ensure_shards(self): assert False - @unittest.skip('test not implemented') + @unittest.skip("test not implemented") def test_save(self): assert False - def test_initialize_file(self): + self.config.paths["shards_file"] = "shards file" shard_set = ShardSet(self.interface) - shard_set.config.shards_file = 'shards file' with patch("builtins.open", mock_open()) as mock_file: shard_set.initialize_file() - mock_file.assert_called_with('shards file', 'wb') + mock_file.assert_called_with("shards file", "wb") mock_file().write.called_with(bson.dumps({})) - def test_create_from_random(self, password_1, password_2): - self.interface.confirm_password.side_effect = [password_1, password_2, b'pw3'] - self.interface.get_password.side_effect = [password_1, password_2, b'pw3'] - self.interface.get_name_for_shard.side_effect = ['one', 'two', 'three'] - self.interface.choose_shard.side_effect = ['one', 'two', 'three', None] - self.interface.enter_group_information.return_value = [1,[(3,3)]] + self.interface.confirm_password.side_effect = [password_1, password_2, b"pw3"] + self.interface.get_password.side_effect = [password_1, password_2, b"pw3"] + self.interface.get_name_for_shard.side_effect = ["one", "two", "three"] + self.interface.choose_shard.side_effect = ["one", "two", "three", None] + self.interface.enter_group_information.return_value = [1, [(3, 3)]] shard_set = ShardSet(self.interface) shard_set._shards_loaded = True @@ -85,19 +84,23 @@ def test_create_from_random(self, password_1, password_2): shard_set.create_random_share() result_words = shard_set.wallet_words() - assert len(result_words.split(' ')) == 24 + assert len(result_words.split(" ")) == 24 assert self.interface.confirm_password.call_count == 3 assert self.interface.get_password.call_count == 3 - get_password_calls = [ call(shard.to_str()) for shard in shard_set.shards.values()] + get_password_calls = [ + call(shard.to_str()) for shard in shard_set.shards.values() + ] assert self.interface.get_password.call_args_list == get_password_calls assert self.interface.get_name_for_shard.call_count == 3 - shareid = shard_set.shards['one'].share_id + shareid = shard_set.shards["one"].share_id # group index 0, group threshold 1, groups 1, memberid changes, member threshold 2 - get_name_calls = [ call(shareid,0,1,1,i,3, shard_set.shards) for i in range(3) ] + get_name_calls = [ + call(shareid, 0, 1, 1, i, 3, shard_set.shards) for i in range(3) + ] assert self.interface.get_name_for_shard.call_args_list == get_name_calls assert self.interface.choose_shard.call_count == 3 @@ -107,26 +110,34 @@ def test_create_from_random(self, password_1, password_2): def test_create_from_random_large_group(self): # Make all the passwords the same to make things easier - self.interface.confirm_password.return_value = b'password' - self.interface.get_password.return_value = b'password' + self.interface.confirm_password.return_value = b"password" + self.interface.get_password.return_value = b"password" self.interface.get_name_for_shard.side_effect = (str(x) for x in range(100)) - self.interface.enter_group_information.return_value = [3, [(7,15), (7,15), (7,15), (7,15)]] + self.interface.enter_group_information.return_value = [ + 3, + [(7, 15), (7, 15), (7, 15), (7, 15)], + ] shard_set = ShardSet(self.interface) shard_set._shards_loaded = True shard_set.create_random_share() - assert self.interface.confirm_password.call_count == 15*4 - assert self.interface.get_name_for_shard.call_count == 15*4 + assert self.interface.confirm_password.call_count == 15 * 4 + assert self.interface.get_name_for_shard.call_count == 15 * 4 assert self.interface.enter_group_information.call_count == 1 calls = ( - [call(32),] + # master secret - [call(2),] + # identifier - [call(32),call(28)] + # group secrets (less digest) - ([call(32)] * 5 + [call(28)]) * 4 # member secrets (less digests) + [ + call(32), + ] + + [ # master secret + call(2), + ] + + [call(32), call(28)] # identifier + + ([call(32)] * 5 + [call(28)]) # group secrets (less digest) + * 4 # member secrets (less digests) ) assert self.rg.random.call_args_list == calls @@ -134,9 +145,9 @@ def test_create_from_wallet_words(self, zero_wallet_words, password_1, password_ self.interface.enter_wallet_words.return_value = zero_wallet_words self.interface.confirm_password.side_effect = [password_1, password_2] self.interface.get_password.side_effect = [password_1, password_2] - self.interface.get_name_for_shard.side_effect = ['one', 'two'] - self.interface.choose_shard.side_effect = ['one', 'two'] - self.interface.enter_group_information.return_value = [1,[(2,2)]] + self.interface.get_name_for_shard.side_effect = ["one", "two"] + self.interface.choose_shard.side_effect = ["one", "two"] + self.interface.enter_group_information.return_value = [1, [(2, 2)]] shard_set = ShardSet(self.interface) shard_set._shards_loaded = True @@ -150,14 +161,18 @@ def test_create_from_wallet_words(self, zero_wallet_words, password_1, password_ assert self.interface.confirm_password.call_count == 2 assert self.interface.get_password.call_count == 2 - get_password_calls = [ call(shard.to_str()) for shard in shard_set.shards.values()] + get_password_calls = [ + call(shard.to_str()) for shard in shard_set.shards.values() + ] assert self.interface.get_password.call_args_list == get_password_calls assert self.interface.get_name_for_shard.call_count == 2 - shareid = shard_set.shards['one'].share_id + shareid = shard_set.shards["one"].share_id # group index 0, group threshold 1, groups 1, memberid changes, member threshold 2 - get_name_calls = [ call(shareid,0,1,1,i,2,shard_set.shards) for i in range(2) ] + get_name_calls = [ + call(shareid, 0, 1, 1, i, 2, shard_set.shards) for i in range(2) + ] assert self.interface.get_name_for_shard.call_args_list == get_name_calls assert self.interface.choose_shard.call_count == 2 @@ -165,33 +180,39 @@ def test_create_from_wallet_words(self, zero_wallet_words, password_1, password_ assert self.rg.random.call_args_list == [call(2), call(28)] - def test_get_secret_seed_from_wallet_words(self, zero_wallet_words, password_1, password_2): + def test_get_secret_seed_from_wallet_words( + self, zero_wallet_words, password_1, password_2 + ): self.interface.enter_wallet_words.return_value = zero_wallet_words self.interface.confirm_password.side_effect = [password_1, password_2] self.interface.get_password.side_effect = [password_1, password_2] - self.interface.get_name_for_shard.side_effect = ['one', 'two'] - self.interface.choose_shard.side_effect = ['one', 'two'] - self.interface.enter_group_information.return_value = [1,[(2,2)]] + self.interface.get_name_for_shard.side_effect = ["one", "two"] + self.interface.choose_shard.side_effect = ["one", "two"] + self.interface.enter_group_information.return_value = [1, [(2, 2)]] shard_set = ShardSet(self.interface) shard_set._shards_loaded = True shard_set.create_share_from_wallet_words() result_bytes = shard_set.secret_seed() - assert result_bytes == b'\x00'*32 + assert result_bytes == b"\x00" * 32 assert self.interface.enter_wallet_words.call_count == 1 assert self.interface.confirm_password.call_count == 2 assert self.interface.get_password.call_count == 2 - get_password_calls = [ call(shard.to_str()) for shard in shard_set.shards.values()] + get_password_calls = [ + call(shard.to_str()) for shard in shard_set.shards.values() + ] assert self.interface.get_password.call_args_list == get_password_calls assert self.interface.get_name_for_shard.call_count == 2 - shareid = shard_set.shards['one'].share_id + shareid = shard_set.shards["one"].share_id # group index 0, group threshold 1, groups 1, memberid changes, member threshold 2 - get_name_calls = [ call(shareid,0,1,1,i,2,shard_set.shards) for i in range(2) ] + get_name_calls = [ + call(shareid, 0, 1, 1, i, 2, shard_set.shards) for i in range(2) + ] assert self.interface.get_name_for_shard.call_args_list == get_name_calls assert self.interface.choose_shard.call_count == 2 @@ -199,7 +220,9 @@ def test_get_secret_seed_from_wallet_words(self, zero_wallet_words, password_1, assert self.rg.random.call_args_list == [call(2), call(28)] - def test_get_secret_seed_complicated_groups_with_wallet_words(self, zero_wallet_words, zero_bytes): + def test_get_secret_seed_complicated_groups_with_wallet_words( + self, zero_wallet_words, zero_bytes + ): # This is a somewhat complicated scenario. # We're going to generate a shamir share set from wallet words. @@ -218,11 +241,34 @@ def test_get_secret_seed_complicated_groups_with_wallet_words(self, zero_wallet_ self.interface.enter_wallet_words.return_value = zero_wallet_words - self.interface.confirm_password.side_effect = [b'0', b'1', b'2', b'3', b'4', b'5', b'6', b'7', b'8'] - self.interface.get_name_for_shard.side_effect = ['0', '10', '11', '12', '20', '21', '22', '23', '24'] - self.interface.choose_shard.side_effect = ['10', '12', '21', '22', '24'] - self.interface.get_password.side_effect = [b'1', b'3', b'5', b'6', b'8'] - self.interface.enter_group_information.return_value = [2,[(1,1), (2,3), (3,5)]] + self.interface.confirm_password.side_effect = [ + b"0", + b"1", + b"2", + b"3", + b"4", + b"5", + b"6", + b"7", + b"8", + ] + self.interface.get_name_for_shard.side_effect = [ + "0", + "10", + "11", + "12", + "20", + "21", + "22", + "23", + "24", + ] + self.interface.choose_shard.side_effect = ["10", "12", "21", "22", "24"] + self.interface.get_password.side_effect = [b"1", b"3", b"5", b"6", b"8"] + self.interface.enter_group_information.return_value = [ + 2, + [(1, 1), (2, 3), (3, 5)], + ] shard_set = ShardSet(self.interface) shard_set._shards_loaded = True @@ -231,13 +277,13 @@ def test_get_secret_seed_complicated_groups_with_wallet_words(self, zero_wallet_ result_bytes = shard_set.secret_seed() assert result_bytes == zero_bytes - assert self.interface.enter_wallet_words.call_count == 1 assert self.interface.confirm_password.call_count == 9 assert self.interface.get_password.call_count == 5 - share_id = shard_set.shards['0'].share_id - calls = ( [call(share_id, 0, 2, 3, 0, 1, shard_set.shards)] + share_id = shard_set.shards["0"].share_id + calls = ( + [call(share_id, 0, 2, 3, 0, 1, shard_set.shards)] + [call(share_id, 1, 2, 3, i, 2, shard_set.shards) for i in range(3)] + [call(share_id, 2, 2, 3, i, 3, shard_set.shards) for i in range(5)] ) @@ -248,7 +294,13 @@ def test_get_secret_seed_complicated_groups_with_wallet_words(self, zero_wallet_ assert self.interface.choose_shard.call_count == 5 assert self.interface.enter_group_information.call_count == 1 - assert self.rg.random.call_args_list == [call(2), call(28), call(28), call(32), call(28)] + assert self.rg.random.call_args_list == [ + call(2), + call(28), + call(28), + call(32), + call(28), + ] def test_enter_shard_words(self, encrypted_mnemonic_1): shard_set = ShardSet(interface=self.interface) @@ -256,8 +308,8 @@ def test_enter_shard_words(self, encrypted_mnemonic_1): self.interface.enter_shard_words.return_value = encrypted_mnemonic_1 - shard_set.input_shard_words('x') + shard_set.input_shard_words("x") assert self.interface.enter_shard_words.call_count == 1 - assert 'x' in shard_set.shards - assert shard_set.shards['x'].encrypted_mnemonic == encrypted_mnemonic_1 + assert "x" in shard_set.shards + assert shard_set.shards["x"].encrypted_mnemonic == encrypted_mnemonic_1 diff --git a/tests/signer/test_base.py b/tests/signer/test_base.py deleted file mode 100644 index ec03aff..0000000 --- a/tests/signer/test_base.py +++ /dev/null @@ -1,199 +0,0 @@ -from prompt_toolkit import prompt, PromptSession, HTML, print_formatted_text -from unittest.mock import patch, create_autospec -import json -import pytest - -import hermit -from hermit.signer import Signer -from hermit.wallet import HDWallet - -class FakeShards: - def __init__(self, words): - self.words = words - - def wallet_words(self): - return self.words - - -@patch('hermit.signer.reader.read_qr_code') -class TestSignerValidates(object): - - @pytest.fixture(autouse=True) - def setup_wallet_and_request(self, - bitcoin_testnet_signature_request, - opensource_wallet_words): - self.wallet = HDWallet() - self.wallet.shards = FakeShards(opensource_wallet_words) - self.request = json.loads(bitcoin_testnet_signature_request) - # - # Request - # - def test_invalid_json_is_error(self, mock_request): - mock_request.return_value = "this is not json" - with pytest.raises(hermit.errors.HermitError) as e_info: - Signer(self.wallet).sign(testnet=True) - print(e_info) - err_msg = "Expecting value: line 1 column 1 (char 0) (JSONDecodeError)" - assert str(e_info.value) == "Invalid signature request: " + err_msg - - # - # BIP32 Path - # - - def test_BIP32_path_not_string_is_error(self, mock_request): - bip32_path = [''] - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - Signer(self.wallet).validate_bip32_path(bip32_path) - - bip32_path = 123 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - Signer(self.wallet).validate_bip32_path(bip32_path) - - bip32_path = True - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - Signer(self.wallet).validate_bip32_path(bip32_path) - - bip32_path = {'a':123, 'b':456} - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info4: - Signer(self.wallet).validate_bip32_path(bip32_path) - - bip32_path = None - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info5: - Signer(self.wallet).validate_bip32_path(bip32_path) - - expected = ("Invalid signature request: " - + "BIP32 path must be a string.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - - def test_invalid_BIP32_paths_raise_error(self, mock_request): - bip32_path = 'm/123/' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - Signer(self.wallet).validate_bip32_path(bip32_path) - - bip32_path = "123'/1234/12" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - Signer(self.wallet).validate_bip32_path(bip32_path) - - bip32_path = "m" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - Signer(self.wallet).validate_bip32_path(bip32_path) - - bip32_path = "m123/123'/123/43" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info4: - Signer(self.wallet).validate_bip32_path(bip32_path) - - bip32_path = "m/123'/12''/12/123" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info5: - Signer(self.wallet).validate_bip32_path(bip32_path) - - bip32_path = "m/123'/12'/-12/123" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info6: - Signer(self.wallet).validate_bip32_path(bip32_path) - - expected = ("Invalid signature request: " - + "invalid BIP32 path formatting.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - assert str(e_info6.value) == expected - - def test_BIP32_node_too_high_raises_error(self, mock_request): - bip32_path = "m/0'/0'/2147483648/0" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - Signer(self.wallet).validate_bip32_path(bip32_path) - - bip32_path = "m/0'/0'/2147483648'/0" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - Signer(self.wallet).validate_bip32_path(bip32_path) - - expected = ("Invalid signature request: " - + "invalid BIP32 path.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - -@patch('hermit.signer.reader.read_qr_code') -@patch('hermit.signer.displayer.display_qr_code') -@patch('hermit.signer.base.input') -class TestSigner(object): - - @pytest.fixture(autouse=True) - def setup_wallet_and_request(self, - bitcoin_testnet_signature_request, - opensource_wallet_words): - self.wallet = HDWallet() - self.wallet.shards = FakeShards(opensource_wallet_words) - self.request = json.loads(bitcoin_testnet_signature_request) - - def test_confirm_signature_prompt(self, - mock_input, - mock_display_qr_code, - mock_request): - mock_input.return_value = 'y' - mock_request.return_value = json.dumps(self.request) - Signer(self.wallet).sign(testnet=True) - input_prompt = "Sign the above transaction? [y/N] " - mock_input.assert_called_with(input_prompt) - - def test_confirm_signature_prompt_in_session(self, - mock_input, - mock_display_qr_code, - mock_request): - mock_session = create_autospec(PromptSession) - mock_session.prompt.return_value = 'y' - mock_request.return_value = json.dumps(self.request) - Signer(self.wallet, mock_session).sign(testnet=True) - input_prompt = 'Sign the above transaction? [y/N] ' - assert input_prompt in mock_session.prompt.call_args[0][0].value - - @patch('hermit.signer.Signer._parse_request') - def test_no_request_returned(self, - mock_parse_request, - mock_input, - mock_display_qr_code, - mock_request): - mock_request.return_value = None - Signer(self.wallet).sign(testnet=True) - assert not mock_parse_request.called - - def test_no_request_raises_error_parse_request(self, - mock_parse_request, - mock_input, - mock_display_qr_code): - with pytest.raises(hermit.errors.HermitError) as e_info: - Signer(self.wallet)._parse_request() - err_msg = "No Request Data" - assert str(e_info.value) == err_msg - - - @patch('hermit.signer.Signer.create_signature') - def test_decline_signature_prompt(self, - mock_create_signature, - mock_input, - mock_display_qr_code, - mock_request): - mock_input.return_value = 'N' - mock_request.return_value = json.dumps(self.request) - Signer(self.wallet).sign(testnet=True) - assert not mock_create_signature.called - - - diff --git a/tests/signer/test_bitcoin_signer.py b/tests/signer/test_bitcoin_signer.py deleted file mode 100644 index c4b1b68..0000000 --- a/tests/signer/test_bitcoin_signer.py +++ /dev/null @@ -1,711 +0,0 @@ -import json -from unittest.mock import patch, create_autospec - -import pytest - -import hermit -from hermit.signer import BitcoinSigner -from hermit.wallet import HDWallet - -# TODO: mainnet test -# TODO: more test vectors - use bitcoin_multisig tests -# TODO: multi-input test -# TODO: generate_multisig_address tests - - -class FakeShards: - def __init__(self, words): - self.words = words - - def wallet_words(self): - return self.words - - -@patch('hermit.signer.reader.read_qr_code') -class TestBitcoinSignerValidation(object): - - @pytest.fixture(autouse=True) - def setup_wallet_and_request(self, - fixture_opensource_bitcoin_vector, - opensource_wallet_words): - self.wallet = HDWallet() - self.wallet.shards = FakeShards(opensource_wallet_words) - self.request = fixture_opensource_bitcoin_vector['request'] - - # - # Input Groups - # - - - def test_no_input_groups_is_error(self, mock_request): - self.request.pop('inputs', None) - mock_request.return_value = json.dumps(self.request) - - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - assert str(e_info.value) == "Invalid signature request: no input groups." - - def test_input_groups_not_array_is_error(self, mock_request): - self.request['inputs'] = 'deadbeef' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'] = 123 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'] = True - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'] = {'a':123, 'b':456} - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info4: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'] = None - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info5: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: input groups is not an array." - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - - def test_empty_input_groups_is_error(self, mock_request): - self.request['inputs'] = [] - mock_request.return_value = json.dumps(self.request) - - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: at least one input group is required." - assert str(e_info.value) == expected - - # - # Single Input Group - # - - - def test_empty_input_group_is_error(self, mock_request): - self.request['inputs'] = [[]] - mock_request.return_value = json.dumps(self.request) - - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: input group must include redeem script, BIP32 path, and at least one input." - assert str(e_info.value) == expected - - def test_too_short_input_group_is_error(self, mock_request): - self.request['inputs'][0] = self.request['inputs'][0][0:2] - mock_request.return_value = json.dumps(self.request) - - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: input group must include redeem script, BIP32 path, and at least one input." - assert str(e_info.value) == expected - - # - # Redeem Script - # - - def test_no_redeem_script_is_error(self, mock_request): - self.request['inputs'][0][0] = None - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - assert str(e_info.value) == "Invalid signature request: redeem script is not valid hex." - - def test_non_hex_redeem_script_is_error(self, mock_request): - self.request['inputs'][0][0] = 'deadbeefgh' - mock_request.return_value = json.dumps(self.request) - - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: redeem script is not valid hex." - assert str(e_info.value) == expected - - def test_odd_length_hex_redeem_script_is_error(self, mock_request): - self.request['inputs'][0][0] = 'deadbeefa' - mock_request.return_value = json.dumps(self.request) - - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: redeem script is not valid hex." - assert str(e_info.value) == expected - - # - # BIP32 Path - # - - def test_BIP32_path_not_string_is_error(self, mock_request): - self.request['inputs'][0][1] = [''] - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][1] = 123 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][1] = True - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][1] = {'a':123, 'b':456} - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info4: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][1] = None - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info5: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "BIP32 path must be a string.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - - def test_invalid_BIP32_paths_raise_error(self, mock_request): - self.request['inputs'][0][1] = 'm/123/' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][1] = "123'/1234/12" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][1] = "m" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][1] = "m123/123'/123/43" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info4: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][1] = "m/123'/12''/12/123" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info5: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][1] = "m/123'/12'/-12/123" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info6: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "invalid BIP32 path formatting.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - assert str(e_info6.value) == expected - - def test_BIP32_node_too_high_raises_error(self, mock_request): - self.request['inputs'][0][1] = "m/0'/0'/2147483648/0" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][1] = "m/0'/0'/2147483648'/0" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "invalid BIP32 path.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - - # - # Input Amount - # - - - def test_input_amount_required(self, mock_request): - self.request['inputs'][0][2].pop('amount', None) - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: no amount in input." - assert str(e_info.value) == expected - - def test_input_amount_must_integer(self, mock_request): - self.request['inputs'][0][2]['amount'] = 'a' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['amount'] = 1.2 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['amount'] = '1.2' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['amount'] = '1' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info4: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['amount'] = True - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info5: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['amount'] = None - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info6: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "input amount must be an integer (satoshis).") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - assert str(e_info6.value) == expected - - - def test_input_amount_must_positive(self, mock_request): - self.request['inputs'][0][2]['amount'] = 0 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['amount'] = -1 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "invalid input amount.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - - # - # Input TXID - # - - def test_input_txid_required(self, mock_request): - self.request['inputs'][0][2].pop('txid', None) - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: no txid in input." - assert str(e_info.value) == expected - - def test_invalid_length_input_txid_is_error(self, mock_request): - self.request['inputs'][0][2]['txid'] = 'deadbeef'*8 + 'a' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['txid'] = 'deadbeef'*8 - self.request['inputs'][0][2]['txid'] = self.request['inputs'][0][2]['txid'][1:] - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "txid must be 64 characters.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - - def test_non_hex_input_txid_is_error(self, mock_request): - self.request['inputs'][0][2]['txid'] = 'deadbeeg'*8 - mock_request.return_value = json.dumps(self.request) - - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "input TXIDs must be hexadecimal strings.") - assert str(e_info.value) == expected - - # - # Input Index - # - - def test_input_index_required(self, mock_request): - self.request['inputs'][0][2].pop('index', None) - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: no index in input." - assert str(e_info.value) == expected - - def test_input_index_must_integer(self, mock_request): - self.request['inputs'][0][2]['index'] = 'a' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['index'] = 1.2 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['index'] = '1.2' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['index'] = '1' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['index'] = True - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info4: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['inputs'][0][2]['index'] = None - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info5: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "input index must be an integer.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - - def test_input_index_cannot_be_negative(self, mock_request): - self.request['inputs'][0][2]['index'] = -1 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "invalid input index.") - assert str(e_info.value) == expected - - # - # Outputs - # - - - def test_no_outputs_is_error(self, mock_request): - self.request.pop('outputs', None) - mock_request.return_value = json.dumps(self.request) - - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - assert str(e_info.value) == "Invalid signature request: no outputs." - - def test_outputs_not_array_is_error(self, mock_request): - self.request['outputs'] = 'deadbeef' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'] = 123 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'] = True - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'] = {'a':123, 'b':456} - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info4: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'] = None - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info5: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: outputs is not an array." - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - - def test_empty_outputs_array_is_error(self, mock_request): - self.request['outputs'] = [] - mock_request.return_value = json.dumps(self.request) - - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: at least one output is required." - assert str(e_info.value) == expected - - # - # Output Address - # - - - def test_output_address_required(self, mock_request): - self.request['outputs'][1].pop('address', None) - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: no address in output." - assert str(e_info.value) == expected - - def test_output_address_must_be_base58(self, mock_request): - self.request['outputs'][1]['address'] = 'aI' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['address'] = 1.2 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['address'] = '1.2' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['address'] = True - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info4: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['address'] = None - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info5: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "output addresses must be base58-encoded strings.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - - def test_output_address_must_be_base58check(self, mock_request): - self.request['outputs'][1]['address'] = 'abcd' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['address'] = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "invalid output address checksum.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - - @patch('hermit.signer.displayer.display_qr_code') - @patch('hermit.signer.base.input') - def test_valid_output_address_types(self, - mock_input, - mock_display_qr_code, - mock_request): - mock_input.return_value = 'y' - mock_request.return_value = json.dumps(self.request) - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['address'] = "1Ma2DrB78K7jmAwaomqZNRMCvgQrNjE2QC" - self.request['outputs'][0]['address'] = "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy" - mock_request.return_value = json.dumps(self.request) - BitcoinSigner(self.wallet).sign(testnet=False) - - @patch('hermit.signer.displayer.display_qr_code') - @patch('hermit.signer.base.input') - def test_valid_segwit_testnet_output_address_types(self, - mock_input, - mock_display_qr_code, - mock_request): - mock_input.return_value = 'y' - mock_request.return_value = json.dumps(self.request) - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['address'] = "tb1q0vv0vsa4ey69h4zgedd88ldd3fhz366u99tnch" - self.request['outputs'][0]['address'] = "tb1qacn2sc90qe4f723rqjwr2kf9z004xl6ftzp0nprq9cnland8udqqzxt0tg" - mock_request.return_value = json.dumps(self.request) - BitcoinSigner(self.wallet).sign(testnet=True) - - - @patch('hermit.signer.displayer.display_qr_code') - @patch('hermit.signer.base.input') - def test_valid_segwit_mainnet_output_address_types(self, - mock_input, - mock_display_qr_code, - mock_request): - mock_input.return_value = 'y' - mock_request.return_value = json.dumps(self.request) - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['address'] = "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3" - self.request['outputs'][0]['address'] = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" - mock_request.return_value = json.dumps(self.request) - BitcoinSigner(self.wallet).sign(testnet=False) - - - def test_invalid_output_bech_addresses_error(self, mock_request): - bech_addr = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4' - self.request['outputs'][0]['address'] = bech_addr - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - test_bech_addr = 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx' - self.request['outputs'][0]['address'] = test_bech_addr - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=False) - - test_bech_addr = 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsxaaa' - self.request['outputs'][0]['address'] = test_bech_addr - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "invalid bech32 output address (check mainnet vs. testnet).") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - - def test_wrong_network_address_type_errors(self, mock_request): - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=False) - - self.request['outputs'][1]['address'] = "1Ma2DrB78K7jmAwaomqZNRMCvgQrNjE2QC" - self.request['outputs'][0]['address'] = "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy" - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "invalid output address (check mainnet vs. testnet).") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - - # - # Output Amount - # - - - def test_output_amount_required(self, mock_request): - self.request['outputs'][1].pop('amount', None) - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = "Invalid signature request: no amount in output." - assert str(e_info.value) == expected - - def test_output_amount_must_integer(self, mock_request): - self.request['outputs'][1]['amount'] = 'a' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['amount'] = 1.2 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['amount'] = '1.2' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info3: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['amount'] = '1' - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info4: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['amount'] = True - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info5: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['amount'] = None - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info6: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "output amount must be an integer (satoshis).") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - assert str(e_info6.value) == expected - - - def test_output_amount_must_positive(self, mock_request): - self.request['outputs'][1]['amount'] = 0 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - self.request['outputs'][1]['amount'] = -1 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info2: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "invalid output amount.") - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - - # - # Fees - # - - def test_network_fee_must_be_positive(self, mock_request): - self.request['outputs'][1]['amount'] = 99999999 - mock_request.return_value = json.dumps(self.request) - with pytest.raises(hermit.errors.InvalidSignatureRequest) as e_info1: - BitcoinSigner(self.wallet).sign(testnet=True) - - expected = ("Invalid signature request: " - + "fee cannot be negative.") - assert str(e_info1.value) == expected - - - diff --git a/tests/signer/test_echo_signer.py b/tests/signer/test_echo_signer.py deleted file mode 100644 index 643909e..0000000 --- a/tests/signer/test_echo_signer.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest -from unittest.mock import patch -import json - -from hermit.signer import EchoSigner -from hermit.wallet import HDWallet - - -class TestEchoSigner(object): - @patch('hermit.signer.displayer.display_qr_code') - @patch('hermit.signer.base.input') - @patch('hermit.signer.reader.read_qr_code') - def test_can_create_valid_signature(self, - mock_request, - mock_input, - mock_display_qr_code, - fixture_opensource_bitcoin_vectors, - fixture_opensource_shard_set, - capsys): - request_json = fixture_opensource_bitcoin_vectors['request_json'] - wallet = HDWallet() - wallet.shards = fixture_opensource_shard_set - mock_request.return_value = request_json - mock_input.return_value = 'y' - EchoSigner(wallet).sign(testnet=True) - mock_display_qr_code.assert_called_once() - captured = capsys.readouterr() - expected_return = json.loads(request_json) - expected_display = """QR Code: - {} - """.format(expected_return) - - mock_display_qr_code.assert_called_with(request_json, name='Request') - # FIXME -- something pretty printing is causing this not to match? - #assert captured.out == expected_display diff --git a/tests/test_config.py b/tests/test_config.py index 8e54aa3..9340653 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,65 +1,152 @@ -from unittest.mock import patch +from yaml import dump +from io import BytesIO +from unittest.mock import patch, Mock -import hermit +from hermit import get_config from hermit.config import HermitConfig -class TestHermitPaths(object): - defaults = { - 'persistShards': "cat {0} | gzip -c - > {0}.persisted", - 'backupShards': "cp {0}.persisted {0}.backup", - 'restoreBackup': "zcat {0}.backup > {0}", - 'getPersistedShards': "zcat {0}.persisted > {0}" +class TestHermitConfig(object): + + InterpolatedDefaultCommands = { + "persistShards": "cat /tmp/shard_words.bson | gzip -c - > /tmp/shard_words.bson.persisted", + "backupShards": "cp /tmp/shard_words.bson.persisted /tmp/shard_words.bson.backup", + "restoreBackup": "zcat /tmp/shard_words.bson.backup > /tmp/shard_words.bson", + "getPersistedShards": "zcat /tmp/shard_words.bson.persisted > /tmp/shard_words.bson", } # - # Loading + # HemitConfig.load # - @patch('hermit.config.path.exists') - def test_with_no_config_file(self, mock_exists): - mock_exists.return_value = False - config = HermitConfig.load() - assert config.config_file == hermit.config.HermitConfig.DefaultPaths['config_file'] - assert config.shards_file == hermit.config.HermitConfig.DefaultPaths['shards_file'] - assert config.plugin_dir == hermit.config.HermitConfig.DefaultPaths['plugin_dir'] - - @patch('hermit.config.environ.get') - def test_with_config_file(self, mock_environ_get): - mock_environ_get.return_value = './tests/fixtures/hermit.yaml' - config = HermitConfig.load() - assert config.shards_file == 'test.bson' - assert config.plugin_dir == 'testdir' + @patch("hermit.config.HermitConfig") + @patch("hermit.config.environ.get") + def test_load_with_no_environment_variable( + self, mock_environ_get, mock_HermitConfig + ): + mock_environ_get.return_value = None + mock_config = Mock() + mock_HermitConfig.return_value = mock_config + assert HermitConfig.load() == mock_config + mock_environ_get.assert_called_once_with("HERMIT_CONFIG") + mock_HermitConfig.assert_called_once_with(config_file=None) + + @patch("hermit.config.HermitConfig") + @patch("hermit.config.environ.get") + def test_load_with_environment_variable(self, mock_environ_get, mock_HermitConfig): + mock_environ_get.return_value = "/tmp/hermit.yml" + mock_config = Mock() + mock_HermitConfig.return_value = mock_config + assert HermitConfig.load() == mock_config + mock_environ_get.assert_called_once_with("HERMIT_CONFIG") + mock_HermitConfig.assert_called_once_with(config_file="/tmp/hermit.yml") # - # Paths + # HemitConfig.__init__ # - @patch('hermit.config.path.exists') - @patch('hermit.config.yaml.safe_load') - @patch('hermit.config.open') - def test_paths_can_be_set(self, mock_open, mock_safe_load, mock_exists): - shards_file = 'shards_file' - plugin_dir = 'plugin_dir' + @patch("hermit.config.exists") + def test_init_with_no_config_file(self, mock_exists): + mock_exists.return_value = False + config = HermitConfig() + assert config.paths == HermitConfig.DefaultPaths + assert config.commands == self.InterpolatedDefaultCommands + assert config.io == HermitConfig.DefaultIO + assert config.coordinator == HermitConfig.DefaultCoordinator + mock_exists.assert_called_once_with(HermitConfig.DefaultPaths["config_file"]) + + @patch("hermit.config.exists") + def test_init_with_non_existent_config_file(self, mock_exists): + mock_exists.return_value = False + config = HermitConfig(config_file="/tmp/hermit.yml") + assert config.paths == HermitConfig.DefaultPaths + assert config.commands == self.InterpolatedDefaultCommands + assert config.io == HermitConfig.DefaultIO + assert config.coordinator == HermitConfig.DefaultCoordinator + mock_exists.assert_called_once_with("/tmp/hermit.yml") + + @patch("hermit.config.exists") + @patch("hermit.config.open") + def test_init_with_existing_but_zero_byte_config_file(self, mock_open, mock_exists): mock_exists.return_value = True - mock_safe_load.return_value = { - 'shards_file': shards_file, - 'plugin_dir': plugin_dir - } - config = hermit.config.HermitConfig.load() - assert config.shards_file == shards_file - assert config.plugin_dir == plugin_dir + mock_open.return_value = BytesIO(b"") + config = HermitConfig(config_file="/tmp/hermit.yml") + assert config.paths == HermitConfig.DefaultPaths + assert config.commands == self.InterpolatedDefaultCommands + assert config.io == HermitConfig.DefaultIO + assert config.coordinator == HermitConfig.DefaultCoordinator + mock_exists.assert_called_once_with("/tmp/hermit.yml") - # - # Commands - # + @patch("hermit.config.exists") + @patch("hermit.config.open") + def test_init_with_existing_but_empty_config_file(self, mock_open, mock_exists): + config = dict() + mock_exists.return_value = True + mock_open.return_value = BytesIO(bytes(dump(config), "utf8")) + config = HermitConfig(config_file="/tmp/hermit.yml") + assert config.paths == HermitConfig.DefaultPaths + assert config.commands == self.InterpolatedDefaultCommands + assert config.io == HermitConfig.DefaultIO + assert config.coordinator == HermitConfig.DefaultCoordinator + mock_exists.assert_called_once_with("/tmp/hermit.yml") - @patch('hermit.config.path.exists') - @patch('hermit.config.yaml.safe_load') - @patch('hermit.config.open') - def test_can_set_command(self, mock_open, mock_safe_load, mock_exists): + @patch("hermit.config.exists") + @patch("hermit.config.open") + def test_init_with_complex_config_file(self, mock_open, mock_exists): + config = dict( + paths=dict( + shards_file="/root/shards.bson", + ), + commands=dict( + persistShards="cat {0} | something_else | gzip -c - > {0}.persisted", + ), + io=dict( + x_position=200, + y_position=200, + ), + coordinator=dict( + public_key="foobar", + ), + ) mock_exists.return_value = True - mock_safe_load.return_value = {'commands': - {'persistShards': 'foo {0}'}} - config = HermitConfig.load() - assert config.commands['persistShards'] == 'foo {}'.format(config.shards_file) + mock_open.return_value = BytesIO(bytes(dump(config), "utf8")) + config = HermitConfig(config_file="/tmp/hermit.yml") + + assert config.paths["shards_file"] == "/root/shards.bson" + assert config.paths["config_file"] == HermitConfig.DefaultPaths["config_file"] + assert config.paths["plugin_dir"] == HermitConfig.DefaultPaths["plugin_dir"] + + assert ( + config.commands["persistShards"] + == "cat /root/shards.bson | something_else | gzip -c - > /root/shards.bson.persisted" + ) + assert ( + config.commands["backupShards"] + == "cp /root/shards.bson.persisted /root/shards.bson.backup" + ) + assert ( + config.commands["restoreBackup"] + == "zcat /root/shards.bson.backup > /root/shards.bson" + ) + assert ( + config.commands["getPersistedShards"] + == "zcat /root/shards.bson.persisted > /root/shards.bson" + ) + + assert config.io["display"] == HermitConfig.DefaultIO["display"] + assert config.io["camera"] == HermitConfig.DefaultIO["camera"] + assert config.io["x_position"] == 200 + assert config.io["y_position"] == 200 + assert config.io["width"] == 300 + assert config.io["height"] == 300 + assert config.io["qr_code_sequence_delay"] == 200 + + assert config.coordinator["public_key"] == "foobar" + assert config.coordinator["signature_required"] is False + + mock_exists.assert_called_once_with("/tmp/hermit.yml") + + +def test_get_config(): + get_config().paths["test_path"] = "foobar" + assert get_config().paths["test_path"] == "foobar" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..16385ab --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,244 @@ +from unittest.mock import Mock, patch +from pytest import raises +from buidl.psbt import PSBT + +from hermit import InvalidCoordinatorSignature +from hermit.coordinator import ( + validate_coordinator_signature_if_necessary, + validate_rsa_signature, + create_rsa_signature, + add_rsa_signature, + extract_rsa_signature_params, + COORDINATOR_SIGNATURE_KEY, +) + + +@patch("hermit.coordinator.get_config") +class TestValidateCoordinatorSignatureIfNecessary(object): + def setup(self): + self.psbt = Mock() + self.extra_map = dict() + self.psbt.extra_map = self.extra_map + + self.config = Mock() + self.coordinator_config = dict() + self.config.coordinator = self.coordinator_config + + self.message = Mock() + self.signature = Mock() + + def test_signature_absent_and_not_required(self, mock_get_config): + mock_get_config.return_value = self.config + self.coordinator_config["signature_required"] = False + validate_coordinator_signature_if_necessary(self.psbt) + mock_get_config.assert_called_once_with() + + def test_when_signature_absent_and_required(self, mock_get_config): + mock_get_config.return_value = self.config + self.coordinator_config["signature_required"] = True + with raises(InvalidCoordinatorSignature) as e: + validate_coordinator_signature_if_necessary(self.psbt) + assert "signature is missing" in str(e) + mock_get_config.assert_called_once_with() + + @patch("hermit.coordinator.extract_rsa_signature_params") + @patch("hermit.coordinator.validate_rsa_signature") + def test_when_signature_valid_and_not_required( + self, + mock_validate_rsa_signature, + mock_extract_rsa_signature_params, + mock_get_config, + ): + mock_get_config.return_value = self.config + self.coordinator_config["signature_required"] = False + + self.extra_map[COORDINATOR_SIGNATURE_KEY] = self.signature + + mock_extract_rsa_signature_params.return_value = (self.message, self.signature) + + validate_coordinator_signature_if_necessary(self.psbt) + mock_get_config.assert_called_once_with() + mock_extract_rsa_signature_params.assert_called_once_with(self.psbt) + mock_validate_rsa_signature.assert_called_once_with( + self.message, self.signature + ) + + @patch("hermit.coordinator.extract_rsa_signature_params") + @patch("hermit.coordinator.validate_rsa_signature") + def test_when_signature_valid_and_required( + self, + mock_validate_rsa_signature, + mock_extract_rsa_signature_params, + mock_get_config, + ): + mock_get_config.return_value = self.config + self.coordinator_config["signature_required"] = True + + self.extra_map[COORDINATOR_SIGNATURE_KEY] = self.signature + + mock_extract_rsa_signature_params.return_value = (self.message, self.signature) + + validate_coordinator_signature_if_necessary(self.psbt) + mock_get_config.assert_called_once_with() + mock_extract_rsa_signature_params.assert_called_once_with(self.psbt) + mock_validate_rsa_signature.assert_called_once_with( + self.message, self.signature + ) + + @patch("hermit.coordinator.extract_rsa_signature_params") + @patch("hermit.coordinator.validate_rsa_signature") + def test_when_signature_invalid_and_not_required( + self, + mock_validate_rsa_signature, + mock_extract_rsa_signature_params, + mock_get_config, + ): + mock_get_config.return_value = self.config + self.coordinator_config["signature_required"] = False + + self.extra_map[COORDINATOR_SIGNATURE_KEY] = self.signature + + mock_extract_rsa_signature_params.return_value = (self.message, self.signature) + + mock_validate_rsa_signature.side_effect = InvalidCoordinatorSignature( + "Invalid signature." + ) + + with raises(InvalidCoordinatorSignature) as e: + validate_coordinator_signature_if_necessary(self.psbt) + + assert "Invalid signature" in str(e) + mock_get_config.assert_called_once_with() + mock_extract_rsa_signature_params.assert_called_once_with(self.psbt) + mock_validate_rsa_signature.assert_called_once_with( + self.message, self.signature + ) + + @patch("hermit.coordinator.extract_rsa_signature_params") + @patch("hermit.coordinator.validate_rsa_signature") + def test_when_signature_invalid_and_required( + self, + mock_validate_rsa_signature, + mock_extract_rsa_signature_params, + mock_get_config, + ): + mock_get_config.return_value = self.config + self.coordinator_config["signature_required"] = True + + self.extra_map[COORDINATOR_SIGNATURE_KEY] = self.signature + + mock_extract_rsa_signature_params.return_value = (self.message, self.signature) + + mock_validate_rsa_signature.side_effect = InvalidCoordinatorSignature( + "Invalid signature." + ) + + with raises(InvalidCoordinatorSignature) as e: + validate_coordinator_signature_if_necessary(self.psbt) + + assert "Invalid signature" in str(e) + mock_get_config.assert_called_once_with() + mock_extract_rsa_signature_params.assert_called_once_with(self.psbt) + mock_validate_rsa_signature.assert_called_once_with( + self.message, self.signature + ) + + +@patch("hermit.coordinator.get_config") +def test_create_rsa_sigature(mock_get_config): + public_key = open("tests/fixtures/coordinator.pub", "r").read() + private_key_path = "tests/fixtures/coordinator.pem" + + message = "Hello there".encode("utf8") + signature = create_rsa_signature(message, private_key_path) + + config = Mock() + coordinator_config = dict(public_key=public_key) + config.coordinator = coordinator_config + mock_get_config.return_value = config + validate_rsa_signature(message, signature) + mock_get_config.assert_called_once_with() + + +@patch("hermit.coordinator.get_config") +class TestValidateRSASignature(object): + def setup(self): + self.public_key = open("tests/fixtures/coordinator.pub", "r").read() + self.private_key_path = "tests/fixtures/coordinator.pem" + + self.message = "Hello there".encode("utf8") + self.signature = create_rsa_signature(self.message, self.private_key_path) + + self.config = Mock() + self.coordinator_config = dict(public_key=self.public_key) + self.config.coordinator = self.coordinator_config + + def test_when_no_coordinator_public_key_is_configured(self, mock_config): + del self.coordinator_config["public_key"] + mock_config.return_value = self.config + with raises(InvalidCoordinatorSignature) as e: + validate_rsa_signature(self.message, self.signature) + assert "no public key is configured" in str(e) + mock_config.assert_called_once_with() + + def test_when_invalid_coordinator_public_key_is_configured(self, mock_config): + self.coordinator_config["public_key"] = "foobar" + mock_config.return_value = self.config + with raises(InvalidCoordinatorSignature) as e: + validate_rsa_signature(self.message, self.signature) + assert "public key is invalid" in str(e) + mock_config.assert_called_once_with() + + def test_when_signature_is_valid(self, mock_config): + mock_config.return_value = self.config + validate_rsa_signature(self.message, self.signature) + mock_config.assert_called_once_with() + + def test_when_signature_is_invalid(self, mock_config): + mock_config.return_value = self.config + with raises(InvalidCoordinatorSignature) as e: + validate_rsa_signature(self.message + b"hello", self.signature) + mock_config.assert_called_once_with() + assert "signature is invalid" in str(e) + + +@patch("hermit.coordinator.get_config") +class TestPSBTSignatureBasics(object): + def setup(self): + self.public_key = open("tests/fixtures/coordinator.pub", "r").read() + self.private_key_path = "tests/fixtures/coordinator.pem" + + self.original_psbt_base64 = open( + "tests/fixtures/signature_requests/2-of-2.p2sh.testnet.psbt", "r" + ).read() + + self.psbt = PSBT.parse_base64(self.original_psbt_base64) + self.psbt_base64 = self.psbt.serialize_base64() + self.signature = create_rsa_signature( + bytes(self.psbt_base64, "utf8"), self.private_key_path + ) + + self.config = Mock() + self.coordinator_config = dict(public_key=self.public_key) + self.config.coordinator = self.coordinator_config + + def test_psbt_serialization_stable(self, mock_config): + mock_config.return_value = self.config + + p2 = PSBT.parse_base64(self.psbt_base64) + assert p2.serialize_base64() == self.psbt.serialize_base64() + + def test_psbt_signature(self, mock_config): + mock_config.return_value = self.config + + add_rsa_signature(self.psbt, self.private_key_path) + + assert self.psbt.extra_map[COORDINATOR_SIGNATURE_KEY] == self.signature + + def test_validate_psbt_signature(self, mock_config): + mock_config.return_value = self.config + + add_rsa_signature(self.psbt, self.private_key_path) + + unsigned_psbt_base64_bytes, sig_bytes = extract_rsa_signature_params(self.psbt) + validate_rsa_signature(unsigned_psbt_base64_bytes, sig_bytes) diff --git a/tests/test_errors.py b/tests/test_errors.py deleted file mode 100644 index 9fa8e94..0000000 --- a/tests/test_errors.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest - -from hermit.errors import HermitError, InvalidSignatureRequest - - -class TestHermitErrors(object): - - def test_HermitError_raisable(self): - with pytest.raises(HermitError): - raise HermitError - - def test_InvalidSignatureRequest_raisable_with_message(self): - with pytest.raises(InvalidSignatureRequest) as e_info: - raise InvalidSignatureRequest("test") - assert str(e_info.value) == "Invalid signature request: test." diff --git a/tests/test_functional_bitcoin_tests.py b/tests/test_functional_bitcoin_tests.py deleted file mode 100644 index 5624b83..0000000 --- a/tests/test_functional_bitcoin_tests.py +++ /dev/null @@ -1,42 +0,0 @@ -import json -from unittest.mock import patch - -import pytest - -import hermit -from hermit.signer import BitcoinSigner -from hermit.wallet import HDWallet - -@pytest.mark.integration -@patch('hermit.signer.displayer.display_qr_code') -@patch('hermit.signer.base.input') -@patch('hermit.signer.reader.read_qr_code') -class TestBitcoinSigningIntegration(object): - - def test_opensouce_bitcoin_vector_0(self, - mock_request, - mock_input, - mock_display_qr_code, - fixture_opensource_shard_set, - fixture_opensource_bitcoin_vectors, - capsys): - # TODO: use an actual shard_file - # TODO: move to all opensource vectors - test_vector = fixture_opensource_bitcoin_vectors - wallet = HDWallet() - wallet.shards = fixture_opensource_shard_set - mock_request.return_value = test_vector['request_json'] - mock_input.return_value = 'y' - - signer = BitcoinSigner(wallet) - signer.sign(testnet=True) - captured = capsys.readouterr() - - expected_display = test_vector['expected_display'] - expected_return = test_vector['expected_signature'] - - mock_display_qr_code.assert_called_once() - mock_display_qr_code.assert_called_with(json.dumps(expected_return), - name='Signature') - - # assert captured.out == expected_display diff --git a/tests/test_rng.py b/tests/test_rng.py index 06e76f9..1da1d63 100644 --- a/tests/test_rng.py +++ b/tests/test_rng.py @@ -1,37 +1,121 @@ -import string - -from hermit.rng import self_entropy, compression_entropy, entropy - -class TestSelfEntropy(object): - - # TODO: How to test selfEntropy? - def test_self_entropy(self): - some_entropy = self_entropy(string.ascii_letters) - assert some_entropy > 256 - - no_entropy = self_entropy('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') - assert no_entropy == 0 - -class TestCompressionEntropy(object): - - # TODO: How to test compressionEntropy? - def test_compression_entropy(self): - some_entropy = compression_entropy(string.ascii_letters) - assert some_entropy > 256 - - no_entropy = compression_entropy('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') - # TODO: ? - assert no_entropy == 88 - - -class TestEntropy(object): - - # TODO: How to test compressionEntropy? - def test_compression_entropy(self): - se = self_entropy(string.ascii_letters) - ce = compression_entropy(string.ascii_letters) - some_entropy = entropy(string.ascii_letters) - assert some_entropy == min(se, ce) - - no_entropy = entropy('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') - assert no_entropy == 0 +import hashlib +from string import ascii_letters +from random import shuffle +from unittest.mock import patch + +from hermit.rng import ( + max_self_entropy, + max_kolmogorov_entropy_estimate, + max_entropy_estimate, + enter_randomness, +) + + +def test_max_self_entropy(): + # An empty string should have zero self entropy. + empty_string_entropy = max_self_entropy("") + assert empty_string_entropy == 0 + + # A repeated character string should have zero self entropy. + single_character_entropy = max_self_entropy("a" * 256) + assert single_character_entropy == 0 + + # A string of N unique characters should have self entropy > N. + alphabet = ascii_letters[:] + alphabet_entropy = max_self_entropy(alphabet) + assert alphabet_entropy > 256 + + # A shuffled string of N unique characters should have self + # entropy == to the original string. + shuffled_alphabet_list = list(alphabet) + shuffle(shuffled_alphabet_list) + shuffled_alphabet = "".join(shuffled_alphabet_list) + shuffled_alphabet_entropy = max_self_entropy(shuffled_alphabet) + assert shuffled_alphabet_entropy == alphabet_entropy + + # Doubling a string of characters just doubles the self entropy. + double_alphabet = alphabet + alphabet + double_alphabet_entropy = max_self_entropy(double_alphabet) + assert double_alphabet_entropy == 2 * alphabet_entropy + + +def test_max_kolmogorov_entropy_estimate(): + # An empty string has some size after compression. + empty_string_entropy = max_kolmogorov_entropy_estimate("") + assert empty_string_entropy == 64.0 + + # A repeated character string has some size after compression. + single_character_entropy = max_kolmogorov_entropy_estimate("a" * 256) + assert single_character_entropy == 96.0 + + # A string of N unique characters has size > N after compression. + alphabet = ascii_letters[:] + alphabet_entropy = max_kolmogorov_entropy_estimate(alphabet) + assert alphabet_entropy > 256 + + # A shuffled string of N unique characters should compress to the + # same value as the original string because there are no + # interesting bigrams. + shuffled_alphabet_list = list(alphabet) + shuffle(shuffled_alphabet_list) + shuffled_alphabet = "".join(shuffled_alphabet_list) + shuffled_alphabet_entropy = max_kolmogorov_entropy_estimate(shuffled_alphabet) + assert shuffled_alphabet_entropy == alphabet_entropy + + # Doublig a string of characters should less than double the + # compression output. + double_alphabet = alphabet + alphabet + double_alphabet_entropy = max_kolmogorov_entropy_estimate(double_alphabet) + assert alphabet_entropy < double_alphabet_entropy < 2 * alphabet_entropy + + +@patch("hermit.rng.max_self_entropy") +@patch("hermit.rng.max_kolmogorov_entropy_estimate") +class TestMaxEntropyEstimate(object): + def setup(self): + self.input = "foobar" + + def test_self_entropy_exceeds_kolmogorov_entropy( + self, mock_max_kolmogorov_entropy_estimate, mock_max_self_entropy + ): + mock_max_kolmogorov_entropy_estimate.return_value = 10 + mock_max_self_entropy.return_value = 100 + assert max_entropy_estimate(self.input) == 10 + assert mock_max_kolmogorov_entropy_estimate.called_once_with(self.input) + assert mock_max_self_entropy.called_once_with(self.input) + + def test_kolmogorov_entropy_exceeds_self_entropy( + self, mock_max_kolmogorov_entropy_estimate, mock_max_self_entropy + ): + mock_max_kolmogorov_entropy_estimate.return_value = 100 + mock_max_self_entropy.return_value = 10 + assert max_entropy_estimate(self.input) == 10 + assert mock_max_kolmogorov_entropy_estimate.called_once_with(self.input) + assert mock_max_self_entropy.called_once_with(self.input) + + def test_kolmogorov_entropy_equals_self_entropy( + self, mock_max_kolmogorov_entropy_estimate, mock_max_self_entropy + ): + mock_max_kolmogorov_entropy_estimate.return_value = 10 + mock_max_self_entropy.return_value = 10 + assert max_entropy_estimate(self.input) == 10 + assert mock_max_kolmogorov_entropy_estimate.called_once_with(self.input) + assert mock_max_self_entropy.called_once_with(self.input) + + +@patch("hermit.rng.prompt") +def test_enter_randomness(mock_prompt): + mock_prompt.side_effect = ["foo\n", "bar\n", ascii_letters + "\n", EOFError()] + data = enter_randomness(1) + assert data == hashlib.sha256(("foobar" + ascii_letters).encode("utf8")).digest() + prompt_calls = mock_prompt.call_args_list + assert len(prompt_calls) == 4 + + for call in prompt_calls: + assert len(call[0]) == 1 + assert call[1] == {} + + assert "0.0 bits" in prompt_calls[0][0][0] + assert "2.8 bits" in prompt_calls[1][0][0] + assert "13.5 bits" in prompt_calls[2][0][0] + assert "327.0 bits" in prompt_calls[3][0][0] diff --git a/tests/test_signer.py b/tests/test_signer.py new file mode 100644 index 0000000..fcf32f7 --- /dev/null +++ b/tests/test_signer.py @@ -0,0 +1,400 @@ +from unittest.mock import patch, Mock +from pytest import raises + +from hermit import ( + Signer, + HDWallet, + HermitError, + InvalidPSBT, +) + + +class TestSignerSign(object): + def setup(self): + self.wallet = HDWallet() + self.signer = Signer(self.wallet) + + +# @pytest.mark.integration +# @patch("hermit.signer.display_data_as_animated_qrs") +# @patch("hermit.signer.base.input") +# @patch("hermit.signer.read_data_from_animated_qrs") +# class TestBitcoinSigningIntegration(object): +# def test_opensouce_bitcoin_vector_0( +# self, +# mock_read_data_from_animated_qrs, +# mock_input, +# mock_display_data_as_animated_qrs, +# fixture_opensource_shard_set, +# fixture_opensource_bitcoin_vectors, +# capsys, +# ): +# # TODO: use an actual shard_file +# # TODO: move to all opensource vectors +# test_vector = fixture_opensource_bitcoin_vectors +# wallet = HDWallet() +# wallet.shards = fixture_opensource_shard_set +# mock_read_data_from_animated_qrs.return_value = test_vector["request_json"] +# mock_input.return_value = "y" + +# signer = Signer(wallet) +# signer.sign(testnet=True) +# captured = capsys.readouterr() + +# expected_display = test_vector["expected_display"] +# expected_return = test_vector["expected_signature"] + +# mock_display_qr_code.assert_called_once() +# mock_display_qr_code.assert_called_with( +# json.dumps(expected_return), name="Signature" +# ) + +# # assert captured.out == expected_display + + +class TestSignerSignatureRequestHandling(object): + def setup(self): + self.wallet = HDWallet() + self.signer = Signer(self.wallet) + self.unsigned_psbt_b64 = Mock() + self.psbt = Mock() + + # + # read_signature_request + # + + @patch("hermit.signer.read_data_from_animated_qrs") + def test_read_signature_request_with_unsigned_PSBT( + self, mock_read_data_from_animated_qrs + ): + signer = Signer(self.wallet, unsigned_psbt_b64=self.unsigned_psbt_b64) + signer.read_signature_request() + assert signer.unsigned_psbt_b64 == self.unsigned_psbt_b64 + mock_read_data_from_animated_qrs.assert_not_called() + + @patch("hermit.signer.read_data_from_animated_qrs") + def test_read_signature_request_without_unsigned_PSBT( + self, mock_read_data_from_animated_qrs + ): + mock_read_data_from_animated_qrs.return_value = self.unsigned_psbt_b64 + self.signer.read_signature_request() + assert self.signer.unsigned_psbt_b64 == self.unsigned_psbt_b64 + mock_read_data_from_animated_qrs.assert_called() + + # + # parse_signature_request + # + + def test_parse_signature_request_without_unsigned_PSBT(self): + with raises(HermitError) as error: + self.signer.parse_signature_request() + assert "No PSBT" in str(error) + + @patch("hermit.signer.PSBT.parse_base64") + def test_mainnet_parse_signature_request_with_valid_PSBT(self, mock_parse_base64): + self.signer.unsigned_psbt_b64 = self.unsigned_psbt_b64 + mock_parse_base64.return_value = self.psbt + self.signer.parse_signature_request() + assert self.signer.psbt == self.psbt + mock_parse_base64.assert_called_once_with( + self.unsigned_psbt_b64, network="mainnet" + ) + + @patch("hermit.signer.PSBT.parse_base64") + def test_mainnet_parse_signature_request_with_invalid_PSBT(self, mock_parse_base64): + self.signer.unsigned_psbt_b64 = self.unsigned_psbt_b64 + mock_parse_base64.side_effect = RuntimeError("foobar") + with raises(InvalidPSBT) as error: + self.signer.parse_signature_request() + assert "Invalid PSBT" in str(error) + assert "RuntimeError" in str(error) + assert "foobar" in str(error) + mock_parse_base64.assert_called_once_with( + self.unsigned_psbt_b64, network="mainnet" + ) + + @patch("hermit.signer.PSBT.parse_base64") + def test_testnet_parse_signature_request_with_valid_PSBT(self, mock_parse_base64): + self.signer.testnet = True + self.signer.unsigned_psbt_b64 = self.unsigned_psbt_b64 + mock_parse_base64.return_value = self.psbt + self.signer.parse_signature_request() + assert self.signer.psbt == self.psbt + mock_parse_base64.assert_called_once_with( + self.unsigned_psbt_b64, network="testnet" + ) + + @patch("hermit.signer.PSBT.parse_base64") + def test_testnet_parse_signature_request_with_invalid_PSBT(self, mock_parse_base64): + self.signer.testnet = True + self.signer.unsigned_psbt_b64 = self.unsigned_psbt_b64 + mock_parse_base64.side_effect = RuntimeError("foobar") + with raises(InvalidPSBT) as error: + self.signer.parse_signature_request() + assert "Invalid PSBT" in str(error) + assert "RuntimeError" in str(error) + assert "foobar" in str(error) + mock_parse_base64.assert_called_once_with( + self.unsigned_psbt_b64, network="testnet" + ) + + # + # validate_signature_request + # + + def test_validate_signature_request_with_valid_PSBT(self): + mock_validate = Mock() + self.psbt.validate = mock_validate + self.psbt.extra_map = dict() + mock_validate.return_value = True + self.signer.psbt = self.psbt + self.signer.validate_signature_request() + + def test_validate_signature_request_with_invalid_PSBT(self): + mock_validate = Mock() + self.psbt.validate = mock_validate + mock_validate.return_value = False + self.signer.psbt = self.psbt + with raises(InvalidPSBT) as error: + self.signer.validate_signature_request() + assert "Invalid PSBT" in str(error) + + +class TestSignerTransactionDescription(object): + def setup(self): + self.wallet = HDWallet() + self.xfp_hex = "deadbeefaa" + self.wallet.xfp_hex = self.xfp_hex + self.psbt = Mock() + self.signer = Signer(self.wallet) + self.signer.psbt = self.psbt + self.metadata = dict( + tx_summary_text="foobar", + txid="this_txid", + tx_fee_sats=100, + locktime=0, + version=1, + inputs_desc=[ + dict( + idx=0, + prev_txhash="input_prev_hash", + prev_idx=2, + sats=10000, + ), + ], + outputs_desc=[ + dict( + idx=0, + addr="output address", + sats=5000, + is_change=False, + ), + dict( + idx=1, + addr="change address", + sats=4900, + is_change=True, + ), + ], + ) + + def test_generate_transaction_metadata(self): + with patch.object( + self.signer.psbt, "describe_basic_multisig" + ) as mock_describe_basic_multisig: + mock_describe_basic_multisig.return_value = self.metadata + self.signer.generate_transaction_metadata() + assert self.signer.transaction_metadata == self.metadata + mock_describe_basic_multisig.assert_called_once_with( + # xfp_for_signing=self.xfp_hex + ) + + def test_transaction_description_lines(self): + self.signer.transaction_metadata = self.metadata + assert self.signer.transaction_description_lines() == [ + "", + "TXID: this_txid", + "Lock Time: 0", + "Version: 1", + "", + "INPUTS (1):", + " Input 0:", + " Prev. TXID: input_prev_hash", + " Prev. Index: 2", + " Amount: 0.0001 BTC", + "", + "OUTPUTS (2):", + " Output 0:", + " Address: output address", + " Amount: 0.00005 BTC", + " Change?: No", + "", + " Output 1:", + " Address: change address", + " Amount: 0.000049 BTC", + " Change?: Yes", + "", + "Total Input Amount: 0.0001 BTC", + "Total Output Amount: 0.000099 BTC", + "Fee: 0.000001 BTC", + "", + ] + + @patch("hermit.signer.print_formatted_text") + def test_print_transaction_description(self, mock_print_formatted_text): + with patch.object( + self.signer, "transaction_description_lines" + ) as mock_transaction_description_lines: + mock_transaction_description_lines.return_value = [ + "line1", + "line2", + "line3", + ] + self.signer.print_transaction_description() + calls = mock_print_formatted_text.call_args_list + assert len(calls) == 3 + for call in calls: + assert len(call[0]) == 1 + assert call[1] == {} + assert calls[0][0][0] == "line1" + assert calls[1][0][0] == "line2" + assert calls[2][0][0] == "line3" + + +class TestSignerApproveSignature(object): + def setup(self): + self.wallet = HDWallet() + self.signer = Signer(self.wallet) + self.yes = "Y eS" + self.no = "anything else" + + def assert_prompt_call(self, mock): + mock.assert_called_once() + call = mock.call_args_list[0] + assert len(call[0]) == 1 + assert "Sign the above transaction? [y/N]" in str(call[0][0]) + assert call[1] == {} + + # + # With session + # + + def test_approve_signature_request_with_session(self): + mock_session = Mock() + mock_prompt = Mock() + mock_session.prompt = mock_prompt + mock_prompt.return_value = self.yes + self.signer.session = mock_session + assert self.signer.approve_signature_request() is True + self.assert_prompt_call(mock_prompt) + + def test_reject_signature_request_with_session(self): + mock_session = Mock() + mock_prompt = Mock() + mock_session.prompt = mock_prompt + mock_prompt.return_value = self.no + self.signer.session = mock_session + assert self.signer.approve_signature_request() is False + self.assert_prompt_call(mock_prompt) + + # + # Without session + # + + @patch("hermit.signer.input") + def test_approve_signature_request_without_session(self, mock_input): + mock_input.return_value = self.yes + assert self.signer.approve_signature_request() is True + self.assert_prompt_call(mock_input) + + @patch("hermit.signer.input") + def test_reject_signature_request_without_session(self, mock_input): + mock_input.return_value = self.no + assert self.signer.approve_signature_request() is False + self.assert_prompt_call(mock_input) + + +class TestSignerCreateSignature(object): + def setup(self): + self.wallet = HDWallet() + self.testnet = Mock() + self.signer = Signer(self.wallet, testnet=self.testnet) + self.psbt = Mock() + self.signer.psbt = self.psbt + self.signer.transaction_metadata = dict( + root_paths=dict( + xfp1=["m/45'/0'/0'"], + xfp2=["m/45'/0'/0'"], + xfp3=["m/45'/0'/1'"], + ) + ) + self.mock_wallet_private_key = Mock() + self.wallet.private_key = self.mock_wallet_private_key + self.mock_private_keys = [Mock(), Mock(), Mock()] + self.mock_wallet_private_key.side_effect = self.mock_private_keys + + self.mock_sign_with_private_keys = Mock() + self.psbt.sign_with_private_keys = self.mock_sign_with_private_keys + + def teardown(self): + self.mock_sign_with_private_keys.assert_called_once_with( + private_keys=self.mock_private_keys + ) + + calls = self.mock_wallet_private_key.call_args_list + assert len(calls) == 3 + for index, call in enumerate(calls): + assert len(call[0]) == 1 + assert call[1] == dict(testnet=self.testnet) + + assert calls[0][0][0] == "m/45'/0'/0'" + assert calls[1][0][0] == "m/45'/0'/0'" + assert calls[2][0][0] == "m/45'/0'/1'" + + # + # Create signature + # + + def test_create_signature_successfully(self): + self.mock_sign_with_private_keys.return_value = True + + mock_signed_psbt_b64 = Mock() + mock_serialize_base64 = Mock() + mock_serialize_base64.return_value = mock_signed_psbt_b64 + self.psbt.serialize_base64 = mock_serialize_base64 + + self.signer.create_signature() + assert self.signer.signed_psbt_b64 == mock_signed_psbt_b64 + + def test_create_signature_unsuccessfully(self): + self.mock_sign_with_private_keys.return_value = False + + with raises(HermitError) as error: + self.signer.create_signature() + assert "Failed to sign" in str(error) + + +class TestSignerShowSignature(object): + @patch("hermit.signer.print_formatted_text") + @patch("hermit.signer.display_data_as_animated_qrs") + def test_show_signature( + self, mock_display_data_as_animated_qrs, mock_print_formatted_text + ): + wallet = HDWallet() + signer = Signer(wallet) + signed_psbt_b64 = "foobar" + signer.signed_psbt_b64 = signed_psbt_b64 + signer.show_signature() + + calls = mock_print_formatted_text.call_args_list + assert len(calls) == 2 + for call in calls: + assert len(call[0]) == 1 + assert call[1] == {} + + assert "Signed PSBT" in str(calls[0][0][0]) + assert signed_psbt_b64 in str(calls[1][0][0]) + + mock_display_data_as_animated_qrs.assert_called_once_with( + base64_data=signed_psbt_b64 + ) diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 6b00c38..2e65901 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1,8 +1,10 @@ -import json -import pytest +from unittest.mock import Mock +from pytest import raises +from buidl import HDPrivateKey from hermit.wallet import HDWallet -import hermit +from hermit.errors import HermitError + class FakeShards: def __init__(self, words): @@ -11,167 +13,88 @@ def __init__(self, words): def wallet_words(self): return self.words -class TestCompressedPrivateKeyFromBIP32(object): - pass - - -class TestCompressedPublickKeyFromBIP32(object): - pass - - -class TestHardened(object): - pass - - -class TestDecodeSegment(object): - pass - - -class TestBIP32Sequence(object): - - def test_invalid_BIP32_paths_raise_error(self): - bip32_path = 'm/123/' - with pytest.raises(hermit.errors.HermitError) as e_info1: - hermit.wallet.bip32_sequence(bip32_path) - - bip32_path = "123'/1234/12" - with pytest.raises(hermit.errors.HermitError) as e_info2: - hermit.wallet.bip32_sequence(bip32_path) - - bip32_path = "m" - with pytest.raises(hermit.errors.HermitError) as e_info3: - hermit.wallet.bip32_sequence(bip32_path) - - bip32_path = "m123/123'/123/43" - with pytest.raises(hermit.errors.HermitError) as e_info4: - hermit.wallet.bip32_sequence(bip32_path) - - bip32_path = "m/123'/12''/12/123" - with pytest.raises(hermit.errors.HermitError) as e_info5: - hermit.wallet.bip32_sequence(bip32_path) - - bip32_path = "m/123'/12'/-12/123" - with pytest.raises(hermit.errors.HermitError) as e_info6: - hermit.wallet.bip32_sequence(bip32_path) - - expected = "Not a valid BIP32 path." - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - assert str(e_info4.value) == expected - assert str(e_info5.value) == expected - assert str(e_info6.value) == expected - - -class TestHDWalletInit(object): - pass - -class TestHDWalletUnlocked(object): +class TestHDWalletLocking(object): + def setup(self): + self.wallet = HDWallet() - def test_unlocked_wallet_is_unlocked(self, - opensource_wallet_words): - wallet = HDWallet() - wallet.shards = FakeShards(opensource_wallet_words) - wallet.unlock() - assert wallet.unlocked() == True + def test_wallet_starts_out_locked(self): + assert not self.wallet.unlocked() - def test_init_wallet_is_not_unlocked(self): - wallet = HDWallet() - assert wallet.unlocked() == False + def test_locked_wallet_can_be_locked(self): + self.wallet.lock() + assert not self.wallet.unlocked() - def test_locked_wallet_is_not_unlocked(self, - opensource_wallet_words): - wallet = HDWallet() - wallet.shards = FakeShards(opensource_wallet_words) - wallet.unlock() - wallet.lock() - assert wallet.unlocked() == False + def test_locked_wallet_can_be_unlocked_if_seed_is_valid( + self, opensource_wallet_words + ): + self.wallet.shards = FakeShards(opensource_wallet_words) + self.wallet.unlock() + assert self.wallet.unlocked() - -class TestHDWalletUnlock(object): - - def test_root_priv_from_trezor_vectors(self,trezor_bip39_vectors): - # With Passphrase - for v in trezor_bip39_vectors['english']: + def test_locked_wallet_can_be_unlocked_if_seed_is_valid_with_passphrase( + self, trezor_bip39_vectors + ): + for v in trezor_bip39_vectors["english"]: wallet = HDWallet() wallet.shards = FakeShards(v[1]) wallet.unlock(passphrase="TREZOR") - xprv = wallet.root_priv + xprv = wallet.root_xprv assert xprv == v[3] - def test_root_priv_from_unchained_vectors(self,unchained_vectors): - # Without Passphrase - for words in unchained_vectors: - wallet = HDWallet() - wallet.shards = FakeShards(words) - wallet.unlock() - xprv = wallet.root_priv - expected_xprv = unchained_vectors[words]['m']['xprv'] - assert xprv == expected_xprv - - - def test_checksum_failed_raises_error(self): - wallet = HDWallet() - - # https://github.com/trezor/python-mnemonic/blob/master/test_mnemonic.py - wallet.shards = FakeShards("bless cloud wheel regular tiny venue" - + "bird web grief security dignity zoo") - with pytest.raises(hermit.errors.HermitError) as e_info1: - wallet.unlock() - - # https://github.com/kristovatlas/bip39_gym/blob/master/test_bip39.py - wallet.shards = FakeShards("town iron abandon") - with pytest.raises(hermit.errors.HermitError) as e_info2: - wallet.unlock() - - #https://github.com/tyler-smith/go-bip39/blob/master/bip39_test.go - wallet.shards = FakeShards("abandon abandon abandon abandon abandon" - + "abandon abandon abandon abandon abandon" - + "abandon yellow") - with pytest.raises(hermit.errors.HermitError) as e_info3: - wallet.unlock() - - expected = "Wallet words failed checksum." - assert str(e_info1.value) == expected - assert str(e_info2.value) == expected - assert str(e_info3.value) == expected - - -class TestHDWalletExtendedPublicKey(object): + def test_locked_wallet_cannot_be_unlocked_if_seed_is_invalid(self): + self.wallet.shards = FakeShards("foo bar") + with raises(HermitError) as error: + self.wallet.unlock() + assert "failed checksum" in str(error) - def test_bip32_vectors(self, bip32_vectors): - for seed in bip32_vectors: - wallet = HDWallet() - wallet.root_priv = bip32_vectors[seed]['m']['xprv'] - for path in bip32_vectors[seed]: - if path != "m": - xpub = wallet.extended_public_key(path) - expected_xpub = bip32_vectors[seed][path]['xpub'] - assert xpub == expected_xpub + def test_unlocked_wallet_can_tell_when_unlocked(self): + self.wallet._root_xprv = Mock() + assert self.wallet.unlocked() + def test_unlocked_wallet_can_be_unlocked(self): + self.wallet._root_xprv = Mock() + self.wallet.unlock() + assert self.wallet.unlocked() -class TestHDWalletPublicKey(object): + +class TestHDWalletTraversal(object): def test_bip32_vectors(self, bip32_vectors): for seed in bip32_vectors: wallet = HDWallet() - wallet.root_priv = bip32_vectors[seed]['m']['xprv'] + wallet.root_xprv = bip32_vectors[seed]["m"]["xprv"] for path in bip32_vectors[seed]: if path != "m": - pubkey = wallet.public_key(path) - expected_pubkey = bip32_vectors[seed][path]['pubkey'] - assert pubkey == expected_pubkey - - -class TestHDWalletExtendedPrivateKey(object): + # The test vectors file doesn't have private keys, + # just xprv, so we have do calculate the private + # key from the xprv ourselves here in order to + # compare + xprv = bip32_vectors[seed][path]["xprv"] + expected_private_key = HDPrivateKey.parse(xprv).private_key + private_key = wallet.private_key(path) + assert expected_private_key.hex() == private_key.hex() + + xpub = wallet.xpub(path) + expected_xpub = bip32_vectors[seed][path]["xpub"] + assert xpub == expected_xpub - def test_bip32_vectors(self, bip32_vectors): - for seed in bip32_vectors: + def test_unchained_vectors(self, unchained_vectors): + for seed in unchained_vectors: wallet = HDWallet() - wallet.root_priv = bip32_vectors[seed]['m']['xprv'] - for path in bip32_vectors[seed]: + wallet.root_xprv = unchained_vectors[seed]["m"]["xprv"] + for path in unchained_vectors[seed]: if path != "m": - xprv = wallet.extended_private_key(path) - expected_xprv = bip32_vectors[seed][path]['xprv'] - assert xprv == expected_xprv + + # The test vectors file doesn't have private keys, + # just xprv, so we have do calculate the private + # key from the xprv ourselves here in order to + # compare + xprv = unchained_vectors[seed][path]["xprv"] + expected_private_key = HDPrivateKey.parse(xprv).private_key + private_key = wallet.private_key(path) + assert expected_private_key.hex() == private_key.hex() + + xpub = wallet.xpub(path) + expected_xpub = unchained_vectors[seed][path]["xpub"] + assert xpub == expected_xpub diff --git a/vendor/.gitkeep b/vendor/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/vendor/pybitcointools b/vendor/pybitcointools deleted file mode 160000 index 9652487..0000000 --- a/vendor/pybitcointools +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9652487560b76fdfa6131274ba27bed2e6fdb9a7