diff --git a/docs/development.md b/docs/development.md index 251ee8f64..bb0fcc955 100644 --- a/docs/development.md +++ b/docs/development.md @@ -14,6 +14,7 @@ - [Set up virtualenv](#set-up-virtualenv) - [If `pip install` fails on `cryptography==3.4.x`](#if-pip-install-fails-on-cryptography34x) - [How to run the tests](#how-to-run-the-tests) + - [Hardware-attended Jade tests](#hardware-attended-jade-tests) - [Code-Style](#code-style) - [Developing on tests](#developing-on-tests) - [bitcoin-specific stuff](#bitcoin-specific-stuff) @@ -209,6 +210,36 @@ Print the logging output live to the terminal: pytest --capture=no --log-cli-level=DEBUG ``` +### Hardware-attended Jade tests + +`tests/test_jade_hardware.py` exercises Specter's HWI integration end-to-end against a physical Blockstream Jade. It is gated by `--run-jade-hardware` and skipped by default, so GitHub Actions ignore it without any workflow change. + +Run with `-s` so operator prompts reach the terminal: +``` +pytest --run-jade-hardware tests/test_jade_hardware.py -s +``` + +Three tests, increasing operator effort: + +| Test | What it does | Operator action | +|---|---|---| +| `test_jade_enumerate_via_specter` | `HWIBridge.enumerate()` finds the Jade and returns a fingerprint | Connect Jade, unlock | +| `test_jade_extract_xpub_via_specter` | Pulls xpub at `m/84h/0h/0h` (mainnet) | Confirm xpub export on device | +| `test_jade_sign_psbt_via_specter` | Signs a canned testnet PSBT through Specter's sign path | Boot Jade in Temporary Signer mode, scan SeedQR, confirm tx | + +The signing test uses the public **BIP-39 abandon vector** (`abandon abandon ... about`) so the PSBT fixture matches anyone's Jade once they load that seed. Setup procedure: + +1. Power-cycle the Jade so it shows the boot menu. +2. Choose **Temporary Signer** -> **Scan SeedQR**. +3. Display `tests/fixtures/jade_seedqr_abandon.png` (or `cat tests/fixtures/jade_seedqr_abandon.txt` for the ASCII version) and scan it with the Jade camera. +4. When Jade asks for the network, select **TESTNET**. +5. Press Enter at the test prompt. +6. Confirm the transaction on the Jade screen when it pops up (~99,500 sats to a testnet bech32 output, ~99,500 change auto-validated, 1,000 fee). + +Temporary Signer state is held in RAM only and wiped on power-cycle/USB-unplug — your real seed is not affected. Expected master fingerprint for the abandon vector is `73c5da0a`; the test fails fast with a clear hint if the loaded seed is wrong. + +The fixture PSBT (`tests/fixtures/jade_hardware.psbt`) was generated with embit against m/84'/1'/0'/0/0 of the abandon vector, including a synthetic `non_witness_utxo` so Jade can verify the input amount per the SegWit fee-spoof mitigation. + Get the log-output of bitcoind side by side with the test-output. For sure you will only see the logs if the test fails. ``` pytest --bitcoind-log-stdout diff --git a/pyproject.toml b/pyproject.toml index c822bb7db..df8c7953d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ readme = "README.md" license = {file = "LICENSE"} -requires-python = ">=3.7,<4.0" +requires-python = ">=3.9,<3.13" dynamic = ["dependencies", "version"] @@ -52,7 +52,8 @@ markers = [ "slow: mark test as slow.", "elm: mark test as elementsd dependent", "bottleneck: mark a test as so ressource intensive that it can create a bottleneck where the test just fails due to a lack of ressources", - "threading: test needs threading to work" + "threading: test needs threading to work", + "jade_hardware: requires a real Jade attached and an operator; opt-in only via --run-jade-hardware" ] filterwarnings = [ diff --git a/requirements.in b/requirements.in index b598a3184..8282f4be8 100644 --- a/requirements.in +++ b/requirements.in @@ -6,7 +6,7 @@ Flask-Cors==6.0.0 Flask-Login==0.6.3 Flask-RESTful==0.3.10 Flask-HTTPAuth==4.8.0 -hwi==2.4.0 +hwi==3.1.0 python-dotenv==0.21.1 requests==2.31.0 pysocks==1.7.1 diff --git a/requirements.txt b/requirements.txt index 00ab63955..81f3bc029 100644 --- a/requirements.txt +++ b/requirements.txt @@ -432,9 +432,9 @@ hidapi==0.14.0 \ --hash=sha256:fb4e94e45f6dddb20d59501187721e5d3b02e6cc8a59d261dd5cac739008582a \ --hash=sha256:fc9ec2321bf3b0b4953910aa87c0c8ab5f93b1f113a9d3d4f18845ce54708d13 # via hwi -hwi==2.4.0 \ - --hash=sha256:3eaa7593f1ab360569eacdd9507dab75532bb58e8cd991d8ad72f5c4fcb67997 \ - --hash=sha256:7cb7ef2a4db4bc434815374d9bad43c6425491f77828314a2d2898d3e86d3f04 +hwi==3.1.0 \ + --hash=sha256:21ba92bb06e2f805e2806c686f2c50d02db6826a363b01e44052415755504d6f \ + --hash=sha256:42e875cbb616a91638fb90679cad93edb5075bf375e92fc1709be9b2a3dfd59c # via -r requirements.in idna==3.7 \ --hash=sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc \ @@ -921,5 +921,6 @@ wtforms==3.1.2 \ # via flask-wtf # WARNING: The following packages were not pinned, but pip requires them to be -# pinned when the requirements file includes hashes. Consider using the --allow-unsafe flag. +# pinned when the requirements file includes hashes and the requirement is not +# satisfied by a package already installed. Consider using the --allow-unsafe flag. # setuptools diff --git a/tests/conftest.py b/tests/conftest.py index a7368138a..6be08fab0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -89,9 +89,24 @@ def pytest_addoption(parser): default="master", help="Version of elementsd (something which works with git checkout ...)", ) + parser.addoption( + "--run-jade-hardware", + action="store_true", + default=False, + help="Run tests marked jade_hardware (real Jade attached + operator).", + ) listen() +def pytest_collection_modifyitems(config, items): + if config.getoption("--run-jade-hardware"): + return + skip = pytest.mark.skip(reason="opt-in via --run-jade-hardware") + for item in items: + if item.get_closest_marker("jade_hardware") is not None: + item.add_marker(skip) + + def pytest_generate_tests(metafunc): # ToDo: use custom compiled version of bitcoind # E.g. test again bitcoind version [currentRelease] + master-branch diff --git a/tests/fixtures/jade_hardware.psbt b/tests/fixtures/jade_hardware.psbt new file mode 100644 index 000000000..3a68aa829 --- /dev/null +++ b/tests/fixtures/jade_hardware.psbt @@ -0,0 +1 @@ +cHNidP8BAHECAAAAAYz9aGwGnHSzsZXBuDlKg9hNl6KeLvo8czy6WHaPTGwGAAAAAAD/////AqyEAQAAAAAAFgAUb6AWUAo8anN+uyYOLdyni6kjRVishAEAAAAAABYAFC80qhzwClOwVaKRoDp9RfCmmItSAAAAAAABAFMCAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wFR/////wFADQMAAAAAABYAFNDEo+8J6Ze26Z45flGP4+QaEYyhAAAAAAEBH0ANAwAAAAAAFgAU0MSj7wnpl7bpnjl+UY/j5BoRjKEiBgLnqyU3tdSelwMJquBunknzbOHJ/rvUTsjg0cygtPnDGRhzxdoKVAAAgAEAAIAAAACAAAAAAAAAAAAAACICA11J7M1U0AmeQ2did8em1GJdYR2oil30m/lReneRp3elGHPF2gpUAACAAQAAgAAAAIABAAAAAAAAAAA= diff --git a/tests/fixtures/jade_seedqr_abandon.png b/tests/fixtures/jade_seedqr_abandon.png new file mode 100644 index 000000000..9ec31d505 Binary files /dev/null and b/tests/fixtures/jade_seedqr_abandon.png differ diff --git a/tests/fixtures/jade_seedqr_abandon.txt b/tests/fixtures/jade_seedqr_abandon.txt new file mode 100644 index 000000000..606ebc795 --- /dev/null +++ b/tests/fixtures/jade_seedqr_abandon.txt @@ -0,0 +1,15 @@ +█████████████████████████████ +██ ▄▄▄▄▄ ██▄▀▀ ▀▄▀▄█ ▄▄▄▄▄ ██ +██ █   █ █▄  ▀▄▀▄▀▄█ █   █ ██ +██ █▄▄▄█ ██▀▄██▀▄▀▄█ █▄▄▄█ ██ +██▄▄▄▄▄▄▄█ ▀ █ ▀▄▀▄█▄▄▄▄▄▄▄██ +██  ▄▀ ▄▄▄▄█  █▀▄▀▄ ▄▀█▀▄▀███ +██ ▀██▄▀▄▀▀▄█▀██▄▀▄▀▄▀▄▀▄▀▄██ +████▀▄▄▄▄▀▀██▀ ▀█▀▄▀▄▀▄▀▄▀▄██ +██▄▀█ ██▄▀██ ▄ ▀▄▀▄▀▄▀▄▀▄▀▄██ +██▄██▄▄▄▄▄▀▀▀ ██▄▀ ▄▄▄ ▀▄▀▄██ +██ ▄▄▄▄▄ █ ▄ ▀█▄█▀ █▄█ ▀▄▀▄██ +██ █   █ █▄▄ ▀ ▀█▀▄ ▄ ▄▀▄ ███ +██ █▄▄▄█ █ ▀█▄ ▀ ▀▄▀▄▀▄▀▄█▄██ +██▄▄▄▄▄▄▄█▄█▄▄██▄█▄█▄█▄█▄█▄██ +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ diff --git a/tests/test_jade_hardware.py b/tests/test_jade_hardware.py new file mode 100644 index 000000000..3bdb947be --- /dev/null +++ b/tests/test_jade_hardware.py @@ -0,0 +1,140 @@ +"""Opt-in tests requiring a physical Blockstream Jade attached + operator. + +Skipped by default. Enable with:: + + pytest --run-jade-hardware tests/test_jade_hardware.py -s + +The ``-s`` is required so operator prompts reach your terminal. + +Operator setup +-------------- +For ``test_jade_enumerate_via_specter`` and +``test_jade_extract_xpub_via_specter``: any Jade with any seed (your +real one is fine — those tests only read public material). + +For ``test_jade_sign_psbt_via_specter``: the Jade must be in +**Temporary Signer mode** with the public **BIP-39 abandon vector** seed +(``abandon abandon ... about``) loaded. The simplest path: + + 1. Power-cycle the Jade so it's at the boot menu. + 2. Choose ``Temporary Signer`` -> ``Scan SeedQR``. + 3. Scan ``tests/fixtures/jade_seedqr_abandon.png`` (or display + ``tests/fixtures/jade_seedqr_abandon.txt`` and scan from screen). + 4. Confirm the **testnet** network on Jade. + 5. The PSBT at ``tests/fixtures/jade_hardware.psbt`` is fabricated by + Coldcard's psbt_faker against this exact seed. + +Temporary Signer state lives in RAM only; it's wiped on power-cycle. Your +real seed is not affected. +""" + +from pathlib import Path + +import pytest + +from cryptoadvance.specter.hwi_rpc import HWIBridge + + +FIXTURE_DIR = Path(__file__).parent / "fixtures" +PSBT_FIXTURE = FIXTURE_DIR / "jade_hardware.psbt" +SEEDQR_PNG = FIXTURE_DIR / "jade_seedqr_abandon.png" +SEEDQR_TXT = FIXTURE_DIR / "jade_seedqr_abandon.txt" + +ABANDON_FINGERPRINT = "73c5da0a" + + +def _enumerate_jade(bridge: HWIBridge, chain: str = "main"): + # HWIBridge.enumerate defaults chain="" which Chain.argparse passes + # through unchanged; Jade's enumerate then fails with + # "Unhandled network: ". Pass an explicit chain. + devs = bridge.enumerate(chain=chain) + return [d for d in devs if d.get("type") == "jade"] + + +def _prompt(msg: str) -> None: + print(f"\n>>> {msg}") + try: + input(">>> Press Enter when ready... ") + except EOFError: + pass + + +@pytest.mark.jade_hardware +def test_jade_enumerate_via_specter(): + """Jade is detected by Specter's HWIBridge and reports a fingerprint.""" + _prompt("Connect and unlock the Jade.") + bridge = HWIBridge(skip_hwi_initialisation=True) + jades = _enumerate_jade(bridge) + assert jades, "no Jade detected — connect, unlock, and rerun" + jade = jades[0] + assert jade.get("fingerprint"), f"Jade enumerated without fingerprint: {jade}" + assert jade.get("path"), f"Jade enumerated without path: {jade}" + + +@pytest.mark.jade_hardware +def test_jade_extract_xpub_via_specter(): + """Specter can pull an xpub at a known derivation from Jade.""" + _prompt("Unlock the Jade. You may be asked to confirm the xpub export.") + bridge = HWIBridge(skip_hwi_initialisation=True) + jades = _enumerate_jade(bridge) + assert jades, "no Jade detected" + fingerprint = jades[0].get("fingerprint") + assert fingerprint, f"Jade enumerated without fingerprint: {jades[0]}" + + # chain must be passed explicitly: HWIBridge.extract_xpub default is + # chain="" which Specter's JadeClient.__init__ rejects via _network() + # before extract_xpub's post-init override can apply. + xpub_line = bridge.extract_xpub( + derivation="m/84h/0h/0h", + device_type="jade", + fingerprint=fingerprint, + chain="main", + ) + assert xpub_line, "extract_xpub returned empty" + assert xpub_line.startswith("["), f"unexpected format: {xpub_line!r}" + assert "]" in xpub_line, f"unexpected format: {xpub_line!r}" + body = xpub_line.split("]", 1)[1].strip() + assert body.startswith(("xpub", "zpub", "ypub")), f"unexpected xpub: {body[:8]}" + + +@pytest.mark.jade_hardware +def test_jade_sign_psbt_via_specter(): + """End-to-end: Specter signs the canned abandon-vector PSBT through Jade. + + Requires Jade in Temporary Signer mode with the abandon-vector seed + (see module docstring). The fixture PSBT was generated by Coldcard's + psbt_faker against m/84'/1'/0' on testnet; xfp is 73c5da0a. + """ + assert PSBT_FIXTURE.exists(), f"missing fixture: {PSBT_FIXTURE}" + + seedqr_hint = ( + f"\n PNG: {SEEDQR_PNG}\n" + f" ASCII: cat {SEEDQR_TXT}" + ) + _prompt( + "Put Jade in Temporary Signer mode -> Scan SeedQR -> select TESTNET." + f"\n SeedQR for the BIP-39 abandon-vector lives at:{seedqr_hint}\n" + " Then confirm the transaction on device when prompted." + ) + + psbt_b64 = PSBT_FIXTURE.read_text().strip() + + bridge = HWIBridge(skip_hwi_initialisation=True) + jades = _enumerate_jade(bridge, chain="test") + assert jades, "no Jade detected" + fingerprint = jades[0].get("fingerprint") + assert fingerprint, f"Jade enumerated without fingerprint: {jades[0]}" + assert fingerprint.lower() == ABANDON_FINGERPRINT, ( + f"connected Jade fingerprint is {fingerprint}; " + f"expected {ABANDON_FINGERPRINT} (abandon-vector). " + "Are you in Temporary Signer mode with the right SeedQR?" + ) + + signed = bridge.sign_tx( + psbt=psbt_b64, + device_type="jade", + fingerprint=fingerprint, + chain="test", + ) + assert signed, "sign_tx returned empty" + assert signed != psbt_b64, "PSBT was returned unsigned"