diff --git a/.circleci/config.yml b/.circleci/config.yml index 7f6aefe6..ca41ba78 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,80 +1,27 @@ -version: 2 # keep 2.0 syntax; CircleCI 2.1 also works +version: 2.1 +# The authoritative multi-release emulator matrix lives in GitHub Actions. +# Keep this smoke job while the legacy CircleCI project remains connected so +# its required status verifies the checkout instead of failing at config load. jobs: - emulator-build-test: + canonical-smoke: docker: - - image: circleci/python:3.7 # upgrade to cimg/python:3.12 if you like + - image: cimg/python:3.11 steps: - # ──────────────────────────────────────────────────────────────── - # 1) Clone the current branch of python-keepkey via HTTPS - # ──────────────────────────────────────────────────────────────── - run: - name: Clone python-keepkey (current branch) + name: Checkout current branch over HTTPS command: | - git clone --depth 1 -b "$CIRCLE_BRANCH" https://github.com/keepkey/python-keepkey.git .pykk - cd .pykk && git submodule update --init --recursive - - # ──────────────────────────────────────────────────────────────── - # 2) Grab firmware repo and inject our fresh python-keepkey copy - # ──────────────────────────────────────────────────────────────── - - run: - name: Checkout firmware & inject python-keepkey - command: | - # Ensure all submodule URLs fall back to HTTPS - git config --global url."https://github.com/".insteadOf git@github.com: - git config --global url."https://".insteadOf git:// - - # Move python-keepkey out of the way - mv .pykk ../ - - # Clone firmware repository (expects $FIRMWARE_REPO env var) - git clone --depth 1 -b master "$FIRMWARE_REPO" . - - # Initialise firmware submodules + git clone --depth 1 -b "$CIRCLE_BRANCH" \ + "https://github.com/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}.git" . git submodule update --init --recursive - - # Replace the vendor copy with our PR branch python-keepkey - rm -rf deps/python-keepkey - mv ../.pykk deps/python-keepkey - - # This is python-keepkey's standalone compatibility lane. Release - # OLED/PDF evidence is generated only by the exact-head firmware - # workflow, where the firmware and Python SHAs are pinned together. - # Do not inherit the firmware checkout's report/screenshot phase: - # it can describe a different (published/master) emulator and is - # therefore not release evidence for this Python PR. - cp deps/python-keepkey/.circleci/standalone-emulator-tests.sh \ - scripts/emulator/python-keepkey-tests.sh - - # ──────────────────────────────────────────────────────────────── - # 3) Build the Docker-based emulator tests - # ──────────────────────────────────────────────────────────────── - - setup_remote_docker - - run: - name: Emulator tests + name: Validate canonical Python and EOS vector contracts command: | - pushd ./scripts/emulator - set +e # don’t exit on first failure - docker-compose up --build firmware-unit - docker-compose up --build python-keepkey - set -e - - # Collect JUnit / pytest XML results - mkdir -p ../../test-reports - docker cp "$(docker-compose ps -q firmware-unit)":/kkemu/test-reports/. ../../test-reports/ - docker cp "$(docker-compose ps -q python-keepkey)":/kkemu/test-reports/. ../../test-reports/ - popd - - # Fail job if either container reported non-zero status - [ "$(cat test-reports/python-keepkey/status)$(cat test-reports/firmware-unit/status)" = "00" ] || exit 1 - - - store_test_results: - path: test-reports + python -m py_compile keepkeylib/*.py tests/test_msg_solana_signtx.py + python -m pip install --quiet pytest requests + python -m pytest -q tests/unit/test_eos_updateauth_vector.py -# ────────────────────────────────────────────────────────────────────── workflows: - version: 2 - emulator: + canonical: jobs: - - emulator-build-test + - canonical-smoke diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72ab9814..b533b8e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,34 @@ # KeepKey python-keepkey CI # -# Pulls the published emulator image (kktech/kkemu) from DockerHub -# and runs the full python integration test suite against it. +# Builds both fork release candidates from immutable commits and runs this +# exact Python head against their regular and Bitcoin-only product surfaces. # # Stage 1: GATE (seconds) -# └─ lint basic Python syntax check +# └─ lint Python syntax + deterministic protocol contract tests # # Stage 2: TEST (gated by Stage 1) -# └─ integration full pytest suite against emulator +# ├─ integration full suite against regular 7.15 +# ├─ integration-7143 full suite against regular 7.14.3 +# └─ integration-btc Bitcoin-only boundary against both candidates name: CI on: push: - branches: [master, develop, 'feature/**', 'fix/**', 'hotfix/**'] + branches: [master, develop, 'reconcile/**', 'feature/**', 'fix/**', 'hotfix/**'] pull_request: - branches: [master, develop] + branches: [master, develop, reconcile/upstream-sync] + +# Every job only checks out source and emits runner-native annotations. Keep +# the workflow token read-only even when the repository default is broader. +permissions: + contents: read + +# One run per ref: a new push supersedes the old instead of both burning a +# runner to completion. +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: # ═══════════════════════════════════════════════════════════ @@ -26,6 +39,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-python@v5 with: @@ -34,9 +49,44 @@ jobs: - name: Syntax check run: python -m py_compile keepkeylib/*.py + - name: Install contract-test dependencies + run: | + pip install "protobuf>=3.20,<4" mnemonic ecdsa pytest requests + + - name: Run deterministic Zcash PCZT contract tests + env: + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python + run: | + python -m pytest -q \ + tests/test_msg_zcash_sign_pczt.py \ + tests/test_zcash_seed_fingerprint_helper.py + + # keepkey-firmware GENERATES its token table by running this repo's + # generators (lib/firmware/CMakeLists.txt -> ethereum_tokens.def), so a + # change here can break the firmware C++ build. .circleci/config.yml no + # longer runs the firmware's own unit suite; this is the gate that + # replaced it. + - name: Run host encoding, page capture, and network contract tests + env: + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python + run: | + python -m pytest -q \ + tests/test_clearsign_abi.py \ + tests/test_confirmation_pages.py \ + tests/test_network_policy.py \ + tests/test_token_table_generators.py + - name: Verify offline transaction fixture manifest run: python tests/tx_fixture_manifest.py --check + - name: Run report layout and coverage regressions + env: + PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python + run: | + python -m pytest -q \ + tests/test_report_rendering.py \ + tests/test_report_variant_validation.py + - name: Lint summary run: | echo "## 🔑 KeepKey python-keepkey — Lint" >> "$GITHUB_STEP_SUMMARY" @@ -44,89 +94,238 @@ jobs: echo "| Check | Status |" >> "$GITHUB_STEP_SUMMARY" echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY" echo "| Syntax | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY" + echo "| Zcash PCZT contract | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY" + echo "| ABI encoder | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY" + echo "| Token-table generators | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY" echo "| Offline fixture integrity | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY" FIXTURE_SHA=$(sha256sum tests/txcache/manifest.json | cut -d' ' -f1) echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY" + printf 'Fixture manifest SHA-256: `%s`\n' "$FIXTURE_SHA" >> "$GITHUB_STEP_SUMMARY" # ═══════════════════════════════════════════════════════════ - # STAGE 2: TEST — pull published emulator, run pytest + # STAGE 2: TEST — build exact candidate emulators, run pytest # ═══════════════════════════════════════════════════════════ integration: needs: [lint] runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 120 - services: - kkemu: - image: kktech/kkemu:latest - ports: - - 11044:11044/udp - - 11045:11045/udp - - 5000:5000 + # NO published emulator image. This job BUILDS one from current firmware. + # + # It used to pull kktech/kkemu:latest -- a floating tag whose image was + # five months and six minor versions stale. That single fact caused every + # symptom we chased: 80 tests gating on requires_firmware("7.15.0") skipped + # silently, and one unskipped test drove a ctime() path that segfaults on + # the old image and does not exist in current firmware. + # + # Publishing a fresher image would only reset that clock. Building from + # source removes the class: the emulator under test is, by construction, + # the firmware the tests were written against. steps: - uses: actions/checkout@v4 with: submodules: recursive + path: python-keepkey + + # Immutable firmware 7.15 code head validated by the release stack. + - name: Checkout firmware + uses: actions/checkout@v4 + with: + repository: BitHighlander/keepkey-firmware + # PINNED, not a moving branch, so a green result remains citeable. + # Pinned 7.15 firmware commit (the stack tip). It reports + # Features.capabilities, so capability-gated tests skip exactly what + # it lacks. + ref: 97fdfa027c356c69309d83d73bd15675f69eb870 + path: keepkey-firmware + + # NOT `submodules: recursive`. trezor-firmware carries a micropython + # vendor tree whose lib/lwip lives on git.savannah.gnu.org, which serves + # dumb HTTP and cannot do the shallow clone actions/checkout requests -- + # it fails the whole job. The firmware repo's own CI inits exactly these + # paths, non-recursively, for the same reason. + - name: Init the submodules the emulator build needs + working-directory: keepkey-firmware + run: | + git submodule update --init --depth 1 deps/crypto/trezor-firmware + git submodule update --init --depth 1 deps/device-protocol + git submodule update --init --depth 1 deps/googletest + git submodule update --init --depth 1 deps/qrenc/QR-Code-generator + git submodule update --init --depth 1 deps/sca-hardening/SecAESSTM32 + + # Test THIS checkout of python-keepkey, not the one the firmware pins. + - name: Overlay this python-keepkey onto the firmware tree + run: | + rm -rf keepkey-firmware/deps/python-keepkey + cp -a python-keepkey keepkey-firmware/deps/python-keepkey + + - name: Build the emulator + timeout-minutes: 20 + working-directory: keepkey-firmware + run: | + docker build -t kkemu-ci -f scripts/emulator/Dockerfile . + + - name: Start the emulator + run: | + # Certified fixtures are signed by the alpha root; 7.16+ emulators + # read it from here at startup (older ones ignore it). + docker run -d --name kkemu \ + -e KK_CLEARSIGN_TEST_ROOT=02de9231b2094433235532fb1932e324a2c7304195e12e610c675cccbbd606dae7 \ + -p 11044:11044/udp -p 11045:11045/udp -p 5000:5000 kkemu-ci + sleep 3 + docker logs kkemu | head -5 - uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install dependencies + working-directory: python-keepkey run: | pip install --upgrade pip pip install "protobuf>=3.20,<4" pip install -e . - pip install pytest semver rlp requests eth-keys pycryptodome + pip install pytest pytest-timeout semver rlp requests eth-keys pycryptodome - name: Wait for emulator run: | echo "Waiting for emulator bridge on port 5000..." + ready=false for i in $(seq 1 30); do if curl -sf -X POST http://localhost:5000/exchange/main \ -H 'Content-Type: application/json' \ -d '{"data":""}' > /dev/null 2>&1; then echo "Emulator ready after ${i}s" + ready=true break fi sleep 1 done + [ "$ready" = true ] || { + docker logs kkemu + echo "FATAL: emulator bridge did not become ready" >&2 + exit 1 + } + + # "The emulator answered a ping" is not "the emulator is the right + # firmware". CI ran a 7.16-era suite against a 7.10.0 image for five + # months: 80 tests gate on requires_firmware("7.15.0") and silently + # SKIPPED, while one unskipped test drove a code path that segfaults in + # 7.10.0 and is already fixed in 7.15 -- which reads as a product failure + # but is only a stale image. A floating tag cannot tell you that. This + # can, and it fails closed. + - name: Assert the emulator is not older than the suite + timeout-minutes: 2 + env: + KK_TRANSPORT_MAIN: "127.0.0.1:11044" + KK_TRANSPORT_DEBUG: "127.0.0.1:11045" + KK_MIN_FW: "7.15.0" + KK_UDP_TIMEOUT: "20" + working-directory: keepkey-firmware/deps/python-keepkey/tests + run: | + python - <<'PY' + import os, sys + sys.path.insert(0, '..') + import config + from keepkeylib.client import KeepKeyDebuglinkClient + c = KeepKeyDebuglinkClient(config.TRANSPORT(*config.TRANSPORT_ARGS, + **config.TRANSPORT_KWARGS)) + c.set_debuglink(config.DEBUG_TRANSPORT(*config.DEBUG_TRANSPORT_ARGS, + **config.DEBUG_TRANSPORT_KWARGS)) + c.init_device() + f = c.features + got = (f.major_version, f.minor_version, f.patch_version) + floor = tuple(int(x) for x in os.environ['KK_MIN_FW'].split('.')) + print('emulator firmware %d.%d.%d, floor %s' % + (got + (os.environ['KK_MIN_FW'],))) + if got < floor: + sys.exit('FATAL: the emulator image predates the tests that run ' + 'against it. Republish kktech/kkemu from current ' + 'firmware and pin the new digest above.') + PY + # Step-level timeout, deliberately: a JOB-level timeout ends the job as + # "cancelled", which reads as an infra blip. A step timeout is a FAILURE. - name: Run integration tests + # The 7.15 suite runs ~16 min locally and 35+ on a hosted runner (real-world EIP-712 corpus). + timeout-minutes: 90 env: KK_TRANSPORT_MAIN: "127.0.0.1:11044" KK_TRANSPORT_DEBUG: "127.0.0.1:11045" - PYTHONPATH: "${{ github.workspace }}/keepkeylib:${{ github.workspace }}" + PYTHONPATH: "${{ github.workspace }}/keepkey-firmware/deps/python-keepkey" + PYTHONUNBUFFERED: "1" + # A crashed emulator now raises instead of blocking in recv() forever. + KK_UDP_TIMEOUT: "45" run: | - cd tests + # From the OVERLAID copy, not the standalone checkout: the + # storage-version-gate tests assert against lib/firmware/storage.c, + # which they find by walking UP. Run them as a sibling of the + # firmware and they resolve; run them standalone and they fail + # claiming the sources are missing. + cd keepkey-firmware/deps/python-keepkey/tests python tx_fixture_manifest.py --check EMULATOR_IP=$(docker inspect -f \ - '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \ - "${{ job.services.kkemu.id }}") + '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' kkemu) test -n "$EMULATOR_IP" - sudo iptables -I OUTPUT 1 -d "$EMULATOR_IP" -j ACCEPT - sudo iptables -I OUTPUT 2 ! -o lo -m conntrack --ctstate NEW -j REJECT + # Only the test UID loses external network access. The runner must + # remain able to renew its job lease and reconnect to GitHub. + PYTHON_BIN=$(command -v python) + TEST_USER=kk-offline-test + sudo useradd --system --user-group --create-home "$TEST_USER" + TEST_UID=$(id -u "$TEST_USER") + test "$TEST_UID" != "$(id -u)" + if ! command -v setfacl >/dev/null; then + sudo apt-get update + sudo apt-get install -y acl + fi + # Keep runner ownership for tee/status; let pytest write its reports. + sudo setfacl -R -m "u:$TEST_UID:rwX" "$PWD" + # The runner home can be mode 750. Python module discovery and + # firmware build-directory globs need directory read/traverse access. + TEST_PARENT=$(dirname "$PWD") + while [ "$TEST_PARENT" != / ]; do + sudo setfacl -m "u:$TEST_UID:rx" "$TEST_PARENT" + TEST_PARENT=$(dirname "$TEST_PARENT") + done cleanup_network_gate() { - sudo iptables -D OUTPUT ! -o lo -m conntrack --ctstate NEW -j REJECT - sudo iptables -D OUTPUT -d "$EMULATOR_IP" -j ACCEPT + sudo ip6tables -w -D OUTPUT -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT 2>/dev/null || true + sudo iptables -w -D OUTPUT -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT 2>/dev/null || true + sudo iptables -w -D OUTPUT -m owner --uid-owner "$TEST_UID" \ + -d "$EMULATOR_IP" -j ACCEPT 2>/dev/null || true } trap cleanup_network_gate EXIT - pytest -v --junitxml=junit.xml 2>&1 | tee pytest-output.txt + sudo iptables -w -I OUTPUT 1 -m owner --uid-owner "$TEST_UID" \ + -d "$EMULATOR_IP" -j ACCEPT + sudo iptables -w -I OUTPUT 2 -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT + sudo ip6tables -w -I OUTPUT 1 -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT + run_offline_pytest() { + sudo -H -u "$TEST_USER" setpriv --no-new-privs env \ + PATH="$PATH" LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" \ + PYTHONPATH="$PYTHONPATH" PYTHONUNBUFFERED=1 \ + KK_TRANSPORT_MAIN="$KK_TRANSPORT_MAIN" \ + KK_TRANSPORT_DEBUG="$KK_TRANSPORT_DEBUG" \ + KK_UDP_TIMEOUT="$KK_UDP_TIMEOUT" \ + "$PYTHON_BIN" -m pytest "$@" + } + run_offline_pytest -v --junitxml=junit.xml 2>&1 | tee pytest-output.txt echo "${PIPESTATUS[0]}" > status - name: Test summary if: always() run: | - XML="tests/junit.xml" - MANIFEST="tests/txcache/manifest.json" + XML="keepkey-firmware/deps/python-keepkey/tests/junit.xml" + MANIFEST="keepkey-firmware/deps/python-keepkey/tests/txcache/manifest.json" echo "## 🔑 KeepKey python-keepkey — Integration Tests" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" if [ -f "$MANIFEST" ]; then FIXTURE_SHA=$(sha256sum "$MANIFEST" | cut -d' ' -f1) - echo "Fixture manifest SHA-256: `$FIXTURE_SHA`" >> "$GITHUB_STEP_SUMMARY" + printf 'Fixture manifest SHA-256: `%s`\n' "$FIXTURE_SHA" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" fi @@ -173,15 +372,544 @@ jobs: echo "---" >> "$GITHUB_STEP_SUMMARY" echo "*KeepKey python-keepkey CI*" >> "$GITHUB_STEP_SUMMARY" - - name: Upload test results + # NO check_name. With one, this action publishes a SEPARATE check run + # via the Checks API, and its require_tests default of 'false' means an + # absent junit.xml -- which is exactly what a killed pytest leaves behind + # -- reports conclusion:success with zero duration. That green check sat + # on top of a job timing out at 30 minutes for at least six merges. + # annotate_only keeps the inline annotations without minting a check. + - name: Annotate test results + uses: mikepenz/action-junit-report@v4 + if: always() + with: + report_paths: keepkey-firmware/deps/python-keepkey/tests/junit.xml + annotate_only: true + require_tests: true + fail_on_failure: true + + - name: Fail on test failure + if: always() + run: | + STATUS=$(cat keepkey-firmware/deps/python-keepkey/tests/status 2>/dev/null || echo "1") + [ "$STATUS" = "0" ] || exit 1 + + # ═══════════════════════════════════════════════════════════ + # STAGE 2c: TEST — the 7.14.3 release candidate + # ═══════════════════════════════════════════════════════════ + + integration-7143: + needs: [lint] + runs-on: ubuntu-latest + timeout-minutes: 15 + + # Run the complete merged Python suite against the immutable fork 7.14.3 + # candidate before canonical Python is merged upstream. + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + path: python-keepkey + + - name: Checkout firmware (7.14.3) + uses: actions/checkout@v4 + with: + repository: BitHighlander/keepkey-firmware + ref: 22659a61586bf69830573b8d659b9d58313cb7c7 + path: keepkey-firmware + + - name: Init the submodules the emulator build needs + working-directory: keepkey-firmware + run: | + git submodule update --init --depth 1 deps/crypto/trezor-firmware + git submodule update --init --depth 1 deps/device-protocol + git submodule update --init --depth 1 deps/googletest + git submodule update --init --depth 1 deps/qrenc/QR-Code-generator + git submodule update --init --depth 1 deps/sca-hardening/SecAESSTM32 + + - name: Overlay this python-keepkey onto the firmware tree + run: | + rm -rf keepkey-firmware/deps/python-keepkey + cp -a python-keepkey keepkey-firmware/deps/python-keepkey + + - name: Build the 7.14.3 emulator + timeout-minutes: 20 + working-directory: keepkey-firmware + run: | + docker build -t kkemu-7143-ci -f scripts/emulator/Dockerfile . + + - name: Start the emulator + run: | + docker run -d --name kkemu-7143 \ + -p 11044:11044/udp -p 11045:11045/udp -p 5000:5000 kkemu-7143-ci + sleep 3 + docker logs kkemu-7143 | head -5 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + working-directory: python-keepkey + run: | + pip install --upgrade pip + pip install "protobuf>=3.20,<4" + pip install -e . + pip install pytest pytest-timeout semver rlp requests eth-keys pycryptodome + + - name: Wait for emulator + run: | + echo "Waiting for emulator bridge on port 5000..." + ready=false + for i in $(seq 1 30); do + if curl -sf -X POST http://localhost:5000/exchange/main \ + -H 'Content-Type: application/json' \ + -d '{"data":""}' > /dev/null 2>&1; then + echo "Emulator ready after ${i}s" + ready=true + break + fi + sleep 1 + done + [ "$ready" = true ] || { + docker logs kkemu-7143 + echo "FATAL: emulator bridge did not become ready" >&2 + exit 1 + } + + # Assert this really is 7.14.3. A silently-wrong ref would make this job + # a duplicate of `integration` and quietly retire the gap it exists to + # measure. + - name: Assert the emulator is 7.14.3 + timeout-minutes: 2 + env: + KK_TRANSPORT_MAIN: "127.0.0.1:11044" + KK_TRANSPORT_DEBUG: "127.0.0.1:11045" + KK_UDP_TIMEOUT: "20" + working-directory: keepkey-firmware/deps/python-keepkey/tests + run: | + python - <<'PY' + import sys + sys.path.insert(0, '..') + import config + from keepkeylib.client import KeepKeyDebuglinkClient + c = KeepKeyDebuglinkClient(config.TRANSPORT(*config.TRANSPORT_ARGS, + **config.TRANSPORT_KWARGS)) + c.set_debuglink(config.DEBUG_TRANSPORT(*config.DEBUG_TRANSPORT_ARGS, + **config.DEBUG_TRANSPORT_KWARGS)) + c.init_device() + f = c.features + got = (f.major_version, f.minor_version, f.patch_version) + print('emulator firmware %d.%d.%d, variant %r' % (got + (f.firmware_variant,))) + if got != (7, 14, 3): + sys.exit('FATAL: expected 7.14.3, got %d.%d.%d -- the pinned ' + 'firmware ref is not the release target.' % got) + PY + + - name: Run the suite against 7.14.3 + timeout-minutes: 10 + env: + KK_TRANSPORT_MAIN: "127.0.0.1:11044" + KK_TRANSPORT_DEBUG: "127.0.0.1:11045" + PYTHONPATH: "${{ github.workspace }}/keepkey-firmware/deps/python-keepkey" + KK_UDP_TIMEOUT: "45" + run: | + cd keepkey-firmware/deps/python-keepkey/tests + python tx_fixture_manifest.py --check + EMULATOR_IP=$(docker inspect -f \ + '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' kkemu-7143) + test -n "$EMULATOR_IP" + # Only the test UID loses external network access. The runner must + # remain able to renew its job lease and reconnect to GitHub. + PYTHON_BIN=$(command -v python) + TEST_USER=kk-offline-test + sudo useradd --system --user-group --create-home "$TEST_USER" + TEST_UID=$(id -u "$TEST_USER") + test "$TEST_UID" != "$(id -u)" + if ! command -v setfacl >/dev/null; then + sudo apt-get update + sudo apt-get install -y acl + fi + # Keep runner ownership for tee/status; let pytest write its reports. + sudo setfacl -R -m "u:$TEST_UID:rwX" "$PWD" + # The runner home can be mode 750. Python module discovery and + # firmware build-directory globs need directory read/traverse access. + TEST_PARENT=$(dirname "$PWD") + while [ "$TEST_PARENT" != / ]; do + sudo setfacl -m "u:$TEST_UID:rx" "$TEST_PARENT" + TEST_PARENT=$(dirname "$TEST_PARENT") + done + cleanup_network_gate() { + sudo ip6tables -w -D OUTPUT -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT 2>/dev/null || true + sudo iptables -w -D OUTPUT -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT 2>/dev/null || true + sudo iptables -w -D OUTPUT -m owner --uid-owner "$TEST_UID" \ + -d "$EMULATOR_IP" -j ACCEPT 2>/dev/null || true + } + trap cleanup_network_gate EXIT + sudo iptables -w -I OUTPUT 1 -m owner --uid-owner "$TEST_UID" \ + -d "$EMULATOR_IP" -j ACCEPT + sudo iptables -w -I OUTPUT 2 -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT + sudo ip6tables -w -I OUTPUT 1 -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT + run_offline_pytest() { + sudo -H -u "$TEST_USER" setpriv --no-new-privs env \ + PATH="$PATH" LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" \ + PYTHONPATH="$PYTHONPATH" PYTHONUNBUFFERED=1 \ + KK_TRANSPORT_MAIN="$KK_TRANSPORT_MAIN" \ + KK_TRANSPORT_DEBUG="$KK_TRANSPORT_DEBUG" \ + KK_UDP_TIMEOUT="$KK_UDP_TIMEOUT" \ + "$PYTHON_BIN" -m pytest "$@" + } + run_offline_pytest -v --junitxml=junit-7143.xml 2>&1 | tee pytest-7143-output.txt + echo "${PIPESTATUS[0]}" > status-7143 + + - name: 7.14.3 summary + if: always() + run: | + XML="keepkey-firmware/deps/python-keepkey/tests/junit-7143.xml" + MANIFEST="keepkey-firmware/deps/python-keepkey/tests/txcache/manifest.json" + echo "## 🔑 python-keepkey — 7.14.3" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Blocking release-target compatibility gate." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [ -f "$MANIFEST" ]; then + FIXTURE_SHA=$(sha256sum "$MANIFEST" | cut -d' ' -f1) + printf 'Fixture manifest SHA-256: `%s`\n' "$FIXTURE_SHA" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + fi + if [ ! -f "$XML" ]; then + echo "❌ **No test results** — the suite crashed before completion." >> "$GITHUB_STEP_SUMMARY" + else + TOTAL=$(grep -oP 'tests="\K[0-9]+' "$XML" | head -1) + FAILED=$(grep -oP 'failures="\K[0-9]+' "$XML" | head -1) + ERRORS=$(grep -oP 'errors="\K[0-9]+' "$XML" | head -1) + SKIPPED=$(grep -oP 'skipped="\K[0-9]+' "$XML" | head -1) + TOTAL=${TOTAL:-0}; FAILED=${FAILED:-0}; ERRORS=${ERRORS:-0}; SKIPPED=${SKIPPED:-0} + PASSED=$((TOTAL - FAILED - ERRORS - SKIPPED)) + echo "| Metric | Count |" >> "$GITHUB_STEP_SUMMARY" + echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Total | $TOTAL |" >> "$GITHUB_STEP_SUMMARY" + echo "| ✅ Passed | $PASSED |" >> "$GITHUB_STEP_SUMMARY" + echo "| ⏭️ Skipped | $SKIPPED |" >> "$GITHUB_STEP_SUMMARY" + echo "| ❌ Failed | $FAILED |" >> "$GITHUB_STEP_SUMMARY" + echo "| 💥 Errors | $ERRORS |" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [ "$FAILED" -eq 0 ] && [ "$ERRORS" -eq 0 ]; then + echo "7.14.3 is green at current head." >> "$GITHUB_STEP_SUMMARY" + else + echo "7.14.3 compatibility failed at current head." >> "$GITHUB_STEP_SUMMARY" + fi + fi + + - name: Annotate 7.14.3 results + uses: mikepenz/action-junit-report@v4 + if: always() + with: + report_paths: keepkey-firmware/deps/python-keepkey/tests/junit-7143.xml + annotate_only: true + require_tests: true + fail_on_failure: false + + # `pytest | tee` makes the run step exit with tee's status, so without + # this the job reports success no matter what 7.14.3 did. This step makes a + # real pytest failure fail the blocking release gate. + - name: Report the 7.14.3 result + if: always() + run: | + STATUS=$(cat keepkey-firmware/deps/python-keepkey/tests/status-7143 2>/dev/null || echo "1") + if [ "$STATUS" = "0" ]; then + echo "7.14.3 suite passed." + else + echo "::error::7.14.3 compatibility failed; see the summary." + exit 1 + fi + + # ═══════════════════════════════════════════════════════════ + # STAGE 2b: TEST — the OTHER shipping product + # ═══════════════════════════════════════════════════════════ + + integration-btc: + name: integration-btc (${{ matrix.release }}) + needs: [lint] + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - release: "7.14.3" + firmware_ref: 22659a61586bf69830573b8d659b9d58313cb7c7 + min_fw: "7.14.3" + - release: "7.15" + firmware_ref: 97fdfa027c356c69309d83d73bd15675f69eb870 + min_fw: "7.15.0" + + # KK_BITCOIN_ONLY=ON is a second shipping product, not a build flavour: + # coins.def keeps only Bitcoin and Testnet, messagemap.def drops every + # altcoin handler, ZCASH_PRIVACY is forced OFF, and transaction.c takes a + # BITCOIN_ONLY arm on the OP_RETURN path. + # + # tests/test_msg_bitcoin_only_variant.py asserts all of that, and its + # setUp() calls requires_bitcoinOnly() -- so against the regular emulator + # the `integration` job runs it as ELEVEN SKIPS. Skips are green. Without + # this job the advertised bitcoin-only coverage is never executed by any + # required check, which is the exact condition that file was written to + # end. The step below therefore fails closed on a skip, not just on a + # failure. + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + path: python-keepkey + + - name: Checkout firmware + uses: actions/checkout@v4 + with: + repository: BitHighlander/keepkey-firmware + # Each matrix row is an immutable shipping candidate. Do not replace + # this with a common alpha SHA: that would make both labels test the + # same firmware and silently discard cross-release compatibility. + ref: ${{ matrix.firmware_ref }} + path: keepkey-firmware + + # Same non-recursive init as the regular job: trezor-firmware's + # micropython vendor tree pulls lib/lwip from git.savannah.gnu.org, + # which cannot serve the shallow clone actions/checkout asks for. + - name: Init the submodules the emulator build needs + working-directory: keepkey-firmware + run: | + git submodule update --init --depth 1 deps/crypto/trezor-firmware + git submodule update --init --depth 1 deps/device-protocol + git submodule update --init --depth 1 deps/googletest + git submodule update --init --depth 1 deps/qrenc/QR-Code-generator + git submodule update --init --depth 1 deps/sca-hardening/SecAESSTM32 + + - name: Overlay this python-keepkey onto the firmware tree + run: | + rm -rf keepkey-firmware/deps/python-keepkey + cp -a python-keepkey keepkey-firmware/deps/python-keepkey + + # scripts/emulator/Dockerfile forwards ARG coinsupport into the cmake + # invocation, so this is the same emulator build with the product flag + # the shipping bitcoin-only image is built with. + - name: Build the bitcoin-only emulator + timeout-minutes: 20 + working-directory: keepkey-firmware + run: | + docker build -t kkemu-btc-ci \ + --build-arg coinsupport=-DKK_BITCOIN_ONLY=ON \ + -f scripts/emulator/Dockerfile . + + - name: Start the emulator + run: | + docker run -d --name kkemu-btc \ + -p 11044:11044/udp -p 11045:11045/udp -p 5000:5000 kkemu-btc-ci + sleep 3 + docker logs kkemu-btc | head -5 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + working-directory: python-keepkey + run: | + pip install --upgrade pip + pip install "protobuf>=3.20,<4" + pip install -e . + pip install pytest pytest-timeout semver rlp requests eth-keys pycryptodome + + - name: Wait for emulator + run: | + echo "Waiting for emulator bridge on port 5000..." + ready=false + for i in $(seq 1 30); do + if curl -sf -X POST http://localhost:5000/exchange/main \ + -H 'Content-Type: application/json' \ + -d '{"data":""}' > /dev/null 2>&1; then + echo "Emulator ready after ${i}s" + ready=true + break + fi + sleep 1 + done + [ "$ready" = true ] || { + docker logs kkemu-btc + echo "FATAL: emulator bridge did not become ready" >&2 + exit 1 + } + + # A bitcoin-only emulator that reports "Emulator" instead of + # "EmulatorBTC" makes requires_bitcoinOnly() skip the whole file, and a + # regular emulator built by a broken --build-arg does the same. Assert + # the variant BEFORE pytest so that failure is named, not silent. + - name: Assert the emulator really is the bitcoin-only product + timeout-minutes: 2 + env: + KK_TRANSPORT_MAIN: "127.0.0.1:11044" + KK_TRANSPORT_DEBUG: "127.0.0.1:11045" + KK_MIN_FW: "${{ matrix.min_fw }}" + KK_UDP_TIMEOUT: "20" + working-directory: keepkey-firmware/deps/python-keepkey/tests + run: | + python - <<'PY' + import os, sys + sys.path.insert(0, '..') + import config + from keepkeylib.client import KeepKeyDebuglinkClient + c = KeepKeyDebuglinkClient(config.TRANSPORT(*config.TRANSPORT_ARGS, + **config.TRANSPORT_KWARGS)) + c.set_debuglink(config.DEBUG_TRANSPORT(*config.DEBUG_TRANSPORT_ARGS, + **config.DEBUG_TRANSPORT_KWARGS)) + c.init_device() + f = c.features + got = (f.major_version, f.minor_version, f.patch_version) + floor = tuple(int(x) for x in os.environ['KK_MIN_FW'].split('.')) + print('emulator firmware %d.%d.%d, variant %r' % + (got + (f.firmware_variant,))) + if got < floor: + sys.exit('FATAL: the emulator image predates the tests that run ' + 'against it.') + if f.firmware_variant not in ('KeepKeyBTC', 'EmulatorBTC'): + sys.exit('FATAL: firmware_variant is %r, so requires_bitcoinOnly() ' + 'would skip every test in this job. The -DKK_BITCOIN_ONLY=ON ' + 'build arg did not take effect.' % (f.firmware_variant,)) + PY + + - name: Run the bitcoin-only product-boundary tests + timeout-minutes: 8 + env: + KK_TRANSPORT_MAIN: "127.0.0.1:11044" + KK_TRANSPORT_DEBUG: "127.0.0.1:11045" + PYTHONPATH: "${{ github.workspace }}/keepkey-firmware/deps/python-keepkey" + KK_UDP_TIMEOUT: "45" + run: | + cd keepkey-firmware/deps/python-keepkey/tests + python tx_fixture_manifest.py --check + EMULATOR_IP=$(docker inspect -f \ + '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' kkemu-btc) + test -n "$EMULATOR_IP" + # Only the test UID loses external network access. The runner must + # remain able to renew its job lease and reconnect to GitHub. + PYTHON_BIN=$(command -v python) + TEST_USER=kk-offline-test + sudo useradd --system --user-group --create-home "$TEST_USER" + TEST_UID=$(id -u "$TEST_USER") + test "$TEST_UID" != "$(id -u)" + if ! command -v setfacl >/dev/null; then + sudo apt-get update + sudo apt-get install -y acl + fi + # Keep runner ownership for tee/status; let pytest write its reports. + sudo setfacl -R -m "u:$TEST_UID:rwX" "$PWD" + # The runner home can be mode 750. Python module discovery and + # firmware build-directory globs need directory read/traverse access. + TEST_PARENT=$(dirname "$PWD") + while [ "$TEST_PARENT" != / ]; do + sudo setfacl -m "u:$TEST_UID:rx" "$TEST_PARENT" + TEST_PARENT=$(dirname "$TEST_PARENT") + done + cleanup_network_gate() { + sudo ip6tables -w -D OUTPUT -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT 2>/dev/null || true + sudo iptables -w -D OUTPUT -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT 2>/dev/null || true + sudo iptables -w -D OUTPUT -m owner --uid-owner "$TEST_UID" \ + -d "$EMULATOR_IP" -j ACCEPT 2>/dev/null || true + } + trap cleanup_network_gate EXIT + sudo iptables -w -I OUTPUT 1 -m owner --uid-owner "$TEST_UID" \ + -d "$EMULATOR_IP" -j ACCEPT + sudo iptables -w -I OUTPUT 2 -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT + sudo ip6tables -w -I OUTPUT 1 -m owner --uid-owner "$TEST_UID" \ + ! -o lo -j REJECT + run_offline_pytest() { + sudo -H -u "$TEST_USER" setpriv --no-new-privs env \ + PATH="$PATH" LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" \ + PYTHONPATH="$PYTHONPATH" PYTHONUNBUFFERED=1 \ + KK_TRANSPORT_MAIN="$KK_TRANSPORT_MAIN" \ + KK_TRANSPORT_DEBUG="$KK_TRANSPORT_DEBUG" \ + KK_UDP_TIMEOUT="$KK_UDP_TIMEOUT" \ + "$PYTHON_BIN" -m pytest "$@" + } + run_offline_pytest -v --junitxml=junit-btc.xml test_msg_bitcoin_only_variant.py \ + 2>&1 | tee pytest-btc-output.txt + echo "${PIPESTATUS[0]}" > status-btc + + # The whole reason this job exists. `pytest` exits 0 on a fully skipped + # module, so a green run proves nothing unless the skip count is zero. + - name: Fail if the product-boundary tests skipped + if: always() + run: | + XML="keepkey-firmware/deps/python-keepkey/tests/junit-btc.xml" + if [ ! -f "$XML" ]; then + echo "::error::no junit-btc.xml -- the suite crashed before completion" + exit 1 + fi + python3 - "$XML" <<'PY' + import sys, xml.etree.ElementTree as ET + tree = ET.parse(sys.argv[1]) + cases = list(tree.iter('testcase')) + skipped = [c for c in cases if c.find('skipped') is not None] + print('bitcoin-only boundary: %d tests, %d skipped' % + (len(cases), len(skipped))) + if not cases: + sys.exit('FATAL: collected zero tests.') + for c in skipped: + print('::error::SKIPPED %s: %s' % + (c.get('name'), c.find('skipped').get('message', ''))) + if skipped: + sys.exit('FATAL: %d of %d bitcoin-only tests skipped. A skip here ' + 'means the variant went unaudited, which is the failure ' + 'this job exists to catch.' % (len(skipped), len(cases))) + PY + + - name: Bitcoin-only summary + if: always() + run: | + XML="keepkey-firmware/deps/python-keepkey/tests/junit-btc.xml" + MANIFEST="keepkey-firmware/deps/python-keepkey/tests/txcache/manifest.json" + echo "## 🔑 KeepKey python-keepkey — Bitcoin-only product boundary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [ -f "$MANIFEST" ]; then + FIXTURE_SHA=$(sha256sum "$MANIFEST" | cut -d' ' -f1) + printf 'Fixture manifest SHA-256: `%s`\n' "$FIXTURE_SHA" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + fi + if [ ! -f "$XML" ]; then + echo "❌ **No test results found** — suite may have crashed." >> "$GITHUB_STEP_SUMMARY" + else + TOTAL=$(grep -oP 'tests="\K[0-9]+' "$XML" | head -1) + FAILED=$(grep -oP 'failures="\K[0-9]+' "$XML" | head -1) + ERRORS=$(grep -oP 'errors="\K[0-9]+' "$XML" | head -1) + SKIPPED=$(grep -oP 'skipped="\K[0-9]+' "$XML" | head -1) + TOTAL=${TOTAL:-0}; FAILED=${FAILED:-0}; ERRORS=${ERRORS:-0}; SKIPPED=${SKIPPED:-0} + PASSED=$((TOTAL - FAILED - ERRORS - SKIPPED)) + echo "| Metric | Count |" >> "$GITHUB_STEP_SUMMARY" + echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Total | $TOTAL |" >> "$GITHUB_STEP_SUMMARY" + echo "| ✅ Passed | $PASSED |" >> "$GITHUB_STEP_SUMMARY" + echo "| ⏭️ Skipped (must be 0) | $SKIPPED |" >> "$GITHUB_STEP_SUMMARY" + echo "| ❌ Failed | $FAILED |" >> "$GITHUB_STEP_SUMMARY" + echo "| 💥 Errors | $ERRORS |" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Annotate test results uses: mikepenz/action-junit-report@v4 if: always() with: - report_paths: tests/junit.xml - check_name: Integration Tests + report_paths: keepkey-firmware/deps/python-keepkey/tests/junit-btc.xml + annotate_only: true + require_tests: true + fail_on_failure: true - name: Fail on test failure if: always() run: | - STATUS=$(cat tests/status 2>/dev/null || echo "1") + STATUS=$(cat keepkey-firmware/deps/python-keepkey/tests/status-btc 2>/dev/null || echo "1") [ "$STATUS" = "0" ] || exit 1 diff --git a/.github/workflows/copilot-review.yml b/.github/workflows/copilot-review.yml index 54db1498..8afdb03e 100644 --- a/.github/workflows/copilot-review.yml +++ b/.github/workflows/copilot-review.yml @@ -1,11 +1,15 @@ name: Request Copilot Review on: - pull_request: + # This workflow never checks out or executes pull-request code. Using the + # base-repository context is therefore safe and is required for cross-fork + # PRs, whose pull_request GITHUB_TOKEN is always downgraded to read-only. + pull_request_target: types: [opened, reopened, ready_for_review, synchronize] jobs: request-copilot-review: + if: github.event.pull_request.draft == false runs-on: ubuntu-latest permissions: pull-requests: write diff --git a/build_pb.sh b/build_pb.sh index 248c7a74..9b48b949 100755 --- a/build_pb.sh +++ b/build_pb.sh @@ -3,7 +3,7 @@ CURDIR=$(pwd) cd "device-protocol" echo "Building with protoc version: $(protoc --version)" -for i in messages messages-ethereum messages-eos messages-nano messages-cosmos messages-ripple messages-binance messages-tendermint messages-thorchain messages-osmosis messages-mayachain messages-solana messages-tron messages-ton messages-zcash types ; do +for i in messages messages-ethereum messages-eos messages-nano messages-cosmos messages-ripple messages-binance messages-hive messages-tendermint messages-thorchain messages-osmosis messages-mayachain messages-solana messages-tron messages-ton messages-zcash types ; do protoc --python_out=$CURDIR/keepkeylib/ -I/usr/include -I. $i.proto i=${i/-/_} sed -i -Ee 's/^import ([^.]+_pb2)/from . import \1/' $CURDIR/keepkeylib/"$i"_pb2.py diff --git a/device-protocol b/device-protocol index d637b782..bd340910 160000 --- a/device-protocol +++ b/device-protocol @@ -1 +1 @@ -Subproject commit d637b78291a423fd8119df9935a9365be8a7758e +Subproject commit bd3409106554e04eedc7b525d547a211a987ff3c diff --git a/keepkeylib/clearsign_abi.py b/keepkeylib/clearsign_abi.py new file mode 100644 index 00000000..0e655d05 --- /dev/null +++ b/keepkeylib/clearsign_abi.py @@ -0,0 +1,169 @@ +""" +Minimal, deterministic Solidity ABI encoder for STATIC types only. + +Used to build REAL calldata for the clear-sign flow catalog from a function +signature + argument values, instead of hand-typing hex (which is how bugs +get shipped in a signing test suite). Selectors are always derived from +keccak256(signature) here — never trusted from an external source — so a +wrong/hallucinated selector fails loudly instead of silently producing a +plausible-looking but wrong test vector. + +Deliberately does NOT support dynamic types (string, bytes, T[], tuples with +dynamic members) — those need offset/length ABI encoding that's easy to get +subtly wrong by hand. Calls with dynamic types are hand-built at the call +site (see clearsign_catalog.py's multicall/handleOps entries) using the +primitives here (_word/_addr_word) plus an explicit comment that the layout +is a representative simplification, not a literal captured mainnet tx. +""" + +import re + +from .signed_metadata import keccak256 + +_SIGNATURE = re.compile(r'^([A-Za-z_$][A-Za-z0-9_$]*)\((.*)\)$') +# Selectors hash canonical types, including aliases nested in arrays/tuples. +_ALIASES = {'uint': 'uint256', 'int': 'int256'} +_INTEGER_ALIAS = re.compile(r'\b(?:uint|int)\b') + + +def parse_signature(signature): + """'supply(address,uint256,address,uint16)' -> ('supply', ['address', 'uint256', 'address', 'uint16'])""" + match = _SIGNATURE.match(signature.strip()) + if not match: + raise ValueError('not a function signature: %r' % signature) + name, rest = match.groups() + types = [t.strip() for t in rest.split(',')] if rest.strip() else [] + if any(not t for t in types): + raise ValueError('empty parameter type in %r' % signature) + return name, [_INTEGER_ALIAS.sub(lambda m: _ALIASES[m.group(0)], t) + for t in types] + + +def canonical_signature(signature): + name, types = parse_signature(signature) + return '%s(%s)' % (name, ','.join(types)) + + +def selector(signature): + """4-byte function selector of the canonical signature, always computed.""" + return keccak256(canonical_signature(signature).encode('ascii'))[:4] + + +def _word(value): + if isinstance(value, str) and value.startswith('0x'): + value = int(value, 16) + return int(value).to_bytes(32, 'big') + + +def _addr_word(address): + if isinstance(address, str): + address = bytes.fromhex(address[2:] if address.startswith('0x') else address) + if len(address) != 20: + raise ValueError('address must be 20 bytes, got %d' % len(address)) + return b'\x00' * 12 + address + + +def _int_bits(digits, typ): + """Validate and return the bit width of a Solidity intN/uintN type. + + Solidity defines uint8..uint256 and int8..int256 in steps of 8, plus the + bare `uint`/`int` aliases for 256. Nothing else exists. Accepting `uint7`, + `uint0` or `int264` here does not produce unusual calldata -- it produces + 32-byte words for a type no compiler will ever emit, so the fixture reads + as a real ABI encoding while encoding a fiction. Fail loudly instead. + """ + if digits == '': + return 256 + if not digits.isdigit(): + raise ValueError( + 'unsupported type %r -- expected %s8..%s256 in steps of 8' + % (typ, typ[:-len(digits)], typ[:-len(digits)])) + bits = int(digits) + if bits < 8 or bits > 256 or bits % 8 != 0: + raise ValueError( + 'invalid Solidity integer width in %r -- must be 8..256 ' + 'in steps of 8' % typ) + return bits + + +def _exact_integer(value): + """Accept decimal strings and numeric values only without truncation.""" + n = int(value) + if not isinstance(value, str) and n != value: + raise ValueError('integer argument would lose precision: %r' % value) + return n + + +def encode_static_args(types, values): + """ABI-encode STATIC Solidity types into concatenated 32-byte words. + Raises on any dynamic type (string/bytes/arrays) — build those by hand.""" + if len(types) != len(values): + raise ValueError( + 'arg count mismatch: %d types, %d values' % (len(types), len(values))) + out = bytearray() + for typ, val in zip(types, values): + # Route arrays to the explicit dynamic-type error below rather than + # letting 'uint256[]' reach the width parser as digits '256[]'. + if typ.endswith(']'): + raise ValueError( + 'dynamic/unsupported type %r — build this call by hand ' + '(see module docstring)' % typ) + if typ == 'address': + out += _addr_word(val) + elif typ.startswith('uint'): + bits = _int_bits(typ[4:], typ) + n = _exact_integer(val) + if not 0 <= n < (1 << bits): + raise ValueError('value %r out of range for %s' % (val, typ)) + out += n.to_bytes(32, 'big') + elif typ.startswith('int'): + # Signed types are NOT unsigned ones with a wider range. intN holds + # [-2^(N-1), 2^(N-1)-1] and is encoded two's-complement, sign- + # extended to the full word. Treating it as unsigned both rejected + # every negative value and silently accepted values at or above + # 2^(N-1), which the EVM reads back as NEGATIVE -- calldata that + # does not mean what the declared type says. + bits = _int_bits(typ[3:], typ) + n = _exact_integer(val) + lo, hi = -(1 << (bits - 1)), (1 << (bits - 1)) - 1 + if not lo <= n <= hi: + raise ValueError( + 'value %r out of range for %s (%d..%d)' + % (val, typ, lo, hi)) + out += n.to_bytes(32, 'big', signed=True) + elif typ == 'bool': + # Require an actual bool. Coercing truthiness here silently turns + # 'false', 0.0 or 2 into ABI true/false, and a fixture that says + # bool should not be the place a type confusion is laundered. + if not isinstance(val, bool): + raise ValueError( + 'bool argument must be a real bool, got %r (%s)' + % (val, type(val).__name__)) + out += (1 if val else 0).to_bytes(32, 'big') + elif typ.startswith('bytes') and typ != 'bytes' and not typ.endswith('[]'): + digits = typ[5:] + # bytes1..bytes32 only. bytes0 is not a Solidity type, and bytes33 + # is worse than invalid: ljust() does not truncate, so a 33-byte + # value emitted a 33-byte "word" and shifted every following + # argument by one byte -- silently corrupt calldata. + if not digits.isdigit() or not 1 <= int(digits) <= 32: + raise ValueError( + 'invalid fixed-bytes type %r -- must be bytes1..bytes32' + % typ) + n = int(digits) + b = val if isinstance(val, (bytes, bytearray)) else bytes.fromhex( + val[2:] if val.startswith('0x') else val) + if len(b) != n: + raise ValueError('bytes%d value has wrong length' % n) + out += b.ljust(32, b'\x00') # bytesN is left-aligned per ABI spec + else: + raise ValueError( + 'dynamic/unsupported type %r — build this call by hand ' + '(see module docstring)' % typ) + return bytes(out) + + +def build_calldata(signature, values): + """selector(signature) + ABI-encoded static args, in one call.""" + _, types = parse_signature(signature) + return selector(signature) + encode_static_args(types, values) diff --git a/keepkeylib/clearsign_catalog.py b/keepkeylib/clearsign_catalog.py new file mode 100644 index 00000000..5e51e6ef --- /dev/null +++ b/keepkeylib/clearsign_catalog.py @@ -0,0 +1,1009 @@ +""" +CLEARSIGN_FLOWS — the canonical reference catalog of real-world EVM contract +calls for KeepKey clear-signing, and the single source of truth for: + - the per-flow device tests in tests/test_msg_ethereum_clear_signing.py + (each flow: build the real tx -> bind metadata to its exact sighash -> + confirm the who/what/why screens -> sign -> recover the signer) + - the batch device test (signs + validates every flow in one run) + - the offline reference vectors (RFC 6979 deterministic — frozen + sha256+length snapshots any signer implementation can be checked against) + - the PDF report's EVM Clear-Signing section (V), generated FROM this + catalog so there is no hand-duplicated, driftable copy of the flow list + +Every flow's real contract address and function signature is sourced from a +public reference (Etherscan / official protocol docs / GitHub) — see the +`source` field. Calldata is built with keepkeylib.clearsign_abi (a small +deterministic Solidity ABI encoder; selectors are always DERIVED via +keccak256(signature), never hand-typed) so there is no hand-typed hex to get +wrong. A handful of flows involve genuinely dynamic ABI types (bytes[], +nested structs) that the encoder deliberately doesn't support — those are +hand-built with an explicit REPRESENTATIVE comment; they still use a real +selector and a real contract address, so "who" is authentic even where the +exact byte layout is a simplification rather than a literal captured tx. + +Display formats used (the entire point: no calldata hex on the OLED, ever): + ADDRESS full 20-byte address, checksummed on-device, never truncated + STRING short attested printable label (protocol name, a deadline + description, a percentage, an NFT id, "N batched calls", ...) + TOKEN_AMOUNT decimals + symbol + big-endian amount -> device renders + "10.5 DAI" (decimal-scaled) or "UNLIMITED " for + max-uint256 approvals. This is the human-readable "why". +""" + +from .signed_metadata import ( + ARG_FORMAT_ADDRESS, ARG_FORMAT_STRING, ARG_FORMAT_TOKEN_AMOUNT, + token_amount_value, serialize_metadata, sign_metadata, eth_sighash_legacy, + keccak256, +) + + +def _ens_namehash(name): + """Standard ENS namehash (EIP-137): recursive keccak256, computed here + rather than hand-typed to avoid transcription errors in a 32-byte value.""" + node = b'\x00' * 32 + for label in reversed(name.split('.')): + node = keccak256(node + keccak256(label.encode())) + return node +from .clearsign_abi import ( + build_calldata, selector as abi_selector, parse_signature, + encode_static_args, +) + +# Fixed tx params so every flow's sighash — and therefore its reference blob +# — is deterministic. Matches the values the device tests actually sign with. +FLOW_CHAIN_ID = 1 +FLOW_NONCE = 0 +FLOW_GAS_PRICE = 20000000000 +FLOW_GAS_LIMIT = 250000 +REFERENCE_TIMESTAMP = 1700000000 # fixed for byte-reproducible reference blobs + + +def addr(hexstr): + """'0xAbc...' or 'Abc...' -> 20 raw bytes.""" + h = hexstr[2:] if hexstr.startswith('0x') else hexstr + b = bytes.fromhex(h) + assert len(b) == 20, 'not a 20-byte address: %r' % hexstr + return b + + +def flow(key, protocol, category, method, signature, contract, arg_values, + display_args, value=0, why='', source='', chain_id=FLOW_CHAIN_ID, + abi_types=None): + """Build one catalog entry: REAL calldata (selector + ABI-encoded static + args, derived — never hand-typed) plus the typed who/what/why args the + metadata attests for display. + + signature: the canonical Solidity signature used to derive the 4-byte + selector (e.g. 'exactInputSingle((address,address,uint24,address, + uint256,uint256,uint256,uint160))' for a single-struct-param + function — the real on-chain selector for a struct of only static + members is computed from this parenthesized form). + arg_values: positional values to ABI-encode, in signature order. By + default types are parsed from `signature`; pass abi_types to encode + against a FLATTENED type list instead (needed when `signature` has a + nested tuple param: ABI-encodes a struct of only-static members + head-only/inline, byte-identical to flattening it, so this is exact + — not an approximation). + display_args: list of {'name','format','value'} dicts in metadata wire + format (ARG_FORMAT_ADDRESS/STRING/TOKEN_AMOUNT) — what the device + screen shows. Not required to be 1:1 with arg_values. + """ + contract_bytes = addr(contract) + sel = abi_selector(signature) + types = abi_types if abi_types is not None else parse_signature(signature)[1] + data = sel + encode_static_args(types, arg_values) + return { + 'key': key, 'protocol': protocol, 'category': category, + 'method': method, 'signature': signature, + 'to': contract_bytes, 'value': value, 'data': data, + 'args': display_args, 'why': why, 'source': source, + 'chain_id': chain_id, + } + + +def flow_raw(key, protocol, category, method, contract, data, + display_args, value=0, why='', source='', chain_id=FLOW_CHAIN_ID): + """Like flow(), but for calls with dynamic ABI types (bytes[], nested + structs) that clearsign_abi can't encode — `data` is hand-built at the + call site from a REAL selector (via abi_selector) and REAL contract, with + a representative (not necessarily literal-mainnet-tx) argument layout. + See each call site's comment for what's simplified and why.""" + return { + 'key': key, 'protocol': protocol, 'category': category, + 'method': method, 'signature': '(dynamic — hand-built, see source)', + 'to': addr(contract), 'value': value, 'data': data, + 'args': display_args, 'why': why, 'source': source, + 'chain_id': chain_id, + } + + +def flow_tx_hash(f): + return eth_sighash_legacy(FLOW_NONCE, FLOW_GAS_PRICE, FLOW_GAS_LIMIT, + f['to'], f['value'], f['data'], f['chain_id']) + + +def flow_blob(f, key_id, timestamp=None): + """Per-tx-bound signed metadata blob for a catalog flow. Pass + timestamp=REFERENCE_TIMESTAMP for byte-reproducible reference vectors.""" + payload = serialize_metadata( + chain_id=f['chain_id'], + contract_address=f['to'], + selector=f['data'][:4], + tx_hash=flow_tx_hash(f), + method_name=f['method'], + args=f['args'], + key_id=key_id, + timestamp=timestamp, + ) + return sign_metadata(payload) + + +CLEARSIGN_FLOWS = [] +CLEARSIGN_FLOWS_BY_KEY = {} + + +def _register(*flows): + for f in flows: + assert f['key'] not in CLEARSIGN_FLOWS_BY_KEY, 'duplicate key: %s' % f['key'] + CLEARSIGN_FLOWS.append(f) + CLEARSIGN_FLOWS_BY_KEY[f['key']] = f + return flows + + +def _word(v): + return int(v).to_bytes(32, 'big') + + +def _addr_word(a): + return b'\x00' * 12 + addr(a) + + +# ── Common addresses (mainnet, verified against Etherscan) ──────────────── +# Aave V3 Pool proxy on Ethereum mainnet. This used to hold +# 0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9, which is the Aave **V2** +# LendingPool -- pairing it with V3's supply() selector (617ba037; V2 +# exposes deposit(), e8eda9df) described a call that would revert, so the +# fixture attested a transaction that cannot exist. The `source` field on +# every entry using this constant already named the correct proxy. +AAVE_V3_POOL = '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2' +DAI = '0x6b175474e89094c44da98b954eedeac495271d0f' +USDC = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' +WETH = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' +UNISWAP_V2_ROUTER = '0x7a250d5630b4cf539739df2c5dacb4c659f2488d' +UNISWAP_V3_ROUTER = '0xe592427a0aece92de3edee1f18e0157c05861564' +UNISWAP_V3_ROUTER2 = '0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45' +VITALIK = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' +RECIPIENT_742 = '0x742d35cc6634c0532950a20547b231011e30c8e7' + + +# ═══════════════════════════════════════════════════════════════════════ +# Category: DeFi lending & DEX (device-verified this session) +# ═══════════════════════════════════════════════════════════════════════ + +_register( + flow( + 'aave-v3-supply', 'Aave V3', 'lending', 'supply', + 'supply(address,uint256,address,uint16)', AAVE_V3_POOL, + [DAI, 10500000000000000000, VITALIK, 0], + [{'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Aave V3'}, + {'name': 'asset', 'format': ARG_FORMAT_ADDRESS, 'value': addr(DAI)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'value': token_amount_value(10500000000000000000, 18, 'DAI')}, + {'name': 'onBehalfOf', 'format': ARG_FORMAT_ADDRESS, 'value': addr(VITALIK)}], + why='Deposit collateral into Aave to earn yield / enable borrowing.', + source='https://etherscan.io/address/0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 (Aave V3 Pool proxy)', + ), + flow( + 'erc20-transfer', 'ERC-20', 'core-tokens', 'transfer', + 'transfer(address,uint256)', USDC, + [RECIPIENT_742, 1000000], + [{'name': 'token', 'format': ARG_FORMAT_STRING, 'value': b'USD Coin'}, + {'name': 'to', 'format': ARG_FORMAT_ADDRESS, 'value': addr(RECIPIENT_742)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'value': token_amount_value(1000000, 6, 'USDC')}], + why='The most common on-chain action: send tokens to an address.', + source='https://eips.ethereum.org/EIPS/eip-20', + ), + flow( + 'erc20-approve', 'ERC-20', 'approvals', 'approve', + 'approve(address,uint256)', USDC, + [UNISWAP_V3_ROUTER2, 1000000000], + [{'name': 'spender', 'format': ARG_FORMAT_ADDRESS, 'value': addr(UNISWAP_V3_ROUTER2)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'value': token_amount_value(1000000000, 6, 'USDC')}], + why='Grants a contract permission to move up to this amount of your tokens.', + source='https://eips.ethereum.org/EIPS/eip-20', + ), + flow( + 'erc20-approve-unlimited', 'ERC-20', 'approvals', 'approve', + 'approve(address,uint256)', USDC, + [UNISWAP_V3_ROUTER2, (2 ** 256) - 1], + [{'name': 'spender', 'format': ARG_FORMAT_ADDRESS, 'value': addr(UNISWAP_V3_ROUTER2)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'value': token_amount_value((2 ** 256) - 1, 6, 'USDC')}], + why='The single most drainer-abused action in EVM: max.uint256 approval. ' + 'Must render as "UNLIMITED", never as a raw 78-digit number or hex.', + source='https://eips.ethereum.org/EIPS/eip-20', + ), + flow_raw( + 'uniswap-v2-eth-to-token', 'Uniswap V2', 'dex-swaps', + 'swapExactETHForTokens', UNISWAP_V2_ROUTER, + # swapExactETHForTokens(uint256 amountOutMin, address[] path, address to, + # uint256 deadline) — path is a dynamic address[]; head = 4 static-slot + # words (amountOutMin, offset-to-path, to, deadline), tail = the array + # (length + elements). offset=0x80 = 4*32 bytes = start of tail. + abi_selector('swapExactETHForTokens(uint256,address[],address,uint256)') + + _word(9500000) + _word(0x80) + _addr_word(RECIPIENT_742) + _word(1700000000) + + _word(2) + _addr_word(WETH) + _addr_word(USDC), + [{'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Uniswap V2'}, + {'name': 'amountOutMin', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'value': token_amount_value(9500000, 6, 'USDC')}, + {'name': 'to', 'format': ARG_FORMAT_ADDRESS, 'value': addr(RECIPIENT_742)}], + value=10000000000000000, # 0.01 ETH in + why='Swap ETH for a token; the tx VALUE leaving the wallet is real and ' + 'shown on the final gas-confirm screen, not hidden in calldata.', + source='https://etherscan.io/address/0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D#code', + ), + flow_raw( + 'uniswap-v2-token-to-eth', 'Uniswap V2', 'dex-swaps', + 'swapExactTokensForETH', UNISWAP_V2_ROUTER, + # swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, + # address[] path, address to, uint256 deadline) — head = 5 static + # slots (amountIn, amountOutMin, offset-to-path, to, deadline); + # offset=0xa0 = 5*32 bytes. + abi_selector('swapExactTokensForETH(uint256,uint256,address[],address,uint256)') + + _word(100000000) + _word(3000000000000000) + _word(0xa0) + + _addr_word(RECIPIENT_742) + _word(1700000000) + + _word(2) + _addr_word(USDC) + _addr_word(WETH), + [{'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Uniswap V2'}, + {'name': 'amountIn', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'value': token_amount_value(100000000, 6, 'USDC')}, + {'name': 'amountOutMin', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'value': token_amount_value(3000000000000000, 18, 'ETH')}, + {'name': 'to', 'format': ARG_FORMAT_ADDRESS, 'value': addr(RECIPIENT_742)}], + why='Both legs of a swap (token in, ETH min-out) shown in human units.', + source='https://etherscan.io/address/0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D#code', + ), + flow( + 'uniswap-v3-exact-input', 'Uniswap V3', 'dex-swaps', 'exactInputSingle', + # ExactInputSingleParams is a struct of ONLY static members, so it + # ABI-encodes head-only/inline — byte-identical to flattening it. + 'exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))', + UNISWAP_V3_ROUTER, + # tokenIn, tokenOut, fee, recipient, deadline, amountIn, amountOutMinimum, sqrtPriceLimitX96 + [WETH, USDC, 3000, RECIPIENT_742, 1700000000, 10000000000000000, 9500000, 0], + [{'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Uniswap V3'}, + {'name': 'tokenIn', 'format': ARG_FORMAT_ADDRESS, 'value': addr(WETH)}, + {'name': 'tokenOut', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'amountIn', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'value': token_amount_value(10000000000000000, 18, 'WETH')}, + {'name': 'amountOutMin', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'value': token_amount_value(9500000, 6, 'USDC')}], + abi_types=['address', 'address', 'uint24', 'address', 'uint256', 'uint256', 'uint256', 'uint160'], + why='V3 single-hop swap with an explicit fee tier; typed in/out amounts.', + source='https://etherscan.io/address/0xE592427A0AEce92De3Edee1F18E0157C05861564#code', + ), + flow_raw( + 'uniswap-v3-multicall', 'Uniswap V3', 'dex-swaps', 'multicall', + UNISWAP_V3_ROUTER2, + # multicall(uint256 deadline, bytes[] data) — REPRESENTATIVE: real + # selector + real router address, one inner call (refundETH(), a + # real V3 Router method) batched, rather than a literal captured + # mainnet multicall (those bundle many different calls and would + # obscure the point being tested: opaque inner calls still render + # as a named, human-readable summary, never as hex). + # Head: [deadline, offset-to-data(0x40)]. Tail: [len=1, elem0-offset + # (0x20), elem0: len(4) + refundETH() selector, padded to 32 bytes]. + abi_selector('multicall(uint256,bytes[])') + + _word(1700000000) + _word(0x40) + + _word(1) + _word(0x20) + _word(4) + + abi_selector('refundETH()') + b'\x00' * 28, + [{'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Uniswap V3'}, + {'name': 'calls', 'format': ARG_FORMAT_STRING, + 'value': b'1 inner call: refundETH'}], + why='Batched calls are opaque by nature; the decode still names the ' + 'protocol and summarizes in words instead of showing raw bytes[].', + source='https://etherscan.io/address/0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45#code', + ), +) + + +def _fmt_unix(ts): + """Unix timestamp -> a short human date string for a STRING display arg + (e.g. deadlines/expiries). Computed at catalog-build time — the device + never does date math, it just displays the attested string.""" + from datetime import datetime, timezone + return datetime.fromtimestamp(ts, tz=timezone.utc).strftime('%Y-%m-%d %H:%M UTC') + + +# ═══════════════════════════════════════════════════════════════════════ +# Category: Lending & borrowing (Aave V3, Compound V3, Spark) +# +# Real contract addresses/signatures researched against Etherscan + official +# docs (see each flow's `source`). Any real ABI parameter NOT chosen for +# display (e.g. Aave's referralCode, always 0 in practice) still gets a real, +# neutral value in the encoded calldata — only the DISPLAY is a curated +# subset, matching the ERC-7730 field-hiding pattern Ledger/Trezor also use +# for non-security-relevant fields. +# ═══════════════════════════════════════════════════════════════════════ + +ONBEHALF_PLACEHOLDER = '0x1234567890AbcdEF1234567890aBcdef12345678' +DEADBEEF_PLACEHOLDER = '0x' + '00' * 16 + 'DeaDBeef' +ZERO_ADDRESS = '0x' + '00' * 20 + +_register( + flow( + 'aave-v3-pool-borrow', 'Aave V3', 'lending', 'borrow', + 'borrow(address,uint256,uint256,uint16,address)', '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2', + [USDC, 1000000000, 2, 0, ONBEHALF_PLACEHOLDER], + [{'name': 'asset', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000, 6, 'USDC')}, + {'name': 'interestRateMode', 'format': ARG_FORMAT_STRING, 'value': b'rate mode: Variable'}, + {'name': 'onBehalfOf', 'format': ARG_FORMAT_ADDRESS, 'value': addr(ONBEHALF_PLACEHOLDER)}], + why='Draws down a variable-rate loan against posted collateral; onBehalfOf lets a delegator drain credit.', + source='https://etherscan.io/address/0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 (Aave V3 Pool proxy)', + ), + flow( + 'aave-v3-pool-repay', 'Aave V3', 'lending', 'repay', + 'repay(address,uint256,uint256,address)', '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2', + [USDC, 500000000, 2, ONBEHALF_PLACEHOLDER], + [{'name': 'asset', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(500000000, 6, 'USDC')}, + {'name': 'interestRateMode', 'format': ARG_FORMAT_STRING, 'value': b'rate mode: Variable'}, + {'name': 'onBehalfOf', 'format': ARG_FORMAT_ADDRESS, 'value': addr(ONBEHALF_PLACEHOLDER)}], + why='Pays down outstanding debt; onBehalfOf can pay off someone else\'s loan.', + source='https://etherscan.io/address/0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 (Aave V3 Pool proxy)', + ), + flow( + 'aave-v3-pool-withdraw', 'Aave V3', 'lending', 'withdraw', + 'withdraw(address,uint256,address)', '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2', + [WETH, 2000000000000000000, ONBEHALF_PLACEHOLDER], + [{'name': 'asset', 'format': ARG_FORMAT_ADDRESS, 'value': addr(WETH)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(2000000000000000000, 18, 'WETH')}, + {'name': 'to', 'format': ARG_FORMAT_ADDRESS, 'value': addr(ONBEHALF_PLACEHOLDER)}], + why='Redeems supplied collateral for the underlying asset; the classic drainer pattern is a spoofed "to".', + source='https://etherscan.io/address/0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 (Aave V3 Pool proxy)', + ), + flow( + 'compound-v3-comet-supply', 'Compound V3 (Comet)', 'lending', 'supply', + 'supply(address,uint256)', '0xc3d688B66703497DAA19211EEdff47f25384cdc3', + [USDC, 1000000000], + [{'name': 'asset', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000, 6, 'USDC')}, + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Compound V3 Comet'}], + why='Deposits the base asset into the USDC Comet market to earn yield or back borrows.', + source='https://etherscan.io/address/0xc3d688B66703497DAA19211EEdff47f25384cdc3 (cUSDCv3)', + ), + flow( + 'compound-v3-comet-withdraw', 'Compound V3 (Comet)', 'lending', 'withdraw', + 'withdraw(address,uint256)', '0xc3d688B66703497DAA19211EEdff47f25384cdc3', + [WETH, 1000000000000000000], + [{'name': 'asset', 'format': ARG_FORMAT_ADDRESS, 'value': addr(WETH)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000000000000, 18, 'WETH')}, + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Compound V3 Comet'}], + why='Withdraws supplied collateral or base-asset balance from the caller\'s own Comet account.', + source='https://etherscan.io/address/0xc3d688B66703497DAA19211EEdff47f25384cdc3 (cUSDCv3)', + ), + flow( + 'spark-protocol-supply', 'Spark Protocol', 'lending', 'supply', + 'supply(address,uint256,address,uint16)', '0xC13e21B648A5Ee794902342038FF3aDAB66BE987', + [DAI, 5000000000000000000000, ONBEHALF_PLACEHOLDER, 0], + [{'name': 'asset', 'format': ARG_FORMAT_ADDRESS, 'value': addr(DAI)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(5000000000000000000000, 18, 'DAI')}, + {'name': 'onBehalfOf', 'format': ARG_FORMAT_ADDRESS, 'value': addr(ONBEHALF_PLACEHOLDER)}, + {'name': 'referralCode', 'format': ARG_FORMAT_STRING, 'value': b'referral code: 0 (none)'}], + why='Spark is a permissioned Aave V3 fork run by the Sky/MakerDAO ecosystem, sharing Aave\'s Pool ABI.', + source='https://etherscan.io/address/0xC13e21B648A5Ee794902342038FF3aDAB66BE987 (SparkLend Pool)', + ), +) + + +# ═══════════════════════════════════════════════════════════════════════ +# Category: Liquid staking & restaking (Lido, Rocket Pool, ether.fi, EigenLayer) +# ═══════════════════════════════════════════════════════════════════════ + +_register( + flow( + 'lido-steth-submit', 'Lido', 'staking', 'submit', + 'submit(address)', '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', + [ZERO_ADDRESS], + [{'name': '_referral', 'format': ARG_FORMAT_ADDRESS, 'value': addr(ZERO_ADDRESS)}, + {'name': 'action', 'format': ARG_FORMAT_STRING, 'value': b'Lido stETH stake'}], + value=1000000000000000000, + why='User stakes ETH directly with Lido\'s stETH contract and is minted stETH 1:1.', + source='https://etherscan.io/address/0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84 (stETH)', + ), + flow( + 'rocketpool-deposit-pool-deposit', 'Rocket Pool', 'staking', 'deposit', + 'deposit()', '0xDD3f50F8A6CafbE9b31a427582963f465E745AF8', + [], + [{'name': 'value', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000000000000, 18, 'ETH')}, + {'name': 'action', 'format': ARG_FORMAT_STRING, 'value': b'Rocket Pool deposit'}], + value=1000000000000000000, + why='User deposits ETH into Rocket Pool\'s deposit pool and is minted rETH at the current exchange rate.', + source='https://etherscan.io/address/0xDD3f50F8A6CafbE9b31a427582963f465E745AF8 (RocketDepositPool)', + ), + flow( + 'etherfi-liquiditypool-deposit', 'ether.fi', 'staking', 'deposit', + 'deposit(address)', '0x308861A430be4cce5502d0A12724771Fc6DaF216', + [ZERO_ADDRESS], + [{'name': '_referral', 'format': ARG_FORMAT_ADDRESS, 'value': addr(ZERO_ADDRESS)}, + {'name': 'value', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000000000000, 18, 'ETH')}, + {'name': 'action', 'format': ARG_FORMAT_STRING, 'value': b'ether.fi stake'}], + value=1000000000000000000, + why='User deposits ETH into ether.fi\'s LiquidityPool and is minted rebasing eETH 1:1 in value.', + source='https://etherscan.io/address/0x308861A430be4cce5502d0A12724771Fc6DaF216 (LiquidityPool)', + ), + flow( + 'eigenlayer-strategymanager-deposit', 'EigenLayer', 'restaking', 'depositIntoStrategy', + 'depositIntoStrategy(address,address,uint256)', '0x858646372CC42E1Ab8f579C244C0AE3F9dcbCE72', + ['0x93c4b944D05dfe6df7645A86cd2206016c51564D', WETH, 1000000000000000000], + [{'name': 'strategy', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0x93c4b944D05dfe6df7645A86cd2206016c51564D')}, + {'name': 'token', 'format': ARG_FORMAT_ADDRESS, 'value': addr(WETH)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000000000000, 18, 'WETH')}, + {'name': 'action', 'format': ARG_FORMAT_STRING, 'value': b'EigenLayer restake'}], + why='User restakes a token by depositing it into a whitelisted EigenLayer strategy vault.', + source='https://etherscan.io/address/0x858646372CC42E1Ab8f579C244C0AE3F9dcbCE72 (StrategyManager)', + ), + flow( + 'eigenlayer-strategymanager-deposit-steth', 'EigenLayer', 'restaking', 'depositIntoStrategy', + 'depositIntoStrategy(address,address,uint256)', '0x858646372CC42E1Ab8f579C244C0AE3F9dcbCE72', + ['0x93c4b944D05dfe6df7645A86cd2206016c51564D', '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', 2000000000000000000], + [{'name': 'strategy', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0x93c4b944D05dfe6df7645A86cd2206016c51564D')}, + {'name': 'token', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84')}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(2000000000000000000, 18, 'stETH')}, + {'name': 'action', 'format': ARG_FORMAT_STRING, 'value': b'EigenLayer restake stETH'}], + why='Same StrategyManager entry point, restaking stETH — the most common real-world case.', + source='https://etherscan.io/address/0x858646372CC42E1Ab8f579C244C0AE3F9dcbCE72 (StrategyManager)', + ), +) + + +# ═══════════════════════════════════════════════════════════════════════ +# Category: Token approvals & permits — the highest-risk category for +# wallet drainers. Precision here matters most: an unlimited approval or a +# permit's spender/amount MUST render as exactly what it is. +# ═══════════════════════════════════════════════════════════════════════ + +SPENDER_1 = '0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD' +PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3' + +_register( + flow( + 'erc20-usdc-increase-allowance', 'ERC-20 (USDC)', 'approvals', 'increaseAllowance', + 'increaseAllowance(address,uint256)', USDC, + [SPENDER_1, 1000000000000], + [{'name': 'spender', 'format': ARG_FORMAT_ADDRESS, 'value': addr(SPENDER_1)}, + {'name': 'addedValue', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000000, 6, 'USDC')}], + why='The front-running-safe alternative to approve() — still grants real spending power.', + source='https://etherscan.io/address/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48#code (USDC FiatTokenV2)', + ), + flow( + 'erc20-usdc-decrease-allowance', 'ERC-20 (USDC)', 'approvals', 'decreaseAllowance', + 'decreaseAllowance(address,uint256)', USDC, + [SPENDER_1, 500000000000], + [{'name': 'spender', 'format': ARG_FORMAT_ADDRESS, 'value': addr(SPENDER_1)}, + {'name': 'subtractedValue', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(500000000000, 6, 'USDC')}], + why='Revocation counterpart to approve/increaseAllowance — legitimate when reducing a stale allowance.', + source='https://etherscan.io/address/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48#code (USDC FiatTokenV2)', + ), + flow( + 'eip2612-usdc-permit', 'ERC-20 (USDC, EIP-2612)', 'approvals', 'permit', + 'permit(address,address,uint256,uint256,uint8,bytes32,bytes32)', USDC, + # v/r/s are the inner EIP-2612 signature bytes — not security-relevant + # to DISPLAY (the user already reviewed owner/spender/value/deadline; + # v/r/s only prove someone signed exactly that data). Placeholder + # values here are just to make the calldata SHAPE correct for the + # test; they don't need to verify as a real signature. + [ZERO_ADDRESS, SPENDER_1, (2 ** 256) - 1, 1830000000, 27, b'\x00' * 32, b'\x00' * 32], + [{'name': 'owner', 'format': ARG_FORMAT_ADDRESS, 'value': addr(ZERO_ADDRESS)}, + {'name': 'spender', 'format': ARG_FORMAT_ADDRESS, 'value': addr(SPENDER_1)}, + {'name': 'value', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value((2 ** 256) - 1, 6, 'USDC')}, + {'name': 'deadline', 'format': ARG_FORMAT_STRING, 'value': ('expires ' + _fmt_unix(1830000000)).encode()}], + why='The #1 wallet-drainer vector in production: an off-chain gasless approval, no on-chain fee gate.', + source='https://eips.ethereum.org/EIPS/eip-2612', + ), + flow( + 'permit2-approve', 'Uniswap Permit2', 'approvals', 'approve', + 'approve(address,address,uint160,uint48)', PERMIT2_ADDRESS, + [USDC, SPENDER_1, (2 ** 160) - 1, 1830000000], + [{'name': 'token', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'spender', 'format': ARG_FORMAT_ADDRESS, 'value': addr(SPENDER_1)}, + # Metadata amount is 2**256-1, NOT the real 2**160-1 uint160 max: + # firmware's UNLIMITED detection requires an exact 32-byte all-0xFF + # amount (signed_metadata.c: is_max = amt_len == 32). The minimal + # big-endian form of a uint160 max is only 20 bytes, which would + # silently fail that check and show a raw 49-digit number instead + # of UNLIMITED. The display arg is independent of the real calldata + # value (which correctly encodes the true uint160 max below) — + # 2**256-1 is simply the firmware's API for "render as unlimited". + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value((2 ** 256) - 1, 6, 'USDC')}, + {'name': 'expiration', 'format': ARG_FORMAT_STRING, 'value': ('expires ' + _fmt_unix(1830000000)).encode()}], + why='Permit2 is a singleton router between the user\'s ERC-20 allowance and every downstream spender.', + source='https://etherscan.io/address/0x000000000022D473030F116dDEE9F6B43aC78BA3 (Uniswap Permit2)', + ), + flow( + 'erc721-bayc-set-approval-for-all', 'Bored Ape Yacht Club', 'approvals', 'setApprovalForAll', + 'setApprovalForAll(address,bool)', '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', + [SPENDER_1, True], + [{'name': 'operator', 'format': ARG_FORMAT_ADDRESS, 'value': addr(SPENDER_1)}, + {'name': 'approved', 'format': ARG_FORMAT_STRING, 'value': b'grants control of ALL NFTs'}, + {'name': 'collection', 'format': ARG_FORMAT_STRING, 'value': b'Bored Ape Yacht Club'}], + why='Grants an operator blanket control over EVERY token the owner holds in this collection.', + source='https://etherscan.io/address/0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D (BAYC)', + ), + flow( + 'erc1155-opensea-storefront-set-approval-for-all', 'OpenSea Shared Storefront', 'approvals', 'setApprovalForAll', + 'setApprovalForAll(address,bool)', '0x495f947276749Ce646f68AC8c248420045cb7b5e', + [SPENDER_1, True], + [{'name': 'operator', 'format': ARG_FORMAT_ADDRESS, 'value': addr(SPENDER_1)}, + {'name': 'approved', 'format': ARG_FORMAT_STRING, 'value': b'grants control of ALL items'}, + {'name': 'collection', 'format': ARG_FORMAT_STRING, 'value': b'OpenSea Storefront'}], + why='Identical blanket-operator risk to ERC-721, on a shared ERC-1155 storefront contract.', + source='https://etherscan.io/address/0x495f947276749Ce646f68AC8c248420045cb7b5e (OpenStore)', + ), + flow( + 'usdt-approve', 'ERC-20 (USDT)', 'approvals', 'approve', + 'approve(address,uint256)', '0xdAC17F958D2ee523a2206206994597C13D831ec7', + [SPENDER_1, 500000000], + [{'name': 'spender', 'format': ARG_FORMAT_ADDRESS, 'value': addr(SPENDER_1)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(500000000, 6, 'USDT')}], + why='USDT\'s approve() omits the standard non-zero-to-non-zero guard other tokens have.', + source='https://etherscan.io/address/0xdAC17F958D2ee523a2206206994597C13D831ec7 (Tether USD)', + ), + flow( + 'dai-permit', 'Dai Stablecoin', 'approvals', 'permit', + # DAI predates EIP-2612 and uses its own non-standard permit layout: + # permit(holder,spender,nonce,expiry,allowed,v,r,s) — note the extra + # bool `allowed` in place of a `value`: DAI permits are ALWAYS either + # zero or unlimited, there is no partial-amount permit. + 'permit(address,address,uint256,uint256,bool,uint8,bytes32,bytes32)', DAI, + ['0x28C6c06298d514Db089934071355E5743bf21d60', SPENDER_1, 0, 1830000000, True, 27, b'\x00' * 32, b'\x00' * 32], + [{'name': 'holder', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0x28C6c06298d514Db089934071355E5743bf21d60')}, + {'name': 'spender', 'format': ARG_FORMAT_ADDRESS, 'value': addr(SPENDER_1)}, + {'name': 'allowed', 'format': ARG_FORMAT_STRING, 'value': b'grant: unlimited allowance'}, + {'name': 'expiry', 'format': ARG_FORMAT_STRING, 'value': _fmt_unix(1830000000).encode()}], + why='DAI\'s permit is boolean allowed/not-allowed, not a partial amount — a subtle drainer trap if a ' + 'wallet renders it like a normal EIP-2612 permit.', + source='https://etherscan.io/address/0x6B175474E89094C44Da98b954EedeAC495271d0f#code (Dai Stablecoin)', + ), +) + + +# ═══════════════════════════════════════════════════════════════════════ +# Category: NFT transfers, governance/ENS, cross-chain bridges, core tokens +# ═══════════════════════════════════════════════════════════════════════ + +FROM_742 = '0x7a16Ff8270133F063aAb6C9977183D9e7283542A' + +_register( + flow( + 'erc721-safe-transfer-from', 'ERC-721 (BAYC)', 'nft-transfer', 'safeTransferFrom', + 'safeTransferFrom(address,address,uint256)', '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', + [FROM_742, RECIPIENT_742, 4576], + [{'name': 'from', 'format': ARG_FORMAT_ADDRESS, 'value': addr(FROM_742)}, + {'name': 'to', 'format': ARG_FORMAT_ADDRESS, 'value': addr(RECIPIENT_742)}, + {'name': 'tokenId', 'format': ARG_FORMAT_STRING, 'value': b'NFT: BAYC #4576'}], + why='Direct peer-to-peer ERC-721 transfer with no on-chain price/consideration.', + source='https://etherscan.io/address/0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D (BAYC)', + ), + flow( + 'safe-addownerwiththreshold', 'Safe (Gnosis Safe)', 'account-abstraction', 'addOwnerWithThreshold', + 'addOwnerWithThreshold(address,uint256)', '0x1B9Cef6Bdd029f378c511E5e6C20eE556b6781b9', + [DEADBEEF_PLACEHOLDER, 3], + [{'name': 'owner', 'format': ARG_FORMAT_ADDRESS, 'value': addr(DEADBEEF_PLACEHOLDER)}, + {'name': '_threshold', 'format': ARG_FORMAT_STRING, 'value': b'new threshold: 3 owners'}, + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Safe: governance change'}], + why='Only reachable self-referentially inside a Safe\'s own execTransaction — a malicious co-signer ' + 'could try to add an attacker-controlled owner and lower the threshold to seize the Safe.', + source='https://etherscan.io/address/0x1B9Cef6Bdd029f378c511E5e6C20eE556b6781b9 (a Safe proxy)', + ), + flow( + 'hop-protocol-l1-bridge-sendtol2', 'Hop Protocol', 'bridge', 'sendToL2', + 'sendToL2(uint256,address,uint256,uint256,uint256,address,uint256)', '0x3666f603Cc164936C1b87e207F36BEBa4AC5f18a', + [137, RECIPIENT_742, 250000000, 245000000, 1830000000, ZERO_ADDRESS, 0], + [{'name': 'chainId', 'format': ARG_FORMAT_STRING, 'value': b'destination: Polygon'}, + {'name': 'recipient', 'format': ARG_FORMAT_ADDRESS, 'value': addr(RECIPIENT_742)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(250000000, 6, 'USDC')}, + {'name': 'relayerFee', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(500000, 6, 'USDC')}], + why='Deposits into Hop\'s L1 AMM/bridge; a bonder fronts liquidity on the destination chain.', + source='https://etherscan.io/address/0x3666f603Cc164936C1b87e207F36BEBa4AC5f18a (Hop L1_Bridge, USDC)', + ), + flow( + 'wormhole-token-bridge-transfertokens', 'Wormhole', 'bridge', 'transferTokens', + 'transferTokens(address,uint256,uint16,bytes32,uint256,uint32)', '0x3ee18B2214AFF97000D974cf647E7C347E8fa585', + [USDC, 100000000, 23, addr(RECIPIENT_742).rjust(32, b'\x00'), 0, 0], + [{'name': 'token', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(100000000, 6, 'USDC')}, + {'name': 'recipientChain', 'format': ARG_FORMAT_STRING, 'value': b'dest: Arbitrum (Wormhole)'}, + {'name': 'recipient', 'format': ARG_FORMAT_ADDRESS, 'value': addr(RECIPIENT_742)}], + why='Locks the ERC-20 in Token Bridge custody and emits a message Wormhole\'s guardians attest to.', + source='https://etherscan.io/address/0x3ee18B2214AFF97000D974cf647E7C347E8fa585 (Wormhole TokenBridge)', + ), + flow( + 'compound-governor-bravo-castvote', 'Compound', 'governance', 'castVote', + 'castVote(uint256,uint8)', '0xc0Da02939E1441F497fd74F78cE7Decb17B66529', + [203, 1], + [{'name': 'proposalId', 'format': ARG_FORMAT_STRING, 'value': b'proposal ID: 203'}, + {'name': 'support', 'format': ARG_FORMAT_STRING, 'value': b'0=Against 1=For 2=Abstain'}], + why='Casts a governance vote on Compound\'s GovernorBravo; weight is the voter\'s COMP balance/delegation.', + source='https://etherscan.io/address/0xc0Da02939E1441F497fd74F78cE7Decb17B66529 (GovernorBravoDelegator)', + ), + flow( + 'ens-public-resolver-setaddr', 'ENS', 'governance', 'setAddr', + 'setAddr(bytes32,address)', '0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63', + # Real ENS namehash("vitalik.eth"), computed via the standard + # recursive-keccak256 algorithm (not hand-typed — the research + # agent's transcription of this value had a truncated tail). + [_ens_namehash('vitalik.eth'), VITALIK], + [{'name': 'node', 'format': ARG_FORMAT_STRING, 'value': b'ENS name (namehash)'}, + {'name': 'a', 'format': ARG_FORMAT_ADDRESS, 'value': addr(VITALIK)}], + why='Updates the ETH address a .eth name resolves to; callable only by the name\'s controller.', + source='https://etherscan.io/address/0x231b0Ee14048e9dCcD1d247744d114a4EB5E8E63 (ENS PublicResolver)', + ), +) + + +# ═══════════════════════════════════════════════════════════════════════ +# Category: Yield vaults (ERC-4626 and legacy) — the "deposit into a +# strategy I trust" pattern shared by Morpho/MetaMorpho, Yearn V2/V3, +# Compound III. +# ═══════════════════════════════════════════════════════════════════════ + +_register( + flow( + 'metamorpho-steakhouse-usdc-deposit', 'Morpho (Steakhouse USDC)', 'vaults', 'deposit', + 'deposit(uint256,address)', '0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB', + [1000000000, DEADBEEF_PLACEHOLDER], + [{'name': 'assets', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000, 6, 'USDC')}, + {'name': 'receiver', 'format': ARG_FORMAT_ADDRESS, 'value': addr(DEADBEEF_PLACEHOLDER)}, + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Steakhouse USDC vault'}], + why='Standard ERC-4626 deposit into a MetaMorpho vault built on Morpho Blue.', + source='https://etherscan.io/address/0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB (Steakhouse USDC)', + ), + flow( + 'metamorpho-steakhouse-usdc-withdraw', 'Morpho (Steakhouse USDC)', 'vaults', 'withdraw', + 'withdraw(uint256,address,address)', '0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB', + [1000000000, DEADBEEF_PLACEHOLDER, DEADBEEF_PLACEHOLDER], + [{'name': 'assets', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000, 6, 'USDC')}, + {'name': 'receiver', 'format': ARG_FORMAT_ADDRESS, 'value': addr(DEADBEEF_PLACEHOLDER)}, + {'name': 'owner', 'format': ARG_FORMAT_ADDRESS, 'value': addr(DEADBEEF_PLACEHOLDER)}], + why='ERC-4626 withdraw burns the caller\'s (or an approved owner\'s) shares to redeem underlying USDC.', + source='https://etherscan.io/address/0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB (Steakhouse USDC)', + ), + flow( + 'yearn-v2-yusdc-deposit', 'Yearn Finance (V2)', 'vaults', 'deposit', + 'deposit(uint256)', '0x5f18C75AbDAe578b483E5F43f12a39cF75b973a9', + [1000000000], + [{'name': '_amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000, 6, 'USDC')}, + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Yearn V2 yUSDC Vault'}], + why='Legacy Yearn V2 vault mints yUSDC shares in proportion to the vault\'s price-per-share.', + source='https://etherscan.io/address/0x5f18C75AbDAe578b483E5F43f12a39cF75b973a9 (yUSDC)', + ), + flow( + 'yearn-v3-aave-usdc-lender-deposit', 'Yearn Finance (V3)', 'vaults', 'deposit', + 'deposit(uint256,address)', '0xbDb97eC319c41c6FA383E94eCE6Bdf383dFC7BE4', + [1000000000, DEADBEEF_PLACEHOLDER], + [{'name': 'assets', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000, 6, 'USDC')}, + {'name': 'receiver', 'format': ARG_FORMAT_ADDRESS, 'value': addr(DEADBEEF_PLACEHOLDER)}, + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Yearn V3 Aave USDC'}], + why='Yearn V3\'s tokenized-strategy ERC-4626 vault passes deposits through to Aave V3.', + source='https://etherscan.io/address/0xbDb97eC319c41c6FA383E94eCE6Bdf383dFC7BE4 (Yearn V3 Aave USDC Lender)', + ), + flow( + 'compound-iii-comet-usdc-supply', 'Compound III (Comet)', 'vaults', 'supply', + 'supply(address,uint256)', '0xc3d688B66703497DAA19211EEdff47f25384cdc3', + [USDC, 1000000000], + [{'name': 'asset', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000, 6, 'USDC')}, + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Compound III Comet'}], + why='Supplying USDC as the Comet base asset mints a rebasing cUSDCv3 balance earning yield.', + source='https://etherscan.io/address/0xc3d688B66703497DAA19211EEdff47f25384cdc3 (cUSDCv3)', + ), +) + + +# ═══════════════════════════════════════════════════════════════════════ +# Category: Core ERC-20 / WETH primitives that round out coverage. +# ═══════════════════════════════════════════════════════════════════════ + +_register( + flow( + 'weth-deposit', 'WETH9', 'core-tokens', 'deposit', + 'deposit()', WETH, + [], + [{'name': 'action', 'format': ARG_FORMAT_STRING, 'value': b'Wrap ETH into WETH'}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000000000000, 18, 'ETH')}], + value=1000000000000000000, + why='deposit() takes no calldata; the ETH being wrapped is carried entirely in the tx value.', + source='https://etherscan.io/address/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 (WETH9)', + ), + flow( + 'weth-withdraw', 'WETH9', 'core-tokens', 'withdraw', + 'withdraw(uint256)', WETH, + [500000000000000000], + [{'name': 'wad', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(500000000000000000, 18, 'WETH')}], + why='Burns wad WETH from the caller and sends wad ETH back to msg.sender.', + source='https://etherscan.io/address/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 (WETH9)', + ), + flow( + 'erc20-transferfrom', 'ERC-20 (USDT)', 'core-tokens', 'transferFrom', + 'transferFrom(address,address,uint256)', '0xdAC17F958D2ee523a2206206994597C13D831ec7', + [FROM_742, RECIPIENT_742, 1000000000], + [{'name': 'action', 'format': ARG_FORMAT_STRING, 'value': b'pull from approved account'}, + {'name': 'from', 'format': ARG_FORMAT_ADDRESS, 'value': addr(FROM_742)}, + {'name': 'to', 'format': ARG_FORMAT_ADDRESS, 'value': addr(RECIPIENT_742)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000, 6, 'USDT')}], + why='The highest-risk ERC-20 call for a hardware wallet to sign: the signer (msg.sender/spender) ' + 'moves funds OUT of a DIFFERENT account (from) that pre-approved it — "from" is not the signer.', + source='https://eips.ethereum.org/EIPS/eip-20', + ), +) + + +# ═══════════════════════════════════════════════════════════════════════ +# Category: more DEX swaps (V3 reverse-direction, Curve stableswap) +# ═══════════════════════════════════════════════════════════════════════ + +_register( + flow( + 'uniswap-v3-exact-output-single', 'Uniswap V3', 'dex-swaps', 'exactOutputSingle', + # ExactOutputSingleParams is a struct of only-static members -> encodes + # head-only/inline, same rule as exactInputSingle above. + 'exactOutputSingle((address,address,uint24,address,uint256,uint256,uint160))', + UNISWAP_V3_ROUTER2, + [USDC, WETH, 3000, DEADBEEF_PLACEHOLDER, 1000000000000000000, 3200000000, 0], + [{'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Uniswap V3'}, + {'name': 'tokenIn', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'tokenOut', 'format': ARG_FORMAT_ADDRESS, 'value': addr(WETH)}, + {'name': 'amountOut', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000000000000, 18, 'WETH')}, + {'name': 'amountInMax', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(3200000000, 6, 'USDC')}, + {'name': 'recipient', 'format': ARG_FORMAT_ADDRESS, 'value': addr(DEADBEEF_PLACEHOLDER)}], + abi_types=['address', 'address', 'uint24', 'address', 'uint256', 'uint256', 'uint160'], + why='Reverse-direction swap (buy an exact output instead of spending an exact input) — ' + 'the risk is amountInMax, an implicit "pay up to" ceiling.', + source='https://etherscan.io/address/0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 (SwapRouter02)', + ), + flow( + 'curve-3pool-exchange', 'Curve Finance (3pool)', 'dex-swaps', 'exchange', + 'exchange(int128,int128,uint256,uint256)', '0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7', + [1, 2, 1000000000, 999000000], + [{'name': 'i', 'format': ARG_FORMAT_STRING, 'value': b'sell coin index: 1 (USDC)'}, + {'name': 'j', 'format': ARG_FORMAT_STRING, 'value': b'buy coin index: 2 (USDT)'}, + {'name': 'dx', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000, 6, 'USDC')}, + {'name': 'min_dy', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(999000000, 6, 'USDT')}], + why='3pool coin indices (0=DAI,1=USDC,2=USDT) are fixed but not self-describing on-chain — ' + 'a hardware wallet must translate the index to a coin name, not show a bare "1".', + source='https://etherscan.io/address/0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7 (Curve 3pool)', + ), +) + + +# ═══════════════════════════════════════════════════════════════════════ +# Category: account abstraction, cross-chain intents, and the newest +# transaction shapes (2024-2026 EIPs) — the whole point of "latest tx +# types." These all involve genuinely dynamic ABI encoding (nested +# structs/arrays with dynamic bytes members) that clearsign_abi's static- +# only encoder deliberately doesn't support, so they're hand-built here. +# Every encoding below was verified by an offline round-trip decode (build +# calldata -> read the head/tail structure back -> confirm the recovered +# values match the inputs) before being committed — see the session's +# construction notes for the exact checks. Selectors are still always +# DERIVED via clearsign_abi.selector(), never hand-typed. +# ═══════════════════════════════════════════════════════════════════════ + +def _bytes_tail(b): + """[length] + data, padded to a 32-byte multiple. The standard ABI tail + encoding for a single dynamic `bytes` value.""" + pad = (-len(b)) % 32 + return _word(len(b)) + b + b'\x00' * pad + + +_register( + flow_raw( + 'erc1155-safe-transfer-from', 'ERC-1155', 'nft-transfer', 'safeTransferFrom', + '0x495f947276749Ce646f68AC8c248420045cb7b5e', + # safeTransferFrom(address,address,uint256,uint256,bytes) — 4 static + # head words (from,to,id,amount) + 1 offset word for the trailing + # `bytes data` (empty here); tail = [length=0]. + abi_selector('safeTransferFrom(address,address,uint256,uint256,bytes)') + + _addr_word(FROM_742) + _addr_word(RECIPIENT_742) + + _word(25675324701249476258287739024130209949696035953385936214507264967972457807873) + + _word(1) + _word(5 * 32) + _bytes_tail(b''), + [{'name': 'from', 'format': ARG_FORMAT_ADDRESS, 'value': addr(FROM_742)}, + {'name': 'to', 'format': ARG_FORMAT_ADDRESS, 'value': addr(RECIPIENT_742)}, + {'name': 'tokenId', 'format': ARG_FORMAT_STRING, 'value': b'NFT: OpenSea Storefront item'}, + {'name': 'quantity', 'format': ARG_FORMAT_STRING, 'value': b'quantity: 1'}], + why='ERC-1155 amount is a raw edition count, not a decimal-scaled token amount — a ' + 'wallet that runs it through TOKEN_AMOUNT formatting would show a nonsense value.', + source='https://etherscan.io/address/0x495f947276749Ce646f68AC8c248420045cb7b5e (OpenStore)', + ), + flow_raw( + 'erc1155-safe-batch-transfer-from', 'ERC-1155', 'nft-transfer', 'safeBatchTransferFrom', + '0x495f947276749Ce646f68AC8c248420045cb7b5e', + # safeBatchTransferFrom(address,address,uint256[],uint256[],bytes) — + # 2 static head words (from,to) + 3 offset words (ids[],amounts[], + # data); each array tail = [length, elem0, elem1, ...], data tail + # empty. Verified round-trip: decoding this exact byte layout + # recovers both arrays correctly. + abi_selector('safeBatchTransferFrom(address,address,uint256[],uint256[],bytes)') + + _addr_word(FROM_742) + _addr_word(RECIPIENT_742) + + _word(5 * 32) + _word(5 * 32 + 3 * 32) + _word(5 * 32 + 6 * 32) + + (_word(2) + _word(103581308236793043998666146738681730055218429023339494195862881700814449116832) + _word(555)) + + (_word(2) + _word(2) + _word(1)) + + _bytes_tail(b''), + [{'name': 'from', 'format': ARG_FORMAT_ADDRESS, 'value': addr(FROM_742)}, + {'name': 'to', 'format': ARG_FORMAT_ADDRESS, 'value': addr(RECIPIENT_742)}, + {'name': 'ids', 'format': ARG_FORMAT_STRING, 'value': b'2 NFT ids in this batch'}, + {'name': 'amounts', 'format': ARG_FORMAT_STRING, 'value': b'quantities: 2, then 1'}], + why='Atomic batch transfer of multiple ids/quantities — a wallet screen can only show a ' + 'handful of typed fields, so a long batch MUST be summarized, never left as raw arrays.', + source='https://etherscan.io/address/0x495f947276749Ce646f68AC8c248420045cb7b5e (OpenStore)', + ), + flow_raw( + 'uniswap-v4-universal-router-swap', 'Uniswap V4', 'dex-swaps', 'execute', + '0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af', + # execute(bytes commands, bytes[] inputs, uint256 deadline). There is + # no standalone EOA-callable PoolManager.swap() in V4 — it can only + # be invoked from inside the pool manager's own unlock() callback, + # so ALL V4 swaps go through the Universal Router's execute(), which + # packs one or more encoded "commands" (single bytes) + per-command + # input blobs. Representative: one command byte (0x10 = V4_SWAP) + # with an empty (placeholder) input blob — real command payloads are + # themselves further ABI-encoded structs, out of scope here. + abi_selector('execute(bytes,bytes[],uint256)') + + _word(3 * 32) + _word(3 * 32 + len(_bytes_tail(bytes.fromhex('10')))) + _word(1830000000) + + _bytes_tail(bytes.fromhex('10')) + + (_word(1) + _word(0x20) + _bytes_tail(b'')), + [{'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Uniswap V4 (Universal Router)'}, + {'name': 'commands', 'format': ARG_FORMAT_STRING, 'value': b'command: 0x10 (V4_SWAP)'}, + {'name': 'deadline', 'format': ARG_FORMAT_STRING, 'value': ('expires ' + _fmt_unix(1830000000)).encode()}], + why='V4\'s command-based router means the swap itself is opaque bytes; the decode must at ' + 'least name the protocol and the command type, not show raw commands hex.', + source='https://github.com/Uniswap/v4-periphery (UniversalRouter, V4_SWAP command)', + ), + flow_raw( + 'permit2-permit-transfer-from', 'Uniswap Permit2 (SignatureTransfer)', 'approvals', 'permitTransferFrom', + PERMIT2_ADDRESS, + # permitTransferFrom(((address,uint256),uint256,uint256),(address, + # uint256),address,bytes) — the permit+transferDetails structs are + # ALL-static so they inline (7 static words: token,amount,nonce, + # deadline,to,requestedAmount,owner) + 1 offset word for the + # trailing `bytes signature` (a 65-byte placeholder here — this is + # the moment funds actually move on an off-chain-signed EIP-712 + # authorization the user produced earlier). + abi_selector('permitTransferFrom(((address,uint256),uint256,uint256),(address,uint256),address,bytes)') + + _addr_word(USDC) + _word(250000000000) + _word(0) + _word(1830000000) + + _addr_word(SPENDER_1) + _word(250000000000) + + _addr_word(DEADBEEF_PLACEHOLDER) + _word(8 * 32) + + _bytes_tail(b'\x00' * 65), + [{'name': 'token', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(250000000000, 6, 'USDC')}, + {'name': 'recipient', 'format': ARG_FORMAT_ADDRESS, 'value': addr(SPENDER_1)}, + {'name': 'deadline', 'format': ARG_FORMAT_STRING, 'value': ('expires ' + _fmt_unix(1830000000)).encode()}], + why='The authorization for this transfer was a PURE off-chain EIP-712 signature made earlier ' + '(often on a phishing site) — this call is the moment the funds actually move.', + source='https://github.com/Uniswap/permit2 (SignatureTransfer.permitTransferFrom)', + ), + flow_raw( + 'across-spokepool-depositv3', 'Across Protocol', 'bridge', 'depositV3', + '0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5', + # depositV3(depositor,recipient,inputToken,outputToken,inputAmount, + # outputAmount,destinationChainId,exclusiveRelayer,quoteTimestamp, + # fillDeadline,exclusivityDeadline,bytes message) — an ERC-7683- + # style cross-chain intent: 11 static head words + 1 offset word for + # the trailing `bytes message` (empty). + abi_selector('depositV3(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes)') + + _addr_word(RECIPIENT_742) + _addr_word('0x9406Cc6185a346906296840746125a0E44976454') + + _addr_word(USDC) + _addr_word('0xaf88d065e77c8cC2239327C5EDb3A432268e5831') + + _word(1000000000) + _word(995000000) + _word(42161) + + _addr_word(ZERO_ADDRESS) + + _word(1751000000) + _word(1830000000) + _word(0) + + _word(12 * 32) + _bytes_tail(b''), + [{'name': 'inputToken', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'inputAmount', 'format': ARG_FORMAT_TOKEN_AMOUNT, 'value': token_amount_value(1000000000, 6, 'USDC')}, + {'name': 'outputToken', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0xaf88d065e77c8cC2239327C5EDb3A432268e5831')}, + {'name': 'recipient', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0x9406Cc6185a346906296840746125a0E44976454')}, + {'name': 'destination', 'format': ARG_FORMAT_STRING, 'value': b'destination: Arbitrum One'}], + why='ERC-7683-style intent bridge: locks the input token so an unbonded relayer can front ' + 'the output token on the destination chain — the signature doesn\'t show final asset ' + 'movement, so the decode must make output token/amount/chain explicit.', + source='https://etherscan.io/address/0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5 (Across SpokePool)', + ), + flow_raw( + 'safe-exectransaction', 'Safe (Gnosis Safe)', 'account-abstraction', 'execTransaction', + '0x1B9Cef6Bdd029f378c511E5e6C20eE556b6781b9', + # execTransaction(to,value,bytes data,operation,safeTxGas,baseGas, + # gasPrice,gasToken,refundReceiver,bytes signatures) — 8 static head + # words + 2 offset words (data, signatures). operation=0 (CALL); + # operation=1 (DELEGATECALL) would run arbitrary code AS the Safe — + # the single highest-stakes field in this call. data=empty (a plain + # value-transfer through the Safe); signatures=a 65-byte placeholder + # (real execution needs >=threshold owner signatures packed here). + abi_selector('execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)') + + _addr_word(USDC) + _word(0) + _word(10 * 32) + _word(0) + + _word(150000) + _word(0) + _word(0) + + _addr_word(ZERO_ADDRESS) + _addr_word(ZERO_ADDRESS) + + _word(10 * 32 + len(_bytes_tail(b''))) + + _bytes_tail(b'') + _bytes_tail(b'\x00' * 65), + [{'name': 'to', 'format': ARG_FORMAT_ADDRESS, 'value': addr(USDC)}, + {'name': 'operation', 'format': ARG_FORMAT_STRING, 'value': b'call type: 0=CALL'}, + {'name': 'gasBudget', 'format': ARG_FORMAT_STRING, 'value': b'gas budget: 150000'}, + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Safe: execute transaction'}], + why='A co-signing Safe owner signs this off-chain "Safe transaction hash" with their hardware ' + 'wallet before relaying; operation=1 (DELEGATECALL) would run arbitrary code as the Safe ' + 'itself — the single field a wallet must never let slide by unshown.', + source='https://etherscan.io/address/0x1B9Cef6Bdd029f378c511E5e6C20eE556b6781b9 (a Safe proxy)', + ), + flow_raw( + 'erc4337-entrypoint-v0.7-handleops', 'ERC-4337 Account Abstraction', 'account-abstraction', 'handleOps', + '0x0000000071727De22E5E9d8BAf0edAc6f37da032', + # handleOps(PackedUserOperation[] ops, address beneficiary) — a + # bundler-submitted meta-transaction. Each UserOperation is itself a + # 9-field struct with FOUR dynamic bytes members (initCode, callData, + # paymasterAndData, signature), making this array-of-dynamic-tuples + # the deepest nesting in this catalog. Representative: ONE UserOp + # with all four dynamic fields empty (real ones carry a decoded + # inner call — see the callDataSummary display arg for what a host + # would show once it decodes callData separately). Verified via an + # offline round-trip decode that recovers `sender` and `nonce` from + # inside the nested structure byte-for-byte. + abi_selector('handleOps((address,uint256,bytes,bytes,bytes32,uint256,bytes32,bytes,bytes)[],address)') + + _word(2 * 32) + _addr_word('0x' + '43' * 20) + + (_word(1) + _word(0x20) + ( + _addr_word('0x9406Cc6185a346906296840746125a0E44976454') + _word(12) + + _word(9 * 32) + _word(9 * 32 + len(_bytes_tail(b''))) + + b'\x00' * 32 + _word(50000) + b'\x00' * 32 + + _word(9 * 32 + 2 * len(_bytes_tail(b''))) + _word(9 * 32 + 3 * len(_bytes_tail(b''))) + + _bytes_tail(b'') + _bytes_tail(b'') + _bytes_tail(b'') + _bytes_tail(b'') + )), + [{'name': 'sender', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0x9406Cc6185a346906296840746125a0E44976454')}, + {'name': 'nonce', 'format': ARG_FORMAT_STRING, 'value': b'UserOperation nonce: 12'}, + {'name': 'beneficiary', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0x' + '43' * 20)}, + {'name': 'innerCall', 'format': ARG_FORMAT_STRING, 'value': b'empty (representative)'}], + why='A bundler-submitted meta-tx: the EntryPoint singleton validates and executes a batch of ' + 'smart-account operations. KNOWN GAP, disclosed: this representative UserOp carries an ' + 'EMPTY inner callData (the array-of-dynamic-tuples nesting is beyond the current static ' + 'ABI encoder), so this flow proves sender/nonce/beneficiary are decoded but does NOT ' + 'prove the inner callData — what the smart account will actually do — is decoded. A real ' + 'UserOp with non-empty callData would need it decoded and shown, never left as an opaque ' + 'blob one layer inside another; that inner-decode capability is future work.', + source='https://etherscan.io/address/0x0000000071727De22E5E9d8BAf0edAc6f37da032 (EntryPoint v0.7)', + ), +) + + +# ═══════════════════════════════════════════════════════════════════════ +# EIP-7702 (Pectra): NOT a contract call. A type-0x04 transaction embeds an +# `authorization_list` of (chain_id, address, nonce, y_parity, r, s) tuples; +# signing one installs `0xef0100 || address` as the SIGNING EOA's own code, +# turning it into a smart account. There is no "to"/calldata in the usual +# sense — the security-critical fact is the DELEGATE address the account is +# handing its execution to. Represented here with a synthetic legacy-style +# tx shape (to=self, empty data) purely so it fits this catalog's tx-hash- +# binding test harness; the REAL security review is the delegate address in +# `args`, not calldata bytes (there are none). +# ═══════════════════════════════════════════════════════════════════════ + +_register( + flow_raw( + 'eip7702-setcode-authorization', 'EIP-7702 (Set Code for EOAs)', 'account-abstraction', 'authorization', + '0x4Cd241E8d1510e30b2076397afc7508Ae59C66c9', + # Not a function call — no real selector exists. A 4-byte marker + # (the tx type byte + padding) keeps this flow flowing through the + # same tx_hash-binding/metadata machinery as every other catalog + # entry without special-casing the test harness. + b'\x04\x00\x00\x00', + [{'name': 'txType', 'format': ARG_FORMAT_STRING, 'value': b'NEW: type-0x04 (EIP-7702)'}, + {'name': 'delegate', 'format': ARG_FORMAT_ADDRESS, + 'value': addr('0x4Cd241E8d1510e30b2076397afc7508Ae59C66c9')}, + {'name': 'chainScope', 'format': ARG_FORMAT_STRING, + 'value': b'chain 1 only (0 = ALL chains)'}, + {'name': 'effect', 'format': ARG_FORMAT_STRING, + 'value': b'EOA becomes alias for this code'}], + why='This EOA is authorizing delegation to a contract — NOT a normal contract call. ' + 'A malicious 7702 delegation disguised as a routine signature is effectively account ' + 'takeover; the delegate address must be shown with the same weight as a recipient.', + source='https://eips.ethereum.org/EIPS/eip-7702', + ), +) diff --git a/keepkeylib/client.py b/keepkeylib/client.py index fecf5dd7..f1ac6194 100644 --- a/keepkeylib/client.py +++ b/keepkeylib/client.py @@ -49,6 +49,7 @@ from . import messages_tron_pb2 as tron_proto from . import messages_ton_pb2 as ton_proto from . import messages_zcash_pb2 as zcash_proto +from . import messages_hive_pb2 as hive_proto from . import types_pb2 as types from . import eos from . import nano @@ -59,6 +60,7 @@ import zlib as _zlib SCREENSHOT = os.environ.get('KEEPKEY_SCREENSHOT', '') == '1' +SCREENSHOT_SETTLE_SECONDS = 0.5 def _write_png(path, width, height, pixels): @@ -460,6 +462,26 @@ def _check_request(self, msg): raise CallException(types.Failure_Other, "Expected %s, got %s" % (pprint(expected), pprint(msg))) + def reset_screenshots(self): + """Drop screenshots captured so far this test and restart numbering. + + Called at the end of the setup_mnemonic_* helpers so the wipe/load + "setUp noise" frames never get picked as a test's representative OLED + image. Lifecycle tests (wipe/reset/recovery) do not use those helpers, + so their setup screens — which ARE the content under test — are kept. + """ + if not SCREENSHOT: + return + screenshot_dir = getattr(self, 'screenshot_dir', None) + if screenshot_dir and os.path.isdir(screenshot_dir): + import glob + for f in glob.glob(os.path.join(screenshot_dir, 'btn*.png')): + try: + os.remove(f) + except OSError: + pass + self.screenshot_id = 0 + def _capture_oled(self, layout=None): """Capture current OLED layout to screenshot directory.""" if not SCREENSHOT: @@ -469,10 +491,19 @@ def _capture_oled(self, layout=None): try: if layout is None: layout = self.debug.read_layout() - if not layout or len(layout) < 1024: + if not layout: + # Firmware answers DebugLinkGetState with an empty state while + # a private ceremony (dice entropy, BIP-85 child mnemonic) is + # on screen, so no capture can carry the secret. That is the + # designed boundary, not a capture fault. Any NON-empty layout + # that is too short still raises below. + import sys + print("[SCREENSHOT] SKIPPED: empty layout (private display)", + file=sys.stderr) + return + if len(layout) < 1024: raise RuntimeError( - "layout too small (%d bytes)" % - (len(layout) if layout else 0)) + "layout too small (%d bytes)" % len(layout)) layout_bytes = len(layout) height = 64 if layout_bytes >= 2048 else 32 rows = [] @@ -560,6 +591,17 @@ def _capture_oled_after_settle(self): return self._capture_oled(self._read_oled_after_settle()) + def capture_oled(self): + """Capture a settled confirmation screen in a manual protocol flow. + + Tests that use call_raw() need per-step control and never dispatch + callback_ButtonRequest(). Keeping the settle delay in this public + helper makes their evidence equivalent to the automatic callback path. + """ + if SCREENSHOT: + time.sleep(SCREENSHOT_SETTLE_SECONDS) + self._capture_oled() + def callback_ButtonRequest(self, msg): if self.verbose: log("ButtonRequest code: " + get_buttonrequest_value(msg.code)) @@ -723,6 +765,39 @@ def e712_types_values(self, n, types_prop, ptype_prop, value_prop, typevals): response = self.call(msg) return response + def ethereum_sign_typed_data(self, n, typed_data): + """Clear-sign structured EIP-712 data on the device. + + The firmware hashes the domain and message itself and displays every + typed value before signing. This is the safe path for EIP-3009 x402 + payments; ``ethereum_sign_typed_data_hash`` remains the explicit + AdvancedMode-only fallback for callers that only have precomputed + hashes. + """ + required = ('types', 'primaryType', 'domain') + missing = [name for name in required if name not in typed_data] + if missing: + raise ValueError('Missing EIP-712 property: %s' % ', '.join(missing)) + + # The legacy structured firmware endpoint expects the standard EIP-712 + # root property names to remain present in each streamed JSON fragment. + types_prop = json.dumps( + {'types': typed_data['types']}, separators=(',', ':')) + ptype_prop = json.dumps( + {'primaryType': typed_data['primaryType']}, separators=(',', ':')) + + # Firmware receives domain and message separately, and retains the + # independently-computed domain separator only until message signing. + self.e712_types_values( + n, types_prop, ptype_prop, + json.dumps({'domain': typed_data['domain']}, separators=(',', ':')), + 1) + return self.e712_types_values( + n, types_prop, ptype_prop, + json.dumps( + {'message': typed_data.get('message', {})}, + separators=(',', ':')), 2) + @expect(eth_proto.EthereumMessageSignature) def ethereum_sign_message(self, n, message): n = self._convert_prime(n) @@ -751,9 +826,69 @@ def ethereum_send_tx_metadata(self, signed_payload, metadata_version, key_id): ) return self.call(msg) + @expect(proto.Success) + def load_clearsign_signer(self, key_id, pubkey, alias, icon=None, + icon_width=None, icon_height=None, persist=None): + """Load a clearsign signer (compressed pubkey + alias) into a key slot. + Triggers a mandatory on-device confirmation. Metadata verified by a + loaded signer shows a warning screen naming the alias before every + clearsign page. + + icon (optional, <= 384 bytes) is an identity logo shown on the trust + screen. It is RUN-LENGTH ENCODED with byte-valued pixels, NOT a packed + bitmap: draw_bitmap_mono_rle() in keepkey-firmware lib/board/draw.c is + the decoder of record, and it is what every bundled image already uses. + + Grammar -- read n = int8(data[i++]): + n in [1, 127] RUN : one value byte follows; emit it n times. + n in [-127, -1] LITERAL : (-n) value bytes follow; emit each once. + n == 0 : invalid. + n == -128 (0x80) : INVALID -- the device's run counter is + int8_t and cannot represent 128. Split a + 128-byte literal into two packets. + The stream must decode EXACTLY: no run may straddle the end of the + image, exactly icon_width*icon_height pixels are emitted (row-major), + and the whole input must be consumed -- trailing packets are rejected. + The device validates this before showing or storing the icon. See + LoadClearsignSigner.icon in messages-ethereum.proto for the grammar and + a golden vector. + + icon_width and icon_height are required with icon. + icon_width : 1..40 -- the confirm screen's icon column + (LEFT_MARGIN_WITH_ICON). Text begins at x=40 and the + icon is drawn after it, so a wider icon would paint over + the alias, fingerprint and the "NOT verified by KeepKey" + warning. Capped, not clipped. + icon_height : 1..64 -- the icon column is 64px tall. + Omit all three for a text-only identity. + + Signers are session-only and are cleared on reboot. ``persist`` remains + in the wire format for compatibility, but firmware 7.15 rejects true + until authenticated persistent storage is available.""" + if persist: + raise ValueError( + "Persistent clearsign signers are disabled until authenticated " + "storage is available" + ) + msg = eth_proto.LoadClearsignSigner( + key_id=key_id, + pubkey=pubkey, + alias=alias, + ) + if icon is not None: + msg.icon = icon + if icon_width is not None: + msg.icon_width = icon_width + if icon_height is not None: + msg.icon_height = icon_height + if persist is not None: + msg.persist = persist + return self.call(msg) + @session - def ethereum_sign_tx(self, n, nonce, gas_limit, value, gas_price=None, max_fee_per_gas=None, max_priority_fee_per_gas=None, to=None, to_n=None, address_type=None, data=None, chain_id=None): + def ethereum_sign_tx(self, n, nonce, gas_limit, value, gas_price=None, max_fee_per_gas=None, max_priority_fee_per_gas=None, to=None, to_n=None, address_type=None, data=None, chain_id=None, erc7730_definition=None, erc7730_catalog=None): from keepkeylib.tools import int_to_big_endian + from keepkeylib import erc7730 if gas_price is None and max_fee_per_gas is None: raise Exception("Either gas_price or max_fee_per_gas must be provided") @@ -799,12 +934,31 @@ def ethereum_sign_tx(self, n, nonce, gas_limit, value, gas_price=None, max_fee_ if chain_id is not None: msg.chain_id = chain_id + if erc7730_definition is not None: + if not isinstance(erc7730_definition, erc7730.Definition): + raise TypeError("erc7730_definition must be a Definition") + if erc7730_catalog is None: + erc7730_catalog = erc7730.Catalog((erc7730_definition,)) + else: + erc7730_catalog.add(erc7730_definition) + erc7730.preload(self, erc7730_definition) + response = self.call(msg) - while response.HasField('data_length'): - data_length = response.data_length - data, chunk = data[data_length:], data[:data_length] - response = self.call(eth_proto.EthereumTxAck(data_chunk=chunk)) + while not (isinstance(response, eth_proto.EthereumTxRequest) and + response.HasField('signature_v')): + if isinstance(response, + eth_proto.EthereumClearSignDefinitionRequest): + if erc7730_catalog is None: + raise RuntimeError("device requested an ERC-7730 definition without a catalog") + response = self.call(erc7730_catalog.chunk(response)) + elif isinstance(response, eth_proto.EthereumTxRequest) and response.HasField('data_length'): + data_length = response.data_length + data, chunk = data[data_length:], data[:data_length] + response = self.call(eth_proto.EthereumTxAck(data_chunk=chunk)) + else: + raise RuntimeError("unexpected Ethereum signing response: %s" % + type(response).__name__) if address_type: return response.signature_v, response.signature_r, response.signature_s, response.hash, response.signature_der @@ -990,15 +1144,46 @@ def osmosis_sign_tx( if len(msg['value']['amount']) != 1: raise CallException("Osmosis.MsgSend", "Multiple amounts per msg not supported") - denom = msg['value']['amount'][0]['denom'] - if denom != 'uatom': - raise CallException("Osmosis.MsgSend", "Unsupported denomination: " + denom) - + # This branch had never executed. It whitelisted 'uatom' — the + # COSMOS denom, so a native OSMO send was impossible — dropped + # the denom instead of forwarding it, and assigned an int to + # OsmosisMsgSend.amount, which is a string field and would have + # raised even for uatom. + # + # Version-gated exactly like thorchain_sign_tx above, and for + # the same reason. + # + # Firmware does not reject a non-uosmo denom on the + # OsmosisMsgAck path. Since 7.14.2 (firmware c9dccf68) + # osmosis_signTxUpdateMsgSend escapes the host-supplied denom + # straight into the signed Amino document -- which is what + # test_osmosis_send_denom_is_committed_to_the_signature proves + # over the raw wire -- and the only strcmp against "uosmo" left + # in firmware picks the display exponent. + # + # BEFORE 7.14.2 the serializer hardcoded "uosmo": it would + # ignore the denom sent here and sign a uosmo transfer the + # caller never asked for. So fail closed there, and expose the + # field on firmware that actually commits it. An unconditional + # rejection made the supported IBC and factory-denom cases + # unreachable through this helper. + coin = msg['value']['amount'][0] + firmware_version = ( + self.features.major_version, + self.features.minor_version, + self.features.patch_version, + ) + if coin['denom'] != 'uosmo' and firmware_version < (7, 14, 2): + raise CallException( + "Osmosis.MsgSend", + "Unsupported denomination before firmware 7.14.2: %s" % + coin['denom']) resp = self.call(osmosis_proto.OsmosisMsgAck( send=osmosis_proto.OsmosisMsgSend( from_address=msg['value']['from_address'], to_address=msg['value']['to_address'], - amount=int(msg['value']['amount'][0]['amount']), + denom=coin['denom'], + amount=str(coin['amount']), address_type=types.SPEND, ) )) @@ -1133,16 +1318,36 @@ def thorchain_sign_tx( raise CallException("Thorchain.MsgSend", "Multiple amounts per send msg not supported") denom = msg['value']['amount'][0]['denom'] - if denom != 'rune': - raise CallException("Thorchain.MsgSend", "Unsupported denomination: " + denom) + firmware_version = ( + self.features.major_version, + self.features.minor_version, + self.features.patch_version, + ) + supports_denom = firmware_version >= (7, 15, 0) + + # Older firmware hardcodes "rune" in its amino sign-doc and + # nanopb skips the unknown denom field. Sending a non-RUNE denom + # there would therefore make the host and device disagree about + # what was signed. Preserve the fail-closed legacy behaviour, + # while exposing the protocol field on firmware that validates, + # displays and commits it to the signature. + if denom != 'rune' and not supports_denom: + raise CallException( + "Thorchain.MsgSend", + "Unsupported denomination before firmware 7.15.0: " + denom, + ) + + send = thorchain_proto.ThorchainMsgSend( + from_address=msg['value']['from_address'], + to_address=msg['value']['to_address'], + amount=int(msg['value']['amount'][0]['amount']), + address_type=types.SPEND, + ) + if supports_denom: + send.denom = denom resp = self.call(thorchain_proto.ThorchainMsgAck( - send=thorchain_proto.ThorchainMsgSend( - from_address=msg['value']['from_address'], - to_address=msg['value']['to_address'], - amount=int(msg['value']['amount'][0]['amount']), - address_type=types.SPEND, - ) + send=send )) elif msg['type'] == "thorchain/MsgDeposit": @@ -1314,7 +1519,9 @@ def apply_policy(self, policy_name, enabled): apply_policies = proto.ApplyPolicies(policy=[policy]) out = self.call(apply_policies) - self.init_device() # Reload Features + # Refresh the cached Features with GetFeatures, which leaves session + # state alone (AdvancedMode survives Initialize but not ClearSession). + self.features = expect(proto.Features)(self.call)(proto.GetFeatures()) return out @field('message') @@ -1467,6 +1674,9 @@ def sign_tx(self, coin_name, inputs, outputs, version=None, lock_time=None, debu else: msg.outputs_cnt = len(current_tx.outputs) msg.extra_data_len = len(current_tx.extra_data) if current_tx.extra_data else 0 + if debug_processor is not None: + from copy import deepcopy + msg = debug_processor(res, deepcopy(msg)) res = self.call(proto.TxAck(tx=msg)) continue @@ -1509,6 +1719,9 @@ def sign_tx(self, coin_name, inputs, outputs, version=None, lock_time=None, debu o, l = res.details.extra_data_offset, res.details.extra_data_len msg = types.TransactionType() msg.extra_data = current_tx.extra_data[o:o + l] + if debug_processor is not None: + from copy import deepcopy + msg = debug_processor(res, deepcopy(msg)) res = self.call(proto.TxAck(tx=msg)) continue @@ -1688,10 +1901,21 @@ def solana_get_address(self, address_n, show_display=False): ) @expect(solana_proto.SolanaSignedTx) - def solana_sign_tx(self, address_n, raw_tx): - return self.call( - solana_proto.SolanaSignTx(address_n=address_n, raw_tx=raw_tx) - ) + def solana_sign_tx(self, address_n, raw_tx, token_info=None, + token_recipient_owner=None): + """Sign a Solana transaction with optional display metadata. + + ``token_recipient_owner`` contains candidate 32-byte SPL token-account + owners (for example an x402 ``payTo`` address). Firmware only displays + a candidate after deriving its associated token account and matching + the destination present in the signed TransferChecked instruction. + """ + return self.call(solana_proto.SolanaSignTx( + address_n=address_n, + raw_tx=raw_tx, + token_info=token_info or [], + token_recipient_owner=token_recipient_owner or [], + )) @expect(solana_proto.SolanaMessageSignature) def solana_sign_message(self, address_n, message, show_display=False): @@ -1786,10 +2010,42 @@ def ton_sign_message(self, address_n, message, show_display=False): # ── Zcash Address Display ───────────────────────────────── @expect(zcash_proto.ZcashAddress) - def zcash_display_address(self, address_n, address, ak, nk, rivk, account=None): - kwargs = dict(address_n=address_n, address=address, ak=ak, nk=nk, rivk=rivk) + def zcash_display_address(self, address_n=None, account=None, + expected_seed_fingerprint=None): + """Display a Zcash unified address on the device for user confirmation. + + The device derives the unified address itself from its own seed — the + host does NOT supply the address or FVK components (that host-comparison + model was dropped; see messages-zcash.proto, where address/ak/nk/rivk + are reserved on ZcashDisplayAddress). + + Args: + address_n: ZIP-32 derivation path [32', 133', account']. + Optional -- messages-zcash.proto marks it "required if account + omitted", so either form is valid and exactly one is needed. + account: account index (alternative to full path) + expected_seed_fingerprint: optional 32-byte ZIP-32 §6.1 seed + fingerprint. If provided, device verifies the match before + deriving/displaying and rejects with Failure on mismatch. + + Returns: + ZcashAddress with .address and .seed_fingerprint of the + attesting device. + """ + # The protocol accepts EITHER form. Sending address_n unconditionally + # made the documented account-only call impossible: it failed in Python + # before a request was built. + if address_n is None and account is None: + raise ValueError( + "zcash_display_address needs address_n or account " + "(messages-zcash.proto: each is required if the other is omitted)") + kwargs = {} + if address_n is not None: + kwargs['address_n'] = address_n if account is not None: kwargs['account'] = account + if expected_seed_fingerprint is not None: + kwargs['expected_seed_fingerprint'] = expected_seed_fingerprint return self.call(zcash_proto.ZcashDisplayAddress(**kwargs)) # ── Zcash Orchard ────────────────────────────────────────── @@ -1805,14 +2061,22 @@ def zcash_sign_pczt(self, address_n, actions, account=None, total_amount=0, fee=0, branch_id=0x37519621, header_digest=None, transparent_digest=None, sapling_digest=None, orchard_digest=None, + shielded_pool=None, ironwood_digest=None, orchard_flags=None, orchard_value_balance=None, - orchard_anchor=None, transparent_inputs=None): - """Sign a Zcash Orchard shielded transaction via PCZT protocol. - - Phase 2: Sends ZcashSignPCZT, then loops on ZcashPCZTActionAck - feeding Orchard actions one at a time. - Phase 3: If transparent_inputs provided, handles ZcashTransparentSig - loop for transparent-to-shielded (shielding) transactions. + orchard_anchor=None, tx_version=None, + version_group_id=None, lock_time=None, + expiry_height=None, transparent_outputs=None, + transparent_inputs=None, + expected_seed_fingerprint=None, + return_transparent_signatures=False, + ironwood_actions=None, ironwood_flags=None, + ironwood_value_balance=None): + """Sign a Zcash Orchard-family shielded transaction via PCZT protocol. + + Streams transparent outputs, then transparent inputs, then shielded + actions in the exact order requested by firmware 7.15. Shielded + signatures are compact: the response contains one signature for each + action whose explicit ``is_spend`` value is true, in action order. Args: address_n: ZIP-32 derivation path [32', 133', account'] @@ -1825,23 +2089,56 @@ def zcash_sign_pczt(self, address_n, actions, account=None, transparent_digest: 32-byte transparent digest sapling_digest: 32-byte sapling digest orchard_digest: 32-byte orchard digest + shielded_pool: ZcashShieldedPool value (Orchard by default) + ironwood_digest: 32-byte Ironwood digest for transaction v6 + ironwood_actions: for a ZIP 318 pool-crossing v6 transaction, the + Ironwood actions, streamed after the Orchard `actions`, with + ironwood_flags and ironwood_value_balance for their bundle orchard_flags: bundle flags byte (enables digest verification) orchard_value_balance: signed i64 value balance orchard_anchor: 32-byte anchor + tx_version: transaction version used to verify header_digest + version_group_id: transaction version group ID + lock_time: transaction lock time + expiry_height: transaction expiry height + transparent_outputs: output dicts matching ZcashTransparentOutput; + is_tex shows a P2PKH output as a ZIP 320 tex1 address, and + address_n marks the account's own one-time address + transparent_inputs: input dicts matching ZcashTransparentInput; + host-provided per-input sighashes are rejected by RC18 + return_transparent_signatures: when true, return a tuple of + (ZcashSignedPCZT, [DER transparent signatures]) Returns: - ZcashSignedPCZT with .signatures list and optional .txid + ZcashSignedPCZT with compact Orchard signatures and optional txid, + or a tuple including transparent signatures when requested. """ - n_actions = len(actions) - if n_actions == 0: - raise ValueError("Must have at least one action") + # No action is a transparent-only transaction (ZIP 320 TEX step 2), + # which needs a transparent input to sign. + if len(actions) == 0 and not transparent_inputs: + raise ValueError("Must have at least one action or transparent input") + # Orchard actions first, then any Ironwood ones; indices count on. + stream = list(actions) + list(ironwood_actions or []) + n_actions = len(stream) + + for idx, action in enumerate(stream): + if 'is_spend' not in action or not isinstance(action['is_spend'], bool): + raise ValueError( + "Orchard action %d must explicitly set boolean is_spend" % idx) + + transparent_outputs = transparent_outputs or [] + transparent_inputs = transparent_inputs or [] + for inp in transparent_inputs: + if 'sighash' in inp: + raise ValueError( + "Host-provided transparent sighash is rejected by firmware 7.15") # Build the initial signing request — only send address_n, # let firmware derive account from the path. Only set account # explicitly if the caller passed it. kwargs = dict( address_n=address_n, - n_actions=n_actions, + n_actions=len(actions), total_amount=total_amount, fee=fee, branch_id=branch_id, @@ -1856,39 +2153,113 @@ def zcash_sign_pczt(self, address_n, actions, account=None, kwargs['sapling_digest'] = sapling_digest if orchard_digest is not None: kwargs['orchard_digest'] = orchard_digest + if shielded_pool is not None: + kwargs['shielded_pool'] = shielded_pool + if ironwood_digest is not None: + kwargs['ironwood_digest'] = ironwood_digest if orchard_flags is not None: kwargs['orchard_flags'] = orchard_flags if orchard_value_balance is not None: kwargs['orchard_value_balance'] = orchard_value_balance if orchard_anchor is not None: kwargs['orchard_anchor'] = orchard_anchor + if tx_version is not None: + kwargs['tx_version'] = tx_version + if version_group_id is not None: + kwargs['version_group_id'] = version_group_id + if lock_time is not None: + kwargs['lock_time'] = lock_time + if expiry_height is not None: + kwargs['expiry_height'] = expiry_height + if transparent_outputs: + kwargs['n_transparent_outputs'] = len(transparent_outputs) + if transparent_inputs: + kwargs['n_transparent_inputs'] = len(transparent_inputs) + if expected_seed_fingerprint is not None: + kwargs['expected_seed_fingerprint'] = expected_seed_fingerprint + if ironwood_actions: + kwargs['n_ironwood_actions'] = len(ironwood_actions) + kwargs['ironwood_flags'] = ironwood_flags + kwargs['ironwood_value_balance'] = ironwood_value_balance resp = self.call(zcash_proto.ZcashSignPCZT(**kwargs)) - # Phase 2: Orchard action-ack loop — device asks for actions one at a time + # Transparent plaintext is streamed outputs-first. Firmware uses field + # presence to distinguish output and input acknowledgments, so never + # infer a missing index as zero. + sent_outputs = 0 + while (sent_outputs < len(transparent_outputs) and + isinstance(resp, zcash_proto.ZcashTransparentAck)): + if not resp.HasField('next_output_index'): + raise Exception("Device did not request the next transparent output") + idx = resp.next_output_index + if idx != sent_outputs: + raise Exception( + "Device requested transparent output %d after %d outputs" + % (idx, sent_outputs)) + if idx >= len(transparent_outputs): + raise Exception( + "Device requested transparent output %d but only %d provided" + % (idx, len(transparent_outputs))) + output = dict(transparent_outputs[idx]) + output.pop('index', None) + resp = self.call(zcash_proto.ZcashTransparentOutput(index=idx, **output)) + sent_outputs += 1 + + sent_inputs = 0 + while (sent_inputs < len(transparent_inputs) and + isinstance(resp, zcash_proto.ZcashTransparentAck)): + if not resp.HasField('next_input_index'): + raise Exception("Device did not request the next transparent input") + idx = resp.next_input_index + if idx != sent_inputs: + raise Exception( + "Device requested transparent input %d after %d inputs" + % (idx, sent_inputs)) + if idx >= len(transparent_inputs): + raise Exception( + "Device requested transparent input %d but only %d provided" + % (idx, len(transparent_inputs))) + inp = dict(transparent_inputs[idx]) + inp.pop('index', None) + resp = self.call(zcash_proto.ZcashTransparentInput(index=idx, **inp)) + sent_inputs += 1 + + if sent_outputs != len(transparent_outputs): + raise Exception("Device did not request every transparent output") + if sent_inputs != len(transparent_inputs): + raise Exception("Device did not request every transparent input") + + # Orchard action-ack loop: the device chooses the next action index. + sent_actions = set() while isinstance(resp, zcash_proto.ZcashPCZTActionAck): + if not resp.HasField('next_index'): + raise Exception("Device did not identify the next Orchard action") idx = resp.next_index if idx >= n_actions: raise Exception( "Device requested action index %d but only %d actions provided" % (idx, n_actions)) - action = actions[idx] + if idx in sent_actions: + raise Exception("Device requested Orchard action %d twice" % idx) + # The public action shape mirrors ZcashPCZTAction and may therefore + # include an index. The device controls stream order, just as it + # does for transparent inputs and outputs above, so discard any + # caller copy before supplying the requested index. + action = dict(stream[idx]) + action.pop('index', None) resp = self.call(zcash_proto.ZcashPCZTAction(index=idx, **action)) + sent_actions.add(idx) + + if sent_actions != set(range(n_actions)): + raise Exception("Device did not request every Orchard action") - # Phase 3: Transparent input signing — device sends back signatures - # and may request transparent inputs for shielding transactions + # RC18 defers transparent signatures until every Orchard action, digest, + # and fee has passed. They are emitted immediately before SignedPCZT. transparent_sigs = [] - while isinstance(resp, zcash_proto.ZcashTransparentSig): - transparent_sigs.append(resp) - if not transparent_inputs: - raise Exception( - "Device sent ZcashTransparentSig but no transparent_inputs provided") - if resp.next_index >= len(transparent_inputs): - raise Exception( - "Device requested transparent input %d but only %d provided" - % (resp.next_index, len(transparent_inputs))) - inp = transparent_inputs[resp.next_index] - resp = self.call(zcash_proto.ZcashTransparentInput(**inp)) + if isinstance(resp, zcash_proto.ZcashTransparentSigned): + transparent_sigs = list(resp.signatures) + resp = self.transport.read_blocking() if isinstance(resp, proto.Failure): raise Exception("Zcash signing failed: %s" % resp.message) @@ -1896,8 +2267,104 @@ def zcash_sign_pczt(self, address_n, actions, account=None, if not isinstance(resp, zcash_proto.ZcashSignedPCZT): raise Exception("Unexpected response type: %s" % type(resp)) + # Count the transparent signatures the same way the Orchard signatures + # are counted below. Without this, a device that skips + # ZcashTransparentSigned entirely, or returns a short list, reaches the + # caller as success and hands back a transaction whose transparent + # inputs can never be spent. Checked after the Failure and response-type + # arms above so a device-reported error still surfaces its own message. + if len(transparent_sigs) != len(transparent_inputs): + raise Exception( + "Device returned %d transparent signatures for %d transparent inputs" + % (len(transparent_sigs), len(transparent_inputs))) + # Transparent signatures are DER ECDSA, so their length is not fixed the + # way a 64-byte RedPallas signature is; an empty entry is still a missing + # signature dressed up as a present one. + for signature in transparent_sigs: + if not signature: + raise Exception("Device returned an empty transparent signature") + + expected_signatures = sum(1 for action in stream if action['is_spend']) + if len(resp.signatures) != expected_signatures: + raise Exception( + "Device returned %d Orchard signatures for %d real spends" + % (len(resp.signatures), expected_signatures)) + for signature in resp.signatures: + if len(signature) != 64: + raise Exception("Device returned an invalid RedPallas signature") + + if return_transparent_signatures: + return resp, transparent_sigs return resp + # ── Hive ──────────────────────────────────────────────────── + @expect(hive_proto.HivePublicKey) + def hive_get_public_key(self, address_n, show_display=False, role=None): + kwargs = dict(address_n=address_n, show_display=show_display) + if role is not None: + kwargs['role'] = role + return self.call(hive_proto.HiveGetPublicKey(**kwargs)) + + @expect(hive_proto.HivePublicKeys) + def hive_get_public_keys(self, account_index=0, show_display=False): + return self.call( + hive_proto.HiveGetPublicKeys(account_index=account_index, show_display=show_display) + ) + + @expect(hive_proto.HiveSignedTx) + def hive_sign_tx(self, address_n, chain_id, ref_block_num, ref_block_prefix, + expiration, sender, recipient, amount, decimals, asset_symbol, memo=''): + return self.call(hive_proto.HiveSignTx(**{ + 'address_n': address_n, + 'chain_id': chain_id, + 'ref_block_num': ref_block_num, + 'ref_block_prefix': ref_block_prefix, + 'expiration': expiration, + 'from': sender, + 'to': recipient, + 'amount': amount, + 'decimals': decimals, + 'asset_symbol': asset_symbol, + 'memo': memo, + })) + + @expect(hive_proto.HiveSignedAccountCreate) + def hive_sign_account_create(self, address_n, chain_id, ref_block_num, ref_block_prefix, + expiration, creator, new_account_name, fee_amount=3000, + owner_key='', active_key='', posting_key='', memo_key=''): + return self.call(hive_proto.HiveSignAccountCreate( + address_n=address_n, + chain_id=chain_id, + ref_block_num=ref_block_num, + ref_block_prefix=ref_block_prefix, + expiration=expiration, + creator=creator, + new_account_name=new_account_name, + fee_amount=fee_amount, + owner_key=owner_key, + active_key=active_key, + posting_key=posting_key, + memo_key=memo_key, + )) + + @expect(hive_proto.HiveSignedAccountUpdate) + def hive_sign_account_update(self, address_n, chain_id, ref_block_num, ref_block_prefix, + expiration, account, + new_owner_key='', new_active_key='', + new_posting_key='', new_memo_key=''): + return self.call(hive_proto.HiveSignAccountUpdate( + address_n=address_n, + chain_id=chain_id, + ref_block_num=ref_block_num, + ref_block_prefix=ref_block_prefix, + expiration=expiration, + account=account, + new_owner_key=new_owner_key, + new_active_key=new_active_key, + new_posting_key=new_posting_key, + new_memo_key=new_memo_key, + )) + class KeepKeyClient(ProtocolMixin, TextUIMixin, BaseClient): pass diff --git a/keepkeylib/debuglink.py b/keepkeylib/debuglink.py index 96aa2f23..1be35a47 100644 --- a/keepkeylib/debuglink.py +++ b/keepkeylib/debuglink.py @@ -87,6 +87,19 @@ def read_reset_entropy(self): obj = self._call(proto.DebugLinkGetState()) return obj.reset_entropy + def read_dice_digest(self): + obj = self._call(proto.DebugLinkGetState()) + return obj.dice_digest + + def read_confirm_text(self): + """(title, body) of the visible confirmation page on current firmware. + + Paged titles include their n/m counter. Older firmware returned the + whole confirmation body on every page instead. + """ + obj = self._call(proto.DebugLinkGetState()) + return (obj.confirm_title, obj.confirm_body) + def read_passphrase_protection(self): obj = self._call(proto.DebugLinkGetState()) return obj.passphrase_protection @@ -127,6 +140,13 @@ def press_button(self, yes_no): def press_yes(self): self.press_button(True) + def press_input(self, text): + """Send synthetic keyboard input to an on-device entry flow + (dice rolls: '1'-'6' and 'u' for undo). Keep each chunk within + the firmware's DebugLinkDecision.input max_size (40 chars).""" + self.log("Injecting input", text) + self._call(proto.DebugLinkDecision(yes_no=False, input=text), nowait=True) + def press_no(self): self.press_button(False) diff --git a/keepkeylib/eip712_stream.py b/keepkeylib/eip712_stream.py new file mode 100644 index 00000000..48310b6f --- /dev/null +++ b/keepkeylib/eip712_stream.py @@ -0,0 +1,378 @@ +"""Host half of the device-driven structured EIP-712 walk. + +The DEVICE leads. It asks for one struct definition, or one leaf value, at a +time, and hashes each value in the same pass that displays it. This module +answers whatever it asks until a signature comes back. + +The host never chooses the order, and that is the property rather than an +accident of the API: a host that answered a different question than the one +asked would produce a digest that does not verify. + +Mirrors packages/hdwallet-keepkey/src/eip712Streaming.ts. The two are +deliberately parallel so a divergence shows up as a test failure in one of +them rather than as a bad signature in the field. +""" + +import re + +from . import messages_ethereum_pb2 as eth_proto + +DataType = eth_proto.EthereumTypedDataStructAck + +UINT = DataType.UINT +INT = DataType.INT +BYTES = DataType.BYTES +STRING = DataType.STRING +BOOL = DataType.BOOL +ADDRESS = DataType.ADDRESS +STRUCT = DataType.STRUCT + +# EthereumTypedDataValueAck.value max_size in messages-ethereum.options, and +# EIP712_MAX_LEAF on the device: one ValueAck, and one chunk of a longer value. +MAX_LEAF_BYTES = 1024 +# EIP712_MAX_VALUE: the longest `bytes` or `string` value the device takes in +# chunks. +MAX_VALUE_BYTES = 1024 * 1024 +# The Features capability (as common.capability_names() spells it) of +# firmware that takes chunked values. +CHUNKED_VALUES = 'eip712-chunked-values' +MAX_IDENTIFIER_BYTES = 31 +# Struct type names (EIP712_MAX_STRUCT_NAME - 1 on the device) may also use +# ':' after the first character, as Hyperliquid's +# "HyperliquidTransaction:ApproveBuilderFee" does. +MAX_TYPE_NAME_BYTES = 47 + +_ARRAY_GROUP = re.compile(r'\[([0-9]*)\]') +_CANONICAL_DIGITS = re.compile(r'^[1-9][0-9]*$') +_IDENTIFIER = re.compile(r'^[A-Za-z_$][A-Za-z0-9_$]*$') +_TYPE_NAME = re.compile(r'^[A-Za-z_$][A-Za-z0-9_$:]*$') + + +class Eip712Error(Exception): + pass + + +def _dimension(levels, used): + """The declared size of the array level being entered. + + Solidity nests right-to-left: in `T[k][j]` the OUTER array has j elements, + so `int16[2][][4]` parses to [2, 0, 4] but the first list a walker meets + holds 4. Levels are therefore consumed from the END. With a single + dimension both ends coincide, which is why this went unnoticed. + """ + return levels[len(levels) - 1 - used] + + +def parse_solidity_type(type_str): + """"uint256", "bytes32", "Person[3]", "int16[2][][4]" -> field descriptor. + + Raises rather than guessing. An unparseable type must never become a + signature. + """ + bracket = type_str.find('[') + base = type_str if bracket == -1 else type_str[:bracket] + suffix = '' if bracket == -1 else type_str[bracket:] + + levels = [] + if suffix: + consumed = 0 + for m in _ARRAY_GROUP.finditer(suffix): + if m.start() != consumed: + raise Eip712Error('Malformed array type: %s' % type_str) + digits = m.group(1) + if digits == '': + levels.append(0) # dynamic + else: + # 0 is the wire's DYNAMIC sentinel, so a fixed dimension of 0 + # has no spelling and "[0]" would be hashed as "[]" -- a + # different type string. Leading zeros re-spell the same way. + if not _CANONICAL_DIGITS.match(digits): + raise Eip712Error('Malformed array dimension: %s' % type_str) + levels.append(int(digits)) + consumed = m.end() + if consumed != len(suffix): + raise Eip712Error('Malformed array type: %s' % type_str) + + if base == 'string': + return {'data_type': STRING, 'array_levels': levels} + if base == 'bool': + return {'data_type': BOOL, 'array_levels': levels} + if base == 'address': + return {'data_type': ADDRESS, 'array_levels': levels} + if base == 'bytes': + return {'data_type': BYTES, 'array_levels': levels} + + m = re.match(r'^bytes([0-9]*)$', base) + if m: + if not _CANONICAL_DIGITS.match(m.group(1)): + raise Eip712Error('Non-canonical bytes width: %s' % base) + n = int(m.group(1)) + if n < 1 or n > 32: + raise Eip712Error('Invalid fixed bytes width: %s' % base) + return {'data_type': BYTES, 'size': n, 'array_levels': levels} + + # Anchored to digits, so a struct named "interest" is not caught here. + m = re.match(r'^(u?)int([0-9]*)$', base) + if m: + if m.group(2) == '': + raise Eip712Error('Integer type must state its width: %s' % base) + if not _CANONICAL_DIGITS.match(m.group(2)): + raise Eip712Error('Non-canonical integer width: %s' % base) + bits = int(m.group(2)) + if bits < 8 or bits > 256 or bits % 8: + raise Eip712Error('Invalid integer width: %s' % base) + return { + 'data_type': UINT if m.group(1) == 'u' else INT, + 'size': bits // 8, + 'array_levels': levels, + } + + if not _TYPE_NAME.match(base) or len(base) > MAX_TYPE_NAME_BYTES: + raise Eip712Error('Unparseable EIP-712 type: %s' % type_str) + return {'data_type': STRUCT, 'struct_name': base, 'array_levels': levels} + + +def _to_int(value, what): + if isinstance(value, bool): + raise Eip712Error('%s is a bool, not an integer' % what) + if isinstance(value, int): + return value + if isinstance(value, str): + s = value.strip() + if re.match(r'^-?[0-9]+$', s): + return int(s, 10) + if re.match(r'^0x[0-9a-fA-F]+$', s): + return int(s, 16) + raise Eip712Error('%s is not an integer: %r' % (what, value)) + + +def _hex_bytes(value, what): + if isinstance(value, (bytes, bytearray)): + return bytes(value) + if not isinstance(value, str): + raise Eip712Error('%s must be hex or bytes' % what) + h = value[2:] if value[:2] in ('0x', '0X') else value + if len(h) % 2 or (h and not re.match(r'^[0-9a-fA-F]+$', h)): + raise Eip712Error('%s is not valid hex: %s' % (what, value)) + return bytes(bytearray.fromhex(h)) + + +def encode_value(field, value, max_bytes=MAX_LEAF_BYTES): + """One leaf, as the exact bytes the device will hash and display. + + Raw big-endian at the declared width, never a decimal string: the device + does no number parsing at all, which is what removes the old path's + 2**63-1 ceiling and any chance of the two sides disagreeing about what a + decimal meant. A dynamic value may be up to `max_bytes` long; past + MAX_LEAF_BYTES only value_ack() can put it on the wire. + """ + dt = field['data_type'] + + if dt in (UINT, INT): + width = field.get('size') + if width is None: + raise Eip712Error('Integer field has no width') + n = _to_int(value, 'Integer field') + bits = width * 8 + if dt == INT: + lo, hi = -(1 << (bits - 1)), (1 << (bits - 1)) - 1 + if n < lo or n > hi: + raise Eip712Error('Value out of range for int%d' % bits) + if n < 0: + n += 1 << bits + else: + if n < 0: + raise Eip712Error('Negative value for uint%d' % bits) + if n >= 1 << bits: + raise Eip712Error('Value out of range for uint%d' % bits) + out = bytearray(width) + for i in range(width - 1, -1, -1): + out[i] = n & 0xFF + n >>= 8 + return bytes(out) + + if dt == BOOL: + if not isinstance(value, bool): + raise Eip712Error('Not a boolean: %r' % (value,)) + return b'\x01' if value else b'\x00' + + if dt == ADDRESS: + b = _hex_bytes(value, 'Address') + if len(b) != 20: + raise Eip712Error('Address must be 20 bytes, got %d' % len(b)) + return b + + if dt == BYTES: + b = _hex_bytes(value, 'bytes') + size = field.get('size') + if size is not None: + if len(b) != size: + raise Eip712Error('bytes%d must be %d bytes, got %d' % (size, size, len(b))) + return b + if len(b) > max_bytes: + raise Eip712Error('bytes value is %d bytes, over the %d-byte limit' + % (len(b), max_bytes)) + return b + + if dt == STRING: + if not isinstance(value, str): + raise Eip712Error('string field must be a string') + b = value.encode('utf-8') + if len(b) > max_bytes: + raise Eip712Error('string value is %d bytes, over the %d-byte limit' + % (len(b), max_bytes)) + return b + + raise Eip712Error('Cannot encode data type %r as a leaf' % (dt,)) + + +def encode_array_length(n): + """Big-endian uint16, the wire form of an array length.""" + if n < 0 or n > 0xFFFF: + raise Eip712Error('Array length out of range: %d' % n) + return bytes(bytearray([(n >> 8) & 0xFF, n & 0xFF])) + + +def struct_members(typed_data, name): + """Member list for one struct, in DECLARATION order. + + Order is part of the signature: it sets both encodeType and the order + encodeData concatenates members. + """ + if not isinstance(name, str) or not _TYPE_NAME.match(name) or len(name) > MAX_TYPE_NAME_BYTES: + raise Eip712Error('Struct name is not a canonical EIP-712 identifier') + members = typed_data['types'].get(name) + if members is None: + raise Eip712Error('Unknown struct: %s' % name) + result = [] + seen = set() + for member in members: + member_name = member.get('name') + if (not isinstance(member_name, str) or not _IDENTIFIER.match(member_name) + or len(member_name) > MAX_IDENTIFIER_BYTES): + raise Eip712Error('Member name in %s is not a canonical EIP-712 identifier' % name) + if member_name in seen: + raise Eip712Error('Duplicate EIP-712 member %s.%s' % (name, member_name)) + seen.add(member_name) + result.append({'name': member_name, 'type': parse_solidity_type(member['type'])}) + return result + + +def resolve_member_path(typed_data, path): + """Resolve a device-supplied member_path against the document. + + path[0] is 0 for the domain and 1 for the message. A path stopping on an + ARRAY is the device asking for its length; a path stopping on a STRUCT is a + protocol error, because the device walks into structs. + """ + if not path: + raise Eip712Error('Empty member_path') + root = path[0] + if root not in (0, 1): + raise Eip712Error('Unknown member_path root: %d' % root) + + field = {'data_type': STRUCT, + 'struct_name': 'EIP712Domain' if root == 0 else typed_data['primaryType'], + 'array_levels': []} + value = typed_data['domain'] if root == 0 else typed_data.get('message', {}) + levels_used = 0 + + for i in range(1, len(path)): + index = path[i] + if levels_used < len(field['array_levels']): + declared = _dimension(field['array_levels'], levels_used) + if not isinstance(value, list): + raise Eip712Error('Expected an array at %r' % (path[:i],)) + if declared and len(value) != declared: + raise Eip712Error('Fixed array declares %d elements, document has %d' + % (declared, len(value))) + if index >= len(value): + raise Eip712Error('Array index %d out of range' % index) + value = value[index] + levels_used += 1 + continue + + if field['data_type'] != STRUCT: + raise Eip712Error('Cannot descend into a leaf at %r' % (path[:i],)) + members = typed_data['types'].get(field['struct_name']) + if members is None: + raise Eip712Error('Unknown struct: %s' % field['struct_name']) + if index >= len(members): + raise Eip712Error('Member index %d out of range for %s' + % (index, field['struct_name'])) + member = members[index] + field = parse_solidity_type(member['type']) + levels_used = 0 + value = value[member['name']] + + if levels_used < len(field['array_levels']): + declared = _dimension(field['array_levels'], levels_used) + if not isinstance(value, list): + raise Eip712Error('Expected an array for a length request') + if declared and len(value) != declared: + raise Eip712Error('Fixed array declares %d elements, document has %d' + % (declared, len(value))) + return ('length', len(value)) + if field['data_type'] == STRUCT: + raise Eip712Error('Device asked for a struct as a value') + return ('value', field, value) + + +def build_struct_ack(members): + """Members, in the shape EthereumTypedDataStructAck wants.""" + ack = eth_proto.EthereumTypedDataStructAck() + for m in members: + entry = ack.members.add() + entry.name = m['name'] + entry.type.data_type = m['type']['data_type'] + if 'size' in m['type']: + entry.type.size = m['type']['size'] + if 'struct_name' in m['type']: + entry.type.struct_name = m['type']['struct_name'] + for lvl in m['type']['array_levels']: + entry.type.array_levels.append(lvl) + return ack + + +def value_ack(typed_data, request, chunked=False): + """The EthereumTypedDataValueAck that answers a device ValueRequest. + + A value of up to MAX_LEAF_BYTES goes whole. A longer `bytes` or `string` + goes in MAX_LEAF_BYTES chunks: the first carries value_total_length, and + each later request names a value_offset that its answer echoes. The + device may read a string twice from offset 0 (it counts and checks it + before showing it), so every chunk is served by its offset alone. + + `chunked` must be True only when the firmware reports CHUNKED_VALUES. + Older firmware skips the chunk fields and would hash the first chunk + alone, so without the capability a long value is refused here. + """ + resolved = resolve_member_path(typed_data, list(request.member_path)) + ack = eth_proto.EthereumTypedDataValueAck() + if resolved[0] == 'length': + if request.HasField('value_offset'): + raise Eip712Error('Device asked for an array length in chunks') + ack.value = encode_array_length(resolved[1]) + return ack + value = encode_value(resolved[1], resolved[2], MAX_VALUE_BYTES) + if len(value) > MAX_LEAF_BYTES and request.member_path[0] == 0: + raise Eip712Error('A domain value of %d bytes cannot be signed; the ' + 'device takes at most %d' % (len(value), MAX_LEAF_BYTES)) + if request.HasField('value_offset'): + offset = request.value_offset + if len(value) <= MAX_LEAF_BYTES or offset >= len(value): + raise Eip712Error('Device asked for offset %d of a %d-byte value' + % (offset, len(value))) + ack.value = value[offset:offset + MAX_LEAF_BYTES] + ack.value_offset = offset + return ack + if len(value) <= MAX_LEAF_BYTES: + ack.value = value + return ack + if not chunked: + raise Eip712Error('A %d-byte value needs firmware that takes chunked ' + 'values (%s); this one takes at most %d bytes' + % (len(value), CHUNKED_VALUES, MAX_LEAF_BYTES)) + ack.value = value[:MAX_LEAF_BYTES] + ack.value_total_length = len(value) + return ack diff --git a/keepkeylib/eos.py b/keepkeylib/eos.py index b7c68880..8e8aa416 100644 --- a/keepkeylib/eos.py +++ b/keepkeylib/eos.py @@ -257,7 +257,7 @@ def parse_authorization(data): keys.append( proto.EosAuthorizationKey( - type=1, + type=0, # K1: the key derived from address_n address_n=address_n, weight=int(key['weight']) ) diff --git a/keepkeylib/erc7730.py b/keepkeylib/erc7730.py new file mode 100644 index 00000000..c7f88659 --- /dev/null +++ b/keepkeylib/erc7730.py @@ -0,0 +1,218 @@ +"""Host-side transport for certified ERC-7730 compiled definitions. + +This module deliberately does not interpret or trust descriptor contents. It +indexes signed envelopes by the lookup facts needed to find them; firmware +recomputes the definition id, authenticates the envelope, and decodes the exact +transaction itself. +""" + +from __future__ import absolute_import + +import hashlib + +from . import messages_ethereum_pb2 as ethereum + + +MAX_CHUNK = 1024 +MAX_RECURSION_DEPTH = 4 +MAX_PROGRAM = 16 * 1024 +MAX_PROOF_DEPTH = 16 +CERTIFICATE_LENGTH = 139 +CATALOG_DOMAIN = b"KEEPKEY:ERC7730:CATALOG\0" + + +def _catalog_leaf(program): + return hashlib.sha256(b"\x00" + program).digest() + + +def _catalog_parent(left, right): + first, second = sorted((bytes(left), bytes(right))) + if len(first) != 32 or len(second) != 32: + raise ValueError("ERC-7730 Merkle nodes must be 32 bytes") + return hashlib.sha256(b"\x01" + first + second).digest() + + +def catalog_root(program, proof=()): + """Return the sorted-pair SHA-256 root committed by a catalog proof.""" + program = bytes(program) + if len(program) < 179 or len(program) > MAX_PROGRAM: + raise ValueError("invalid ERC-7730 program length") + proof = tuple(bytes(item) for item in proof) + if len(proof) > MAX_PROOF_DEPTH: + raise ValueError("ERC-7730 proof exceeds device limit") + root = _catalog_leaf(program) + for sibling in proof: + root = _catalog_parent(root, sibling) + return root + + +def sign_envelope(program, certificate, private_key, proof=()): + """Build a K773 envelope using a KeepKey-certified delegate.""" + program = bytes(program) + certificate = bytes(certificate) + private_key = bytes(private_key) + proof = tuple(bytes(item) for item in proof) + if len(certificate) != CERTIFICATE_LENGTH: + raise ValueError("invalid KeepKey delegation certificate length") + if len(private_key) != 32: + raise ValueError("delegate private key must be 32 bytes") + if any(len(item) != 32 for item in proof): + raise ValueError("ERC-7730 proof siblings must be 32 bytes") + root = catalog_root(program, proof) + digest = hashlib.sha256(CATALOG_DOMAIN + root).digest() + try: + from ecdsa import SigningKey, SECP256k1, VerifyingKey, util + except ImportError as exc: + raise RuntimeError("The 'ecdsa' package is required to sign catalogs") from exc + key = SigningKey.from_string(private_key, curve=SECP256k1) + signature = key.sign_digest_deterministic( + digest, hashfunc=hashlib.sha256, sigencode=util.sigencode_string) + recovered = VerifyingKey.from_public_key_recovery_with_digest( + signature, digest, SECP256k1, hashfunc=hashlib.sha256) + recovery = None + for index, candidate in enumerate(recovered): + if candidate.to_string() == key.get_verifying_key().to_string(): + recovery = index + break + if recovery is None or recovery > 1: + raise RuntimeError("unable to derive canonical catalog recovery id") + return (b"K773" + bytes([1, 1]) + len(program).to_bytes(4, "big") + + program + bytes([len(proof)]) + b"".join(proof) + + len(certificate).to_bytes(2, "big") + certificate + signature + + bytes([recovery])) + + +class Definition(object): + def __init__(self, envelope, kind, chain_id, contract_address=None, + selector_or_type_hash=None): + envelope = bytes(envelope) + if not envelope: + raise ValueError("ERC-7730 envelope must not be empty") + if kind not in (ethereum.ERC7730_CALLDATA, ethereum.ERC7730_EIP712, + ethereum.ERC7730_TOKEN, ethereum.ERC7730_NETWORK): + raise ValueError("unknown ERC-7730 definition kind") + if chain_id <= 0: + raise ValueError("ERC-7730 chain_id must be positive") + contract_address = (None if contract_address is None + else bytes(contract_address)) + selector_or_type_hash = ( + None if selector_or_type_hash is None + else bytes(selector_or_type_hash)) + if contract_address is not None and len(contract_address) != 20: + raise ValueError("ERC-7730 contract address must be 20 bytes") + expected_selector = 4 if kind == ethereum.ERC7730_CALLDATA else 32 + if kind in (ethereum.ERC7730_CALLDATA, ethereum.ERC7730_EIP712): + if (selector_or_type_hash is None or + len(selector_or_type_hash) != expected_selector): + raise ValueError("invalid ERC-7730 selector/type hash") + self.envelope = envelope + self.kind = kind + self.chain_id = chain_id + self.contract_address = contract_address + self.selector_or_type_hash = selector_or_type_hash + self.definition_id = hashlib.sha256(envelope).digest() + + +class Catalog(object): + def __init__(self, definitions=()): + self._by_id = {} + self._by_lookup = {} + for definition in definitions: + self.add(definition) + + @staticmethod + def _lookup_key(kind, chain_id, contract_address, + selector_or_type_hash): + return (kind, chain_id, + None if contract_address is None else bytes(contract_address), + None if selector_or_type_hash is None + else bytes(selector_or_type_hash)) + + def add(self, definition): + if not isinstance(definition, Definition): + raise TypeError("catalog entries must be ERC-7730 Definition objects") + existing = self._by_id.get(definition.definition_id) + if existing is not None and existing.envelope != definition.envelope: + raise ValueError("ERC-7730 definition id collision") + key = self._lookup_key( + definition.kind, definition.chain_id, + definition.contract_address, definition.selector_or_type_hash) + if existing is not None and self._lookup_key( + existing.kind, existing.chain_id, existing.contract_address, + existing.selector_or_type_hash) != key: + raise ValueError( + "ERC-7730 definition already registered under another lookup") + lookup_existing = self._by_lookup.get(key) + if (lookup_existing is not None and + lookup_existing.definition_id != definition.definition_id): + raise ValueError("ambiguous ERC-7730 lookup tuple") + self._by_id[definition.definition_id] = definition + self._by_lookup[key] = definition + return definition + + def resolve(self, request): + if request.HasField("recursion_depth"): + if request.recursion_depth > MAX_RECURSION_DEPTH: + raise ValueError("ERC-7730 recursion depth exceeds device limit") + if request.HasField("definition_id"): + definition = self._by_id.get(bytes(request.definition_id)) + else: + definition = self._by_lookup.get(self._lookup_key( + request.kind, request.chain_id, + bytes(request.contract_address) + if request.HasField("contract_address") else None, + bytes(request.selector_or_type_hash) + if request.HasField("selector_or_type_hash") else None)) + if definition is None: + raise KeyError("requested ERC-7730 definition is not in the catalog") + if (request.kind != definition.kind or + request.chain_id != definition.chain_id): + raise ValueError("ERC-7730 request does not match definition") + return definition + + def chunk(self, request): + try: + definition = self.resolve(request) + except KeyError: + if (not request.HasField("recursion_depth") or + request.recursion_depth == 0): + raise + # An embedded call's definition that the catalog does not hold: + # the empty chunk means "none". Firmware 7.15 then shows the + # inner call under a blind-sign warning; 7.16 rejects it. + return ethereum.EthereumClearSignDefinitionChunk( + definition_id=bytes(32), offset=0, total_length=0, data=b"") + offset = request.offset + length = request.length + if length == 0 or length > MAX_CHUNK or offset >= len(definition.envelope): + raise ValueError("invalid ERC-7730 chunk request") + end = min(offset + length, len(definition.envelope)) + return ethereum.EthereumClearSignDefinitionChunk( + definition_id=definition.definition_id, + offset=offset, + total_length=len(definition.envelope), + data=definition.envelope[offset:end], + ) + + +def preload(client, definition): + """Stream one envelope into the device's authenticated preload slot.""" + if not isinstance(definition, Definition): + raise TypeError("definition must be an ERC-7730 Definition") + offset = 0 + while offset < len(definition.envelope): + data = definition.envelope[offset:offset + MAX_CHUNK] + response = client.call(ethereum.EthereumClearSignDefinition( + definition_id=definition.definition_id, + offset=offset, + total_length=len(definition.envelope), + data=data, + )) + if not isinstance(response, ethereum.EthereumClearSignDefinitionAck): + raise RuntimeError("unexpected ERC-7730 preload response") + expected = offset + len(data) + if (bytes(response.definition_id) != definition.definition_id or + response.next_offset != expected or + response.complete != (expected == len(definition.envelope))): + raise RuntimeError("invalid ERC-7730 preload acknowledgement") + offset = expected diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py new file mode 100644 index 00000000..7272087e --- /dev/null +++ b/keepkeylib/erc7730_compiler.py @@ -0,0 +1,1449 @@ +"""Deterministic compiler for KeepKey's bounded ERC-7730 calldata format. + +The compiler intentionally emits the small authenticated instruction set +understood by firmware. It never decodes transaction values: paths and ABI +types are compiled here, while values are decoded again by the device. +""" + +from __future__ import absolute_import + +import hashlib +import json +import os +import re +import struct + +from .signed_metadata import keccak256 + + +ABSENT = 0xffff +HEADER_SIZE = 179 +COMPILER_ID = hashlib.sha256(b"python-keepkey:erc7730-compiler:1").digest() + + +def _merge_descriptor(base, overlay): + result = dict(base) + for key, value in overlay.items(): + if (key in result and isinstance(result[key], dict) and + isinstance(value, dict)): + result[key] = _merge_descriptor(result[key], value) + else: + result[key] = value + return result + + +def load_descriptor(path, root=None, _active=()): + """Load one descriptor and deterministically merge bounded includes.""" + path = os.path.realpath(path) + root = os.path.realpath(root or os.path.dirname(path)) + if os.path.commonpath((root, path)) != root: + raise ValueError("ERC-7730 include escapes registry root") + if path in _active: + raise ValueError("cyclic ERC-7730 include") + if len(_active) >= 16: + raise ValueError("ERC-7730 include depth exceeds limit") + with open(path, "r") as source: + descriptor = json.load(source) + includes = descriptor.pop("includes", []) + if isinstance(includes, str): + includes = [includes] + if not isinstance(includes, list): + raise ValueError("invalid ERC-7730 includes") + merged = {} + for include in includes: + if not isinstance(include, str) or not include: + raise ValueError("invalid ERC-7730 include path") + merged = _merge_descriptor( + merged, load_descriptor( + os.path.join(os.path.dirname(path), include), root, + _active + (path,))) + return _merge_descriptor(merged, descriptor) + + +def _u16(value): + if value < 0 or value > 0xffff: + raise ValueError("u16 overflow") + return struct.pack(">H", value) + + +def _u32(value): + if value < 0 or value > 0xffffffff: + raise ValueError("u32 overflow") + return struct.pack(">I", value) + + +def _u64(value): + if value < 0 or value > 0xffffffffffffffff: + raise ValueError("u64 overflow") + return struct.pack(">Q", value) + + +def _i32(value): + return struct.pack(">i", value) + + +def _hex_address(value): + if not isinstance(value, str) or not value.startswith("0x"): + raise ValueError("address must be 0x-prefixed") + raw = bytes.fromhex(value[2:]) + if len(raw) != 20: + raise ValueError("address must be 20 bytes") + return raw + + +def _unsigned_literal(value): + value = int(value) + if value < 0 or value >= (1 << 256): + raise ValueError("unsigned literal out of range") + width = max(1, (value.bit_length() + 7) // 8) + return value.to_bytes(width, "big") + + +class AbiType(object): + def __init__(self, kind, name="", size=0, children=None, + array_length=None): + self.kind = kind + self.name = name + self.size = size + self.children = list(children or ()) + self.array_length = array_length + + +class _SignatureParser(object): + _ident = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*") + + def __init__(self, text): + self.text = text + self.pos = 0 + + def _space(self): + while self.pos < len(self.text) and self.text[self.pos].isspace(): + self.pos += 1 + + def _take(self, token): + self._space() + if not self.text.startswith(token, self.pos): + raise ValueError("expected %s at %d" % (token, self.pos)) + self.pos += len(token) + + def _name(self, required=True): + self._space() + match = self._ident.match(self.text, self.pos) + if not match: + if required: + raise ValueError("expected identifier at %d" % self.pos) + return "" + self.pos = match.end() + return match.group(0) + + def type(self): + self._space() + if self.pos < len(self.text) and self.text[self.pos] == "(": + self.pos += 1 + children = [] + self._space() + if self.pos >= len(self.text) or self.text[self.pos] != ")": + while True: + child = self.type() + child.name = self._name(False) + children.append(child) + self._space() + if self.pos < len(self.text) and self.text[self.pos] == ",": + self.pos += 1 + continue + break + self._take(")") + node = AbiType(8, children=children) + else: + token = self._name() + if token.startswith("uint"): + bits = int(token[4:] or "256") + if bits < 8 or bits > 256 or bits % 8: + raise ValueError("invalid uint width") + node = AbiType(1, size=bits) + elif token.startswith("int"): + bits = int(token[3:] or "256") + if bits < 8 or bits > 256 or bits % 8: + raise ValueError("invalid int width") + node = AbiType(2, size=bits) + elif token == "address": + node = AbiType(3) + elif token == "bool": + node = AbiType(4) + elif token == "bytes": + node = AbiType(6) + elif token.startswith("bytes"): + width = int(token[5:]) + if width < 1 or width > 32: + raise ValueError("invalid bytes width") + node = AbiType(5, size=width) + elif token == "string": + node = AbiType(7) + else: + raise ValueError("unsupported ABI type %s" % token) + while True: + self._space() + if self.pos >= len(self.text) or self.text[self.pos] != "[": + break + self.pos += 1 + self._space() + start = self.pos + while self.pos < len(self.text) and self.text[self.pos].isdigit(): + self.pos += 1 + length = (ABSENT if self.pos == start + else int(self.text[start:self.pos])) + self._take("]") + node = AbiType(9, children=[node], array_length=length) + return node + + def function(self): + name = self._name() + self._take("(") + args = [] + self._space() + if self.pos >= len(self.text) or self.text[self.pos] != ")": + while True: + arg = self.type() + arg.name = self._name(False) + args.append(arg) + self._space() + if self.pos < len(self.text) and self.text[self.pos] == ",": + self.pos += 1 + continue + break + self._take(")") + self._space() + if self.pos != len(self.text): + raise ValueError("trailing function signature text") + return name, AbiType(8, children=args) + + +def parse_function_signature(signature): + return _SignatureParser(signature).function() + + +def _canonical_type(node): + names = {1: "uint%d", 2: "int%d", 3: "address", 4: "bool", + 5: "bytes%d", 6: "bytes", 7: "string"} + if node.kind == 8: + value = "(" + ",".join(_canonical_type(c) for c in node.children) + ")" + elif node.kind in (1, 2, 5): + value = names[node.kind] % node.size + elif node.kind in names: + value = names[node.kind] + elif node.kind == 9: + suffix = "" if node.array_length == ABSENT else str(node.array_length) + value = _canonical_type(node.children[0]) + "[" + suffix + "]" + else: + raise ValueError("invalid ABI kind") + return value + + +def _flatten_abi(root): + nodes = [None] + + def fill(node, index): + first = 0 + count = len(node.children) + if count: + first = len(nodes) + nodes.extend([None] * count) + nodes[index] = (node, first, count) + for offset, child in enumerate(node.children): + fill(child, first + offset) + + fill(root, 0) + if len(nodes) > 64: + raise ValueError("ABI exceeds firmware node limit") + return nodes + + +def _resolve_path(root, expression): + if not isinstance(expression, str) or not expression: + raise ValueError("invalid ERC-7730 path") + if expression == "#": + return [] + if expression.startswith("#."): + expression = expression[2:] + if expression.startswith("@."): + container = {"from": 1, "to": 2, "value": 3, "chainId": 4, + "domain": 5, "primaryType": 6} + name = expression[2:] + if name not in container: + raise ValueError("unknown container path %s" % expression) + return [("container", container[name])] + parts = expression.split(".") + node = root + steps = [] + for part in parts: + if part.startswith("[") and part.endswith("]"): + body = part[1:-1] + if body == "": + if node.kind != 9: + raise ValueError("array iteration applied to non-array") + steps.append((2,)) + node = node.children[0] + continue + if ":" in body: + start, end = body.split(":", 1) + flags = (1 if start else 0) | (2 if end else 0) + steps.append((3, flags, + int(start) if start else None, + int(end) if end else None)) + continue + if node.kind != 9: + raise ValueError("array index applied to non-array") + index = int(body) + steps.append((1, index)) + node = node.children[0] + continue + if node.kind != 8: + raise ValueError("field applied to non-tuple") + matches = [i for i, child in enumerate(node.children) + if child.name == part] + if len(matches) != 1: + raise ValueError("unknown or ambiguous path component %s" % part) + index = matches[0] + steps.append((1, index)) + node = node.children[index] + if len(steps) > 16: + raise ValueError("path exceeds firmware limit") + return steps + + +def _path_node(root, steps): + if steps and steps[0][0] == "container": + # from/to are addresses; value/chainId are unsigned integers. Domain + # and primaryType are containers and cannot be formatted as leaves. + kind = 3 if steps[0][1] in (1, 2) else 1 + return AbiType(kind) + node = root + for step in steps: + if step[0] == 1: + if node.kind == 8: + node = node.children[step[1]] + elif node.kind == 9: + node = node.children[0] + else: + raise ValueError("path continues through primitive") + elif step[0] == 2: + if node.kind != 9: + raise ValueError("iteration through non-array") + node = node.children[0] + elif step[0] == 3: + break + return node + + +def _condition_literal(node, value): + if node.kind == 1 and isinstance(value, int) and not isinstance(value, bool): + return 1, _unsigned_literal(value) + if node.kind == 2 and isinstance(value, int) and not isinstance(value, bool): + # Minimal two's complement, as the device requires: -128 is 0x80. + width = max(1, ((value if value >= 0 else ~value).bit_length() + 8) + // 8) + return 2, value.to_bytes(width, "big", signed=True) + if node.kind == 3 and isinstance(value, str): + return 5, _hex_address(value) + if node.kind == 4 and isinstance(value, bool): + return 6, bytes([1 if value else 0]) + if node.kind in (5, 6) and isinstance(value, str) and value.startswith("0x"): + return 3, bytes.fromhex(value[2:]) + if node.kind in (5, 6) and isinstance(value, int) and value >= 0: + return 3, _unsigned_literal(value) + raise ValueError("condition value does not match ABI leaf type") + + +class CalldataCompiler(object): + """Compile one calldata format and deployment into canonical C773 bytes.""" + + FORMAT_KIND = {"raw": 1, "amount": 2, "tokenAmount": 3, + "nftName": 4, "date": 5, "duration": 6, + "unit": 7, "enum": 8, "chainId": 9, + "addressName": 10, "tokenTicker": 11, + "interoperableAddress": 12, "calldata": 13, + "encrypted": 14} + + def __init__(self, descriptor, signature, chain_id, address, + provider_id=1, issuance_epoch=0, revocation_epoch=0, + token_records=(), network_records=(), definition_kind=1, + primary_type_hash=None, domain_constraints=()): + self.descriptor = descriptor + self.signature = signature + self.chain_id = int(chain_id) + self.address = _hex_address(address) if isinstance(address, str) else bytes(address) + if len(self.address) != 20: + raise ValueError("contract address must be 20 bytes") + self.provider_id = provider_id + self.issuance_epoch = issuance_epoch + self.revocation_epoch = revocation_epoch + self.token_records = list(token_records) + self.network_records = list(network_records) + self.definition_kind = definition_kind + self.primary_type_hash = primary_type_hash + self.domain_constraints = list(domain_constraints) + if definition_kind not in (1, 2): + raise ValueError("compiler supports calldata or EIP-712 programs") + if definition_kind == 2 and (primary_type_hash is None or + len(primary_type_hash) != 32): + raise ValueError("EIP-712 requires a primary type hash") + + def compile(self): + function_name, root = parse_function_signature(self.signature) + canonical_signature = function_name + "(" + ",".join( + _canonical_type(c) for c in root.children) + ")" + formats = self.descriptor.get("display", {}).get("formats", {}) + selected = formats.get(self.signature) + if selected is None: + for candidate, value in formats.items(): + parsed_name, parsed_root = parse_function_signature(candidate) + candidate_canonical = parsed_name + "(" + ",".join( + _canonical_type(c) for c in parsed_root.children) + ")" + if candidate_canonical == canonical_signature: + selected = value + break + if selected is None: + raise KeyError("signature is not described") + + def descriptor_value(reference): + if not isinstance(reference, str) or not reference.startswith("$."): + return reference + value = self.descriptor + for component in reference[2:].split("."): + if not isinstance(value, dict) or component not in value: + raise ValueError("unresolved descriptor value %s" % reference) + value = value[component] + return value + + def normalized_path(path): + return path[2:] if isinstance(path, str) and path.startswith("#.") else path + + def resolve_field(field): + reference = field.get("$ref") + if not reference: + return dict(field) + prefix = "$.display.definitions." + if not reference.startswith(prefix): + raise ValueError("unsupported field reference") + name = reference[len(prefix):] + base = self.descriptor.get("display", {}).get( + "definitions", {}).get(name) + if not isinstance(base, dict): + raise ValueError("unresolved field reference") + result = dict(base) + result.update((key, value) for key, value in field.items() + if key != "$ref" and key != "params") + params = dict(base.get("params", {})) + params.update(field.get("params", {})) + if params: + result["params"] = params + return result + + field_specs = [] + group_ranges = [] + + def join_path(prefix, path): + if not prefix or (isinstance(path, str) and + path.startswith(("#.", "@.", "$."))): + return path + if not path: + return prefix + return prefix + "." + path + + def flatten_fields(items, prefix=None): + for item in items: + if "fields" not in item: + field = dict(item) + if "path" in field: + field["path"] = join_path(prefix, field["path"]) + if prefix: + field["_path_prefix"] = prefix + field_specs.append(field) + continue + group_path = join_path(prefix, item.get("path")) + start = len(field_specs) + flatten_fields(item.get("fields", ()), group_path) + end = len(field_specs) + if end == start: + raise ValueError("display group must contain a field") + group_ranges.append((start, end, item.get("label"), group_path)) + + flatten_fields(selected.get("fields", [])) + + strings = set([selected.get("intent", selected.get("$id", function_name))]) + for unused_start, unused_end, group_label, unused_path in group_ranges: + if group_label: + strings.add(group_label) + interpolation = selected.get("interpolatedIntent") + interpolation_tokens = [] + interpolation_values = set() + if interpolation is not None: + # The device shows each fragment and value as its own numbered + # screen and escapes edge spaces, so trim fragments here; a + # fragment of spaces alone separates nothing and is dropped. + def add_text(text): + text = text.strip() + if text: + strings.add(text) + interpolation_tokens.append(("text", text)) + cursor = 0 + for match in re.finditer(r"\{([^{}]+)\}", interpolation): + if match.start() > cursor: + add_text(interpolation[cursor:match.start()]) + interpolation_tokens.append(("value", match.group(1))) + interpolation_values.add(normalized_path(match.group(1))) + cursor = match.end() + if cursor < len(interpolation): + add_text(interpolation[cursor:]) + for record in self.token_records: + strings.add(record[2]) + for record in self.network_records: + strings.add(record[1]) + strings.add(record[2]) + for field, value in self.domain_constraints: + if field in (1, 2): + strings.add(value) + fields = [] + for unresolved in field_specs: + field = resolve_field(unresolved) + if field.get("visible") == "never": + if normalized_path(field.get("path")) in interpolation_values: + # The compiled protocol requires every interpolated value + # to remain independently reviewable as an unconditional + # field. Promote registry shorthand that marks the source + # field hidden rather than weakening atomic interpolation. + field["visible"] = "always" + else: + continue + label = field.get("label") + path = field.get("path") + constant_value = (descriptor_value(field.get("value")) + if "value" in field else None) + kind_name = field.get("format", "raw") + if (not label or (not path and "value" not in field) or + kind_name not in self.FORMAT_KIND): + raise ValueError("invalid display field") + strings.add(label) + if isinstance(constant_value, str) and not constant_value.startswith("0x"): + strings.add(constant_value) + params = field.get("params", {}) + prefix = field.get("_path_prefix") + if prefix: + params = dict(params) + for key in ("tokenPath", "collectionPath", "chainIdPath", + "calleePath", "selectorPath", "amountPath", + "spenderPath"): + if key in params: + params[key] = join_path(prefix, params[key]) + field["params"] = params + if kind_name == "enum": + enum_ref = params.get("$ref") + prefix = "$.metadata.enums." + if not isinstance(enum_ref, str) or not enum_ref.startswith(prefix): + raise ValueError("enum requires a metadata enum reference") + enum_values = self.descriptor.get("metadata", {}).get( + "enums", {}).get(enum_ref[len(prefix):]) + if not isinstance(enum_values, dict) or not enum_values: + raise ValueError("unresolved enum reference") + for enum_label in enum_values.values(): + strings.add(enum_label) + if kind_name == "date": + strings.add(params.get("encoding", "timestamp")) + elif kind_name == "unit": + base = params.get("base") + if not isinstance(base, str) or not base: + raise ValueError("unit requires base") + strings.add(base) + elif kind_name == "tokenAmount" and params.get("message"): + strings.add(params["message"]) + separator = field.get("separator") + if separator: + strings.add(separator) + fields.append((field, (("constant", constant_value),) + if "value" in field else _resolve_path(root, path))) + strings = sorted(s.encode("utf-8") for s in strings) + if any(not value or len(value) > 128 for value in strings): + raise ValueError("invalid string length") + string_index = dict((value.decode("utf-8"), i) + for i, value in enumerate(strings)) + + paths = [] + path_index = {} + def intern_path(steps): + key = tuple(steps) + if key not in path_index: + path_index[key] = len(paths) + paths.append(steps) + return path_index[key] + + literals = [] + formatters = [] + conditions = [] + optional_condition = None + displays = [[1, string_index[selected.get("intent", selected.get("$id", function_name))], ABSENT, ABSENT]] + if interpolation_tokens: + formatter_by_path = {} + for index, (field, unused_steps) in enumerate(fields): + if field.get("visible", "always") == "always": + formatter_by_path[normalized_path(field.get("path"))] = index + for token_kind, token_value in interpolation_tokens: + if token_kind == "text": + displays.append([2, string_index[token_value], ABSENT, ABSENT]) + else: + token_value = normalized_path(token_value) + if token_value not in formatter_by_path: + raise ValueError( + "interpolated value must reference an always-visible field") + displays.append([3, formatter_by_path[token_value], ABSENT, ABSENT]) + + domain_records = [] + for field, value in self.domain_constraints: + if field in (1, 2): + literal = (4, _u16(string_index[value])) + elif field == 3: + literal = (7, _unsigned_literal(value)) + elif field == 4: + literal = (5, _hex_address(value) if isinstance(value, str) + else bytes(value)) + elif field == 5: + literal = (3, bytes.fromhex(value[2:]) + if isinstance(value, str) and value.startswith("0x") + else bytes(value)) + else: + raise ValueError("unknown EIP-712 domain field") + literals.append(literal) + domain_records.append((2, bytes([field, 1]) + + _u16(len(literals) - 1))) + groups_starting = {} + groups_ending = {} + for start, end, label, group_path in group_ranges: + groups_starting.setdefault(start, []).append((end, label, group_path)) + groups_ending.setdefault(end, []).append((start, label, group_path)) + active_group_pcs = [] + + for field_number, (field, steps) in enumerate(fields): + controlled_arrays = 0 + for unused_end, label, group_path in sorted( + groups_starting.get(field_number, ()), reverse=True): + array_pc = None + if group_path and ".[]" in group_path: + array_steps = _resolve_path(root, group_path) + array_pc = len(displays) + displays.append([7, intern_path(array_steps), ABSENT, 0]) + controlled_arrays += sum( + 1 for step in array_steps if step[0] == 2) + begin_pc = len(displays) + displays.append([5, string_index[label] if label else ABSENT, + ABSENT, 0]) + active_group_pcs.append((begin_pc, array_pc)) + for start, end, unused_label, group_path in group_ranges: + if start < field_number < end and group_path and ".[]" in group_path: + controlled_arrays += group_path.split(".").count("[]") + if steps and steps[0][0] == "constant": + value = descriptor_value(steps[0][1]) + if isinstance(value, bool): + literal = (6, bytes([1 if value else 0])) + elif isinstance(value, int): + literal = (1, _unsigned_literal(value)) + elif isinstance(value, str) and value.startswith("0x"): + raw = bytes.fromhex(value[2:]) + literal = (5 if len(raw) == 20 else 3, raw) + elif isinstance(value, str): + literal = (4, _u16(string_index[value])) + else: + raise ValueError("unsupported constant display value") + literals.append(literal) + steps = (("literal", len(literals) - 1),) + value_path = intern_path(steps) + kind = self.FORMAT_KIND[field.get("format", "raw")] + arguments = [(1, 1, value_path)] + params = field.get("params", {}) + if kind == 3: + token = params.get("tokenPath") + if token is None: + token = params.get("token") + token = descriptor_value(token) + if isinstance(token, str) and token.startswith("0x"): + raw = _hex_address(token) + literals.append((5, raw)) + literal_path = intern_path((("literal", len(literals) - 1),)) + arguments.append((2, 1, literal_path)) + elif isinstance(token, str): + arguments.append((2, 1, intern_path(_resolve_path(root, token)))) + elif token is not None: + raise ValueError("tokenAmount requires token or tokenPath") + elif any(key in params for key in ( + "threshold", "message", "chainId", "chainIdPath")): + raise ValueError( + "unknown token amount cannot use token metadata parameters") + else: + # No token is named, so the device can only show the raw + # integer. Its verifier refuses a tokenAmount without a + # token argument, so compile the raw formatter it runs. + kind = 1 + if "threshold" in params: + threshold = descriptor_value(params["threshold"]) + if isinstance(threshold, str) and threshold.startswith("0x"): + threshold = int(threshold, 16) + literals.append((1, _unsigned_literal(threshold))) + arguments.append((7, 2, len(literals) - 1)) + if params.get("message"): + arguments.append((8, 3, string_index[params["message"]])) + if "chainId" in params: + chain = params["chainId"] + if isinstance(chain, int): + literals.append((7, _unsigned_literal(chain))) + arguments.append((11, 2, len(literals) - 1)) + elif isinstance(chain, str): + arguments.append((11, 1, + intern_path(_resolve_path(root, chain)))) + else: + raise ValueError("invalid tokenAmount chainId") + aliases = params.get("nativeCurrencyAddress", ()) + aliases = descriptor_value(aliases) + if aliases and token is not None: + if isinstance(aliases, str): + aliases = [aliases] + references = [] + for alias in aliases: + alias = descriptor_value(alias) + literals.append((5, _hex_address(alias))) + references.append(len(literals) - 1) + literals.append((9, _u16(len(references)) + b"".join( + _u16(index) for index in references))) + arguments.append((22, 2, len(literals) - 1)) + elif kind == 4: + collection = params.get("collectionPath") + if collection is None: + collection = params.get("collection") + collection = descriptor_value(collection) + if isinstance(collection, str) and collection.startswith("0x"): + literals.append((5, _hex_address(collection))) + collection_path = intern_path( + (("literal", len(literals) - 1),)) + elif isinstance(collection, str): + collection_path = intern_path(_resolve_path(root, collection)) + else: + raise ValueError("nftName requires collection or collectionPath") + arguments.append((3, 1, collection_path)) + chain = params.get("chainIdPath", params.get("chainId")) + if chain is not None: + chain = descriptor_value(chain) + if isinstance(chain, int): + literals.append((7, _unsigned_literal(chain))) + arguments.append((11, 2, len(literals) - 1)) + elif isinstance(chain, str): + arguments.append((11, 1, + intern_path(_resolve_path(root, chain)))) + else: + raise ValueError("invalid nftName chainId") + elif kind == 5: + encoding = params.get("encoding", "timestamp") + arguments.append((9, 3, string_index[encoding])) + elif kind == 7: + decimals = params.get("decimals", 0) + literals.append((1, _unsigned_literal(decimals))) + arguments.append((4, 2, len(literals) - 1)) + arguments.append((5, 3, string_index[params["base"]])) + literals.append((6, bytes([1 if params.get("prefix") else 0]))) + arguments.append((6, 2, len(literals) - 1)) + elif kind == 8: + enum_ref = params["$ref"] + enum_values = self.descriptor["metadata"]["enums"][ + enum_ref[len("$.metadata.enums."):]] + node = _path_node(root, steps) + pairs = [] + for raw_key, label in sorted(enum_values.items()): + if node.kind == 4: + bool_key = raw_key.lower() + if bool_key not in ("true", "false", "1", "0"): + raise ValueError("boolean enum key must be true or false") + key = bool_key in ("true", "1") + elif node.kind in (1, 2): + key = int(raw_key, 0) + elif node.kind == 3: + key = raw_key + elif node.kind in (5, 6): + key = (raw_key if raw_key.startswith("0x") + else int(raw_key, 0)) + else: + raise ValueError("enum requires a scalar ABI value") + literals.append(_condition_literal(node, key)) + pairs.append((len(literals) - 1, string_index[label])) + pairs.sort() + literals.append((8, _u16(len(pairs)) + b"".join( + _u16(key) + _u16(value) for key, value in pairs))) + arguments.append((10, 2, len(literals) - 1)) + elif kind == 13: + callee = params.get("calleePath") + if not callee: + raise ValueError("embedded calldata requires calleePath") + arguments.append((15, 1, intern_path(_resolve_path(root, callee)))) + # 17: the native value the inner call moves; 18: whose + # authority it runs with. + for role, key in ((17, "amountPath"), (18, "spenderPath")): + if params.get(key): + arguments.append((role, 1, intern_path( + _resolve_path(root, params[key])))) + formatter_index = len(formatters) + formatters.append((kind, arguments)) + condition = ABSENT + visibility = field.get("visible") + if visibility == "optional": + if optional_condition is None: + optional_condition = len(conditions) + conditions.append((3, ABSENT, ABSENT)) + condition = optional_condition + elif isinstance(visibility, dict): + keys = [key for key in ("ifNotIn", "mustMatch") + if key in visibility] + if len(keys) != 1 or not visibility[keys[0]]: + raise ValueError("invalid visibility rule") + references = [] + node = _path_node(root, steps) + for value in visibility[keys[0]]: + literal = _condition_literal(node, value) + literals.append(literal) + references.append(len(literals) - 1) + set_value = _u16(len(references)) + b"".join( + _u16(index) for index in references) + literals.append((9, set_value)) + condition = len(conditions) + conditions.append((7 if keys[0] == "ifNotIn" else 8, + value_path, len(literals) - 1)) + all_positions = [i for i, step in enumerate(steps) + if step[0] == 2] + all_positions = all_positions[controlled_arrays:] + if all_positions: + begins = [] + for position in all_positions: + array_path = intern_path(steps[:position + 1]) + begin = len(displays) + begins.append(begin) + displays.append([7, array_path, condition, 0]) + displays.append([4, string_index[field["label"]], + formatter_index, ABSENT]) + separator = field.get("separator") + for depth, begin in enumerate(reversed(begins)): + end = len(displays) + displays.append([ + 8, begin, + (string_index[separator] + if separator and depth == len(begins) - 1 else ABSENT), + ABSENT]) + displays[begin][3] = end + else: + displays.append([4, string_index[field["label"]], + formatter_index, condition]) + for unused_start, unused_label, unused_path in reversed( + groups_ending.get(field_number + 1, ())): + if not active_group_pcs: + raise ValueError("unbalanced display group") + begin_pc, array_pc = active_group_pcs.pop() + end_pc = len(displays) + displays.append([6, begin_pc, ABSENT, ABSENT]) + displays[begin_pc][3] = end_pc + if array_pc is not None: + array_end = len(displays) + displays.append([8, array_pc, ABSENT, ABSENT]) + displays[array_pc][3] = array_end + if active_group_pcs: + raise ValueError("unbalanced display group") + displays.append([10, ABSENT, ABSENT, ABSENT]) + + if (len(paths) > 64 or len(formatters) > 64 or len(displays) > 64 or + len(conditions) > 32): + raise ValueError("compiled program exceeds table limits") + + sections = [] + payload = _u16(len(strings)) + b"".join(_u16(len(s)) + s for s in strings) + sections.append((1, payload)) + flat = _flatten_abi(root) + payload = _u16(len(flat)) + def depth(node): + return 1 + (max(depth(child) for child in node.children) + if node.children else 0) + max_depth = depth(root) + # The device's ABI verifier counts the root as depth 1, as depth() + # does, and refuses a node deeper than ERC7730_ABI_MAX_DEPTH (8). + if max_depth > 8: + raise ValueError( + "ERC-7730 ABI nests deeper than the device supports") + for node, first, count in flat: + array_length = node.array_length if node.kind == 9 else 0 + payload += bytes([node.kind]) + _u16(node.size) + _u16(first) + _u16(count) + _u16(array_length or 0) + sections.append((2, payload)) + payload = _u16(len(paths)) + for steps in paths: + if steps and steps[0][0] == "literal": + payload += bytes([3, 0]) + _u16(steps[0][1]) + continue + if steps and steps[0][0] == "container": + payload += bytes([2, 0]) + _u16(steps[0][1]) + continue + # Device captures descend one ABI level per step and refuse + # ERC7730_ABI_MAX_DEPTH (8) or more steps. + if len(steps) >= 8: + raise ValueError( + "ERC-7730 path nests deeper than the device supports") + # The device iterates at most one array per path ("[]" step). + if sum(1 for step in steps if step[0] == 2) > 1: + raise ValueError( + "ERC-7730 path iterates more than one array; the device " + "supports one") + payload += bytes([1, len(steps)]) + _u16(ABSENT) + for step in steps: + if step[0] == 1: + payload += bytes([1]) + _i32(step[1]) + elif step[0] == 2: + payload += bytes([2]) + else: + payload += bytes([3, step[1]]) + if step[1] & 1: + payload += _i32(step[2]) + if step[1] & 2: + payload += _i32(step[3]) + sections.append((3, payload)) + if literals: + payload = _u16(len(literals)) + b"".join( + bytes([kind]) + _u16(len(value)) + value + for kind, value in literals) + sections.append((4, payload)) + if conditions: + payload = _u16(len(conditions)) + b"".join( + bytes([opcode]) + _u16(path) + _u16(literal_set) + + bytes([0]) + _u16(0) + for opcode, path, literal_set in conditions) + sections.append((5, payload)) + payload = _u16(len(formatters)) + for kind, arguments in formatters: + payload += bytes([kind, 0, len(arguments)]) + for role, source, index in sorted(arguments): + payload += bytes([role, source]) + _u16(index) + sections.append((6, payload)) + payload = _u16(len(displays)) + b"".join( + bytes([op, 0]) + _u16(a) + _u16(b) + _u16(c) + for op, a, b, c in displays) + sections.append((7, payload)) + bindings = [(1, _u64(self.chain_id) + self.address)] + domain_records + for record in self.token_records: + ticker = record[2] + if ticker not in string_index: + raise ValueError("token ticker must be used by display strings") + bindings.append((3, _u64(record[0]) + _hex_address(record[1]) + + _u16(string_index[ticker]) + bytes([record[3]]))) + for record in self.network_records: + bindings.append((4, _u64(record[0]) + + _u16(string_index[record[1]]) + + _u16(string_index[record[2]]) + + bytes([record[3]]))) + bindings.sort(key=lambda item: (item[0], item[1])) + payload = _u16(len(bindings)) + b"".join( + bytes([kind]) + _u16(len(value)) + value for kind, value in bindings) + sections.append((8, payload)) + display_depth = 0 + display_max_depth = 0 + for instruction in displays: + if instruction[0] in (5, 7): + display_depth += 1 + display_max_depth = max(display_max_depth, display_depth) + elif instruction[0] in (6, 8): + display_depth -= 1 + if display_depth != 0: + raise ValueError("unbalanced display program") + resource = [_u16(len(strings)), _u16(len(flat)), _u16(len(paths)), + _u16(len(literals)), _u16(len(conditions)), _u16(len(formatters)), + _u16(len(displays)), _u16(len(bindings)), + bytes([max_depth, + 64 if any(step[0] == 2 for path in paths + for step in path) else 0, + display_max_depth, + 1 if any(kind == 13 for kind, _ in formatters) else 0]), + _u16(max([len(s) for s in strings] or [0]))] + sections.append((9, b"".join(resource))) + + source = json.dumps(self.descriptor, sort_keys=True, + separators=(",", ":")).encode("utf-8") + token_hash = hashlib.sha256(b"".join(value for _, value in bindings + if _ != 1)).digest() + selector = (keccak256(canonical_signature.encode("ascii"))[:4] + bytes(28) + if self.definition_kind == 1 else self.primary_type_hash) + header = (b"C773" + bytes([1, 2, 0, self.definition_kind]) + _u16(0) + + _u64(self.chain_id) + self.address + selector + + hashlib.sha256(source).digest() + COMPILER_ID + token_hash + + _u32(self.provider_id) + _u32(self.issuance_epoch) + + _u32(self.revocation_epoch) + bytes([len(sections)])) + if len(header) != HEADER_SIZE: + raise AssertionError("invalid C773 header size") + return header + b"".join(bytes([kind]) + _u32(len(value)) + value + for kind, value in sections) + + +# What the device executes: a mirror of keepkey-firmware's +# include/keepkey/firmware/erc7730_capabilities.h. The device refuses every +# program outside it at preload, before the first screen. Widen this only +# together with the firmware table. +_PATH, _LITERAL, _STRING = frozenset((1,)), frozenset((2,)), frozenset((3,)) +DEVICE_CAPABILITIES = { + # 2 and 3 (interpolated intent) only as one run directly after the intent; + # 5/6 groups; 7/8 one iteration at a time, calldata only + "display_opcodes": frozenset((1, 2, 3, 4, 5, 6, 7, 8, 10)), + # formatter kind -> (argument role -> permitted sources, required roles) + "formatters": { + 1: ({1: _PATH}, frozenset((1,))), # raw + 2: ({1: _PATH}, frozenset((1,))), # amount + 3: ({1: _PATH, 2: _PATH, 7: _LITERAL, 8: _STRING, # tokenAmount + 22: _LITERAL}, frozenset((1, 2))), + 4: ({1: _PATH, 3: _PATH}, frozenset((1, 3))), # nftName + 5: ({1: _PATH, 9: _STRING}, frozenset((1,))), # date + 6: ({1: _PATH}, frozenset((1,))), # duration + 7: ({1: _PATH, 4: _LITERAL, 5: _STRING, 6: _LITERAL}, # unit + frozenset((1, 5))), + 8: ({1: _PATH, 10: _LITERAL}, frozenset((1, 10))), # enum + 10: ({1: _PATH}, frozenset((1,))), # addressName + 13: ({1: _PATH, 15: _PATH, 17: _PATH, 18: _PATH}, # calldata + frozenset((1, 15))), + }, + "path_sources": frozenset((1, 2, 3)), + # @.from, @.to and @.value, calldata definitions only + "containers": frozenset((1, 2, 3)), + # 2: every element, bound to the iteration's current element + "path_step_opcodes": frozenset((1, 2)), + # only "optional" (3), which is always shown + "condition_opcodes": frozenset((3,)), + "conditions": True, + "alias_set_max": 4, + "enum_max": 16, + # signer text shown inside a value's screen, and unit decimals + "signer_text_max": 64, + "unit_decimals_max": 77, +} +# The verifier's table limits (erc7730_catalog.c). +TABLE_LIMITS = {1: 96, 2: 64, 3: 64, 4: 64, 5: 32, 6: 64, 7: 64, 8: 64} +# Value classes: 1-7 are ABI leaf kinds; literals map to what they hold. +CLASS_BYTES = 6 +(CLASS_UINT, CLASS_INT, CLASS_ADDRESS, CLASS_BOOL, CLASS_STRING, + CLASS_STRING_REF, CLASS_ALIAS_SET, CLASS_UINT_SMALL, CLASS_DATE_ENCODING, + CLASS_ENUM_MAP, CLASS_FLAG) = 1, 2, 3, 4, 7, 8, 9, 10, 11, 12, 13 + + +def _value_allowed(kind, role, cls): + """Mirror of erc7730_cap_value().""" + if not cls: + return False + unsigned = (CLASS_UINT, CLASS_UINT_SMALL) + rules = { + (1, 1): lambda: cls <= CLASS_STRING_REF or cls == CLASS_UINT_SMALL, + (10, 1): lambda: cls == CLASS_ADDRESS, + (2, 1): lambda: cls == CLASS_UINT, + (6, 1): lambda: cls == CLASS_UINT, + (3, 1): lambda: cls == CLASS_UINT, + (3, 7): lambda: cls in unsigned, + (3, 2): lambda: cls == CLASS_ADDRESS, + (3, 8): lambda: cls in (CLASS_STRING, CLASS_DATE_ENCODING), + (3, 22): lambda: cls == CLASS_ALIAS_SET, + (4, 1): lambda: cls == CLASS_UINT, + (4, 3): lambda: cls == CLASS_ADDRESS, + (5, 1): lambda: cls == CLASS_UINT, + (5, 9): lambda: cls == CLASS_DATE_ENCODING, + (7, 1): lambda: cls == CLASS_UINT, + (7, 4): lambda: cls == CLASS_UINT_SMALL, + (7, 5): lambda: cls in (CLASS_STRING, CLASS_DATE_ENCODING), + (7, 6): lambda: cls == CLASS_FLAG, + (8, 1): lambda: cls in (CLASS_UINT, CLASS_INT, CLASS_BOOL), + (8, 10): lambda: cls == CLASS_ENUM_MAP, + (13, 1): lambda: cls == CLASS_BYTES, + (13, 15): lambda: cls == CLASS_ADDRESS, + (13, 17): lambda: cls == CLASS_UINT, + (13, 18): lambda: cls == CLASS_ADDRESS, + } + rule = rules.get((kind, role)) + return bool(rule and rule()) + + +MAX_ARRAY_ELEMENTS = 64 + + +class DeviceCannotExecute(ValueError): + """The program is valid ERC-7730 but outside the device's capabilities.""" + + +def _program_sections(program): + offset = HEADER_SIZE + sections = {} + for _ in range(program[178]): + kind = program[offset] + length = struct.unpack(">I", program[offset + 1:offset + 5])[0] + sections[kind] = program[offset + 5:offset + 5 + length] + offset += 5 + length + return sections + + +def _abi_walk(nodes, steps): + """Mirror of the firmware's preload walk; steps are (opcode, index). + Returns (refusal, leaf kind or 0 for an iteration path, [] array node).""" + node = 0 + indexed = False + array = None + for opcode, index in steps: + if node >= len(nodes): + return "path leaves the ABI", 0, None + kind, _, first, count, length = nodes[node] + if kind == 8 and opcode == 1 and count and 0 <= index < count: + node = first + index + elif kind == 9 and opcode in (1, 2): + if opcode == 2: + if indexed: + return "path indexes an array before iterating", 0, None + array = node + else: + indexed = True + limit = MAX_ARRAY_ELEMENTS if length == ABSENT else length + if not -limit <= index < limit: + return "path indexes beyond the array", 0, None + node = first + else: + return "path does not name an ABI member", 0, None + if node >= len(nodes): + return "path does not end at a value", 0, None + if steps and steps[-1][0] == 2: + # An iteration path: a value too when its element is a leaf. + leaf = nodes[node][0] + return None, (leaf if leaf <= 7 else 0), array + if nodes[node][0] > 7: + return "path does not end at a value", 0, None + return None, nodes[node][0], array + + +def device_refusal(program, capabilities=DEVICE_CAPABILITIES): + """Why the device would refuse `program` at preload, or None.""" + sections = _program_sections(program) + u16 = lambda data, at: struct.unpack(">H", data[at:at + 2])[0] + + abi = sections.get(2, b"\0\0") + nodes = [struct.unpack(">BHHHH", abi[2 + 9 * i:11 + 9 * i]) + for i in range(u16(abi, 0))] + # A fixed array holds 1-64 elements (0xffff marks a dynamic one). + for kind, _, _, _, length in nodes: + if kind == 9 and (length == 0 or (length > 64 and length != 0xffff)): + return "an ABI array exceeds the device limit" + + string_table = sections.get(1, b"\0\0") + date_strings = set() + short_strings = set() + at = 2 + for index in range(u16(string_table, 0)): + length = u16(string_table, at) + # Printable ASCII or well-formed UTF-8, never a control character. + try: + text = string_table[at + 2:at + 2 + length].decode("utf-8") + except UnicodeDecodeError: + return "a program string is not printable text" + if any(ch < " " or ch == "\x7f" for ch in text): + return "a program string is not printable text" + if length <= capabilities.get("signer_text_max", 128): + short_strings.add(index) + if string_table[at + 2:at + 2 + length] in (b"timestamp", + b"blockheight"): + date_strings.add(index) + at += 2 + length + + literal_table = sections.get(4, b"\0\0") + literal_classes = [] + decimals_literals = set() + at = 2 + for literal_index in range(u16(literal_table, 0)): + kind, length = literal_table[at], u16(literal_table, at + 1) + value = literal_table[at + 3:at + 3 + length] + at += 3 + length + if (kind == 1 and length == 1 and + value[0] <= capabilities.get("unit_decimals_max", 255)): + decimals_literals.add(literal_index) + if kind == 8: + for entry in range(u16(value, 0)): + if u16(value, 4 + 4 * entry) not in short_strings: + return "an enum label is longer than the device shows" + members = u16(value, 0) if kind in (8, 9) else 0 + if kind == 1: + cls = CLASS_UINT_SMALL if length == 1 else CLASS_UINT + elif kind == 8: + cls = (CLASS_ENUM_MAP if 0 < members <= capabilities.get( + "enum_max", 0) else 0) + elif kind == 9: + cls = (CLASS_ALIAS_SET if 0 < members <= capabilities.get( + "alias_set_max", 0) else 0) + else: + cls = {4: CLASS_STRING_REF, 5: CLASS_ADDRESS, + 6: CLASS_FLAG}.get(kind, 0) + literal_classes.append(cls) + + def literal_class(index): + return literal_classes[index] if index < len(literal_classes) else 0 + + for kind, limit in TABLE_LIMITS.items(): + if kind in sections and u16(sections[kind], 0) > limit: + return "program table %d exceeds the device limit" % kind + + calldata = program[7] == 1 + path_classes = [] + path_arrays = [] + iterable = set() + paths = sections.get(3, b"\0\0") + at = 2 + for _ in range(u16(paths, 0)): + source, count, index = paths[at], paths[at + 1], u16(paths, at + 2) + at += 4 + if source not in capabilities["path_sources"]: + return "path source %d is not executed" % source + if source == 2 and (index not in capabilities["containers"] or + not calldata): + return "container %d is not executed" % index + if source == 3 and index >= 64: + return "path names a literal beyond the table" + path_classes.append( + (CLASS_UINT if index == 3 else CLASS_ADDRESS) if source == 2 else + ("literal", index) if source == 3 else None) + path_arrays.append(None) + steps = [] + for _ in range(count): + opcode = paths[at] + at += 1 + if opcode not in capabilities["path_step_opcodes"]: + return "path step opcode %d is not executed" % opcode + if opcode == 1: + steps.append((1, struct.unpack(">i", paths[at:at + 4])[0])) + at += 4 + elif opcode == 2: + steps.append((2, 0)) + else: + flags = paths[at] + at += 1 + 4 * bin(flags).count("1") + if source == 1: + if sum(1 for step in steps if step[0] == 2) > 1: + return "path iterates more than once" + reason, leaf, array = _abi_walk(nodes, steps) + if reason: + return reason + path_classes[-1] = leaf + path_arrays[-1] = array + if steps and steps[-1][0] == 2: + iterable.add(len(path_classes) - 1) + + conditions = sections.get(5, b"\0\0") + if u16(conditions, 0) and not capabilities["conditions"]: + return "display conditions are not executed" + for i in range(u16(conditions, 0)): + if conditions[2 + 8 * i] not in capabilities.get("condition_opcodes", + ()): + return "condition opcode %d is not executed" % conditions[2 + 8 * i] + + formatters = sections.get(6, b"\0\0") + formatter_arrays = [] + at = 2 + for _ in range(u16(formatters, 0)): + value_array, any_array, mixed_arrays, value_literal = ( + None, False, False, False) + kind, argc = formatters[at], formatters[at + 2] + at += 3 + if kind not in capabilities["formatters"]: + return "formatter kind %d is not executed" % kind + roles, required = capabilities["formatters"][kind] + seen = set() + for _ in range(argc): + role, source = formatters[at], formatters[at + 1] + index = u16(formatters, at + 2) + at += 4 + if source not in roles.get(role, ()): + return "formatter kind %d argument role %d is not executed" % ( + kind, role) + if source == 3: + cls = (CLASS_DATE_ENCODING if index in date_strings else + CLASS_STRING) + else: + cls = (literal_class(index) if source == 2 else + path_classes[index] if index < len(path_classes) else 0) + if isinstance(cls, tuple): + cls = literal_class(cls[1]) + if not _value_allowed(kind, role, cls): + return ("formatter kind %d argument role %d has the wrong " + "type" % (kind, role)) + if source == 3 and role in (5, 8) and index not in short_strings: + return "signer text is longer than the device shows" + if kind == 7 and role == 4 and index not in decimals_literals: + return "unit decimals exceed the device limit" + constant = (source == 1 and index < len(path_classes) and + isinstance(path_classes[index], tuple)) + if constant and kind == 13 and role in (15, 17, 18): + return ("an embedded call's callee, value and authority must " + "come from calldata") + if constant and role == 1 and kind != 1: + return "only a raw field may show a signer constant" + if (role == 1 and source == 1 and index < len(path_classes) and + isinstance(path_classes[index], tuple)): + value_literal = True + if source == 1: + argument_array = path_arrays[index] + if argument_array is not None: + any_array = True + if role == 1: + value_array = argument_array + elif argument_array != value_array: + mixed_arrays = True + seen.add(role) + if not required <= seen: + return "formatter kind %d lacks a required argument" % kind + if kind == 13 and not calldata: + return "embedded calldata is executed for calldata only" + formatter_arrays.append((value_array, any_array, mixed_arrays, + kind == 13, value_literal)) + + displays = sections.get(7, b"\0\0") + run_closed = False + iteration = None + for pc in range(u16(displays, 0)): + opcode, _, a, b, c = struct.unpack( + ">BBHHH", displays[2 + 8 * pc:10 + 8 * pc]) + if opcode not in capabilities["display_opcodes"]: + return "display opcode %d is not executed" % opcode + if (opcode == 1) != (pc == 0): + return "the intent must be the first display instruction only" + if pc and opcode in (2, 3): + if run_closed: + return "interpolated intent must directly follow the intent" + elif pc: + run_closed = True + if opcode == 4 and c != ABSENT and not capabilities["conditions"]: + return "display conditions are not executed" + if opcode == 7: + if a not in iterable or iteration is not None or not calldata: + return "iteration is not executed here" + iteration = path_arrays[a] + elif opcode == 8: + iteration = None + elif opcode in (3, 4): + value_array, any_array, mixed_arrays, embedded, value_literal = ( + formatter_arrays[a if opcode == 3 else b]) + if opcode == 3 and embedded: + return "an embedded call cannot be an intent value" + if opcode == 3 and value_literal: + return "a signer constant cannot be an intent value" + if opcode == 4 and a not in short_strings: + return "a field label is longer than the device shows" + if any_array and iteration is None: + return "an iterating value outside an iteration" + if iteration is not None and (value_array != iteration or + mixed_arrays): + return "a field reads another array than its iteration" + return None + + +def _executable(program, executable_only): + if executable_only: + reason = device_refusal(program) + if reason: + raise DeviceCannotExecute("device cannot execute: " + reason) + return program + + +def compile_calldata(descriptor, signature, chain_id, address, + executable_only=True, **kwargs): + """Compile one calldata format. By default only a program the device + executes is returned; pass executable_only=False to obtain the program + anyway, for example to prove that the device refuses it.""" + return _executable(CalldataCompiler(descriptor, signature, chain_id, + address, **kwargs).compile(), + executable_only) + + +def _typed_base(type_name, types, active): + array = re.match(r"^(.*)\[([0-9]*)\]$", type_name) + if array: + child = _typed_base(array.group(1), types, active) + length = ABSENT if array.group(2) == "" else int(array.group(2)) + return AbiType(9, children=[child], array_length=length) + if type_name in types: + if type_name in active: + raise ValueError("recursive EIP-712 type") + children = [] + for member in types[type_name]: + child = _typed_base(member["type"], types, active | {type_name}) + child.name = member["name"] + children.append(child) + return AbiType(8, children=children) + parser = _SignatureParser(type_name) + node = parser.type() + parser._space() + if parser.pos != len(type_name): + raise ValueError("invalid EIP-712 member type") + return node + + +def eip712_encode_type(primary_type, types): + if primary_type not in types: + raise ValueError("missing EIP-712 primary type") + dependencies = set() + + def visit(name): + for member in types[name]: + base = re.sub(r"(\[[0-9]*\])+$", "", member["type"]) + if base in types and base != primary_type and base not in dependencies: + dependencies.add(base) + visit(base) + + visit(primary_type) + def declaration(name): + return name + "(" + ",".join( + member["type"] + " " + member["name"] + for member in types[name]) + ")" + return declaration(primary_type) + "".join( + declaration(name) for name in sorted(dependencies)) + + +def compile_eip712(descriptor, typed_data, chain_id=None, address=None, + executable_only=True, **kwargs): + """Compile one EIP-712 descriptor against its exact typed-data schema.""" + types = typed_data["types"] + primary = typed_data["primaryType"] + root = _typed_base(primary, types, set()) + def named(node): + if node.kind == 8: + value = "(" + ",".join( + named(child) + (" " + child.name if child.name else "") + for child in node.children) + ")" + elif node.kind == 9: + suffix = "" if node.array_length == ABSENT else str(node.array_length) + value = named(node.children[0]) + "[" + suffix + "]" + else: + value = _canonical_type(node) + return value + signature = primary + "(" + ",".join( + named(child) + (" " + child.name if child.name else "") + for child in root.children) + ")" + formats = descriptor.get("display", {}).get("formats", {}) + selected = None + for key, value in formats.items(): + if key.startswith(primary + "("): + selected = value + break + if selected is None: + raise KeyError("primary type is not described") + synthetic = dict(descriptor) + synthetic["display"] = dict(descriptor.get("display", {})) + synthetic["display"]["formats"] = {signature: selected} + domain = typed_data.get("domain", {}) + if chain_id is None: + chain_id = domain.get("chainId") + if address is None: + address = domain.get("verifyingContract") + if not chain_id or not address: + raise ValueError("EIP-712 chain and verifying contract are required") + constraints = [] + for name, field in (("name", 1), ("version", 2), ("chainId", 3), + ("verifyingContract", 4), ("salt", 5)): + if name in domain: + constraints.append((field, domain[name])) + type_hash = keccak256(eip712_encode_type(primary, types).encode("ascii")) + return _executable(CalldataCompiler( + synthetic, signature, chain_id, address, definition_kind=2, + primary_type_hash=type_hash, domain_constraints=constraints, + **kwargs).compile(), executable_only) diff --git a/keepkeylib/eth/ethereum_tokens.py b/keepkeylib/eth/ethereum_tokens.py index 9160b1ab..995f6d7e 100644 --- a/keepkeylib/eth/ethereum_tokens.py +++ b/keepkeylib/eth/ethereum_tokens.py @@ -29,7 +29,7 @@ def add_tokens(self, network): fullpath = os.path.join(dirname, filename) if not os.path.isfile(fullpath): - return + continue with open(fullpath, 'r') as f: token = json.load(f) @@ -37,14 +37,46 @@ def add_tokens(self, network): self.tokens.append(ETHToken(token, network)) def build(self): + source = HERE + '/ethereum-lists/src/tokens' + if not os.path.isdir(source): + raise RuntimeError( + 'vetted ethereum-lists token source is missing; initialize ' + 'submodules recursively before generating firmware tables') + with open(HERE + '/ethereum_networks.json', 'r') as f: networks = json.load(f) for network in networks: self.add_tokens(network) - def serialize_c(self, outf): - for token in sorted(self.tokens, key=lambda t: t.token['address']): + if not self.tokens: + raise RuntimeError( + 'vetted ethereum-lists token source produced zero candidates') + + def serialize_c(self, outf, profile=None): + # Flash budget: this table is the largest read-only symbol in the ARM + # image. See token_policy for why it is capped rather than complete. + # Run as a standalone script by the build, so there is no package + # context for a relative import. + import os as _os, sys as _s + _s.path.insert(0, _os.path.dirname(_os.path.realpath(__file__))) + import token_policy + chosen, ambiguous = token_policy.select( + self.tokens, + token_policy.BUDGET_ETHEREUM_LISTS, + symbol_of=lambda t: t.token.get('symbol', ''), + address_of=lambda t: t.token['address'].lower(), + chain_of=lambda t: t.network['chain_id'], + profile=token_policy.DEFAULT_PROFILE if profile is None else profile) + print('ethereum_tokens: %d of %d kept (budget %d, profile %s)' + % (len(chosen), len(self.tokens), + token_policy.BUDGET_ETHEREUM_LISTS, + token_policy.DEFAULT_PROFILE if profile is None else profile), file=sys.stderr) + if ambiguous: + print('ethereum_tokens: priority symbols DROPPED as ambiguous ' + '(>1 address, a scam token can inherit a real label): %s' + % ', '.join(sorted(ambiguous)), file=sys.stderr) + for token in sorted(chosen, key=lambda t: t.token['address']): token.serialize_c(outf) def is_ascii(s): @@ -73,16 +105,22 @@ def serialize_c(self, outf): def main(): - if len(sys.argv) != 2: - print("Usage:\n\tpython %s ethereum_tokens.def" % (__file__,)) + # ` [--profile fill|priority-only]` -- see token_policy. + args = sys.argv[1:] + profile = None + if len(args) == 3 and args[1] == '--profile': + profile = args[2] + args = args[:1] + if len(args) != 1: + print("Usage:\n\tpython %s ethereum_tokens.def [--profile fill|priority-only]" % (__file__,)) sys.exit(-1) - out_filename = sys.argv[1] + out_filename = args[0] outf = StringIO() table = ETHTokenTable() table.build() - table.serialize_c(outf) + table.serialize_c(outf, profile) print('#undef X', file=outf) if os.path.isfile(out_filename): diff --git a/keepkeylib/eth/token_policy.py b/keepkeylib/eth/token_policy.py new file mode 100644 index 00000000..b5580ec5 --- /dev/null +++ b/keepkeylib/eth/token_policy.py @@ -0,0 +1,182 @@ +"""Which ERC-20s earn their place in firmware flash. + +The built-in token table is the single largest read-only symbol in the ARM +image -- 31,104 bytes of `tokens` for 1,945 entries, larger than MessagesMap or +the BIP-39 wordlist. It exists so the device can render "10.5 DAI" instead of a +raw amount against a bare contract address. + +It cannot be complete, and should not try to be. Two facts settle that: + + * The vetted source (ethereum-lists) is a SNAPSHOT and is stale. It has no + UNI, no AAVE, no stETH, no PEPE, none of the modern stables (FRAX, PYUSD, + crvUSD, USDe), and its `ARB` entry is a 2018 token called "ARBITRAGE", not + Arbitrum's. Shipping 1,945 entries does not make the table current; it + makes it 1,945 entries of mostly-2018 long tail. + * Anything outside the table is not undisplayable -- it is the clear-sign + provider's job, which is exactly the direction + docs/security/token-table-retirement.md sets out. + +So the table's job is narrow: the assets a user is most likely to hold, whose +addresses this repository can actually vouch for. Everything else is a provider +schema away. + +POLICY + 1. A budget, because flash is finite and this symbol is the biggest one. + 2. Priority symbols first. + 3. A priority symbol is only taken when the vetted source gives it exactly + ONE address. Two entries sharing a symbol is how a scam token inherits a + real one's label, and the device would render the attacker's name. + 4. What happens after the priority pass is chosen per firmware line by a + PROFILE, because one canonical python-keepkey head generates the table + for every firmware line that pins it: + + fill (default) priority = coins[] needs, fixtures, + stablecoins, majors; the remaining budget is filled in + the deterministic order (by address). This is the 7.15 + table, byte for byte; a build that passes no profile + gets it. + priority-only priority = coins[] needs, fixtures, stablecoins; no fill + (DECISIONS.md D-014, 2026-10-04). Opted into by the 7.16 + firmware build with `--profile priority-only`. + +Addresses are NEVER written here. They come from the vetted source, matched by +symbol. A hand-typed address in a token table is a mislabelling defect waiting +to happen, and this file must not become the place one appears. +""" + +# 500 entries * 16 bytes = ~8 KB, against 31 KB today. +TOKEN_BUDGET = 500 + +# Split across the two generators, which emit into one array. +BUDGET_ETHEREUM_LISTS = 350 +BUDGET_UNISWAP_LIST = 150 + +STABLECOINS = [ + "USDC", "USDT", "DAI", "TUSD", "BUSD", "USDP", "GUSD", "SAI", + "EURS", "EURT", "sUSD", "USDS", "FRAX", "LUSD", "PYUSD", "crvUSD", "USDe", +] + +# Required by coins[] in the firmware, not by popularity. Each of these is a +# display-only entry in the device's own coin table carrying a contract +# address, and unittests/firmware/coins.cpp (Coins.TableSanity) asserts every +# one of them resolves UNIQUELY in this token table. Dropping any is a build +# failure, correctly: the device would advertise a coin it cannot name. +# +# They are overwhelmingly 2017-era ICO tokens and are exactly the long tail +# this budget exists to cut -- but the cut has to happen in coins[] first, and +# coins[] is itself a 23,808-byte symbol. That is the next reduction, not this +# one. See docs/security/token-table-retirement.md. +REQUIRED_BY_COINS = [ + "0xBTC", "1ST", "AE", "ANT", "CVC", "DGD", "ELF", "FOX", "FUN", "GNT", + "GUP", "ICN", "MLN", "MTL", "PAY", "POLY", "PPT", "RCN", "RLC", "SALT", + "SNGLS", "SNT", "SPANK", "SWT", "TRST", "WINGS", +] + +# Also required by coins[]. The fill profile reaches these through MAJORS or +# the address-order fill; priority-only has no fill, so it names them. +REQUIRED_BY_COINS_VIA_FILL = [ + "BAT", "BNT", "DNT", "EDG", "GNO", "MANA", "MKR", "NMR", "OMG", "REP", + "STORJ", "ZRX", +] + +MAJORS = [ + "WETH", "WBTC", "stETH", "wstETH", "rETH", "cbETH", "LINK", "UNI", "AAVE", + "MKR", "LDO", "CRV", "SNX", "COMP", "ENS", "GRT", "MATIC", "ARB", "OP", + "SHIB", "PEPE", "APE", "SAND", "MANA", "AXS", "IMX", "INJ", "RNDR", "FET", + "STG", "BAL", "1INCH", "SUSHI", "YFI", "BAT", "ZRX", "KNC", "LRC", "GNO", + "RPL", "FXS", "CVX", "PAXG", "AMPL", "OMG", "REP", "ZIL", "ENJ", "STORJ", + "GUSD", +] + +# Required by a TEST FIXTURE rather than by the product. ADT (AdToken) is a +# 2017 ICO token that test_ethereum_signtx_knownerc20_eip_1559 uses as its +# canonical "known ERC-20", asserting a hardcoded signature over a transfer to +# its address -- so dropping it fails the suite, and the fixture cannot be +# repointed at a current token without regenerating that signature. +# +# It is listed separately and deliberately: a fixture should not get to pin +# firmware flash. Migrating that test to USDC (which every user actually holds) +# retires this entry, and is tracked as fixture debt rather than done here, +# because changing a signature fixture is a change to what the test proves. +REQUIRED_BY_TESTS = ["ADT"] + +PROFILE_FILL = 'fill' +PROFILE_PRIORITY_ONLY = 'priority-only' +PROFILES = (PROFILE_FILL, PROFILE_PRIORITY_ONLY) +DEFAULT_PROFILE = PROFILE_FILL + +# The 7.15 selection. Order matters: it decides which records survive the +# budget cut, so it is kept exactly as 7.15 shipped it. +FILL_PRIORITY_SYMBOLS = (REQUIRED_BY_COINS + REQUIRED_BY_TESTS + + STABLECOINS + MAJORS) + +# Owner decision 2026-10-04 (DECISIONS.md D-014, 7.16): stablecoins plus what +# coins[] and the fixtures still need. Majors and the long tail are described +# by signed ClearSign metadata instead of firmware flash. +PRIORITY_ONLY_SYMBOLS = (REQUIRED_BY_COINS + REQUIRED_BY_COINS_VIA_FILL + + REQUIRED_BY_TESTS + STABLECOINS) + +PRIORITY_SYMBOLS = { + PROFILE_FILL: FILL_PRIORITY_SYMBOLS, + PROFILE_PRIORITY_ONLY: PRIORITY_ONLY_SYMBOLS, +} + + +def select(records, budget, symbol_of, address_of, chain_of=None, + profile=DEFAULT_PROFILE): + """Return `records` trimmed to `budget`, priority symbols first. + + `records` is any iterable; `symbol_of`/`address_of` pull the two fields. + Priority symbols with more than one address in `records` are DROPPED from + the priority pass -- see rule 3 -- though under the fill profile they may + still be picked up by the deterministic fill, where they carry no special + standing. + + `chain_of` supplies the chain id. A token's identity is (chain_id, + address), NOT address alone: the vetted source carries the same address on + several chains -- 0x0000..0000 is listed as BURNER under EXP (2), OP (10) + and MATIC (137) -- and deduplicating on address alone silently dropped + every chain after the first from the generated firmware table. Left as + None the key falls back to address alone, which is only correct for a + single-chain source. + """ + if profile not in PROFILES: + raise ValueError('unknown token profile %r' % (profile,)) + if chain_of is None: + chain_of = lambda r: None + + def key_of(r): + return (chain_of(r), address_of(r)) + records = list(records) + by_symbol = {} + for r in records: + by_symbol.setdefault(symbol_of(r), []).append(r) + + chosen, seen = [], set() + ambiguous = [] + for sym in PRIORITY_SYMBOLS[profile]: + hits = by_symbol.get(sym, []) + # Ambiguous only when one chain lists the symbol at two addresses; + # one address per chain (e.g. USDC on several chains) is fine. + per_chain = {} + for r in hits: + per_chain.setdefault(chain_of(r), set()).add(address_of(r)) + if any(len(addresses) > 1 for addresses in per_chain.values()): + ambiguous.append(sym) + continue + for r in hits: + key = key_of(r) + if key not in seen: + seen.add(key) + chosen.append(r) + + if profile == PROFILE_FILL: + for r in sorted(records, key=address_of): + if len(chosen) >= budget: + break + key = key_of(r) + if key not in seen: + seen.add(key) + chosen.append(r) + + return chosen[:budget], ambiguous diff --git a/keepkeylib/eth/uniswap_tokens.json b/keepkeylib/eth/uniswap_tokens.json index 565a6615..de2a2784 100644 --- a/keepkeylib/eth/uniswap_tokens.json +++ b/keepkeylib/eth/uniswap_tokens.json @@ -4542,5 +4542,13 @@ "contractAddress": "0x0Ae055097C6d159879521C384F1D2123D1f195e6", "precision": 18, "network": "ETH" + }, + { + "symbol": "WETH", + "identifier": "weth", + "displayName": "Wrapped Ether", + "contractAddress": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "precision": 18, + "network": "ETH" } ] \ No newline at end of file diff --git a/keepkeylib/eth/uniswap_tokens.py b/keepkeylib/eth/uniswap_tokens.py index 72f8f97a..cdc2ca8d 100644 --- a/keepkeylib/eth/uniswap_tokens.py +++ b/keepkeylib/eth/uniswap_tokens.py @@ -26,9 +26,32 @@ def build(self): for token in ustoksjson: self.ustoks.append(USETHToken(token)) - def serialize_c(self): + def serialize_c(self, profile=None): + # Flash budget -- see token_policy. + # Run as a standalone script by the build, so there is no package + # context for a relative import. + import os as _os, sys as _s + _s.path.insert(0, _os.path.dirname(_os.path.realpath(__file__))) + import token_policy + import sys as _sys + chosen, ambiguous = token_policy.select( + self.ustoks, + token_policy.BUDGET_UNISWAP_LIST, + symbol_of=lambda t: t.token.get('symbol', ''), + address_of=lambda t: t.token['contractAddress'].lower(), + # This list is mainnet-only (serialize_c hardcodes chain_id 1), + # so the chain component is constant rather than absent. + chain_of=lambda t: 1, + profile=token_policy.DEFAULT_PROFILE if profile is None else profile) + print('uniswap_tokens: %d of %d kept (budget %d, profile %s)' + % (len(chosen), len(self.ustoks), + token_policy.BUDGET_UNISWAP_LIST, + token_policy.DEFAULT_PROFILE if profile is None else profile), file=_sys.stderr) + if ambiguous: + print('uniswap_tokens: priority symbols DROPPED as ambiguous: %s' + % ', '.join(sorted(ambiguous)), file=_sys.stderr) ser_list = [] - for token in sorted(self.ustoks, key=lambda t: t.token['contractAddress']): + for token in sorted(chosen, key=lambda t: t.token['contractAddress']): ser_list.append(token.serialize_c()) return(ser_list) @@ -64,17 +87,23 @@ def serialize_c(self): return(line) def main(): - if len(sys.argv) != 2: - print("Usage:\n\tpython %s uniswap_tokens.def" % (__file__,)) + # ` [--profile fill|priority-only]` -- see token_policy. + args = sys.argv[1:] + profile = None + if len(args) == 3 and args[1] == '--profile': + profile = args[2] + args = args[:1] + if len(args) != 1: + print("Usage:\n\tpython %s uniswap_tokens.def [--profile fill|priority-only]" % (__file__,)) sys.exit(-1) - out_filename = sys.argv[1] + out_filename = args[0] outf = StringIO() table = USETHTokenTable() table.build() - usset = table.serialize_c() + usset = table.serialize_c(profile) writeout(usset, outf) if os.path.isfile(out_filename): diff --git a/keepkeylib/hive.py b/keepkeylib/hive.py new file mode 100644 index 00000000..c8758b89 --- /dev/null +++ b/keepkeylib/hive.py @@ -0,0 +1,87 @@ +from . import messages_hive_pb2 as proto + + +def get_public_key(client, address_n, show_display=False, role=None): + kwargs = dict(address_n=address_n, show_display=show_display) + if role is not None: + kwargs['role'] = role + return client.call(proto.HiveGetPublicKey(**kwargs)) + + +def get_public_keys(client, account_index=0, show_display=False): + return client.call( + proto.HiveGetPublicKeys(account_index=account_index, show_display=show_display) + ) + + +def sign_tx(client, address_n, chain_id, ref_block_num, ref_block_prefix, + expiration, sender, recipient, amount, decimals, asset_symbol, memo=''): + # 'from' is a Python keyword so use **-unpacking to set the field + return client.call(proto.HiveSignTx(**{ + 'address_n': address_n, + 'chain_id': chain_id, + 'ref_block_num': ref_block_num, + 'ref_block_prefix': ref_block_prefix, + 'expiration': expiration, + 'from': sender, + 'to': recipient, + 'amount': amount, + 'decimals': decimals, + 'asset_symbol': asset_symbol, + 'memo': memo, + })) + + +def sign_message(client, address_n, message): + """Keychain signBuffer contract: sig over SHA256(raw message bytes) only — + no chain_id prepend, no message prefix.""" + if isinstance(message, str): + message = message.encode('utf-8') + return client.call(proto.HiveSignMessage(address_n=address_n, message=message)) + + +def sign_operations(client, address_n, serialized_tx, chain_id=None): + """Sign a host-serialized Graphene transaction (HiveSignOperations). + Firmware parses the bytes and clear-signs the phase-1 op table + (vote, comment, custom_json); digest = SHA256(chain_id || tx).""" + kwargs = dict(address_n=address_n, serialized_tx=serialized_tx) + if chain_id is not None: + kwargs['chain_id'] = chain_id + return client.call(proto.HiveSignOperations(**kwargs)) + + +def sign_account_create(client, address_n, chain_id, ref_block_num, ref_block_prefix, + expiration, creator, new_account_name, fee_amount=3000, + owner_key='', active_key='', posting_key='', memo_key=''): + return client.call(proto.HiveSignAccountCreate( + address_n=address_n, + chain_id=chain_id, + ref_block_num=ref_block_num, + ref_block_prefix=ref_block_prefix, + expiration=expiration, + creator=creator, + new_account_name=new_account_name, + fee_amount=fee_amount, + owner_key=owner_key, + active_key=active_key, + posting_key=posting_key, + memo_key=memo_key, + )) + + +def sign_account_update(client, address_n, chain_id, ref_block_num, ref_block_prefix, + expiration, account, + new_owner_key='', new_active_key='', + new_posting_key='', new_memo_key=''): + return client.call(proto.HiveSignAccountUpdate( + address_n=address_n, + chain_id=chain_id, + ref_block_num=ref_block_num, + ref_block_prefix=ref_block_prefix, + expiration=expiration, + account=account, + new_owner_key=new_owner_key, + new_active_key=new_active_key, + new_posting_key=new_posting_key, + new_memo_key=new_memo_key, + )) diff --git a/keepkeylib/mapping.py b/keepkeylib/mapping.py index c8c37397..5b851dc0 100644 --- a/keepkeylib/mapping.py +++ b/keepkeylib/mapping.py @@ -13,6 +13,7 @@ from . import messages_tron_pb2 as tron_proto from . import messages_ton_pb2 as ton_proto from . import messages_zcash_pb2 as zcash_proto +from . import messages_hive_pb2 as hive_proto map_type_to_class = {} map_class_to_type = {} @@ -22,6 +23,10 @@ def build_map(): msg_name = msg_type.replace('MessageType_', '') if msg_type.startswith('MessageType_Ethereum'): msg_class = getattr(eth_proto, msg_name) + elif msg_type == 'MessageType_LoadClearsignSigner': + # clearsign signer loading lives in messages-ethereum.proto + # without the Ethereum name prefix (chain-agnostic by design) + msg_class = getattr(eth_proto, msg_name) elif msg_type.startswith('MessageType_Eos'): msg_class = getattr(eos_proto, msg_name) elif msg_type.startswith('MessageType_Nano'): @@ -97,4 +102,28 @@ def check_missing(): map_type_to_class[wire_id] = msg_class map_class_to_type[msg_class] = wire_id -# check_missing() — skip: Zcash types are not in old messages_pb2 enum +# Manually register Hive messages (not in the old messages_pb2.py enum) +_hive_wire_ids = { + 1600: ('HiveGetPublicKey', hive_proto), + 1601: ('HivePublicKey', hive_proto), + 1602: ('HiveSignTx', hive_proto), + 1603: ('HiveSignedTx', hive_proto), + 1604: ('HiveGetPublicKeys', hive_proto), + 1605: ('HivePublicKeys', hive_proto), + 1606: ('HiveSignAccountCreate', hive_proto), + 1607: ('HiveSignedAccountCreate', hive_proto), + 1608: ('HiveSignAccountUpdate', hive_proto), + 1609: ('HiveSignedAccountUpdate', hive_proto), + # 1610-1613 reserved: NEAR + 1614: ('HiveSignMessage', hive_proto), + 1615: ('HiveSignedMessage', hive_proto), + 1616: ('HiveSignOperations', hive_proto), + 1617: ('HiveSignedOperations', hive_proto), +} +for wire_id, (msg_name, mod) in _hive_wire_ids.items(): + msg_class = getattr(mod, msg_name, None) + if msg_class is not None: + map_type_to_class[wire_id] = msg_class + map_class_to_type[msg_class] = wire_id + +# check_missing() — skip: Zcash/Hive types are not in old messages_pb2 enum diff --git a/keepkeylib/messages_ethereum_pb2.py b/keepkeylib/messages_ethereum_pb2.py index 36dbc107..e6998504 100644 --- a/keepkeylib/messages_ethereum_pb2.py +++ b/keepkeylib/messages_ethereum_pb2.py @@ -3,6 +3,7 @@ import sys _b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf.internal import enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message from google.protobuf import reflection as _reflection @@ -20,11 +21,92 @@ name='messages-ethereum.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x17messages-ethereum.proto\x1a\x0btypes.proto\"=\n\x12\x45thereumGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\"7\n\x0f\x45thereumAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x02(\x0c\x12\x13\n\x0b\x61\x64\x64ress_str\x18\x02 \x01(\t\"\x95\x03\n\x0e\x45thereumSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\r\n\x05nonce\x18\x02 \x01(\x0c\x12\x11\n\tgas_price\x18\x03 \x01(\x0c\x12\x11\n\tgas_limit\x18\x04 \x01(\x0c\x12\n\n\x02to\x18\x05 \x01(\x0c\x12\r\n\x05value\x18\x06 \x01(\x0c\x12\x1a\n\x12\x64\x61ta_initial_chunk\x18\x07 \x01(\x0c\x12\x13\n\x0b\x64\x61ta_length\x18\x08 \x01(\r\x12\x14\n\x0cto_address_n\x18\t \x03(\r\x12(\n\x0c\x61\x64\x64ress_type\x18\n \x01(\x0e\x32\x12.OutputAddressType\x12\x10\n\x08\x63hain_id\x18\x0c \x01(\r\x12\x17\n\x0fmax_fee_per_gas\x18\r \x01(\x0c\x12 \n\x18max_priority_fee_per_gas\x18\x0e \x01(\x0c\x12\x13\n\x0btoken_value\x18\x64 \x01(\x0c\x12\x10\n\x08token_to\x18\x65 \x01(\x0c\x12\x16\n\x0etoken_shortcut\x18\x66 \x01(\t\x12\x0f\n\x07tx_type\x18g \x01(\r\x12\x0c\n\x04type\x18h \x01(\rJ\x04\x08\x0b\x10\x0c\"\x8c\x01\n\x11\x45thereumTxRequest\x12\x13\n\x0b\x64\x61ta_length\x18\x01 \x01(\r\x12\x13\n\x0bsignature_v\x18\x02 \x01(\r\x12\x13\n\x0bsignature_r\x18\x03 \x01(\x0c\x12\x13\n\x0bsignature_s\x18\x04 \x01(\x0c\x12\x0c\n\x04hash\x18\x05 \x01(\x0c\x12\x15\n\rsignature_der\x18\x06 \x01(\x0c\"#\n\rEthereumTxAck\x12\x12\n\ndata_chunk\x18\x01 \x01(\x0c\"V\n\x12\x45thereumTxMetadata\x12\x16\n\x0esigned_payload\x18\x01 \x01(\x0c\x12\x18\n\x10metadata_version\x18\x02 \x01(\r\x12\x0e\n\x06key_id\x18\x03 \x01(\r\"F\n\x13\x45thereumMetadataAck\x12\x16\n\x0e\x63lassification\x18\x01 \x02(\r\x12\x17\n\x0f\x64isplay_summary\x18\x02 \x01(\t\"9\n\x13\x45thereumSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x02(\x0c\"L\n\x15\x45thereumVerifyMessage\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\">\n\x18\x45thereumMessageSignature\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"_\n\x15\x45thereumSignTypedHash\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x1d\n\x15\x64omain_separator_hash\x18\x02 \x02(\x0c\x12\x14\n\x0cmessage_hash\x18\x03 \x01(\x0c\"\x8b\x01\n\x1a\x45thereumTypedDataSignature\x12\x11\n\tsignature\x18\x01 \x02(\x0c\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x02(\t\x12\x1d\n\x15\x64omain_separator_hash\x18\x03 \x01(\x0c\x12\x14\n\x0chas_msg_hash\x18\x04 \x02(\x08\x12\x14\n\x0cmessage_hash\x18\x05 \x01(\x0c\"\x85\x01\n\x16\x45thereum712TypesValues\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x13\n\x0b\x65ip712types\x18\x02 \x02(\t\x12\x17\n\x0f\x65ip712primetype\x18\x03 \x02(\t\x12\x12\n\neip712data\x18\x04 \x02(\t\x12\x16\n\x0e\x65ip712typevals\x18\x05 \x02(\rB4\n\x1a\x63om.keepkey.deviceprotocolB\x16KeepKeyMessageEthereum') + serialized_pb=_b('\n\x17messages-ethereum.proto\x1a\x0btypes.proto\"=\n\x12\x45thereumGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\"7\n\x0f\x45thereumAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x02(\x0c\x12\x13\n\x0b\x61\x64\x64ress_str\x18\x02 \x01(\t\"\x95\x03\n\x0e\x45thereumSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\r\n\x05nonce\x18\x02 \x01(\x0c\x12\x11\n\tgas_price\x18\x03 \x01(\x0c\x12\x11\n\tgas_limit\x18\x04 \x01(\x0c\x12\n\n\x02to\x18\x05 \x01(\x0c\x12\r\n\x05value\x18\x06 \x01(\x0c\x12\x1a\n\x12\x64\x61ta_initial_chunk\x18\x07 \x01(\x0c\x12\x13\n\x0b\x64\x61ta_length\x18\x08 \x01(\r\x12\x14\n\x0cto_address_n\x18\t \x03(\r\x12(\n\x0c\x61\x64\x64ress_type\x18\n \x01(\x0e\x32\x12.OutputAddressType\x12\x10\n\x08\x63hain_id\x18\x0c \x01(\r\x12\x17\n\x0fmax_fee_per_gas\x18\r \x01(\x0c\x12 \n\x18max_priority_fee_per_gas\x18\x0e \x01(\x0c\x12\x13\n\x0btoken_value\x18\x64 \x01(\x0c\x12\x10\n\x08token_to\x18\x65 \x01(\x0c\x12\x16\n\x0etoken_shortcut\x18\x66 \x01(\t\x12\x0f\n\x07tx_type\x18g \x01(\r\x12\x0c\n\x04type\x18h \x01(\rJ\x04\x08\x0b\x10\x0c\"\x8c\x01\n\x11\x45thereumTxRequest\x12\x13\n\x0b\x64\x61ta_length\x18\x01 \x01(\r\x12\x13\n\x0bsignature_v\x18\x02 \x01(\r\x12\x13\n\x0bsignature_r\x18\x03 \x01(\x0c\x12\x13\n\x0bsignature_s\x18\x04 \x01(\x0c\x12\x0c\n\x04hash\x18\x05 \x01(\x0c\x12\x15\n\rsignature_der\x18\x06 \x01(\x0c\"#\n\rEthereumTxAck\x12\x12\n\ndata_chunk\x18\x01 \x01(\x0c\"V\n\x12\x45thereumTxMetadata\x12\x16\n\x0esigned_payload\x18\x01 \x01(\x0c\x12\x18\n\x10metadata_version\x18\x02 \x01(\r\x12\x0e\n\x06key_id\x18\x03 \x01(\r\"F\n\x13\x45thereumMetadataAck\x12\x16\n\x0e\x63lassification\x18\x01 \x02(\r\x12\x17\n\x0f\x64isplay_summary\x18\x02 \x01(\t\"\x8c\x01\n\x13LoadClearsignSigner\x12\x0e\n\x06key_id\x18\x01 \x01(\r\x12\x0e\n\x06pubkey\x18\x02 \x01(\x0c\x12\r\n\x05\x61lias\x18\x03 \x01(\t\x12\x0c\n\x04icon\x18\x04 \x01(\x0c\x12\x12\n\nicon_width\x18\x05 \x01(\r\x12\x13\n\x0bicon_height\x18\x06 \x01(\r\x12\x0f\n\x07persist\x18\x07 \x01(\x08\"9\n\x13\x45thereumSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x02(\x0c\"L\n\x15\x45thereumVerifyMessage\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\">\n\x18\x45thereumMessageSignature\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"_\n\x15\x45thereumSignTypedHash\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x1d\n\x15\x64omain_separator_hash\x18\x02 \x02(\x0c\x12\x14\n\x0cmessage_hash\x18\x03 \x01(\x0c\"\x8b\x01\n\x1a\x45thereumTypedDataSignature\x12\x11\n\tsignature\x18\x01 \x02(\x0c\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x02(\t\x12\x1d\n\x15\x64omain_separator_hash\x18\x03 \x01(\x0c\x12\x14\n\x0chas_msg_hash\x18\x04 \x02(\x08\x12\x14\n\x0cmessage_hash\x18\x05 \x01(\x0c\"\x85\x01\n\x16\x45thereum712TypesValues\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x13\n\x0b\x65ip712types\x18\x02 \x02(\t\x12\x17\n\x0f\x65ip712primetype\x18\x03 \x02(\t\x12\x12\n\neip712data\x18\x04 \x02(\t\x12\x16\n\x0e\x65ip712typevals\x18\x05 \x02(\r\"b\n\x15\x45thereumSignTypedData\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cprimary_type\x18\x02 \x02(\t\x12 \n\x12metamask_v4_compat\x18\x03 \x01(\x08:\x04true\".\n\x1e\x45thereumTypedDataStructRequest\x12\x0c\n\x04name\x18\x01 \x02(\t\"\xbe\x03\n\x1a\x45thereumTypedDataStructAck\x12\x41\n\x07members\x18\x01 \x03(\x0b\x32\x30.EthereumTypedDataStructAck.EthereumStructMember\x1a\x61\n\x14\x45thereumStructMember\x12;\n\x04type\x18\x01 \x02(\x0b\x32-.EthereumTypedDataStructAck.EthereumFieldType\x12\x0c\n\x04name\x18\x02 \x02(\t\x1a\x8d\x01\n\x11\x45thereumFieldType\x12?\n\tdata_type\x18\x01 \x02(\x0e\x32,.EthereumTypedDataStructAck.EthereumDataType\x12\x0c\n\x04size\x18\x02 \x01(\r\x12\x13\n\x0bstruct_name\x18\x03 \x01(\t\x12\x14\n\x0c\x61rray_levels\x18\x04 \x03(\r\"j\n\x10\x45thereumDataType\x12\x08\n\x04UINT\x10\x01\x12\x07\n\x03INT\x10\x02\x12\t\n\x05\x42YTES\x10\x03\x12\n\n\x06STRING\x10\x04\x12\x08\n\x04\x42OOL\x10\x05\x12\x0b\n\x07\x41\x44\x44RESS\x10\x06\x12\t\n\x05\x41RRAY\x10\x07\x12\n\n\x06STRUCT\x10\x08\"J\n\x1d\x45thereumTypedDataValueRequest\x12\x13\n\x0bmember_path\x18\x01 \x03(\r\x12\x14\n\x0cvalue_offset\x18\x02 \x01(\r\"\\\n\x19\x45thereumTypedDataValueAck\x12\r\n\x05value\x18\x01 \x02(\x0c\x12\x1a\n\x12value_total_length\x18\x02 \x01(\r\x12\x14\n\x0cvalue_offset\x18\x03 \x01(\r\"h\n\x1b\x45thereumClearSignDefinition\x12\x15\n\rdefinition_id\x18\x01 \x02(\x0c\x12\x0e\n\x06offset\x18\x02 \x02(\r\x12\x14\n\x0ctotal_length\x18\x03 \x02(\r\x12\x0c\n\x04\x64\x61ta\x18\x04 \x02(\x0c\"^\n\x1e\x45thereumClearSignDefinitionAck\x12\x15\n\rdefinition_id\x18\x01 \x02(\x0c\x12\x13\n\x0bnext_offset\x18\x02 \x02(\r\x12\x10\n\x08\x63omplete\x18\x03 \x02(\x08\"\xf3\x01\n\"EthereumClearSignDefinitionRequest\x12.\n\x04kind\x18\x01 \x02(\x0e\x32 .EthereumClearSignDefinitionKind\x12\x14\n\x08\x63hain_id\x18\x02 \x02(\x04\x42\x02\x30\x01\x12\x18\n\x10\x63ontract_address\x18\x03 \x01(\x0c\x12\x1d\n\x15selector_or_type_hash\x18\x04 \x01(\x0c\x12\x15\n\rdefinition_id\x18\x05 \x01(\x0c\x12\x0e\n\x06offset\x18\x06 \x02(\r\x12\x0e\n\x06length\x18\x07 \x02(\r\x12\x17\n\x0frecursion_depth\x18\x08 \x01(\r\"m\n EthereumClearSignDefinitionChunk\x12\x15\n\rdefinition_id\x18\x01 \x02(\x0c\x12\x0e\n\x06offset\x18\x02 \x02(\r\x12\x14\n\x0ctotal_length\x18\x03 \x02(\r\x12\x0c\n\x04\x64\x61ta\x18\x04 \x02(\x0c*s\n\x1f\x45thereumClearSignDefinitionKind\x12\x14\n\x10\x45RC7730_CALLDATA\x10\x01\x12\x12\n\x0e\x45RC7730_EIP712\x10\x02\x12\x11\n\rERC7730_TOKEN\x10\x03\x12\x13\n\x0f\x45RC7730_NETWORK\x10\x04\x42\x34\n\x1a\x63om.keepkey.deviceprotocolB\x16KeepKeyMessageEthereum') , dependencies=[types__pb2.DESCRIPTOR,]) +_ETHEREUMCLEARSIGNDEFINITIONKIND = _descriptor.EnumDescriptor( + name='EthereumClearSignDefinitionKind', + full_name='EthereumClearSignDefinitionKind', + filename=None, + file=DESCRIPTOR, + values=[ + _descriptor.EnumValueDescriptor( + name='ERC7730_CALLDATA', index=0, number=1, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='ERC7730_EIP712', index=1, number=2, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='ERC7730_TOKEN', index=2, number=3, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='ERC7730_NETWORK', index=3, number=4, + options=None, + type=None), + ], + containing_type=None, + options=None, + serialized_start=2953, + serialized_end=3068, +) +_sym_db.RegisterEnumDescriptor(_ETHEREUMCLEARSIGNDEFINITIONKIND) + +EthereumClearSignDefinitionKind = enum_type_wrapper.EnumTypeWrapper(_ETHEREUMCLEARSIGNDEFINITIONKIND) +ERC7730_CALLDATA = 1 +ERC7730_EIP712 = 2 +ERC7730_TOKEN = 3 +ERC7730_NETWORK = 4 + +_ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMDATATYPE = _descriptor.EnumDescriptor( + name='EthereumDataType', + full_name='EthereumTypedDataStructAck.EthereumDataType', + filename=None, + file=DESCRIPTOR, + values=[ + _descriptor.EnumValueDescriptor( + name='UINT', index=0, number=1, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='INT', index=1, number=2, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='BYTES', index=2, number=3, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='STRING', index=3, number=4, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='BOOL', index=4, number=5, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='ADDRESS', index=5, number=6, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='ARRAY', index=6, number=7, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='STRUCT', index=7, number=8, + options=None, + type=None), + ], + containing_type=None, + options=None, + serialized_start=2116, + serialized_end=2222, +) +_sym_db.RegisterEnumDescriptor(_ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMDATATYPE) _ETHEREUMGETADDRESS = _descriptor.Descriptor( @@ -433,6 +515,79 @@ ) +_LOADCLEARSIGNSIGNER = _descriptor.Descriptor( + name='LoadClearsignSigner', + full_name='LoadClearsignSigner', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='key_id', full_name='LoadClearsignSigner.key_id', index=0, + number=1, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='pubkey', full_name='LoadClearsignSigner.pubkey', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='alias', full_name='LoadClearsignSigner.alias', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='icon', full_name='LoadClearsignSigner.icon', index=3, + number=4, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='icon_width', full_name='LoadClearsignSigner.icon_width', index=4, + number=5, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='icon_height', full_name='LoadClearsignSigner.icon_height', index=5, + number=6, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='persist', full_name='LoadClearsignSigner.persist', index=6, + number=7, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=909, + serialized_end=1049, +) + + _ETHEREUMSIGNMESSAGE = _descriptor.Descriptor( name='EthereumSignMessage', full_name='EthereumSignMessage', @@ -466,8 +621,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=908, - serialized_end=965, + serialized_start=1051, + serialized_end=1108, ) @@ -511,8 +666,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=967, - serialized_end=1043, + serialized_start=1110, + serialized_end=1186, ) @@ -549,8 +704,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1045, - serialized_end=1107, + serialized_start=1188, + serialized_end=1250, ) @@ -594,8 +749,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1109, - serialized_end=1204, + serialized_start=1252, + serialized_end=1347, ) @@ -653,8 +808,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1207, - serialized_end=1346, + serialized_start=1350, + serialized_end=1489, ) @@ -712,11 +867,526 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1349, - serialized_end=1482, + serialized_start=1492, + serialized_end=1625, +) + + +_ETHEREUMSIGNTYPEDDATA = _descriptor.Descriptor( + name='EthereumSignTypedData', + full_name='EthereumSignTypedData', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='address_n', full_name='EthereumSignTypedData.address_n', index=0, + number=1, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='primary_type', full_name='EthereumSignTypedData.primary_type', index=1, + number=2, type=9, cpp_type=9, label=2, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='metamask_v4_compat', full_name='EthereumSignTypedData.metamask_v4_compat', index=2, + number=3, type=8, cpp_type=7, label=1, + has_default_value=True, default_value=True, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1627, + serialized_end=1725, +) + + +_ETHEREUMTYPEDDATASTRUCTREQUEST = _descriptor.Descriptor( + name='EthereumTypedDataStructRequest', + full_name='EthereumTypedDataStructRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='name', full_name='EthereumTypedDataStructRequest.name', index=0, + number=1, type=9, cpp_type=9, label=2, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1727, + serialized_end=1773, +) + + +_ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMSTRUCTMEMBER = _descriptor.Descriptor( + name='EthereumStructMember', + full_name='EthereumTypedDataStructAck.EthereumStructMember', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='type', full_name='EthereumTypedDataStructAck.EthereumStructMember.type', index=0, + number=1, type=11, cpp_type=10, label=2, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='name', full_name='EthereumTypedDataStructAck.EthereumStructMember.name', index=1, + number=2, type=9, cpp_type=9, label=2, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1873, + serialized_end=1970, +) + +_ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMFIELDTYPE = _descriptor.Descriptor( + name='EthereumFieldType', + full_name='EthereumTypedDataStructAck.EthereumFieldType', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='data_type', full_name='EthereumTypedDataStructAck.EthereumFieldType.data_type', index=0, + number=1, type=14, cpp_type=8, label=2, + has_default_value=False, default_value=1, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='size', full_name='EthereumTypedDataStructAck.EthereumFieldType.size', index=1, + number=2, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='struct_name', full_name='EthereumTypedDataStructAck.EthereumFieldType.struct_name', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='array_levels', full_name='EthereumTypedDataStructAck.EthereumFieldType.array_levels', index=3, + number=4, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1973, + serialized_end=2114, +) + +_ETHEREUMTYPEDDATASTRUCTACK = _descriptor.Descriptor( + name='EthereumTypedDataStructAck', + full_name='EthereumTypedDataStructAck', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='members', full_name='EthereumTypedDataStructAck.members', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[_ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMSTRUCTMEMBER, _ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMFIELDTYPE, ], + enum_types=[ + _ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMDATATYPE, + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1776, + serialized_end=2222, +) + + +_ETHEREUMTYPEDDATAVALUEREQUEST = _descriptor.Descriptor( + name='EthereumTypedDataValueRequest', + full_name='EthereumTypedDataValueRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='member_path', full_name='EthereumTypedDataValueRequest.member_path', index=0, + number=1, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='value_offset', full_name='EthereumTypedDataValueRequest.value_offset', index=1, + number=2, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2224, + serialized_end=2298, +) + + +_ETHEREUMTYPEDDATAVALUEACK = _descriptor.Descriptor( + name='EthereumTypedDataValueAck', + full_name='EthereumTypedDataValueAck', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='value', full_name='EthereumTypedDataValueAck.value', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='value_total_length', full_name='EthereumTypedDataValueAck.value_total_length', index=1, + number=2, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='value_offset', full_name='EthereumTypedDataValueAck.value_offset', index=2, + number=3, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2300, + serialized_end=2392, +) + + +_ETHEREUMCLEARSIGNDEFINITION = _descriptor.Descriptor( + name='EthereumClearSignDefinition', + full_name='EthereumClearSignDefinition', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='definition_id', full_name='EthereumClearSignDefinition.definition_id', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='offset', full_name='EthereumClearSignDefinition.offset', index=1, + number=2, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='total_length', full_name='EthereumClearSignDefinition.total_length', index=2, + number=3, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='data', full_name='EthereumClearSignDefinition.data', index=3, + number=4, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2394, + serialized_end=2498, +) + + +_ETHEREUMCLEARSIGNDEFINITIONACK = _descriptor.Descriptor( + name='EthereumClearSignDefinitionAck', + full_name='EthereumClearSignDefinitionAck', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='definition_id', full_name='EthereumClearSignDefinitionAck.definition_id', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='next_offset', full_name='EthereumClearSignDefinitionAck.next_offset', index=1, + number=2, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='complete', full_name='EthereumClearSignDefinitionAck.complete', index=2, + number=3, type=8, cpp_type=7, label=2, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2500, + serialized_end=2594, +) + + +_ETHEREUMCLEARSIGNDEFINITIONREQUEST = _descriptor.Descriptor( + name='EthereumClearSignDefinitionRequest', + full_name='EthereumClearSignDefinitionRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='kind', full_name='EthereumClearSignDefinitionRequest.kind', index=0, + number=1, type=14, cpp_type=8, label=2, + has_default_value=False, default_value=1, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='chain_id', full_name='EthereumClearSignDefinitionRequest.chain_id', index=1, + number=2, type=4, cpp_type=4, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=_descriptor._ParseOptions(descriptor_pb2.FieldOptions(), _b('0\001')), file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='contract_address', full_name='EthereumClearSignDefinitionRequest.contract_address', index=2, + number=3, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='selector_or_type_hash', full_name='EthereumClearSignDefinitionRequest.selector_or_type_hash', index=3, + number=4, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='definition_id', full_name='EthereumClearSignDefinitionRequest.definition_id', index=4, + number=5, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='offset', full_name='EthereumClearSignDefinitionRequest.offset', index=5, + number=6, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='length', full_name='EthereumClearSignDefinitionRequest.length', index=6, + number=7, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='recursion_depth', full_name='EthereumClearSignDefinitionRequest.recursion_depth', index=7, + number=8, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2597, + serialized_end=2840, +) + + +_ETHEREUMCLEARSIGNDEFINITIONCHUNK = _descriptor.Descriptor( + name='EthereumClearSignDefinitionChunk', + full_name='EthereumClearSignDefinitionChunk', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='definition_id', full_name='EthereumClearSignDefinitionChunk.definition_id', index=0, + number=1, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='offset', full_name='EthereumClearSignDefinitionChunk.offset', index=1, + number=2, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='total_length', full_name='EthereumClearSignDefinitionChunk.total_length', index=2, + number=3, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='data', full_name='EthereumClearSignDefinitionChunk.data', index=3, + number=4, type=12, cpp_type=9, label=2, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2842, + serialized_end=2951, ) _ETHEREUMSIGNTX.fields_by_name['address_type'].enum_type = types__pb2._OUTPUTADDRESSTYPE +_ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMSTRUCTMEMBER.fields_by_name['type'].message_type = _ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMFIELDTYPE +_ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMSTRUCTMEMBER.containing_type = _ETHEREUMTYPEDDATASTRUCTACK +_ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMFIELDTYPE.fields_by_name['data_type'].enum_type = _ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMDATATYPE +_ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMFIELDTYPE.containing_type = _ETHEREUMTYPEDDATASTRUCTACK +_ETHEREUMTYPEDDATASTRUCTACK.fields_by_name['members'].message_type = _ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMSTRUCTMEMBER +_ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMDATATYPE.containing_type = _ETHEREUMTYPEDDATASTRUCTACK +_ETHEREUMCLEARSIGNDEFINITIONREQUEST.fields_by_name['kind'].enum_type = _ETHEREUMCLEARSIGNDEFINITIONKIND DESCRIPTOR.message_types_by_name['EthereumGetAddress'] = _ETHEREUMGETADDRESS DESCRIPTOR.message_types_by_name['EthereumAddress'] = _ETHEREUMADDRESS DESCRIPTOR.message_types_by_name['EthereumSignTx'] = _ETHEREUMSIGNTX @@ -724,12 +1394,23 @@ DESCRIPTOR.message_types_by_name['EthereumTxAck'] = _ETHEREUMTXACK DESCRIPTOR.message_types_by_name['EthereumTxMetadata'] = _ETHEREUMTXMETADATA DESCRIPTOR.message_types_by_name['EthereumMetadataAck'] = _ETHEREUMMETADATAACK +DESCRIPTOR.message_types_by_name['LoadClearsignSigner'] = _LOADCLEARSIGNSIGNER DESCRIPTOR.message_types_by_name['EthereumSignMessage'] = _ETHEREUMSIGNMESSAGE DESCRIPTOR.message_types_by_name['EthereumVerifyMessage'] = _ETHEREUMVERIFYMESSAGE DESCRIPTOR.message_types_by_name['EthereumMessageSignature'] = _ETHEREUMMESSAGESIGNATURE DESCRIPTOR.message_types_by_name['EthereumSignTypedHash'] = _ETHEREUMSIGNTYPEDHASH DESCRIPTOR.message_types_by_name['EthereumTypedDataSignature'] = _ETHEREUMTYPEDDATASIGNATURE DESCRIPTOR.message_types_by_name['Ethereum712TypesValues'] = _ETHEREUM712TYPESVALUES +DESCRIPTOR.message_types_by_name['EthereumSignTypedData'] = _ETHEREUMSIGNTYPEDDATA +DESCRIPTOR.message_types_by_name['EthereumTypedDataStructRequest'] = _ETHEREUMTYPEDDATASTRUCTREQUEST +DESCRIPTOR.message_types_by_name['EthereumTypedDataStructAck'] = _ETHEREUMTYPEDDATASTRUCTACK +DESCRIPTOR.message_types_by_name['EthereumTypedDataValueRequest'] = _ETHEREUMTYPEDDATAVALUEREQUEST +DESCRIPTOR.message_types_by_name['EthereumTypedDataValueAck'] = _ETHEREUMTYPEDDATAVALUEACK +DESCRIPTOR.message_types_by_name['EthereumClearSignDefinition'] = _ETHEREUMCLEARSIGNDEFINITION +DESCRIPTOR.message_types_by_name['EthereumClearSignDefinitionAck'] = _ETHEREUMCLEARSIGNDEFINITIONACK +DESCRIPTOR.message_types_by_name['EthereumClearSignDefinitionRequest'] = _ETHEREUMCLEARSIGNDEFINITIONREQUEST +DESCRIPTOR.message_types_by_name['EthereumClearSignDefinitionChunk'] = _ETHEREUMCLEARSIGNDEFINITIONCHUNK +DESCRIPTOR.enum_types_by_name['EthereumClearSignDefinitionKind'] = _ETHEREUMCLEARSIGNDEFINITIONKIND _sym_db.RegisterFileDescriptor(DESCRIPTOR) EthereumGetAddress = _reflection.GeneratedProtocolMessageType('EthereumGetAddress', (_message.Message,), dict( @@ -781,6 +1462,13 @@ )) _sym_db.RegisterMessage(EthereumMetadataAck) +LoadClearsignSigner = _reflection.GeneratedProtocolMessageType('LoadClearsignSigner', (_message.Message,), dict( + DESCRIPTOR = _LOADCLEARSIGNSIGNER, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:LoadClearsignSigner) + )) +_sym_db.RegisterMessage(LoadClearsignSigner) + EthereumSignMessage = _reflection.GeneratedProtocolMessageType('EthereumSignMessage', (_message.Message,), dict( DESCRIPTOR = _ETHEREUMSIGNMESSAGE, __module__ = 'messages_ethereum_pb2' @@ -823,7 +1511,88 @@ )) _sym_db.RegisterMessage(Ethereum712TypesValues) +EthereumSignTypedData = _reflection.GeneratedProtocolMessageType('EthereumSignTypedData', (_message.Message,), dict( + DESCRIPTOR = _ETHEREUMSIGNTYPEDDATA, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumSignTypedData) + )) +_sym_db.RegisterMessage(EthereumSignTypedData) + +EthereumTypedDataStructRequest = _reflection.GeneratedProtocolMessageType('EthereumTypedDataStructRequest', (_message.Message,), dict( + DESCRIPTOR = _ETHEREUMTYPEDDATASTRUCTREQUEST, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumTypedDataStructRequest) + )) +_sym_db.RegisterMessage(EthereumTypedDataStructRequest) + +EthereumTypedDataStructAck = _reflection.GeneratedProtocolMessageType('EthereumTypedDataStructAck', (_message.Message,), dict( + + EthereumStructMember = _reflection.GeneratedProtocolMessageType('EthereumStructMember', (_message.Message,), dict( + DESCRIPTOR = _ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMSTRUCTMEMBER, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumTypedDataStructAck.EthereumStructMember) + )) + , + + EthereumFieldType = _reflection.GeneratedProtocolMessageType('EthereumFieldType', (_message.Message,), dict( + DESCRIPTOR = _ETHEREUMTYPEDDATASTRUCTACK_ETHEREUMFIELDTYPE, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumTypedDataStructAck.EthereumFieldType) + )) + , + DESCRIPTOR = _ETHEREUMTYPEDDATASTRUCTACK, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumTypedDataStructAck) + )) +_sym_db.RegisterMessage(EthereumTypedDataStructAck) +_sym_db.RegisterMessage(EthereumTypedDataStructAck.EthereumStructMember) +_sym_db.RegisterMessage(EthereumTypedDataStructAck.EthereumFieldType) + +EthereumTypedDataValueRequest = _reflection.GeneratedProtocolMessageType('EthereumTypedDataValueRequest', (_message.Message,), dict( + DESCRIPTOR = _ETHEREUMTYPEDDATAVALUEREQUEST, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumTypedDataValueRequest) + )) +_sym_db.RegisterMessage(EthereumTypedDataValueRequest) + +EthereumTypedDataValueAck = _reflection.GeneratedProtocolMessageType('EthereumTypedDataValueAck', (_message.Message,), dict( + DESCRIPTOR = _ETHEREUMTYPEDDATAVALUEACK, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumTypedDataValueAck) + )) +_sym_db.RegisterMessage(EthereumTypedDataValueAck) + +EthereumClearSignDefinition = _reflection.GeneratedProtocolMessageType('EthereumClearSignDefinition', (_message.Message,), dict( + DESCRIPTOR = _ETHEREUMCLEARSIGNDEFINITION, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumClearSignDefinition) + )) +_sym_db.RegisterMessage(EthereumClearSignDefinition) + +EthereumClearSignDefinitionAck = _reflection.GeneratedProtocolMessageType('EthereumClearSignDefinitionAck', (_message.Message,), dict( + DESCRIPTOR = _ETHEREUMCLEARSIGNDEFINITIONACK, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumClearSignDefinitionAck) + )) +_sym_db.RegisterMessage(EthereumClearSignDefinitionAck) + +EthereumClearSignDefinitionRequest = _reflection.GeneratedProtocolMessageType('EthereumClearSignDefinitionRequest', (_message.Message,), dict( + DESCRIPTOR = _ETHEREUMCLEARSIGNDEFINITIONREQUEST, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumClearSignDefinitionRequest) + )) +_sym_db.RegisterMessage(EthereumClearSignDefinitionRequest) + +EthereumClearSignDefinitionChunk = _reflection.GeneratedProtocolMessageType('EthereumClearSignDefinitionChunk', (_message.Message,), dict( + DESCRIPTOR = _ETHEREUMCLEARSIGNDEFINITIONCHUNK, + __module__ = 'messages_ethereum_pb2' + # @@protoc_insertion_point(class_scope:EthereumClearSignDefinitionChunk) + )) +_sym_db.RegisterMessage(EthereumClearSignDefinitionChunk) + DESCRIPTOR.has_options = True DESCRIPTOR._options = _descriptor._ParseOptions(descriptor_pb2.FileOptions(), _b('\n\032com.keepkey.deviceprotocolB\026KeepKeyMessageEthereum')) +_ETHEREUMCLEARSIGNDEFINITIONREQUEST.fields_by_name['chain_id'].has_options = True +_ETHEREUMCLEARSIGNDEFINITIONREQUEST.fields_by_name['chain_id']._options = _descriptor._ParseOptions(descriptor_pb2.FieldOptions(), _b('0\001')) # @@protoc_insertion_point(module_scope) diff --git a/keepkeylib/messages_hive_pb2.py b/keepkeylib/messages_hive_pb2.py new file mode 100644 index 00000000..c83b6460 --- /dev/null +++ b/keepkeylib/messages_hive_pb2.py @@ -0,0 +1,886 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: messages-hive.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='messages-hive.proto', + package='', + syntax='proto2', + serialized_pb=_b('\n\x13messages-hive.proto\"I\n\x10HiveGetPublicKey\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\x12\x0c\n\x04role\x18\x03 \x01(\r\";\n\rHivePublicKey\x12\x12\n\npublic_key\x18\x01 \x01(\t\x12\x16\n\x0eraw_public_key\x18\x02 \x01(\x0c\"C\n\x11HiveGetPublicKeys\x12\x18\n\raccount_index\x18\x01 \x01(\r:\x01\x30\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\"^\n\x0eHivePublicKeys\x12\x11\n\towner_key\x18\x01 \x01(\t\x12\x12\n\nactive_key\x18\x02 \x01(\t\x12\x10\n\x08memo_key\x18\x03 \x01(\t\x12\x13\n\x0bposting_key\x18\x04 \x01(\t\"\xd6\x01\n\nHiveSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0c\n\x04\x66rom\x18\x06 \x01(\t\x12\n\n\x02to\x18\x07 \x01(\t\x12\x0e\n\x06\x61mount\x18\x08 \x01(\x04\x12\x10\n\x08\x64\x65\x63imals\x18\t \x01(\r\x12\x14\n\x0c\x61sset_symbol\x18\n \x01(\t\x12\x0c\n\x04memo\x18\x0b \x01(\t\"8\n\x0cHiveSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"\x8e\x02\n\x15HiveSignAccountCreate\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0f\n\x07\x63reator\x18\x06 \x01(\t\x12\x18\n\x10new_account_name\x18\x07 \x01(\t\x12\x11\n\towner_key\x18\x08 \x01(\t\x12\x12\n\nactive_key\x18\t \x01(\t\x12\x13\n\x0bposting_key\x18\n \x01(\t\x12\x10\n\x08memo_key\x18\x0b \x01(\t\x12\x12\n\nfee_amount\x18\x0c \x01(\x04\"C\n\x17HiveSignedAccountCreate\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"\xf0\x01\n\x15HiveSignAccountUpdate\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0f\n\x07\x61\x63\x63ount\x18\x06 \x01(\t\x12\x15\n\rnew_owner_key\x18\x07 \x01(\t\x12\x16\n\x0enew_active_key\x18\x08 \x01(\t\x12\x17\n\x0fnew_posting_key\x18\t \x01(\t\x12\x14\n\x0cnew_memo_key\x18\n \x01(\t\"C\n\x17HiveSignedAccountUpdate\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"5\n\x0fHiveSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x01(\x0c\":\n\x11HiveSignedMessage\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x12\n\npublic_key\x18\x02 \x01(\x0c\"P\n\x12HiveSignOperations\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x03 \x01(\x0c\")\n\x14HiveSignedOperations\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x42\x39\n#com.shapeshift.keepkey.lib.protobufB\x12KeepKeyMessageHive') +) + + + + +_HIVEGETPUBLICKEY = _descriptor.Descriptor( + name='HiveGetPublicKey', + full_name='HiveGetPublicKey', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='address_n', full_name='HiveGetPublicKey.address_n', index=0, + number=1, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='show_display', full_name='HiveGetPublicKey.show_display', index=1, + number=2, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='role', full_name='HiveGetPublicKey.role', index=2, + number=3, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=23, + serialized_end=96, +) + + +_HIVEPUBLICKEY = _descriptor.Descriptor( + name='HivePublicKey', + full_name='HivePublicKey', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='public_key', full_name='HivePublicKey.public_key', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='raw_public_key', full_name='HivePublicKey.raw_public_key', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=98, + serialized_end=157, +) + + +_HIVEGETPUBLICKEYS = _descriptor.Descriptor( + name='HiveGetPublicKeys', + full_name='HiveGetPublicKeys', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='account_index', full_name='HiveGetPublicKeys.account_index', index=0, + number=1, type=13, cpp_type=3, label=1, + has_default_value=True, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='show_display', full_name='HiveGetPublicKeys.show_display', index=1, + number=2, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=159, + serialized_end=226, +) + + +_HIVEPUBLICKEYS = _descriptor.Descriptor( + name='HivePublicKeys', + full_name='HivePublicKeys', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='owner_key', full_name='HivePublicKeys.owner_key', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='active_key', full_name='HivePublicKeys.active_key', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='memo_key', full_name='HivePublicKeys.memo_key', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='posting_key', full_name='HivePublicKeys.posting_key', index=3, + number=4, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=228, + serialized_end=322, +) + + +_HIVESIGNTX = _descriptor.Descriptor( + name='HiveSignTx', + full_name='HiveSignTx', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='address_n', full_name='HiveSignTx.address_n', index=0, + number=1, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='chain_id', full_name='HiveSignTx.chain_id', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='ref_block_num', full_name='HiveSignTx.ref_block_num', index=2, + number=3, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='ref_block_prefix', full_name='HiveSignTx.ref_block_prefix', index=3, + number=4, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='expiration', full_name='HiveSignTx.expiration', index=4, + number=5, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='from', full_name='HiveSignTx.from', index=5, + number=6, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='to', full_name='HiveSignTx.to', index=6, + number=7, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='amount', full_name='HiveSignTx.amount', index=7, + number=8, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='decimals', full_name='HiveSignTx.decimals', index=8, + number=9, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='asset_symbol', full_name='HiveSignTx.asset_symbol', index=9, + number=10, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='memo', full_name='HiveSignTx.memo', index=10, + number=11, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=325, + serialized_end=539, +) + + +_HIVESIGNEDTX = _descriptor.Descriptor( + name='HiveSignedTx', + full_name='HiveSignedTx', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='signature', full_name='HiveSignedTx.signature', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='serialized_tx', full_name='HiveSignedTx.serialized_tx', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=541, + serialized_end=597, +) + + +_HIVESIGNACCOUNTCREATE = _descriptor.Descriptor( + name='HiveSignAccountCreate', + full_name='HiveSignAccountCreate', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='address_n', full_name='HiveSignAccountCreate.address_n', index=0, + number=1, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='chain_id', full_name='HiveSignAccountCreate.chain_id', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='ref_block_num', full_name='HiveSignAccountCreate.ref_block_num', index=2, + number=3, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='ref_block_prefix', full_name='HiveSignAccountCreate.ref_block_prefix', index=3, + number=4, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='expiration', full_name='HiveSignAccountCreate.expiration', index=4, + number=5, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='creator', full_name='HiveSignAccountCreate.creator', index=5, + number=6, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='new_account_name', full_name='HiveSignAccountCreate.new_account_name', index=6, + number=7, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='owner_key', full_name='HiveSignAccountCreate.owner_key', index=7, + number=8, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='active_key', full_name='HiveSignAccountCreate.active_key', index=8, + number=9, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='posting_key', full_name='HiveSignAccountCreate.posting_key', index=9, + number=10, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='memo_key', full_name='HiveSignAccountCreate.memo_key', index=10, + number=11, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='fee_amount', full_name='HiveSignAccountCreate.fee_amount', index=11, + number=12, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=600, + serialized_end=870, +) + + +_HIVESIGNEDACCOUNTCREATE = _descriptor.Descriptor( + name='HiveSignedAccountCreate', + full_name='HiveSignedAccountCreate', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='signature', full_name='HiveSignedAccountCreate.signature', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='serialized_tx', full_name='HiveSignedAccountCreate.serialized_tx', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=872, + serialized_end=939, +) + + +_HIVESIGNACCOUNTUPDATE = _descriptor.Descriptor( + name='HiveSignAccountUpdate', + full_name='HiveSignAccountUpdate', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='address_n', full_name='HiveSignAccountUpdate.address_n', index=0, + number=1, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='chain_id', full_name='HiveSignAccountUpdate.chain_id', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='ref_block_num', full_name='HiveSignAccountUpdate.ref_block_num', index=2, + number=3, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='ref_block_prefix', full_name='HiveSignAccountUpdate.ref_block_prefix', index=3, + number=4, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='expiration', full_name='HiveSignAccountUpdate.expiration', index=4, + number=5, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='account', full_name='HiveSignAccountUpdate.account', index=5, + number=6, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='new_owner_key', full_name='HiveSignAccountUpdate.new_owner_key', index=6, + number=7, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='new_active_key', full_name='HiveSignAccountUpdate.new_active_key', index=7, + number=8, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='new_posting_key', full_name='HiveSignAccountUpdate.new_posting_key', index=8, + number=9, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='new_memo_key', full_name='HiveSignAccountUpdate.new_memo_key', index=9, + number=10, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=942, + serialized_end=1182, +) + + +_HIVESIGNEDACCOUNTUPDATE = _descriptor.Descriptor( + name='HiveSignedAccountUpdate', + full_name='HiveSignedAccountUpdate', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='signature', full_name='HiveSignedAccountUpdate.signature', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='serialized_tx', full_name='HiveSignedAccountUpdate.serialized_tx', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1184, + serialized_end=1251, +) + + +_HIVESIGNMESSAGE = _descriptor.Descriptor( + name='HiveSignMessage', + full_name='HiveSignMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='address_n', full_name='HiveSignMessage.address_n', index=0, + number=1, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='message', full_name='HiveSignMessage.message', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1253, + serialized_end=1306, +) + + +_HIVESIGNEDMESSAGE = _descriptor.Descriptor( + name='HiveSignedMessage', + full_name='HiveSignedMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='signature', full_name='HiveSignedMessage.signature', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='public_key', full_name='HiveSignedMessage.public_key', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1308, + serialized_end=1366, +) + + +_HIVESIGNOPERATIONS = _descriptor.Descriptor( + name='HiveSignOperations', + full_name='HiveSignOperations', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='address_n', full_name='HiveSignOperations.address_n', index=0, + number=1, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='chain_id', full_name='HiveSignOperations.chain_id', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='serialized_tx', full_name='HiveSignOperations.serialized_tx', index=2, + number=3, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1368, + serialized_end=1448, +) + + +_HIVESIGNEDOPERATIONS = _descriptor.Descriptor( + name='HiveSignedOperations', + full_name='HiveSignedOperations', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='signature', full_name='HiveSignedOperations.signature', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1450, + serialized_end=1491, +) + +DESCRIPTOR.message_types_by_name['HiveGetPublicKey'] = _HIVEGETPUBLICKEY +DESCRIPTOR.message_types_by_name['HivePublicKey'] = _HIVEPUBLICKEY +DESCRIPTOR.message_types_by_name['HiveGetPublicKeys'] = _HIVEGETPUBLICKEYS +DESCRIPTOR.message_types_by_name['HivePublicKeys'] = _HIVEPUBLICKEYS +DESCRIPTOR.message_types_by_name['HiveSignTx'] = _HIVESIGNTX +DESCRIPTOR.message_types_by_name['HiveSignedTx'] = _HIVESIGNEDTX +DESCRIPTOR.message_types_by_name['HiveSignAccountCreate'] = _HIVESIGNACCOUNTCREATE +DESCRIPTOR.message_types_by_name['HiveSignedAccountCreate'] = _HIVESIGNEDACCOUNTCREATE +DESCRIPTOR.message_types_by_name['HiveSignAccountUpdate'] = _HIVESIGNACCOUNTUPDATE +DESCRIPTOR.message_types_by_name['HiveSignedAccountUpdate'] = _HIVESIGNEDACCOUNTUPDATE +DESCRIPTOR.message_types_by_name['HiveSignMessage'] = _HIVESIGNMESSAGE +DESCRIPTOR.message_types_by_name['HiveSignedMessage'] = _HIVESIGNEDMESSAGE +DESCRIPTOR.message_types_by_name['HiveSignOperations'] = _HIVESIGNOPERATIONS +DESCRIPTOR.message_types_by_name['HiveSignedOperations'] = _HIVESIGNEDOPERATIONS +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +HiveGetPublicKey = _reflection.GeneratedProtocolMessageType('HiveGetPublicKey', (_message.Message,), dict( + DESCRIPTOR = _HIVEGETPUBLICKEY, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveGetPublicKey) + )) +_sym_db.RegisterMessage(HiveGetPublicKey) + +HivePublicKey = _reflection.GeneratedProtocolMessageType('HivePublicKey', (_message.Message,), dict( + DESCRIPTOR = _HIVEPUBLICKEY, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HivePublicKey) + )) +_sym_db.RegisterMessage(HivePublicKey) + +HiveGetPublicKeys = _reflection.GeneratedProtocolMessageType('HiveGetPublicKeys', (_message.Message,), dict( + DESCRIPTOR = _HIVEGETPUBLICKEYS, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveGetPublicKeys) + )) +_sym_db.RegisterMessage(HiveGetPublicKeys) + +HivePublicKeys = _reflection.GeneratedProtocolMessageType('HivePublicKeys', (_message.Message,), dict( + DESCRIPTOR = _HIVEPUBLICKEYS, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HivePublicKeys) + )) +_sym_db.RegisterMessage(HivePublicKeys) + +HiveSignTx = _reflection.GeneratedProtocolMessageType('HiveSignTx', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNTX, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignTx) + )) +_sym_db.RegisterMessage(HiveSignTx) + +HiveSignedTx = _reflection.GeneratedProtocolMessageType('HiveSignedTx', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNEDTX, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignedTx) + )) +_sym_db.RegisterMessage(HiveSignedTx) + +HiveSignAccountCreate = _reflection.GeneratedProtocolMessageType('HiveSignAccountCreate', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNACCOUNTCREATE, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignAccountCreate) + )) +_sym_db.RegisterMessage(HiveSignAccountCreate) + +HiveSignedAccountCreate = _reflection.GeneratedProtocolMessageType('HiveSignedAccountCreate', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNEDACCOUNTCREATE, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignedAccountCreate) + )) +_sym_db.RegisterMessage(HiveSignedAccountCreate) + +HiveSignAccountUpdate = _reflection.GeneratedProtocolMessageType('HiveSignAccountUpdate', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNACCOUNTUPDATE, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignAccountUpdate) + )) +_sym_db.RegisterMessage(HiveSignAccountUpdate) + +HiveSignedAccountUpdate = _reflection.GeneratedProtocolMessageType('HiveSignedAccountUpdate', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNEDACCOUNTUPDATE, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignedAccountUpdate) + )) +_sym_db.RegisterMessage(HiveSignedAccountUpdate) + +HiveSignMessage = _reflection.GeneratedProtocolMessageType('HiveSignMessage', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNMESSAGE, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignMessage) + )) +_sym_db.RegisterMessage(HiveSignMessage) + +HiveSignedMessage = _reflection.GeneratedProtocolMessageType('HiveSignedMessage', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNEDMESSAGE, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignedMessage) + )) +_sym_db.RegisterMessage(HiveSignedMessage) + +HiveSignOperations = _reflection.GeneratedProtocolMessageType('HiveSignOperations', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNOPERATIONS, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignOperations) + )) +_sym_db.RegisterMessage(HiveSignOperations) + +HiveSignedOperations = _reflection.GeneratedProtocolMessageType('HiveSignedOperations', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNEDOPERATIONS, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignedOperations) + )) +_sym_db.RegisterMessage(HiveSignedOperations) + + +DESCRIPTOR.has_options = True +DESCRIPTOR._options = _descriptor._ParseOptions(descriptor_pb2.FileOptions(), _b('\n#com.shapeshift.keepkey.lib.protobufB\022KeepKeyMessageHive')) +# @@protoc_insertion_point(module_scope) diff --git a/keepkeylib/messages_pb2.py b/keepkeylib/messages_pb2.py index a79606fc..1de82e9d 100644 --- a/keepkeylib/messages_pb2.py +++ b/keepkeylib/messages_pb2.py @@ -21,7 +21,7 @@ name='messages.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x0emessages.proto\x1a\x0btypes.proto\"\x0c\n\nInitialize\"\r\n\x0bGetFeatures\"\xaa\x04\n\x08\x46\x65\x61tures\x12\x0e\n\x06vendor\x18\x01 \x01(\t\x12\x15\n\rmajor_version\x18\x02 \x01(\r\x12\x15\n\rminor_version\x18\x03 \x01(\r\x12\x15\n\rpatch_version\x18\x04 \x01(\r\x12\x17\n\x0f\x62ootloader_mode\x18\x05 \x01(\x08\x12\x11\n\tdevice_id\x18\x06 \x01(\t\x12\x16\n\x0epin_protection\x18\x07 \x01(\x08\x12\x1d\n\x15passphrase_protection\x18\x08 \x01(\x08\x12\x10\n\x08language\x18\t \x01(\t\x12\r\n\x05label\x18\n \x01(\t\x12\x18\n\x05\x63oins\x18\x0b \x03(\x0b\x32\t.CoinType\x12\x13\n\x0binitialized\x18\x0c \x01(\x08\x12\x10\n\x08revision\x18\r \x01(\x0c\x12\x17\n\x0f\x62ootloader_hash\x18\x0e \x01(\x0c\x12\x10\n\x08imported\x18\x0f \x01(\x08\x12\x12\n\npin_cached\x18\x10 \x01(\x08\x12\x19\n\x11passphrase_cached\x18\x11 \x01(\x08\x12\x1d\n\x08policies\x18\x12 \x03(\x0b\x32\x0b.PolicyType\x12\r\n\x05model\x18\x15 \x01(\t\x12\x18\n\x10\x66irmware_variant\x18\x16 \x01(\t\x12\x15\n\rfirmware_hash\x18\x17 \x01(\x0c\x12\x11\n\tno_backup\x18\x18 \x01(\x08\x12\x1c\n\x14wipe_code_protection\x18\x19 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x1a \x01(\r\"*\n\x0cGetCoinTable\x12\r\n\x05start\x18\x01 \x01(\r\x12\x0b\n\x03\x65nd\x18\x02 \x01(\r\"L\n\tCoinTable\x12\x18\n\x05table\x18\x01 \x03(\x0b\x32\t.CoinType\x12\x11\n\tnum_coins\x18\x02 \x01(\r\x12\x12\n\nchunk_size\x18\x03 \x01(\r\"\x0e\n\x0c\x43learSession\"y\n\rApplySettings\x12\x10\n\x08language\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\x16\n\x0euse_passphrase\x18\x03 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x04 \x01(\r\x12\x13\n\x0bu2f_counter\x18\x05 \x01(\r\"\x1b\n\tChangePin\x12\x0e\n\x06remove\x18\x01 \x01(\x08\"\x87\x01\n\x04Ping\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x19\n\x11\x62utton_protection\x18\x02 \x01(\x08\x12\x16\n\x0epin_protection\x18\x03 \x01(\x08\x12\x1d\n\x15passphrase_protection\x18\x04 \x01(\x08\x12\x1c\n\x14wipe_code_protection\x18\x05 \x01(\x08\"\x1a\n\x07Success\x12\x0f\n\x07message\x18\x01 \x01(\t\"6\n\x07\x46\x61ilure\x12\x1a\n\x04\x63ode\x18\x01 \x01(\x0e\x32\x0c.FailureType\x12\x0f\n\x07message\x18\x02 \x01(\t\"?\n\rButtonRequest\x12 \n\x04\x63ode\x18\x01 \x01(\x0e\x32\x12.ButtonRequestType\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\t\"\x0b\n\tButtonAck\"7\n\x10PinMatrixRequest\x12#\n\x04type\x18\x01 \x01(\x0e\x32\x15.PinMatrixRequestType\"\x1b\n\x0cPinMatrixAck\x12\x0b\n\x03pin\x18\x01 \x02(\t\"\x08\n\x06\x43\x61ncel\"\x13\n\x11PassphraseRequest\"#\n\rPassphraseAck\x12\x12\n\npassphrase\x18\x01 \x02(\t\"\x1a\n\nGetEntropy\x12\x0c\n\x04size\x18\x01 \x02(\r\"\x1a\n\x07\x45ntropy\x12\x0f\n\x07\x65ntropy\x18\x01 \x02(\x0c\"\xa2\x01\n\x0cGetPublicKey\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x18\n\x10\x65\x63\x64sa_curve_name\x18\x02 \x01(\t\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\x12\x1a\n\tcoin_name\x18\x04 \x01(\t:\x07\x42itcoin\x12\x33\n\x0bscript_type\x18\x05 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"4\n\tPublicKey\x12\x19\n\x04node\x18\x01 \x02(\x0b\x32\x0b.HDNodeType\x12\x0c\n\x04xpub\x18\x02 \x01(\t\"\xb3\x01\n\nGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x1a\n\tcoin_name\x18\x02 \x01(\t:\x07\x42itcoin\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\x12+\n\x08multisig\x18\x04 \x01(\x0b\x32\x19.MultisigRedeemScriptType\x12\x33\n\x0bscript_type\x18\x05 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"\x1a\n\x07\x41\x64\x64ress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x02(\t\"\x0c\n\nWipeDevice\"\xbb\x01\n\nLoadDevice\x12\x10\n\x08mnemonic\x18\x01 \x01(\t\x12\x19\n\x04node\x18\x02 \x01(\x0b\x32\x0b.HDNodeType\x12\x0b\n\x03pin\x18\x03 \x01(\t\x12\x1d\n\x15passphrase_protection\x18\x04 \x01(\x08\x12\x19\n\x08language\x18\x05 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x06 \x01(\t\x12\x15\n\rskip_checksum\x18\x07 \x01(\x08\x12\x13\n\x0bu2f_counter\x18\x08 \x01(\r\"\xe1\x01\n\x0bResetDevice\x12\x16\n\x0e\x64isplay_random\x18\x01 \x01(\x08\x12\x15\n\x08strength\x18\x02 \x01(\r:\x03\x32\x35\x36\x12\x1d\n\x15passphrase_protection\x18\x03 \x01(\x08\x12\x16\n\x0epin_protection\x18\x04 \x01(\x08\x12\x19\n\x08language\x18\x05 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x06 \x01(\t\x12\x11\n\tno_backup\x18\x07 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x08 \x01(\r\x12\x13\n\x0bu2f_counter\x18\t \x01(\r\"\x10\n\x0e\x45ntropyRequest\"\x1d\n\nEntropyAck\x12\x0f\n\x07\x65ntropy\x18\x01 \x01(\x0c\"\xff\x01\n\x0eRecoveryDevice\x12\x12\n\nword_count\x18\x01 \x01(\r\x12\x1d\n\x15passphrase_protection\x18\x02 \x01(\x08\x12\x16\n\x0epin_protection\x18\x03 \x01(\x08\x12\x19\n\x08language\x18\x04 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x05 \x01(\t\x12\x18\n\x10\x65nforce_wordlist\x18\x06 \x01(\x08\x12\x1c\n\x14use_character_cipher\x18\x07 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x08 \x01(\r\x12\x13\n\x0bu2f_counter\x18\t \x01(\r\x12\x0f\n\x07\x64ry_run\x18\n \x01(\x08\"\r\n\x0bWordRequest\"\x17\n\x07WordAck\x12\x0c\n\x04word\x18\x01 \x02(\t\";\n\x10\x43haracterRequest\x12\x10\n\x08word_pos\x18\x01 \x02(\r\x12\x15\n\rcharacter_pos\x18\x02 \x02(\r\"?\n\x0c\x43haracterAck\x12\x11\n\tcharacter\x18\x01 \x01(\t\x12\x0e\n\x06\x64\x65lete\x18\x02 \x01(\x08\x12\x0c\n\x04\x64one\x18\x03 \x01(\x08\"\x82\x01\n\x0bSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x02(\x0c\x12\x1a\n\tcoin_name\x18\x03 \x01(\t:\x07\x42itcoin\x12\x33\n\x0bscript_type\x18\x04 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"`\n\rVerifyMessage\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x1a\n\tcoin_name\x18\x04 \x01(\t:\x07\x42itcoin\"6\n\x10MessageSignature\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"v\n\x0e\x45ncryptMessage\x12\x0e\n\x06pubkey\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\x0c\x12\x14\n\x0c\x64isplay_only\x18\x03 \x01(\x08\x12\x11\n\taddress_n\x18\x04 \x03(\r\x12\x1a\n\tcoin_name\x18\x05 \x01(\t:\x07\x42itcoin\"@\n\x10\x45ncryptedMessage\x12\r\n\x05nonce\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\x0c\x12\x0c\n\x04hmac\x18\x03 \x01(\x0c\"Q\n\x0e\x44\x65\x63ryptMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\r\n\x05nonce\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x0c\n\x04hmac\x18\x04 \x01(\x0c\"4\n\x10\x44\x65\x63ryptedMessage\x12\x0f\n\x07message\x18\x01 \x01(\x0c\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x01(\t\"\x8c\x01\n\x0e\x43ipherKeyValue\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0b\n\x03key\x18\x02 \x01(\t\x12\r\n\x05value\x18\x03 \x01(\x0c\x12\x0f\n\x07\x65ncrypt\x18\x04 \x01(\x08\x12\x16\n\x0e\x61sk_on_encrypt\x18\x05 \x01(\x08\x12\x16\n\x0e\x61sk_on_decrypt\x18\x06 \x01(\x08\x12\n\n\x02iv\x18\x07 \x01(\x0c\"!\n\x10\x43ipheredKeyValue\x12\r\n\x05value\x18\x01 \x01(\x0c\"5\n\x10GetBip85Mnemonic\x12\x12\n\nword_count\x18\x01 \x02(\r\x12\r\n\x05index\x18\x02 \x02(\r\"!\n\rBip85Mnemonic\x12\x10\n\x08mnemonic\x18\x01 \x02(\t\"\xce\x01\n\x06SignTx\x12\x15\n\routputs_count\x18\x01 \x02(\r\x12\x14\n\x0cinputs_count\x18\x02 \x02(\r\x12\x1a\n\tcoin_name\x18\x03 \x01(\t:\x07\x42itcoin\x12\x12\n\x07version\x18\x04 \x01(\r:\x01\x31\x12\x14\n\tlock_time\x18\x05 \x01(\r:\x01\x30\x12\x0e\n\x06\x65xpiry\x18\x06 \x01(\r\x12\x14\n\x0coverwintered\x18\x07 \x01(\x08\x12\x18\n\x10version_group_id\x18\x08 \x01(\r\x12\x11\n\tbranch_id\x18\n \x01(\r\"\x85\x01\n\tTxRequest\x12\"\n\x0crequest_type\x18\x01 \x01(\x0e\x32\x0c.RequestType\x12&\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32\x15.TxRequestDetailsType\x12,\n\nserialized\x18\x03 \x01(\x0b\x32\x18.TxRequestSerializedType\"%\n\x05TxAck\x12\x1c\n\x02tx\x18\x01 \x01(\x0b\x32\x10.TransactionType\"+\n\x08RawTxAck\x12\x1f\n\x02tx\x18\x01 \x01(\x0b\x32\x13.RawTransactionType\"}\n\x0cSignIdentity\x12\x1f\n\x08identity\x18\x01 \x01(\x0b\x32\r.IdentityType\x12\x18\n\x10\x63hallenge_hidden\x18\x02 \x01(\x0c\x12\x18\n\x10\x63hallenge_visual\x18\x03 \x01(\t\x12\x18\n\x10\x65\x63\x64sa_curve_name\x18\x04 \x01(\t\"H\n\x0eSignedIdentity\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x12\n\npublic_key\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\",\n\rApplyPolicies\x12\x1b\n\x06policy\x18\x01 \x03(\x0b\x32\x0b.PolicyType\"?\n\tFlashHash\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0e\n\x06length\x18\x02 \x01(\r\x12\x11\n\tchallenge\x18\x03 \x01(\x0c\":\n\nFlashWrite\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\r\n\x05\x65rase\x18\x03 \x01(\x08\"!\n\x11\x46lashHashResponse\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"5\n\x12\x44\x65\x62ugLinkFlashDump\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0e\n\x06length\x18\x02 \x01(\r\"*\n\x1a\x44\x65\x62ugLinkFlashDumpResponse\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"\x0b\n\tSoftReset\"\x0f\n\rFirmwareErase\"7\n\x0e\x46irmwareUpload\x12\x14\n\x0cpayload_hash\x18\x01 \x02(\x0c\x12\x0f\n\x07payload\x18\x02 \x02(\x0c\"#\n\x11\x44\x65\x62ugLinkDecision\x12\x0e\n\x06yes_no\x18\x01 \x02(\x08\"\x13\n\x11\x44\x65\x62ugLinkGetState\"\xd7\x02\n\x0e\x44\x65\x62ugLinkState\x12\x0e\n\x06layout\x18\x01 \x01(\x0c\x12\x0b\n\x03pin\x18\x02 \x01(\t\x12\x0e\n\x06matrix\x18\x03 \x01(\t\x12\x10\n\x08mnemonic\x18\x04 \x01(\t\x12\x19\n\x04node\x18\x05 \x01(\x0b\x32\x0b.HDNodeType\x12\x1d\n\x15passphrase_protection\x18\x06 \x01(\x08\x12\x12\n\nreset_word\x18\x07 \x01(\t\x12\x15\n\rreset_entropy\x18\x08 \x01(\x0c\x12\x1a\n\x12recovery_fake_word\x18\t \x01(\t\x12\x19\n\x11recovery_word_pos\x18\n \x01(\r\x12\x17\n\x0frecovery_cipher\x18\x0b \x01(\t\x12$\n\x1crecovery_auto_completed_word\x18\x0c \x01(\t\x12\x15\n\rfirmware_hash\x18\r \x01(\x0c\x12\x14\n\x0cstorage_hash\x18\x0e \x01(\x0c\"\x0f\n\rDebugLinkStop\";\n\x0c\x44\x65\x62ugLinkLog\x12\r\n\x05level\x18\x01 \x01(\r\x12\x0e\n\x06\x62ucket\x18\x02 \x01(\t\x12\x0c\n\x04text\x18\x03 \x01(\t\"\x15\n\x13\x44\x65\x62ugLinkFillConfig\" \n\x0e\x43hangeWipeCode\x12\x0e\n\x06remove\x18\x01 \x01(\x08*\xcb\x36\n\x0bMessageType\x12 \n\x16MessageType_Initialize\x10\x00\x1a\x04\x90\xb5\x18\x01\x12\x1a\n\x10MessageType_Ping\x10\x01\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Success\x10\x02\x1a\x04\x98\xb5\x18\x01\x12\x1d\n\x13MessageType_Failure\x10\x03\x1a\x04\x98\xb5\x18\x01\x12\x1f\n\x15MessageType_ChangePin\x10\x04\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_WipeDevice\x10\x05\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_FirmwareErase\x10\x06\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_FirmwareUpload\x10\x07\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_GetEntropy\x10\t\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Entropy\x10\n\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_GetPublicKey\x10\x0b\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_PublicKey\x10\x0c\x1a\x04\x98\xb5\x18\x01\x12 \n\x16MessageType_LoadDevice\x10\r\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_ResetDevice\x10\x0e\x1a\x04\x90\xb5\x18\x01\x12\x1c\n\x12MessageType_SignTx\x10\x0f\x1a\x04\x90\xb5\x18\x01\x12\x1e\n\x14MessageType_Features\x10\x11\x1a\x04\x98\xb5\x18\x01\x12&\n\x1cMessageType_PinMatrixRequest\x10\x12\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_PinMatrixAck\x10\x13\x1a\x04\x90\xb5\x18\x01\x12\x1c\n\x12MessageType_Cancel\x10\x14\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_TxRequest\x10\x15\x1a\x04\x98\xb5\x18\x01\x12\x1b\n\x11MessageType_TxAck\x10\x16\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_CipherKeyValue\x10\x17\x1a\x04\x90\xb5\x18\x01\x12\"\n\x18MessageType_ClearSession\x10\x18\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ApplySettings\x10\x19\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ButtonRequest\x10\x1a\x1a\x04\x98\xb5\x18\x01\x12\x1f\n\x15MessageType_ButtonAck\x10\x1b\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_GetAddress\x10\x1d\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Address\x10\x1e\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EntropyRequest\x10#\x1a\x04\x98\xb5\x18\x01\x12 \n\x16MessageType_EntropyAck\x10$\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_SignMessage\x10&\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_VerifyMessage\x10\'\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_MessageSignature\x10(\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1dMessageType_PassphraseRequest\x10)\x1a\x04\x98\xb5\x18\x01\x12#\n\x19MessageType_PassphraseAck\x10*\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_RecoveryDevice\x10-\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_WordRequest\x10.\x1a\x04\x98\xb5\x18\x01\x12\x1d\n\x13MessageType_WordAck\x10/\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_CipheredKeyValue\x10\x30\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EncryptMessage\x10\x31\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_EncryptedMessage\x10\x32\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_DecryptMessage\x10\x33\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_DecryptedMessage\x10\x34\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_SignIdentity\x10\x35\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_SignedIdentity\x10\x36\x1a\x04\x98\xb5\x18\x01\x12!\n\x17MessageType_GetFeatures\x10\x37\x1a\x04\x90\xb5\x18\x01\x12(\n\x1eMessageType_EthereumGetAddress\x10\x38\x1a\x04\x90\xb5\x18\x01\x12%\n\x1bMessageType_EthereumAddress\x10\x39\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EthereumSignTx\x10:\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1dMessageType_EthereumTxRequest\x10;\x1a\x04\x98\xb5\x18\x01\x12#\n\x19MessageType_EthereumTxAck\x10<\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_CharacterRequest\x10P\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_CharacterAck\x10Q\x1a\x04\x90\xb5\x18\x01\x12\x1e\n\x14MessageType_RawTxAck\x10R\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ApplyPolicies\x10S\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_FlashHash\x10T\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_FlashWrite\x10U\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1dMessageType_FlashHashResponse\x10V\x1a\x04\x98\xb5\x18\x01\x12(\n\x1eMessageType_DebugLinkFlashDump\x10W\x1a\x04\xa0\xb5\x18\x01\x12\x30\n&MessageType_DebugLinkFlashDumpResponse\x10X\x1a\x04\xa8\xb5\x18\x01\x12\x1f\n\x15MessageType_SoftReset\x10Y\x1a\x04\xa0\xb5\x18\x01\x12\'\n\x1dMessageType_DebugLinkDecision\x10\x64\x1a\x04\xa0\xb5\x18\x01\x12\'\n\x1dMessageType_DebugLinkGetState\x10\x65\x1a\x04\xa0\xb5\x18\x01\x12$\n\x1aMessageType_DebugLinkState\x10\x66\x1a\x04\xa8\xb5\x18\x01\x12#\n\x19MessageType_DebugLinkStop\x10g\x1a\x04\xa0\xb5\x18\x01\x12\"\n\x18MessageType_DebugLinkLog\x10h\x1a\x04\xa8\xb5\x18\x01\x12)\n\x1fMessageType_DebugLinkFillConfig\x10i\x1a\x04\xa8\xb5\x18\x01\x12\"\n\x18MessageType_GetCoinTable\x10j\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_CoinTable\x10k\x1a\x04\x98\xb5\x18\x01\x12)\n\x1fMessageType_EthereumSignMessage\x10l\x1a\x04\x90\xb5\x18\x01\x12+\n!MessageType_EthereumVerifyMessage\x10m\x1a\x04\x90\xb5\x18\x01\x12.\n$MessageType_EthereumMessageSignature\x10n\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_ChangeWipeCode\x10o\x1a\x04\x90\xb5\x18\x01\x12+\n!MessageType_EthereumSignTypedHash\x10p\x1a\x04\x90\xb5\x18\x01\x12\x30\n&MessageType_EthereumTypedDataSignature\x10q\x1a\x04\x98\xb5\x18\x01\x12,\n\"MessageType_Ethereum712TypesValues\x10r\x1a\x04\x90\xb5\x18\x01\x12(\n\x1eMessageType_EthereumTxMetadata\x10s\x1a\x04\x90\xb5\x18\x01\x12)\n\x1fMessageType_EthereumMetadataAck\x10t\x1a\x04\x98\xb5\x18\x01\x12&\n\x1cMessageType_GetBip85Mnemonic\x10x\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_Bip85Mnemonic\x10y\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_RippleGetAddress\x10\x90\x03\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_RippleAddress\x10\x91\x03\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_RippleSignTx\x10\x92\x03\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_RippleSignedTx\x10\x93\x03\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ThorchainGetAddress\x10\xf4\x03\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_ThorchainAddress\x10\xf5\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ThorchainSignTx\x10\xf6\x03\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ThorchainMsgRequest\x10\xf7\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ThorchainMsgAck\x10\xf8\x03\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_ThorchainSignedTx\x10\xf9\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_EosGetPublicKey\x10\xd8\x04\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_EosPublicKey\x10\xd9\x04\x1a\x04\x98\xb5\x18\x01\x12 \n\x15MessageType_EosSignTx\x10\xda\x04\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_EosTxActionRequest\x10\xdb\x04\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_EosTxActionAck\x10\xdc\x04\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_EosSignedTx\x10\xdd\x04\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_NanoGetAddress\x10\xbc\x05\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_NanoAddress\x10\xbd\x05\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_NanoSignTx\x10\xbe\x05\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_NanoSignedTx\x10\xbf\x05\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_SolanaGetAddress\x10\xee\x05\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_SolanaAddress\x10\xef\x05\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_SolanaSignTx\x10\xf0\x05\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_SolanaSignedTx\x10\xf1\x05\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_SolanaSignMessage\x10\xf2\x05\x1a\x04\x90\xb5\x18\x01\x12-\n\"MessageType_SolanaMessageSignature\x10\xf3\x05\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_BinanceGetAddress\x10\xa0\x06\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_BinanceAddress\x10\xa1\x06\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_BinanceGetPublicKey\x10\xa2\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinancePublicKey\x10\xa3\x06\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_BinanceSignTx\x10\xa4\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinanceTxRequest\x10\xa5\x06\x1a\x04\x98\xb5\x18\x01\x12)\n\x1eMessageType_BinanceTransferMsg\x10\xa6\x06\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_BinanceOrderMsg\x10\xa7\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinanceCancelMsg\x10\xa8\x06\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_BinanceSignedTx\x10\xa9\x06\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosGetAddress\x10\x84\x07\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_CosmosAddress\x10\x85\x07\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_CosmosSignTx\x10\x86\x07\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosMsgRequest\x10\x87\x07\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_CosmosMsgAck\x10\x88\x07\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_CosmosSignedTx\x10\x89\x07\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_CosmosMsgDelegate\x10\x8a\x07\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_CosmosMsgUndelegate\x10\x8b\x07\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_CosmosMsgRedelegate\x10\x8c\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosMsgRewards\x10\x8d\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_CosmosMsgIBCTransfer\x10\x8e\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_TendermintGetAddress\x10\xe8\x07\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TendermintAddress\x10\xe9\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_TendermintSignTx\x10\xea\x07\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_TendermintMsgRequest\x10\xeb\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_TendermintMsgAck\x10\xec\x07\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TendermintMsgSend\x10\xed\x07\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_TendermintSignedTx\x10\xee\x07\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_TendermintMsgDelegate\x10\xef\x07\x1a\x04\x98\xb5\x18\x01\x12.\n#MessageType_TendermintMsgUndelegate\x10\xf0\x07\x1a\x04\x98\xb5\x18\x01\x12.\n#MessageType_TendermintMsgRedelegate\x10\xf1\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_TendermintMsgRewards\x10\xf2\x07\x1a\x04\x98\xb5\x18\x01\x12/\n$MessageType_TendermintMsgIBCTransfer\x10\xf3\x07\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisGetAddress\x10\xcc\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisAddress\x10\xcd\x08\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_OsmosisSignTx\x10\xce\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgRequest\x10\xcf\x08\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_OsmosisMsgAck\x10\xd0\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisMsgSend\x10\xd1\x08\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_OsmosisMsgDelegate\x10\xd2\x08\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_OsmosisMsgUndelegate\x10\xd3\x08\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_OsmosisMsgRedelegate\x10\xd4\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgRewards\x10\xd5\x08\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_OsmosisMsgLPAdd\x10\xd6\x08\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_OsmosisMsgLPRemove\x10\xd7\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgLPStake\x10\xd8\x08\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_OsmosisMsgLPUnstake\x10\xd9\x08\x1a\x04\x90\xb5\x18\x01\x12,\n!MessageType_OsmosisMsgIBCTransfer\x10\xda\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisMsgSwap\x10\xdb\x08\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_OsmosisSignedTx\x10\xdc\x08\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_MayachainGetAddress\x10\xb0\t\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_MayachainAddress\x10\xb1\t\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_MayachainSignTx\x10\xb2\t\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_MayachainMsgRequest\x10\xb3\t\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_MayachainMsgAck\x10\xb4\t\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_MayachainSignedTx\x10\xb5\t\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_ZcashSignPCZT\x10\x94\n\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_ZcashPCZTAction\x10\x95\n\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_ZcashPCZTActionAck\x10\x96\n\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ZcashSignedPCZT\x10\x97\n\x1a\x04\x98\xb5\x18\x01\x12)\n\x1eMessageType_ZcashGetOrchardFVK\x10\x98\n\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_ZcashOrchardFVK\x10\x99\n\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_ZcashTransparentInput\x10\x9a\n\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ZcashTransparentSig\x10\x9b\n\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_TronGetAddress\x10\xf8\n\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_TronAddress\x10\xf9\n\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_TronSignTx\x10\xfa\n\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_TronSignedTx\x10\xfb\n\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_TonGetAddress\x10\xdc\x0b\x1a\x04\x90\xb5\x18\x01\x12!\n\x16MessageType_TonAddress\x10\xdd\x0b\x1a\x04\x98\xb5\x18\x01\x12 \n\x15MessageType_TonSignTx\x10\xde\x0b\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_TonSignedTx\x10\xdf\x0b\x1a\x04\x98\xb5\x18\x01\x42,\n\x1a\x63om.keepkey.deviceprotocolB\x0eKeepKeyMessage') + serialized_pb=_b('\n\x0emessages.proto\x1a\x0btypes.proto\"\x0c\n\nInitialize\"\r\n\x0bGetFeatures\"\xdc\r\n\x08\x46\x65\x61tures\x12\x0e\n\x06vendor\x18\x01 \x01(\t\x12\x15\n\rmajor_version\x18\x02 \x01(\r\x12\x15\n\rminor_version\x18\x03 \x01(\r\x12\x15\n\rpatch_version\x18\x04 \x01(\r\x12\x17\n\x0f\x62ootloader_mode\x18\x05 \x01(\x08\x12\x11\n\tdevice_id\x18\x06 \x01(\t\x12\x16\n\x0epin_protection\x18\x07 \x01(\x08\x12\x1d\n\x15passphrase_protection\x18\x08 \x01(\x08\x12\x10\n\x08language\x18\t \x01(\t\x12\r\n\x05label\x18\n \x01(\t\x12\x18\n\x05\x63oins\x18\x0b \x03(\x0b\x32\t.CoinType\x12\x13\n\x0binitialized\x18\x0c \x01(\x08\x12\x10\n\x08revision\x18\r \x01(\x0c\x12\x17\n\x0f\x62ootloader_hash\x18\x0e \x01(\x0c\x12\x10\n\x08imported\x18\x0f \x01(\x08\x12\x12\n\npin_cached\x18\x10 \x01(\x08\x12\x19\n\x11passphrase_cached\x18\x11 \x01(\x08\x12\x1d\n\x08policies\x18\x12 \x03(\x0b\x32\x0b.PolicyType\x12\r\n\x05model\x18\x15 \x01(\t\x12\x18\n\x10\x66irmware_variant\x18\x16 \x01(\t\x12\x15\n\rfirmware_hash\x18\x17 \x01(\x0c\x12\x11\n\tno_backup\x18\x18 \x01(\x08\x12\x1c\n\x14wipe_code_protection\x18\x19 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x1a \x01(\r\x12\x18\n\x10supports_taproot\x18\x1b \x01(\x08\x12\x1b\n\x13supports_dice_modes\x18\x1c \x01(\x08\x12\'\n\x1fsupports_solana_lut_attestation\x18\x1d \x01(\x08\x12.\n\x0c\x63\x61pabilities\x18\x1e \x03(\x0e\x32\x14.Features.CapabilityB\x02\x10\x01\"\x9f\x08\n\nCapability\x12\x1a\n\x16\x43\x41PABILITY_UNSPECIFIED\x10\x00\x12#\n\x1f\x43\x41PABILITY_ENTROPY_AUDIT_BUDGET\x10\x01\x12-\n)CAPABILITY_ERC20_UNLIMITED_APPROVE_REVIEW\x10\x02\x12,\n(CAPABILITY_ERC20_UNLIMITED_PERMIT_REVIEW\x10\x03\x12%\n!CAPABILITY_ERC7730_RUNTIME_REVIEW\x10\x04\x12#\n\x1f\x43\x41PABILITY_EVM_CERTIFIED_INTENT\x10\x05\x12$\n CAPABILITY_EVM_MAX_AMOUNT_REVIEW\x10\x06\x12\x1e\n\x1a\x43\x41PABILITY_EVM_TX_METADATA\x10\x07\x12\'\n#CAPABILITY_EVM_UNKNOWN_TOKEN_REVIEW\x10\x08\x12\"\n\x1e\x43\x41PABILITY_HIVE_RELEASE_REVIEW\x10\t\x12(\n$CAPABILITY_LEGACY_EVM_ROUTER_SIGNING\x10\n\x12\"\n\x1e\x43\x41PABILITY_MAYA_SINGLE_MESSAGE\x10\x0b\x12\"\n\x1e\x43\x41PABILITY_OSMOSIS_WIRE_GUARDS\x10\x0c\x12\x1d\n\x19\x43\x41PABILITY_PERMIT2_REVIEW\x10\r\x12%\n!CAPABILITY_PROMPT_WORKFLOW_UNWIND\x10\x0e\x12&\n\"CAPABILITY_PROTECTED_PING_PRESENCE\x10\x0f\x12!\n\x1d\x43\x41PABILITY_RIPPLE_MEMO_POLICY\x10\x10\x12%\n!CAPABILITY_SESSION_TRUST_LIFETIME\x10\x11\x12&\n\"CAPABILITY_SOLANA_CERTIFIED_REVIEW\x10\x12\x12%\n!CAPABILITY_SOLANA_LUT_ATTESTATION\x10\x13\x12$\n CAPABILITY_SOLANA_RUNTIME_REVIEW\x10\x14\x12\x1e\n\x1a\x43\x41PABILITY_STORAGE_V19_KDF\x10\x15\x12\"\n\x1e\x43\x41PABILITY_TENDERMINT_PROGRESS\x10\x16\x12\"\n\x1e\x43\x41PABILITY_SAFE_RESET_CEREMONY\x10\x17\x12\"\n\x1e\x43\x41PABILITY_THOR_DEPOSIT_REVIEW\x10\x18\x12 \n\x1c\x43\x41PABILITY_TRON_TRC20_REVIEW\x10\x19\x12$\n CAPABILITY_EIP712_CHUNKED_VALUES\x10\x1a\x12!\n\x1d\x43\x41PABILITY_CERTIFIED_CONTACTS\x10\x1b\"*\n\x0cGetCoinTable\x12\r\n\x05start\x18\x01 \x01(\r\x12\x0b\n\x03\x65nd\x18\x02 \x01(\r\"L\n\tCoinTable\x12\x18\n\x05table\x18\x01 \x03(\x0b\x32\t.CoinType\x12\x11\n\tnum_coins\x18\x02 \x01(\r\x12\x12\n\nchunk_size\x18\x03 \x01(\r\"\x0e\n\x0c\x43learSession\"y\n\rApplySettings\x12\x10\n\x08language\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\x16\n\x0euse_passphrase\x18\x03 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x04 \x01(\r\x12\x13\n\x0bu2f_counter\x18\x05 \x01(\r\"\x1b\n\tChangePin\x12\x0e\n\x06remove\x18\x01 \x01(\x08\"\x87\x01\n\x04Ping\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x19\n\x11\x62utton_protection\x18\x02 \x01(\x08\x12\x16\n\x0epin_protection\x18\x03 \x01(\x08\x12\x1d\n\x15passphrase_protection\x18\x04 \x01(\x08\x12\x1c\n\x14wipe_code_protection\x18\x05 \x01(\x08\"\x1a\n\x07Success\x12\x0f\n\x07message\x18\x01 \x01(\t\"6\n\x07\x46\x61ilure\x12\x1a\n\x04\x63ode\x18\x01 \x01(\x0e\x32\x0c.FailureType\x12\x0f\n\x07message\x18\x02 \x01(\t\"?\n\rButtonRequest\x12 \n\x04\x63ode\x18\x01 \x01(\x0e\x32\x12.ButtonRequestType\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\t\"\x0b\n\tButtonAck\"7\n\x10PinMatrixRequest\x12#\n\x04type\x18\x01 \x01(\x0e\x32\x15.PinMatrixRequestType\"\x1b\n\x0cPinMatrixAck\x12\x0b\n\x03pin\x18\x01 \x02(\t\"\x08\n\x06\x43\x61ncel\"\x13\n\x11PassphraseRequest\"#\n\rPassphraseAck\x12\x12\n\npassphrase\x18\x01 \x02(\t\"\x1a\n\nGetEntropy\x12\x0c\n\x04size\x18\x01 \x02(\r\"\x1a\n\x07\x45ntropy\x12\x0f\n\x07\x65ntropy\x18\x01 \x02(\x0c\"\xa2\x01\n\x0cGetPublicKey\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x18\n\x10\x65\x63\x64sa_curve_name\x18\x02 \x01(\t\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\x12\x1a\n\tcoin_name\x18\x04 \x01(\t:\x07\x42itcoin\x12\x33\n\x0bscript_type\x18\x05 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"4\n\tPublicKey\x12\x19\n\x04node\x18\x01 \x02(\x0b\x32\x0b.HDNodeType\x12\x0c\n\x04xpub\x18\x02 \x01(\t\"\xb3\x01\n\nGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x1a\n\tcoin_name\x18\x02 \x01(\t:\x07\x42itcoin\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\x12+\n\x08multisig\x18\x04 \x01(\x0b\x32\x19.MultisigRedeemScriptType\x12\x33\n\x0bscript_type\x18\x05 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"\x1a\n\x07\x41\x64\x64ress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x02(\t\"\x0c\n\nWipeDevice\"\xbb\x01\n\nLoadDevice\x12\x10\n\x08mnemonic\x18\x01 \x01(\t\x12\x19\n\x04node\x18\x02 \x01(\x0b\x32\x0b.HDNodeType\x12\x0b\n\x03pin\x18\x03 \x01(\t\x12\x1d\n\x15passphrase_protection\x18\x04 \x01(\x08\x12\x19\n\x08language\x18\x05 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x06 \x01(\t\x12\x15\n\rskip_checksum\x18\x07 \x01(\x08\x12\x13\n\x0bu2f_counter\x18\x08 \x01(\r\"\x8a\x02\n\x0bResetDevice\x12\x16\n\x0e\x64isplay_random\x18\x01 \x01(\x08\x12\x15\n\x08strength\x18\x02 \x01(\r:\x03\x32\x35\x36\x12\x1d\n\x15passphrase_protection\x18\x03 \x01(\x08\x12\x16\n\x0epin_protection\x18\x04 \x01(\x08\x12\x19\n\x08language\x18\x05 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x06 \x01(\t\x12\x11\n\tno_backup\x18\x07 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x08 \x01(\r\x12\x13\n\x0bu2f_counter\x18\t \x01(\r\x12\x14\n\x0c\x64ice_entropy\x18\n \x01(\x08\x12\x11\n\tdice_only\x18\x0b \x01(\x08\"\x10\n\x0e\x45ntropyRequest\"\x1d\n\nEntropyAck\x12\x0f\n\x07\x65ntropy\x18\x01 \x01(\x0c\"\xff\x01\n\x0eRecoveryDevice\x12\x12\n\nword_count\x18\x01 \x01(\r\x12\x1d\n\x15passphrase_protection\x18\x02 \x01(\x08\x12\x16\n\x0epin_protection\x18\x03 \x01(\x08\x12\x19\n\x08language\x18\x04 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x05 \x01(\t\x12\x18\n\x10\x65nforce_wordlist\x18\x06 \x01(\x08\x12\x1c\n\x14use_character_cipher\x18\x07 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x08 \x01(\r\x12\x13\n\x0bu2f_counter\x18\t \x01(\r\x12\x0f\n\x07\x64ry_run\x18\n \x01(\x08\"\r\n\x0bWordRequest\"\x17\n\x07WordAck\x12\x0c\n\x04word\x18\x01 \x02(\t\";\n\x10\x43haracterRequest\x12\x10\n\x08word_pos\x18\x01 \x02(\r\x12\x15\n\rcharacter_pos\x18\x02 \x02(\r\"?\n\x0c\x43haracterAck\x12\x11\n\tcharacter\x18\x01 \x01(\t\x12\x0e\n\x06\x64\x65lete\x18\x02 \x01(\x08\x12\x0c\n\x04\x64one\x18\x03 \x01(\x08\"\x82\x01\n\x0bSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x02(\x0c\x12\x1a\n\tcoin_name\x18\x03 \x01(\t:\x07\x42itcoin\x12\x33\n\x0bscript_type\x18\x04 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"`\n\rVerifyMessage\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x1a\n\tcoin_name\x18\x04 \x01(\t:\x07\x42itcoin\"6\n\x10MessageSignature\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"v\n\x0e\x45ncryptMessage\x12\x0e\n\x06pubkey\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\x0c\x12\x14\n\x0c\x64isplay_only\x18\x03 \x01(\x08\x12\x11\n\taddress_n\x18\x04 \x03(\r\x12\x1a\n\tcoin_name\x18\x05 \x01(\t:\x07\x42itcoin\"@\n\x10\x45ncryptedMessage\x12\r\n\x05nonce\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\x0c\x12\x0c\n\x04hmac\x18\x03 \x01(\x0c\"Q\n\x0e\x44\x65\x63ryptMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\r\n\x05nonce\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x0c\n\x04hmac\x18\x04 \x01(\x0c\"4\n\x10\x44\x65\x63ryptedMessage\x12\x0f\n\x07message\x18\x01 \x01(\x0c\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x01(\t\"\x8c\x01\n\x0e\x43ipherKeyValue\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0b\n\x03key\x18\x02 \x01(\t\x12\r\n\x05value\x18\x03 \x01(\x0c\x12\x0f\n\x07\x65ncrypt\x18\x04 \x01(\x08\x12\x16\n\x0e\x61sk_on_encrypt\x18\x05 \x01(\x08\x12\x16\n\x0e\x61sk_on_decrypt\x18\x06 \x01(\x08\x12\n\n\x02iv\x18\x07 \x01(\x0c\"!\n\x10\x43ipheredKeyValue\x12\r\n\x05value\x18\x01 \x01(\x0c\"5\n\x10GetBip85Mnemonic\x12\x12\n\nword_count\x18\x01 \x02(\r\x12\r\n\x05index\x18\x02 \x02(\r\"!\n\rBip85Mnemonic\x12\x10\n\x08mnemonic\x18\x01 \x02(\t\"\xce\x01\n\x06SignTx\x12\x15\n\routputs_count\x18\x01 \x02(\r\x12\x14\n\x0cinputs_count\x18\x02 \x02(\r\x12\x1a\n\tcoin_name\x18\x03 \x01(\t:\x07\x42itcoin\x12\x12\n\x07version\x18\x04 \x01(\r:\x01\x31\x12\x14\n\tlock_time\x18\x05 \x01(\r:\x01\x30\x12\x0e\n\x06\x65xpiry\x18\x06 \x01(\r\x12\x14\n\x0coverwintered\x18\x07 \x01(\x08\x12\x18\n\x10version_group_id\x18\x08 \x01(\r\x12\x11\n\tbranch_id\x18\n \x01(\r\"\x85\x01\n\tTxRequest\x12\"\n\x0crequest_type\x18\x01 \x01(\x0e\x32\x0c.RequestType\x12&\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32\x15.TxRequestDetailsType\x12,\n\nserialized\x18\x03 \x01(\x0b\x32\x18.TxRequestSerializedType\"%\n\x05TxAck\x12\x1c\n\x02tx\x18\x01 \x01(\x0b\x32\x10.TransactionType\"+\n\x08RawTxAck\x12\x1f\n\x02tx\x18\x01 \x01(\x0b\x32\x13.RawTransactionType\"}\n\x0cSignIdentity\x12\x1f\n\x08identity\x18\x01 \x01(\x0b\x32\r.IdentityType\x12\x18\n\x10\x63hallenge_hidden\x18\x02 \x01(\x0c\x12\x18\n\x10\x63hallenge_visual\x18\x03 \x01(\t\x12\x18\n\x10\x65\x63\x64sa_curve_name\x18\x04 \x01(\t\"H\n\x0eSignedIdentity\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x12\n\npublic_key\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\",\n\rApplyPolicies\x12\x1b\n\x06policy\x18\x01 \x03(\x0b\x32\x0b.PolicyType\"?\n\tFlashHash\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0e\n\x06length\x18\x02 \x01(\r\x12\x11\n\tchallenge\x18\x03 \x01(\x0c\":\n\nFlashWrite\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\r\n\x05\x65rase\x18\x03 \x01(\x08\"!\n\x11\x46lashHashResponse\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"5\n\x12\x44\x65\x62ugLinkFlashDump\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0e\n\x06length\x18\x02 \x01(\r\"*\n\x1a\x44\x65\x62ugLinkFlashDumpResponse\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"\x0b\n\tSoftReset\"\x0f\n\rFirmwareErase\"7\n\x0e\x46irmwareUpload\x12\x14\n\x0cpayload_hash\x18\x01 \x02(\x0c\x12\x0f\n\x07payload\x18\x02 \x02(\x0c\"2\n\x11\x44\x65\x62ugLinkDecision\x12\x0e\n\x06yes_no\x18\x01 \x02(\x08\x12\r\n\x05input\x18\x02 \x01(\t\"\x13\n\x11\x44\x65\x62ugLinkGetState\"\x99\x03\n\x0e\x44\x65\x62ugLinkState\x12\x0e\n\x06layout\x18\x01 \x01(\x0c\x12\x0b\n\x03pin\x18\x02 \x01(\t\x12\x0e\n\x06matrix\x18\x03 \x01(\t\x12\x10\n\x08mnemonic\x18\x04 \x01(\t\x12\x19\n\x04node\x18\x05 \x01(\x0b\x32\x0b.HDNodeType\x12\x1d\n\x15passphrase_protection\x18\x06 \x01(\x08\x12\x12\n\nreset_word\x18\x07 \x01(\t\x12\x15\n\rreset_entropy\x18\x08 \x01(\x0c\x12\x1a\n\x12recovery_fake_word\x18\t \x01(\t\x12\x19\n\x11recovery_word_pos\x18\n \x01(\r\x12\x17\n\x0frecovery_cipher\x18\x0b \x01(\t\x12$\n\x1crecovery_auto_completed_word\x18\x0c \x01(\t\x12\x15\n\rfirmware_hash\x18\r \x01(\x0c\x12\x14\n\x0cstorage_hash\x18\x0e \x01(\x0c\x12\x13\n\x0b\x64ice_digest\x18\x0f \x01(\x0c\x12\x15\n\rconfirm_title\x18\x10 \x01(\t\x12\x14\n\x0c\x63onfirm_body\x18\x11 \x01(\t\"\x0f\n\rDebugLinkStop\";\n\x0c\x44\x65\x62ugLinkLog\x12\r\n\x05level\x18\x01 \x01(\r\x12\x0e\n\x06\x62ucket\x18\x02 \x01(\t\x12\x0c\n\x04text\x18\x03 \x01(\t\"\x15\n\x13\x44\x65\x62ugLinkFillConfig\" \n\x0e\x43hangeWipeCode\x12\x0e\n\x06remove\x18\x01 \x01(\x08\"\x1f\n\x1d\x43learsignAttestorGetPublicKey\"0\n\x1a\x43learsignAttestorPublicKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\"(\n\x15\x43learsignAttestorSign\x12\x0f\n\x07payload\x18\x01 \x01(\x0c\"C\n\x1a\x43learsignAttestorSignature\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x12\n\npublic_key\x18\x02 \x01(\x0c*\xc3\x46\n\x0bMessageType\x12 \n\x16MessageType_Initialize\x10\x00\x1a\x04\x90\xb5\x18\x01\x12\x1a\n\x10MessageType_Ping\x10\x01\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Success\x10\x02\x1a\x04\x98\xb5\x18\x01\x12\x1d\n\x13MessageType_Failure\x10\x03\x1a\x04\x98\xb5\x18\x01\x12\x1f\n\x15MessageType_ChangePin\x10\x04\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_WipeDevice\x10\x05\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_FirmwareErase\x10\x06\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_FirmwareUpload\x10\x07\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_GetEntropy\x10\t\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Entropy\x10\n\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_GetPublicKey\x10\x0b\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_PublicKey\x10\x0c\x1a\x04\x98\xb5\x18\x01\x12 \n\x16MessageType_LoadDevice\x10\r\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_ResetDevice\x10\x0e\x1a\x04\x90\xb5\x18\x01\x12\x1c\n\x12MessageType_SignTx\x10\x0f\x1a\x04\x90\xb5\x18\x01\x12\x1e\n\x14MessageType_Features\x10\x11\x1a\x04\x98\xb5\x18\x01\x12&\n\x1cMessageType_PinMatrixRequest\x10\x12\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_PinMatrixAck\x10\x13\x1a\x04\x90\xb5\x18\x01\x12\x1c\n\x12MessageType_Cancel\x10\x14\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_TxRequest\x10\x15\x1a\x04\x98\xb5\x18\x01\x12\x1b\n\x11MessageType_TxAck\x10\x16\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_CipherKeyValue\x10\x17\x1a\x04\x90\xb5\x18\x01\x12\"\n\x18MessageType_ClearSession\x10\x18\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ApplySettings\x10\x19\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ButtonRequest\x10\x1a\x1a\x04\x98\xb5\x18\x01\x12\x1f\n\x15MessageType_ButtonAck\x10\x1b\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_GetAddress\x10\x1d\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Address\x10\x1e\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EntropyRequest\x10#\x1a\x04\x98\xb5\x18\x01\x12 \n\x16MessageType_EntropyAck\x10$\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_SignMessage\x10&\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_VerifyMessage\x10\'\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_MessageSignature\x10(\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1dMessageType_PassphraseRequest\x10)\x1a\x04\x98\xb5\x18\x01\x12#\n\x19MessageType_PassphraseAck\x10*\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_RecoveryDevice\x10-\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_WordRequest\x10.\x1a\x04\x98\xb5\x18\x01\x12\x1d\n\x13MessageType_WordAck\x10/\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_CipheredKeyValue\x10\x30\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EncryptMessage\x10\x31\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_EncryptedMessage\x10\x32\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_DecryptMessage\x10\x33\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_DecryptedMessage\x10\x34\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_SignIdentity\x10\x35\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_SignedIdentity\x10\x36\x1a\x04\x98\xb5\x18\x01\x12!\n\x17MessageType_GetFeatures\x10\x37\x1a\x04\x90\xb5\x18\x01\x12(\n\x1eMessageType_EthereumGetAddress\x10\x38\x1a\x04\x90\xb5\x18\x01\x12%\n\x1bMessageType_EthereumAddress\x10\x39\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EthereumSignTx\x10:\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1dMessageType_EthereumTxRequest\x10;\x1a\x04\x98\xb5\x18\x01\x12#\n\x19MessageType_EthereumTxAck\x10<\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_CharacterRequest\x10P\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_CharacterAck\x10Q\x1a\x04\x90\xb5\x18\x01\x12\x1e\n\x14MessageType_RawTxAck\x10R\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ApplyPolicies\x10S\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_FlashHash\x10T\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_FlashWrite\x10U\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1dMessageType_FlashHashResponse\x10V\x1a\x04\x98\xb5\x18\x01\x12(\n\x1eMessageType_DebugLinkFlashDump\x10W\x1a\x04\xa0\xb5\x18\x01\x12\x30\n&MessageType_DebugLinkFlashDumpResponse\x10X\x1a\x04\xa8\xb5\x18\x01\x12\x1f\n\x15MessageType_SoftReset\x10Y\x1a\x04\xa0\xb5\x18\x01\x12\'\n\x1dMessageType_DebugLinkDecision\x10\x64\x1a\x04\xa0\xb5\x18\x01\x12\'\n\x1dMessageType_DebugLinkGetState\x10\x65\x1a\x04\xa0\xb5\x18\x01\x12$\n\x1aMessageType_DebugLinkState\x10\x66\x1a\x04\xa8\xb5\x18\x01\x12#\n\x19MessageType_DebugLinkStop\x10g\x1a\x04\xa0\xb5\x18\x01\x12\"\n\x18MessageType_DebugLinkLog\x10h\x1a\x04\xa8\xb5\x18\x01\x12)\n\x1fMessageType_DebugLinkFillConfig\x10i\x1a\x04\xa8\xb5\x18\x01\x12\"\n\x18MessageType_GetCoinTable\x10j\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_CoinTable\x10k\x1a\x04\x98\xb5\x18\x01\x12)\n\x1fMessageType_EthereumSignMessage\x10l\x1a\x04\x90\xb5\x18\x01\x12+\n!MessageType_EthereumVerifyMessage\x10m\x1a\x04\x90\xb5\x18\x01\x12.\n$MessageType_EthereumMessageSignature\x10n\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_ChangeWipeCode\x10o\x1a\x04\x90\xb5\x18\x01\x12+\n!MessageType_EthereumSignTypedHash\x10p\x1a\x04\x90\xb5\x18\x01\x12\x30\n&MessageType_EthereumTypedDataSignature\x10q\x1a\x04\x98\xb5\x18\x01\x12,\n\"MessageType_Ethereum712TypesValues\x10r\x1a\x04\x90\xb5\x18\x01\x12(\n\x1eMessageType_EthereumTxMetadata\x10s\x1a\x04\x90\xb5\x18\x01\x12)\n\x1fMessageType_EthereumMetadataAck\x10t\x1a\x04\x98\xb5\x18\x01\x12)\n\x1fMessageType_LoadClearsignSigner\x10u\x1a\x04\x90\xb5\x18\x01\x12,\n!MessageType_EthereumSignTypedData\x10\xa8\r\x1a\x04\x90\xb5\x18\x01\x12\x35\n*MessageType_EthereumTypedDataStructRequest\x10\xa9\r\x1a\x04\x98\xb5\x18\x01\x12\x31\n&MessageType_EthereumTypedDataStructAck\x10\xaa\r\x1a\x04\x90\xb5\x18\x01\x12\x34\n)MessageType_EthereumTypedDataValueRequest\x10\xab\r\x1a\x04\x98\xb5\x18\x01\x12\x30\n%MessageType_EthereumTypedDataValueAck\x10\xac\r\x1a\x04\x90\xb5\x18\x01\x12\x32\n\'MessageType_EthereumClearSignDefinition\x10\xad\r\x1a\x04\x90\xb5\x18\x01\x12\x35\n*MessageType_EthereumClearSignDefinitionAck\x10\xae\r\x1a\x04\x98\xb5\x18\x01\x12\x39\n.MessageType_EthereumClearSignDefinitionRequest\x10\xaf\r\x1a\x04\x98\xb5\x18\x01\x12\x37\n,MessageType_EthereumClearSignDefinitionChunk\x10\xb0\r\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_GetBip85Mnemonic\x10x\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_Bip85Mnemonic\x10y\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_RippleGetAddress\x10\x90\x03\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_RippleAddress\x10\x91\x03\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_RippleSignTx\x10\x92\x03\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_RippleSignedTx\x10\x93\x03\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ThorchainGetAddress\x10\xf4\x03\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_ThorchainAddress\x10\xf5\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ThorchainSignTx\x10\xf6\x03\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ThorchainMsgRequest\x10\xf7\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ThorchainMsgAck\x10\xf8\x03\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_ThorchainSignedTx\x10\xf9\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_EosGetPublicKey\x10\xd8\x04\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_EosPublicKey\x10\xd9\x04\x1a\x04\x98\xb5\x18\x01\x12 \n\x15MessageType_EosSignTx\x10\xda\x04\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_EosTxActionRequest\x10\xdb\x04\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_EosTxActionAck\x10\xdc\x04\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_EosSignedTx\x10\xdd\x04\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_NanoGetAddress\x10\xbc\x05\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_NanoAddress\x10\xbd\x05\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_NanoSignTx\x10\xbe\x05\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_NanoSignedTx\x10\xbf\x05\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_SolanaGetAddress\x10\xee\x05\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_SolanaAddress\x10\xef\x05\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_SolanaSignTx\x10\xf0\x05\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_SolanaSignedTx\x10\xf1\x05\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_SolanaSignMessage\x10\xf2\x05\x1a\x04\x90\xb5\x18\x01\x12-\n\"MessageType_SolanaMessageSignature\x10\xf3\x05\x1a\x04\x98\xb5\x18\x01\x12\x30\n%MessageType_SolanaSignOffchainMessage\x10\xf4\x05\x1a\x04\x90\xb5\x18\x01\x12\x35\n*MessageType_SolanaOffchainMessageSignature\x10\xf5\x05\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_BinanceGetAddress\x10\xa0\x06\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_BinanceAddress\x10\xa1\x06\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_BinanceGetPublicKey\x10\xa2\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinancePublicKey\x10\xa3\x06\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_BinanceSignTx\x10\xa4\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinanceTxRequest\x10\xa5\x06\x1a\x04\x98\xb5\x18\x01\x12)\n\x1eMessageType_BinanceTransferMsg\x10\xa6\x06\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_BinanceOrderMsg\x10\xa7\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinanceCancelMsg\x10\xa8\x06\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_BinanceSignedTx\x10\xa9\x06\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosGetAddress\x10\x84\x07\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_CosmosAddress\x10\x85\x07\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_CosmosSignTx\x10\x86\x07\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosMsgRequest\x10\x87\x07\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_CosmosMsgAck\x10\x88\x07\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_CosmosSignedTx\x10\x89\x07\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_CosmosMsgDelegate\x10\x8a\x07\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_CosmosMsgUndelegate\x10\x8b\x07\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_CosmosMsgRedelegate\x10\x8c\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosMsgRewards\x10\x8d\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_CosmosMsgIBCTransfer\x10\x8e\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_TendermintGetAddress\x10\xe8\x07\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TendermintAddress\x10\xe9\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_TendermintSignTx\x10\xea\x07\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_TendermintMsgRequest\x10\xeb\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_TendermintMsgAck\x10\xec\x07\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TendermintMsgSend\x10\xed\x07\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_TendermintSignedTx\x10\xee\x07\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_TendermintMsgDelegate\x10\xef\x07\x1a\x04\x98\xb5\x18\x01\x12.\n#MessageType_TendermintMsgUndelegate\x10\xf0\x07\x1a\x04\x98\xb5\x18\x01\x12.\n#MessageType_TendermintMsgRedelegate\x10\xf1\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_TendermintMsgRewards\x10\xf2\x07\x1a\x04\x98\xb5\x18\x01\x12/\n$MessageType_TendermintMsgIBCTransfer\x10\xf3\x07\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisGetAddress\x10\xcc\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisAddress\x10\xcd\x08\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_OsmosisSignTx\x10\xce\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgRequest\x10\xcf\x08\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_OsmosisMsgAck\x10\xd0\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisMsgSend\x10\xd1\x08\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_OsmosisMsgDelegate\x10\xd2\x08\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_OsmosisMsgUndelegate\x10\xd3\x08\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_OsmosisMsgRedelegate\x10\xd4\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgRewards\x10\xd5\x08\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_OsmosisMsgLPAdd\x10\xd6\x08\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_OsmosisMsgLPRemove\x10\xd7\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgLPStake\x10\xd8\x08\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_OsmosisMsgLPUnstake\x10\xd9\x08\x1a\x04\x90\xb5\x18\x01\x12,\n!MessageType_OsmosisMsgIBCTransfer\x10\xda\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisMsgSwap\x10\xdb\x08\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_OsmosisSignedTx\x10\xdc\x08\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_MayachainGetAddress\x10\xb0\t\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_MayachainAddress\x10\xb1\t\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_MayachainSignTx\x10\xb2\t\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_MayachainMsgRequest\x10\xb3\t\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_MayachainMsgAck\x10\xb4\t\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_MayachainSignedTx\x10\xb5\t\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_ZcashSignPCZT\x10\x94\n\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_ZcashPCZTAction\x10\x95\n\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_ZcashPCZTActionAck\x10\x96\n\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ZcashSignedPCZT\x10\x97\n\x1a\x04\x98\xb5\x18\x01\x12)\n\x1eMessageType_ZcashGetOrchardFVK\x10\x98\n\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_ZcashOrchardFVK\x10\x99\n\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_ZcashTransparentInput\x10\x9a\n\x1a\x04\x90\xb5\x18\x01\x12-\n\"MessageType_ZcashTransparentSigned\x10\x9b\n\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_ZcashDisplayAddress\x10\x9c\n\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_ZcashAddress\x10\x9d\n\x1a\x04\x98\xb5\x18\x01\x12-\n\"MessageType_ZcashTransparentOutput\x10\x9e\n\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ZcashTransparentAck\x10\x9f\n\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_TronGetAddress\x10\xf8\n\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_TronAddress\x10\xf9\n\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_TronSignTx\x10\xfa\n\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_TronSignedTx\x10\xfb\n\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_TronSignMessage\x10\xfc\n\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_TronMessageSignature\x10\xfd\n\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_TronVerifyMessage\x10\xfe\n\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TronSignTypedHash\x10\xff\n\x1a\x04\x90\xb5\x18\x01\x12-\n\"MessageType_TronTypedDataSignature\x10\x80\x0b\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_TonGetAddress\x10\xdc\x0b\x1a\x04\x90\xb5\x18\x01\x12!\n\x16MessageType_TonAddress\x10\xdd\x0b\x1a\x04\x98\xb5\x18\x01\x12 \n\x15MessageType_TonSignTx\x10\xde\x0b\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_TonSignedTx\x10\xdf\x0b\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_TonSignMessage\x10\xe0\x0b\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_TonMessageSignature\x10\xe1\x0b\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_HiveGetPublicKey\x10\xc0\x0c\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_HivePublicKey\x10\xc1\x0c\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_HiveSignTx\x10\xc2\x0c\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_HiveSignedTx\x10\xc3\x0c\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_HiveGetPublicKeys\x10\xc4\x0c\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_HivePublicKeys\x10\xc5\x0c\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_HiveSignAccountCreate\x10\xc6\x0c\x1a\x04\x90\xb5\x18\x01\x12.\n#MessageType_HiveSignedAccountCreate\x10\xc7\x0c\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_HiveSignAccountUpdate\x10\xc8\x0c\x1a\x04\x90\xb5\x18\x01\x12.\n#MessageType_HiveSignedAccountUpdate\x10\xc9\x0c\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_NearGetAddress\x10\xca\x0c\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_NearAddress\x10\xcb\x0c\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_NearSignTx\x10\xcc\x0c\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_NearSignedTx\x10\xcd\x0c\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_HiveSignMessage\x10\xce\x0c\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_HiveSignedMessage\x10\xcf\x0c\x1a\x04\x98\xb5\x18\x01\x12)\n\x1eMessageType_HiveSignOperations\x10\xd0\x0c\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_HiveSignedOperations\x10\xd1\x0c\x1a\x04\x98\xb5\x18\x01\x12\x34\n)MessageType_ClearsignAttestorGetPublicKey\x10\xa4\r\x1a\x04\x90\xb5\x18\x01\x12\x31\n&MessageType_ClearsignAttestorPublicKey\x10\xa5\r\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_ClearsignAttestorSign\x10\xa6\r\x1a\x04\x90\xb5\x18\x01\x12\x31\n&MessageType_ClearsignAttestorSignature\x10\xa7\r\x1a\x04\x98\xb5\x18\x01\x42,\n\x1a\x63om.keepkey.deviceprotocolB\x0eKeepKeyMessage') , dependencies=[types__pb2.DESCRIPTOR,]) @@ -344,446 +344,590 @@ options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_GetBip85Mnemonic', index=78, number=120, + name='MessageType_LoadClearsignSigner', index=78, number=117, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_Bip85Mnemonic', index=79, number=121, + name='MessageType_EthereumSignTypedData', index=79, number=1704, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_EthereumTypedDataStructRequest', index=80, number=1705, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_RippleGetAddress', index=80, number=400, + name='MessageType_EthereumTypedDataStructAck', index=81, number=1706, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_RippleAddress', index=81, number=401, + name='MessageType_EthereumTypedDataValueRequest', index=82, number=1707, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_RippleSignTx', index=82, number=402, + name='MessageType_EthereumTypedDataValueAck', index=83, number=1708, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_EthereumClearSignDefinition', index=84, number=1709, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_RippleSignedTx', index=83, number=403, + name='MessageType_EthereumClearSignDefinitionAck', index=85, number=1710, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_EthereumClearSignDefinitionRequest', index=86, number=1711, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_EthereumClearSignDefinitionChunk', index=87, number=1712, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ThorchainGetAddress', index=84, number=500, + name='MessageType_GetBip85Mnemonic', index=88, number=120, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ThorchainAddress', index=85, number=501, + name='MessageType_Bip85Mnemonic', index=89, number=121, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ThorchainSignTx', index=86, number=502, + name='MessageType_RippleGetAddress', index=90, number=400, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ThorchainMsgRequest', index=87, number=503, + name='MessageType_RippleAddress', index=91, number=401, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ThorchainMsgAck', index=88, number=504, + name='MessageType_RippleSignTx', index=92, number=402, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_RippleSignedTx', index=93, number=403, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ThorchainSignedTx', index=89, number=505, + name='MessageType_ThorchainGetAddress', index=94, number=500, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ThorchainAddress', index=95, number=501, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_EosGetPublicKey', index=90, number=600, + name='MessageType_ThorchainSignTx', index=96, number=502, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_EosPublicKey', index=91, number=601, + name='MessageType_ThorchainMsgRequest', index=97, number=503, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_EosSignTx', index=92, number=602, + name='MessageType_ThorchainMsgAck', index=98, number=504, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_EosTxActionRequest', index=93, number=603, + name='MessageType_ThorchainSignedTx', index=99, number=505, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_EosTxActionAck', index=94, number=604, + name='MessageType_EosGetPublicKey', index=100, number=600, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_EosSignedTx', index=95, number=605, + name='MessageType_EosPublicKey', index=101, number=601, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_NanoGetAddress', index=96, number=700, + name='MessageType_EosSignTx', index=102, number=602, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_NanoAddress', index=97, number=701, + name='MessageType_EosTxActionRequest', index=103, number=603, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_NanoSignTx', index=98, number=702, + name='MessageType_EosTxActionAck', index=104, number=604, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_NanoSignedTx', index=99, number=703, + name='MessageType_EosSignedTx', index=105, number=605, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_SolanaGetAddress', index=100, number=750, + name='MessageType_NanoGetAddress', index=106, number=700, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_SolanaAddress', index=101, number=751, + name='MessageType_NanoAddress', index=107, number=701, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_SolanaSignTx', index=102, number=752, + name='MessageType_NanoSignTx', index=108, number=702, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_SolanaSignedTx', index=103, number=753, + name='MessageType_NanoSignedTx', index=109, number=703, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_SolanaSignMessage', index=104, number=754, + name='MessageType_SolanaGetAddress', index=110, number=750, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_SolanaMessageSignature', index=105, number=755, + name='MessageType_SolanaAddress', index=111, number=751, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_BinanceGetAddress', index=106, number=800, + name='MessageType_SolanaSignTx', index=112, number=752, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_BinanceAddress', index=107, number=801, + name='MessageType_SolanaSignedTx', index=113, number=753, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_BinanceGetPublicKey', index=108, number=802, + name='MessageType_SolanaSignMessage', index=114, number=754, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_BinancePublicKey', index=109, number=803, + name='MessageType_SolanaMessageSignature', index=115, number=755, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_BinanceSignTx', index=110, number=804, + name='MessageType_SolanaSignOffchainMessage', index=116, number=756, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_BinanceTxRequest', index=111, number=805, + name='MessageType_SolanaOffchainMessageSignature', index=117, number=757, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_BinanceTransferMsg', index=112, number=806, + name='MessageType_BinanceGetAddress', index=118, number=800, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_BinanceOrderMsg', index=113, number=807, + name='MessageType_BinanceAddress', index=119, number=801, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_BinanceGetPublicKey', index=120, number=802, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_BinanceCancelMsg', index=114, number=808, + name='MessageType_BinancePublicKey', index=121, number=803, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_BinanceSignTx', index=122, number=804, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_BinanceSignedTx', index=115, number=809, + name='MessageType_BinanceTxRequest', index=123, number=805, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosGetAddress', index=116, number=900, + name='MessageType_BinanceTransferMsg', index=124, number=806, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_BinanceOrderMsg', index=125, number=807, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosAddress', index=117, number=901, + name='MessageType_BinanceCancelMsg', index=126, number=808, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_BinanceSignedTx', index=127, number=809, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosSignTx', index=118, number=902, + name='MessageType_CosmosGetAddress', index=128, number=900, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosMsgRequest', index=119, number=903, + name='MessageType_CosmosAddress', index=129, number=901, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosMsgAck', index=120, number=904, + name='MessageType_CosmosSignTx', index=130, number=902, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosSignedTx', index=121, number=905, + name='MessageType_CosmosMsgRequest', index=131, number=903, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosMsgDelegate', index=122, number=906, + name='MessageType_CosmosMsgAck', index=132, number=904, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_CosmosSignedTx', index=133, number=905, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosMsgUndelegate', index=123, number=907, + name='MessageType_CosmosMsgDelegate', index=134, number=906, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosMsgRedelegate', index=124, number=908, + name='MessageType_CosmosMsgUndelegate', index=135, number=907, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosMsgRewards', index=125, number=909, + name='MessageType_CosmosMsgRedelegate', index=136, number=908, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_CosmosMsgIBCTransfer', index=126, number=910, + name='MessageType_CosmosMsgRewards', index=137, number=909, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintGetAddress', index=127, number=1000, + name='MessageType_CosmosMsgIBCTransfer', index=138, number=910, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_TendermintGetAddress', index=139, number=1000, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintAddress', index=128, number=1001, + name='MessageType_TendermintAddress', index=140, number=1001, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintSignTx', index=129, number=1002, + name='MessageType_TendermintSignTx', index=141, number=1002, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintMsgRequest', index=130, number=1003, + name='MessageType_TendermintMsgRequest', index=142, number=1003, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintMsgAck', index=131, number=1004, + name='MessageType_TendermintMsgAck', index=143, number=1004, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintMsgSend', index=132, number=1005, + name='MessageType_TendermintMsgSend', index=144, number=1005, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintSignedTx', index=133, number=1006, + name='MessageType_TendermintSignedTx', index=145, number=1006, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintMsgDelegate', index=134, number=1007, + name='MessageType_TendermintMsgDelegate', index=146, number=1007, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintMsgUndelegate', index=135, number=1008, + name='MessageType_TendermintMsgUndelegate', index=147, number=1008, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintMsgRedelegate', index=136, number=1009, + name='MessageType_TendermintMsgRedelegate', index=148, number=1009, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintMsgRewards', index=137, number=1010, + name='MessageType_TendermintMsgRewards', index=149, number=1010, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TendermintMsgIBCTransfer', index=138, number=1011, + name='MessageType_TendermintMsgIBCTransfer', index=150, number=1011, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisGetAddress', index=139, number=1100, + name='MessageType_OsmosisGetAddress', index=151, number=1100, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisAddress', index=140, number=1101, + name='MessageType_OsmosisAddress', index=152, number=1101, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisSignTx', index=141, number=1102, + name='MessageType_OsmosisSignTx', index=153, number=1102, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgRequest', index=142, number=1103, + name='MessageType_OsmosisMsgRequest', index=154, number=1103, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgAck', index=143, number=1104, + name='MessageType_OsmosisMsgAck', index=155, number=1104, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_OsmosisMsgSend', index=156, number=1105, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgSend', index=144, number=1105, + name='MessageType_OsmosisMsgDelegate', index=157, number=1106, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgDelegate', index=145, number=1106, + name='MessageType_OsmosisMsgUndelegate', index=158, number=1107, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgUndelegate', index=146, number=1107, + name='MessageType_OsmosisMsgRedelegate', index=159, number=1108, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgRedelegate', index=147, number=1108, + name='MessageType_OsmosisMsgRewards', index=160, number=1109, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgRewards', index=148, number=1109, + name='MessageType_OsmosisMsgLPAdd', index=161, number=1110, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgLPAdd', index=149, number=1110, + name='MessageType_OsmosisMsgLPRemove', index=162, number=1111, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgLPRemove', index=150, number=1111, + name='MessageType_OsmosisMsgLPStake', index=163, number=1112, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgLPStake', index=151, number=1112, + name='MessageType_OsmosisMsgLPUnstake', index=164, number=1113, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_OsmosisMsgIBCTransfer', index=165, number=1114, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_OsmosisMsgSwap', index=166, number=1115, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_OsmosisSignedTx', index=167, number=1116, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_MayachainGetAddress', index=168, number=1200, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_MayachainAddress', index=169, number=1201, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_MayachainSignTx', index=170, number=1202, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_MayachainMsgRequest', index=171, number=1203, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_MayachainMsgAck', index=172, number=1204, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_MayachainSignedTx', index=173, number=1205, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ZcashSignPCZT', index=174, number=1300, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ZcashPCZTAction', index=175, number=1301, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ZcashPCZTActionAck', index=176, number=1302, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ZcashSignedPCZT', index=177, number=1303, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ZcashGetOrchardFVK', index=178, number=1304, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ZcashOrchardFVK', index=179, number=1305, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ZcashTransparentInput', index=180, number=1306, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgLPUnstake', index=152, number=1113, + name='MessageType_ZcashTransparentSigned', index=181, number=1307, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ZcashDisplayAddress', index=182, number=1308, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgIBCTransfer', index=153, number=1114, + name='MessageType_ZcashAddress', index=183, number=1309, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ZcashTransparentOutput', index=184, number=1310, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisMsgSwap', index=154, number=1115, + name='MessageType_ZcashTransparentAck', index=185, number=1311, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_TronGetAddress', index=186, number=1400, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_OsmosisSignedTx', index=155, number=1116, + name='MessageType_TronAddress', index=187, number=1401, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_MayachainGetAddress', index=156, number=1200, + name='MessageType_TronSignTx', index=188, number=1402, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_MayachainAddress', index=157, number=1201, + name='MessageType_TronSignedTx', index=189, number=1403, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_MayachainSignTx', index=158, number=1202, + name='MessageType_TronSignMessage', index=190, number=1404, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_MayachainMsgRequest', index=159, number=1203, + name='MessageType_TronMessageSignature', index=191, number=1405, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_MayachainMsgAck', index=160, number=1204, + name='MessageType_TronVerifyMessage', index=192, number=1406, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_MayachainSignedTx', index=161, number=1205, + name='MessageType_TronSignTypedHash', index=193, number=1407, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_TronTypedDataSignature', index=194, number=1408, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ZcashSignPCZT', index=162, number=1300, + name='MessageType_TonGetAddress', index=195, number=1500, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ZcashPCZTAction', index=163, number=1301, + name='MessageType_TonAddress', index=196, number=1501, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_TonSignTx', index=197, number=1502, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ZcashPCZTActionAck', index=164, number=1302, + name='MessageType_TonSignedTx', index=198, number=1503, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ZcashSignedPCZT', index=165, number=1303, + name='MessageType_TonSignMessage', index=199, number=1504, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_TonMessageSignature', index=200, number=1505, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ZcashGetOrchardFVK', index=166, number=1304, + name='MessageType_HiveGetPublicKey', index=201, number=1600, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ZcashOrchardFVK', index=167, number=1305, + name='MessageType_HivePublicKey', index=202, number=1601, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ZcashTransparentInput', index=168, number=1306, + name='MessageType_HiveSignTx', index=203, number=1602, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_ZcashTransparentSig', index=169, number=1307, + name='MessageType_HiveSignedTx', index=204, number=1603, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TronGetAddress', index=170, number=1400, + name='MessageType_HiveGetPublicKeys', index=205, number=1604, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TronAddress', index=171, number=1401, + name='MessageType_HivePublicKeys', index=206, number=1605, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TronSignTx', index=172, number=1402, + name='MessageType_HiveSignAccountCreate', index=207, number=1606, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TronSignedTx', index=173, number=1403, + name='MessageType_HiveSignedAccountCreate', index=208, number=1607, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TonGetAddress', index=174, number=1500, + name='MessageType_HiveSignAccountUpdate', index=209, number=1608, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TonAddress', index=175, number=1501, + name='MessageType_HiveSignedAccountUpdate', index=210, number=1609, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TonSignTx', index=176, number=1502, + name='MessageType_NearGetAddress', index=211, number=1610, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TonSignedTx', index=177, number=1503, + name='MessageType_NearAddress', index=212, number=1611, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_SolanaSignOffchainMessage', index=178, number=756, + name='MessageType_NearSignTx', index=213, number=1612, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_SolanaOffchainMessageSignature', index=179, number=757, + name='MessageType_NearSignedTx', index=214, number=1613, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TronSignMessage', index=180, number=1404, + name='MessageType_HiveSignMessage', index=215, number=1614, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TronMessageSignature', index=181, number=1405, + name='MessageType_HiveSignedMessage', index=216, number=1615, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TronVerifyMessage', index=182, number=1406, + name='MessageType_HiveSignOperations', index=217, number=1616, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TronSignTypedHash', index=183, number=1407, + name='MessageType_HiveSignedOperations', index=218, number=1617, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_ClearsignAttestorGetPublicKey', index=219, number=1700, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TronTypedDataSignature', index=184, number=1408, + name='MessageType_ClearsignAttestorPublicKey', index=220, number=1701, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TonSignMessage', index=185, number=1504, + name='MessageType_ClearsignAttestorSign', index=221, number=1702, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), type=None), _descriptor.EnumValueDescriptor( - name='MessageType_TonMessageSignature', index=186, number=1505, + name='MessageType_ClearsignAttestorSignature', index=222, number=1703, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), ], containing_type=None, options=None, - serialized_start=5191, - serialized_end=12178, + serialized_start=6709, + serialized_end=15736, ) _sym_db.RegisterEnumDescriptor(_MESSAGETYPE) @@ -866,6 +1010,16 @@ MessageType_Ethereum712TypesValues = 114 MessageType_EthereumTxMetadata = 115 MessageType_EthereumMetadataAck = 116 +MessageType_LoadClearsignSigner = 117 +MessageType_EthereumSignTypedData = 1704 +MessageType_EthereumTypedDataStructRequest = 1705 +MessageType_EthereumTypedDataStructAck = 1706 +MessageType_EthereumTypedDataValueRequest = 1707 +MessageType_EthereumTypedDataValueAck = 1708 +MessageType_EthereumClearSignDefinition = 1709 +MessageType_EthereumClearSignDefinitionAck = 1710 +MessageType_EthereumClearSignDefinitionRequest = 1711 +MessageType_EthereumClearSignDefinitionChunk = 1712 MessageType_GetBip85Mnemonic = 120 MessageType_Bip85Mnemonic = 121 MessageType_RippleGetAddress = 400 @@ -959,7 +1113,11 @@ MessageType_ZcashGetOrchardFVK = 1304 MessageType_ZcashOrchardFVK = 1305 MessageType_ZcashTransparentInput = 1306 -MessageType_ZcashTransparentSig = 1307 +MessageType_ZcashTransparentSigned = 1307 +MessageType_ZcashDisplayAddress = 1308 +MessageType_ZcashAddress = 1309 +MessageType_ZcashTransparentOutput = 1310 +MessageType_ZcashTransparentAck = 1311 MessageType_TronGetAddress = 1400 MessageType_TronAddress = 1401 MessageType_TronSignTx = 1402 @@ -975,7 +1133,155 @@ MessageType_TonSignedTx = 1503 MessageType_TonSignMessage = 1504 MessageType_TonMessageSignature = 1505 - +MessageType_HiveGetPublicKey = 1600 +MessageType_HivePublicKey = 1601 +MessageType_HiveSignTx = 1602 +MessageType_HiveSignedTx = 1603 +MessageType_HiveGetPublicKeys = 1604 +MessageType_HivePublicKeys = 1605 +MessageType_HiveSignAccountCreate = 1606 +MessageType_HiveSignedAccountCreate = 1607 +MessageType_HiveSignAccountUpdate = 1608 +MessageType_HiveSignedAccountUpdate = 1609 +MessageType_NearGetAddress = 1610 +MessageType_NearAddress = 1611 +MessageType_NearSignTx = 1612 +MessageType_NearSignedTx = 1613 +MessageType_HiveSignMessage = 1614 +MessageType_HiveSignedMessage = 1615 +MessageType_HiveSignOperations = 1616 +MessageType_HiveSignedOperations = 1617 +MessageType_ClearsignAttestorGetPublicKey = 1700 +MessageType_ClearsignAttestorPublicKey = 1701 +MessageType_ClearsignAttestorSign = 1702 +MessageType_ClearsignAttestorSignature = 1703 + + +_FEATURES_CAPABILITY = _descriptor.EnumDescriptor( + name='Capability', + full_name='Features.Capability', + filename=None, + file=DESCRIPTOR, + values=[ + _descriptor.EnumValueDescriptor( + name='CAPABILITY_UNSPECIFIED', index=0, number=0, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_ENTROPY_AUDIT_BUDGET', index=1, number=1, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_ERC20_UNLIMITED_APPROVE_REVIEW', index=2, number=2, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_ERC20_UNLIMITED_PERMIT_REVIEW', index=3, number=3, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_ERC7730_RUNTIME_REVIEW', index=4, number=4, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_EVM_CERTIFIED_INTENT', index=5, number=5, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_EVM_MAX_AMOUNT_REVIEW', index=6, number=6, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_EVM_TX_METADATA', index=7, number=7, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_EVM_UNKNOWN_TOKEN_REVIEW', index=8, number=8, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_HIVE_RELEASE_REVIEW', index=9, number=9, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_LEGACY_EVM_ROUTER_SIGNING', index=10, number=10, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_MAYA_SINGLE_MESSAGE', index=11, number=11, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_OSMOSIS_WIRE_GUARDS', index=12, number=12, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_PERMIT2_REVIEW', index=13, number=13, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_PROMPT_WORKFLOW_UNWIND', index=14, number=14, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_PROTECTED_PING_PRESENCE', index=15, number=15, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_RIPPLE_MEMO_POLICY', index=16, number=16, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_SESSION_TRUST_LIFETIME', index=17, number=17, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_SOLANA_CERTIFIED_REVIEW', index=18, number=18, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_SOLANA_LUT_ATTESTATION', index=19, number=19, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_SOLANA_RUNTIME_REVIEW', index=20, number=20, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_STORAGE_V19_KDF', index=21, number=21, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_TENDERMINT_PROGRESS', index=22, number=22, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_SAFE_RESET_CEREMONY', index=23, number=23, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_THOR_DEPOSIT_REVIEW', index=24, number=24, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_TRON_TRC20_REVIEW', index=25, number=25, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_EIP712_CHUNKED_VALUES', index=26, number=26, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='CAPABILITY_CERTIFIED_CONTACTS', index=27, number=27, + options=None, + type=None), + ], + containing_type=None, + options=None, + serialized_start=762, + serialized_end=1817, +) +_sym_db.RegisterEnumDescriptor(_FEATURES_CAPABILITY) _INITIALIZE = _descriptor.Descriptor( @@ -1201,11 +1507,40 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='supports_taproot', full_name='Features.supports_taproot', index=24, + number=27, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='supports_dice_modes', full_name='Features.supports_dice_modes', index=25, + number=28, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='supports_solana_lut_attestation', full_name='Features.supports_solana_lut_attestation', index=26, + number=29, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='capabilities', full_name='Features.capabilities', index=27, + number=30, type=14, cpp_type=8, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=_descriptor._ParseOptions(descriptor_pb2.FieldOptions(), _b('\020\001')), file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ + _FEATURES_CAPABILITY, ], options=None, is_extendable=False, @@ -1214,7 +1549,7 @@ oneofs=[ ], serialized_start=61, - serialized_end=615, + serialized_end=1817, ) @@ -1251,8 +1586,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=617, - serialized_end=659, + serialized_start=1819, + serialized_end=1861, ) @@ -1296,8 +1631,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=661, - serialized_end=737, + serialized_start=1863, + serialized_end=1939, ) @@ -1320,8 +1655,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=739, - serialized_end=753, + serialized_start=1941, + serialized_end=1955, ) @@ -1379,8 +1714,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=755, - serialized_end=876, + serialized_start=1957, + serialized_end=2078, ) @@ -1410,8 +1745,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=878, - serialized_end=905, + serialized_start=2080, + serialized_end=2107, ) @@ -1469,8 +1804,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=908, - serialized_end=1043, + serialized_start=2110, + serialized_end=2245, ) @@ -1500,8 +1835,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1045, - serialized_end=1071, + serialized_start=2247, + serialized_end=2273, ) @@ -1538,8 +1873,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1073, - serialized_end=1127, + serialized_start=2275, + serialized_end=2329, ) @@ -1576,8 +1911,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1129, - serialized_end=1192, + serialized_start=2331, + serialized_end=2394, ) @@ -1600,8 +1935,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1194, - serialized_end=1205, + serialized_start=2396, + serialized_end=2407, ) @@ -1631,8 +1966,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1207, - serialized_end=1262, + serialized_start=2409, + serialized_end=2464, ) @@ -1662,8 +1997,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1264, - serialized_end=1291, + serialized_start=2466, + serialized_end=2493, ) @@ -1686,8 +2021,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1293, - serialized_end=1301, + serialized_start=2495, + serialized_end=2503, ) @@ -1710,8 +2045,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1303, - serialized_end=1322, + serialized_start=2505, + serialized_end=2524, ) @@ -1741,8 +2076,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1324, - serialized_end=1359, + serialized_start=2526, + serialized_end=2561, ) @@ -1772,8 +2107,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1361, - serialized_end=1387, + serialized_start=2563, + serialized_end=2589, ) @@ -1803,8 +2138,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1389, - serialized_end=1415, + serialized_start=2591, + serialized_end=2617, ) @@ -1862,8 +2197,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1418, - serialized_end=1580, + serialized_start=2620, + serialized_end=2782, ) @@ -1900,8 +2235,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1582, - serialized_end=1634, + serialized_start=2784, + serialized_end=2836, ) @@ -1959,8 +2294,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1637, - serialized_end=1816, + serialized_start=2839, + serialized_end=3018, ) @@ -1990,8 +2325,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1818, - serialized_end=1844, + serialized_start=3020, + serialized_end=3046, ) @@ -2014,8 +2349,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1846, - serialized_end=1858, + serialized_start=3048, + serialized_end=3060, ) @@ -2094,8 +2429,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1861, - serialized_end=2048, + serialized_start=3063, + serialized_end=3250, ) @@ -2169,6 +2504,20 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='dice_entropy', full_name='ResetDevice.dice_entropy', index=9, + number=10, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='dice_only', full_name='ResetDevice.dice_only', index=10, + number=11, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -2181,8 +2530,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2051, - serialized_end=2276, + serialized_start=3253, + serialized_end=3519, ) @@ -2205,8 +2554,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2278, - serialized_end=2294, + serialized_start=3521, + serialized_end=3537, ) @@ -2236,8 +2585,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2296, - serialized_end=2325, + serialized_start=3539, + serialized_end=3568, ) @@ -2330,8 +2679,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2328, - serialized_end=2583, + serialized_start=3571, + serialized_end=3826, ) @@ -2354,8 +2703,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2585, - serialized_end=2598, + serialized_start=3828, + serialized_end=3841, ) @@ -2385,8 +2734,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2600, - serialized_end=2623, + serialized_start=3843, + serialized_end=3866, ) @@ -2423,8 +2772,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2625, - serialized_end=2684, + serialized_start=3868, + serialized_end=3927, ) @@ -2468,8 +2817,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2686, - serialized_end=2749, + serialized_start=3929, + serialized_end=3992, ) @@ -2520,8 +2869,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2752, - serialized_end=2882, + serialized_start=3995, + serialized_end=4125, ) @@ -2572,8 +2921,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2884, - serialized_end=2980, + serialized_start=4127, + serialized_end=4223, ) @@ -2610,8 +2959,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2982, - serialized_end=3036, + serialized_start=4225, + serialized_end=4279, ) @@ -2669,8 +3018,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3038, - serialized_end=3156, + serialized_start=4281, + serialized_end=4399, ) @@ -2714,8 +3063,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3158, - serialized_end=3222, + serialized_start=4401, + serialized_end=4465, ) @@ -2766,8 +3115,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3224, - serialized_end=3305, + serialized_start=4467, + serialized_end=4548, ) @@ -2804,8 +3153,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3307, - serialized_end=3359, + serialized_start=4550, + serialized_end=4602, ) @@ -2877,8 +3226,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3362, - serialized_end=3502, + serialized_start=4605, + serialized_end=4745, ) @@ -2908,8 +3257,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3504, - serialized_end=3537, + serialized_start=4747, + serialized_end=4780, ) @@ -2946,8 +3295,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3539, - serialized_end=3592, + serialized_start=4782, + serialized_end=4835, ) @@ -2977,8 +3326,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3594, - serialized_end=3627, + serialized_start=4837, + serialized_end=4870, ) @@ -3064,8 +3413,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3630, - serialized_end=3836, + serialized_start=4873, + serialized_end=5079, ) @@ -3109,8 +3458,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3839, - serialized_end=3972, + serialized_start=5082, + serialized_end=5215, ) @@ -3140,8 +3489,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3974, - serialized_end=4011, + serialized_start=5217, + serialized_end=5254, ) @@ -3171,8 +3520,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4013, - serialized_end=4056, + serialized_start=5256, + serialized_end=5299, ) @@ -3223,8 +3572,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4058, - serialized_end=4183, + serialized_start=5301, + serialized_end=5426, ) @@ -3268,8 +3617,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4185, - serialized_end=4257, + serialized_start=5428, + serialized_end=5500, ) @@ -3299,8 +3648,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4259, - serialized_end=4303, + serialized_start=5502, + serialized_end=5546, ) @@ -3344,8 +3693,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4305, - serialized_end=4368, + serialized_start=5548, + serialized_end=5611, ) @@ -3389,8 +3738,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4370, - serialized_end=4428, + serialized_start=5613, + serialized_end=5671, ) @@ -3420,8 +3769,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4430, - serialized_end=4463, + serialized_start=5673, + serialized_end=5706, ) @@ -3458,8 +3807,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4465, - serialized_end=4518, + serialized_start=5708, + serialized_end=5761, ) @@ -3489,8 +3838,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4520, - serialized_end=4562, + serialized_start=5763, + serialized_end=5805, ) @@ -3513,8 +3862,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4564, - serialized_end=4575, + serialized_start=5807, + serialized_end=5818, ) @@ -3537,8 +3886,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4577, - serialized_end=4592, + serialized_start=5820, + serialized_end=5835, ) @@ -3575,8 +3924,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4594, - serialized_end=4649, + serialized_start=5837, + serialized_end=5892, ) @@ -3594,6 +3943,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='input', full_name='DebugLinkDecision.input', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -3606,8 +3962,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4651, - serialized_end=4686, + serialized_start=5894, + serialized_end=5944, ) @@ -3630,8 +3986,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4688, - serialized_end=4707, + serialized_start=5946, + serialized_end=5965, ) @@ -3740,6 +4096,27 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='dice_digest', full_name='DebugLinkState.dice_digest', index=14, + number=15, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='confirm_title', full_name='DebugLinkState.confirm_title', index=15, + number=16, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='confirm_body', full_name='DebugLinkState.confirm_body', index=16, + number=17, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -3752,8 +4129,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=4710, - serialized_end=5053, + serialized_start=5968, + serialized_end=6377, ) @@ -3776,8 +4153,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=5055, - serialized_end=5070, + serialized_start=6379, + serialized_end=6394, ) @@ -3821,8 +4198,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=5072, - serialized_end=5131, + serialized_start=6396, + serialized_end=6455, ) @@ -3845,8 +4222,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=5133, - serialized_end=5154, + serialized_start=6457, + serialized_end=6478, ) @@ -3876,12 +4253,138 @@ extension_ranges=[], oneofs=[ ], - serialized_start=5156, - serialized_end=5188, + serialized_start=6480, + serialized_end=6512, +) + + +_CLEARSIGNATTESTORGETPUBLICKEY = _descriptor.Descriptor( + name='ClearsignAttestorGetPublicKey', + full_name='ClearsignAttestorGetPublicKey', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=6514, + serialized_end=6545, +) + + +_CLEARSIGNATTESTORPUBLICKEY = _descriptor.Descriptor( + name='ClearsignAttestorPublicKey', + full_name='ClearsignAttestorPublicKey', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='public_key', full_name='ClearsignAttestorPublicKey.public_key', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=6547, + serialized_end=6595, +) + + +_CLEARSIGNATTESTORSIGN = _descriptor.Descriptor( + name='ClearsignAttestorSign', + full_name='ClearsignAttestorSign', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='payload', full_name='ClearsignAttestorSign.payload', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=6597, + serialized_end=6637, +) + + +_CLEARSIGNATTESTORSIGNATURE = _descriptor.Descriptor( + name='ClearsignAttestorSignature', + full_name='ClearsignAttestorSignature', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='signature', full_name='ClearsignAttestorSignature.signature', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='public_key', full_name='ClearsignAttestorSignature.public_key', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=6639, + serialized_end=6706, ) _FEATURES.fields_by_name['coins'].message_type = types__pb2._COINTYPE _FEATURES.fields_by_name['policies'].message_type = types__pb2._POLICYTYPE +_FEATURES.fields_by_name['capabilities'].enum_type = _FEATURES_CAPABILITY +_FEATURES_CAPABILITY.containing_type = _FEATURES _COINTABLE.fields_by_name['table'].message_type = types__pb2._COINTYPE _FAILURE.fields_by_name['code'].enum_type = types__pb2._FAILURETYPE _BUTTONREQUEST.fields_by_name['code'].enum_type = types__pb2._BUTTONREQUESTTYPE @@ -3967,6 +4470,10 @@ DESCRIPTOR.message_types_by_name['DebugLinkLog'] = _DEBUGLINKLOG DESCRIPTOR.message_types_by_name['DebugLinkFillConfig'] = _DEBUGLINKFILLCONFIG DESCRIPTOR.message_types_by_name['ChangeWipeCode'] = _CHANGEWIPECODE +DESCRIPTOR.message_types_by_name['ClearsignAttestorGetPublicKey'] = _CLEARSIGNATTESTORGETPUBLICKEY +DESCRIPTOR.message_types_by_name['ClearsignAttestorPublicKey'] = _CLEARSIGNATTESTORPUBLICKEY +DESCRIPTOR.message_types_by_name['ClearsignAttestorSign'] = _CLEARSIGNATTESTORSIGN +DESCRIPTOR.message_types_by_name['ClearsignAttestorSignature'] = _CLEARSIGNATTESTORSIGNATURE DESCRIPTOR.enum_types_by_name['MessageType'] = _MESSAGETYPE _sym_db.RegisterFileDescriptor(DESCRIPTOR) @@ -4439,6 +4946,34 @@ )) _sym_db.RegisterMessage(ChangeWipeCode) +ClearsignAttestorGetPublicKey = _reflection.GeneratedProtocolMessageType('ClearsignAttestorGetPublicKey', (_message.Message,), dict( + DESCRIPTOR = _CLEARSIGNATTESTORGETPUBLICKEY, + __module__ = 'messages_pb2' + # @@protoc_insertion_point(class_scope:ClearsignAttestorGetPublicKey) + )) +_sym_db.RegisterMessage(ClearsignAttestorGetPublicKey) + +ClearsignAttestorPublicKey = _reflection.GeneratedProtocolMessageType('ClearsignAttestorPublicKey', (_message.Message,), dict( + DESCRIPTOR = _CLEARSIGNATTESTORPUBLICKEY, + __module__ = 'messages_pb2' + # @@protoc_insertion_point(class_scope:ClearsignAttestorPublicKey) + )) +_sym_db.RegisterMessage(ClearsignAttestorPublicKey) + +ClearsignAttestorSign = _reflection.GeneratedProtocolMessageType('ClearsignAttestorSign', (_message.Message,), dict( + DESCRIPTOR = _CLEARSIGNATTESTORSIGN, + __module__ = 'messages_pb2' + # @@protoc_insertion_point(class_scope:ClearsignAttestorSign) + )) +_sym_db.RegisterMessage(ClearsignAttestorSign) + +ClearsignAttestorSignature = _reflection.GeneratedProtocolMessageType('ClearsignAttestorSignature', (_message.Message,), dict( + DESCRIPTOR = _CLEARSIGNATTESTORSIGNATURE, + __module__ = 'messages_pb2' + # @@protoc_insertion_point(class_scope:ClearsignAttestorSignature) + )) +_sym_db.RegisterMessage(ClearsignAttestorSignature) + DESCRIPTOR.has_options = True DESCRIPTOR._options = _descriptor._ParseOptions(descriptor_pb2.FileOptions(), _b('\n\032com.keepkey.deviceprotocolB\016KeepKeyMessage')) @@ -4598,6 +5133,26 @@ _MESSAGETYPE.values_by_name["MessageType_EthereumTxMetadata"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) _MESSAGETYPE.values_by_name["MessageType_EthereumMetadataAck"].has_options = True _MESSAGETYPE.values_by_name["MessageType_EthereumMetadataAck"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_LoadClearsignSigner"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_LoadClearsignSigner"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_EthereumSignTypedData"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_EthereumSignTypedData"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_EthereumTypedDataStructRequest"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_EthereumTypedDataStructRequest"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_EthereumTypedDataStructAck"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_EthereumTypedDataStructAck"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_EthereumTypedDataValueRequest"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_EthereumTypedDataValueRequest"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_EthereumTypedDataValueAck"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_EthereumTypedDataValueAck"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_EthereumClearSignDefinition"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_EthereumClearSignDefinition"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_EthereumClearSignDefinitionAck"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_EthereumClearSignDefinitionAck"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_EthereumClearSignDefinitionRequest"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_EthereumClearSignDefinitionRequest"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_EthereumClearSignDefinitionChunk"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_EthereumClearSignDefinitionChunk"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) _MESSAGETYPE.values_by_name["MessageType_GetBip85Mnemonic"].has_options = True _MESSAGETYPE.values_by_name["MessageType_GetBip85Mnemonic"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) _MESSAGETYPE.values_by_name["MessageType_Bip85Mnemonic"].has_options = True @@ -4784,8 +5339,16 @@ _MESSAGETYPE.values_by_name["MessageType_ZcashOrchardFVK"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) _MESSAGETYPE.values_by_name["MessageType_ZcashTransparentInput"].has_options = True _MESSAGETYPE.values_by_name["MessageType_ZcashTransparentInput"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) -_MESSAGETYPE.values_by_name["MessageType_ZcashTransparentSig"].has_options = True -_MESSAGETYPE.values_by_name["MessageType_ZcashTransparentSig"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_ZcashTransparentSigned"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_ZcashTransparentSigned"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_ZcashDisplayAddress"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_ZcashDisplayAddress"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_ZcashAddress"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_ZcashAddress"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_ZcashTransparentOutput"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_ZcashTransparentOutput"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_ZcashTransparentAck"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_ZcashTransparentAck"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) _MESSAGETYPE.values_by_name["MessageType_TronGetAddress"].has_options = True _MESSAGETYPE.values_by_name["MessageType_TronGetAddress"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) _MESSAGETYPE.values_by_name["MessageType_TronAddress"].has_options = True @@ -4816,4 +5379,50 @@ _MESSAGETYPE.values_by_name["MessageType_TonSignMessage"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) _MESSAGETYPE.values_by_name["MessageType_TonMessageSignature"].has_options = True _MESSAGETYPE.values_by_name["MessageType_TonMessageSignature"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveGetPublicKey"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveGetPublicKey"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HivePublicKey"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HivePublicKey"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignTx"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignTx"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignedTx"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignedTx"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveGetPublicKeys"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveGetPublicKeys"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HivePublicKeys"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HivePublicKeys"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignAccountCreate"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignAccountCreate"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignedAccountCreate"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignedAccountCreate"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignAccountUpdate"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignAccountUpdate"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignedAccountUpdate"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignedAccountUpdate"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_NearGetAddress"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_NearGetAddress"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_NearAddress"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_NearAddress"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_NearSignTx"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_NearSignTx"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_NearSignedTx"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_NearSignedTx"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignMessage"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignMessage"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignedMessage"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignedMessage"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignOperations"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignOperations"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignedOperations"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignedOperations"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_ClearsignAttestorGetPublicKey"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_ClearsignAttestorGetPublicKey"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_ClearsignAttestorPublicKey"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_ClearsignAttestorPublicKey"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_ClearsignAttestorSign"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_ClearsignAttestorSign"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_ClearsignAttestorSignature"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_ClearsignAttestorSignature"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_FEATURES.fields_by_name['capabilities'].has_options = True +_FEATURES.fields_by_name['capabilities']._options = _descriptor._ParseOptions(descriptor_pb2.FieldOptions(), _b('\020\001')) # @@protoc_insertion_point(module_scope) diff --git a/keepkeylib/messages_ripple_pb2.py b/keepkeylib/messages_ripple_pb2.py index 7ab35638..ad084fca 100644 --- a/keepkeylib/messages_ripple_pb2.py +++ b/keepkeylib/messages_ripple_pb2.py @@ -19,7 +19,7 @@ name='messages-ripple.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x15messages-ripple.proto\";\n\x10RippleGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\" \n\rRippleAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"\x8e\x01\n\x0cRippleSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0b\n\x03\x66\x65\x65\x18\x02 \x01(\x04\x12\r\n\x05\x66lags\x18\x03 \x01(\r\x12\x10\n\x08sequence\x18\x04 \x01(\r\x12\x1c\n\x14last_ledger_sequence\x18\x05 \x01(\r\x12\x1f\n\x07payment\x18\x06 \x01(\x0b\x32\x0e.RipplePayment\"M\n\rRipplePayment\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x13\n\x0b\x64\x65stination\x18\x02 \x01(\t\x12\x17\n\x0f\x64\x65stination_tag\x18\x03 \x01(\r\":\n\x0eRippleSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\x42;\n#com.shapeshift.keepkey.lib.protobufB\x14KeepKeyMessageRipple') + serialized_pb=_b('\n\x15messages-ripple.proto\";\n\x10RippleGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\" \n\rRippleAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"\x9c\x01\n\x0cRippleSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0b\n\x03\x66\x65\x65\x18\x02 \x01(\x04\x12\r\n\x05\x66lags\x18\x03 \x01(\r\x12\x10\n\x08sequence\x18\x04 \x01(\r\x12\x1c\n\x14last_ledger_sequence\x18\x05 \x01(\r\x12\x1f\n\x07payment\x18\x06 \x01(\x0b\x32\x0e.RipplePayment\x12\x0c\n\x04memo\x18\x07 \x01(\t\"M\n\rRipplePayment\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x13\n\x0b\x64\x65stination\x18\x02 \x01(\t\x12\x17\n\x0f\x64\x65stination_tag\x18\x03 \x01(\r\":\n\x0eRippleSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\x42;\n#com.shapeshift.keepkey.lib.protobufB\x14KeepKeyMessageRipple') ) @@ -143,6 +143,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='memo', full_name='RippleSignTx.memo', index=6, + number=7, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -156,7 +163,7 @@ oneofs=[ ], serialized_start=121, - serialized_end=263, + serialized_end=277, ) @@ -200,8 +207,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=265, - serialized_end=342, + serialized_start=279, + serialized_end=356, ) @@ -238,8 +245,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=344, - serialized_end=402, + serialized_start=358, + serialized_end=416, ) _RIPPLESIGNTX.fields_by_name['payment'].message_type = _RIPPLEPAYMENT diff --git a/keepkeylib/messages_solana_pb2.py b/keepkeylib/messages_solana_pb2.py index cf8d5ed6..b43a13ae 100644 --- a/keepkeylib/messages_solana_pb2.py +++ b/keepkeylib/messages_solana_pb2.py @@ -19,7 +19,7 @@ name='messages-solana.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x15messages-solana.proto\"V\n\x10SolanaGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\" \n\rSolanaAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"A\n\x0fSolanaTokenInfo\x12\x0c\n\x04mint\x18\x01 \x01(\x0c\x12\x0e\n\x06symbol\x18\x02 \x01(\t\x12\x10\n\x08\x64\x65\x63imals\x18\x03 \x01(\r\"r\n\x0cSolanaSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0e\n\x06raw_tx\x18\x03 \x01(\x0c\x12$\n\ntoken_info\x18\x04 \x03(\x0b\x32\x10.SolanaTokenInfo\"#\n\x0eSolanaSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\"h\n\x11SolanaSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x04 \x01(\x08\"?\n\x16SolanaMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"\x9c\x01\n\x19SolanaSignOffchainMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x12\n\x07version\x18\x03 \x01(\r:\x01\x30\x12\x16\n\x0emessage_format\x18\x04 \x01(\r\x12\x0f\n\x07message\x18\x05 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x06 \x01(\x08\"G\n\x1eSolanaOffchainMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x42\x32\n\x1a\x63om.keepkey.deviceprotocolB\x14KeepKeyMessageSolana') + serialized_pb=_b('\n\x15messages-solana.proto\"V\n\x10SolanaGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\" \n\rSolanaAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"k\n\x0fSolanaTokenInfo\x12\x0c\n\x04mint\x18\x01 \x01(\x0c\x12\x0e\n\x06symbol\x18\x02 \x01(\t\x12\x10\n\x08\x64\x65\x63imals\x18\x03 \x01(\r\x12\x11\n\tsignature\x18\x04 \x01(\x0c\x12\x15\n\rsigner_key_id\x18\x05 \x01(\r\"\xcd\x02\n\x0cSolanaSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0e\n\x06raw_tx\x18\x03 \x01(\x0c\x12$\n\ntoken_info\x18\x04 \x03(\x0b\x32\x10.SolanaTokenInfo\x12\x13\n\x0blut_account\x18\x05 \x03(\x0c\x12\x15\n\rlut_signature\x18\x06 \x01(\x0c\x12\x19\n\x11lut_signer_key_id\x18\x07 \x01(\r\x12\x16\n\x0eschema_payload\x18\t \x01(\x0c\x12\x18\n\x10schema_signature\x18\n \x01(\x0c\x12\x1c\n\x14schema_signer_key_id\x18\x0b \x01(\r\x12\x1d\n\x15token_recipient_owner\x18\x0c \x03(\x0c\x12\x1d\n\x15\x63learsign_certificate\x18\r \x01(\x0cJ\x04\x08\x08\x10\t\"#\n\x0eSolanaSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\"h\n\x11SolanaSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x04 \x01(\x08\"?\n\x16SolanaMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"\x9c\x01\n\x19SolanaSignOffchainMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x12\n\x07version\x18\x03 \x01(\r:\x01\x30\x12\x16\n\x0emessage_format\x18\x04 \x01(\r\x12\x0f\n\x07message\x18\x05 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x06 \x01(\x08\"G\n\x1eSolanaOffchainMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x42\x32\n\x1a\x63om.keepkey.deviceprotocolB\x14KeepKeyMessageSolana') ) @@ -129,6 +129,20 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='signature', full_name='SolanaTokenInfo.signature', index=3, + number=4, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='signer_key_id', full_name='SolanaTokenInfo.signer_key_id', index=4, + number=5, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -142,7 +156,7 @@ oneofs=[ ], serialized_start=147, - serialized_end=212, + serialized_end=254, ) @@ -181,6 +195,62 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='lut_account', full_name='SolanaSignTx.lut_account', index=4, + number=5, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='lut_signature', full_name='SolanaSignTx.lut_signature', index=5, + number=6, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='lut_signer_key_id', full_name='SolanaSignTx.lut_signer_key_id', index=6, + number=7, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='schema_payload', full_name='SolanaSignTx.schema_payload', index=7, + number=9, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='schema_signature', full_name='SolanaSignTx.schema_signature', index=8, + number=10, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='schema_signer_key_id', full_name='SolanaSignTx.schema_signer_key_id', index=9, + number=11, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='token_recipient_owner', full_name='SolanaSignTx.token_recipient_owner', index=10, + number=12, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='clearsign_certificate', full_name='SolanaSignTx.clearsign_certificate', index=11, + number=13, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -193,8 +263,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=214, - serialized_end=328, + serialized_start=257, + serialized_end=590, ) @@ -224,8 +294,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=330, - serialized_end=365, + serialized_start=592, + serialized_end=627, ) @@ -276,8 +346,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=367, - serialized_end=471, + serialized_start=629, + serialized_end=733, ) @@ -314,8 +384,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=473, - serialized_end=536, + serialized_start=735, + serialized_end=798, ) @@ -380,8 +450,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=539, - serialized_end=695, + serialized_start=801, + serialized_end=957, ) @@ -418,8 +488,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=697, - serialized_end=768, + serialized_start=959, + serialized_end=1030, ) _SOLANASIGNTX.fields_by_name['token_info'].message_type = _SOLANATOKENINFO diff --git a/keepkeylib/messages_thorchain_pb2.py b/keepkeylib/messages_thorchain_pb2.py index 8d297659..e0851d36 100644 --- a/keepkeylib/messages_thorchain_pb2.py +++ b/keepkeylib/messages_thorchain_pb2.py @@ -20,7 +20,7 @@ name='messages-thorchain.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x18messages-thorchain.proto\x1a\x0btypes.proto\"O\n\x13ThorchainGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\x12\x0f\n\x07testnet\x18\x03 \x01(\x08\"#\n\x10ThorchainAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"\xbb\x01\n\x0fThorchainSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x1a\n\x0e\x61\x63\x63ount_number\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x10\n\x08\x63hain_id\x18\x03 \x01(\t\x12\x12\n\nfee_amount\x18\x04 \x01(\r\x12\x0b\n\x03gas\x18\x05 \x01(\r\x12\x0c\n\x04memo\x18\x06 \x01(\t\x12\x14\n\x08sequence\x18\x07 \x01(\x04\x42\x02\x30\x01\x12\x11\n\tmsg_count\x18\x08 \x01(\r\x12\x0f\n\x07testnet\x18\t \x01(\x08\"\x15\n\x13ThorchainMsgRequest\"Y\n\x0fThorchainMsgAck\x12\x1f\n\x04send\x18\x01 \x01(\x0b\x32\x11.ThorchainMsgSend\x12%\n\x07\x64\x65posit\x18\x02 \x01(\x0b\x32\x14.ThorchainMsgDeposit\"\x80\x01\n\x10ThorchainMsgSend\x12\x14\n\x0c\x66rom_address\x18\x06 \x01(\t\x12\x12\n\nto_address\x18\x07 \x01(\t\x12\x12\n\x06\x61mount\x18\x08 \x01(\x04\x42\x02\x30\x01\x12(\n\x0c\x61\x64\x64ress_type\x18\t \x01(\x0e\x32\x12.OutputAddressTypeJ\x04\x08\n\x10\x0b\"V\n\x13ThorchainMsgDeposit\x12\r\n\x05\x61sset\x18\x01 \x01(\t\x12\x12\n\x06\x61mount\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x0c\n\x04memo\x18\x03 \x01(\t\x12\x0e\n\x06signer\x18\x04 \x01(\t\":\n\x11ThorchainSignedTx\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x42\x35\n\x1a\x63om.keepkey.deviceprotocolB\x17KeepKeyMessageThorchain') + serialized_pb=_b('\n\x18messages-thorchain.proto\x1a\x0btypes.proto\"O\n\x13ThorchainGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\x12\x0f\n\x07testnet\x18\x03 \x01(\x08\"#\n\x10ThorchainAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"\xbb\x01\n\x0fThorchainSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x1a\n\x0e\x61\x63\x63ount_number\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x10\n\x08\x63hain_id\x18\x03 \x01(\t\x12\x12\n\nfee_amount\x18\x04 \x01(\r\x12\x0b\n\x03gas\x18\x05 \x01(\r\x12\x0c\n\x04memo\x18\x06 \x01(\t\x12\x14\n\x08sequence\x18\x07 \x01(\x04\x42\x02\x30\x01\x12\x11\n\tmsg_count\x18\x08 \x01(\r\x12\x0f\n\x07testnet\x18\t \x01(\x08\"\x15\n\x13ThorchainMsgRequest\"Y\n\x0fThorchainMsgAck\x12\x1f\n\x04send\x18\x01 \x01(\x0b\x32\x11.ThorchainMsgSend\x12%\n\x07\x64\x65posit\x18\x02 \x01(\x0b\x32\x14.ThorchainMsgDeposit\"\x8f\x01\n\x10ThorchainMsgSend\x12\x14\n\x0c\x66rom_address\x18\x06 \x01(\t\x12\x12\n\nto_address\x18\x07 \x01(\t\x12\x12\n\x06\x61mount\x18\x08 \x01(\x04\x42\x02\x30\x01\x12(\n\x0c\x61\x64\x64ress_type\x18\t \x01(\x0e\x32\x12.OutputAddressType\x12\r\n\x05\x64\x65nom\x18\x0b \x01(\tJ\x04\x08\n\x10\x0b\"V\n\x13ThorchainMsgDeposit\x12\r\n\x05\x61sset\x18\x01 \x01(\t\x12\x12\n\x06\x61mount\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x0c\n\x04memo\x18\x03 \x01(\t\x12\x0e\n\x06signer\x18\x04 \x01(\t\":\n\x11ThorchainSignedTx\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x42\x35\n\x1a\x63om.keepkey.deviceprotocolB\x17KeepKeyMessageThorchain') , dependencies=[types__pb2.DESCRIPTOR,]) @@ -287,6 +287,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='denom', full_name='ThorchainMsgSend.denom', index=4, + number=11, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -300,7 +307,7 @@ oneofs=[ ], serialized_start=464, - serialized_end=592, + serialized_end=607, ) @@ -351,8 +358,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=594, - serialized_end=680, + serialized_start=609, + serialized_end=695, ) @@ -389,8 +396,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=682, - serialized_end=740, + serialized_start=697, + serialized_end=755, ) _THORCHAINMSGACK.fields_by_name['send'].message_type = _THORCHAINMSGSEND diff --git a/keepkeylib/messages_zcash_pb2.py b/keepkeylib/messages_zcash_pb2.py index cfd76679..026e3128 100644 --- a/keepkeylib/messages_zcash_pb2.py +++ b/keepkeylib/messages_zcash_pb2.py @@ -3,6 +3,7 @@ import sys _b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf.internal import enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message from google.protobuf import reflection as _reflection @@ -19,9 +20,34 @@ name='messages-zcash.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x14messages-zcash.proto\"\xde\x02\n\rZcashSignPCZT\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07\x61\x63\x63ount\x18\x02 \x01(\r\x12\x11\n\tpczt_data\x18\x03 \x01(\x0c\x12\x11\n\tn_actions\x18\x04 \x01(\r\x12\x14\n\x0ctotal_amount\x18\x05 \x01(\x04\x12\x0b\n\x03\x66\x65\x65\x18\x06 \x01(\x04\x12\x11\n\tbranch_id\x18\x07 \x01(\r\x12\x15\n\rheader_digest\x18\x08 \x01(\x0c\x12\x1a\n\x12transparent_digest\x18\t \x01(\x0c\x12\x16\n\x0esapling_digest\x18\n \x01(\x0c\x12\x16\n\x0eorchard_digest\x18\x0b \x01(\x0c\x12\x15\n\rorchard_flags\x18\x0c \x01(\r\x12\x1d\n\x15orchard_value_balance\x18\r \x01(\x03\x12\x16\n\x0eorchard_anchor\x18\x0e \x01(\x0c\x12\x1c\n\x14n_transparent_inputs\x18\x1e \x01(\r\"\x81\x02\n\x0fZcashPCZTAction\x12\r\n\x05index\x18\x01 \x01(\r\x12\r\n\x05\x61lpha\x18\x02 \x01(\x0c\x12\x0f\n\x07sighash\x18\x03 \x01(\x0c\x12\x0e\n\x06\x63v_net\x18\x04 \x01(\x0c\x12\r\n\x05value\x18\x05 \x01(\x04\x12\x10\n\x08is_spend\x18\x06 \x01(\x08\x12\x11\n\tnullifier\x18\x07 \x01(\x0c\x12\x0b\n\x03\x63mx\x18\x08 \x01(\x0c\x12\x0b\n\x03\x65pk\x18\t \x01(\x0c\x12\x13\n\x0b\x65nc_compact\x18\n \x01(\x0c\x12\x10\n\x08\x65nc_memo\x18\x0b \x01(\x0c\x12\x16\n\x0e\x65nc_noncompact\x18\x0c \x01(\x0c\x12\n\n\x02rk\x18\r \x01(\x0c\x12\x16\n\x0eout_ciphertext\x18\x0e \x01(\x0c\"(\n\x12ZcashPCZTActionAck\x12\x12\n\nnext_index\x18\x01 \x01(\r\"3\n\x0fZcashSignedPCZT\x12\x12\n\nsignatures\x18\x01 \x03(\x0c\x12\x0c\n\x04txid\x18\x02 \x01(\x0c\"N\n\x12ZcashGetOrchardFVK\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07\x61\x63\x63ount\x18\x02 \x01(\r\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\"7\n\x0fZcashOrchardFVK\x12\n\n\x02\x61k\x18\x01 \x01(\x0c\x12\n\n\x02nk\x18\x02 \x01(\x0c\x12\x0c\n\x04rivk\x18\x03 \x01(\x0c\"Z\n\x15ZcashTransparentInput\x12\r\n\x05index\x18\x01 \x02(\r\x12\x0f\n\x07sighash\x18\x02 \x02(\x0c\x12\x11\n\taddress_n\x18\x03 \x03(\r\x12\x0e\n\x06\x61mount\x18\x04 \x01(\x04\"<\n\x13ZcashTransparentSig\x12\x11\n\tsignature\x18\x01 \x02(\x0c\x12\x12\n\nnext_index\x18\x02 \x01(\rB1\n\x1a\x63om.keepkey.deviceprotocolB\x13KeepKeyMessageZcash') + serialized_pb=_b('\n\x14messages-zcash.proto\"\xad\x05\n\rZcashSignPCZT\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07\x61\x63\x63ount\x18\x02 \x01(\r\x12\x11\n\tpczt_data\x18\x03 \x01(\x0c\x12\x11\n\tn_actions\x18\x04 \x01(\r\x12\x14\n\x0ctotal_amount\x18\x05 \x01(\x04\x12\x0b\n\x03\x66\x65\x65\x18\x06 \x01(\x04\x12\x11\n\tbranch_id\x18\x07 \x01(\r\x12\x15\n\rheader_digest\x18\x08 \x01(\x0c\x12\x1a\n\x12transparent_digest\x18\t \x01(\x0c\x12\x16\n\x0esapling_digest\x18\n \x01(\x0c\x12\x16\n\x0eorchard_digest\x18\x0b \x01(\x0c\x12\x15\n\rorchard_flags\x18\x0c \x01(\r\x12\x1d\n\x15orchard_value_balance\x18\r \x01(\x03\x12\x16\n\x0eorchard_anchor\x18\x0e \x01(\x0c\x12\x12\n\ntx_version\x18\x0f \x01(\r\x12\x18\n\x10version_group_id\x18\x10 \x01(\r\x12\x11\n\tlock_time\x18\x11 \x01(\r\x12\x15\n\rexpiry_height\x18\x12 \x01(\r\x12\x46\n\rshielded_pool\x18\x13 \x01(\x0e\x32\x12.ZcashShieldedPool:\x1bZCASH_SHIELDED_POOL_ORCHARD\x12\x17\n\x0fironwood_digest\x18\x14 \x01(\x0c\x12\x1a\n\x12n_ironwood_actions\x18\x15 \x01(\r\x12\x16\n\x0eironwood_flags\x18\x16 \x01(\r\x12\x1e\n\x16ironwood_value_balance\x18\x17 \x01(\x03\x12\x1d\n\x15n_transparent_outputs\x18\x1d \x01(\r\x12\x1c\n\x14n_transparent_inputs\x18\x1e \x01(\r\x12!\n\x19\x65xpected_seed_fingerprint\x18\x1f \x01(\x0c\"\xb9\x02\n\x0fZcashPCZTAction\x12\r\n\x05index\x18\x01 \x01(\r\x12\r\n\x05\x61lpha\x18\x02 \x01(\x0c\x12\x0f\n\x07sighash\x18\x03 \x01(\x0c\x12\x0e\n\x06\x63v_net\x18\x04 \x01(\x0c\x12\r\n\x05value\x18\x05 \x01(\x04\x12\x10\n\x08is_spend\x18\x06 \x01(\x08\x12\x11\n\tnullifier\x18\x07 \x01(\x0c\x12\x0b\n\x03\x63mx\x18\x08 \x01(\x0c\x12\x0b\n\x03\x65pk\x18\t \x01(\x0c\x12\x13\n\x0b\x65nc_compact\x18\n \x01(\x0c\x12\x10\n\x08\x65nc_memo\x18\x0b \x01(\x0c\x12\x16\n\x0e\x65nc_noncompact\x18\x0c \x01(\x0c\x12\n\n\x02rk\x18\r \x01(\x0c\x12\x16\n\x0eout_ciphertext\x18\x0e \x01(\x0c\x12\x11\n\trecipient\x18\x0f \x01(\x0c\x12\r\n\x05rseed\x18\x10 \x01(\x0c\x12\x14\n\x0cuser_address\x18\x11 \x01(\t\"(\n\x12ZcashPCZTActionAck\x12\x12\n\nnext_index\x18\x01 \x01(\r\"3\n\x0fZcashSignedPCZT\x12\x12\n\nsignatures\x18\x01 \x03(\x0c\x12\x0c\n\x04txid\x18\x02 \x01(\x0c\"N\n\x12ZcashGetOrchardFVK\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07\x61\x63\x63ount\x18\x02 \x01(\r\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\"Q\n\x0fZcashOrchardFVK\x12\n\n\x02\x61k\x18\x01 \x01(\x0c\x12\n\n\x02nk\x18\x02 \x01(\x0c\x12\x0c\n\x04rivk\x18\x03 \x01(\x0c\x12\x18\n\x10seed_fingerprint\x18\x04 \x01(\x0c\"q\n\x16ZcashTransparentOutput\x12\r\n\x05index\x18\x01 \x02(\r\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x12\x15\n\rscript_pubkey\x18\x03 \x01(\x0c\x12\x0e\n\x06is_tex\x18\x04 \x01(\x08\x12\x11\n\taddress_n\x18\x05 \x03(\r\"\xb0\x01\n\x15ZcashTransparentInput\x12\r\n\x05index\x18\x01 \x02(\r\x12\x0f\n\x07sighash\x18\x02 \x01(\x0c\x12\x11\n\taddress_n\x18\x03 \x03(\r\x12\x0e\n\x06\x61mount\x18\x04 \x01(\x04\x12\x14\n\x0cprevout_txid\x18\x05 \x01(\x0c\x12\x15\n\rprevout_index\x18\x06 \x01(\r\x12\x10\n\x08sequence\x18\x07 \x01(\r\x12\x15\n\rscript_pubkey\x18\x08 \x01(\x0c\"J\n\x13ZcashTransparentAck\x12\x19\n\x11next_output_index\x18\x01 \x01(\r\x12\x18\n\x10next_input_index\x18\x02 \x01(\r\",\n\x16ZcashTransparentSigned\x12\x12\n\nsignatures\x18\x01 \x03(\x0c\"\x8b\x01\n\x13ZcashDisplayAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07\x61\x63\x63ount\x18\x02 \x01(\r\x12!\n\x19\x65xpected_seed_fingerprint\x18\x07 \x01(\x0cJ\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06J\x04\x08\x06\x10\x07R\x07\x61\x64\x64ressR\x02\x61kR\x02nkR\x04rivk\"9\n\x0cZcashAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x18\n\x10seed_fingerprint\x18\x02 \x01(\x0c*V\n\x11ZcashShieldedPool\x12\x1f\n\x1bZCASH_SHIELDED_POOL_ORCHARD\x10\x00\x12 \n\x1cZCASH_SHIELDED_POOL_IRONWOOD\x10\x01\x42\x31\n\x1a\x63om.keepkey.deviceprotocolB\x13KeepKeyMessageZcash') ) +_ZCASHSHIELDEDPOOL = _descriptor.EnumDescriptor( + name='ZcashShieldedPool', + full_name='ZcashShieldedPool', + filename=None, + file=DESCRIPTOR, + values=[ + _descriptor.EnumValueDescriptor( + name='ZCASH_SHIELDED_POOL_ORCHARD', index=0, number=0, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='ZCASH_SHIELDED_POOL_IRONWOOD', index=1, number=1, + options=None, + type=None), + ], + containing_type=None, + options=None, + serialized_start=1903, + serialized_end=1989, +) +_sym_db.RegisterEnumDescriptor(_ZCASHSHIELDEDPOOL) + +ZcashShieldedPool = enum_type_wrapper.EnumTypeWrapper(_ZCASHSHIELDEDPOOL) +ZCASH_SHIELDED_POOL_ORCHARD = 0 +ZCASH_SHIELDED_POOL_IRONWOOD = 1 @@ -131,12 +157,89 @@ is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='n_transparent_inputs', full_name='ZcashSignPCZT.n_transparent_inputs', index=14, + name='tx_version', full_name='ZcashSignPCZT.tx_version', index=14, + number=15, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='version_group_id', full_name='ZcashSignPCZT.version_group_id', index=15, + number=16, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='lock_time', full_name='ZcashSignPCZT.lock_time', index=16, + number=17, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='expiry_height', full_name='ZcashSignPCZT.expiry_height', index=17, + number=18, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='shielded_pool', full_name='ZcashSignPCZT.shielded_pool', index=18, + number=19, type=14, cpp_type=8, label=1, + has_default_value=True, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='ironwood_digest', full_name='ZcashSignPCZT.ironwood_digest', index=19, + number=20, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='n_ironwood_actions', full_name='ZcashSignPCZT.n_ironwood_actions', index=20, + number=21, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='ironwood_flags', full_name='ZcashSignPCZT.ironwood_flags', index=21, + number=22, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='ironwood_value_balance', full_name='ZcashSignPCZT.ironwood_value_balance', index=22, + number=23, type=3, cpp_type=2, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='n_transparent_outputs', full_name='ZcashSignPCZT.n_transparent_outputs', index=23, + number=29, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='n_transparent_inputs', full_name='ZcashSignPCZT.n_transparent_inputs', index=24, number=30, type=13, cpp_type=3, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='expected_seed_fingerprint', full_name='ZcashSignPCZT.expected_seed_fingerprint', index=25, + number=31, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -150,7 +253,7 @@ oneofs=[ ], serialized_start=25, - serialized_end=375, + serialized_end=710, ) @@ -259,6 +362,27 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='recipient', full_name='ZcashPCZTAction.recipient', index=14, + number=15, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='rseed', full_name='ZcashPCZTAction.rseed', index=15, + number=16, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='user_address', full_name='ZcashPCZTAction.user_address', index=16, + number=17, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -271,8 +395,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=378, - serialized_end=635, + serialized_start=713, + serialized_end=1026, ) @@ -302,8 +426,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=637, - serialized_end=677, + serialized_start=1028, + serialized_end=1068, ) @@ -340,8 +464,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=679, - serialized_end=730, + serialized_start=1070, + serialized_end=1121, ) @@ -385,8 +509,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=732, - serialized_end=810, + serialized_start=1123, + serialized_end=1201, ) @@ -418,6 +542,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='seed_fingerprint', full_name='ZcashOrchardFVK.seed_fingerprint', index=3, + number=4, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -430,8 +561,67 @@ extension_ranges=[], oneofs=[ ], - serialized_start=812, - serialized_end=867, + serialized_start=1203, + serialized_end=1284, +) + + +_ZCASHTRANSPARENTOUTPUT = _descriptor.Descriptor( + name='ZcashTransparentOutput', + full_name='ZcashTransparentOutput', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='index', full_name='ZcashTransparentOutput.index', index=0, + number=1, type=13, cpp_type=3, label=2, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='amount', full_name='ZcashTransparentOutput.amount', index=1, + number=2, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='script_pubkey', full_name='ZcashTransparentOutput.script_pubkey', index=2, + number=3, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='is_tex', full_name='ZcashTransparentOutput.is_tex', index=3, + number=4, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='address_n', full_name='ZcashTransparentOutput.address_n', index=4, + number=5, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1286, + serialized_end=1399, ) @@ -451,7 +641,7 @@ options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='sighash', full_name='ZcashTransparentInput.sighash', index=1, - number=2, type=12, cpp_type=9, label=2, + number=2, type=12, cpp_type=9, label=1, has_default_value=False, default_value=_b(""), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, @@ -470,6 +660,34 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='prevout_txid', full_name='ZcashTransparentInput.prevout_txid', index=4, + number=5, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='prevout_index', full_name='ZcashTransparentInput.prevout_index', index=5, + number=6, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='sequence', full_name='ZcashTransparentInput.sequence', index=6, + number=7, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='script_pubkey', full_name='ZcashTransparentInput.script_pubkey', index=7, + number=8, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -482,27 +700,27 @@ extension_ranges=[], oneofs=[ ], - serialized_start=869, - serialized_end=959, + serialized_start=1402, + serialized_end=1578, ) -_ZCASHTRANSPARENTSIG = _descriptor.Descriptor( - name='ZcashTransparentSig', - full_name='ZcashTransparentSig', +_ZCASHTRANSPARENTACK = _descriptor.Descriptor( + name='ZcashTransparentAck', + full_name='ZcashTransparentAck', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( - name='signature', full_name='ZcashTransparentSig.signature', index=0, - number=1, type=12, cpp_type=9, label=2, - has_default_value=False, default_value=_b(""), + name='next_output_index', full_name='ZcashTransparentAck.next_output_index', index=0, + number=1, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='next_index', full_name='ZcashTransparentSig.next_index', index=1, + name='next_input_index', full_name='ZcashTransparentAck.next_input_index', index=1, number=2, type=13, cpp_type=3, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, @@ -520,10 +738,42 @@ extension_ranges=[], oneofs=[ ], - serialized_start=961, - serialized_end=1021, + serialized_start=1580, + serialized_end=1654, ) + +_ZCASHTRANSPARENTSIGNED = _descriptor.Descriptor( + name='ZcashTransparentSigned', + full_name='ZcashTransparentSigned', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='signatures', full_name='ZcashTransparentSigned.signatures', index=0, + number=1, type=12, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1656, + serialized_end=1700, +) + + _ZCASHDISPLAYADDRESS = _descriptor.Descriptor( name='ZcashDisplayAddress', full_name='ZcashDisplayAddress', @@ -546,29 +796,8 @@ is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='address', full_name='ZcashDisplayAddress.address', index=2, - number=3, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=_b("").decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), - _descriptor.FieldDescriptor( - name='ak', full_name='ZcashDisplayAddress.ak', index=3, - number=4, type=12, cpp_type=9, label=1, - has_default_value=False, default_value=_b(""), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), - _descriptor.FieldDescriptor( - name='nk', full_name='ZcashDisplayAddress.nk', index=4, - number=5, type=12, cpp_type=9, label=1, - has_default_value=False, default_value=_b(""), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), - _descriptor.FieldDescriptor( - name='rivk', full_name='ZcashDisplayAddress.rivk', index=5, - number=6, type=12, cpp_type=9, label=1, + name='expected_seed_fingerprint', full_name='ZcashDisplayAddress.expected_seed_fingerprint', index=2, + number=7, type=12, cpp_type=9, label=1, has_default_value=False, default_value=_b(""), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, @@ -585,8 +814,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1023, - serialized_end=1133, + serialized_start=1703, + serialized_end=1842, ) @@ -604,6 +833,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='seed_fingerprint', full_name='ZcashAddress.seed_fingerprint', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -616,20 +852,24 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1135, - serialized_end=1167, + serialized_start=1844, + serialized_end=1901, ) +_ZCASHSIGNPCZT.fields_by_name['shielded_pool'].enum_type = _ZCASHSHIELDEDPOOL DESCRIPTOR.message_types_by_name['ZcashSignPCZT'] = _ZCASHSIGNPCZT DESCRIPTOR.message_types_by_name['ZcashPCZTAction'] = _ZCASHPCZTACTION DESCRIPTOR.message_types_by_name['ZcashPCZTActionAck'] = _ZCASHPCZTACTIONACK DESCRIPTOR.message_types_by_name['ZcashSignedPCZT'] = _ZCASHSIGNEDPCZT DESCRIPTOR.message_types_by_name['ZcashGetOrchardFVK'] = _ZCASHGETORCHARDFVK DESCRIPTOR.message_types_by_name['ZcashOrchardFVK'] = _ZCASHORCHARDFVK +DESCRIPTOR.message_types_by_name['ZcashTransparentOutput'] = _ZCASHTRANSPARENTOUTPUT DESCRIPTOR.message_types_by_name['ZcashTransparentInput'] = _ZCASHTRANSPARENTINPUT -DESCRIPTOR.message_types_by_name['ZcashTransparentSig'] = _ZCASHTRANSPARENTSIG +DESCRIPTOR.message_types_by_name['ZcashTransparentAck'] = _ZCASHTRANSPARENTACK +DESCRIPTOR.message_types_by_name['ZcashTransparentSigned'] = _ZCASHTRANSPARENTSIGNED DESCRIPTOR.message_types_by_name['ZcashDisplayAddress'] = _ZCASHDISPLAYADDRESS DESCRIPTOR.message_types_by_name['ZcashAddress'] = _ZCASHADDRESS +DESCRIPTOR.enum_types_by_name['ZcashShieldedPool'] = _ZCASHSHIELDEDPOOL _sym_db.RegisterFileDescriptor(DESCRIPTOR) ZcashSignPCZT = _reflection.GeneratedProtocolMessageType('ZcashSignPCZT', (_message.Message,), dict( @@ -674,6 +914,13 @@ )) _sym_db.RegisterMessage(ZcashOrchardFVK) +ZcashTransparentOutput = _reflection.GeneratedProtocolMessageType('ZcashTransparentOutput', (_message.Message,), dict( + DESCRIPTOR = _ZCASHTRANSPARENTOUTPUT, + __module__ = 'messages_zcash_pb2' + # @@protoc_insertion_point(class_scope:ZcashTransparentOutput) + )) +_sym_db.RegisterMessage(ZcashTransparentOutput) + ZcashTransparentInput = _reflection.GeneratedProtocolMessageType('ZcashTransparentInput', (_message.Message,), dict( DESCRIPTOR = _ZCASHTRANSPARENTINPUT, __module__ = 'messages_zcash_pb2' @@ -681,12 +928,19 @@ )) _sym_db.RegisterMessage(ZcashTransparentInput) -ZcashTransparentSig = _reflection.GeneratedProtocolMessageType('ZcashTransparentSig', (_message.Message,), dict( - DESCRIPTOR = _ZCASHTRANSPARENTSIG, +ZcashTransparentAck = _reflection.GeneratedProtocolMessageType('ZcashTransparentAck', (_message.Message,), dict( + DESCRIPTOR = _ZCASHTRANSPARENTACK, + __module__ = 'messages_zcash_pb2' + # @@protoc_insertion_point(class_scope:ZcashTransparentAck) + )) +_sym_db.RegisterMessage(ZcashTransparentAck) + +ZcashTransparentSigned = _reflection.GeneratedProtocolMessageType('ZcashTransparentSigned', (_message.Message,), dict( + DESCRIPTOR = _ZCASHTRANSPARENTSIGNED, __module__ = 'messages_zcash_pb2' - # @@protoc_insertion_point(class_scope:ZcashTransparentSig) + # @@protoc_insertion_point(class_scope:ZcashTransparentSigned) )) -_sym_db.RegisterMessage(ZcashTransparentSig) +_sym_db.RegisterMessage(ZcashTransparentSigned) ZcashDisplayAddress = _reflection.GeneratedProtocolMessageType('ZcashDisplayAddress', (_message.Message,), dict( DESCRIPTOR = _ZCASHDISPLAYADDRESS, diff --git a/keepkeylib/signed_metadata.py b/keepkeylib/signed_metadata.py index faab78ed..be890083 100644 --- a/keepkeylib/signed_metadata.py +++ b/keepkeylib/signed_metadata.py @@ -20,6 +20,45 @@ ARG_FORMAT_ADDRESS = 1 ARG_FORMAT_AMOUNT = 2 ARG_FORMAT_BYTES = 3 +# Attested printable label (e.g. protocol name "Uniswap V2"). value = ASCII. +ARG_FORMAT_STRING = 4 +# Human-readable token amount: value = decimals(1) + symbol_len(1) + +# symbol(<=10 [A-Za-z0-9]) + amount(1..32 big-endian). Firmware renders it +# decimal-scaled with the symbol, e.g. "1000 USDC" — this is the "what" the +# clear-signing plan asks for instead of a raw wei integer. +ARG_FORMAT_TOKEN_AMOUNT = 5 + +# Max value bytes on the wire. Legacy formats stay <=32; TOKEN_AMOUNT needs +# decimals(1)+symbol_len(1)+symbol(<=10)+amount(<=32) = up to 44. +METADATA_MAX_ARG_VALUE_LEN = 44 + + +def _require(condition, message): + """Precondition check that survives `python -O`. + + These serializers build the bytes that get SIGNED. `assert` is stripped + under -O, so an optimized process would serialize and sign an over-long + method name, a 19-byte address or a 9th argument instead of refusing it. + A signing precondition must not be an assertion. + """ + if not condition: + raise ValueError(message) + + +def token_amount_value(amount, decimals, symbol): + """Build an ARG_FORMAT_TOKEN_AMOUNT value: decimals + symbol + amount. + + amount: non-negative int (raw on-chain units). decimals: int 0..36. + symbol: short ticker, [A-Za-z0-9], <=10 chars. + """ + sym = symbol.encode('ascii') + _require(0 < len(sym) <= 10 and sym.isalnum(), + 'symbol must be 1-10 alphanumeric characters, got %r' % symbol) + _require(0 <= decimals <= 36, 'decimals must be 0..36, got %r' % decimals) + # Minimal big-endian amount, at least 1 byte, at most 32. + n = amount.to_bytes(32, 'big').lstrip(b'\x00') or b'\x00' + _require(len(n) <= 32, 'amount does not fit in 32 bytes') + return bytes([decimals, len(sym)]) + sym + n CLASSIFICATION_OPAQUE = 0 CLASSIFICATION_VERIFIED = 1 @@ -128,7 +167,7 @@ def serialize_metadata( args: list, classification: int = CLASSIFICATION_VERIFIED, timestamp: int = None, - key_id: int = 0, + key_id: int = 3, version: int = 1, ) -> bytes: """Serialize metadata fields into canonical binary (unsigned). @@ -137,12 +176,21 @@ def serialize_metadata( chain_id: EIP-155 chain ID contract_address: 20-byte contract address selector: 4-byte function selector - tx_hash: 32-byte keccak-256 of unsigned tx (can be zeroed for phase 1) + tx_hash: 32-byte keccak-256 sighash of the UNSIGNED tx. Firmware binds + the emitted signature to this value (signed_metadata_enforce), so it + MUST equal the real digest the device will sign. Compute it with + eth_sighash_legacy() / eth_sighash_eip1559() below — never zero it. method_name: UTF-8 method name (max 64 bytes) args: list of dicts with keys: name, format, value (bytes) classification: 0=OPAQUE, 1=VERIFIED, 2=MALFORMED timestamp: Unix seconds (defaults to now) - key_id: embedded public key slot (0-3) + key_id: embedded public key slot. Defaults to 3, the DEBUG_LINK CI test + slot whose pubkey == TEST_PRIVATE_KEY's pubkey (see + assert_test_key_matches_slot3). The embedded key_id MUST equal both + the protocol-level EthereumTxMetadata.key_id and the slot the + signature verifies against, or firmware returns MALFORMED. + PRODUCTION callers (Pioneer) MUST pass key_id=0 explicitly and sign + with the offline production key. version: schema version (must be 1) Returns: @@ -151,11 +199,14 @@ def serialize_metadata( if timestamp is None: timestamp = int(time.time()) - assert len(contract_address) == 20 - assert len(selector) == 4 - assert len(tx_hash) == 32 - assert len(method_name.encode('utf-8')) <= 64 - assert len(args) <= 8 + _require(len(contract_address) == 20, + 'contract_address must be 20 bytes, got %d' % len(contract_address)) + _require(len(selector) == 4, 'selector must be 4 bytes, got %d' % len(selector)) + _require(len(tx_hash) == 32, 'tx_hash must be 32 bytes, got %d' % len(tx_hash)) + _require(len(method_name.encode('utf-8')) <= 64, + 'method_name must be <=64 UTF-8 bytes, got %d' + % len(method_name.encode('utf-8'))) + _require(len(args) <= 8, 'at most 8 args, got %d' % len(args)) buf = bytearray() @@ -186,7 +237,8 @@ def serialize_metadata( for arg in args: # name (1-byte length prefix + UTF-8) arg_name = arg['name'].encode('utf-8') - assert len(arg_name) <= 32 + _require(len(arg_name) <= 32, + 'arg name must be <=32 UTF-8 bytes, got %d' % len(arg_name)) buf.append(len(arg_name)) buf.extend(arg_name) @@ -195,7 +247,9 @@ def serialize_metadata( # value (2-byte length prefix + raw bytes) val = arg['value'] - assert len(val) <= 32 # METADATA_MAX_ARG_VALUE_LEN + _require(len(val) <= METADATA_MAX_ARG_VALUE_LEN, + 'arg value must be <=%d bytes, got %d' + % (METADATA_MAX_ARG_VALUE_LEN, len(val))) buf.extend(struct.pack('>H', len(val))) buf.extend(val) @@ -211,6 +265,120 @@ def serialize_metadata( return bytes(buf) +# ── v2: static schema (no tx_hash, no values; device decodes calldata) ── +# +# METADATA_VERSION_SCHEMA blobs attest only HOW to decode a curated +# (chainId, contract, selector): the method label and, per argument, a name + +# display format (+ static decimals/symbol for token amounts). They carry NO +# tx_hash and NO argument values — the device decodes the values from the exact +# calldata it is about to sign. Signed once, OFFLINE; no per-tx signer. +# +# Firmware format (parse_v2_args in lib/firmware/signed_metadata.c): +# version(1)=0x02 + chain_id(4 BE) + contract(20) + selector(4) + +# method_len(2 BE) + method + num_args(1) + +# [per arg: name_len(1) + name + display_format(1) + +# (if TOKEN_AMOUNT: decimals(1) + symbol_len(1) + symbol)] + +# classification(1) + timestamp(4 BE) + key_id(1) + signature(64) + recovery(1) +# +# Supported display formats (fixed single ABI word at offset 4 + 32*i): +# ARG_FORMAT_ADDRESS, ARG_FORMAT_AMOUNT, ARG_FORMAT_TOKEN_AMOUNT. +METADATA_VERSION_SCHEMA = 2 + + +def serialize_schema_metadata( + chain_id: int, + contract_address: bytes, + selector: bytes, + method_name: str, + args: list, + classification: int = CLASSIFICATION_VERIFIED, + timestamp: int = None, + key_id: int = 3, +) -> bytes: + """Serialize a v2 (static schema) metadata payload (unsigned). + + Args mirror serialize_metadata(), minus tx_hash. Each entry of `args` is a + dict: {name, format, [decimals, symbol]} — NO 'value' (the device decodes it + from the calldata). `decimals`/`symbol` are required for TOKEN_AMOUNT and + ignored otherwise. Call sign_metadata() on the result. + """ + if timestamp is None: + timestamp = int(time.time()) + + _require(len(contract_address) == 20, + 'contract_address must be 20 bytes, got %d' % len(contract_address)) + _require(len(selector) == 4, 'selector must be 4 bytes, got %d' % len(selector)) + _require(len(method_name.encode('utf-8')) <= 64, + 'method_name must be <=64 UTF-8 bytes, got %d' + % len(method_name.encode('utf-8'))) + _require(len(args) <= 8, 'at most 8 args, got %d' % len(args)) + + buf = bytearray() + buf.append(METADATA_VERSION_SCHEMA) + buf.extend(struct.pack('>I', chain_id)) + buf.extend(contract_address) + buf.extend(selector) + + name_bytes = method_name.encode('utf-8') + buf.extend(struct.pack('>H', len(name_bytes))) + buf.extend(name_bytes) + + buf.append(len(args)) + for arg in args: + arg_name = arg['name'].encode('utf-8') + _require(len(arg_name) <= 32, + 'arg name must be <=32 UTF-8 bytes, got %d' % len(arg_name)) + buf.append(len(arg_name)) + buf.extend(arg_name) + + fmt = arg['format'] + _require(fmt in (ARG_FORMAT_ADDRESS, ARG_FORMAT_AMOUNT, + ARG_FORMAT_TOKEN_AMOUNT), + 'v2 supports only fixed-word ADDRESS/AMOUNT/TOKEN_AMOUNT, ' + 'got %r' % fmt) + buf.append(fmt) + if fmt == ARG_FORMAT_TOKEN_AMOUNT: + sym = arg['symbol'].encode('ascii') + _require(0 < len(sym) <= 10 and sym.isalnum(), + 'symbol must be 1-10 alphanumeric characters, got %r' + % arg['symbol']) + _require(0 <= arg['decimals'] <= 36, + 'decimals must be 0..36, got %r' % arg['decimals']) + buf.append(arg['decimals']) + buf.append(len(sym)) + buf.extend(sym) + + buf.append(classification) + buf.extend(struct.pack('>I', timestamp)) + buf.append(key_id) + + return bytes(buf) + + +def schema_calldata(selector: bytes, args: list) -> bytes: + """ABI-encode the calldata a v2 schema decodes: selector + one 32-byte head + word per arg. ADDRESS -> left-zero-padded 20-byte address; AMOUNT / + TOKEN_AMOUNT -> big-endian uint256. Used to build a tx whose calldata the + device will decode against a serialize_schema_metadata() blob. + + Each arg dict needs 'format' plus a concrete value: 'address' (20 bytes) for + ADDRESS, or 'amount' (int) for AMOUNT/TOKEN_AMOUNT. + """ + data = bytearray(selector) + for arg in args: + fmt = arg['format'] + if fmt == ARG_FORMAT_ADDRESS: + addr = arg['address'] + _require(len(addr) == 20, + 'ADDRESS arg must be 20 bytes, got %d' % len(addr)) + data.extend(b'\x00' * 12 + addr) + elif fmt in (ARG_FORMAT_AMOUNT, ARG_FORMAT_TOKEN_AMOUNT): + data.extend(int(arg['amount']).to_bytes(32, 'big')) + else: + raise ValueError('unsupported v2 arg format %r' % fmt) + return bytes(data) + + def sign_metadata(payload: bytes, private_key: bytes = None) -> bytes: """Sign the canonical binary payload and return the complete signed blob. @@ -228,41 +396,41 @@ def sign_metadata(payload: bytes, private_key: bytes = None) -> bytes: digest = hashlib.sha256(payload).digest() + # NOTE: firmware hashes the identical byte range — sha256 over + # version..key_id (i.e. the whole serialize_metadata() output), excluding + # the trailing signature(64)+recovery(1). See signed_metadata_process(): + # signed_len = payload_len - 64 - 1. try: - from ecdsa import SigningKey, SECP256k1, util - sk = SigningKey.from_string(private_key, curve=SECP256k1) - sig_der = sk.sign_digest(digest, sigencode=util.sigencode_string) - # sig_der is r(32) || s(32) = 64 bytes - r = sig_der[:32] - s = sig_der[32:] - - # Recovery: compute v (27 or 28) - vk = sk.get_verifying_key() - pubkey = b'\x04' + vk.to_string() - # Try recovery with v=0 and v=1 - from ecdsa import VerifyingKey - for v in (0, 1): - try: - recovered = VerifyingKey.from_public_key_recovery_with_digest( - sig_der, digest, SECP256k1, hashfunc=hashlib.sha256 - ) - for i, rk in enumerate(recovered): - if rk.to_string() == vk.to_string(): - recovery = 27 + i - break - else: - recovery = 27 - break - except Exception: - continue - else: - recovery = 27 - - except ImportError: - # Fallback: zero signature for struct-only testing - r = b'\x00' * 32 - s = b'\x00' * 32 - recovery = 27 + from ecdsa import SigningKey, SECP256k1, util, VerifyingKey + except ImportError as exc: + # Fail loud. A zero signature would be silently rejected by firmware as + # MALFORMED, disguising "ecdsa not installed" as a crypto/key mismatch. + raise RuntimeError( + "The 'ecdsa' package is required to sign metadata " + "(pip install ecdsa)." + ) from exc + + sk = SigningKey.from_string(private_key, curve=SECP256k1) + # RFC 6979 deterministic nonce: same payload + key => byte-identical blob. + # Reference vectors stay reproducible and signers never depend on an RNG + # (nonce reuse with a bad RNG would leak the signing key). + sig = sk.sign_digest_deterministic( + digest, hashfunc=hashlib.sha256, + sigencode=util.sigencode_string) # r(32)||s(32) + r = sig[:32] + s = sig[32:] + + # Recovery byte (27/28). Firmware verifies against the stored slot pubkey and + # ignores this byte, but the canonical blob carries it. + vk = sk.get_verifying_key() + recovered = VerifyingKey.from_public_key_recovery_with_digest( + sig, digest, SECP256k1, hashfunc=hashlib.sha256 + ) + recovery = 27 + for i, rk in enumerate(recovered): + if rk.to_string() == vk.to_string(): + recovery = 27 + i + break return payload + r + s + bytes([recovery]) @@ -280,10 +448,13 @@ def build_test_metadata( """Convenience: build a complete signed test metadata blob. Defaults to an Aave V3 supply() call on Ethereum mainnet. - Uses key_id=1 (CI test slot) by default. + Uses key_id=3 (the DEBUG_LINK CI test slot) by default and signs with + TEST_PRIVATE_KEY, whose pubkey == firmware METADATA_PUBKEYS[3]. """ if contract_address is None: - contract_address = bytes.fromhex('7d2768de32b0b80b7a3454c06bdac94a69ddc7a9') + # Aave V3 Pool proxy. Was the V2 LendingPool, which does not + # expose the supply() selector defaulted below. + contract_address = bytes.fromhex('87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2') if selector is None: selector = bytes.fromhex('617ba037') if tx_hash is None: @@ -318,3 +489,178 @@ def build_test_metadata( **kwargs, ) return sign_metadata(payload) + + +# ── Test-signer ↔ key-slot binding ──────────────────────────────────── +# The only key the test suite can sign with is TEST_PRIVATE_KEY, derived via +# SignIdentity index 0 (see _derive_insight_key(slot=0)). Phase 1 firmware +# has NO built-in keys: the suite loads this pubkey into key slot 3 through +# LoadClearsignSigner (user-confirmed, RAM-only) before signing vectors. +# The "0" and the "3" are DIFFERENT namespaces — derivation index vs key_id +# slot — and the mapping index0 -> slot3 is intentional. Do NOT "fix" it by +# deriving at slot=3 or embedding key_id=0. +FIRMWARE_SLOT3_PUBKEY = bytes.fromhex( + '02e3b3015c47ddcaabe4f8e872f1ed8f09ca145a8d81770d92213d56da31ab5107' +) + + +def test_signer_compressed_pubkey(private_key: bytes = None) -> bytes: + """Return the 33-byte compressed secp256k1 pubkey for the signer.""" + from ecdsa import SigningKey, SECP256k1 + if private_key is None: + private_key = TEST_PRIVATE_KEY + vk = SigningKey.from_string(private_key, curve=SECP256k1).get_verifying_key() + point = vk.pubkey.point + prefix = 0x02 if (point.y() % 2 == 0) else 0x03 + return bytes([prefix]) + point.x().to_bytes(32, 'big') + + +def assert_test_key_matches_slot3(): + """Prove pubkey(TEST_PRIVATE_KEY) == FIRMWARE_SLOT3_PUBKEY (the key the + suite loads into slot 3 via LoadClearsignSigner). + + Guards the key_id=3 default: if this fails, every VERIFIED test vector would + be rejected as MALFORMED by ecdsa_verify_digest against the wrong key. + """ + pub = test_signer_compressed_pubkey() + if pub != FIRMWARE_SLOT3_PUBKEY: + raise AssertionError( + "Test signer pubkey %s != firmware slot 3 %s — key_id=3 vectors " + "will not verify on device." % (pub.hex(), FIRMWARE_SLOT3_PUBKEY.hex()) + ) + return pub + + +# ── Ethereum sighash (keccak-256 over RLP) ───────────────────────────── +# Produces the EXACT digest firmware feeds to ecdsa_sign_digest, so that a +# metadata blob's tx_hash binds the real transaction. Cross-checked against the +# device: a known signed legacy tx recovers to its m/44'/60'/0'/0/0 signer. + +_KECCAK_RC = [ + 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000, + 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009, + 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A, + 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003, + 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A, + 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008, +] +_KECCAK_ROT = [ + [0, 36, 3, 41, 18], + [1, 44, 10, 45, 2], + [62, 6, 43, 15, 61], + [28, 55, 25, 21, 56], + [27, 20, 39, 8, 14], +] +_KECCAK_MASK = (1 << 64) - 1 + + +def _rotl64(x, n): + return ((x << n) | (x >> (64 - n))) & _KECCAK_MASK + + +def _keccak_f1600(st): + for rc in _KECCAK_RC: + c = [st[x][0] ^ st[x][1] ^ st[x][2] ^ st[x][3] ^ st[x][4] for x in range(5)] + d = [c[(x - 1) % 5] ^ _rotl64(c[(x + 1) % 5], 1) for x in range(5)] + for x in range(5): + for y in range(5): + st[x][y] ^= d[x] + b = [[0] * 5 for _ in range(5)] + for x in range(5): + for y in range(5): + b[y][(2 * x + 3 * y) % 5] = _rotl64(st[x][y], _KECCAK_ROT[x][y]) + for x in range(5): + for y in range(5): + st[x][y] = b[x][y] ^ ((~b[(x + 1) % 5][y]) & b[(x + 2) % 5][y]) + st[0][0] ^= rc + + +def keccak256(data: bytes) -> bytes: + """Keccak-256 (Ethereum), NOT NIST SHA3-256 (different padding).""" + rate = 136 # 1088-bit rate for 256-bit output + st = [[0] * 5 for _ in range(5)] + msg = bytearray(data) + msg.append(0x01) # keccak pad10*1 (0x01 .. 0x80), distinct from SHA3's 0x06 + while len(msg) % rate != 0: + msg.append(0x00) + msg[-1] ^= 0x80 + for off in range(0, len(msg), rate): + block = msg[off:off + rate] + for i in range(rate // 8): + st[i % 5][i // 5] ^= int.from_bytes(block[i * 8:i * 8 + 8], 'little') + _keccak_f1600(st) + out = bytearray() + while len(out) < 32: + for y in range(5): + for x in range(5): + if len(out) < 32: + out += st[x][y].to_bytes(8, 'little') + return bytes(out[:32]) + + +def _int_min_be(value: int) -> bytes: + """Minimal big-endian (no leading zeros); 0 -> b'' (RLP integer encoding).""" + if value == 0: + return b'' + out = bytearray() + while value > 0: + out.insert(0, value & 0xFF) + value >>= 8 + return bytes(out) + + +def _rlp_str(b: bytes) -> bytes: + if len(b) == 1 and b[0] < 0x80: + return b + if len(b) <= 55: + return bytes([0x80 + len(b)]) + b + le = _int_min_be(len(b)) + return bytes([0xB7 + len(le)]) + le + b + + +def _rlp_list(items) -> bytes: + body = b''.join(items) + if len(body) <= 55: + return bytes([0xC0 + len(body)]) + body + le = _int_min_be(len(body)) + return bytes([0xF7 + len(le)]) + le + body + + +def eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, data, chain_id): + """keccak256(rlp([nonce, gasPrice, gasLimit, to, value, data, chainId,0,0])). + + `to` is 20 raw bytes (b'' for contract creation); ints are minimal-BE. + Matches firmware ethereum.c legacy EIP-155 hashing exactly. + """ + items = [ + _rlp_str(_int_min_be(nonce)), + _rlp_str(_int_min_be(gas_price)), + _rlp_str(_int_min_be(gas_limit)), + _rlp_str(bytes(to)), + _rlp_str(_int_min_be(value)), + _rlp_str(bytes(data)), + ] + if chain_id: + items += [_rlp_str(_int_min_be(chain_id)), _rlp_str(b''), _rlp_str(b'')] + return keccak256(_rlp_list(items)) + + +def eth_sighash_eip1559(chain_id, nonce, max_priority_fee_per_gas, + max_fee_per_gas, gas_limit, to, value, data): + """keccak256(0x02 || rlp([chainId, nonce, maxPriorityFee, maxFee, gasLimit, + to, value, data, []])) with an empty (0xC0) access list. + + Matches firmware ethereum.c EIP-1559 hashing exactly. + """ + items = [ + _rlp_str(_int_min_be(chain_id)), + _rlp_str(_int_min_be(nonce)), + _rlp_str(_int_min_be(max_priority_fee_per_gas)), + _rlp_str(_int_min_be(max_fee_per_gas)), + _rlp_str(_int_min_be(gas_limit)), + _rlp_str(bytes(to)), + _rlp_str(_int_min_be(value)), + _rlp_str(bytes(data)), + _rlp_list([]), # empty access list -> 0xC0 + ] + return keccak256(b'\x02' + _rlp_list(items)) diff --git a/keepkeylib/transport_udp.py b/keepkeylib/transport_udp.py index 05767de7..8fa080ae 100644 --- a/keepkeylib/transport_udp.py +++ b/keepkeylib/transport_udp.py @@ -2,10 +2,35 @@ '''SocketTransport implements TCP socket interface for Transport.''' +import os import socket from select import select from .transport import Transport +# A dead emulator must surface as an ERROR, not as an infinite wait. +# +# The socket had no timeout, so when the emulator segfaulted mid-suite, +# recv() blocked in a syscall until something outside killed the process -- +# in CI that was a 30-minute job timeout reported as "cancelled", which reads +# as an infrastructure blip rather than the device crash it actually was. It +# hid a real segfault for at least six merges. +# +# Generous by default because a confirm screen legitimately waits on a human; +# override for unattended runs with KK_UDP_TIMEOUT (seconds, 0 disables). +DEFAULT_TIMEOUT = float(os.environ.get('KK_UDP_TIMEOUT', '60')) + +class EmulatorNotResponding(Exception): + """The emulator stopped answering. + + Deliberately NOT an IOError/OSError. On Python 3, IOError, OSError and + socket.error are the same class, so the detailed timeout this transport + raises was caught by _read()'s own `except socket.error`, printed as + "Failed to read from device" and turned into None -- the caller never saw + the actionable message. Raising outside that hierarchy is what lets it + reach the caller. + """ + + class FakeRead(object): # Let's pretend we have a file-like interface def __init__(self, func): @@ -31,6 +56,8 @@ def __init__(self, device, *args, **kwargs): def _open(self): self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket.connect(self.device) + if DEFAULT_TIMEOUT > 0: + self.socket.settimeout(DEFAULT_TIMEOUT) def _close(self): self.socket.close() @@ -51,13 +78,36 @@ def _read(self): try: (msg_type, datalen) = self._read_headers(FakeRead(self._raw_read)) return (msg_type, self._raw_read(datalen)) + except EmulatorNotResponding: + # Actionable and already explained -- let it reach the caller + # instead of collapsing it to None. Listed first because on + # Python 3 the handler below would otherwise catch it: IOError, + # OSError and socket.error are one class. + raise except socket.error: print("Failed to read from device") return None def _raw_read(self, length): while len(self.buffer) < length: - data = self.socket.recv(64) + try: + data = self.socket.recv(64) + except ConnectionRefusedError: + # A closed emulator port usually refuses rather than times out. + raise EmulatorNotResponding( + 'The emulator at %s:%d refused the connection -- it is not ' + 'running or has crashed.' % (self.device[0], self.device[1])) + except socket.timeout: + # Name the cause. "timed out" alone sends people looking at the + # test; the device is what stopped answering. + raise EmulatorNotResponding( + 'No response from the emulator at %s:%d after %gs -- it is ' + 'not running, has crashed, or is wedged on a confirm screen ' + 'nothing acknowledged. Set KK_UDP_TIMEOUT to change or 0 to ' + 'disable.' % (self.device[0], self.device[1], + DEFAULT_TIMEOUT)) + if not data: + raise EmulatorNotResponding('Emulator closed the connection') self.buffer += data[1:] ret = self.buffer[:length] diff --git a/keepkeylib/types_pb2.py b/keepkeylib/types_pb2.py index 9497bfd1..e33c52df 100644 --- a/keepkeylib/types_pb2.py +++ b/keepkeylib/types_pb2.py @@ -21,7 +21,7 @@ name='types.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x0btypes.proto\x1a google/protobuf/descriptor.proto\"\x80\x01\n\nHDNodeType\x12\r\n\x05\x64\x65pth\x18\x01 \x02(\r\x12\x13\n\x0b\x66ingerprint\x18\x02 \x02(\r\x12\x11\n\tchild_num\x18\x03 \x02(\r\x12\x12\n\nchain_code\x18\x04 \x02(\x0c\x12\x13\n\x0bprivate_key\x18\x05 \x01(\x0c\x12\x12\n\npublic_key\x18\x06 \x01(\x0c\">\n\x0eHDNodePathType\x12\x19\n\x04node\x18\x01 \x02(\x0b\x32\x0b.HDNodeType\x12\x11\n\taddress_n\x18\x02 \x03(\r\"\xf9\x03\n\x08\x43oinType\x12\x11\n\tcoin_name\x18\x01 \x01(\t\x12\x15\n\rcoin_shortcut\x18\x02 \x01(\t\x12\x17\n\x0c\x61\x64\x64ress_type\x18\x03 \x01(\r:\x01\x30\x12\x11\n\tmaxfee_kb\x18\x04 \x01(\x04\x12\x1c\n\x11\x61\x64\x64ress_type_p2sh\x18\x05 \x01(\r:\x01\x35\x12\x1d\n\x15signed_message_header\x18\x08 \x01(\t\x12\x1a\n\x12\x62ip44_account_path\x18\t \x01(\r\x12\x0e\n\x06\x66orkid\x18\x0c \x01(\r\x12\x10\n\x08\x64\x65\x63imals\x18\r \x01(\r\x12\x18\n\x10\x63ontract_address\x18\x0e \x01(\x0c\x12\x1c\n\nxpub_magic\x18\x10 \x01(\r:\x08\x37\x36\x30\x36\x37\x33\x35\x38\x12\x0e\n\x06segwit\x18\x12 \x01(\x08\x12\x14\n\x0c\x66orce_bip143\x18\x13 \x01(\x08\x12\x12\n\ncurve_name\x18\x14 \x01(\t\x12\x17\n\x0f\x63\x61shaddr_prefix\x18\x15 \x01(\t\x12\x15\n\rbech32_prefix\x18\x16 \x01(\t\x12\x0e\n\x06\x64\x65\x63red\x18\x17 \x01(\x08\x12\x1e\n\x16xpub_magic_segwit_p2sh\x18\x19 \x01(\r\x12 \n\x18xpub_magic_segwit_native\x18\x1a \x01(\r\x12\x17\n\x0fnanoaddr_prefix\x18\x1b \x01(\t\x12\x0f\n\x07taproot\x18\x1c \x01(\x08\"[\n\x18MultisigRedeemScriptType\x12 \n\x07pubkeys\x18\x01 \x03(\x0b\x32\x0f.HDNodePathType\x12\x12\n\nsignatures\x18\x02 \x03(\x0c\x12\t\n\x01m\x18\x03 \x01(\r\"\x9f\x02\n\x0bTxInputType\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x11\n\tprev_hash\x18\x02 \x02(\x0c\x12\x12\n\nprev_index\x18\x03 \x02(\r\x12\x12\n\nscript_sig\x18\x04 \x01(\x0c\x12\x1c\n\x08sequence\x18\x05 \x01(\r:\n4294967295\x12\x33\n\x0bscript_type\x18\x06 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\x12+\n\x08multisig\x18\x07 \x01(\x0b\x32\x19.MultisigRedeemScriptType\x12\x0e\n\x06\x61mount\x18\x08 \x01(\x04\x12\x13\n\x0b\x64\x65\x63red_tree\x18\t \x01(\r\x12\x1d\n\x15\x64\x65\x63red_script_version\x18\n \x01(\r\"\xfe\x01\n\x0cTxOutputType\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\taddress_n\x18\x02 \x03(\r\x12\x0e\n\x06\x61mount\x18\x03 \x02(\x04\x12&\n\x0bscript_type\x18\x04 \x02(\x0e\x32\x11.OutputScriptType\x12+\n\x08multisig\x18\x05 \x01(\x0b\x32\x19.MultisigRedeemScriptType\x12\x16\n\x0eop_return_data\x18\x06 \x01(\x0c\x12(\n\x0c\x61\x64\x64ress_type\x18\x07 \x01(\x0e\x32\x12.OutputAddressType\x12\x1d\n\x15\x64\x65\x63red_script_version\x18\t \x01(\rJ\x04\x08\x08\x10\t\"W\n\x0fTxOutputBinType\x12\x0e\n\x06\x61mount\x18\x01 \x02(\x04\x12\x15\n\rscript_pubkey\x18\x02 \x02(\x0c\x12\x1d\n\x15\x64\x65\x63red_script_version\x18\x03 \x01(\r\"\xc2\x02\n\x0fTransactionType\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x1c\n\x06inputs\x18\x02 \x03(\x0b\x32\x0c.TxInputType\x12%\n\x0b\x62in_outputs\x18\x03 \x03(\x0b\x32\x10.TxOutputBinType\x12\x1e\n\x07outputs\x18\x05 \x03(\x0b\x32\r.TxOutputType\x12\x11\n\tlock_time\x18\x04 \x01(\r\x12\x12\n\ninputs_cnt\x18\x06 \x01(\r\x12\x13\n\x0boutputs_cnt\x18\x07 \x01(\r\x12\x12\n\nextra_data\x18\x08 \x01(\x0c\x12\x16\n\x0e\x65xtra_data_len\x18\t \x01(\r\x12\x0e\n\x06\x65xpiry\x18\n \x01(\r\x12\x14\n\x0coverwintered\x18\x0b \x01(\x08\x12\x18\n\x10version_group_id\x18\x0c \x01(\r\x12\x11\n\tbranch_id\x18\r \x01(\r\"%\n\x12RawTransactionType\x12\x0f\n\x07payload\x18\x01 \x02(\x0c\"q\n\x14TxRequestDetailsType\x12\x15\n\rrequest_index\x18\x01 \x01(\r\x12\x0f\n\x07tx_hash\x18\x02 \x01(\x0c\x12\x16\n\x0e\x65xtra_data_len\x18\x03 \x01(\r\x12\x19\n\x11\x65xtra_data_offset\x18\x04 \x01(\r\"\\\n\x17TxRequestSerializedType\x12\x17\n\x0fsignature_index\x18\x01 \x01(\r\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x03 \x01(\x0c\"g\n\x0cIdentityType\x12\r\n\x05proto\x18\x01 \x01(\t\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0c\n\x04host\x18\x03 \x01(\t\x12\x0c\n\x04port\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\x10\n\x05index\x18\x06 \x01(\r:\x01\x30\"2\n\nPolicyType\x12\x13\n\x0bpolicy_name\x18\x01 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x02 \x01(\x08*\xe6\x02\n\x0b\x46\x61ilureType\x12\x1d\n\x19\x46\x61ilure_UnexpectedMessage\x10\x01\x12\x1a\n\x16\x46\x61ilure_ButtonExpected\x10\x02\x12\x17\n\x13\x46\x61ilure_SyntaxError\x10\x03\x12\x1b\n\x17\x46\x61ilure_ActionCancelled\x10\x04\x12\x17\n\x13\x46\x61ilure_PinExpected\x10\x05\x12\x18\n\x14\x46\x61ilure_PinCancelled\x10\x06\x12\x16\n\x12\x46\x61ilure_PinInvalid\x10\x07\x12\x1c\n\x18\x46\x61ilure_InvalidSignature\x10\x08\x12\x11\n\rFailure_Other\x10\t\x12\x1a\n\x16\x46\x61ilure_NotEnoughFunds\x10\n\x12\x1a\n\x16\x46\x61ilure_NotInitialized\x10\x0b\x12\x17\n\x13\x46\x61ilure_PinMismatch\x10\x0c\x12\x19\n\x15\x46\x61ilure_FirmwareError\x10\x63*\x99\x01\n\x10OutputScriptType\x12\x10\n\x0cPAYTOADDRESS\x10\x00\x12\x13\n\x0fPAYTOSCRIPTHASH\x10\x01\x12\x11\n\rPAYTOMULTISIG\x10\x02\x12\x11\n\rPAYTOOPRETURN\x10\x03\x12\x10\n\x0cPAYTOWITNESS\x10\x04\x12\x14\n\x10PAYTOP2SHWITNESS\x10\x05\x12\x10\n\x0cPAYTOTAPROOT\x10\x06*~\n\x0fInputScriptType\x12\x10\n\x0cSPENDADDRESS\x10\x00\x12\x11\n\rSPENDMULTISIG\x10\x01\x12\x0c\n\x08\x45XTERNAL\x10\x02\x12\x10\n\x0cSPENDWITNESS\x10\x03\x12\x14\n\x10SPENDP2SHWITNESS\x10\x04\x12\x10\n\x0cSPENDTAPROOT\x10\x05*U\n\x0bRequestType\x12\x0b\n\x07TXINPUT\x10\x00\x12\x0c\n\x08TXOUTPUT\x10\x01\x12\n\n\x06TXMETA\x10\x02\x12\x0e\n\nTXFINISHED\x10\x03\x12\x0f\n\x0bTXEXTRADATA\x10\x04*>\n\x11OutputAddressType\x12\t\n\x05SPEND\x10\x00\x12\x0c\n\x08TRANSFER\x10\x01\x12\n\n\x06\x43HANGE\x10\x02\"\x04\x08\x03\x10\x03*\xe0\t\n\x11\x42uttonRequestType\x12\x17\n\x13\x42uttonRequest_Other\x10\x01\x12\"\n\x1e\x42uttonRequest_FeeOverThreshold\x10\x02\x12\x1f\n\x1b\x42uttonRequest_ConfirmOutput\x10\x03\x12\x1d\n\x19\x42uttonRequest_ResetDevice\x10\x04\x12\x1d\n\x19\x42uttonRequest_ConfirmWord\x10\x05\x12\x1c\n\x18\x42uttonRequest_WipeDevice\x10\x06\x12\x1d\n\x19\x42uttonRequest_ProtectCall\x10\x07\x12\x18\n\x14\x42uttonRequest_SignTx\x10\x08\x12\x1f\n\x1b\x42uttonRequest_FirmwareCheck\x10\t\x12\x19\n\x15\x42uttonRequest_Address\x10\n\x12\x1f\n\x1b\x42uttonRequest_FirmwareErase\x10\x0b\x12*\n&ButtonRequest_ConfirmTransferToAccount\x10\x0c\x12+\n\'ButtonRequest_ConfirmTransferToNodePath\x10\r\x12\x1d\n\x19\x42uttonRequest_ChangeLabel\x10\x0e\x12 \n\x1c\x42uttonRequest_ChangeLanguage\x10\x0f\x12\"\n\x1e\x42uttonRequest_EnablePassphrase\x10\x10\x12#\n\x1f\x42uttonRequest_DisablePassphrase\x10\x11\x12\'\n#ButtonRequest_EncryptAndSignMessage\x10\x12\x12 \n\x1c\x42uttonRequest_EncryptMessage\x10\x13\x12\"\n\x1e\x42uttonRequest_ImportPrivateKey\x10\x14\x12(\n$ButtonRequest_ImportRecoverySentence\x10\x15\x12\x1e\n\x1a\x42uttonRequest_SignIdentity\x10\x16\x12\x16\n\x12\x42uttonRequest_Ping\x10\x17\x12\x1b\n\x17\x42uttonRequest_RemovePin\x10\x18\x12\x1b\n\x17\x42uttonRequest_ChangePin\x10\x19\x12\x1b\n\x17\x42uttonRequest_CreatePin\x10\x1a\x12\x1c\n\x18\x42uttonRequest_GetEntropy\x10\x1b\x12\x1d\n\x19\x42uttonRequest_SignMessage\x10\x1c\x12\x1f\n\x1b\x42uttonRequest_ApplyPolicies\x10\x1d\x12!\n\x1d\x42uttonRequest_AutoLockDelayMs\x10\x1f\x12\x1c\n\x18\x42uttonRequest_U2FCounter\x10 \x12\"\n\x1e\x42uttonRequest_ConfirmEosAction\x10!\x12\"\n\x1e\x42uttonRequest_ConfirmEosBudget\x10\"\x12\x1d\n\x19\x42uttonRequest_ConfirmMemo\x10#\x12 \n\x1c\x42uttonRequest_RemoveWipeCode\x10$\x12 \n\x1c\x42uttonRequest_ChangeWipeCode\x10%\x12 \n\x1c\x42uttonRequest_CreateWipeCode\x10&\"\x04\x08\x1e\x10\x1e*\x7f\n\x14PinMatrixRequestType\x12 \n\x1cPinMatrixRequestType_Current\x10\x01\x12!\n\x1dPinMatrixRequestType_NewFirst\x10\x02\x12\"\n\x1ePinMatrixRequestType_NewSecond\x10\x03:4\n\x07wire_in\x12!.google.protobuf.EnumValueOptions\x18\xd2\x86\x03 \x01(\x08:5\n\x08wire_out\x12!.google.protobuf.EnumValueOptions\x18\xd3\x86\x03 \x01(\x08::\n\rwire_debug_in\x12!.google.protobuf.EnumValueOptions\x18\xd4\x86\x03 \x01(\x08:;\n\x0ewire_debug_out\x12!.google.protobuf.EnumValueOptions\x18\xd5\x86\x03 \x01(\x08\x42)\n\x1a\x63om.keepkey.deviceprotocolB\x0bKeepKeyType') + serialized_pb=_b('\n\x0btypes.proto\x1a google/protobuf/descriptor.proto\"\x80\x01\n\nHDNodeType\x12\r\n\x05\x64\x65pth\x18\x01 \x02(\r\x12\x13\n\x0b\x66ingerprint\x18\x02 \x02(\r\x12\x11\n\tchild_num\x18\x03 \x02(\r\x12\x12\n\nchain_code\x18\x04 \x02(\x0c\x12\x13\n\x0bprivate_key\x18\x05 \x01(\x0c\x12\x12\n\npublic_key\x18\x06 \x01(\x0c\">\n\x0eHDNodePathType\x12\x19\n\x04node\x18\x01 \x02(\x0b\x32\x0b.HDNodeType\x12\x11\n\taddress_n\x18\x02 \x03(\r\"\xf9\x03\n\x08\x43oinType\x12\x11\n\tcoin_name\x18\x01 \x01(\t\x12\x15\n\rcoin_shortcut\x18\x02 \x01(\t\x12\x17\n\x0c\x61\x64\x64ress_type\x18\x03 \x01(\r:\x01\x30\x12\x11\n\tmaxfee_kb\x18\x04 \x01(\x04\x12\x1c\n\x11\x61\x64\x64ress_type_p2sh\x18\x05 \x01(\r:\x01\x35\x12\x1d\n\x15signed_message_header\x18\x08 \x01(\t\x12\x1a\n\x12\x62ip44_account_path\x18\t \x01(\r\x12\x0e\n\x06\x66orkid\x18\x0c \x01(\r\x12\x10\n\x08\x64\x65\x63imals\x18\r \x01(\r\x12\x18\n\x10\x63ontract_address\x18\x0e \x01(\x0c\x12\x1c\n\nxpub_magic\x18\x10 \x01(\r:\x08\x37\x36\x30\x36\x37\x33\x35\x38\x12\x0e\n\x06segwit\x18\x12 \x01(\x08\x12\x14\n\x0c\x66orce_bip143\x18\x13 \x01(\x08\x12\x12\n\ncurve_name\x18\x14 \x01(\t\x12\x17\n\x0f\x63\x61shaddr_prefix\x18\x15 \x01(\t\x12\x15\n\rbech32_prefix\x18\x16 \x01(\t\x12\x0e\n\x06\x64\x65\x63red\x18\x17 \x01(\x08\x12\x1e\n\x16xpub_magic_segwit_p2sh\x18\x19 \x01(\r\x12 \n\x18xpub_magic_segwit_native\x18\x1a \x01(\r\x12\x17\n\x0fnanoaddr_prefix\x18\x1b \x01(\t\x12\x0f\n\x07taproot\x18\x1c \x01(\x08\"[\n\x18MultisigRedeemScriptType\x12 \n\x07pubkeys\x18\x01 \x03(\x0b\x32\x0f.HDNodePathType\x12\x12\n\nsignatures\x18\x02 \x03(\x0c\x12\t\n\x01m\x18\x03 \x01(\r\"\x9f\x02\n\x0bTxInputType\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x11\n\tprev_hash\x18\x02 \x02(\x0c\x12\x12\n\nprev_index\x18\x03 \x02(\r\x12\x12\n\nscript_sig\x18\x04 \x01(\x0c\x12\x1c\n\x08sequence\x18\x05 \x01(\r:\n4294967295\x12\x33\n\x0bscript_type\x18\x06 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\x12+\n\x08multisig\x18\x07 \x01(\x0b\x32\x19.MultisigRedeemScriptType\x12\x0e\n\x06\x61mount\x18\x08 \x01(\x04\x12\x13\n\x0b\x64\x65\x63red_tree\x18\t \x01(\r\x12\x1d\n\x15\x64\x65\x63red_script_version\x18\n \x01(\r\"\xfe\x01\n\x0cTxOutputType\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\taddress_n\x18\x02 \x03(\r\x12\x0e\n\x06\x61mount\x18\x03 \x02(\x04\x12&\n\x0bscript_type\x18\x04 \x02(\x0e\x32\x11.OutputScriptType\x12+\n\x08multisig\x18\x05 \x01(\x0b\x32\x19.MultisigRedeemScriptType\x12\x16\n\x0eop_return_data\x18\x06 \x01(\x0c\x12(\n\x0c\x61\x64\x64ress_type\x18\x07 \x01(\x0e\x32\x12.OutputAddressType\x12\x1d\n\x15\x64\x65\x63red_script_version\x18\t \x01(\rJ\x04\x08\x08\x10\t\"W\n\x0fTxOutputBinType\x12\x0e\n\x06\x61mount\x18\x01 \x02(\x04\x12\x15\n\rscript_pubkey\x18\x02 \x02(\x0c\x12\x1d\n\x15\x64\x65\x63red_script_version\x18\x03 \x01(\r\"\xc2\x02\n\x0fTransactionType\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x1c\n\x06inputs\x18\x02 \x03(\x0b\x32\x0c.TxInputType\x12%\n\x0b\x62in_outputs\x18\x03 \x03(\x0b\x32\x10.TxOutputBinType\x12\x1e\n\x07outputs\x18\x05 \x03(\x0b\x32\r.TxOutputType\x12\x11\n\tlock_time\x18\x04 \x01(\r\x12\x12\n\ninputs_cnt\x18\x06 \x01(\r\x12\x13\n\x0boutputs_cnt\x18\x07 \x01(\r\x12\x12\n\nextra_data\x18\x08 \x01(\x0c\x12\x16\n\x0e\x65xtra_data_len\x18\t \x01(\r\x12\x0e\n\x06\x65xpiry\x18\n \x01(\r\x12\x14\n\x0coverwintered\x18\x0b \x01(\x08\x12\x18\n\x10version_group_id\x18\x0c \x01(\r\x12\x11\n\tbranch_id\x18\r \x01(\r\"%\n\x12RawTransactionType\x12\x0f\n\x07payload\x18\x01 \x02(\x0c\"q\n\x14TxRequestDetailsType\x12\x15\n\rrequest_index\x18\x01 \x01(\r\x12\x0f\n\x07tx_hash\x18\x02 \x01(\x0c\x12\x16\n\x0e\x65xtra_data_len\x18\x03 \x01(\r\x12\x19\n\x11\x65xtra_data_offset\x18\x04 \x01(\r\"\\\n\x17TxRequestSerializedType\x12\x17\n\x0fsignature_index\x18\x01 \x01(\r\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x03 \x01(\x0c\"g\n\x0cIdentityType\x12\r\n\x05proto\x18\x01 \x01(\t\x12\x0c\n\x04user\x18\x02 \x01(\t\x12\x0c\n\x04host\x18\x03 \x01(\t\x12\x0c\n\x04port\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\x10\n\x05index\x18\x06 \x01(\r:\x01\x30\"2\n\nPolicyType\x12\x13\n\x0bpolicy_name\x18\x01 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x02 \x01(\x08*\xe6\x02\n\x0b\x46\x61ilureType\x12\x1d\n\x19\x46\x61ilure_UnexpectedMessage\x10\x01\x12\x1a\n\x16\x46\x61ilure_ButtonExpected\x10\x02\x12\x17\n\x13\x46\x61ilure_SyntaxError\x10\x03\x12\x1b\n\x17\x46\x61ilure_ActionCancelled\x10\x04\x12\x17\n\x13\x46\x61ilure_PinExpected\x10\x05\x12\x18\n\x14\x46\x61ilure_PinCancelled\x10\x06\x12\x16\n\x12\x46\x61ilure_PinInvalid\x10\x07\x12\x1c\n\x18\x46\x61ilure_InvalidSignature\x10\x08\x12\x11\n\rFailure_Other\x10\t\x12\x1a\n\x16\x46\x61ilure_NotEnoughFunds\x10\n\x12\x1a\n\x16\x46\x61ilure_NotInitialized\x10\x0b\x12\x17\n\x13\x46\x61ilure_PinMismatch\x10\x0c\x12\x19\n\x15\x46\x61ilure_FirmwareError\x10\x63*\x99\x01\n\x10OutputScriptType\x12\x10\n\x0cPAYTOADDRESS\x10\x00\x12\x13\n\x0fPAYTOSCRIPTHASH\x10\x01\x12\x11\n\rPAYTOMULTISIG\x10\x02\x12\x11\n\rPAYTOOPRETURN\x10\x03\x12\x10\n\x0cPAYTOWITNESS\x10\x04\x12\x14\n\x10PAYTOP2SHWITNESS\x10\x05\x12\x10\n\x0cPAYTOTAPROOT\x10\x06*~\n\x0fInputScriptType\x12\x10\n\x0cSPENDADDRESS\x10\x00\x12\x11\n\rSPENDMULTISIG\x10\x01\x12\x0c\n\x08\x45XTERNAL\x10\x02\x12\x10\n\x0cSPENDWITNESS\x10\x03\x12\x14\n\x10SPENDP2SHWITNESS\x10\x04\x12\x10\n\x0cSPENDTAPROOT\x10\x05*U\n\x0bRequestType\x12\x0b\n\x07TXINPUT\x10\x00\x12\x0c\n\x08TXOUTPUT\x10\x01\x12\n\n\x06TXMETA\x10\x02\x12\x0e\n\nTXFINISHED\x10\x03\x12\x0f\n\x0bTXEXTRADATA\x10\x04*>\n\x11OutputAddressType\x12\t\n\x05SPEND\x10\x00\x12\x0c\n\x08TRANSFER\x10\x01\x12\n\n\x06\x43HANGE\x10\x02\"\x04\x08\x03\x10\x03*\xfc\t\n\x11\x42uttonRequestType\x12\x17\n\x13\x42uttonRequest_Other\x10\x01\x12\"\n\x1e\x42uttonRequest_FeeOverThreshold\x10\x02\x12\x1f\n\x1b\x42uttonRequest_ConfirmOutput\x10\x03\x12\x1d\n\x19\x42uttonRequest_ResetDevice\x10\x04\x12\x1d\n\x19\x42uttonRequest_ConfirmWord\x10\x05\x12\x1c\n\x18\x42uttonRequest_WipeDevice\x10\x06\x12\x1d\n\x19\x42uttonRequest_ProtectCall\x10\x07\x12\x18\n\x14\x42uttonRequest_SignTx\x10\x08\x12\x1f\n\x1b\x42uttonRequest_FirmwareCheck\x10\t\x12\x19\n\x15\x42uttonRequest_Address\x10\n\x12\x1f\n\x1b\x42uttonRequest_FirmwareErase\x10\x0b\x12*\n&ButtonRequest_ConfirmTransferToAccount\x10\x0c\x12+\n\'ButtonRequest_ConfirmTransferToNodePath\x10\r\x12\x1d\n\x19\x42uttonRequest_ChangeLabel\x10\x0e\x12 \n\x1c\x42uttonRequest_ChangeLanguage\x10\x0f\x12\"\n\x1e\x42uttonRequest_EnablePassphrase\x10\x10\x12#\n\x1f\x42uttonRequest_DisablePassphrase\x10\x11\x12\'\n#ButtonRequest_EncryptAndSignMessage\x10\x12\x12 \n\x1c\x42uttonRequest_EncryptMessage\x10\x13\x12\"\n\x1e\x42uttonRequest_ImportPrivateKey\x10\x14\x12(\n$ButtonRequest_ImportRecoverySentence\x10\x15\x12\x1e\n\x1a\x42uttonRequest_SignIdentity\x10\x16\x12\x16\n\x12\x42uttonRequest_Ping\x10\x17\x12\x1b\n\x17\x42uttonRequest_RemovePin\x10\x18\x12\x1b\n\x17\x42uttonRequest_ChangePin\x10\x19\x12\x1b\n\x17\x42uttonRequest_CreatePin\x10\x1a\x12\x1c\n\x18\x42uttonRequest_GetEntropy\x10\x1b\x12\x1d\n\x19\x42uttonRequest_SignMessage\x10\x1c\x12\x1f\n\x1b\x42uttonRequest_ApplyPolicies\x10\x1d\x12!\n\x1d\x42uttonRequest_AutoLockDelayMs\x10\x1f\x12\x1c\n\x18\x42uttonRequest_U2FCounter\x10 \x12\"\n\x1e\x42uttonRequest_ConfirmEosAction\x10!\x12\"\n\x1e\x42uttonRequest_ConfirmEosBudget\x10\"\x12\x1d\n\x19\x42uttonRequest_ConfirmMemo\x10#\x12 \n\x1c\x42uttonRequest_RemoveWipeCode\x10$\x12 \n\x1c\x42uttonRequest_ChangeWipeCode\x10%\x12 \n\x1c\x42uttonRequest_CreateWipeCode\x10&\x12\x1a\n\x16\x42uttonRequest_DiceRoll\x10\'\"\x04\x08\x1e\x10\x1e*\x7f\n\x14PinMatrixRequestType\x12 \n\x1cPinMatrixRequestType_Current\x10\x01\x12!\n\x1dPinMatrixRequestType_NewFirst\x10\x02\x12\"\n\x1ePinMatrixRequestType_NewSecond\x10\x03:4\n\x07wire_in\x12!.google.protobuf.EnumValueOptions\x18\xd2\x86\x03 \x01(\x08:5\n\x08wire_out\x12!.google.protobuf.EnumValueOptions\x18\xd3\x86\x03 \x01(\x08::\n\rwire_debug_in\x12!.google.protobuf.EnumValueOptions\x18\xd4\x86\x03 \x01(\x08:;\n\x0ewire_debug_out\x12!.google.protobuf.EnumValueOptions\x18\xd5\x86\x03 \x01(\x08\x42)\n\x1a\x63om.keepkey.deviceprotocolB\x0bKeepKeyType') , dependencies=[google_dot_protobuf_dot_descriptor__pb2.DESCRIPTOR,]) @@ -390,11 +390,15 @@ name='ButtonRequest_CreateWipeCode', index=36, number=38, options=None, type=None), + _descriptor.EnumValueDescriptor( + name='ButtonRequest_DiceRoll', index=37, number=39, + options=None, + type=None), ], containing_type=None, options=None, serialized_start=3008, - serialized_end=4256, + serialized_end=4284, ) _sym_db.RegisterEnumDescriptor(_BUTTONREQUESTTYPE) @@ -420,8 +424,8 @@ ], containing_type=None, options=None, - serialized_start=4258, - serialized_end=4385, + serialized_start=4286, + serialized_end=4413, ) _sym_db.RegisterEnumDescriptor(_PINMATRIXREQUESTTYPE) @@ -497,6 +501,7 @@ ButtonRequest_RemoveWipeCode = 36 ButtonRequest_ChangeWipeCode = 37 ButtonRequest_CreateWipeCode = 38 +ButtonRequest_DiceRoll = 39 PinMatrixRequestType_Current = 1 PinMatrixRequestType_NewFirst = 2 PinMatrixRequestType_NewSecond = 3 diff --git a/keepkeylib/zcash.py b/keepkeylib/zcash.py new file mode 100644 index 00000000..c110bba2 --- /dev/null +++ b/keepkeylib/zcash.py @@ -0,0 +1,44 @@ +"""Zcash helpers for client-side computations. + +Mirrors the firmware's ZIP-32 §6.1 seed fingerprint so callers can build the +expected_seed_fingerprint they pass to display/sign messages without having to +ask the device. +""" + +from hashlib import blake2b + + +_PERSONAL = b"Zcash_HD_Seed_FP" + + +def calculate_seed_fingerprint(seed): + """Compute the ZIP-32 §6.1 seed fingerprint. + + SeedFingerprint := BLAKE2b-256( + "Zcash_HD_Seed_FP", I2LEBSP_8(len(seed)) || seed + ) + + The 1-byte length prefix domain-separates seeds of different lengths + that happen to share a prefix; per the spec. + + Args: + seed: bytes, length 32-252. + + Returns: + 32-byte fingerprint. + + Raises: + ValueError: if seed length is out of range or the seed is trivially + all-zero or all-0xFF (matches firmware's rejection per §6.1). + """ + if not isinstance(seed, (bytes, bytearray)): + raise TypeError("seed must be bytes") + if len(seed) < 32 or len(seed) > 252: + raise ValueError("seed length must be in [32, 252]") + if all(b == 0x00 for b in seed) or all(b == 0xFF for b in seed): + raise ValueError("trivial seed (all-zero or all-0xFF) rejected") + + h = blake2b(digest_size=32, person=_PERSONAL) + h.update(bytes([len(seed)])) + h.update(bytes(seed)) + return h.digest() diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 3f7ec0c3..e243e660 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -12,6 +12,21 @@ import struct, zlib, os, sys, argparse from datetime import datetime +# Make keepkeylib importable regardless of invocation cwd (pytest inserts it +# automatically; this script is often run standalone as +# `python3 ../scripts/generate-test-report.py` from tests/, or directly from +# the repo root during local iteration). +for _cand in (os.getcwd(), os.path.join(os.getcwd(), '..'), + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))): + if os.path.isdir(os.path.join(_cand, 'keepkeylib')) and _cand not in sys.path: + sys.path.insert(0, _cand) +del _cand + +try: + from keepkeylib.clearsign_catalog import CLEARSIGN_FLOWS +except ImportError: + CLEARSIGN_FLOWS = None # report still renders; V section just won't expand from the catalog + # --------------------------------------------------------------- # PDF writer + page builder (stdlib only) # --------------------------------------------------------------- @@ -72,7 +87,7 @@ def add_page(self, lines, w=612, h=792): y, sz, txt = item[0], item[1], item[2] style = item[3] if len(item) > 3 else False color = item[4] if len(item) > 4 else None - txt = txt.replace('\\','\\\\').replace('(','\\(').replace(')','\\)') + txt = _ascii(txt).replace('\\','\\\\').replace('(','\\(').replace(')','\\)') if color: ops.append(f'{color[0]} {color[1]} {color[2]} rg') if style == 'ding': @@ -138,6 +153,67 @@ def write(self, path): CHECK = '\x34' CROSS = '\x38' +# Map non-Latin-1 Unicode punctuation to ASCII so it survives the PDF content +# stream (encoded latin-1); em-dashes etc. were rendering as '?'. +_ASCII_MAP = { + '—': '-', '–': '-', '→': '->', '←': '<-', + '’': "'", '‘': "'", '“': '"', '”': '"', + '…': '...', '•': '*', '₿': 'BTC', '≤': '<=', + '≥': '>=', '±': '+/-', +} +def _ascii(s): + for k, v in _ASCII_MAP.items(): + if k in s: + s = s.replace(k, v) + return s + +# Widths of printable ASCII in the two PDF core fonts, in 1/1000 em. +# Keep layout independent of optional font/PDF packages: these are also the +# fonts declared by PDF.write(). Unknown glyphs get a conservative full em. +_HELVETICA_WIDTHS = ( + 278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278, + 556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556, + 1015,667,667,722,722,667,611,778,722,278,500,667,556,833,722,778, + 667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556, + 333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556, + 556,556,333,500,278,556,500,722,500,500,500,334,260,334,584, +) +_HELVETICA_BOLD_WIDTHS = ( + 278,333,474,556,556,889,722,238,333,333,389,584,278,333,278,278, + 556,556,556,556,556,556,556,556,556,556,333,333,584,584,584,611, + 975,722,722,722,722,667,611,778,722,278,556,722,611,833,722,778, + 667,778,722,667,611,722,667,944,667,667,611,333,278,333,584,556, + 333,556,611,556,611,556,333,611,611,278,278,556,278,889,611,611, + 611,611,389,556,333,611,556,778,556,556,500,389,280,389,584, +) + +def _text_width(text, size, bold=False): + widths = _HELVETICA_BOLD_WIDTHS if bold else _HELVETICA_WIDTHS + return sum(widths[ord(c) - 32] if 32 <= ord(c) <= 126 else 1000 + for c in _ascii(text)) * size / 1000 + +def _fit_text(text, size, bold=False, width=532): + """Wrap to the PDF content width, including URLs and long test names.""" + output = [] + for line in _ascii(text).split('\n'): + while _text_width(line, size, bold) > width: + length, used = 0, 0 + for char in line: + advance = _text_width(char, size, bold) + if used + advance > width: + break + used += advance + length += 1 + if not length: + raise ValueError('font size exceeds PDF content width') + split = line.rfind(' ', 0, length + 1) + if split <= 0: + split = length + output.append(line[:split].rstrip()) + line = line[split:].lstrip() + output.append(line) + return output + class PB: def __init__(self, pdf): self.pdf = pdf; self.lines = []; self.y = 755 @@ -146,21 +222,31 @@ def _flush(self): def need(self, h): if self.y - h < 45: self._flush() def text(self, sz, txt, bold=False, color=None): - self.need(sz + 2); self.lines.append((self.y, sz, txt, bold, color) if color else (self.y, sz, txt, bold)); self.y -= sz + 2 + for line in _fit_text(txt, sz, bold): + self.need(sz + 2) + self.lines.append((self.y, sz, line, bold, color) if color else + (self.y, sz, line, bold)) + self.y -= sz + 2 def check(self, sz, txt_after, passed): """Render checkmark/cross + text on same conceptual line""" + bold = passed in ('pass', 'fail', 'error') + prefix = ' ' if bold else '-- ' if passed == 'skip' else ' ' + lines = _fit_text(prefix + txt_after, sz, bold) self.need(sz + 2) if passed == 'pass': self.lines.append((self.y, sz, CHECK, 'ding', GREEN)) - self.lines.append((self.y, sz, f' {txt_after}', True, GREEN)) + self.lines.append((self.y, sz, lines[0], True, GREEN)) elif passed in ('fail', 'error'): self.lines.append((self.y, sz, CROSS, 'ding', RED)) - self.lines.append((self.y, sz, f' {txt_after}', True, RED)) + self.lines.append((self.y, sz, lines[0], True, RED)) elif passed == 'skip': - self.lines.append((self.y, sz, f'-- {txt_after}', False, GRAY)) + self.lines.append((self.y, sz, lines[0], False, GRAY)) else: - self.lines.append((self.y, sz, f' {txt_after}', False, GRAY)) + self.lines.append((self.y, sz, lines[0], False, GRAY)) self.y -= sz + 2 + color = GREEN if passed == 'pass' else RED if bold else GRAY + for line in lines[1:]: + self.text(sz, line, bold=bold, color=color) def image(self, png_path, display_w=400, display_h=100): """Embed a 256x64 OLED screenshot, scaled to display_w x display_h""" self.need(display_h + 4) @@ -173,12 +259,76 @@ def gap(self, h=4): def finish(self): self._flush() +def parse_report_junit(path): + """parse_junit, plus section L from the bitcoin-only JUnit CI writes + beside it: those tests only run on the bitcoin-only image and skip on + the full one, so the full JUnit alone shows them as withheld.""" + results = parse_junit(path) + btc = os.path.join(os.path.dirname(path), 'junit-merged-bitcoin-only.xml') + if os.path.abspath(btc) != os.path.abspath(path) and os.path.isfile(btc): + # The census and missing capabilities describe the full run only. + census, missing = dict(JUNIT_CENSUS), set(JUNIT_MISSING_CAPABILITIES) + btc_results = parse_junit(btc) + JUNIT_CENSUS.clear(); JUNIT_CENSUS.update(census) + JUNIT_MISSING_CAPABILITIES.clear(); JUNIT_MISSING_CAPABILITIES.update(missing) + for key, status in btc_results.items(): + if key.startswith('test_msg_bitcoin_only_variant::'): + results[key] = status + return results + def _lookup(results, mod, meth): - """Look up test result by module::method (precise), then bare method (fallback).""" - return results.get(f'{mod}::{meth}') or results.get(meth) or '' + """Look up a test result by module::method. Every SECTIONS module is a + test_msg_* module, so parse_junit always emits a 'mod::meth' key -- there is + no bare-method fallback (it let a cross-module method-name collision render a + never-run test as PASS, defeating the --validate-junit release gate).""" + return results.get(f'{mod}::{meth}', '') -def ver_t(s): return tuple(int(x) for x in s.replace('v','').split('.')[:3]) +def ver_t(s): + # Defensive: tolerate pre-release tags (7.15.0-rc3), 'v' prefixes and short + # versions ('7.15' -> (7,15,0)) so report/filter/validate never crash. + s = str(s).split('-')[0].replace('v', '') + parts = (s.split('.') + ['0', '0', '0'])[:3] + return tuple(int(''.join(ch for ch in p if ch.isdigit()) or '0') for p in parts) def ver_ge(a, b): return ver_t(a) >= ver_t(b) + +# Tests whose newer fail-closed behavior deliberately returns before drawing a +# confirmation screen. Keep their historical catalog text, but do not schedule +# or audit an OLED capture once the refusal behavior is active. +# (floor, ceiling): no capture from floor up to, not including, ceiling. +_NO_SCREEN_FROM = { + # 7.14.x refuses an unlimited approve before drawing; from 7.15 it signs + # after an UNLIMITED APPROVAL warning, which is the evidence to capture. + ('test_msg_signtx_ethereum_erc20', 'test_approve_all'): ('7.14.2', '7.15.0'), +} + + +# From this release the dice ceremony is private over DebugLink. +DICE_PRIVATE_FROM = '7.15.0' + + +def _ctx_for(fw_version, ctx): + """A case description; a {'private': ..., 'public': ...} pair is picked by + whether this firmware keeps the dice ceremony private.""" + if isinstance(ctx, dict): + return ctx['private' if ver_ge(fw_version, DICE_PRIVATE_FROM) else 'public'] + return ctx + + +def _screens_for(fw_version, mod, meth, screens): + # Private dice displays cannot be captured through the interface whose + # non-disclosure is being tested. Native known-draw fixtures cover device + # page content; full integration still requires both wire tests to execute. + if (ver_ge(fw_version, DICE_PRIVATE_FROM) and + mod == 'test_msg_resetdevice' and meth in ( + 'test_reset_device_dice_mixed_is_verifiable', + 'test_reset_device_dice_only_is_verifiable')): + return [] + window = _NO_SCREEN_FROM.get((mod, meth)) + if window and ver_ge(fw_version, window[0]) and not ver_ge(fw_version, window[1]): + return [] + return screens + + def _w(text, n=95): words, lines, cur = text.split(), [], '' for w in words: @@ -187,45 +337,110 @@ def _w(text, n=95): if cur: lines.append(cur) return lines -def _is_setup_frame(path): - """Check if a screenshot is a setUp noise frame (IMPORT RECOVERY, WIPE, or blank/logo).""" +def _frame_lit_ratio(path): + """Fraction of lit pixels in an OLED PNG, or None if unreadable.""" try: pixels, w, h = _read_png_pixels(path) - # Count non-zero pixels -- blank/logo frames have very few or very specific patterns - lit = sum(1 for b in pixels if b > 128) - total = w * h - # Very blank (< 5% lit) = idle/logo screen - if lit < total * 0.05: - return True - # Check for "IMPORT RECOVERY" text by looking at pixel density in top-left region - # setUp always shows this screen -- it's ~20% lit with specific pattern - # Real test screens vary widely, so we check the raw bytes for known patterns - # Simple heuristic: if first 2 btn frames match, skip them (setUp wipe + load) - return False - except: - return False + if not w or not h: + return None + return sum(1 for b in pixels if b > 128) / float(w * h) + except Exception: + return None + + +def _frame_hash(path): + """Content hash of an OLED PNG with the top-right animation region masked + (the scroll arrow renders in a per-capture animation state, defeating + exact-byte comparison of otherwise identical screens). None if unreadable. + """ + try: + import hashlib + pixels, w, h = _read_png_pixels(path) + if not w or not h: + return None + px = bytearray(pixels) + for y in range(min(16, h)): + row = y * w + for x in range(max(0, w - 64), w): + px[row + x] = 0 + return hashlib.md5(bytes(px)).hexdigest() + except Exception: + return None + + +# hash -> number of distinct test dirs the frame appears in. 1 = the frame is +# unique to its test (its own content); large = generic device chrome shared +# across unrelated tests (load-device prompt, policy toggles, lock screens). +_FRAME_DIR_COUNTS = {} +# Hashes appearing in >= 3 distinct dirs — used to keep chrome out of the +# "extra frames" strip when a test has real content frames of its own. +_GENERIC_FRAME_HASHES = set() + +def _build_frame_census(screenshot_dir): + """Populate the cross-test frame census from every per-test capture dir.""" + _FRAME_DIR_COUNTS.clear() + _GENERIC_FRAME_HASHES.clear() + if not screenshot_dir or not os.path.isdir(screenshot_dir): + return + dirs_per_hash = {} + for mod in sorted(os.listdir(screenshot_dir)): + mod_dir = os.path.join(screenshot_dir, mod) + if not os.path.isdir(mod_dir): + continue + for meth in sorted(os.listdir(mod_dir)): + test_dir = os.path.join(mod_dir, meth) + if not os.path.isdir(test_dir): + continue + for f in os.listdir(test_dir): + if not f.startswith('btn'): + continue + h = _frame_hash(os.path.join(test_dir, f)) + if h: + dirs_per_hash.setdefault(h, set()).add(test_dir) + _FRAME_DIR_COUNTS.update((h, len(d)) for h, d in dirs_per_hash.items()) + _GENERIC_FRAME_HASHES.update( + h for h, dirs in dirs_per_hash.items() if len(dirs) >= 3) + def _pick_best_frame(test_dir, btn_files): - """Pick the best screenshot for a test, skipping setUp noise frames. - setUp always produces: btn00000 (wipe confirm) + btn00001 (load_device confirm). - Real test frames come after. If only setUp frames exist, return None.""" + """Pick the best screenshot for a test. + + setUp noise (wipe/load frames) is removed at capture time for the signing + tests (see reset_screenshots / setup_mnemonic_*), so the frames here are + the test's own operation confirms. Defensive layers on top: + - blank/near-blank frames (idle, lock glyph) are NEVER shown — a reject + that fires before any confirm UI gets no image, not a blank one; + - rank by how test-SPECIFIC a frame is (fewest other test dirs showing the + byte-identical screen), so shared chrome (the load-device prompt, policy + toggles) loses to the test's own screens, yet still renders when it IS + the content (gate tests whose every frame is shared chrome); + - density breaks ties (the address/amount screen carries more lit pixels + than a bare "Sign?" prompt); dense out-of-band frames (QR screens) are + a last resort behind in-band ones. + + ponytail: specificity census + density, no OCR — capture-time reset is the + real guard, this is the safety net. + """ if not btn_files: return None - # 3+ frames: [0]=setUp wipe, [1]=setUp load or instruction detail, [-1]=final confirm - # Prefer second-to-last frame -- it's the instruction-specific content - # (amounts, addresses, parameters). The last frame is usually a generic - # "Sign this transaction?" confirmation that's the same for every tx. - if len(btn_files) > 2: - # Use second-to-last for instruction detail, skip setUp frames - idx = -2 if len(btn_files) > 2 else -1 - return os.path.join(test_dir, btn_files[idx]) - elif len(btn_files) == 2: - # 2 frames: btn00000 is always setUp (wipe confirm), btn00001 is the test. - # Always show btn00001 -- it's the only real test frame. - return os.path.join(test_dir, btn_files[1]) - else: - # Single frame -- almost always setUp noise (wipe confirm from setUp). - return None + inband, dense = [], [] + for f in btn_files: + p = os.path.join(test_dir, f) + r = _frame_lit_ratio(p) + if r is None or r < 0.02: + continue # unreadable or blank/lock — never show + if r > 0.55: + dense.append((r, f)) # QR/near-full: last resort, real content + continue + h = _frame_hash(p) + inband.append((_FRAME_DIR_COUNTS.get(h, 1), -r, f)) + if inband: + inband.sort() + return os.path.join(test_dir, inband[0][2]) + if dense: + dense.sort() + return os.path.join(test_dir, dense[-1][1]) + return None def detect_fw(): try: @@ -238,9 +453,22 @@ def detect_fw(): v = f'{r.major_version}.{r.minor_version}.{r.patch_version}'; c.close(); return v except: return None +# Census of everything the merged JUnit actually contained, so the report can +# state how much of the run it covers. Without this the PDF silently implies +# that its catalog IS the test suite -- an RC audit read "no dice in the report" +# as "dice is untested" when the dice reset test had in fact run green. +JUNIT_CENSUS = {'ran': 0, 'skipped': 0, 'native': 0} + + def parse_junit(path): """Parse junit XML for pass/fail. Returns dict keyed by 'module::method' (precise) - and 'method' (fallback). Module is extracted from classname: tests.test_msg_foo.TestBar → test_msg_foo.""" + and 'method' (fallback). Module is extracted from classname: tests.test_msg_foo.TestBar → test_msg_foo. + + Native gtest suites carry a bare classname ("Dice", "Storage") with no dotted + python module, so they get keyed as 'Suite::Test'. They used to produce no + 'mod::meth' key at all, which made every native unit test structurally + impossible to put in SECTIONS -- the firmware-unit XMLs were merged in and + then silently unusable.""" if not path or not os.path.exists(path): return {} import xml.etree.ElementTree as ET results = {} @@ -249,16 +477,35 @@ def parse_junit(path): cls = tc.get('classname', '') if tc.find('failure') is not None: status = 'fail' elif tc.find('error') is not None: status = 'error' - elif tc.find('skipped') is not None: status = 'skip' + elif tc.find('skipped') is not None: + status = 'skip' + reason = tc.find('skipped').get('message', '') + if reason.startswith(CAPABILITY_SKIP_PREFIX): + JUNIT_MISSING_CAPABILITIES.add( + reason[len(CAPABILITY_SKIP_PREFIX):].strip()) else: status = 'pass' + JUNIT_CENSUS['ran'] += 1 + # 'ran' counts every collected testcase, skips included. A version-gated + # feature test that SKIPs on an older emulator is NOT evidence the feature + # works, so the two must never be reported as one number. + if status == 'skip': + JUNIT_CENSUS['skipped'] += 1 # Extract module from classname: tests.test_msg_foo.TestBar → test_msg_foo mod = '' if cls: parts = cls.split('.') for p in parts: - if p.startswith('test_msg_') or p.startswith('test_sign_') or p.startswith('test_verify_'): + # Any test module, not just the test_msg_/test_sign_/test_verify_ + # families. test_storage_version_gate matched none of those, so + # it produced no 'mod::meth' key and all eight of its results + # were invisible -- the section rendered "Pending (no firmware + # support yet)" while the tests were passing. + if p.startswith('test_'): mod = p break + if not mod and '.' not in cls: + mod = cls # native gtest suite + JUNIT_CENSUS['native'] += 1 results[f'{cls}.{name}'] = status # Key by module::method (disambiguates collisions like test_sign_btc_eth_swap) if mod: @@ -268,37 +515,157 @@ def parse_junit(path): results[name] = status return results - -def junit_cases(path): - """Return every executed JUnit case once, with its exact status.""" - if not path or not os.path.exists(path): - return [] - import xml.etree.ElementTree as ET - cases = [] - for tc in ET.parse(path).iter('testcase'): - if tc.find('failure') is not None: - status = 'fail' - elif tc.find('error') is not None: - status = 'error' - elif tc.find('skipped') is not None: - status = 'skip' - else: - status = 'pass' - cases.append({ - 'classname': tc.get('classname', ''), - 'name': tc.get('name', ''), - 'status': status, - }) - return cases - # --------------------------------------------------------------- # Test catalog with full context per test # --------------------------------------------------------------- # (id, module, method, title, context, [screenshots]) # context = why this test exists, what it proves, what user sees +# Tests whose whole point is the ordered on-device review sequence — render +# every review screen in order (who/what/why), not a single "best" thumbnail. +FULL_SEQUENCE_TESTS = { + ('test_msg_zcash_sign_pczt_device', + 'test_shielded_output_review_is_two_screens'), + # The additive invariant IS an ordered-sequence claim: the decoded screens + # are additional and the baseline raw review still follows them. Showing a + # best-of-3 sample would hide exactly the thing being proved. + ('test_msg_ethereum_clearsign_additive', + 'test_successful_decode_still_runs_the_raw_review'), + ('test_msg_ethereum_clearsign_additive', + 'test_v2_schema_decode_still_runs_the_raw_review'), + ('test_msg_ethereum_clearsign_additive', + 'test_failed_signature_falls_back_to_the_unverified_review'), + # The claim is the order: name, then the full address. + ('test_msg_ethereum_certified_contacts', + 'test_certifying_shows_every_contact_and_signs_only_the_commitment'), + ('test_msg_ethereum_certified_contacts', + 'test_a_send_to_a_contact_names_it_and_still_shows_the_address'), + ('test_msg_ethereum_clear_signing', 'test_binding_happy_path_signs_and_recovers'), + ('test_msg_ethereum_clear_signing', 'test_clearsign_uniswap_v2_eth_to_token'), + # ERC-7730 runtime: the order of the screens is the claim. + ('test_msg_ethereum_erc7730_runtime', 'test_interpolated_intent_parts_show_the_same_values_as_fields'), + ('test_msg_ethereum_erc7730_runtime', 'test_embedded_call_is_shown_under_a_blind_sign_warning'), + ('test_msg_ethereum_erc7730_runtime', 'test_inner_call_is_clear_signed_with_its_own_definition'), + ('test_msg_ethereum_erc7730_runtime', 'test_restoring_outer_definition_rejects_another_valid_definition'), + ('test_msg_ethereum_erc7730_runtime', 'test_an_inner_definition_the_device_refuses_falls_back_to_blind'), + # The newest/highest-stakes tx shapes get the full ordered walkthrough too. + ('test_msg_ethereum_clear_signing', 'test_clearsign_eip7702_setcode_authorization'), + ('test_msg_ethereum_clear_signing', 'test_clearsign_erc4337_entrypoint_v0_7_handleops'), + ('test_msg_ethereum_clear_signing', 'test_clearsign_safe_exectransaction'), + ('test_msg_ethereum_clear_signing', 'test_clearsign_permit2_permit_transfer_from'), + ('test_msg_ethereum_clear_signing', + 'test_v2_calldata_length_mismatch_falls_back_to_raw_review'), + # Native THOR/MAYA memo hardening: the raw memo pager (MEMO 1/N .. N/N, + # complete memo bytes, sole memo gate) IS the security story — show every + # page for every memo variant, not a single best frame. + ('test_msg_thorchain_signtx', 'test_thorchain_sign_tx'), + ('test_msg_mayachain_signtx', 'test_mayachain_sign_tx_memos'), + ('test_msg_osmosis_signtx', 'test_osmosis_swap_max_fields_are_fully_paged'), + # Every screen of a certified Solana review is a claim about what the user + # was shown before signing. + ('test_msg_solana_schema_v2', 'test_certified_soltoshi_join_reviews_every_screen'), +} + +def _v_catalog_tests(start_id=17): + """Generate one V-section test entry per CLEARSIGN_FLOWS flow (skipping + 'aave-v3-supply', the flagship V9 walkthrough). THE catalog is the + single source of truth — growing it (keepkeylib/clearsign_catalog.py) + needs no changes here, unlike a hand-typed per-flow entry that would + silently go stale (as happened when the old hand-written V17-V23 test + names drifted from the dynamically-generated ones). + + Signing entries get screenshot hints, the unlimited approval included: + from 7.15 it signs after a warning screen. + """ + if not CLEARSIGN_FLOWS: + return [] + out = [] + i = start_id + for f in CLEARSIGN_FLOWS: + if f['key'] == 'aave-v3-supply': + continue + method = 'test_clearsign_' + f['key'].replace('-', '_').replace('.', '_') + if f['key'] == 'erc20-approve-unlimited': + out.append(( + 'V%d' % i, 'test_msg_ethereum_clear_signing', method, + 'Unlimited ERC-20 approval signs after a warning', + 'From 7.15 an unlimited approval is signed after an UNLIMITED ' + 'APPROVAL warning naming the spender and token; the signature is ' + 'checked against the device address. 7.14.x refused it before ' + 'any ButtonRequest.', + ['Unlimited approval warning'], + )) + i += 1 + continue + + def _arg_shown(a): + # Render what the OLED will actually show for this arg: + # STRING -> the attested label; ADDRESS -> abbreviated 0x…; + # TOKEN_AMOUNT -> decimal-scaled amount + symbol (or UNLIMITED). + v = a['value'] + if a['format'] == 4: # ARG_FORMAT_STRING + return v.decode('ascii', 'replace') + if a['format'] == 1: # ARG_FORMAT_ADDRESS + return '0x%s..%s' % (v.hex()[:4], v.hex()[-4:]) + if a['format'] == 5: # ARG_FORMAT_TOKEN_AMOUNT + dec, symlen = v[0], v[1] + sym = v[2:2+symlen].decode('ascii', 'replace') + amt = v[2+symlen:] + if len(amt) == 32 and amt == b'\xff' * 32: + return 'UNLIMITED ' + sym + n = int.from_bytes(amt, 'big') + if dec: + scaled = ('%f' % (n / 10 ** dec)).rstrip('0').rstrip('.') + else: + scaled = str(n) + return '%s %s' % (scaled, sym) + return a['name'] + + shows = '; '.join('%s: %s' % (a['name'], _arg_shown(a)) + for a in f['args'][:3]) + # Prefer any TOKEN_AMOUNT/ADDRESS/STRING label as the screenshot hint + # so it reads like what the OLED will actually show. + hint_names = [a['name'] for a in f['args'][:2]] or [f['method']] + ctx = ('%s.%s (%s). %s AdvancedMode ON; runtime annotations are ' + 'additional to ordinary raw review. Real tx: to=0x%s..%s, ' + 'chainId %d. Decode: %s.' % ( + f['protocol'], f['method'], f['category'], f.get('why', ''), + f['to'].hex()[:4], f['to'].hex()[-4:], f['chain_id'], shows)) + out.append(( + 'V%d' % i, 'test_msg_ethereum_clear_signing', method, + '%s %s — annotated with ordinary review' % (f['protocol'], f['method']), + ctx, + hint_names, + )) + i += 1 + return out + + +_V_CATALOG_TESTS = _v_catalog_tests(start_id=17) + SECTIONS = [ - ('S', 'Display Binding - What the Device Signs Is What It Shows', '7.14.2', + ('AU', 'Authenticator identity and consent', '7.15.0', + 'New credentials use bounded printable identities and 16-20 byte secrets. ' + 'Both confirmations are required; duplicates never replace an existing secret.', + ['Applies to full and Bitcoin-only builds. Legacy short secrets remain usable.'], + [('AU1', 'test_msg_authenticator_boundaries', + 'test_block09_add_reviews_complete_identity_and_secret', + 'Complete identity and secret review', + 'Maximum-length identities and the entire secret appear on separate confirmations.', + ['Identity', 'Secret']), + ('AU2', 'test_msg_authenticator_boundaries', + 'test_block09_duplicate_and_cancellation_have_exact_failures', + 'Refusal, retry and duplicate handling', + 'Declining either screen stores nothing; immediate retry succeeds and duplicates fail before review.', []), + ('AU3', 'test_msg_authenticator_boundaries', + 'test_block09_invalid_identity_and_secret_fail_before_buttons', + 'Malformed and aliased identities are refused', + 'Empty, overlong, non-ASCII and control-containing identities and invalid secrets fail before consent. OTP prefix aliases fail.', []), + ('AU4', 'test_msg_authenticator_boundaries', + 'test_block09_delete_decline_preserves_account_then_retry_removes', + 'Deletion requires consent', + 'Declining deletion preserves the account; an immediate confirmed retry removes it.', [])]), + ('J', 'Display Binding - What the Device Signs Is What It Shows', '7.14.2', 'The 7.14.2 security release changed what reaches the OLED on the signing paths. Every ' 'defect it fixed was a case of the device hashing bytes it never rendered, or rendering ' 'text it could not vouch for. These tests exist to capture those screens: a passing wire ' @@ -319,43 +686,54 @@ def junit_cases(path): 'and their evidence is the Failure on the wire plus the absence of a ButtonRequest.', ], [ - ('S1', 'test_msg_ethereum_erc20_0x_signtx', 'test__sign_transformERC20', + ('J1', 'test_msg_ethereum_erc20_0x_signtx', 'test__sign_transformERC20', '0x transformERC20 raw disclosure', 'A 1480-byte transformERC20 payload exceeds one 1024-byte chunk. The device must NOT ' 'clear-sign it as a token swap, because the bytes past the initial chunk are hashed ' - 'without being decoded. With AdvancedMode on it falls to the raw path, where the byte ' - 'count shown must be the FULL length (1480), not the chunk length (1024) - a short ' - 'count would under-report what is being signed.', - ['Raw contract data screen showing the full byte count']), - ('S2', 'test_msg_ethereum_erc20_0x_signtx', 'test_sign_0x_swap_ERC20_to_ETH', + 'without being decoded. With AdvancedMode on it falls to the raw path, where the final ' + 'screen commits to all 1480 bytes with a Keccak-256 the user can compare to the host.', + ['Complete contract-data Keccak-256 commitment']), + ('J2', 'test_msg_ethereum_erc20_0x_signtx', 'test_sign_0x_swap_ERC20_to_ETH', '0x sellToUniswap names both assets', 'Clear-signing is only honest when BOTH token words resolve to known assets. This ' 'payload resolves (USDC -> ETH) and must name both sides with real amounts. The ' 'failure this guards is a screen naming a DEX while showing no amount.', ['Swap screen naming both assets and amounts']), - ('S3', 'test_msg_ethereum_erc20_0x_signtx', 'test_sign_longdata_swap', + ('J3', 'test_msg_ethereum_erc20_0x_signtx', 'test_sign_longdata_swap', 'Long 0x calldata stays disclosed', - 'Calldata spanning multiple chunks must not silently lose its tail from the display ' - 'while remaining inside the signature.', - ['Contract data screen']), - ('S8', 'test_msg_ethereum_data_disclosure', - 'test_streamed_tail_changes_data_hash_screen', - 'Streamed calldata hash covers the tail', - 'Two multi-chunk calls differing only in the streamed tail must produce different ' - 'signatures and different complete OLED approval sequences.', - ['Both complete A/B data-hash approval sequences']), - ('S10', 'test_verify_typed_data', 'test_structured_eip712_is_refused', + 'Calldata spanning multiple chunks must not silently lose its tail from the user-visible ' + 'commitment while remaining inside the signature.', + ['Complete contract-data Keccak-256 commitment']), + ('J4', 'test_msg_ethereum_signing_guards', + 'test_streamed_calldata_tail_changes_user_commitment', + 'A streamed tail changes the approval screens', + 'Signs two equal-length payloads with identical initial 1024-byte chunks and a one-bit ' + 'difference in the final EthereumTxAck byte. Their ordered OLED frame sequences must ' + 'differ, proving the complete-calldata Keccak-256 -- not merely the visible prefix or ' + 'declared length -- reaches the user before either signature is emitted.', + ['Complete contract-data Keccak-256 commitment']), + ('J8', 'test_msg_ethereum_signing_guards', + 'test_contract_handler_streamed_calldata_signs_full_data', + 'Streamed calldata is fully covered', + 'Calldata delivered across several chunks must be hashed in full, and the final OLED ' + 'commitment must cover that same complete byte string. This is the positive control for ' + 'the chunk-completeness gate.', + ['Complete contract-data Keccak-256 commitment']), + ('J9', 'test_msg_ethereum_signing_guards', 'test_eip1559_requires_chain_id', + 'Omitted chain_id is refused before any screen', + 'Without a chain_id the device cannot name the network, and a signature would be ' + 'pre-EIP-155 - replayable on every EVM chain. The refusal happens before the first ' + 'confirm(), so NO screen is drawn and no ButtonRequest is emitted. The empty ' + 'screenshot list below is the assertion.', + []), + ('J10', 'test_verify_typed_data', 'test_structured_eip712_is_refused', 'Structured EIP-712 is closed by default', 'The legacy JSON parser could not guarantee that every displayed value was the ' 'canonical value being hashed, and one screen took its title from the attacker-supplied ' 'domain name. The feature is withdrawn rather than shipped with a screen it could not ' 'vouch for: zero screens, refusal on the wire.', []), - ('S11', 'test_msg_binance_sign_tx', 'test_transfer', - 'Binance denom renders in full', - 'A long denom must render completely and must not overflow the formatting buffer.', - ['Transfer screen showing the full denom']), - ('S12', 'test_msg_ping', 'test_ping_long_body_is_paged', + ('J12', 'test_msg_ping', 'test_ping_long_body_is_paged', 'A long body is paged, not clipped', 'A body that will not fit one screen is shown across several, with the page number ' 'in the title. Before 7.14.2 the device drew what fitted and stopped - no ellipsis, ' @@ -364,9 +742,9 @@ def junit_cases(path): 'remainder is now actually reachable. The press DURATIONS (click to page, hold to ' 'approve) are not assertable in an emulator with no physical button.', ['Numbered page screens covering the whole body']), - ('S13', 'test_msg_ping', 'test_ping_short_body_is_not_paged', + ('J13', 'test_msg_ping', 'test_ping_short_body_is_not_paged', 'A body that fits is not paged', - 'The control for S12. A fitting body must still take exactly one screen with an ' + 'The control for J12. A fitting body must still take exactly one screen with an ' 'unnumbered title - otherwise a pager that numbered every confirmation, making ' 'ordinary approvals cost extra presses, would pass unnoticed.', ['Single unnumbered confirmation screen']), @@ -374,9 +752,10 @@ def junit_cases(path): ('X', 'Device Specifications', '0.0.0', 'The KeepKey is an open-source hardware wallet built on an ARM Cortex-M3 (STM32F205, 120MHz) ' 'with a 256x64 monochrome OLED, single confirmation button, and micro-USB interface. The ' - 'bootloader verifies firmware signatures before transferring control. Supported signed ' - 'bootloader-updater images can replace it, so release evidence treats bootloader and firmware ' - 'artifacts as separate binaries rather than calling either immutable.', + 'bootloader (v2.x) verifies the firmware signature using redundant F3 checks before ' + 'transferring control. Supported bootloaders can be updated by the protected bootloader ' + 'updater, which checks the existing version and verifies the update bytes before and ' + 'after writing; the bootloader is not immutable.', [ 'BOOT SEQUENCE:', '1. USB connect -> bootloader executes (always first)', @@ -391,13 +770,28 @@ def junit_cases(path): '- Input: single capacitive button (confirm/reject)', '- USB: micro-B, HID + WebUSB transports, HID fallback', '- Storage: BIP-39 seed encrypted in isolated flash region', - '- Curves: secp256k1, ed25519, NIST P-256, Pallas (Zcash)', + '- Curves: secp256k1, ed25519, NIST P-256; regular firmware also includes Pallas/Orchard', '', 'SECURITY MODEL:', '- All private key operations happen on-device, keys never leave', '- Every transaction output displayed on OLED for user verification', '- PIN grid randomized on each prompt (position-based, not digit-based)', '- BIP-39 passphrase creates hidden wallets (plausible deniability)', + '', + 'FIRMWARE VARIANTS (7.15, PR #282):', + '- Full multi-chain (default): all coin families including Zcash Orchard privacy;', + ' firmware_variant = model name.', + '- Bitcoin-only (KK_BITCOIN_ONLY): only Bitcoin + Testnet; all altcoin and', + ' shielded-Zcash handlers stripped; firmware_variant = KeepKeyBTC (EmulatorBTC', + ' on the emulator). Clients gate multi-chain-only tests on this string.', + '- There is no separate Zcash artifact: KK_ZCASH_PRIVACY is ON for the regular', + ' product and OFF only for KK_BITCOIN_ONLY.', + '', + 'SEED LOCK (7.15, PR #282):', + '- A seed created under bitcoin-only firmware is stamped in a reserved storage-', + ' version band. Multi-chain firmware refuses to load it and requires an explicit', + ' wipe (wipe-to-exit); the seed is never exposed to stripped-out code. Old', + ' multi-chain firmware treats the band as unknown and resets.', ], []), ('C', 'Core - Device Lifecycle', '7.0.0', @@ -420,18 +814,10 @@ def junit_cases(path): ['Wipe confirmation screen']), ('C2', 'test_msg_resetdevice', 'test_reset_device', 'Generate new seed', - 'Device generates 128 bits of entropy from hardware RNG, converts to a 12-word BIP-39 mnemonic, ' + 'Device generates 256 bits of entropy from hardware RNG, converts to BIP-39 mnemonic, ' 'and displays words on OLED one page at a time. Words are NEVER sent to the host. ' 'User writes them down as their backup.', ['Seed word display']), - ('C2b', 'test_msg_resetdevice', 'test_reset_device_18_words', - 'Generate 18-word seed', - 'Uses 192-bit strength and retains every physical backup page.', - ['Complete 18-word seed page sequence']), - ('C2c', 'test_msg_resetdevice', 'test_reset_device_24_words', - 'Generate 24-word seed', - 'Uses 256-bit strength and retains every physical backup page.', - ['Complete 24-word seed page sequence']), ('C3', 'test_msg_resetdevice', 'test_reset_device_pin', 'Generate seed with PIN', 'Same as C2 but also sets a PIN. PIN is entered twice for confirmation via the ' @@ -458,7 +844,7 @@ def junit_cases(path): []), ('C8', 'test_msg_loaddevice', 'test_load_device_3', 'Load 24-word mnemonic (debug)', - 'Tests 24-word BIP-39 mnemonic support (256 bits of entropy).', + 'Tests 24-word BIP-39 mnemonic support (256 bits of entropy, maximum security).', []), ('C9', 'test_msg_loaddevice', 'test_load_device_utf', 'Load with UTF-8 device label', @@ -543,8 +929,12 @@ def junit_cases(path): 'or information leaks. Verifies input sanitization.', []), ('C27', 'test_msg_getentropy', 'test_entropy', - 'Hardware RNG entropy', - 'Reads random bytes from the hardware RNG. Used to verify the entropy source is functional.', + 'Hardware RNG audit budget and lock policy', + 'Proves a fresh initialized, PIN-protected, locked device still requires confirmation; ' + 'then proves an uninitialized device returns exactly 8 x 8192 bytes (64 KiB) without a ' + 'press, with exact lengths, unique blocks, and conservative catastrophic-failure health ' + 'checks. The next request must restore confirmation. These checks detect a stuck or ' + 'grossly biased source; they are not a statistical certification of the hardware RNG.', []), ('C28', 'test_msg_cipherkeyvalue', 'test_encrypt', 'Symmetric key encryption', @@ -567,6 +957,160 @@ def junit_cases(path): ['Wordlist rejection warning']), ]), + ('K', 'Seed Generation Hardening (7.15.0+)', '7.15.0', + 'The 7.15 changes to how a seed comes into existence: user-supplied dice entropy folded in ' + 'on-device, and the PIN key-derivation rewrap. These ran green from the first 7.15 RC but ' + 'appeared nowhere in this report, because the catalog could not reference native firmware ' + 'unit tests at all and nobody had catalogued the two new pyk cases. Absent evidence read as ' + 'absent coverage during an RC audit, which is exactly the failure this section exists to ' + 'prevent.', + [ + 'DICE: user rolls a d6 on-device; short press advances 1-6, long press commits, undo backs out.', + 'The roll string is hashed and the digest confirmed on the OLED before it is mixed in.', + 'MIX: int_entropy = SHA256(int_entropy || rolls), folded in BEFORE the host EntropyRequest,', + 'so the device commits to its own contribution first and the host cannot choose the seed.', + 'ABORT: any aborted reset must disarm EntropyAck, or a later host EntropyAck would derive', + 'a seed from sha256(0*32 || host_bytes) -- entirely host-chosen. That is K2.', + 'PIN KDF: a v16 storage blob must still unlock and then rewrap to v19, or the upgrade bricks.', + ], + [ + ('K1', 'test_msg_resetdevice', 'test_reset_device_dice_mixed_is_verifiable', + 'Dice + device entropy, verified offline', + {'private': ('Strict privacy profile: the wire test checks every DebugLinkState is empty through ' + 'consent, device-word pages, input, digest, entropy acknowledgement and backup. ' + 'It checks successful commit and a valid mnemonic. The firmware native ' + 'DiceCeremonyPrivacy fixture supplies a known draw and independently verifies ' + 'MIXED derivation and device-side logical pages. Private screens are not captured.'), + 'public': ('Host selects MIXED (dice_entropy alone). The device shows the consent screen naming the ' + 'mode, then its own 32-byte draw as 24 BIP-39 words BEFORE any roll, then collects 99 rolls ' + 'over DebugLink with undo exercised. The test decodes the 24 words with its own ' + 'checksum-verified BIP-39 decoder, recomputes ' + 'seed = SHA256d(tag || draw || SHA256(tag || rolls)) from the published formula -- with the ' + 'host\'s EntropyAck bytes nowhere in it -- and requires the backup words to match. That is ' + 'the proof a user can repeat with tools/verify_dice_seed.py: the rolls reached the seed, ' + 'the device draw was the one it committed to, and the host contributed nothing.')}, + ['Mode consent', 'Dice entry screen', 'Digest confirmation']), + ('K1b', 'test_msg_resetdevice', 'test_reset_device_dice_only_is_verifiable', + 'Dice only, verified offline', + {'private': ('Strict privacy profile: every DebugLinkState is empty until commit. The host ' + 'injects 50 rolls, sends nonzero host entropy, and verifies the committed mnemonic ' + 'equals BIP39(SHA256(rolls)). Native fixtures verify the private device-side pages. ' + 'Private screens are not captured.'), + 'public': ('Host selects DICE ONLY (dice_entropy + dice_only), 50 rolls for a 12-word seed. No device ' + 'words are shown -- the rolls are the entire derivation -- and the test requires the backup ' + 'words to equal BIP39(SHA256(rolls)) while sending a nonzero EntropyAck that must be ' + 'ignored. Byte-identical to Coldcard\'s Dice-Rolls-Only.')}, + ['Mode consent', 'Dice entry screen', 'Digest confirmation']), + ('K1c', 'test_msg_resetdevice', 'test_reset_device_dice_rejects_biased_rolls', + 'Loaded die is refused', + 'Fifty ones -- one face on 100% of the rolls. Refused with SyntaxError before any digest ' + 'is drawn: this exceeds the one-in-a-million fair-die frequency threshold. ' + 'The frequency check detects severe bias; it is not an entropy guarantee.', + []), + ('K1d', 'test_msg_resetdevice', 'test_reset_device_dice_only_requires_dice_entropy', + 'dice_only without dice_entropy is refused', + 'The rolls-only derivation is a modifier of the dice ceremony, not a ceremony of its own; ' + 'the request is refused before any screen.', + []), + ('K1e', 'test_msg_resetdevice', 'test_reset_device_dice_refuses_no_backup', + 'Dice with no_backup is refused', + 'The dice modes exist to be checked against the backup words. A reset that never shows ' + 'them has nothing to verify and would put seed material on the screen under a WARNING ' + 'that recovery is impossible; refused before any screen.', + []), + ('K1f', 'test_msg_resetdevice', 'test_reset_device_dice_consent_cancel_aborts', + 'Cancel at the consent screen aborts everything', + 'The consent screen\'s only "no" is the host\'s Cancel. Asserts ActionCancelled, that a ' + 'subsequent EntropyAck finds no armed ceremony, and that the device is still uninitialized.', + []), + ('K2', 'test_msg_resetdevice', 'test_reset_reentry_disarms_entropy_ack', + 'Aborted reset disarms EntropyAck', + 'Regression for a host-chosen-seed hole: reset_init aborts left awaiting_entropy set from ' + 'an earlier run while zeroing int_entropy, so a following EntropyAck derived the seed ' + 'from host bytes alone. Arms a reset, re-enters with dice, cancels, and asserts the ' + 'next EntropyAck is refused with "Not in Reset mode" and the device stays uninitialized.', + []), + ('K3', 'Dice', 'RollsForStrength', + 'Roll count per seed strength', + 'd6 carries log2(6)=2.585 bits, so 128/192/256-bit seeds need 50/75/99 rolls ' + '(the Coldcard convention). A short count would silently weaken the seed.', + []), + ('K4', 'Dice', 'DeriveOnlyIsPlainSha256OfRolls', + 'DICE ONLY known-answer vector', + 'seed = SHA256("123456") against a digest computed in Python from the published formula, ' + 'not captured from this code. Pins the derivation to Coldcard\'s Dice-Rolls-Only byte ' + 'for byte, so a refactor cannot quietly change what a user must recompute offline.', + []), + ('K5', 'Dice', 'DeriveMixedVector', + 'MIXED known-answer vector', + 'seed = SHA256d("KK\\x01SM" || 0x00..0x1f || SHA256("KK\\x01D" || "654321165243")) against a ' + 'Python-computed digest. Pins the tag bytes, hash order and double-SHA of the mixed ' + 'derivation -- the exact formula tools/verify_dice_seed.py implements.', + []), + ('K5b', 'Dice', 'DeriveMixedZeroDeviceVector', + 'MIXED known-answer vector (zero device draw)', + 'Same construction with an all-zero device draw, pinned to a Python-computed digest.', + []), + ('K5c', 'Dice', 'DeriveMixedAliasesInPlace', + 'MIXED derives safely into its own input buffer', + 'reset.c derives into the buffer the device draw lives in. In-place and separate-output ' + 'results must be identical, or the aliasing would corrupt the seed.', + []), + ('K6', 'Dice', 'DeriveMixedDiffersFromUntaggedMix', + 'Tagged derivation cannot collide with the old formula', + 'The MIXED seed for zero draw and "123456" must differ from SHA256(draw || rolls), the ' + 'derivation earlier firmware used, so a wallet is never silently re-derived under the ' + 'wrong formula.', + []), + ('K7', 'Dice', 'DeriveOnlyUsesExactCount', + 'Only the counted rolls contribute', + 'Bytes past the declared roll count must not affect the result, so uninitialized tail ' + 'bytes of the roll buffer can never leak into seed material.', + []), + ('K7b', 'Dice', 'BiasGateRefusesOnlyAtOneInAMillion', + 'Loaded-die gate threshold', + 'For every face, 25/50, 32/75, and 39/99 rolls are refused; one fewer passes. ' + 'Each threshold bounds the fair-die probability of any face reaching that count ' + 'below one in a million. This avoids rejecting ordinary fair-die sequences.', + []), + ('K7c', 'Dice', 'BiasGateRejectsNonDiceBytes', + 'Non-d6 bytes are refused', + 'A byte outside \'1\'-\'6\' anywhere inside the counted rolls is refused regardless of the ' + 'distribution of the rest.', + []), + ('K8', 'Storage', 'PinKdfRewrapsToActiveVersionAfterCorrectPin', + 'Correct PIN unlocks and rewraps to the ACTIVE KDF', + 'The migration path for the hardened PIN KDF: an existing device must still unlock with ' + 'its current PIN, and any rewrap must target whatever KDF the build actually has ' + 'enabled. Renamed from PinKdfV16RewrapsToV19AfterCorrectPin because it is no longer ' + 'v19-specific -- the test now asserts BOTH sides of the STORAGE_PIN_KDF_V19 gate, so it ' + 'is meaningful in the shipping build where v19 is off. If this regressed, every ' + 'upgrading device would be locked out of its own seed.', + []), + ('K8b', 'Storage', 'PinUnlocksAfterRebootUnderV17', + 'The PIN still opens the wallet after a reboot', + 'The whole round trip in device order: create, set a PIN, serialize the V17 record as ' + 'storage_commit() does, reload into fresh state as a boot would, unlock, decrypt. Every ' + 'other storage test stays in RAM, and the wallet lockout this guards against lived ' + 'exactly on the serialize/reboot boundary -- a wrap the persisted record could not ' + 'describe, so the next boot derived the wrong KDF and every PIN failed.', + []), + ('K9', 'Storage', 'PinKdfV2FlagIsVersionedInV19', + 'KDF version flag is recorded in v19', + 'The new KDF is marked in the storage version band, so firmware can tell which derivation ' + 'a blob was written with instead of guessing.', + []), + ('K10', 'Storage', 'StorageUpgrade_Normal', + 'Normal storage upgrade path', + 'Baseline upgrade across storage versions with policies and cache preserved.', + []), + ('K11', 'Storage', 'NoopSecMigrate', + 'Idempotent security migration', + 'Re-running the migration on already-migrated storage must be a no-op rather than a ' + 'second rewrap.', + []), + ]), + ('B', 'Bitcoin', '7.0.0', 'Bitcoin is the primary chain and most extensively tested. Covers legacy P2PKH, P2SH-wrapped ' 'SegWit, native SegWit (bech32), and Taproot (P2TR). Transaction signing validates that the ' @@ -666,37 +1210,76 @@ def junit_cases(path): 'Transaction with both legacy and SegWit inputs in the same transaction.', []), ('B20', 'test_msg_signtx_p2tr', 'test_send_p2tr_only', - 'Sign Taproot P2TR tx', - 'Taproot (BIP-341/342) with Schnorr signatures. Newest address type with improved ' - 'privacy and efficiency.', - ['Taproot confirmation']), - ('B21', 'test_msg_signmessage', 'test_sign', + 'Create a Taproot P2TR output', + 'Pays from SegWit inputs to a P2TR output. This exercises P2TR output parsing and ' + 'display, but does not exercise a Schnorr key-path spend.', + ['Taproot output confirmation']), + ('B21', 'test_msg_signtx_taproot', 'test_send_p2tr', + 'Sign a Taproot key-path spend', + 'Spends a BIP-86 P2TR input using BIP-341 SIGHASH_DEFAULT and a BIP-340 Schnorr ' + 'signature. The 64-byte witness is compared byte-for-byte with an independently ' + 'computed reference value. The complete 153-byte transaction is then parsed as ' + 'BIP-144 and must consume every byte, proving the witness stack and the 4-byte ' + 'locktime footer actually reached the host rather than only the signature field.', + ['P2TR recipient confirmation', 'Fee confirmation']), + ('B22', 'test_msg_signtx_taproot', 'test_send_p2tr_with_change', + 'Sign P2TR with device-derived change', + 'Derives m/86\'/0\'/0\'/1/0 on-device, emits a P2TR change output, and verifies ' + 'the Schnorr witness against an independent BIP-340/341 reference. The complete ' + '196-byte transaction is parsed as BIP-144 and must consume every byte, and the ' + 'change output is matched as a full value/length/script triple.', + ['P2TR recipient confirmation', 'Fee confirmation']), + ('B23', 'test_msg_signtx_taproot', 'test_send_mixed_p2tr_and_legacy', + 'Sign mixed Taproot and legacy inputs', + 'Commits the P2TR signature to both inputs, including the legacy prevout amount and ' + 'scriptPubKey, while independently verifying the resulting Schnorr witness. The ' + 'complete 301-byte transaction is parsed as BIP-144; the Taproot input must carry ' + 'a single 64-byte stack item and the legacy input its empty 0x00 witness.', + []), + ('B24', 'test_msg_signtx_taproot', + 'test_mixed_p2tr_requires_every_input_amount', + 'Reject incomplete mixed Taproot commitments', + 'Fails closed when any input amount is absent, preventing the device from producing ' + 'a valid Schnorr signature over an incomplete BIP-341 commitment.', + []), + ('B25', 'test_msg_signtx_taproot', + 'test_mixed_p2tr_rejects_wrong_legacy_amount', + 'Reject a tampered legacy prevout amount', + 'Fetches the actual legacy prevout and rejects a host-provided amount that differs by ' + 'one satoshi, preventing a false BIP-341 commitment in a mixed-input transaction.', + []), + ('B26', 'test_msg_getaddress_taproot', 'test_show_taproot_address', + 'Show BIP-86 address on OLED', + 'Displays the complete bech32m Taproot receive address and QR code on the trusted ' + 'device screen for host-independent verification.', + ['Taproot address + QR code']), + ('B27', 'test_msg_signmessage', 'test_sign', 'Sign message with BTC key', 'Signs arbitrary text with a BTC address key. Used for proof-of-ownership and login.', ['Sign message on OLED']), - ('B22', 'test_msg_signmessage_segwit', 'test_sign', + ('B28', 'test_msg_signmessage_segwit', 'test_sign', 'Sign message with SegWit key', 'Message signing with P2SH-SegWit address key.', []), - ('B23', 'test_msg_signmessage_segwit_native', 'test_sign', + ('B29', 'test_msg_signmessage_segwit_native', 'test_sign', 'Sign message with bech32 key', 'Message signing with native SegWit address key.', []), - ('B24', 'test_msg_verifymessage', 'test_message_verify', + ('B30', 'test_msg_verifymessage', 'test_message_verify', 'Verify signed message', 'Device verifies a message signature against a BTC address.', []), - ('B25', 'test_msg_signtx_bgold', 'test_send_bitcoin_gold_nochange', + ('B31', 'test_msg_signtx_bgold', 'test_send_bitcoin_gold_nochange', 'Sign Bitcoin Gold tx', 'BTG fork uses same signing code with different chain parameters.', []), - ('B26', 'test_msg_signtx_dash', 'test_send_dash', + ('B32', 'test_msg_signtx_dash', 'test_send_dash', 'Sign Dash transaction', 'Dash special transaction types (InstantSend-compatible).', []), - ('B27', 'test_msg_signtx_grs', 'test_one_one_fee', + ('B33', 'test_msg_signtx_grs', 'test_one_one_fee', 'Sign Groestlcoin tx', 'GRS uses Groestl hash instead of SHA-256d for tx hashing.', []), - ('B28', 'test_msg_signtx_zcash', 'test_transparent_one_one', - 'Sign Zcash transparent tx', - 'Zcash transparent transactions use Overwinter/Sapling serialization format with ' - 'version group IDs and expiry height.', - ['Zcash tx confirm']), + # Zcash transparent signing moved to its own section Y (Zcash Transparent). ]), ('E', 'Ethereum', '7.0.0', 'Ethereum covers native ETH transfers, ERC-20 tokens, EIP-1559 gas, personal message signing ' - '(EIP-191), and contract interactions. The device displays checksummed addresses (EIP-55), ' - 'values in ETH with 18-decimal precision, and gas parameters.', + '(EIP-191), and contract interactions. The device displays checksummed addresses (EIP-55) and ' + 'gas parameters. Amount UNIT rule: values below 1 gwei (1e9 wei) show as raw "Wei" (there is ' + 'no smaller human unit to scale to); values at or above 1 gwei show 18-decimal-scaled ETH (or ' + 'the chain-native ticker on other EVM chains). Some tests below use small conformance-vector ' + 'amounts (e.g. 10 wei) for deterministic-signature pinning — their OLED frames legitimately ' + 'show raw "Wei", not a display bug.', [ 'ETH TRANSFER: Show "Send X ETH to 0x..." -> show gas -> confirm -> sign with secp256k1', 'ERC-20: Decode transfer(to,amount) from contract data -> show token name + amount', @@ -704,17 +1287,17 @@ def junit_cases(path): 'MESSAGE: EIP-191 prefix -> show text on OLED -> sign with ETH key', ], [ - ('E1', 'test_msg_ethereum_getaddress', 'test_ethereum_show_address', - 'Derive ETH address', 'Standard m/44\'/60\'/0\'/0/0 derivation. EIP-55 checksum address.', ['ETH address']), + ('E1', 'test_msg_ethereum_getaddress', 'test_ethereum_getaddress', + 'Derive ETH address', 'Standard m/44\'/60\'/0\'/0/0 derivation. EIP-55 checksum address. No screen: GetAddress without show_display returns on the wire and draws nothing.', []), ('E2', 'test_msg_ethereum_signtx', 'test_ethereum_signtx_nodata', 'Sign ETH transfer', 'Simple value transfer with no contract data. Device shows recipient + amount + gas.', ['ETH send confirmation']), ('E3', 'test_msg_ethereum_signtx', 'test_ethereum_signtx_data', 'Sign ETH tx with contract data', - 'With AdvancedMode enabled, arbitrary calldata is committed by its complete Keccak-256 ' - 'hash and byte count; it is not represented as decoded ABI or complete raw hex.', - ['Contract destination, data hash and byte count']), + 'Transaction with data field (contract call). Device shows data as hex since it cannot ' + 'decode arbitrary ABI without metadata.', + ['Contract data hex']), ('E4', 'test_msg_ethereum_signtx', 'test_ethereum_signtx_nodata_eip155', 'Sign ETH with EIP-155 replay protection', 'Chain ID embedded in signature v value to prevent cross-chain replay attacks.', []), @@ -749,8 +1332,9 @@ def junit_cases(path): ['Approval screen']), ('E11', 'test_msg_signtx_ethereum_erc20', 'test_approve_all', 'ERC-20 approve unlimited', - 'MAX_UINT256 approval is refused by the 7.14.2 default-deny boundary.', - []), + 'MAX_UINT256 approval. 7.14.2 through 7.14.x refuse it before drawing anything. From ' + '7.15 it signs after an UNLIMITED APPROVAL warning that names the spender and token.', + ['Unlimited approval warning']), ('E12', 'test_msg_ethereum_makerdao', 'test_generate', 'MakerDAO generate DAI', 'Complex DeFi contract interaction (MakerDAO CDP).', []), ('E13', 'test_msg_ethereum_sablier', 'test_sign_salarywithdrawal', @@ -759,6 +1343,83 @@ def junit_cases(path): '0x swap ETH to ERC-20', 'DEX aggregator swap via 0x protocol.', []), ('E15', 'test_msg_ethereum_cfunc', 'test_sign_execTx', 'Contract function call', 'Generic contract call signing.', []), + ('E16', 'test_sign_typed_data', 'test_ethereum_sign_typed_data_hash', + 'Precomputed EIP-712 hashes are BLIND-signed, behind AdvancedMode', + 'For a host that sends only the two 32-byte hashes. The device cannot show a recipient, ' + 'an amount or a chain from them -- it shows the digests and asks whether to trust the ' + 'host. The test proves both halves of the gate: with AdvancedMode ON the signature is ' + 'produced, and with it OFF the device refuses with "Enable AdvancedMode to blind-sign ' + 'typed hashes". From 7.15 a host that sends the document itself gets the streamed path ' + 'in section TD, where the device reads and shows every field it signs.', + []), + ('E16b', 'test_sign_typed_data', 'test_ethereum_sign_x402_eip3009', + 'The legacy one-shot JSON EIP-712 endpoint stays refused', + 'Ethereum712TypesValues is answered with "Structured EIP-712 disabled". Its JSON parser ' + 'could not guarantee that the value displayed was the value hashed, so 7.14.2 withdrew it ' + 'and 7.15 keeps it closed: structured EIP-712 goes through the streamed protocol in ' + 'section TD instead. The vector is an x402 EIP-3009 TransferWithAuthorization; its EIP-712 ' + 'V4 reference hashes stay in the fixture as an independent oracle. The screen list is ' + 'empty because a refusal draws nothing -- the evidence is the Failure on the wire.', + []), + ('E17', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_approve_liquidity_ETH_refused', + 'Uniswap V2 unlimited LP-token approval refused (7.14.x)', + 'Enables AdvancedMode, then attempts an unlimited FOX/WETH LP-token approval. ' + '7.14.x refuses it with Failure_ActionCancelled before any signing consent, checked on ' + 'the wire. From 7.15 unlimited approvals sign after a warning, so this skips there.', + ['Enable Policy: AdvancedMode']), + ('E18', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_add_liquidity_ETH', + 'Uniswap V2 add liquidity ETH+token', + 'Clear-signs both desired/minimum FOX and ETH amounts, the signed recipient, and the ' + 'deadline before the final fee review. The fixed signature binds those confirmations ' + 'to the complete addLiquidityETH calldata.', + ['FOX desired amount', 'FOX minimum', 'Recipient', 'ETH desired amount', + 'ETH minimum', 'Deadline', 'Fee and final approval']), + ('E19', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_remove_liquidity_ETH', + 'Uniswap V2 unsafe remove-liquidity recipient refused', + 'Enables AdvancedMode and reviews the LP burn amount and minimum FOX output, then ' + 'discloses that the signed recipient is not the signing wallet and refuses before ' + 'the remaining output, deadline, fee, or signing consent. The Failure_ActionCancelled ' + 'response proves the non-self recipient cannot route both withdrawn assets away.', + ['Enable Policy: AdvancedMode', 'LP burn amount', 'FOX minimum', + 'Non-self recipient refusal']), + ('E20', 'test_msg_ethereum_thorchain_deposit', 'test_deposit_legacy_selector', + 'THORChain router deposit() (legacy selector)', + 'Cross-chain swap via the THORChain router contract — a daily-driver EVM<->THORChain ' + 'swap path, natively decoded (asset/amount/memo) without clear-sign metadata. The ' + 'native amount shown is the signed msg.value (the ABI amount word is a router-ignored ' + 'hint and is never displayed as the send amount).', + ['Deposit amount (msg.value)', 'Full memo']), + ('E21', 'test_msg_ethereum_thorchain_deposit', 'test_deposit_with_expiry_selector', + 'THORChain router depositWithExpiry()', + 'Newer router selector variant with an expiry field; same native decode path. The ABI ' + 'memo length word is read from the calldata (not assumed 64 bytes) and the padded memo ' + 'must end exactly at the calldata end.', + ['Deposit amount (msg.value)', 'Full memo']), + ('E22', 'test_msg_ethereum_thorchain_deposit', + 'test_deposit_with_expiry_non_thor_address_blind_sign_blocked', + 'THORChain router call to a non-pinned address is blind-sign gated', + 'WHY it can be trusted: the router CONTRACT ADDRESS is pinned; a call shaped like a ' + 'THORChain deposit but sent to an unpinned address is refused native decoding and falls ' + 'through to the ordinary blind-sign gate instead of being silently native-decoded — the ' + 'fix for the router-spoofing / blind-sign-bypass class of attack.', + ['Blind sign disabled (Blocked)']), + ('E23', 'test_msg_ethereum_thorchain_deposit', + 'test_deposit_with_expiry_avalanche_router', + 'THORChain deposit on Avalanche clear-signs (per-chain router pin)', + 'THORChain deploys its router at a DIFFERENT address on every EVM chain, so the pin is ' + '(chain_id, address) together. Before the chain scope, only mainnet deposits ever ' + 'matched and an AVAX->ETH swap fell into the blind-sign gate. The Avalanche C-Chain ' + 'router (00dc61..f1d4) is verified live against THORChain /inbound_addresses; the ' + 'native amount screen shows msg.value with the CHAIN\'s ticker (AVAX), and the ' + 'signature is ECDSA-recovered against the host-built pre-image over chainId 43114.', + ['Thorchain router screen', 'AVAX amount', 'Full memo']), + ('E24', 'test_msg_ethereum_thorchain_deposit', + 'test_deposit_unpinned_chain_blind_sign_blocked', + 'Deposit on an unpinned chain is blind-sign gated', + 'The mainnet router ADDRESS on a chain with no pinned router (BSC) must not inherit ' + 'the deposit UX — the same address on another chain may hold unrelated attacker code. ' + 'Falls to the AdvancedMode gate; rejection is pre-UI (no frame).', + []), ]), ('R', 'Ripple (XRP)', '7.0.0', @@ -773,8 +1434,8 @@ def junit_cases(path): 'FEE: XRP requires a minimum fee (currently 10 drops). Device validates fee is within bounds.', ], [ - ('R1', 'test_msg_ripple_get_address', 'test_ripple_show_address', - 'Derive XRP address', 'Standard m/44\'/144\'/0\'/0/0 derivation.', ['XRP address']), + ('R1', 'test_msg_ripple_get_address', 'test_ripple_get_address', + 'Derive XRP address', 'Standard m/44\'/144\'/0\'/0/0 derivation. No screen: address is returned on the wire; the display path is the show variant.', []), ('R2', 'test_msg_ripple_sign_tx', 'test_sign', 'Sign XRP payment', 'Payment with amount in drops (1 XRP = 1,000,000 drops).', ['XRP send']), ('R3', 'test_msg_ripple_sign_tx', 'test_ripple_sign_invalid_fee', @@ -802,6 +1463,66 @@ def junit_cases(path): 'Sign Cosmos with memo', 'Memo field displayed for exchange deposit tags.', []), ]), + ('P', 'Osmosis', '7.15.0', + 'Osmosis is the Cosmos-ecosystem DEX, signed with the same amino encoding as Cosmos Hub and ' + 'derived from the same coin type (118). 7.15.0 CHANGED how every Osmosis amount is drawn: the ' + 'confirm screens formatted with atof() + "%.6f", and a float carries only ~7 significant ' + 'decimal digits, so a large transfer was displayed ROUNDED on the screen the user approves ' + '(123456789.123456 OSMO rendered as 123456792.000000). The signature was always over the ' + 'correct amount — the error was confined to the display, which is the half a hardware wallet ' + 'exists to get right. Amounts now use bounded decimal-string formatting; native uosmo is ' + 'canonical uint64, unknown denominations remain exact base-unit strings, and every long ' + 'signed asset is renderer-paged before signing.', + [ + 'SEND: recipient + OSMO amount rendered from integer base units, never a float', + 'PRECISION: 15-significant-digit amounts display exactly, not rounded to 7', + 'UNKNOWN DENOM: shown as raw base units — the device does not guess a decimal point', + ], + [ + ('P1', 'test_msg_osmosis_signtx', 'test_osmosis_sign_tx', + 'Sign Osmosis send', + 'Baseline MsgSend: recipient and a whole-OSMO amount on the confirm screen.', + ['OSMO send']), + ('P2', 'test_msg_osmosis_signtx', 'test_osmosis_send_amount_beyond_float_precision', + 'Amount beyond float precision displays exactly', + 'The regression this section exists for: 123456789123456 uosmo needs 15 significant ' + 'digits. The old float path drew 123456792.000000 OSMO over a transaction moving ' + '123456789.123456 OSMO. The captured frame is the evidence.', + ['Exact large amount']), + ('P3', 'test_msg_osmosis_signtx', 'test_osmosis_send_subunit_amount', + 'Sub-unit amount keeps its tail', + '500 uosmo is 0.000500 OSMO — no integer part and six decimals; it must not collapse ' + 'to 0 or lose the trailing digits.', + ['Sub-unit amount']), + ('P4', 'test_msg_osmosis_signtx', 'test_osmosis_send_denom_is_committed_to_the_signature', + 'Direct-wire denomination is committed', + 'Two otherwise-identical raw MsgSend requests using uosmo and uatom produce different ' + 'signatures, proving the reviewed denomination is part of the signed payload rather ' + 'than a hardcoded display-only label.', + []), + ('P5', 'test_msg_osmosis_signtx', 'test_osmosis_send_rejects_noncanonical_wire_amounts', + 'Noncanonical and overflowing uosmo are refused', + 'Raw-wire 01, -1, leading-space and UINT64 overflow values are rejected before any ' + 'display/signature divergence can occur.', + []), + ('P6', 'test_msg_osmosis_signtx', 'test_osmosis_swap_max_fields_are_fully_paged', + 'Maximum Swap fields are fully paged', + 'Two maximum-size 68-character IBC denominations plus 32-digit amounts force the ' + 'exact OLED renderer across separate bounded screens. The full ordered input and minimum-output ' + 'sequence is captured before the signature is returned.', + ['Swap Input', 'Minimum Output']), + ('P7', 'test_msg_osmosis_signtx', 'test_osmosis_amount_is_committed_to_the_signature', + 'Displayed amount is in the digest', + 'Two sends differing only in amount produce different signatures, so the confirm ' + 'screen is bound to what is signed rather than decorative.', + []), + ('P8', 'test_msg_osmosis_signtx', 'test_osmosis_signing_is_deterministic', + 'Deterministic nonces (RFC6979)', + 'Identical input yields an identical signature; a mismatch is a key-recovery risk, ' + 'not a cosmetic one.', + []), + ]), + ('H', 'THORChain', '7.0.0', 'THORChain is a decentralized cross-chain liquidity protocol. Native RUNE transactions use amino ' 'encoding with thor1... bech32 addresses. The memo field is the critical security element - it ' @@ -819,11 +1540,18 @@ def junit_cases(path): ('H1', 'test_msg_thorchain_getaddress', 'test_thorchain_get_address', 'Derive THORChain address', 'Bech32 thor1... address.', []), ('H2', 'test_msg_thorchain_signtx', 'test_thorchain_sign_tx', - 'Sign THORChain tx', 'Native RUNE transfer with memo.', ['Memo display']), + 'Sign THORChain tx — raw memo paged in full (7 memo variants)', + 'Native RUNE transfer. The COMPLETE raw memo is paged on the OLED (MEMO 1/N..N/N, ' + '72-char pages) as the sole memo gate — no structured summary can hide trailing ' + 'content, and a reject on any page aborts signing. The frames below show every page ' + 'for each routed memo shape (SWAP/s/=/ADD/a/+ and bare-pool).', + ['Memo pages 1/N..N/N', 'Send + asset', 'Sign confirm']), ('H3', 'test_msg_thorchain_signtx', 'test_sign_btc_eth_swap', 'Sign BTC->ETH swap', 'Cross-chain swap via THORChain memo routing.', ['Swap memo']), ('H4', 'test_msg_2thorchain_signtx', 'test_thorchain_sign_tx_deposit', - 'Sign THORChain deposit', 'LP deposit transaction.', []), + 'Sign THORChain deposit', 'LP deposit transaction (MsgDeposit): asset, amount and the ' + 'full memo are displayed from the exact bytes being signed.', + ['Deposit asset + memo']), ]), ('M', 'Maya Protocol', '7.0.0', @@ -841,9 +1569,29 @@ def junit_cases(path): ('M1', 'test_msg_mayachain_getaddress', 'test_mayachain_get_address', 'Derive Maya address', 'Bech32 maya1... address.', []), ('M2', 'test_msg_mayachain_signtx', 'test_sign_btc_eth_swap', - 'Sign BTC-ETH swap via Maya', 'Cross-chain swap via Maya memo routing.', []), + 'Sign BTC-ETH swap via Maya', 'Cross-chain swap via Maya memo routing (BTC OP_RETURN ' + 'side).', []), ('M3', 'test_msg_mayachain_signtx', 'test_sign_eth_add_liquidity', - 'Sign swap via Maya', 'Cross-chain swap via Maya memo routing.', []), + 'Add liquidity via Maya router (EVM side)', + 'depositWithExpiry() to the firmware-pinned Maya router; the signature is recovered ' + 'to the device signer over the exact calldata.', []), + ('M4', 'test_msg_mayachain_signtx', 'test_mayachain_sign_tx', + 'Sign native CACAO MsgSend — raw memo paged', + 'Native CACAO transfer. Signature verified host-side against the amino sign-doc ' + 'digest (account/chain/fee/memo/amount/addresses all bound) and the known device ' + 'pubkey — no frozen vectors to go stale. The complete raw memo is paged on the OLED ' + '(thorchain_confirm_full_memo is the sole memo gate for native MAYA too).', + ['CACAO send confirm', 'Memo page', 'Sign confirm']), + ('M5', 'test_msg_mayachain_signtx', 'test_mayachain_sign_tx_memos', + 'Native memo variants — every routed shape paged in full', + 'Each memo shape MAYA routes on (SWAP/s/=/ADD/a/+ and bare-pool) signs, each ' + 'signature is bound to its exact memo bytes via the sign-doc digest, and every page ' + 'of every memo is displayed (frames below, in order).', + ['Memo pages 1/N..N/N per variant']), + ('M6', 'test_msg_mayachain_signtx', 'test_mayachain_remove_liquidity', + 'Native WITHDRAW memo', + 'WITHDRAW:pool:basis-points memo paged in full; signature digest-verified.', + ['WITHDRAW memo page']), ]), # Binance Chain (BNB) - REMOVED: chain deprecated, beacon chain shut down 2024. @@ -888,22 +1636,35 @@ def junit_cases(path): 'cause fund loss or invalid transactions on the block-lattice.', [])]), - # ===== 7.14 NEW FEATURES ===== - ('V', 'EVM Clear-Signing', '7.14.0', - 'NEW: Verified transaction metadata for EVM contracts. Host sends a signed blob with contract ' - 'name, function, and decoded parameters. Device verifies blob signature against trusted key, ' - 'then shows human-readable details with VERIFIED icon. Firmware 7.14.2 defaults arbitrary ' - 'calldata to deny unless AdvancedMode is explicitly enabled.', + # ===== 7.15.0 NEW FEATURES ===== + ('V', 'EVM Clear-Signing', '7.15.0', + 'The purpose of clear-signing: instead of blind-signing an opaque hash, the device screen ' + 'answers WHO / WHAT / WHY before the user approves. WHO = the validated contract address ' + '(full, never truncated) + attested protocol name. WHAT = the decoded method and its typed ' + 'arguments in human terms (recipient address, "amount: 10.5 DAI" — not raw wei). WHY it can ' + 'be trusted = a signer whose key the device trusts attested that this exact description ' + 'matches this exact transaction, and the signature is REFUSED unless the signed digest ' + 'equals the metadata\'s committed tx hash (fail-closed, replay-proof). ' + 'NEW (phase 1): there is NO built-in "KeepKey says this is safe" key — every signer is loaded ' + 'at runtime (LoadClearsignSigner, user-confirmed, RAM-only) and EVERY tx it describes is ' + 'preceded by a warning naming the signer alias + fingerprint ("NOT verified by KeepKey"). ' + 'The built-in warning-free path returns once the signer infra is hardened. ' + 'The V9 flow below shows the full ordered review of a REAL Aave V3 supply() tx: the actual ' + 'calldata (selector 0x617ba037 + asset + amount + onBehalfOf + referralCode, 132 bytes) is ' + 'signed, and the metadata decodes it to protocol=Aave V3, asset=DAI, amount=10.5 DAI.', [ - 'CLEAR-SIGN: Signed metadata -> verify signature -> VERIFIED icon + method + decoded args', - 'BLIND SIGN: AdvancedMode off -> refuse; AdvancedMode on -> disclose hash and sign', + 'LOAD SIGNER: LoadClearsignSigner -> on-device confirm (alias + fingerprint) -> RAM slot', + 'WHO: warning (signer alias) + Contract: 0x… (full address) + protocol name', + 'WHAT: Call: + each decoded arg (ADDRESS / TOKEN_AMOUNT "10.5 DAI" / STRING)', + 'WHY: signature refused unless signed digest == metadata tx_hash (replay-proof)', + 'BLIND SIGN: No metadata + AdvancedMode off -> unknown contract data hard-rejected', ], [ ('V1', 'test_msg_ethereum_clear_signing', 'test_valid_metadata_returns_verified', 'Valid metadata accepted', - 'Correctly signed metadata blob is accepted. Device shows VERIFIED icon with decoded ' - 'method name and contract address.', - ['VERIFIED icon + method']), + 'Correctly signed metadata blob from a loaded signer is accepted. Device shows the ' + 'clearsign warning (signer alias + fingerprint) then the decoded method + contract. No screen: this asserts the VERIFIED classification on the wire, before any render.', + []), ('V2', 'test_msg_ethereum_clear_signing', 'test_wrong_key_returns_malformed', 'Wrong signing key rejected', 'Metadata signed with wrong key is rejected as malformed.', []), ('V3', 'test_msg_ethereum_clear_signing', 'test_tampered_method_returns_malformed', @@ -918,20 +1679,435 @@ def junit_cases(path): 'Signature verification math', 'Unit test for the metadata blob signature algorithm.', []), ('V7', 'test_msg_ethereum_clear_signing', 'test_tampered_blob_fails_verification', 'Tampered blob fails', 'Any byte change in the blob invalidates the signature.', []), + ('V7a', 'test_msg_ethereum_clear_signing', 'test_empty_payload_returns_malformed', + 'Empty metadata payload rejected', 'A zero-length blob classifies MALFORMED, never VERIFIED.', []), + ('V7b', 'test_msg_ethereum_clear_signing', 'test_truncated_payload_returns_malformed', + 'Truncated metadata payload rejected', + 'A blob cut short of the minimum structural size classifies MALFORMED.', []), + ('V7c', 'test_msg_ethereum_clear_signing', 'test_extra_trailing_bytes_returns_malformed', + 'Trailing garbage bytes rejected', + 'A blob with extra bytes appended past its declared structure classifies MALFORMED — ' + 'the parser cannot be tricked by appended data.', []), + ('V7d', 'test_msg_ethereum_clear_signing', 'test_wrong_version_returns_malformed', + 'Unknown version byte rejected', 'A blob with a version byte the firmware does not ' + 'recognize classifies MALFORMED rather than being guessed-parsed.', []), + ('V7e', 'test_msg_ethereum_clear_signing', 'test_zero_signature_returns_malformed', + 'All-zero signature rejected', 'A blob with a zeroed signature field classifies ' + 'MALFORMED — an attacker cannot skip signing by leaving the field blank.', []), + ('V7f', 'test_msg_ethereum_clear_signing', 'test_empty_key_slot_returns_malformed', + 'Metadata against an empty key slot rejected', + 'A blob referencing a signer slot with no key loaded classifies MALFORMED.', []), ('V8', 'test_msg_ethereum_signtx', 'test_ethereum_blind_sign_allowed', 'Blind sign permitted (AdvancedMode ON)', - 'Contract data with AdvancedMode enabled is committed by hash and allowed.', - ['Blind-sign warning and complete calldata hash']), - ('V9', 'test_msg_ethereum_signtx', 'test_ethereum_blind_sign_blocked', - 'Blind sign refused by default (AdvancedMode OFF)', - 'The policy-critical negative path must end in Failure without reaching approval.', + 'Contract data with AdvancedMode enabled. Device allows signing. ' + 'Blind-sign policy gating covered in 7.15.0+.', + []), + ('V9', 'test_msg_ethereum_clear_signing', 'test_binding_happy_path_signs_and_recovers', + 'Full who/what/why review of a real Aave V3 supply()', + 'TX: to=0x7d27..c7a9 (Aave V3 Pool), data=0x617ba037 + asset(DAI) + amount(10.5e18) + ' + 'onBehalfOf(0xd8dA..6045) + referralCode(0), chainId 1. METADATA decodes it to ' + 'protocol="Aave V3", asset=0x6B17..1d0F, amount=10.5 DAI, onBehalfOf=0xd8dA..6045, ' + 'bound to the exact sighash. The OLED screens below are the full ordered review the ' + 'user sees: warning -> Call: supply -> Contract -> protocol -> asset -> amount (10.5 ' + 'DAI, decimal-scaled, NOT wei) -> onBehalfOf -> tx confirm. The signature then recovers ' + 'to the device signer over THIS tx digest, proving the metadata was bound to this tx.', + ['warning', 'Call: supply', 'Contract', 'protocol: Aave V3', 'asset', 'amount: 10.5 DAI', + 'onBehalfOf', 'tx confirm']), + ('V10', 'test_msg_ethereum_clear_signing', 'test_replay_rejected_when_digest_differs', + 'Replay reject (binding enforced)', + 'Metadata committed to tx A; signing tx B (same contract/selector/chain, different ' + 'calldata) is refused at send_signature with "Metadata does not match signed transaction".', + ['Verified screen then reject']), + ('V11', 'test_msg_ethereum_clear_signing', 'test_advanced_mode_gate', + 'AdvancedMode blind-sign gate', + 'AdvancedMode OFF + unknown contract + no metadata is hard-rejected; ON signs; a ' + 'natively-decoded ERC-20 transfer is unaffected.', + ['Blind sign disabled (Blocked)']), + ('V12', 'test_msg_ethereum_clear_signing', 'test_cancel_clears_metadata_not_reused', + 'Cancel clears metadata (no stale reuse)', + 'Cancelling the verified confirm clears the blob; a later matching tx is not silently ' + 'signed with the stale metadata.', + []), + ('V13', 'test_msg_ethereum_clear_signing', 'test_load_required_before_verify', + 'No built-in key: load required (phase 1)', + 'On a fresh device a valid metadata blob is MALFORMED until a signer is loaded. Proves ' + 'there is no hardcoded warning-free trust path in phase 1.', + []), + ('V14', 'test_msg_ethereum_clear_signing', 'test_load_signer_cancel_refuses', + 'Load signer requires on-device consent', + 'Pressing reject on the LoadClearsignSigner confirm refuses the signer; the slot stays ' + 'empty and metadata for it is MALFORMED.', + ['Load clearsigner confirm']), + ('V15', 'test_msg_ethereum_clear_signing', 'test_load_signer_invalid_pubkey_rejected', + 'Invalid signer key rejected', + 'Uncompressed, zero (empty-slot sentinel), and truncated pubkeys are refused before any ' + 'confirm — a malicious host cannot install a bogus key.', + []), + ('V16', 'test_msg_ethereum_clear_signing', 'test_load_signer_bad_alias_rejected', + 'Signer alias sanitized', + 'Empty, oversized, control-char and format-specifier aliases are rejected — the alias ' + 'is rendered on the warning screen, so it cannot carry a display-spoofing payload.', + []), + + # ── ethereum signing-path guards (the blind-sign policy negative + # half + the EIP-1559 type/fee/chain_id regression suite) ── + ('VG1', 'test_msg_ethereum_signtx', 'test_ethereum_blind_sign_blocked', + 'Blind sign refused (AdvancedMode OFF)', + 'Unknown contract data with AdvancedMode disabled is hard-rejected before any confirm ' + 'screen — the negative half of the V8 policy pair.', + ['Blind signing disabled (Failure)']), + ('VG2', 'test_msg_ethereum_signing_guards', 'test_eip1559_requires_chain_id', + 'EIP-1559 requires chain_id', + 'A type-2 tx with no chain_id would hash a garbage pre-image and recover the wrong ' + 'signer; the device rejects it outright instead of signing an unbroadcastable tx.', + []), + ('VG3', 'test_msg_ethereum_signing_guards', 'test_eip1559_no_priority_fee_signs', + 'EIP-1559 zero priority fee signs correctly', + 'Regression test for the non-canonical-RLP wrong-signer bug: a type-2 tx with zero/' + 'absent priority fee must still hash and sign to the correct device address.', + []), + ('VG4', 'test_msg_ethereum_signing_guards', 'test_type2_without_max_fee_rejected', + 'Type-2 tx without max_fee_per_gas rejected', + 'The 0x02 envelope prefix comes from msg.type but the fee fields come from ' + 'has_max_fee_per_gas, so a type-2 tx carrying only gas_price would hash a legacy ' + 'fee into a 1559 field list. Refused, because a signature over a malformed field ' + 'list is still a valid signature over SOMETHING.', + []), + ('VG5', 'test_msg_ethereum_signing_guards', 'test_legacy_with_max_fee_rejected', + 'Legacy tx with max_fee_per_gas rejected', + 'Mixing legacy gas_price semantics with EIP-1559 fee fields is refused rather than ' + 'silently mis-hashed.', + []), + ('VG6', 'test_msg_ethereum_signing_guards', + 'test_contract_handler_streamed_calldata_signs_full_data', + 'Streamed calldata signs the full payload', + 'A contract-clear-sign handler must not confirm only the first chunk while signing ' + 'unshown streamed bytes after it.', + []), + ] + _V_CATALOG_TESTS + [ + ('V%d' % (17 + len(_V_CATALOG_TESTS)), + 'test_msg_ethereum_clear_signing', 'test_clearsign_batch_all_payloads', + 'Batch: sign + device-validate the whole catalog', + 'Signs every CLEARSIGN_FLOWS payload (%d real-world flows spanning DEX swaps, lending, ' + 'staking, approvals/permits, NFTs, governance, bridges, and account abstraction — ' + 'ERC-4337, EIP-7702, Safe multisig, Permit2, Uniswap V4) in one batch and has the ' + 'device validate each: every blob returns VERIFIED, and the same blob with one ' + 'tampered byte returns MALFORMED. Together with the frozen offline reference vectors ' + '(RFC 6979 deterministic — byte-identical blobs, sha256 snapshots in the test), this ' + 'makes python-keepkey the complete signer reference: produce these bytes and the ' + 'device accepts them; deviate by one byte and it refuses.' % ( + len(CLEARSIGN_FLOWS) if CLEARSIGN_FLOWS else 0), + []), + + # ── v2 static schema (no online signer) ────────────────────── + # v2 attests only the decode SCHEMA (no tx_hash, no arg values); the + # DEVICE decodes the argument values from the calldata it signs. This + # removes the per-tx online signer: the catalog is signed once, offline. + # Offline format tests run every cycle; the on-device decode test is + # gated to the release that ships v2 (METADATA_VERSION_SCHEMA). + ('VS1', 'test_msg_ethereum_clear_signing', 'test_layout_has_no_tx_hash', + 'v2 schema blob carries no tx_hash / no values', + 'The v2 (static schema) blob attests only how to decode a curated ' + '(chainId, contract, selector): method + per-arg name/format (+ static ' + 'decimals/symbol). It has NO committed tx_hash and NO argument values — ' + 'so it can be signed ONCE, offline, and served from a CDN with no hot ' + 'key. The device decodes the values itself from the calldata it signs.', + []), + ('VS2', 'test_msg_ethereum_clear_signing', + 'test_token_arg_carries_static_decimals_symbol_not_value', + 'v2 token arg = static decimals/symbol, value decoded on-device', + 'A TOKEN_AMOUNT arg encodes the token\'s static decimals + symbol (a ' + 'property of the contract), but NOT the amount — the amount is decoded ' + 'from the calldata word on-device, then rendered "1.5 USDC".', + []), + ('VS3', 'test_msg_ethereum_clear_signing', 'test_frozen_body_snapshot', + 'v2 wire format frozen vs firmware parser', + 'The canonical v2 body\'s length + sha256 are frozen, so the ' + 'serializer can never drift from firmware\'s parse_v2_args() undetected ' + '— the same byte-parity discipline the v1 reference vectors use.', + []), + ('VS4', 'test_msg_ethereum_clear_signing', 'test_rejects_dynamic_format', + 'v2 scope: fixed-word types only', + 'v2 decodes fixed single ABI words (ADDRESS / AMOUNT / TOKEN_AMOUNT) — ' + 'approve/transfer/transferFrom and fixed-arg calls. Dynamic types ' + '(string/bytes/arrays) are rejected by the serializer and fall to the ' + 'blind-sign path on-device; a bounded dynamic decoder is future work.', + []), + ('VS5', 'test_msg_ethereum_clear_signing', + 'test_v2_transfer_decodes_signs_and_recovers', + 'v2 on-device: decode from calldata, sign, recover', + 'END-TO-END with AdvancedMode OFF: a v2 transfer() schema blob + a real ' + 'transfer(to, amount) tx. The device decodes to/amount from the calldata ' + 'and clear-signs; the signature recovers to this device\'s signer over ' + 'the tx digest — so the who/what/why shown was bound to the exact tx, ' + 'with no tx_hash. The offline format tests above pin the wire format ' + 'the device decodes.', + ['Clearsign warning', 'v2 decoded transfer to/amount', 'Sign transaction']), + ('VS6', 'test_msg_ethereum_clear_signing', + 'test_v2_calldata_length_mismatch_falls_back_to_raw_review', + 'v2 decode-mismatch falls back to raw review (fail-closed)', + 'THE headline v2 security property: schema says 2 words, calldata carries 3. ' + 'decode_v2_args\' structural completeness check fails, so the device does NOT ' + 'clear-sign a decode that would not match what it is about to sign. With ' + 'AdvancedMode ON it falls through to the ordinary unverified raw review, and ' + 'the ordered OLED captures prove the decoded ClearSign display was not used.', + ['Unverified transaction warning', 'Raw data review', 'Sign transaction']), + ('VS7', 'test_msg_ethereum_clear_signing', + 'test_v2_unsupported_arg_format_returns_malformed', + 'v2 unsupported arg format rejected at blob load', + 'A hand-crafted v2 blob using an unsupported dynamic format (STRING) — the kind the ' + 'Python serializer itself refuses to build — is independently rejected by the ' + 'device\'s own parser as MALFORMED, before any calldata is even considered.', + []), + ]), + + ('G', 'Hive', '7.15.0', + 'NEW: Hive (Graphene) support with SLIP-0048 role derivation. Four role keys per account ' + '(owner, active, posting, memo), each an STM-prefixed secp256k1 key. Signs Graphene ' + 'transactions — transfer, the account-create / account-update authority operations ' + 'Pioneer uses to onboard sponsored accounts, and parsed generic operations ' + '(vote, comment, custom_json). HiveSignMessage / Keychain signBuffer is not exposed ' + 'by the selected 7.15 release; its catalog cases remain skipped rather than proving ' + 'message-signing support. Every transaction signature ' + 'recovers to the role key it was signed under, each serialized field is bound at its byte ' + 'position, and every user-controlled string is paged IN FULL on the OLED (72-char ASCII ' + 'pages; non-ASCII shown as complete hex).', + [ + 'KEYS: SLIP-0048 m/48\'/13\'/role\'/0\'/account\' -> STM-prefixed pubkey per role', + 'SIGN TX: Graphene serialize -> per-op confirm (amount + recipient + full memo pages) -> ECDSA sign', + 'ACCOUNT CREATE: attest 4 role authorities + new-account name -> owner-key signature', + 'SIGN MESSAGE: unavailable in this 7.15 release; skipped message cases are not support evidence', + 'SIGN OPS: device re-parses the Graphene bytes; unrecognized ops are refused (no blind-sign)', + ], + [ + ('G1', 'test_msg_hive', 'test_hive_get_public_key_active', + 'Derive active-role key', + 'Active-role key derives and returns an STM-prefixed key plus the 33-byte compressed ' + 'raw pubkey (0x02/0x03 prefix).', + []), + ('G2', 'test_msg_hive', 'test_hive_get_public_keys_all_roles', + 'Derive all four role keys', + 'Owner, active, posting and memo keys all derive, are distinct, and STM-formatted. The ' + 'bulk path agrees with the single-key path for the active role.', + []), + ('G3', 'test_msg_hive', 'test_hive_sign_transfer', + 'Sign Hive transfer', + 'Transfer (op 2) signs; the signature recovers to the active key. The device shows the ' + 'recipient account and amount, and every serialized field (from/to/amount/asset/memo) ' + 'is bound at its position so a rewritten recipient or amount fails.', + ['Transfer amount + recipient']), + ('G4', 'test_msg_hive', 'test_hive_sign_account_create', + 'Sign account-create attestation', + 'account_create (op 9) signs and recovers to the owner key — the attestation a Pioneer ' + 'sponsor verifies before spending an account-creation token. Binds the four role ' + 'authorities, creator, new-account name and fee at their exact positions.', + ['Account-create confirm']), + ('G5', 'test_msg_hive', 'test_hive_sign_account_update', + 'Sign account-update', + 'account_update (op 10) signs and recovers to the owner key; the replacement ' + 'authorities are bound to their slots so updating the wrong authority fails.', + ['Account-update confirm']), + ('G6', 'test_msg_hive', 'test_hive_sign_transfer_max_memo_ok', + 'Max-length memo paged in full (boundary)', + 'A memo of exactly 440 bytes (the serialization limit) still signs, and the OLED ' + 'pages the COMPLETE memo (MEMO 1/7..7/7) — nothing is truncated behind a ' + 'benign-looking prefix.', + ['Memo pages 1/7..7/7']), + ('G7', 'test_msg_hive', 'test_hive_sign_transfer_rejects_long_memo', + 'Over-limit memo rejected', + 'A 441-byte memo fails with a specific "memo too long" error before any signing. ' + 'Rejection happens before any confirm UI, so there is no OLED frame — the proof is ' + 'the specific device error.', + []), + ('G8', 'test_msg_hive', 'test_hive_sign_transfer_rejects_foreign_path', + 'Foreign derivation paths rejected', + 'BIP-44 trees, wrong registry, unassigned roles and short paths are all refused for ' + 'transaction signing — the SLIP-0048 fence.', + []), + ('G9', 'test_msg_hive', 'test_hive_sign_transfer_rejects_wrong_network', + 'Wrong network index rejected', + 'A path whose network index is not Hive (13\') must not sign.', + []), + ('G10', 'test_msg_hive', 'test_hive_sign_transfer_rejects_non_active_roles', + 'Transfer requires the active role', + 'Transfers signed under owner/posting/memo paths are refused; only active\' moves ' + 'funds.', + []), + ('G11', 'test_msg_hive', 'test_hive_sign_message_posting', + 'Sign Hive message (dApp login)', + 'Keychain signBuffer contract: signature over SHA256(raw message bytes) with the ' + 'posting key. The device names the signing role and pages the full message text. The ' + 'signature recovers to the posting key — exactly what a Hive dApp verifies for login.', + ['Signing-role screen', 'Message text']), + ('G12', 'test_msg_hive', 'test_hive_sign_message_all_roles', + 'Message signing across roles', + 'Posting, active and memo roles may sign (owner\' is refused); each signature ' + 'recovers to that role\'s distinct key.', + ['Role + message screens']), + ('G13', 'test_msg_hive', 'test_hive_sign_message_long_printable_ok', + 'Long message paged in full', + 'Printable text over the display budget routes through 72-char pages — never ' + 'silently truncated — and the signature covers every byte.', + ['Message pages']), + ('G14', 'test_msg_hive', 'test_hive_sign_message_max_length_ok', + 'Max-length (1024 B) message', + 'A message of exactly 1024 bytes (the proto cap) pages and signs.', + ['1024-byte message paged']), + ('G15', 'test_msg_hive', 'test_hive_sign_message_nonprintable_bytes', + 'SECURITY: binary messages refused (oracle fix)', + 'A binary "message" equal to chain_id || serialized_tx would hash to a valid ' + 'TRANSACTION signature on any fork chain — an active-key fund-theft oracle. The ' + 'printable-ASCII whitelist refuses every binary buffer, making signable messages ' + 'provably disjoint from all transaction preimages. Rejection is pre-UI (no frame); ' + 'the proof is the "printable" device error.', + []), + ('G16', 'test_msg_hive', 'test_hive_sign_message_rejects_chain_id_prefix', + 'Chain-id-prefixed message refused', + 'Belt-and-suspenders subset of G15: a message starting with the Hive mainnet chain ' + 'id is refused outright.', + []), + ('G17', 'test_msg_hive', 'test_hive_sign_message_rejects_oversize', + 'Oversize message refused', + '1025 bytes must fail — the proto cap and the handler agree on 1024.', + []), + ('G18', 'test_msg_hive', 'test_hive_sign_message_rejects_bad_paths', + 'Message signing path fence', + 'Foreign trees, wrong network, unassigned roles, owner\' and short paths are all ' + 'refused — the same SLIP-0048 fence as transactions.', + []), + ('G19', 'test_msg_hive', 'test_hive_sign_ops_vote', + 'Parsed vote operation', + 'The device re-parses the Graphene bytes and displays voter, author, permlink and ' + 'weight from the exact bytes being signed — a host serializer bug can only produce a ' + 'rejection, never a silent wrong-sign.', + ['Vote op screens']), + ('G20', 'test_msg_hive', 'test_hive_sign_ops_comment', + 'Parsed comment operation', + 'Comment title and body are user-controlled strings — both paged in full (72-char ' + 'ASCII pages / complete hex for non-ASCII).', + ['Comment fields paged']), + ('G21', 'test_msg_hive', 'test_hive_sign_ops_custom_json_active', + 'Parsed custom_json (active)', + 'custom_json id and payload paged in full under the active role.', + ['custom_json paged']), + ('G22', 'test_msg_hive', 'test_hive_sign_ops_custom_json_posting', + 'Parsed custom_json (posting)', + 'Same shape under the posting role (the common dApp path).', + []), + ('G23', 'test_msg_hive', 'test_hive_sign_ops_downvote_and_default_chain_id', + 'Downvote + default chain id', + 'Negative weights display correctly and the default chain id binds the mainnet ' + 'digest.', + []), + ('G24', 'test_msg_hive', 'test_hive_sign_ops_role_fences', + 'Ops role fences', + 'vote/comment sign under posting\'; custom_json under its declared auth; memo\' and ' + 'owner\' never sign operations.', + []), + ('G25', 'test_msg_hive', 'test_hive_sign_ops_rejects_excluded_and_unknown_ops', + 'Unknown/excluded ops refused (no blind-sign)', + 'transfer-shaped and unrecognized operations inside SignOperations are refused — ' + 'there is no blind-sign fallback for Graphene bytes the device cannot display.', + []), + ('G26', 'test_msg_hive', 'test_hive_sign_ops_rejects_malformed_structure', + 'Malformed Graphene structure refused', + 'Truncated fields, wrong op counts and trailing bytes are all parse failures, not ' + 'sign-what-you-can.', + []), + ('G27', 'test_msg_hive', 'test_hive_sign_ops_rejects_oversize', + 'Oversize operations refused', + 'Payloads beyond the proto cap are refused before parsing.', + []), + ('G28', 'test_msg_hive', 'test_hive_sign_account_ops_reject_non_owner_roles', + 'Account authority ops require owner', + 'account_create / account_update sign only under the owner role.', + []), + # ── Phase 2/3 op table (fw #315) ──────────────────────────────── + # These ran green in the full suite from the day they landed, but had + # no SECTIONS entry, so screenshot_filter() never selected them and + # eleven newly clear-signed ops shipped with zero OLED proof. A + # correct signature over bytes the user was shown something else for + # is the exact failure the clear-sign table exists to prevent, so + # every op that renders a confirm screen gets a non-empty hint. + ('G29', 'test_msg_hive', 'test_hive_sign_ops_limit_order_create', + 'Internal market: limit_order_create', + 'The op that motivated phase 3 — a HIVE->HBD market swap. Both sides of the order ' + 'are shown with their symbols pinned (a swapped symbol hides a ~2000x value ' + 'difference behind an identical-looking number), and order id / fill-or-kill / ' + 'expiry get their own screen so they cannot be crowded off the first.', + ['Sell and receive amounts', 'Order terms screen']), + ('G30', 'test_msg_hive', 'test_hive_sign_ops_limit_order_cancel', + 'Internal market: limit_order_cancel', + 'Cancelling names the order id and the owner. No recipient row is forged — the op ' + 'acts on the signer\'s own book entry.', + ['Cancel order screen']), + ('G31', 'test_msg_hive', 'test_hive_sign_ops_active_tier_value_ops', + 'Active-tier value ops', + 'transfer_to_vesting, convert, transfer_to/from_savings, delegate_vesting_shares ' + 'and withdraw_vesting all move or lock value, so all six sign only under active. ' + 'Each renders its own amount + counterparty.', + ['Power up', 'Convert', 'Savings deposit/withdraw', 'Delegation', 'Power down']), + ('G32', 'test_msg_hive', 'test_hive_sign_ops_posting_tier_ops', + 'claim_reward_balance is posting tier', + 'Claiming is not spending, so it signs under posting. Three reward assets across ' + 'two screens (the OLED body fits three rows; a fourth would be signed but never ' + 'shown).', + ['Claim rewards screens']), + ('G33', 'test_msg_hive', 'test_hive_sign_ops_zero_amount_semantics', + 'Zero means something for two ops, nothing for the rest', + '0 VESTS stops a power-down and removes a delegation — both legitimate, so zero is ' + 'NOT rejected there and the screen must say which action it is. Everywhere else a ' + 'zero amount is a no-op and refused.', + ['Stop power down', 'Remove delegation']), + ('G34', 'test_msg_hive', 'test_hive_sign_ops_asset_symbol_and_precision_pinned', + 'Asset symbol pinned to its protocol precision', + 'HIVE/HBD are 3-decimal, VESTS is 6. The parser refuses any other pairing: a wrong ' + 'precision moves the decimal point on the confirmation screen relative to what the ' + 'chain applies.', + []), + ('G35', 'test_msg_hive', 'test_hive_sign_ops_comment_options_binds_to_its_comment', + 'comment_options binds to its own comment', + 'Payout redirection is only accepted immediately after a comment op with the same ' + 'author and permlink. Standing alone it could attach beneficiaries to a post the ' + 'user published earlier and is not reviewing on this screen.', + ['Payout options screens']), + ('G36', 'test_msg_hive', 'test_hive_sign_ops_comment_options_beneficiary_rules', + 'Beneficiary ordering, uniqueness and total enforced on-device', + # Scoped to exactly what the mapped test asserts. It covers three + # rejections — unsorted, duplicate, and weights summing over 100%. + # The extension-count cap, the 1-8 count bound and per-beneficiary + # weight range are enforced by the parser but are NOT exercised here, + # so the entry must not claim them. + 'Beneficiaries must be strictly ascending by account (which also makes them unique) ' + 'and their weights must sum to no more than 10000 bp. Unsorted, duplicate and ' + 'over-100% lists are each refused.', + # Rejection-only: every case here is _assert_ops_fails, so the device + # refuses before drawing anything and the capture would be three + # frames of the idle home screen — a report entry that LOOKS like + # visual proof and is not. The per-beneficiary confirm screens are + # captured by G35, which actually signs a two-beneficiary payout. + []), + ('G37', 'test_msg_hive', 'test_hive_sign_ops_account_update2_is_rejected', + 'account_update2 is refused', + 'Any owner/active/posting authority or memo_key present is a hard reject, so a key ' + 'change is never summarized as a profile-only update.', + []), + ('G38', 'test_msg_hive', 'test_hive_sign_ops_truncated_bodies_rejected', + 'Truncated op bodies refused', + 'A body cut short mid-field is a parse failure, not sign-what-you-can.', []), ]), ('S', 'Solana', '7.14.0', - 'NEW: Full Solana with Ed25519 (SLIP-10), base58 addresses, 37 instruction types across 7 ' - 'programs. Key security fix: full 44-character address display replaces old 8-char truncation ' - 'that was a spoofing vector.', + 'Full Solana with Ed25519 (SLIP-10), base58 addresses, 37 instruction types across 7 ' + 'programs. The 44-character address is displayed in full: the old 8-character truncation ' + 'was a spoofing vector, because two addresses agreeing on their first eight base58 ' + 'characters are cheap to grind. The open gap this release closes is versioned (v0) ' + 'transactions whose accounts live in an Address Lookup Table. The device cannot resolve a ' + 'table it has never seen, so until now it routed them to the blind-sign gate (S24) and ' + 'signed accounts it never showed. S26-S29 are KKSOLSW1: a loaded provider attests the ' + 'resolved accounts, bound to sha256(raw_tx), and the device DISPLAYS them -- in addition ' + 'to, never instead of, the review that already existed.', [ 'ADDRESS: m/44\'/501\'/0\' Ed25519 -> full 44-char base58 on OLED', 'SIGN TX: Parse instructions -> per-instruction confirmation -> Ed25519 sign', @@ -939,7 +2115,7 @@ def junit_cases(path): ], [ ('S1', 'test_msg_solana_getaddress', 'test_solana_get_address', - 'Derive Solana address', 'Full 44-character base58 address returned without display.', []), + 'Derive Solana address', 'Full 44-character base58 address displayed on OLED. No screen: the drawn address is test_solana_show_address (S3b).', []), ('S2', 'test_msg_solana_getaddress', 'test_solana_different_accounts', 'Different account indices', 'Verifies different accounts produce different addresses.', []), ('S3', 'test_msg_solana_getaddress', 'test_solana_deterministic', @@ -955,9 +2131,11 @@ def junit_cases(path): ('S7', 'test_msg_solana_signtx', 'test_solana_sign_deterministic', 'Deterministic signing', 'Same tx always produces same signature.', []), ('S8', 'test_msg_solana_signtx', 'test_solana_sign_token_transfer', - 'Unchecked SPL Token transfer is refused', - 'A transfer without a signed mint cannot be given verified token semantics.', - []), + 'Unchecked SPL Transfer requires AdvancedMode', + 'Unchecked Transfer (op 3) carries NO signed mint — the device cannot prove which ' + 'token is moving, so it is forced through the AdvancedMode blind-sign gate (matching ' + 'Trezor and Ledger, which both reject it). Only TransferChecked clear-signs.', + ['Blind-sign gate']), ('S9', 'test_msg_solana_signtx', 'test_solana_sign_stake_delegate', 'Stake delegate', 'Delegate SOL to a validator for staking rewards. OLED shows delegate confirmation.', @@ -971,21 +2149,190 @@ def junit_cases(path): 'Set priority fee for transaction. OLED shows compute unit price.', ['Unit price']), ('S12', 'test_msg_solana_signtx', 'test_solana_sign_token_transfer_with_metadata', - 'SPL Token with metadata', - 'Token transfer with SolanaTokenInfo (mint, symbol, decimals). OLED shows human-readable token name.', - ['Token name + amount']), + 'Host metadata does NOT bypass the unchecked-transfer gate', + 'An unchecked Transfer accompanied by host SolanaTokenInfo still requires ' + 'AdvancedMode: the mint is not part of the signed instruction, so the metadata is ' + 'unauthenticated and must not make the tx look clear-signable.', + ['Blind-sign gate']), + ('S13', 'test_msg_solana_signtx', 'test_solana_sign_token_transfer_checked', + 'TransferChecked clear-signs with the mint on its own screen', + 'TransferChecked (op 12) binds the mint in the signed instruction bytes. The device ' + 'shows "Token mint " on a DEDICATED screen before the amount — the ' + 'authenticated token identity cannot be pushed off-view by a host-controlled symbol ' + '— and decimals come from the signed instruction, never from the host. AdvancedMode ' + 'stays OFF.', + ['Token mint screen', 'Amount + symbol']), + ('S14', 'test_msg_solana_signtx', + 'test_solana_sign_token_transfer_checked_attested_symbol', + 'Signed token definition: symbol attested by a loaded signer', + 'The token_info carries a secp256k1 attestation over (mint, decimals, symbol) by a ' + 'signer loaded via LoadClearsignSigner — the same chain-agnostic trust anchor as EVM ' + 'clear-sign metadata (KeepKey\'s open equivalent of Trezor\'s CoSi-signed token ' + 'definitions). The device verifies it, requires the attested decimals to equal the ' + 'signed instruction\'s, and adds a \'Token "USDC" signed by \' ' + 'screen. An invalid attestation rejects the symbol outright (never falls back to the ' + 'claim).', + ['Load signer consent', 'Token mint screen', 'Signed-by alias + fingerprint']), + ('S15', 'test_msg_solana_signtx', 'test_solana_sign_token_approve', + 'Unchecked SPL Approve requires AdvancedMode', + 'Approve (op 4) hides the delegated token\'s mint — same gate as unchecked Transfer.', + ['Blind-sign gate']), + ('S16', 'test_msg_solana_signtx', + 'test_solana_sign_create_account_requires_advanced_mode', + 'CreateAccount requires AdvancedMode', + 'CreateAccount assigns the new account\'s owner program and space, which the screen ' + 'does not fully disclose — gated rather than partially clear-signed.', + ['Blind-sign gate']), + ('S17', 'test_msg_solana_signtx', + 'test_solana_sign_set_authority_requires_advanced_mode', + 'SetAuthority requires AdvancedMode', + 'SetAuthority hands over control of a mint/account (including the undistinguishable ' + '"clear authority" case) — an account-takeover vector, gated.', + ['Blind-sign gate']), + ('S18', 'test_msg_solana_signtx', 'test_solana_sign_stake_authorize_clearsigns', + 'StakeAuthorize clear-signs role + new authority', + 'Shows the stake account, the role being reassigned (staker/withdrawer) and the full ' + 'new authority address.', + ['Role + new authority']), + ('S19', 'test_msg_solana_signtx', 'test_solana_sign_stake_withdraw', + 'Stake withdraw shows the destination', + 'The withdrawal destination account is displayed in full — a host cannot silently ' + 'redirect withdrawn SOL.', + ['Withdraw + destination']), + ('S20', 'test_msg_solana_signtx', 'test_solana_sign_stake_deactivate', + 'Stake deactivate shows the stake account', + 'The acted-on stake account is named on-screen.', + ['Stake account']), + ('S21', 'test_msg_solana_signtx', 'test_solana_sign_multi_instruction_2x_transfer', + 'Multi-instruction: each instruction confirmed', + 'Two transfers in one tx produce INSTR 1/2 and INSTR 2/2 screens — nothing rides ' + 'along unconfirmed.', + ['INSTR 1/2 + 2/2']), + ('S22', 'test_msg_solana_signtx', + 'test_solana_sign_multi_instruction_transfer_and_memo', + 'Transfer + memo both shown', + 'A transfer with an attached memo instruction confirms both.', + ['Transfer + memo screens']), + ('S23', 'test_msg_solana_signtx', 'test_solana_sign_versioned_v0_static_verified', + 'Versioned (v0) tx with static keys clear-signs', + 'A v0-format tx whose accounts are all static parses and clear-signs like legacy.', + ['v0 instruction screens']), + ('S24', 'test_msg_solana_signtx', 'test_solana_sign_versioned_v0_opaque', + 'v0 with address-table lookups requires AdvancedMode', + 'Lookup-table accounts cannot be resolved on-device, so the tx routes to the ' + 'blind-sign gate.', + []), + ('S25', 'test_msg_solana_signtx', + 'test_solana_sign_x402_zero_lut_usdc_payment', + 'x402 zero-LUT v0 USDC payment is hardware verified', + 'The sponsor pays fees while the KeepKey key authorizes TransferChecked. The device ' + 'renders 0.002 USDC from firmware-owned mint metadata, derives ATA(payTo, mint) ' + 'offline, and displays the merchant owner only after it matches the signed ' + 'destination token account. The required x402 uniqueness memo is also displayed; ' + 'AdvancedMode stays OFF.', + ['Compute budget', 'Known USDC mint', 'Verified recipient owner', + '0.002 USDC', 'x402 memo']), + # KKSOLSW1 -- the answer to S24. A v0 tx whose accounts live in a + # lookup table cannot be resolved on-device, so today the device signs + # accounts it never showed. These four are the additive invariant + # (section F) restated for Solana, and R-4.1 of SRS-7.15. The + # canonical 7.15 product includes this protocol work. + ('S26', 'test_msg_solana_lut_attestation', + 'test_attested_accounts_are_shown_and_blind_sign_still_follows', + 'Attested lookup-table accounts are shown, and the blind-sign warning survives', + 'A loaded provider attests the resolved accounts over ' + '"KeepKeySolanaTxAccounts/1" || sha256(raw_tx) || count || keys. The device verifies ' + 'through the same chain-agnostic anchor as every other runtime signer, then adds one ' + 'identity screen and one screen per account IN FRONT of the existing flow. The ' + 'assertion is exact and it is the whole point: the attested run shows ' + 'len(base) + 1 + len(accounts) screens and its TAIL equals the baseline sequence ' + 'exactly. More screens, never fewer.', + ['Provider identity + NOT verified by KeepKey', 'Lookup account 1', + 'Lookup account 2', 'Existing blind-sign warning']), + ('S27', 'test_msg_solana_lut_attestation', + 'test_bad_signature_degrades_to_todays_flow', + 'A signature that does not verify changes nothing', + 'The failure mode of a describer must be silence, not a refusal: a provider outage ' + 'or a botched signature costs the user the extra screens and nothing else. The ' + 'confirmation sequence is asserted EQUAL to the no-attestation baseline, and the ' + 'transaction still signs.', + []), + ('S28', 'test_msg_solana_lut_attestation', + 'test_attestation_does_not_replay_onto_another_transaction', + 'An attestation cannot be replayed onto another transaction', + 'sha256(raw_tx) is inside the preimage, so an attestation is worthless anywhere but ' + 'the transaction it was issued for. The test perturbs one byte of the lookup-table ' + 'address and replays the signature: the device falls back to the baseline flow. ' + 'Without this binding, a provider\'s single honest attestation could be reused to ' + 'describe a transaction it never saw -- the accounts would be real, and the ' + 'transaction spending them would not be.', + []), + ('S29', 'test_msg_solana_lut_attestation', + 'test_no_signer_loaded_means_no_extra_screens', + 'With no signer loaded a well-formed attestation is inert', + 'Trust is opt-in and per-session. A perfectly valid attestation from a provider the ' + 'user never loaded verifies against nothing and renders nothing, which is the ' + 'property that keeps sessions without a loaded provider safe.', + []), + # KKSOLSC1 v2, certified: the real SoltoshiDICE "Blackjack join" with the + # schema and SDICE token definition the deployed ClearSign Worker signs. + ('S30', 'test_msg_solana_schema_v2', + 'test_certified_soltoshi_join_reviews_every_screen', + 'Certified SoltoshiDICE join: every value on screen, AdvancedMode OFF', + 'The real dapp transaction (legacy, no lookup tables), re-keyed so its fee payer ' + 'is this device. A 501-scope ClearSign root certificate authorizes the Vault ' + 'delegate that signed the join\'s schema and a KeepKeySolanaTokenDef/2 definition ' + 'of SDICE; neither signature covers a transaction. The certificate is issued by ' + 'the alpha root, which every 7.16+ build embeds, so a build that refuses it fails ' + 'rather than skips; production gets its own root after the 7.15 re-release. The ' + 'test reads the text of all 13 screens of the certified review (SRS-7.16 ' + '§3.7) in order: the summary (SoltoshiDICE / Blackjack join), Limits with ' + 'the 0.002 SOL sent to the full session key, Limits with the network-fee ' + 'bound, then every value: Round 86, Revision 980, Seat 1, Buy-in, Session ' + 'key, Expires in 1 h, Allowance and Max wager as 1000.000000 SDICE with the ' + 'full mint, then "Described by KeepKey Vault A9531B9D / certified by ' + 'KeepKey" and the sign prompt. It then verifies the ed25519 signature over ' + 'the message.', + ['Summary: SoltoshiDICE Blackjack join', + 'Limits: also sends 0.002 SOL to session key', 'Limits: network fee bound', + 'Round 86', 'Revision 980', 'Seat 1', 'Buy-in 1000 SDICE + mint', + 'Session key', 'Expires in 1 h', 'Allowance 1000 SDICE + mint', + 'Max wager 1000 SDICE + mint', 'Described by / certified by KeepKey', + 'Sign this Solana transaction?']), + ('S31', 'test_msg_solana_schema_v2', + 'test_certified_soltoshi_join_priority_fee_names_fee_payer', + 'Certified join with a priority fee raises the fee bound', + 'With a SetComputeUnitPrice added, the Limits fee screen of the same ' + 'certified review raises its bound to include the priority fee: Network fee ' + 'up to 0.000205000 SOL.', + []), + ('S32', 'test_msg_solana_schema_v2', + 'test_certified_soltoshi_join_untrusted_definition_shows_base_units', + 'Certified join without a valid token definition shows base units', + 'Without the SDICE definition, the three amounts read 1000000000 base units of ' + 'mint with the full mint. A definition whose signature differs by one byte ' + 'gives exactly the same screens, frame for frame. No symbol, and no scaling, ' + 'comes from an unverified definition.', + []), + ('S33', 'test_msg_solana_schema_v2', + 'test_certified_soltoshi_join_ignores_blind_sign_policy', + 'Certified join is not a blind sign when AdvancedMode is on', + 'Turning AdvancedMode on leaves the certified review identical, frame for frame, ' + 'and never adds the Blind Sign screen: the certificate, not the policy, decides ' + 'how the transaction is shown.', + []), ]), ('T', 'TRON', '7.14.0', 'NEW: TRON with secp256k1 signing, base58 addresses. Blind-sign via raw_data. ' - 'Structured reconstruct-then-sign and TRC-20 clear-signing deferred to 7.15+.', + 'Structured reconstruct-then-sign and TRC-20 clear-signing deferred to a future release.', [ 'ADDRESS: m/44\'/195\'/0\'/0/0 -> full 34-char base58 TRON address', 'BLIND-SIGN: Raw protobuf data -> hash + sign', ], [ ('T1', 'test_msg_tron_getaddress', 'test_tron_get_address', - 'Derive TRON address', 'Full 34-character base58 address returned without display.', []), + 'Derive TRON address', 'Full 34-character base58 address. No screen: the drawn address is test_tron_show_address (T3b).', []), ('T2', 'test_msg_tron_getaddress', 'test_tron_different_accounts', 'Different accounts', 'Different indices produce different addresses.', []), ('T3', 'test_msg_tron_getaddress', 'test_tron_deterministic', @@ -1001,7 +2348,7 @@ def junit_cases(path): ('N', 'TON', '7.14.0', 'NEW: TON v4r2 wallet contracts. Ed25519 signing with structured field display. ' 'Blind-sign for raw transactions. Memo/comment support. ' - 'Full clear-sign with cell tree reconstruction deferred to 7.15+.', + 'Full clear-sign with cell tree reconstruction deferred to a future release.', [ 'ADDRESS: m/44\'/607\'/0\' -> full 48-char base64url TON address', 'STRUCTURED: Amount + address + memo shown as display context -> sign', @@ -1009,7 +2356,7 @@ def junit_cases(path): ], [ ('N1', 'test_msg_ton_getaddress', 'test_ton_get_address', - 'Derive TON address', 'Full 48-character base64url address returned without display.', []), + 'Derive TON address', 'Full 48-character base64url address. No screen: the drawn address is test_ton_show_address (N2b).', []), ('N2', 'test_msg_ton_getaddress', 'test_ton_different_accounts', 'Different accounts', 'Different indices produce different addresses.', []), ('N2b', 'test_msg_ton_getaddress', 'test_ton_show_address', @@ -1026,34 +2373,212 @@ def junit_cases(path): 'Missing fields rejected', 'Incomplete data refused.', []), ]), - ('Z', 'Zcash Orchard', '7.14.0', - 'NEW: Shielded transactions via PCZT streaming. Orchard hides sender, recipient, and amount ' - 'using ZK proofs. Raw seed access (ZIP-32 Orchard derivation uses BIP-39 seed + Pallas curve). ' - 'Full Viewing Key (FVK) export for watch-only wallets.', + ('Y', 'Zcash Transparent', '7.0.0', + 'Transparent t-address Zcash (send/receive) over the generic Bitcoin UTXO signing path with ' + 'Overwinter/Sapling-v4 branch handling. This is the Zcash functionality that ships ENABLED on ' + 'the regular 7.15.0 build -- t1.../t3... addresses sign like Bitcoin (SECP256K1) with a ' + 'FeeOverThreshold guard. No shielded/Orchard engine is involved; contrast with section Z ' + '(shielded), which also ships in the regular product and is stripped from bitcoin-only.', + [ + 'INPUT: TxInputType over the Zcash coin (t-address, SECP256K1)', + 'METADATA: version_group_id + branch_id for the target upgrade', + 'CONFIRM: amount + destination on the OLED, then sign each input', + 'FEE GUARD: an implausibly high fee triggers a confirmation prompt', + ], + [ + ('Y1', 'test_msg_signtx_zcash', 'test_transparent_one_one', + 'Transparent 1-in 1-out', + 'Sign a standard transparent Zcash spend; the device shows the amount and destination ' + 't-address before producing a signature over the overwinter sighash.', + ['Zcash send confirm']), + ('Y2', 'test_msg_signtx_zcash', 'test_transparent_one_one_fee_too_high', + 'High-fee guard', + 'An implausibly high fee triggers the FeeOverThreshold confirmation before signing.', + []), + ('Y3', 'test_msg_signtx_zcash', 'test_shieldedIn_one_one_fee_1', + 'Transparent spend (fee scenario 1)', + 'Despite the legacy method name, this signs a transparent input/output over the same ' + 'overwinter path (no Orchard).', + []), + ('Y4', 'test_msg_signtx_zcash', 'test_shieldedIn_one_one_fee_2', + 'Transparent spend (fee scenario 2)', + 'Second transparent fee scenario over the overwinter path.', + []), + ]), + + ('Z', 'Zcash Shielded (Orchard)', '7.14.0', + 'Shielded Orchard (PCZT streaming, Full Viewing Key export, unified-address display with an ' + 'on-device ZIP-32 Sec 6.1 seed-fingerprint attestation) ships in the regular 7.15.0 product. ' + 'KK_ZCASH_PRIVACY is enabled for the regular build and disabled only for bitcoin-only. This ' + 'report covers device FVK/address behavior and the Python PCZT streaming contract. Mainnet ' + 'proof construction and the physical shield, deshield, and Orchard-to-Orchard matrix are ' + 'recorded separately in the RC18 release evidence.', [ 'FVK: Derive ak, nk, rivk components via ZIP-32 Orchard path', + 'ADDRESS: Device derives its own unified address + shows it; optional seed-fingerprint pin', 'PCZT: Stream header -> actions one at a time -> confirm each -> return signatures', 'HYBRID: Transparent inputs + Orchard outputs in same tx', ], [ ('Z1', 'test_msg_zcash_orchard', 'test_fvk_reference_vectors', - 'FVK reference vectors', 'FVK output matches known test vectors.', ['FVK export']), + 'FVK reference vectors', 'FVK output matches known test vectors. No screen: reference-vector arithmetic, compared in memory.', []), ('Z2', 'test_msg_zcash_orchard', 'test_fvk_field_ranges', 'FVK field ranges', 'ak, nk, rivk are within valid Pallas curve ranges.', []), ('Z3', 'test_msg_zcash_orchard', 'test_fvk_consistency_across_calls', 'FVK deterministic', 'Same account always produces same FVK.', []), ('Z4', 'test_msg_zcash_orchard', 'test_fvk_different_accounts', 'FVK different accounts', 'Different accounts produce different FVKs.', []), - ('Z5', 'test_msg_zcash_sign_pczt', 'test_single_action_legacy_sighash', - 'Sign single Orchard action', 'One shielded action, device shows amount + fee.', ['Shielded confirm']), - ('Z6', 'test_msg_zcash_sign_pczt', 'test_multi_action_legacy_sighash', - 'Sign multiple actions', 'Multiple Orchard actions in one transaction.', []), - ('Z7', 'test_msg_zcash_sign_pczt', 'test_signatures_are_64_bytes', - 'Signature format', 'Orchard signatures must be exactly 64 bytes (RedPallas).', []), - ('Z8', 'test_msg_zcash_sign_pczt', 'test_transparent_shielding_single_input', - 'Transparent to shielded', 'Transparent BTC-like input shielded into Orchard pool.', ['Hybrid shield']), - ('Z9', 'test_msg_zcash_sign_pczt', 'test_transparent_shielding_multiple_inputs', - 'Multi-input shielding', 'Multiple transparent inputs shielded in one tx.', []), + ('Z5', 'test_msg_zcash_orchard', 'test_fvk_abandon_mnemonic', + 'FVK abandon-mnemonic vector', + 'FVK derivation matches the Orchard reference vector for the standard abandon mnemonic.', + []), + ('Z6', 'test_msg_zcash_display_address', 'test_zcash_display_address_basic', + 'Display unified address', + 'Device derives its OWN Orchard unified address (u1...) from the ZIP-32 path, shows it ' + 'on the OLED for confirmation, and returns it with the device seed fingerprint. The host ' + 'does not supply the address — this defends against a compromised host showing a fake UA.', + ['Unified address (u1...)']), + ('Z7', 'test_msg_zcash_display_address', 'test_zcash_display_address_bad_path_rejected', + 'Reject malformed address path', + 'A path that is neither m/32\'/133\'/account\' nor an explicit account is rejected with a ' + 'SyntaxError, so no wrong-account address is ever derived silently.', + []), + ('Z8', 'test_msg_zcash_seed_fingerprint', 'test_get_orchard_fvk_returns_seed_fingerprint', + 'FVK carries seed fingerprint', + 'ZcashGetOrchardFVK returns a 32-byte ZIP-32 §6.1 seed fingerprint alongside the FVK.', + []), + ('Z9', 'test_msg_zcash_seed_fingerprint', 'test_fingerprint_stable_across_accounts', + 'Fingerprint bound to seed not account', + 'The seed fingerprint is identical across account indices — it identifies the device seed.', + []), + ('Z10', 'test_msg_zcash_seed_fingerprint', 'test_display_address_helper_accepts_matching_fingerprint', + 'Address display accepts matching fingerprint', + 'When the host supplies expected_seed_fingerprint and it matches, the device derives and ' + 'displays the address and echoes the fingerprint.', + ['Unified address (u1...)']), + ('Z11', 'test_msg_zcash_seed_fingerprint', 'test_display_address_helper_rejects_wrong_fingerprint', + 'Address display rejects wrong fingerprint', + 'A mismatched expected_seed_fingerprint is rejected before any derivation — the host ' + 'cannot get an attestation from the wrong device.', + []), + ('Z12', 'test_msg_zcash_seed_fingerprint', 'test_display_address_helper_backward_compat', + 'Address display without fingerprint', + 'Omitting expected_seed_fingerprint still works; the device populates the fingerprint on ' + 'the response regardless.', + []), + ('Z13', 'test_msg_zcash_seed_fingerprint', 'test_device_fingerprint_matches_python_helper', + 'Fingerprint matches host computation', + 'The device-derived fingerprint equals calculate_seed_fingerprint(seed) — firmware C and ' + 'the python helper agree byte-for-byte for the all-all-all seed.', + []), + ('Z14', 'test_msg_zcash_seed_fingerprint', 'test_sign_pczt_helper_rejects_wrong_fingerprint', + 'PCZT signing rejects wrong fingerprint', + 'A wrong expected_seed_fingerprint on a PCZT signing request is rejected before any ' + 'signing crypto runs.', + []), + ('Z15', 'test_msg_zcash_sign_pczt', + 'test_all_dummy_shield_streams_outputs_inputs_and_no_orchard_sigs', + 'Shield streams dummy actions without device signatures', + 'The client streams transparent inputs/outputs and both dummy Orchard actions, preserves ' + 'their finalized PCZT signatures, and expects no compact device Orchard signatures.', + []), + ('Z16', 'test_msg_zcash_sign_pczt', + 'test_mixed_deshield_returns_only_real_spend_signature', + 'Deshield returns only real-spend signatures', + 'A mixed real/dummy Orchard action set returns one compact signature for the real spend.', + []), + ('Z17', 'test_msg_zcash_sign_pczt', + 'test_private_send_preserves_compact_real_spend_order', + 'Private send preserves real-spend signature order', + 'Compact device signatures remain ordered by the real-spend actions when dummy actions ' + 'are interleaved. OFFLINE CONTRACT TEST -- like every test in test_msg_zcash_sign_pczt, ' + 'it drives a ScriptedTransport with canned responses and never reaches a device. It ' + 'proves the client builds and orders the messages correctly; it proves nothing about ' + 'firmware behaviour, and it can never produce an OLED frame. ZcashSignPCZT is not sent ' + 'to a device anywhere in THIS module. On-device shielded signing is covered ' + 'separately by test_msg_zcash_sign_pczt_device (see Z22), which drives a real ' + 'device and asserts the per-output confirm screens; this module proves only that ' + 'the client builds and orders the messages correctly.', + []), + ('Z18', 'test_msg_zcash_sign_pczt', + 'test_missing_is_spend_is_rejected_before_device_call', + 'Missing spend classification rejected', + 'Every action must explicitly declare is_spend before any device call is made.', + []), + ('Z19', 'test_msg_zcash_sign_pczt', + 'test_host_transparent_sighash_is_rejected_before_device_call', + 'Host transparent sighash rejected', + 'The client refuses a host-provided transparent sighash instead of forwarding it as ' + 'trusted device input.', + []), + ('Z20', 'test_msg_zcash_sign_pczt', + 'test_signature_count_must_match_real_spends', + 'Signature count bound to real spends', + 'The returned compact signature count must equal the number of real-spend actions.', + []), + ('Z21', 'test_msg_zcash_sign_pczt', + 'test_duplicate_action_request_is_rejected', + 'Duplicate action requests rejected', + 'A repeated device request for the same action index aborts the streaming session.', + []), + ('Z22', 'test_msg_zcash_sign_pczt_device', + 'test_shielded_output_review_is_two_screens', + 'Shielded output review: amount, full address and memo (FIRMWARE)', + 'The first test in this suite that sends ZcashSignPCZT to an actual device -- Z15-Z21 ' + 'above are offline contract tests against a scripted transport. Signs a shielded-only ' + 'transaction built from the firmware\'s own known-answer note vector, so the device ' + 'accepts its recomputed commitment. The current fixture requires three ConfirmOutput ' + 'screens: amount, full recipient address, then memo (non-text memo bytes are shown ' + 'by their SHA-256). Amount and address need separate screens: a unified address is ' + '106 characters, three full body rows, and the body is ' + 'three rows total, so a single confirm holding the question, the address and the amount ' + 'renders 76 characters of address and silently drops the rest along with the amount. ' + 'That screen is the verification gate for Orchard output values -- total_amount on the ' + 'summary is a host-supplied prompt -- so the amount vanishing there is the whole trust ' + 'story. The earlier one-screen regression is historical; the current assertion ' + 'also includes the memo review. CI executes this device-facing path on the firmware ' + 'emulator, not physical hardware.', + ['Shielded amount review', 'Shielded recipient address', 'Shielded memo review']), + ('Z23', 'test_msg_zcash_sign_pczt_device', + 'test_note_commitment_binds_the_recipient', + 'Tampered recipient breaks the note commitment (ON DEVICE)', + 'Flipping one bit of the recipient makes the device-recomputed cmx disagree with the ' + 'supplied commitment, and signing is refused. This is what stops a host displaying one ' + 'recipient while committing to another.', + []), + ('Z24', 'test_msg_zcash_sign_pczt_device', + 'test_pool_selection_is_honoured', + 'Orchard commitment rejected under the Ironwood pool (ON DEVICE)', + 'The same note commits to a different value in each pool, so offering the Orchard ' + 'commitment while declaring Ironwood must be rejected. Passes trivially if the device ' + 'ignores shielded_pool, which is why it is paired with Z25.', + []), + ('Z25', 'test_msg_zcash_sign_pczt_device', + 'test_ironwood_note_is_accepted', + 'Ironwood commitment for the same note is accepted (ON DEVICE)', + 'The positive half of Z24: identical inputs, Ironwood commitment, accepted. Together ' + 'they prove the pool branch is selected by shielded_pool rather than one path serving ' + 'both.', + []), + ('Z26', 'test_msg_zcash_sign_pczt_device', + 'test_ironwood_rejects_a_non_empty_orchard_bundle', + 'v6 refuses an unverified Orchard bundle (ON DEVICE)', + 'A v6 transaction streams and verifies only its Ironwood actions, so its Orchard ' + 'bundle must be the ZIP-244 empty-bundle digest. Any other value describes a bundle ' + 'the device never inspected but still commits to in the sighash it signs. That was ' + 'exploitable: point orchard_digest at a real bundle spending one of this seed ' + 'notes, reuse an approved action alpha so rk is byte-identical, and the single ' + 'RedPallas signature the device emits verifies in BOTH bundles.', + []), + ('Z27', 'test_multisig', + 'test_oversized_signature_is_rejected', + 'Oversized multisig signature refused (ON DEVICE)', + 'MultisigRedeemScriptType.signatures is declared max_size:73 but a DER ECDSA signature ' + 'is at most 72. The witness serializer appended the sighash byte AT signatures[i].size, ' + 'so 73 wrote one past the end of bytes[73] -- onto signatures[i+1].size for i < 14, ' + 'which can revive a slot the host left empty and change the witness stack after the ' + 'user reviewed it. A declared max_size is a decoder bound, not a runtime one.', + []), ]), ('D', 'BIP-85 Child Derivation', '7.14.0', @@ -1067,8 +2592,10 @@ def junit_cases(path): [ ('D1', 'test_msg_bip85', 'test_bip85_12word_flow', 'Derive 12-word child', - 'Derives 128 bits of child entropy -> 12-word BIP-39 mnemonic displayed on OLED.', - ['Derivation params', 'Mnemonic on OLED']), + 'Derives 128 bits of child entropy -> 12-word BIP-39 mnemonic displayed on OLED. ' + 'The mnemonic pages are deliberately not captured: firmware answers DebugLink with an ' + 'empty state while a child mnemonic is on screen.', + ['Derivation params']), ('D2', 'test_msg_bip85', 'test_bip85_24word_flow', 'Derive 24-word child', '256 bits -> 24 words.', []), ('D3', 'test_msg_bip85', 'test_bip85_18word_flow', @@ -1080,121 +2607,7 @@ def junit_cases(path): ('D6', 'test_msg_bip85', 'test_bip85_invalid_word_count', 'Invalid count rejected', 'Word counts other than 12/18/24 are refused.', []), ]), - ('R', '7.14.2 Presign Security Controls', '7.14.2', - 'Release-specific controls for every display, validation, bounds and terminal-state defect ' - 'found during the presign review. Positive display cases retain every A/B framebuffer page; ' - 'negative cases prove refusal occurs before an approval screen.', - [ - 'A/B: mutate one signed semantic field and require a different complete review.', - 'NEGATIVE: malformed or opaque operations must terminate without approval.', - 'BOUNDARY: a Failure, Cancel or ClearSession ends the active workflow.', - ], - [ - ('R1', 'test_msg_solana_display_disclosure', - 'test_raw_message_tail_changes_oled_review', - 'Solana raw-message tail A/B', 'Byte 96 must survive exact-byte paging.', - ['Both complete A/B sequences']), - ('R2', 'test_msg_solana_display_disclosure', - 'test_offchain_message_tail_changes_oled_review', - 'Solana off-chain tail A/B', 'Byte 96 must survive exact-byte paging.', - ['Both complete A/B sequences']), - ('R3', 'test_msg_solana_display_disclosure', - 'test_offchain_format_changes_oled_review', - 'Solana off-chain format A/B', 'The signed format/version domain is visible.', - ['ASCII and UTF-8 approval sequences']), - ('R4', 'test_msg_solana_display_disclosure', - 'test_memo_tail_changes_oled_review', - 'Solana memo tail A/B', 'The complete signed memo is visible.', - ['Both complete memo sequences']), - ('R5', 'test_msg_solana_instruction_disclosure', - 'test_set_authority_roles_require_opaque_mode', - 'SetAuthority defaults opaque', 'Undisclosed authority roles are refused.', []), - ('R6', 'test_msg_solana_instruction_disclosure', - 'test_token_2022_transfer_checked_with_hook_accounts_is_opaque', - 'Token-2022 defaults opaque', 'Extension and hook semantics are not clear-signed.', []), - ('R7', 'test_msg_solana_instruction_disclosure', - 'test_close_account_destination_changes_oled_review', - 'CloseAccount destination A/B', 'Closed account and balance destination are visible.', - ['Both destination sequences']), - ('R8', 'test_msg_solana_instruction_disclosure', - 'test_priority_fee_payer_changes_oled_review', - 'Priority-fee payer A/B', 'The maximum fee and payer are visible.', - ['Both fee-payer sequences']), - ('R9', 'test_msg_solana_instruction_disclosure', - 'test_all_verified_instruction_fields_change_oled_review', - 'All verified Solana fields A/B', - 'Every field used by a verified instruction has an independent differential control.', - ['Every complete field-mutation sequence']), - ('R10', 'test_msg_ton_display_disclosure', - 'test_raw_message_tail_changes_oled_review', - 'TON raw-message tail A/B', 'Every signed raw Ed25519 byte is paged.', - ['Both complete A/B sequences']), - ('R11', 'test_msg_confirm_data_disclosure', - 'test_binary_op_return_tail_changes_oled_review', - 'Bitcoin OP_RETURN tail A/B', 'Binary output data is displayed exactly.', - ['Both complete A/B sequences']), - ('R12', 'test_msg_confirm_data_disclosure', - 'test_non_ascii_eos_memo_tail_changes_oled_review', - 'EOS memo tail A/B', 'Binary memo bytes are displayed exactly.', - ['Both complete A/B sequences']), - ('R13', 'test_msg_confirm_data_disclosure', - 'test_unknown_omni_property_changes_oled_review', - 'Unknown Omni property A/B', 'Opaque Omni fields remain distinguishable.', - ['Both complete A/B sequences']), - ('R14', 'test_msg_confirm_data_disclosure', - 'test_unknown_omni_type_changes_oled_review', - 'Unknown Omni type A/B', 'Opaque Omni types remain distinguishable.', - ['Both complete A/B sequences']), - ('R15', 'test_msg_ethereum_data_disclosure', - 'test_initial_chunk_tail_changes_data_hash_screen', - 'Ethereum initial-chunk hash A/B', 'The displayed hash binds the complete chunk.', - ['Both complete A/B sequences']), - ('R16', 'test_msg_ethereum_data_disclosure', - 'test_streamed_tail_changes_data_hash_screen', - 'Ethereum streamed-tail hash A/B', 'The displayed hash binds every streamed byte.', - ['Both complete A/B sequences']), - ('R17', 'test_msg_ethereum_signtx_xfer', - 'test_transfer_review_uses_signing_chain_asset', - 'Ethereum transfer chain/asset binding', - 'The first asset approval changes between Ethereum, BNB Chain and Polygon.', - ['ETH, BNB and MATIC approval sequences']), - ('R18', 'test_msg_ethereum_signtx', - 'test_ethereum_erc20_high_chain_id_does_not_alias_mainnet', - 'ERC-20 chain-ID width binding', - 'Chain 257 cannot borrow chain-1 token labels or decimals.', - ['Chain 1 and chain 257 approval sequences']), - ('R19', 'test_msg_ethereum_signtx_xfer', - 'test_erc20_transfer_high_chain_id_does_not_alias_mainnet', - 'ERC-20 transfer chain-ID width binding', - 'TRANSFER review scopes token metadata to the complete chain ID.', - ['Chain 1 and chain 257 transfer sequences']), - ('R20', 'test_msg_ethereum_signtx', - 'test_ethereum_native_pseudo_address_is_unknown_off_mainnet', - 'ERC-20 native pseudo-address isolation', - 'Chain 257 transfer and approve calls render the exact unknown-token frame.', - ['Transfer and approve unknown-token sequences']), - ('R21', 'test_msg_ethereum_signtx_xfer', - 'test_native_pseudo_address_transfer_is_unknown_off_mainnet', - 'ERC-20 TRANSFER pseudo-address isolation', - 'TRANSFER review renders 0xeeee..eeee as unknown outside chain 1.', - ['Exact unknown-token transfer sequence']), - ('R22', 'test_msg_osmosis_validation', - 'test_present_but_empty_amount_is_rejected_before_review', - 'Osmosis empty amount refusal', 'Present-but-empty is not displayed as zero.', []), - ('R23', 'test_msg_osmosis_validation', - 'test_ibc_omitted_amount_and_receiver_are_rejected_before_review', - 'Osmosis IBC required fields', 'Omitted value and receiver are refused.', []), - ('R24', 'test_msg_recoverydevice_cipher', - 'test_unknown_word_count_failure_aborts_recovery', - 'Recovery Failure is terminal', 'A stale CharacterAck cannot resume the ceremony.', []), - ('R25', 'test_msg_signing_boundaries', - 'test_multisig_signature_over_72_bytes_is_rejected', - 'Multisig serialization bound', 'Oversized host signatures are refused.', []), - ('R26', 'test_msg_signing_boundaries', - 'test_clear_session_aborts_active_bitcoin_signing', - 'ClearSession ends signing', 'A stale TxAck cannot resume the signer.', []), - ]), - ('D', 'Display Disclosure - What Is Shown Is What Is Signed', '7.14.2', + ('Q', 'Display Disclosure - What Is Shown Is What Is Signed', '7.14.2', 'The single property behind every display/sign divergence found in the 7.14.2 audit: two ' 'requests whose SIGNED BYTES differ must not produce IDENTICAL screens. If two payloads render ' 'the same pixels, whatever separates them was invisible when the user approved, and the ' @@ -1215,113 +2628,1303 @@ def junit_cases(path): 'the property; the failure under test is signing it while looking identical to the benign case.', ], [ - ('D1', 'test_msg_display_disclosure', 'test_bytes_past_an_embedded_nul_are_disclosed', + ('Q1', 'test_msg_display_disclosure', 'test_bytes_past_an_embedded_nul_are_disclosed', 'Bytes after a NUL are shown', 'A protobuf bytes field is not a NUL-terminated string. Rendering it with "%s" stops at the ' 'first NUL while the signature covers message.size bytes, so a payload like ' '"benign login\\0 AND APPROVE TRANSFER" displays only the benign prefix. This asserts the ' 'two payloads do not present identically.', ['Message screen, plain', 'Message screen, NUL-suffixed']), - ('D2', 'test_msg_display_disclosure', 'test_bytes_past_whitespace_padding_are_disclosed', + ('Q2', 'test_msg_display_disclosure', 'test_bytes_past_whitespace_padding_are_disclosed', 'Whitespace cannot hide signed text', 'Whitespace is the cheapest way to push content out of view: a leading space costs zero ' 'pixels once a line has wrapped, so padding can make an over-long body measure as fitting ' 'while the tail is neither shown nor dropped from the signature.', ['Message screen, short', 'Message screen, padded']), - ('D3', 'test_msg_display_disclosure', 'test_bytes_past_the_first_screen_are_disclosed', + ('Q3', 'test_msg_display_disclosure', 'test_bytes_past_the_first_screen_are_disclosed', 'Content beyond one screen is not silently dropped', 'Whether the device pages the remainder, states how much is hidden, or refuses is not ' 'asserted - only that a long payload with a distinct tail does not look identical to a ' 'short one.', ['Message screen, fits', 'Message screen, overlong']), - ('D4', 'test_msg_display_disclosure', 'test_newline_padding_does_not_collapse_the_screen', + ('Q4', 'test_msg_display_disclosure', 'test_newline_padding_does_not_collapse_the_screen', 'Line counting cannot be overflowed', 'Line counting is a security boundary once it gates a truncation warning. A body carrying ' 'many newlines exercises the row counter rather than the character count; if that counter ' 'wraps, an arbitrarily long body reports as fitting.', ['Message screen, one line', 'Message screen, newline-padded']), - ('D5', 'test_msg_display_disclosure', 'test_signing_shows_at_least_one_screen', + ('Q5', 'test_msg_display_disclosure', 'test_signing_shows_at_least_one_screen', 'Guard: the comparisons are not vacuous', 'Every other test in this section compares screen sequences. A flow that produced no ' 'ButtonRequest would make two payloads compare equal as empty tuples and pass while showing ' 'the user nothing. This asserts at least one non-blank screen is actually displayed.', ['Control message screen']), ]), + ('F', 'Clear-Sign Provider Context - Additive Invariant', '7.15.0', + 'Clear-signing is annotation, not authority. A provider signer is loaded at runtime by the ' + 'host (LoadClearsignSigner: RAM-only, user-confirmed, dropped on reboot) and is NOT verified ' + 'by KeepKey, so its decoded who/what/why screens must be ADDED to the ordinary unverified ' + 'review, never substituted for it. A runtime schema that could suppress the amount screen, ' + 'the raw-calldata screen or the fee screen would be a screen-substitution oracle: a friendly ' + '"supply 10.5 DAI to Aave" on the glass with arbitrary bytes under the signature. ' + 'lib/firmware/ethereum.c forces needs_confirm and data_needs_confirm back to TRUE whenever ' + 'the metadata came from a loaded signer; the else-branch that is allowed to suppress is ' + 'reserved for a future firmware-PINNED key and has no reachable input in this build. Every ' + 'test below proves this by MEASUREMENT rather than by model: it signs the same transaction ' + 'twice against the same device state, records the raw 2048-byte OLED framebuffer at every ' + 'ButtonRequest, and requires the no-metadata baseline frames to reappear byte-for-byte as the ' + 'tail of the clear-signed run. Adjacent sections cover "no metadata -> blind sign", replay ' + 'rejection and cancel-clears-metadata; none of them proves the raw review FOLLOWS a ' + 'SUCCESSFUL decode.', + [ + 'ADDITIVE RULE: a runtime provider may ADD screens. It may never REMOVE one.', + '', + 'Measured on the Aave V3 supply() fixture (132 bytes of real ABI calldata, AdvancedMode on):', + '- baseline, no metadata : 3 screens - Send / Confirm Ethereum Data / Transaction', + '- v1 metadata VERIFIED : 10 screens - Identity, "Call: supply", Contract, one screen', + ' per attested argument (4), THEN the same 3 baseline screens', + '- v2 static schema VERIFIED : 13 screens - 7 decoded, then the same 3 baseline screens', + '- signature fails to verify : 3 screens - byte-identical to the baseline. The device does', + ' NOT refuse, and shows NO partial decoded information.', + '', + 'The tail comparison is a byte-for-byte framebuffer match, so it is immune to pagination and', + 'to value-dependent rendering: whatever the baseline drew, the clear-signed run must draw.', + '', + 'Phase 1 ships with every built-in verification slot zeroed, so a VERIFIED blob can only come', + 'from a runtime-loaded signer and the suppression branch cannot be reached. F5 has an EMPTY', + 'screenshot list on purpose: rejecting metadata draws nothing at all.', + ], + [ + ('F1', 'test_msg_ethereum_clearsign_additive', + 'test_successful_decode_still_runs_the_raw_review', + 'A successful decode adds screens, replaces none', + 'The headline invariant. A runtime provider clear-signs a real Aave V3 supply() call, and ' + 'the decoded identity/method/contract/argument screens are followed by the SAME ' + 'amount, raw-calldata and fee screens the device draws with no metadata at all - proven by ' + 'signing the identical transaction twice and requiring the three baseline frames to ' + 'reappear byte-for-byte at the tail. The signature still recovers to this device over this ' + 'exact digest, so the screens shown were bound to the transaction signed.', + ['Identity screen naming the loaded signer and its fingerprint', + 'Decoded argument screens (protocol / asset / amount / onBehalfOf)', + 'Raw contract data screen, unchanged from the baseline', + 'Fee screen']), + ('F2', 'test_msg_ethereum_clearsign_additive', + 'test_v2_schema_decode_still_runs_the_raw_review', + 'v2 static schema is additive too', + 'v2 is where suppression would be most tempting: the blob attests a decode shape and no ' + 'tx_hash, so the reserved branch drops the raw review outright and keeps the amount screen ' + 'only if the schema moves value. For a runtime signer that branch is not taken. Decoded ' + 'against the Aave fixture rather than an ERC-20 transfer on purpose - a recognized token ' + 'contract has no raw-data screen in its own baseline, so it could not show that the raw ' + 'review survives.', + ['Decoded screens with values read from the calldata being signed (amount: 10.5 DAI)', + 'Raw contract data screen, unchanged from the baseline', + 'Fee screen']), + ('F3', 'test_msg_ethereum_clearsign_additive', + 'test_failed_signature_falls_back_to_the_unverified_review', + 'A payload that fails to verify falls back, it does not refuse', + 'One tampered byte inside the signed region makes the blob MALFORMED. The device must then ' + 'behave exactly as if no metadata had ever been sent: the ordinary unverified review, no ' + 'refusal, and no partial decoded information on the glass. The assertion is that the whole ' + 'signing run is frame-for-frame identical to the baseline - any decoded screen would be a ' + 'frame the baseline does not contain.', + ['Amount/recipient screen identical to the no-metadata baseline', + 'Raw contract data screen identical to the no-metadata baseline', + 'Fee screen identical to the no-metadata baseline']), + ('F4', 'test_msg_ethereum_clearsign_additive', + 'test_no_runtime_slot_can_reach_the_suppression_branch', + 'Every runtime key slot stays additive', + 'The suppression branch is gated on a signer that is NOT runtime-loaded. All four key slots ' + 'are loaded at runtime and each in turn produces a VERIFIED decode that is still followed ' + 'by the complete baseline review, so no slot is a privileged one. A slot that suppressed ' + 'would surface here as a missing tail frame.', + ['Identity screen for each loaded slot', + 'Raw contract data screen after every slot\'s decode']), + ('F5', 'test_msg_ethereum_clearsign_additive', + 'test_no_slot_verifies_without_a_runtime_load', + 'No firmware-pinned signer exists to suppress anything', + 'The complementary half. With no signer loaded, a correctly signed blob addressed to each ' + 'of the four slots comes back MALFORMED: this build carries no built-in verification key, ' + 'so the branch that may suppress the raw review has no reachable input. Sending metadata ' + 'draws no screen, so the empty screenshot list below is the assertion.', + []), + ]), + ('AB', 'Certified Contacts - The User\'s Own Address Book', '7.15.0', + 'A recipient address is forty characters that nobody reads twice. A label in the wallet ' + 'software helps, but the software is the thing a hardware wallet exists not to trust. With ' + 'certified contacts the user approves each contact on the device once - name, network and ' + 'full address - and the device signs a commitment to the list with a key derived from the ' + 'seed. Nothing is stored on the device. On a later send, the host hands back a proof for the ' + 'recipient, the device checks it against its own key, names the contact, and then shows the ' + 'full address as well. No server and no outside signer is involved, so this is not live ' + 'signing.', + [ + 'RULE: a name is shown only for an address the user approved on this device under that', + 'name, on that network, and the full address is always shown as well.', + '', + '- Certify: one summary screen, then a name screen and an address screen for EVERY contact.', + ' The device signs only the 46-byte commitment, never bytes it did not parse and show.', + '- Send: "Send to ", then "Contact Address" with the full address.', + '- Shown for a plain send or a transfer of a known token. Never for an approval, a contract', + ' call, an unknown token, or when a contract description is loaded.', + '- A proof that fails, or is for another address or network, changes nothing: the review', + ' is the ordinary one, frame count included.', + '', + 'KNOWN LIMIT: the device keeps no state, so a contact certified once stays provable for that', + 'seed. Removing a contact in the host does not revoke the earlier certification.', + '', + 'The request, the commitment and the proof are built in the test from the format alone,', + 'and the device signature is checked with python-ecdsa.', + ], + [ + ('AB1', 'test_msg_ethereum_certified_contacts', + 'test_certifying_shows_every_contact_and_signs_only_the_commitment', + 'Every contact is reviewed before anything is signed', + 'Three contacts on two networks are certified with AdvancedMode off. The device shows a ' + 'summary with the count and revision, then each contact\'s name with its network and, on ' + 'its own screen, the full address. The signature it returns verifies, with an independent ' + 'library, over the commitment the test computed itself.', + ['Address Book: Approve 3 contacts? Revision 7', + 'Certify Contact: Alice / eip155:1', + 'Destination: Alice\'s full address', + 'Certify Contact: Bob at the bank / eip155:8453', + 'Destination: Bob\'s full address', + 'Certify Contact: Carol / eip155:1', + 'Destination: Carol\'s full address']), + ('AB2', 'test_msg_ethereum_certified_contacts', + 'test_a_send_to_a_contact_names_it_and_still_shows_the_address', + 'A send to a contact names it, then shows the full address', + 'The same 1 ETH send is signed three times. Without a proof the send screen shows the ' + 'address. With Alice\'s proof it reads "Send 1 ETH to Alice" and one extra screen follows ' + 'with her full address, so the review is the baseline plus one screen. The third send, ' + 'with no new proof, is the baseline again: one proof names one send.', + ['Send 1 ETH to the full address (baseline)', + 'Send 1 ETH to Alice', + 'Contact Address: the full address']), + ('AB3', 'test_msg_ethereum_certified_contacts', + 'test_a_known_token_transfer_names_the_recipient', + 'A token transfer names the recipient, not the token contract', + 'A USDC transfer to Alice. The label applies to the recipient inside the transfer call; ' + 'the token contract is what the device already knows.', + ['Send 0.000001 USDC to Alice', + 'Contact Address: the full address']), + ('AB4', 'test_msg_ethereum_certified_contacts', + 'test_a_proof_names_only_its_own_address_and_network', + 'A proof is for one address on one network', + 'Carol\'s proof on a send to Alice names nobody. Bob is a contact on Base only, so a send ' + 'to his address on Ethereum shows the address.', + ['Send 1 ETH to the full address, with another contact\'s proof loaded', + 'Send 1 ETH to Bob\'s full address on a network he was not certified for']), + ('AB5', 'test_msg_ethereum_certified_contacts', + 'test_an_altered_proof_is_refused_and_the_send_is_unchanged', + 'An altered proof is refused and the send is the ordinary one', + 'Four alterations: one letter of the label, the address, one bit of the signature, and a ' + 'proof computed over a different list. Each is answered "Invalid contact" and the send ' + 'that follows has the baseline number of screens and no name.', + ['Send 1 ETH to the full address after a refused proof']), + ('AB6', 'test_msg_ethereum_certified_contacts', + 'test_a_book_the_device_cannot_vouch_for_is_refused_unreviewed', + 'A list the device could not stand behind is refused before any screen', + 'One address under two names, one name on two addresses, a label ending in a space, a ' + 'label padded to push text onto another line, and a non-EVM network. Each is refused with ' + '"Invalid address book" and no confirmation is ever requested, so the empty screenshot ' + 'list is the assertion.', + []), + ]), + ('I', 'Session and Trust Lifetime', '7.15.0', + 'Clear-signing works by trusting somebody else. A provider key loaded with LoadClearsignSigner ' + 'decides which transactions the device is willing to describe in words, and AdvancedMode decides ' + 'whether the device will sign contract data it cannot describe at all. Neither is a decision a ' + 'user should still be living with tomorrow. Both are session state by design: AdvancedMode is a ' + 'policy the storage writer refuses to persist, and loaded signers are RAM slots that no code path ' + 'writes to flash. Design intent is not evidence, so this section revokes them for real - it ' + 'restarts the firmware process with its flash image intact, which is a reboot and not a wipe, and ' + 'watches what comes back.', + [ + 'LIFETIME RULE: trust granted by a button press dies with the session that granted it.', + '', + 'The two claims under test, and where they live:', + '- AdvancedMode is session-scoped. Storage flags bit 12 is written as zero and ignored on', + ' read at four sites in storage.c; policy.h calls the bit BURNED because firmware <= 7.15', + ' would read a reused bit as "blind signing enabled".', + '- Loaded signers are RAM only. session_clear() calls signed_metadata_clear_signers()', + ' unconditionally, so Initialize and ClearSession both drop them; a reboot drops them', + ' because they were never anywhere else.', + '', + 'The asymmetry between the two is deliberate and is asserted, not assumed: Initialize drops', + 'the signer but LEAVES AdvancedMode armed (hosts send Initialize before nearly every', + 'operation, so disarming there would demand a button press each time), while ClearSession', + 'drops both.', + '', + 'READING THE POWER-CYCLE TESTS: on the emulator flash_erase_word() is compiled out, so the', + 'sectors that storage_commit() abandons keep their "stor" magic and find_active_storage()', + 'may boot into a record two commits stale. A test that ignored this would read every policy', + 'back OFF for the wrong reason and pass against firmware that persisted it. Each power-cycle', + 'test therefore sets a MARKER policy (Experimental) after the state under test and commits', + 'until every sector carries it; the marker coming back is what licenses any conclusion about', + 'AdvancedMode, and the surviving seed and label are what distinguish a reboot from a wipe.', + ], + [ + ('I1', 'test_msg_session_trust_lifetime', + 'test_advanced_mode_is_off_after_power_cycle', + 'AdvancedMode does not survive a reboot', + 'AdvancedMode and Experimental are neighbouring bits of the same storage flags word, set by ' + 'the same ApplyPolicies message and written by the same storage_writeStorageV16Plaintext ' + 'call. Both are turned on, Experimental second, and the firmware is restarted with its flash ' + 'image untouched. Experimental must come back - proving flash survived AND that the record ' + 'read at boot was written while AdvancedMode was armed - and AdvancedMode must be OFF. A ' + 'device that inherited the policy from flash would boot with blind signing already enabled ' + 'and no confirmation, which is precisely why bit 12 was retired.', + ['Enable Policy: AdvancedMode', 'Enable Policy: Experimental (marker, four commits)']), + ('I2', 'test_msg_session_trust_lifetime', + 'test_advanced_mode_survives_initialize_but_not_clear_session', + 'Initialize keeps the policy, ClearSession revokes it', + 'session_clear_impl() disarms AdvancedMode only when clear_pin is set: ClearSession passes ' + 'true, Initialize passes false. This pins the asymmetry from both sides. If Initialize ever ' + 'started disarming, every host that sends it before an operation would demand a fresh ' + 'confirmation and the policy would be unusable; if ClearSession ever stopped, an explicit ' + 'lock would leave the blind-signing capability armed behind it.', + ['Enable Policy: AdvancedMode']), + ('I3', 'test_msg_session_trust_lifetime', 'test_signer_dropped_by_initialize', + 'Session teardown drops the loaded signer', + 'A signer is loaded, verified live, and then Initialize is sent. The metadata blob that was ' + 'VERIFIED becomes MALFORMED. AdvancedMode is asserted still ON immediately before that probe, ' + 'so the policy gate cannot be what refused it - the slot is empty. An ordinary GetFeatures is ' + 'sent first as the negative control: if merely exchanging messages dropped signers, the ' + 'teardown assertion would be proving nothing.', + ['Enable Policy: AdvancedMode', + "Load Clearsigner: Trust 'CI Test' (fingerprint) ... NOT verified by KeepKey"]), + ('I4', 'test_msg_session_trust_lifetime', 'test_signer_dropped_by_clear_session', + 'ClearSession revokes both halves of the trust', + 'ClearSession is the explicit lock, and it must take the provider key with it. Straight ' + 'afterwards the metadata message is refused outright ("AdvancedMode required") - that Failure ' + 'is the policy gate and says nothing about the slot, so the policy is re-armed with a bare ' + 'ApplyPolicies (no Initialize, which would clear the slot by itself) and the blob probed ' + 'again. MALFORMED is the assertion: the signer itself is gone.', + ['Enable Policy: AdvancedMode', + "Load Clearsigner: Trust 'CI Test' (fingerprint) ... NOT verified by KeepKey", + 'Enable Policy: AdvancedMode (re-armed to isolate the slot)']), + ('I5', 'test_msg_session_trust_lifetime', 'test_signer_dropped_by_power_cycle', + 'Reboot drops the loaded signer', + 'RAM-only should make this true by construction, but "by construction" is exactly what a ' + 'persistence bug breaks, and the report should carry the reboot rather than infer it. The ' + 'marker policy is set AFTER the signer is loaded, so the record the device boots into is one ' + 'that was written while the signer was live - the record a firmware that persisted signers ' + 'would have persisted them into. Seed, label and marker all come back; the signer does not.', + ['Enable Policy: AdvancedMode', + "Load Clearsigner: Trust 'CI Test' (fingerprint) ... NOT verified by KeepKey", + 'Enable Policy: Experimental (marker, four commits)', + 'Enable Policy: AdvancedMode (re-armed after the reboot to isolate the slot)']), + ('I6', 'test_msg_session_trust_lifetime', + 'test_disabling_advanced_mode_revokes_the_signer', + 'Disabling AdvancedMode revokes the signer, it does not suspend it', + 'With the policy off, revoking and suspending are indistinguishable: every consumer in ' + 'signed_metadata.c refuses a runtime slot while AdvancedMode is off, so metadata fails ' + 'closed either way. The difference shows on the way back. Suspending would mean ' + 're-enabling the policy silently re-arms a provider the user never re-loaded, on a ' + 'confirmation screen that names the policy and never names the signer - so a user who ' + 'disabled AdvancedMode to drop a provider would not have dropped it. ' + 'fsm_msgApplyPolicies therefore calls signed_metadata_clear_signers() on disable. The ' + 're-enable is sent as the bare ApplyPolicies with an exact expected-response list - one ' + 'ButtonRequest and a Success - so the absence of a trust screen there is proof, not ' + 'observation: trust cannot be restored by a policy toggle at all. Coming back costs a ' + 'fresh LoadClearsignSigner consent, the screen that names the alias and fingerprint.', + ['Enable Policy: AdvancedMode', + "Load Clearsigner: Trust 'CI Test' (fingerprint) ... NOT verified by KeepKey", + 'Disable Policy: AdvancedMode', + 'Enable Policy: AdvancedMode - the only confirm on re-arming, and the signer does NOT ' + 'come back with it']), + ]), + ('L', 'Bitcoin-Only Variant', '7.15.0', + 'KK_BITCOIN_ONLY=ON builds a second shipping product out of the same tree: coins.def keeps ' + 'only Bitcoin and Testnet, messagemap.def drops every altcoin handler, KK_ZCASH_PRIVACY is ' + 'forced OFF, and transaction.c takes a BITCOIN_ONLY arm on the OP_RETURN path that confirms ' + 'raw bytes instead of decoding a THORChain memo. Until this section none of it had a test and ' + 'CI only ever ran the multi-chain emulator, so an entire shipping product was audited by ' + 'nothing. These tests never skip: each asserts the behaviour that is correct for the variant ' + 'it is talking to, so a run against the regular image proves the strip did NOT leak into the ' + 'multi-chain product, and a run against the bitcoin-only image proves it happened. The ' + 'variant is identified from GetCoinTable, not from features.firmware_variant -- L3 explains ' + 'why that field cannot be trusted.', + [ + 'PRODUCT: two build products, one tree. Regular = every coin family plus Zcash Orchard.', + 'Bitcoin-only = Bitcoin + Testnet, no altcoins, no shielded Zcash, no ERC-20 token table.', + 'STRIPPED BY NAME: coinByName() must refuse Litecoin/Dogecoin/BCH/Zcash/DigiByte/Dash --', + ' "bitcoin-only" is not "UTXO-only", and a silent fallback to Bitcoin parameters would', + ' hand back an xpub with the wrong version bytes under an altcoin label.', + 'STRIPPED BY MESSAGE: an absent handler answers Failure_UnexpectedMessage from the board', + ' dispatcher, draws nothing, and leaves the message loop usable.', + 'OP_RETURN: no memo parser is linked, so a THORChain memo is disclosed as the bytes', + ' themselves. The OMNI branch sits ABOVE the #if and must still decode.', + 'REFUSAL: refusing the raw OP_RETURN screen returns -1 from compile_output(), which must', + ' surface as ActionCancelled with no signature and no further screens.', + ], + [ + ('L1', 'test_msg_bitcoin_only_variant', 'test_bitcoin_signing_survives_the_strip', + 'Bitcoin still signs, byte for byte', + 'The one thing the bitcoin-only product must still do. Stripping coins, handlers and the ' + 'Orchard engine touches coins.def, messagemap.def, fsm.c and the AES table selection; any ' + 'of them going wrong surfaces here first. The signature is compared against the exact ' + 'vector test_msg_signtx.test_one_one_fee pins on the multi-chain build, so both products ' + 'must produce identical transactions from the same seed. The two review screens are ' + 'asserted as well: a signing test alone cannot see a dropped confirmation.', + ['Send 0.0038 BTC to 1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1', + 'TRANSACTION: send 0.0039 BTC from your wallet, including a 0.0001 BTC fee']), + ('L2', 'test_msg_bitcoin_only_variant', 'test_coin_table_is_bitcoin_and_testnet_only', + 'The coin table is the product boundary', + 'GetCoinTable must report exactly two coins, Bitcoin and Testnet, with no ERC-20 tokens ' + '(TOKENS_COUNT is 0 and `tokens` is not linked at all). A host enumerating coins is the ' + 'only way a user learns what the device will sign, so the count and the names are part ' + 'of the product, not an implementation detail. On the regular image the same test ' + 'asserts the table is larger -- the strip must not leak.', + []), + ('L3', 'test_msg_bitcoin_only_variant', 'test_firmware_variant_names_the_bitcoin_only_product', + 'features.firmware_variant must name the product', + 'The bitcoin-only emulator once reported plain "Emulator", so ' + 'common.requires_fullFeature(), which skips on "KeepKeyBTC" or "EmulatorBTC", was dead ' + 'code and altcoin tests failed against the bitcoin-only image instead of skipping. The ' + 'variant now names the product on both the emulator and hardware arms. The assertion is ' + 'by suffix, not a fixed string, so it holds for both.', + []), + ('L4', 'test_msg_bitcoin_only_variant', 'test_altcoin_message_handlers_are_absent', + 'Every stripped chain refuses without drawing', + 'Thirteen probes -- Ethereum, Cosmos, Osmosis, Nano, EOS, THORChain, Maya, Ripple, ' + 'Binance, TRON, TON, Solana, Hive -- must each answer Failure_UnexpectedMessage, the ' + 'board dispatcher\'s answer for a message type that is not in the map. The two ways this ' + 'goes wrong are a half-linked handler (wrong failure, or a hang) and one that renders ' + 'before refusing: a bitcoin-only device must never draw a chain it cannot sign. The ' + 'framebuffer is compared byte-for-byte across all thirteen for exactly that reason, and ' + 'a Ping afterwards proves the message loop is not wedged. The screenshot list is ' + 'deliberately empty -- the evidence is that nothing was drawn.', + []), + ('L5', 'test_msg_bitcoin_only_variant', 'test_altcoin_coin_names_are_refused', + 'Stripped coins are refused by name', + 'The other half of the boundary. GetPublicKey is a Bitcoin-family message and stays in ' + 'the map, so coinByName() is what has to say no: Litecoin, Dogecoin, BitcoinCash, Zcash, ' + 'DigiByte and Dash must each come back Failure_Other "Invalid coin name" rather than ' + 'falling through to Bitcoin\'s parameters and returning an xpub with the wrong version ' + 'bytes under an altcoin label. Bitcoin and Testnet must still work.', + []), + ('L6', 'test_msg_bitcoin_only_variant', 'test_zcash_privacy_is_compiled_out', + 'Zcash privacy is compiled out with the coin', + 'The Orchard engine is the largest thing in the image and its handlers live behind ' + 'ZCASH_PRIVACY, not BITCOIN_ONLY -- the two gates are tied together in CMakeLists, not ' + 'in the source, so nothing in C would catch that wiring breaking. ZcashGetOrchardFVK and ' + 'ZcashDisplayAddress must be unknown messages, and transparent Zcash must be gone from ' + 'the coin table in the same breath, so no Zcash path of either kind survives.', + []), + ('L7', 'test_msg_bitcoin_only_variant', 'test_op_return_thorchain_memo_is_confirmed_raw', + 'A THORChain memo is disclosed raw, not decoded', + 'The arm the alpha merge added to compile_output(). With no memo parser linked, a memo ' + 'the multi-chain image explains -- swap, asset, destination, affiliate -- is shown on the ' + 'bitcoin-only image as the bytes themselves. That is the right answer (a decode the image ' + 'cannot perform must never be faked) but it had never been executed, because CI runs only ' + 'the multi-chain emulator. Screen counts are measured, not modelled: bitcoin-only shows ' + 'exactly three requests (output, raw OP_RETURN, SignTx) while the regular image expands ' + 'the same memo into strictly more ConfirmOutput screens. Both must sign a script carrying ' + 'the memo verbatim, so disclosure and signature are pinned to the same bytes.', + ['Send 0.0038 BTC to 1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1', + 'CONFIRM OP_RETURN: SWAP:ETH.ETH:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75', + 'TRANSACTION: send 0.0039 BTC from your wallet, including a 0.0001 BTC fee']), + ('L8', 'test_msg_bitcoin_only_variant', 'test_op_return_refusal_cancels_the_signature', + 'Refusing the OP_RETURN screen aborts the signature', + 'The BITCOIN_ONLY arm returns -1 when confirm_data is refused, and the multi-chain arm ' + 'has its own CANCELLED path that must not answer a refusal by asking again on a second ' + 'screen. Both must surface as Failure_ActionCancelled with no signature, and the flow ' + 'must stop AT the refused screen -- a SignTx request afterwards would mean the refusal ' + 'was recorded and then ignored.', + ['Send 0.0038 BTC to 1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1', + 'CONFIRM OP_RETURN: the memo screen the user refuses']), + ('L9', 'test_msg_bitcoin_only_variant', 'test_omni_op_return_is_still_decoded', + 'The shared OMNI branch survived the strip', + 'compile_output() tests for an "omni" prefix ABOVE the BITCOIN_ONLY split, so an OMNI ' + 'simple send is still decoded into a sentence on the bitcoin-only image. The regression ' + 'guarded against is the new #else swallowing the OMNI case, silently downgrading a ' + 'decoded amount to a hex dump. Proved by contrast rather than by OCR: the same twenty ' + 'bytes with the leading "o" changed to "p" are no longer OMNI and fall through to the ' + 'raw-data screen, so the two screens must differ and the decoded one must be the sparser ' + 'of the two. Both payloads ride in ONE transaction, as two data outputs, because L11 ' + 'makes a second signing in the same session impossible.', + ['CONFIRM OMNI: Do you want to send 1 OMNI?', + 'CONFIRM OP_RETURN: 706D6E6900000000000000010000000005F5E100 -- the same bytes, raw', + 'Send 0.0038 BTC to 1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1', + 'TRANSACTION: send 0.0039 BTC from your wallet, including a 0.0001 BTC fee']), + ('L10', 'test_msg_bitcoin_only_variant', 'test_repeated_transaction_is_allowed_without_op_return', + 'An exact repeat is not a duplicate', + 'The control for L11. compile_output() carries an anti-malware check (txin_check.c): warn ' + 'when a transaction pays the same amount to the same address as the previous one but was ' + 'built from DIFFERENT inputs, which is what a host rewriting a segwit txid looks like. An ' + 'exact repeat -- same outputs AND same inputs -- is not that and is deliberately allowed. ' + 'Signing it twice here pins that, so the refusal in L11 cannot be explained away as the ' + 'duplicate guard doing its job.', + []), + ('L11', 'test_msg_bitcoin_only_variant', 'test_op_return_does_not_poison_the_duplicate_detector', + 'An OP_RETURN output must not poison the duplicate detector', + 'Sign a transaction whose last output is OP_RETURN, then sign the transaction L10 proved ' + 'is allowed: it must sign. It once failed on both products with "WARNING: DUPLICATE ' + 'TRANSACTION!", because an OP_RETURN output returned before txin_dgst_save_and_reset() ' + 're-initialised the digest context, so the next transaction hashed its inputs into a ' + 'finalised context. Every THORChain and Maya swap ends in OP_RETURN and armed it. The ' + 'common setUp wipes the device, so no other test signs two transactions in one session.', + ['Send 0.0038 BTC to 1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1 (first transaction)', + 'CONFIRM OP_RETURN: the memo that used to arm the detector']), + ]), + ('U', 'Storage Upgrade Preservation', '7.15.0', + 'A signed UPGRADE must never wipe. An older firmware must never parse a newer storage ' + 'format; depending on the tree it either resets that record or explicitly refuses it ' + 'without writing. Until this section nothing in the suite tested those boundaries - every ' + 'other test creates storage with the ' + 'firmware under test and never crosses a release boundary, which is exactly where this ' + 'class of defect lives. The mechanism is one function: storage_init() hands flash to ' + 'storage_fromFlash(). Historical trees map unknown versions to SUS_Invalid and commit a ' + 'reset; trees implementing SUS_TooNew refuse a newer normal-band record without committing. ' + 'The flash format this build reads and writes is V17, the same format shipped in v7.14.1. ' + '7.15 reverted the RC27 bump to V19 (commit 6bebde7b2) because one boot silently migrated ' + '17 to 19 and from that moment no downgrade was possible without a wipe; V18, the ' + 'clear-sign identity block, is dead, and the V19 serializer survives only behind ' + 'STORAGE_PIN_KDF_V19 == 0. U5 independently ratchets the shipped floor at V17: the ' + 'compile-time assert compares two numbers in the same header, and lowering both to make a ' + 'build compile is the edit the SOP calls its highest-severity review item.', + [ + 'THE RULE: recognise every version any shipped firmware ever wrote, and never lower', + 'STORAGE_VERSION. Both ways of breaking it compile cleanly and pass every other test:', + '- lowering STORAGE_VERSION below a version that has shipped;', + '- deleting, reordering or renumbering an entry in storage_versions.inc.', + '', + 'The reverse direction is tree-specific but must be explicit. Older firmware cannot', + 'read a newer record: historical trees reset it, while SUS_TooNew trees lock it and', + 'preserve the bytes. Neither policy may load the incompatible wallet.', + '', + 'HOW THESE TESTS REACH THE GATE: it only runs at boot, and no host message can reboot', + 'the device. SoftReset (messages.proto type 89) has no messagemap entry and no handler', + 'body, and fsm_msgDebugLinkFlashDump() is compiled out under EMULATOR, so the emulator', + 'can neither be restarted nor have its flash read over the wire. U1-U4 therefore start', + 'their OWN kkemu on their own port pair and own its emulator.img, which lib/emulator/', + 'setup.c mmaps as the flash array. Killing that process and starting it again IS a', + 'power cycle, and restamping the version word in the image is what an arriving device', + 'presents: a record whose header says one version while the firmware says another.', + '', + 'WHAT THIS SECTION DOES NOT COVER, stated plainly:', + '- No signed image is involved. The bootloader preserves storage only when SIG_FLAG is', + ' set, the firmware being replaced was officially signed, and the new image verifies.', + ' An unsigned development or RC build fails two of those by construction, so "the', + ' upgrade did not wipe" is finally proven only with a signed build on a production', + ' device.', + '- U2 restamps a record THIS build wrote rather than replaying one 7.14.x wrote, so the', + ' V16 reader runs but the older LAYOUTS (V1-V15) and their fallthrough chain do not.', + '- U1-U4 SKIP wherever no kkemu binary can be started. The release Compose harness', + ' supplies the exact variant binary built by the firmware-unit service; running the', + ' python-keepkey image by itself does not. A skipped U1-U4 in this report means the', + ' release was NOT audited for upgrade preservation.', + ], + [ + ('U1', 'test_storage_version_gate', 'test_reboot_preserves_the_wallet', + 'A power cycle keeps the wallet', + 'The boundary the ordinary storage tests never cross. Every other test lives inside ' + 'one session, where the wallet is a RAM shadow; only a power cycle re-runs ' + 'storage_init() and proves the bytes committed to flash were both written and ' + 'readable. The PIN is load-bearing: the seed lives in encrypted_sec and the key that ' + 'decrypts it is only ever stored wrapped by the PIN, so an address that still derives ' + 'after the reboot proves the wrapped key, its fingerprint and the ciphertext all ' + 'round-tripped together. This test is also the control for U2 - a record already at ' + 'STORAGE_VERSION reports SUS_Valid, so nothing is rewritten at boot, and the flash ' + 'image is asserted byte-identical across the restart.', + ['Wipe Device confirm (the arrangement wipes before loading the seed)', + 'Import Recovery Sentence confirm', + 'Home screen after the power cycle: locked, wallet still present', + 'Bitcoin Account #0 / Address #0 showing the same address as before the reboot']), + ('U2', 'test_storage_version_gate', 'test_v16_blob_upgrades_without_wiping', + 'A V16 wallet upgrades, it does not wipe', + 'The policy in one test: the device arrives carrying the format written by the release ' + 'it is leaving, and the incoming firmware must READ it rather than reset it. ' + 'storage_fromFlash() takes case StorageVersion_16, reads through storage_readV16(), ' + 'restamps the record V17 and reports SUS_Updated, which storage_init() answers with a ' + 'commit - a migration, not a wipe. The V16 record is built from the four things that ' + 'actually differ between the formats: the version stamp, flags bits 18/19 ' + '(authdata_initialized / authdata_encrypted), authdata_fingerprint at +469, and the ' + '512-byte V16 ciphertext against the 1024-byte V17 one. The same address behind the ' + 'same PIN is the assertion; it can only derive if the wrapped storage key unwrapped, ' + 'the V16 ciphertext decrypted and the seed came back byte-identical. A surviving ' + 'wallet alone would not prove the V16 branch ran, so the test also asserts flash was ' + 'written at boot - the side effect only SUS_Updated has.', + ['Wipe Device confirm', 'Import Recovery Sentence confirm', + 'Home screen after the migrating boot: wallet still present', + 'Bitcoin Account #0 / Address #0 - the same address the V16 record held']), + ('U3', 'test_storage_version_gate', 'test_unrecognised_version_wipes_on_boot', + 'A newer storage version follows the firmware policy', + 'A device that has run newer firmware carries a newer stamp that this firmware cannot ' + 'parse. The canonical test derives the policy from the firmware tree: historical ' + 'trees reset unknown normal-band storage, while trees implementing SUS_TooNew refuse ' + 'the record without modifying flash so reinstalling the newer firmware can recover ' + 'the wallet. In both cases the older firmware must expose no wallet, PIN or label. ' + 'The stamp is one past the value the device just committed, so the test crosses the ' + 'actual next-version boundary rather than pinning python-keepkey to one release.', + ['Wipe Device confirm', 'Import Recovery Sentence confirm', + 'Home screen after the incompatible boot: no wallet']), + ('U4', 'test_storage_version_gate', 'test_bitcoin_only_band_refuses_without_wiping', + 'A bitcoin-only wallet is refused, not destroyed', + 'Seeds created under bitcoin-only firmware are stamped in a reserved band (10000 + the ' + 'normal version). Multi-chain firmware must not load one - that seed was never meant ' + 'to be multi-chain-exposed - but it must also leave it alone: SUS_BitcoinOnlyLocked ' + 'resets only the RAM shadow, and storage_commit() returns early while btc_only_locked, ' + 'so flash is never touched. Three assertions, in order of what they cost you: the ' + 'device comes up locked and uninitialized; the storage sector is byte-for-byte what it ' + 'was, everywhere except the stamp the test itself changed; and once the band stamp is ' + 'removed the wallet boots again and derives the original address. Without the third, ' + '"refuse rather than wipe" would be a claim about intent rather than about bytes.', + ['Wipe Device confirm', 'Import Recovery Sentence confirm', + 'Home screen while locked out by the bitcoin-only band: no wallet', + 'Bitcoin Account #0 / Address #0 after the band stamp is removed - the wallet is back']), + ('U5', 'test_storage_version_gate', 'test_last_shipped_never_moves_backwards', + 'STORAGE_VERSION_LAST_SHIPPED never moves backwards', + 'An independent witness for the number the whole gate turns on. The compile-time ' + 'assert in storage.c compares STORAGE_VERSION against STORAGE_VERSION_LAST_SHIPPED - ' + 'two values in the same header, editable in one commit - so it cannot notice a release ' + 'that raises both. 7.15 deliberately reverted to V17; if V19 (or anything else) ' + 're-lands, this test fails and the bump has to be argued for in review rather than ' + 'discovered in the field. Reads the firmware sources, so it runs even where no ' + 'emulator can be restarted. No screen: it never touches the device, and the empty ' + 'list below says so.\n' + '7.16 moves to V20 to hold passkey credentials. It skips 18 and 19 because both were ' + 'ACTIVE formats in alpha builds before 6bebde7b2 reverted to V17 - 18 the clear-sign ' + 'identity block, 19 the PIN-KDF migration - so devices carrying those blobs exist, and ' + 'reusing a number would make this firmware PARSE one as passkey state rather than ' + 'refuse it. Upgrading preserves the wallet; downgrading to 7.15 or earlier erases it, ' + 'which is normal downgrade behaviour and is in the release note rather than left to be ' + 'discovered.', + []), + ('U5b', 'test_storage_version_gate', + 'test_burned_versions_are_dispatched_to_the_wipe_path', + 'A burned format is dispatched, and what it reaches is the wipe', + 'This used to assert the ABSENCE of a dispatch case, on the theory that a burned blob ' + 'falls through to a default. It does not: storage_fromFlash() has no default case, ' + 'deliberately, so that -Werror=switch names any version nobody handled. An unlisted ' + 'version therefore does not fall anywhere - it fails the ARM build. So the label must ' + 'exist; what must NOT exist is a reader behind it. Asserted as the real property: the ' + 'burned versions are dispatched, and the arm they reach returns SUS_Invalid with no ' + 'storage_readVxx call. Which versions are burned is read from ' + 'storage_versions.inc rather than written down here, so the test holds on a line that ' + 'burns nothing as readily as on one that burns two.', + []), + ('U6', 'test_storage_version_gate', 'test_version_never_drops_below_a_shipped_release', + 'The version never goes backwards or into the band', + 'Lowering STORAGE_VERSION wipes every device upgrading FROM a shipped release: its ' + 'record stops being recognised, so the gate maps it to StorageVersion_NONE and ' + 'storage_init() resets. The version must also stay below STORAGE_VERSION_BTC_ONLY_BASE ' + '(10000), or a multi-chain wallet would be stamped into the band that multi-chain ' + 'firmware refuses to load - locking the wallet out of its own firmware.', + []), + ('U7', 'test_storage_version_gate', + 'test_version_ladder_is_contiguous_and_ends_at_storage_version', + 'storage_versions.inc is append-only', + 'The enum is emitted in .inc order after StorageVersion_NONE = 0, which is what makes ' + 'StorageVersion_N == N. Delete or renumber an entry and version_from_int() quietly ' + 'loses that case, wiping every device carrying it. This asserts the ladder is ' + 'contiguous from 1 and that its last entry is STORAGE_VERSION - the two properties the ' + 'in-tree static asserts depend on.', + []), + ('U8', 'test_storage_version_gate', 'test_every_shipped_version_has_a_reader', + 'Every shipped version still has a reader', + 'The failure the static asserts do NOT cover. They pin the enum to its own numbering ' + 'and say nothing about what the switch in storage_fromFlash() does with it. Drop the ' + 'reader for a version that reached hardware and every device carrying it is wiped on ' + 'upgrade. Scoped to SHIPPED versions on purpose: a burned format legitimately has no ' + 'reader, so asserting "every ladder version has a reader" would make burning one ' + 'impossible to express. The companion assertion, that no shipped version is ever ' + 'declared burned, is what stops that scoping being used as a loophole.', + []), + ]), + + ('ER', 'ERC-7730 v2 Certified Clear-Signing Conformance', '7.19.0', + 'ERC-7730 definitions are compiled into a bounded canonical program, authenticated by ' + 'KeepKey\'s certified delegation hierarchy, and interpreted against the exact calldata or ' + 'EIP-712 document being signed. The host supplies types, labels and formatting policy; it ' + 'never supplies authoritative decoded transaction values. A certified-definition failure ' + 'refuses signing and cannot silently downgrade to a less-specific certified display.', + [ + 'AUTHENTICATION AND REPLAY BOUNDARY:', + '- K773 envelope: version + purpose + canonical C773 program + sorted Merkle proof +', + ' 139-byte KeepKey delegation certificate + compact secp256k1 signature.', + '- Purpose digest: SHA256("KEEPKEY:ERC7730:CATALOG\\0" || catalog_root).', + '- Definition id commits to the complete envelope; offset-zero replay is re-hashed before', + ' staged interpreter output becomes authoritative.', + '- Catalog lookup binds kind, chain, target and selector/typeHash. Unknown, ambiguous,', + ' reordered, oversized and recursively repeated definitions fail closed.', + '', + 'CANONICAL PROGRAM AND DEVICE INTERPRETER:', + '- 179-byte header; sorted UTF-8 strings; canonical recursive ABI/EIP-712 node tree;', + ' typed structured/container/literal paths; typed literals and conditions;', + ' formatter, display, deployment/domain binding and resource sections.', + '- Recursive tuples and arrays through depth 12, 64 ABI nodes, 64 aggregate array', + ' elements, negative indices, half-open slices, nested array frames and separators.', + '- Display bytecode: intent, atomic interpolation fallback, fields, nested groups, arrays,', + ' embedded calls and explicit end; all forward/back links and depth are recomputed.', + '- Conditions: always, never, optional, empty/not-empty, in/not-in and must-match.', + '- Formatters: raw, native/token amounts, NFT, date, duration, unit, enum, chain, address', + ' name, ticker, ERC-7930 interoperable address, embedded calldata and encrypted fallback.', + '- Signed token/network records establish ticker, decimals and native currency. Live', + ' results may annotate but cannot replace a device-decoded value.', + '', + 'REAL-WORLD AND EXHAUSTIVE EVIDENCE:', + '- Exact KeepKey SDK THORChain router deposit calldata, including vault, asset, amount and', + ' memo, is selector-bound and firmware-validated.', + '- Official Uniswap Router02 signed transaction and Permit2 typed-data fixtures bind the', + ' expected selector, target, chain, typeHash and domain constraints.', + '- Every one of 1,450 calldata formats exposed after bounded include expansion in the', + ' official ERC-7730 registry compiles and reaches the firmware catalog verifier.', + '- Deep ParaSwap Augustus multiSwap/megaSwap definitions exercise depths 9 and 11 while', + ' the compacted signing workflow remains exactly 4,096 bytes of fixed SRAM.', + '- The SDK/Vault consumer path accepts the same signed catalog on ethSignTransaction and', + ' from Relay/ShapeShift quote payloads, preserves it through swap construction, preloads', + ' the primary envelope, and answers device-driven definition requests in 1,024-byte', + ' chunks. Missing, malformed, mismatched, oversized and over-depth requests abort.', + '- Consumer evidence: hdwallet PR #3 passed build/lint and 8 focused catalog/policy tests;', + ' keepkey-vault PR #444 passed 59 focused REST schema and real Relay swap parser tests,', + ' Linux/macOS release builds and the packaged Intel macOS smoke test before merge.', + '- Device evidence: 133 focused firmware tests pass: ABI (5), streamed ABI (7), catalog', + ' (19), conditions (6), canonical EIP-712 (33), formatting (8), program readers (20),', + ' transaction continuation (3), and authenticated workflow execution (32).', + '- Host evidence: 25 focused python-keepkey tests pass for deterministic compilation,', + ' official-registry traversal, signed envelopes, request policy and protobuf transport.', + '', + 'COMPLETE ACTIVE-V2 SUPPORT BREAKDOWN:', + '- Descriptor/context: schema-major enforcement; bounded includes and deterministic merge;', + ' owner/info/constants/maps/enums; canonical selector and EIP-712 encodeType/typeHash;', + ' exact chain/deployment/domain/contract/selector binding; unknown selectors refuse.', + '- ABI/value model: every legal int width; address/bool/bytesN/bytes/string; fixed and', + ' dynamic arrays; static/dynamic tuples; depth-12 recursion; negative index, full-array', + ' and bounded-slice paths; transaction, structured and literal containers.', + '- References: #/$/@ names, fields, groups, constants, maps, enums, tokens and networks are', + ' host-resolved to typed canonical ids; the device rechecks type, range and section links.', + '- Display: string/object intents, atomic interpolated intent fallback, ordered fields,', + ' recursive groups, authenticated array iteration and separators.', + '- Visibility/assertions: always, never, empty, not-empty, in, not-in and must-match;', + ' condition operands are captured from device-decoded calldata or streamed typed data.', + '- Formatting: raw integer/string/bytes/address; native/token amounts and thresholds; NFT;', + ' date, duration, unit, enum, chain id, trusted-name fallback, ticker and ERC-7930.', + '- Nested/special: embedded calldata requests a separately certified definition with a', + ' depth-4/cycle boundary; encrypted data never claims plaintext without verified proof;', + ' ERC-4337/EIP-5792 shapes are handled as ordinary bounded EIP-712 structs/arrays.', + '- Failure policy: dirty padding, gaps/overlap/trailing bytes, bad offsets, wrong context,', + ' missing definitions, invalid proof/certificate/purpose, resource excess, ambiguity and', + ' host/device value conflicts all refuse signing; certified failure never downgrades.', + '', + 'LIMIT OF THIS SECTION: catalog-verifier success ends at the expected UNTRUSTED result', + 'when tests use a synthetic certificate. A trusted-success emulator capture additionally', + 'requires a certificate issued by the KeepKey root for the test delegate.', + ], + [ + ('ER1', 'test_erc7730_compiler', + 'test_official_registry_all_calldata_formats_reach_firmware', + 'All 1,450 official calldata formats reach firmware', + 'Recursively merges bounded includes, compiles every applicable registry format and ' + 'feeds each complete canonical program through the firmware catalog verifier.', []), + ('ER2', 'test_erc7730_compiler', + 'test_compiles_and_checks_exact_keepkey_sdk_thorchain_swap', + 'Exact KeepKey SDK THORChain swap fixture', + 'Checks selector, vault, asset, amount, dynamic memo and transaction value before ' + 'firmware validation.', []), + ('ER3', 'test_erc7730_compiler', + 'test_compiles_official_uniswap_tuple_fixture_through_firmware', + 'Official Uniswap signed transaction', + 'Binds the real Router02 target, chain, selector and published transaction hash.', []), + ('ER4', 'test_erc7730_compiler', + 'test_compiles_official_uniswap_eip712_fixture_through_firmware', + 'Official Permit2 EIP-712 fixture', + 'Proves canonical encodeType/typeHash, domain constraints and signed token/network ' + 'metadata reach the firmware parser.', []), + ('ER5', 'test_erc7730_compiler', + 'test_loads_bounded_includes_and_compiles_array_backed_group', + 'References, includes and grouped arrays', + 'Exercises deterministic include merge, shared field references and a group executed ' + 'inside an authenticated array frame.', []), + ('ER6', 'test_erc7730_compiler', + 'test_compiles_recursive_array_iteration_frames', + 'Recursive ABI arrays and separators', + 'Builds nested array begin/end links and validates their declared resource depth.', []), + ('ER7', 'test_erc7730_compiler', + 'test_compiles_interpolated_intent_and_metadata_enum', + 'Atomic interpolation and enum maps', + 'Every interpolated operand is also an unconditional field; failure selects the ' + 'fallback intent atomically.', []), + ('ER8', 'test_erc7730_compiler', + 'test_compiles_typed_if_not_in_and_must_match_conditions', + 'Typed visibility and must-match conditions', + 'Typed literal sets control visibility; must-match is a signing assertion.', []), + ('ER9', 'test_erc7730_catalog', + 'test_signed_catalog_envelope_commits_program_proof_and_certificate', + 'Certified Merkle catalog envelope', + 'Verifies deterministic root construction, purpose digest, signature and exact wire ' + 'layout.', []), + ('ER10', 'test_erc7730_catalog', + 'test_catalog_refuses_unknown_ambiguous_and_malformed_requests', + 'Catalog lookup fails closed', + 'Unknown selectors, oversized chunks, excessive recursion and ambiguous tuples are ' + 'rejected.', []), + ('ER11', 'test_erc7730_protocol_bindings', + 'test_definition_chunk_round_trip_is_byte_exact', + 'Canonical protocol transport is byte exact', + 'Protobuf bindings preserve definition id, offsets, total length and envelope bytes.', []), + ('ER12', 'test_erc7730_compiler', + 'test_token_amount_without_token_uses_firmware_raw_fallback', + 'Unknown tokens never become trusted labels', + 'A missing token reference displays the device-decoded raw integer and forbids ' + 'token-specific auxiliary metadata.', []), + ('ER13', 'test_erc7730_compiler', + 'test_parses_recursive_tuple_and_array_signature', + 'Canonical recursive ABI signature parsing', + 'Parses nested tuple and array fragments into the exact bounded forward tree consumed by ' + 'the firmware interpreter.', []), + ('ER14', 'test_erc7730_compiler', + 'test_compiles_deterministic_canonical_calldata_program', + 'Compilation is byte-for-byte deterministic', + 'Equivalent descriptor input produces one canonical program, selector and source-binding ' + 'identity rather than host-dependent wire bytes.', []), + ('ER15', 'test_erc7730_compiler', + 'test_compiles_array_iteration_separator_and_optional_visibility', + 'Array iteration, separators and optional visibility', + 'Compiles array-backed fields with authenticated length, separator control flow and ' + 'ifEmpty/ifNotEmpty behavior.', []), + ('ER16', 'test_erc7730_compiler', + 'test_compiles_nested_field_group_with_balanced_links', + 'Recursive groups have balanced control flow', + 'Every group call/return and display-program forward link is recomputed and bounded.', []), + ('ER17', 'test_erc7730_catalog', + 'test_catalog_serves_exact_lookup_and_id_replays', + 'Exact-context lookup and authenticated replay', + 'Serves only the requested identity tuple and re-authenticates offset-zero definition-id ' + 'replays before interpreter output becomes authoritative.', []), + ('ER18', 'test_erc7730_catalog', + 'test_preload_streams_and_checks_every_acknowledgement', + 'Preload transport checks every acknowledgement', + 'Streams bounded chunks and refuses reordered, missing or mismatched device acknowledgements.', []), + ('ER19', 'test_erc7730_protocol_bindings', + 'test_erc7730_message_ids_and_mapping', + 'Canonical message ids are stable', + 'Pins request, chunk and acknowledgement messages to the canonical protocol mapping.', []), + ('ER20', 'test_erc7730_protocol_bindings', + 'test_lookup_kinds_encode_expected_context', + 'Every definition kind carries its security context', + 'Calldata, EIP-712, token and network lookups encode only their required chain, target, ' + 'selector or type-hash identity fields.', []), + ]), + + ('EX', 'ERC-7730 Runtime - What 7.15 Shows For A Certified Definition', '7.15.0', + 'A host may send a signed ERC-7730 definition before an Ethereum transaction. The device ' + 'verifies it while it streams and then shows the transaction through it: the contract action, ' + 'the signer\'s labels beside values the device decodes from the signed calldata, and inner ' + 'calls reviewed with their own definition. The definition only adds screens. The ' + 'ordinary review and signature follow unchanged: every test here that signs first signs ' + 'the same transaction on the ordinary path and requires an identical signature.\n' + 'In 7.15 this runs only with AdvancedMode on, and the definition\'s signer is shown as a runtime ' + 'signer, "NOT verified by KeepKey". It is annotation, not KeepKey-verified clear signing.', + ['WHAT THE DEVICE EXECUTES (one capability table, shared by the preload verifier, the', + 'runtime and python-keepkey\'s compiler; anything else is refused at preload, before', + 'any screen):', + '- Formatters: raw, amount, tokenAmount, nftName, date, duration, unit, enum,', + ' addressName, embedded calldata. Containers @.from/@.to/@.value. A signed', + ' constant is shown only as a raw field, as the signer\'s own.', + '- Display: intent; interpolated intent as numbered parts; fields; groups; one array', + ' iterated at a time. Condition "optional" only, and it always shows.', + '- Refused: slices, packed words, never/ifIn/ifNotIn/mustMatch/ifEmpty, nested', + ' iteration, ABIs deeper than 8. The compiler omits visible:never fields.', + '- Official registry (pinned 9f37816a): 1,326 of 1,450 calldata formats pass the', + ' device\'s preload checks (asserted in lockstep by test_erc7730_compiler).', + '', + 'WHERE EACH FACT COMES FROM:', + '- Values: decoded on device from the signed calldata, replayed and hash-checked on', + ' every pass. Token tickers and decimals: the firmware table only. Signer text', + ' (labels, messages, units, enum labels) is escaped and shown beside the raw value.', + '', + 'EMBEDDED CALLS (7.15 rule: what cannot be clear-signed is shown under a', + '"Blind signature" warning; 7.16 will reject it):', + '- The inner definition is requested by chain, callee and selector read from the', + ' signed calldata, and bound before any inner screen. One level deep.', + '- Blind in 7.15: no inner definition; a refused one; one showing @.value with no', + ' amountPath; depth 2; calls inside an array; inner bytes without a selector.', + '', + 'KNOWN LIMITS (fail closed, no signature): parallel arrays pair by index and a', + 'shorter one aborts mid-review; a fixed index into a short dynamic array aborts; inner', + 'bytes not canonical for the inner ABI abort after the outer screens. The board pager', + 'may wrap a checksummed address across two OLED pages; every page must be confirmed.', + 'Open for 7.16: Safe DELEGATECALL is shown only as the operation field.'], + [ + ('EX1', 'test_msg_ethereum_erc7730_runtime', + 'test_program_outside_capability_table_is_refused_at_preload', + 'Unexecutable programs are refused at preload', + "A definition using a formatter, condition or path step outside the runtime's capability table is refused while it streams, before any screen. A review can never start and then fail part-way.", + []), + ('EX2', 'test_msg_ethereum_erc7730_runtime', + 'test_path_outside_abi_is_refused_at_preload', + 'Paths outside the ABI are refused at preload', + "Every value path is walked against the function's ABI at preload: a tuple index out of range, a step into a scalar or a path ending on an array is refused before any screen.", + []), + ('EX3', 'test_msg_ethereum_erc7730_runtime', + 'test_raw_field_screens_show_exact_text', + 'Raw fields show the exact decoded value', + "Each field is its own required confirmation under a device-owned title; the label is the signer's, the value is decoded from the signed calldata.", + ['Signer field with decoded value']), + ('EX4', 'test_msg_ethereum_erc7730_runtime', + 'test_token_amount_uses_the_firmware_token_table', + 'Token amounts use the firmware token table', + "Ticker and decimals come only from the firmware's own table for the chain, rendered exactly as the ordinary Ethereum review renders them.", + ['Amount with firmware ticker']), + ('EX5', 'test_msg_ethereum_erc7730_runtime', + 'test_token_named_by_calldata_wins_over_the_hosts_claim', + 'The token is read from calldata, not the host', + "The transaction goes to one contract; the calldata names another token. The device shows the calldata's token, and an unknown token as the exact integer plus its address.", + ['Unknown token, exact integer and address']), + ('EX6', 'test_msg_ethereum_erc7730_runtime', + 'test_signer_label_cannot_name_an_unknown_token', + 'A signer label cannot name a token', + 'A label saying "USDC amount" over an unknown token still shows the raw integer, "unknown token" and the address.', + ['Signer label beside unknown token']), + ('EX7', 'test_msg_ethereum_erc7730_runtime', + 'test_threshold_message_is_shown_beside_the_exact_amount', + 'Threshold messages sit beside the amount', + 'At or above the threshold the signer\'s message is shown, marked "Signer:", above the exact amount, never instead of it; below it no message appears.', + ['Signer message above the exact amount']), + ('EX8', 'test_msg_ethereum_erc7730_runtime', + 'test_native_alias_discloses_signer_mapping_and_token_address', + 'Native-currency aliases disclose the signer mapping and token address', + "An address in the signer's alias set is marked as a signer native alias and shown in full before the native-unit amount; any other address stays an unknown token. The firmware token table wins over an alias.", + ['Signer alias, token address and native-unit amount']), + ('EX9', 'test_msg_ethereum_erc7730_runtime', + 'test_address_name_marks_only_the_signing_account', + '"(this wallet)" marks only the signing account', + 'The signing address is derived on device; an address one byte away is shown without the mark. Addresses are EIP-55 and never truncated.', + ['Address marked (this wallet)']), + ('EX10', 'test_msg_ethereum_erc7730_runtime', + 'test_containers_and_signed_constants', + 'Transaction containers and signed constants', + '@.to is read from the transaction being signed; a constant comes from the signed definition.', + ['Container and constant fields']), + ('EX11', 'test_msg_ethereum_erc7730_runtime', + 'test_interpolated_intent_parts_show_the_same_values_as_fields', + 'Interpolated intent as numbered parts', + 'The intent sentence is shown as numbered parts after the plain intent: "Intent text i of n" for the signer\'s fragments, "Intent value i of n" for values the device formats, identical to the matching field.', + ['Intent text part', 'Intent value part', 'Matching field']), + ('EX12', 'test_msg_ethereum_erc7730_runtime', + 'test_phase_c_formatters_show_exact_text', + 'Date, duration, unit and enum formatters', + 'Each value is formatted on device and always shown with its raw integer; a unit\'s base is preceded by "unit set by signer", so the mark is never on a later page than the value.', + ['Unit value with raw integer']), + ('EX13', 'test_msg_ethereum_erc7730_runtime', + 'test_enum_labels_are_the_signers_claim_beside_the_value', + 'Enum labels never replace the value', + 'A mapped value shows "label (value)" after "label set by signer"; an unmapped one shows "value (unmapped)".', + ['Unmapped enum value']), + ('EX14', 'test_msg_ethereum_erc7730_runtime', + 'test_nft_shows_the_collection_address', + 'NFTs show the token ID and collection address', + 'No collection name is claimed: the token ID and the full collection address.', + ['Token ID and collection']), + ('EX15', 'test_msg_ethereum_erc7730_runtime', + 'test_malformed_calldata_is_refused_before_a_constant_field', + 'Malformed calldata is refused before any field', + 'The up-front validation pass rejects calldata that does not match the ABI before the first field, even one showing a constant.', + []), + ('EX16', 'test_msg_ethereum_erc7730_runtime', + 'test_iteration_shows_every_element_numbered', + 'Dynamic and fixed arrays show every element, numbered', + 'Dynamic and fixed arrays title each element "Signer field i of N"; an empty dynamic array shows no element and still signs.', + ['Dynamic element 1 of 2', 'Dynamic element 2 of 2', + 'Fixed element 1 of 2', 'Fixed element 2 of 2']), + ('EX17', 'test_msg_ethereum_erc7730_runtime', + 'test_grouped_tuple_iteration_and_optional_fields', + 'Grouped tuple arrays and optional fields', + 'Groups only group; an "optional" field is always shown. Nothing given to the device is hidden.', + ['Tuple element fields', 'Optional field shown']), + ('EX18', 'test_msg_ethereum_erc7730_runtime', + 'test_embedded_call_is_shown_under_a_blind_sign_warning', + '7.15: embedded calls without a definition are blind', + 'A "Blind signature" screen, then the inner call\'s callee, selector, length, value and authority, all read from the signed calldata. 7.16 rejects instead.', + ['Blind signature warning', 'Inner call summary']), + ('EX19', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_call_is_clear_signed_with_its_own_definition', + 'Inner calls are clear-signed with their own definition', + 'The device requests the inner definition by chain, callee and selector read from the signed calldata, binds it before any inner screen, runs it under "Inner" titles, then resumes the outer definition.', + ['Call summary', 'Inner action', 'Inner fields', 'Outer field after the inner call']), + ('EX20', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_call_without_a_definition_is_blind_in_715', + 'No inner definition means a blind-sign warning', + 'When the host has no definition for the inner call, the 7.15 blind path follows; it is never a silent blind sign.', + ['Blind signature warning']), + ('EX21', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_definition_for_another_call_is_refused', + 'An inner definition for another selector is refused', + 'A hostile host substitutes the inner definition; the binding check refuses it before any inner screen.', + []), + ('EX22', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_bytes_changed_in_an_inner_pass_are_refused', + 'Changed inner bytes are refused', + 'Every inner pass replays the whole outer calldata against the reviewed digest; a change on a later pass is refused before any inner field.', + []), + ('EX23', 'test_msg_ethereum_erc7730_runtime', + 'test_only_a_raw_field_shows_a_signer_constant', + 'Only a raw field shows a signer constant', + 'A signer constant formatted as an amount (or any formatter but raw) would look like a decoded value, so preload refuses it; as a raw field it is shown as the signer\'s own field.', + ['Constant as a raw field']), + ('EX24', 'test_msg_ethereum_erc7730_runtime', + 'test_control_characters_in_a_value_are_escaped', + 'Control characters are escaped', + 'A newline or tab inside a value is shown as \\x0a / \\x09, so no value can fake a line break.', + ['Escaped value']), + ('EX25', 'test_msg_ethereum_erc7730_runtime', + 'test_a_raw_value_too_long_to_capture_is_shown_blind', + 'Over-long raw values are shown blind with their length', + 'A value longer than the device can hold gets a "Blind signature" screen and "Not shown: N bytes".', + ['Blind signature warning', 'Length of the unshown value']), + ('EX26', 'test_msg_ethereum_erc7730_runtime', + 'test_multiline_values_split_between_lines', + 'Long values split between lines', + 'A value too long for one confirmation is split into numbered confirmations at line boundaries. Within one confirmation the board pager still wraps by pixel width, so a long line can continue on the next OLED page.', + ['Numbered part', 'Next part']), + ('EX27', 'test_msg_ethereum_erc7730_runtime', + 'test_an_inner_definition_the_device_refuses_falls_back_to_blind', + 'A refused inner definition falls back to blind', + 'An inner definition the device cannot execute, or signed by an unknown key (also one refused on a later chunk of a multi-chunk stream), is dropped and the call is shown blind; the outer review then continues.', + ['Blind signature warning', 'Outer review continues']), + ('EX28', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_definition_for_another_callee_or_chain_is_refused', + 'Inner definitions are bound to callee and chain', + 'Substituting a definition for another contract or another chain is refused before any inner screen.', + []), + ('EX29', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_calls_read_their_own_containers', + 'Inner calls read their own context', + 'Inside the inner call @.value is the value it moves, @.from whose authority it runs with and @.to the callee.', + ['Inner value, authority and callee']), + ('EX30', 'test_msg_ethereum_erc7730_runtime', + 'test_embedded_calls_inside_an_iteration_are_shown_blind', + 'Calls inside an array are shown blind', + 'Each element of a multicall gets its own blind-sign warning and summary (multicall clear-signing is future work).', + ['Blind warning per element']), + ('EX31', 'test_msg_ethereum_erc7730_runtime', + 'test_a_fixed_index_into_a_short_array_fails_closed', + 'A fixed index past a short array fails closed', + "Preload cannot know a dynamic array's length; reaching path.[0] of an empty array aborts without a signature.", + []), + ('EX32', 'test_msg_ethereum_erc7730_runtime', + 'test_an_inner_value_the_calldata_does_not_carry_is_never_shown', + 'An inner value the calldata does not carry is never shown', + 'Without amountPath the calldata does not say what the inner call moves; an inner definition that shows @.value is refused and the call shown blind.', + ['Blind signature warning']), + ('EX33', 'test_msg_ethereum_erc7730_runtime', + 'test_parallel_arrays_are_refused_before_preload', + 'Parallel formatter arrays are refused', + 'A token path in a separate array is refused before preload; no signature or review screen is produced.', + []), + ('EX34', 'test_msg_ethereum_erc7730_runtime', + 'test_a_call_at_depth_two_is_shown_blind', + 'Depth is bounded at one', + 'A call inside an inner call is shown blind; the device never requests a depth-2 definition.', + ['Blind warning inside the inner call']), + ('EX35', 'test_msg_ethereum_erc7730_runtime', + 'test_a_wanchain_transaction_is_never_certified', + 'A Wanchain transaction is never certified', + 'A transaction carrying tx_type (Wanchain, valued in WAN) is refused on the certified path before any screen; the review would otherwise name its value ETH.', + []), + ('EX36', 'test_msg_ethereum_erc7730_runtime', + 'test_declining_any_certified_screen_returns_no_signature', + 'Declining any certified screen returns no signature', + 'Declining a blind-sign warning (long value, no inner definition, refused inner definition), a later part of a split value, or an inner field aborts with no signature.', + []), + ('EX37', 'test_msg_ethereum_erc7730_runtime', + 'test_a_long_typed_data_value_points_to_the_walk_not_blind', + 'A long typed-data value is not a blind sign', + 'The typed-data walk shows every leaf in full; a raw field too long to capture says "Shown above in full: N bytes" with no blind-sign warning, and the signature equals the ordinary one.', + ['Shown above in full']), + ('EX38', 'test_msg_ethereum_erc7730_runtime', + 'test_a_refused_inner_call_does_not_blind_the_next_one', + 'A refused inner call does not blind the next one', + 'With two embedded calls, the first inner definition is refused and shown blind; the second is still clear-signed with its own definition.', + ['Blind first call', 'Second call clear-signed']), + ('EX39', 'test_msg_ethereum_erc7730_runtime', + 'test_groups_and_optional_fields_are_always_visible', + 'Nested optional fields remain visible', + 'Groups preserve every field; zero and nonzero optional amounts and fees both appear with the decoded recipient.', + ['Recipient', 'Amount', 'Fee']), + ('EX40', 'test_msg_ethereum_erc7730_runtime', + 'test_large_inner_call_is_bound_across_transport_chunks', + 'Large inner calls bind across transport chunks', + 'A 1,200-byte memo is streamed in 31- and 1,024-byte replies; the inner amount is shown, the ordinary signature matches, and a changed inner replay is refused.', + []), + ('EX41', 'test_msg_ethereum_erc7730_runtime', + 'test_outer_bytes_changed_during_inner_review_are_refused', + 'Inner review binds the entire outer transaction', + 'Changing only the outer operation word during inner validation is refused before any inner field.', + []), + ('EX42', 'test_msg_ethereum_erc7730_runtime', + 'test_restoring_outer_definition_rejects_another_valid_definition', + 'Restored parent definition keeps its identity', + 'A separately accepted signed definition for the same outer selector cannot replace the parent after inner review.', + ['Inner review before parent restore refusal']), + ('EX43', 'test_msg_ethereum_erc7730_runtime', + 'test_inner_definition_identity_cannot_change_between_chunks', + 'Inner definition identity is stable across chunks', + 'Changing the definition ID after the first 31-byte reply is refused before inner action review.', + []), + ('EX44', 'test_msg_ethereum_erc7730_runtime', + 'test_cancelled_inner_review_clears_preload_in_the_same_session', + 'Inner cancellation clears the preload', + 'Declining inner signer, action or field returns no signature; an immediate ordinary retry without Initialize has no certified screens or definition requests.', + []), + ]), + + # Two-character id because all 26 letters were taken. The catalog keys on a + # string, not a char, so this costs nothing. + ('TD', 'Structured EIP-712 - The Device Reads The Document', '7.15.0', + 'Until now every EIP-712 signature a KeepKey produced was BLIND. The host computed ' + 'domainSeparator and messageHash and the device signed two opaque 32-byte values -- it could ' + 'not see a spender, an amount or a chain. Permit2 approvals, the single most common instrument ' + 'in a drainer, took that path.\n' + 'Now the device walks the document itself. It asks for one struct definition, or one leaf ' + 'value, at a time. Small values are displayed and hashed from the same bytes. Values over ' + 'one packet are requested by member_path and byte offset. Long strings use a count/check ' + 'pass followed by a display pass; their Keccak digests must match before signing, so a ' + 'host cannot change the reviewed value between passes and still obtain a signature.\n' + 'The predecessor was withdrawn in 7.14.2 because its JSON parser could not guarantee the ' + 'displayed value was the value being hashed. Here that property is structural rather than ' + 'reviewed.', + ['THE HASHES COME FROM OUTSIDE THIS REPOSITORY. TD1 asserts the values published by', + 'assets/eip-712/Example.js in ethereum/EIPs -- the reference implementation the spec', + 'links to -- and independently republished by Example.sol, by eth-sig-util\'s V3 and V4', + 'snapshots, and by Mrtenz/eip-712.', + '', + 'That matters more than it looks. The firmware C, the hdwallet TypeScript and the python', + 'client were written by one hand against one reading of the spec. Three of them agreeing', + 'proves the reading is SELF-CONSISTENT and nothing more; a shared misreading would produce', + 'three consistent wrong answers. It cannot produce these two numbers.', + '', + 'HARDWARE, 2026-08-21, K1-14AM, unsigned build of the 7.15 line:', + ' 9 screens, one per leaf; all rendered correctly per operator review', + ' 42-character addresses displayed IN FULL -- the truncation class that shipped as a', + ' bug at >42 chars does not reproduce', + ' domainSeparator and messageHash matched the published values on silicon', + ' device address 0x73d0385F4d8E00C5e6504C6030F47BF6212736A8, same as the emulator', + '', + 'Device-driven typed-data review is available with AdvancedMode off. Blind typed-hash', + 'signing and runtime-provider annotation have separate policy gates.', + 'The dated hardware observation above is historical, not acceptance of this candidate.'], + [('TD1', 'test_msg_eip712_streaming', 'test_spec_example_matches_the_published_hashes', + 'The device\'s own hashes equal the EIP-712 reference implementation\'s', + 'The canonical Mail/Person document. Mail references Person TWICE, so the walk pushes a ' + 'child frame, derives Person\'s typeHash through its own dependency closure, folds it to 32 ' + 'bytes and hands it back to the parent -- the nested-struct machinery, exercised rather ' + 'than reasoned about. Forty round trips. domainSeparator ' + 'f2cee375...912090f and messageHash c52c0ee5...4b371e, both published, both matched on ' + 'hardware and in the emulator.', + ['Domain name', 'Domain version', 'chainId', 'verifyingContract (42 chars, in full)', + 'From name: Cow', 'From wallet', 'To name: Bob', 'To wallet', 'contents']), + ('TD2', 'test_msg_eip712_streaming', 'test_array_of_structs_walks', + 'An array of structs walks and signs', + 'Arrays hash WITHOUT a typeHash prefix -- enc(array) is the keccak of the concatenated ' + 'element encodings and nothing else -- so getting this wrong yields a digest no verifier ' + 'reproduces rather than an error anyone would notice. Arrays were refused entirely until a ' + 'kilobyte was reclaimed from MAX_DECODE_SIZE: at 13 KB the ARM image missed the linker\'s ' + '16,384 B runtime-reserve gate by 204 bytes, at 12 KB it clears it by 812.', + []), + ('TD3', 'test_msg_eip712_streaming', + 'test_fixed_array_length_must_match_the_declared_size', + 'A fixed dimension must match the document', + 'address[2] carrying three elements is refused. The dimension is part of the type string ' + 'and therefore part of typeHash, and the COUNT is the only thing the device is ever told -- ' + 'accept a different one and it signs a document whose type declares another, with nothing ' + 'downstream able to notice.', + []), + ('TD4', 'test_msg_eip712_streaming', 'test_advanced_mode_is_not_required_for_structured_review', + 'Structured review works with AdvancedMode off', + 'Disables AdvancedMode, walks the canonical Mail document, and requires a typed-data ' + 'signature with the independently published domain and message hashes. This exercises ' + 'device-driven review; it does not enable the separate blind typed-hash endpoint.', + [])]), + ] +# A section may span adjacent release lines even when individual native tests +# landed later. Filter those rows before validation so a 7.14.3 report cannot +# demand 7.15-only binaries, while 7.15 still requires the coverage. +_TEST_MIN_VERSION = { + ('Storage', 'PinKdfRewrapsToActiveVersionAfterCorrectPin'): '7.15.0', + ('Storage', 'PinUnlocksAfterRebootUnderV17'): '7.15.0', + ('Storage', 'PinKdfV2FlagIsVersionedInV19'): '7.15.0', + ('test_msg_solana_schema_v2', + 'test_certified_soltoshi_join_reviews_every_screen'): '7.16.0', + ('test_msg_solana_schema_v2', + 'test_certified_soltoshi_join_priority_fee_names_fee_payer'): '7.16.0', + ('test_msg_solana_schema_v2', + 'test_certified_soltoshi_join_untrusted_definition_shows_base_units'): '7.16.0', + ('test_msg_solana_schema_v2', + 'test_certified_soltoshi_join_ignores_blind_sign_policy'): '7.16.0', +} + +# Intermediate release-stack trees deliberately omit controls that land in a +# later adjacent slice. The firmware reports the behaviours it implements +# (Features.capabilities); a capability-gated test skips with +# CAPABILITY_SKIP_PREFIX + name when the device does not report it, and +# parse_junit collects those names. Only these exact catalog rows are exempt +# while their capability is absent; a complete release reports every +# capability and remains strict. +_TEST_CAPABILITY = { + ('Storage', 'PinKdfRewrapsToActiveVersionAfterCorrectPin'): + 'storage-v19-kdf', + ('Storage', 'PinUnlocksAfterRebootUnderV17'): 'storage-v19-kdf', + ('Storage', 'PinKdfV2FlagIsVersionedInV19'): 'storage-v19-kdf', + ('test_msg_solana_lut_attestation', + 'test_attested_accounts_are_shown_and_blind_sign_still_follows'): + 'solana-lut-attestation', + ('test_msg_solana_lut_attestation', + 'test_bad_signature_degrades_to_todays_flow'): + 'solana-lut-attestation', + ('test_msg_solana_lut_attestation', + 'test_attestation_does_not_replay_onto_another_transaction'): + 'solana-lut-attestation', + ('test_msg_solana_lut_attestation', + 'test_no_signer_loaded_means_no_extra_screens'): + 'solana-lut-attestation', +} + +# Modules whose every row is gated in setUp on one staged capability. +_MODULE_CAPABILITY = { + 'test_msg_ethereum_erc7730_runtime': 'erc7730-runtime-review', + 'test_msg_ethereum_certified_contacts': 'certified-contacts', +} + + +CAPABILITY_SKIP_PREFIX = 'Staged release tree does not yet provide capability: ' +# Capabilities the firmware under test did not report, from the skip reasons +# parse_junit read (never from the environment: the device decides). +JUNIT_MISSING_CAPABILITIES = set() + + +def _active_sections(fw_version): + active = [] + for letter, title, minimum, background, flow, tests in SECTIONS: + if not ver_ge(fw_version, minimum): + continue + filtered = [ + test for test in tests + if ver_ge(fw_version, + _TEST_MIN_VERSION.get((test[1], test[2]), minimum)) + ] + active.append((letter, title, minimum, background, flow, filtered)) + return active + # --------------------------------------------------------------- # Render # --------------------------------------------------------------- -def _test_frame_paths(screenshot_dir, mod, meth): - """Return every retained frame, including named A/B sub-sequences.""" - test_dir = os.path.join( - screenshot_dir, mod.replace('test_', '', 1), meth) - if not os.path.isdir(test_dir): - return [] - frames = [] - for directory, subdirs, files in os.walk(test_dir): - subdirs.sort() - for name in sorted(files): - if name.startswith('btn') and name.endswith('.png'): - frames.append(os.path.join(directory, name)) - return frames +def _audit_catalog(): + """Structural check on SECTIONS, run on every render. + + A catalog entry with a blank context renders as a bare test name, which is + exactly the row a human auditor cannot evaluate -- VG4 shipped that way and + nothing complained. Duplicate ids or letters silently overwrite each other + in cross-references. Cheap to assert, and the report is evidence. + """ + letters, ids = set(), set() + for letter, title, mf, bg, notes, tests in SECTIONS: + assert letter not in letters, 'duplicate section letter %s' % letter + letters.add(letter) + assert (bg or '').strip(), 'section %s has no background' % letter + for t in tests: + assert len(t) == 6, 'malformed entry in section %s: %r' % (letter, t) + tid, mod, meth, ttl, ctx, scr = t + assert tid not in ids, 'duplicate test id %s' % tid + ids.add(tid) + assert (ttl or '').strip(), '%s has no title' % tid + for text in (ctx.values() if isinstance(ctx, dict) else (ctx,)): + assert (text or '').strip(), '%s has no context -- it would render as a bare name' % tid -def render(output_path, fw_version, results, screenshot_dir=None, - cases=None, metadata=None): +def render(output_path, fw_version, results, screenshot_dir=None, variant='full'): + _audit_catalog() pdf = PDF(); pb = PB(pdf) + _build_frame_census(screenshot_dir) ts = datetime.now().strftime('%Y-%m-%d %H:%M') - cases = cases or [] - metadata = metadata or {} - active = [(l,t,mf,bg,fl,tests) for l,t,mf,bg,fl,tests in SECTIONS if ver_ge(fw_version, mf)] + build_label = os.environ.get('KK_BUILD_LABEL', '').strip() + active = _active_sections(fw_version) # Separate specs section (no tests) from test sections specs = [s for s in active if not s[5]] - # Sections with results first, pending sections at bottom. - # Within each group: existing chains first (proven), then new features. - has_results = [s for s in active if s[5] and any(_lookup(results, t[1], t[2]) for t in s[5])] - no_results = [s for s in active if s[5] and not any(_lookup(results, t[1], t[2]) for t in s[5])] - test_sections = has_results + no_results - catalog_total = sum(len(s[5]) for s in test_sections) - catalog_passed = sum(1 for s in test_sections for t in s[5] if _lookup(results, t[1], t[2]) == 'pass') - catalog_failed = sum(1 for s in test_sections for t in s[5] if _lookup(results, t[1], t[2]) in ('fail','error')) - total = len(cases) if cases else catalog_total - passed = sum(1 for case in cases if case['status'] == 'pass') if cases else catalog_passed - failed = sum(1 for case in cases if case['status'] in ('fail', 'error')) if cases else catalog_failed - skipped = total - passed - failed + + # Classify each section by its strongest per-test outcome so the report + # distinguishes "ran and passed/failed" from "skipped by design (build-flag + # or policy gated, e.g. KK_ZCASH_PRIVACY-off shielded Zcash)" from "no result + # at all". A design-skip is NOT missing firmware support. + def _section_state(s): + st = [_lookup(results, t[1], t[2]) for t in s[5]] + if any(x in ('pass', 'fail', 'error') for x in st): + return 'tested' + if any(x == 'skip' for x in st): + return 'withheld' # only skips -> intentionally gated on this build + return 'pending' # nothing ran -> feature not present + tested = [s for s in active if s[5] and _section_state(s) == 'tested'] + withheld = [s for s in active if s[5] and _section_state(s) == 'withheld'] + pending = [s for s in active if s[5] and _section_state(s) == 'pending'] + test_sections = tested + withheld + pending + # Count DISTINCT tests, not catalog rows. A few tests are deliberately + # catalogued twice because they carry two different arguments -- e.g. + # test_eip1559_requires_chain_id is the replayable-signature refusal in the + # 7.14.2 defect narrative (J9) AND a guard in the EVM catalog (VG2). Both + # entries earn their place, but summing rows made the header claim more + # tests than the run contains, and an auditor reconciling the header + # against the JUnit finds a shortfall that is pure double-counting. + distinct = {} + for s in test_sections: + for t in s[5]: + distinct[(t[1], t[2])] = _lookup(results, t[1], t[2]) + total = len(distinct) + passed = sum(1 for v in distinct.values() if v == 'pass') + failed = sum(1 for v in distinct.values() if v in ('fail', 'error')) + skipped = sum(1 for v in distinct.values() if v == 'skip') + missing = total - passed - failed - skipped # Title pb.text(20, 'KeepKey Firmware Test Report', bold=True) pb.gap(2) - if passed == total and total > 0: - pb.text(11, f'Firmware {fw_version} | {ts} | ALL {total} TESTS PASSED', bold=True, color=GREEN) - elif failed > 0: + pb.text(10, 'Product: ' + ('Bitcoin-only' if variant == 'bitcoin-only' else + 'Full firmware; Bitcoin-only variant controls are identified separately')) + if failed > 0: pb.text(11, f'Firmware {fw_version} | {ts} | {failed} FAILED of {total} tests', bold=True, color=RED) + elif missing == 0 and total > 0: + # Everything that exists ran green; remaining are deliberate design-skips. + extra = f', {skipped} skipped (withheld)' if skipped else '' + pb.text(11, f'Firmware {fw_version} | {ts} | {passed}/{total} PASSED{extra}', bold=True, color=GREEN) else: - status_label = 'skipped' if cases else 'pending' - pb.text(10, f'Firmware {fw_version} | {ts} | {total} tests: ' - f'{passed} passed, {skipped} {status_label}') - for label, key in ( - ('Firmware SHA', 'firmware_sha'), - ('Python SHA', 'python_sha'), - ('Workflow', 'run_url'), - ('Generator SHA-256', 'generator_sha256'), - ('ARM manifest SHA-256', 'arm_manifest_sha256')): - if metadata.get(key): - for line in _w('%s: %s' % (label, metadata[key]), 95): - pb.text(7, line) + parts = [f'{passed} passed'] + if skipped: parts.append(f'{skipped} skipped') + if missing: parts.append(f'{missing} pending') + pb.text(10, f'Firmware {fw_version} | {ts} | {total} tests: {", ".join(parts)}') + if build_label: + for line in _w(f'Candidate: {build_label}', 95): + pb.text(8, line, bold=True) + # Scope of this document. The catalog is a curated subset, and saying so is + # the difference between evidence and a misleading completeness claim: an RC + # audit grepped this PDF for feature keywords, found none, and reported four + # features as untested when their tests had run green in the same CI run. + ran = JUNIT_CENSUS['ran'] + if ran: + pb.gap(3) + for line in _w('Scope: this report is a curated catalog of %d tests. The CI run collected %d ' + '(%d of them native firmware unit tests); %d SKIPPED and did not execute, ' + 'because of version, product, capability, policy or harness gates. Exact ' + 'skip reasons are in JUnit; a skip is not evidence the feature works. ' + 'Absence from this report is NOT ' + 'evidence that a feature is untested -- check the JUnit artifacts.' + % (total, ran, JUNIT_CENSUS['native'], JUNIT_CENSUS['skipped']), 100): + pb.text(8, line, color=GRAY) pb.gap(6) - pb.text(12, 'Curated review sections', bold=True) - pb.text(7, '%d catalog entries; complete %d-case index follows.' % - (catalog_total, total), color=GRAY) - _shown_tested = _shown_pending = False + pb.text(12, 'Sections', bold=True) + _hdr_withheld = _hdr_pending = False for letter, title, mf, _, _, tests in test_sections: - has_any = any(_lookup(results, t[1], t[2]) for t in tests) + state = _section_state((letter, title, mf, None, None, tests)) is_new = ver_t(mf) > (7, 10, 0) - if has_any and not _shown_tested: - _shown_tested = True - elif not has_any and not _shown_pending: - pb.text(9, f' --- Pending (no firmware support yet) ---', bold=True, color=GRAY) - _shown_pending = True + if state == 'withheld' and not _hdr_withheld: + pb.text(9, ' --- Skipped on this candidate (see JUnit for the gating reason) ---', bold=True, color=GRAY) + _hdr_withheld = True + elif state == 'pending' and not _hdr_pending: + pb.text(9, ' --- Pending (no firmware support yet) ---', bold=True, color=GRAY) + _hdr_pending = True tag = ' [NEW]' if is_new else '' p = sum(1 for t in tests if _lookup(results, t[1], t[2]) == 'pass') if p == len(tests) and len(tests) > 0: @@ -1353,43 +3956,93 @@ def render(output_path, fw_version, results, screenshot_dir=None, pb.text(9, f'Tests: {len(tests)}', bold=True) pb.gap(2) for tid, mod, meth, title, ctx, scr in tests: + scr = _screens_for(fw_version, mod, meth, scr) pb.need(50) r = _lookup(results, mod, meth) pb.check(9, f'{tid} {meth}', r) pb.text(7, f'{title} ({mod}.py)') - for cline in _w(ctx, 95): pb.text(7, cline) - # Security evidence is never selected heuristically. Retain every - # frame of every named sequence in deterministic path order. + for cline in _w(_ctx_for(fw_version, ctx), 95): pb.text(7, cline) + # Embed OLED screenshots -- use _pick_best_frame for the primary image, + # then show up to 2 more frames for multi-screen flows (signing, swaps) if screenshot_dir: - frames = _test_frame_paths(screenshot_dir, mod, meth) - if frames: - for frame in frames: - pb.need(62) - pb.text(6, os.path.relpath(frame, screenshot_dir), - color=GRAY) - pb.image(frame, display_w=384, display_h=96) + test_dir = os.path.join(screenshot_dir, mod.replace('test_',''), meth) + btn_files = sorted(f for f in os.listdir(test_dir) if f.startswith('btn')) if os.path.isdir(test_dir) else [] + # Flagship who/what/why flows: show EVERY review screen in the + # order the user sees them, not a "best" thumbnail. This is the + # proof that the device decodes and displays the transaction. + if (mod, meth) in FULL_SEQUENCE_TESTS: + shown = 0 + for f in btn_files: + p = os.path.join(test_dir, f) + lr = _frame_lit_ratio(p) + if lr is None or lr < 0.02 or lr > 0.55: + continue + try: + pb.need(55) + pb.image(p, display_w=384, display_h=96) + shown += 1 + except Exception: + pass + if shown: + pb.text(6, f'({shown} OLED review screens, in order)', color=GRAY) + elif scr: + pb.text(7, f'OLED needed: {", ".join(scr)}', color=GRAY) + pb.gap(3) + continue + best = _pick_best_frame(test_dir, btn_files) + if best: + # Show the best frame (most representative) + try: + pb.need(55) + pb.image(best, display_w=384, display_h=96) + except Exception: + pass + # For multi-screen tests, show up to 2 more meaningful frames. + # setUp noise is already stripped at capture time; drop + # blanks, generic cross-test chrome, and the `best` frame. + extra = [] + for f in btn_files: + p = os.path.join(test_dir, f) + if p == best: + continue + r = _frame_lit_ratio(p) + if (r is not None and 0.02 <= r <= 0.55 and + _frame_hash(p) not in _GENERIC_FRAME_HASHES): + extra.append(f) + if not extra: + # Every other frame is cross-test-shared. Outcome frames + # that FOLLOW the best one (a blocked-gate screen after + # the send preamble) are still this test's story — show + # moderately-shared ones; frames in 8+ dirs are pure + # chrome (policy toggles), and anything before `best` + # is setup noise. + seen_best = False + for f in btn_files: + p = os.path.join(test_dir, f) + if p == best: + seen_best = True + continue + if not seen_best: + continue + r = _frame_lit_ratio(p) + if (r is not None and 0.02 <= r <= 0.55 and + _FRAME_DIR_COUNTS.get(_frame_hash(p), 1) < 8): + extra.append(f) + extra = extra[:2] + for frame in extra: + try: + pb.need(55) + pb.image(os.path.join(test_dir, frame), display_w=384, display_h=96) + except Exception: + pass + if len(extra) + 1 < len(btn_files): + pb.text(6, f'({len(btn_files)} OLED frames captured, showing {len(extra)+1})', color=GRAY) elif scr: pb.text(7, f'OLED needed: {", ".join(scr)}', color=GRAY) elif scr: pb.text(7, f'OLED needed: {", ".join(scr)}', color=GRAY) pb.gap(3) - if cases: - pb.gap(15) - pb.text(14, 'Complete Executed Test Index', bold=True) - pb.text(7, '%d total: %d passed, %d skipped, %d failed/error.' % - (total, passed, skipped, failed)) - pb.gap(3) - for case in sorted(cases, - key=lambda x: (x['classname'], x['name'])): - pb.need(12) - color = GREEN if case['status'] == 'pass' else ( - RED if case['status'] in ('fail', 'error') else GRAY) - text = '%s %s.%s' % ( - case['status'].upper(), case['classname'], case['name']) - for line in _w(text, 110): - pb.text(6, line, color=color) - # Appendix: Device Specifications (after all test results) if specs: pb.gap(15) @@ -1402,9 +4055,11 @@ def render(output_path, fw_version, results, screenshot_dir=None, pb.finish() pdf.write(output_path) - status_label = 'skipped' if cases else 'pending' + assert passed + failed + skipped + missing == total, ( + 'catalog counts do not reconcile: %d+%d+%d+%d != %d' + % (passed, failed, skipped, missing, total)) print(f'{output_path}: fw={fw_version}, {len(active)} sections, {total} tests ' - f'({passed} passed, {failed} failed, {skipped} {status_label})') + f'({passed} passed, {failed} failed, {skipped} skipped, {missing} pending)') def screenshot_filter(fw_version): """Return pytest -k expression for tests with non-empty screenshot expectations. @@ -1413,11 +4068,11 @@ def screenshot_filter(fw_version): The shell script calls this instead of maintaining a hardcoded filter. Adding screenshots to a test in SECTIONS automatically includes it in CI Phase 1. """ - active = [(l,t,mf,bg,fl,tests) for l,t,mf,bg,fl,tests in SECTIONS if ver_ge(fw_version, mf)] + active = _active_sections(fw_version) terms = [] for letter, title, mf, bg, fl, tests in active: for tid, mod, meth, ttl, ctx, scr in tests: - if scr: # non-empty screenshot list = needs OLED capture + if _screens_for(fw_version, mod, meth, scr): # Use (method and module) for unambiguous pytest -k matching terms.append(f'({meth} and {mod})') return ' or '.join(terms) @@ -1425,27 +4080,73 @@ def screenshot_filter(fw_version): def screenshot_test_list(fw_version): """Return exact module::method selectors consumed by conftest.py.""" - active = [x for x in SECTIONS if ver_ge(fw_version, x[2])] + active = _active_sections(fw_version) pairs = set() for _letter, _title, _mf, _bg, _fl, tests in active: for _tid, mod, meth, _ttl, _ctx, screens in tests: - if screens: + if _screens_for(fw_version, mod, meth, screens): pairs.add('%s::%s' % (mod, meth)) return '\n'.join(sorted(pairs)) +# Modules whose tests must actually RUN once the firmware is new enough to be +# catalogued for them -- a skip is a failure, not a waiver. +# +# The general rule below treats 'skip' as a design waiver, which is right for +# build-flag-gated features (bitcoin-only, zcash-privacy). It is wrong for a +# capability the build claims to have: every taproot test opens with +# requires_taproot(), so if that capability regressed, all six would skip and +# the report would still read green -- the report would be certifying coverage +# it never obtained. Listing a module here converts that silence into a failure. +# Mapped to the firmware version from which a skip becomes a failure. A +# version-blind set would fail every older-firmware run for a module that +# legitimately cannot exist yet. +MUST_RUN_MODULES = { + 'test_msg_authenticator_boundaries': '7.15.0', + # Taproot did not exist at 7.0.0. That floor only ever held because this + # table was applied to products that happen to carry taproot: 7.14.2 + # reports no supports_taproot and has no P2TR path in signing.c at all, so + # requiring its six taproot cases to run demanded coverage the product + # cannot have. The floor is the release taproot actually ships in, which + # keeps the requirement binding on 7.14.3 and 7.15, both of which carry it. + 'test_msg_signtx_taproot': '7.14.3', + 'test_msg_getaddress_taproot': '7.14.3', + # GH #516: all three Uniswap liquidity tests used to skip together on the + # emulator, leaving a daily-driver signing path completely unexercised. + 'test_msg_ethereum_erc20_uniswap_liquidity': '7.16.0', + # R-4.1. Gated on requires_message('LoadClearsignSigner'), so if provider + # loading regressed, all four would skip and the report would certify a + # feature it never exercised. + 'test_msg_solana_lut_attestation': '7.15.0', + # Block 7b: every ERC-7730 runtime row in section EX is part of 7.15. + 'test_msg_ethereum_erc7730_runtime': '7.15.0', + # Certified contacts: every row in section AB. + 'test_msg_ethereum_certified_contacts': '7.15.0', +} + +# A module can be mandatory for the regular product while being intentionally +# absent from KK_BITCOIN_ONLY. Keep this narrower than MUST_RUN_MODULES: Taproot +# remains mandatory in both products, and the expected build variant comes from +# CI rather than the firmware identity being tested. +FULL_FEATURE_ONLY_MUST_RUN_MODULES = { + 'test_msg_ethereum_erc20_uniswap_liquidity', + 'test_msg_solana_lut_attestation', + 'test_msg_ethereum_erc7730_runtime', + 'test_msg_ethereum_certified_contacts', +} + + def screenshot_audit(fw_version, screenshot_root, junit_path=None): - """Which SECTIONS tests DECLARED screens but captured none? + """Which SECTIONS tests captured fewer frames than they declared? The CI gate was `total PNG count > 0`, which a single captured suite satisfies. That cannot distinguish "captured everything" from "captured something": in the 7.14.2 round, 345 PNGs were produced while every suite the release actually changed captured zero, and the phase reported healthy. - Returns (ok, missing) where missing is a list of (module, method) that - declared a non-empty screenshot list, were not skipped, and produced no - PNG directory. Skipped tests are not missing -- a version-gated test - cannot draw. + Returns (ok, missing) where missing contains + (module, method, expected_count, captured_count). Skipped tests are not + missing -- a version-gated test cannot draw. """ import os as _os skipped = set() @@ -1460,41 +4161,82 @@ def screenshot_audit(fw_version, screenshot_root, junit_path=None): mod = next((p for p in cn.split('.') if p.startswith('test_')), '') skipped.add((mod, tc.get('name'))) - active = [x for x in SECTIONS if ver_ge(fw_version, x[2])] + active = _active_sections(fw_version) missing = [] for letter, title, mf, bg, fl, tests in active: for tid, mod, meth, ttl, ctx, scr in tests: - if not scr: + if not _screens_for(fw_version, mod, meth, scr): continue if (mod, meth) in skipped: continue - frames = _test_frame_paths(screenshot_root, mod, meth) - if not frames: - missing.append((mod, meth)) + d = _os.path.join(screenshot_root, mod.replace('test_', '', 1), meth) + pngs = ([f for f in _os.listdir(d) if f.endswith('.png')] + if _os.path.isdir(d) else []) + if len(pngs) < len(scr): + missing.append((mod, meth, len(scr), len(pngs))) return (len(missing) == 0, missing) -def validate_junit(fw_version, results): +# Tests of behaviour a release deliberately removes, by the first firmware +# version without it. They skip there (requires_firmware_below), which is not +# a failure even in a must-run module. +RETIRED_FROM = { + ('test_msg_ethereum_erc7730_runtime', m): '7.16.0' for m in ( + 'test_embedded_call_is_shown_under_a_blind_sign_warning', + 'test_inner_call_without_a_definition_is_blind_in_715', + 'test_a_raw_value_too_long_to_capture_is_shown_blind', + 'test_an_inner_definition_the_device_refuses_falls_back_to_blind', + 'test_embedded_calls_inside_an_iteration_are_shown_blind', + 'test_a_call_at_depth_two_is_shown_blind', + 'test_a_refused_inner_call_does_not_blind_the_next_one', + ) +} +# Unlimited approvals sign behind a warning from 7.16 (owner rule 2026-10-07). +RETIRED_FROM[('test_msg_ethereum_erc20_uniswap_liquidity', + 'test_sign_uni_approve_liquidity_ETH_refused')] = '7.16.0' + + +def validate_junit(fw_version, results, variant='full', + missing_capabilities=None): """Check SECTIONS tests against JUnit results. Returns (passed, failed_list). A test is considered failed if it appears in SECTIONS for this firmware version and the JUnit result is 'fail' or 'error' (not 'skip' or 'pass'). Tests with no JUnit entry are treated as missing (also a failure). - Tests that were skipped (gated by requires_message/requires_firmware) are OK. + Tests that were skipped (gated by requires_message/requires_firmware) are OK, + unless their module is in MUST_RUN_MODULES. """ - active = [(l,t,mf,bg,fl,tests) for l,t,mf,bg,fl,tests in SECTIONS if ver_ge(fw_version, mf)] + active = _active_sections(fw_version) + if missing_capabilities is None: + missing_capabilities = JUNIT_MISSING_CAPABILITIES failures = [] for letter, title, mf, bg, fl, tests in active: for tid, mod, meth, ttl, ctx, scr in tests: status = _lookup(results, mod, meth) if status in ('fail', 'error'): failures.append((tid, mod, meth, status)) + continue + capability = (_TEST_CAPABILITY.get((mod, meth)) or + _MODULE_CAPABILITY.get(mod)) + if capability in missing_capabilities: + continue + if (status == 'skip' and + ver_ge(fw_version, RETIRED_FROM.get((mod, meth), '99.0.0'))): + continue + must_run = not ( + variant == 'bitcoin-only' and + mod in FULL_FEATURE_ONLY_MUST_RUN_MODULES + ) + if (status == 'skip' and must_run and + ver_ge(fw_version, MUST_RUN_MODULES.get(mod, '99.0.0'))): + failures.append((tid, mod, meth, 'skipped-but-required')) elif not status: failures.append((tid, mod, meth, 'missing')) return (len(failures) == 0, failures) def main(): + global SECTIONS p = argparse.ArgumentParser(description='KeepKey Firmware Test Report') p.add_argument('--output', default='test-report.pdf') p.add_argument('--fw-version', default=None) @@ -1510,13 +4252,33 @@ def main(): help='Print exact module::method screenshot selectors, then exit') p.add_argument('--validate-junit', action='store_true', help='Validate JUnit results against SECTIONS, exit non-zero on failures') - p.add_argument('--firmware-sha', default=None) - p.add_argument('--python-sha', default=None) - p.add_argument('--run-url', default=None) - p.add_argument('--generator-sha256', default=None) - p.add_argument('--arm-manifest-sha256', default=None) + # --build-variant is the spelling firmware CI (python-keepkey-tests.sh) + # passes; keep it as an alias so either repo can move first. + p.add_argument('--variant', '--build-variant', dest='variant', + choices=('full', 'bitcoin-only'), + default=os.environ.get('KK_FIRMWARE_VARIANT', 'full'), + help='Product variant whose required report coverage is validated') + p.add_argument('--section', action='append', default=[], + help='Render only the named section id (repeatable)') + p.add_argument('--firmware-sha', default=None, + help='Exact firmware commit represented by this report') + p.add_argument('--python-sha', default=None, + help='Exact python-keepkey commit represented by this report') + p.add_argument('--run-url', default=None, + help='Exact CI run that produced the evidence') + p.add_argument('--generator-sha256', default=None, + help='Combined wrapper/renderer digest') + p.add_argument('--arm-manifest-sha256', default=None, + help='Digest binding the complete ARM manifest set') args = p.parse_args() + if args.section: + wanted = set(args.section) + SECTIONS = [section for section in SECTIONS if section[0] in wanted] + missing = wanted.difference(section[0] for section in SECTIONS) + if missing: + p.error('unknown section(s): %s' % ', '.join(sorted(missing))) + fw = args.fw_version if not fw: print('Detecting firmware from emulator...', file=sys.stderr) @@ -1529,9 +4291,10 @@ def main(): if ok: print('screenshot audit: every declared screen was captured') sys.exit(0) - print('screenshot audit FAILED -- declared screens with no capture:') - for mod, meth in missing: - print(' %s::%s' % (mod, meth)) + print('screenshot audit FAILED -- fewer captures than declared screens:') + for mod, meth, expected, captured in missing: + print(' %s::%s (declared %d, captured %d)' % + (mod, meth, expected, captured)) sys.exit(1) if args.screenshot_filter: print(screenshot_filter(fw)) @@ -1544,8 +4307,8 @@ def main(): if not args.junit: print('ERROR: --validate-junit requires --junit=', file=sys.stderr) sys.exit(2) - results = parse_junit(args.junit) - ok, failures = validate_junit(fw, results) + results = parse_report_junit(args.junit) + ok, failures = validate_junit(fw, results, args.variant) if ok: print(f'SECTIONS validation passed: all tests for fw {fw} are pass or skip') sys.exit(0) @@ -1555,16 +4318,25 @@ def main(): print(f' {tid} {mod}::{meth} -> {status}') sys.exit(1) - results = parse_junit(args.junit) if args.junit else {} - metadata = { - 'firmware_sha': args.firmware_sha, - 'python_sha': args.python_sha, - 'run_url': args.run_url, - 'generator_sha256': args.generator_sha256, - 'arm_manifest_sha256': args.arm_manifest_sha256, - } - render(args.output, fw, results, args.screenshots, - junit_cases(args.junit), metadata) + provenance = [ + ('firmware', args.firmware_sha), + ('python', args.python_sha), + ('run', args.run_url), + ('generator', args.generator_sha256), + ('arm-manifests', args.arm_manifest_sha256), + ] + supplied = ['%s=%s' % item for item in provenance if item[1]] + if supplied: + os.environ['KK_BUILD_LABEL'] = ' | '.join(supplied) + + results = parse_report_junit(args.junit) if args.junit else {} + # A staged control has no row of its own in a tree whose firmware reports + # its capability missing; show it withheld rather than pending. Rendering + # only: validate_junit applies the same exemption itself. + for (mod, meth), cap in _TEST_CAPABILITY.items(): + if cap in JUNIT_MISSING_CAPABILITIES: + results.setdefault('%s::%s' % (mod, meth), 'skip') + render(args.output, fw, results, args.screenshots, variant=args.variant) if __name__ == '__main__': main() diff --git a/setup.py b/setup.py index c49f73e8..b665be69 100755 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ test_suite='tests/**/test_*.py', install_requires=[ 'ecdsa>=0.9', - 'protobuf>=3.0.0', + 'protobuf>=3.17,<4', 'mnemonic>=0.8', 'hidapi>=0.7.99.post15', 'libusb1>=1.6' diff --git a/tests/common.py b/tests/common.py index 0632f130..de156f25 100644 --- a/tests/common.py +++ b/tests/common.py @@ -27,7 +27,12 @@ import os import semver +import oled_text + +from google.protobuf.message import EncodeError from keepkeylib.client import KeepKeyClient, KeepKeyDebuglinkClient, KeepKeyDebuglinkClientVerbose +from keepkeylib.transport_udp import EmulatorNotResponding +from keepkeylib import messages_pb2 from keepkeylib import tx_api TX_FIXTURE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), @@ -56,6 +61,47 @@ def reset_screenshot_capture(client): os.unlink(os.path.join(screenshot_dir, name)) client.screenshot_id = 0 +# Firmware before this release does not report Features.capabilities; there, +# version gates alone decide. +CAPABILITIES_SINCE = "7.15.0" + + +def capability_names(features): + """Features.capabilities as names such as 'permit2-review', or None for + firmware that predates the list.""" + version = semver.VersionInfo.parse("%s.%s.%s" % ( + features.major_version, features.minor_version, features.patch_version)) + if version < semver.VersionInfo.parse(CAPABILITIES_SINCE): + return None + enum = messages_pb2.Features.Capability + return set(enum.Name(value)[len("CAPABILITY_"):].lower().replace("_", "-") + for value in features.capabilities) + + +_DEVICE_CAPABILITIES = [] + + +def device_capabilities(): + """For host-side tests: the connected firmware's capabilities, read once, + or None when the firmware predates the list or no device is attached.""" + if not _DEVICE_CAPABILITIES: + names = None + try: + client = KeepKeyClient(config.TRANSPORT( + *config.TRANSPORT_ARGS, **config.TRANSPORT_KWARGS)) + try: + client.init_device() + names = capability_names(client.features) + finally: + client.close() + except EmulatorNotResponding: + raise # a crashed emulator is an error, not "no device" + except Exception: + names = None # no device: nothing to gate on + _DEVICE_CAPABILITIES.append(names) + return _DEVICE_CAPABILITIES[0] + + class KeepKeyTest(unittest.TestCase): def setUp(self): transport = config.TRANSPORT(*config.TRANSPORT_ARGS, **config.TRANSPORT_KWARGS) @@ -104,6 +150,13 @@ def setUp(self): print("Setup finished") print("--------------") + def _drop_setup_screenshots(self): + # Discard wipe/load "setUp noise" frames so they can't be picked as a + # test's representative OLED image. No-op without a debuglink client. + fn = getattr(self.client, 'reset_screenshots', None) + if fn: + fn() + def setup_mnemonic_allallall(self): self.client.load_device_by_mnemonic(mnemonic=self.mnemonic_all, pin='', passphrase_protection=False, label='test', language='english') reset_screenshot_capture(self.client) @@ -131,6 +184,46 @@ def setup_mnemonic_pin_passphrase(self): def tearDown(self): self.client.close() + def assert_shows_address(self, call, expected): + """Run `call`, a show_display request, and assert one of the screens + it confirms shows ALL of `expected` -- a known vector or the + show_display=False response, never the value under test. Returns + what `call` returns. Only where neither exists may `expected` be a + function of that result. + + Before 7.14.3 DebugLinkState was built in the shared response arena + (fixed in 7.14.3 and 7.15), so reading the screen during the confirm + wiped a response already built. There `call` runs unwatched and + nothing is asserted.""" + # Cached Features, not firmware_at_least(): its Initialize would break + # a caller's set_expected_responses(). + features = self.client.features + if (features.major_version, features.minor_version, + features.patch_version) < (7, 14, 3): + return call() + screens = [] + press = self.client.callback_ButtonRequest + nested = 'callback_ButtonRequest' in vars(self.client) + + def capture(msg): + screens.append(self.client._read_oled_after_settle()) + return press(msg) + + self.client.callback_ButtonRequest = capture + try: + result = call() + finally: + if nested: # an enclosing check is capturing too + self.client.callback_ButtonRequest = press + else: + del self.client.callback_ButtonRequest + if callable(expected): + expected = expected(result) + self.assertTrue( + any(oled_text.shows_address(s, expected) for s in screens), + "none of %d screens shows all of %r" % (len(screens), expected)) + return result + def assertEqual(self, lhs, rhs): if type(lhs) == type(b'') and type(rhs) == type(''): super(KeepKeyTest, self).assertEqual(lhs, rhs.encode('utf-8')) @@ -155,6 +248,121 @@ def requires_firmware(self, ver_required): if version < semver.VersionInfo.parse(ver_required): self.skipTest("Firmware version " + ver_required + " or higher is required to run this test") + def requires_firmware_below(self, ver_limit): + """Skip on firmware at or above ver_limit: for behaviour a later + release deliberately removes, so the test pins the release it is + about rather than going red when the behaviour is retired.""" + if self.firmware_version() >= semver.VersionInfo.parse(ver_limit): + self.skipTest("Behaviour retired in firmware " + ver_limit) + + def firmware_capabilities(self): + """The capabilities the connected firmware reports, as names such as + 'permit2-review', or None for firmware that predates the list.""" + self.client.init_device() + return capability_names(self.client.features) + + def requires_release_capability(self, capability): + """Skip unless the firmware reports `capability`. + + Two levels, so every firmware passes: version first (firmware before + CAPABILITIES_SINCE reports no list, and the test's version gates + decide), then the firmware's own Features.capabilities, which tells + apart builds of the same version such as staged release blocks. + """ + enum = messages_pb2.Features.Capability + # A misspelt name must fail loudly, never skip quietly. + enum.Value("CAPABILITY_" + capability.upper().replace("-", "_")) + reported = self.firmware_capabilities() + if reported is not None and capability not in reported: + self.skipTest( + "Staged release tree does not yet provide capability: " + + capability) + + def requires_capability_absent(self, capability): + """Skip when the firmware reports `capability`: for a refusal that + the capability's behaviour replaces within the same version.""" + enum = messages_pb2.Features.Capability + enum.Value("CAPABILITY_" + capability.upper().replace("-", "_")) + reported = self.firmware_capabilities() + if reported is not None and capability in reported: + self.skipTest("Firmware provides capability: " + capability) + + def requires_taproot(self): + """Skip unless the firmware reports taproot support. + + Gates on the device flag rather than a version, so a build without + taproot skips instead of failing. + """ + self.client.init_device() + if not getattr(self.client.features, 'supports_taproot', False): + self.skipTest("Firmware does not report supports_taproot") + + def requires_dice_modes(self): + """Skip unless the firmware reports the verifiable dice modes. + + A capability, not a version. Firmware without the unit skips the + unknown ResetDevice.dice_only field and runs the older ceremony, so a + version gate would fail these tests red on such a build -- and a host + must refuse to offer the modes on exactly this same signal, because + that older firmware would derive a different wallet without complaint. + """ + self.client.init_device() + if not getattr(self.client.features, 'supports_dice_modes', False): + self.skipTest("Firmware does not report supports_dice_modes") + + def requires_solana_lut_attestation(self): + """Skip unless firmware authenticates and presents resolved LUT data. + + The Solana wire fields are shared across releases, so their presence in + generated Python bindings does not prove the connected firmware trusts + or displays them. Gate the provider-attestation tests on the device's + explicit capability instead of treating every 7.15 build as identical. + """ + self.requires_release_capability('solana-lut-attestation') + self.client.init_device() + if not getattr(self.client.features, + 'supports_solana_lut_attestation', False): + self.skipTest( + "Firmware does not report supports_solana_lut_attestation") + + def requires_structured_eip712(self): + """Skip unless the FIRMWARE drives the structured EIP-712 walk. + + requires_message() cannot answer this. It asks whether + python-keepkey's own bindings define a message, which is a property of + the pinned submodule and not of the firmware under test -- so it passes + on every branch regardless, and a branch without eip712_stream.c fails + these tests as though the feature were broken rather than absent. + + Probes the device instead: firmware that does not implement the walk + answers the opening message with Failure_UnexpectedMessage. A firmware + that DOES implement it answers with a struct request, and we cancel. + Anything else is left to fail the test, because "the feature is present + but misbehaving" must never be mistaken for "the feature is absent". + """ + from keepkeylib import messages_ethereum_pb2 as _eth + from keepkeylib import messages_pb2 as _proto + from keepkeylib import types_pb2 as _types + + probe = _eth.EthereumSignTypedData() + for n in (0x8000002C, 0x8000003C, 0x80000000, 0, 0): + probe.address_n.append(n) + probe.primary_type = "EIP712Domain" + probe.metamask_v4_compat = True + + resp = self.client.call_raw(probe) + if isinstance(resp, _proto.Failure): + self.client.init_device() + if resp.code == _types.Failure_UnexpectedMessage: + self.skipTest( + "Firmware does not implement structured EIP-712 " + "(EthereumSignTypedData is not handled)") + # Any other Failure is a real problem; let the test run and report it. + return + # Feature is present -- put the device back before the test starts. + self.client.call_raw(_proto.Cancel()) + self.client.init_device() + def requires_message(self, msg_name): """Skip if firmware does not handle this message type. Use alongside requires_firmware for per-feature gating: @@ -187,12 +395,25 @@ def requires_message(self, msg_name): # Send a minimal probe -- if firmware returns Failure_UnexpectedMessage, skip. from keepkeylib import messages_pb2 as base_proto msg = getattr(proto, msg_name)() + try: + # An empty probe cannot be serialized for messages with `required` + # fields (e.g. GetBip85Mnemonic word_count/index). That is a + # client-side limitation, NOT a firmware-support signal: the proto + # class exists and requires_firmware already gates the version, so + # let the real test exercise it rather than skipping. + msg.SerializeToString() + except EncodeError: + return try: resp = self.client.call_raw(msg) if hasattr(resp, 'code') and resp.code == 1: # Failure_UnexpectedMessage self.skipTest("%s not supported by this firmware build" % msg_name) # Re-init device state after probe (some messages may have changed state) self.client.call_raw(base_proto.Initialize()) + except EmulatorNotResponding: + raise # a dead emulator is an error, never a skip + except unittest.SkipTest: + raise except Exception: self.skipTest("%s not supported by this firmware build" % msg_name) @@ -201,4 +422,15 @@ def requires_fullFeature(self): self.client.features.firmware_variant == "EmulatorBTC": self.skipTest("Full feature firmware required to run this test") - + def requires_bitcoinOnly(self): + """Inverse of requires_fullFeature(): skip unless this IS the + bitcoin-only product. + + Usable since the firmware learned to report the variant honestly -- + variant_getName() used to answer "Emulator" for both products, so a + bitcoin-only emulator was indistinguishable from a full one and this + guard could not be written. + """ + if self.client.features.firmware_variant not in ("KeepKeyBTC", + "EmulatorBTC"): + self.skipTest("Bitcoin-only firmware required to run this test") diff --git a/tests/config.py b/tests/config.py index cca59765..8de09c0e 100644 --- a/tests/config.py +++ b/tests/config.py @@ -44,7 +44,12 @@ (_explicit_transport, sorted(_KNOWN_TRANSPORTS)) ) -if _explicit_transport == "dylib": +if os.getenv("KK_FORCE_UDP") == "1": + # Local-only escape hatch: skip HID/WebUSB autodetect so tests hit the + # UDP emulator even with a real KeepKey plugged in. NOT for CI. + hid_devices = [] + webusb_devices = [] +elif _explicit_transport == "dylib": # Skip HID/WebUSB autodetect — dylib is opt-in by env var. Without # this skip, a connected real KeepKey would win over the explicit # request and the dylib regression suite would route to hardware. diff --git a/tests/confirmation_pages.py b/tests/confirmation_pages.py new file mode 100644 index 00000000..97e1870c --- /dev/null +++ b/tests/confirmation_pages.py @@ -0,0 +1,55 @@ +"""Reconstruct confirmations from the exact DebugLink pages shown on screen.""" + +import re + +from keepkeylib import types_pb2 as types + + +_PAGE = re.compile(r'^(.*) ([1-9][0-9]*)/([1-9][0-9]*)$') + + +class ConfirmationPages: + def __init__(self): + self.raw = [] + self.records = [] + self.texts = [] + self.pending = None + + def add(self, code, text): + title, body = text + self.raw.append((code, title, body)) + match = _PAGE.fullmatch(title) + if not match: + assert self.pending is None, 'confirmation ended before its last page' + self.records.append((code, title, body)) + self.texts.append(text) + return title + + title, index, total = match.groups() + index, total = int(index), int(total) + assert 1 <= index <= total <= 99 and total > 1, 'invalid page count' + if index == 1: + assert self.pending is None, 'confirmation restarted before its last page' + self.records.append((code, title, body)) + self.texts.append((title, body)) + else: + assert self.pending == (title, index, total), 'missing or reordered page' + assert code == types.ButtonRequest_Other, 'continuation has wrong request code' + first_code, _, previous = self.records[-1] + self.records[-1] = (first_code, title, previous + body) + self.texts[-1] = (title, previous + body) + self.pending = (title, index + 1, total) if index < total else None + return title + + def finish(self): + assert self.pending is None, 'confirmation ended before its last page' + return self.records + + def __iter__(self): + return iter(self.finish()) + + def __getitem__(self, index): + return self.finish()[index] + + def __len__(self): + return len(self.finish()) diff --git a/tests/conftest.py b/tests/conftest.py index 0137e090..05aa1d04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,7 @@ import shutil import socket import sys +from urllib.parse import urlparse import requests @@ -151,11 +152,15 @@ def _configured_emulator_endpoints(getaddrinfo): @pytest.fixture(autouse=True) def deny_external_network(monkeypatch, request): """Allow only local sockets and the harness's exact UDP emulator endpoints.""" + from emulator_endpoints import is_owned_endpoint + nodeid = request.node.nodeid original_getaddrinfo = socket.getaddrinfo original_connect = socket.socket.connect original_connect_ex = socket.socket.connect_ex original_sendto = socket.socket.sendto + original_send = socket.socket.send + original_sendall = socket.socket.sendall emulator_names, emulator_addresses = _configured_emulator_endpoints( original_getaddrinfo) @@ -166,7 +171,7 @@ def denied(destination): def guarded_getaddrinfo(host, *args, **kwargs): port = args[0] if args else kwargs.get('port') - if (host, port) not in emulator_names: + if (host, port) not in emulator_names and not is_owned_endpoint((host, port)): denied((host, port)) return original_getaddrinfo(host, *args, **kwargs) @@ -175,13 +180,14 @@ def allowed_socket_address(sock, address): return False # No Unix sockets, TCP loopback, or arbitrary localhost ports. The # authoritative suite may talk only to the two exact UDP transports - # configured for this emulator run. + # configured for this emulator run, plus explicit test-owned leases. if sock.family not in (socket.AF_INET, socket.AF_INET6): return False if (sock.type & 0x0f) != socket.SOCK_DGRAM: return False endpoint = (address[0], address[1]) - return endpoint in emulator_names or endpoint in emulator_addresses + return (endpoint in emulator_names or endpoint in emulator_addresses + or is_owned_endpoint(endpoint)) def guarded_connect(sock, address): if not allowed_socket_address(sock, address): @@ -199,6 +205,19 @@ def guarded_sendto(sock, data, *args): denied(address) return original_sendto(sock, data, *args) + def check_connected_peer(sock): + address = sock.getpeername() + if not allowed_socket_address(sock, address): + denied(address) + + def guarded_send(sock, data, *args): + check_connected_peer(sock) + return original_send(sock, data, *args) + + def guarded_sendall(sock, data, *args): + check_connected_peer(sock) + return original_sendall(sock, data, *args) + def guarded_request(session, method, url, *args, **kwargs): raise AssertionError( 'authoritative test attempted HTTP access: test=%s method=%s ' @@ -208,6 +227,8 @@ def guarded_request(session, method, url, *args, **kwargs): monkeypatch.setattr(socket.socket, 'connect', guarded_connect) monkeypatch.setattr(socket.socket, 'connect_ex', guarded_connect_ex) monkeypatch.setattr(socket.socket, 'sendto', guarded_sendto) + monkeypatch.setattr(socket.socket, 'send', guarded_send) + monkeypatch.setattr(socket.socket, 'sendall', guarded_sendall) monkeypatch.setattr(requests.sessions.Session, 'request', guarded_request) diff --git a/tests/eip712_corpus.json b/tests/eip712_corpus.json new file mode 100644 index 00000000..6e498a6c --- /dev/null +++ b/tests/eip712_corpus.json @@ -0,0 +1,6968 @@ +[ + { + "id": "permit2-PermitSingle", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitSingle": [ + { + "name": "details", + "type": "PermitDetails" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "sigDeadline", + "type": "uint256" + } + ], + "PermitDetails": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint160" + }, + { + "name": "expiration", + "type": "uint48" + }, + { + "name": "nonce", + "type": "uint48" + } + ] + }, + "primaryType": "PermitSingle", + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "details": { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "1000000", + "expiration": "1893456000", + "nonce": "0" + }, + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "sigDeadline": "1775803746" + } + }, + "expected": { + "domain_separator": "866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28", + "message_hash": "434404194ecf97fbcbf7e55f4bdd2ff1f78f0689ea793bda4b85c7e73060a815", + "digest": "8b4985b6263546864eb8921379708c29bf922304edf4fc0abe5e077f853a60b2" + } + }, + { + "id": "permit2-PermitSingle-unlimited", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol", + "https://github.com/Uniswap/sdks/blob/main/sdks/permit2-sdk/src/constants.ts" + ], + "requires_capability": "erc20-unlimited-permit-review", + "unlimited_screen": "details.amount\nuint160: UNLIMITED", + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitSingle": [ + { + "name": "details", + "type": "PermitDetails" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "sigDeadline", + "type": "uint256" + } + ], + "PermitDetails": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint160" + }, + { + "name": "expiration", + "type": "uint48" + }, + { + "name": "nonce", + "type": "uint48" + } + ] + }, + "primaryType": "PermitSingle", + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "details": { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "1461501637330902918203684832716283019655932542975", + "expiration": "1778393946", + "nonce": "0" + }, + "spender": "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af", + "sigDeadline": "1775803746" + } + }, + "expected": { + "domain_separator": "866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28", + "message_hash": "8cadbd22e396ba77b30a62c8219759ec221ed18cde6fe1c31204fb3d53c1ecf8", + "digest": "eb4da5f3381371818ca113f3f4697da6c5ee1bbb098effde0f8f399b208f7bd7" + } + }, + { + "id": "permit2-PermitBatch", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitBatch": [ + { + "name": "details", + "type": "PermitDetails[]" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "sigDeadline", + "type": "uint256" + } + ], + "PermitDetails": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint160" + }, + { + "name": "expiration", + "type": "uint48" + }, + { + "name": "nonce", + "type": "uint48" + } + ] + }, + "primaryType": "PermitBatch", + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "details": [ + { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "1000000", + "expiration": "1893456000", + "nonce": "0" + }, + { + "token": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "amount": "2000000", + "expiration": "1893456000", + "nonce": "1" + }, + { + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "amount": "3000000", + "expiration": "1893456000", + "nonce": "2" + }, + { + "token": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "amount": "4000000", + "expiration": "1893456000", + "nonce": "3" + }, + { + "token": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "amount": "5000000", + "expiration": "1893456000", + "nonce": "4" + } + ], + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "sigDeadline": "1775803746" + } + }, + "expected": { + "domain_separator": "866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28", + "message_hash": "988853f08bc1888f09fc0fac97c129a5f79f8700da45e7135511753138073bb2", + "digest": "21b393815af1f64e893e0681d486fd4baf58532c1b079aa5d52a3d212fba7d31" + } + }, + { + "id": "permit2-PermitBatch-20", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitBatch": [ + { + "name": "details", + "type": "PermitDetails[]" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "sigDeadline", + "type": "uint256" + } + ], + "PermitDetails": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint160" + }, + { + "name": "expiration", + "type": "uint48" + }, + { + "name": "nonce", + "type": "uint48" + } + ] + }, + "primaryType": "PermitBatch", + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "details": [ + { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "1000000", + "expiration": "1893456000", + "nonce": "0" + }, + { + "token": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "amount": "2000000", + "expiration": "1893456000", + "nonce": "1" + }, + { + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "amount": "3000000", + "expiration": "1893456000", + "nonce": "2" + }, + { + "token": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "amount": "4000000", + "expiration": "1893456000", + "nonce": "3" + }, + { + "token": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "amount": "5000000", + "expiration": "1893456000", + "nonce": "4" + }, + { + "token": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "amount": "6000000", + "expiration": "1893456000", + "nonce": "5" + }, + { + "token": "0x514910771AF9Ca656af840dff83E8264EcF986CA", + "amount": "7000000", + "expiration": "1893456000", + "nonce": "6" + }, + { + "token": "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", + "amount": "8000000", + "expiration": "1893456000", + "nonce": "7" + }, + { + "token": "0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2", + "amount": "9000000", + "expiration": "1893456000", + "nonce": "8" + }, + { + "token": "0xc00e94Cb662C3520282E6f5717214004A7f26888", + "amount": "10000000", + "expiration": "1893456000", + "nonce": "9" + }, + { + "token": "0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F", + "amount": "11000000", + "expiration": "1893456000", + "nonce": "10" + }, + { + "token": "0xD533a949740bb3306d119CC777fa900bA034cd52", + "amount": "12000000", + "expiration": "1893456000", + "nonce": "11" + }, + { + "token": "0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32", + "amount": "13000000", + "expiration": "1893456000", + "nonce": "12" + }, + { + "token": "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0", + "amount": "14000000", + "expiration": "1893456000", + "nonce": "13" + }, + { + "token": "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE", + "amount": "15000000", + "expiration": "1893456000", + "nonce": "14" + }, + { + "token": "0x4d224452801ACEd8B2F0aebE155379bb5D594381", + "amount": "16000000", + "expiration": "1893456000", + "nonce": "15" + }, + { + "token": "0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72", + "amount": "17000000", + "expiration": "1893456000", + "nonce": "16" + }, + { + "token": "0x111111111117dC0aa78b770fA6A738034120C302", + "amount": "18000000", + "expiration": "1893456000", + "nonce": "17" + }, + { + "token": "0x6B3595068778DD592e39A122f4f5a5cF09C90fE2", + "amount": "19000000", + "expiration": "1893456000", + "nonce": "18" + }, + { + "token": "0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e", + "amount": "20000000", + "expiration": "1893456000", + "nonce": "19" + } + ], + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "sigDeadline": "1775803746" + } + }, + "expected": { + "domain_separator": "866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28", + "message_hash": "9be08a04cc2b5ee94ecba4347a8469ee3d3192f50b2613d59887f090f195142f", + "digest": "384af6a2fc07a1e70fa27fec8120723fb7ac8c922c53cfe2d8aaed6e6a401e33" + } + }, + { + "id": "permit2-PermitBatch-32", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitBatch": [ + { + "name": "details", + "type": "PermitDetails[]" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "sigDeadline", + "type": "uint256" + } + ], + "PermitDetails": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint160" + }, + { + "name": "expiration", + "type": "uint48" + }, + { + "name": "nonce", + "type": "uint48" + } + ] + }, + "primaryType": "PermitBatch", + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "details": [ + { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "1000000", + "expiration": "1893456000", + "nonce": "0" + }, + { + "token": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "amount": "2000000", + "expiration": "1893456000", + "nonce": "1" + }, + { + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "amount": "3000000", + "expiration": "1893456000", + "nonce": "2" + }, + { + "token": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "amount": "4000000", + "expiration": "1893456000", + "nonce": "3" + }, + { + "token": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "amount": "5000000", + "expiration": "1893456000", + "nonce": "4" + }, + { + "token": "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "amount": "6000000", + "expiration": "1893456000", + "nonce": "5" + }, + { + "token": "0x514910771AF9Ca656af840dff83E8264EcF986CA", + "amount": "7000000", + "expiration": "1893456000", + "nonce": "6" + }, + { + "token": "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", + "amount": "8000000", + "expiration": "1893456000", + "nonce": "7" + }, + { + "token": "0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2", + "amount": "9000000", + "expiration": "1893456000", + "nonce": "8" + }, + { + "token": "0xc00e94Cb662C3520282E6f5717214004A7f26888", + "amount": "10000000", + "expiration": "1893456000", + "nonce": "9" + }, + { + "token": "0xC011a73ee8576Fb46F5E1c5751cA3B9Fe0af2a6F", + "amount": "11000000", + "expiration": "1893456000", + "nonce": "10" + }, + { + "token": "0xD533a949740bb3306d119CC777fa900bA034cd52", + "amount": "12000000", + "expiration": "1893456000", + "nonce": "11" + }, + { + "token": "0x5A98FcBEA516Cf06857215779Fd812CA3beF1B32", + "amount": "13000000", + "expiration": "1893456000", + "nonce": "12" + }, + { + "token": "0x7D1AfA7B718fb893dB30A3aBc0Cfc608AaCfeBB0", + "amount": "14000000", + "expiration": "1893456000", + "nonce": "13" + }, + { + "token": "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE", + "amount": "15000000", + "expiration": "1893456000", + "nonce": "14" + }, + { + "token": "0x4d224452801ACEd8B2F0aebE155379bb5D594381", + "amount": "16000000", + "expiration": "1893456000", + "nonce": "15" + }, + { + "token": "0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72", + "amount": "17000000", + "expiration": "1893456000", + "nonce": "16" + }, + { + "token": "0x111111111117dC0aa78b770fA6A738034120C302", + "amount": "18000000", + "expiration": "1893456000", + "nonce": "17" + }, + { + "token": "0x6B3595068778DD592e39A122f4f5a5cF09C90fE2", + "amount": "19000000", + "expiration": "1893456000", + "nonce": "18" + }, + { + "token": "0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e", + "amount": "20000000", + "expiration": "1893456000", + "nonce": "19" + }, + { + "token": "0xba100000625a3754423978a60c9317c58a424e3D", + "amount": "21000000", + "expiration": "1893456000", + "nonce": "20" + }, + { + "token": "0xc944E90C64B2c07662A292be6244BDf05Cda44a7", + "amount": "22000000", + "expiration": "1893456000", + "nonce": "21" + }, + { + "token": "0xD33526068D116cE69F19A9ee46F0bd304F21A51f", + "amount": "23000000", + "expiration": "1893456000", + "nonce": "22" + }, + { + "token": "0x853d955aCEf822Db058eb8505911ED77F175b99e", + "amount": "24000000", + "expiration": "1893456000", + "nonce": "23" + }, + { + "token": "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84", + "amount": "25000000", + "expiration": "1893456000", + "nonce": "24" + }, + { + "token": "0xae78736Cd615f374D3085123A210448E74Fc6393", + "amount": "26000000", + "expiration": "1893456000", + "nonce": "25" + }, + { + "token": "0xBe9895146f7AF43049ca1c1AE358B0541Ea49704", + "amount": "27000000", + "expiration": "1893456000", + "nonce": "26" + }, + { + "token": "0x6982508145454Ce325dDbE47a25d4ec3d2311933", + "amount": "28000000", + "expiration": "1893456000", + "nonce": "27" + }, + { + "token": "0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1", + "amount": "29000000", + "expiration": "1893456000", + "nonce": "28" + }, + { + "token": "0x5283D291DBCF85356A21bA090E6db59121208b44", + "amount": "30000000", + "expiration": "1893456000", + "nonce": "29" + }, + { + "token": "0x92D6C1e31e14520e676a687F0a93788B716BEff5", + "amount": "31000000", + "expiration": "1893456000", + "nonce": "30" + }, + { + "token": "0x3432B6A60D23Ca0dFCa7761B7ab56459D9C964D0", + "amount": "32000000", + "expiration": "1893456000", + "nonce": "31" + } + ], + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "sigDeadline": "1775803746" + } + }, + "expected": { + "domain_separator": "866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28", + "message_hash": "26ac74547827726b6c9b707bd325892b633aaef21046c2f12937a075a47fe852", + "digest": "a85ffbece1abfb51170f9d17e6e63ed14b6a5e8ee6b6b39054fe5b8153a9f76a" + } + }, + { + "id": "permit2-PermitTransferFrom", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ] + }, + "primaryType": "PermitTransferFrom", + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "permitted": { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "250000000" + }, + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "nonce": "1997", + "deadline": "1775803746" + } + }, + "expected": { + "domain_separator": "866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28", + "message_hash": "a72b11929a9c4bff37f00eb63b3615519e00a8aa297140855c44cfff4da27146", + "digest": "ece4ce2c0d6c3f27171599e51e0b42287645bb3029f07ef9671ddca458007d5e" + } + }, + { + "id": "permit2-PermitBatchTransferFrom", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitBatchTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions[]" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ] + }, + "primaryType": "PermitBatchTransferFrom", + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "permitted": [ + { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "1000000" + }, + { + "token": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "amount": "10000000" + }, + { + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "amount": "100000000" + }, + { + "token": "0xdAC17F958D2ee523a2206206994597C13D831ec7", + "amount": "1000000000" + }, + { + "token": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + "amount": "10000000000" + } + ], + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "nonce": "1998", + "deadline": "1775803746" + } + }, + "expected": { + "domain_separator": "866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28", + "message_hash": "b0347a078a3a77dee64d7c255ba56e39221a3d6c5b2375d2e89d09b28533b5ab", + "digest": "31227578aa0209aad26446c56711f4f4ad009a6befab3034ce466ad45e295090" + } + }, + { + "id": "uniswapx-ExclusiveDutchOrder", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/ExclusiveDutchOrderLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/OrderInfoLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/DutchOrderLib.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "ExclusiveDutchOrder" + } + ], + "DutchOutput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ], + "ExclusiveDutchOrder": [ + { + "name": "info", + "type": "OrderInfo" + }, + { + "name": "decayStartTime", + "type": "uint256" + }, + { + "name": "decayEndTime", + "type": "uint256" + }, + { + "name": "exclusiveFiller", + "type": "address" + }, + { + "name": "exclusivityOverrideBps", + "type": "uint256" + }, + { + "name": "inputToken", + "type": "address" + }, + { + "name": "inputStartAmount", + "type": "uint256" + }, + { + "name": "inputEndAmount", + "type": "uint256" + }, + { + "name": "outputs", + "type": "DutchOutput[]" + } + ], + "OrderInfo": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ] + }, + "primaryType": "PermitWitnessTransferFrom", + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "permitted": { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "3500000000" + }, + "spender": "0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4", + "nonce": "1993353164669688581970088190602701610056397261180510327052393484288", + "deadline": "1775803746", + "witness": { + "info": { + "reactor": "0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4", + "swapper": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "nonce": "1993353164669688581970088190602701610056397261180510327052393484288", + "deadline": "1775803746", + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x" + }, + "decayStartTime": "1775803680", + "decayEndTime": "1775803740", + "exclusiveFiller": "0xff8ba4d1fc3762f6154cc942ccf30049a2a0cec6", + "exclusivityOverrideBps": "100", + "inputToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "inputStartAmount": "3500000000", + "inputEndAmount": "3500000000", + "outputs": [ + { + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "startAmount": "1000000000000000000", + "endAmount": "990000000000000000", + "recipient": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + }, + { + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "startAmount": "1500000000000000", + "endAmount": "1485000000000000", + "recipient": "0x27213E28D7fDA5c57Fe9e5dD923818DBCcf71c47" + } + ] + } + } + }, + "expected": { + "domain_separator": "866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28", + "message_hash": "9d5278e60026d517ef5795ddfa37fdbb3c45bb9aa1428e1100420191c4d582bf", + "digest": "5def1fbddaf0631deb17c013e1778be8a134da1e4f4ce1ed070479f12e4cdc67" + } + }, + { + "id": "uniswapx-V2DutchOrder", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/V2DutchOrderLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/OrderInfoLib.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "V2DutchOrder" + } + ], + "DutchOutput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ], + "OrderInfo": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "V2DutchOrder": [ + { + "name": "info", + "type": "OrderInfo" + }, + { + "name": "cosigner", + "type": "address" + }, + { + "name": "baseInputToken", + "type": "address" + }, + { + "name": "baseInputStartAmount", + "type": "uint256" + }, + { + "name": "baseInputEndAmount", + "type": "uint256" + }, + { + "name": "baseOutputs", + "type": "DutchOutput[]" + } + ] + }, + "primaryType": "PermitWitnessTransferFrom", + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "permitted": { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "3500000000" + }, + "spender": "0x00000011F84B9aa48e5f8aA8B9897600006289Be", + "nonce": "1993353164669688581970088190602701610056397261180510327052393484289", + "deadline": "1775803746", + "witness": { + "info": { + "reactor": "0x00000011F84B9aa48e5f8aA8B9897600006289Be", + "swapper": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "nonce": "1993353164669688581970088190602701610056397261180510327052393484288", + "deadline": "1775803746", + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x" + }, + "cosigner": "0x4449cd34d1eb1fedcf02a1be3834ffde8e6a6180", + "baseInputToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "baseInputStartAmount": "3500000000", + "baseInputEndAmount": "3500000000", + "baseOutputs": [ + { + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "startAmount": "1000000000000000000", + "endAmount": "990000000000000000", + "recipient": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + }, + { + "token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "startAmount": "1500000000000000", + "endAmount": "1485000000000000", + "recipient": "0x27213E28D7fDA5c57Fe9e5dD923818DBCcf71c47" + } + ] + } + } + }, + "expected": { + "domain_separator": "866a5aba21966af95d6c7ab78eb2b2fc913915c28be3b9aa07cc04ff903e3f28", + "message_hash": "1d895c005457e7cdee8450d706c53a359a4176c12dd4d48f9e38339160809b83", + "digest": "7cc7576f193542ecfbed9254ff7785c3aa296d2382d317858aa13d87dd24d652" + } + }, + { + "id": "uniswapx-PriorityOrder", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/PriorityOrderLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/OrderInfoLib.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "PriorityOrder" + } + ], + "OrderInfo": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes" + } + ], + "PriorityInput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + }, + { + "name": "mpsPerPriorityFeeWei", + "type": "uint256" + } + ], + "PriorityOrder": [ + { + "name": "info", + "type": "OrderInfo" + }, + { + "name": "cosigner", + "type": "address" + }, + { + "name": "auctionStartBlock", + "type": "uint256" + }, + { + "name": "baselinePriorityFeeWei", + "type": "uint256" + }, + { + "name": "input", + "type": "PriorityInput" + }, + { + "name": "outputs", + "type": "PriorityOutput[]" + } + ], + "PriorityOutput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + }, + { + "name": "mpsPerPriorityFeeWei", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ] + }, + "primaryType": "PermitWitnessTransferFrom", + "domain": { + "name": "Permit2", + "chainId": 8453, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "permitted": { + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "100000000" + }, + "spender": "0x0000000000fbd5e2a41b6d0f3b0fe9ed9b2d0cc9", + "nonce": "1993353164669688581970088190602701610056397261180510327052393484290", + "deadline": "1775803746", + "witness": { + "info": { + "reactor": "0x0000000000fbd5e2a41b6d0f3b0fe9ed9b2d0cc9", + "swapper": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "nonce": "1993353164669688581970088190602701610056397261180510327052393484288", + "deadline": "1775803746", + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x" + }, + "cosigner": "0x0000000000000000000000000000000000000000", + "auctionStartBlock": "24000000", + "baselinePriorityFeeWei": "0", + "input": { + "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "100000000", + "mpsPerPriorityFeeWei": "0" + }, + "outputs": [ + { + "token": "0x4200000000000000000000000000000000000006", + "amount": "28000000000000000", + "mpsPerPriorityFeeWei": "1", + "recipient": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + }, + { + "token": "0x4200000000000000000000000000000000000006", + "amount": "42000000000000", + "mpsPerPriorityFeeWei": "0", + "recipient": "0x7FFC3DBF3B2b50Ff3A1D5523bc24Bb5043837B14" + } + ] + } + } + }, + "expected": { + "domain_separator": "3b6f35e4fce979ef8eac3bcdc8c3fc38fe7911bb0c69c8fe72bf1fd1a17e6f07", + "message_hash": "a641126490de075257de4fc0b0ade25f8fa4bfc12858d49bb388cdcf34fd0c78", + "digest": "c7b4e513709348a52a6b13dea66c18975713be646ec021d9d62fb632874c7eb5" + } + }, + { + "id": "seaport-OrderComponents", + "source": [ + "https://github.com/ProjectOpenSea/seaport-core/blob/523097f9cee66c15d308c900c50f336b291cda08/src/lib/ConsiderationBase.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "OrderComponents": [ + { + "name": "offerer", + "type": "address" + }, + { + "name": "zone", + "type": "address" + }, + { + "name": "offer", + "type": "OfferItem[]" + }, + { + "name": "consideration", + "type": "ConsiderationItem[]" + }, + { + "name": "orderType", + "type": "uint8" + }, + { + "name": "startTime", + "type": "uint256" + }, + { + "name": "endTime", + "type": "uint256" + }, + { + "name": "zoneHash", + "type": "bytes32" + }, + { + "name": "salt", + "type": "uint256" + }, + { + "name": "conduitKey", + "type": "bytes32" + }, + { + "name": "counter", + "type": "uint256" + } + ], + "ConsiderationItem": [ + { + "name": "itemType", + "type": "uint8" + }, + { + "name": "token", + "type": "address" + }, + { + "name": "identifierOrCriteria", + "type": "uint256" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ], + "OfferItem": [ + { + "name": "itemType", + "type": "uint8" + }, + { + "name": "token", + "type": "address" + }, + { + "name": "identifierOrCriteria", + "type": "uint256" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + } + ] + }, + "primaryType": "OrderComponents", + "domain": { + "name": "Seaport", + "version": "1.6", + "chainId": 1, + "verifyingContract": "0x0000000000000068F116a894984e2DB1123eB395" + }, + "message": { + "offerer": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "zone": "0x0000000000000000000000000000000000000000", + "offer": [ + { + "itemType": 2, + "token": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", + "identifierOrCriteria": "8520", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", + "identifierOrCriteria": "8521", + "startAmount": "1", + "endAmount": "1" + } + ], + "consideration": [ + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "19500000000000000000", + "endAmount": "19500000000000000000", + "recipient": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "500000000000000000", + "endAmount": "500000000000000000", + "recipient": "0x0000a26b00c1F0DF003000390027140000fAa719" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "1000000000000000000", + "endAmount": "1000000000000000000", + "recipient": "0xA858DDc0445d8131daC4d1DE01f834ffcbA52Ef1" + } + ], + "orderType": 0, + "startTime": "1775700000", + "endTime": "1778300000", + "zoneHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "salt": "24446860302761739304752683030156737591518664810215442929818227897836383814680", + "conduitKey": "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000", + "counter": "0" + } + }, + "expected": { + "domain_separator": "fce34bc6e1752c1117e5063116d25cad2fa2bdcf15ff2d2e275eece7dc31ba64", + "message_hash": "5bdedde642e259a1841ef18afaf47285be8da69b311260a27436eba16517c016", + "digest": "b6f6cd11f7cd0e8f8ed8964727b57e23d38680ad2d818d3a87b551e4762a1f69" + } + }, + { + "id": "seaport-OrderComponents-bundle-10x6", + "source": [ + "https://github.com/ProjectOpenSea/seaport-core/blob/523097f9cee66c15d308c900c50f336b291cda08/src/lib/ConsiderationBase.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "OrderComponents": [ + { + "name": "offerer", + "type": "address" + }, + { + "name": "zone", + "type": "address" + }, + { + "name": "offer", + "type": "OfferItem[]" + }, + { + "name": "consideration", + "type": "ConsiderationItem[]" + }, + { + "name": "orderType", + "type": "uint8" + }, + { + "name": "startTime", + "type": "uint256" + }, + { + "name": "endTime", + "type": "uint256" + }, + { + "name": "zoneHash", + "type": "bytes32" + }, + { + "name": "salt", + "type": "uint256" + }, + { + "name": "conduitKey", + "type": "bytes32" + }, + { + "name": "counter", + "type": "uint256" + } + ], + "ConsiderationItem": [ + { + "name": "itemType", + "type": "uint8" + }, + { + "name": "token", + "type": "address" + }, + { + "name": "identifierOrCriteria", + "type": "uint256" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ], + "OfferItem": [ + { + "name": "itemType", + "type": "uint8" + }, + { + "name": "token", + "type": "address" + }, + { + "name": "identifierOrCriteria", + "type": "uint256" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + } + ] + }, + "primaryType": "OrderComponents", + "domain": { + "name": "Seaport", + "version": "1.6", + "chainId": 1, + "verifyingContract": "0x0000000000000068F116a894984e2DB1123eB395" + }, + "message": { + "offerer": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "zone": "0x0000000000000000000000000000000000000000", + "offer": [ + { + "itemType": 2, + "token": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", + "identifierOrCriteria": "8520", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", + "identifierOrCriteria": "8521", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", + "identifierOrCriteria": "8522", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xED5AF388653567Af2F388E6224dC7C4b3241C544", + "identifierOrCriteria": "4100", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xED5AF388653567Af2F388E6224dC7C4b3241C544", + "identifierOrCriteria": "4101", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xED5AF388653567Af2F388E6224dC7C4b3241C544", + "identifierOrCriteria": "4102", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e", + "identifierOrCriteria": "700", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0x8a90CAb2b38dba80c64b7734e58Ee1dB38B8992e", + "identifierOrCriteria": "701", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "3300", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "3301", + "startAmount": "1", + "endAmount": "1" + } + ], + "consideration": [ + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "118500000000000000000", + "endAmount": "118500000000000000000", + "recipient": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "625000000000000000", + "endAmount": "625000000000000000", + "recipient": "0x0000a26b00c1F0DF003000390027140000fAa719" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "2400000000000000000", + "endAmount": "2400000000000000000", + "recipient": "0x00000000000000000000000000000000a11cE000" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "1800000000000000000", + "endAmount": "1800000000000000000", + "recipient": "0x00000000000000000000000000000000A11ce001" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "1500000000000000000", + "endAmount": "1500000000000000000", + "recipient": "0x00000000000000000000000000000000a11CE002" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "1175000000000000000", + "endAmount": "1175000000000000000", + "recipient": "0x00000000000000000000000000000000A11ce003" + } + ], + "orderType": 0, + "startTime": "1775700000", + "endTime": "1778300000", + "zoneHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "salt": "24446860302761739304752683030156737591518664810215442929818227897836383814680", + "conduitKey": "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000", + "counter": "0" + } + }, + "expected": { + "domain_separator": "fce34bc6e1752c1117e5063116d25cad2fa2bdcf15ff2d2e275eece7dc31ba64", + "message_hash": "777e35ac429840a63b28d70ae83c7d0917f2c287fc0cf8fe659d2527510b93b1", + "digest": "af42126e4b5bfe74494b27766b259c698ba314a928d06ac1633b5ca1cf3e5e30" + } + }, + { + "id": "seaport-OrderComponents-consideration-12", + "source": [ + "https://github.com/ProjectOpenSea/seaport-core/blob/523097f9cee66c15d308c900c50f336b291cda08/src/lib/ConsiderationBase.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "OrderComponents": [ + { + "name": "offerer", + "type": "address" + }, + { + "name": "zone", + "type": "address" + }, + { + "name": "offer", + "type": "OfferItem[]" + }, + { + "name": "consideration", + "type": "ConsiderationItem[]" + }, + { + "name": "orderType", + "type": "uint8" + }, + { + "name": "startTime", + "type": "uint256" + }, + { + "name": "endTime", + "type": "uint256" + }, + { + "name": "zoneHash", + "type": "bytes32" + }, + { + "name": "salt", + "type": "uint256" + }, + { + "name": "conduitKey", + "type": "bytes32" + }, + { + "name": "counter", + "type": "uint256" + } + ], + "ConsiderationItem": [ + { + "name": "itemType", + "type": "uint8" + }, + { + "name": "token", + "type": "address" + }, + { + "name": "identifierOrCriteria", + "type": "uint256" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ], + "OfferItem": [ + { + "name": "itemType", + "type": "uint8" + }, + { + "name": "token", + "type": "address" + }, + { + "name": "identifierOrCriteria", + "type": "uint256" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + } + ] + }, + "primaryType": "OrderComponents", + "domain": { + "name": "Seaport", + "version": "1.6", + "chainId": 1, + "verifyingContract": "0x0000000000000068F116a894984e2DB1123eB395" + }, + "message": { + "offerer": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "zone": "0x0000000000000000000000000000000000000000", + "offer": [ + { + "itemType": 2, + "token": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", + "identifierOrCriteria": "9001", + "startAmount": "1", + "endAmount": "1" + } + ], + "consideration": [ + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "15000000000000000000", + "endAmount": "15000000000000000000", + "recipient": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "100000000000000000", + "endAmount": "100000000000000000", + "recipient": "0x0000a26b00c1F0DF003000390027140000fAa719" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "50000000000000000", + "endAmount": "50000000000000000", + "recipient": "0x0000000000000000000000000000000005B11700" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "100000000000000000", + "endAmount": "100000000000000000", + "recipient": "0x0000000000000000000000000000000005B11701" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "150000000000000000", + "endAmount": "150000000000000000", + "recipient": "0x0000000000000000000000000000000005b11702" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "200000000000000000", + "endAmount": "200000000000000000", + "recipient": "0x0000000000000000000000000000000005B11703" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "250000000000000000", + "endAmount": "250000000000000000", + "recipient": "0x0000000000000000000000000000000005b11704" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "300000000000000000", + "endAmount": "300000000000000000", + "recipient": "0x0000000000000000000000000000000005b11705" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "350000000000000000", + "endAmount": "350000000000000000", + "recipient": "0x0000000000000000000000000000000005B11706" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "400000000000000000", + "endAmount": "400000000000000000", + "recipient": "0x0000000000000000000000000000000005b11707" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "450000000000000000", + "endAmount": "450000000000000000", + "recipient": "0x0000000000000000000000000000000005b11708" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "500000000000000000", + "endAmount": "500000000000000000", + "recipient": "0x0000000000000000000000000000000005b11709" + } + ], + "orderType": 0, + "startTime": "1775700000", + "endTime": "1778300000", + "zoneHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "salt": "24446860302761739304752683030156737591518664810215442929818227897836383814680", + "conduitKey": "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000", + "counter": "0" + } + }, + "expected": { + "domain_separator": "fce34bc6e1752c1117e5063116d25cad2fa2bdcf15ff2d2e275eece7dc31ba64", + "message_hash": "66e666071d1faf7d9f74130449bf619b95c9f52fd04eb7a435903319e8afaa20", + "digest": "bfb6130599bda35f4a32de5ec2c1f65d5024bae151657ad3445fd0042fb0f844" + } + }, + { + "id": "seaport-OrderComponents-bundle-32", + "source": [ + "https://github.com/ProjectOpenSea/seaport-core/blob/523097f9cee66c15d308c900c50f336b291cda08/src/lib/ConsiderationBase.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "OrderComponents": [ + { + "name": "offerer", + "type": "address" + }, + { + "name": "zone", + "type": "address" + }, + { + "name": "offer", + "type": "OfferItem[]" + }, + { + "name": "consideration", + "type": "ConsiderationItem[]" + }, + { + "name": "orderType", + "type": "uint8" + }, + { + "name": "startTime", + "type": "uint256" + }, + { + "name": "endTime", + "type": "uint256" + }, + { + "name": "zoneHash", + "type": "bytes32" + }, + { + "name": "salt", + "type": "uint256" + }, + { + "name": "conduitKey", + "type": "bytes32" + }, + { + "name": "counter", + "type": "uint256" + } + ], + "ConsiderationItem": [ + { + "name": "itemType", + "type": "uint8" + }, + { + "name": "token", + "type": "address" + }, + { + "name": "identifierOrCriteria", + "type": "uint256" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + }, + { + "name": "recipient", + "type": "address" + } + ], + "OfferItem": [ + { + "name": "itemType", + "type": "uint8" + }, + { + "name": "token", + "type": "address" + }, + { + "name": "identifierOrCriteria", + "type": "uint256" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "endAmount", + "type": "uint256" + } + ] + }, + "primaryType": "OrderComponents", + "domain": { + "name": "Seaport", + "version": "1.6", + "chainId": 1, + "verifyingContract": "0x0000000000000068F116a894984e2DB1123eB395" + }, + "message": { + "offerer": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "zone": "0x0000000000000000000000000000000000000000", + "offer": [ + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5000", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5001", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5002", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5003", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5004", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5005", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5006", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5007", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5008", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5009", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5010", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5011", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5012", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5013", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5014", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5015", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5016", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5017", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5018", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5019", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5020", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5021", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5022", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5023", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5024", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5025", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5026", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5027", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5028", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5029", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5030", + "startAmount": "1", + "endAmount": "1" + }, + { + "itemType": 2, + "token": "0xBd3531dA5CF5857e7CfAA92426877b022e612cf8", + "identifierOrCriteria": "5031", + "startAmount": "1", + "endAmount": "1" + } + ], + "consideration": [ + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "320000000000000000000", + "endAmount": "320000000000000000000", + "recipient": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + }, + { + "itemType": 0, + "token": "0x0000000000000000000000000000000000000000", + "identifierOrCriteria": "0", + "startAmount": "1600000000000000000", + "endAmount": "1600000000000000000", + "recipient": "0x0000a26b00c1F0DF003000390027140000fAa719" + } + ], + "orderType": 0, + "startTime": "1775700000", + "endTime": "1778300000", + "zoneHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "salt": "24446860302761739304752683030156737591518664810215442929818227897836383814680", + "conduitKey": "0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000", + "counter": "0" + } + }, + "expected": { + "domain_separator": "fce34bc6e1752c1117e5063116d25cad2fa2bdcf15ff2d2e275eece7dc31ba64", + "message_hash": "b3b1ba0d3e233fb87c9d866c87b2ef5b77e85b80d64f1ba9b2e3d9dc98a14402", + "digest": "8f9f180ebb10f7799149df0dbdcf11edc50f7f3cce4e5b2c742a142f286c5ee0" + } + }, + { + "id": "looksrare-Maker", + "source": [ + "https://github.com/LooksRare/contracts-exchange-v2/blob/7fca5650da653a2a0680e18e60164e639c18f830/contracts/libraries/OrderStructs.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Maker": [ + { + "name": "quoteType", + "type": "uint8" + }, + { + "name": "globalNonce", + "type": "uint256" + }, + { + "name": "subsetNonce", + "type": "uint256" + }, + { + "name": "orderNonce", + "type": "uint256" + }, + { + "name": "strategyId", + "type": "uint256" + }, + { + "name": "collectionType", + "type": "uint8" + }, + { + "name": "collection", + "type": "address" + }, + { + "name": "currency", + "type": "address" + }, + { + "name": "signer", + "type": "address" + }, + { + "name": "startTime", + "type": "uint256" + }, + { + "name": "endTime", + "type": "uint256" + }, + { + "name": "price", + "type": "uint256" + }, + { + "name": "itemIds", + "type": "uint256[]" + }, + { + "name": "amounts", + "type": "uint256[]" + }, + { + "name": "additionalParameters", + "type": "bytes" + } + ] + }, + "primaryType": "Maker", + "domain": { + "name": "LooksRareProtocol", + "version": "2", + "chainId": 1, + "verifyingContract": "0x0000000000E655fAe4d56241588680F86E3b2377" + }, + "message": { + "quoteType": 1, + "globalNonce": "0", + "subsetNonce": "0", + "orderNonce": "7", + "strategyId": "0", + "collectionType": 0, + "collection": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", + "currency": "0x0000000000000000000000000000000000000000", + "signer": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "startTime": "1775700000", + "endTime": "1778300000", + "price": "21000000000000000000", + "itemIds": [ + "8520", + "8521" + ], + "amounts": [ + "1", + "1" + ], + "additionalParameters": "0x" + } + }, + "expected": { + "domain_separator": "13be80cb8c4239588caab638256c555b825f3fbead883414b042fd375781040c", + "message_hash": "f66521e3a0d0416b42e738575d0e63db76241755e1d097629aa2e5f17927513c", + "digest": "c63794a61c6b972160381131e7d63b7d3a695f512248b9e061327c85d10e9004" + } + }, + { + "id": "blur-Order", + "source": [ + "https://github.com/code-423n4/2022-10-blur/blob/main/contracts/lib/EIP712.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Order": [ + { + "name": "trader", + "type": "address" + }, + { + "name": "side", + "type": "uint8" + }, + { + "name": "matchingPolicy", + "type": "address" + }, + { + "name": "collection", + "type": "address" + }, + { + "name": "tokenId", + "type": "uint256" + }, + { + "name": "amount", + "type": "uint256" + }, + { + "name": "paymentToken", + "type": "address" + }, + { + "name": "price", + "type": "uint256" + }, + { + "name": "listingTime", + "type": "uint256" + }, + { + "name": "expirationTime", + "type": "uint256" + }, + { + "name": "fees", + "type": "Fee[]" + }, + { + "name": "salt", + "type": "uint256" + }, + { + "name": "extraParams", + "type": "bytes" + }, + { + "name": "nonce", + "type": "uint256" + } + ], + "Fee": [ + { + "name": "rate", + "type": "uint16" + }, + { + "name": "recipient", + "type": "address" + } + ] + }, + "primaryType": "Order", + "domain": { + "name": "Blur Exchange", + "version": "1.0", + "chainId": 1, + "verifyingContract": "0x000000000000Ad05Ccc4F10045630fb830B95127" + }, + "message": { + "trader": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "side": 1, + "matchingPolicy": "0x00000000006411739DA1c40B106F8511de5D1FAC", + "collection": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", + "tokenId": "8520", + "amount": "1", + "paymentToken": "0x0000000000000000000000000000000000000000", + "price": "20000000000000000000", + "listingTime": "1775700000", + "expirationTime": "1778300000", + "fees": [ + { + "rate": 50, + "recipient": "0xaab1d2c1fc1c5b4e8a1de8b3fe2f5fc6e2fb86d3" + }, + { + "rate": 250, + "recipient": "0xA858DDc0445d8131daC4d1DE01f834ffcbA52Ef1" + } + ], + "salt": "265875887785256855606558013756560384533", + "extraParams": "0x01", + "nonce": "0" + } + }, + "expected": { + "domain_separator": "68e25ba9bdf16f19b7227e3ee7fff0b93558367587fc73fd50c5d978a0cd227f", + "message_hash": "c8858619b0e96f15b33b91c260c45570945ec84d5310016888785cec1252ec40", + "digest": "89424a5eaf944e4bda74c080e649e8f6bd3443a6e6b86a48e4100fc43c01a380" + } + }, + { + "id": "blur-Root", + "source": [ + "https://github.com/code-423n4/2022-10-blur/blob/main/contracts/lib/EIP712.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Root": [ + { + "name": "root", + "type": "bytes32" + } + ] + }, + "primaryType": "Root", + "domain": { + "name": "Blur Exchange", + "version": "1.0", + "chainId": 1, + "verifyingContract": "0x000000000000Ad05Ccc4F10045630fb830B95127" + }, + "message": { + "root": "0x6f1c86e1ea4b8b1d9f7f0c5c2e1d6a3b4c5d6e7f8091a2b3c4d5e6f708192a3b" + } + }, + "expected": { + "domain_separator": "68e25ba9bdf16f19b7227e3ee7fff0b93558367587fc73fd50c5d978a0cd227f", + "message_hash": "aeadf10ef9755aa81ad8a3219731271fe417be9c783dc542a42d5edfb3b77317", + "digest": "6d3bc4aec615720499edd705ee6673b5f1b40a1a3afa1a8d85ac273ebc427253" + } + }, + { + "id": "1inch-Order", + "source": [ + "https://github.com/1inch/limit-order-protocol/blob/50bd1300315606a3d32e9021898374c9d0dc2335/contracts/OrderLib.sol", + "https://sourcify.dev/#/lookup/0x111111125421cA6dc452d289314280a0f8842A65" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Order": [ + { + "name": "salt", + "type": "uint256" + }, + { + "name": "maker", + "type": "address" + }, + { + "name": "receiver", + "type": "address" + }, + { + "name": "makerAsset", + "type": "address" + }, + { + "name": "takerAsset", + "type": "address" + }, + { + "name": "makingAmount", + "type": "uint256" + }, + { + "name": "takingAmount", + "type": "uint256" + }, + { + "name": "makerTraits", + "type": "uint256" + } + ] + }, + "primaryType": "Order", + "domain": { + "name": "1inch Aggregation Router", + "version": "6", + "chainId": 1, + "verifyingContract": "0x111111125421cA6dc452d289314280a0f8842A65" + }, + "message": { + "salt": "102412815611287462193218283713587453612380483585130722463424346577", + "maker": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "receiver": "0x0000000000000000000000000000000000000000", + "makerAsset": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "takerAsset": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "makingAmount": "1000000000000000000", + "takingAmount": "3500000000", + "makerTraits": "62419173104490761595518734106557662061518414611782227068396304425790442831872" + } + }, + "expected": { + "domain_separator": "d999e213f11c7bfa3e796c3409e316f25e02aa3e25e5c207a92e381c7d22b6de", + "message_hash": "0dfdbba222c62bc366f22ca6339e12520ce10684376c80d61176781ac00c3f4b", + "digest": "cddd12ad70f117d173e3e40ae060696c549fdc0ec98bc913083f20f6ba3163d9" + } + }, + { + "id": "cow-Order", + "source": [ + "https://github.com/cowprotocol/contracts/blob/c07a93e3596194c5e3cf331c755a3f9f0e4a17d8/src/contracts/libraries/GPv2Order.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Order": [ + { + "name": "sellToken", + "type": "address" + }, + { + "name": "buyToken", + "type": "address" + }, + { + "name": "receiver", + "type": "address" + }, + { + "name": "sellAmount", + "type": "uint256" + }, + { + "name": "buyAmount", + "type": "uint256" + }, + { + "name": "validTo", + "type": "uint32" + }, + { + "name": "appData", + "type": "bytes32" + }, + { + "name": "feeAmount", + "type": "uint256" + }, + { + "name": "kind", + "type": "string" + }, + { + "name": "partiallyFillable", + "type": "bool" + }, + { + "name": "sellTokenBalance", + "type": "string" + }, + { + "name": "buyTokenBalance", + "type": "string" + } + ] + }, + "primaryType": "Order", + "domain": { + "name": "Gnosis Protocol", + "version": "v2", + "chainId": 1, + "verifyingContract": "0x9008D19f58AAbD9eD0D60971565AA8510560ab41" + }, + "message": { + "sellToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "buyToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "receiver": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "sellAmount": "1000000000000000000", + "buyAmount": "3480000000", + "validTo": 1775803746, + "appData": "0xf785fae7a7c5abc49f3cd6a61f6df1ff26433392b066ee9ff2240ff1eb7ab6e4", + "feeAmount": "0", + "kind": "sell", + "partiallyFillable": false, + "sellTokenBalance": "erc20", + "buyTokenBalance": "erc20" + } + }, + "expected": { + "domain_separator": "c078f884a2676e1345748b1feace7b0abee5d00ecadb6e574dcdd109a63e8943", + "message_hash": "cdc3f27a64d63d4d714edd7ac17d8ba1891d43b9743d7776cd58ad5fc4cec3a7", + "digest": "576a9e87719762457271789254f3ab5a2fa6a6bc7d20972883ac69ca076ffad8" + } + }, + { + "id": "cow-OrderCancellations", + "source": [ + "https://github.com/cowprotocol/cow-sdk/blob/8ac240cbea721a546618d3667a24c8054664d934/packages/contracts-ts/src/order.ts" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "OrderCancellations": [ + { + "name": "orderUids", + "type": "bytes[]" + } + ] + }, + "primaryType": "OrderCancellations", + "domain": { + "name": "Gnosis Protocol", + "version": "v2", + "chainId": 1, + "verifyingContract": "0x9008D19f58AAbD9eD0D60971565AA8510560ab41" + }, + "message": { + "orderUids": [ + "0xabababababababababababababababababababababababababababababababab9858effd232b4033e47d90003d41ec34ecaeda9469d89d62", + "0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd9858effd232b4033e47d90003d41ec34ecaeda9469d89d63" + ] + } + }, + "expected": { + "domain_separator": "c078f884a2676e1345748b1feace7b0abee5d00ecadb6e574dcdd109a63e8943", + "message_hash": "c2db95270ee0d0e6ae186f42a5acbd1d0809010e2f83398fca5ad7fa7e7dd803", + "digest": "5898792dcf9f9d984e4d0ce6b0dc8a10ebd652e8722e944b38bad099f1a968a4" + } + }, + { + "id": "0x-LimitOrder", + "source": [ + "https://github.com/0xProject/protocol/blob/2c8e51e36ccbafa39f7c9003f4087710665acb02/contracts/zero-ex/contracts/src/features/libs/LibNativeOrder.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "LimitOrder": [ + { + "name": "makerToken", + "type": "address" + }, + { + "name": "takerToken", + "type": "address" + }, + { + "name": "makerAmount", + "type": "uint128" + }, + { + "name": "takerAmount", + "type": "uint128" + }, + { + "name": "takerTokenFeeAmount", + "type": "uint128" + }, + { + "name": "maker", + "type": "address" + }, + { + "name": "taker", + "type": "address" + }, + { + "name": "sender", + "type": "address" + }, + { + "name": "feeRecipient", + "type": "address" + }, + { + "name": "pool", + "type": "bytes32" + }, + { + "name": "expiry", + "type": "uint64" + }, + { + "name": "salt", + "type": "uint256" + } + ] + }, + "primaryType": "LimitOrder", + "domain": { + "name": "ZeroEx", + "version": "1.0.0", + "chainId": 1, + "verifyingContract": "0xDef1C0ded9bec7F1a1670819833240f027b25EfF" + }, + "message": { + "makerToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "takerToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "makerAmount": "1000000000000000000", + "takerAmount": "3500000000", + "takerTokenFeeAmount": "0", + "maker": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "taker": "0x0000000000000000000000000000000000000000", + "sender": "0x0000000000000000000000000000000000000000", + "feeRecipient": "0x0000000000000000000000000000000000000000", + "pool": "0x0000000000000000000000000000000000000000000000000000000000000000", + "expiry": "1775803746", + "salt": "54308148374513278436071736734563213846290466613155327734536137082462373451223" + } + }, + "expected": { + "domain_separator": "fe3a8808ff7909b8c36164e6e9a076597c21c3fc2ec6f2c8ac04529c41ce507e", + "message_hash": "52eeeafb7e1db5a5dd3c0604bac947a340ea48a297fb5b4eabe88509e4bd8688", + "digest": "474fd3e0afa906ca55fd290375e08ccde65bf824e8bf85fff0c656849e713e88" + } + }, + { + "id": "0x-RfqOrder", + "source": [ + "https://github.com/0xProject/protocol/blob/2c8e51e36ccbafa39f7c9003f4087710665acb02/contracts/zero-ex/contracts/src/features/libs/LibNativeOrder.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "RfqOrder": [ + { + "name": "makerToken", + "type": "address" + }, + { + "name": "takerToken", + "type": "address" + }, + { + "name": "makerAmount", + "type": "uint128" + }, + { + "name": "takerAmount", + "type": "uint128" + }, + { + "name": "maker", + "type": "address" + }, + { + "name": "taker", + "type": "address" + }, + { + "name": "txOrigin", + "type": "address" + }, + { + "name": "pool", + "type": "bytes32" + }, + { + "name": "expiry", + "type": "uint64" + }, + { + "name": "salt", + "type": "uint256" + } + ] + }, + "primaryType": "RfqOrder", + "domain": { + "name": "ZeroEx", + "version": "1.0.0", + "chainId": 1, + "verifyingContract": "0xDef1C0ded9bec7F1a1670819833240f027b25EfF" + }, + "message": { + "makerToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "takerToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "makerAmount": "3500000000", + "takerAmount": "1000000000000000000", + "maker": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "taker": "0x0000000000000000000000000000000000000000", + "txOrigin": "0x27213E28D7fDA5c57Fe9e5dD923818DBCcf71c47", + "pool": "0x0000000000000000000000000000000000000000000000000000000000000000", + "expiry": "1775803746", + "salt": "1775803000" + } + }, + "expected": { + "domain_separator": "fe3a8808ff7909b8c36164e6e9a076597c21c3fc2ec6f2c8ac04529c41ce507e", + "message_hash": "4fe4aaaecb08c6b07ebd5c06ba57304d9e8bf650668e51afd87c81f243578043", + "digest": "24dd6dea5cd2de49c1701d67eaf8487efdc213828d2c00a97408379a0e1fcd98" + } + }, + { + "id": "0x-OtcOrder", + "source": [ + "https://github.com/0xProject/protocol/blob/2c8e51e36ccbafa39f7c9003f4087710665acb02/contracts/zero-ex/contracts/src/features/libs/LibNativeOrder.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "OtcOrder": [ + { + "name": "makerToken", + "type": "address" + }, + { + "name": "takerToken", + "type": "address" + }, + { + "name": "makerAmount", + "type": "uint128" + }, + { + "name": "takerAmount", + "type": "uint128" + }, + { + "name": "maker", + "type": "address" + }, + { + "name": "taker", + "type": "address" + }, + { + "name": "txOrigin", + "type": "address" + }, + { + "name": "expiryAndNonce", + "type": "uint256" + } + ] + }, + "primaryType": "OtcOrder", + "domain": { + "name": "ZeroEx", + "version": "1.0.0", + "chainId": 1, + "verifyingContract": "0xDef1C0ded9bec7F1a1670819833240f027b25EfF" + }, + "message": { + "makerToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "takerToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "makerAmount": "3500000000", + "takerAmount": "1000000000000000000", + "maker": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "taker": "0x0000000000000000000000000000000000000000", + "txOrigin": "0x27213E28D7fDA5c57Fe9e5dD923818DBCcf71c47", + "expiryAndNonce": "2724834893716958296099287542290232898829043766902734489108736" + } + }, + "expected": { + "domain_separator": "fe3a8808ff7909b8c36164e6e9a076597c21c3fc2ec6f2c8ac04529c41ce507e", + "message_hash": "c3d531dfd43da6373e92accb783b6c56e7eaa7148a88b36a0bdd4b156c73133d", + "digest": "a575e5d5eab33f2c528564d24febb5c74ad8919c25581ac9c7012f338b0590d1" + } + }, + { + "id": "safe-SafeTx", + "source": [ + "https://github.com/safe-global/safe-smart-account/blob/v1.4.1/contracts/Safe.sol", + "https://github.com/safe-global/safe-smart-account/blob/v1.3.0/contracts/GnosisSafe.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "SafeTx": [ + { + "name": "to", + "type": "address" + }, + { + "name": "value", + "type": "uint256" + }, + { + "name": "data", + "type": "bytes" + }, + { + "name": "operation", + "type": "uint8" + }, + { + "name": "safeTxGas", + "type": "uint256" + }, + { + "name": "baseGas", + "type": "uint256" + }, + { + "name": "gasPrice", + "type": "uint256" + }, + { + "name": "gasToken", + "type": "address" + }, + { + "name": "refundReceiver", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + } + ] + }, + "primaryType": "SafeTx", + "domain": { + "chainId": 1, + "verifyingContract": "0x4F2083f5fBede34C2714aFfb3105539775f7FE64" + }, + "message": { + "to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "value": "0", + "data": "0xa9059cbb00000000000000000000000027213e28d7fda5c57fe9e5dd923818dbccf71c470000000000000000000000000000000000000000000000000000000059682f00", + "operation": 0, + "safeTxGas": "0", + "baseGas": "0", + "gasPrice": "0", + "gasToken": "0x0000000000000000000000000000000000000000", + "refundReceiver": "0x0000000000000000000000000000000000000000", + "nonce": "42" + } + }, + "expected": { + "domain_separator": "facf7a68664ed3dabebe8e263b4cf03d3e1ba8bbda764c9697c80c7e38b41c62", + "message_hash": "f1e5adf415ba2dc900ccdd63831d31c79f9a30e40a2d41a8a0683c8d8f535f1d", + "digest": "c6d5a71fd45369207a574d910998c708553d0a8960cbc8ea4515f04891f78878" + } + }, + { + "id": "safe-SafeTx-approve-unlimited", + "source": [ + "https://github.com/safe-global/safe-smart-account/blob/v1.4.1/contracts/Safe.sol", + "https://github.com/safe-global/safe-smart-account/blob/v1.3.0/contracts/GnosisSafe.sol" + ], + "requires_capability": "erc20-unlimited-approve-review", + "unlimited_screen": "Allow 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 to spend ALL your USDC", + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "SafeTx": [ + { + "name": "to", + "type": "address" + }, + { + "name": "value", + "type": "uint256" + }, + { + "name": "data", + "type": "bytes" + }, + { + "name": "operation", + "type": "uint8" + }, + { + "name": "safeTxGas", + "type": "uint256" + }, + { + "name": "baseGas", + "type": "uint256" + }, + { + "name": "gasPrice", + "type": "uint256" + }, + { + "name": "gasToken", + "type": "address" + }, + { + "name": "refundReceiver", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + } + ] + }, + "primaryType": "SafeTx", + "domain": { + "chainId": 1, + "verifyingContract": "0x4F2083f5fBede34C2714aFfb3105539775f7FE64" + }, + "message": { + "to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "value": "0", + "data": "0x095ea7b300000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "operation": 0, + "safeTxGas": "0", + "baseGas": "0", + "gasPrice": "0", + "gasToken": "0x0000000000000000000000000000000000000000", + "refundReceiver": "0x0000000000000000000000000000000000000000", + "nonce": "43" + } + }, + "expected": { + "domain_separator": "facf7a68664ed3dabebe8e263b4cf03d3e1ba8bbda764c9697c80c7e38b41c62", + "message_hash": "52f96cddec6b14f4496fe53708bb359ef7fcc6f1ccedf63177fd6237b5054b4b", + "digest": "efbe9929ce3de6463119639b299b403e582c1113a2d2a9434b4ba914a1dcffb3" + } + }, + { + "id": "safe-SafeMessage", + "source": [ + "https://github.com/safe-global/safe-smart-account/blob/v1.4.1/contracts/handler/CompatibilityFallbackHandler.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "SafeMessage": [ + { + "name": "message", + "type": "bytes" + } + ] + }, + "primaryType": "SafeMessage", + "domain": { + "chainId": 1, + "verifyingContract": "0x4F2083f5fBede34C2714aFfb3105539775f7FE64" + }, + "message": { + "message": "0x5369676e20696e20746f206170702e6578616d706c652e6f7267207769746820796f75722053616665" + } + }, + "expected": { + "domain_separator": "facf7a68664ed3dabebe8e263b4cf03d3e1ba8bbda764c9697c80c7e38b41c62", + "message_hash": "50c1323949a0f56f1d841a54dcf0b029edeeb52ad42daa386d59ce7d8f5d9e26", + "digest": "10d37e6ca3e5d88f02051f712267a3763f108f8cc1bf478290254f3f6770cc79" + } + }, + { + "id": "eip2612-Permit", + "source": [ + "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/c98b7188338e3c5fce903747894561af78863bbb/contracts/token/ERC20/extensions/ERC20Permit.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Permit": [ + { + "name": "owner", + "type": "address" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "value", + "type": "uint256" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + } + ] + }, + "primaryType": "Permit", + "domain": { + "name": "USD Coin", + "version": "2", + "chainId": 1, + "verifyingContract": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "message": { + "owner": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "value": "250000000", + "nonce": "0", + "deadline": "1775803746" + } + }, + "expected": { + "domain_separator": "06c37168a7db5138defc7866392bb87a741f9b3d104deb5094588ce041cae335", + "message_hash": "10de0bd7514d5a137be30ca8b8d727c944556d219ac5338577ea5b44bbecc21a", + "digest": "9dfbe3c2bc66846b4e4d05cf917567d5448bac92b4b575285df4b198f459ed94" + } + }, + { + "id": "eip2612-Permit-unlimited", + "source": [ + "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/c98b7188338e3c5fce903747894561af78863bbb/contracts/token/ERC20/extensions/ERC20Permit.sol" + ], + "requires_capability": "erc20-unlimited-permit-review", + "unlimited_screen": "value\nuint256: UNLIMITED", + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Permit": [ + { + "name": "owner", + "type": "address" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "value", + "type": "uint256" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + } + ] + }, + "primaryType": "Permit", + "domain": { + "name": "USD Coin", + "version": "2", + "chainId": 1, + "verifyingContract": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "message": { + "owner": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "value": "115792089237316195423570985008687907853269984665640564039457584007913129639935", + "nonce": "0", + "deadline": "1775803746" + } + }, + "expected": { + "domain_separator": "06c37168a7db5138defc7866392bb87a741f9b3d104deb5094588ce041cae335", + "message_hash": "33ffd631195880f28f81f273a3be51c770ca95311a3f90cf2caa81f3b338a26c", + "digest": "e8f07ca0d9556242ed695a7367e5ffe5d5e8b3913a82a763a83cf1be5d03ad1f" + } + }, + { + "id": "dai-Permit", + "source": [ + "https://github.com/makerdao/dss/blob/fa4f6630afb0624d04a003e920b0d71a00331d98/src/dai.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Permit": [ + { + "name": "holder", + "type": "address" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "expiry", + "type": "uint256" + }, + { + "name": "allowed", + "type": "bool" + } + ] + }, + "primaryType": "Permit", + "domain": { + "name": "Dai Stablecoin", + "version": "1", + "chainId": 1, + "verifyingContract": "0x6B175474E89094C44Da98b954EedeAC495271d0F" + }, + "message": { + "holder": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "nonce": "3", + "expiry": "1775803746", + "allowed": false + } + }, + "expected": { + "domain_separator": "dbb8cf42e1ecb028be3f3dbc922e1d878b963f411dc388ced501601c60f7c6f7", + "message_hash": "ab11f761da4f18c351b557993fff3af1b5276e0ca318a34eb7ff374040587863", + "digest": "ea585737a895795b5b84df7ff9082e15098bda1e22cc312b27ce64c6b656bde6" + } + }, + { + "id": "dai-Permit-allowed", + "source": [ + "https://github.com/makerdao/dss/blob/fa4f6630afb0624d04a003e920b0d71a00331d98/src/dai.sol" + ], + "requires_capability": "erc20-unlimited-permit-review", + "unlimited_screen": "allowed\nbool: UNLIMITED (allowed)", + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Permit": [ + { + "name": "holder", + "type": "address" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "expiry", + "type": "uint256" + }, + { + "name": "allowed", + "type": "bool" + } + ] + }, + "primaryType": "Permit", + "domain": { + "name": "Dai Stablecoin", + "version": "1", + "chainId": 1, + "verifyingContract": "0x6B175474E89094C44Da98b954EedeAC495271d0F" + }, + "message": { + "holder": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "nonce": "3", + "expiry": "1775803746", + "allowed": true + } + }, + "expected": { + "domain_separator": "dbb8cf42e1ecb028be3f3dbc922e1d878b963f411dc388ced501601c60f7c6f7", + "message_hash": "d087cc737aa821393dd824ea87dcb51376c51b9815d52c6f5102c6e843b128c0", + "digest": "c2b81aa8b44a91816e41ff0810a9ccb9bf0226cbdfd901493205a18fc5eb94a4" + } + }, + { + "id": "hyperliquid-Agent", + "source": [ + "https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/2fdb18f9517675ea03695a0962bd19eece9c83f0/hyperliquid/utils/signing.py" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Agent": [ + { + "name": "source", + "type": "string" + }, + { + "name": "connectionId", + "type": "bytes32" + } + ] + }, + "primaryType": "Agent", + "domain": { + "name": "Exchange", + "version": "1", + "chainId": 1337, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "source": "a", + "connectionId": "0x5a9c4e1b6f3d2a8c7e0b1d4f6a8c2e4b6d8f0a1c3e5b7d9f1a3c5e7b9d1f3a5c" + } + }, + "expected": { + "domain_separator": "d79297fcdf2ffcd4ae223d01edaa2ba214ff8f401d7c9300d995d17c82aa4040", + "message_hash": "f4ece6c5a5f1499c017ee17624f5a4ac0a476f42a270b0300b96840c4fb407ad", + "digest": "6bce8eb48553ebe6181a9cdba358196071b0ac1f204f124cc3c6d9fcfe0d87e0" + } + }, + { + "id": "hyperliquid-UsdSend", + "source": [ + "https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/2fdb18f9517675ea03695a0962bd19eece9c83f0/hyperliquid/utils/signing.py" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:UsdSend": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "destination", + "type": "string" + }, + { + "name": "amount", + "type": "string" + }, + { + "name": "time", + "type": "uint64" + } + ] + }, + "primaryType": "HyperliquidTransaction:UsdSend", + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 421614, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "hyperliquidChain": "Mainnet", + "destination": "0x27213e28d7fda5c57fe9e5dd923818dbccf71c47", + "amount": "125.5", + "time": 1775803746123 + } + }, + "expected": { + "domain_separator": "feb1393ca4412a4ca577bd51d04f0a77033514c602f4d0a11490fb95f7428df6", + "message_hash": "518cb9e1a640cb3f4f1284c76dfd63a9a71962c694c22dc3f6dff33ba6f6ab92", + "digest": "ccaa5b3b61614b569661dc3b3898d09db03661064d3300c6dfc50a48c5257621" + } + }, + { + "id": "hyperliquid-Withdraw", + "source": [ + "https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/2fdb18f9517675ea03695a0962bd19eece9c83f0/hyperliquid/utils/signing.py" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:Withdraw": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "destination", + "type": "string" + }, + { + "name": "amount", + "type": "string" + }, + { + "name": "time", + "type": "uint64" + } + ] + }, + "primaryType": "HyperliquidTransaction:Withdraw", + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 421614, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "hyperliquidChain": "Mainnet", + "destination": "0x9858effd232b4033e47d90003d41ec34ecaeda94", + "amount": "1000", + "time": 1775803746124 + } + }, + "expected": { + "domain_separator": "feb1393ca4412a4ca577bd51d04f0a77033514c602f4d0a11490fb95f7428df6", + "message_hash": "dd5d1b845532de3345180aaeaf23bbb2b7bbcd4ff128c3278dae59485cb62609", + "digest": "7da96947dfdab71f98adc8b1dd35f14ca9d89a3748a47948fcbce63459a00153" + } + }, + { + "id": "hyperliquid-SpotSend", + "source": [ + "https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/2fdb18f9517675ea03695a0962bd19eece9c83f0/hyperliquid/utils/signing.py" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:SpotSend": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "destination", + "type": "string" + }, + { + "name": "token", + "type": "string" + }, + { + "name": "amount", + "type": "string" + }, + { + "name": "time", + "type": "uint64" + } + ] + }, + "primaryType": "HyperliquidTransaction:SpotSend", + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 421614, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "hyperliquidChain": "Mainnet", + "destination": "0x27213e28d7fda5c57fe9e5dd923818dbccf71c47", + "token": "PURR:0xc4bf3f870c0e9465323c0b6ed28096c2", + "amount": "42", + "time": 1775803746125 + } + }, + "expected": { + "domain_separator": "feb1393ca4412a4ca577bd51d04f0a77033514c602f4d0a11490fb95f7428df6", + "message_hash": "9873b52950b87cda36625decbdeedcfbc0821cc1c49c413ec361f50286ed624a", + "digest": "024b66a25a2fd6473d749d58a5ab9811278d3ac66f0fa014d7a2c7ae3ef22086" + } + }, + { + "id": "hyperliquid-ApproveAgent", + "source": [ + "https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/2fdb18f9517675ea03695a0962bd19eece9c83f0/hyperliquid/utils/signing.py" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:ApproveAgent": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "agentAddress", + "type": "address" + }, + { + "name": "agentName", + "type": "string" + }, + { + "name": "nonce", + "type": "uint64" + } + ] + }, + "primaryType": "HyperliquidTransaction:ApproveAgent", + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 421614, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "hyperliquidChain": "Mainnet", + "agentAddress": "0x7FFC3DBF3B2b50Ff3A1D5523bc24Bb5043837B14", + "agentName": "keepkey", + "nonce": 1775803746126 + } + }, + "expected": { + "domain_separator": "feb1393ca4412a4ca577bd51d04f0a77033514c602f4d0a11490fb95f7428df6", + "message_hash": "0edbc16bac693bf270904285960c7a2aad984948616503c31b188cc314001f50", + "digest": "727fae5d7d7ffa519fb0f06bff340d863a9d366d3650feda6c25bc834d616b7e" + } + }, + { + "id": "hyperliquid-ApproveBuilderFee", + "source": [ + "https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/2fdb18f9517675ea03695a0962bd19eece9c83f0/hyperliquid/utils/signing.py" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "HyperliquidTransaction:ApproveBuilderFee": [ + { + "name": "hyperliquidChain", + "type": "string" + }, + { + "name": "maxFeeRate", + "type": "string" + }, + { + "name": "builder", + "type": "address" + }, + { + "name": "nonce", + "type": "uint64" + } + ] + }, + "primaryType": "HyperliquidTransaction:ApproveBuilderFee", + "domain": { + "name": "HyperliquidSignTransaction", + "version": "1", + "chainId": 421614, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "hyperliquidChain": "Mainnet", + "maxFeeRate": "0.1%", + "builder": "0xaab1d2c1fc1c5b4e8a1de8b3fe2f5fc6e2fb86d3", + "nonce": 1775803746127 + } + }, + "expected": { + "domain_separator": "feb1393ca4412a4ca577bd51d04f0a77033514c602f4d0a11490fb95f7428df6", + "message_hash": "40faac2eae76730154845cadb657e0eb6189a2e62d8996a3507384d37df30dd8", + "digest": "a157aa4e572a9371cd72316b9e56d5c6b870932d480b9c1c213ce754e27f0b7d" + } + }, + { + "id": "polymarket-Order", + "source": [ + "https://github.com/Polymarket/ctf-exchange/blob/ed5c7708b7be3aa98bf5f0c6602b57cc498e2ef4/src/exchange/libraries/OrderStructs.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Order": [ + { + "name": "salt", + "type": "uint256" + }, + { + "name": "maker", + "type": "address" + }, + { + "name": "signer", + "type": "address" + }, + { + "name": "taker", + "type": "address" + }, + { + "name": "tokenId", + "type": "uint256" + }, + { + "name": "makerAmount", + "type": "uint256" + }, + { + "name": "takerAmount", + "type": "uint256" + }, + { + "name": "expiration", + "type": "uint256" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "feeRateBps", + "type": "uint256" + }, + { + "name": "side", + "type": "uint8" + }, + { + "name": "signatureType", + "type": "uint8" + } + ] + }, + "primaryType": "Order", + "domain": { + "name": "Polymarket CTF Exchange", + "version": "1", + "chainId": 137, + "verifyingContract": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E" + }, + "message": { + "salt": "479249096354", + "maker": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "signer": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "taker": "0x0000000000000000000000000000000000000000", + "tokenId": "71321045679252212594626385532706912750332728571942532289631379312455583992563", + "makerAmount": "50000000", + "takerAmount": "100000000", + "expiration": "0", + "nonce": "0", + "feeRateBps": "0", + "side": 0, + "signatureType": 0 + } + }, + "expected": { + "domain_separator": "1a573e3617c78403b5b4b892827992f027b03d4eaf570048b8ee8cdd84d151be", + "message_hash": "02d6222deeec96f60bb0fd5b2fa76da7f7a416223bbb77a5126bdd95c6ed4c27", + "digest": "4adaa897f6212a9a351ea8f1e30129c3746eccf4a02e2128961a152e7c10ce0d" + } + }, + { + "id": "polymarket-ClobAuth", + "source": [ + "https://github.com/Polymarket/clob-client/blob/7df8257dc95f99edb257b53a7873e273a9b4a9b3/src/signing/eip712.ts" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + } + ], + "ClobAuth": [ + { + "name": "address", + "type": "address" + }, + { + "name": "timestamp", + "type": "string" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "message", + "type": "string" + } + ] + }, + "primaryType": "ClobAuth", + "domain": { + "name": "ClobAuthDomain", + "version": "1", + "chainId": 137 + }, + "message": { + "address": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "timestamp": "1775803746", + "nonce": "0", + "message": "This message attests that I control the given wallet" + } + }, + "expected": { + "domain_separator": "cfc66be2a3b30464cb3b588324101f660c9a205fa76e8e5f83ee16a528e1c4cb", + "message_hash": "6998fb300c3c08c2bafe9e47d73aab51025acdf054307f4f4583d94404c97fcf", + "digest": "26632c686b33505c3e6f3001b3a7581f30dce9f94720ad249a2286fea252adff" + } + }, + { + "id": "compound-Ballot", + "source": [ + "https://github.com/compound-finance/compound-protocol/blob/a3214f67b73310d547e00fc578e8355911c9d376/contracts/Governance/GovernorBravoDelegate.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Ballot": [ + { + "name": "proposalId", + "type": "uint256" + }, + { + "name": "support", + "type": "uint8" + } + ] + }, + "primaryType": "Ballot", + "domain": { + "name": "Compound Governor Bravo", + "chainId": 1, + "verifyingContract": "0xc0Da02939E1441F497fd74F78cE7Decb17B66529" + }, + "message": { + "proposalId": "289", + "support": 1 + } + }, + "expected": { + "domain_separator": "d34731531481fb0dd71602b43ef32fd12c76bc12d76869812352236afaa4b346", + "message_hash": "91d8e3e95c41471dd654460e8b12395e1b0ee5682bfc1598134898b7a964a582", + "digest": "5e70ede1f42b52b245a35a58e2f2da91eef453a540c808247f785ed62dc7c9e2" + } + }, + { + "id": "oz-governor-v5-ExtendedBallot", + "source": [ + "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.4.0/contracts/governance/Governor.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "ExtendedBallot": [ + { + "name": "proposalId", + "type": "uint256" + }, + { + "name": "support", + "type": "uint8" + }, + { + "name": "voter", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "reason", + "type": "string" + }, + { + "name": "params", + "type": "bytes" + } + ] + }, + "primaryType": "ExtendedBallot", + "domain": { + "name": "Compound Governor", + "version": "1", + "chainId": 1, + "verifyingContract": "0x309a862bbC1A00e45506cB8A802D1ff10004c8C0" + }, + "message": { + "proposalId": "10770658185911291573227826359627312367262036453227096346419573049937349025001", + "support": 1, + "voter": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "nonce": "0", + "reason": "Supports the risk parameter update.", + "params": "0x" + } + }, + "expected": { + "domain_separator": "3abcb92fd8e6968fd834a2cc50bb05abdd78c74cd0fac3f017c47fcc8b743e87", + "message_hash": "05ce3e96fc27ffd71d0e36c688ecab14c00a49fac2c65bdfbd64af3ca49191f5", + "digest": "2f8c569b46c093b83c7ae290f31893ef6956910121ddcb1ca55f1326a6819845" + } + }, + { + "id": "oz-governor-v4-ExtendedBallot", + "source": [ + "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.9.6/contracts/governance/Governor.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "ExtendedBallot": [ + { + "name": "proposalId", + "type": "uint256" + }, + { + "name": "support", + "type": "uint8" + }, + { + "name": "reason", + "type": "string" + }, + { + "name": "params", + "type": "bytes" + } + ] + }, + "primaryType": "ExtendedBallot", + "domain": { + "name": "ENS Governor", + "version": "1", + "chainId": 1, + "verifyingContract": "0x323A76393544d5ecca80cd6ef2A560C6a395b7E3" + }, + "message": { + "proposalId": "42", + "support": 0, + "reason": "Against: needs an audit first.", + "params": "0x" + } + }, + "expected": { + "domain_separator": "7569da846e8a3624f3e70461387f0c2bae53ef7edc30b5d3a5fbe2b74fa7c741", + "message_hash": "d0e00d45e7cd483b28090a1512888e0bd907dce94815a2750c5064941e7bf76b", + "digest": "1d95b248abb2fc734628421684b93ce1980641738dca34190a3ee4916c20f6d8" + } + }, + { + "id": "compound-Delegation", + "source": [ + "https://github.com/compound-finance/compound-protocol/blob/a3214f67b73310d547e00fc578e8355911c9d376/contracts/Governance/Comp.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Delegation": [ + { + "name": "delegatee", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "expiry", + "type": "uint256" + } + ] + }, + "primaryType": "Delegation", + "domain": { + "name": "Compound", + "chainId": 1, + "verifyingContract": "0xc00e94Cb662C3520282E6f5717214004A7f26888" + }, + "message": { + "delegatee": "0x7FFC3DBF3B2b50Ff3A1D5523bc24Bb5043837B14", + "nonce": "0", + "expiry": "1775803746" + } + }, + "expected": { + "domain_separator": "97529df852bab657e1ffa498f205743fdd7e2b2dcbcc1173bacf2cd17133d278", + "message_hash": "ca7258eec1792ca72c50d4c4e2aca40d71485e77c86b39b9d04695ab7098500d", + "digest": "014c88c699f48e4680fdf86ab4a638876ff93812b0dd20106f3797caa8adb4ca" + } + }, + { + "id": "aave-SubmitVote", + "source": [ + "https://github.com/bgd-labs/aave-governance-v3/blob/497226efad1ca3723074034c8341fb90931fc2b2/src/contracts/voting/VotingMachineWithProofs.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "SubmitVote": [ + { + "name": "proposalId", + "type": "uint256" + }, + { + "name": "voter", + "type": "address" + }, + { + "name": "support", + "type": "bool" + }, + { + "name": "votingAssetsWithSlot", + "type": "VotingAssetWithSlot[]" + } + ], + "VotingAssetWithSlot": [ + { + "name": "underlyingAsset", + "type": "address" + }, + { + "name": "slot", + "type": "uint128" + } + ] + }, + "primaryType": "SubmitVote", + "domain": { + "name": "Aave Voting Machine", + "version": "V1", + "chainId": 137, + "verifyingContract": "0xc8a2ADC4261c6b669CdFf69E717E77C9cFeB420d" + }, + "message": { + "proposalId": "312", + "voter": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "support": true, + "votingAssetsWithSlot": [ + { + "underlyingAsset": "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", + "slot": "0" + }, + { + "underlyingAsset": "0x4da27a545c0c5B758a6BA100e3a049001de870f5", + "slot": "0" + }, + { + "underlyingAsset": "0xA700b4eB416Be35b2911fd5Dee80678ff64fF6C9", + "slot": "52" + } + ] + } + }, + "expected": { + "domain_separator": "ae1a4d19b2c550c11439cbcb934c88b16b8585f876d64fe1a6a6ae8e9e863c46", + "message_hash": "0fbe44b065abb8e818ecd154ba83b7a7f6f4824400446e44e97c426eb4989019", + "digest": "ad46f18dee90c1e41f3505c75706e0780e5b47ceb64e6406e85863ad17278f2c" + } + }, + { + "id": "dydx-DelegateByType", + "source": [ + "https://github.com/dydxfoundation/governance-contracts/blob/804a6e41f96f8b67daeff1d681b4c1c66ceb2547/contracts/governance/token/GovernancePowerDelegationERC20Mixin.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "DelegateByType": [ + { + "name": "delegatee", + "type": "address" + }, + { + "name": "type", + "type": "uint256" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "expiry", + "type": "uint256" + } + ] + }, + "primaryType": "DelegateByType", + "domain": { + "name": "dYdX", + "version": "1", + "chainId": 1, + "verifyingContract": "0x92D6C1e31e14520e676a687F0a93788B716BEff5" + }, + "message": { + "delegatee": "0x7FFC3DBF3B2b50Ff3A1D5523bc24Bb5043837B14", + "type": "1", + "nonce": "0", + "expiry": "1775803746" + } + }, + "expected": { + "domain_separator": "17e76dd75f3dfe3e50d31322433cc1405e898404a5724da2d4804cab81e0a88a", + "message_hash": "6dfed76dfede64f4af0c2768ac6d52b71f00d84ae5d32686448b7b5beb4c33a2", + "digest": "820d885ba6d39bf57def942b8c33ee3508d4fff87a09e50fb2f6ecaa486f8dd9" + } + }, + { + "id": "across-GaslessCrossChainOrder", + "source": [ + "https://github.com/across-protocol/contracts/blob/master/contracts/erc7683/ERC7683Permit2Lib.sol", + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "GaslessCrossChainOrder" + } + ], + "AcrossOrderData": [ + { + "name": "inputToken", + "type": "address" + }, + { + "name": "inputAmount", + "type": "uint256" + }, + { + "name": "outputToken", + "type": "address" + }, + { + "name": "outputAmount", + "type": "uint256" + }, + { + "name": "destinationChainId", + "type": "uint256" + }, + { + "name": "recipient", + "type": "bytes32" + }, + { + "name": "exclusiveRelayer", + "type": "address" + }, + { + "name": "exclusivityPeriod", + "type": "uint32" + }, + { + "name": "message", + "type": "bytes" + } + ], + "GaslessCrossChainOrder": [ + { + "name": "originSettler", + "type": "address" + }, + { + "name": "user", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "originChainId", + "type": "uint256" + }, + { + "name": "openDeadline", + "type": "uint32" + }, + { + "name": "fillDeadline", + "type": "uint32" + }, + { + "name": "orderDataType", + "type": "bytes32" + }, + { + "name": "orderData", + "type": "AcrossOrderData" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ] + }, + "primaryType": "PermitWitnessTransferFrom", + "domain": { + "name": "Permit2", + "chainId": 42161, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "permitted": { + "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "amount": "500000000" + }, + "spender": "0xB0B07055F214Ce59ccB968663d3435B9f3294998", + "nonce": "77", + "deadline": "1775803746", + "witness": { + "originSettler": "0xB0B07055F214Ce59ccB968663d3435B9f3294998", + "user": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "nonce": "77", + "originChainId": "42161", + "openDeadline": 1775803746, + "fillDeadline": 1775814546, + "orderDataType": "0x9df4b782e7bbc178b3b93bfe8aafb909e84e39484d7f3c59f400f1b4691f85e2", + "orderData": { + "inputToken": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "inputAmount": "500000000", + "outputToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "outputAmount": "499650000", + "destinationChainId": "1", + "recipient": "0x0000000000000000000000009858effd232b4033e47d90003d41ec34ecaeda94", + "exclusiveRelayer": "0x0000000000000000000000000000000000000000", + "exclusivityPeriod": 0, + "message": "0x" + } + } + } + }, + "expected": { + "domain_separator": "8a6e6e19bdfb3db3409910416b47c2f8fc28b49488d6555c7fceaa4479135bc3", + "message_hash": "a0e1fae1fef174757d2b9652c3d1721a9262a393e50904e7f51ba49c2e5e18a6", + "digest": "89121d721fbf3935ca58a646da33ae08eef33bdc61fb9584826bc68eb5b867d9" + } + }, + { + "id": "snapshot-Vote-uint32", + "source": [ + "https://github.com/snapshot-labs/snapshot.js/blob/ae2a8209e03ca1fa133a9105949a9386f3b539ed/src/sign/types.ts" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ], + "Vote": [ + { + "name": "from", + "type": "string" + }, + { + "name": "space", + "type": "string" + }, + { + "name": "timestamp", + "type": "uint64" + }, + { + "name": "proposal", + "type": "string" + }, + { + "name": "choice", + "type": "uint32" + }, + { + "name": "reason", + "type": "string" + }, + { + "name": "app", + "type": "string" + }, + { + "name": "metadata", + "type": "string" + } + ] + }, + "primaryType": "Vote", + "domain": { + "name": "snapshot", + "version": "0.1.4" + }, + "message": { + "from": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "space": "aavedao.eth", + "timestamp": 1775803746, + "proposal": "0x9e0b3d8a0b6b31c5c1b8c0b4e2c6b0bfa4f3dd1b0ec0e4b2d8a6b5c2e1f3a4d5", + "choice": 1, + "reason": "", + "app": "snapshot-v2", + "metadata": "{}" + } + }, + "expected": { + "domain_separator": "484fce18f892e8535a4b6700e197a8026f4213f809d23ae117da03b497e18670", + "message_hash": "7fabb0616963ab661ad570fdac5cd6dfc1896f62e75891bde5fdcd8e8fda9c30", + "digest": "2ebf83b128be8aa355a81a15424a3fc8966f27c7c2bceadf191be84304a16760" + } + }, + { + "id": "snapshot-Vote-reason-multiline", + "source": [ + "https://github.com/snapshot-labs/snapshot.js/blob/ae2a8209e03ca1fa133a9105949a9386f3b539ed/src/sign/types.ts" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ], + "Vote": [ + { + "name": "from", + "type": "string" + }, + { + "name": "space", + "type": "string" + }, + { + "name": "timestamp", + "type": "uint64" + }, + { + "name": "proposal", + "type": "string" + }, + { + "name": "choice", + "type": "uint32" + }, + { + "name": "reason", + "type": "string" + }, + { + "name": "app", + "type": "string" + }, + { + "name": "metadata", + "type": "string" + } + ] + }, + "primaryType": "Vote", + "domain": { + "name": "snapshot", + "version": "0.1.4" + }, + "message": { + "from": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "space": "aavedao.eth", + "timestamp": 1775803746, + "proposal": "0x9e0b3d8a0b6b31c5c1b8c0b4e2c6b0bfa4f3dd1b0ec0e4b2d8a6b5c2e1f3a4d5", + "choice": 1, + "reason": "Supporting this.\n\n\tThe treasury numbers check out;\r\nsee the forum thread.", + "app": "snapshot-v2", + "metadata": "{}" + } + }, + "expected": { + "domain_separator": "484fce18f892e8535a4b6700e197a8026f4213f809d23ae117da03b497e18670", + "message_hash": "8b3db279e4027f639c6e9e69aeaa2760e4cbd76172e0508eb9d55504b0203dd5", + "digest": "fb036054282ff5c208db428da469c2aad81334ee2ea75713a848adefd932f8ae" + } + }, + { + "id": "snapshot-Vote-uint32[]", + "source": [ + "https://github.com/snapshot-labs/snapshot.js/blob/ae2a8209e03ca1fa133a9105949a9386f3b539ed/src/sign/types.ts" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ], + "Vote": [ + { + "name": "from", + "type": "string" + }, + { + "name": "space", + "type": "string" + }, + { + "name": "timestamp", + "type": "uint64" + }, + { + "name": "proposal", + "type": "string" + }, + { + "name": "choice", + "type": "uint32[]" + }, + { + "name": "reason", + "type": "string" + }, + { + "name": "app", + "type": "string" + }, + { + "name": "metadata", + "type": "string" + } + ] + }, + "primaryType": "Vote", + "domain": { + "name": "snapshot", + "version": "0.1.4" + }, + "message": { + "from": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "space": "aavedao.eth", + "timestamp": 1775803746, + "proposal": "0x9e0b3d8a0b6b31c5c1b8c0b4e2c6b0bfa4f3dd1b0ec0e4b2d8a6b5c2e1f3a4d5", + "choice": [ + 1, + 3 + ], + "reason": "", + "app": "snapshot-v2", + "metadata": "{}" + } + }, + "expected": { + "domain_separator": "484fce18f892e8535a4b6700e197a8026f4213f809d23ae117da03b497e18670", + "message_hash": "445458b701f220e28b2f7851e2fb67d2d30f86ee9cfec5e947a86915c5b374fa", + "digest": "8942a9b491d57715c6becc2325fbe330fcb3c1fb1d4027267b64ca0bfadaa60a" + } + }, + { + "id": "snapshot-Vote-string", + "source": [ + "https://github.com/snapshot-labs/snapshot.js/blob/ae2a8209e03ca1fa133a9105949a9386f3b539ed/src/sign/types.ts" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ], + "Vote": [ + { + "name": "from", + "type": "string" + }, + { + "name": "space", + "type": "string" + }, + { + "name": "timestamp", + "type": "uint64" + }, + { + "name": "proposal", + "type": "string" + }, + { + "name": "choice", + "type": "string" + }, + { + "name": "reason", + "type": "string" + }, + { + "name": "app", + "type": "string" + }, + { + "name": "metadata", + "type": "string" + } + ] + }, + "primaryType": "Vote", + "domain": { + "name": "snapshot", + "version": "0.1.4" + }, + "message": { + "from": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "space": "aavedao.eth", + "timestamp": 1775803746, + "proposal": "0x9e0b3d8a0b6b31c5c1b8c0b4e2c6b0bfa4f3dd1b0ec0e4b2d8a6b5c2e1f3a4d5", + "choice": "{\"1\":60,\"2\":40}", + "reason": "", + "app": "snapshot-v2", + "metadata": "{}" + } + }, + "expected": { + "domain_separator": "484fce18f892e8535a4b6700e197a8026f4213f809d23ae117da03b497e18670", + "message_hash": "c5740e9f7b1eb3cc1c58a4c2e7ece755e59f657c58e707ecfb699847e787954e", + "digest": "13505558c6022d086c119463c0c57887a4de806e74d298e3b26b95b31bd763c8" + } + }, + { + "id": "snapshot-Vote-legacy-bytes32", + "source": [ + "https://github.com/snapshot-labs/snapshot.js/blob/2a13d16e/src/sign/types.ts" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ], + "Vote": [ + { + "name": "from", + "type": "address" + }, + { + "name": "space", + "type": "string" + }, + { + "name": "timestamp", + "type": "uint64" + }, + { + "name": "proposal", + "type": "bytes32" + }, + { + "name": "choice", + "type": "uint32" + }, + { + "name": "reason", + "type": "string" + }, + { + "name": "app", + "type": "string" + }, + { + "name": "metadata", + "type": "string" + } + ] + }, + "primaryType": "Vote", + "domain": { + "name": "snapshot", + "version": "0.1.4" + }, + "message": { + "from": "0x9858EfFD232B4033E47d90003D41EC34EcaEda94", + "space": "ens.eth", + "timestamp": 1775803746, + "proposal": "0x9e0b3d8a0b6b31c5c1b8c0b4e2c6b0bfa4f3dd1b0ec0e4b2d8a6b5c2e1f3a4d5", + "choice": 2, + "reason": "", + "app": "snapshot", + "metadata": "{}" + } + }, + "expected": { + "domain_separator": "484fce18f892e8535a4b6700e197a8026f4213f809d23ae117da03b497e18670", + "message_hash": "39aed682da209db5214780de752b4319204bb3e8d48faa0bc1698c2bb0147637", + "digest": "6672ce6256f657e4e9af0ac1eb6f7ca3e256cbdc9c8052f743da4bf899e211d0" + } + }, + { + "id": "metamask-Mail-v4", + "source": [ + "https://github.com/MetaMask/test-dapp/blob/v7.0.0/src/index.js" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Mail": [ + { + "name": "from", + "type": "Person" + }, + { + "name": "to", + "type": "Person[]" + }, + { + "name": "contents", + "type": "string" + } + ], + "Person": [ + { + "name": "name", + "type": "string" + }, + { + "name": "wallets", + "type": "address[]" + } + ], + "Group": [ + { + "name": "name", + "type": "string" + }, + { + "name": "members", + "type": "Person[]" + } + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallets": [ + "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", + "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF" + ] + }, + "to": [ + { + "name": "Bob", + "wallets": [ + "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", + "0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57", + "0xB0B0b0b0b0b0B000000000000000000000000000" + ] + } + ], + "contents": "Hello, Bob!" + } + }, + "expected": { + "domain_separator": "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f", + "message_hash": "eb4221181ff3f1a83ea7313993ca9218496e424604ba9492bb4052c03d5c3df8", + "digest": "a85c2e2b118698e88db68a8105b794a8cc7cec074e89ef991cb4f5f533819cc2" + } + }, + { + "id": "eip712-spec-Mail", + "source": [ + "https://github.com/ethereum/EIPs/blob/master/assets/eip-712/Example.js" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Mail": [ + { + "name": "from", + "type": "Person" + }, + { + "name": "to", + "type": "Person" + }, + { + "name": "contents", + "type": "string" + } + ], + "Person": [ + { + "name": "name", + "type": "string" + }, + { + "name": "wallet", + "type": "address" + } + ] + }, + "primaryType": "Mail", + "domain": { + "name": "Ether Mail", + "version": "1", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "message": { + "from": { + "name": "Cow", + "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826" + }, + "to": { + "name": "Bob", + "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB" + }, + "contents": "Hello, Bob!" + } + }, + "expected": { + "domain_separator": "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f", + "message_hash": "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e", + "digest": "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2" + } + }, + { + "id": "uniswapx-V3DutchOrder-arbitrum", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/V3DutchOrderLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/OrderInfoLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/DutchOrderLib.sol", + "https://arbiscan.io/tx/0x1f2f270bf65d2b7f02b2a141f71e23bfcc3a84b37c9a0921577516c58031678d" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "V3DutchOrder" + } + ], + "NonlinearDutchDecay": [ + { + "name": "relativeBlocks", + "type": "uint256" + }, + { + "name": "relativeAmounts", + "type": "int256[]" + } + ], + "OrderInfo": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "V3DutchInput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "curve", + "type": "NonlinearDutchDecay" + }, + { + "name": "maxAmount", + "type": "uint256" + }, + { + "name": "adjustmentPerGweiBaseFee", + "type": "uint256" + } + ], + "V3DutchOrder": [ + { + "name": "info", + "type": "OrderInfo" + }, + { + "name": "cosigner", + "type": "address" + }, + { + "name": "startingBaseFee", + "type": "uint256" + }, + { + "name": "baseInput", + "type": "V3DutchInput" + }, + { + "name": "baseOutputs", + "type": "V3DutchOutput[]" + } + ], + "V3DutchOutput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "curve", + "type": "NonlinearDutchDecay" + }, + { + "name": "recipient", + "type": "address" + }, + { + "name": "minAmount", + "type": "uint256" + }, + { + "name": "adjustmentPerGweiBaseFee", + "type": "uint256" + } + ] + }, + "primaryType": "PermitWitnessTransferFrom", + "domain": { + "name": "Permit2", + "chainId": 42161, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "permitted": { + "token": "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", + "amount": "12514477" + }, + "spender": "0xB274d5F4b833b61B340b654d600A864fB604a87c", + "nonce": "1993355410060173619975858542007397613024848003638926934372819132074995782145", + "deadline": "1791402675", + "witness": { + "info": { + "reactor": "0xB274d5F4b833b61B340b654d600A864fB604a87c", + "swapper": "0x7f05C94cE299Eea9FCdAE798Be34E226a0cb1e77", + "nonce": "1993355410060173619975858542007397613024848003638926934372819132074995782145", + "deadline": "1791402675", + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x" + }, + "cosigner": "0x4449Cd34d1eb1FEDCF02A1Be3834FfDe8E6A6180", + "startingBaseFee": "0", + "baseInput": { + "token": "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", + "startAmount": "12514477", + "curve": { + "relativeBlocks": "32", + "relativeAmounts": [ + "0" + ] + }, + "maxAmount": "12514477", + "adjustmentPerGweiBaseFee": "0" + }, + "baseOutputs": [ + { + "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "startAmount": "10407262726", + "curve": { + "relativeBlocks": "32", + "relativeAmounts": [ + "260181569" + ] + }, + "recipient": "0x7f05C94cE299Eea9FCdAE798Be34E226a0cb1e77", + "minAmount": "10147081157", + "adjustmentPerGweiBaseFee": "0" + } + ] + } + } + }, + "expected": { + "domain_separator": "8a6e6e19bdfb3db3409910416b47c2f8fc28b49488d6555c7fceaa4479135bc3", + "message_hash": "d9f38d5095c80dc8118342f144211cb537cc6b9b98027c20cefed85ffaf1cf5c", + "digest": "2a1fba9e8c26066eadd659350dc6e6cc17a9a63c8c4fbdbd771049adb754c33a" + } + }, + { + "id": "uniswapx-V3DutchOrder-arbitrum-curve16", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/V3DutchOrderLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/OrderInfoLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/DutchOrderLib.sol", + "https://arbiscan.io/tx/0x1f2f270bf65d2b7f02b2a141f71e23bfcc3a84b37c9a0921577516c58031678d", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/NonlinearDutchDecayLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/types/Uint16Array.sol" + ], + "requires_capability": "eip712-chunked-values", + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "V3DutchOrder" + } + ], + "NonlinearDutchDecay": [ + { + "name": "relativeBlocks", + "type": "uint256" + }, + { + "name": "relativeAmounts", + "type": "int256[]" + } + ], + "OrderInfo": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "V3DutchInput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "curve", + "type": "NonlinearDutchDecay" + }, + { + "name": "maxAmount", + "type": "uint256" + }, + { + "name": "adjustmentPerGweiBaseFee", + "type": "uint256" + } + ], + "V3DutchOrder": [ + { + "name": "info", + "type": "OrderInfo" + }, + { + "name": "cosigner", + "type": "address" + }, + { + "name": "startingBaseFee", + "type": "uint256" + }, + { + "name": "baseInput", + "type": "V3DutchInput" + }, + { + "name": "baseOutputs", + "type": "V3DutchOutput[]" + } + ], + "V3DutchOutput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "curve", + "type": "NonlinearDutchDecay" + }, + { + "name": "recipient", + "type": "address" + }, + { + "name": "minAmount", + "type": "uint256" + }, + { + "name": "adjustmentPerGweiBaseFee", + "type": "uint256" + } + ] + }, + "primaryType": "PermitWitnessTransferFrom", + "domain": { + "name": "Permit2", + "chainId": 42161, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "permitted": { + "token": "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", + "amount": "12514477" + }, + "spender": "0xB274d5F4b833b61B340b654d600A864fB604a87c", + "nonce": "1993355410060173619975858542007397613024848003638926934372819132074995782145", + "deadline": "1791402675", + "witness": { + "info": { + "reactor": "0xB274d5F4b833b61B340b654d600A864fB604a87c", + "swapper": "0x7f05C94cE299Eea9FCdAE798Be34E226a0cb1e77", + "nonce": "1993355410060173619975858542007397613024848003638926934372819132074995782145", + "deadline": "1791402675", + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x" + }, + "cosigner": "0x4449Cd34d1eb1FEDCF02A1Be3834FfDe8E6A6180", + "startingBaseFee": "0", + "baseInput": { + "token": "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", + "startAmount": "12514477", + "curve": { + "relativeBlocks": "56539914882827008174051697847791522700364166669018632573655171137406238722", + "relativeAmounts": [ + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0" + ] + }, + "maxAmount": "12514477", + "adjustmentPerGweiBaseFee": "0" + }, + "baseOutputs": [ + { + "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "startAmount": "10407262726", + "curve": { + "relativeBlocks": "56539914882827008174051697847791522700364166669018632573655171137406238722", + "relativeAmounts": [ + "16261348", + "32522696", + "48784044", + "65045392", + "81306740", + "97568088", + "113829436", + "130090784", + "146352132", + "162613480", + "178874828", + "195136176", + "211397524", + "227658872", + "243920220", + "260181568" + ] + }, + "recipient": "0x7f05C94cE299Eea9FCdAE798Be34E226a0cb1e77", + "minAmount": "10147081157", + "adjustmentPerGweiBaseFee": "0" + } + ] + } + } + }, + "expected": { + "domain_separator": "8a6e6e19bdfb3db3409910416b47c2f8fc28b49488d6555c7fceaa4479135bc3", + "message_hash": "1b7a77c239ffc05a83f3ac787234661b980a447d8791a54a785d9bb8950f3255", + "digest": "95fbdb5178abf6c7d90c1f5d463cc767e7b08ae701238bde83b16d002e2d5a5a" + } + }, + { + "id": "uniswapx-V3DutchOrder-arbitrum-fee-output", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/V3DutchOrderLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/OrderInfoLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/DutchOrderLib.sol", + "https://arbiscan.io/tx/0x3c67c0eb31f12747d675fe1c65734b2ce7b3ce608dcc02be348285b7435e7855" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "V3DutchOrder" + } + ], + "NonlinearDutchDecay": [ + { + "name": "relativeBlocks", + "type": "uint256" + }, + { + "name": "relativeAmounts", + "type": "int256[]" + } + ], + "OrderInfo": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "V3DutchInput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "curve", + "type": "NonlinearDutchDecay" + }, + { + "name": "maxAmount", + "type": "uint256" + }, + { + "name": "adjustmentPerGweiBaseFee", + "type": "uint256" + } + ], + "V3DutchOrder": [ + { + "name": "info", + "type": "OrderInfo" + }, + { + "name": "cosigner", + "type": "address" + }, + { + "name": "startingBaseFee", + "type": "uint256" + }, + { + "name": "baseInput", + "type": "V3DutchInput" + }, + { + "name": "baseOutputs", + "type": "V3DutchOutput[]" + } + ], + "V3DutchOutput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "curve", + "type": "NonlinearDutchDecay" + }, + { + "name": "recipient", + "type": "address" + }, + { + "name": "minAmount", + "type": "uint256" + }, + { + "name": "adjustmentPerGweiBaseFee", + "type": "uint256" + } + ] + }, + "primaryType": "PermitWitnessTransferFrom", + "domain": { + "name": "Permit2", + "chainId": 42161, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "permitted": { + "token": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "amount": "1226547455" + }, + "spender": "0xB274d5F4b833b61B340b654d600A864fB604a87c", + "nonce": "1993355115482027011983709557485874525075304680291794092761105090895583133443", + "deadline": "1791393631", + "witness": { + "info": { + "reactor": "0xB274d5F4b833b61B340b654d600A864fB604a87c", + "swapper": "0xD606742f4f6a05305B59174C9DA0B3BB30987556", + "nonce": "1993355115482027011983709557485874525075304680291794092761105090895583133443", + "deadline": "1791393631", + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x" + }, + "cosigner": "0x4449Cd34d1eb1FEDCF02A1Be3834FfDe8E6A6180", + "startingBaseFee": "0", + "baseInput": { + "token": "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + "startAmount": "1226547455", + "curve": { + "relativeBlocks": "32", + "relativeAmounts": [ + "0" + ] + }, + "maxAmount": "1226547455", + "adjustmentPerGweiBaseFee": "0" + }, + "baseOutputs": [ + { + "token": "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", + "startAmount": "1446161", + "curve": { + "relativeBlocks": "32", + "relativeAmounts": [ + "7232" + ] + }, + "recipient": "0xD606742f4f6a05305B59174C9DA0B3BB30987556", + "minAmount": "1438929", + "adjustmentPerGweiBaseFee": "0" + }, + { + "token": "0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f", + "startAmount": "23514", + "curve": { + "relativeBlocks": "32", + "relativeAmounts": [ + "117" + ] + }, + "recipient": "0x147CF09E7373B8FDA6f12021F1b0f98D6da1A566", + "minAmount": "23397", + "adjustmentPerGweiBaseFee": "0" + } + ] + } + } + }, + "expected": { + "domain_separator": "8a6e6e19bdfb3db3409910416b47c2f8fc28b49488d6555c7fceaa4479135bc3", + "message_hash": "d4e97cc058c321e71216e131c51438117e2c106360aa5d893bc194c54b51bfe1", + "digest": "4ef893b03e07c41dad0a7811640b7fcd7eafd518099eea36a6191f7c56061595" + } + }, + { + "id": "uniswapx-V3DutchOrder-arbitrum-negative-curve", + "source": [ + "https://github.com/Uniswap/permit2/blob/cc56ad0f3439c502c246fc5cfcc3db92bb8b7219/src/libraries/PermitHash.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/V3DutchOrderLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/OrderInfoLib.sol", + "https://github.com/Uniswap/UniswapX/blob/eb7ce077036258b9510b42ad37aa920cf3aaf308/src/lib/DutchOrderLib.sol", + "https://arbiscan.io/tx/0xfa14890926a78bb4095b7453c97150b3dea12fe8d53d21d97fd7366dfb595b7a" + ], + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "PermitWitnessTransferFrom": [ + { + "name": "permitted", + "type": "TokenPermissions" + }, + { + "name": "spender", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "witness", + "type": "V3DutchOrder" + } + ], + "NonlinearDutchDecay": [ + { + "name": "relativeBlocks", + "type": "uint256" + }, + { + "name": "relativeAmounts", + "type": "int256[]" + } + ], + "OrderInfo": [ + { + "name": "reactor", + "type": "address" + }, + { + "name": "swapper", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + }, + { + "name": "deadline", + "type": "uint256" + }, + { + "name": "additionalValidationContract", + "type": "address" + }, + { + "name": "additionalValidationData", + "type": "bytes" + } + ], + "TokenPermissions": [ + { + "name": "token", + "type": "address" + }, + { + "name": "amount", + "type": "uint256" + } + ], + "V3DutchInput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "curve", + "type": "NonlinearDutchDecay" + }, + { + "name": "maxAmount", + "type": "uint256" + }, + { + "name": "adjustmentPerGweiBaseFee", + "type": "uint256" + } + ], + "V3DutchOrder": [ + { + "name": "info", + "type": "OrderInfo" + }, + { + "name": "cosigner", + "type": "address" + }, + { + "name": "startingBaseFee", + "type": "uint256" + }, + { + "name": "baseInput", + "type": "V3DutchInput" + }, + { + "name": "baseOutputs", + "type": "V3DutchOutput[]" + } + ], + "V3DutchOutput": [ + { + "name": "token", + "type": "address" + }, + { + "name": "startAmount", + "type": "uint256" + }, + { + "name": "curve", + "type": "NonlinearDutchDecay" + }, + { + "name": "recipient", + "type": "address" + }, + { + "name": "minAmount", + "type": "uint256" + }, + { + "name": "adjustmentPerGweiBaseFee", + "type": "uint256" + } + ] + }, + "primaryType": "PermitWitnessTransferFrom", + "domain": { + "name": "Permit2", + "chainId": 42161, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3" + }, + "message": { + "permitted": { + "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "amount": "18453595142" + }, + "spender": "0xB274d5F4b833b61B340b654d600A864fB604a87c", + "nonce": "1993353428323324451238365100392469529508888071612295616155122962755034285057", + "deadline": "1791397885", + "witness": { + "info": { + "reactor": "0xB274d5F4b833b61B340b654d600A864fB604a87c", + "swapper": "0xd603a49886c9B500f96C0d798aed10068D73bF7C", + "nonce": "1993353428323324451238365100392469529508888071612295616155122962755034285057", + "deadline": "1791397885", + "additionalValidationContract": "0x0000000000000000000000000000000000000000", + "additionalValidationData": "0x" + }, + "cosigner": "0x4449Cd34d1eb1FEDCF02A1Be3834FfDe8E6A6180", + "startingBaseFee": "0", + "baseInput": { + "token": "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + "startAmount": "18361786211", + "curve": { + "relativeBlocks": "32", + "relativeAmounts": [ + "-91808931" + ] + }, + "maxAmount": "18453595142", + "adjustmentPerGweiBaseFee": "0" + }, + "baseOutputs": [ + { + "token": "0x0000000000000000000000000000000000000000", + "startAmount": "7165000000000000000", + "curve": { + "relativeBlocks": "32", + "relativeAmounts": [ + "0" + ] + }, + "recipient": "0xd603a49886c9B500f96C0d798aed10068D73bF7C", + "minAmount": "7165000000000000000", + "adjustmentPerGweiBaseFee": "0" + } + ] + } + } + }, + "expected": { + "domain_separator": "8a6e6e19bdfb3db3409910416b47c2f8fc28b49488d6555c7fceaa4479135bc3", + "message_hash": "b8c2d5a7b402683b9422031588d6231566d3813eb284905088fdab5bbbfb3e27", + "digest": "6e576a58ecf5e234ea6fc3eae0b97fe2ac8fa90c5a8e822da53fad929b5977b2" + } + }, + { + "id": "safe-SafeTx-multisend-4900", + "source": [ + "https://github.com/safe-global/safe-smart-account/blob/v1.3.0/contracts/GnosisSafe.sol", + "https://api.safe.global/tx-service/eth/api/v1/multisig-transactions/0x41bb8e482fadb98e15032ad92890e4289e3986030b7eee9ad061284481ceefc5/", + "https://etherscan.io/tx/0x9ab909ba050b277e8b426b90e299b6bd107a1baf8c59f95153b45e067475bf38" + ], + "requires_capability": "eip712-chunked-values", + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "SafeTx": [ + { + "name": "to", + "type": "address" + }, + { + "name": "value", + "type": "uint256" + }, + { + "name": "data", + "type": "bytes" + }, + { + "name": "operation", + "type": "uint8" + }, + { + "name": "safeTxGas", + "type": "uint256" + }, + { + "name": "baseGas", + "type": "uint256" + }, + { + "name": "gasPrice", + "type": "uint256" + }, + { + "name": "gasToken", + "type": "address" + }, + { + "name": "refundReceiver", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + } + ] + }, + "primaryType": "SafeTx", + "domain": { + "chainId": 1, + "verifyingContract": "0x849D52316331967b6fF1198e5E32A0eB168D039d" + }, + "message": { + "to": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D", + "value": "0", + "data": "0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000012d20027d8bb2e33bc38a9ce93fdd90c80677b8436affb000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000440c6c76b8475541524449414e2d4175726152656c6f636b657200000000000000000000000000000000000000000000005b2364fd757e262253423373e4d57c5c011ad7f40027d8bb2e33bc38a9ce93fdd90c80677b8436affb000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011e47508dd98475541524449414e2d4175726152656c6f636b657200000000000000000000000000000000000000000000005b2364fd757e262253423373e4d57c5c011ad7f4d34640b20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000002c00000000000000000000000000000000000000000000000000000000000000360000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000004a0000000000000000000000000000000000000000000000000000000000000054000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000000000000000000000000000000000000000000680000000000000000000000000000000000000000000000000000000000000072000000000000000000000000000000000000000000000000000000000000007c00000000000000000000000000000000000000000000000000000000000000860000000000000000000000000000000000000000000000000000000000000090000000000000000000000000000000000000000000000000000000000000009a00000000000000000000000000000000000000000000000000000000000000a400000000000000000000000000000000000000000000000000000000000000ae00000000000000000000000000000000000000000000000000000000000000b800000000000000000000000000000000000000000000000000000000000000c200000000000000000000000000000000000000000000000000000000000000cc00000000000000000000000000000000000000000000000000000000000000d600000000000000000000000000000000000000000000000000000000000000e200000000000000000000000000000000000000000000000000000000000000ee00000000000000000000000000000000000000000000000000000000000000fa0000000000000000000000000000000000000000000000000000000000000106000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000", + "operation": 1, + "safeTxGas": "0", + "baseGas": "0", + "gasPrice": "0", + "gasToken": "0x0000000000000000000000000000000000000000", + "refundReceiver": "0x0000000000000000000000000000000000000000", + "nonce": "2372" + } + }, + "expected": { + "domain_separator": "561d71a273896bbd4ecfb3e135730658c966531ed15bdee93660d109a8970f01", + "message_hash": "0d579dc9c09d2e219fdeb0e2e000ca33371e6f426ebf26f2a73e0559dd5b8e78", + "digest": "41bb8e482fadb98e15032ad92890e4289e3986030b7eee9ad061284481ceefc5" + } + }, + { + "id": "safe-SafeTx-multisend-approve-unlimited", + "source": [ + "https://github.com/safe-global/safe-smart-account/blob/v1.3.0/contracts/GnosisSafe.sol", + "https://github.com/safe-global/safe-smart-account/blob/v1.3.0/contracts/libraries/MultiSendCallOnly.sol", + "https://api.safe.global/tx-service/eth/api/v1/multisig-transactions/0xc5a2fac8f82fb6185796bf07e1c1d17b52538414c6e87c3dbc7b4f1bb2e16c12/", + "https://etherscan.io/tx/0x171f08630810946c52d3b379839e66b2dcb67db91f1be9ab3acbe74bac16af06" + ], + "requires_capability": "eip712-chunked-values", + "unlimited_screen": "Allow 0xC92E8bdf79f0507f65a392b0ab4667716BFE0110 to spend ALL your WETH", + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "SafeTx": [ + { + "name": "to", + "type": "address" + }, + { + "name": "value", + "type": "uint256" + }, + { + "name": "data", + "type": "bytes" + }, + { + "name": "operation", + "type": "uint8" + }, + { + "name": "safeTxGas", + "type": "uint256" + }, + { + "name": "baseGas", + "type": "uint256" + }, + { + "name": "gasPrice", + "type": "uint256" + }, + { + "name": "gasToken", + "type": "address" + }, + { + "name": "refundReceiver", + "type": "address" + }, + { + "name": "nonce", + "type": "uint256" + } + ] + }, + "primaryType": "SafeTx", + "domain": { + "chainId": 1, + "verifyingContract": "0x245cc372C84B3645Bf0Ffe6538620B04a217988B" + }, + "message": { + "to": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D", + "value": "0", + "data": "0x8d80ff0a0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000019200c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044095ea7b3000000000000000000000000c92e8bdf79f0507f65a392b0ab4667716bfe0110ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff009008d19f58aabd9ed0d60971565aa8510560ab41000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a4ec6cb13f0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000380c22c8071c7af43fd1f72d8f076e5ca5ef1bc341cd07607f2eef6a63d5940c80245cc372c84b3645bf0ffe6538620b04a217988b692df75700000000000000000000000000000000000000000000", + "operation": 1, + "safeTxGas": 0, + "baseGas": 0, + "gasPrice": "0", + "gasToken": "0x0000000000000000000000000000000000000000", + "refundReceiver": "0x0000000000000000000000000000000000000000", + "nonce": "1802" + } + }, + "expected": { + "domain_separator": "962a762e4486fcd558237b6934e27ec2383bc843e1bba8996935225f84c910ec", + "message_hash": "9b200c822d235c9321d93e495fa4e3b53eb7aa570ea8746e886b96a8a72386f0", + "digest": "c5a2fac8f82fb6185796bf07e1c1d17b52538414c6e87c3dbc7b4f1bb2e16c12" + } + }, + { + "id": "snapshot-Proposal-body-3929", + "source": [ + "https://github.com/snapshot-labs/snapshot.js/blob/ae2a8209e03ca1fa133a9105949a9386f3b539ed/src/sign/types.ts", + "https://snapshot.box/#/s:arbitrumfoundation.eth/proposal/0xa69fce8c756ac96160ddb6ac09653d3254415d046fdd43e5876663e9f343f183", + "ipfs://bafkreibjtqf4eeo3lstytfcqaxeyr2ycxzxysjhtuyyxe7bkf5rzowfvtu" + ], + "requires_capability": "eip712-chunked-values", + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + } + ], + "Proposal": [ + { + "name": "from", + "type": "string" + }, + { + "name": "space", + "type": "string" + }, + { + "name": "timestamp", + "type": "uint64" + }, + { + "name": "type", + "type": "string" + }, + { + "name": "title", + "type": "string" + }, + { + "name": "body", + "type": "string" + }, + { + "name": "discussion", + "type": "string" + }, + { + "name": "choices", + "type": "string[]" + }, + { + "name": "labels", + "type": "string[]" + }, + { + "name": "start", + "type": "uint64" + }, + { + "name": "end", + "type": "uint64" + }, + { + "name": "snapshot", + "type": "uint64" + }, + { + "name": "plugins", + "type": "string" + }, + { + "name": "privacy", + "type": "string" + }, + { + "name": "app", + "type": "string" + } + ] + }, + "primaryType": "Proposal", + "domain": { + "name": "snapshot", + "version": "0.1.4" + }, + "message": { + "from": "0xb4c064f466931B8d0F637654c916E3F203c46f13", + "timestamp": 1781800138, + "space": "arbitrumfoundation.eth", + "title": "Extending DRIP\u2019s Mandate", + "body": "### Non-Constitutional\n\n## Abstract\n\nThis proposal extends the DRIP program's mandate end date from July 1st, 2026 to July 1st, 2027, giving the Season Selection Committee additional time to deploy remaining funds across future seasons. No additional funding is requested in this proposal. All other terms of the original DRIP proposal remain unchanged, such as the committee composition and the DAO's ability to terminate the program via an offchain vote.\n\n## Motivation\n\nAs background, when the DAO approved DRIP in June 2025, the proposal outlined a 1-year mandate to execute up to 4 seasons with a total budget of 80M ARB. Season durations were originally estimated to be ~3-months, despite the proposal acknowledging this length was somewhat [arbitrary](https://forum.arbitrum.foundation/t/defi-renaissance-incentive-program-drip/29049#:~:text=The%20committee%20will%20maintain%20the%20right%20to%20modify%2C%20extend%2C%20or%20discontinue%20a%20season%20during%20its%20lifecycle.%20The%20%E2%80%9C3%2Dmonth%E2%80%9D%20timeline%20is%20somewhat%20arbitrary%2C%20and%20is%20open%20to%20change%20dependent%20on%20perceived%20program%20success%20and%20market%20conditions.) and the Season Selection Committee retained the ability to modify or extend season lengths.\n\nWhen designing Season 1 in collaboration with the selected distribution partner, Merkl, it became clear that a 3-month season was likely too short of a time period to run an effective campaign. Going live in early September, Season 1 ultimately ran for approximately 5.5 months to fully utilize the distribution partnerships secured and to create a slower tapering of rewards. We believe these design decisions helped the campaign achieve an overall adjusted cost-effectiveness ratio of 51 (76 on USD assets specifically), the highest of any major Arbitrum incentive program to date, alongside yield-bearing stablecoin supply growth on Arbitrum from $130M to over $1B, as detailed in the season [retrospective](https://forum.arbitrum.foundation/t/drip-season-1-retrospective/30644).\n\nWe want to address the most obvious question upfront. Since DRIP Season 1 concluded on February 18, 2026, the program has been quiet. We recognize that work on the next season has taken longer than we anticipated and appreciate the DAO\u2019s patience.\n\nThere are several strategic developments in flight that materially affect what an effective Season 2 should be designed around. We judged it more prudent to wait than to deploy meaningful ARB in a shortened season that did not fully align with these developments should they be confirmed. Spending 15-20M ARB on the wrong campaign is a much worse outcome than waiting a few additional months to run the right one. We are not in a position to detail every development driving this judgment publicly, but the OAT, Offchain, and the Arbitrum Foundation have been looped in throughout, and we hope to be able to share more in the coming weeks. The remaining ~63.7M ARB budget continues to sit with the AF, with deployment subject to Season Selection Committee approval.\n\n## Specification\n\nExtend the DRIP mandate end date from July 1st, 2026 to July 1st, 2027.\n\nThe following language from the original proposal:\n\n> \"DRIP End Date (funds returned if not used or another proposal is not passed): July 1, 2026. An ongoing season may go past this date, but new seasons will not begin after this date.\"\n\nIs amended to:\n\n> \"DRIP End Date (funds returned if not used or another proposal is not passed): July 1, 2027. An ongoing season may go past this date, but new seasons will not begin after this date.\"\n\nAll other terms of the original DRIP proposal, including the 80M ARB total budget, season structure, Season Selection Committee composition and approval thresholds, and the DAO's ability to terminate the program remain unchanged.\n\n## Timeline\n\n- Forum: Thursday June 11th, 2026\n \n- Offchain vote: Thursday June 18th, 2026", + "type": "basic", + "discussion": "https://forum.arbitrum.foundation/t/extending-drips-mandate/30985/1", + "choices": [ + "For", + "Against", + "Abstain" + ], + "privacy": "", + "labels": [], + "start": 1781800800, + "end": 1782405600, + "snapshot": 474836218, + "plugins": "{}", + "app": "snapshot-v2" + } + }, + "expected": { + "domain_separator": "484fce18f892e8535a4b6700e197a8026f4213f809d23ae117da03b497e18670", + "message_hash": "19195d2352d10330a4ad01c832298eea9b353e113154932be82e856eb3aa87c1", + "digest": "a69fce8c756ac96160ddb6ac09653d3254415d046fdd43e5876663e9f343f183" + } + }, + { + "id": "evmos-MsgSend", + "source": [ + "https://github.com/evmos/evmosjs/blob/e47e44b1683ffa57737cee2f6abbd138436d9fc3/packages/eip712/src/messages/base.ts", + "https://github.com/evmos/evmosjs/blob/e47e44b1683ffa57737cee2f6abbd138436d9fc3/packages/eip712/src/messages/bank/send.ts", + "https://github.com/evmos/evmosjs/blob/e47e44b1683ffa57737cee2f6abbd138436d9fc3/packages/eip712/src/tests/utils.ts" + ], + "requires_capability": "eip712-chunked-values", + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "string" + }, + { + "name": "salt", + "type": "string" + } + ], + "Tx": [ + { + "name": "account_number", + "type": "string" + }, + { + "name": "chain_id", + "type": "string" + }, + { + "name": "fee", + "type": "Fee" + }, + { + "name": "memo", + "type": "string" + }, + { + "name": "msgs", + "type": "Msg[]" + }, + { + "name": "sequence", + "type": "string" + } + ], + "Fee": [ + { + "name": "feePayer", + "type": "string" + }, + { + "name": "amount", + "type": "Coin[]" + }, + { + "name": "gas", + "type": "string" + } + ], + "Coin": [ + { + "name": "denom", + "type": "string" + }, + { + "name": "amount", + "type": "string" + } + ], + "Msg": [ + { + "name": "type", + "type": "string" + }, + { + "name": "value", + "type": "MsgValue" + } + ], + "MsgValue": [ + { + "name": "from_address", + "type": "string" + }, + { + "name": "to_address", + "type": "string" + }, + { + "name": "amount", + "type": "TypeAmount[]" + } + ], + "TypeAmount": [ + { + "name": "denom", + "type": "string" + }, + { + "name": "amount", + "type": "string" + } + ] + }, + "primaryType": "Tx", + "domain": { + "name": "Cosmos Web3", + "version": "1.0.0", + "chainId": 9001, + "verifyingContract": "cosmos", + "salt": "0" + }, + "message": { + "account_number": "10", + "chain_id": "evmos_9001-2", + "fee": { + "feePayer": "evmos1pmk2r32ssqwps42y3c9d4clqlca403yd9wymgr", + "amount": [ + { + "denom": "aevmos", + "amount": "4000000000000000" + } + ], + "gas": "200000" + }, + "memo": "Transaction Memo", + "msgs": [ + { + "type": "cosmos-sdk/MsgSend", + "value": { + "from_address": "evmos1pmk2r32ssqwps42y3c9d4clqlca403yd9wymgr", + "to_address": "evmos12nml2w93uva0smjw3c36stczfay0k67ny94ecz", + "amount": [ + { + "denom": "aevmos", + "amount": "1000000000000000000" + } + ] + } + } + ], + "sequence": "6" + } + }, + "expected": { + "domain_separator": "1633cfb1cf33b8790808cb60604cfd1f261c59a367ccb3aaff568a000149ace9", + "message_hash": "57c04fd23163cd0f2f0d20d6e9228010f7b111e0a499c208bc798b6e1aae9d0c", + "digest": "a475e759cc3637f92de75029aeb2eee6e410efae4a0b1834d8529853f06cf5f8" + } + }, + { + "id": "injective-MsgSend-v2", + "source": [ + "https://github.com/InjectiveLabs/injective-ts/blob/f8373753d6fe0662c1a8af7fb88192ad126e2e2e/packages/sdk-ts/src/core/tx/eip712/eip712.spec.ts", + "https://github.com/InjectiveLabs/injective-ts/blob/f8373753d6fe0662c1a8af7fb88192ad126e2e2e/packages/sdk-ts/src/core/tx/eip712/utils.ts", + "https://github.com/InjectiveLabs/injective-ts/blob/f8373753d6fe0662c1a8af7fb88192ad126e2e2e/packages/utils/src/test-utils/mocks/index.ts" + ], + "requires_capability": "eip712-chunked-values", + "doc": { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + }, + { + "name": "salt", + "type": "string" + } + ], + "Tx": [ + { + "name": "context", + "type": "string" + }, + { + "name": "msgs", + "type": "string" + } + ] + }, + "primaryType": "Tx", + "domain": { + "name": "Injective Web3", + "version": "1.0.0", + "chainId": "0x5", + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC", + "salt": "0" + }, + "message": { + "context": "{\"account_number\":11,\"chain_id\":\"injective-777\",\"fee\":{\"amount\":[{\"denom\":\"inj\",\"amount\":\"64000000000000\"}],\"gas\":400000,\"payer\":\"inj18j2myhaf2at75kwwaqxfstk4q28n4am45nlfg7\",\"granter\":\"inj1granter\"},\"memo\":\"\",\"sequence\":88,\"timeout_height\":1896}", + "msgs": "[{\"@type\":\"/cosmos.bank.v1beta1.MsgSend\",\"from_address\":\"inj14au322k9munkmx5wrchz9q30juf5wjgz2cfqku\",\"to_address\":\"inj14au322k9munkmx5wrchz9q30juf5wjgz2cfqku\",\"amount\":[{\"denom\":\"inj\",\"amount\":\"10000000000000000\"}]}]" + } + }, + "expected": { + "domain_separator": "a908147858193dcbc852a78aa9ab32d35da72e7229292dd41a6d1b02b305d1cd", + "message_hash": "699085c19a1eae36c57c33a44f907a1e805ce9f7c5ee1a0cdec62057839e85fb", + "digest": "0a599095745e1057fe40b2c5e36bcf753d38d1fcb8106f12c15c51aed3237e2e" + } + } +] diff --git a/tests/emulator_endpoints.py b/tests/emulator_endpoints.py new file mode 100644 index 00000000..c9b25cd5 --- /dev/null +++ b/tests/emulator_endpoints.py @@ -0,0 +1,23 @@ +"""Exact loopback UDP endpoints leased by test-owned emulator processes.""" + +from contextlib import contextmanager + +_owned = set() + + +def is_owned_endpoint(endpoint): + return endpoint in _owned + + +@contextmanager +def owned_emulator_pair(port): + if not isinstance(port, int) or isinstance(port, bool) or not 0 < port < 65535: + raise ValueError("emulator port must leave room for its debug port") + endpoints = {("127.0.0.1", port), ("127.0.0.1", port + 1)} + if endpoints & _owned: + raise RuntimeError("emulator endpoints already leased") + _owned.update(endpoints) + try: + yield + finally: + _owned.difference_update(endpoints) diff --git a/tests/fixtures/base-thorchain-real-deposits.json b/tests/fixtures/base-thorchain-real-deposits.json new file mode 100644 index 00000000..18c9d72e --- /dev/null +++ b/tests/fixtures/base-thorchain-real-deposits.json @@ -0,0 +1,32 @@ +[ + { + "txid": "0xd567902e8e057283c10c41cc5f95cc236d60eaea39834295a53851dee231d808", + "chain_id": 8453, + "to": "0x00dc6100103bc402d490aee3f9a5560cbd91f1d4", + "value": 41276400000000000, + "nonce": 49, + "gas_price": 50000000, + "gas_limit": 200000, + "data": "0x44bc937b0000000000000000000000004d39d5c55cfeb48f44d680fcac20493635dafdee00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000092a4aac79b600000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006acd804100000000000000000000000000000000000000000000000000000000000000643d3a54524f4e2e555344542d5452374e48514a454b5158475443493851385a5934504c384f54535a474a4c4a36543a544d6f554a3245715645536763614435654a647670686b6b6e384b337563586454703a393332313030343337393a6b6565703a333000000000000000000000000000000000000000000000000000000000", + "memo": "=:TRON.USDT-TR7NHQJEKQXGTCI8Q8ZY4PL8OTSZGJLJ6T:TMoUJ2EqVESgcaD5eJdvphkkn8K3ucXdTp:9321004379:keep:30", + "memo_length": 100, + "calldata_length": 324, + "vault": "0x4d39d5c55cfeb48f44d680fcac20493635dafdee", + "expiry": 1791852609 + }, + { + "txid": "0x3492f35c2b19554d761dd342b8f2bc5d07f7dfac3a6118893c82d86aacd0e856", + "chain_id": 8453, + "to": "0x00dc6100103bc402d490aee3f9a5560cbd91f1d4", + "value": 8255225600000000, + "nonce": 50, + "gas_price": 50000000, + "gas_limit": 200000, + "data": "0x44bc937b0000000000000000000000004d39d5c55cfeb48f44d680fcac20493635dafdee0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001d54157d6dc00000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006acd810400000000000000000000000000000000000000000000000000000000000000403d3a54524f4e2e5452583a544d6f554a3245715645536763614435654a647670686b6b6e384b337563586454703a353734303833303833333a6b6565703a3330", + "memo": "=:TRON.TRX:TMoUJ2EqVESgcaD5eJdvphkkn8K3ucXdTp:5740830833:keep:30", + "memo_length": 64, + "calldata_length": 260, + "vault": "0x4d39d5c55cfeb48f44d680fcac20493635dafdee", + "expiry": 1791852804 + } +] diff --git a/tests/fixtures/erc7730-thorchain-router-v3.json b/tests/fixtures/erc7730-thorchain-router-v3.json new file mode 100644 index 00000000..9445fe27 --- /dev/null +++ b/tests/fixtures/erc7730-thorchain-router-v3.json @@ -0,0 +1,62 @@ +{ + "source": "keepkey-sdk/packages/ts-keepkey-sdk/__tests__/sign-tx-eth-thorchain.js", + "description": "THORChain ETH to BCH swap", + "chainId": 1, + "to": "0x3624525075b88B24ecc29CE226b0CEc1fFcB6976", + "value": "0x5af3107a4000", + "signature": "deposit(address vault,address asset,uint256 amount,string memo)", + "data": "0x1fece7b4000000000000000000000000aff6edbf71badb2bc504c499ac8f344a5cd86008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005af3107a4000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000343d3a4243482e4243483a717a78703078633676736a3861706739796d346e346a6c3435707978746b70736875767239736d6a7033000000000000000000000000", + "expected": { + "selector": "1fece7b4", + "vault": "0xaff6edbf71badb2bc504c499ac8f344a5cd86008", + "asset": "0x0000000000000000000000000000000000000000", + "amount": "100000000000000", + "memo": "=:BCH.BCH:qzxp0xc6vsj8apg9ym4n4jl45pyxtkpshuvr9smjp3" + }, + "descriptor": { + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + "context": { + "$id": "THORChain Router v3.0.1", + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0x3624525075b88B24ecc29CE226b0CEc1fFcB6976" + } + ] + } + }, + "metadata": { + "owner": "THORChain", + "contractName": "THORChain Router v3.0.1" + }, + "display": { + "formats": { + "deposit(address vault,address asset,uint256 amount,string memo)": { + "$id": "deposit", + "intent": "Swap via THORChain", + "fields": [ + { + "path": "vault", + "label": "Inbound vault", + "format": "addressName", + "visible": "always" + }, + { + "path": "amount", + "label": "Amount to send", + "format": "amount", + "visible": "always" + }, + { + "path": "memo", + "label": "Swap memo", + "format": "raw", + "visible": "always" + } + ] + } + } + } + } +} diff --git a/tests/oled_text.py b/tests/oled_text.py new file mode 100644 index 00000000..df8fa3f8 --- /dev/null +++ b/tests/oled_text.py @@ -0,0 +1,266 @@ +"""Find text on a DebugLink OLED frame. + +DebugLinkState.layout is the display framebuffer -- 256x64, one bit per pixel, +each byte eight vertical pixels with the least significant bit on top -- and +there is no text channel. So a test that must know WHAT a screen says renders +the expected string with the firmware's own glyphs and looks for exactly those +pixels: every ink pixel of every glyph lit, every other pixel of its cell dark. + +The glyph tables below are the title and body fonts of keepkey-firmware +lib/board/font.c (printable ASCII). Regenerate them after a font change with + + python oled_text.py path/to/keepkey-firmware/lib/board/font.c + +A body is found the way confirm() draws it: broken into lines as +draw_string_walk() in lib/board/draw.c breaks them (a 44-character Solana +address always wraps), each line whole -- nothing drawn just before or after +it -- and the lines left-aligned on consecutive rows of ONE screen. A body +that pages is not found; assert each page's part instead. shows_address() +finds an address or key the way the QR-code address screens draw it. +""" + +from __future__ import print_function + +import re +import sys + +GLYPH_HEIGHT = 10 +BODY_WIDTH = 225 # layout.h BODY_WIDTH, the wrap width of confirm() bodies +BODY_LINE_PITCH = GLYPH_HEIGHT + 4 # font height + BODY_FONT_LINE_PADDING + + +def _font(table): + """'w:rows' per printable character from 0x20, rows as fixed-width hex + with bit i = column i.""" + glyphs = {} + for i, entry in enumerate(table.split()): + width, rows = entry.split(':') + n = len(rows) // GLYPH_HEIGHT + glyphs[chr(0x20 + i)] = (int(width), tuple( + int(rows[r * n:(r + 1) * n], 16) for r in range(GLYPH_HEIGHT))) + assert len(glyphs) == 95 + return glyphs + + +# Generated from keepkey-firmware lib/board/font.c by this module's __main__. +TITLE_FONT = _font(""" +5:000000000000000000000000000000 3:000003003003003003000003000000 5:00000f00f000000000000000000000 8:00003607f03603607f036000000000 +7:00c03e00f00f01e03c03c01f00c000 9:0000c606f03601806c0f6063000000 8:00000e01b01b00e07b03307e000000 3:000003003000000000000000000000 +4:006003003003003003003003006000 4:003006006006006006006006003000 7:00c03f01e03f00c000000000000000 7:00000000c00c03f00c00c000000000 +4:000000000000000000007007006003 7:00000000000003f000000000000000 4:000000000000000000007007000000 9:0000c006003001800c006003000000 +7:00001e03303b03f03703301e000000 4:000007006006006006006006000000 7:00001f03003001e00300303f000000 7:00001f03003001e03003001f000000 +7:00001801c01e01b03f018018000000 7:00003f00300301f03003001f000000 7:00001e00300301f03303301e000000 7:00003f03001801800c00c006000000 +7:00001e03303301e03303301e000000 7:00001e03303303e03003001e000000 4:000000000007007000007007000000 4:000000000007007000007007006003 +6:00001800c00600300600c018000000 7:00000000003f00003f000000000000 6:00000300600c01800c006003000000 7:00001e03303001800c00000c000000 +9:07c0c61bb1e31fb1ef0fb00607c000 7:00001e03303303f033033033000000 7:00001f03303301f03303301f000000 7:00001e03300300300303301e000000 +7:00001f03303303303303301f000000 7:00003f00300301f00300303f000000 7:00003f00300301f003003003000000 7:00001e03300303b03303303e000000 +7:00003303303303f033033033000000 5:00000f00600600600600600f000000 7:00003003003003003003301e000000 7:00003301b00f00700f01b033000000 +7:00000300300300300300303f000000 9:0000c30e70ff0db0c30c30c3000000 7:00003303303703f03b033033000000 7:00001e03303303303303301e000000 +7:00001f03303303301f003003000000 7:00001e03303303303303301e030000 7:00001f03303303301f01b033000000 7:00001e03300301e03003301e000000 +7:00003f00c00c00c00c00c00c000000 7:00003303303303303303301e000000 7:00003303303303301e01e00c000000 9:0000db0db0db0db0db0db07e000000 +7:00003303301e00c01e033033000000 7:00003303303301e00c00c00c000000 7:00003f03001800c00600303f000000 5:00000f00300300300300300f000000 +9:00000300600c0180300600c0000000 5:00000f00c00c00c00c00c00f000000 5:00000600f000000000000000000000 7:00000000000000000000003f000000 +4:000003006000000000000000000000 7:00000000001e03003e03303e000000 7:00000300301f03303303301f000000 7:00000000003e00300300303e000000 +7:00003003003e03303303303e000000 7:00000000001e03303f00303e000000 6:00001c00601f006006006006000000 7:00000000003e03303303303e03001e +7:00000300301f033033033033000000 3:000003000003003003003003000000 4:000006000006006006006006006003 6:00000300301b00f00700f01b000000 +3:000003003003003003003003000000 9:00000000007f0db0db0db0db000000 7:00000000001f033033033033000000 7:00000000001e03303303301e000000 +7:00000000001f03303303301f003003 7:00000000003e03303303303e030030 6:00000000001f007003003003000000 7:00000000003e00301e03001f000000 +6:00000600601f00600600601c000000 7:00000000003303303303303e000000 7:00000000003303301e01e00c000000 9:0000000000db0db0db0db07e000000 +7:00000000003301e00c01e033000000 7:00000000003303303303303e03001e 7:00000000003f01800c00603f000000 6:00001c00600600300600601c000000 +3:003003003003003003003003003003 6:00000700c00c01800c00c007000000 7:00003e01f000000000000000000000 +""") + +BODY_FONT = _font(""" +4:00000000000000000000 2:00010101010100010000 4:00050500000000000000 7:00123f12123f12000000 +6:041e05050e14140f0400 8:00422512082452210000 7:000609090629112e0000 3:00020100000000000000 +3:02010101010101010200 3:01020202020202020100 6:04150e15040000000000 6:000004041f0404000000 +3:00000000000003030201 6:000000001f0000000000 3:00000000000003030000 8:00402010080402010000 +6:000e11191513110e0000 3:00030202020202020000 6:000f10100e01011f0000 6:000f10100e10100f0000 +6:00080c0a091f08080000 6:001f01010f10100f0000 6:000e01010f11110e0000 6:001f1008080404020000 +6:000e11110e11110e0000 6:000e11111e10100e0000 3:00000003030003030000 3:00000003030003030201 +5:00080402010204080000 6:0000001f001f00000000 5:00010204080402010000 6:000e1110080400040000 +8:3c4299a1b9a579023c00 6:000e11111f1111110000 6:000f11110f11110f0000 6:000e11010101110e0000 +6:000f11111111110f0000 6:001f01010f01011f0000 6:001f01010f0101010000 6:000e11011d11111e0000 +6:001111111f1111110000 4:00070202020202070000 6:001010101010110e0000 6:00110905030509110000 +6:000101010101011f0000 8:00416355494141410000 6:00111113151911110000 6:000e11111111110e0000 +6:000f1111110f01010000 6:000e11111111110e1000 6:000f1111110f09110000 6:000e11010e10110e0000 +6:001f0404040404040000 6:001111111111110e0000 6:00111111110a0a040000 8:00494949494949360000 +6:0011110a040a11110000 6:001111110a0404040000 6:001f10080402011f0000 4:00070101010101070000 +8:00010204081020400000 4:00070404040404070000 4:00020500000000000000 6:000000000000001f0000 +3:00010200000000000000 6:0000000e101e111e0000 6:0001010f1111110f0000 6:0000001e0101011e0000 +6:0010101e1111111e0000 6:0000000e111f011e0000 5:000c020f020202020000 6:0000001e1111111e100e +6:0001010f111111110000 2:00010001010101010000 3:00020002020202020201 5:00010109050305090000 +2:00010101010101010000 8:0000003f494949490000 6:0000000f111111110000 6:0000000e1111110e0000 +6:0000000f1111110f0101 6:0000001e1111111e1010 5:0000000d030101010000 6:0000001e010e100f0000 +5:0002020f0202020c0000 6:000000111111111e0000 6:00000011110a0a040000 8:00000049494949360000 +6:000000110a040a110000 6:000000111111111e100e 6:0000001f0804021f0000 5:000c02020102020c0000 +2:01010101010101010101 5:00030404080404030000 7:00404020201214080000 +""") + + +def wrap(text, font=BODY_FONT, width=BODY_WIDTH): + """The lines draw_string_walk() breaks `text` into.""" + lines, line, x = [], '', 0 + for i, c in enumerate(text): + if c == '\n': + lines.append(line) + line, x = '', 0 + continue + word = font[c][0] + if c == ' ': + for n in text[i + 1:]: + if n in ' \n': + break + word += font[n][0] + if x + word > width: + lines.append(line) + line, x = '', 0 + if x == 0 and c == ' ': + continue + line += c + x += font[c][0] + lines.append(line) + return lines + + +def _rows(layout): + rows = [0] * 64 + for x in range(256): + for band in range(8): + byte = layout[x + band * 256] + if not isinstance(byte, int): + byte = ord(byte) + for bit in range(8): + if byte >> bit & 1: + rows[band * 8 + bit] |= 1 << x + return rows + + +def _render(line, font): + rows, x = [0] * GLYPH_HEIGHT, 0 + for c in line: + width, glyph = font[c] + for r in range(GLYPH_HEIGHT): + rows[r] |= glyph[r] << x + x += width + return x, rows + + +def _at(rows, want, width, x, y, whole, end=256): + """`want` drawn at (x, y); if `whole`, with nothing lit within a glyph's + width on either side of it (a longer line is not this line) -- short of + `end`, the wrap limit, past which the line cannot continue and an icon + may sit. A row with no ink in `want` is not compared: layout_address() + draws the QR code after the text, and its border may cover the line's + blank top row.""" + mask = (1 << width) - 1 + if any(want[r] and (rows[y + r] >> x) & mask != want[r] + for r in range(GLYPH_HEIGHT)): + return False + if not whole: + return True + before = min(x, 9) + after = max(0, min(end - x - width, 9)) + edges = ((1 << before) - 1) << (x - before) | \ + ((1 << after) - 1) << (x + width) + return not any(rows[y + r] & edges for r in range(GLYPH_HEIGHT)) + + +def find_line(layout, line, font=BODY_FONT): + """(x, y) of the topmost rendering of `line` (a prefix of a longer line + counts), or None.""" + width, want = _render(line, font) + rows = _rows(layout) + for y in range(64 - GLYPH_HEIGHT + 1): + for x in range(256 - width + 1): + if _at(rows, want, width, x, y, False): + return x, y + return None + + +def shows(layout, text, font=BODY_FONT, width=BODY_WIDTH, + pitch=BODY_LINE_PITCH): + """Whether this screen shows `text` as confirm() draws a body, or as + draw_string() draws it at another wrap width and line pitch.""" + lines = [_render(line, font) for line in wrap(text, font, width)] + rows = _rows(layout) + height = (len(lines) - 1) * pitch + GLYPH_HEIGHT + for y in range(64 - height + 1): + for x in range(256 - lines[0][0] + 1): + if all(_at(rows, want, w, x, y + i * pitch, True, x + width) + for i, (w, want) in enumerate(lines)): + return True + return False + + +# How lib/firmware/app_layout.c draws an address or key beside its QR code, +# as (font, wrap width, line pitch): +ADDRESS_LAYOUTS = ( + # layout_address_notification(): bold while it fits TRANSACTION_WIDTH... + (TITLE_FONT, 250, BODY_LINE_PITCH), + # ...else the body font, its lines closed up to the font height. + (BODY_FONT, 250, GLYPH_HEIGHT), + # layout_{ethereum,cosmos,nano}_address_notification(); two lines fit. + (BODY_FONT, 140, BODY_LINE_PITCH), + # layout_osmosis_address_notification() + (BODY_FONT, 160, BODY_LINE_PITCH), + # confirm() bodies and layout_xpub_notification() (TRANSACTION_WIDTH - 25) + (BODY_FONT, BODY_WIDTH, BODY_LINE_PITCH), +) + + +def shows_address(layout, text): + """Whether this screen shows ALL of `text` in one of ADDRESS_LAYOUTS. A + layout that drops the lines past the bottom of the screen does not.""" + return any(shows(layout, text, font, width, pitch) + for font, width, pitch in ADDRESS_LAYOUTS) + + +def find_text(screens, text, font=BODY_FONT, width=BODY_WIDTH, start=0): + """Index of the first screen from `start` that shows `text`, or None.""" + for i in range(start, len(screens)): + if shows(screens[i], text, font, width): + return i + return None + + +def _generate(font_c): + src = open(font_c).read() + data = {m.group(1): [int(v, 16) for v in + re.findall(r'0x([0-9a-fA-F]{2})', m.group(2))] + for m in re.finditer( + r'static const uint8_t (image_data_\w+)\[[^\]]*\]\s*=\s*' + r'\{([^}]*)\}', src)} + images = {m.group(1): (m.group(2), int(m.group(3)), int(m.group(4))) + for m in re.finditer( + r'static const CharacterImage (\w+)\s*=\s*\{\s*(\w+),\s*' + r'(\d+),\s*(\d+)\s*\}', src)} + out = {} + for name in ('title_font', 'body_font'): + array = re.search(r'static const Character %s_array\[\]\s*=\s*\{(.*?)' + r'\n\};' % name, src, re.S).group(1) + glyphs = {} + for code, image in re.findall(r'\{\s*(0x[0-9a-fA-F]+)\s*,\s*&(\w+)\s*\}', + array): + if not 0x20 <= int(code, 16) <= 0x7e: + continue + pixels, width, height = images[image] + assert height == GLYPH_HEIGHT, image + ink = data[pixels] + assert len(ink) == width * height, image + glyphs[int(code, 16)] = (width, [ + sum(1 << x for x in range(width) if ink[y * width + x] == 0) + for y in range(height)]) + assert sorted(glyphs) == list(range(0x20, 0x7f)), name + digits = (max(w for w, _ in glyphs.values()) + 3) // 4 + entries = ['%d:%s' % (w, ''.join('%0*x' % (digits, r) for r in rows)) + for w, rows in (glyphs[c] for c in range(0x20, 0x7f))] + out[name] = '\n'.join(' '.join(entries[i:i + 4]) + for i in range(0, len(entries), 4)) + return out + + +if __name__ == '__main__': + tables = _generate(sys.argv[1]) + print('TITLE_FONT = _font("""\n%s\n""")\n' % tables['title_font']) + print('BODY_FONT = _font("""\n%s\n""")' % tables['body_font']) diff --git a/tests/test_clearsign_abi.py b/tests/test_clearsign_abi.py new file mode 100644 index 00000000..7b1c7f63 --- /dev/null +++ b/tests/test_clearsign_abi.py @@ -0,0 +1,156 @@ +import os +import subprocess +import sys +import unittest +from decimal import Decimal +from fractions import Fraction + +import pytest + +from keepkeylib.clearsign_abi import canonical_signature, encode_static_args, selector + + +@pytest.mark.parametrize('typ', ['uint8', 'int8']) +@pytest.mark.parametrize('value', [1.9, -0.9, -1.9, Decimal('1.9'), + Decimal('-0.9'), Fraction(19, 10), + Fraction(-9, 10)]) +def test_integer_arguments_reject_fractional_values(typ, value): + with pytest.raises(ValueError): + encode_static_args([typ], [value]) + + +@pytest.mark.parametrize('typ,value,expected', [ + ('uint8', '255', 255), ('int8', '-128', -128), + ('uint8', 1.0, 1), ('int8', -1.0, -1), + ('uint256', str(2**256 - 1), 2**256 - 1), + ('int256', str(-(2**255)), -(2**255)), + ('uint8', Decimal('2'), 2), ('int8', Fraction(-2, 1), -2), +]) +def test_integer_arguments_preserve_exact_values(typ, value, expected): + assert encode_static_args([typ], [value]) == expected.to_bytes( + 32, 'big', signed=typ.startswith('int')) + + +class TestClearsignAbiSignedIntegers(unittest.TestCase): + + def test_negative_int8_is_sign_extended(self): + self.assertEqual(encode_static_args(['int8'], [-1]), b'\xff' * 32) + self.assertEqual( + encode_static_args(['int8'], [-128]), + b'\xff' * 31 + b'\x80', + ) + + def test_int8_bounds_are_enforced(self): + self.assertEqual( + encode_static_args(['int8'], [127]), + b'\x00' * 31 + b'\x7f', + ) + for value in (-129, 128): + with self.assertRaises(ValueError): + encode_static_args(['int8'], [value]) + + def test_uint8_keeps_unsigned_bounds(self): + self.assertEqual( + encode_static_args(['uint8'], [255]), + b'\x00' * 31 + b'\xff', + ) + for value in (-1, 256): + with self.assertRaises(ValueError): + encode_static_args(['uint8'], [value]) + + def test_validation_survives_optimized_python(self): + """Interpreter optimization must not remove calldata validation.""" + script = r''' +from keepkeylib.clearsign_abi import canonical_signature, encode_static_args, selector + +invalid = ( + (['address'], [b'\x11' * 19]), + (['uint8'], []), + (['uint8'], [256]), + (['int8'], [128]), + (['uint8'], [-0.9]), + (['int8'], [-1.9]), + (['bytes2'], [b'\x11']), +) +for types, values in invalid: + try: + encode_static_args(types, values) + except ValueError: + continue + raise SystemExit('accepted invalid ABI value: %r %r' % (types, values)) +''' + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + subprocess.check_call([sys.executable, '-O', '-c', script], cwd=repo_root) + + +class TestClearsignAbiTypeValidation(unittest.TestCase): + """The encoder must refuse types Solidity does not have. + + Emitting a plausible 32-byte word for `uint7` or `int264` makes a fixture + read as a real ABI encoding while encoding a type no compiler can produce. + """ + + def test_non_multiple_of_eight_widths_are_rejected(self): + for typ in ('uint7', 'int7', 'uint255', 'int13'): + with self.assertRaises(ValueError): + encode_static_args([typ], [1]) + + def test_zero_and_oversized_widths_are_rejected(self): + for typ in ('uint0', 'int0', 'uint264', 'int264', 'uint512'): + with self.assertRaises(ValueError): + encode_static_args([typ], [0]) + + def test_valid_widths_still_encode(self): + for typ in ('uint8', 'uint16', 'uint256', 'uint', 'int8', 'int256', 'int'): + self.assertEqual(len(encode_static_args([typ], [1])), 32) + + def test_bool_requires_an_actual_bool(self): + # 1 and 'false' would both have become ABI true. + for val in (1, 0, 'false', 'true', 2, None): + with self.assertRaises(ValueError): + encode_static_args(['bool'], [val]) + self.assertEqual(encode_static_args(['bool'], [True]), + b'\x00' * 31 + b'\x01') + self.assertEqual(encode_static_args(['bool'], [False]), b'\x00' * 32) + + def test_fixed_bytes_width_is_bounded(self): + for typ in ('bytes0', 'bytes33', 'bytes64'): + with self.assertRaises(ValueError): + encode_static_args([typ], [b'\x11' * 32]) + + def test_oversized_fixed_bytes_cannot_shift_later_arguments(self): + """bytes33 used to emit 33 bytes -- ljust does not truncate -- which + pushed every following argument one byte to the right.""" + with self.assertRaises(ValueError): + encode_static_args(['bytes33', 'uint256'], [b'\x11' * 33, 1]) + + def test_valid_fixed_bytes_still_encode_left_aligned(self): + self.assertEqual(encode_static_args(['bytes1'], [b'\xab']), + b'\xab' + b'\x00' * 31) + self.assertEqual(len(encode_static_args(['bytes32'], [b'\x11' * 32])), 32) + + def test_arrays_report_the_dynamic_type_error(self): + for typ in ('uint256[]', 'address[]', 'uint256[2]'): + with self.assertRaisesRegex(ValueError, 'dynamic/unsupported type'): + encode_static_args([typ], [[1]]) + + +class TestClearsignAbiSelectors(unittest.TestCase): + def test_integer_aliases_inside_composite_types(self): + for shorthand, canonical in ( + ('f(uint[])', 'f(uint256[])'), + ('f(int[2][][4])', 'f(int256[2][][4])'), + ('f((uint,int[]),uint[3])', 'f((uint256,int256[]),uint256[3])'), + ('f((address,(uint,int)[])[])', 'f((address,(uint256,int256)[])[])'), + ): + with self.subTest(signature=shorthand): + self.assertEqual(canonical_signature(shorthand), canonical) + self.assertEqual(selector(shorthand), selector(canonical)) + + def test_aliases_do_not_rewrite_function_names_or_other_identifiers(self): + for signature in ('uint(uint8,int32)', 'f(myuint,my_int,uint256)'): + self.assertEqual(canonical_signature(signature), signature) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_confirmation_pages.py b/tests/test_confirmation_pages.py new file mode 100644 index 00000000..6de76df9 --- /dev/null +++ b/tests/test_confirmation_pages.py @@ -0,0 +1,40 @@ +import pytest + +from confirmation_pages import ConfirmationPages +from keepkeylib import types_pb2 as types + + +OUTPUT = types.ButtonRequest_ConfirmOutput +OTHER = types.ButtonRequest_Other + + +def test_every_page_is_joined_in_order_without_deduplicating_text(): + pages = ConfirmationPages() + pages.add(OUTPUT, ('Address 1/3', 'abc')) + pages.add(OTHER, ('Address 2/3', 'abc')) + pages.add(OTHER, ('Address 3/3', 'def')) + pages.add(OUTPUT, ('Address', 'abcabcdef')) + assert list(pages) == [(OUTPUT, 'Address', 'abcabcdef')] * 2 + assert len(pages.raw) == 4 + + +@pytest.mark.parametrize('next_code,next_title', [ + (OTHER, 'Address 3/3'), (OTHER, 'Address 1/3'), + (OTHER, 'Address 2/4'), (OTHER, 'Other 2/3'), + (OUTPUT, 'Address 2/3'), (OUTPUT, 'Done'), +]) +def test_missing_repeated_or_changed_pages_fail(next_code, next_title): + pages = ConfirmationPages() + pages.add(OUTPUT, ('Address 1/3', 'abc')) + with pytest.raises(AssertionError): + pages.add(next_code, (next_title, 'def')) + + +def test_incomplete_and_orphaned_sequences_fail(): + pages = ConfirmationPages() + with pytest.raises(AssertionError): + pages.add(OTHER, ('Address 2/2', 'def')) + pages = ConfirmationPages() + pages.add(OUTPUT, ('Address 1/2', 'abc')) + with pytest.raises(AssertionError): + list(pages) diff --git a/tests/test_dylib_confirm_flow.py b/tests/test_dylib_confirm_flow.py index ea4ab088..cb801efd 100644 --- a/tests/test_dylib_confirm_flow.py +++ b/tests/test_dylib_confirm_flow.py @@ -75,6 +75,22 @@ def test_features_round_trip(self): self.client.init_device() f = self.client.features self.assertGreaterEqual(f.major_version, 7) + revision = f.revision.decode("ascii") + self.assertRegex(revision, r"^[0-9a-f]{40}$") + # GITHUB_SHA is the synthetic pull-request merge commit for PR jobs, + # even when the workflow deliberately checks out the PR's head commit. + # The caller must therefore pass the revision of the source tree that + # actually produced the dylib instead of relying on GitHub's ambient + # merge-ref value. + expected_revision = os.environ.get("KK_EXPECTED_FIRMWARE_REVISION") + if os.environ.get("GITHUB_ACTIONS") == "true": + self.assertIsNotNone( + expected_revision, + "GitHub Actions must bind the dylib test to its checked-out source revision", + ) + if expected_revision: + self.assertRegex(expected_revision, r"^[0-9a-f]{40}$") + self.assertEqual(revision, expected_revision) @unittest.skip( "Pending firmware fix — confirm_helper busy-loops on a ButtonAck " diff --git a/tests/test_erc7730_catalog.py b/tests/test_erc7730_catalog.py new file mode 100644 index 00000000..1f791c8f --- /dev/null +++ b/tests/test_erc7730_catalog.py @@ -0,0 +1,144 @@ +import hashlib + +import pytest +from ecdsa import SECP256k1, SigningKey, util + +from keepkeylib import erc7730 +from keepkeylib import messages_ethereum_pb2 as ethereum +from keepkeylib.signed_metadata import TEST_PRIVATE_KEY + + +ADDRESS = bytes.fromhex("11" * 20) +SELECTOR = bytes.fromhex("1fece7b4") + + +def minimal_program(): + program = bytearray(179) + program[:4] = b"C773" + program[4:8] = bytes((1, 2, 0, 1)) + program[10:18] = (1).to_bytes(8, "big") + program[18:38] = ADDRESS + program[38:42] = SELECTOR + return bytes(program) + + +def definition(payload=b"signed-envelope"): + return erc7730.Definition(payload, ethereum.ERC7730_CALLDATA, 1, + ADDRESS, SELECTOR) + + +def request(**kwargs): + values = dict(kind=ethereum.ERC7730_CALLDATA, chain_id=1, + contract_address=ADDRESS, + selector_or_type_hash=SELECTOR, offset=0, length=1024, + recursion_depth=0) + values.update(kwargs) + return ethereum.EthereumClearSignDefinitionRequest(**values) + + +def test_catalog_serves_exact_lookup_and_id_replays(): + item = definition() + catalog = erc7730.Catalog((item,)) + first = catalog.chunk(request()) + assert first.definition_id == hashlib.sha256(item.envelope).digest() + assert first.data == item.envelope + replay = catalog.chunk(ethereum.EthereumClearSignDefinitionRequest( + kind=ethereum.ERC7730_CALLDATA, chain_id=1, + definition_id=item.definition_id, offset=0, length=1024, + recursion_depth=1)) + assert replay.SerializeToString() == first.SerializeToString() + + +def test_catalog_refuses_unknown_ambiguous_and_malformed_requests(): + item = definition() + catalog = erc7730.Catalog((item,)) + with pytest.raises(KeyError): + catalog.chunk(request(selector_or_type_hash=b"\0" * 4)) + with pytest.raises(ValueError): + catalog.chunk(request(length=1025)) + with pytest.raises(ValueError): + catalog.chunk(request(recursion_depth=5)) + with pytest.raises(ValueError): + catalog.add(definition(b"different-envelope")) + + +def test_catalog_answers_an_unknown_embedded_call_with_none(): + # A top-level lookup the catalog cannot satisfy is an error; an embedded + # call's (recursion_depth set) gets the empty "none" chunk, which firmware + # 7.15 shows under a blind-sign warning and 7.16 rejects. + catalog = erc7730.Catalog((definition(),)) + none = catalog.chunk(request(selector_or_type_hash=b"\0" * 4, + recursion_depth=1)) + assert (none.offset, none.total_length, none.data) == (0, 0, b"") + assert len(none.definition_id) == 32 + with pytest.raises(KeyError): + catalog.chunk(request(selector_or_type_hash=b"\0" * 4)) + + +class PreloadClient(object): + def __init__(self, envelope): + self.envelope = envelope + self.offset = 0 + + def call(self, message): + assert isinstance(message, ethereum.EthereumClearSignDefinition) + assert message.offset == self.offset + assert message.data == self.envelope[self.offset:self.offset + 1024] + self.offset += len(message.data) + return ethereum.EthereumClearSignDefinitionAck( + definition_id=message.definition_id, + next_offset=self.offset, + complete=self.offset == len(self.envelope), + ) + + +def test_preload_streams_and_checks_every_acknowledgement(): + item = definition(bytes(range(256)) * 9) + client = PreloadClient(item.envelope) + erc7730.preload(client, item) + assert client.offset == len(item.envelope) + + +def test_signed_catalog_envelope_commits_program_proof_and_certificate(): + program = minimal_program() + proof = (bytes.fromhex("22" * 32), bytes.fromhex("33" * 32)) + certificate = bytes(range(139)) + envelope = erc7730.sign_envelope( + program, certificate, TEST_PRIVATE_KEY, proof) + assert envelope == erc7730.sign_envelope( + program, certificate, TEST_PRIVATE_KEY, proof) + assert envelope[:10] == b"K773\x01\x01" + len(program).to_bytes(4, "big") + cursor = 10 + len(program) + assert envelope[cursor] == len(proof) + cursor += 1 + assert envelope[cursor:cursor + 64] == b"".join(proof) + cursor += 64 + assert envelope[cursor:cursor + 2] == (139).to_bytes(2, "big") + cursor += 2 + assert envelope[cursor:cursor + 139] == certificate + cursor += 139 + signature = envelope[cursor:cursor + 64] + assert envelope[cursor + 64] in (0, 1) + assert len(envelope) == cursor + 65 + + digest = hashlib.sha256( + erc7730.CATALOG_DOMAIN + erc7730.catalog_root(program, proof)).digest() + public_key = SigningKey.from_string( + TEST_PRIVATE_KEY, curve=SECP256k1).get_verifying_key() + assert public_key.verify_digest(signature, digest, + sigdecode=util.sigdecode_string) + assert erc7730.catalog_root(program, proof) != erc7730.catalog_root(program) + + +@pytest.mark.parametrize("program,certificate,private_key,proof", [ + (b"short", bytes(139), TEST_PRIVATE_KEY, ()), + (minimal_program(), bytes(138), TEST_PRIVATE_KEY, ()), + (minimal_program(), bytes(139), bytes(31), ()), + (minimal_program(), bytes(139), TEST_PRIVATE_KEY, (bytes(31),)), + (minimal_program(), bytes(139), TEST_PRIVATE_KEY, + tuple(bytes(32) for _ in range(17))), +]) +def test_signed_catalog_envelope_rejects_invalid_bounds( + program, certificate, private_key, proof): + with pytest.raises(ValueError): + erc7730.sign_envelope(program, certificate, private_key, proof) diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py new file mode 100644 index 00000000..6a3ea0a2 --- /dev/null +++ b/tests/test_erc7730_compiler.py @@ -0,0 +1,660 @@ +from __future__ import absolute_import + +import hashlib +import json +import os +import subprocess +import struct +import tempfile + +import pytest + +import common + +from keepkeylib.erc7730_compiler import ( + HEADER_SIZE, DeviceCannotExecute, compile_calldata, compile_eip712, + device_refusal, eip712_encode_type, load_descriptor, + parse_function_signature, +) +from keepkeylib.signed_metadata import keccak256 + + +# CI sets KK_REQUIRE_ERC7730_EVIDENCE=1. There, a missing firmware validator or +# official registry is a FAILURE: these tests are reported as firmware and +# registry conformance evidence, and silently skipping that step would let a +# host-only compile masquerade as firmware acceptance. Elsewhere the absence is +# reported as an explicit skip, never as a pass. +REQUIRE_EVIDENCE = os.environ.get("KK_REQUIRE_ERC7730_EVIDENCE") == "1" + + +def _evidence_input(name): + value = os.environ.get(name) + if value: + return value + message = "%s is not configured" % name + if REQUIRE_EVIDENCE: + pytest.fail(message + " (required by KK_REQUIRE_ERC7730_EVIDENCE=1)") + pytest.skip(message) + + +def _firmware_accepts(program): + # The firmware under test must implement the interpreter the validator + # is built from; it says so in Features.capabilities. + reported = common.device_capabilities() + if reported is not None and "erc7730-runtime-review" not in reported: + pytest.skip("Staged release tree does not yet provide capability: " + "erc7730-runtime-review") + validator = _evidence_input("ERC7730_FIRMWARE_VALIDATOR") + with tempfile.NamedTemporaryFile() as compiled: + compiled.write(program) + compiled.flush() + return subprocess.call([validator, compiled.name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) == 0 + + +def _unchecked(compile, *args, **kwargs): + return compile(*args, executable_only=False, **kwargs) + + +def _firmware_validate(program, refusal=None): + """The device verifier accepts `program` exactly when the compiler's + capability mirror does, and `refusal` names the expected reason (None + for a program the device executes).""" + assert device_refusal(program) == refusal + assert _firmware_accepts(program) == (refusal is None) + + +def _sections(program): + count = program[178] + offset = HEADER_SIZE + result = {} + for _ in range(count): + kind = program[offset] + length = struct.unpack(">I", program[offset + 1:offset + 5])[0] + result[kind] = program[offset + 5:offset + 5 + length] + offset += 5 + length + assert offset == len(program) + return result + + +def _rlp_item(data, offset=0): + prefix = data[offset] + if prefix <= 0x7f: + return bytes([prefix]), offset + 1 + if prefix <= 0xb7: + length = prefix - 0x80 + start = offset + 1 + return data[start:start + length], start + length + if prefix <= 0xbf: + width = prefix - 0xb7 + length = int.from_bytes(data[offset + 1:offset + 1 + width], "big") + start = offset + 1 + width + return data[start:start + length], start + length + if prefix <= 0xf7: + length = prefix - 0xc0 + start = offset + 1 + else: + width = prefix - 0xf7 + length = int.from_bytes(data[offset + 1:offset + 1 + width], "big") + start = offset + 1 + width + end = start + length + values = [] + while start < end: + value, start = _rlp_item(data, start) + values.append(value) + assert start == end + return values, end + + +def _ethereum_transaction(raw_hex): + raw = bytes.fromhex(raw_hex[2:]) + typed = raw[0] in (1, 2, 3, 4) + transaction, end = _rlp_item(raw, 1 if typed else 0) + assert end == len(raw) + if typed and raw[0] == 2: + return int.from_bytes(transaction[0], "big"), transaction[5], transaction[7] + if typed: + raise ValueError("unsupported typed fixture") + return None, transaction[3], transaction[5] + + +def test_parses_recursive_tuple_and_array_signature(): + name, root = parse_function_signature( + "route((address token,uint256 amount)[] legs,address recipient)") + assert name == "route" + assert root.children[0].name == "legs" + assert root.children[0].kind == 9 + assert root.children[0].children[0].children[1].name == "amount" + + +def test_compiles_deterministic_canonical_calldata_program(): + descriptor = { + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + "context": {"contract": {"deployments": [ + {"chainId": 1, + "address": "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45"} + ]}}, + "display": {"formats": { + "swapExactTokensForTokens(uint256 amountIn,uint256 amountOutMin,address[] path,address to)": { + "intent": "Swap", + "fields": [ + {"path": "amountIn", "label": "Amount to Send", + "format": "tokenAmount", + "params": {"tokenPath": "path.[0]"}}, + {"path": "amountOutMin", "label": "Minimum to Receive", + "format": "tokenAmount", + "params": {"tokenPath": "path.[-1]"}}, + {"path": "to", "label": "Recipient", + "format": "addressName"}, + ], + } + }}, + } + kwargs = dict( + signature="swapExactTokensForTokens(uint256 amountIn,uint256 amountOutMin,address[] path,address to)", + chain_id=1, + address="0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45", + token_records=[ + (1, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "WETH", 18), + ], + network_records=[(1, "Ethereum", "ETH", 18)], + ) + first = _unchecked(compile_calldata, descriptor, **kwargs) + second = _unchecked(compile_calldata, descriptor, **kwargs) + assert first == second + assert first[:4] == b"C773" + assert first[4:8] == bytes([1, 2, 0, 1]) + assert first[10:18] == struct.pack(">Q", 1) + assert first[18:38] == bytes.fromhex( + "68b3465833fb72a70ecdf485e0e4c7bd8665fc45") + # Registry fixture has the four-argument router variant; the selector is + # computed from its canonical ABI rather than copied from descriptor data. + assert first[38:42].hex() == "472b43f3" + assert first[42:70] == bytes(28) + sections = _sections(first) + assert set(sections) == {1, 2, 3, 6, 7, 8, 9} + assert hashlib.sha256(first).digest() == hashlib.sha256(second).digest() + assert len(first) < 16384 + _firmware_validate(first) + + +def test_compiles_official_uniswap_tuple_fixture_through_firmware(): + registry = _evidence_input("ERC7730_REGISTRY") + path = os.path.join( + registry, "registry", "uniswap", "calldata-UniswapV3Router02.json") + with open(path, "r") as source: + descriptor = json.load(source) + tests_path = os.path.join( + registry, "registry", "uniswap", "testsv2", + "calldata-UniswapV3Router02.tests.json") + with open(tests_path, "r") as source: + fixtures = json.load(source)["tests"] + signature = ( + "exactInputSingle((address tokenIn, address tokenOut, uint24 fee, " + "address recipient, uint256 amountIn, uint256 amountOutMinimum, " + "uint160 sqrtPriceLimitX96) params)") + compiled = _unchecked(compile_calldata, + descriptor, signature, 1, + "0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45", + token_records=[ + (1, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", "WETH", 18), + (1, "0xdac17f958d2ee523a2206206994597c13d831ec7", "USDT", 6), + ], + network_records=[(1, "Ethereum", "ETH", 18)], + ) + assert compiled[38:42].hex() == "04e45aaf" + chain_id, target, calldata = _ethereum_transaction(fixtures[1]["rawTx"]) + assert chain_id == 1 + assert target.hex() == "68b3465833fb72a70ecdf485e0e4c7bd8665fc45" + assert calldata[:4] == compiled[38:42] + assert fixtures[1]["txHash"] == ( + "0xb25281abb3e6bbfe18c746187522c2e915aa02fdb8175082005340e00c1f0b30") + _firmware_validate(compiled) + + +def test_compiles_and_checks_exact_keepkey_sdk_thorchain_swap(): + fixture_path = os.path.join( + os.path.dirname(__file__), "fixtures", "erc7730-thorchain-router-v3.json") + with open(fixture_path, "r") as source: + fixture = json.load(source) + calldata = bytes.fromhex(fixture["data"][2:]) + expected = fixture["expected"] + assert calldata[:4].hex() == expected["selector"] + assert ("0x" + calldata[16:36].hex()) == expected["vault"] + assert ("0x" + calldata[48:68].hex()) == expected["asset"] + assert str(int.from_bytes(calldata[68:100], "big")) == expected["amount"] + dynamic_offset = int.from_bytes(calldata[100:132], "big") + memo_length = int.from_bytes( + calldata[4 + dynamic_offset:4 + dynamic_offset + 32], "big") + memo = calldata[4 + dynamic_offset + 32: + 4 + dynamic_offset + 32 + memo_length].decode("utf-8") + assert memo == expected["memo"] + assert int(fixture["value"], 16) == int(expected["amount"]) + + compiled = _unchecked(compile_calldata, + fixture["descriptor"], fixture["signature"], fixture["chainId"], + fixture["to"], network_records=[(1, "Ethereum", "ETH", 18)]) + assert compiled[38:42].hex() == expected["selector"] + _firmware_validate(compiled) + + +def test_compiles_array_iteration_separator_and_optional_visibility(): + descriptor = { + "display": {"formats": { + "batch((address recipient,uint256 amount)[] items)": { + "intent": "Batch transfer", + "fields": [{ + "path": "items.[].recipient", + "label": "Recipient", + "format": "addressName", + "separator": "Next recipient", + "visible": "optional", + }], + } + }} + } + compiled = _unchecked(compile_calldata, + descriptor, + "batch((address recipient,uint256 amount)[] items)", 1, + "0x1111111111111111111111111111111111111111") + sections = _sections(compiled) + assert 5 in sections + display = sections[7] + count = int.from_bytes(display[:2], "big") + opcodes = [display[2 + i * 8] for i in range(count)] + assert opcodes == [1, 7, 4, 8, 10] + begin = display[10:18] + end = display[26:34] + assert int.from_bytes(begin[6:8], "big") == 3 + assert int.from_bytes(end[2:4], "big") == 1 + _firmware_validate(compiled) + + +def test_refuses_scalar_value_repeated_inside_iteration(): + descriptor = {"display": {"formats": { + "batch(address[] recipients,address fallback)": { + "intent": "Batch transfer", + "fields": [ + {"path": "recipients.[]", "label": "Recipient", + "format": "addressName", "separator": "Next recipient"}, + {"path": "fallback", "label": "Fallback", + "format": "addressName"}, + ], + } + }}} + program = bytearray(_unchecked( + compile_calldata, descriptor, + "batch(address[] recipients,address fallback)", 1, + "0x1111111111111111111111111111111111111111")) + _firmware_validate(bytes(program)) + # Replace the iterated formatter's path with the scalar formatter's path. + # The display still contains an iteration, so the device must refuse it. + offset = HEADER_SIZE + while offset < len(program): + kind = program[offset] + length = struct.unpack_from(">I", program, offset + 1)[0] + if kind == 6: + start = offset + 5 + assert struct.unpack_from(">H", program, start)[0] == 2 + assert program[start + 7:start + 9] != program[start + 14:start + 16] + program[start + 7:start + 9] = program[start + 14:start + 16] + break + offset += 5 + length + else: + pytest.fail("formatter section missing") + _firmware_validate(bytes(program), + "a field reads another array than its iteration") + + +def test_refuses_nested_array_iteration_the_device_cannot_verify(): + # The device's catalog verifier accepts one "[]" step per path, so a + # program iterating a nested array could never be loaded. The compiler + # refuses it by name instead of emitting a program the device rejects. + descriptor = {"display": {"formats": { + "matrix(uint256[][] values)": { + "intent": "Review matrix", + "fields": [{"path": "values.[].[]", "label": "Value", + "format": "raw", "separator": "Next row"}], + } + }}} + with pytest.raises(ValueError, match="iterates more than one array"): + compile_calldata( + descriptor, "matrix(uint256[][] values)", 1, + "0x1111111111111111111111111111111111111111") + + +def test_token_amount_without_token_uses_firmware_raw_fallback(): + descriptor = {"display": {"formats": { + "quote(uint256 amount)": { + "intent": "Review quote", + "fields": [{"path": "amount", "label": "Unknown token amount", + "format": "tokenAmount"}], + } + }}} + compiled = _unchecked(compile_calldata, + descriptor, "quote(uint256 amount)", 1, + "0x1111111111111111111111111111111111111111") + # With no token named the device can only show the raw integer, and its + # verifier refuses a tokenAmount formatter without a token argument. + formatter = _sections(compiled)[6] + assert formatter[2:5] == bytes([1, 0, 1]) + assert formatter[5:9] == bytes([1, 1, 0, 0]) + _firmware_validate(compiled) + + +def test_compiles_typed_if_not_in_and_must_match_conditions(): + descriptor = {"display": {"formats": { + "guard(uint256 mode,address recipient)": { + "intent": "Guarded call", + "fields": [ + {"path": "mode", "label": "Mode", "format": "raw", + "visible": {"ifNotIn": [0, 255]}}, + {"path": "recipient", "label": "Bound recipient", + "format": "addressName", + "visible": {"mustMatch": [ + "0x2222222222222222222222222222222222222222"]}}, + ], + } + }}} + compiled = _unchecked(compile_calldata, + descriptor, "guard(uint256 mode,address recipient)", 1, + "0x1111111111111111111111111111111111111111") + sections = _sections(compiled) + conditions = sections[5] + assert int.from_bytes(conditions[:2], "big") == 2 + assert conditions[2] == 7 + assert conditions[10] == 8 + literals = sections[4] + assert int.from_bytes(literals[:2], "big") == 5 + _firmware_validate(compiled, + "condition opcode 7 is not executed") + + +def test_compiles_interpolated_intent_and_metadata_enum(): + descriptor = { + "metadata": {"enums": {"side": {"false": "Buy", "true": "Sell"}}}, + "display": {"formats": { + "swap(bool selling,uint256 amount)": { + "intent": "Swap", + "interpolatedIntent": "Swap {amount} as {selling}", + "fields": [ + {"path": "selling", "label": "Side", "format": "enum", + "params": {"$ref": "$.metadata.enums.side"}, + "visible": "always"}, + {"path": "amount", "label": "Amount", "format": "raw", + "visible": "always"}, + ], + } + }} + } + compiled = _unchecked(compile_calldata, + descriptor, "swap(bool selling,uint256 amount)", 1, + "0x1111111111111111111111111111111111111111") + sections = _sections(compiled) + display = sections[7] + count = int.from_bytes(display[:2], "big") + opcodes = [display[2 + i * 8] for i in range(count)] + assert opcodes == [1, 2, 3, 2, 3, 4, 4, 10] + formatters = sections[6] + assert int.from_bytes(formatters[:2], "big") == 2 + assert formatters[2] == 8 + literals = sections[4] + assert int.from_bytes(literals[:2], "big") == 3 + _firmware_validate(compiled) + + +def test_compiles_nested_field_group_with_balanced_links(): + descriptor = {"display": {"formats": { + "act((address owner,uint256 amount) details)": { + "intent": "Grouped action", + "fields": [{ + "path": "details", "label": "Details", "fields": [ + {"path": "owner", "label": "Owner", + "format": "addressName"}, + {"path": "amount", "label": "Amount", "format": "raw"}, + ], + }], + } + }}} + compiled = _unchecked(compile_calldata, + descriptor, "act((address owner,uint256 amount) details)", 1, + "0x1111111111111111111111111111111111111111") + display = _sections(compiled)[7] + count = int.from_bytes(display[:2], "big") + instructions = [display[2 + i * 8:10 + i * 8] for i in range(count)] + assert [item[0] for item in instructions] == [1, 5, 4, 4, 6, 10] + assert int.from_bytes(instructions[1][6:8], "big") == 4 + assert int.from_bytes(instructions[4][2:4], "big") == 1 + _firmware_validate(compiled) + + +def test_loads_bounded_includes_and_compiles_array_backed_group(tmp_path): + shared = { + "display": {"definitions": { + "recipient": {"label": "Recipient", "format": "addressName"} + }} + } + descriptor = { + "includes": "shared.json", + "display": {"formats": { + "batch((address to,uint256 amount)[] items)": { + "intent": "Batch", + "fields": [{"path": "items.[]", "label": "Item", + "fields": [ + {"path": "to", + "$ref": "$.display.definitions.recipient"}, + {"path": "amount", "label": "Amount", + "format": "raw"}, + ]}], + } + }} + } + (tmp_path / "shared.json").write_text(json.dumps(shared)) + path = tmp_path / "descriptor.json" + path.write_text(json.dumps(descriptor)) + loaded = load_descriptor(str(path), str(tmp_path)) + compiled = _unchecked(compile_calldata, + loaded, "batch((address to,uint256 amount)[] items)", 1, + "0x1111111111111111111111111111111111111111") + display = _sections(compiled)[7] + count = int.from_bytes(display[:2], "big") + opcodes = [display[2 + i * 8] for i in range(count)] + assert opcodes == [1, 7, 5, 4, 4, 6, 8, 10] + _firmware_validate(compiled) + + +DEVICE_LIMITS = ( + "iterates more than one array", + "nests deeper than the device supports", +) + + +# Formats the device can fully sign with this firmware's capability table. +# Each later phase of the ERC-7730 formatter plan raises this number +# (docs/security/HANDOFF-ERC7730-715-FORMATTERS.md in keepkey-firmware). +# Phase 0 signed 92 (raw fields only). Phase A adds tokenAmount, addressName, +# @.from/@.to and signed constants: 812. It refuses addressName and +# tokenAmount over bytes32/uint256 words that pack an address or an encrypted +# amount, rather than reinterpret bytes the calldata does not say are one. +# Phase B adds the interpolated intent, shown as numbered parts: 954. +# Phase C adds amount, nftName, date, duration, unit, enum and @.value: 1138. +# Phase D adds groups, single-array iteration and "optional" fields. The +# reviewed array-binding rule refuses 21 registry programs whose auxiliary +# formatter paths do not walk the displayed item's array: 1273. +# Phase E1 adds embedded calldata, shown under a blind-sign warning. The +# strict formatter rule refuses 29 formerly accepted programs in this phase: +# 1297 remain signable across the 1450 checked registry formats. +REGISTRY_SIGNABLE = 1297 + + +def test_official_registry_all_calldata_formats_reach_firmware(): + registry = _evidence_input("ERC7730_REGISTRY") + _evidence_input("ERC7730_FIRMWARE_VALIDATOR") + import glob + failures = [] + unsupported = [] + signable = 0 + checked = 0 + for path in glob.glob(os.path.join( + registry, "registry", "**", "calldata-*.json"), recursive=True): + descriptor = load_descriptor(path, registry) + deployments = descriptor.get("context", {}).get( + "contract", {}).get("deployments", []) + if not deployments: + continue + deployment = deployments[0] + if (not isinstance(deployment.get("chainId"), int) or + not isinstance(deployment.get("address"), str)): + continue + for signature in descriptor.get("display", {}).get("formats", {}): + checked += 1 + try: + try: + program = _unchecked( + compile_calldata, descriptor, signature, + deployment["chainId"], deployment["address"]) + except ValueError as exc: + if any(reason in str(exc) for reason in DEVICE_LIMITS): + unsupported.append(signature) + continue + raise + # The device verifier and the compiler's capability mirror + # must agree on every format, in both directions. + executable = device_refusal(program) is None + if _firmware_accepts(program) != executable: + raise AssertionError( + "device and compiler disagree: %r" % + device_refusal(program)) + signable += executable + except Exception as exc: + failures.append("%s :: %s :: %s" % ( + os.path.basename(path), signature, exc)) + assert checked == 1450 + assert failures == [] + # Every other format is refused by the compiler for a named device limit: + # 8 iterate nested arrays, 2 nest their ABI deeper than 8 levels. + assert len(unsupported) == 10 + # Passing the parser is not signability: only these pass the device's + # preload capability checks. + assert signable == REGISTRY_SIGNABLE + + +def test_compiles_official_uniswap_eip712_fixture_through_firmware(): + registry = _evidence_input("ERC7730_REGISTRY") + with open(os.path.join(registry, "registry", "uniswap", + "eip712-uniswap-permit2.json"), "r") as source: + descriptor = json.load(source) + with open(os.path.join(registry, "registry", "uniswap", "testsv2", + "eip712-uniswap-permit2.tests.json"), "r") as source: + fixture = json.load(source)["tests"][0]["data"] + encoded = eip712_encode_type(fixture["primaryType"], fixture["types"]) + assert encoded == ( + "PermitSingle(PermitDetails details,address spender,uint256 sigDeadline)" + "PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)") + compiled = _unchecked(compile_eip712, + descriptor, fixture, + token_records=[ + (1, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "USDC", 6) + ], network_records=[(1, "Ethereum", "ETH", 18)]) + assert compiled[7] == 2 + assert compiled[10:18] == (1).to_bytes(8, "big") + assert compiled[18:38].hex() == "000000000022d473030f116ddee9f6b43ac78ba3" + assert compiled[38:70] == keccak256(encoded.encode("ascii")) + sections = _sections(compiled) + binding = sections[8] + # deployment + name/chain/contract domain facts + token + network + assert int.from_bytes(binding[:2], "big") == 6 + _firmware_validate(compiled) + + +def test_mirror_applies_the_devices_abi_and_text_limits(): + # Each shape the device refuses at preload is refused by the mirror too, + # and a neighbour inside the limit is accepted by both. + address = "0x" + "11" * 20 + for length, refusal in ((64, None), + (65, "an ABI array exceeds the device limit")): + signature = "f(uint256[%d] a,uint256 b)" % length + descriptor = {"display": {"formats": {signature: { + "intent": "F", "fields": [ + {"path": "b", "label": "B", "format": "raw"}]}}}} + _firmware_validate(_unchecked(compile_calldata, descriptor, signature, + 1, address), refusal) + for label, refusal in (("Line one", None), + ("Line\none", "a program string is not printable text"), + ("Tab\there", "a program string is not printable text"), + ("Del\x7f", "a program string is not printable text")): + descriptor = {"display": {"formats": {"f(uint256 a)": { + "intent": "F", "fields": [ + {"path": "a", "label": label, "format": "raw"}]}}}} + _firmware_validate(_unchecked(compile_calldata, descriptor, + "f(uint256 a)", 1, address), refusal) + + +def test_signed_enum_keys_are_minimal_twos_complement(): + # -128 fits one byte (0x80); the device refuses a longer encoding. + for signature, key in (("f(int8 side)", -128), ("f(int16 side)", -32768), + ("f(int16 side)", -129), ("f(int8 side)", 127)): + descriptor = { + "metadata": {"enums": {"side": {str(key): "Edge", "1": "Long"}}}, + "display": {"formats": {signature: { + "intent": "F", "fields": [{ + "path": "side", "label": "Side", "format": "enum", + "params": {"$ref": "$.metadata.enums.side"}}]}}}} + _firmware_validate(compile_calldata(descriptor, signature, 1, + "0x" + "11" * 20), None) + +def test_iteration_binds_every_formatter_path_to_its_array(): + signature = ( + "batch((uint256 amount,address token)[] items,address[] other," + "address fallback)") + for token_path, refusal in ( + ("items.[].token", None), + ("other.[]", "a field reads another array than its iteration"), + ("fallback", "a field reads another array than its iteration")): + descriptor = {"display": {"formats": {signature: { + "intent": "Batch", "fields": [{ + "path": "items.[].amount", "label": "Amount", + "format": "tokenAmount", + "params": {"tokenPath": token_path}}]}}}} + program = _unchecked(compile_calldata, descriptor, signature, 1, + "0x" + "11" * 20) + _firmware_validate(program, refusal) + if refusal: + with pytest.raises(DeviceCannotExecute, match=refusal): + compile_calldata(descriptor, signature, 1, "0x" + "11" * 20) + + +def test_signer_constant_is_not_an_intent_value(): + signature = "pay(address recipient)" + descriptor = {"display": {"formats": {signature: { + "intent": "Pay", "fields": [ + {"value": "Signer claim", "label": "Claim", "format": "raw"}]}}}} + program = bytearray(_unchecked(compile_calldata, descriptor, signature, + 1, "0x" + "11" * 20)) + _firmware_validate(bytes(program)) # a signer-owned field is allowed + offset = HEADER_SIZE + while offset < len(program): + kind = program[offset] + length = struct.unpack_from(">I", program, offset + 1)[0] + if kind == 7: + instruction = offset + 5 + 2 + 8 + assert program[instruction] == 4 + formatter = struct.unpack_from(">H", program, instruction + 4)[0] + program[instruction:instruction + 8] = struct.pack( + ">BBHHH", 3, 0, formatter, 0xffff, 0xffff) + break + offset += 5 + length + else: + pytest.fail("display section missing") + _firmware_validate(bytes(program), + "a signer constant cannot be an intent value") + + +def test_encode_type_finds_struct_dependency_through_every_array_dimension(): + types = { + "Group": [{"name": "people", "type": "Person[2][][4]"}], + "Person": [{"name": "name", "type": "string"}], + } + assert eip712_encode_type("Group", types) == ( + "Group(Person[2][][4] people)Person(string name)") diff --git a/tests/test_erc7730_protocol_bindings.py b/tests/test_erc7730_protocol_bindings.py new file mode 100644 index 00000000..954d74fc --- /dev/null +++ b/tests/test_erc7730_protocol_bindings.py @@ -0,0 +1,71 @@ +from keepkeylib import mapping +from keepkeylib import messages_ethereum_pb2 as ethereum +from keepkeylib import messages_pb2 as messages +import pytest + + +def test_erc7730_message_ids_and_mapping(): + expected = { + 1709: ethereum.EthereumClearSignDefinition, + 1710: ethereum.EthereumClearSignDefinitionAck, + 1711: ethereum.EthereumClearSignDefinitionRequest, + 1712: ethereum.EthereumClearSignDefinitionChunk, + } + for wire_id, message_class in expected.items(): + assert mapping.get_class(wire_id) is message_class + assert mapping.get_type(message_class()) == wire_id + + assert messages.MessageType_EthereumClearSignDefinition == 1709 + assert messages.MessageType_EthereumClearSignDefinitionChunk == 1712 + + +def test_definition_chunk_round_trip_is_byte_exact(): + definition_id = bytes(range(32)) + payload = bytes((i * 17) & 0xFF for i in range(1024)) + original = ethereum.EthereumClearSignDefinitionChunk( + definition_id=definition_id, + offset=2048, + total_length=4097, + data=payload, + ) + + decoded = ethereum.EthereumClearSignDefinitionChunk() + decoded.ParseFromString(original.SerializeToString()) + assert decoded.definition_id == definition_id + assert decoded.offset == 2048 + assert decoded.total_length == 4097 + assert decoded.data == payload + + +def test_lookup_kinds_encode_expected_context(): + request = ethereum.EthereumClearSignDefinitionRequest( + kind=ethereum.ERC7730_CALLDATA, + chain_id=1, + contract_address=bytes.fromhex("11" * 20), + selector_or_type_hash=bytes.fromhex("a9059cbb"), + offset=0, + length=1024, + recursion_depth=0, + ) + assert request.IsInitialized() + + typed = ethereum.EthereumClearSignDefinitionRequest( + kind=ethereum.ERC7730_EIP712, + chain_id=1, + selector_or_type_hash=bytes.fromhex("22" * 32), + offset=1024, + length=512, + ) + assert typed.IsInitialized() + + +@pytest.mark.parametrize('chain_id', [1, 2**53 - 1, 2**53 + 1, 2**64 - 1]) +def test_lookup_chain_id_retains_full_uint64(chain_id): + request = ethereum.EthereumClearSignDefinitionRequest( + kind=ethereum.ERC7730_CALLDATA, chain_id=chain_id, offset=0, length=0) + wire = request.SerializeToString() + decoded = ethereum.EthereumClearSignDefinitionRequest.FromString(wire) + assert decoded.chain_id == chain_id + assert isinstance(decoded.chain_id, int) + options = request.DESCRIPTOR.fields_by_name['chain_id'].GetOptions() + assert options.jstype == options.JS_STRING diff --git a/tests/test_firmware_capabilities.py b/tests/test_firmware_capabilities.py new file mode 100644 index 00000000..c3fb4515 --- /dev/null +++ b/tests/test_firmware_capabilities.py @@ -0,0 +1,44 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +"""One test per Features.Capability value. + +Each passes when the firmware reports the capability and skips, with the +capability prefix the reports read, when it does not. Release reports learn +what a build lacks from these skips, so the device alone decides; nothing is +declared outside the firmware. Firmware before 7.15.0 sends no list, so these +tests pass there. +""" + +import unittest + +import common +from keepkeylib import messages_pb2 + + +class TestFirmwareCapabilities(common.KeepKeyTest): + pass + + +def _capability_test(name): + def test(self): + self.requires_release_capability(name) + return test + + +for _value in messages_pb2.Features.Capability.values(): + _enum_name = messages_pb2.Features.Capability.Name(_value) + if _value == 0: + continue + _name = _enum_name[len("CAPABILITY_"):].lower().replace("_", "-") + setattr(TestFirmwareCapabilities, "test_" + _name.replace("-", "_"), + _capability_test(_name)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_message_signing_protocol_bindings.py b/tests/test_message_signing_protocol_bindings.py index 10cce3f7..744d8e66 100644 --- a/tests/test_message_signing_protocol_bindings.py +++ b/tests/test_message_signing_protocol_bindings.py @@ -9,6 +9,53 @@ class TestMessageSigningProtocolBindings(unittest.TestCase): + def test_solana_recipient_owner_hint_is_additive_field_12(self): + field = solana_proto.SolanaSignTx.DESCRIPTOR.fields_by_name[ + 'token_recipient_owner' + ] + self.assertEqual(field.number, 12) + # protobuf 6 removed the public ``label`` accessor in favor of the + # semantic predicates; generated bindings must remain testable with + # both the release toolchain and current developer environments. + if hasattr(field, 'label'): + self.assertEqual(field.label, field.LABEL_REPEATED) + else: + self.assertTrue(field.is_repeated) + self.assertEqual(field.type, field.TYPE_BYTES) + + owner = bytes(range(32)) + encoded = solana_proto.SolanaSignTx( + address_n=[0x8000002c, 0x800001f5, 0x80000000, 0x80000000], + raw_tx=b'\x80x402', + token_recipient_owner=[owner], + ).SerializeToString() + decoded = solana_proto.SolanaSignTx.FromString(encoded) + self.assertEqual(list(decoded.token_recipient_owner), [owner]) + + def test_solana_clearsign_certificate_is_additive_field_13(self): + field = solana_proto.SolanaSignTx.DESCRIPTOR.fields_by_name[ + 'clearsign_certificate' + ] + self.assertEqual(field.number, 13) + if hasattr(field, 'label'): + self.assertEqual(field.label, field.LABEL_OPTIONAL) + else: + self.assertFalse(field.is_repeated) + self.assertEqual(field.type, field.TYPE_BYTES) + + certificate = bytes(range(139)) + encoded = solana_proto.SolanaSignTx( + address_n=[0x8000002c, 0x800001f5, 0x80000000, 0x80000000], + raw_tx=b'\x80relay', + schema_payload=b'\x01schema', + schema_signature=bytes(range(64)), + schema_signer_key_id=0x80, + clearsign_certificate=certificate, + ).SerializeToString() + decoded = solana_proto.SolanaSignTx.FromString(encoded) + self.assertEqual(decoded.clearsign_certificate, certificate) + self.assertEqual(decoded.schema_signer_key_id, 0x80) + def test_solana_offchain_messages_are_mapped(self): self.assertEqual(proto.MessageType_SolanaSignOffchainMessage, 756) self.assertEqual(proto.MessageType_SolanaOffchainMessageSignature, 757) diff --git a/tests/test_msg_authenticator_boundaries.py b/tests/test_msg_authenticator_boundaries.py new file mode 100644 index 00000000..5262c8a1 --- /dev/null +++ b/tests/test_msg_authenticator_boundaries.py @@ -0,0 +1,184 @@ +# This file is part of the TREZOR project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +from __future__ import print_function + +import unittest + +import common +import keepkeylib.messages_pb2 as proto +import keepkeylib.types_pb2 as proto_types + + +class TestAuthenticatorBoundaries(common.KeepKeyTest): + # 7.15 enforces the RFC-recommended 128-bit minimum for TOTP secrets. + ADD_ACCOUNT = ('\x15initializeAuth:example:alice:' + 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP') + GET_ACCOUNT = '\x17getAccount:0' + WIPE_ACCOUNTS = '\x19wipeAuthdata:' + + def _auth_ping(self, message): + return self.client.call(proto.Ping(message=message)) + + def _reject_passphrase_and_assert_no_account(self): + response = self.client.call_raw(proto.Ping(message=self.GET_ACCOUNT)) + self.assertIsInstance(response, proto.PassphraseRequest) + + response = self.client.call_raw(proto.Cancel()) + self.assertIsInstance(response, proto.Failure) + self.assertEqual(response.code, proto_types.Failure_ActionCancelled) + self.assertNotIn('example:alice', response.message) + + def test_authorization_loss_drops_cache_and_requires_reauthorization(self): + self.client.load_device_by_mnemonic( + mnemonic=self.mnemonic12, + pin='', + passphrase_protection=True, + label='test', + language='english') + self.client.set_passphrase('authenticator-wallet') + + # Authenticator storage is encrypted independently from the wallet. + # Initialize its fingerprint for this passphrase before adding data. + response = self._auth_ping(self.WIPE_ACCOUNTS) + self.assertIsInstance(response, proto.Success) + response = self._auth_ping(self.ADD_ACCOUNT) + self.assertIsInstance(response, proto.Success) + self.assertEqual(self._auth_ping(self.GET_ACCOUNT).message, + 'example:alice') + + authorization_losses = ( + ('ClearSession/lock', lambda: self.client.call(proto.ClearSession())), + ('Initialize', lambda: self.client.call(proto.Initialize())), + ) + + for name, revoke in authorization_losses: + response = revoke() + self.assertIsInstance(response, (proto.Success, proto.Features, + proto.Failure), name) + self._reject_passphrase_and_assert_no_account() + + # The rejected operation must not have consumed or changed the + # persistent account. A fresh authorization reloads it from the + # encrypted storage rather than a stale plaintext cache. + response = self._auth_ping(self.GET_ACCOUNT) + self.assertIsInstance(response, proto.Success, name) + self.assertEqual(response.message, 'example:alice') + + def _reset_accounts(self): + self.setup_mnemonic_nopin_nopassphrase() + self.assertIsInstance(self._auth_ping(self.WIPE_ACCOUNTS), proto.Success) + common.reset_screenshot_capture(self.client) + + def _walk_auth(self, message, reject=None): + response = self.client.call_raw(proto.Ping(message=message)) + screens = [] + for _ in range(12): + if not isinstance(response, proto.ButtonRequest): + return response, screens + screens.append(self.client.debug.read_confirm_text()) + self.client.capture_oled() + if len(screens) == reject: + self.client.debug.press_no() + else: + self.client.debug.press_yes() + response = self.client.call_raw(proto.ButtonAck()) + self.fail('authenticator did not terminate within 12 screens') + + def test_block09_add_reviews_complete_identity_and_secret(self): + self.requires_firmware("7.15.0") # confirm text over DebugLink + self._reset_accounts() + secret = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' + response, screens = self._walk_auth( + '\x15initializeAuth:abcdefghijk:ABCDEFGHIJK:' + secret) + self.assertIsInstance(response, proto.Success) + self.assertFalse(response.HasField('message')) + self.assertEqual(screens, [ + ('Add Auth Account', 'Domain: abcdefghijk\nAccount: ABCDEFGHIJK'), + ('TOTP Secret', secret)]) + self.assertEqual(self._auth_ping(self.GET_ACCOUNT).message, + 'abcdefghijk:ABCDEFGHIJK') + + def test_block09_duplicate_and_cancellation_have_exact_failures(self): + self.requires_firmware("7.15.0") # confirm text over DebugLink + self._reset_accounts() + for reject in (1, 2): + response, screens = self._walk_auth(self.ADD_ACCOUNT, reject=reject) + self.assertIsInstance(response, proto.Failure) + self.assertEqual(response.code, proto_types.Failure_ActionCancelled) + self.assertEqual(response.message, 'Action cancelled') + self.assertEqual(len(screens), reject) + missing = self.client.call_raw(proto.Ping(message=self.GET_ACCOUNT)) + self.assertIsInstance(missing, proto.Failure) + self.assertEqual(missing.message, 'Account not found') + response, screens = self._walk_auth(self.ADD_ACCOUNT) + self.assertIsInstance(response, proto.Success) + self.assertEqual(len(screens), 2) + # No Initialize or reload between refusal and retry or duplicate. + duplicate = self.client.call_raw(proto.Ping(message=self.ADD_ACCOUNT)) + self.assertIsInstance(duplicate, proto.Failure) + self.assertEqual(duplicate.message, 'Authenticator account already exists') + self.assertEqual(self._auth_ping(self.GET_ACCOUNT).message, 'example:alice') + + def test_block09_invalid_identity_and_secret_fail_before_buttons(self): + self.requires_firmware("7.15.0") # confirm text over DebugLink + self._reset_accounts() + secret = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP' + credentials = [ + ':alice:' + secret, 'example::' + secret, + '::example:alice:' + secret, 'exam\nple:alice:' + secret, + 'example:ali\tce:' + secret, 'example:\u00e9:' + secret, + 'abcdefghijkl:alice:' + secret, 'example:abcdefghijkl:' + secret, + 'example:alice:' + 'A' * 25, 'example:alice:' + 'A' * 34, + 'example:alice:' + '!' * 32, + ] + for credential in credentials: + with self.subTest(credential=credential): + response = self.client.call_raw(proto.Ping( + message='\x15initializeAuth:' + credential)) + self.assertIsInstance(response, proto.Failure) + self.assertEqual(response.code, proto_types.Failure_ActionCancelled) + response, _ = self._walk_auth( + '\x15initializeAuth:abcdefghijk:ABCDEFGHIJK:' + 'A' * 26) + self.assertIsInstance(response, proto.Success) + for request in ( + '\x16generateOTPFrom:abcdefghijkX:ABCDEFGHIJK:1:30', + '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJKX:1:30', + '\x16generateOTPFrom::abcdefghijk:ABCDEFGHIJK:1:30', + '\x18removeAccount::abcdefghijk:ABCDEFGHIJK', + '\x18removeAccount:abcdefghijk:ABCDEFGHIJKX', + '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJK:+1:30', + '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJK:1:31', + '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJK:1:30junk', + '\x16generateOTPFrom:abcdefghijk:ABCDEFGHIJK:4294967296:30'): + response = self.client.call_raw(proto.Ping(message=request)) + self.assertIsInstance(response, proto.Failure) + self.assertEqual(self._auth_ping(self.GET_ACCOUNT).message, + 'abcdefghijk:ABCDEFGHIJK') + + def test_block09_delete_decline_preserves_account_then_retry_removes(self): + self._reset_accounts() + response, _ = self._walk_auth(self.ADD_ACCOUNT) + self.assertIsInstance(response, proto.Success) + request = '\x18removeAccount:example:alice' + response, screens = self._walk_auth(request, reject=1) + self.assertIsInstance(response, proto.Failure) + self.assertEqual(response.message, 'Action cancelled') + self.assertEqual(len(screens), 1) + self.assertEqual(self._auth_ping(self.GET_ACCOUNT).message, 'example:alice') + response, screens = self._walk_auth(request) + self.assertIsInstance(response, proto.Success) + self.assertEqual(len(screens), 1) + missing = self.client.call_raw(proto.Ping(message=self.GET_ACCOUNT)) + self.assertIsInstance(missing, proto.Failure) + self.assertEqual(missing.message, 'Account not found') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_binance_sign_tx.py b/tests/test_msg_binance_sign_tx.py index 811be9a1..c98b50c6 100644 --- a/tests/test_msg_binance_sign_tx.py +++ b/tests/test_msg_binance_sign_tx.py @@ -22,10 +22,39 @@ from keepkeylib.tools import parse_path import keepkeylib.binance as binance +from keepkeylib import messages_binance_pb2 as proto_binance +from keepkeylib import messages_pb2 as proto +from keepkeylib import types_pb2 as proto_types class TestMsgBinanceSignTx(common.KeepKeyTest): + def test_retired_handlers_stay_unregistered(self): + """Current full firmware must not revive the retired signing surface.""" + self.requires_fullFeature() + self.requires_firmware("7.16.0") + + retired_messages = ( + proto_binance.BinanceGetAddress(), + proto_binance.BinanceGetPublicKey(), + proto_binance.BinanceSignTx(), + proto_binance.BinanceTransferMsg(), + proto_binance.BinanceOrderMsg(), + proto_binance.BinanceCancelMsg(), + ) + for message in retired_messages: + response = self.client.call_raw(message) + self.assertIsInstance(response, proto.Failure) + self.assertEqual( + response.code, + proto_types.Failure_UnexpectedMessage, + ) + def setup_binance(self): + # Native Binance Beacon Chain signing was deliberately removed from + # firmware (#541). Keep these vectors useful for older firmware, but + # do not treat an unregistered message on current builds as a signing + # regression. + self.requires_message("BinanceSignTx") self.client.load_device_by_mnemonic( mnemonic="offer caution gift cross surge pretty orange during eye soldier popular holiday mention east eight office fashion ill parrot vault rent devote earth cousin", pin=self.pin4, diff --git a/tests/test_msg_bip85.py b/tests/test_msg_bip85.py index fcfc589c..1020d280 100644 --- a/tests/test_msg_bip85.py +++ b/tests/test_msg_bip85.py @@ -1,6 +1,6 @@ """BIP-85 display-only tests. -Firmware >= 7.14.0 derives the BIP-85 child mnemonic, displays it on the +Firmware >= 7.15.0 derives the BIP-85 child mnemonic, displays it on the device screen, and responds with Success (mnemonic is never sent over USB). Tests verify: @@ -19,8 +19,8 @@ class TestMsgBip85(common.KeepKeyTest): def setUp(self): super().setUp() - self.requires_firmware("7.14.0") - self.requires_message("GetBip85Mnemonic") + self.requires_firmware("7.15.0") + self.requires_fullFeature() def test_bip85_12word_flow(self): """12-word derivation: verify device goes through display flow and returns Success.""" diff --git a/tests/test_msg_bitcoin_only_variant.py b/tests/test_msg_bitcoin_only_variant.py new file mode 100644 index 00000000..00596918 --- /dev/null +++ b/tests/test_msg_bitcoin_only_variant.py @@ -0,0 +1,683 @@ +"""Bitcoin-only variant -- the product boundary, measured over the wire. + +KK_BITCOIN_ONLY=ON builds a second shipping product: coins.def keeps only +Bitcoin and Testnet, messagemap.def drops every altcoin handler, ZCASH_PRIVACY +is forced OFF, and lib/firmware/transaction.c takes a BITCOIN_ONLY arm on the +OP_RETURN path that confirms raw bytes instead of decoding a THORChain memo. +None of that had a test, and CI only ever ran the multi-chain emulator -- so +the whole variant was unaudited. + +SCOPE: THIS FILE RUNS ON THE BITCOIN-ONLY IMAGE ONLY. `setUp()` calls +`requires_bitcoinOnly()`, so every test here SKIPS on the regular multi-chain +build. That is deliberate and not symmetric coverage: several tests assert +screen sequences that legitimately differ on the multi-chain build -- the +OP_RETURN one decodes a THORChain memo there and draws more screens -- so +running them against a full-feature device is a category error, not a finding. +The regular image is covered by the rest of the suite, which asserts the +altcoin handlers these tests assert are absent. + +Because of that gate, this file is only evidence when a bitcoin-only emulator +is actually under test. `.github/workflows/ci.yml` runs the `integration-btc` +job for exactly that reason: it builds the emulator with +`-DKK_BITCOIN_ONLY=ON` and runs this module against it. If that job is ever +dropped, these eleven tests go silently green-by-skip and the variant is +unaudited again -- which is the state this file was written to end. + +The variant is identified by GetCoinTable, not by features.firmware_variant: +the coin table comes from coins.def, which is a different mechanism from the +message map, the Zcash gate and the OP_RETURN arm that the other tests probe, +so nothing here is circular. +""" + +import binascii +import time +import unittest + +import common + +import keepkeylib.messages_pb2 as proto +import keepkeylib.types_pb2 as proto_types +from keepkeylib.client import CallException + +from keepkeylib import messages_binance_pb2 as messages_binance +from keepkeylib import messages_cosmos_pb2 as messages_cosmos +from keepkeylib import messages_eos_pb2 as messages_eos +from keepkeylib import messages_ethereum_pb2 as messages_eth +from keepkeylib import messages_hive_pb2 as messages_hive +from keepkeylib import messages_mayachain_pb2 as messages_maya +from keepkeylib import messages_nano_pb2 as messages_nano +from keepkeylib import messages_osmosis_pb2 as messages_osmosis +from keepkeylib import messages_ripple_pb2 as messages_ripple +from keepkeylib import messages_solana_pb2 as messages_solana +from keepkeylib import messages_thorchain_pb2 as messages_thorchain +from keepkeylib import messages_ton_pb2 as messages_ton +from keepkeylib import messages_tron_pb2 as messages_tron +from keepkeylib import messages_zcash_pb2 as messages_zcash + + +# tx d5f65ee8... input 0 is 0.0039 BTC; the vector every other Bitcoin test in +# this directory spends, and it is in txcache/, so nothing here needs network. +PREV_HASH = binascii.unhexlify( + 'd5f65ee80147b4bcc70b75e4bbf2d7382021b871bd8867ef8fa525ef50864882') +PREV_INDEX = 0 +INPUT_AMOUNT = 390000 +OUT_ADDRESS = '1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1' +OUT_AMOUNT = 380000 # 0.0001 BTC fee + +# A well-formed THORChain swap memo. The multi-chain firmware parses this and +# renders who/what/how-much; the bitcoin-only firmware has no parser linked and +# must disclose the bytes themselves. +THORCHAIN_MEMO = (b'SWAP:ETH.ETH:' + b'0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75') + +# OMNI simple send, 1.00000000 OMNI. The OMNI branch of compile_output() sits +# ABOVE the #if BITCOIN_ONLY, so it must survive the strip untouched. +OMNI_SIMPLE_SEND = binascii.unhexlify('6f6d6e6900000000000000010000000005f5e100') +# The same 20 bytes with the 'o' of "omni" changed to 'p', so the OMNI prefix +# test fails and the payload falls through to the raw-data confirmation. +NOT_OMNI = b'p' + OMNI_SIMPLE_SEND[1:] + +# A BIP-44 path that is valid on every chain probed below, so a refusal can +# only be the message type being absent, never a path rejection. +BIP44_PATH = [2147483692, 2147483708, 2147483648, 0, 0] + +# Matches client.SCREENSHOT_SETTLE_SECONDS. The firmware writes ButtonRequest +# immediately BEFORE drawing, so read_layout() must be given time to settle or +# it returns the previous screen. +BUTTON_RENDER_SETTLE_SECONDS = 0.5 + + +def lit_pixels(layout): + """Count set pixels in a raw 2048-byte OLED framebuffer. + + read_layout() returns the framebuffer, not text, and there is no glyph + decoder in this repo. Screen assertions here are therefore structural: a + screen that draws nothing, and two screens that draw identically, are both + detectable without OCR. + """ + total = 0 + for b in layout: + if isinstance(b, str): + b = ord(b) + total += bin(b).count('1') + return total + + +class TestBitcoinOnlyVariant(common.KeepKeyTest): + + def setUp(self): + super(TestBitcoinOnlyVariant, self).setUp() + # The Bitcoin-only product shipped on the 7.14.3 release fork before + # 7.15. The variant check below keeps these product assertions away + # from regular 7.14.x images, while this exact floor prevents the + # stripped 7.14.3 emulator from silently skipping its entire suite. + self.requires_firmware("7.14.3") + # This whole file describes the BITCOIN-ONLY product. Several tests + # assert screen sequences that differ on the multi-chain build -- the + # OP_RETURN one decodes a THORChain memo there and draws more screens -- + # so running them against a full-feature device is a category error, not + # a finding. CI points the pyk suite at the full emulator image. + self.requires_bitcoinOnly() + self.screens = [] + # Refuse (press NO) on the Nth ButtonRequest of the current flow; + # None means confirm everything. + self.refuse_on = None + self._install_screen_capture() + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + def _install_screen_capture(self): + """Record the framebuffer at each ButtonRequest, before it is acked.""" + original = self.client.callback_ButtonRequest + + def capture(msg): + # Unconditional settle, unlike client.callback_ButtonRequest's + # SCREENSHOT-gated sleep: these are structural assertions that must + # hold on every run, not just screenshot runs. + time.sleep(BUTTON_RENDER_SETTLE_SECONDS) + self.screens.append((msg.code, self.client.debug.read_layout())) + self.client.button = (self.refuse_on != len(self.screens)) + return original(msg) + + self.client.callback_ButtonRequest = capture + + def _reset_screens(self): + self.screens = [] + self.client.button = True + + def _confirm_codes(self): + return [code for code, _ in self.screens] + + def _screen(self, index): + return self.screens[index][1] + + def _is_bitcoin_only(self): + """Identify the product from coins.def, over the wire. + + Deliberately NOT features.firmware_variant: that field does not + distinguish the two builds at all (see + test_firmware_variant_names_the_bitcoin_only_product). + """ + return self.client.call(proto.GetCoinTable()).num_coins == 2 + + def _coin_names(self): + table = self.client.call(proto.GetCoinTable()) + end = min(table.num_coins, table.chunk_size) + chunk = self.client.call(proto.GetCoinTable(start=0, end=end)) + return [entry.coin_name for entry in chunk.table] + + def _data_output(self, op_return_data): + return proto_types.TxOutputType(op_return_data=op_return_data, + amount=0, + script_type=proto_types.PAYTOOPRETURN) + + def _sign(self, outputs): + inp = proto_types.TxInputType(address_n=[0], prev_hash=PREV_HASH, + prev_index=PREV_INDEX) + return self.client.sign_tx('Bitcoin', [inp], outputs) + + def _sign_with_op_return(self, op_return_data): + out_pay = proto_types.TxOutputType(address=OUT_ADDRESS, + amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS) + return self._sign([out_pay, self._data_output(op_return_data)]) + + def _settled_layout(self): + """The framebuffer once any post-setup redraw has finished. + + A device's delays complete, so the home screen may still be drawing + when setup returns. Compare against the screen it settles on. + """ + layout = bytes(self.client.debug.read_layout()) + for _ in range(20): + time.sleep(BUTTON_RENDER_SETTLE_SECONDS) + current = bytes(self.client.debug.read_layout()) + if current == layout: + return layout + layout = current + self.fail("home screen never stopped changing") + + def _probe(self, msg): + """Send one message and return the response, leaving the device idle.""" + resp = self.client.call_raw(msg) + self.client.call_raw(proto.Initialize()) + return resp + + def _assert_unknown_message(self, name, resp): + self.assertTrue( + isinstance(resp, proto.Failure), + "%s: expected a Failure on the bitcoin-only image, got %s" + % (name, type(resp).__name__)) + self.assertTrue( + resp.code == proto_types.Failure_UnexpectedMessage, + "%s: expected Failure_UnexpectedMessage (the handler is not in the " + "message map at all); got code %d %r" + % (name, resp.code, resp.message)) + + def _assert_handler_present(self, name, resp): + self.assertTrue( + not (isinstance(resp, proto.Failure) + and resp.code == proto_types.Failure_UnexpectedMessage), + "%s: the multi-chain image answered Failure_UnexpectedMessage, so " + "a BITCOIN_ONLY guard leaked into the regular product" % name) + + # ------------------------------------------------------------------ + # L1 -- Bitcoin still signs + # ------------------------------------------------------------------ + + def test_bitcoin_signing_survives_the_strip(self): + """The one thing the bitcoin-only product must still do. + + Stripping coins, message handlers and the Zcash engine touches + coins.def, messagemap.def, fsm.c and the AES table selection. Any of + those going wrong shows up here first: the signature is compared + against the exact vector test_msg_signtx.test_one_one_fee pins on the + multi-chain build, so the two products must produce byte-identical + Bitcoin transactions from the same seed. + """ + self.setup_mnemonic_nopin_nopassphrase() + self._reset_screens() + + inp = proto_types.TxInputType(address_n=[0], prev_hash=PREV_HASH, + prev_index=PREV_INDEX) + out = proto_types.TxOutputType(address=OUT_ADDRESS, amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS) + _, serialized_tx = self.client.sign_tx('Bitcoin', [inp], [out]) + + self.assertEqual( + binascii.hexlify(serialized_tx), + '010000000182488650ef25a58fef6788bd71b8212038d7f2bbe4750bc7bcb4470' + '1e85ef6d5000000006b4830450221009a0b7be0d4ed3146ee262b42202841834' + '698bb3ee39c24e7437df208b8b7077102202b79ab1e7736219387dffe8d615bbd' + 'ba87e11477104b867ef47afed1a5ede7810121023230848585885f63803a0a8ae' + 'cdd6538792d5c539215c91698e315bf0253b43dffffffff0160cc050000000000' + '1976a914de9b2a8da088824e8fe51debea566617d851537888ac00000000') + + # One output review, then the whole-transaction confirmation. Measured, + # not modelled: a silently dropped output screen is exactly the failure + # a signing test alone cannot see. + self.assertEqual( + self._confirm_codes(), + [proto_types.ButtonRequest_ConfirmOutput, + proto_types.ButtonRequest_SignTx]) + for index in range(len(self.screens)): + self.assertGreater(lit_pixels(self._screen(index)), 200) + + # ------------------------------------------------------------------ + # L2 -- the coin table IS the product boundary + # ------------------------------------------------------------------ + + def test_coin_table_is_bitcoin_and_testnet_only(self): + """coins.def under BITCOIN_ONLY keeps exactly two entries. + + "Bitcoin-only" is not "UTXO-only": Litecoin, Dogecoin, Bitcoin Cash and + transparent Zcash are all stripped too, and ERC-20 tokens leave the + table entirely (TOKENS_COUNT is 0 and `tokens` is not linked). A host + that enumerates coins is the only way a user learns what the device + will sign, so the count and the names are both part of the product. + """ + table = self.client.call(proto.GetCoinTable()) + names = self._coin_names() + + if self._is_bitcoin_only(): + self.assertEqual(table.num_coins, 2) + self.assertEqual(names, ['Bitcoin', 'Testnet']) + else: + self.assertGreater(table.num_coins, 2) + self.assertTrue('Ethereum' in names or len(names) > 2, + "multi-chain image reported %r" % (names,)) + + # ------------------------------------------------------------------ + # L3 -- the variant string + # ------------------------------------------------------------------ + + def test_firmware_variant_names_the_bitcoin_only_product(self): + """features.firmware_variant must distinguish the two products. + + It is the only wire-visible product identifier, and the whole test + suite gates on it: common.requires_fullFeature() skips a test when + firmware_variant is "KeepKeyBTC" or "EmulatorBTC". + + variant_getName() has two arms. Under EMULATOR it returns a literal; + otherwise it returns the model's variant name from variant_getInfo(), + and THAT arm has no BITCOIN_ONLY case at all -- a bitcoin-only device + reports whatever a multi-chain device of the same model reports. So + this is asserted by suffix rather than against a fixed string: the + contract is that the two products are distinguishable, on the emulator + and on hardware alike. + + If it fails, requires_fullFeature() is dead code and every altcoin test + in this directory runs -- and fails -- against a bitcoin-only image + instead of skipping. + """ + self.client.init_device() + variant = self.client.features.firmware_variant + + if self._is_bitcoin_only(): + self.assertTrue( + variant.endswith('BTC'), + "coins.def carries only Bitcoin+Testnet, so this is the " + "bitcoin-only product, but firmware_variant is %r. " + "common.requires_fullFeature() compares against 'KeepKeyBTC'/" + "'EmulatorBTC' and therefore never skips anything." % variant) + else: + self.assertTrue( + not variant.endswith('BTC'), + "multi-chain image reported the bitcoin-only variant %r" + % variant) + + # ------------------------------------------------------------------ + # L4 -- altcoin handlers are absent, not broken + # ------------------------------------------------------------------ + + def test_altcoin_message_handlers_are_absent(self): + """Every stripped chain must refuse cleanly and leave the screen alone. + + messagemap.def drops these MSG_IN entries under BITCOIN_ONLY, so the + board-level dispatcher answers Failure_UnexpectedMessage without ever + reaching a handler. The two things that could go wrong are a handler + that is half-linked (wrong failure, or a hang) and one that draws + something before refusing -- a bitcoin-only device must never render a + chain it cannot sign. The framebuffer is compared byte-for-byte across + all fifteen probes for exactly that reason. + """ + self.setup_mnemonic_nopin_nopassphrase() + probes = [ + ('EthereumGetAddress', messages_eth.EthereumGetAddress(address_n=BIP44_PATH)), + ('CosmosGetAddress', messages_cosmos.CosmosGetAddress(address_n=BIP44_PATH)), + ('OsmosisGetAddress', messages_osmosis.OsmosisGetAddress(address_n=BIP44_PATH)), + ('NanoGetAddress', messages_nano.NanoGetAddress(address_n=BIP44_PATH)), + ('EosGetPublicKey', messages_eos.EosGetPublicKey(address_n=BIP44_PATH)), + ('ThorchainGetAddress', messages_thorchain.ThorchainGetAddress(address_n=BIP44_PATH)), + ('MayachainGetAddress', messages_maya.MayachainGetAddress(address_n=BIP44_PATH)), + ('RippleGetAddress', messages_ripple.RippleGetAddress(address_n=BIP44_PATH)), + ('BinanceGetAddress', messages_binance.BinanceGetAddress(address_n=BIP44_PATH)), + ('TronGetAddress', messages_tron.TronGetAddress(address_n=BIP44_PATH)), + ('TonGetAddress', messages_ton.TonGetAddress(address_n=BIP44_PATH)), + ('SolanaGetAddress', messages_solana.SolanaGetAddress(address_n=BIP44_PATH)), + ('HiveGetPublicKey', messages_hive.HiveGetPublicKey(address_n=BIP44_PATH)), + ] + + bitcoin_only = self._is_bitcoin_only() + home_before = self._settled_layout() + + for name, msg in probes: + resp = self._probe(msg) + if bitcoin_only: + self._assert_unknown_message(name, resp) + else: + self._assert_handler_present(name, resp) + + if bitcoin_only: + time.sleep(BUTTON_RENDER_SETTLE_SECONDS) + home_after = self.client.debug.read_layout() + self.assertEqual(bytes(home_before), bytes(home_after)) + + # The device is still usable after all of that: a refusal must not + # wedge the message loop. + self.assertEqual(self.client.call(proto.Ping(message='alive')).message, + 'alive') + + # ------------------------------------------------------------------ + # L5 -- stripped coin NAMES are refused + # ------------------------------------------------------------------ + + def test_altcoin_coin_names_are_refused(self): + """A stripped coin is refused by name, on a handler that still exists. + + GetPublicKey is a Bitcoin-family message and stays in the message map, + so this is the other half of the boundary: coinByName() must fail for + every coin the image no longer carries, rather than falling back to + Bitcoin's parameters and handing back an xpub with the wrong version + bytes under a Litecoin label. + """ + self.setup_mnemonic_nopin_nopassphrase() + bitcoin_only = self._is_bitcoin_only() + account = [2147483692, 2147483648, 2147483648] + + for name in ('Bitcoin', 'Testnet'): + resp = self._probe(proto.GetPublicKey(address_n=account, + coin_name=name)) + self.assertTrue(isinstance(resp, proto.PublicKey), + "%s must always be supported; got %s" + % (name, type(resp).__name__)) + + for name in ('Litecoin', 'Dogecoin', 'BitcoinCash', 'Zcash', + 'DigiByte', 'Dash'): + resp = self._probe(proto.GetPublicKey(address_n=account, + coin_name=name)) + if bitcoin_only: + self.assertTrue( + isinstance(resp, proto.Failure) + and resp.code == proto_types.Failure_Other, + "%s is not in the bitcoin-only coin table, so it must be " + "refused by name; got %s" % (name, type(resp).__name__)) + else: + self.assertTrue(isinstance(resp, proto.PublicKey), + "%s must work on the multi-chain image; got %s" + % (name, type(resp).__name__)) + + # ------------------------------------------------------------------ + # L6 -- Zcash privacy is compiled out + # ------------------------------------------------------------------ + + def test_zcash_privacy_is_compiled_out(self): + """KK_ZCASH_PRIVACY is forced OFF whenever KK_BITCOIN_ONLY is ON. + + The Orchard engine is the largest thing in the image and its handlers + live behind ZCASH_PRIVACY, not BITCOIN_ONLY, so the two gates are wired + together in CMakeLists rather than in the source. If that wiring ever + breaks, the bitcoin-only image ships a shielded-Zcash signer it does + not have the coin table to support -- and the transparent side is gone + too, so 'Zcash' is refused as a coin name in the same breath. + """ + self.setup_mnemonic_nopin_nopassphrase() + bitcoin_only = self._is_bitcoin_only() + probes = [ + ('ZcashGetOrchardFVK', + messages_zcash.ZcashGetOrchardFVK(address_n=BIP44_PATH)), + ('ZcashDisplayAddress', + messages_zcash.ZcashDisplayAddress(address_n=BIP44_PATH)), + ] + for name, msg in probes: + resp = self._probe(msg) + if bitcoin_only: + self._assert_unknown_message(name, resp) + else: + self._assert_handler_present(name, resp) + + resp = self._probe(proto.GetAddress( + address_n=[2147483692, 2147483781, 2147483648, 0, 0], + coin_name='Zcash')) + if bitcoin_only: + self.assertTrue( + isinstance(resp, proto.Failure) + and resp.code == proto_types.Failure_Other, + "transparent Zcash must be gone from the coin table too; got %s" + % type(resp).__name__) + else: + self.assertTrue(isinstance(resp, proto.Address), + "multi-chain image refused transparent Zcash: %s" + % type(resp).__name__) + + # ------------------------------------------------------------------ + # L7 -- the BITCOIN_ONLY arm of the OP_RETURN path + # ------------------------------------------------------------------ + + def test_op_return_thorchain_memo_is_confirmed_raw(self): + """The arm added to compile_output() by the alpha merge. + + transaction.c wraps the THORChain memo decode in `#if !BITCOIN_ONLY` + and confirms the raw OP_RETURN bytes in the #else. So a memo that the + multi-chain image explains -- swap, asset, destination, affiliate -- + is shown on the bitcoin-only image as the bytes themselves. That is the + right answer (a decode the image cannot perform must not be faked), but + it had never been executed: CI runs only the multi-chain emulator. + + The screen count is measured, not modelled. Bitcoin-only: one output + review, one raw OP_RETURN screen, one SignTx -- three. Multi-chain: the + same memo expands to several decoded screens, so the count is strictly + higher. Either way the signed script must carry the memo verbatim, so + the disclosure and the signature are pinned to the same bytes. + """ + self.setup_mnemonic_nopin_nopassphrase() + self._reset_screens() + + _, serialized_tx = self._sign_with_op_return(THORCHAIN_MEMO) + + # OP_RETURN -- what was signed. + expected_script = (b'\x6a' + bytes([len(THORCHAIN_MEMO)]) + + THORCHAIN_MEMO) + self.assertTrue( + expected_script in serialized_tx, + "the signed script must carry the memo bytes verbatim") + + confirm_outputs = [c for c in self._confirm_codes() + if c == proto_types.ButtonRequest_ConfirmOutput] + + if self._is_bitcoin_only(): + self.assertEqual( + self._confirm_codes(), + [proto_types.ButtonRequest_ConfirmOutput, # pay-to-address + proto_types.ButtonRequest_ConfirmOutput, # raw OP_RETURN + proto_types.ButtonRequest_SignTx]) + op_return_screen = self._screen(1) + # It has to actually draw the memo: a blank or near-blank screen + # here would mean the user approved bytes they never saw. + self.assertGreater(lit_pixels(op_return_screen), 400) + self.assertNotEqual(bytes(op_return_screen), + bytes(self._screen(0))) + else: + self.assertGreater( + len(confirm_outputs), 2, + "the multi-chain image must decode the memo into its own " + "screens; %d ConfirmOutput screen(s) means it fell through to " + "the raw-data path" % len(confirm_outputs)) + + def test_op_return_refusal_cancels_the_signature(self): + """Refusing the OP_RETURN screen must abort, on both products. + + The BITCOIN_ONLY arm returns -1 from compile_output() when confirm_data + is refused, and the multi-chain arm has its own THORCHAIN_MEMO_CANCELLED + path that must not answer a refusal by asking again on a second screen. + Both must surface as Failure_ActionCancelled with no signature, and the + flow must stop AT the refused screen -- a SignTx request afterwards + would mean the refusal was recorded and then ignored. + """ + self.setup_mnemonic_nopin_nopassphrase() + self._reset_screens() + self.refuse_on = 2 # the screen after the pay-to-address review + + try: + self._sign_with_op_return(THORCHAIN_MEMO) + self.fail("the device signed a transaction whose OP_RETURN output " + "the user refused") + except CallException as exc: + self.assertEqual(exc.args[0], proto_types.Failure_ActionCancelled) + + self.assertEqual(len(self.screens), 2) + self.assertTrue( + proto_types.ButtonRequest_SignTx not in self._confirm_codes(), + "the flow reached the SignTx confirmation after the user refused " + "an output") + + # ------------------------------------------------------------------ + # L9 -- the shared OMNI branch survived the strip + # ------------------------------------------------------------------ + + def test_omni_op_return_is_still_decoded(self): + """The OMNI branch sits above the #if and must be untouched. + + compile_output() tests for an "omni" prefix BEFORE the BITCOIN_ONLY + split, so an OMNI simple send is still decoded into "Do you want to + send 1.0 OMNI?" on the bitcoin-only image. The regression this guards + against is the new #else swallowing the OMNI case, which would silently + downgrade a decoded amount to a hex dump. + + Proved by contrast rather than by OCR: the same twenty bytes with the + leading 'o' changed to 'p' are no longer OMNI and fall through to the + raw-data confirmation. The two screens must differ, and the decoded one + must be the sparser of the two -- one short sentence against forty hex + digits. + + Both payloads ride in ONE transaction, as two data outputs, rather than + in two signings. That is not stylistic: a transaction ending in + OP_RETURN poisons the duplicate-transaction detector, so a second + signing in the same session is refused (see + test_op_return_does_not_poison_the_duplicate_detector). + """ + self.setup_mnemonic_nopin_nopassphrase() + self._reset_screens() + + out_pay = proto_types.TxOutputType(address=OUT_ADDRESS, + amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS) + self._sign([self._data_output(OMNI_SIMPLE_SEND), + self._data_output(NOT_OMNI), + out_pay]) + + self.assertEqual( + self._confirm_codes(), + [proto_types.ButtonRequest_ConfirmOutput, # OMNI, decoded + proto_types.ButtonRequest_ConfirmOutput, # same bytes, raw + proto_types.ButtonRequest_ConfirmOutput, # pay-to-address + proto_types.ButtonRequest_SignTx]) + + omni_screen = self._screen(0) + raw_screen = self._screen(1) + + self.assertNotEqual(bytes(omni_screen), bytes(raw_screen)) + self.assertGreater(lit_pixels(omni_screen), 200) + self.assertGreater(lit_pixels(raw_screen), lit_pixels(omni_screen)) + + + # ------------------------------------------------------------------ + # L10/L11 -- the duplicate-transaction detector and OP_RETURN + # ------------------------------------------------------------------ + + def test_repeated_transaction_is_allowed_without_op_return(self): + """The control for the test below: an exact repeat is NOT a duplicate. + + compile_output() carries an anti-malware check (txin_check.c): warn + when a transaction pays the SAME amount to the SAME address as the + previous one but was built from DIFFERENT inputs, which is what host + malware rewriting a segwit txid looks like. An exact repeat -- same + outputs AND same inputs -- is not that, and is deliberately allowed. + + This is signed twice from the same input here to pin that, so the + refusal in the next test cannot be explained away as the duplicate + guard doing its job. + """ + self.setup_mnemonic_nopin_nopassphrase() + out_pay = proto_types.TxOutputType(address=OUT_ADDRESS, + amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS) + + _, first = self._sign([out_pay]) + _, second = self._sign([out_pay]) + self.assertEqual(binascii.hexlify(first), binascii.hexlify(second)) + + def test_op_return_does_not_poison_the_duplicate_detector(self): + """An OP_RETURN output must not falsely condemn the next transaction. + + Found while exercising the BITCOIN_ONLY arm above and NOT caused by it: + it reproduces identically on the multi-chain build, because the code is + shared. Sign a transaction whose LAST output is OP_RETURN, then sign + the transaction the test above just proved is allowed -- and the device + answers "WARNING: DUPLICATE TRANSACTION! Already signed a tx with the + same outputs. To try again, unplug/replug KeepKey." and aborts. + + Mechanism. signing.c calls txin_dgst_final() once per output, and + compile_output() calls txin_dgst_save_and_reset() -- the only thing + that re-initialises the SHA-256 context -- only on the pay-to-address + path. An OP_RETURN output returns before it. So a transaction ending + in OP_RETURN leaves the context finalised and never re-initialised, and + the NEXT transaction's inputs are hashed into a finalised context. Its + digest no longer matches, while the amount and address still do, which + is exactly the (same outputs, different inputs) pattern the check + exists to flag. + + The failure is fail-safe -- it refuses rather than signs -- but it + refuses a legitimate transaction and tells the user to replug, and + every OP_RETURN-terminated transaction arms it. That is every + THORChain/Maya swap the wallet builds. + + Nothing caught it because common.KeepKeyTest wipes the device in + setUp, so no existing test signs two transactions in one session. + """ + self.setup_mnemonic_nopin_nopassphrase() + + out_pay = proto_types.TxOutputType(address=OUT_ADDRESS, + amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS) + + self._reset_screens() + self._sign([out_pay, self._data_output(THORCHAIN_MEMO)]) + self.assertEqual( + self._confirm_codes(), + [proto_types.ButtonRequest_ConfirmOutput, # pay-to-address + proto_types.ButtonRequest_ConfirmOutput, # OP_RETURN + proto_types.ButtonRequest_SignTx]) + + self._reset_screens() + try: + self._sign([out_pay]) + except CallException as exc: + self.fail( + "after an OP_RETURN-terminated transaction the device refused " + "the next one with %r; its review screens were %r -- a " + "ConfirmOutput followed by the ButtonRequest_Other of the " + "duplicate-transaction warning. The same transaction signs " + "twice in a row when no OP_RETURN precedes it." + % (exc.args, self._confirm_codes())) + + self.assertEqual( + self._confirm_codes(), + [proto_types.ButtonRequest_ConfirmOutput, + proto_types.ButtonRequest_SignTx]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_cosmos_getaddress.py b/tests/test_msg_cosmos_getaddress.py index 86d6691e..ca619356 100644 --- a/tests/test_msg_cosmos_getaddress.py +++ b/tests/test_msg_cosmos_getaddress.py @@ -28,7 +28,9 @@ def test_standard(self): cosmos_proto.CosmosAddress(address=expected) ]) - self.assertEqual(expected, self.client.cosmos_get_address(path, show_display=True)) + self.assertEqual(expected, self.assert_shows_address( + lambda: self.client.cosmos_get_address(path, show_display=True), + expected)) with self.client: self.client.set_expected_responses([ @@ -61,7 +63,9 @@ def test_nonstandard(self): cosmos_proto.CosmosAddress(address=expected) ]) - self.assertEqual(expected, self.client.cosmos_get_address(path, show_display=True)) + self.assertEqual(expected, self.assert_shows_address( + lambda: self.client.cosmos_get_address(path, show_display=True), + expected)) with self.client: self.client.set_expected_responses([ @@ -85,10 +89,12 @@ def test_cosmos_get_address_sep(self): address = self.client.cosmos_get_address(parse_path(DEFAULT_BIP32_PATH)) assert address == "cosmos1jcwdsdelc4cwvall0twl974sfkpqmzrgkszu9l" - address = self.client.cosmos_get_address( - parse_path("m/44h/118h/1h/0/0"), show_display=True - ) - assert address == "cosmos1280uphuty5rxr2m05t6xujvylkkftlrvdnw0pp" + expected = "cosmos1280uphuty5rxr2m05t6xujvylkkftlrvdnw0pp" + address = self.assert_shows_address( + lambda: self.client.cosmos_get_address( + parse_path("m/44h/118h/1h/0/0"), show_display=True), + expected) + assert address == expected def test_onchain(self): self.requires_fullFeature() diff --git a/tests/test_msg_cosmos_signtx.py b/tests/test_msg_cosmos_signtx.py index 5ca12076..b04d5b35 100644 --- a/tests/test_msg_cosmos_signtx.py +++ b/tests/test_msg_cosmos_signtx.py @@ -5,11 +5,16 @@ from binascii import hexlify, unhexlify import keepkeylib.messages_pb2 as proto +import keepkeylib.messages_cosmos_pb2 as cosmos_proto import keepkeylib.types_pb2 as proto_types from keepkeylib.tools import parse_path DEFAULT_BIP32_PATH = "m/44h/118h/0h/0/0" +# Real 52-character operator addresses (valid bech32): three 140 px rows. +VALIDATOR = "cosmosvaloper1sjllsnramtg3ewxqwwrwjxfgc4n4ef9u2lcnj0" +VALIDATOR_2 = "cosmosvaloper1c4k24jzduc365kywrsvf5ujz4ya6mwympnc4en" + def make_send(from_address, to_address, amount): return { 'type': 'cosmos-sdk/MsgSend', @@ -61,10 +66,10 @@ def test_cosmos_sign_tx_memo(self): "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", 8675309 )], - memo="Epstein didn't kill himself.", + memo="test memo", sequence=3 ) - self.assertEqual(hexlify(signature.signature), "9f2434543bc4afd2fc7bb43db05facdd6d529aa7c467ef0d41e1c2954f68db9942b8eb431cf27b52d1b3d914bbde076960179b7f426bd1a182448bb9c245009c") + self.assertEqual(hexlify(signature.signature), "db0e8039f2cd0b7d06527074a7e9079b5cd3d973f3090e04a685cfef0f145a9262dd828faa421027e583dd58fa5c6942c1f7c82fd53e54fb668fe0ebe5f83a12") self.assertEqual(hexlify(signature.public_key), "03bee3af30e53a73f38abc5a2fcdac426d7b04eb72a8ebd3b01992e2d206e24ad8") @@ -127,5 +132,57 @@ def test_onchain2(self): self.assertEqual(hexlify(signature.signature), "71295606d64f1fa987fea1af2292d0b735a5c2d5104b7cc3f818a7208ea9b1a504a386c40011242c115f77268c67af841d29137af5d608d21361ebc7e0513a11") + def _sign_staking(self, **msg): + path = parse_path(DEFAULT_BIP32_PATH) + self.client.call(cosmos_proto.CosmosSignTx( + address_n=path, account_number=19637, chain_id="cosmoshub-4", + fee_amount=5000, gas=200000, memo="", sequence=3, msg_count=1)) + resp = self.client.call(cosmos_proto.CosmosMsgAck(**msg)) + self.assertIsInstance(resp, cosmos_proto.CosmosSignedTx) + return resp + + def _delegator(self): + self.setup_mnemonic_nopin_nopassphrase() + return self.client.cosmos_get_address(parse_path(DEFAULT_BIP32_PATH)) + + # 7.14.x drops the third row of a validator address: these skip there. + def test_delegate_shows_whole_validator(self): + self.requires_fullFeature() + self.requires_firmware("7.15.0") + me = self._delegator() + self.assert_shows_address(lambda: self._sign_staking( + delegate=cosmos_proto.CosmosMsgDelegate( + delegator_address=me, validator_address=VALIDATOR, + amount=1000000)), VALIDATOR) + + def test_undelegate_shows_whole_validator(self): + self.requires_fullFeature() + self.requires_firmware("7.15.0") + me = self._delegator() + self.assert_shows_address(lambda: self._sign_staking( + undelegate=cosmos_proto.CosmosMsgUndelegate( + delegator_address=me, validator_address=VALIDATOR, + amount=1000000)), VALIDATOR) + + def test_redelegate_shows_both_whole_validators(self): + self.requires_fullFeature() + self.requires_firmware("7.15.0") + me = self._delegator() + # Nested: the inner check sees the same screens as the outer one. + self.assert_shows_address(lambda: self.assert_shows_address( + lambda: self._sign_staking( + redelegate=cosmos_proto.CosmosMsgRedelegate( + delegator_address=me, validator_src_address=VALIDATOR, + validator_dst_address=VALIDATOR_2, amount=1000000)), + VALIDATOR), VALIDATOR_2) + + def test_rewards_shows_whole_validator(self): + self.requires_fullFeature() + self.requires_firmware("7.15.0") + me = self._delegator() + self.assert_shows_address(lambda: self._sign_staking( + rewards=cosmos_proto.CosmosMsgRewards( + delegator_address=me, validator_address=VALIDATOR)), VALIDATOR) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_msg_display_disclosure.py b/tests/test_msg_display_disclosure.py index 658d7e8e..62379388 100644 --- a/tests/test_msg_display_disclosure.py +++ b/tests/test_msg_display_disclosure.py @@ -175,12 +175,13 @@ class TestDisplayDisclosesSignedContent(common.KeepKeyTest): def setUp(self): super(TestDisplayDisclosesSignedContent, self).setUp() self.requires_firmware(self.MIN_FIRMWARE) - # These are positive display-binding controls, not refusal tests. A - # fresh emulator is uninitialized; without an explicit seed setup every - # request is rejected before its first ButtonRequest, the differential - # cases vacuously "pass", and the only non-vacuity control skips. Keep - # the fixture capable of reaching the confirmation path. - self.setup_mnemonic_allallall() + # The inherited setUp wipes the device. Without a seed every + # SignMessage below is refused with Failure_NotInitialized before + # confirm_bytes() is ever reached, _sign_message_screens() returns + # None, and _assert_distinguishable() returns without asserting -- so + # the whole suite passed while exercising zero display logic. Load a + # seed so the device actually renders the screens under test. + self.setup_mnemonic_nopin_nopassphrase() # ── helpers ───────────────────────────────────────────────────────── @@ -215,8 +216,22 @@ def _assert_distinguishable(self, a_label, a_msg, b_label, b_msg): b = self._sign_message_screens(b_msg) if a is None or b is None: - # Refusing to display something it cannot show honestly is a pass. - return + # A refusal is only meaningful from an initialized device that + # could have signed and chose not to. On an uninitialized device + # every call is refused for an unrelated reason, which is what let + # this suite pass vacuously -- so assert the device can sign at + # all before treating a refusal as the honest-refusal pass. + self.assertTrue( + self.client.features.initialized, + "device is not initialized, so this refusal says nothing " + "about display disclosure -- the assertion below never ran") + refused = a_label if a is None else b_label + raise AssertionError( + "device refused to sign %s. Refusing to display what it " + "cannot show honestly is defensible, but it must be an " + "explicit, reviewed decision rather than a silent pass: if " + "this is intended, assert the refusal here by name." + % refused) self.assertNotEqual( a, b, @@ -228,6 +243,14 @@ def _assert_distinguishable(self, a_label, a_msg, b_label, b_msg): # ── the property, at each place an implementation stops looking ───── + def test_pipe_is_distinguishable_from_lowercase_l(self): + self._assert_distinguishable( + 'lowercase letters', b'allow', 'vertical bars', b'a||ow') + + def test_double_quote_is_distinguishable_from_two_apostrophes(self): + self._assert_distinguishable( + 'one double quote', b'"', 'two apostrophes', b"''") + def test_bytes_past_an_embedded_nul_are_disclosed(self): """A protobuf `bytes` field is not a C string. @@ -294,11 +317,14 @@ def test_signing_shows_at_least_one_screen(self): empty tuples and the suite would pass while showing the user nothing. """ screens = self._sign_message_screens(b"hello") + # Do NOT skip here. This test exists to prove the rest of the file is + # not vacuous, so skipping itself when the device will not sign is the + # one failure mode it cannot be allowed to have -- that is exactly how + # the whole suite went green against an uninitialized device. self.assertIsNotNone( screens, - "device refused the control request; the display-binding A/B " - "tests did not prove they can reach a confirmation path", - ) + "device refused to sign the control message, so every comparison " + "in this file compared None against None and asserted nothing") self.assertGreater( len(screens), 0, "signing produced no ButtonRequest, so nothing was shown to the " diff --git a/tests/test_msg_eip712_corpus.py b/tests/test_msg_eip712_corpus.py new file mode 100644 index 00000000..a288af64 --- /dev/null +++ b/tests/test_msg_eip712_corpus.py @@ -0,0 +1,126 @@ +# Real-world EIP-712 documents must sign. +# +# eip712_corpus.json holds one document per protocol (Permit2, UniswapX, +# Seaport, LooksRare, Blur, 1inch, CoW, 0x, Safe, EIP-2612/DAI, Hyperliquid, +# Polymarket, Compound, Aave, dYdX, Across, Snapshot, eth-sig-util, and the +# Ethermint chains Evmos and Injective), written +# from the type strings in each protocol's own source ("source" in each +# entry). It is generated by unittests/firmware/eip712_corpus_gen.py in the +# firmware repository, whose "expected" hashes were computed by a reference +# encoder and cross-checked against @metamask/eth-sig-util and ethers. +# +# Each document is its own test (test_doc_). Every one must be ACCEPTED: its hashes must equal both the embedded +# values and this suite's own independent encoder, its signature must +# recover to the device's address, and the final screen must name the whole +# primary type. A document with "requires_capability" (the max approvals) +# runs only where the firmware reports it; before 7.16 its value screen is +# titled UNLIMITED APPROVAL and shows "unlimited_screen". + +import json +import os +import re +import unittest + +import common +import oled_text +import test_msg_eip712_streaming as streaming +from keepkeylib import messages_ethereum_pb2 as eth +from keepkeylib import types_pb2 as types + +CORPUS = os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'eip712_corpus.json') + + +def load_corpus(): + with open(CORPUS) as f: + return json.load(f) + + +class TestEip712CorpusReference(unittest.TestCase): + + def test_embedded_hashes_match_the_independent_encoder(self): + """The suite's own encoder agrees with the generator's values.""" + for entry in load_corpus(): + with self.subTest(entry['id']): + domain, message, digest = \ + streaming.reference_eip712_hashes(entry['doc']) + self.assertEqual(domain.hex(), + entry['expected']['domain_separator']) + self.assertEqual(message.hex(), + entry['expected']['message_hash']) + self.assertEqual(digest.hex(), entry['expected']['digest']) + + +class TestMsgEip712Corpus(common.KeepKeyTest): + + # The device-driven walk from the streaming suite; borrowed, not + # inherited, so its tests do not run twice. + _walk = streaming.TestMsgEip712Streaming._walk + _chunked_values = streaming.TestMsgEip712Streaming._chunked_values + + def setUp(self): + super(TestMsgEip712Corpus, self).setUp() + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.requires_structured_eip712() + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy('AdvancedMode', 0) + self.client.reset_screenshots() + + def _sign_and_check(self, entry): + needs = entry.get('requires_capability') + if needs and needs not in (self.firmware_capabilities() or ()): + self.skipTest("Staged release tree does not yet provide " + "capability: " + needs) + address = self.client.ethereum_get_address(streaming.PATH) + doc = entry['doc'] + resp = self._walk(doc, max_steps=2000) + self.assertIsInstance(resp, eth.EthereumTypedDataSignature, + getattr(resp, 'message', resp)) + self.assertEqual(resp.domain_separator_hash.hex(), + entry['expected']['domain_separator']) + self.assertEqual(resp.message_hash.hex(), + entry['expected']['message_hash']) + digest = bytes.fromhex(entry['expected']['digest']) + self.assertEqual( + streaming.recover_signer(resp.signature, digest).hex(), + address.hex()) + # The final screen (and its continuation pages) names the whole + # primary type, however long. + codes = [code for code, _ in self.frames] + self.assertEqual(codes.count(types.ButtonRequest_SignTx), 1) + final = codes.index(types.ButtonRequest_SignTx) + self.assertIsNotNone(oled_text.find_text( + [layout for _, layout in self.frames[final:]], + 'Sign ' + doc['primaryType'])) + warned = [layout for _, layout in self.frames + if oled_text.find_line(layout, 'UNLIMITED APPROVAL', + oled_text.TITLE_FONT) + is not None] + if 'unlimited_screen' not in entry: + self.assertEqual(warned, []) + elif not self.firmware_at_least("7.16.0"): + self.assertEqual(len(warned), 1) + self.assertTrue(oled_text.shows( + warned[0], entry['unlimited_screen'])) + + +# One test per document, so each gets its own timeout and failure line: +# test_doc_seaport_OrderComponents_bundle_10x6, ... +def _add_document_tests(): + for entry in load_corpus(): + spelled = entry['id'].replace('[]', '_array') + name = 'test_doc_' + re.sub(r'[^0-9A-Za-z]+', '_', spelled).strip('_') + assert not hasattr(TestMsgEip712Corpus, name), name + + def test(self, entry=entry): + self._sign_and_check(entry) + test.__name__ = name + test.__doc__ = entry['id'] + ' signs with the expected hashes.' + setattr(TestMsgEip712Corpus, name, test) + + +_add_document_tests() + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_eip712_streaming.py b/tests/test_msg_eip712_streaming.py new file mode 100644 index 00000000..0e2e26f2 --- /dev/null +++ b/tests/test_msg_eip712_streaming.py @@ -0,0 +1,1226 @@ +# Structured EIP-712 over the device-driven streaming protocol. +# +# The expected hashes here come from OUTSIDE this repository -- the reference +# implementation EIP-712 itself links to, and a constant published by Circle in +# the deployed USDC contract. That matters more than it looks: the firmware, +# hdwallet and the python client were all written by the same hand against the +# same reading of the spec, so three of them agreeing proves only that the +# reading is self-consistent. Only an outside number can catch a shared +# misreading. + +import copy +import time +import unittest + +import common +from confirmation_pages import ConfirmationPages +import oled_text +from keepkeylib import eip712_stream as es +from keepkeylib import messages_ethereum_pb2 as eth +from keepkeylib import messages_pb2 as proto +from keepkeylib import types_pb2 as types +from keepkeylib.client import CallException + +PATH = [0x8000002C, 0x8000003C, 0x80000000, 0, 0] +SETTLE = 0.3 # seconds for a confirm screen to finish drawing + +# assets/eip-712/Example.js in ethereum/EIPs publishes every intermediate. +SPEC_MAIL = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "Person": [ + {"name": "name", "type": "string"}, + {"name": "wallet", "type": "address"}, + ], + "Mail": [ + {"name": "from", "type": "Person"}, + {"name": "to", "type": "Person"}, + {"name": "contents", "type": "string"}, + ], + }, + "primaryType": "Mail", + "domain": {"name": "Ether Mail", "version": "1", "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"}, + "message": { + "from": {"name": "Cow", "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"}, + "to": {"name": "Bob", "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"}, + "contents": "Hello, Bob!", + }, +} +SPEC_DOMAIN_SEPARATOR = "f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f" +SPEC_MESSAGE_HASH = "c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e" + + +# --- Independent EIP-712 reference encoder ---------------------------------- +# Written from the EIP-712 specification text and deliberately NOT built on +# keepkeylib.eip712_stream (the host half of the protocol under test), so a +# shared misreading of arrays or nested dimensions cannot make both sides +# agree. keccak comes from pycryptodome directly. +def _keccak(data): + from Crypto.Hash import keccak + return keccak.new(digest_bits=256, data=data).digest() + + +def _ref_base_struct(type_name): + return type_name.split('[', 1)[0] + + +def _ref_encode_type(primary, types): + deps = set() + + def collect(name): + if name in deps or name not in types: + return + deps.add(name) + for field in types[name]: + collect(_ref_base_struct(field['type'])) + + collect(primary) + deps.discard(primary) + return ''.join( + '%s(%s)' % (name, ','.join('%s %s' % (f['type'], f['name']) + for f in types[name])) + for name in [primary] + sorted(deps)) + + +def _ref_int(value): + if isinstance(value, str): + return int(value, 16) if value.startswith('0x') else int(value) + return int(value) + + +def _ref_encode_value(type_name, value, types): + if type_name.endswith(']'): + # Arrays: keccak of the concatenated encodings of the elements, where + # each element of T[n][m] is itself an encoded T[n] array. + inner = type_name[:type_name.rindex('[')] + return _keccak(b''.join( + _ref_encode_value(inner, item, types) for item in value)) + if type_name in types: + return _ref_hash_struct(type_name, value, types) + if type_name == 'string': + return _keccak(value.encode('utf-8')) + if type_name == 'bytes': + return _keccak(bytes.fromhex(value[2:])) + if type_name == 'address': + return bytes(12) + bytes.fromhex(value[2:]) + if type_name == 'bool': + return (1 if value else 0).to_bytes(32, 'big') + if type_name.startswith('bytes'): + return bytes.fromhex(value[2:]).ljust(32, b'\0') + if type_name.startswith('uint'): + return _ref_int(value).to_bytes(32, 'big') + if type_name.startswith('int'): + return (_ref_int(value) % (1 << 256)).to_bytes(32, 'big') + raise ValueError('reference encoder: unsupported type ' + type_name) + + +def _ref_hash_struct(name, data, types): + encoded = _keccak(_ref_encode_type(name, types).encode('ascii')) + for field in types[name]: + encoded += _ref_encode_value(field['type'], data[field['name']], types) + return _keccak(encoded) + + +def reference_eip712_hashes(doc): + """Return (domain_separator, message_hash, signing_digest). + + primaryType EIP712Domain signs keccak(0x1901 || domainSeparator) with no + message hash (eth-sig-util v4); message_hash is None then.""" + types = doc['types'] + domain = _ref_hash_struct('EIP712Domain', doc['domain'], types) + if doc['primaryType'] == 'EIP712Domain': + return domain, None, _keccak(b'\x19\x01' + domain) + message = _ref_hash_struct(doc['primaryType'], doc['message'], types) + return domain, message, _keccak(b'\x19\x01' + domain + message) + + +def recover_signer(signature, digest): + from ecdsa import VerifyingKey, SECP256k1, util + rec = signature[64] - 27 + if rec not in (0, 1): + raise AssertionError('unexpected recovery byte %d' % signature[64]) + keys = VerifyingKey.from_public_key_recovery_with_digest( + signature[:64], digest, SECP256k1, hashfunc=None, + sigdecode=util.sigdecode_string) + return _keccak(keys[rec].to_string())[-20:] + + +class TestEip712StreamHelpers(unittest.TestCase): + + def test_reference_encoder_reproduces_the_published_spec_hashes(self): + """The independent encoder is itself checked against Example.js.""" + domain, message, _ = reference_eip712_hashes(SPEC_MAIL) + self.assertEqual(domain.hex(), SPEC_DOMAIN_SEPARATOR) + self.assertEqual(message.hex(), SPEC_MESSAGE_HASH) + + def test_long_values_are_chunked_only_for_capable_firmware(self): + """Over 1 KB, a value goes in 1 KB chunks: the first names the total, + each later one echoes the offset asked for. Without the capability, + or in the domain, the host refuses rather than send a value an older + device would truncate.""" + doc = { + "types": { + "EIP712Domain": [{"name": "name", "type": "string"}], + "Msg": [{"name": "data", "type": "bytes"}, + {"name": "note", "type": "string"}, + {"name": "list", "type": "uint8[]"}], + }, + "primaryType": "Msg", + "domain": {"name": "x" * 1025}, + "message": {"data": "0x" + "ab" * 1025, "note": "y" * 1024, + "list": [1]}, + } + + def request(path, offset=None): + req = eth.EthereumTypedDataValueRequest(member_path=path) + if offset is not None: + req.value_offset = offset + return req + + with self.assertRaisesRegex(es.Eip712Error, 'chunked values'): + es.value_ack(doc, request([1, 0])) + first = es.value_ack(doc, request([1, 0]), chunked=True) + self.assertEqual(first.value, b'\xab' * 1024) + self.assertEqual(first.value_total_length, 1025) + self.assertFalse(first.HasField('value_offset')) + last = es.value_ack(doc, request([1, 0], 1024), chunked=True) + self.assertEqual((last.value, last.value_offset), (b'\xab', 1024)) + self.assertFalse(last.HasField('value_total_length')) + # A string is read a second time from 0. + again = es.value_ack(doc, request([1, 0], 0), chunked=True) + self.assertEqual((len(again.value), again.value_offset), (1024, 0)) + with self.assertRaises(es.Eip712Error): + es.value_ack(doc, request([1, 0], 1025), chunked=True) + # 1024 bytes still go whole, with or without the capability. + for chunked in (False, True): + whole = es.value_ack(doc, request([1, 1]), chunked) + self.assertEqual(whole.value, b'y' * 1024) + self.assertFalse(whole.HasField('value_total_length')) + with self.assertRaises(es.Eip712Error): + es.value_ack(doc, request([1, 1], 1024), chunked) + with self.assertRaisesRegex(es.Eip712Error, 'domain'): + es.value_ack(doc, request([0, 0]), chunked=True) + with self.assertRaises(es.Eip712Error): + es.value_ack(doc, request([1, 2], 0), chunked=True) + doc["message"]["data"] = "0x" + "00" * (es.MAX_VALUE_BYTES + 1) + with self.assertRaises(es.Eip712Error): + es.value_ack(doc, request([1, 0]), chunked=True) + + def test_review_identifiers_are_exact_and_unambiguous(self): + doc = { + 'types': { + 'Permit': [ + {'name': 'value', 'type': 'uint256'}, + {'name': 'value', 'type': 'uint256'}, + ], + }, + } + with self.assertRaises(es.Eip712Error) as duplicate: + es.struct_members(doc, 'Permit') + self.assertIn('Duplicate', str(duplicate.exception)) + + doc['types']['Permit'][1]['name'] = 'identifier_that_would_be_truncated' + with self.assertRaises(es.Eip712Error) as overlong: + es.struct_members(doc, 'Permit') + self.assertIn('canonical EIP-712 identifier', str(overlong.exception)) + + doc['types']['Permit'][1]['name'] = 'amount%08x' + with self.assertRaises(es.Eip712Error) as malformed: + es.struct_members(doc, 'Permit') + self.assertIn('canonical EIP-712 identifier', str(malformed.exception)) + + def test_multidimensional_arrays_are_walked_outermost_first(self): + doc = { + 'types': { + 'EIP712Domain': [], + 'Matrix': [{'name': 'values', 'type': 'int16[2][][4]'}], + }, + 'primaryType': 'Matrix', + 'domain': {}, + 'message': { + 'values': [ + [[1, 2]], + [[3, 4], [5, 6]], + [[7, 8], [9, 10], [11, 12]], + [[13, 14]], + ], + }, + } + + self.assertEqual(es.resolve_member_path(doc, [1, 0]), ('length', 4)) + self.assertEqual(es.resolve_member_path(doc, [1, 0, 2]), ('length', 3)) + self.assertEqual(es.resolve_member_path(doc, [1, 0, 2, 1]), ('length', 2)) + result = es.resolve_member_path(doc, [1, 0, 2, 1, 0]) + self.assertEqual(result[0], 'value') + self.assertEqual(result[2], 9) + + def test_innermost_fixed_array_length_is_checked(self): + doc = { + 'types': { + 'EIP712Domain': [], + 'Matrix': [{'name': 'values', 'type': 'int16[2][][4]'}], + }, + 'primaryType': 'Matrix', + 'domain': {}, + 'message': { + 'values': [ + [[1, 2]], + [[3, 4]], + [[5]], + [[6, 7]], + ], + }, + } + + with self.assertRaises(es.Eip712Error) as ctx: + es.resolve_member_path(doc, [1, 0, 2, 0]) + self.assertIn('declares 2 elements', str(ctx.exception)) + + +class TestMsgEip712Streaming(common.KeepKeyTest): + + def _walk(self, doc, max_steps=400, decline_final=False): + """Answer whatever the device asks until it returns a signature. + + The DEVICE leads. Nothing here chooses the order, which is the property + under test: a host that answered a different question than the one asked + would produce a digest that does not verify. + """ + msg = eth.EthereumSignTypedData() + for n in PATH: + msg.address_n.append(n) + msg.primary_type = doc['primaryType'] + msg.metamask_v4_compat = True + # Read before the walk: reading Features mid-walk would end it. + chunked = self._chunked_values() + + # (ButtonRequest code, framebuffer) for every screen, in order. + self.frames = [] + resp = self.client.call_raw(msg) + for _ in range(max_steps): + if isinstance(resp, proto.ButtonRequest): + # This is a manual, device-driven call_raw() loop, so no + # callback_ButtonRequest() will capture the field being + # approved. Retain it while that exact field is still active. + self.client.capture_oled() + # capture_oled() only settles when screenshots are enabled; + # the text assertions need the finished frame either way. + time.sleep(SETTLE) + self.frames.append( + (resp.code, bytes(self.client.debug.read_layout()))) + if decline_final and resp.code == types.ButtonRequest_SignTx: + self.client.debug.press_no() + else: + self.client.debug.press_yes() + resp = self.client.call_raw(proto.ButtonAck()) + elif isinstance(resp, eth.EthereumTypedDataStructRequest): + resp = self.client.call_raw( + es.build_struct_ack(es.struct_members(doc, resp.name))) + elif isinstance(resp, eth.EthereumTypedDataValueRequest): + resp = self.client.call_raw(es.value_ack(doc, resp, chunked)) + else: + return resp + raise AssertionError('walk did not terminate') + + def _chunked_values(self): + """Whether the firmware takes values over 1 KB in chunks. + + From the cached Features, not a fresh Initialize: Initialize ends the + session, and with it a name record sent ahead of the walk.""" + return es.CHUNKED_VALUES in ( + common.capability_names(self.client.features) or ()) + + def _assert_final_sign_screen(self, first_line): + """Every typed-data signature ends with ONE "Sign Typed Data" screen + (ButtonRequest_SignTx); only its own continuation pages follow it.""" + codes = [code for code, _ in self.frames] + self.assertEqual(codes.count(types.ButtonRequest_SignTx), 1) + final = codes.index(types.ButtonRequest_SignTx) + self.assertTrue(all(code == types.ButtonRequest_Other + for code in codes[final + 1:])) + layout = self.frames[final][1] + self.assertIsNotNone(oled_text.find_line( + layout, 'SIGN TYPED DATA', oled_text.TITLE_FONT)) + self.assertIsNotNone(oled_text.find_line(layout, first_line)) + return final + + def _leaf_index(self, path): + """Index of the first screen whose body starts with `path`.""" + for index, (_, layout) in enumerate(self.frames): + if oled_text.find_line(layout, path) is not None: + return index + self.fail('no screen shows the %r leaf' % path) + + def _assert_reference_signature(self, doc, resp): + """The device's hashes AND signature match an independent encoder.""" + self.assertIsInstance(resp, eth.EthereumTypedDataSignature) + domain, message, digest = reference_eip712_hashes(doc) + self.assertEqual(resp.domain_separator_hash.hex(), domain.hex()) + if doc['primaryType'] == 'EIP712Domain': + self.assertFalse(resp.has_msg_hash) + self.assertEqual(resp.message_hash, b'') + else: + self.assertEqual(resp.message_hash.hex(), message.hex()) + self._assert_final_sign_screen('Sign ' + doc['primaryType']) + self.assertEqual(len(resp.signature), 65) + address = self.client.ethereum_get_address(PATH) + self.assertEqual(recover_signer(resp.signature, digest).hex(), + address.hex()) + # The reported signer must be intact too: the response is built after + # the final screen, so a DebugLink read during it cannot erase it. + self.assertEqual(resp.address.lower(), '0x' + address.hex()) + + def setUp(self): + super(TestMsgEip712Streaming, self).setUp() + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.requires_structured_eip712() + self.setup_mnemonic_nopin_nopassphrase() + # The device-driven stream validates, displays and hashes the same + # bytes. It is not the blind precomputed-hash endpoint and must work + # with Advanced Mode disabled. + self.client.apply_policy('AdvancedMode', 0) + # The report entries describe typed-data fields, not the policy prompt. + self.client.reset_screenshots() + + def _assert_strings_have_distinct_screens(self, first, second): + doc = { + 'types': { + 'EIP712Domain': [], + 'Message': [{'name': 'note', 'type': 'string'}], + }, + 'primaryType': 'Message', 'domain': {}, 'message': {'note': first}, + } + responses, screens = [], [] + for value in (first, second): + doc['message']['note'] = value + response = self._walk(doc) + self._assert_reference_signature(doc, response) + responses.append(response) + screens.append(tuple(self.frames)) + self.assertNotEqual(responses[0].message_hash, responses[1].message_hash) + self.assertNotEqual(responses[0].signature, responses[1].signature) + self.assertFalse(screens[0] == screens[1], + 'Different signed values produced identical OLED screens') + + def test_pipe_is_distinguishable_from_lowercase_l(self): + self._assert_strings_have_distinct_screens('allow', 'a||ow') + + def test_pipe_after_chunk_boundary_is_distinguishable_from_lowercase_l(self): + if not self._chunked_values(): + self.skipTest('firmware does not support chunked EIP-712 values') + self._assert_strings_have_distinct_screens( + 'a' * 1024 + 'allow', 'a' * 1024 + 'a||ow') + + def test_distinct_visible_character_changes_review_pixels(self): + self._assert_strings_have_distinct_screens('allow', 'a!!ow') + + def test_double_quote_is_distinguishable_from_two_apostrophes(self): + self._assert_strings_have_distinct_screens('"', "''") + + def test_spec_example_matches_the_published_hashes(self): + """The device's own hashes equal the EIP-712 reference implementation's. + + This is the one assertion that three agreeing implementations cannot + substitute for. Both numbers are published by Example.js in + ethereum/EIPs and are reproduced independently by Example.sol, by + eth-sig-util's V3 and V4 snapshots, and by Mrtenz/eip-712. + + It also exercises the nested-struct path: Mail references Person twice, + so the walk pushes a child frame, derives Person's typeHash through its + own closure, folds it to 32 bytes and hands it back to the parent. + """ + resp = self._walk(SPEC_MAIL) + self.assertIsInstance(resp, eth.EthereumTypedDataSignature) + self.assertEqual(resp.domain_separator_hash.hex(), SPEC_DOMAIN_SEPARATOR) + self.assertEqual(resp.message_hash.hex(), SPEC_MESSAGE_HASH) + self._assert_reference_signature(SPEC_MAIL, resp) + + def test_array_of_structs_walks(self): + """Arrays, which the walk refused until the decode buffer was reclaimed. + + An array hashes WITHOUT a typeHash prefix -- enc(array) is the keccak of + the concatenated element encodings and nothing else -- so getting this + wrong produces a digest no verifier reproduces rather than an error. + """ + doc = { + "types": { + "EIP712Domain": [{"name": "name", "type": "string"}], + "Item": [{"name": "id", "type": "uint256"}], + "Basket": [{"name": "items", "type": "Item[]"}], + }, + "primaryType": "Basket", + "domain": {"name": "Basket"}, + "message": {"items": [{"id": 1}, {"id": 2}]}, + } + resp = self._walk(doc) + self._assert_reference_signature(doc, resp) + + def test_multidimensional_arrays_walk_outermost_first_on_device(self): + """Host and device must traverse asymmetric Solidity dimensions alike.""" + doc = { + 'types': { + 'EIP712Domain': [], + 'Matrix': [{'name': 'values', 'type': 'int16[2][4]'}], + }, + 'primaryType': 'Matrix', + 'domain': {}, + 'message': { + 'values': [ + [1, 2], + [3, 4], + [5, 6], + [7, 8], + ], + }, + } + + resp = self._walk(doc) + self._assert_reference_signature(doc, resp) + + def test_permit2_batch_walks_realistic_nested_array(self): + """The production Permit2 Batch shape, including trailing root fields. + + The smaller Basket fixture proves the array primitive, but does not + exercise a multi-field child struct followed by more members on the + parent. That is the shape Uniswap and swap providers actually send. + """ + doc = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "PermitDetails": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint160"}, + {"name": "expiration", "type": "uint48"}, + {"name": "nonce", "type": "uint48"}, + ], + "PermitBatch": [ + {"name": "details", "type": "PermitDetails[]"}, + {"name": "spender", "type": "address"}, + {"name": "sigDeadline", "type": "uint256"}, + ], + }, + "primaryType": "PermitBatch", + "domain": { + "name": "Permit2", + "chainId": 1, + "verifyingContract": "0x000000000022D473030F116dDEE9F6B43aC78BA3", + }, + "message": { + "details": [ + { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "250000000", + "expiration": "1893456000", + "nonce": "1", + }, + { + "token": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "amount": "500000000000000000000", + "expiration": "1893456000", + "nonce": "2", + }, + ], + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "sigDeadline": "1893456000", + }, + } + resp = self._walk(doc) + self._assert_reference_signature(doc, resp) + + def test_fixed_array_length_must_match_the_declared_size(self): + """A declared dimension is part of the type string and so of typeHash. + + The device only ever learns the count from us, so if it accepted a + different one it would sign a document whose type declares another and + nothing downstream could notice. + """ + doc = { + "types": { + "EIP712Domain": [{"name": "name", "type": "string"}], + "Pair": [{"name": "who", "type": "address[2]"}], + }, + "primaryType": "Pair", + "domain": {"name": "Pair"}, + "message": {"who": ["0x" + "aa" * 20, "0x" + "bb" * 20, "0x" + "cc" * 20]}, + } + # The host refuses before the device is ever asked to hash it. + with self.assertRaises(es.Eip712Error) as ctx: + self._walk(doc) + self.assertIn('declares 2 elements', str(ctx.exception)) + + def test_advanced_mode_is_not_required_for_structured_review(self): + """Exact device-driven review is available with blind signing off.""" + self.client.apply_policy('AdvancedMode', 0) + resp = self._walk(SPEC_MAIL) + self.assertIsInstance(resp, eth.EthereumTypedDataSignature) + self.assertEqual(resp.domain_separator_hash.hex(), SPEC_DOMAIN_SEPARATOR) + self.assertEqual(resp.message_hash.hex(), SPEC_MESSAGE_HASH) + + def test_declining_the_final_sign_screen_returns_no_signature(self): + """The final "Sign Typed Data" screen is a real consent: declining it + after every leaf was approved signs nothing.""" + resp = self._walk(SPEC_MAIL, decline_final=True) + self.assertIsInstance(resp, proto.Failure) + self.assertEqual((resp.code, resp.message), + (types.Failure_ActionCancelled, + 'Signing cancelled by user')) + codes = [code for code, _ in self.frames] + self.assertEqual(codes.count(types.ButtonRequest_SignTx), 1) + self.assertEqual(codes[-1], types.ButtonRequest_SignTx) + + def test_domain_only_signature_matches_an_independent_digest(self): + """primaryType EIP712Domain signs keccak(0x1901 || domainSeparator).""" + doc = dict(SPEC_MAIL, primaryType='EIP712Domain', message={}) + resp = self._walk(doc) + self.assertIsInstance(resp, eth.EthereumTypedDataSignature) + self.assertEqual(resp.domain_separator_hash.hex(), SPEC_DOMAIN_SEPARATOR) + self._assert_reference_signature(doc, resp) + + # Uniswap's request from the owner's Vault log (2026-04-10): mainnet USDC, + # spender 0x66a9...A8Af, an UNLIMITED allowance expiring in 30 days. + UNISWAP_PERMIT2 = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "PermitDetails": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint160"}, + {"name": "expiration", "type": "uint48"}, + {"name": "nonce", "type": "uint48"}, + ], + "PermitSingle": [ + {"name": "details", "type": "PermitDetails"}, + {"name": "spender", "type": "address"}, + {"name": "sigDeadline", "type": "uint256"}, + ], + }, + "primaryType": "PermitSingle", + "domain": {"name": "Permit2", "chainId": "1", + "verifyingContract": + "0x000000000022d473030f116ddee9f6b43ac78ba3"}, + "message": { + "details": { + "token": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "amount": "1461501637330902918203684832716283019655932542975", + "expiration": "1778393946", "nonce": "0"}, + "spender": "0x66a9893cc07d91d95644aedd05d03f95e1dba8af", + "sigDeadline": "1775803746"}, + } + + def _screens(self): + """(title, layout) of every screen before the final sign screen.""" + final = self._assert_final_sign_screen('Sign PermitSingle') + return [layout for _, layout in self.frames[:final]] + + def test_permit2_reads_as_who_what_until_when(self): + """SRS-7.16 §3.7: a canonical Permit2 PermitSingle is described in + words. No raw domain or leaf screens; an unlimited allowance is + allowed and stated, with exact dates from the signed timestamps.""" + self.requires_firmware("7.16.0") + self.requires_release_capability("permit2-review") + doc = self.UNISWAP_PERMIT2 + resp = self._walk(doc) + self._assert_reference_signature(doc, resp) + want = [ + ("PERMIT2", "Allow 0x66a9...A8Af to spend UNLIMITED USDC from " + "this wallet until 2026-05-10 06:19 UTC"), + ("LIMITS", "Spender may take\nUNLIMITED USDC"), + ("LIMITS", "Allowance expires\n2026-05-10 06:19 UTC"), + ("LIMITS", "Signature valid until\n2026-04-10 06:49 UTC"), + ("TOKEN", "USDC\n0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"), + ("SPENDER", "Not identified\n" + "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af"), + ("DETAILS", "Nonce 0"), + ("DETAILS", "Permit2 contract on chain 1"), + ] + screens = self._screens() + self.assertEqual(len(screens), len(want)) + for i, (layout, (title, body)) in enumerate(zip(screens, want)): + self.assertIsNotNone( + oled_text.find_line(layout, title, oled_text.TITLE_FONT), + "screen %d is not titled %r" % (i, title)) + self.assertTrue(oled_text.shows(layout, body), + "screen %d does not show %r" % (i, body)) + + # signedPayload from the deployed ClearSign Worker (source d2849aaf8), + # POST /v1/evm/name {chainId 1, address 0x66a9...a8af}; deterministic. + UR_V2_NAME = bytes.fromhex( + "030101000000016b359b004b6565704b657920416c7068612037313600000000" + "00000000000000000000000342f5f9704494b3f9bd72295eecaf29d783d23ea0" + "2b2dc9f48abcd2e46d4850cffc4364672f0172aa70ed21eacc2ea8bd0d333119" + "ddf048b096686e1a8e56bda438f9e70b9cfc229a1e93e2d91846c31c4083d8b1" + "76e225d76341069dd22826cf060000000166a9893cc07d91d95644aedd05d03f" + "95e1dba8af18556e697377617020556e6976657273616c20526f757465720100" + "000000806d645ca60ffadfd620a8abda7a77f84dbbf4770644e51c234937f715" + "12d2019b30068dfd95e88e2ebbdd2186db629012a9d861b55b14d3ef9000c99f" + "ef5de4811b") + + def test_permit2_names_a_vouched_spender_beside_its_address(self): + """A KeepKey-certified name record names the spender; the full address + is still shown, and the voucher is identified.""" + self.requires_firmware("7.16.0") + self.requires_message("EthereumTxMetadata") + self.requires_release_capability("permit2-review") + resp = self.client.ethereum_send_tx_metadata( + signed_payload=self.UR_V2_NAME, metadata_version=3, key_id=0x80) + self.assertEqual(resp.classification, 1) # VERIFIED + doc = self.UNISWAP_PERMIT2 + self._assert_reference_signature(doc, self._walk(doc)) + screens = self._screens() + self.assertEqual(len(screens), 9) + self.assertTrue(oled_text.shows( + screens[0], "Allow Uniswap Universal Router to spend UNLIMITED " + "USDC from this wallet until 2026-05-10 06:19 UTC")) + self.assertTrue(oled_text.shows( + screens[5], "Uniswap Universal Router\n" + "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af")) + self.assertTrue(oled_text.shows( + screens[8], "Spender named by KeepKey Alpha 716 A9531B9D\n" + "certified by KeepKey")) + # One review per record: the next request is not named. + self._walk(doc) + self.assertTrue(oled_text.shows( + self._screens()[5], "Not identified\n" + "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af")) + + def test_permit2_name_never_outlives_a_failed_request(self): + """A record sent ahead of a request that FAILS is gone with it: the + next Permit2 says "Not identified" (Copilot r1 on #919).""" + self.requires_firmware("7.16.0") + self.requires_message("EthereumTxMetadata") + self.requires_release_capability("permit2-review") + resp = self.client.ethereum_send_tx_metadata( + signed_payload=self.UR_V2_NAME, metadata_version=3, key_id=0x80) + self.assertEqual(resp.classification, 1) # VERIFIED + # An EIP-2612 permit to the same spender, declined at the final sign + # screen. (This used an ambiguous unlimited permit, but those now sign + # behind an UNLIMITED warning: owner rule 2026-10-07, ledger D10.) + declined = copy.deepcopy(self.USDC_PERMIT) + declined["message"]["spender"] = ( + "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af") + failure = self._walk(declined, decline_final=True) + self.assertIsInstance(failure, proto.Failure) + self.assertEqual((failure.code, failure.message), + (types.Failure_ActionCancelled, + 'Signing cancelled by user')) + doc = self.UNISWAP_PERMIT2 + self._assert_reference_signature(doc, self._walk(doc)) + self.assertTrue(oled_text.shows( + self._screens()[5], "Not identified\n" + "0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af")) + + def test_permit2_exact_amount_and_lookalikes(self): + """An exact amount is shown exactly. A PermitSingle outside the Permit2 + contract keeps the raw review, and its domain screens still appear in + order, so nothing is hidden by the attempt to recognise it.""" + self.requires_firmware("7.16.0") + self.requires_release_capability("permit2-review") + exact = copy.deepcopy(self.UNISWAP_PERMIT2) + exact["message"]["details"]["amount"] = "250000000" + self._assert_reference_signature(exact, self._walk(exact)) + self.assertTrue(oled_text.shows(self._screens()[1], + "Spender may take\n250 USDC")) + other = copy.deepcopy(exact) + other["domain"]["verifyingContract"] = ( + "0x1111111111111111111111111111111111111111") + self._assert_reference_signature(other, self._walk(other)) + self.assertIsNotNone(oled_text.find_line( + self.frames[0][1], 'EIP-712 DOMAIN', oled_text.TITLE_FONT)) + self.assertIsNotNone(self._leaf_index("details.amount")) + + USDC_PERMIT = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "Permit": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + ], + }, + "primaryType": "Permit", + "domain": {"name": "USD Coin", "version": "2", "chainId": 1, + "verifyingContract": + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}, + "message": { + "owner": "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "value": "1000000", "nonce": "0", "deadline": "1893456000"}, + } + + # The canonical DAI permit (all-or-nothing `allowed`, `expiry` 0 = never). + DAI_PERMIT = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "Permit": [ + {"name": "holder", "type": "address"}, + {"name": "spender", "type": "address"}, + {"name": "nonce", "type": "uint256"}, + {"name": "expiry", "type": "uint256"}, + {"name": "allowed", "type": "bool"}, + ], + }, + "primaryType": "Permit", + "domain": {"name": "Dai Stablecoin", "version": "1", "chainId": 1, + "verifyingContract": + "0x6B175474E89094C44Da98b954EedeAC495271d0F"}, + "message": { + "holder": "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "nonce": "0", "expiry": "0", "allowed": True}, + } + + def _warnings(self): + """Indexes of the "UNLIMITED allowance" screens (F-D, D-010).""" + return [i for i, (_, layout) in enumerate(self.frames) + if oled_text.find_line(layout, 'UNLIMITED ALLOWANCE', + oled_text.TITLE_FONT) is not None] + + def _assert_unlimited_warning(self, ticker, token, deadline): + """Every leaf was shown; then the two warning screens, then the sign + screen. Screen 1: "Allow to spend an UNLIMITED amount of + . It does not expire."; screen 2: ticker, token address, + "Signature valid until", deadline, one per line. The device pages a + long body by characters, so each drawn line is checked on the warning + pages.""" + warnings = self._warnings() + self.assertGreaterEqual(len(warnings), 2) + final = self._assert_final_sign_screen('Sign Permit') + self.assertEqual(warnings[-1], final - 1) + pages = [self.frames[i][1] for i in warnings] + spender = self.USDC_PERMIT["message"]["spender"] + first = oled_text.wrap("Allow %s to spend an UNLIMITED amount of %s. " + "It does not expire." % (spender, ticker)) + second = oled_text.wrap("%s\n%s\nSignature valid until\n%s" % + (ticker, token, deadline)) + for line in first[:3] + ["It does not expire."] + second: + self.assertTrue(any(oled_text.find_line(page, line) is not None + for page in pages), line) + + def test_unlimited_permits_are_refused_before_the_amount_screen(self): + """7.15: EIP-2612 Permit.value at its all-ones maximum is refused + before that leaf is ever displayed. 7.16 reviews a canonical one + instead (F-D, D-010): test_canonical_unlimited_permits_sign_after_a_ + warning.""" + self.requires_firmware_below("7.16.0") + self.requires_capability_absent("erc20-unlimited-permit-review") + permit = copy.deepcopy(self.USDC_PERMIT) + for doc, path, leaf, bits in ((permit, ("value",), "value", 256),): + # A finite amount signs and shows the leaf; its position is where + # the unlimited amount must stop. + self._assert_reference_signature(doc, self._walk(doc)) + before_leaf = self._leaf_index(leaf) + unlimited = copy.deepcopy(doc) + target = unlimited["message"] + for key in path[:-1]: + target = target[key] + target[path[-1]] = str((1 << bits) - 1) + resp = self._walk(unlimited) + self.assertIsInstance(resp, proto.Failure) + self.assertEqual((resp.code, resp.message), + (types.Failure_SyntaxError, + 'Unlimited ERC20 approval is disabled')) + self.assertEqual(len(self.frames), before_leaf) + + def test_unlimited_permits_sign_with_unlimited_on_the_value_screen(self): + """7.15 (owner decision 2026-10-07): an unlimited EIP-2612 value, a + DAI allowed=true and a Permit2 amount at its uint160 maximum are + signed. The value's own screen is titled UNLIMITED APPROVAL and reads + UNLIMITED; a finite value keeps the ordinary screen.""" + self.requires_firmware("7.15.0") + self.requires_firmware_below("7.16.0") + self.requires_release_capability("erc20-unlimited-permit-review") + usdc = copy.deepcopy(self.USDC_PERMIT) + usdc["message"]["value"] = str((1 << 256) - 1) + permit2 = copy.deepcopy(self.UNISWAP_PERMIT2) + cases = ((usdc, "value", "value\nuint256: UNLIMITED"), + (self.DAI_PERMIT, "allowed", + "allowed\nbool: UNLIMITED (allowed)"), + (permit2, "details.amount", + "details.amount\nuint160: UNLIMITED")) + for doc, leaf, body in cases: + self._assert_reference_signature(doc, self._walk(doc)) + warnings = [i for i, (_, layout) in enumerate(self.frames) + if oled_text.find_line(layout, 'UNLIMITED APPROVAL', + oled_text.TITLE_FONT) + is not None] + with self.subTest(leaf): + self.assertEqual(warnings, [self._leaf_index(leaf)]) + self.assertTrue(oled_text.shows(self.frames[warnings[0]][1], + body), body) + self._assert_reference_signature(self.USDC_PERMIT, + self._walk(self.USDC_PERMIT)) + self.assertIsNone(oled_text.find_text( + [layout for _, layout in self.frames], "value\nuint256: UNLIMITED")) + self.assertTrue(all( + oled_text.find_line(layout, 'UNLIMITED APPROVAL', + oled_text.TITLE_FONT) is None + for _, layout in self.frames)) + + def test_canonical_unlimited_permits_sign_after_a_warning(self): + """D-010 for permits (F-D): a canonical EIP-2612 permit of 2^255 or + more, or a canonical DAI permit with allowed=true, is signed after + two "UNLIMITED allowance" screens naming spender, token and deadline. + 2^255 - 1 is an exact amount: no warning.""" + self.requires_firmware("7.16.0") + self.requires_release_capability("erc20-unlimited-permit-review") + usdc = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + for value in ((1 << 256) - 1, 1 << 255): + doc = copy.deepcopy(self.USDC_PERMIT) + doc["message"]["value"] = str(value) + self._assert_reference_signature(doc, self._walk(doc)) + self._assert_unlimited_warning("USDC", usdc, + "2030-01-01 00:00 UTC") + # The amount leaf itself was still disclosed. + self.assertIsNotNone(self._leaf_index("value")) + + exact = copy.deepcopy(self.USDC_PERMIT) + exact["message"]["value"] = str((1 << 255) - 1) + self._assert_reference_signature(exact, self._walk(exact)) + self.assertEqual(self._warnings(), []) + + dai = "0x6B175474E89094C44Da98b954EedeAC495271d0F" + doc = copy.deepcopy(self.DAI_PERMIT) + self._assert_reference_signature(doc, self._walk(doc)) + self._assert_unlimited_warning("DAI", dai, "no deadline") + doc["message"]["expiry"] = "1893456000" + self._assert_reference_signature(doc, self._walk(doc)) + self._assert_unlimited_warning("DAI", dai, "2030-01-01 00:00 UTC") + # allowed=false revokes: nothing to warn about. + doc["message"]["allowed"] = False + self._assert_reference_signature(doc, self._walk(doc)) + self.assertEqual(self._warnings(), []) + + def test_declining_the_unlimited_warning_signs_nothing(self): + self.requires_firmware("7.16.0") + self.requires_release_capability("erc20-unlimited-permit-review") + doc = copy.deepcopy(self.USDC_PERMIT) + doc["message"]["value"] = str((1 << 256) - 1) + msg = eth.EthereumSignTypedData() + for n in PATH: + msg.address_n.append(n) + msg.primary_type = doc['primaryType'] + msg.metamask_v4_compat = True + resp = self.client.call_raw(msg) + for _ in range(400): + if isinstance(resp, proto.ButtonRequest): + time.sleep(SETTLE) + layout = bytes(self.client.debug.read_layout()) + if oled_text.find_line(layout, 'UNLIMITED ALLOWANCE', + oled_text.TITLE_FONT) is not None: + self.client.debug.press_no() + else: + self.client.debug.press_yes() + resp = self.client.call_raw(proto.ButtonAck()) + elif isinstance(resp, eth.EthereumTypedDataStructRequest): + resp = self.client.call_raw( + es.build_struct_ack(es.struct_members(doc, resp.name))) + elif isinstance(resp, eth.EthereumTypedDataValueRequest): + resp = self.client.call_raw(es.value_ack(doc, resp)) + else: + break + self.assertIsInstance(resp, proto.Failure) + self.assertEqual(resp.code, types.Failure_ActionCancelled) + + def test_ambiguous_unlimited_permits_sign_with_unlimited_on_the_value_screen( + self): + """An unlimited permit the device cannot attribute -- a Permit whose + type hash is not EIP-2612's or DAI's, an EIP-2612 permit whose domain + names no token, an unlimited Permit2-shaped amount outside canonical + PermitSingle -- is signed, never refused (owner rule 2026-10-07): its + value screen is titled UNLIMITED APPROVAL. With no token to name, no + "UNLIMITED allowance" warning follows.""" + self.requires_firmware("7.16.0") + self.requires_release_capability("erc20-unlimited-permit-review") + no_nonce = copy.deepcopy(self.USDC_PERMIT) # another type hash + no_nonce["types"]["Permit"] = [ + m for m in no_nonce["types"]["Permit"] if m["name"] != "nonce"] + del no_nonce["message"]["nonce"] + no_token = copy.deepcopy(self.USDC_PERMIT) + no_token["types"]["EIP712Domain"] = [ + m for m in no_token["types"]["EIP712Domain"] + if m["name"] != "verifyingContract"] + del no_token["domain"]["verifyingContract"] + no_token_dai = copy.deepcopy(self.DAI_PERMIT) + no_token_dai["types"]["EIP712Domain"] = [ + m for m in no_token_dai["types"]["EIP712Domain"] + if m["name"] != "verifyingContract"] + del no_token_dai["domain"]["verifyingContract"] + transfer = { # Permit2 SignatureTransfer: not reviewed in words + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "TokenPermissions": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "PermitTransferFrom": [ + {"name": "permitted", "type": "TokenPermissions"}, + {"name": "spender", "type": "address"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + ], + }, + "primaryType": "PermitTransferFrom", + "domain": {"name": "Permit2", "chainId": 1, "verifyingContract": + "0x000000000022D473030F116dDEE9F6B43aC78BA3"}, + "message": { + "permitted": { + "token": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "1000000"}, + "spender": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + "nonce": "0", "deadline": "1893456000"}, + } + cases = ( + (no_nonce, ("value",), "value", str((1 << 256) - 1)), + (no_token, ("value",), "value", str(1 << 255)), + (no_token_dai, ("allowed",), "allowed", True), + (transfer, ("permitted", "amount"), "permitted.amount", + str((1 << 256) - 1)), + ) + for doc, path, leaf, unlimited_value in cases: + finite = copy.deepcopy(doc) + if leaf == "allowed": + finite["message"]["allowed"] = False + # A finite amount signs and shows the leaf; its position is where + # the unlimited amount must stop. + self._assert_reference_signature(finite, self._walk(finite)) + before_leaf = self._leaf_index(leaf) + unlimited = copy.deepcopy(doc) + target = unlimited["message"] + for key in path[:-1]: + target = target[key] + target[path[-1]] = unlimited_value + self._assert_reference_signature(unlimited, + self._walk(unlimited)) + titled = [i for i, (_, layout) in enumerate(self.frames) + if oled_text.find_line(layout, 'UNLIMITED APPROVAL', + oled_text.TITLE_FONT) is not None] + with self.subTest(leaf): + self.assertEqual(titled, [before_leaf]) + self.assertEqual(self._warnings(), []) + + def test_1024_byte_dynamic_bytes_leaf_signs(self): + """A 1024-byte `bytes` leaf is reviewed in parts and signs; the digest + and signer are checked independently.""" + doc = { + "types": { + "EIP712Domain": [{"name": "name", "type": "string"}], + "Blob": [{"name": "data", "type": "bytes"}], + }, + "primaryType": "Blob", + "domain": {"name": "Blob"}, + "message": {"data": "0x" + bytes(range(256)).hex() * 4}, + } + resp = self._walk(doc) + self._assert_reference_signature(doc, resp) + + + # -- Values over one ValueAck ------------------------------------------ + # Safe MultiSend data and Snapshot proposal bodies run past 1 KB. Firmware + # with CAPABILITY_EIP712_CHUNKED_VALUES takes them in 1 KB chunks and + # shows every byte, numbered over the whole value. + + def _walk_text(self, doc, tamper=None, decline=None, max_steps=600): + """The walk, recording each confirmation's (title, body) exactly as + formatted (once per confirmation, however it is paged). `tamper` + may rewrite a value answer; `decline` names a body prefix to reject.""" + msg = eth.EthereumSignTypedData(address_n=PATH, + primary_type=doc['primaryType'], + metamask_v4_compat=True) + pages = ConfirmationPages() + screens = pages.texts + resp = self.client.call_raw(msg) + for _ in range(max_steps): + if isinstance(resp, proto.ButtonRequest): + text = self.client.debug.read_confirm_text() + pages.add(resp.code, text) + if decline and text[1].startswith(decline): + self.client.debug.press_no() + else: + self.client.debug.press_yes() + resp = self.client.call_raw(proto.ButtonAck()) + elif isinstance(resp, eth.EthereumTypedDataStructRequest): + resp = self.client.call_raw( + es.build_struct_ack(es.struct_members(doc, resp.name))) + elif isinstance(resp, eth.EthereumTypedDataValueRequest): + ack = es.value_ack(doc, resp, chunked=True) + if tamper: + tamper(ack) + resp = self.client.call_raw(ack) + else: + if not isinstance(resp, proto.Failure): + pages.finish() + return resp, screens + raise AssertionError('walk did not terminate') + + @staticmethod + def _escaped(data): + """A string as the review draws it: 0x21-0x7e as itself but a + backslash doubled, any other byte (a space too) as \\xNN.""" + return ''.join('\\\\' if b == 0x5c else chr(b) if 0x21 <= b <= 0x7e + else '\\x%02x' % b for b in data) + + def _parts(self, screens, path, type_name): + """The parts of `path`, checked to count 1..N in order, joined.""" + joined, total = '', None + for number, (_, body) in enumerate( + b for b in screens if b[1].startswith(path + ' (')): + head, _, rest = body.partition('\n') + index, of = head[len(path) + 2:-1].split('/') + self.assertEqual(int(index), number + 1) + self.assertIn(total, (None, int(of))) + total = int(of) + self.assertTrue(rest.startswith(type_name + ': '), body[:60]) + joined += rest[len(type_name) + 2:] + self.assertIsNotNone(total, 'no part of ' + path) + self.assertEqual(number + 1, total) + return joined, total + + def _assert_signed(self, doc, resp): + self.assertIsInstance(resp, eth.EthereumTypedDataSignature, + getattr(resp, 'message', resp)) + domain, message, digest = reference_eip712_hashes(doc) + self.assertEqual(resp.domain_separator_hash.hex(), domain.hex()) + self.assertEqual(resp.message_hash.hex(), message.hex()) + address = self.client.ethereum_get_address(PATH) + self.assertEqual(recover_signer(resp.signature, digest).hex(), + address.hex()) + + @staticmethod + def _blob(data_type, value): + return { + "types": { + "EIP712Domain": [{"name": "name", "type": "string"}], + "Blob": [{"name": "data", "type": data_type}, + {"name": "n", "type": "uint256"}], + }, + "primaryType": "Blob", + "domain": {"name": "Blob"}, + "message": {"data": value, "n": 7}, + } + + def test_values_over_1kb_sign_in_chunks(self): + """1024, 1025 and 2048 bytes, and a string with a three-byte + character split across the first chunk boundary: each signs with the + independent digest, every byte is shown once and in order, and the + part counter covers the whole value from its first screen.""" + self.requires_release_capability(es.CHUNKED_VALUES) + euro = 'a' * 1023 + '\u20ac' + 'b' * 500 + cases = [('bytes', bytes(range(256)) * 4), + ('bytes', bytes(range(256)) * 4 + b'\xfe'), + ('bytes', bytes(range(256)) * 8), + ('string', ('x' * 1025).encode()), + ('string', euro.encode('utf-8'))] + for data_type, data in cases: + with self.subTest(type=data_type, length=len(data)): + value = ('0x' + data.hex() if data_type == 'bytes' + else data.decode('utf-8')) + doc = self._blob(data_type, value) + resp, screens = self._walk_text(doc) + self._assert_signed(doc, resp) + shown, parts = self._parts(screens, 'data', data_type) + self.assertEqual(shown, '0x' + data.hex() + if data_type == 'bytes' + else self._escaped(data)) + self.assertGreater(parts, 1) + + def test_misframed_chunks_are_refused(self): + """A chunk at another offset than the one asked for, or one running + past the total, is refused, and the session is gone.""" + self.requires_release_capability(es.CHUNKED_VALUES) + doc = self._blob('bytes', '0x' + 'ab' * 1500) + + def wrong_offset(ack): + if ack.HasField('value_offset'): + ack.value_offset += 1 + + def past_total(ack): + if ack.HasField('value_offset'): + ack.value = ack.value + b'\xab' * 100 + + for tamper, error in ((wrong_offset, 'EIP-712 value chunk out of order'), + (past_total, + 'EIP-712 value chunk has the wrong length')): + with self.subTest(error=error): + resp, _ = self._walk_text(doc, tamper) + self.assertIsInstance(resp, proto.Failure) + self.assertEqual((resp.code, resp.message), + (types.Failure_SyntaxError, error)) + stale = self.client.call_raw(eth.EthereumTypedDataValueAck( + value=b'\xab' * 476, value_offset=1024)) + self.assertIsInstance(stale, proto.Failure) + self._assert_signed(doc, self._walk_text(doc)[0]) + + def test_declining_a_part_mid_value_signs_nothing(self): + """Rejecting a part of the second chunk cancels; the next document + signs with its own digest.""" + self.requires_release_capability(es.CHUNKED_VALUES) + doc = self._blob('bytes', '0x' + 'cd' * 3000) + resp, screens = self._walk_text(doc, decline='data (8/') + self.assertIsInstance(resp, proto.Failure) + self.assertEqual(resp.code, types.Failure_ActionCancelled) + self.assertTrue(screens[-1][1].startswith('data (8/')) + self._assert_signed(doc, self._walk_text(doc)[0]) + + def test_unlimited_approve_in_chunked_safe_data_warns_first(self): + """approve(spender, 2^256-1) heading a 3 KB SafeTx.data: the + UNLIMITED warning is read from the first chunk and shown before any + screen of the bytes.""" + self.requires_release_capability(es.CHUNKED_VALUES) + self.requires_release_capability("erc20-unlimited-approve-review") + spender = '68b3465833fb72a70ecdf485e0e4c7bd8665fc45' + data = ('095ea7b3' + '00' * 12 + spender + 'ff' * 32).ljust(6000, '0') + doc = { + "types": { + "EIP712Domain": [{"name": "chainId", "type": "uint256"}], + "SafeTx": [{"name": "to", "type": "address"}, + {"name": "data", "type": "bytes"}], + }, + "primaryType": "SafeTx", + "domain": {"chainId": 1}, + "message": {"to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "data": "0x" + data}, + } + resp, screens = self._walk_text(doc) + self._assert_signed(doc, resp) + titles = [title for title, _ in screens] + self.assertEqual(titles.count('UNLIMITED approval'), 1) + warning = titles.index('UNLIMITED approval') + first = next(i for i, (_, body) in enumerate(screens) + if body.startswith('data (')) + self.assertLess(warning, first) + self.assertIn('to spend ALL your USDC', screens[warning][1]) + self.assertEqual(self._parts(screens, 'data', 'bytes')[0], + '0x' + data) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_eos_getpublickey.py b/tests/test_msg_eos_getpublickey.py index d1121ef3..75d82d58 100644 --- a/tests/test_msg_eos_getpublickey.py +++ b/tests/test_msg_eos_getpublickey.py @@ -51,6 +51,17 @@ def test(self): res = self.client.eos_get_public_key(path, show, legacy) self.assertEqual(res.wif_public_key, wif) + def test_show(self): + self.requires_fullFeature() + self.requires_message("EosGetPublicKey") + self.setup_mnemonic_nopin_nopassphrase() + for legacy, prefix in ((True, 'EOS'), (False, 'EOS_K1_')): + wif = prefix + EOS_ACCOUNT_0_PUBKEY + res = self.assert_shows_address( + lambda: self.client.eos_get_public_key( + EOS_ACCOUNT_0_PATH, True, legacy), wif) + self.assertEqual(res.wif_public_key, wif) + def test_trezor(self): self.requires_fullFeature() self.setup_mnemonic_abandon() diff --git a/tests/test_msg_eos_signtx.py b/tests/test_msg_eos_signtx.py index f04c990d..a939c7d6 100644 --- a/tests/test_msg_eos_signtx.py +++ b/tests/test_msg_eos_signtx.py @@ -247,7 +247,7 @@ def authorization(self, is_slip48): threshold=1, keys=[ proto.EosAuthorizationKey( - type=1, + type=0, address_n=parse_path("m/48'/4'/1'/0'/0'"), weight=1) ], @@ -267,7 +267,7 @@ def authorization(self, is_slip48): threshold=2, keys=[ proto.EosAuthorizationKey( - type=1, + type=0, address_n=parse_path("m/44'/194'/0'/0/0"), weight=2) ], @@ -331,7 +331,7 @@ def action_newaccount(self): threshold=1, keys=[ proto.EosAuthorizationKey( - type=1, + type=0, address_n=parse_path("m/44'/194'/0'/0/0"), weight=1) ], @@ -558,7 +558,19 @@ def test_updateauth(self): num_actions=1), [self.action_updateauth(False)]) - self.assertEqual(binascii.hexlify(res.hash), "0282c00575f451a47e99902ab2a51243499ea004df309148d1f2e23c007520b7") + self.assertEqual(binascii.hexlify(res.hash), "bf6f207d8f79500180aa5025d1f4a44d36bdd1136f093979d6975c20b2319a07") + + # Fork #568/#563: eos_hashAuthorization() used to emit accounts_count + # wait entries instead of waits_count. This SLIP-48 vector has one + # delegated account and zero waits, so older firmware hashes a phantom + # 6-byte zero wait that was neither present nor confirmed on-device + # (the known-bad digest d46ec843...). The fix is on the 7.15 and 7.16 + # release heads; 7.14.3 is a bitcoin-only release and its full image + # keeps the 7.14.2 EOS serializer. c94f1271... is checked against an + # independent EOSIO ABI serializer; the derived key is K1 (type 0). + if not self.firmware_at_least("7.15.0"): + self.skipTest("Firmware before 7.15.0 hashes a phantom updateauth " + "wait (fork #568/#563)") res = self.client.eos_sign_tx_raw( proto.EosSignTx( @@ -568,7 +580,7 @@ def test_updateauth(self): num_actions=1), [self.action_updateauth(True)]) - self.assertEqual(binascii.hexlify(res.hash), "fb936ef1be4bda680d93bd10b6d062357d8dd7272038a706dc0d61a91f39c5ee") + self.assertEqual(binascii.hexlify(res.hash), "c94f1271f26492a4ca308a6cfae610ce41a3beb7354beb1747e2a2261c8c1973") def test_deleteauth(self): self.requires_fullFeature() @@ -624,7 +636,7 @@ def test_newaccount(self): num_actions=1), [self.action_newaccount()]) - self.assertEqual(binascii.hexlify(res.hash), "8e0accde9fb6529b5d72b4d9a9859e1dae0c6ae9a159bb1ea8c8f579f942c291") + self.assertEqual(binascii.hexlify(res.hash), "0f2c85cfc957eda7e37d6d006f98a56948fe4ab34ecea64d1a1462f4c6fbb387") def test_unknown_advanced(self): self.requires_fullFeature() @@ -1405,9 +1417,10 @@ def test_eos_signtx_newaccount(self): json.loads(data)) assert isinstance(actionResp, proto.EosSignedTx) - self.assertEqual(binascii.hexlify(actionResp.signature_r), "61317ec7dc5c6e62ef7e45b4961ad89fe748c5a9076f23e0c6afc90ad1f93f47") - self.assertEqual(binascii.hexlify(actionResp.signature_s), "056996515e6c6e28be136821859cf2092f719a8da25171ce6534c2bf9a013d3d") - self.assertEqual(actionResp.signature_v, 32) + self.assertEqual(binascii.hexlify(actionResp.hash), "584a5c3ded379ce4f3d3f6ea3364726e0e608214170eb9f7b25baa3010255cb1") + self.assertEqual(binascii.hexlify(actionResp.signature_r), "2db69cbe5f1600ca5d54c77b6f79426cde215531ee84d90eada8de310d07292f") + self.assertEqual(binascii.hexlify(actionResp.signature_s), "6d113bbdb017b95f4d9fb7e8fc3493b763576193409ed19d6e0d9b42c0cf754a") + self.assertEqual(actionResp.signature_v, 31) def test_eos_signtx_setcontract(self): self.requires_fullFeature() diff --git a/tests/test_msg_ethereum_certified_contacts.py b/tests/test_msg_ethereum_certified_contacts.py new file mode 100644 index 00000000..5fef3084 --- /dev/null +++ b/tests/test_msg_ethereum_certified_contacts.py @@ -0,0 +1,264 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +"""Certified address-book contacts. + +The user approves a list of contacts on the device, which signs a commitment +to it with a key derived from the seed. A later send to one of them carries a +proof, and the device names the contact, then shows the full address. + +The request, commitment and proof are built here from the format alone, not +with the firmware's or KeepKey Desktop's code, and the device's signature is +checked with python-ecdsa. +""" + +import hashlib +import unittest + +import common +import oled_text + +from ecdsa import SECP256k1, VerifyingKey, util + +from keepkeylib import messages_pb2 as proto +from keepkeylib import messages_ethereum_pb2 as eth_proto +from keepkeylib import types_pb2 as types +from keepkeylib.tools import int_to_big_endian, parse_path + +ALICE = "d8dA6BF26964aF9D7eEd9e03E53415D37aA96045" +BOB = "52908400098527886E0F7030069857D2E4169EE7" +CAROL = "8617E340B3D01FA5F11F306F4090FD50E238070D" +USDC = "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + +BOOK = (("eip155:1", ALICE, "Alice"), + ("eip155:8453", BOB, "Bob at the bank"), + ("eip155:1", CAROL, "Carol")) +REVISION = 7 + + +def _entry(network, address, label): + net, dest, name = network.encode(), bytes.fromhex(address), label.encode() + return (bytes([len(net)]) + net + bytes([1, len(dest)]) + dest + + bytes([len(name)]) + name) + + +def _sha(data): + return hashlib.sha256(data).digest() + + +def _levels(book): + """Merkle levels, leaves first; a short level repeats its last leaf.""" + level = [_sha(b"KKABLEAF" + _entry(*c)) for c in book] + width = 1 + while width < len(level): + width *= 2 + level += [level[-1]] * (width - len(level)) + levels = [level] + while len(level) > 1: + level = [_sha(level[i] + level[i + 1]) for i in range(0, len(level), 2)] + levels.append(level) + return levels + + +def _header(book, revision): + return b"\x01" + revision.to_bytes(4, "big") + bytes([len(book)]) + + +def request(book, revision=REVISION): + return (b"KKABREQ1" + _header(book, revision) + + b"".join(_entry(*c) for c in book)) + + +def manifest(book, revision=REVISION): + return b"KKABRT01" + _header(book, revision) + _levels(book)[-1][0] + + +def proof(book, index, public_key, signature, revision=REVISION): + levels = _levels(book) + siblings, cursor = [], index + for level in levels[:-1]: + siblings.append(level[cursor ^ 1]) + cursor >>= 1 + return (b"KKABPRF1" + _header(book, revision) + levels[-1][0] + public_key + + signature + _entry(*book[index]) + + bytes([index, len(siblings)]) + b"".join(siblings)) + + +class TestMsgEthereumCertifiedContacts(common.KeepKeyTest): + + def _ready(self): + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.requires_message("ClearsignAttestorSign") + self.requires_release_capability("certified-contacts") + self.setup_mnemonic_allallall() + + def _screens(self, msg): + """Send `msg`, approving every screen; return (screens, response).""" + screens = [] + press = self.client.callback_ButtonRequest + + def capture(req): + screens.append(self.client._read_oled_after_settle()) + return press(req) + + self.client.callback_ButtonRequest = capture + try: + resp = self.client.call(msg) + finally: + del self.client.callback_ButtonRequest + return screens, resp + + def _certify(self, book=BOOK): + screens, resp = self._screens( + proto.ClearsignAttestorSign(payload=request(book))) + self.assertIsInstance(resp, proto.ClearsignAttestorSignature) + return screens, resp.public_key, resp.signature + + def _send(self, to=ALICE, **kw): + fields = dict( + address_n=parse_path("m/44'/60'/0'/0/0"), + nonce=int_to_big_endian(1), + gas_price=int_to_big_endian(20000000000), + gas_limit=int_to_big_endian(21000), + value=int_to_big_endian(10**18), + to=bytes.fromhex(to), + chain_id=1) + fields.update(kw) + screens, resp = self._screens(eth_proto.EthereumSignTx(**fields)) + self.assertIsInstance(resp, eth_proto.EthereumTxRequest) + self.assertEqual(len(resp.signature_r), 32) + return screens + + def _load(self, payload): + ack = self.client.call_raw( + eth_proto.EthereumTxMetadata(signed_payload=payload)) + self.assertIsInstance(ack, eth_proto.EthereumMetadataAck) + return ack + + def _shown(self, screens, text): + return [i for i, s in enumerate(screens) if oled_text.shows(s, text)] + + def test_certifying_shows_every_contact_and_signs_only_the_commitment(self): + self._ready() + # No AdvancedMode: the user reviews their own contacts. + screens, public_key, signature = self._certify() + + # One summary, then a name screen and an address screen per contact. + self.assertEqual(len(screens), 1 + 2 * len(BOOK)) + self.assertTrue(oled_text.shows( + screens[0], "Approve 3 contacts?\nRevision 7")) + for i, (network, address, label) in enumerate(BOOK): + self.assertTrue(oled_text.shows( + screens[1 + 2 * i], "%s\n%s" % (label, network))) + self.assertTrue(oled_text.shows( + screens[2 + 2 * i], "0x" + address)) + + self.assertEqual(len(public_key), 33) + self.assertEqual(len(signature), 64) + key = VerifyingKey.from_string(public_key, curve=SECP256k1) + self.assertTrue(key.verify_digest( + signature, _sha(manifest(BOOK)), sigdecode=util.sigdecode_string)) + + def test_a_send_to_a_contact_names_it_and_still_shows_the_address(self): + self._ready() + baseline = self._send() + self.assertEqual( + len(self._shown(baseline, "Send 1 ETH to 0x" + ALICE)), 1) + + _, public_key, signature = self._certify() + ack = self._load(proof(BOOK, 0, public_key, signature)) + self.assertEqual(ack.display_summary, "Contact verified") + named = self._send() + + # The same review plus one screen: the name, then the full address. + self.assertEqual(len(named), len(baseline) + 1) + at = self._shown(named, "Send 1 ETH to Alice") + self.assertEqual(len(at), 1) + self.assertTrue(oled_text.shows(named[at[0] + 1], "0x" + ALICE)) + self.assertEqual(self._shown(named, "Send 1 ETH to 0x" + ALICE), []) + + # One proof names one send: the next one is the baseline again. + again = self._send() + self.assertEqual(len(again), len(baseline)) + self.assertEqual(self._shown(again, "Send 1 ETH to Alice"), []) + + def test_a_known_token_transfer_names_the_recipient(self): + self._ready() + _, public_key, signature = self._certify() + self._load(proof(BOOK, 0, public_key, signature)) + data = (bytes.fromhex("a9059cbb") + bytes(12) + bytes.fromhex(ALICE) + + (1).to_bytes(32, "big")) + screens = self._send(to=USDC, value=b"", data_length=len(data), + data_initial_chunk=data) + at = self._shown(screens, "Send 0.000001 USDC to Alice") + self.assertEqual(len(at), 1) + self.assertTrue(oled_text.shows(screens[at[0] + 1], "0x" + ALICE)) + + def test_a_proof_names_only_its_own_address_and_network(self): + self._ready() + baseline = self._send() + _, public_key, signature = self._certify() + + # Carol's proof, a send to Alice. + self._load(proof(BOOK, 2, public_key, signature)) + self.assertEqual(len(self._send()), len(baseline)) + + # Bob is a contact on Base only: on Ethereum he is an address. + self._load(proof(BOOK, 1, public_key, signature)) + screens = self._send(to=BOB) + self.assertEqual(self._shown(screens, "Send 1 ETH to Bob at the bank"), + []) + self.assertEqual(len(self._shown(screens, "Send 1 ETH to 0x" + BOB)), 1) + + def test_an_altered_proof_is_refused_and_the_send_is_unchanged(self): + self._ready() + baseline = self._send() + _, public_key, signature = self._certify() + good = proof(BOOK, 0, public_key, signature) + label_at = good.index(b"Alice") + cases = { + "label": good[:label_at] + b"Alicf" + good[label_at + 5:], + "address": good.replace(bytes.fromhex(ALICE), bytes.fromhex(BOB)), + "signature": good.replace( + signature, signature[:-1] + bytes([signature[-1] ^ 1])), + "another book": proof(BOOK[:2], 0, public_key, signature), + } + for name, bad in cases.items(): + with self.subTest(name): + ack = self._load(bad) + self.assertEqual(ack.display_summary, "Invalid contact") + screens = self._send() + self.assertEqual(len(screens), len(baseline)) + self.assertEqual(self._shown(screens, "Send 1 ETH to Alice"), + []) + + def test_a_book_the_device_cannot_vouch_for_is_refused_unreviewed(self): + self._ready() + bad_books = { + "one address, two names": (("eip155:1", ALICE, "Alice"), + ("eip155:1", ALICE, "Mallory")), + "one name, two addresses": (("eip155:1", ALICE, "Alice"), + ("eip155:1", BOB, "Alice")), + "label ends in a space": (("eip155:1", ALICE, "Alice "),), + "label is padded": (("eip155:1", ALICE, "Bob eip155:1"),), + "not an EVM network": (("cosmos:cosmoshub-4", ALICE, "Alice"),), + } + for name, book in bad_books.items(): + with self.subTest(name): + # call_raw answers nothing: a ButtonRequest here would mean + # the device started a review. + resp = self.client.call_raw( + proto.ClearsignAttestorSign(payload=request(book))) + self.assertIsInstance(resp, proto.Failure) + self.assertEqual(resp.code, types.Failure_SyntaxError) + self.assertEqual(resp.message, "Invalid address book") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_ethereum_certified_intent.py b/tests/test_msg_ethereum_certified_intent.py new file mode 100644 index 00000000..0daa6c99 --- /dev/null +++ b/tests/test_msg_ethereum_certified_intent.py @@ -0,0 +1,105 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +"""SRS-7.16 §3.7: the certified EVM review states who, what, why and limits. + +RELAY_ENVELOPE is the exact signedPayload the deployed ClearSign Worker +returned on 2026-10-02 (source revision 6a33fd944) for POST /v1/evm/schema +with the chainId 1 Relay bridgeDeposit shape. It is deterministic: the same +request returns the same bytes. Its inner schema is version 0x05. +""" + +import unittest + +import common +from keepkeylib.tools import parse_path +from oled_text import TITLE_FONT, find_line, find_text, shows +from test_msg_display_disclosure import ScreenRecorder + +RELAY_ENVELOPE = bytes.fromhex( + "030101000000016b359b004b6565704b657920416c7068612037313600000000" + "00000000000000000000000342f5f9704494b3f9bd72295eecaf29d783d23ea0" + "2b2dc9f48abcd2e46d4850cffc4364672f0172aa70ed21eacc2ea8bd0d333119" + "ddf048b096686e1a8e56bda438f9e70b9cfc229a1e93e2d91846c31c4083d8b1" + "76e225d76341069dd22826cf05000000014cd00e387622c35bddb9b4c962c136" + "462338bc3149290c1c000d6272696467654465706f73697402096465706f7369" + "746f720100076f7264657249640300030552656c617936427269646765207b76" + "7d207468726f7567682052656c617920666f72207b307d3b2064656c69766572" + "792069732062792052656c6179010000000080dd0b0e67378e8447342c826ef3" + "89149bb5adf8e2bab1e51cfe1ebc561b547acd1ad3360a774730273035fd962f" + "bb3d01dc75044ffee8d26680558957c6a5db521c") +RELAY = bytes.fromhex("4cd00e387622c35bddb9b4c962c136462338bc31") +DEPOSITOR = bytes.fromhex("909ef6b32dfdc12ca86aa710b54c991af3c5f82e") +ORDER_ID = bytes.fromhex( + "8a2c121197efc95c42f53142ab409735ee353287f877ed4d351f63094d5bfcb1") +VALUE = 7988 * 10**12 # 0.007988 ETH +CLASSIFICATION_VERIFIED = 1 +KEYID_DELEGATE = 0x80 + + +class TestEthereumCertifiedIntent(common.KeepKeyTest): + + def setUp(self): + super(TestEthereumCertifiedIntent, self).setUp() + self.requires_firmware("7.16.0") + self.requires_message("EthereumTxMetadata") + self.setup_mnemonic_allallall() + + def _sign(self): + data = (bytes.fromhex("49290c1c") + b"\0" * 12 + DEPOSITOR + ORDER_ID) + recorder = ScreenRecorder(self.client, answer=True, + screenshot_group="eth-certified-intent") + with recorder: + sig = self.client.ethereum_sign_tx( + n=parse_path("m/44'/60'/0'/0/0"), nonce=0, gas_price=20 * 10**9, + gas_limit=100000, to=RELAY, value=VALUE, data=data, chain_id=1) + return sig, recorder.screens + + def assertShows(self, screens, title, body): + for i, screen in enumerate(screens): + if (find_line(screen, title, TITLE_FONT) is not None and + shows(screen, body)): + return i + self.fail("no screen titled %r shows %r" % (title, body)) + + def test_relay_bridge_deposit_reads_as_a_sentence_with_limits(self): + self.requires_release_capability("evm-certified-intent") + resp = self.client.ethereum_send_tx_metadata( + signed_payload=RELAY_ENVELOPE, metadata_version=3, + key_id=KEYID_DELEGATE) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + sig, screens = self._sign() + self.assertEqual(len(sig[1]), 32) + + summary = self.assertShows( + screens, "RELAY", + "Bridge 0.007988 ETH through Relay for 0x909E...F82E; " + "delivery is by Relay") + limits = self.assertShows(screens, "LIMITS", "You spend\n0.007988 ETH") + contract = self.assertShows( + screens, "CONTRACT", + "bridgeDeposit\n0x4cD00E387622C35bDDB9b4c962C136462338BC31") + # Shortened in the sentence, so shown in full on its own screen. + depositor = self.assertShows( + screens, "DEPOSITOR", "0x909Ef6B32DfDc12CA86aA710b54c991af3C5F82E") + who = next(i for i, sc in enumerate(screens) + if find_line(sc, "KEEPKEY CLEARSIGN", TITLE_FONT)) + # The root-authenticated id is the 8-char short form, as on Solana. + self.assertTrue(shows( + screens[who], + "Described by KeepKey Alpha 716 A9531B9D\ncertified by KeepKey")) + # Summary, limits, contract, depositor, orderId x2, who, fee. + self.assertEqual(len(screens), 8) + self.assertEqual([summary, limits, contract, depositor], + sorted([summary, limits, contract, depositor])) + self.assertLess(depositor, who) + # msg.value has a role: no separate ETH amount screen. + self.assertIsNone(find_text(screens, "Send 0.007988 ETH")) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_msg_ethereum_certified_uniswap.py b/tests/test_msg_ethereum_certified_uniswap.py new file mode 100644 index 00000000..fda3478d --- /dev/null +++ b/tests/test_msg_ethereum_certified_uniswap.py @@ -0,0 +1,232 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +"""Uniswap on Base clear-signs end to end, with AdvancedMode off. + +APPROVE_ENVELOPE and SWAP_ENVELOPE are the exact signedPayloads the deployed +ClearSign Worker returned on 2026-10-03 (source revision 77121a317) for the +Base USDC approve to Permit2 and for the Universal Router decoder entry with +USDC and USDbC identities. They are certified by the Base (8453) certificate +from the 2026-10-03 root ceremony. SWAP_CALLDATA is real Base transaction +0x81fa9e1f87d0b269986082edbdaebbf6c0b005918f389f6762601b47139d27e1 (PERMIT2_PERMIT + V3_SWAP_EXACT_IN, USDC -> USDbC). +""" + +import unittest + +import common +from keepkeylib.tools import parse_path +from oled_text import TITLE_FONT, find_line, shows +from test_msg_display_disclosure import ScreenRecorder + +APPROVE_ENVELOPE = bytes.fromhex( + "030101000021056b359b004b6565704b657920416c7068612037313600000000" + "00000000000000000000000342f5f9704494b3f9bd72295eecaf29d783d23ea0" + "2b2dc9f48abcd2e46d4850cfd3f4638bcd78bd822b88d56dc66bb34c167e3509" + "597a8d3e86a37f14c5b89a504b04f2868e5fcb3ff93f317e2cae7b1cfca14955" + "9a272a3ea713125d37757a980500002105833589fcd6edb6e08f4c7c32d4f71b" + "54bda02913095ea7b30007617070726f766502075370656e6465720600000000" + "0022d473030f116ddee9f6b43ac78ba30009416c6c6f77616e63650506045553" + "4443060007556e6973776170454c65742074686520556e697377617020617070" + "726f76616c20636f6e7472616374207370656e6420757020746f207b317d2066" + "6f722074726164657320796f75207369676e01000000008087528a4f35ce9aff" + "ca9aa2423523d70c508f88c69be9027d42fd9379a6fcb33e2cce6c7f2eb0d5bd" + "46465ab67eb03ee46006804b9d42fccd530ea35c86078d9d1c") + +SWAP_ENVELOPE = bytes.fromhex( + "030101000021056b359b004b6565704b657920416c7068612037313600000000" + "00000000000000000000000342f5f9704494b3f9bd72295eecaf29d783d23ea0" + "2b2dc9f48abcd2e46d4850cfd3f4638bcd78bd822b88d56dc66bb34c167e3509" + "597a8d3e86a37f14c5b89a504b04f2868e5fcb3ff93f317e2cae7b1cfca14955" + "9a272a3ea713125d37757a9807000021053fc91a3afd70395cd496c647d5a6cc" + "9d4b2b7fad3593564c0007657865637574650107556e697377617002833589fc" + "d6edb6e08f4c7c32d4f71b54bda02913060455534443d9aaec86b65d86f6a7b5" + "b1b0c42ffa531710b6ca060555534462430100000000801a02764ec9e5f453d8" + "1ef1ec5eb3ca22504ff159cc414e78ffbe72ed68f595373b2335bf0a8e043e4b" + "ec4951398b081d968961f8bc9ee6602274115f0903f27b1b") + +SWAP_CALLDATA = bytes.fromhex( + "3593564c00000000000000000000000000000000000000000000000000000000" + "0000006000000000000000000000000000000000000000000000000000000000" + "000000a000000000000000000000000000000000000000000000000000000000" + "6ac1740a00000000000000000000000000000000000000000000000000000000" + "000000020a000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "0000000200000000000000000000000000000000000000000000000000000000" + "0000004000000000000000000000000000000000000000000000000000000000" + "000001c000000000000000000000000000000000000000000000000000000000" + "00000160000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54" + "bda02913000000000000000000000000ffffffffffffffffffffffffffffffff" + "ffffffff00000000000000000000000000000000000000000000000000000000" + "6ae8f9fc00000000000000000000000000000000000000000000000000000000" + "000000140000000000000000000000003fc91a3afd70395cd496c647d5a6cc9d" + "4b2b7fad00000000000000000000000000000000000000000000000000000000" + "6ac1740400000000000000000000000000000000000000000000000000000000" + "000000e000000000000000000000000000000000000000000000000000000000" + "00000041e00cccc890d55b0ef26aca5cff2de292fef671a44effbc2a193aedce" + "b46b392545d54a2b769516388081ab2300e7e1567c12bb9a060c0a0f89e5ca2b" + "0b4041311b000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "00000100000000000000000000000000b0712964ddbaa3430dc0637f1ee4dd19" + "279829e900000000000000000000000000000000000000000000000000000000" + "015ef3c000000000000000000000000000000000000000000000000000000000" + "0000b35000000000000000000000000000000000000000000000000000000000" + "000000a000000000000000000000000000000000000000000000000000000000" + "0000000100000000000000000000000000000000000000000000000000000000" + "0000002b833589fcd6edb6e08f4c7c32d4f71b54bda02913000064d9aaec86b6" + "5d86f6a7b5b1b0c42ffa531710b6ca0000000000000000000000000000000000" + "00000000") + +# Live Worker entry (2026-10-03, revision 2592e2570) for Universal Router 2.1.2 +# with the USDC identity, and real Base transaction +# 0x30f15250e5cf168721146e10b5ee5348f65e13c18b91eae451f7a27459c7168e +# (PERMIT2_PERMIT + V3_SWAP_EXACT_IN + UNWRAP_WETH, USDC -> ETH): 1,146 bytes, +# past the first 1,024-byte chunk. +UR212_ENVELOPE = bytes.fromhex( + "030101000021056b359b004b6565704b657920416c7068612037313600000000" + "00000000000000000000000342f5f9704494b3f9bd72295eecaf29d783d23ea0" + "2b2dc9f48abcd2e46d4850cfd3f4638bcd78bd822b88d56dc66bb34c167e3509" + "597a8d3e86a37f14c5b89a504b04f2868e5fcb3ff93f317e2cae7b1cfca14955" + "9a272a3ea713125d37757a980700002105d6145b2d3f379919e8cdeda7b97e37" + "c4b2ca9c403593564c0007657865637574650107556e697377617001833589fc" + "d6edb6e08f4c7c32d4f71b54bda02913060455534443010000000080a3e5938d" + "7538555cb964d071fdd661717878436237a02a1b8a9db5e3f8ab2a2748e8e106" + "2fed388480f70b2941ea79ab46c2511bf8a3b75ca0e29d146ce7ac7b1b") + +USDC_TO_ETH_CALLDATA = bytes.fromhex( + "3593564c00000000000000000000000000000000000000000000000000000000" + "0000006000000000000000000000000000000000000000000000000000000000" + "000000a000000000000000000000000000000000000000000000000000000000" + "6ac1ce0e00000000000000000000000000000000000000000000000000000000" + "000000030a000c00000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "0000000300000000000000000000000000000000000000000000000000000000" + "0000006000000000000000000000000000000000000000000000000000000000" + "000001e000000000000000000000000000000000000000000000000000000000" + "0000034000000000000000000000000000000000000000000000000000000000" + "00000160000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54" + "bda02913000000000000000000000000ffffffffffffffffffffffffffffffff" + "ffffffff00000000000000000000000000000000000000000000000000000000" + "6ae9540600000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000d6145b2d3f379919e8cdeda7b97e37c4" + "b2ca9c4000000000000000000000000000000000000000000000000000000000" + "6ac1ce0e00000000000000000000000000000000000000000000000000000000" + "000000e000000000000000000000000000000000000000000000000000000000" + "00000041bca0b1b572f6c49216e7bc80f764b2bd463f3648cceeb049139b082e" + "b8cf25e12b39513398869adc3e19d6840a20537f762088699307da12d6b04804" + "c8c4b3061b000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "0000014000000000000000000000000000000000000000000000000000000000" + "0000000200000000000000000000000000000000000000000000000000000000" + "019a954600000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "000000c000000000000000000000000000000000000000000000000000000000" + "0000000100000000000000000000000000000000000000000000000000000000" + "0000012000000000000000000000000000000000000000000000000000000000" + "0000002b833589fcd6edb6e08f4c7c32d4f71b54bda029130000644200000000" + "0000000000000000000000000000060000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "000000400000000000000000000000008212891ace49f6dd4a5951f287b93a9c" + "970ee4740000000000000000000000000000000000000000000000000022f5e2" + "7c87c9a0756e6978000001a104f16ff48000964c0000000c0100") + +USDC = bytes.fromhex("833589fcd6edb6e08f4c7c32d4f71b54bda02913") +PERMIT2 = bytes.fromhex("000000000022d473030f116ddee9f6b43ac78ba3") +ROUTER = bytes.fromhex("3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad") +ROUTER_212 = bytes.fromhex("d6145b2d3f379919e8cdeda7b97e37c4b2ca9c40") +CLASSIFICATION_VERIFIED = 1 +KEYID_DELEGATE = 0x80 +# The fee screen names the chain off mainnet (7.15 ethereum_transactionTitle). +FEE_TITLE = "TX ON BASE" + + +class TestEthereumCertifiedUniswap(common.KeepKeyTest): + def setUp(self): + super(TestEthereumCertifiedUniswap, self).setUp() + self.requires_firmware("7.16.0") + self.requires_message("EthereumTxMetadata") + self.setup_mnemonic_allallall() + + def _sign(self, to, data, envelope): + resp = self.client.ethereum_send_tx_metadata( + signed_payload=envelope, metadata_version=3, key_id=KEYID_DELEGATE) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + recorder = ScreenRecorder(self.client, answer=True, + screenshot_group="eth-certified-uniswap") + with recorder: + sig = self.client.ethereum_sign_tx( + n=parse_path("m/44'/60'/0'/0/0"), nonce=0, + gas_price=10**7, gas_limit=200000, to=to, value=0, data=data, + chain_id=8453) + self.assertEqual(len(sig[1]), 32) + return recorder.screens + + def assertScreens(self, screens, expected): + """Every screen, in order: title and full body. Nothing raw, nothing extra.""" + if len(screens) != len(expected): + self.fail("%d screens, expected %d" % (len(screens), len(expected))) + for i, (screen, (title, body)) in enumerate(zip(screens, expected)): + if find_line(screen, title, TITLE_FONT) is None: + self.fail("screen %d title is not %r" % (i, title)) + if body is not None and not shows(screen, body): + self.fail("screen %d (%s) does not show %r" % (i, title, body)) + + def test_unlimited_approve_to_permit2_names_uniswap_and_the_limit(self): + data = (bytes.fromhex("095ea7b3") + b"\0" * 12 + PERMIT2 + b"\xff" * 32) + screens = self._sign(USDC, data, APPROVE_ENVELOPE) + self.assertScreens(screens, [ + ("UNISWAP", "Let the Uniswap approval contract spend up to UNLIMITED " + "USDC for trades you sign"), + ("LIMITS", "Can spend up to\nUNLIMITED USDC"), + ("CONTRACT", None), + ("SPENDER", None), + ("KEEPKEY CLEARSIGN", "Described by KeepKey Alpha 716 A9531B9D\n" + "certified by KeepKey"), + (FEE_TITLE, None), + ]) + + def test_real_base_swap_reads_as_swap_with_limits_and_allowance(self): + screens = self._sign(ROUTER, SWAP_CALLDATA, SWAP_ENVELOPE) + self.assertScreens(screens, [ + ("UNISWAP", "Swap 23 USDC for at least 0.045904 USDbC"), + ("LIMITS", "You spend\n23 USDC"), + ("LIMITS", "You receive at least\n0.045904 USDbC"), + ("RECIPIENT", None), + ("ALLOWANCE", "This router may spend up to UNLIMITED USDC until " + "2026-11-02 UTC"), + ("CONTRACT", None), + ("KEEPKEY CLEARSIGN", "Described by KeepKey Alpha 716 A9531B9D\n" + "certified by KeepKey"), + (FEE_TITLE, None), + ]) + + def test_token_to_eth_past_the_first_chunk_is_reviewed_after_the_last_byte(self): + self.assertGreater(len(USDC_TO_ETH_CALLDATA), 1024) + screens = self._sign(ROUTER_212, USDC_TO_ETH_CALLDATA, UR212_ENVELOPE) + # The fee screen comes first: the swap is reviewed once every byte is in. + self.assertScreens(screens, [ + (FEE_TITLE, None), + ("UNISWAP", "Swap 26.907974 USDC for at least 0.00984050230885008 ETH"), + ("LIMITS", "You spend\n26.907974 USDC"), + ("LIMITS", "You receive at least\n0.00984050230885008 ETH"), + ("RECIPIENT", None), + ("ALLOWANCE", None), + ("CONTRACT", None), + ("KEEPKEY CLEARSIGN", "Described by KeepKey Alpha 716 A9531B9D\n" + "certified by KeepKey"), + ]) + + def test_token_to_eth_with_a_changed_tail_byte_is_not_the_reviewed_swap(self): + data = bytearray(USDC_TO_ETH_CALLDATA) + data[-33] ^= 0x01 # the unwrap minimum, past byte 1024 + screens = self._sign(ROUTER_212, bytes(data), UR212_ENVELOPE) + self.assertFalse(any(shows(s, "0.00984050230885008 ETH") for s in screens)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_msg_ethereum_clear_signing.py b/tests/test_msg_ethereum_clear_signing.py index 5d9e661a..0be2bc30 100644 --- a/tests/test_msg_ethereum_clear_signing.py +++ b/tests/test_msg_ethereum_clear_signing.py @@ -6,18 +6,33 @@ 1. Valid signed metadata → VERIFIED classification 2. Invalid/malicious metadata → MALFORMED classification - 3. Policy: EthBlindSigning disabled → hard reject on unknown contract data + 3. Policy: AdvancedMode disabled → hard reject on unknown contract data 4. Backwards compat: no metadata sent → existing flow unchanged 5. Adversarial: tampered fields, wrong key, replayed metadata, truncated payloads + 6. tx_hash binding: signature is refused unless the signed digest equals the + metadata's committed tx_hash (signed_metadata_enforce) Requires: pip install ecdsa -Test key: private=0x01 (secp256k1 generator point G) — NEVER use in production. +Metadata signer: TEST_PRIVATE_KEY (SignIdentity index 0 of the BIP-39 test +mnemonic). Phase 1 firmware ships with NO built-in verification keys — every +signer is loaded at runtime via LoadClearsignSigner (user-confirmed, RAM-only, +dropped on reboot/wipe), and metadata verified by a loaded signer shows a +warning screen naming the alias before every clearsign page. setUp() loads +the test pubkey into slot 3 with alias 'CI Test'; all metadata vectors use +key_id=3. NEVER use this key in production. +The device wallet (mnemonic12 from common.py) signs the actual transactions. """ +import os import unittest import hashlib import struct +from keepkeylib import messages_ethereum_pb2 as messages_eth +from keepkeylib import messages_pb2 as messages +from keepkeylib import types_pb2 as types +from keepkeylib.tools import int_to_big_endian + try: import common except ImportError: @@ -27,22 +42,44 @@ from keepkeylib.signed_metadata import ( serialize_metadata, + serialize_schema_metadata, + schema_calldata, sign_metadata, build_test_metadata, + token_amount_value, ARG_FORMAT_RAW, ARG_FORMAT_ADDRESS, ARG_FORMAT_AMOUNT, ARG_FORMAT_BYTES, + ARG_FORMAT_STRING, + ARG_FORMAT_TOKEN_AMOUNT, + METADATA_VERSION_SCHEMA, CLASSIFICATION_VERIFIED, CLASSIFICATION_OPAQUE, CLASSIFICATION_MALFORMED, TEST_PRIVATE_KEY, + keccak256, + eth_sighash_legacy, + assert_test_key_matches_slot3, + FIRMWARE_SLOT3_PUBKEY, + test_signer_compressed_pubkey, ) from keepkeylib.tools import parse_path +from keepkeylib.client import CallException, ProtocolMixin + +# The metadata CI slot. Must match: embedded payload key_id, protocol +# EthereumTxMetadata.key_id, and the slot LoadClearsignSigner loaded the +# test pubkey into (phase 1: all built-in METADATA_PUBKEYS slots are zero). +TEST_KEY_ID = 3 + +# Alias shown on the load confirm and on every per-tx warning screen. +CI_SIGNER_ALIAS = 'CI Test' # ─── Test constants ──────────────────────────────────────────────────── -AAVE_V3_POOL = bytes.fromhex('7d2768de32b0b80b7a3454c06bdac94a69ddc7a9') +# Aave V3 Pool proxy, matching AAVE_SUPPLY_SELECTOR below. Was the V2 +# LendingPool address, which exposes deposit() (e8eda9df), not supply(). +AAVE_V3_POOL = bytes.fromhex('87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2') AAVE_SUPPLY_SELECTOR = bytes.fromhex('617ba037') DAI_ADDRESS = bytes.fromhex('6b175474e89094c44da98b954eedeac495271d0f') UNISWAP_ROUTER = bytes.fromhex('68b3465833fb72a70ecdf485e0e4c7bd8665fc45') @@ -52,13 +89,132 @@ # Wrong key for adversarial tests (private key = 0x02) WRONG_PRIVATE_KEY = b'\x00' * 31 + b'\x02' +# The decoded who/what/why for the Aave supply tx below. This is what the +# device screen should show the user, in human terms — NOT raw hex/wei: +# protocol : Aave V3 (STRING — "who": the attested protocol) +# asset : 0x6B17…1d0F (DAI) (ADDRESS — "what": full, never truncated) +# amount : 10.5 DAI (TOKEN_AMOUNT — decimals+symbol scaled) +# onBehalfOf: 0xd8dA…6045 (ADDRESS) +# 10500000000000000000 raw / 1e18 = 10.5 DAI. DEFAULT_ARGS = [ + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Aave V3'}, {'name': 'asset', 'format': ARG_FORMAT_ADDRESS, 'value': DAI_ADDRESS}, - {'name': 'amount', 'format': ARG_FORMAT_AMOUNT, - 'value': (10500000000000000000).to_bytes(32, 'big')}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'value': token_amount_value(10500000000000000000, 18, 'DAI')}, {'name': 'onBehalfOf', 'format': ARG_FORMAT_ADDRESS, 'value': VITALIK}, ] +# A token the firmware token list recognizes (CVC) — see +# test_msg_ethereum_erc20_approve.py, which signs to it with AdvancedMode OFF. +CVC_TOKEN = bytes.fromhex('41e5560054824ea6b0732e656e3ad64e20e94e45') + +# Real mainnet contracts for the full clear-sign flow suite (mirrors the +# keepkey-sdk tests/evm-clearsign payload set). +USDC = bytes.fromhex('a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') +WETH = bytes.fromhex('c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2') +UNISWAP_V2_ROUTER = bytes.fromhex('7a250d5630b4cf539739df2c5dacb4c659f2488d') +UNISWAP_V3_ROUTER = bytes.fromhex('e592427a0aece92de3edee1f18e0157c05861564') +UNISWAP_V3_ROUTER2 = bytes.fromhex('68b3465833fb72a70ecdf485e0e4c7bd8665fc45') +RECIPIENT_742 = bytes.fromhex('742d35cc6634c0532950a20547b231011e30c8e7') + +def _word(v): + return v.to_bytes(32, 'big') + +def _addr_word(a): + return b'\x00' * 12 + a + +# Device wallet path. With mnemonic12 (common.KeepKeyTest) this is signer +# 0x3f2329c9adfbccd9a84f52c906e936a42da18cb8 — used to check recovered signer. +DEVICE_PATH = "44'/60'/0'/0/0" + + +def bound_metadata(tx_hash, contract=AAVE_V3_POOL, selector=AAVE_SUPPLY_SELECTOR, + chain_id=1, method_name='supply', args=None): + """Signed VERIFIED metadata committing to a specific real tx sighash.""" + payload = serialize_metadata( + chain_id=chain_id, + contract_address=contract, + selector=selector, + tx_hash=tx_hash, + method_name=method_name, + args=DEFAULT_ARGS if args is None else args, + key_id=TEST_KEY_ID, + ) + return sign_metadata(payload) + + +def recover_eth_signer(sig_r, sig_s, sig_v, digest, chain_id): + """Recover the 20-byte Ethereum signer from a legacy (EIP-155) signature.""" + from ecdsa import VerifyingKey, SECP256k1, util + if chain_id: + rec = sig_v - (35 + 2 * chain_id) + else: + rec = sig_v - 27 + keys = VerifyingKey.from_public_key_recovery_with_digest( + sig_r + sig_s, digest, SECP256k1, hashfunc=None, + sigdecode=util.sigdecode_string, + ) + return keccak256(keys[rec].to_string())[-20:] + + +def aave_supply_calldata(amount, on_behalf=VITALIK, asset=DAI_ADDRESS, + referral=0): + """Real Aave V3 supply(address asset, uint256 amount, address onBehalfOf, + uint16 referralCode) calldata — selector 0x617ba037 + 4 x 32-byte words = + 132 bytes. Matches the on-chain ABI so the signed tx_hash binds a genuine + transaction, not a toy payload.""" + return (AAVE_SUPPLY_SELECTOR + + b'\x00' * 12 + asset + + amount.to_bytes(32, 'big') + + b'\x00' * 12 + on_behalf + + referral.to_bytes(32, 'big')) + + +# ═══════════════════════════════════════════════════════════════════════ +# CLEARSIGN_FLOWS — the canonical clear-sign payload catalog. +# +# This is the COMPLETE REFERENCE for building a clearsign signer: every +# real-world flow, its exact transaction bytes, and the decoded who/what/why +# the metadata must carry. Uses only the typed formats (ADDRESS / STRING / +# TOKEN_AMOUNT) so the device never renders calldata hex. THE catalog itself +# lives in keepkeylib/clearsign_catalog.py — a single source of truth shared +# with scripts/generate-test-report.py, so the PDF's V section is generated +# FROM these flows rather than hand-duplicated (which drifts). Consumed by: +# - the per-flow device tests (full confirm + sign + recover) +# - test_clearsign_batch_all_payloads (device validates every blob) +# - TestClearsignReferenceVectors (offline: deterministic bytes, snapshots) +# - print_clearsign_flows() --flows (hex dump for external implementations) +# All flows: chain 1, legacy gas, nonce/gas fixed => deterministic tx_hash; +# with REFERENCE_TIMESTAMP + RFC 6979 signing the blobs are byte-reproducible. +# ═══════════════════════════════════════════════════════════════════════ + +from keepkeylib.clearsign_catalog import ( + CLEARSIGN_FLOWS, CLEARSIGN_FLOWS_BY_KEY, FLOW_NONCE, FLOW_GAS_PRICE, + FLOW_GAS_LIMIT, REFERENCE_TIMESTAMP, + flow_tx_hash as _catalog_flow_tx_hash, + flow_blob as _catalog_flow_blob, +) + + +def flow_tx_hash(flow, chain_id=1): + """Deterministic legacy sighash for a catalog flow (fixed nonce/gas). + Every catalog flow is chain_id=1; the param exists only so old call + sites don't need updating, and mismatches fail loudly rather than + silently signing the wrong chain.""" + assert flow['chain_id'] == chain_id, ( + 'flow %s is chain_id=%d, not %d' % (flow['key'], flow['chain_id'], chain_id)) + return _catalog_flow_tx_hash(flow) + + +def flow_blob(flow, chain_id=1, timestamp=None): + """Per-tx-bound signed metadata blob for a catalog flow, signed with + TEST_KEY_ID (the CI signer loaded via LoadClearsignSigner in setUp). + Pass timestamp=REFERENCE_TIMESTAMP for byte-reproducible reference + vectors.""" + assert flow['chain_id'] == chain_id, ( + 'flow %s is chain_id=%d, not %d' % (flow['key'], flow['chain_id'], chain_id)) + return _catalog_flow_blob(flow, key_id=TEST_KEY_ID, timestamp=timestamp) + # ═══════════════════════════════════════════════════════════════════════ # Test Vector Catalog — reference list of signed vs unsigned/invalid/ @@ -401,6 +557,274 @@ def test_tampered_blob_fails_verification(self): with self.assertRaises(BadSignatureError): vk.verify_digest(sig, digest) + def test_test_key_matches_firmware_slot3(self): + """The signing key's pubkey == firmware METADATA_PUBKEYS[3]. + + Guards the BLOCKER: if these diverge, every VERIFIED vector would be + rejected as MALFORMED on device. This is why all vectors use key_id=3. + """ + try: + import ecdsa # noqa: F401 + except ImportError: + self.skipTest('ecdsa library not installed') + self.assertEqual(test_signer_compressed_pubkey(), FIRMWARE_SLOT3_PUBKEY) + # Must not raise. + assert_test_key_matches_slot3() + + def test_default_key_id_is_slot3(self): + """serialize_metadata embeds key_id=3 by default (matches the signer).""" + blob = build_test_metadata(args=[]) + # key_id is the last byte of the payload, i.e. before sig(64)+recovery(1). + self.assertEqual(blob[-66], TEST_KEY_ID) + + def test_keccak256_known_vectors(self): + """keccak256 (not NIST SHA3) — empty string + function selectors.""" + self.assertEqual( + keccak256(b'').hex(), + 'c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470', + ) + self.assertEqual(keccak256(b'transfer(address,uint256)')[:4].hex(), + 'a9059cbb') + self.assertEqual(keccak256(b'approve(address,uint256)')[:4].hex(), + '095ea7b3') + + +# ═══════════════════════════════════════════════════════════════════════ +# Offline reference vectors — the signer contract, frozen in bytes. +# Any implementation (pioneer-insight, keepkey-sdk) that produces these +# exact blobs from the catalog inputs will be accepted by the firmware. +# ═══════════════════════════════════════════════════════════════════════ + +# sha256(blob) + blob length for every catalog flow, signed with +# TEST_PRIVATE_KEY at REFERENCE_TIMESTAMP using RFC 6979 deterministic ECDSA. +# Regenerate (only after an intentional format change): +# python3 -c "import test_msg_ethereum_clear_signing as t, hashlib; +# [print(f['key'], hashlib.sha256(t.flow_blob(f, timestamp=t.REFERENCE_TIMESTAMP)).hexdigest()) +# for f in t.CLEARSIGN_FLOWS]" +REFERENCE_BLOB_SNAPSHOTS = { + 'aave-v3-supply': ('710dc044c914a7320c91774ae5193f5b6509b00eb14e17719f7b31d0274d4892', 246), + 'erc20-transfer': ('adbd1e054f8b59b1bb86af046951df53510c10dcc0ec0e3e46b19eaf6410cf05', 205), + 'erc20-approve': ('75e5108f578f27d60c572d12072fb4cf0455321c6f39445e1d59fe4d99713c91', 193), + 'erc20-approve-unlimited': ('a5c043a60da8f317975ee8f1b9f3a0718186f6bdce625b605ce71973b3fa3811', 221), + 'uniswap-v2-eth-to-token': ('ec5aac82aa9b03f043456e486d6bfc6cbd5cde507997fc07a122f9fb1fb32194', 229), + 'uniswap-v2-token-to-eth': ('d94e8842cde731f2dd77ea47a896618b1a317736744ac34f6cbdaf7367e794a7', 254), + 'uniswap-v3-exact-input': ('7186e5b902209bb68630a4ff360727df3696395c69782d1a94adc4ae58abfa59', 286), + 'uniswap-v3-multicall': ('e76f3d88be226a1cbd51923cf9753fed30bef1a8e830e5f5ea71a362dd7e43d9', 198), + 'aave-v3-pool-borrow': ('224af25cac14759def6a6272ad5572c991bb46beae8ad253ee2e9d9764674f0a', 263), + 'aave-v3-pool-repay': ('4cb1f4742731ba3c90a2c9a41e5dbe72ace0357d47726df6df1861ffd4b291b0', 262), + 'aave-v3-pool-withdraw': ('584234a72fb32c63ba70aeda1e21def382df6fa85c6e6d88291f8f8530975ef6', 222), + 'compound-v3-comet-supply': ('f7324ea680b02a9eb6b8274592195c048690081dd75ce77deaba69790155a045', 219), + 'compound-v3-comet-withdraw': ('a1a9ec8cb33e4f21c8e746ef805f44747a7b42aff26c3687f14aac145316135c', 225), + 'spark-protocol-supply': ('70e8a0f11ab1b8d12960442c2449b865860e93e2c6c9710473070774f38aba6f', 268), + 'lido-steth-submit': ('c1d0efa2dfdac3e824156ed891d8ac405d86a69dd9241608d5bb43c75e8c01c7', 200), + 'rocketpool-deposit-pool-deposit': ('31b67c47a72fc80dce6c54ff50d1eca0281e62ac34657892b00c3ef79ef1bf85', 193), + 'etherfi-liquiditypool-deposit': ('4a85922bf92ef1b6e0d6c6fbcf720a5240c037161dab18222ec73da255a34ea5', 221), + 'eigenlayer-strategymanager-deposit': ('2728fc859048bcc71288bfa04a6e3957638ebc8c841cea2d6bbfa802b3ebaf4d', 267), + 'eigenlayer-strategymanager-deposit-steth': ('688c636044e4572c4a2d02b38eb6d30277fc43d8852c06f489cbe41db961eb31', 274), + 'erc20-usdc-increase-allowance': ('e689183d751352f6f517bffe53028a1d497cf45c3e2c146ee470a9e21901df09', 208), + 'erc20-usdc-decrease-allowance': ('c4997d82e03bd748dab00dfcec0c2f673a2458634efada134b1ae57689fe66b6', 213), + 'eip2612-usdc-permit': ('06889bb26039122fd59f859196cc2d201c343c66bab3b6dba3bbc6860f4f7346', 288), + 'permit2-approve': ('02c762e1ac3c9b3974a4f5d26a48e7766fe139ba6dd803505be3da24d2f0b1ad', 292), + 'erc721-bayc-set-approval-for-all': ('6449489e8d0c6275d532ba40a99f4077a764a8687c10f2583f9a4dbe39da8ccb', 256), + 'erc1155-opensea-storefront-set-approval-for-all': ('a9acc53ea1f1b88a2679495d2e4e5e5f0f089e8daf073699d1504b0d92b974d3', 255), + 'usdt-approve': ('52a5aa020b2151ffb3694277026ea671c095fc7a59a4e37d29d2c9d3a5917302', 193), + 'dai-permit': ('a625ee696af3add431c6be7f6e870875432b726db3e951445ae0899f93a2777b', 269), + 'erc721-safe-transfer-from': ('57a50c128066e30a14ffbfe3ad6fbc913086d1678c6d6abcc9ab1aca48dde555', 231), + 'safe-addownerwiththreshold': ('12979ff0d05396be10daf6016eee0fa4da73f5d44c64899bfb43d09d75075dc7', 257), + 'hop-protocol-l1-bridge-sendtol2': ('2bf4be50ca05159780a8baf3dc73de7d88f4b9150a1331dfd4c4c6e5c11bb7d6', 250), + 'wormhole-token-bridge-transfertokens': ('b903447283627ea9f7dc051652fa26713d715193e2577ddcee99ae3892c0757c', 274), + 'compound-governor-bravo-castvote': ('869f2aaadb966cde633da10b9dd2fdc4419aa2c22d7bd5b0a98ef0a8777da8bd', 209), + 'ens-public-resolver-setaddr': ('38983de76989898d1bc1d6d07f2dfcb93141ac78f263588d67e7829fa7ea5f75', 194), + 'metamorpho-steakhouse-usdc-deposit': ('c965b8598311e92a1399503b9c69b52e6efe274de1b9a168a893c77bb7803a9e', 227), + 'metamorpho-steakhouse-usdc-withdraw': ('fb0415338d2733b46b72157623f0a4e153cb2baf9bc70911fefa001d98e35049', 224), + 'yearn-v2-yusdc-deposit': ('402cf60cf1b79d201e082ffb1c2c8ea4c26f375e2a2296fe258c820a52fc240a', 195), + 'yearn-v3-aave-usdc-lender-deposit': ('4222df2284f1ff9bcd767d5c38961b1687d8d3685aa655c28bbbe7a92346e21c', 224), + 'compound-iii-comet-usdc-supply': ('6419f4f524b6ce606aa822d15afed70b5dc56c92ebb62c691b07196fba3ef2bc', 220), + 'weth-deposit': ('a9d5f44091a616e2c226b40433bcac99ecb2e03b0936241c814d4c844772a387', 193), + 'weth-withdraw': ('6cfcda551f935439cb79b23c35625d5f6288be2f2fff420415018b012f78ef88', 164), + 'erc20-transferfrom': ('2c5e697d6e0c50eb9c256969e00790b5d56163159fa0f352e65d6445fd27e60b', 257), + 'uniswap-v3-exact-output-single': ('b86dc23deb60c3ef29328cf2567e2170ebb20fc2a6b937551e552aabda335a09', 322), + 'curve-3pool-exchange': ('a90e07ecc65c5e40427811a7580095e6278997125bc42ed324bdcf7bac8f1cff', 238), + 'erc1155-safe-transfer-from': ('4b4f46aa1f3be99c131103146120d3bcc72334758055292d5b792470a0240984', 267), + 'erc1155-safe-batch-transfer-from': ('1d9b41bc88b2b635327f5aa5a748a5705b59e6b8a5d3c30f39df48b4793f20a3', 272), + 'uniswap-v4-universal-router-swap': ('7e1584ce8615670ce54972fe6f538d806afa35803033bbe98e2ec75643f81dc1', 258), + 'permit2-permit-transfer-from': ('c0fde596537a6bf1e53b98d3746638b4249a7a90d8196fe4a9f40f711729ec84', 276), + 'across-spokepool-depositv3': ('ab185113f0b47ef5f6e1fab6a6839df8b71bf8d48796afee64a61ba8b336ac01', 311), + 'safe-exectransaction': ('00a523f8e02d196db7213813edfbeee2a707679b026c6c6b6f8af88d35bf4889', 274), + 'erc4337-entrypoint-v0.7-handleops': ('218c253b00780eeeb4f47b343feba7fafe2ecf3441f32afbd13e555cd56db6d2', 276), + 'eip7702-setcode-authorization': ('0518442c7172b8c57fcbd09ded11b54e1d20076c4b5e79a7490c4ae9c2096a18', 299), +} + + +class TestClearsignReferenceVectors(unittest.TestCase): + """Offline (no device): the catalog signs deterministically, every + signature self-verifies, and the bytes match the frozen snapshots.""" + + def setUp(self): + try: + import ecdsa # noqa: F401 + except ImportError: + self.skipTest('ecdsa library not installed') + + def test_batch_sign_all_deterministic_and_verifies(self): + from ecdsa import SigningKey, SECP256k1, util + vk = SigningKey.from_string( + TEST_PRIVATE_KEY, curve=SECP256k1).get_verifying_key() + for flow in CLEARSIGN_FLOWS: + with self.subTest(flow=flow['key']): + blob = flow_blob(flow, timestamp=REFERENCE_TIMESTAMP) + # RFC 6979: signing twice yields identical bytes. + self.assertEqual( + blob, flow_blob(flow, timestamp=REFERENCE_TIMESTAMP)) + # Signature verifies over sha256(signed region). + payload, sig = blob[:-65], blob[-65:-1] + digest = hashlib.sha256(payload).digest() + self.assertTrue(vk.verify_digest( + sig, digest, sigdecode=util.sigdecode_string)) + # Embedded key_id (last payload byte) is the CI slot. + self.assertEqual(payload[-1], TEST_KEY_ID) + + def test_batch_matches_frozen_snapshots(self): + self.assertEqual(set(REFERENCE_BLOB_SNAPSHOTS), + {f['key'] for f in CLEARSIGN_FLOWS}) + for flow in CLEARSIGN_FLOWS: + with self.subTest(flow=flow['key']): + blob = flow_blob(flow, timestamp=REFERENCE_TIMESTAMP) + want_sha, want_len = REFERENCE_BLOB_SNAPSHOTS[flow['key']] + self.assertEqual(len(blob), want_len) + self.assertEqual(hashlib.sha256(blob).hexdigest(), want_sha) + + def test_catalog_uses_only_hexfree_formats(self): + """The catalog is the no-hex reference: RAW/BYTES args (which render + as hex on the OLED) are banned from it.""" + for flow in CLEARSIGN_FLOWS: + for arg in flow['args']: + self.assertIn( + arg['format'], + (ARG_FORMAT_ADDRESS, ARG_FORMAT_STRING, + ARG_FORMAT_TOKEN_AMOUNT), + '%s arg %s uses a hex-rendering format' % + (flow['key'], arg['name'])) + + +# ═══════════════════════════════════════════════════════════════════════ +# v2 static-schema blobs (offline) — no device required +# +# v2 attests only the decode SCHEMA (no tx_hash, no arg values); the device +# decodes the argument values from the calldata it signs. These offline tests +# pin the wire format serialize_schema_metadata() emits so it can never drift +# from firmware's parse_v2_args() / decode_v2_args() undetected. +# ═══════════════════════════════════════════════════════════════════════ + +# transfer(to, amount) on USDC — the canonical v2 fixture. amount is a token +# amount (6 decimals, "USDC"); the value is NOT in the blob, it is decoded from +# the calldata word by the device. +USDC_ADDRESS = bytes.fromhex('a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') +ERC20_TRANSFER_SELECTOR = bytes.fromhex('a9059cbb') +V2_SCHEMA_ARGS = [ + {'name': 'to', 'format': ARG_FORMAT_ADDRESS}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'decimals': 6, 'symbol': 'USDC'}, +] + + +def _v2_transfer_blob(): + body = serialize_schema_metadata( + chain_id=1, contract_address=USDC_ADDRESS, + selector=ERC20_TRANSFER_SELECTOR, method_name='transfer', + args=V2_SCHEMA_ARGS, timestamp=0, key_id=TEST_KEY_ID) + return body, sign_metadata(body) + + +class TestClearSignV2SchemaOffline(unittest.TestCase): + """Offline byte-format tests for the v2 static-schema serializer.""" + + def test_version_byte_is_schema(self): + body, _ = _v2_transfer_blob() + self.assertEqual(body[0], METADATA_VERSION_SCHEMA) + + def test_layout_has_no_tx_hash(self): + """v2 body = version(1)+chain(4)+contract(20)+selector(4)+method... — + the selector sits at offset 25, immediately after the contract, with NO + 32-byte tx_hash in between (that is the whole point of v2).""" + body, _ = _v2_transfer_blob() + self.assertEqual(body[1:5], b'\x00\x00\x00\x01') # chain_id + self.assertEqual(body[5:25], USDC_ADDRESS) # contract + self.assertEqual(body[25:29], ERC20_TRANSFER_SELECTOR) # selector @25 + # method_len(2) + 'transfer'(8) then num_args + self.assertEqual(body[29:31], b'\x00\x08') + self.assertEqual(body[31:39], b'transfer') + self.assertEqual(body[39], len(V2_SCHEMA_ARGS)) + + def test_token_arg_carries_static_decimals_symbol_not_value(self): + """The token arg encodes name + format + decimals + symbol, and NO + value — decimals/symbol are static (a property of the contract), the + amount is decoded on-device from the calldata.""" + body, _ = _v2_transfer_blob() + # after num_args @39: arg0 'to' = len(1)+'to'(2)+format(1) = 4 bytes + p = 40 + self.assertEqual(body[p], 2) # name_len 'to' + self.assertEqual(body[p + 1:p + 3], b'to') + self.assertEqual(body[p + 3], ARG_FORMAT_ADDRESS) + p += 4 + # arg1 'amount' = len(1)+'amount'(6)+format(1)+decimals(1)+symlen(1)+'USDC'(4) + self.assertEqual(body[p], 6) + self.assertEqual(body[p + 1:p + 7], b'amount') + self.assertEqual(body[p + 7], ARG_FORMAT_TOKEN_AMOUNT) + self.assertEqual(body[p + 8], 6) # decimals + self.assertEqual(body[p + 9], 4) # symbol_len + self.assertEqual(body[p + 10:p + 14], b'USDC') + + def test_signed_blob_is_body_plus_65(self): + body, blob = _v2_transfer_blob() + self.assertEqual(len(blob), len(body) + 65) + + def test_frozen_body_snapshot(self): + """Freeze the canonical v2 UNSIGNED body's length + sha256. The body is + key-independent (no signature) and deterministic (timestamp=0), so this + is a pure wire-format drift gate: it trips iff serialize_schema_metadata() + changes the bytes, which must stay in lockstep with firmware's + parse_v2_args(). (The signature is exercised separately.)""" + body, _ = _v2_transfer_blob() + got = (len(body), hashlib.sha256(body).hexdigest()) + self.assertEqual(got, V2_BODY_SNAPSHOT, + 'v2 body drift: only update V2_BODY_SNAPSHOT if the wire ' + 'format intentionally changed (and firmware too)') + + def test_calldata_matches_schema_shape(self): + """schema_calldata() builds selector + one 32-byte word per arg, so the + device decodes exactly num_args words (the structural binding).""" + cd = schema_calldata(ERC20_TRANSFER_SELECTOR, [ + {'format': ARG_FORMAT_ADDRESS, 'address': VITALIK}, + {'format': ARG_FORMAT_TOKEN_AMOUNT, 'amount': 1500000}, + ]) + self.assertEqual(len(cd), 4 + 32 * 2) + self.assertEqual(cd[:4], ERC20_TRANSFER_SELECTOR) + self.assertEqual(cd[4:16], b'\x00' * 12) # address left-padding + self.assertEqual(cd[16:36], VITALIK) + self.assertEqual(int.from_bytes(cd[36:68], 'big'), 1500000) + + def test_rejects_dynamic_format(self): + """v2 only encodes fixed single-word types; STRING/BYTES are rejected by + the serializer (they have no fixed on-chain word). + + ValueError, not AssertionError: this is a precondition on a function + that builds SIGNED bytes, so it must survive `python -O`. + """ + with self.assertRaises(ValueError): + serialize_schema_metadata( + chain_id=1, contract_address=USDC_ADDRESS, + selector=ERC20_TRANSFER_SELECTOR, method_name='x', + args=[{'name': 'label', 'format': ARG_FORMAT_STRING}]) + + +# Frozen len + sha256 of the canonical v2 UNSIGNED transfer body (timestamp=0, +# key-independent). Regenerate ONLY on an intentional wire-format change: +# python3 -c "from tests.test_msg_ethereum_clear_signing import _v2_transfer_blob; \ +# import hashlib; b,_=_v2_transfer_blob(); print(len(b), hashlib.sha256(b).hexdigest())" +V2_BODY_SNAPSHOT = ( + 64, '01a24001460f8a69684f3d2a10f75b14e7449d8912a3833f7f8758e8fccadc05') + # ═══════════════════════════════════════════════════════════════════════ # Device tests — require KeepKey connected with test firmware @@ -411,9 +835,29 @@ class TestEthereumClearSigning(common.KeepKeyTest): def setUp(self): super().setUp() - self.requires_firmware("7.14.0") + self.requires_firmware("7.15.0") self.requires_message("EthereumTxMetadata") + self.requires_message("LoadClearsignSigner") + self.requires_release_capability("erc7730-runtime-review") self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + self._load_ci_signer() + # apply_policy() calls Initialize to refresh Features, and Initialize + # deliberately starts a new session that clears RAM-only signers. Tests + # must not redundantly re-apply AdvancedMode after loading this signer. + + def _load_ci_signer(self): + """Load the CI test signer through the production trust path (device + confirm auto-acked by debuglink). Wipe drops it, so every test starts + from an explicit, observable load.""" + self.client.load_clearsign_signer( + key_id=TEST_KEY_ID, + pubkey=test_signer_compressed_pubkey(), + alias=CI_SIGNER_ALIAS, + ) + # The load-confirm frame is setUp noise for the signing tests; drop it + # so each test's own operation frames are what the report picks. + self._drop_setup_screenshots() def test_valid_metadata_returns_verified(self): """Send valid signed metadata → device returns VERIFIED.""" @@ -530,11 +974,495 @@ def test_no_metadata_then_sign_unchanged(self): self.assertIsNotNone(sig_r) self.assertIsNotNone(sig_s) + # ── tx_hash binding (the authoritative gate) ────────────────────── + + def test_binding_happy_path_signs_and_recovers(self): + """Full who/what/why clear-sign of a REAL Aave V3 supply() transaction. + + The device is sent (1) an actual EthereumSignTx with genuine Aave + supply(asset,amount,onBehalfOf,referralCode) calldata, and (2) a signed + metadata blob whose tx_hash == the exact sighash of that tx. Runtime + identities require AdvancedMode, and their decoded annotation is + followed by the normal raw-calldata review. + + On device this renders, in order: + WHO -> Clearsign Warning (signer 'CI Test') + Contract: 0x7d27…c7a9 + WHAT -> Call: supply / protocol: Aave V3 / asset: 0x6B17…1d0F (DAI) + / amount: 10.5 DAI / onBehalfOf: 0xd8dA…6045 + WHY -> the signature is REFUSED unless the signed digest equals the + metadata's committed tx_hash (asserted by the recover below). + """ + n = parse_path(DEVICE_PATH) + chain_id, nonce, gas_price, gas_limit, value = 1, 7, 20000000000, 200000, 0 + amount = 10500000000000000000 # 10.5 DAI (18 decimals) + data = aave_supply_calldata(amount) + # Byte-accurate real Aave supply calldata: selector + 4 x 32-byte words. + self.assertEqual(data[:4], bytes.fromhex('617ba037')) + self.assertEqual(len(data), 4 + 4 * 32) + tx_hash = eth_sighash_legacy(nonce, gas_price, gas_limit, AAVE_V3_POOL, + value, data, chain_id) + + # The metadata blob carries the decoded who/what/why (see DEFAULT_ARGS): + # protocol=Aave V3, asset=DAI, amount=10.5 DAI, onBehalfOf. + blob = bound_metadata(tx_hash) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + to=AAVE_V3_POOL, value=value, data=data, chain_id=chain_id) + self.assertIsNotNone(sig_r) + self.assertIsNotNone(sig_s) + # WHY it's trustworthy: the signature recovers to THIS device's signer + # over THIS tx's digest — the metadata was bound to the exact tx. + signer = recover_eth_signer(sig_r, sig_s, sig_v, tx_hash, chain_id) + self.assertEqual(signer, self.client.ethereum_get_address(n)) + + def _clearsign_flow(self, flow, chain_id=1): + """Run one catalog flow END-TO-END with AdvancedMode ON: real tx, + per-tx-bound metadata, who/what/why annotation plus the ordinary raw + review (auto-acked), then either sign and recover the exact digest or + assert the release policy's explicit fail-closed rejection.""" + n = parse_path(DEVICE_PATH) + tx_hash = flow_tx_hash(flow, chain_id) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=flow_blob(flow, chain_id), + metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + if (flow['key'] == 'erc20-approve-unlimited' + and not self.firmware_at_least("7.16.0") + and "erc20-unlimited-approve-review" not in + (self.firmware_capabilities() or ())): + # 7.15 refused an unlimited approve until + # erc20-unlimited-approve-review; from then it is signed after a + # warning, handled by the common path below. + # Drive the wire directly: the policy is refused on the FIRST + # response, before any ButtonRequest. Expected-response helpers + # would raise their own exception text containing the expected + # message, so they cannot prove what the device actually sent. + msg = messages_eth.EthereumSignTx( + address_n=n, + nonce=int_to_big_endian(FLOW_NONCE), + gas_price=int_to_big_endian(FLOW_GAS_PRICE), + gas_limit=int_to_big_endian(FLOW_GAS_LIMIT), + value=int_to_big_endian(flow['value']), + to=flow['to'], chain_id=chain_id, + data_length=len(flow['data']), + data_initial_chunk=flow['data'][:1024]) + self.assertEqual(len(flow['data']), 68) + response = self.client.call_raw(msg) + self.assertIsInstance(response, messages.Failure) + self.assertEqual(response.code, + types.Failure_ActionCancelled) + self.assertEqual(response.message, + 'Unlimited ERC20 approval is disabled') + return + if flow['key'] == 'erc20-approve-unlimited': + self.requires_release_capability("erc20-unlimited-approve-review") + + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=n, nonce=FLOW_NONCE, gas_price=FLOW_GAS_PRICE, + gas_limit=FLOW_GAS_LIMIT, to=flow['to'], value=flow['value'], + data=flow['data'], chain_id=chain_id) + self.assertIsNotNone(sig_r) + signer = recover_eth_signer(sig_r, sig_s, sig_v, tx_hash, chain_id) + self.assertEqual(signer, self.client.ethereum_get_address(n)) + + def test_clearsign_batch_all_payloads(self): + """Sign the ENTIRE payload catalog in one batch and have the DEVICE + validate every blob: each flow's metadata comes back VERIFIED, and a + tampered byte in any blob comes back MALFORMED. This is the + reference contract for signer implementations: produce these bytes + and the device will accept them.""" + for flow in CLEARSIGN_FLOWS: + with self.subTest(flow=flow['key']): + blob = flow_blob(flow) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, + key_id=TEST_KEY_ID) + # NB: common.KeepKeyTest overrides assertEqual with a + # 2-arg signature (no msg param); subTest names the flow. + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + # Adversarial cross-check: any single tampered byte in the + # signed region must flip the SAME blob to MALFORMED. + tampered = bytearray(blob) + tampered[10] ^= 0xFF + resp = self.client.ethereum_send_tx_metadata( + signed_payload=bytes(tampered), metadata_version=1, + key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, + CLASSIFICATION_MALFORMED) + + def test_replay_rejected_when_digest_differs(self): + """Metadata bound to tx A, then sign tx B (same contract+selector+chain, + different calldata) → device aborts at send_signature, NO signature.""" + n = parse_path(DEVICE_PATH) + chain_id, gas_price, gas_limit = 1, 20000000000, 200000 + + data_a = aave_supply_calldata(1000000000000000000) + tx_hash_a = eth_sighash_legacy(0, gas_price, gas_limit, AAVE_V3_POOL, + 0, data_a, chain_id) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=bound_metadata(tx_hash_a), + metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + # Same selector/contract/chain (matches_tx → screens shown), but the + # amount differs so the real digest != committed tx_hash. + data_b = aave_supply_calldata(500000000000000000000) + try: + self.client.ethereum_sign_tx( + n=n, nonce=0, gas_price=gas_price, gas_limit=gas_limit, + to=AAVE_V3_POOL, value=0, data=data_b, chain_id=chain_id) + self.fail("Expected Failure — metadata committed to a different tx") + except CallException as e: + self.assertIn("Metadata does not match signed transaction", str(e)) + + def test_advanced_mode_gate(self): + """AdvancedMode OFF + unknown contract + no metadata → hard reject; + ON → raw-data confirm path signs; recognized ERC-20 transfer unaffected.""" + # Canonical 7.15 requires AdvancedMode before runtime signer loading. + self.requires_firmware("7.15.0") + n = parse_path(DEVICE_PATH) + data = aave_supply_calldata(1000000000000000000) + + # OFF + unknown contract + no metadata → blocked + self.client.apply_policy("AdvancedMode", 0) + with self.assertRaises(CallException) as ctx: + self.client.load_clearsign_signer( + key_id=TEST_KEY_ID, + pubkey=test_signer_compressed_pubkey(), + alias=CI_SIGNER_ALIAS, + ) + self.assertIn("AdvancedMode required", str(ctx.exception)) + + try: + self.client.ethereum_sign_tx( + n=n, nonce=0, gas_price=20000000000, gas_limit=200000, + to=AAVE_V3_POOL, value=0, data=data, chain_id=1) + self.fail("Expected Failure — blind signing disabled") + except CallException as e: + message = str(e) + self.assertTrue( + "Arbitrary contract data signing disabled by policy" in message + or "Blind signing disabled by policy" in message, + "unexpected blind-sign refusal: %s" % message) + + # ON → raw-data confirm path → signs + self.client.apply_policy("AdvancedMode", 1) + _, sig_r, _ = self.client.ethereum_sign_tx( + n=n, nonce=0, gas_price=20000000000, gas_limit=200000, + to=AAVE_V3_POOL, value=0, data=data, chain_id=1) + self.assertIsNotNone(sig_r) + self.client.apply_policy("AdvancedMode", 0) + + # Recognized ERC-20 transfer is decoded natively → NOT blind-gated even + # with AdvancedMode OFF (token resolves via tokenByChainAddress). + erc20 = (bytes.fromhex('a9059cbb') + b'\x00' * 12 + VITALIK + + (1000000).to_bytes(32, 'big')) + _, sig_r, _ = self.client.ethereum_sign_tx( + n=n, nonce=1, gas_price=20000000000, gas_limit=80000, + to=CVC_TOKEN, value=0, data=erc20, chain_id=1) + self.assertIsNotNone(sig_r) + + def test_cancel_clears_metadata_not_reused(self): + """Cancel mid-confirm → metadata cleared; a later matching tx is NOT + silently signed using the stale blob.""" + n = parse_path(DEVICE_PATH) + chain_id, gas_price, gas_limit = 1, 20000000000, 200000 + data = aave_supply_calldata(1000000000000000000) + tx_hash = eth_sighash_legacy(0, gas_price, gas_limit, AAVE_V3_POOL, + 0, data, chain_id) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=bound_metadata(tx_hash), + metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + # Press NO on the first decoded confirm screen → signed_metadata_confirm + # returns false → ActionCancelled + ethereum_signing_abort (clears blob). + self.client.button = False + try: + self.client.ethereum_sign_tx( + n=n, nonce=0, gas_price=gas_price, gas_limit=gas_limit, + to=AAVE_V3_POOL, value=0, data=data, chain_id=chain_id) + self.fail("Expected Failure — user cancelled the verified confirm") + except CallException as e: + self.assertIn("cancelled", str(e).lower()) + finally: + self.client.button = True + + # Same tx, no new metadata, AdvancedMode OFF → blind-sign gate must fire. + # If the stale blob were reused it would suppress the gate and sign. + self.client.apply_policy("AdvancedMode", 0) + try: + self.client.ethereum_sign_tx( + n=n, nonce=0, gas_price=gas_price, gas_limit=gas_limit, + to=AAVE_V3_POOL, value=0, data=data, chain_id=chain_id) + self.fail("Expected Failure — stale metadata must not be reused") + except CallException as e: + message = str(e) + self.assertTrue( + "Arbitrary contract data signing disabled by policy" in message + or "Blind signing disabled by policy" in message, + "unexpected blind-sign refusal: %s" % message) + + + # ── LoadClearsignSigner — the phase-1 trust path ─────────────────── + + def test_load_required_before_verify(self): + """Fresh (wiped) device: a VERIFIED blob is MALFORMED until the signer + is loaded — proves there is no built-in trust path in phase 1.""" + self.client.wipe_device() # factory reset drops loaded signers + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + + blob, _, _ = TestVectorCatalog.valid_aave_supply() + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_MALFORMED) + + self._load_ci_signer() + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + def test_load_signer_cancel_refuses(self): + """Pressing NO on the load confirm must refuse the signer.""" + pub = test_signer_compressed_pubkey() + self.client.button = False + try: + with self.assertRaises(CallException): + self.client.load_clearsign_signer( + key_id=1, pubkey=pub, alias=CI_SIGNER_ALIAS) + finally: + self.client.button = True + + # Slot 1 must still be empty: a blob signed for slot 1 is MALFORMED. + payload = serialize_metadata( + chain_id=1, contract_address=AAVE_V3_POOL, + selector=AAVE_SUPPLY_SELECTOR, tx_hash=ZERO_TX_HASH, + method_name='supply', args=DEFAULT_ARGS, key_id=1) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=sign_metadata(payload), metadata_version=1, key_id=1) + self.assertEqual(resp.classification, CLASSIFICATION_MALFORMED) + + def test_persistent_signer_rejected_without_session_mutation(self): + """RC18 firmware fails closed on persist=true without slot mutation.""" + pub = test_signer_compressed_pubkey() + + with self.assertRaises(CallException): + self.client.call(messages_eth.LoadClearsignSigner( + key_id=1, pubkey=pub, alias=CI_SIGNER_ALIAS, persist=True)) + + payload = serialize_metadata( + chain_id=1, contract_address=AAVE_V3_POOL, + selector=AAVE_SUPPLY_SELECTOR, tx_hash=ZERO_TX_HASH, + method_name='supply', args=DEFAULT_ARGS, key_id=1) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=sign_metadata(payload), metadata_version=1, key_id=1) + self.assertEqual(resp.classification, CLASSIFICATION_MALFORMED) + + def test_load_signer_invalid_pubkey_rejected(self): + """Uncompressed / zero / truncated pubkeys refused without a confirm.""" + for bad in (b'\x04' + b'\x00' * 32, # uncompressed prefix + b'\x00' * 33, # zero key (empty-slot sentinel) + test_signer_compressed_pubkey()[:32]): # short + with self.assertRaises(CallException): + self.client.load_clearsign_signer( + key_id=1, pubkey=bad, alias=CI_SIGNER_ALIAS) + + def test_load_signer_bad_alias_rejected(self): + """Empty/oversized aliases, control/'%' chars, and semantic-injection + punctuation are rejected. The alias renders inside quotes on the trust + screen, so a quote-breakout or a "." / "(" that appends a false + "verified by KeepKey." claim must not pass validation.""" + pub = test_signer_compressed_pubkey() + for alias in ('', 'x' * 32, 'evil\nalias', 'a%sb', + "x' verified by KeepKey. Safe (", 'safe.KeepKey', + 'trust(me)'): + with self.assertRaises(CallException): + self.client.load_clearsign_signer( + key_id=1, pubkey=pub, alias=alias) + + def test_load_signer_key_id_out_of_range_rejected(self): + with self.assertRaises(CallException): + self.client.load_clearsign_signer( + key_id=4, pubkey=test_signer_compressed_pubkey(), + alias=CI_SIGNER_ALIAS) + + +class TestClearSignV2Device(common.KeepKeyTest): + """Device integration for v2 (static schema) blobs. + + A v2 blob attests only the decode schema; the device decodes the argument + values from the calldata it signs. This exercises the full round-trip: load + signer -> send v2 metadata -> sign a matching transfer() tx -> the signature + recovers to this device's signer over the tx digest (so the who/what/why + shown was bound to the exact tx, with no committed tx_hash). + + v2 (METADATA_VERSION_SCHEMA) lands in the in-progress 7.15.0 line, so this + runs against the develop firmware alongside the v1 clear-sign device tests. + """ + + V2_FIRMWARE = "7.15.0" + + def setUp(self): + super().setUp() + self.requires_firmware(self.V2_FIRMWARE) + self.requires_message("EthereumTxMetadata") + self.requires_message("LoadClearsignSigner") + self.requires_release_capability("erc7730-runtime-review") + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + self.client.load_clearsign_signer( + key_id=TEST_KEY_ID, pubkey=test_signer_compressed_pubkey(), + alias=CI_SIGNER_ALIAS) + self._drop_setup_screenshots() + # As above, do not call apply_policy() again after loading the signer: + # its Initialize refresh correctly clears session-only trust anchors. + + def test_v2_transfer_decodes_signs_and_recovers(self): + n = parse_path(DEVICE_PATH) + chain_id, nonce, gas_price, gas_limit, value = 1, 3, 20000000000, 250000, 0 + # transfer(to=VITALIK, amount=1.5 USDC) — the device decodes both from + # the calldata using the v2 schema (address word + token-amount word). + args = [ + {'format': ARG_FORMAT_ADDRESS, 'address': VITALIK}, + {'format': ARG_FORMAT_TOKEN_AMOUNT, 'amount': 1500000}, + ] + data = schema_calldata(ERC20_TRANSFER_SELECTOR, args) + _, blob = _v2_transfer_blob() + + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + to=USDC_ADDRESS, value=value, data=data, chain_id=chain_id) + self.assertIsNotNone(sig_r) + self.assertIsNotNone(sig_s) + tx_hash = eth_sighash_legacy(nonce, gas_price, gas_limit, USDC_ADDRESS, + value, data, chain_id) + signer = recover_eth_signer(sig_r, sig_s, sig_v, tx_hash, chain_id) + self.assertEqual(signer, self.client.ethereum_get_address(n)) + + def test_v2_calldata_length_mismatch_falls_back_to_raw_review(self): + """The headline v2 security property: a blob's schema says 2 words, + but the calldata actually being signed carries 3. decode_v2_args' + structural completeness check (total calldata bytes must equal + exactly 4 + 32*num_args) fails, matches_tx returns false, and the tx + falls through to the ordinary AdvancedMode raw review, never a + clear-signed-but-wrong display.""" + n = parse_path(DEVICE_PATH) + chain_id, nonce, gas_price, gas_limit, value = 1, 3, 20000000000, 250000, 0 + args = [ + {'format': ARG_FORMAT_ADDRESS, 'address': VITALIK}, + {'format': ARG_FORMAT_TOKEN_AMOUNT, 'amount': 1500000}, + ] + # calldata carries one EXTRA 32-byte word beyond the 2-arg schema. + data = schema_calldata(ERC20_TRANSFER_SELECTOR, args) + (b'\x00' * 32) + _, blob = _v2_transfer_blob() + + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + _, sig_r, sig_s = self.client.ethereum_sign_tx( + n=n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + to=USDC_ADDRESS, value=value, data=data, chain_id=chain_id) + self.assertIsNotNone(sig_r) + self.assertIsNotNone(sig_s) + + def test_v2_unsupported_arg_format_returns_malformed(self): + """v2 supports only fixed single-word ADDRESS/AMOUNT/TOKEN_AMOUNT arg + formats (decode_v2_args has no dynamic-type support, by design). The + Python serializer refuses to BUILD a STRING-format v2 blob (see the + offline test_rejects_dynamic_format), but a malicious or buggy host + could still hand-craft the raw bytes — the device's own parser must + independently reject an unsupported v2 arg format as MALFORMED at + blob-load time, before any calldata is even seen.""" + self._drop_setup_screenshots() + body = bytearray() + body.append(METADATA_VERSION_SCHEMA) + body.extend((1).to_bytes(4, 'big')) # chain_id + body.extend(USDC_ADDRESS) + body.extend(ERC20_TRANSFER_SELECTOR) + name = b'transfer' + body.extend(len(name).to_bytes(2, 'big')) + body.extend(name) + body.append(1) # num_args + arg_name = b'label' + body.append(len(arg_name)) + body.extend(arg_name) + body.append(ARG_FORMAT_STRING) # unsupported in v2 + body.append(CLASSIFICATION_VERIFIED) + body.extend((0).to_bytes(4, 'big')) # timestamp + body.append(TEST_KEY_ID) + blob = sign_metadata(bytes(body)) + + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_MALFORMED) + + +# ═══════════════════════════════════════════════════════════════════════ +# Dynamically generate one full-confirm device test per CLEARSIGN_FLOWS +# entry (mirrors keepkey-sdk tests/evm-clearsign): every real-world flow a +# user actually performs, each confirmed end-to-end with AdvancedMode ON and +# both the who/what/why annotation and raw calldata review. Avoids +# hand-writing 50+ near-identical test methods; the catalog IS the test +# list, so growing it (see keepkeylib/clearsign_catalog.py) needs no +# changes here. 'aave-v3-supply' is excluded — it's the flagship full- +# sequence walkthrough in test_binding_happy_path_signs_and_recovers above. +# ═══════════════════════════════════════════════════════════════════════ + +def _make_clearsign_flow_test(flow_key): + def test(self): + self._clearsign_flow(CLEARSIGN_FLOWS_BY_KEY[flow_key]) + f = CLEARSIGN_FLOWS_BY_KEY[flow_key] + test.__doc__ = '%s.%s (%s): %s' % (f['protocol'], f['method'], f['category'], f.get('why', '')) + return test + + +for _flow in CLEARSIGN_FLOWS: + if _flow['key'] == 'aave-v3-supply': + continue + setattr(TestEthereumClearSigning, + 'test_clearsign_' + _flow['key'].replace('-', '_').replace('.', '_'), + _make_clearsign_flow_test(_flow['key'])) +del _flow + # ═══════════════════════════════════════════════════════════════════════ # Print all test vectors (for documentation / external verification) # ═══════════════════════════════════════════════════════════════════════ +def print_clearsign_flows(): + """Dump the complete clear-sign flow catalog: tx params, calldata hex and + the deterministic reference blob hex. THE external reference for signer + implementations (pioneer-insight, keepkey-sdk).""" + print('=' * 70) + print('CLEARSIGN FLOW CATALOG (chain 1, nonce=%d, gas_price=%d, gas_limit=%d,' % + (FLOW_NONCE, FLOW_GAS_PRICE, FLOW_GAS_LIMIT)) + print('timestamp=%d, key_id=%d, RFC6979 deterministic ECDSA)' % + (REFERENCE_TIMESTAMP, TEST_KEY_ID)) + print('=' * 70) + for flow in CLEARSIGN_FLOWS: + print() + print('[%s] %s' % (flow['key'], flow['method'])) + shows = ', '.join('%s=%r' % (a['name'], a.get('value')) for a in flow['args']) + print(' shows : %s' % shows) + print(' to : 0x%s' % flow['to'].hex()) + print(' value : %d' % flow['value']) + print(' calldata : 0x%s' % flow['data'].hex()) + print(' tx_hash : 0x%s' % flow_tx_hash(flow).hex()) + print(' blob : %s' % flow_blob(flow, TEST_KEY_ID, timestamp=REFERENCE_TIMESTAMP).hex()) + + def print_test_vectors(): """Print all test vectors as hex for external verification.""" vectors = [ @@ -561,7 +1489,7 @@ def print_test_vectors(): print('═' * 72) print(' EVM Clear Signing — Test Vector Catalog') - print(' Test key: privkey=0x01 (secp256k1 generator)') + print(' Metadata signer: SignIdentity idx0 == firmware slot 3 (key_id=3)') print('═' * 72) for i, gen in enumerate(vectors): @@ -575,9 +1503,178 @@ def print_test_vectors(): print('\n' + '═' * 72) + +def _decode_icon_rle(data, width, height): + """Reference decoder for LoadClearsignSigner.icon, traced from the decoder + of record: keepkey-firmware lib/board/draw.c draw_bitmap_mono_rle(). + + The icon is NOT a packed 1bpp bitmap (a packed 64x64 needs 512 bytes and the + wire cap is 384). It is run-length encoded with byte-valued pixels: + n = int8(data[i++]); n in [1,127] -> RUN: emit the next value byte n times + n in [-127,-1] -> LITERAL: emit the next (-n) bytes once each + n == 0 -> invalid + n == -128 (0x80)-> invalid: firmware's counter is int8_t + and cannot represent 128, so the packet + is undecodable (it previously asserted / + ran with a negative counter under NDEBUG) + Pixels fill row-major until exactly width*height are emitted. + """ + seq = nonseq = i = 0 + out = [] + for _ in range(height): + for _ in range(width): + if i >= len(data): + raise ValueError("overrun reading RLE count") + if seq == 0 and nonseq == 0: + n = data[i] + n = n - 256 if n > 127 else n + i += 1 + if n == 0: + raise ValueError("n == 0 is invalid") + if n == -128: + # Mirror firmware: -(-128) overflows int8_t. Accepting 128 + # here would mask a decoder incompatibility. + raise ValueError("n == -128 (0x80) is invalid: undecodable") + if n < 0: + nonseq, seq = -n, 0 + else: + seq = n + if i >= len(data): + raise ValueError("overrun reading RLE value") + out.append(data[i]) + if seq > 0: + seq -= 1 + if seq == 0: + i += 1 + else: + i += 1 + nonseq -= 1 + # Exactness, mirroring firmware's draw_bitmap_mono_rle_valid(): a run that + # straddles the end of the image, or packets trailing past the last pixel, + # are NOT well-formed. The drawing path fills the canvas and stops, so it + # cannot catch these -- the validator must. + if seq != 0 or nonseq != 0: + raise ValueError("run straddles the end of the image") + if i != len(data): + raise ValueError("trailing packets after the final pixel") + return out + + +class TestClearsignSignerIcon(unittest.TestCase): + """Offline coverage for the LoadClearsignSigner identity-icon wire contract. + + Regression guard for the review finding that the proto documented a packed + 1bpp row-major bitmap while firmware fed the bytes to an RLE decoder — a + client following the old doc rendered a garbled/absent logo on a TRUST screen. + """ + + ICON_MAX = 384 # METADATA_ICON_MAX / CLEARSIGN_ICON_MAX / proto max_size + MAX_WIDTH = 40 # LEFT_MARGIN_WITH_ICON -- the confirm screen's icon column + MAX_HEIGHT = 64 # the icon column's height + + def test_geometry_caps_are_asymmetric(self): + # width is capped at the 40px text column, NOT at the 64px height: text + # begins at x=40 and the icon is drawn after it, so a wider icon paints + # over the alias/fingerprint/"NOT verified by KeepKey" warning. + self.assertLess(self.MAX_WIDTH, self.MAX_HEIGHT) + + def test_rle_is_the_format_of_record_not_a_size_workaround(self): + # Deliberately NOT justified by "packed wouldn't fit": at the legal max + # geometry a packed 1bpp icon is 40*64/8 = 320 bytes and WOULD fit the + # 384-byte cap. RLE is the format because draw_bitmap_mono_rle() is the + # decoder of record (shared with every bundled image) -- so the encoder + # contract is RLE regardless of what packed would cost. + self.assertLessEqual((self.MAX_WIDTH * self.MAX_HEIGHT) // 8, + self.ICON_MAX) + + def test_golden_vector_matches_the_documented_decode(self): + # The golden vector published in messages-ethereum.proto. + self.assertEqual( + _decode_icon_rle(bytes([0x03, 0xFF, 0xFF, 0x00]), 2, 2), + [0xFF, 0xFF, 0xFF, 0x00], + ) + + def test_run_and_literal_packets(self): + self.assertEqual(_decode_icon_rle(bytes([0x04, 0xAB]), 4, 1), + [0xAB] * 4) # RUN + self.assertEqual(_decode_icon_rle(bytes([0xFD, 0x01, 0x02, 0x03]), 3, 1), + [0x01, 0x02, 0x03]) # LITERAL (-3) + + def test_literal_of_128_is_invalid(self): + # 0x80 => n = -128. Spec-valid under the original doc, but firmware's + # int8_t counter cannot represent 128: it asserted (debug) or decoded + # with a negative counter (NDEBUG). Both proto and firmware now reject. + with self.assertRaises(ValueError): + _decode_icon_rle(bytes([0x80]) + bytes([0xAA] * 128), 128, 1) + + def test_literal_of_127_is_the_valid_boundary(self): + data = bytes([0x81]) + bytes(range(127)) + self.assertEqual(_decode_icon_rle(data, 127, 1), list(range(127))) + + def test_run_of_127_is_the_valid_boundary(self): + self.assertEqual(_decode_icon_rle(bytes([0x7F, 0x5A]), 127, 1), + [0x5A] * 127) + + def test_zero_count_is_invalid(self): + with self.assertRaises(ValueError): + _decode_icon_rle(bytes([0x00, 0xFF]), 1, 1) + + def test_straddling_run_is_rejected(self): + # 05 FF for a 2x2: RUN of 5 into a 4-pixel image. The draw path would + # fill 4 and report success; the stream is not well-formed. + with self.assertRaises(ValueError): + _decode_icon_rle(bytes([0x05, 0xFF]), 2, 2) + + def test_trailing_packets_are_rejected(self): + # Exactly fills 2x2, then carries an unread packet. + with self.assertRaises(ValueError): + _decode_icon_rle(bytes([0x04, 0xFF, 0x01, 0xAA]), 2, 2) + + def test_truncated_stream_is_rejected(self): + with self.assertRaises(ValueError): + _decode_icon_rle(bytes([0x08, 0xFF]), 4, 4) # claims 8, only 2 bytes + + def test_message_exposes_icon_dimensions_and_persist(self): + # Regression guard: the generated bindings previously carried only + # key_id/pubkey/alias, so constructing with icon raised ValueError. The + # persist bit still round-trips for wire compatibility even though RC18 + # firmware and the high-level client reject true. + icon = bytes([0x03, 0xFF, 0xFF, 0x00]) + msg = messages_eth.LoadClearsignSigner( + key_id=3, pubkey=b'\x02' * 33, alias="Pioneer", + icon=icon, icon_width=2, icon_height=2, persist=True, + ) + parsed = messages_eth.LoadClearsignSigner() + parsed.ParseFromString(msg.SerializeToString()) + self.assertEqual(parsed.icon, icon) + self.assertEqual(parsed.icon_width, 2) + self.assertEqual(parsed.icon_height, 2) + self.assertTrue(parsed.persist) + self.assertEqual(_decode_icon_rle(parsed.icon, parsed.icon_width, + parsed.icon_height), + [0xFF, 0xFF, 0xFF, 0x00]) + + def test_high_level_client_rejects_persist_true(self): + client = object.__new__(ProtocolMixin) + with self.assertRaisesRegex(ValueError, 'authenticated storage'): + client.load_clearsign_signer( + key_id=1, pubkey=b'\x02' * 33, alias='Pioneer', persist=True) + + def test_text_only_identity_omits_icon_fields(self): + msg = messages_eth.LoadClearsignSigner( + key_id=3, pubkey=b'\x02' * 33, alias="Pioneer") + parsed = messages_eth.LoadClearsignSigner() + parsed.ParseFromString(msg.SerializeToString()) + self.assertFalse(parsed.HasField('icon')) + self.assertFalse(parsed.HasField('icon_width')) + self.assertFalse(parsed.HasField('icon_height')) + + if __name__ == '__main__': import sys if '--vectors' in sys.argv: print_test_vectors() + elif '--flows' in sys.argv: + print_clearsign_flows() else: unittest.main() diff --git a/tests/test_msg_ethereum_clearsign_additive.py b/tests/test_msg_ethereum_clearsign_additive.py new file mode 100644 index 00000000..6f5ebaf9 --- /dev/null +++ b/tests/test_msg_ethereum_clearsign_additive.py @@ -0,0 +1,405 @@ +""" +EVM Clear Signing — the ADDITIVE INVARIANT. + +The whole clear-sign tier rests on one property: + + A runtime-loaded provider may ADD screens. It may never REMOVE one. + +A provider signer is loaded at runtime (LoadClearsignSigner, RAM-only, +user-confirmed) and is NOT verified by KeepKey. Its metadata is therefore +annotation, not authority: after the decoded who/what/why screens the device +must still run the ordinary unverified review — the amount/recipient screen, +the raw-calldata screen and the fee screen a user would have seen with no +metadata at all. If a lying provider could suppress any of those, a runtime +schema would be a screen-substitution oracle: "supply 10.5 DAI to Aave" on the +glass, arbitrary calldata under the signature. + +Firmware 7.15 has no firmware-trusted signer, so signed metadata is always +additive: after the decoded screens are approved, ethereum_signing_init() +still sets needs_confirm and data_needs_confirm, and the ordinary amount, +raw-calldata and fee screens follow. + +HOW THESE TESTS MEASURE SCREENS +------------------------------- +Screen counts are never modelled here, they are compared. Every test signs the +SAME transaction twice against the SAME device state — once with no metadata +(the baseline) and once with metadata — and records the raw 2048-byte OLED +framebuffer at each ButtonRequest (ScreenRecorder below, which reads the layout +before the debuglink auto-press). The proof of "nothing was removed" is that +the baseline frames reappear BYTE-FOR-BYTE as the tail of the clear-signed run. +That is immune to pagination and to value-dependent rendering: whatever the +baseline drew, the clear-signed run must still draw, in the same order, last. + +Existing coverage in test_msg_ethereum_clear_signing.py is adjacent but not +this: V5 covers "no metadata -> blind sign", V10 covers replay rejection, V12 +covers cancel-clears-metadata. None of them proves the raw review FOLLOWS a +SUCCESSFUL decode. +""" + +import time +import unittest + +try: + import common +except ImportError: + import sys, os + sys.path.insert(0, os.path.dirname(__file__)) + import common + +from keepkeylib.signed_metadata import ( + serialize_metadata, + serialize_schema_metadata, + sign_metadata, + eth_sighash_legacy, + # aliased: a module-level name starting with 'test_' would be + # collected as a test function by pytest. + test_signer_compressed_pubkey as signer_pubkey, + ARG_FORMAT_ADDRESS, + ARG_FORMAT_AMOUNT, + ARG_FORMAT_TOKEN_AMOUNT, + CLASSIFICATION_VERIFIED, + CLASSIFICATION_MALFORMED, +) +from keepkeylib.tools import parse_path + +# Fixtures and helpers shared with the main clear-sign suite. Imported rather +# than duplicated so a change to the reference vectors cannot leave this +# section quietly testing a different transaction than the atlas describes. +from test_msg_ethereum_clear_signing import ( + AAVE_V3_POOL, + AAVE_SUPPLY_SELECTOR, + CI_SIGNER_ALIAS, + DEFAULT_ARGS, + DEVICE_PATH, + TEST_KEY_ID, + aave_supply_calldata, + recover_eth_signer, +) + +# METADATA_MAX_KEYS in include/keepkey/firmware/signed_metadata.h. +METADATA_MAX_KEYS = 4 + +# Additive runtime provider review ships in firmware 7.15. +ADDITIVE_REVIEW_FIRMWARE = "7.15.0" + +# The Aave V3 supply() transaction every additive test signs. Real ABI +# calldata (selector + 4 x 32-byte words), so the metadata below binds a +# genuine transaction rather than a toy payload. +TX = dict(chain_id=1, nonce=7, gas_price=20000000000, gas_limit=200000, + value=0) +SUPPLY_AMOUNT = 10500000000000000000 # 10.5 DAI (18 decimals) + + +class ScreenRecorder(object): + """Record the OLED framebuffer of every confirm screen an operation draws. + + Wraps callback_ButtonRequest: reads the layout over DebugLink BEFORE the + normal auto-press (which would replace the screen), then delegates to the + original callback so screenshot capture and the button press still happen + exactly as they do in every other test. + """ + + # The firmware emits ButtonRequest immediately before drawing; the same + # settle used by the screenshot path (client.SCREENSHOT_SETTLE_SECONDS) + # keeps a half-drawn frame out of the comparison. + SETTLE = 0.3 + + def __init__(self, client): + self.client = client + self.frames = [] # list of (ButtonRequestType, 2048-byte layout) + + def __enter__(self): + original = self.client.callback_ButtonRequest + + def record(msg): + time.sleep(self.SETTLE) + self.frames.append((msg.code, bytes(self.client.debug.read_layout()))) + return original(msg) + + # Instance attribute shadows the bound method; client.call() resolves + # the handler with getattr(self, 'callback_ButtonRequest'). + self.client.callback_ButtonRequest = record + return self + + def __exit__(self, *exc): + del self.client.callback_ButtonRequest + return False + + @property + def codes(self): + return [code for code, _ in self.frames] + + @property + def layouts(self): + return [layout for _, layout in self.frames] + + +def bound_supply_metadata(tx_hash, key_id=TEST_KEY_ID): + """v1 metadata committing to a specific real Aave supply() sighash.""" + return sign_metadata(serialize_metadata( + chain_id=TX['chain_id'], + contract_address=AAVE_V3_POOL, + selector=AAVE_SUPPLY_SELECTOR, + tx_hash=tx_hash, + method_name='supply', + args=DEFAULT_ARGS, + key_id=key_id, + )) + + +class TestClearSignAdditiveInvariant(common.KeepKeyTest): + """A runtime provider adds screens; it never removes one.""" + + def setUp(self): + super().setUp() + # Version, then message availability, then the firmware-reported + # capability (runtime EVM transaction metadata, not ERC-7730). + self.requires_firmware("7.15.0") + self.requires_message("EthereumTxMetadata") + self.requires_message("LoadClearsignSigner") + self.requires_release_capability("evm-tx-metadata") + self.setup_mnemonic_nopin_nopassphrase() + # AdvancedMode is required both for the raw-calldata review to be + # reachable at all and for a runtime signer to verify anything. + # apply_policy() re-Initializes, which clears RAM-only signers, so it + # must come BEFORE any load_clearsign_signer() call. + self.client.apply_policy("AdvancedMode", 1) + self.n = parse_path(DEVICE_PATH) + self.data = aave_supply_calldata(SUPPLY_AMOUNT) + self.tx_hash = eth_sighash_legacy( + TX['nonce'], TX['gas_price'], TX['gas_limit'], AAVE_V3_POOL, + TX['value'], self.data, TX['chain_id']) + + def _load_signer(self, key_id=TEST_KEY_ID, alias=CI_SIGNER_ALIAS): + self.client.load_clearsign_signer( + key_id=key_id, pubkey=signer_pubkey(), alias=alias) + + def _sign_supply(self): + return self.client.ethereum_sign_tx( + n=self.n, to=AAVE_V3_POOL, data=self.data, **TX) + + def _record_supply(self): + """Sign the fixture tx, returning (ScreenRecorder, (v, r, s)).""" + with ScreenRecorder(self.client) as rec: + sig = self._sign_supply() + return rec, sig + + def _assert_recovers(self, sig, tx_hash=None): + sig_v, sig_r, sig_s = sig + self.assertIsNotNone(sig_r) + self.assertIsNotNone(sig_s) + signer = recover_eth_signer(sig_r, sig_s, sig_v, + tx_hash or self.tx_hash, TX['chain_id']) + self.assertEqual(signer, self.client.ethereum_get_address(self.n)) + + def _assert_baseline_survives(self, baseline, observed): + """The core assertion: every baseline screen still appears, unchanged, + in order, as the TAIL of the clear-signed run.""" + self.assertTrue(len(observed.frames) > len(baseline.frames)) + self.assertEqual(observed.frames[-len(baseline.frames):], + baseline.frames) + # And the extra frames really are extra — no baseline screen was + # merely re-drawn earlier to pad the count. + added = observed.frames[:-len(baseline.frames)] + for code, layout in added: + self.assertTrue(layout not in baseline.layouts) + + # ── the invariant ──────────────────────────────────────────────── + + def test_successful_decode_still_runs_the_raw_review(self): + """A VERIFIED v1 decode from a runtime provider ADDS its who/what/why + screens in front of the ordinary unverified review — it replaces none + of them. + + Measured on the emulator for this fixture: the baseline (no metadata) + run draws 3 screens — amount/recipient, raw contract data, fee. The + clear-signed run draws 10: identity, 'Call: supply', contract address, + one screen per attested argument (4), then the SAME 3 baseline frames, + byte-for-byte. 3 + num_args is the structural minimum from + signed_metadata_confirm_screens(); pagination can only raise it. + """ + self.requires_firmware(ADDITIVE_REVIEW_FIRMWARE) + self._load_signer() + self._drop_setup_screenshots() + + # Baseline: the exact same transaction with no metadata in play. + baseline, sig = self._record_supply() + self._assert_recovers(sig) + + blob = bound_supply_metadata(self.tx_hash) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + observed, sig = self._record_supply() + self._assert_recovers(sig) + + self._assert_baseline_survives(baseline, observed) + # Identity + method + contract + one screen per attested argument. + added = len(observed.frames) - len(baseline.frames) + self.assertTrue(added >= 3 + len(DEFAULT_ARGS)) + + def test_failed_signature_falls_back_to_the_unverified_review(self): + """Metadata whose signature does not verify must leave the signing + flow EXACTLY as it was: the ordinary unverified review, no refusal and + no partial decoded information. + + The device classifies the tampered blob MALFORMED and the subsequent + signing run draws frames byte-identical to the baseline — which is the + strongest available statement of 'nothing decoded leaked onto the + glass', since any decoded screen would be a frame the baseline does + not contain. + """ + self._load_signer() + self._drop_setup_screenshots() + + baseline, sig = self._record_supply() + self._assert_recovers(sig) + + tampered = bytearray(bound_supply_metadata(self.tx_hash)) + tampered[10] ^= 0xFF # inside the signed region + resp = self.client.ethereum_send_tx_metadata( + signed_payload=bytes(tampered), metadata_version=1, + key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_MALFORMED) + + observed, sig = self._record_supply() + self._assert_recovers(sig) + self.assertEqual(observed.frames, baseline.frames) + + def test_no_runtime_slot_can_reach_the_suppression_branch(self): + """Every key slot is additive, so signed_metadata_from_loaded_signer() + is true for every VERIFIED blob this firmware can produce. + + The suppression else-branch is gated on a signer that is NOT runtime- + loaded. This test walks all METADATA_MAX_KEYS slots: each one is loaded + at runtime and each one still shows the full baseline review after its + decode. A slot that suppressed would be caught as a missing tail frame. + """ + self.requires_firmware(ADDITIVE_REVIEW_FIRMWARE) + for key_id in range(METADATA_MAX_KEYS): + self._load_signer(key_id=key_id, alias='CI Slot %d' % key_id) + self._drop_setup_screenshots() + + baseline, sig = self._record_supply() + self._assert_recovers(sig) + + for key_id in range(METADATA_MAX_KEYS): + with self.subTest(key_id=key_id): + blob = bound_supply_metadata(self.tx_hash, key_id=key_id) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=key_id) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + observed, sig = self._record_supply() + self._assert_recovers(sig) + self._assert_baseline_survives(baseline, observed) + + def test_no_slot_verifies_without_a_runtime_load(self): + """The complementary half: with no signer loaded, NO slot verifies + anything, so there is no firmware-pinned signer in this build that + could take the suppression branch. + + Phase 1 ships with every built-in METADATA_PUBKEYS slot zeroed; + metadata_pubkey_for() returns NULL for an unloaded slot and + signed_metadata_process() classifies MALFORMED. Sending metadata draws + nothing, so the empty screenshot list for this test is deliberate — the + setUp policy-confirm frame is dropped below so the capture directory + stays empty rather than offering an unrelated screen as evidence. + """ + self._drop_setup_screenshots() + for key_id in range(METADATA_MAX_KEYS): + with self.subTest(key_id=key_id): + blob = bound_supply_metadata(self.tx_hash, key_id=key_id) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=key_id) + self.assertEqual(resp.classification, CLASSIFICATION_MALFORMED) + + def test_v2_schema_decode_still_runs_the_raw_review(self): + """The v2 (static schema) path is additive too. + + v2 is where suppression would be most tempting: the schema attests a + decode shape and no tx_hash, so the else-branch drops the raw review + outright (data_needs_confirm = false) and keeps the amount screen only + if signed_metadata_schema_moves_value(). For a runtime signer that + branch is not taken — the decoded screens are followed by the SAME + amount, raw-calldata and fee screens the baseline drew. + + Deliberately schema-decoded against the Aave supply() fixture rather + than an ERC-20 transfer: a recognized token contract has no raw-data + screen in its own baseline (the token path already skips it), so it + could not show that the raw review survives. + """ + self.requires_firmware(ADDITIVE_REVIEW_FIRMWARE) + self._load_signer() + self._drop_setup_screenshots() + + baseline, sig = self._record_supply() + self._assert_recovers(sig) + + # Same 132-byte supply() calldata, described as a 4-word static + # schema: the device decodes the values from the bytes it signs. + v2_args = [ + {'name': 'asset', 'format': ARG_FORMAT_ADDRESS}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'decimals': 18, 'symbol': 'DAI'}, + {'name': 'onBehalfOf', 'format': ARG_FORMAT_ADDRESS}, + {'name': 'referral', 'format': ARG_FORMAT_AMOUNT}, + ] + blob = sign_metadata(serialize_schema_metadata( + chain_id=TX['chain_id'], contract_address=AAVE_V3_POOL, + selector=AAVE_SUPPLY_SELECTOR, method_name='supply', + args=v2_args, timestamp=0, key_id=TEST_KEY_ID)) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + observed, sig = self._record_supply() + self._assert_recovers(sig) + self._assert_baseline_survives(baseline, observed) + added = len(observed.frames) - len(baseline.frames) + self.assertTrue(added >= 3 + len(v2_args)) + + + def test_runtime_intent_heading_and_limits_precede_the_unchanged_review(self): + """A runtime-signed 0x05 intent schema (SRS-7.15 R-1.5) adds a "NOT + verified by KeepKey" heading and its Limits IN FRONT of the review, + and every baseline screen still follows, unchanged and in order.""" + import struct + import oled_text + self.requires_firmware("7.16.0") + self.requires_release_capability("evm-certified-intent") + self._load_signer() + self._drop_setup_screenshots() + baseline, sig = self._record_supply() + self._assert_recovers(sig) + + def text(b): + return bytes([len(b)]) + b + body = bytearray([0x05]) + struct.pack('>I', TX['chain_id']) + body += AAVE_V3_POOL + AAVE_SUPPLY_SELECTOR + body += struct.pack('>H', 6) + b'supply' + bytes([4]) + body += text(b'asset') + bytes([ARG_FORMAT_ADDRESS, 0]) + body += text(b'amount') + bytes([ARG_FORMAT_TOKEN_AMOUNT, 18, 3]) + \ + b'DAI' + bytes([3]) # spend exact + body += text(b'onBehalfOf') + bytes([ARG_FORMAT_ADDRESS, 0]) + body += text(b'referral') + bytes([3, 0]) # BYTES, no role + body += bytes([0]) + text(b'Aave') + text(b'Supply {1} to Aave for {2}') + body += bytes([CLASSIFICATION_VERIFIED]) + struct.pack('>I', 0) + \ + bytes([TEST_KEY_ID]) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=sign_metadata(bytes(body)), metadata_version=1, + key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + observed, sig = self._record_supply() + self._assert_recovers(sig) + title = oled_text.TITLE_FONT + self.assertIsNotNone(oled_text.find_line(observed.frames[0][1], + 'UNVERIFIED', title)) + self.assertIsNotNone(oled_text.find_line(observed.frames[1][1], + 'LIMITS', title)) + self._assert_baseline_survives(baseline, observed) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_ethereum_erc20_0x_signtx.py b/tests/test_msg_ethereum_erc20_0x_signtx.py index 00f4d2a4..e7079315 100644 --- a/tests/test_msg_ethereum_erc20_0x_signtx.py +++ b/tests/test_msg_ethereum_erc20_0x_signtx.py @@ -166,16 +166,24 @@ def test__sign_transformERC20(self): self.requires_fullFeature() self.requires_firmware("7.1.5") self.setup_mnemonic_nopin_nopassphrase() - # This payload is 1480 bytes, so it exceeds one 1024-byte chunk and the # 0x decoder no longer claims it: the bytes past the initial chunk are # hashed without being decoded, so describing them as a token swap would # be a screen the device cannot vouch for. It falls to the generic - # contract-data path, which requires AdvancedMode. + # contract-data path (blind contract data, no recognized token / + # contract handler), which since 7.15.0 the device hard-rejects unless + # AdvancedMode is on (Insight clear-signing policy) — same as + # test_sign_longdata_swap above. # # This test is about SIGNING CORRECTNESS, not about the gate, so enable # the policy and keep asserting the signature. The gate itself is # covered by test_msg_ethereum_signing_guards. + # + # SUPERSEDED, kept so the behaviour change stays visible: transformERC20 + # was once pinned to the 0x ExchangeProxy and bounded by its displayed + # input/min-output amounts, and on that basis clear-signed WITHOUT + # AdvancedMode at any calldata size (the transformations[] tail exceeds + # one chunk). That claim was withdrawn for payloads past the first chunk. self.client.apply_policy("AdvancedMode", 1) sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( diff --git a/tests/test_msg_ethereum_erc20_approve.py b/tests/test_msg_ethereum_erc20_approve.py index ed9cab03..f3a5c9e7 100644 --- a/tests/test_msg_ethereum_erc20_approve.py +++ b/tests/test_msg_ethereum_erc20_approve.py @@ -24,6 +24,8 @@ import keepkeylib.messages_pb2 as proto import keepkeylib.types_pb2 as proto_types from keepkeylib.client import CallException +from oled_text import TITLE_FONT, find_line, shows +from test_msg_display_disclosure import ScreenRecorder class TestMsgEthereumtxERC20_approve(common.KeepKeyTest): @@ -67,36 +69,58 @@ def test_approve_cvc_0(self): self.assertEqual(binascii.hexlify(sig_r), 'b37dbfa65c37906de2037f4684941c7144773786621037962d43db7836170ac0') self.assertEqual(binascii.hexlify(sig_s), '0bc7319762281d839c436adb41c35f8de5f4db1aec953f677c3a83062d93fc51') - def test_approve_cvc_all(self): + def test_approve_cvc_all_refused(self): + # 7.14.2 through 7.15.x refuse an unlimited approve outright, until + # erc20-unlimited-approve-review: from then it is signed after an + # UNLIMITED warning (test_approve_cvc_all). self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.requires_firmware_below("7.16.0") + self.requires_capability_absent("erc20-unlimited-approve-review") self.setup_mnemonic_nopin_nopassphrase() + with self.assertRaises(CallException): + self.client.ethereum_sign_tx( + n=[2147483692,2147483708,2147483648,0,0], + nonce=1, + gas_price=20, + gas_limit=20, + value=0, + to=binascii.unhexlify('41e5560054824ea6b0732e656e3ad64e20e94e45'), + address_type=0, + chain_id=1, + data=binascii.unhexlify('095ea7b3' + '0000000000000000000000001d8ce9022f6284c3a5c317f8f34620107214e545' + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff') + ) + + def test_approve_cvc_all(self): + self.requires_fullFeature() if self.firmware_at_least("7.14.2"): - with self.assertRaises(CallException): - self.client.ethereum_sign_tx( - n=[2147483692,2147483708,2147483648,0,0], - nonce=1, - gas_price=20, - gas_limit=20, - value=0, - to=binascii.unhexlify('41e5560054824ea6b0732e656e3ad64e20e94e45'), - address_type=0, - chain_id=1, - data=binascii.unhexlify('095ea7b3' + '0000000000000000000000001d8ce9022f6284c3a5c317f8f34620107214e545' + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff') - ) - return + # Refused on 7.14.2-7.15.x (test_approve_cvc_all_refused) until + # erc20-unlimited-approve-review; signed after a warning since. + self.requires_firmware("7.15.0") + self.requires_release_capability("erc20-unlimited-approve-review") + self.setup_mnemonic_nopin_nopassphrase() - sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( - n=[2147483692,2147483708,2147483648,0,0], - nonce=1, - gas_price=20, - gas_limit=20, - value=0, - to=binascii.unhexlify('41e5560054824ea6b0732e656e3ad64e20e94e45'), - address_type=0, - chain_id=1, - data=binascii.unhexlify('095ea7b3' + '0000000000000000000000001d8ce9022f6284c3a5c317f8f34620107214e545' + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff') - ) + recorder = ScreenRecorder(self.client) + with recorder: + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=[2147483692,2147483708,2147483648,0,0], + nonce=1, + gas_price=20, + gas_limit=20, + value=0, + to=binascii.unhexlify('41e5560054824ea6b0732e656e3ad64e20e94e45'), + address_type=0, + chain_id=1, + data=binascii.unhexlify('095ea7b3' + '0000000000000000000000001d8ce9022f6284c3a5c317f8f34620107214e545' + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff') + ) + if self.firmware_at_least("7.15.0"): + # The UNLIMITED warning is the first screen, full spender. + self.assertIsNotNone(find_line(recorder.screens[0], + 'UNLIMITED APPROVAL', TITLE_FONT)) + self.assertTrue(shows(recorder.screens[0], + 'Allow 0x1D8CE9022f6284c3A5c317f8f34620107214E545 ' + 'to spend ALL your CVC')) self.assertEqual(sig_v, 37) self.assertEqual(binascii.hexlify(sig_r), 'bb4c640b79f946e1399450dfc615b0a6024b6724f167cef70cf2530408fc6339') diff --git a/tests/test_msg_ethereum_erc20_uniswap_liquidity.py b/tests/test_msg_ethereum_erc20_uniswap_liquidity.py index 2f75df28..f5a50729 100644 --- a/tests/test_msg_ethereum_erc20_uniswap_liquidity.py +++ b/tests/test_msg_ethereum_erc20_uniswap_liquidity.py @@ -26,13 +26,53 @@ from keepkeylib.tools import int_to_big_endian class TestMsgEthereumUniswaptxERC20(common.KeepKeyTest): - + + def setUp(self): + super(TestMsgEthereumUniswaptxERC20, self).setUp() + # Canonical 7.15 routes unknown token contracts through explicitly + # authorized raw review. Exercise that path instead of an emulator skip. + self.requires_firmware("7.15.0") + + def test_sign_uni_approve_liquidity_ETH_refused(self): + self.requires_fullFeature() + self.requires_firmware_below("7.16.0") + self.requires_release_capability("erc7730-runtime-review") + self.requires_capability_absent("erc20-unlimited-approve-review") + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + + # Unlimited approval is deliberately disabled on canonical 7.15. + # This legacy vector must be refused, not skipped or signed. + with self.assertRaises(CallException) as caught: + self.client.ethereum_sign_tx( + n=[2147483692,2147483708,2147483648,0,0], + nonce=0xf, + gas_price=0x2980872680, + gas_limit=0xbd0e, + value=0x0, + to=binascii.unhexlify('470e8de2ebaef52014a47cb5e6af86884947f08c'), # fox pool + address_type=0, + chain_id=1, + # The data below is generally broken into 32-byte chunks except for the function selector (4 bytes_ and + # keccak signatures (4 bytes) + data=binascii.unhexlify('095ea7b3' + # approve + '0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d' + # uniswap v2: router 2 contract address + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff') # approve amount + + ) + self.assertEqual(caught.exception.args[0], + proto_types.Failure_ActionCancelled) + self.assertIn("Unlimited ERC20 approval is disabled", + str(caught.exception)) + def test_sign_uni_approve_liquidity_ETH(self): self.requires_fullFeature() - self.requires_firmware("7.1.0") + self.requires_release_capability("erc20-unlimited-approve-review") self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) - # Approval tx for the ETH/FOX pool + # Unlimited approval is reviewed, then signed (owner policy 2026-10-03). + # Signature vector from pre-7.15 firmware, which signed the same tx. sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( n=[2147483692,2147483708,2147483648,0,0], nonce=0xf, @@ -42,24 +82,20 @@ def test_sign_uni_approve_liquidity_ETH(self): to=binascii.unhexlify('470e8de2ebaef52014a47cb5e6af86884947f08c'), # fox pool address_type=0, chain_id=1, - # The data below is generally broken into 32-byte chunks except for the function selector (4 bytes_ and - # keccak signatures (4 bytes) data=binascii.unhexlify('095ea7b3' + # approve '0000000000000000000000007a250d5630b4cf539739df2c5dacb4c659f2488d' + # uniswap v2: router 2 contract address 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff') # approve amount - - ) + ) self.assertEqual(sig_v, 38) self.assertEqual(binascii.hexlify(sig_r), '7f7a5ce501371a01ead394d2186385742d5fbdc3d85da98249d2a05043ac6d5a') self.assertEqual(binascii.hexlify(sig_s), '329954b284ed1df9a6242820e793b9719c0c6c21cae5f90190ce61c7f73c731e') - + def test_sign_uni_add_liquidity_ETH(self): self.requires_fullFeature() - if self.client.features.firmware_variant[0:8] == "Emulator": - self.skipTest("Skip until emulator issue resolved") - return self.requires_firmware("7.1.0") + self.requires_release_capability("evm-unknown-token-review") self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) # Add liquidity to ETH/FOX pool sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( @@ -71,7 +107,7 @@ def test_sign_uni_add_liquidity_ETH(self): to=binascii.unhexlify('7a250d5630B4cF539739dF2C5dAcb4c659F2488D'), # UNISWAP router address_type=0, chain_id=1, - # The data below is generally broken into 32-byte chunks except for the function selector (4 bytes_ and + # The data below is generally broken into 32-byte chunks except for the function selector (4 bytes_ and # keccak signatures (4 bytes) data=binascii.unhexlify('f305d719' + # addLiquidityETH '000000000000000000000000c770eefad204b5180df6a14ee197d99d808ee52d' + # FOX token @@ -80,18 +116,16 @@ def test_sign_uni_add_liquidity_ETH(self): '0000000000000000000000000000000000000000000000000000fb98b65aba40' + # min amount of eth token '0000000000000000000000003f2329C9ADFbcCd9A84f52c906E936A42dA18CB8' + # eth address (self) '00000000000000000000000000000000000000000000000000000178a9380e5f') # deadline - ) + ) self.assertEqual(sig_v, 37) - self.assertEqual(binascii.hexlify(sig_r), '8547542bc74c0dcc6ca8b02a79e0dccd336856d8c48376289a2a697d864a5892') - self.assertEqual(binascii.hexlify(sig_s), '0a8eec6856aef8caa234240b06862976f8e238e8b24f5c989279507dd7e51ccd') + self.assertEqual(binascii.hexlify(sig_r).decode("ascii"), '8547542bc74c0dcc6ca8b02a79e0dccd336856d8c48376289a2a697d864a5892') + self.assertEqual(binascii.hexlify(sig_s).decode("ascii"), '0a8eec6856aef8caa234240b06862976f8e238e8b24f5c989279507dd7e51ccd') def test_sign_uni_remove_liquidity_ETH(self): self.requires_fullFeature() - if self.client.features.firmware_variant[0:8] == "Emulator": - self.skipTest("Skip until emulator issue resolved") - return self.requires_firmware("7.1.0") self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) # remove liquidity from the ETH/FOX pool sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( @@ -103,7 +137,7 @@ def test_sign_uni_remove_liquidity_ETH(self): to=binascii.unhexlify('7a250d5630B4cF539739dF2C5dAcb4c659F2488D'), # UNISWAP router address_type=0, chain_id=1, - # The data below is generally broken into 32-byte chunks except for the function selector (4 bytes_ and + # The data below is generally broken into 32-byte chunks except for the function selector (4 bytes_ and # keccak signatures (4 bytes) data=binascii.unhexlify('02751cec' + # addLiquidityETH '000000000000000000000000c770eefad204b5180df6a14ee197d99d808ee52d' + # FOX token @@ -112,10 +146,10 @@ def test_sign_uni_remove_liquidity_ETH(self): '0000000000000000000000000000000000000000000000000000fb04c77f3e94' + # min amount of eth token '0000000000000000000000005028d647b74f12903e6d5f3969f8f624e6a9a93d' + # to address (not self) '00000000000000000000000000000000000000000000000000000178b2062f3d') # deadline - ) + ) self.assertEqual(sig_v, 37) - self.assertEqual(binascii.hexlify(sig_r), '7143f0d8e5505a8cfb1df55e9c5d7433eba33a61959137c08cc5c088ec12ab5d') - self.assertEqual(binascii.hexlify(sig_s), '20b456d6c13295f5abb6109d7ade2c5d5fc395963b1e45d92e6dc8c33749c517') + self.assertEqual(binascii.hexlify(sig_r).decode("ascii"), '7143f0d8e5505a8cfb1df55e9c5d7433eba33a61959137c08cc5c088ec12ab5d') + self.assertEqual(binascii.hexlify(sig_s).decode("ascii"), '20b456d6c13295f5abb6109d7ade2c5d5fc395963b1e45d92e6dc8c33749c517') if __name__ == '__main__': unittest.main() diff --git a/tests/test_msg_ethereum_erc7730_runtime.py b/tests/test_msg_ethereum_erc7730_runtime.py new file mode 100644 index 00000000..f27ec2ba --- /dev/null +++ b/tests/test_msg_ethereum_erc7730_runtime.py @@ -0,0 +1,1523 @@ +"""ERC-7730 runtime (7.15): what the device shows for a certified definition. + +Each test signs the same transaction on the ordinary path and then with a +certified ERC-7730 definition, reads every confirmation's exact title and +body over DebugLink, and requires the same signature both times: the +definition adds screens, never changes what is signed. + +The definition is signed by a throwaway catalog key loaded as a runtime +signer ("NOT verified by KeepKey"); AdvancedMode is required. Uses only the +public test mnemonic. Also imported by keepkey-firmware's +scripts/emulator/test_stack07_regressions.py for its harness. +""" + +import copy +import hashlib + +import semver + +import common +from confirmation_pages import ConfirmationPages +from keepkeylib import erc7730, erc7730_compiler, eip712_stream +from keepkeylib import messages_ethereum_pb2 as eth +from keepkeylib import messages_pb2 as proto +from keepkeylib import types_pb2 as types +from keepkeylib.signed_metadata import TEST_PRIVATE_KEY, test_signer_compressed_pubkey as signer_pubkey + + +PATH = [0x8000002C, 0x8000003C, 0x80000000, 0, 0] +ADDRESS = bytes.fromhex("11" * 20) +OTHER_ADDRESS = bytes.fromhex("33" * 20) +# An unrelated throwaway key that is never loaded into any signer slot. +UNKNOWN_SIGNER_KEY = bytes.fromhex("42" * 32) +# The ordinary review that follows every certified one. Its screens are not +# captured for the report: they are unchanged, which the identical signature +# already proves, and the report's frame picker would otherwise prefer the +# per-transaction data hash over the screens the definition adds. +ORDINARY_REVIEW = ("Send", "Transaction", "Ethereum Data Hash") + + +def assert_failure(test, result, code, message): + test.assertIsInstance(result, proto.Failure) + test.assertEqual((result.code, result.message), (code, message)) + + +class Erc7730Harness(object): + """Preload, sign and walk helpers. Not a TestCase: importing it collects + no tests.""" + + def _envelope(self, program, signer_key=TEST_PRIVATE_KEY, signer_pub=None, + chain=1): + cert = bytearray(139) + cert[0] = 1 + cert[2:6] = chain.to_bytes(4, "big") + cert[10:21] = b"Host alias\0" + cert[42:75] = signer_pub if signer_pub is not None else signer_pubkey() + return erc7730.sign_envelope(program, cert, signer_key) + + def _definition(self, program, envelope): + return erc7730.Definition( + envelope, program[7], 1, ADDRESS, + program[38:42] if program[7] == 1 else program[38:70]) + + def _load_signer(self): + self.client.load_clearsign_signer( + key_id=3, pubkey=signer_pubkey(), alias="Audit signer") + + def _preload(self, program): + self._load_signer() + envelope = self._envelope(program) + erc7730.preload(self.client, self._definition(program, envelope)) + self._drop_setup_screenshots() + return envelope + + def _raw_preload(self, envelope): + """Stream an envelope; return the first non-Ack response. + + Every refusal contract below is a refusal BEFORE any ButtonRequest, + so a ButtonRequest here is returned (and fails the caller's Failure + assertion) rather than acknowledged. + """ + definition_id = hashlib.sha256(envelope).digest() + offset = 0 + while offset < len(envelope): + data = envelope[offset:offset + erc7730.MAX_CHUNK] + response = self.client.call_raw(eth.EthereumClearSignDefinition( + definition_id=definition_id, offset=offset, + total_length=len(envelope), data=data)) + if not isinstance(response, eth.EthereumClearSignDefinitionAck): + return response + offset += len(data) + return response + + def _first_pages(self): + """Complete confirmations, after checking every numbered page.""" + self.confirmations.finish() + return self.confirmations.texts + + def _walk(self, start, envelope=b"", doc=None, change_pass=None, cancel_button=None, + arguments=None, catalog=None, altered=None, cancel_title=None, + chunk_size=None): + response = self.client.call_raw(start) + self.definition_requests = 0 + self.button_codes = [] + self.confirmations = ConfirmationPages() + # Keep one entry per physical ButtonRequest for cancellation sweeps. + # Complete bodies are available separately through _first_pages(). + self.screens = [] + self.calldata_chunk_sizes = [] + buttons = 0 + calldata_passes = 0 + calldata_offset = 0 + typed_passes = 0 + for _ in range(1000): + if isinstance(response, proto.ButtonRequest): + buttons += 1 + self.button_codes.append(response.code) + text = self.client.debug.read_confirm_text() + title = self.confirmations.add(response.code, text) + self.screens.append((title, text[1])) + if self.screens[-1][0] not in ORDINARY_REVIEW: + self.client.capture_oled() + decline = (buttons == cancel_button or + self.screens[-1][0] == cancel_title) + self.client.debug.press_no() if decline else self.client.debug.press_yes() + response = self.client.call_raw(proto.ButtonAck()) + elif isinstance(response, eth.EthereumClearSignDefinitionRequest) and catalog: + self.definition_requests += 1 + response = self.client.call_raw(catalog.chunk(response)) + elif isinstance(response, eth.EthereumClearSignDefinitionRequest): + self.definition_requests += 1 + offset = response.offset + response = self.client.call_raw(eth.EthereumClearSignDefinitionChunk( + definition_id=hashlib.sha256(envelope).digest(), offset=offset, + total_length=len(envelope), data=envelope[offset:offset + response.length])) + elif isinstance(response, eth.EthereumTypedDataStructRequest): + response = self.client.call_raw(eip712_stream.build_struct_ack( + eip712_stream.struct_members(doc, response.name))) + elif isinstance(response, eth.EthereumTypedDataValueRequest): + path = list(response.member_path) + if path == [1, 0]: + typed_passes += 1 + current = copy.deepcopy(doc) + if change_pass == typed_passes: + current["message"]["first"] += 1 + resolved = eip712_stream.resolve_member_path(current, path) + value = (eip712_stream.encode_array_length(resolved[1]) + if resolved[0] == "length" else + eip712_stream.encode_value(resolved[1], resolved[2])) + response = self.client.call_raw(eth.EthereumTypedDataValueAck(value=value)) + elif isinstance(response, eth.EthereumTxRequest) and response.HasField("data_length"): + if calldata_offset == 0: + calldata_passes += 1 + data = arguments + if altered and altered[0] == calldata_passes: + data = altered[1] + if data is None: + data = (43 if change_pass == calldata_passes else 42).to_bytes(32, "big") + data += (7).to_bytes(32, "big") + self.assertGreater(response.data_length, 0) + self.assertLessEqual(response.data_length, + len(data) - calldata_offset) + length = min(response.data_length, + chunk_size or response.data_length) + chunk = data[calldata_offset:calldata_offset + length] + self.calldata_chunk_sizes.append(length) + calldata_offset += length + if calldata_offset == len(data): + calldata_offset = 0 + response = self.client.call_raw(eth.EthereumTxAck(data_chunk=chunk)) + else: + if not isinstance(response, proto.Failure): + self.confirmations.finish() + return response, buttons, calldata_passes, typed_passes + self.fail("protocol did not terminate") + + +class TestMsgEthereumErc7730Runtime(Erc7730Harness, common.KeepKeyTest): + def setUp(self): + super().setUp() + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.requires_release_capability("erc7730-runtime-review") + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + + # Phase 0 of the ERC-7730 formatter plan: the preload verifier and the + # runtime share one capability table, so a program the runtime cannot + # finish is refused before the first screen instead of after the user has + # approved the signer, intent and earlier fields. + def _audit_start(self, program, data_length=68): + return eth.EthereumSignTx(address_n=PATH, nonce=b"", gas_price=b"\x01", + gas_limit=b"\xff\xff", to=ADDRESS, value=b"", chain_id=1, + data_length=data_length, data_initial_chunk=program[38:42]) + + def test_program_outside_capability_table_is_refused_at_preload(self): + signature = "audit(uint256 first,uint256 second)" + # Conditions that could hide or veto a field are never executed. + fields = { + "ifNotIn": {"path": "first", "label": "First value", + "format": "raw", "visible": {"ifNotIn": [0]}}, + "mustMatch": {"path": "first", "label": "First value", + "format": "raw", "visible": {"mustMatch": [42]}}, + } + self._load_signer() + for name, field in sorted(fields.items()): + descriptor = {"display": {"formats": {signature: { + "intent": "Audit action", "fields": [ + field, + {"path": "second", "label": "Second value", + "format": "raw"}]}}}} + with self.assertRaises(erc7730_compiler.DeviceCannotExecute): + erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS, executable_only=False) + result = self._raw_preload(self._envelope(program)) + assert_failure(self, result, types.Failure_SyntaxError, + "Invalid certified ERC-7730 definition") + # Nothing is left preloaded: the transaction takes the ordinary, + # uncertified path and never asks for a definition. + result, _, passes, _ = self._walk(self._audit_start(program)) + self.assertIsInstance(result, eth.EthereumTxRequest, msg=name) + self.assertTrue(result.HasField("signature_r"), msg=name) + self.assertEqual((name, passes, self.definition_requests), + (name, 1, 0)) + + def test_path_outside_abi_is_refused_at_preload(self): + signature = "audit(uint256 first,uint256 second)" + descriptor = {"display": {"formats": {signature: { + "intent": "Audit action", "fields": [ + {"path": "second", "label": "Second value", + "format": "raw"}]}}}} + program = bytearray(erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS)) + # The single value path is (source 1, one step, index 1). Point it at + # a third argument the function does not have. + step = program.index(bytes([1, 1, 0xff, 0xff, 1, 0, 0, 0, 1])) + program[step + 8] = 2 + self._load_signer() + result = self._raw_preload(self._envelope(bytes(program))) + assert_failure(self, result, types.Failure_SyntaxError, + "Invalid certified ERC-7730 definition") + + def test_raw_field_screens_show_exact_text(self): + signature = "audit(uint256 first,uint256 second)" + descriptor = {"display": {"formats": {signature: { + "intent": "Audit action", "fields": [ + {"path": "first", "label": "First value", "format": "raw"}, + {"path": "second", "label": "Second value", + "format": "raw"}]}}}} + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, self._word(42) + self._word(7)) + self.assertEqual(self.calldata_passes, 4) + certified = [screen for screen in self.screens if screen[0] in ( + "Runtime signer", "Unverified data", "Contract action", + "Signer field")] + self.assertEqual(certified[1:], [ + ("Unverified data", "NOT verified by KeepKey"), + ("Contract action", "Audit action"), + ("Signer field", "First value:\n42"), + ("Signer field", "Second value:\n7"), + ]) + self.assertEqual(certified[0][0], "Runtime signer") + self.assertTrue(certified[0][1].startswith("Audit signer ("), + certified[0][1]) + + # Phase A: tokenAmount, addressName, @.from/@.to and signed constants. + # Asset facts come only from the firmware token table; an address is always + # shown in full; the signer's message sits beside the value. + USDC = bytes.fromhex("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") + # Not in the firmware token table on chain 1 (0xeeee..ee is: there the + # table's own entry wins over any signer alias). + NATIVE = bytes.fromhex("44" * 20) + + def _certified(self, program, arguments, preload=None, catalog=None): + """Sign once on the ordinary path, then with the definition, and + require the same signature: the annotations are additive only.""" + start = self._audit_start(program, 4 + len(arguments)) + baseline, _, _, _ = self._walk(start, arguments=arguments) + self.assertIsInstance(baseline, eth.EthereumTxRequest) + self.assertTrue(baseline.HasField("signature_r")) + envelope = self._preload(program) if preload is None else preload() + result, _, passes, _ = self._walk(start, envelope, arguments=arguments, + catalog=catalog) + self.calldata_passes = passes + # The certified path ran: the device asked for its definition. + self.assertGreater(self.definition_requests, 0) + self.assertIsInstance(result, eth.EthereumTxRequest) + self.assertEqual((result.signature_r, result.signature_s), + (baseline.signature_r, baseline.signature_s)) + return result + + def _field_screens(self, descriptor, signature, arguments): + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + return [body for title, body in self._first_pages() + if title == "Signer field"] + + @staticmethod + def _word(value): + if isinstance(value, bytes): + return bytes(12) + value + return value.to_bytes(32, "big") + + def _token_screen(self, token, amount, params=None): + signature = "send(address token,uint256 amount)" + fields = [{"path": "amount", "label": "Amount", "format": "tokenAmount", + "params": dict({"tokenPath": "token"}, **(params or {}))}] + descriptor = {"display": {"formats": {signature: { + "intent": "Send", "fields": fields}}}} + return self._field_screens(descriptor, signature, + self._word(token) + self._word(amount)) + + def test_token_amount_uses_the_firmware_token_table(self): + self.assertEqual(self._token_screen(self.USDC, 1500000), + ["Amount:\n1.5 USDC"]) + + def test_token_named_by_calldata_wins_over_the_hosts_claim(self): + # The transaction goes to ADDRESS and a host might call it USDC; the + # calldata names another token, which the firmware table does not know. + self.assertEqual(self._token_screen(OTHER_ADDRESS, 42), [ + "Amount:\n42\nunknown token\n0x" + OTHER_ADDRESS.hex()]) + + def test_signer_label_cannot_name_an_unknown_token(self): + signature = "send(uint256 amount)" + descriptor = {"display": {"formats": {signature: { + "intent": "Send", "fields": [{ + "path": "amount", "label": "USDC amount", + "format": "tokenAmount", + "params": {"token": "0x" + OTHER_ADDRESS.hex()}}]}}}} + self.assertEqual( + self._field_screens(descriptor, signature, self._word(42)), + ["USDC amount:\n42\nunknown token\n0x" + OTHER_ADDRESS.hex()]) + + def test_threshold_message_is_shown_beside_the_exact_amount(self): + params = {"threshold": 1000000, "message": "Large amount"} + self.assertEqual(self._token_screen(self.USDC, 999999, params), + ["Amount:\n0.999999 USDC"]) + self.assertEqual(self._token_screen(self.USDC, 1500000, params), + ["Amount:\nSigner: Large amount\n1.5 USDC"]) + + def test_native_alias_discloses_signer_mapping_and_token_address(self): + params = {"nativeCurrencyAddress": ["0x" + self.NATIVE.hex()]} + # An address outside the alias set is not the native asset. + self.assertEqual( + self._token_screen(OTHER_ADDRESS, 1500000000000000000, params), + ["Amount:\n1500000000000000000\nunknown token\n0x" + + OTHER_ADDRESS.hex()]) + self.assertEqual( + self._token_screen(self.NATIVE, 1500000000000000000, params), + ["Amount:\nSigner native alias:\n0x" + self.NATIVE.hex() + + "\n1.5 ETH"]) + + def test_address_name_marks_only_the_signing_account(self): + signer = self.client.ethereum_get_address(PATH) + if not isinstance(signer, bytes): + signer = bytes.fromhex(signer[2:]) + signature = "pay(address recipient)" + descriptor = {"display": {"formats": {signature: { + "intent": "Pay", "fields": [{ + "path": "recipient", "label": "Recipient", + "format": "addressName"}]}}}} + from keepkeylib.signed_metadata import keccak256 + + def checksummed(address): + digest = keccak256(address.hex().encode("ascii")).hex() + return "0x" + "".join( + c.upper() if c.isalpha() and int(digest[i], 16) >= 8 else c + for i, c in enumerate(address.hex())) + + near = bytes(signer[:19]) + bytes([signer[19] ^ 1]) + self.assertEqual( + self._field_screens(descriptor, signature, self._word(near)), + ["Recipient:\n" + checksummed(near)]) + self.assertEqual( + self._field_screens(descriptor, signature, self._word(signer)), + ["Recipient:\n" + checksummed(signer) + "\n(this wallet)"]) + + def test_containers_and_signed_constants(self): + signature = "audit(uint256 first,uint256 second)" + descriptor = {"display": {"formats": {signature: { + "intent": "Audit action", "fields": [ + {"path": "@.to", "label": "Contract", "format": "addressName"}, + {"value": "Audit protocol", "label": "Protocol"}]}}}} + self.assertEqual( + self._field_screens(descriptor, signature, + self._word(42) + self._word(7)), + ["Contract:\n0x" + ADDRESS.hex(), "Protocol:\nAudit protocol"]) + + # Phase B: the interpolated intent is shown as numbered parts after the + # plain intent. Each value part is formatted exactly as its field is. + def test_interpolated_intent_parts_show_the_same_values_as_fields(self): + signature = "send(address token,uint256 amount)" + descriptor = {"display": {"formats": {signature: { + "intent": "Send tokens", + "interpolatedIntent": "Send {amount} now", + "fields": [{"path": "amount", "label": "Amount", + "format": "tokenAmount", + "params": {"tokenPath": "token"}}]}}}} + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, self._word(self.USDC) + self._word(1500000)) + shown = [screen for screen in self._first_pages() + if screen[0] in ("Contract action", "Intent text 1 of 3", + "Intent value 2 of 3", "Intent text 3 of 3", + "Signer field")] + self.assertEqual(shown, [ + ("Contract action", "Send tokens"), + ("Intent text 1 of 3", "Send"), + ("Intent value 2 of 3", "1.5 USDC"), + ("Intent text 3 of 3", "now"), + ("Signer field", "Amount:\n1.5 USDC"), + ]) + + # Phase C: amount, date, duration, unit, enum and nftName. Signer-supplied + # units and enum labels appear beside the raw value, never instead. + def _one_field(self, field, arguments, signature="act(uint256 a,address b)", + metadata=None): + descriptor = {"display": {"formats": {signature: { + "intent": "Act", "fields": [field]}}}} + if metadata: + descriptor["metadata"] = metadata + return self._field_screens(descriptor, signature, arguments) + + def test_phase_c_formatters_show_exact_text(self): + pad = self._word(OTHER_ADDRESS) + cases = [ + ({"path": "a", "label": "Value", "format": "amount"}, + 1500000000000000000, "Value:\n1.5 ETH"), + ({"path": "a", "label": "When", "format": "date", + "params": {"encoding": "timestamp"}}, + 1700000000, "When:\n2023-11-14 22:13:20 UTC\n(1700000000)"), + ({"path": "a", "label": "At", "format": "date", + "params": {"encoding": "blockheight"}}, + 19000000, "At:\nBlock 19000000"), + ({"path": "a", "label": "Lock", "format": "duration"}, + 93784, "Lock:\n1d 2h 3m 4s\n(93784 s)"), + ({"path": "a", "label": "Weight", "format": "unit", + "params": {"base": "kg", "decimals": 3}}, + 93784, "Weight:\nunit set by signer\n93.784 kg\nraw 93784"), + ] + for field, value, expected in cases: + self.assertEqual( + (field["format"], + self._one_field(field, self._word(value) + pad)), + (field["format"], [expected])) + + def test_enum_labels_are_the_signers_claim_beside_the_value(self): + field = {"path": "a", "label": "Side", "format": "enum", + "params": {"$ref": "$.metadata.enums.side"}} + metadata = {"enums": {"side": {"0": "Buy", "1": "Sell"}}} + pad = self._word(OTHER_ADDRESS) + self.assertEqual(self._one_field(field, self._word(1) + pad, + metadata=metadata), + ["Side:\nlabel set by signer\nSell (1)"]) + self.assertEqual(self._one_field(field, self._word(5) + pad, + metadata=metadata), + ["Side:\n5 (unmapped)"]) + + def test_nft_shows_the_collection_address(self): + field = {"path": "a", "label": "Item", "format": "nftName", + "params": {"collectionPath": "b"}} + self.assertEqual( + self._one_field(field, self._word(42) + self._word(self.USDC)), + ["Item:\nToken ID 42\nCollection\n" + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"]) + + def test_malformed_calldata_is_refused_before_a_constant_field(self): + # The first field shows a signed constant and captures nothing, so + # only the up-front validation pass stands between malformed calldata + # and the first screen. + signature = "pay(address recipient)" + descriptor = {"display": {"formats": {signature: { + "intent": "Pay", "fields": [ + {"value": "Audit protocol", "label": "Protocol"}, + {"path": "recipient", "label": "Recipient", + "format": "addressName"}]}}}} + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + envelope = self._preload(program) + dirty = b"\x01" + bytes(11) + OTHER_ADDRESS # non-canonical address + result, buttons, passes, _ = self._walk( + self._audit_start(program, 36), envelope, arguments=dirty) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 calldata does not match definition") + self.assertEqual((buttons, passes), (0, 1)) + + # Phase D: groups, "optional" fields and one iteration at a time. Every + # element gets its own numbered screens; nothing is ever hidden. + def _titled_fields(self, descriptor, signature, arguments): + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + return [screen for screen in self._first_pages() + if screen[0].startswith("Signer field")] + + def test_iteration_shows_every_element_numbered(self): + signature = "pay(address[] recipients)" + descriptor = {"display": {"formats": {signature: { + "intent": "Pay", "fields": [{ + "path": "recipients.[]", "label": "Recipient", + "format": "addressName"}]}}}} + # An empty array shows no element and still signs. + self.assertEqual( + self._titled_fields(descriptor, signature, + self._word(32) + self._word(0)), []) + arguments = (self._word(32) + self._word(2) + + self._word(OTHER_ADDRESS) + self._word(ADDRESS)) + self.assertEqual( + self._titled_fields(descriptor, signature, arguments), [ + ("Signer field 1 of 2", "Recipient:\n0x" + OTHER_ADDRESS.hex()), + ("Signer field 2 of 2", "Recipient:\n0x" + ADDRESS.hex()), + ]) + # A fixed ABI array has no length word. Capture still binds the + # declared count before replaying both element screens. + fixed_signature = "payFixed(address[2] recipients)" + fixed_descriptor = {"display": {"formats": {fixed_signature: { + "intent": "Pay fixed", "fields": [{ + "path": "recipients.[]", "label": "Recipient", + "format": "addressName"}]}}}} + self.assertEqual( + self._titled_fields( + fixed_descriptor, fixed_signature, + self._word(OTHER_ADDRESS) + self._word(ADDRESS)), [ + ("Signer field 1 of 2", "Recipient:\n0x" + + OTHER_ADDRESS.hex()), + ("Signer field 2 of 2", "Recipient:\n0x" + ADDRESS.hex()), + ]) + + def test_grouped_tuple_iteration_and_optional_fields(self): + signature = "batch((address to,uint256 amount)[] items,uint256 fee)" + descriptor = {"display": {"formats": {signature: { + "intent": "Batch", "fields": [ + {"path": "items.[]", "label": "Transfer", "fields": [ + {"path": "to", "label": "To", "format": "addressName"}, + {"path": "amount", "label": "Amount", "format": "raw"}]}, + {"path": "fee", "label": "Fee", "format": "raw", + "visible": "optional"}]}}}} + arguments = (self._word(64) + self._word(9) + self._word(2) + + self._word(OTHER_ADDRESS) + self._word(5) + + self._word(ADDRESS) + self._word(6)) + self.assertEqual( + self._titled_fields(descriptor, signature, arguments), [ + ("Signer field 1 of 2", "To:\n0x" + OTHER_ADDRESS.hex()), + ("Signer field 1 of 2", "Amount:\n5"), + ("Signer field 2 of 2", "To:\n0x" + ADDRESS.hex()), + ("Signer field 2 of 2", "Amount:\n6"), + ("Signer field", "Fee:\n9"), + ]) + + # Phase E1 (7.15): an embedded call the device cannot clear-sign is shown + # under a blind-sign warning: its callee, selector, length, value and + # authority, all read from the signed calldata. 7.16 rejects it instead. + def _exec_screens(self, inner, value=1500000000000000000): + signature = ("execTransaction(address to,uint256 value,bytes data," + "uint8 operation)") + descriptor = {"display": {"formats": {signature: { + "intent": "sign multisig operation", "fields": [ + {"path": "data", "label": "Transaction", "format": "calldata", + "params": {"calleePath": "to", "amountPath": "value", + "spenderPath": "@.to"}}]}}}} + padded = inner + bytes(-len(inner) % 32) + arguments = (self._word(OTHER_ADDRESS) + self._word(value) + + self._word(128) + self._word(0) + + self._word(len(inner)) + padded) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + return [screen for screen in self._first_pages() + if screen[0] in ("Blind signature", "Signer field")] + + def test_embedded_call_is_shown_under_a_blind_sign_warning(self): + self.requires_firmware_below("7.16.0") # 7.16 refuses instead + inner = bytes.fromhex("a9059cbb") + bytes(296) # 300 bytes: no capture + self.assertEqual(self._exec_screens(inner), [ + ("Blind signature", "The inner call is not clear-signed"), + ("Signer field", + "Transaction:\nTo 0x" + OTHER_ADDRESS.hex() + + "\nFunction 0xa9059cbb\nData 300 bytes\nValue 1.5 ETH\nAs 0x" + + ADDRESS.hex()), + ]) + self.assertEqual(self._exec_screens(b"", value=0), [ + ("Blind signature", "The inner call is not clear-signed"), + ("Signer field", + "Transaction:\nTo 0x" + OTHER_ADDRESS.hex() + + "\nNo data\nValue 0 Wei\nAs 0x" + ADDRESS.hex()), + ]) + + # Phase E2: the inner call is clear-signed with its own definition, bound + # to the callee and selector in the signed calldata. Every inner pass + # replays the whole outer calldata against the reviewed digest. + EXEC = ("execTransaction(address to,uint256 value,bytes data," + "uint8 operation)") + TRANSFER = "transfer(address to,uint256 amount)" + + def _exec_setup(self, amount_path=True, spender="@.to", memo=None): + inner_signature = (self.TRANSFER if memo is None else + "transfer(address to,uint256 amount,bytes memo)") + params = {"calleePath": "to"} + if spender: + params["spenderPath"] = spender + if amount_path: + params["amountPath"] = "value" + outer_descriptor = {"display": {"formats": {self.EXEC: { + "intent": "sign multisig operation", "fields": [ + {"path": "data", "label": "Transaction", "format": "calldata", + "params": params}, + {"path": "operation", "label": "Operation", "format": "raw"}]}}}} + inner_descriptor = {"display": {"formats": {inner_signature: { + "intent": "Transfer", "fields": [ + {"path": "to", "label": "Recipient", "format": "addressName"}, + {"path": "amount", "label": "Amount", "format": "tokenAmount", + "params": {"tokenPath": "@.to"}}]}}}} + outer = erc7730_compiler.compile_calldata( + outer_descriptor, self.EXEC, 1, ADDRESS) + inner = erc7730_compiler.compile_calldata( + inner_descriptor, inner_signature, 1, self.USDC) + self._load_signer() + outer_env = self._envelope(outer) + inner_def = erc7730.Definition(self._envelope(inner), 1, 1, self.USDC, + inner[38:42]) + outer_def = self._definition(outer, outer_env) + self._exec_outer = (outer, outer_def) + call = (bytes.fromhex("a9059cbb") + self._word(OTHER_ADDRESS) + + self._word(1500000)) + if memo is not None: + call = (inner[38:42] + self._word(OTHER_ADDRESS) + + self._word(1500000) + self._word(96) + + self._word(len(memo)) + memo + bytes(-len(memo) % 32)) + arguments = (self._word(self.USDC) + self._word(0) + + self._word(128) + self._word(0) + + self._word(len(call)) + call + + bytes(-len(call) % 32)) + start = self._audit_start(outer, 4 + len(arguments)) + return start, arguments, outer_def, inner_def + + def _exec_certified(self, arguments, catalog): + outer, outer_def = self._exec_outer + + def preload(): + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + return b"" + return self._certified(outer, arguments, preload=preload, + catalog=catalog) + + def _relevant(self): + titles = ("Runtime signer", "Inner signer", "Contract action", + "Inner action", "Signer field", "Inner field", + "Blind signature") + return [screen for screen in self._first_pages() if screen[0] in titles] + + def test_inner_call_is_clear_signed_with_its_own_definition(self): + start, arguments, outer_def, inner_def = self._exec_setup() + self._exec_certified(arguments, erc7730.Catalog((outer_def, inner_def))) + shown = [s for s in self._relevant() + if s[0] not in ("Runtime signer", "Inner signer")] + self.assertEqual(shown, [ + ("Contract action", "sign multisig operation"), + ("Signer field", + "Transaction:\nTo 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + "\nFunction 0xa9059cbb\nData 68 bytes\nValue 0 Wei\nAs 0x" + + ADDRESS.hex()), + ("Inner action", "Transfer"), + ("Inner field", "Recipient:\n0x" + OTHER_ADDRESS.hex()), + ("Inner field", "Amount:\n1.5 USDC"), + ("Signer field", "Operation:\n0"), + ]) + self.assertIn("Inner signer", [s[0] for s in self.screens]) + + APPROVE = "approve(address spender,uint256 amount)" + MAX = (1 << 256) - 1 + + def _exec_approve(self, amount, spender=None, with_definition=True): + """execTransaction carrying approve(OTHER_ADDRESS, amount) on USDC.""" + self._exec_setup() + inner = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.APPROVE: { + "intent": "Approve", "fields": [ + {"path": "spender", "label": "Spender", + "format": "addressName"}, + {"path": "amount", "label": "Amount", "format": "raw"}]}}}}, + self.APPROVE, 1, self.USDC) + inner_def = erc7730.Definition(self._envelope(inner), 1, 1, self.USDC, + inner[38:42]) + outer_def = self._exec_outer[1] + call = (bytes.fromhex("095ea7b3") + + (spender or self._word(OTHER_ADDRESS)) + self._word(amount)) + arguments = (self._word(self.USDC) + self._word(0) + + self._word(128) + self._word(0) + + self._word(len(call)) + call + bytes(-len(call) % 32)) + catalog = erc7730.Catalog( + (outer_def, inner_def) if with_definition else (outer_def,)) + return arguments, catalog + + UNLIMITED = ("UNLIMITED approval", + "Allow 0x" + OTHER_ADDRESS.hex() + " to spend ALL your USDC") + + def _approve_screens(self): + return [s for s in self._first_pages() + if s[0] in ("UNLIMITED approval", "Contract action", + "Signer field", "Inner signer", "Inner action", + "Blind signature")] + + def test_inner_unlimited_approve_warns_before_the_inner_review(self): + self.requires_release_capability("erc20-unlimited-approve-review") + arguments, catalog = self._exec_approve(self.MAX) + self._exec_certified(arguments, catalog) + shown = self._approve_screens() + self.assertEqual(shown[:2], [ + ("Contract action", "sign multisig operation"), self.UNLIMITED]) + self.assertEqual(shown[2][0], "Signer field") + self.assertEqual(shown[3][0], "Inner signer") + self.assertEqual([s[0] for s in self._first_pages()].count( + "UNLIMITED approval"), 1) + + def test_declining_an_inner_unlimited_approve_signs_nothing(self): + self.requires_release_capability("erc20-unlimited-approve-review") + arguments, catalog = self._exec_approve(self.MAX) + outer, outer_def = self._exec_outer + + def preload(): + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + return b"" + self._declined(outer, arguments, "UNLIMITED approval", + preload=preload, catalog=catalog) + self.assertNotIn("Inner signer", [s[0] for s in self.screens]) + + def test_inner_finite_approve_shows_no_unlimited_warning(self): + arguments, catalog = self._exec_approve(self.MAX - 1) + self._exec_certified(arguments, catalog) + titles = [s[0] for s in self.screens] + self.assertNotIn("UNLIMITED approval", titles) + self.assertIn("Inner action", titles) + + def test_blind_inner_unlimited_approve_still_warns_in_715(self): + self.requires_firmware_below("7.16.0") # 7.16 refuses blind calls + self.requires_release_capability("erc20-unlimited-approve-review") + arguments, catalog = self._exec_approve(self.MAX, + with_definition=False) + self._exec_certified(arguments, catalog) + shown = self._approve_screens() + self.assertEqual(shown[:3], [ + ("Contract action", "sign multisig operation"), self.UNLIMITED, + ("Blind signature", "The inner call is not clear-signed")]) + + def test_inner_approve_with_a_dirty_spender_word_is_refused(self): + self.requires_release_capability("erc20-unlimited-approve-review") + arguments, catalog = self._exec_approve( + self.MAX, spender=b"\x01" + bytes(11) + OTHER_ADDRESS) + outer, outer_def = self._exec_outer + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + result, _, _, _ = self._walk( + self._audit_start(outer, 4 + len(arguments)), b"", + arguments=arguments, catalog=catalog) + assert_failure(self, result, types.Failure_SyntaxError, + "Invalid ERC-7730 embedded call") + self.assertNotIn("UNLIMITED approval", [s[0] for s in self.screens]) + self.assertNotIn(types.ButtonRequest_SignTx, self.button_codes) + + def test_inner_call_without_a_definition_is_blind_in_715(self): + self.requires_firmware_below("7.16.0") # 7.16 refuses instead + start, arguments, outer_def, _ = self._exec_setup() + self._exec_certified(arguments, erc7730.Catalog((outer_def,))) + shown = [s for s in self._relevant() + if s[0] != "Runtime signer"] + self.assertEqual(shown[1:3], [ + ("Blind signature", "The inner call is not clear-signed"), + ("Signer field", + "Transaction:\nTo 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + "\nFunction 0xa9059cbb\nData 68 bytes\nValue 0 Wei\nAs 0x" + + ADDRESS.hex()), + ]) + + def test_inner_definition_for_another_call_is_refused(self): + start, arguments, outer_def, inner_def = self._exec_setup() + # A host that answers the inner request with a definition for another + # selector: the device binds it to the signed selector and refuses. + approve = erc7730_compiler.compile_calldata( + {"display": {"formats": {"approve(address spender,uint256 amount)": { + "intent": "Approve", "fields": []}}}}, + "approve(address spender,uint256 amount)", 1, self.USDC) + wrong = erc7730.Definition(self._envelope(approve), 1, 1, self.USDC, + approve[38:42]) + + class Substituting(erc7730.Catalog): + def chunk(self, request): + if request.HasField("recursion_depth"): + request = copy.deepcopy(request) + request.selector_or_type_hash = wrong.selector_or_type_hash + return erc7730.Catalog.chunk(self, request) + + erc7730.preload(self.client, outer_def) + result, _, _, _ = self._walk( + start, arguments=arguments, + catalog=Substituting((outer_def, inner_def, wrong))) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 inner definition does not match") + self.assertNotIn("Inner action", [s[0] for s in self.screens]) + + def test_inner_bytes_changed_in_an_inner_pass_are_refused(self): + start, arguments, outer_def, inner_def = self._exec_setup() + # Pass 1 validates and fixes the digest; passes 2-4 capture the outer + # fields up to the inner call. Pass 5 is the inner call's own + # validation pass: change one byte of its amount there. + altered = bytearray(arguments) + altered[-40] ^= 1 + erc7730.preload(self.client, outer_def) + result, buttons, passes, _ = self._walk( + start, arguments=arguments, altered=(5, bytes(altered)), + catalog=erc7730.Catalog((outer_def, inner_def))) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 calldata does not match definition") + self.assertEqual(passes, 5) + self.assertNotIn("Inner field", [s[0] for s in self.screens]) + + def test_large_inner_call_is_bound_across_transport_chunks(self): + # The inner bytes exceed both the capture buffer and one transport + # reply. A 31-byte reply splits selectors, offsets and ABI words. + for chunk_size in (1024, 31): + with self.subTest(chunk_size=chunk_size): + _, arguments, outer_def, inner_def = self._exec_setup( + memo=b"Z" * 1200) + outer = self._exec_outer[0] + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + start = self._audit_start(outer, 4 + len(arguments)) + reviewed, _, _, _ = self._walk( + start, arguments=arguments, chunk_size=chunk_size, + catalog=erc7730.Catalog((outer_def, inner_def))) + self.assertIsInstance(reviewed, eth.EthereumTxRequest) + self.assertTrue(reviewed.HasField("signature_r")) + reviewed_chunks = list(self.calldata_chunk_sizes) + self.assertGreater(len(reviewed_chunks), 1) + self.assertLessEqual(max(reviewed_chunks), chunk_size) + if chunk_size == 31: + self.assertGreater(len(reviewed_chunks), 40) + self.assertIn(("Inner field", "Amount:\n1.5 USDC"), + self._relevant()) + ordinary, _, _, _ = self._walk( + start, arguments=arguments, chunk_size=chunk_size) + self.assertIsInstance(ordinary, eth.EthereumTxRequest) + self.assertTrue(ordinary.HasField("signature_r")) + self.assertGreater(len(self.calldata_chunk_sizes), 1) + self.assertLessEqual(max(self.calldata_chunk_sizes), chunk_size) + self.assertEqual(self.definition_requests, 0) + self.assertEqual( + (reviewed.signature_r, reviewed.signature_s, + reviewed.signature_v), + (ordinary.signature_r, ordinary.signature_s, + ordinary.signature_v)) + if chunk_size == 31: + # Change an embedded memo byte on the inner validation + # pass, after the outer review fixed the signed digest. + altered = bytearray(arguments) + altered[-40] ^= 1 + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + rejected, _, passes, _ = self._walk( + start, arguments=arguments, altered=(5, bytes(altered)), + chunk_size=chunk_size, + catalog=erc7730.Catalog((outer_def, inner_def))) + assert_failure(self, rejected, types.Failure_SyntaxError, + "ERC-7730 calldata does not match definition") + self.assertEqual(passes, 5) + self.assertNotIn("Inner field", [s[0] for s in self.screens]) + + def test_outer_bytes_changed_during_inner_review_are_refused(self): + start, arguments, outer_def, inner_def = self._exec_setup() + # The operation word belongs to the outer call, outside the inner + # byte range. Inner validation must still bind the whole transaction. + altered = bytearray(arguments) + altered[127] = 1 + erc7730.preload(self.client, outer_def) + result, _, passes, _ = self._walk( + start, arguments=arguments, altered=(5, bytes(altered)), + catalog=erc7730.Catalog((outer_def, inner_def))) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 calldata does not match definition") + self.assertEqual(passes, 5) + self.assertNotIn("Inner field", [s[0] for s in self.screens]) + + def test_restoring_outer_definition_rejects_another_valid_definition(self): + start, arguments, outer_def, inner_def = self._exec_setup() + replacement = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.EXEC: { + "intent": "Replacement", "fields": []}}}}, + self.EXEC, 1, ADDRESS) + wrong = self._definition(replacement, self._envelope(replacement)) + # Establish that this is a valid signed definition for the same call; + # the failure must come from the frozen outer definition identity. + erc7730.preload(self.client, wrong) + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + + class Substituting(erc7730.Catalog): + substituted = False + + def chunk(self, request): + if (request.HasField("definition_id") and + not request.HasField("contract_address")): + self.substituted = True + return eth.EthereumClearSignDefinitionChunk( + definition_id=wrong.definition_id, offset=0, + total_length=len(wrong.envelope), + data=wrong.envelope[:request.length]) + return super().chunk(request) + + catalog = Substituting((outer_def, inner_def)) + result, _, _, _ = self._walk(start, arguments=arguments, + catalog=catalog) + self.assertTrue(catalog.substituted) + self.assertIn(("Inner field", "Amount:\n1.5 USDC"), self._relevant()) + assert_failure(self, result, types.Failure_SyntaxError, + "Invalid certified ERC-7730 definition") + self.assertNotIn(types.ButtonRequest_SignTx, self.button_codes) + + def test_inner_definition_identity_cannot_change_between_chunks(self): + start, arguments, outer_def, inner_def = self._exec_setup() + erc7730.preload(self.client, outer_def) + + class ChangingIdentity(erc7730.Catalog): + changed = False + + def chunk(self, request): + response = super().chunk(request) + if request.HasField("recursion_depth"): + if request.offset == 0: + response.data = response.data[:31] + else: + self.changed = True + response.definition_id = bytes(32) + return response + + catalog = ChangingIdentity((outer_def, inner_def)) + result, _, _, _ = self._walk(start, arguments=arguments, + catalog=catalog) + self.assertTrue(catalog.changed) + assert_failure(self, result, types.Failure_SyntaxError, + "Invalid certified ERC-7730 definition") + self.assertNotIn("Inner action", [s[0] for s in self.screens]) + + def test_cancelled_inner_review_clears_preload_in_the_same_session(self): + for title in ("Inner signer", "Inner action", "Inner field"): + with self.subTest(title=title): + start, arguments, outer_def, inner_def = self._exec_setup() + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + result, _, _, _ = self._walk( + start, arguments=arguments, cancel_title=title, + catalog=erc7730.Catalog((outer_def, inner_def))) + assert_failure(self, result, types.Failure_ActionCancelled, + "Signing cancelled by user") + self.assertEqual(self.screens[-1][0], title) + self.assertNotIn(types.ButtonRequest_SignTx, self.button_codes) + # No Initialize, signer reset or new preload between calls. + result, _, _, _ = self._walk(start, arguments=arguments) + self.assertIsInstance(result, eth.EthereumTxRequest) + self.assertTrue(result.HasField("signature_r")) + self.assertEqual(self.definition_requests, 0) + self.assertEqual(self._relevant(), []) + + # ---- Audit remediation: each of these used to fail mid-review, take a + # fact from the wrong source, or was untested. ---- + + def test_only_a_raw_field_shows_a_signer_constant(self): + # Formatters show values the device decodes. A signer constant + # formatted as an amount would look decoded, so preload refuses it; + # as a raw field it is the signer's own field, and shows. + signature = "act(uint256 a,address b)" + for constant in (5, 1000): + descriptor = {"display": {"formats": {signature: { + "intent": "Act", "fields": [{ + "value": constant, "label": "Fee", "format": "amount"}]}}}} + with self.assertRaises(erc7730_compiler.DeviceCannotExecute): + erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS, executable_only=False) + self._load_signer() + assert_failure(self, self._raw_preload(self._envelope(program)), + types.Failure_SyntaxError, + "Invalid certified ERC-7730 definition") + self.assertEqual( + self._one_field({"value": 1000, "label": "Fee"}, + self._word(1) + self._word(OTHER_ADDRESS)), + ["Fee:\n1000"]) + + def test_a_wanchain_transaction_is_never_certified(self): + # tx_type marks a Wanchain transaction, whose value is WAN; the + # certified review would name it ETH. Refused before any screen. + signature = "audit(uint256 first,uint256 second)" + program = erc7730_compiler.compile_calldata( + {"display": {"formats": {signature: { + "intent": "Audit action", "fields": [ + {"path": "first", "label": "First", + "format": "amount"}]}}}}, + signature, 1, ADDRESS) + self._preload(program) + start = self._audit_start(program) + start.tx_type = 1 + result, buttons, _, _ = self._walk(start) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 definition does not match transaction") + self.assertEqual(buttons, 0) + + def test_control_characters_in_a_value_are_escaped(self): + signature = "note(string text)" + descriptor = {"display": {"formats": {signature: { + "intent": "Note", "fields": [ + {"path": "text", "label": "Text", "format": "raw"}]}}}} + text = b"a\nb\tc" + arguments = (self._word(32) + self._word(len(text)) + text + + bytes(-len(text) % 32)) + self.assertEqual(self._field_screens(descriptor, signature, arguments), + ["Text:\na\\x0ab\\x09c"]) + + def test_a_raw_value_too_long_to_capture_is_shown_blind(self): + self.requires_firmware_below("7.16.0") # 7.16 refuses instead + signature = "blob(bytes data)" + descriptor = {"display": {"formats": {signature: { + "intent": "Blob", "fields": [ + {"path": "data", "label": "Data", "format": "raw"}]}}}} + data = bytes(range(200)) + arguments = (self._word(32) + self._word(len(data)) + data + + bytes(-len(data) % 32)) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + self.assertEqual( + [s for s in self._first_pages() + if s[0] in ("Blind signature", "Signer field")], + [("Blind signature", "The value is too long to show"), + ("Signer field", "Data:\nNot shown: 200 bytes")]) + + def test_a_long_typed_data_value_points_to_the_walk_not_blind(self): + # Typed data: the walk shows every leaf in full, so a raw field too + # long to capture says where it was shown, with no blind warning. + data = bytes(range(200)) + doc = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}], + "Blob": [{"name": "data", "type": "bytes"}]}, + "primaryType": "Blob", + "domain": {"name": "Audit app", "chainId": 1, + "verifyingContract": "0x" + ADDRESS.hex()}, + "message": {"data": "0x" + data.hex()}} + program = erc7730_compiler.compile_eip712( + {"display": {"formats": {"Blob(bytes data)": { + "intent": "Blob", "fields": [ + {"path": "data", "label": "Data", "format": "raw"}]}}}}, + doc) + start = eth.EthereumSignTypedData(address_n=PATH, primary_type="Blob", + metamask_v4_compat=True) + baseline, _, _, _ = self._walk(start, doc=doc) + self.assertIsInstance(baseline, eth.EthereumTypedDataSignature) + envelope = self._preload(program) + result, _, _, _ = self._walk(start, envelope, doc) + self.assertIsInstance(result, eth.EthereumTypedDataSignature) + self.assertEqual(result.signature, baseline.signature) + self.assertNotIn("Blind signature", [s[0] for s in self.screens]) + self.assertIn(("Signer field", "Data:\nShown above in full: 200 bytes"), + self._first_pages()) + + def test_multiline_values_split_between_lines(self): + # A label of 64 non-ASCII bytes escapes to 256 characters, leaving 93 + # per confirmation: the unknown-token body splits into numbered + # confirmations between lines, so no confirmation ends mid-address. + label = "é" * 32 + signature = "send(address token,uint256 amount)" + descriptor = {"display": {"formats": {signature: { + "intent": "Send", "fields": [{ + "path": "amount", "label": label, "format": "tokenAmount", + "params": {"tokenPath": "token"}}]}}}} + arguments = self._word(OTHER_ADDRESS) + self._word(10 ** 60) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + parts = [body for title, body in self._first_pages() + if title.startswith("Signer field")] + self.assertGreater(len(parts), 1) + address = "0x" + OTHER_ADDRESS.hex() + self.assertEqual(sum(1 for body in parts if body.endswith(address)), 1) + for body in parts: + self.assertFalse(body.endswith(address[:10]), body) + + def _exec_inner_catalog(self, inner_program, chain=1, signer=None): + env = (self._envelope(inner_program, chain=chain) if signer is None + else self._envelope(inner_program, *signer, chain=chain)) + return erc7730.Definition(env, 1, chain, inner_program[18:38], + inner_program[38:42]) + + def _refused_inner_catalogs(self): + """Inner definitions the device refuses: a program it cannot run + (BAD_PROGRAM), one from an unloaded signer (UNTRUSTED), and an + untrusted one large enough to be refused on a later chunk.""" + refused = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [{ + "path": "to", "label": "Recipient", "format": "raw", + "visible": {"ifNotIn": ["0x" + ADDRESS.hex()]}}]}}}}, + self.TRANSFER, 1, self.USDC, executable_only=False) + from ecdsa import SigningKey, SECP256k1 + unknown = SigningKey.from_string( + UNKNOWN_SIGNER_KEY, curve=SECP256k1).get_verifying_key().to_string( + "compressed") + clear = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": []}}}}, + self.TRANSFER, 1, self.USDC) + # Large enough to stream in several chunks, so the refusal comes on a + # later chunk and the fetch position must be reset for the outer. + large = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [ + {"path": "to", "label": "Recipient %02d %s" % (i, "r" * 44), + "format": "raw"} for i in range(24)]}}}}, + self.TRANSFER, 1, self.USDC) + signed_large = self._exec_inner_catalog( + large, signer=(UNKNOWN_SIGNER_KEY, unknown)) + self.assertGreater(len(signed_large.envelope), 2 * erc7730.MAX_CHUNK) + return (self._exec_inner_catalog(refused), + self._exec_inner_catalog( + clear, signer=(UNKNOWN_SIGNER_KEY, unknown)), + signed_large) + + def test_an_inner_definition_the_device_refuses_falls_back_to_blind(self): + self.requires_firmware_below("7.16.0") # 7.16 refuses instead + start, arguments, outer_def, _ = self._exec_setup() + for inner in self._refused_inner_catalogs(): + self._exec_certified(arguments, + erc7730.Catalog((outer_def, inner))) + shown = self._relevant() + self.assertIn(("Blind signature", + "The inner call is not clear-signed"), shown) + self.assertNotIn("Inner action", [s[0] for s in shown]) + self.assertEqual(shown[-1], ("Signer field", "Operation:\n0")) + + def test_an_inner_definition_the_device_refuses_is_refused_from_716(self): + self.requires_firmware("7.16.0") + for inner in self._refused_inner_catalogs(): + _, arguments, outer_def, _ = self._exec_setup() + self._exec_refused(arguments, erc7730.Catalog((outer_def, inner))) + self.assertNotIn("Inner action", [s[0] for s in self.screens]) + + def test_inner_definition_for_another_callee_or_chain_is_refused(self): + start, arguments, outer_def, inner_def = self._exec_setup() + other_callee = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": []}}}}, + self.TRANSFER, 1, OTHER_ADDRESS) + other_chain = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": []}}}}, + self.TRANSFER, 56, self.USDC) + for wrong in (self._exec_inner_catalog(other_callee), + self._exec_inner_catalog(other_chain, chain=56)): + class Substituting(erc7730.Catalog): + def chunk(self, request): + if request.HasField("recursion_depth") and request.recursion_depth: + replaced = copy.deepcopy(request) + replaced.chain_id = wrong.chain_id + replaced.contract_address = wrong.contract_address + return erc7730.Catalog.chunk(self, replaced) + return erc7730.Catalog.chunk(self, request) + erc7730.preload(self.client, outer_def) + result, _, _, _ = self._walk( + start, arguments=arguments, + catalog=Substituting((outer_def, inner_def, wrong))) + assert_failure(self, result, types.Failure_SyntaxError, + "ERC-7730 inner definition does not match") + self.assertNotIn("Inner action", [s[0] for s in self.screens]) + + def test_inner_calls_read_their_own_containers(self): + # Inside the inner call @.value is the value it moves and @.from + # whose authority it runs with: the outer spenderPath, or without + # one the outer contract. + inner = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [ + {"path": "@.value", "label": "Moves", "format": "amount"}, + {"path": "@.from", "label": "As", "format": "addressName"}, + {"path": "@.to", "label": "Token", "format": "addressName"}, + ]}}}}, + self.TRANSFER, 1, self.USDC) + usdc = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + for spender, authority in ((None, "0x" + ADDRESS.hex()), + ("to", usdc)): + _, arguments, outer_def, _ = self._exec_setup(spender=spender) + # Give the outer call a value: the inner call moves 2 ETH. + moving = bytearray(arguments) + moving[32:64] = self._word(2 * 10 ** 18) + self._exec_certified(bytes(moving), erc7730.Catalog( + (outer_def, self._exec_inner_catalog(inner)))) + inner_fields = [s[1] for s in self._relevant() + if s[0] == "Inner field"] + self.assertEqual(inner_fields, [ + "Moves:\n2 ETH", "As:\n" + authority, "Token:\n" + usdc]) + + def test_embedded_calls_inside_an_iteration_are_shown_blind(self): + self.requires_firmware_below("7.16.0") # 7.16 refuses instead + signature = "multicall((address callee,bytes data)[] calls)" + descriptor = {"display": {"formats": {signature: { + "intent": "Multicall", "fields": [{ + "path": "calls.[].data", "label": "Call", "format": "calldata", + "params": {"calleePath": "calls.[].callee"}}]}}}} + call = bytes.fromhex("a9059cbb") + self._word(OTHER_ADDRESS) + self._word(1) + padded = call + bytes(-len(call) % 32) + element = (self._word(OTHER_ADDRESS) + self._word(64) + + self._word(len(call)) + padded) + arguments = (self._word(32) + self._word(2) + self._word(64) + + self._word(64 + len(element)) + element + element) + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + self._certified(program, arguments) + shown = [s for s in self._first_pages() + if s[0] == "Blind signature" or s[0].startswith("Signer field")] + body = ("Call:\nTo 0x" + OTHER_ADDRESS.hex() + + "\nFunction 0xa9059cbb\nData 68 bytes") + self.assertEqual(shown, [ + ("Blind signature", "The inner call is not clear-signed"), + ("Signer field 1 of 2", body), + ("Blind signature", "The inner call is not clear-signed"), + ("Signer field 2 of 2", body), + ]) + + def test_a_fixed_index_into_a_short_array_fails_closed(self): + # Data-dependent: preload cannot know the array's length. The device + # refuses without a signature when it reaches the field. + signature = "swap(address[] path,uint256 amount)" + descriptor = {"display": {"formats": {signature: { + "intent": "Swap", "fields": [{ + "path": "amount", "label": "Amount", "format": "tokenAmount", + "params": {"tokenPath": "path.[0]"}}]}}}} + program = erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + arguments = self._word(64) + self._word(5) + self._word(0) + envelope = self._preload(program) + result, _, _, _ = self._walk( + self._audit_start(program, 4 + len(arguments)), envelope, + arguments=arguments) + self.assertIsInstance(result, proto.Failure) + self.assertEqual(result.message, + "ERC-7730 calldata does not match definition") + self.assertNotIn(types.ButtonRequest_SignTx, self.button_codes) + + def test_an_inner_value_the_calldata_does_not_carry_is_never_shown(self): + # Without amountPath the calldata does not say what the inner call + # moves: an inner definition that shows @.value is shown blind, while + # one that does not is still clear-signed. (7.16 refuses the former.) + # Read the version first: it re-initialises the device, which would + # drop the signer the setup below loads. + refuses = self.firmware_version() >= semver.VersionInfo.parse("7.16.0") + _, arguments, outer_def, inner_def = self._exec_setup(amount_path=False) + self._exec_certified(arguments, erc7730.Catalog((outer_def, inner_def))) + self.assertIn("Inner field", [s[0] for s in self._relevant()]) + _, arguments, outer_def, _ = self._exec_setup(amount_path=False) + shows_value = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [ + {"path": "@.value", "label": "Moves", "format": "amount"}]}}}}, + self.TRANSFER, 1, self.USDC) + catalog = erc7730.Catalog( + (outer_def, self._exec_inner_catalog(shows_value))) + if refuses: + self._exec_refused(arguments, catalog) + return + self._exec_certified(arguments, catalog) + shown = self._relevant() + self.assertIn(("Blind signature", + "The inner call is not clear-signed"), shown) + self.assertNotIn("Inner field", [s[0] for s in shown]) + + def test_parallel_arrays_are_refused_before_preload(self): + # A token formatter cannot pair amounts[i] with an independent + # tokens[i] array: the auxiliary token path must read the active array. + signature = "batch(address[] tokens,uint256[] amounts)" + descriptor = {"display": {"formats": {signature: { + "intent": "Batch", "fields": [{ + "path": "amounts.[]", "label": "Amount", "format": "tokenAmount", + "params": {"tokenPath": "tokens.[]"}}]}}}} + + with self.assertRaisesRegex( + erc7730_compiler.DeviceCannotExecute, + "a field reads another array than its iteration"): + erc7730_compiler.compile_calldata( + descriptor, signature, 1, ADDRESS) + + def test_a_call_at_depth_two_is_shown_blind(self): + self.requires_firmware_below("7.16.0") # 7.16 refuses instead + # A Safe executing a call on a second Safe: the second Safe's + # definition is clear-signed one level deep, and the transfer it + # carries is shown blind. The device never fetches a depth-2 + # definition. + from keepkeylib.signed_metadata import keccak256 + _, _, outer_def, _ = self._exec_setup() + second_safe = bytes.fromhex("55" * 20) + middle = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.EXEC: { + "intent": "sign multisig operation", "fields": [ + {"path": "data", "label": "Transaction", "format": "calldata", + "params": {"calleePath": "to", "amountPath": "value", + "spenderPath": "@.to"}}, + {"path": "operation", "label": "Operation", + "format": "raw"}]}}}}, + self.EXEC, 1, second_safe) + + def execute(to, data): + return (self._word(to) + self._word(0) + self._word(128) + + self._word(0) + self._word(len(data)) + data + + bytes(-len(data) % 32)) + selector = keccak256( + b"execTransaction(address,uint256,bytes,uint8)")[:4] + transfer = (bytes.fromhex("a9059cbb") + self._word(OTHER_ADDRESS) + + self._word(1500000)) + arguments = execute(second_safe, + selector + execute(self.USDC, transfer)) + depths = [] + + class Recording(erc7730.Catalog): + def chunk(self, request): + depths.append(request.recursion_depth) + return erc7730.Catalog.chunk(self, request) + self._exec_certified(arguments, Recording( + (outer_def, self._exec_inner_catalog(middle)))) + self.assertEqual(max(depths), 1) + shown = [s for s in self._relevant() + if s[0] not in ("Runtime signer", "Inner signer")] + self.assertEqual([s[0] for s in shown], [ + "Contract action", "Signer field", "Inner action", + "Blind signature", "Inner field", "Inner field", "Signer field"]) + self.assertEqual(shown[3][1], "The inner call is not clear-signed") + self.assertTrue(shown[4][1].startswith( + "Transaction:\nTo 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + "\nFunction 0xa9059cbb\nData 68 bytes"), shown[4][1]) + self.assertEqual(shown[5][1], "Operation:\n0") + self.assertEqual(shown[6][1], "Operation:\n0") + + + def _refused(self, program, arguments, message, preload=None, + catalog=None): + """7.16+: what 7.15 showed under a blind-sign warning is refused, + with no warning screen and no signature, while the same transaction + still signs on the ordinary path.""" + start = self._audit_start(program, 4 + len(arguments)) + # The same transaction still signs on the ordinary path: the refusal + # is the definition's, not the transaction's. + baseline, _, _, _ = self._walk(start, arguments=arguments) + self.assertTrue(baseline.HasField("signature_r")) + envelope = self._preload(program) if preload is None else preload() + result, _, _, _ = self._walk(start, envelope, arguments=arguments, + catalog=catalog) + assert_failure(self, result, types.Failure_Other, message) + self.assertNotIn("Blind signature", [s[0] for s in self.screens]) + self.assertNotIn(types.ButtonRequest_SignTx, self.button_codes) + + def _exec_refused(self, arguments, catalog): + outer, outer_def = self._exec_outer + + def preload(): + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + return b"" + self._refused(outer, arguments, "Inner call cannot be clear-signed", + preload=preload, catalog=catalog) + + def test_inner_call_without_a_definition_is_refused_from_716(self): + self.requires_firmware("7.16.0") + _, arguments, outer_def, _ = self._exec_setup() + self._exec_refused(arguments, erc7730.Catalog((outer_def,))) + + def test_a_raw_value_too_long_to_capture_is_refused_from_716(self): + self.requires_firmware("7.16.0") + signature = "blob(bytes data)" + program = erc7730_compiler.compile_calldata( + {"display": {"formats": {signature: {"intent": "Blob", "fields": [ + {"path": "data", "label": "Data", "format": "raw"}]}}}}, + signature, 1, ADDRESS) + data = bytes(range(200)) + self._refused(program, self._word(32) + self._word(len(data)) + data + + bytes(-len(data) % 32), "Value too long to clear-sign") + + def _declined(self, program, arguments, title, preload=None, + catalog=None): + envelope = self._preload(program) if preload is None else preload() + result, _, _, _ = self._walk( + self._audit_start(program, 4 + len(arguments)), envelope, + arguments=arguments, catalog=catalog, cancel_title=title) + assert_failure(self, result, types.Failure_ActionCancelled, + "Signing cancelled by user") + self.assertEqual(self.screens[-1][0], title) + self.assertNotIn(types.ButtonRequest_SignTx, self.button_codes) + + def test_declining_any_certified_screen_returns_no_signature(self): + # Every certified screen is a consent: the blind-sign warnings, a + # later part of a split value and an inner field each abort when + # declined, with no signature. + signature = "blob(bytes data)" + blob = erc7730_compiler.compile_calldata( + {"display": {"formats": {signature: {"intent": "Blob", "fields": [ + {"path": "data", "label": "Data", "format": "raw"}]}}}}, + signature, 1, ADDRESS) + data = bytes(range(200)) + blind = self.firmware_version() < semver.VersionInfo.parse("7.16.0") + if blind: # 7.16 refuses these instead: no warning to decline + self._declined(blob, self._word(32) + self._word(len(data)) + + data + bytes(-len(data) % 32), "Blind signature") + + signature = "send(address token,uint256 amount)" + split = erc7730_compiler.compile_calldata( + {"display": {"formats": {signature: {"intent": "Send", "fields": [{ + "path": "amount", "label": "\u00e9" * 32, + "format": "tokenAmount", + "params": {"tokenPath": "token"}}]}}}}, + signature, 1, ADDRESS) + self._declined(split, self._word(OTHER_ADDRESS) + self._word(10 ** 60), + "Signer field (2/2)") + + _, arguments, outer_def, inner_def = self._exec_setup() + outer = self._exec_outer[0] + + def preload(): + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + return b"" + # No inner definition: the E1 warning. + if blind: + self._declined(outer, arguments, "Blind signature", + preload=preload, + catalog=erc7730.Catalog((outer_def,))) + # A refused inner definition: the warning on the blind re-run. + refused = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [{ + "path": "to", "label": "Recipient", "format": "raw", + "visible": {"ifNotIn": ["0x" + ADDRESS.hex()]}}]}}}}, + self.TRANSFER, 1, self.USDC, executable_only=False) + if blind: + self._declined(outer, arguments, "Blind signature", + preload=preload, + catalog=erc7730.Catalog( + (outer_def, self._exec_inner_catalog(refused)))) + # A clear-signed inner call: its field. + self._declined(outer, arguments, "Inner field", preload=preload, + catalog=erc7730.Catalog((outer_def, inner_def))) + + + def test_a_refused_inner_call_does_not_blind_the_next_one(self): + self.requires_firmware_below("7.16.0") # 7.16 refuses instead + # Two embedded calls: the first inner definition is refused and shown + # blind; the second is still clear-signed with its own definition. + signature = "execTwo(address a,bytes da,address b,bytes db)" + other_token = bytes.fromhex("55" * 20) + outer = erc7730_compiler.compile_calldata( + {"display": {"formats": {signature: { + "intent": "Run two calls", "fields": [ + {"path": "da", "label": "First", "format": "calldata", + "params": {"calleePath": "a"}}, + {"path": "db", "label": "Second", "format": "calldata", + "params": {"calleePath": "b"}}]}}}}, + signature, 1, ADDRESS) + refused = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [{ + "path": "to", "label": "Recipient", "format": "raw", + "visible": {"ifNotIn": ["0x" + ADDRESS.hex()]}}]}}}}, + self.TRANSFER, 1, self.USDC, executable_only=False) + second = erc7730_compiler.compile_calldata( + {"display": {"formats": {self.TRANSFER: { + "intent": "Transfer", "fields": [ + {"path": "to", "label": "Recipient", + "format": "addressName"}]}}}}, + self.TRANSFER, 1, other_token) + call = (bytes.fromhex("a9059cbb") + self._word(OTHER_ADDRESS) + + self._word(1500000)) + padded = self._word(len(call)) + call + bytes(-len(call) % 32) + arguments = (self._word(self.USDC) + self._word(128) + + self._word(other_token) + self._word(128 + len(padded)) + + padded + padded) + self._load_signer() + outer_def = self._definition(outer, self._envelope(outer)) + + def preload(): + erc7730.preload(self.client, outer_def) + self._drop_setup_screenshots() + return b"" + self._certified(outer, arguments, preload=preload, + catalog=erc7730.Catalog(( + outer_def, self._exec_inner_catalog(refused), + self._exec_inner_catalog(second)))) + titles = [s[0] for s in self._relevant() + if s[0] not in ("Runtime signer", "Inner signer")] + self.assertEqual(titles, [ + "Contract action", "Blind signature", "Signer field", + "Signer field", "Inner action", "Inner field"]) + self.assertEqual(self._relevant()[-1], + ("Inner field", "Recipient:\n0x" + OTHER_ADDRESS.hex())) + + def test_groups_and_optional_fields_are_always_visible(self): + signature = "pay(address recipient,uint256 amount,uint256 fee)" + descriptor = {"display": {"formats": {signature: { + "intent": "Pay", "fields": [ + {"label": "Payment", "visible": "optional", "fields": [ + {"path": "recipient", "label": "Recipient", + "format": "addressName"}, + {"label": "Details", "fields": [ + {"path": "amount", "label": "Amount", "format": "raw", + "visible": "optional"}]}]}, + {"path": "fee", "label": "Fee", "format": "raw", + "visible": "optional"}]}}}} + for amount, fee in ((0, 0), (5, 9)): + self.assertEqual( + self._field_screens(descriptor, signature, + self._word(OTHER_ADDRESS) + self._word(amount) + self._word(fee)), + ["Recipient:\n0x" + OTHER_ADDRESS.hex(), + "Amount:\n" + str(amount), "Fee:\n" + str(fee)]) diff --git a/tests/test_msg_ethereum_getaddress.py b/tests/test_msg_ethereum_getaddress.py index 501ad8d6..39f1f605 100644 --- a/tests/test_msg_ethereum_getaddress.py +++ b/tests/test_msg_ethereum_getaddress.py @@ -38,9 +38,11 @@ def test_ethereum_show_address(self): self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() # The legacy display response is empty after the ButtonAck; address - # correctness is covered above. This case exists to retain the actual - # operation-specific OLED sequence for visual review. - self.client.ethereum_get_address([], show_display=True) + # correctness is covered above. The screen must show the whole + # EIP-55 form of the first vector above. + self.assert_shows_address( + lambda: self.client.ethereum_get_address([], show_display=True), + '0x1D1C328764A41BdA0492b66bAA30C4A339FF85EF') if __name__ == '__main__': unittest.main() diff --git a/tests/test_msg_ethereum_metadata_during_signing.py b/tests/test_msg_ethereum_metadata_during_signing.py new file mode 100644 index 00000000..0edee819 --- /dev/null +++ b/tests/test_msg_ethereum_metadata_during_signing.py @@ -0,0 +1,100 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +"""EthereumTxMetadata must arrive before signing starts. + +Metadata processing clears the transaction<->metadata binding, so metadata +sent while a signing stream waits for more data is refused, and the refusal +ends that stream. +""" + +import unittest + +import common +from keepkeylib import messages_pb2 as proto +from keepkeylib import messages_ethereum_pb2 as eth_proto +from keepkeylib import types_pb2 as types +from keepkeylib.tools import int_to_big_endian + + +class TestEthereumMetadataDuringSigning(common.KeepKeyTest): + def test_metadata_during_signing_is_refused_and_ends_the_stream(self): + self.requires_firmware("7.15.0") + self.requires_message("EthereumTxMetadata") + self.requires_fullFeature() + self.requires_release_capability("evm-tx-metadata") + self.setup_mnemonic_allallall() + # With AdvancedMode on, metadata is otherwise processable, so only the + # in-progress signing session can explain a refusal. + self.client.apply_policy("AdvancedMode", 1) + + data = b"\xa9\x05\x9c\xbb" + b"\x00" * 1996 # longer than one chunk + resp = self.client.call(eth_proto.EthereumSignTx( + address_n=self.client.expand_path("m/44'/60'/0'/0/0"), + nonce=int_to_big_endian(0), + gas_price=int_to_big_endian(20), + gas_limit=int_to_big_endian(100000), + value=int_to_big_endian(0), + to=bytes.fromhex("1d1c328764a41bda0492b66baa30c4a339ff85ef"), + chain_id=1, + data_length=len(data), + data_initial_chunk=data[:1024])) + self.assertIsInstance(resp, eth_proto.EthereumTxRequest) + self.assertTrue(resp.HasField("data_length")) + + ret = self.client.call_raw( + eth_proto.EthereumTxMetadata(signed_payload=b"\x00" * 64)) + self.assertIsInstance(ret, proto.Failure) + self.assertEqual(ret.code, types.Failure_UnexpectedMessage) + self.assertEqual(ret.message, "Metadata not allowed during signing") + + ret = self.client.call_raw( + eth_proto.EthereumTxAck(data_chunk=data[1024:])) + self.assertIsInstance(ret, proto.Failure) + self.assertEqual(ret.code, types.Failure_UnexpectedMessage) + + def test_signer_load_during_signing_is_refused_and_ends_the_stream(self): + self.requires_firmware("7.15.0") + self.requires_message("LoadClearsignSigner") + self.requires_fullFeature() + self.requires_release_capability("evm-tx-metadata") + self.setup_mnemonic_allallall() + self.client.apply_policy("AdvancedMode", 1) + + data = b"\xa9\x05\x9c\xbb" + b"\x00" * 1996 + resp = self.client.call(self._streamed_sign_tx(data)) + self.assertIsInstance(resp, eth_proto.EthereumTxRequest) + self.assertTrue(resp.HasField("data_length")) + + # Storing a signer clears the same binding metadata does. + ret = self.client.call_raw(eth_proto.LoadClearsignSigner( + key_id=0, pubkey=b"\x02" + b"\x11" * 32, alias="test")) + self.assertIsInstance(ret, proto.Failure) + self.assertEqual(ret.code, types.Failure_UnexpectedMessage) + self.assertEqual(ret.message, "Signer load not allowed during signing") + + ret = self.client.call_raw( + eth_proto.EthereumTxAck(data_chunk=data[1024:])) + self.assertIsInstance(ret, proto.Failure) + self.assertEqual(ret.code, types.Failure_UnexpectedMessage) + + def _streamed_sign_tx(self, data): + return eth_proto.EthereumSignTx( + address_n=self.client.expand_path("m/44'/60'/0'/0/0"), + nonce=int_to_big_endian(0), + gas_price=int_to_big_endian(20), + gas_limit=int_to_big_endian(100000), + value=int_to_big_endian(0), + to=bytes.fromhex("1d1c328764a41bda0492b66baa30c4a339ff85ef"), + chain_id=1, + data_length=len(data), + data_initial_chunk=data[:1024]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_ethereum_signing_guards.py b/tests/test_msg_ethereum_signing_guards.py new file mode 100644 index 00000000..b5eedf26 --- /dev/null +++ b/tests/test_msg_ethereum_signing_guards.py @@ -0,0 +1,308 @@ +# This file is part of the KeepKey project. +# +# Regression tests for Ethereum signing pre-image / clear-sign correctness: +# - EIP-1559 transaction-type vs fee-field / chain_id consistency, and +# - contract clear-sign handlers must not confirm a prefix while later +# streamed calldata is signed unshown, nor classify a contract CREATE. +# +# These exercise the guards added in the firmware ethereum signing path. + +import time +import unittest +import common +import binascii + +import keepkeylib.messages_ethereum_pb2 as eth_proto +from keepkeylib.client import CallException +from keepkeylib.tools import int_to_big_endian +from keepkeylib.signed_metadata import ( + eth_sighash_eip1559, eth_sighash_legacy, keccak256, +) + +# Sablier proxy address — the withdrawFromSalary clear-sign handler target. +SABLIER_PROXY = binascii.unhexlify("bd6a40bb904aea5a49c59050b5395f7484a4203d") +RECIPIENT = binascii.unhexlify("1d1c328764a41bda0492b66baa30c4a339ff85ef") + + +def recover_eth_signer(sig_r, sig_s, recovery_id, digest): + """Recover the 20-byte signer from (r, s, recovery_id) over `digest`. + + Same approach as test_msg_thorchain_signtx.py. Recovering the signer -- + rather than asserting r/s are 32 bytes -- is what makes these tests able to + fail: the regressions they describe (a desynced RLP list header, a prefix + hashed instead of the full calldata) still produce a perfectly well-formed + 32-byte r/s, so a length assertion passes while the device signs a + pre-image that is not the transaction under test. + """ + from ecdsa import VerifyingKey, SECP256k1, util + keys = VerifyingKey.from_public_key_recovery_with_digest( + sig_r + sig_s, digest, SECP256k1, hashfunc=None, + sigdecode=util.sigdecode_string, + ) + return keccak256(keys[recovery_id].to_string())[-20:] + + +class _ScreenRecorder(object): + """Record the framebuffer of every confirm screen an operation draws. + + Mirrors ScreenRecorder in test_msg_ethereum_clearsign_additive.py. + """ + + SETTLE = 0.3 + + def __init__(self, client): + self.client = client + self.frames = [] + + def __enter__(self): + original = self.client.callback_ButtonRequest + + def record(msg): + time.sleep(self.SETTLE) + self.frames.append((msg.code, bytes(self.client.debug.read_layout()))) + return original(msg) + + self.client.callback_ButtonRequest = record + return self + + def __exit__(self, *exc): + del self.client.callback_ButtonRequest + return False + + @property + def layouts(self): + return [layout for _, layout in self.frames] + + +class TestMsgEthereumSigningGuards(common.KeepKeyTest): + # ---- EIP-1559 type / fee / chain_id pre-image consistency ---- + + def test_eip1559_requires_chain_id(self): + """type=2 with no chain_id: Stage 1 counts chain_id as 1 byte but + hash_rlp_number(0) hashes nothing -> over-declared list header -> + wrong/garbage signer. The device must reject rather than sign it.""" + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + self.assertRaises( + CallException, + self.client.ethereum_sign_tx, + n=[0, 0], + nonce=0, + gas_limit=21000, + max_fee_per_gas=20, + max_priority_fee_per_gas=1, + to=RECIPIENT, + value=10, + # chain_id intentionally omitted -> chain_id == 0 + ) + + def test_eip1559_no_priority_fee_signs(self): + """max_priority_fee_per_gas is a mandatory EIP-1559 RLP field; when + absent it must encode as the empty integer (0x80). Stage 1 always + counts it, so Stage 2 must always hash it -- the device must still + produce a valid signature (not desync the list header).""" + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + address_n = [0, 0] + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=address_n, + nonce=0, + gas_limit=21000, + max_fee_per_gas=20, # no max_priority_fee_per_gas + to=RECIPIENT, + value=10, + chain_id=1, + ) + self.assertIn(sig_v, (0, 1)) # EIP-1559 recovery-id parity + self.assertEqual(len(sig_r), 32) + self.assertEqual(len(sig_s), 32) + # The regression this test names -- Stage 1 counting the priority-fee + # field while Stage 2 skips hashing it -- desyncs the RLP list header + # and yields a signature over a DIFFERENT pre-image. That signature is + # still 32+32 bytes, so only reconstructing the intended digest and + # recovering the signer can detect it. The absent field must encode as + # the empty integer, i.e. exactly max_priority_fee_per_gas = 0. + digest = eth_sighash_eip1559( + chain_id=1, nonce=0, max_priority_fee_per_gas=0, + max_fee_per_gas=20, gas_limit=21000, to=RECIPIENT, + value=10, data=b'', + ) + signer = recover_eth_signer(sig_r, sig_s, sig_v, digest) + # NB: KeepKeyTest's assertEqual override takes no msg argument. + self.assertEqual(signer, self.client.ethereum_get_address(address_n)) + + def test_type2_without_max_fee_rejected(self): + """Typed prefix (0x02) is chosen from msg.type but the fee fields from + has_max_fee_per_gas. A type=2 tx carrying only gas_price would sign a + malformed (legacy-fee-in-1559-envelope) field list -> reject.""" + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + msg = eth_proto.EthereumSignTx( + address_n=[0, 0], + nonce=int_to_big_endian(0), + gas_price=int_to_big_endian(20), # legacy fee field ... + gas_limit=int_to_big_endian(21000), + value=int_to_big_endian(10), + chain_id=1, + type=2, # ... but typed as EIP-1559 + ) + msg.to = RECIPIENT + self.assertRaises(CallException, self.client.call, msg) + + def test_legacy_with_max_fee_rejected(self): + """A legacy tx (type omitted) carrying max_fee_per_gas would hash two + fee fields into a legacy structure -> reject the mismatch.""" + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + msg = eth_proto.EthereumSignTx( + address_n=[0, 0], + nonce=int_to_big_endian(0), + max_fee_per_gas=int_to_big_endian(20), + max_priority_fee_per_gas=int_to_big_endian(1), + gas_limit=int_to_big_endian(21000), + value=int_to_big_endian(10), + chain_id=1, + # type omitted -> legacy + ) + msg.to = RECIPIENT + self.assertRaises(CallException, self.client.call, msg) + + # ---- Contract clear-sign handler gate ---- + + # withdrawFromSalary selector + 2 words, then padded past 1024 bytes so + # data_total != data_initial_chunk.size (forces the streaming path). + STREAMED_TAIL = ( + binascii.unhexlify( + "0000000000000000000000000000000000000000000000000000000000001210" + "0000000000000000000000000000000000000000000000000000000000000001" + ) + b"\x00" * 1100 + ) + HANDLER_SELECTOR = binascii.unhexlify("fea7c53f") # withdrawFromSalary + # A selector the device has no clear-sign handler for. Same length, same + # streaming path, same `to` -- so the only thing that can change the screen + # sequence is whether the handler fired. + NO_HANDLER_SELECTOR = binascii.unhexlify("deadbeef") + + STREAM_TX = dict( + n=[2147483692, 2147483708, 2147483648, 0, 0], + nonce=0xAB, + gas_price=0x24C988AC00, + gas_limit=0x26249, + value=0, + to=SABLIER_PROXY, + address_type=0, + chain_id=1, + ) + + def _sign_streamed(self, selector): + """Sign the streaming-path tx with `selector`, recording its screens.""" + data = selector + self.STREAMED_TAIL + return self._sign_streamed_data(data) + + def _sign_streamed_data(self, data): + """Sign exact streamed calldata and retain every approval framebuffer.""" + with _ScreenRecorder(self.client) as rec: + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + data=data, **self.STREAM_TX) + return rec, data, (sig_v, sig_r, sig_s) + + def _assert_signed_full_calldata(self, data, sig): + """Recover the signer against a digest over the COMPLETE calldata.""" + sig_v, sig_r, sig_s = sig + self.assertEqual(len(sig_r), 32) + self.assertEqual(len(sig_s), 32) + self.assertIn(sig_v, [37, 38]) # EIP-155, chain_id = 1 + digest = eth_sighash_legacy( + self.STREAM_TX['nonce'], self.STREAM_TX['gas_price'], + self.STREAM_TX['gas_limit'], SABLIER_PROXY, + self.STREAM_TX['value'], data, 1, + ) + signer = recover_eth_signer(sig_r, sig_s, sig_v - 37, digest) + # NB: KeepKeyTest's assertEqual override takes no msg argument. + self.assertEqual( + signer, self.client.ethereum_get_address(self.STREAM_TX['n'])) + + def test_contract_handler_streamed_calldata_signs_full_data(self): + """A handler selector whose calldata is larger than the initial chunk + must sign the FULL streamed calldata, not the confirmed prefix. + + Recovering the signer against a digest built over the complete `data` + is what makes this test able to fail: if the device hashed only the + first chunk it would still return a well-formed 32-byte r/s, and the + length assertions this test used to make would pass. + """ + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + + _, data, sig = self._sign_streamed(self.HANDLER_SELECTOR) + self._assert_signed_full_calldata(data, sig) + + def test_streamed_handler_calldata_is_not_clear_signed(self): + """The Sablier summary must NOT be drawn for streamed calldata. + + The handler may only clear-sign what it actually verified, and it + cannot verify calldata it has not seen. So the streamed run must fall + back to the ordinary raw-data review -- the same screens a selector + with no handler at all draws. + + Compared against a no-handler baseline of identical shape (same `to`, + same calldata length, same streaming path) rather than a hardcoded + screen count, because the raw-data screen paginates with the calldata. + A clear-signed run would add summary frames the baseline does not have. + """ + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + + baseline, base_data, base_sig = self._sign_streamed( + self.NO_HANDLER_SELECTOR) + self._assert_signed_full_calldata(base_data, base_sig) + + observed, data, sig = self._sign_streamed(self.HANDLER_SELECTOR) + self._assert_signed_full_calldata(data, sig) + + # Any clear-sign summary would be one or more EXTRA confirm screens. + self.assertEqual(len(observed.frames), len(baseline.frames)) + + def test_streamed_calldata_tail_changes_user_commitment(self): + """Bytes arriving after the initial chunk must change what is shown. + + Both transactions have identical first 1024-byte protobuf chunks and + identical lengths; only the final byte in EthereumTxAck differs. The + pre-fix firmware displayed the same prefix/count screens for both and + then hashed the distinct tails invisibly. The complete-calldata + Keccak-256 confirmation makes the approval sequences distinguishable. + """ + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + self.client.apply_policy("AdvancedMode", 1) + + base_data = self.NO_HANDLER_SELECTOR + self.STREAMED_TAIL + changed_data = base_data[:-1] + binascii.unhexlify( + "%02x" % (bytearray(base_data)[-1] ^ 0x01)) + self.assertEqual(base_data[:1024], changed_data[:1024]) + + baseline, _, baseline_sig = self._sign_streamed_data(base_data) + self._assert_signed_full_calldata(base_data, baseline_sig) + changed, _, changed_sig = self._sign_streamed_data(changed_data) + self._assert_signed_full_calldata(changed_data, changed_sig) + + self.assertEqual(len(baseline.frames), len(changed.frames)) + self.assertNotEqual( + baseline.layouts, changed.layouts, + "a streamed tail changed the signature but not the user's screens") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_msg_ethereum_signtx.py b/tests/test_msg_ethereum_signtx.py index b366769d..b4fdf70f 100644 --- a/tests/test_msg_ethereum_signtx.py +++ b/tests/test_msg_ethereum_signtx.py @@ -23,6 +23,7 @@ import binascii import hashlib +import oled_text import keepkeylib.messages_pb2 as proto import keepkeylib.messages_ethereum_pb2 as eth_proto import keepkeylib.types_pb2 as proto_types @@ -36,27 +37,45 @@ def test_ethereum_native_pseudo_address_is_unknown_off_mainnet(self): """0xeeee..eeee must render as unknown for chain-257 token calls.""" self.requires_firmware("7.14.2") self.requires_fullFeature() + self.requires_release_capability("evm-unknown-token-review") self.setup_mnemonic_nopin_nopassphrase() self.client.apply_policy("AdvancedMode", 1) common.reset_screenshot_capture(self.client) recipient = self.client.ethereum_get_address([0, 0]) pseudo_address = b"\xee" * 20 + if self.firmware_at_least("7.15.0"): + expected_frames = { + "transfer": ( + "48eab4f4d0e125199325f4b5a601583250462f06b98514b4b56b3b3298169211"), + "approve": ( + "76dc40a70e5b6a95de28d57b71b40e4ca3da2c115e03c66fe03d3c0afda68c15"), + } + else: + # 7.14.3 uses the pre-7.15 review layout while proving the same + # off-mainnet unknown-token property. + expected_frames = { + "transfer": ( + "85d9054ee56836c1784c90dd777fc89444bf82b840d0818a59c73aa5b57ee35d"), + "approve": ( + "ab30156ff400957ffa9146ea827318bf878614e6ab4ae7dd731824e285fa5da6"), + } calls = ( ( "transfer", binascii.unhexlify("a9059cbb" + "00" * 12) + recipient + int_to_big_endian(1).rjust(32, b"\x00"), - "85d9054ee56836c1784c90dd777fc89444bf82b840d0818a59c73aa5b57ee35d", + expected_frames["transfer"], ), ( "approve", binascii.unhexlify("095ea7b3" + "00" * 12) + recipient + int_to_big_endian(1).rjust(32, b"\x00"), - "ab30156ff400957ffa9146ea827318bf878614e6ab4ae7dd731824e285fa5da6", + expected_frames.get("approve"), ), ) + observed_frames = {} try: for label, data, expected_frame_sha256 in calls: with ScreenRecorder( @@ -67,10 +86,14 @@ def test_ethereum_native_pseudo_address_is_unknown_off_mainnet(self): to=pseudo_address, value=0, chain_id=257, data=data, ) self.assertGreaterEqual(len(recorder.screens), 2) - self.assertEqual( - hashlib.sha256(recorder.screens[0]).hexdigest(), - expected_frame_sha256, - ) + observed_frames[label] = hashlib.sha256( + recorder.screens[0]).hexdigest() + if expected_frame_sha256 is not None: + self.assertEqual( + observed_frames[label], expected_frame_sha256) + if self.firmware_at_least("7.15.0"): + self.assertNotEqual( + observed_frames["transfer"], observed_frames["approve"]) finally: self.client.apply_policy("AdvancedMode", 0) common.reset_screenshot_capture(self.client) @@ -108,9 +131,10 @@ def test_ethereum_erc20_high_chain_id_does_not_alias_mainnet(self): self.assertNotEqual(first_screens[1], first_screens[257]) def test_ethereum_unrenderable_amounts_are_rejected(self): - """Neither a native nor ERC-20 amount may reach an approval blank.""" + """Maximum native and token amounts must never reach a blank review.""" self.requires_firmware("7.14.2") self.requires_fullFeature() + self.requires_release_capability('evm-max-amount-review') self.setup_mnemonic_nopin_nopassphrase() recipient = binascii.unhexlify( @@ -118,37 +142,59 @@ def test_ethereum_unrenderable_amounts_are_rejected(self): ) max_uint256 = (1 << 256) - 1 - with self.client: - self.client.set_expected_responses([ - proto.Failure( - code=proto_types.Failure_SyntaxError, - message="Ethereum amount too large"), - ]) - with self.assertRaises(CallException): - self.client.ethereum_sign_tx( - n=[0, 0], nonce=0, gas_price=20, gas_limit=21000, - to=recipient, value=max_uint256, chain_id=1, - ) - - # Known mainnet ERC-20 transfer with the same unrenderable amount. + if self.firmware_at_least("7.15.0"): + def assert_amount_is_safe(**tx): + # The 7.15 formatter has enough capacity for every uint256 + # value. Prove disclosure, then cancel cleanly so this path + # cannot poison the next test's session. + with self.client: + self.client.set_expected_responses([ + proto.ButtonRequest( + code=proto_types.ButtonRequest_ConfirmOutput), + proto.Failure( + code=proto_types.Failure_ActionCancelled, + message="Signing cancelled by user"), + ]) + with ScreenRecorder(self.client, answer=False) as recorder: + with self.assertRaises(CallException): + self.client.ethereum_sign_tx(**tx) + self.assertEqual(len(recorder.screens), 1) + # The full value is drawn, not blanked: its leading digits fill + # the first body line exactly as the firmware's font renders. + self.assertIsNotNone(oled_text.find_line( + recorder.screens[0], str(max_uint256)[:39])) + else: + def assert_amount_is_safe(**tx): + # 7.14.x has the smaller formatter and its safe behavior is + # refusal before presenting an approval. Keep this canonical + # branch compatible with that released behavior rather than + # demanding the 7.15 rendering policy from older firmware. + with self.client: + self.client.set_expected_responses([ + proto.Failure( + code=proto_types.Failure_SyntaxError, + message="Ethereum amount too large"), + ]) + with self.assertRaises(CallException): + self.client.ethereum_sign_tx(**tx) + + assert_amount_is_safe( + n=[0, 0], nonce=0, gas_price=20, gas_limit=21000, + to=recipient, value=max_uint256, chain_id=1, + ) + + # Known mainnet ERC-20 transfer with the same maximum amount. erc20_data = ( binascii.unhexlify("a9059cbb" + "00" * 12) + recipient + int_to_big_endian(max_uint256).rjust(32, b"\x00") ) - with self.client: - self.client.set_expected_responses([ - proto.Failure( - code=proto_types.Failure_SyntaxError, - message="Ethereum amount too large"), - ]) - with self.assertRaises(CallException): - self.client.ethereum_sign_tx( - n=[0, 0], nonce=0, gas_price=20, gas_limit=60000, - to=binascii.unhexlify( - "d0d6d6c5fe4a677d343cc433536bb717bae167dd" - ), - value=0, chain_id=1, data=erc20_data, - ) + assert_amount_is_safe( + n=[0, 0], nonce=0, gas_price=20, gas_limit=60000, + to=binascii.unhexlify( + "d0d6d6c5fe4a677d343cc433536bb717bae167dd" + ), + value=0, chain_id=1, data=erc20_data, + ) def test_ethereum_signtx_data(self): self.requires_fullFeature() @@ -221,6 +267,10 @@ def test_ethereum_signtx_data(self): def test_ethereum_blind_sign_blocked(self): """AdvancedMode OFF + contract data = device refuses to sign (7.14.2+). + OLED shows the blind-sign refusal, then Failure. The wire message is + 7.14.2's "Arbitrary contract data signing disabled by policy", which + replaced alpha's shorter "Blind signing disabled" -- it names WHICH + policy refused and what it refused. The device identifies the transaction destination, displays one non-approving Blocked notice, then returns the policy failure. The exact two-ButtonRequest -> Failure sequence proves the host cannot @@ -267,7 +317,7 @@ def test_ethereum_blind_sign_allowed(self): OLED shows 'BLIND SIGNATURE' before signing. """ - self.requires_firmware("7.14.0") + self.requires_firmware("7.15.0") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() self.client.apply_policy("AdvancedMode", 1) @@ -406,7 +456,7 @@ def test_ethereum_sanity_checks(self): def test_ethereum_signtx_omitted_chain_id_rejected(self): """An omitted chain_id must be refused, not silently signed pre-EIP-155. - Before 7.14.2 the `chain_id < 1` bounds check lived inside + Before the post-RC18 hardening the `chain_id < 1` bounds check lived inside `if (msg->has_chain_id)`, so a host that simply left the field out reached chain_id == 0 without tripping it. Two things followed: @@ -422,7 +472,8 @@ def test_ethereum_signtx_omitted_chain_id_rejected(self): sibling tests in this file all now pass chain_id explicitly so they keep exercising their own subject rather than this one. """ - self.requires_firmware("7.14.2") + # Canonical 7.15 rejects omitted chain IDs as well as explicit zero. + self.requires_firmware("7.15.0") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() self.client.apply_policy("AdvancedMode", 1) @@ -623,6 +674,65 @@ def test_ethereum_eip_1559(self): "67297089e0ba53c29dda1aafc23fce64a772c5433e127e5885edc03ece4670c9", ) + def test_ethereum_eip_1559_multibyte_chain_id(self): + """EIP-1559 must hash the WHOLE chain_id, not just its low byte. + + Regression for the multi-byte chain_id bug (firmware ed6db167). The + EIP-1559 hash step used hash_rlp_field((uint8_t*)&chain_id, 1), which on + little-endian ARM fed only the least-significant byte into keccak. For + Base (8453 = 0x2105) that hashed 0x05, so the signature recovered to an + unrelated address with no funds. The RLP *length* was computed correctly + from the full value and the legacy EIP-155 path was always correct — + only the EIP-1559 hash was wrong. Affected: Base (8453), Arbitrum + (42161), Avalanche (43114). Unaffected: ETH (1), OP (10), BSC (56), + Polygon (137) — all single-byte. + + Every other EIP-1559 case in this file uses chain_id 1 or 3, so the bug + had no coverage in the file that tests the feature. + + A golden r/s would need a device run to produce, so this is a + differential. Sign one identical transaction under two chain ids the + BUGGY firmware cannot tell apart: + + 8453 = 0x2105 low byte 0x05, two-byte value + 4357 = 0x1105 low byte 0x05, two-byte value + + Same low byte AND same RLP length header, so the broken code hashes a + byte-identical pre-image for both. Signing is deterministic (RFC 6979), + so buggy firmware returns the SAME signature twice and this fails. + Correct firmware hashes 0x21 0x05 vs 0x11 0x05, which must differ. + + Note a comparison against chain_id=5 would NOT work: the RLP length was + always derived from the full value, so the buggy pre-image for 8453 is + malformed rather than equal to a well-formed single-byte encoding. The + twin must match on both low byte and byte-width. + """ + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.setup_mnemonic_nopin_nopassphrase() + + def sign(chain_id): + return self.client.ethereum_sign_tx( + n=[0x80000000 | 44, 0x80000000 | 60, 0x80000000, 0, 0], + nonce=0, + gas_limit=0x5ac3, + max_fee_per_gas=0x16854be509, + max_priority_fee_per_gas=0x540ae480, + to=binascii.unhexlify("fc0cc6e85dff3d75e3985e0cb83b090cfd498dd1"), + value=0x1550f7dca70000, + chain_id=chain_id, + ) + + _, base_r, base_s = sign(8453) + _, twin_r, twin_s = sign(4357) + + self.assertNotEqual( + (binascii.hexlify(base_r), binascii.hexlify(base_s)), + (binascii.hexlify(twin_r), binascii.hexlify(twin_s)), + "chain_id 8453 and 4357 produced the same signature — only the low " + "byte of chain_id reached the EIP-1559 hash", + ) + def test_ethereum_signtx_nodata_eip_1559(self): self.requires_fullFeature() self.requires_firmware("7.2.1") diff --git a/tests/test_msg_ethereum_signtx_integer_encoding.py b/tests/test_msg_ethereum_signtx_integer_encoding.py new file mode 100644 index 00000000..bb0b4bd6 --- /dev/null +++ b/tests/test_msg_ethereum_signtx_integer_encoding.py @@ -0,0 +1,107 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +"""Integer fields sign the same with or without leading zero bytes. + +RLP encodes an integer without leading zeros, so the firmware strips them +both when it sizes the list (stage 1) and when it hashes the bytes (stage 2). +If the two stages disagreed the pre-image would be malformed. A padded +encoding must therefore produce exactly the signature of the canonical one. +""" + +import unittest + +import common +import rlp +from eth_keys import keys +from eth_utils import keccak +from keepkeylib import messages_ethereum_pb2 as eth_proto + +TO = bytes.fromhex("1d1c328764a41bda0492b66baa30c4a339ff85ef") + + +class TestEthereumSignTxIntegerEncoding(common.KeepKeyTest): + def setUp(self): + super().setUp() + self.requires_firmware("7.15.0") + self.requires_fullFeature() + # The stripping shipped in the same release block as this capability. + self.requires_release_capability("evm-max-amount-review") + self.setup_mnemonic_allallall() + self.path = self.client.expand_path("m/44'/60'/0'/0/0") + self.signer = self.client.ethereum_get_address(self.path) + + def _sign(self, **fields): + resp = self.client.call(eth_proto.EthereumSignTx( + address_n=self.path, + to=TO, chain_id=1, **fields)) + self.assertIsInstance(resp, eth_proto.EthereumTxRequest) + self.assertTrue(resp.HasField("signature_r")) + def integer(name): + return int.from_bytes(fields.get(name, b""), "big") + + if fields.get("type") == 2: + preimage = b"\x02" + rlp.encode([ + 1, integer("nonce"), integer("max_priority_fee_per_gas"), + integer("max_fee_per_gas"), integer("gas_limit"), TO, + integer("value"), b"", []]) + recovery_id = resp.signature_v + else: + preimage = rlp.encode([ + integer("nonce"), integer("gas_price"), integer("gas_limit"), + TO, integer("value"), b"", 1, 0, 0]) + recovery_id = resp.signature_v - 37 + signature = keys.Signature(vrs=( + recovery_id, int.from_bytes(resp.signature_r, "big"), + int.from_bytes(resp.signature_s, "big"))) + recovered = signature.recover_public_key_from_msg_hash(keccak(preimage)) + self.assertEqual(recovered.to_canonical_address(), self.signer) + return resp.signature_v, resp.signature_r, resp.signature_s + + def _assert_same_signature(self, canonical, padded): + self.assertEqual(self._sign(**canonical), self._sign(**padded)) + + def test_legacy_fields_with_leading_zeros(self): + self._assert_same_signature( + dict(nonce=b"\x01", gas_price=b"\x14", gas_limit=b"\x52\x08", + value=b"\x0d\xe0\xb6\xb3\xa7\x64\x00\x00"), + dict(nonce=b"\x00\x00\x01", gas_price=b"\x00\x14", + gas_limit=b"\x00\x52\x08", + value=b"\x00\x0d\xe0\xb6\xb3\xa7\x64\x00\x00")) + + def test_zero_value_in_every_spelling(self): + canonical = dict(nonce=b"", gas_price=b"\x14", gas_limit=b"\x52\x08", + value=b"") + for zero in (b"\x00", b"\x00\x00\x00"): + with self.subTest(zero=zero): + self._assert_same_signature( + canonical, dict(canonical, nonce=zero, value=zero)) + + def test_eip1559_fees_with_leading_zeros(self): + self._assert_same_signature( + dict(type=2, nonce=b"\x07", max_fee_per_gas=b"\x77\x35\x94\x00", + max_priority_fee_per_gas=b"\x3b\x9a\xca\x00", + gas_limit=b"\x52\x08", value=b"\x01"), + dict(type=2, nonce=b"\x00\x07", + max_fee_per_gas=b"\x00\x00\x77\x35\x94\x00", + max_priority_fee_per_gas=b"\x00\x3b\x9a\xca\x00", + gas_limit=b"\x00\x00\x52\x08", value=b"\x00\x01")) + + def test_eip1559_zero_value_in_every_spelling(self): + canonical = dict(type=2, nonce=b"", max_fee_per_gas=b"\x77\x35\x94\x00", + max_priority_fee_per_gas=b"", gas_limit=b"\x52\x08", + value=b"") + for zero in (b"\x00", b"\x00\x00\x00"): + with self.subTest(zero=zero): + self._assert_same_signature( + canonical, dict(canonical, nonce=zero, value=zero, + max_priority_fee_per_gas=zero)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_ethereum_signtx_xfer.py b/tests/test_msg_ethereum_signtx_xfer.py index 8c78201c..51783c72 100644 --- a/tests/test_msg_ethereum_signtx_xfer.py +++ b/tests/test_msg_ethereum_signtx_xfer.py @@ -36,6 +36,7 @@ def test_native_pseudo_address_transfer_is_unknown_off_mainnet(self): """TRANSFER must show the exact unknown-token frame on chain 257.""" self.requires_firmware("7.14.2") self.requires_fullFeature() + self.requires_release_capability("evm-unknown-token-review") self.setup_mnemonic_nopin_nopassphrase() self.client.apply_policy('ShapeShift', 1) self.client.apply_policy('AdvancedMode', 1) @@ -59,9 +60,16 @@ def test_native_pseudo_address_transfer_is_unknown_off_mainnet(self): data=erc20_data, chain_id=257, ) self.assertGreaterEqual(len(recorder.screens), 2) + # 7.15 explicitly labels the untrusted token value before the raw + # calldata review; pin that first warning frame exactly. + expected_frame = ( + "ccaae357cd0efe7dfa015d93ba08079e415b8857e6139a7773fd6efbec37ea35" + if self.firmware_at_least("7.15.0") else + "b0a3026e7af1778ebd71a968ace25c03945cccf2d8abc951e5dd65abc04e914e" + ) self.assertEqual( hashlib.sha256(recorder.screens[0]).hexdigest(), - "b0a3026e7af1778ebd71a968ace25c03945cccf2d8abc951e5dd65abc04e914e", + expected_frame, ) finally: self.client.apply_policy('AdvancedMode', 0) diff --git a/tests/test_msg_ethereum_thorchain_deposit.py b/tests/test_msg_ethereum_thorchain_deposit.py new file mode 100644 index 00000000..b6da8acf --- /dev/null +++ b/tests/test_msg_ethereum_thorchain_deposit.py @@ -0,0 +1,341 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Test coverage for THORChain EVM depositWithExpiry() selector recognition. +# The legacy deposit() selector (0x1fece7b4) was already handled; firmware +# 7.14.2 adds recognition of the modern depositWithExpiry() selector (0x44bc937b). + +import unittest +import common +import binascii + +import keepkeylib.messages_pb2 as proto +from keepkeylib.tools import parse_path + + +THOR_ROUTER = "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146" # ETH THORChain router +THOR_ROUTER_AVAX = "00dc6100103bc402d490aee3f9a5560cbd91f1d4" # Avalanche C-Chain router +ETH_NATIVE = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # sentinel for native ETH + + +def _build_deposit_calldata(memo): + """Build deposit(address,address,uint256,string) calldata (legacy selector).""" + selector = bytes.fromhex("1fece7b4") + vault = bytes(12) + bytes.fromhex(THOR_ROUTER) + asset = bytes(32) # address(0): the only native-ETH form the routers accept + amount = (500000000000000000).to_bytes(32, "big") # 0.5 ETH + memo_offset = (4 * 32).to_bytes(32, "big") # offset = 128 + memo_bytes = memo.encode("ascii") + memo_len = len(memo_bytes).to_bytes(32, "big") + pad = ((len(memo_bytes) + 31) // 32) * 32 + memo_data = memo_bytes + bytes(pad - len(memo_bytes)) + return selector + vault + asset + amount + memo_offset + memo_len + memo_data + + +def _build_deposit_with_expiry_calldata(memo, expiry=9999999999): + """Build depositWithExpiry(address,address,uint256,string,uint256) calldata.""" + selector = bytes.fromhex("44bc937b") + vault = bytes(12) + bytes.fromhex(THOR_ROUTER) + asset = bytes(32) # address(0): the only native-ETH form the routers accept + amount = (500000000000000000).to_bytes(32, "big") # 0.5 ETH + memo_offset = (5 * 32).to_bytes(32, "big") # offset = 160 (after expiry) + expiry_b = expiry.to_bytes(32, "big") + memo_bytes = memo.encode("ascii") + memo_len = len(memo_bytes).to_bytes(32, "big") + pad = ((len(memo_bytes) + 31) // 32) * 32 + memo_data = memo_bytes + bytes(pad - len(memo_bytes)) + return selector + vault + asset + amount + memo_offset + expiry_b + memo_len + memo_data + + +class TestMsgEthereumThorchainDeposit(common.KeepKeyTest): + + def test_deposit_legacy_selector(self): + """Existing deposit() selector (0x1fece7b4) is recognized without AdvancedMode.""" + self.requires_fullFeature() + self.requires_firmware("7.5.0") + self.requires_release_capability('thor-deposit-review') + self.setup_mnemonic_allallall() + + memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0" + data = _build_deposit_calldata(memo) + + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=parse_path("m/44'/60'/0'/0/0"), + nonce=1, + gas_price=50000000000, + gas_limit=300000, + to=binascii.unhexlify(THOR_ROUTER), + value=500000000000000000, + chain_id=1, + data=data, + ) + self.assertIn(sig_v, [37, 38]) # EIP-155 with chain_id=1: v = 35 + chain_id*2 + recovery + self.assertEqual(len(sig_r), 32) + self.assertEqual(len(sig_s), 32) + + def test_deposit_with_expiry_selector(self): + """Modern depositWithExpiry() selector (0x44bc937b) is recognized without AdvancedMode. + + Before 7.14.2 the firmware only matched the legacy 0x1fece7b4 selector. + All modern THORChain routers use depositWithExpiry. Without this fix the + device would fall through to the blind-sign gate and refuse to sign (or + require AdvancedMode), breaking every EVM->THORChain swap. + """ + self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.requires_release_capability('thor-deposit-review') + self.setup_mnemonic_allallall() + + memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0" + data = _build_deposit_with_expiry_calldata(memo) + + # AdvancedMode is intentionally OFF — THORChain txs must sign without it. + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=parse_path("m/44'/60'/0'/0/0"), + nonce=2, + gas_price=50000000000, + gas_limit=300000, + to=binascii.unhexlify(THOR_ROUTER), + value=500000000000000000, + chain_id=1, + data=data, + ) + self.assertIn(sig_v, [37, 38]) # EIP-155 with chain_id=1: v = 35 + chain_id*2 + recovery + self.assertEqual(len(sig_r), 32) + self.assertEqual(len(sig_s), 32) + + def test_deposit_with_expiry_non_thor_address_blind_sign_blocked(self): + """depositWithExpiry to a non-THORChain address must not be auto-approved. + + The firmware only clears the blind-sign gate when msg->has_to && the + deposit selector matches. Sending to an arbitrary address must still + require AdvancedMode so unrelated contracts can't exploit the selector. + """ + self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.requires_release_capability('thor-deposit-review') + self.setup_mnemonic_allallall() + + memo = "malicious memo" + data = _build_deposit_with_expiry_calldata(memo) + + from keepkeylib.client import CallException + import keepkeylib.types_pb2 as types + + # No AdvancedMode, random contract address — should be rejected + with self.assertRaises(CallException): + self.client.ethereum_sign_tx( + n=parse_path("m/44'/60'/0'/0/0"), + nonce=3, + gas_price=50000000000, + gas_limit=300000, + to=binascii.unhexlify("1234567890123456789012345678901234567890"), + value=0, + chain_id=1, + data=data, + ) + + def test_deposit_with_expiry_avalanche_router(self): + """A THORChain deposit on Avalanche clear-signs — the router pin is + (chain_id, address), not Ethereum-mainnet-only. + + Before the per-chain pin, thor_isThorchainTx only ever matched the + mainnet router, so an AVAX->ETH swap fell into the AdvancedMode + blind-sign gate and the device returned a bare ActionCancelled. The + signature is ECDSA-recovered against the host-built EIP-155 pre-image, + so a wrong digest, chain id, or key fails — not just a shape check. + The native amount screen shows msg.value with the CHAIN's ticker + (AVAX), never the mainnet pseudo-token's ETH label. + """ + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.requires_release_capability('thor-deposit-review') + self.setup_mnemonic_allallall() + + from keepkeylib.signed_metadata import eth_sighash_legacy, keccak256 + + memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0" + data = _build_deposit_with_expiry_calldata(memo) + + n = parse_path("m/44'/60'/0'/0/0") + nonce, gas_price, gas_limit = 4, 50000000000, 300000 + to = binascii.unhexlify(THOR_ROUTER_AVAX) + value = 500000000000000000 # 0.5 AVAX (native = msg.value) + chain_id = 43114 + + # AdvancedMode intentionally OFF — the deposit must clear-sign. + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + to=to, value=value, chain_id=chain_id, data=data, + ) + self.assertIn(sig_v, [2 * chain_id + 35, 2 * chain_id + 36]) + digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, + data, chain_id) + from ecdsa import VerifyingKey, SECP256k1, util + rec = sig_v - (35 + 2 * chain_id) + keys = VerifyingKey.from_public_key_recovery_with_digest( + sig_r + sig_s, digest, SECP256k1, hashfunc=None, + sigdecode=util.sigdecode_string, + ) + signer = keccak256(keys[rec].to_string())[-20:] + self.assertEqual(signer, self.client.ethereum_get_address(n)) + + def test_deposit_unpinned_chain_blind_sign_blocked(self): + """A deposit-shaped tx on a chain with NO pinned router must fall to + the blind-sign gate — a router address borrowed onto an unpinned chain + (where it may hold attacker code) cannot inherit the deposit UX.""" + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.requires_release_capability('thor-deposit-review') + self.setup_mnemonic_allallall() + + from keepkeylib.client import CallException + + memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0" + data = _build_deposit_with_expiry_calldata(memo) + + with self.assertRaises(CallException): + self.client.ethereum_sign_tx( + n=parse_path("m/44'/60'/0'/0/0"), + nonce=5, + gas_price=50000000000, + gas_limit=300000, + to=binascii.unhexlify(THOR_ROUTER), # real mainnet router addr + value=0, + chain_id=56, # BSC: no pinned router + data=data, + ) + + + # (router, chain_id) pins added with the BSC, Base and Arbitrum routers. + NEW_ROUTER_PINS = [ + ("b30ec53f98ff5947ede720d32ac2da7e52a5f56b", 56), # THORChain BSC + ("00dc6100103bc402d490aee3f9a5560cbd91f1d4", 8453), # THORChain Base + ("700e97ef07219440487840dc472e7120a7ff11f4", 42161), # Maya Arbitrum + ] + + def test_deposit_new_routers_native_and_token(self): + """Native and token deposits clear-sign on the BSC and Base THORChain + routers and Maya's Arbitrum router, each on its own chain. Each + signature is recovered against the EIP-155 pre-image.""" + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.requires_release_capability('thor-deposit-review') + self.setup_mnemonic_allallall() + + from keepkeylib.signed_metadata import eth_sighash_legacy, keccak256 + from ecdsa import VerifyingKey, SECP256k1, util + + memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0" + native = _build_deposit_with_expiry_calldata(memo) + token = bytearray(native) + token[4 + 32 + 12:4 + 64] = b"\x11" * 20 # asset = an ERC-20 + token = bytes(token) + + n = parse_path("m/44'/60'/0'/0/0") + me = self.client.ethereum_get_address(n) + nonce, gas_price, gas_limit = 6, 50000000000, 300000 + for router, chain_id in self.NEW_ROUTER_PINS: + to = binascii.unhexlify(router) + for data, value in ((native, 500000000000000000), (token, 0)): + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=n, nonce=nonce, gas_price=gas_price, + gas_limit=gas_limit, to=to, value=value, + chain_id=chain_id, data=data, + ) + self.assertIn(sig_v, [2 * chain_id + 35, 2 * chain_id + 36]) + digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, + value, data, chain_id) + keys = VerifyingKey.from_public_key_recovery_with_digest( + sig_r + sig_s, digest, SECP256k1, hashfunc=None, + sigdecode=util.sigdecode_string, + ) + rec = sig_v - (35 + 2 * chain_id) + self.assertEqual(keccak256(keys[rec].to_string())[-20:], me) + + def test_deposit_new_routers_wrong_chain_blocked(self): + """Each new router is pinned to its own chain only: the same address + on another chain falls to the blind-sign gate.""" + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.requires_release_capability('thor-deposit-review') + self.setup_mnemonic_allallall() + + from keepkeylib.client import CallException + + memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0" + data = _build_deposit_with_expiry_calldata(memo) + bsc, base, arb = (r for r, _ in self.NEW_ROUTER_PINS) + for router, chain_id in ((bsc, 1), (bsc, 8453), (base, 1), (base, 56), + (base, 42161), (arb, 1), (arb, 8453)): + with self.assertRaises(CallException, + msg="router %s chain %d" % (router, chain_id)): + self.client.ethereum_sign_tx( + n=parse_path("m/44'/60'/0'/0/0"), + nonce=7, + gas_price=50000000000, + gas_limit=300000, + to=binascii.unhexlify(router), + value=500000000000000000, + chain_id=chain_id, + data=data, + ) + + def test_base_real_deposits_without_advanced_mode(self): + """Replay the two Base swaps with their exact calldata and recover + the emulator's signer; no AdvancedMode or metadata is supplied.""" + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.requires_release_capability('thor-deposit-review') + self.setup_mnemonic_allallall() + + import json + from pathlib import Path + from keepkeylib.signed_metadata import eth_sighash_legacy, keccak256 + from keepkeylib.client import CallException + from ecdsa import VerifyingKey, SECP256k1, util + + self.client.init_device() + advanced = [p for p in self.client.features.policies + if p.policy_name == "AdvancedMode"] + self.assertTrue(advanced, "AdvancedMode policy must be reported") + self.assertFalse(advanced[0].enabled) + vectors = json.loads((Path(__file__).parent / "fixtures" / + "base-thorchain-real-deposits.json").read_text()) + n = parse_path("m/44'/60'/0'/0/0") + me = self.client.ethereum_get_address(n) + for tx in vectors: + with self.subTest(txid=tx["txid"]): + data = bytes.fromhex(tx["data"][2:]) + to = bytes.fromhex(tx["to"][2:]) + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=n, nonce=tx["nonce"], gas_price=tx["gas_price"], + gas_limit=tx["gas_limit"], to=to, value=tx["value"], + chain_id=tx["chain_id"], data=data, + ) + self.assertIn(sig_v, [16941, 16942]) + digest = eth_sighash_legacy( + tx["nonce"], tx["gas_price"], tx["gas_limit"], to, + tx["value"], data, tx["chain_id"]) + keys = VerifyingKey.from_public_key_recovery_with_digest( + sig_r + sig_s, digest, SECP256k1, hashfunc=None, + sigdecode=util.sigdecode_string) + self.assertEqual(keccak256(keys[sig_v - 16941].to_string())[-20:], me) + # Avalanche shares the router identity; it is a valid pin. + for dest, chain in ((b"\x11" * 20, 8453), (to, 1), (to, 56), + (to, 42161)): + with self.assertRaises(CallException): + self.client.ethereum_sign_tx( + n=n, nonce=tx["nonce"], gas_price=tx["gas_price"], + gas_limit=tx["gas_limit"], to=dest, value=tx["value"], + chain_id=chain, data=data, + ) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_msg_getaddress_segwit.py b/tests/test_msg_getaddress_segwit.py index 8a9202a2..40353a59 100644 --- a/tests/test_msg_getaddress_segwit.py +++ b/tests/test_msg_getaddress_segwit.py @@ -28,7 +28,10 @@ class TestMsgGetaddressSegwit(common.KeepKeyTest): def test_show_segwit(self): self.setup_mnemonic_allallall() self.client.clear_session() - self.assertEqual(self.client.get_address("Testnet", parse_path("49'/1'/0'/1/0"), True, None, script_type=proto.SPENDP2SHWITNESS), '2N1LGaGg836mqSQqiuUBLfcyGBhyZbremDX') + address = '2N1LGaGg836mqSQqiuUBLfcyGBhyZbremDX' + self.assertEqual(self.assert_shows_address( + lambda: self.client.get_address("Testnet", parse_path("49'/1'/0'/1/0"), True, None, script_type=proto.SPENDP2SHWITNESS), + address), address) self.assertEqual(self.client.get_address("Testnet", parse_path("49'/1'/0'/0/0"), False, None, script_type=proto.SPENDP2SHWITNESS), '2N4Q5FhU2497BryFfUgbqkAJE87aKHUhXMp') self.assertEqual(self.client.get_address("Testnet", parse_path("44'/1'/0'/0/0"), False, None, script_type=proto.SPENDP2SHWITNESS), '2N6UeBoqYEEnybg4cReFYDammpsyDw8R2Mc') self.assertEqual(self.client.get_address("Testnet", parse_path("44'/1'/0'/0/0"), False, None, script_type=proto.SPENDADDRESS), 'mvbu1Gdy8SUjTenqerxUaZyYjmveZvt33q') @@ -37,7 +40,10 @@ def test_grs(self): self.requires_fullFeature() self.setup_mnemonic_allallall() self.client.clear_session() - self.assertEqual(self.client.get_address('Groestlcoin', parse_path("49'/17'/0'/0/0"), True, None, script_type=proto.SPENDP2SHWITNESS), '31inaRqambLsd9D7Ke4USZmGEVd3PHkh7P') + address = '31inaRqambLsd9D7Ke4USZmGEVd3PHkh7P' + self.assertEqual(self.assert_shows_address( + lambda: self.client.get_address('Groestlcoin', parse_path("49'/17'/0'/0/0"), True, None, script_type=proto.SPENDP2SHWITNESS), + address), address) self.assertEqual(self.client.get_address('Groestlcoin', parse_path("49'/17'/0'/1/0"), False, None, script_type=proto.SPENDP2SHWITNESS), '3NH9SuUAjw1ZocQdTDMuqm3My3Mcg3ovEV') self.assertEqual(self.client.get_address('Groestlcoin', parse_path("49'/17'/0'/1/1"), False, None, script_type=proto.SPENDP2SHWITNESS), '3D65LEJYJ2Yda6UJr8tYBWspP5MZSeR5wz') @@ -45,7 +51,10 @@ def test_tgrs(self): self.requires_fullFeature() self.setup_mnemonic_allallall() self.client.clear_session() - self.assertEqual(self.client.get_address('GRS Testnet', parse_path("49'/1'/0'/0/0"), True, None, script_type=proto.SPENDP2SHWITNESS), '2N4Q5FhU2497BryFfUgbqkAJE87aKDv3V3e') + address = '2N4Q5FhU2497BryFfUgbqkAJE87aKDv3V3e' + self.assertEqual(self.assert_shows_address( + lambda: self.client.get_address('GRS Testnet', parse_path("49'/1'/0'/0/0"), True, None, script_type=proto.SPENDP2SHWITNESS), + address), address) self.assertEqual(self.client.get_address('GRS Testnet', parse_path("49'/1'/0'/1/0"), False, None, script_type=proto.SPENDP2SHWITNESS), '2N1LGaGg836mqSQqiuUBLfcyGBhyZYBtBZ7') self.assertEqual(self.client.get_address('GRS Testnet', parse_path("49'/1'/0'/1/1"), False, None, script_type=proto.SPENDP2SHWITNESS), '2NFWLCJQBSpz1oUJwwLpX8ECifFWGxQyzGu') diff --git a/tests/test_msg_getaddress_segwit_native.py b/tests/test_msg_getaddress_segwit_native.py index 2cdd0a04..44bf9138 100644 --- a/tests/test_msg_getaddress_segwit_native.py +++ b/tests/test_msg_getaddress_segwit_native.py @@ -26,13 +26,22 @@ class TestMsgGetaddressSegwitNative(common.KeepKeyTest): def test_show_segwit(self): - self.requires_fullFeature() self.setup_mnemonic_allallall() self.client.clear_session() - self.assertEqual(self.client.get_address("Testnet", parse_path("49'/1'/0'/0/0"), True, None, script_type=proto.SPENDWITNESS), 'tb1qqzv60m9ajw8drqulta4ld4gfx0rdh82un5s65s') + address = 'tb1qqzv60m9ajw8drqulta4ld4gfx0rdh82un5s65s' + self.assertEqual(self.assert_shows_address( + lambda: self.client.get_address("Testnet", parse_path("49'/1'/0'/0/0"), True, None, script_type=proto.SPENDWITNESS), + address), address) self.assertEqual(self.client.get_address("Testnet", parse_path("49'/1'/0'/1/0"), False, None, script_type=proto.SPENDWITNESS), 'tb1q694ccp5qcc0udmfwgp692u2s2hjpq5h407urtu') self.assertEqual(self.client.get_address("Testnet", parse_path("44'/1'/0'/0/0"), False, None, script_type=proto.SPENDWITNESS), 'tb1q54un3q39sf7e7tlfq99d6ezys7qgc62a6rxllc') self.assertEqual(self.client.get_address("Testnet", parse_path("44'/1'/0'/0/0"), False, None, script_type=proto.SPENDADDRESS), 'mvbu1Gdy8SUjTenqerxUaZyYjmveZvt33q') + + def test_groestlcoin_segwit(self): + # Groestlcoin is not in bitcoin-only firmware; Bitcoin bech32 display + # above runs on every build. + self.requires_fullFeature() + self.setup_mnemonic_allallall() + self.client.clear_session() self.assertEqual(self.client.get_address("Groestlcoin", parse_path("84'/17'/0'/0/0"), False, None, script_type=proto.SPENDWITNESS), 'grs1qw4teyraux2s77nhjdwh9ar8rl9dt7zww8r6lne') self.assertEqual(self.client.get_address("GRS Testnet", parse_path("84'/1'/0'/0/0"), False, None, script_type=proto.SPENDWITNESS), 'tgrs1qkvwu9g3k2pdxewfqr7syz89r3gj557l3ued7ja') diff --git a/tests/test_msg_getaddress_show.py b/tests/test_msg_getaddress_show.py index 196aeea7..a6aa923a 100644 --- a/tests/test_msg_getaddress_show.py +++ b/tests/test_msg_getaddress_show.py @@ -28,9 +28,12 @@ class TestMsgGetaddress(common.KeepKeyTest): def test_show(self): self.setup_mnemonic_nopin_nopassphrase() - self.assertEqual(self.client.get_address('Bitcoin', [1], show_display=True), '1CK7SJdcb8z9HuvVft3D91HLpLC6KSsGb') - self.assertEqual(self.client.get_address('Bitcoin', [2], show_display=True), '15AeAhtNJNKyowK8qPHwgpXkhsokzLtUpG') - self.assertEqual(self.client.get_address('Bitcoin', [3], show_display=True), '1CmzyJp9w3NafXMSEFH4SLYUPAVCSUrrJ5') + for n, address in ((1, '1CK7SJdcb8z9HuvVft3D91HLpLC6KSsGb'), + (2, '15AeAhtNJNKyowK8qPHwgpXkhsokzLtUpG'), + (3, '1CmzyJp9w3NafXMSEFH4SLYUPAVCSUrrJ5')): + self.assertEqual(self.assert_shows_address( + lambda: self.client.get_address('Bitcoin', [n], show_display=True), + address), address) def test_show_multisig_3(self): self.setup_mnemonic_nopin_nopassphrase() @@ -45,7 +48,10 @@ def test_show_multisig_3(self): ) for i in [1, 2, 3]: - self.assertEqual(self.client.get_address('Bitcoin', [i], show_display=True, multisig=multisig), '3E7GDtuHqnqPmDgwH59pVC7AvySiSkbibz') + address = '3E7GDtuHqnqPmDgwH59pVC7AvySiSkbibz' + self.assertEqual(self.assert_shows_address( + lambda: self.client.get_address('Bitcoin', [i], show_display=True, multisig=multisig), + address), address) def test_show_multisig_15(self): self.setup_mnemonic_nopin_nopassphrase() @@ -63,7 +69,10 @@ def test_show_multisig_15(self): ) for i in range(15): - self.assertEqual(self.client.get_address('Bitcoin', [i], show_display=True, multisig=multisig), '3QaKF8zobqcqY8aS6nxCD5ZYdiRfL3RCmU') + address = '3QaKF8zobqcqY8aS6nxCD5ZYdiRfL3RCmU' + self.assertEqual(self.assert_shows_address( + lambda: self.client.get_address('Bitcoin', [i], show_display=True, multisig=multisig), + address), address) if __name__ == '__main__': unittest.main() diff --git a/tests/test_msg_getaddress_taproot.py b/tests/test_msg_getaddress_taproot.py new file mode 100644 index 00000000..aaec24a8 --- /dev/null +++ b/tests/test_msg_getaddress_taproot.py @@ -0,0 +1,77 @@ +# This file is part of the KeepKey project. +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the License along with this library. +# If not, see . + +import common +import unittest + +from keepkeylib import types_pb2 as proto +from keepkeylib.tools import parse_path + + + + +class TestMsgGetaddressTaproot(common.KeepKeyTest): + + def test_taproot_bip86_vectors(self): + """Official BIP-86 test vectors. + + https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki + + BIP-86 publishes these against the "abandon abandon ... about" + mnemonic, which is exactly what setup_mnemonic_abandon loads. The + expected addresses are therefore the spec's own constants, not values + this implementation produced -- the comparison is against independent + ground truth. + """ + self.requires_taproot() + self.setup_mnemonic_abandon() + self.client.clear_session() + + # Account 0, first receiving address + self.assertEqual( + self.client.get_address( + "Bitcoin", parse_path("86'/0'/0'/0/0"), False, None, + script_type=proto.SPENDTAPROOT), + 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr') + + # Account 0, second receiving address + self.assertEqual( + self.client.get_address( + "Bitcoin", parse_path("86'/0'/0'/0/1"), False, None, + script_type=proto.SPENDTAPROOT), + 'bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh') + + # Account 0, first change address + self.assertEqual( + self.client.get_address( + "Bitcoin", parse_path("86'/0'/0'/1/0"), False, None, + script_type=proto.SPENDTAPROOT), + 'bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7') + + def test_show_taproot_address(self): + """Display the full BIP-86 address on the trusted OLED.""" + self.requires_taproot() + self.setup_mnemonic_abandon() + self.client.clear_session() + expected = 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr' + address = self.assert_shows_address( + lambda: self.client.get_address( + "Bitcoin", parse_path("86'/0'/0'/0/0"), True, None, + script_type=proto.SPENDTAPROOT), + expected) + self.assertEqual(address, expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_getentropy.py b/tests/test_msg_getentropy.py index 96ea7abe..3285f092 100644 --- a/tests/test_msg_getentropy.py +++ b/tests/test_msg_getentropy.py @@ -20,35 +20,103 @@ from __future__ import print_function +import os import unittest import common -import math +from collections import Counter import keepkeylib.messages_pb2 as proto import keepkeylib.types_pb2 as proto_types -def entropy(data): - counts = {} - for c in data: - if c in counts: - counts[c] += 1 - else: - counts[c] = 1 - e = 0 - for _, v in counts.items(): - p = 1.0 * v / len(data) - e -= p * math.log(p, 256) - return e - class TestMsgGetentropy(common.KeepKeyTest): def test_entropy(self): - for l in [0, 1, 2, 3, 4, 5, 8, 9, 16, 17, 32, 33, 64, 65, 128, 129, 256, 257, 512, 513, 1024]: + self.requires_release_capability("entropy-audit-budget") + if os.getenv("KK_EXPECT_ENTROPY_BUDGET") != "1": + self.requires_firmware("7.14.3") + # Entropy is a bounded nanopb response (1024 bytes on every supported + # release). Collect 64 KiB for the health sample, then consume the + # complete consent-based 8 MiB grant in wire-valid chunks. + chunk_size = 1024 + chunk_count = 64 + grant_bytes = 8 * 1024 * 1024 + # 7.15 advertises an initial 64 KiB press-free budget. 7.14.3 requires + # approval immediately; both then use consent-based 8 MiB grants. + initial_press_free = self.firmware_at_least("7.15.0") + + # A fresh budget must not make raw RNG output silently available from + # an initialized, PIN-protected, locked device. Confirm one request in + # that state before spending any of the press-free budget. + self.setup_mnemonic_pin_passphrase() + self.client.clear_session() + with self.client: + self.client.set_expected_responses([ + proto.ButtonRequest(code=proto_types.ButtonRequest_GetEntropy), + proto.Entropy(), + ]) + locked_sample = self.client.get_entropy(chunk_size) + self.assertEqual(len(locked_sample), chunk_size) + + # Wiping returns the device to the uninitialized audit state. The + # confirmed locked request above does not acquire an audit grant. + self.client.wipe_device() + + samples = [] + for i in range(chunk_count): with self.client: - self.client.set_expected_responses([proto.ButtonRequest(code=proto_types.ButtonRequest_GetEntropy), proto.Entropy()]) - ent = self.client.get_entropy(l) - self.assertTrue(len(ent) >= l) - print('entropy = ', entropy(ent)) + expected = [proto.Entropy()] + if i == 0 and not initial_press_free: + expected.insert(0, proto.ButtonRequest( + code=proto_types.ButtonRequest_GetEntropy)) + self.client.set_expected_responses(expected) + sample = self.client.get_entropy(chunk_size) + self.assertEqual(len(sample), chunk_size) + samples.append(sample) + + self.assertEqual(sum(len(sample) for sample in samples), 64 * 1024) + self.assertEqual(len(set(samples)), chunk_count) + + # Deliberately broad catastrophic-failure checks, not a statistical + # certification of the hardware RNG. They catch a stuck/constant or + # grossly biased source without imposing a fragile quality threshold. + combined = b''.join(samples) + counts = Counter(combined) + self.assertGreaterEqual(len(counts), 200) + self.assertLess(max(counts.values()), len(combined) // 20) + one_bits = sum(bin(value).count('1') for value in combined) + one_ratio = float(one_bits) / (8 * len(combined)) + self.assertGreater(one_ratio, 0.40) + self.assertLess(one_ratio, 0.60) + + if initial_press_free: + # The initial 64 KiB is exhausted. Approval now acquires the + # separate 8 MiB grant, and this request consumes its first chunk. + with self.client: + self.client.set_expected_responses([ + proto.ButtonRequest(code=proto_types.ButtonRequest_GetEntropy), + proto.Entropy(), + ]) + acquired = self.client.get_entropy(chunk_size) + self.assertEqual(len(acquired), chunk_size) + grant_consumed = chunk_size + else: + # The health sample already consumed part of the first grant. + grant_consumed = chunk_count * chunk_size + + for _ in range((grant_bytes - grant_consumed) // chunk_size): + with self.client: + self.client.set_expected_responses([proto.Entropy()]) + sample = self.client.get_entropy(chunk_size) + self.assertEqual(len(sample), chunk_size) + + # Exhaustion requires a fresh approval and starts the next grant. + with self.client: + self.client.set_expected_responses([ + proto.ButtonRequest(code=proto_types.ButtonRequest_GetEntropy), + proto.Entropy(), + ]) + after_budget = self.client.get_entropy(chunk_size) + self.assertEqual(len(after_budget), chunk_size) if __name__ == '__main__': unittest.main() diff --git a/tests/test_msg_getpublickey.py b/tests/test_msg_getpublickey.py index 4d43d5c6..596b0da8 100644 --- a/tests/test_msg_getpublickey.py +++ b/tests/test_msg_getpublickey.py @@ -51,7 +51,10 @@ def test_script_type(self): self.assertEqual(self.client.get_public_node([], coin_name="Bitcoin", script_type=proto_types.SPENDADDRESS).xpub, "xpub661MyMwAqRbcF1zGijBb2K6x9YiJPh58xpcCeLvTxMX6spkY3PcpJ4ABcCyWfskq5DDxM3e6Ez5ePCqG5bnPUXR4wL8TZWyoDaUdiWW7bKy") self.assertEqual(self.client.get_public_node([], coin_name="Bitcoin", script_type=proto_types.SPENDP2SHWITNESS).xpub, "ypub6QqdH2c5z7966KBPZ5yDEQCTKWrkLK4dsw8RRjpMLMtyvvZmJ3nNv7pKdQw6fnQkUrLm6XEeheSCGVSpoJCQGm6fofpt9RoHVJYH72ecmVm") self.assertEqual(self.client.get_public_node([], coin_name="Bitcoin", script_type=proto_types.SPENDWITNESS).xpub, "zpub6jftahH18ngZwcNWPSkqSVHxVV1CGw48o3eeD8iEiNGrz2NzYhwwYBUTectgfh4ftVTZqzqDAJnk9n4PWzcR4znGg1XJjLcmm2bvVc3Honv") - self.assertEqual(self.client.get_public_node([], coin_name="Bitcoin", script_type=proto_types.SPENDWITNESS, show_display=True).xpub, "zpub6jftahH18ngZwcNWPSkqSVHxVV1CGw48o3eeD8iEiNGrz2NzYhwwYBUTectgfh4ftVTZqzqDAJnk9n4PWzcR4znGg1XJjLcmm2bvVc3Honv") + zpub = "zpub6jftahH18ngZwcNWPSkqSVHxVV1CGw48o3eeD8iEiNGrz2NzYhwwYBUTectgfh4ftVTZqzqDAJnk9n4PWzcR4znGg1XJjLcmm2bvVc3Honv" + self.assertEqual(self.assert_shows_address( + lambda: self.client.get_public_node([], coin_name="Bitcoin", script_type=proto_types.SPENDWITNESS, show_display=True), + zpub).xpub, zpub) if __name__ == '__main__': unittest.main() diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py new file mode 100644 index 00000000..52cca18c --- /dev/null +++ b/tests/test_msg_hive.py @@ -0,0 +1,1110 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +"""Hive (SLIP-0048) device tests — multi-role keys + account operations. + +Uses the standard 12-word test seed (mnemonic12, "alcohol ... aisle") via +setup_mnemonic_nopin_nopassphrase(). + +The account_create / account_update / transfer tests are self-validating: they +recover the signer from the 65-byte device signature over +SHA256(chain_id || serialized_tx) and assert it equals the device-derived +signing key. This exercises the device AND validates the attestation-digest +contract documented in keepkey-vault docs/HIVE-ATTESTATION-DIGEST-SPEC.md — +no precomputed golden vector required, and not circular (recovery is an +independent cryptographic check). +""" + +import hashlib +import struct +import unittest + +import common + +from ecdsa import SECP256k1, VerifyingKey +from ecdsa.util import sigdecode_string + +from keepkeylib import hive +from keepkeylib.tools import parse_path + +# Hive mainnet chain id: beeab0de followed by 28 zero bytes (32 bytes). +HIVE_CHAIN_ID = bytes.fromhex("beeab0de" + "00" * 28) + +# SLIP-0048 roles (hardened offsets within the role component). +ROLE_OWNER, ROLE_ACTIVE, ROLE_MEMO, ROLE_POSTING = 0, 1, 3, 4 + +HIVE_OP_VOTE = 0 +HIVE_OP_COMMENT = 1 +HIVE_OP_TRANSFER = 2 +HIVE_OP_ACCOUNT_CREATE = 9 +HIVE_OP_ACCOUNT_UPDATE = 10 +HIVE_OP_CUSTOM_JSON = 18 + + +def hive_path(role, account_index=0): + """m/48'/13'/role'/account'/0' — all five components hardened.""" + h = 0x80000000 + return [h + 48, h + 13, h + role, h + account_index, h] + + +def recover_compressed(serialized_tx, sig65): + """Recover the 33-byte compressed signer pubkey from a Hive device signature. + + Mirrors HIVE-ATTESTATION-DIGEST-SPEC.md §1-2: + digest = SHA256(chain_id || serialized_tx) + sig[0] = 27 + recovery_id + 4 -> recovery_id = sig[0] - 31 + sig[1:65] = r || s + """ + assert len(sig65) == 65, "Hive signature must be 65 bytes" + recid = sig65[0] - 31 + assert 0 <= recid <= 3, "unexpected recovery header byte %d" % sig65[0] + digest = hashlib.sha256(HIVE_CHAIN_ID + serialized_tx).digest() + candidates = VerifyingKey.from_public_key_recovery_with_digest( + sig65[1:], digest, SECP256k1, hashfunc=hashlib.sha256, sigdecode=sigdecode_string + ) + return candidates[recid].to_string("compressed") + + +# ── Independent Graphene serializer for HiveSignOperations tests ────────── +# dhive-equivalent byte building, written here so firmware parser bugs can't +# cancel out against firmware serializer bugs. + +def _varint(n): + out = b"" + while True: + b_ = n & 0x7F + n >>= 7 + if n: + out += bytes([b_ | 0x80]) + else: + return out + bytes([b_]) + + +def _string(s): + if isinstance(s, str): + s = s.encode("utf-8") + return _varint(len(s)) + s + + +def _ops_tx(op_blobs, ref_num=12345, ref_prefix=67890, expiration=1700000000, + ext=b"\x00", opcount=None): + """header + varint op count + ops + extensions (default: empty).""" + head = struct.pack(" in op 43 (dhive and + # hive-tx OptionalSerializer): a 0/1 presence byte, then the value. + memo_key = bytes([1, 0x02]) + bytes(32) if memo_key_present else bytes([0]) + return (_varint(43) + _string(account) + + bytes([1 if authority_present else 0, 0, 0]) + memo_key + + _string(json_metadata) + _string(posting_json_metadata) + + _varint(0)) + + +class _Reader: + """Cursor over the device-emitted Graphene bytes. Matches firmware + serialization exactly (see hive.c append_* helpers).""" + + def __init__(self, data): + self.d = data + self.i = 0 + + def take(self, n): + v = self.d[self.i:self.i + n] + assert len(v) == n, "truncated serialized_tx" + self.i += n + return v + + def u8(self): + return self.take(1)[0] + + def u16le(self): + return int.from_bytes(self.take(2), "little") + + def u32le(self): + return int.from_bytes(self.take(4), "little") + + def u64le(self): + return int.from_bytes(self.take(8), "little") + + def varint(self): + shift = result = 0 + while True: + b = self.u8() + result |= (b & 0x7F) << shift + if not (b & 0x80): + return result + shift += 7 + + def string(self): + return self.take(self.varint()) + + def asset(self): + amount = self.u64le() + precision = self.u8() + symbol = self.take(7).rstrip(b"\x00").decode() + return amount, precision, symbol + + def authority(self): + # weight_threshold=1, 0 account auths, 1 key auth, key(33), weight=1 + assert self.u32le() == 1, "weight_threshold must be 1" + assert self.varint() == 0, "expected 0 account_auths" + assert self.varint() == 1, "expected 1 key_auth" + key = self.take(33) + assert self.u16le() == 1, "key weight must be 1" + return key + + def assert_end(self): + assert self.i == len(self.d), "trailing bytes after operation (offset %d/%d)" % (self.i, len(self.d)) + + +def _parse_header(r, expected_op): + ref_block_num = r.u16le() + ref_block_prefix = r.u32le() + expiration = r.u32le() + assert r.varint() == 1, "expected exactly one operation" + op_type = r.varint() + assert op_type == expected_op, "op_type %d != expected %d" % (op_type, expected_op) + return ref_block_num, ref_block_prefix, expiration + + +class TestMsgHive(common.KeepKeyTest): + + def test_hive_get_public_key_active(self): + """Active-role key derives and returns an STM-prefixed key + 33-byte raw.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveGetPublicKey") + self.setup_mnemonic_nopin_nopassphrase() + + resp = hive.get_public_key(self.client, hive_path(ROLE_ACTIVE), show_display=False) + self.assertTrue(resp.public_key.startswith("STM"), "expected STM-prefixed key") + self.assertEqual(len(resp.raw_public_key), 33) + self.assertIn(resp.raw_public_key[0], (2, 3), "compressed pubkey prefix") + + def test_hive_show_public_key(self): + """The whole STM key is on the screen: confirm_ethereum_address() had + room for two 140 px rows and silently dropped the tail of the key.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveGetPublicKey") + self.requires_release_capability("hive-release-review") + self.setup_mnemonic_nopin_nopassphrase() + + for role in (ROLE_OWNER, ROLE_ACTIVE, ROLE_MEMO, ROLE_POSTING): + expected = hive.get_public_key( + self.client, hive_path(role), show_display=False).public_key + resp = self.assert_shows_address( + lambda: hive.get_public_key( + self.client, hive_path(role), show_display=True), + expected) + self.assertEqual(resp.public_key, expected) + + def test_hive_get_public_keys_all_roles(self): + """All four role keys derive, are distinct, and STM-formatted.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveGetPublicKeys") + self.setup_mnemonic_nopin_nopassphrase() + + resp = hive.get_public_keys(self.client, account_index=0, show_display=False) + keys = [resp.owner_key, resp.active_key, resp.memo_key, resp.posting_key] + for k in keys: + self.assertTrue(k.startswith("STM"), "expected STM-prefixed key, got %r" % k) + self.assertEqual(len(set(keys)), 4) + + # The single-key path must agree with the bulk path for the active role. + single = hive.get_public_key(self.client, hive_path(ROLE_ACTIVE), show_display=False) + self.assertEqual(single.public_key, resp.active_key) + + def test_hive_sign_transfer(self): + """Transfer (op 2) signs and the signature recovers to the active key.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.requires_release_capability("hive-release-review") + self.setup_mnemonic_nopin_nopassphrase() + + active = hive.get_public_key(self.client, hive_path(ROLE_ACTIVE), show_display=False) + resp = hive.sign_tx( + self.client, + address_n=hive_path(ROLE_ACTIVE), + chain_id=HIVE_CHAIN_ID, + ref_block_num=12345, + ref_block_prefix=67890, + expiration=1700000000, + sender="kktester", + recipient="kkrecipient", + amount=1000, # 1.000 HIVE + decimals=3, + asset_symbol="HIVE", + memo="kktest", + ) + self.assertEqual(len(resp.signature), 65) + self.assertIn(resp.signature[0], (31, 32)) + self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), active.raw_public_key) + + # Parse the transfer op and bind EVERY field — a rewritten recipient, + # amount, or asset must fail, not just a missing substring. + r = _Reader(resp.serialized_tx) + ref_num, ref_prefix, expiration = _parse_header(r, HIVE_OP_TRANSFER) + self.assertEqual((ref_num, ref_prefix, expiration), (12345, 67890, 1700000000)) + self.assertEqual(r.string(), b"kktester") # from + self.assertEqual(r.string(), b"kkrecipient") # to + # The WIRE spelling, per _WIRE_SYMBOL above: hived writes "STEEM" for + # HIVE, and this reads the bytes the device actually signed. Asserting + # the display name here passed only while the firmware serialized a + # symbol the chain does not use. + self.assertEqual(r.asset(), (1000, 3, _WIRE_SYMBOL["HIVE"])) + self.assertEqual(r.string(), b"kktest") # memo + self.assertEqual(r.varint(), 0) # extensions + r.assert_end() + + def test_hive_sign_account_create(self): + """account_create (op 9): signs, recovers to owner key, binds the 4 keys + name. + + This is the attestation a Pioneer sponsor verifies before spending an ACT. + """ + self.requires_firmware("7.15.0") + self.requires_message("HiveSignAccountCreate") + self.requires_message("HiveGetPublicKeys") + self.requires_release_capability("hive-release-review") + self.setup_mnemonic_nopin_nopassphrase() + + # Device-derived raw keys per role, for slot-exact comparison. + raw = {role: hive.get_public_key(self.client, hive_path(role), show_display=False).raw_public_key + for role in (ROLE_OWNER, ROLE_ACTIVE, ROLE_POSTING, ROLE_MEMO)} + keys = hive.get_public_keys(self.client, account_index=0, show_display=False) + + resp = hive.sign_account_create( + self.client, + address_n=hive_path(ROLE_OWNER), + chain_id=HIVE_CHAIN_ID, + ref_block_num=12345, + ref_block_prefix=67890, + expiration=1700000000, + creator="kksponsor", + new_account_name="kktestacct", + fee_amount=3000, + owner_key=keys.owner_key, + active_key=keys.active_key, + posting_key=keys.posting_key, + memo_key=keys.memo_key, + ) + self.assertEqual(len(resp.signature), 65) + self.assertIn(resp.signature[0], (31, 32)) + + # Attestation: signature recovers to the device owner key. + self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), raw[ROLE_OWNER]) + + # Parse op 9 and bind EVERY field at its position. A firmware bug that + # swaps roles, rewrites the creator, or alters the fee must fail here. + r = _Reader(resp.serialized_tx) + ref_num, ref_prefix, expiration = _parse_header(r, HIVE_OP_ACCOUNT_CREATE) + self.assertEqual((ref_num, ref_prefix, expiration), (12345, 67890, 1700000000)) + self.assertEqual(r.asset(), (3000, 3, _WIRE_SYMBOL["HIVE"])) # fee + self.assertEqual(r.string(), b"kksponsor") # creator + self.assertEqual(r.string(), b"kktestacct") # new_account_name + self.assertEqual(r.authority(), raw[ROLE_OWNER]) + self.assertEqual(r.authority(), raw[ROLE_ACTIVE]) + self.assertEqual(r.authority(), raw[ROLE_POSTING]) + self.assertEqual(r.take(33), raw[ROLE_MEMO]) + self.assertEqual(r.string(), b"") # json_metadata + self.assertEqual(r.varint(), 0) # extensions + r.assert_end() + + def test_hive_sign_account_update(self): + """account_update (op 10): signs and recovers to the owner key.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignAccountUpdate") + self.requires_message("HiveGetPublicKeys") + self.setup_mnemonic_nopin_nopassphrase() + + raw = {role: hive.get_public_key(self.client, hive_path(role), show_display=False).raw_public_key + for role in (ROLE_OWNER, ROLE_ACTIVE, ROLE_POSTING, ROLE_MEMO)} + keys = hive.get_public_keys(self.client, account_index=0, show_display=False) + + resp = hive.sign_account_update( + self.client, + address_n=hive_path(ROLE_OWNER), + chain_id=HIVE_CHAIN_ID, + ref_block_num=12345, + ref_block_prefix=67890, + expiration=1700000000, + account="kktestacct", + new_owner_key=keys.owner_key, + new_active_key=keys.active_key, + new_posting_key=keys.posting_key, + new_memo_key=keys.memo_key, + ) + self.assertEqual(len(resp.signature), 65) + self.assertIn(resp.signature[0], (31, 32)) + self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), raw[ROLE_OWNER]) + + # Parse op 10 and bind the replacement keys to their slots. A bad impl + # that updates the wrong authorities must fail even if op/name are right. + r = _Reader(resp.serialized_tx) + ref_num, ref_prefix, expiration = _parse_header(r, HIVE_OP_ACCOUNT_UPDATE) + self.assertEqual((ref_num, ref_prefix, expiration), (12345, 67890, 1700000000)) + self.assertEqual(r.string(), b"kktestacct") # account + for role, label in ((ROLE_OWNER, "owner"), (ROLE_ACTIVE, "active"), (ROLE_POSTING, "posting")): + self.assertEqual(r.u8(), 0x01) + self.assertEqual(r.authority(), raw[role]) + self.assertEqual(r.take(33), raw[ROLE_MEMO]) + self.assertEqual(r.string(), b"") # json_metadata + self.assertEqual(r.varint(), 0) # extensions + r.assert_end() + + + def _transfer_kwargs(self, **overrides): + """Baseline valid HiveSignTx args; override per negative case.""" + kw = dict( + address_n=hive_path(ROLE_ACTIVE), + chain_id=HIVE_CHAIN_ID, + ref_block_num=12345, + ref_block_prefix=67890, + expiration=1700000000, + sender="kktester", + recipient="kkrecipient", + amount=1000, + decimals=3, + asset_symbol="HIVE", + memo="kktest", + ) + kw.update(overrides) + return kw + + def _assert_sign_tx_fails(self, message_fragment, **overrides): + from keepkeylib.client import CallException + with self.assertRaises(CallException) as ctx: + hive.sign_tx(self.client, **self._transfer_kwargs(**overrides)) + self.assertIn(message_fragment, str(ctx.exception)) + + def test_hive_sign_transfer_rejects_foreign_path(self): + """A path outside SLIP-0048 (e.g. BIP-44 BTC) must be rejected before + signing — a compromised host cannot obtain a Hive signature with a + key from another coin's derivation tree.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.requires_release_capability("hive-release-review") + self.setup_mnemonic_nopin_nopassphrase() + self._assert_sign_tx_fails( + "Invalid Hive SLIP-0048 path", + address_n=parse_path("m/44'/0'/0'/0/0"), + ) + + def test_hive_sign_transfer_rejects_wrong_network(self): + """Wrong SLIP-0048 network index (registry 3054' instead of the + de-facto 13') must be rejected — keys must be Ledger-compatible.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.requires_release_capability("hive-release-review") + self.setup_mnemonic_nopin_nopassphrase() + h = 0x80000000 + self._assert_sign_tx_fails( + "Invalid Hive SLIP-0048 path", + address_n=[h + 48, h + 3054, h + ROLE_ACTIVE, h, h], + ) + + def test_hive_sign_transfer_rejects_non_active_roles(self): + """Transfers must sign with the active key ONLY. Post-HF28 hived no + longer accepts higher-role substitution, so an owner/memo/posting + signature would be rejected at broadcast — and the cold owner key + must never be spent on a transfer. Unassigned roles reject too.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.requires_release_capability("hive-release-review") + self.setup_mnemonic_nopin_nopassphrase() + for role in (ROLE_OWNER, ROLE_MEMO, ROLE_POSTING, 2): # 2' unassigned + self._assert_sign_tx_fails( + "Invalid Hive SLIP-0048 path", + address_n=hive_path(role), + ) + + def test_hive_sign_account_ops_reject_non_owner_roles(self): + """account_create/account_update must sign with the owner key ONLY — + the sponsor's attestation check recovers to the device OWNER key, and + account_update replaces the owner authority itself.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignAccountCreate") + self.requires_message("HiveSignAccountUpdate") + self.requires_message("HiveGetPublicKeys") + self.requires_release_capability("hive-release-review") + self.setup_mnemonic_nopin_nopassphrase() + from keepkeylib.client import CallException + keys = hive.get_public_keys(self.client, account_index=0, show_display=False) + tx_kw = dict( + chain_id=HIVE_CHAIN_ID, + ref_block_num=12345, + ref_block_prefix=67890, + expiration=1700000000, + ) + with self.assertRaises(CallException) as ctx: + hive.sign_account_create( + self.client, address_n=hive_path(ROLE_ACTIVE), + creator="kksponsor", new_account_name="kktestacct", + fee_amount=3000, owner_key=keys.owner_key, + active_key=keys.active_key, posting_key=keys.posting_key, + memo_key=keys.memo_key, **tx_kw) + self.assertIn("Invalid Hive SLIP-0048 path", str(ctx.exception)) + with self.assertRaises(CallException) as ctx: + hive.sign_account_update( + self.client, address_n=hive_path(ROLE_ACTIVE), + account="kktestacct", new_owner_key=keys.owner_key, + new_active_key=keys.active_key, + new_posting_key=keys.posting_key, + new_memo_key=keys.memo_key, **tx_kw) + self.assertIn("Invalid Hive SLIP-0048 path", str(ctx.exception)) + + def test_hive_sign_transfer_rejects_long_memo(self): + """Memo over the 440-byte serialization limit must fail with a + specific error, not a generic signing failure.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.requires_release_capability("hive-release-review") + self.setup_mnemonic_nopin_nopassphrase() + self._assert_sign_tx_fails("memo too long", memo="x" * 441) + + def test_hive_sign_transfer_max_memo_ok(self): + """A memo of exactly 440 bytes still signs (boundary check).""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.requires_release_capability("hive-release-review") + self.setup_mnemonic_nopin_nopassphrase() + active = hive.get_public_key(self.client, hive_path(ROLE_ACTIVE), show_display=False) + resp = hive.sign_tx(self.client, **self._transfer_kwargs(memo="x" * 440)) + self.assertEqual(len(resp.signature), 65) + self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), active.raw_public_key) + + + # ── Message signing (Keychain signBuffer contract) ──────────────────── + + def _recover_message_signer(self, message, sig65): + """Recover the compressed signer pubkey from a HiveSignedMessage. + + The signBuffer contract: digest = SHA256(message bytes) ONLY — no + chain_id prepend (unlike transactions), no message prefix. This + recovery is exactly what a Hive dApp does to verify a login. + """ + # NB: common.KeepKeyTest overrides assertEqual without the msg param. + self.assertEqual(len(sig65), 65) + recid = sig65[0] - 31 + self.assertTrue(0 <= recid <= 3, "unexpected recovery header byte %d" % sig65[0]) + digest = hashlib.sha256(message).digest() + candidates = VerifyingKey.from_public_key_recovery_with_digest( + sig65[1:], digest, SECP256k1, hashfunc=hashlib.sha256, + sigdecode=sigdecode_string + ) + return candidates[recid].to_string("compressed") + + def test_hive_sign_message_posting(self): + """dApp login: a posting-key signBuffer signature recovers to the + posting key — both against the response public_key and against an + independently derived HiveGetPublicKey for the same path.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + + challenge = b'{"login":"skatehive","ts":1700000000,"nonce":"abc123"}' + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_message(self.client, hive_path(ROLE_POSTING), challenge) + + self.assertEqual(len(resp.signature), 65) + self.assertEqual(len(resp.public_key), 33) + self.assertEqual(resp.public_key, posting.raw_public_key) + self.assertEqual(self._recover_message_signer(challenge, resp.signature), + posting.raw_public_key) + + def test_hive_sign_message_all_roles(self): + """The three Keychain-exposed roles (Posting/Active/Memo) may sign; + each signature recovers to that role's key and to no other's. + owner' is rejected — see test_hive_sign_message_rejects_bad_paths.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + + message = b"kk role check" + seen = set() + for role in (ROLE_ACTIVE, ROLE_MEMO, ROLE_POSTING): + expected = hive.get_public_key(self.client, hive_path(role), show_display=False) + resp = hive.sign_message(self.client, hive_path(role), message) + self.assertEqual(resp.public_key, expected.raw_public_key) + self.assertEqual(self._recover_message_signer(message, resp.signature), + expected.raw_public_key) + seen.add(resp.public_key) + self.assertEqual(len(seen), 3) # role keys must be distinct + + def test_hive_sign_message_nonprintable_bytes(self): + """Non-printable buffers are REFUSED. A Hive transaction digest is + SHA256(chain_id || serialized_tx) over binary bytes, so a binary + "message" equal to C || tx would hash to a valid transaction signature + on any fork chain C. Restricting signable messages to printable ASCII + keeps them in a domain disjoint from every transaction preimage, closing + that cross-chain message->transaction signature oracle.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + + from keepkeylib.client import CallException + message = bytes(range(0, 48)) # non-printable bytes + with self.assertRaises(CallException) as ctx: + hive.sign_message(self.client, hive_path(ROLE_POSTING), message) + self.assertIn("printable", str(ctx.exception)) + + def test_hive_sign_message_long_printable_ok(self): + """Printable text over the 128-byte display budget still signs — it + routes through the hex-preview confirm (never silently truncated + text), and the signature covers every byte.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + + message = (b"benign preamble. " * 20)[:300] # printable, > 128 bytes + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_message(self.client, hive_path(ROLE_POSTING), message) + self.assertEqual(self._recover_message_signer(message, resp.signature), + posting.raw_public_key) + + def test_hive_sign_message_max_length_ok(self): + """A message of exactly 1024 bytes (the proto cap) still signs.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + + message = b"x" * 1024 + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_message(self.client, hive_path(ROLE_POSTING), message) + self.assertEqual(self._recover_message_signer(message, resp.signature), + posting.raw_public_key) + + def test_hive_sign_message_rejects_oversize(self): + """1025 bytes must fail (nanopb max_size cap — the proto and handler + agree on 1024).""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + from keepkeylib.client import CallException + with self.assertRaises(CallException): + hive.sign_message(self.client, hive_path(ROLE_POSTING), b"x" * 1025) + + def test_hive_sign_message_rejects_bad_paths(self): + """Foreign trees, wrong network index, and unassigned roles must all + be rejected — same fence as the transaction handlers.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + from keepkeylib.client import CallException + h = 0x80000000 + bad_paths = [ + parse_path("m/44'/0'/0'/0/0"), # BIP-44 BTC + [h + 48, h + 3054, h + ROLE_POSTING, h, h], # registry 3054', not 13' + hive_path(2), # unassigned role 2' + hive_path(ROLE_OWNER), # owner' not a Keychain signBuffer role + [h + 48, h + 13, h + ROLE_POSTING, h], # short path + ] + for path in bad_paths: + with self.assertRaises(CallException) as ctx: + hive.sign_message(self.client, path, b"login challenge") + self.assertIn("Invalid Hive SLIP-0048 path", str(ctx.exception)) + + # ── Operations signing (HiveSignOperations — parsed generic ops) ────── + # The test builds transactions byte-exactly with its OWN serializer + # (below, module level) — never firmware-emitted bytes — so a parser bug + # and a serializer bug cannot cancel out. + + def _recover_ops_signer(self, tx, sig65): + """digest = SHA256(chain_id || serialized_tx), same as transfers.""" + self.assertEqual(len(sig65), 65) + recid = sig65[0] - 31 + self.assertTrue(0 <= recid <= 3, "unexpected recovery header byte %d" % sig65[0]) + digest = hashlib.sha256(HIVE_CHAIN_ID + tx).digest() + candidates = VerifyingKey.from_public_key_recovery_with_digest( + sig65[1:], digest, SECP256k1, hashfunc=hashlib.sha256, + sigdecode=sigdecode_string + ) + return candidates[recid].to_string("compressed") + + def test_hive_sign_ops_vote(self): + """A vote tx signs with the posting key and recovers to it.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + tx = _ops_tx([_op_vote("kkvoter", "someauthor", "cool-post-permlink", 10000)]) + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_operations(self.client, hive_path(ROLE_POSTING), tx, chain_id=HIVE_CHAIN_ID) + self.assertEqual(self._recover_ops_signer(tx, resp.signature), posting.raw_public_key) + + def test_hive_sign_ops_downvote_and_default_chain_id(self): + """Negative weight (downvote) signs; omitted chain_id defaults to + mainnet in firmware — recovery against the mainnet id proves it.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + tx = _ops_tx([_op_vote("kkvoter", "spammer", "bad-post", -10000)]) + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_operations(self.client, hive_path(ROLE_POSTING), tx) # no chain_id + self.assertEqual(self._recover_ops_signer(tx, resp.signature), posting.raw_public_key) + + def test_hive_sign_ops_comment(self): + """A top-level post (empty parent_author) with a unicode body signs — + the body routes through the non-ASCII display fallback.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + body = "skate clip of the day — hardflip".encode("utf-8") + tx = _ops_tx([_op_comment("", "hive-173115", "kkauthor", + "my-first-post", "My first post", body, "{}")]) + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_operations(self.client, hive_path(ROLE_POSTING), tx, chain_id=HIVE_CHAIN_ID) + self.assertEqual(self._recover_ops_signer(tx, resp.signature), posting.raw_public_key) + + def test_hive_sign_ops_custom_json_posting(self): + """custom_json with posting auths (Hive Engine style) signs with the + posting key.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + tx = _ops_tx([_op_custom_json([], ["kkplayer"], "ssc-mainnet-hive", + '{"contractName":"tokens","contractAction":"transfer"}')]) + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_operations(self.client, hive_path(ROLE_POSTING), tx, chain_id=HIVE_CHAIN_ID) + self.assertEqual(self._recover_ops_signer(tx, resp.signature), posting.raw_public_key) + + def test_hive_sign_ops_custom_json_active(self): + """custom_json with required_auths (active tier) must sign with the + ACTIVE key — and does.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + tx = _ops_tx([_op_custom_json(["kkadmin"], [], "witness-ops", '{"op":"x"}')]) + active = hive.get_public_key(self.client, hive_path(ROLE_ACTIVE), show_display=False) + resp = hive.sign_operations(self.client, hive_path(ROLE_ACTIVE), tx, chain_id=HIVE_CHAIN_ID) + self.assertEqual(self._recover_ops_signer(tx, resp.signature), active.raw_public_key) + + def _assert_ops_fails(self, fragment, tx, path=None): + from keepkeylib.client import CallException + with self.assertRaises(CallException) as ctx: + hive.sign_operations(self.client, path or hive_path(ROLE_POSTING), + tx, chain_id=HIVE_CHAIN_ID) + if fragment: + self.assertIn(fragment, str(ctx.exception)) + + def test_hive_sign_ops_rejects_excluded_and_unknown_ops(self): + """Op types 2/9/10 are PERMANENTLY excluded (dedicated messages keep + their stronger invariants); unknown types reject too. The parser + refuses on the op-type byte, so the bodies never matter.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + for op_type in (2, 9, 10): + self._assert_ops_fails("dedicated message", + _ops_tx([_varint(op_type)])) + # 49 = recurrent_transfer: a real Hive op deliberately kept out of the + # table. (This previously used op 3, mislabelled "comment_options"; + # op 3 is transfer_to_vesting and is now clear-signed, so it no longer + # exercises the unknown-op path.) + self._assert_ops_fails("unsupported operation", + _ops_tx([_varint(49)])) + + def test_hive_sign_ops_rejects_malformed_structure(self): + """Zero ops, >4 ops, nonzero extensions, trailing bytes, overlong + varint, out-of-range weight — each refused with a specific error.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + vote = _op_vote("kkvoter", "author", "permlink", 100) + self._assert_ops_fails("op count", _ops_tx([], opcount=0)) + self._assert_ops_fails("op count", _ops_tx([vote] * 5)) + self._assert_ops_fails("extensions must be empty", + _ops_tx([vote], ext=b"\x01")) + self._assert_ops_fails("trailing bytes", _ops_tx([vote]) + b"\x00") + # op_count as an overlong 6-byte varint encoding of 1 + head = struct.pack(" 2048) + from keepkeylib.client import CallException + with self.assertRaises(CallException): + hive.sign_operations(self.client, hive_path(ROLE_POSTING), tx, + chain_id=HIVE_CHAIN_ID) + + # ── Phase-3 op table ───────────────────────────────────────────────── + # Every tx below is built by THIS file's serializer, never by firmware, so + # a parser bug and a serializer bug cannot cancel out. + + def _ops_signs_with(self, tx, role): + """Sign tx with `role` and assert the signature recovers to that key.""" + key = hive.get_public_key(self.client, hive_path(role), show_display=False) + resp = hive.sign_operations(self.client, hive_path(role), tx, + chain_id=HIVE_CHAIN_ID) + self.assertEqual(self._recover_ops_signer(tx, resp.signature), + key.raw_public_key) + + def test_hive_sign_ops_limit_order_create(self): + """The op that motivated phase 3: a HIVE->HBD internal-market swap. + Active tier, since it moves funds.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + tx = _ops_tx([_op_limit_order_create("kktrader", 42, 1500, "HIVE", + 400, "HBD", True, 1700003600)]) + self._ops_signs_with(tx, ROLE_ACTIVE) + + def test_hive_sign_ops_limit_order_cancel(self): + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + self._ops_signs_with(_ops_tx([_op_limit_order_cancel("kktrader", 42)]), + ROLE_ACTIVE) + + def test_hive_sign_ops_active_tier_value_ops(self): + """The active-tier ops that move or lock value all sign with active.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + for op in ( + _op_transfer_to_vesting("kkuser", "kkuser", 1000), + _op_convert("kkuser", 7, 2500), + _op_transfer_to_savings("kkuser", "kkfriend", 1500, "HBD", "rent"), + _op_transfer_from_savings("kkuser", 7, "kkfriend", 1500, "HIVE"), + _op_delegate_vesting_shares("kkuser", "kkfriend", 1000000), + _op_withdraw_vesting("kkuser", 5000000), + ): + self._ops_signs_with(_ops_tx([op]), ROLE_ACTIVE) + + def test_hive_sign_ops_posting_tier_ops(self): + """claim_reward_balance is posting tier — claiming is not spending.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + tx = _ops_tx([_op_claim_reward_balance("kkuser", 1234, 5678, 90123456)]) + self._ops_signs_with(tx, ROLE_POSTING) + + def test_hive_sign_ops_zero_amount_semantics(self): + """Zero means something for these two and nothing for the rest, so the + parser must not apply one blanket rule.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + # 0 VESTS withdraw_vesting cancels an in-progress power-down. + self._ops_signs_with(_ops_tx([_op_withdraw_vesting("kkuser", 0)]), + ROLE_ACTIVE) + # 0 VESTS delegation removes an existing delegation. + self._ops_signs_with( + _ops_tx([_op_delegate_vesting_shares("kkuser", "kkfriend", 0)]), + ROLE_ACTIVE) + # A zero power-up, by contrast, does nothing and is refused. + self._assert_ops_fails("amount must be greater than zero", + _ops_tx([_op_transfer_to_vesting("kkuser", "kkuser", 0)]), + path=hive_path(ROLE_ACTIVE)) + # Nothing to claim. + self._assert_ops_fails("no effect", + _ops_tx([_op_claim_reward_balance("kkuser", 0, 0, 0)])) + + def test_hive_sign_ops_asset_symbol_and_precision_pinned(self): + """A swapped symbol hides a ~2000x value difference behind an + identical-looking number; a wrong precision moves the decimal point + relative to what the chain applies. Both must be refused.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + active = hive_path(ROLE_ACTIVE) + + # transfer_to_vesting is HIVE-only. + wrong_symbol = (_varint(3) + _string("kkuser") + _string("kkuser") + + _asset(1000, "HBD")) + self._assert_ops_fails("malformed operation", _ops_tx([wrong_symbol]), + path=active) + # Right symbol, wrong precision. + wrong_precision = (_varint(3) + _string("kkuser") + _string("kkuser") + + _asset_raw(1000, 6, "HIVE")) + self._assert_ops_fails("malformed operation", _ops_tx([wrong_precision]), + path=active) + # Negative int64 would render as an enormous positive amount. + negative = (_varint(3) + _string("kkuser") + _string("kkuser") + + _asset_raw(-1000, 3, "HIVE")) + self._assert_ops_fails("malformed operation", _ops_tx([negative]), + path=active) + # An order priced VESTS-for-HBD is not a market that exists. + vests_order = (_varint(5) + _string("kktrader") + struct.pack(" 100% + tx = _ops_tx([comment, _op_comment_options( + "kkauthor", "my-post", 1000000, 10000, beneficiaries=bens)]) + self._assert_ops_fails("beneficiaries", tx) + + def test_hive_sign_ops_account_update2_is_rejected(self): + """account_update2 can rotate account keys. Any authority or memo_key + present is refused, so no key change is summarized as profile-only.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + self._assert_ops_fails( + "authority changes", + _ops_tx([_op_account_update2("kkuser", '{"profile":{}}', "", + authority_present=True)]), + path=hive_path(ROLE_ACTIVE)) + + self._assert_ops_fails( + "authority changes", + _ops_tx([_op_account_update2("kkuser", '{"profile":{}}', "", + memo_key_present=True)]), + path=hive_path(ROLE_ACTIVE)) + self._assert_ops_fails( + "authority changes", + _ops_tx([_op_account_update2("kkuser", "", '{"profile":{}}', + memo_key_present=True)]), + path=hive_path(ROLE_POSTING)) + + def test_hive_sign_ops_truncated_bodies_rejected(self): + """The signature covers the whole buffer, so a short read would mean + signing bytes the device never displayed. Every truncation must be + refused rather than partially parsed.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + for op in (_op_limit_order_create("kktrader", 1, 100, "HIVE", 50, + "HBD", False, 9), + _op_claim_reward_balance("kkuser", 1, 1, 1), + _op_transfer_from_savings("kkuser", 7, "kkfriend", 1500, + "HBD", "memo")): + # One byte short is the boundary case; a deeper cut exercises the + # length-prefixed string readers. + for cut in (1, 5): + if cut >= len(op): + continue + self._assert_ops_fails(None, _ops_tx([op[:-cut]]), + path=hive_path(ROLE_ACTIVE)) + + def test_hive_sign_message_rejects_chain_id_prefix(self): + """A 'message' that begins with the mainnet chain id would hash to a + broadcastable TRANSACTION digest (tx digest = SHA256(chain_id || tx)). + The firmware must refuse the collision.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + from keepkeylib.client import CallException + disguised_tx = HIVE_CHAIN_ID + b"\x39\x30" + b"\x00" * 40 + with self.assertRaises(CallException) as ctx: + hive.sign_message(self.client, hive_path(ROLE_ACTIVE), disguised_tx) + self.assertIn("chain ID", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_msg_mayachain_getaddress.py b/tests/test_msg_mayachain_getaddress.py index 372092e8..68439ec2 100644 --- a/tests/test_msg_mayachain_getaddress.py +++ b/tests/test_msg_mayachain_getaddress.py @@ -17,5 +17,16 @@ def test_mayachain_get_address(self): address = self.client.mayachain_get_address(parse_path(DEFAULT_BIP32_PATH), testnet=True) self.assertEqual(address, "smaya1ls33ayg26kmltw7jjy55p32ghjna09zp2mf0av") + def test_mayachain_show_address(self): + self.requires_firmware("7.9.1") + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + expected = "smaya1ls33ayg26kmltw7jjy55p32ghjna09zp2mf0av" + address = self.assert_shows_address( + lambda: self.client.mayachain_get_address( + parse_path(DEFAULT_BIP32_PATH), show_display=True, testnet=True), + expected) + self.assertEqual(address, expected) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_msg_mayachain_signtx.py b/tests/test_msg_mayachain_signtx.py index 6a050a88..55ca1cde 100644 --- a/tests/test_msg_mayachain_signtx.py +++ b/tests/test_msg_mayachain_signtx.py @@ -1,15 +1,26 @@ +import hashlib import unittest import common from base64 import b64encode from binascii import hexlify, unhexlify +from ecdsa import VerifyingKey, SECP256k1 +from ecdsa.util import sigdecode_string + import keepkeylib.messages_pb2 as proto +import keepkeylib.messages_mayachain_pb2 as mayachain_proto import keepkeylib.types_pb2 as proto_types +from keepkeylib.client import CallException from keepkeylib.tools import parse_path +from keepkeylib.signed_metadata import eth_sighash_legacy, keccak256 DEFAULT_BIP32_PATH = "m/44h/931h/0h/0/0" +# Compressed secp256k1 pubkey for the standard test seed at m/44'/931'/0'/0/0. +# Proven by the (green) thorchain frozen-vector test over the same path/curve. +DEVICE_PUBKEY_HEX = b"031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3" + def make_send(from_address, to_address, amount): return { 'type': 'mayachain/MsgSend', @@ -23,31 +34,145 @@ def make_send(from_address, to_address, amount): } } +def recover_eth_signer(sig_r, sig_s, sig_v, digest, chain_id): + """Recover the 20-byte Ethereum signer from a legacy (EIP-155) signature. + + Mirrors the helper proven in test_msg_ethereum_clear_signing.py. Verifying + recovery — rather than asserting r/s lengths — means a wrong digest, wrong + calldata or wrong key fails the test, and it stays correct across router + changes without re-freezing vectors. + """ + from ecdsa import VerifyingKey, SECP256k1, util + if chain_id: + rec = sig_v - (35 + 2 * chain_id) + else: + rec = sig_v - 27 + keys = VerifyingKey.from_public_key_recovery_with_digest( + sig_r + sig_s, digest, SECP256k1, hashfunc=None, + sigdecode=util.sigdecode_string, + ) + return keccak256(keys[rec].to_string())[-20:] + + class TestMsgMayaChainSignTx(common.KeepKeyTest): - @unittest.skip("TODO: capture expected signatures from emulator") - def test_mayachain_sign_tx(self): - self.requires_firmware("7.9.1") + def test_ack_rejects_send_and_deposit_together(self): + """An unused deposit submessage must not suppress the signed tx memo.""" + # The exactly-one-message check is part of the corrected 7.15 release. + self.requires_firmware("7.15.0") self.requires_fullFeature() + self.requires_release_capability("maya-single-message") self.setup_mnemonic_nopin_nopassphrase() - signature = self.client.mayachain_sign_tx( + + response = self.client.call(mayachain_proto.MayachainSignTx( + address_n=parse_path(DEFAULT_BIP32_PATH), account_number=92, + chain_id="mayachain", fee_amount=3000, gas=200000, + memo="SWAP:BTC.BTC:bc1qreviewthismemo", sequence=3, + msg_count=1, testnet=False)) + self.assertIsInstance(response, mayachain_proto.MayachainMsgRequest) + + with self.assertRaises(CallException): + self.client.call(mayachain_proto.MayachainMsgAck( + send=mayachain_proto.MayachainMsgSend( + to_address="maya1jvt443rvhq5h8yrna55yjysvhtju0el7mdujp3", + amount=10000, denom="cacao"), + deposit=mayachain_proto.MayachainMsgDeposit( + asset="MAYA.CACAO", amount=1, memo="unused", + signer="maya1ls33ayg26kmltw7jjy55p32ghjna09zp7z4etj"))) + + def _maya_send_digest(self, account_number, chain_id, fee, gas, memo, + amount, from_address, to_address, sequence): + """SHA256 of the amino StdSignDoc exactly as mayachain.c streams it. + + Byte-for-byte mirror of mayachain_signTxInit/UpdateMsgSend/Finalize + (denom "cacao", type "mayachain/MsgSend", from_address DERIVED BY THE + DEVICE — the host-supplied from_address is not part of the digest). + The identical construction for thorchain ("rune"/"thorchain/MsgSend") + reproduces that suite's green frozen vector, which pins this format. + """ + doc = ('{"account_number":"%s"' + ',"chain_id":"%s"' + ',"fee":{"amount":[{"amount":"%s","denom":"cacao"}],"gas":"%s"}' + ',"memo":"%s"' + ',"msgs":[{"type":"mayachain/MsgSend","value":{' + '"amount":[{"amount":"%s","denom":"cacao"}]' + ',"from_address":"%s"' + ',"to_address":"%s"' + '}}],"sequence":"%s"}') % ( + account_number, chain_id, fee, gas, memo, + amount, from_address, to_address, sequence) + return hashlib.sha256(doc.encode()).digest() + + def _sign_and_verify_send(self, memo, amount=10000, + to_address="maya1jvt443rvhq5h8yrna55yjysvhtju0el7mdujp3"): + """Sign a single-MsgSend maya tx and verify the signature against the + host-reconstructed sign-doc digest and the known device pubkey. A wrong + digest (any field not bound), wrong key, or wrong curve fails here — + no frozen signature vectors to go stale.""" + # The device derives the sign-doc from_address itself (mainnet "maya" + # prefix); fetch it so the host digest matches by construction. + device_address = self.client.mayachain_get_address( + parse_path(DEFAULT_BIP32_PATH)) + + resp = self.client.mayachain_sign_tx( address_n=parse_path(DEFAULT_BIP32_PATH), account_number=92, chain_id="mayachain", fee=3000, gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], - memo="foobar", + msgs=[make_send(device_address, to_address, amount)], + memo=memo, sequence=3, - testnet = True + testnet=False, ) - self.assertEqual(hexlify(signature.signature), "164ea435b39444fa780e453ffe0d0ca07fa74a44272713a283f6297b951e06dc71575e83a6a5405b324c8bc187c50951f1d46fd58acadf060fdf23980d61488a") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - return + + self.assertEqual(hexlify(resp.public_key), DEVICE_PUBKEY_HEX) + self.assertEqual(len(resp.signature), 64) + digest = self._maya_send_digest( + account_number=92, chain_id="mayachain", fee=3000, gas=200000, + memo=memo, amount=amount, from_address=device_address, + to_address=to_address, sequence=3) + vk = VerifyingKey.from_string(unhexlify(DEVICE_PUBKEY_HEX), + curve=SECP256k1) + # Raises BadSignatureError if the device signed anything but this doc. + self.assertTrue(vk.verify_digest(resp.signature, digest, + sigdecode=sigdecode_string)) + + def _confirm_bodies(self): + bodies = [] + original = self.client.callback_ButtonRequest + + def capture(msg): + bodies.append(self.client.debug.read_confirm_text()[1]) + return original(msg) + + self.client.callback_ButtonRequest = capture + return bodies + def test_fee_is_shown_in_whole_cacao(self): + """fee_amount is in 1e-10 CACAO; the sign screen must not print the + raw count.""" + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.setup_mnemonic_nopin_nopassphrase() + bodies = self._confirm_bodies() + self.client.mayachain_sign_tx( + address_n=parse_path(DEFAULT_BIP32_PATH), account_number=92, + chain_id="mayachain", fee=2000000, gas=200000, + msgs=[make_send(self.client.mayachain_get_address( + parse_path(DEFAULT_BIP32_PATH)), + "maya1jvt443rvhq5h8yrna55yjysvhtju0el7mdujp3", + 10000)], + memo="foobar", sequence=3, testnet=False) + self.assertIn("Fee: 0.0002 cacao. Gas: 200000.", "\n".join(bodies)) + + def test_mayachain_sign_tx(self): + """Native CACAO MsgSend with a plain memo; the full raw memo is paged + on the OLED before signing (thorchain_confirm_full_memo is the sole + memo gate for native MAYA).""" + self.requires_firmware("7.9.1") + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + self._sign_and_verify_send(memo="foobar") def test_sign_btc_eth_swap(self): self.requires_firmware("7.9.1") @@ -67,22 +192,17 @@ def test_sign_btc_eth_swap(self): (signatures, serialized_tx) = self.client.sign_tx('Bitcoin', [inp1, ], [out1, ]) self.assertEqual(hexlify(serialized_tx), '010000000182488650ef25a58fef6788bd71b8212038d7f2bbe4750bc7bcb44701e85ef6d5000000006b483045022100c1cf12191f0a50398dae21553d14d5c796ff3e2e1c378bce3d0a7d43fa9bdf4402201245f76291db518dd8b496b4406128ca0e07165c64d2fe927161eee17402f9c40121023230848585885f63803a0a8aecdd6538792d5c539215c91698e315bf0253b43dffffffff0100000000000000003d6a3b535741503a4554482e4554483a3078343165353536303035343832346561366230373332653635366533616436346532306539346534353a34323000000000') - + def test_sign_eth_btc_swap(self): self.requires_firmware("7.1.0") self.requires_fullFeature() + self.requires_release_capability("legacy-evm-router-signing") self.setup_mnemonic_nopin_nopassphrase() - sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( - n=[2147483692,2147483708,2147483648,0,0], - nonce=0x0, - gas_price=0x5FB9ACA00, - gas_limit=0x186A0, - value=0x00, - to=unhexlify('42a5ed456650a09dc10ebc6361a7480fdd61f27b'), - address_type=0, - chain_id=1, - data=unhexlify('1fece7b4' + - '000000000000000000000000345b297ec83add7ff74d2f7933651bffa037d956' + # asgard vault address + address_n = [2147483692,2147483708,2147483648,0,0] + nonce, gas_price, gas_limit, value = 0x0, 0x5FB9ACA00, 0x186A0, 0x00 + to = unhexlify('e3985e6b61b814f7cdb188766562ba71b446b46d') # Maya router v4 (firmware-pinned) + data = unhexlify('1fece7b4' + + '000000000000000000000000345b297ec83add7ff74d2f7933651bffa037d956' + # asgard vault address '0000000000000000000000000000000000000000000000000000000000000000' + # asset ETH '000000000000000000000000000000000000000000000065945acd2b867ef000' + # amount '0000000000000000000000000000000000000000000000000000000000000080' + # offset of memo string from after func sig @@ -90,10 +210,22 @@ def test_sign_eth_btc_swap(self): # SWAP:BTC.BTC:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420 '535741503a4254432e4254433a30783431653535363030353438323465613662' + # mayachain transaction memo '30373332653635366533616436346532306539346534353a3432300000000000') - ) - self.assertEqual(sig_v, 37) - self.assertEqual(hexlify(sig_r), 'da472e9d40fb3c981cebbc6dec70d9d756e5f03aca1ca4259f26dd4c257f8a68') - self.assertEqual(hexlify(sig_s), '025af171f9bd0af71266417f82a72214f349d96ed6505288c1a4032463ef920a') + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=address_n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + value=value, to=to, address_type=0, chain_id=1, data=data) + # Verify the signature is over the EXACT tx above and by THIS device's + # key, rather than merely checking r/s lengths (which a wrong digest, + # wrong calldata or wrong key would also pass). Recovery keeps the test + # correct across router changes without re-freezing r/s vectors. + self.assertIn(sig_v, [37, 38]) # EIP-155 chain_id=1 + self.assertEqual(len(sig_r), 32) + self.assertEqual(len(sig_s), 32) + digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, + data, 1) + signer = recover_eth_signer(sig_r, sig_s, sig_v, digest, 1) + # ethereum_get_address returns the raw 20 bytes. NB: KeepKeyTest's + # assertEqual override takes no msg argument. + self.assertEqual(signer, self.client.ethereum_get_address(address_n)) def test_sign_btc_add_liquidity(self): @@ -114,200 +246,76 @@ def test_sign_btc_add_liquidity(self): (signatures, serialized_tx) = self.client.sign_tx('Bitcoin', [inp1, ], [out1, ]) self.assertEqual(hexlify(serialized_tx), '010000000182488650ef25a58fef6788bd71b8212038d7f2bbe4750bc7bcb44701e85ef6d5000000006b483045022100ed9206af5ba7fe82dda17cf20574197924a120be5b415f875f7d9880f4591e4202201081cb688cceadad65dc20e9843d910d895342ce9316f792b748b0e4a0f757870121023230848585885f63803a0a8aecdd6538792d5c539215c91698e315bf0253b43dffffffff0100000000000000005e6a4c5b4144443a4254432e4254433a74686f7270756231616464776e7065707132796e717435303066616733777978736a7576373537307178723872717470783933687733637071617178747778657379373675746774656d703a34323000000000') - + def test_sign_eth_add_liquidity(self): self.requires_firmware("7.9.1") self.requires_fullFeature() + self.requires_release_capability("legacy-evm-router-signing") self.setup_mnemonic_nopin_nopassphrase() - sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( - n=[2147483692,2147483708,2147483648,0,0], - nonce=0x0, - gas_price=0x5FB9ACA00, - gas_limit=0x186A0, - value=0x00, - to=unhexlify('41e5560054824ea6b0732e656e3ad64e20e94e45'), - address_type=0, - chain_id=1, - data=unhexlify('1fece7b4' + - '0000000000000000000000000000000000000000000000000000000000000000' + + address_n = [2147483692,2147483708,2147483648,0,0] + nonce, gas_price, gas_limit, value = 0x0, 0x5FB9ACA00, 0x186A0, 0x00 + to = unhexlify('e3985e6b61b814f7cdb188766562ba71b446b46d') # Maya router v4 (firmware-pinned) + data = unhexlify('1fece7b4' + + '0000000000000000000000000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000080' + # offset of memo string from 4 - '000000000000000000000000000000000000000000000000000000000000003a' + # length of memo string in bytes (58: ADD:ETH.ETH::420) + '000000000000000000000000000000000000000000000000000000000000003a' + # length of memo string in bytes (58: ADD:ETH.ETH::420; the 59th byte the old 0x3b counted was ABI padding) # ADD:ETH.ETH:0xc5b2608927ea95ed43f842f553e3a27b09c050e8:420 '4144443a4554482e4554483a3078633562323630383932376561393565643433' + '663834326635353365336132376230396330353065383a343230000000000000') + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=address_n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + value=value, to=to, address_type=0, chain_id=1, data=data) + # Verify the signature is over the EXACT tx above and by THIS device's + # key, rather than merely checking r/s lengths (which a wrong digest, + # wrong calldata or wrong key would also pass). Recovery keeps the test + # correct across router changes without re-freezing r/s vectors. + self.assertIn(sig_v, [37, 38]) # EIP-155 chain_id=1 + self.assertEqual(len(sig_r), 32) + self.assertEqual(len(sig_s), 32) + digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, + data, 1) + signer = recover_eth_signer(sig_r, sig_s, sig_v, digest, 1) + # ethereum_get_address returns the raw 20 bytes. NB: KeepKeyTest's + # assertEqual override takes no msg argument. + self.assertEqual(signer, self.client.ethereum_get_address(address_n)) - ) - self.assertEqual(sig_v, 37) - self.assertEqual(hexlify(sig_r), '7adc5bda6e66b37a81962557c844509c4bfaa1e9217fc6d05968286d60b67dbf') - self.assertEqual(hexlify(sig_s), '613479150c4cfbcdc8243055aa5137afc89826c4176c420a60409f139171831b') - - @unittest.skip("TODO: capture expected signatures from emulator") def test_mayachain_remove_liquidity(self): - self.requires_firmware("7.1.1") + """WITHDRAW memo: pool + basis points paged in full on the OLED.""" + self.requires_firmware("7.9.1") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], - memo="WITHDRAW:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:10000", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "13d8ab1a8514c6163064a3e097dd8c33d7063b5994f2ce1c71c691f6fdcf4f1e54860ca7c6d8a478e15b2b07274d9752d8df0af0cd48a6113adf9ecf881ff20e") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - return + self._sign_and_verify_send( + memo="WITHDRAW:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:10000") - @unittest.skip("TODO: capture expected signatures from emulator") def test_mayachain_sign_tx_memos(self): + """Every memo shape MAYA routes on (SWAP/s/=/ADD/a/+ and bare-pool) + signs, and each signature is bound to its exact memo bytes — a memo + substitution changes the sign-doc digest and fails verification.""" self.requires_firmware("7.9.1") + self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], + memos = [ # full memo - memo="SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "a1b9082c6817d4c80b82a2d955f2be26a39b8a5e6909c5fcc52114a5c5e5476e68df191c2be5c88e35ef3090c3bafbd44083e32fbf4d26a809218aeec42ec8a9") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], + "SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420", # no limit, 's' for swap token - memo="s:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "77f24a90428d104fcb0b2bd5ffe1f05e800c032e01a0f1de883616ba8e26c3781044bc8ce1497d24b1b0997061ed664d378c62e04bac54b4ffe5699177c7387f") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], + "s:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:", # swap to self, "=" for swap token - memo="=:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7::420", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "67ca2ad82a276645bea14fa9ae7d3f947fefe15906f93a605387d21db37c51f46f2961b62efcb7762d9008b1dbb723b2156294f35031cdd16e8e6931f68e4844") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], + "=:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7::420", # swap to self, no limit - memo="SWAP:BTC.BTC", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "6e6908262ae5f268e104a567f64b4be18297cc68577962925a1dcbcc2333f7ba5a5446f623a774359d68335804e88448bf432c95dc9777b26effecb339a790a9") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], + "SWAP:BTC.BTC", # full memo - memo="ADD:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "186e81a054517ce4f5134fa5ed6acc6398bd15d5c58361babadd9087fafd7a9122c7978ecc6710f76bebd46df72523f3409c33af387473f61ef167575f11a68b") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], - #'a' for add liquidity - memo="a:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", - #memo="a:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "a98354ed6ee626603cd4416d314d1b875c5ab6a6af83fe1be05a6ac56d620e8f2322d500bba6a7f6e0e2fae810016ebc00be5a580766f171cd5f4a5b2e67263f") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], - #"+" for add liquidity - memo="+:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "0409d104aaafe400e86b6172811bf1b44b6cc0065c13df10083a86d02b13b8ce7d40a4935bc022c76dae4793223c0c7d8446c83acdbd8d0188d35d2b7b8e22fc") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - return + "ADD:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", + # 'a' for add liquidity + "a:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", + # "+" for add liquidity + "+:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", + ] + for memo in memos: + self._sign_and_verify_send(memo=memo) if __name__ == '__main__': unittest.main() diff --git a/tests/test_msg_nano_getaddress.py b/tests/test_msg_nano_getaddress.py index 2ae17ac0..a3def5eb 100644 --- a/tests/test_msg_nano_getaddress.py +++ b/tests/test_msg_nano_getaddress.py @@ -47,7 +47,8 @@ def test(self): ] for path, show, address in vec: - res = self.client.nano_get_address('Nano', path, show) + call = lambda: self.client.nano_get_address('Nano', path, show) + res = self.assert_shows_address(call, address) if show else call() self.assertEqual(res.address, address) if __name__ == '__main__': diff --git a/tests/test_msg_osmosis_getaddress.py b/tests/test_msg_osmosis_getaddress.py new file mode 100644 index 00000000..98b5445d --- /dev/null +++ b/tests/test_msg_osmosis_getaddress.py @@ -0,0 +1,23 @@ +import unittest +import common + +from keepkeylib.tools import parse_path + + +class TestMsgOsmosisGetAddress(common.KeepKeyTest): + + def test_osmosis_show_address(self): + self.requires_fullFeature() + self.requires_firmware("7.7.0") + self.requires_message("OsmosisGetAddress") + self.setup_mnemonic_nopin_nopassphrase() + path = parse_path("m/44h/118h/0h/0/0") + expected = self.client.osmosis_get_address(path) + address = self.assert_shows_address( + lambda: self.client.osmosis_get_address(path, show_display=True), + expected) + self.assertEqual(address, expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_osmosis_signtx.py b/tests/test_msg_osmosis_signtx.py new file mode 100644 index 00000000..f9bbaca9 --- /dev/null +++ b/tests/test_msg_osmosis_signtx.py @@ -0,0 +1,344 @@ +"""Osmosis MsgSend signing — with the confirm-screen amount as the point. + +Osmosis had NO device tests at all: the confirm screens that render amounts +were covered only by host-side unit tests of the formatter in isolation. That +matters more than it sounds, because 7.15.0 CHANGED how every Osmosis amount +is drawn. + +Before, fsm_msg_osmosis.h rendered amounts with atof() + "%.6f". A float +carries ~7 significant decimal digits, so a large amount was displayed +ROUNDED on the very screen the user approves: + + 123456789123456 uosmo -> shown as "123456792.000000 OSMO" + actual 123456789.123456 OSMO + +The signature was over the correct amount either way — the lie was only on +the screen, which is the half a hardware wallet exists to get right. It now +formats with bounded decimal-string arithmetic. Native uosmo values must be +canonical uint64 strings; alternate spellings and overflow are rejected +before confirmation or hashing. + +These tests are paired with SECTIONS entries carrying screenshot hints, so +the rendered frame is captured as evidence. A test asserting only "it signed" +cannot prove what the OLED drew. + +pyk's osmosis_sign_tx currently implements osmosis-sdk/MsgSend only; the +delegate/undelegate/LP/swap/IBC screens share the same formatter but are not +reachable from here until the client learns those message types. +""" +import unittest +import common + +from binascii import hexlify + +from keepkeylib import messages_pb2 as base_proto +from keepkeylib import messages_osmosis_pb2 as osmosis_proto +from keepkeylib.client import CallException, ProtocolMixin +from keepkeylib.tools import parse_path + +# Osmosis uses the Cosmos coin type (118), not one of its own. +DEFAULT_BIP32_PATH = "m/44h/118h/0h/0/0" + + +def make_send(from_address, to_address, amount, denom='uosmo'): + return { + 'type': 'osmosis-sdk/MsgSend', + 'value': { + 'from_address': from_address, + 'to_address': to_address, + 'amount': [{'denom': denom, 'amount': str(amount)}], + }, + } + + +class TestMsgOsmosisSignTx(common.KeepKeyTest): + + def _address(self): + """Ask the device for its own osmo1 address. + + Deliberately NOT a hardcoded constant: the firmware bech32-decodes + to_address and refuses a bad checksum, so a literal invented by + swapping a cosmos1 prefix for osmo1 fails with the opaque "Failed to + include send message in transaction". Deriving it keeps the fixture + honest and makes these self-sends. + """ + # osmosis_get_address is decorated @field('address'), so it already + # returns the string rather than the OsmosisAddress message. + return self.client.osmosis_get_address( + address_n=parse_path(DEFAULT_BIP32_PATH) + ) + + def _sign(self, amount, denom='uosmo'): + addr = self._address() + return self.client.osmosis_sign_tx( + address_n=parse_path(DEFAULT_BIP32_PATH), + account_number=16359, + chain_id="osmosis-1", + fee=800, + gas=290000, + msgs=[make_send(addr, addr, amount, denom)], + memo="", + sequence=17, + ) + + def _start_raw_signing(self): + """Start the wire protocol without the high-level MsgSend checks.""" + addr = self._address() + resp = self.client.call(osmosis_proto.OsmosisSignTx( + address_n=parse_path(DEFAULT_BIP32_PATH), + account_number=16359, + chain_id="osmosis-1", + fee_amount=800, + gas=290000, + memo="", + sequence=17, + msg_count=1, + )) + self.assertIsInstance(resp, osmosis_proto.OsmosisMsgRequest) + return addr + + def test_osmosis_sign_tx(self): + """Baseline: a whole-OSMO send signs and returns a well-formed + secp256k1 signature + compressed pubkey.""" + self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.setup_mnemonic_nopin_nopassphrase() + + sig = self._sign(1500000) # 1.500000 OSMO + self.assertEqual(len(sig.signature), 64) + self.assertEqual(len(sig.public_key), 33) + self.assertIn(hexlify(sig.public_key)[:2], (b'02', b'03')) + + def test_osmosis_send_amount_beyond_float_precision(self): + """THE regression. 123456789123456 uosmo needs 15 significant digits; + a float holds ~7, so the old atof()+"%.6f" path drew + "123456792.000000 OSMO" over a transaction that actually moves + 123456789.123456 OSMO. The captured frame is the proof — assert here + only that the device signs it, and read the amount off the screenshot. + """ + self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.setup_mnemonic_nopin_nopassphrase() + + sig = self._sign(123456789123456) + self.assertEqual(len(sig.signature), 64) + + def test_osmosis_send_subunit_amount(self): + """500 uosmo is 0.000500 OSMO — six decimal places, no integer part. + The formatter must not collapse it to "0" or drop the tail.""" + self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.setup_mnemonic_nopin_nopassphrase() + + sig = self._sign(500) + self.assertEqual(len(sig.signature), 64) + + def test_osmosis_send_denom_is_committed_to_the_signature(self): + """A raw MsgSend signs the reviewed canonical denomination. + + Two otherwise-identical sends must produce different signatures when + only the denomination changes. This catches both the old hardcoded + ``uosmo`` serializer and any future display/signing mismatch. + """ + self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.setup_mnemonic_nopin_nopassphrase() + + def sign_denom(denom): + addr = self._start_raw_signing() + response = self.client.call(osmosis_proto.OsmosisMsgAck( + send=osmosis_proto.OsmosisMsgSend( + from_address=addr, + to_address=addr, + denom=denom, + amount='1500000', + ) + )) + self.assertIsInstance(response, osmosis_proto.OsmosisSignedTx) + self.assertEqual(len(response.signature), 64) + return response + + native = sign_denom('uosmo') + non_native = sign_denom('uatom') + self.assertNotEqual(hexlify(native.signature), + hexlify(non_native.signature)) + self.assertEqual(hexlify(native.public_key), + hexlify(non_native.public_key)) + + def test_osmosis_send_rejects_noncanonical_wire_amounts(self): + """Wire callers cannot exploit strtoull spellings or saturation.""" + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.requires_release_capability("osmosis-wire-guards") + self.setup_mnemonic_nopin_nopassphrase() + + for amount in ('01', '-1', ' 1', '18446744073709551616'): + addr = self._start_raw_signing() + with self.assertRaises(CallException) as ctx: + self.client.call(osmosis_proto.OsmosisMsgAck( + send=osmosis_proto.OsmosisMsgSend( + from_address=addr, + to_address=addr, + denom='uosmo', + amount=amount, + ) + )) + self.assertIn('Invalid Osmosis amount', str(ctx.exception)) + + def test_osmosis_swap_max_fields_are_fully_paged(self): + """Maximum Swap assets exercise separate three-row screen bounds.""" + self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.setup_mnemonic_nopin_nopassphrase() + + addr = self._start_raw_signing() + denom = 'ibc/' + ('A' * 64) + resp = self.client.call(osmosis_proto.OsmosisMsgAck( + swap=osmosis_proto.OsmosisMsgSwap( + sender=addr, + pool_id=1, + token_out_denom=denom, + token_in_denom=denom, + token_in_amount='12345678901234567890123456789012', + token_out_min_amount='12345678901234567890123456789012', + ) + )) + self.assertIsInstance(resp, osmosis_proto.OsmosisSignedTx) + self.assertEqual(len(resp.signature), 64) + + def test_osmosis_amount_is_committed_to_the_signature(self): + """Guards the pairing between what is shown and what is signed: two + sends differing ONLY in amount must produce different signatures. If + they matched, the amount would not be in the digest and the confirm + screen would be decorative.""" + self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.setup_mnemonic_nopin_nopassphrase() + + a = self._sign(1500000) + b = self._sign(1500001) + self.assertNotEqual(hexlify(a.signature), hexlify(b.signature)) + # Same key throughout — only the message differed. + self.assertEqual(hexlify(a.public_key), hexlify(b.public_key)) + + def test_osmosis_signing_is_deterministic(self): + """RFC6979: identical input must yield an identical signature. A + mismatch here means nonce generation is not deterministic, which is a + key-recovery risk long before it is a display problem.""" + self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.setup_mnemonic_nopin_nopassphrase() + + first = self._sign(1500000) + second = self._sign(1500000) + self.assertEqual(hexlify(first.signature), hexlify(second.signature)) + + +class _SessionTransport(object): + def session_begin(self): + pass + + def session_end(self): + pass + + +class _ScriptedOsmosisClient(object): + """Offline driver for the public osmosis_sign_tx helper. + + Mirrors _ScriptedThorchainClient in test_msg_thorchain_signtx.py: no + device, so the version gate can be exercised at both firmware versions in + a run that does not need an emulator. + """ + + osmosis_sign_tx = ProtocolMixin.osmosis_sign_tx + + def __init__(self, version): + self.features = base_proto.Features( + major_version=version[0], + minor_version=version[1], + patch_version=version[2], + ) + self.transport = _SessionTransport() + self.responses = [ + osmosis_proto.OsmosisMsgRequest(), + osmosis_proto.OsmosisSignedTx( + public_key=b'\x02' + b'\x11' * 32, + signature=b'\x22' * 64, + ), + ] + self.sent = [] + + def call(self, message): + self.sent.append(message) + if not self.responses: + raise AssertionError('unexpected device call: %s' % type(message)) + return self.responses.pop(0) + + +class TestOsmosisClientDenom(unittest.TestCase): + """The public helper must reach the denominations firmware supports. + + Firmware commits the host-supplied denom to the signed Amino document from + 7.14.2 on (osmosis_signTxUpdateMsgSend escapes it verbatim), so an + unconditional uosmo-only check in the helper made every supported IBC and + factory denom unreachable except by driving OsmosisMsgAck by hand. + Before 7.14.2 the serializer hardcoded uosmo, so a non-uosmo send there + would sign a uosmo transfer the caller never asked for -- fail closed. + """ + + ADDRESS_N = [0x8000002C, 0x80000076, 0x80000000, 0, 0] + ADDR = 'osmo1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8' + IBC_DENOM = 'ibc/' + ('A' * 64) + + def _sign(self, client, denom): + return client.osmosis_sign_tx( + address_n=self.ADDRESS_N, + account_number=92, + chain_id='osmosis-1', + fee=3000, + gas=200000, + msgs=[{ + 'type': 'osmosis-sdk/MsgSend', + 'value': { + 'amount': [{'denom': denom, 'amount': '1500000'}], + 'from_address': self.ADDR, + 'to_address': self.ADDR, + }, + }], + memo='client denom test', + sequence=3, + ) + + def test_ibc_denom_is_forwarded_on_7_15(self): + client = _ScriptedOsmosisClient((7, 15, 0)) + response = self._sign(client, self.IBC_DENOM) + + self.assertIsInstance(response, osmosis_proto.OsmosisSignedTx) + self.assertEqual(client.sent[1].send.denom, self.IBC_DENOM) + + def test_ibc_denom_is_forwarded_on_7_14_2(self): + """7.14.2 is the first release whose serializer commits the denom.""" + client = _ScriptedOsmosisClient((7, 14, 2)) + response = self._sign(client, self.IBC_DENOM) + + self.assertIsInstance(response, osmosis_proto.OsmosisSignedTx) + self.assertEqual(client.sent[1].send.denom, self.IBC_DENOM) + + def test_non_uosmo_denom_is_rejected_before_7_14_2(self): + client = _ScriptedOsmosisClient((7, 14, 1)) + + with self.assertRaises(CallException) as ctx: + self._sign(client, self.IBC_DENOM) + self.assertIn('Unsupported denomination before firmware 7.14.2', + str(ctx.exception)) + + def test_uosmo_still_signs_on_legacy_firmware(self): + client = _ScriptedOsmosisClient((7, 14, 1)) + response = self._sign(client, 'uosmo') + + self.assertIsInstance(response, osmosis_proto.OsmosisSignedTx) + self.assertEqual(client.sent[1].send.denom, 'uosmo') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_osmosis_validation.py b/tests/test_msg_osmosis_validation.py index 96eff418..50ed0dd0 100644 --- a/tests/test_msg_osmosis_validation.py +++ b/tests/test_msg_osmosis_validation.py @@ -32,6 +32,11 @@ def _assert_missing_parameter_failure(self, ack): self.assertEndsWith(ret.message, "missing required parameters") def test_present_but_empty_amount_is_rejected_before_review(self): + # 7.14.3 treats a present-but-empty protobuf string as a missing + # required parameter. Keep that release control explicit even though + # 7.15 adds the stronger, syntax-specific rejection tested below. + if self.firmware_at_least("7.15.0"): + self.skipTest("7.15+ uses the syntax-specific empty amount gate") self._start_signing() send = osmosis_proto.OsmosisMsgSend( to_address="osmo1g9el7lzjwh9yun2c4jjzhy09j98vkhfx8tzcpt", @@ -42,6 +47,24 @@ def test_present_but_empty_amount_is_rejected_before_review(self): self._assert_missing_parameter_failure( osmosis_proto.OsmosisMsgAck(send=send)) + def test_present_but_empty_amount_is_rejected_as_invalid(self): + # The fail-closed empty-string validator is part of the 7.15 audit + # fixes; 7.14.3 predates that specific Osmosis hardening. + self.requires_firmware("7.15.0") + self.requires_release_capability("osmosis-wire-guards") + self._start_signing() + send = osmosis_proto.OsmosisMsgSend( + to_address="osmo1g9el7lzjwh9yun2c4jjzhy09j98vkhfx8tzcpt", + amount="", + denom="uosmo", + ) + self.assertTrue(send.HasField("amount")) + ret = self.client.call_raw(osmosis_proto.OsmosisMsgAck(send=send)) + self.assertIsInstance(ret, proto.Failure) + self.assertEqual(ret.code, proto_types.Failure_SyntaxError) + self.assertEndsWith(ret.message, + "Invalid Osmosis amount or denomination") + def test_ibc_omitted_amount_and_receiver_are_rejected_before_review(self): self._start_signing() transfer = osmosis_proto.OsmosisMsgIBCTransfer( diff --git a/tests/test_msg_ping.py b/tests/test_msg_ping.py index 4cf55a89..5c3b9602 100644 --- a/tests/test_msg_ping.py +++ b/tests/test_msg_ping.py @@ -27,6 +27,26 @@ class TestPing(common.KeepKeyTest): + def test_protected_ping_preserves_message_presence_after_debug_read(self): + self.requires_firmware("7.14.2") + self.requires_release_capability("protected-ping-presence") + for message in (None, '', 'ping response'): + with self.subTest(message=message): + request = proto.Ping(button_protection=True) + if message is not None: + request.message = message + response = self.client.call_raw(request) + self.assertIsInstance(response, proto.ButtonRequest) + # Read the screen while the normal response is suspended. + self.client.debug.read_layout() + self.client.debug.press_yes() + response = self.client.call_raw(proto.ButtonAck()) + self.assertIsInstance(response, proto.Success) + self.assertEqual(response.HasField('message'), message is not None) + if message is not None: + self.assertEqual(response.message, message) + + def test_ping(self): self.setup_mnemonic_pin_passphrase() self.client.clear_session() @@ -142,7 +162,8 @@ def test_authenticator_passphrase_cancel_is_terminal(self): # local cache. This is the precondition that made the stale-data path # reachable after ClearSession. self.client.ping('\x19wipeAuthdata:') - init_auth = '\x15initializeAuth:example.com:alice:JBSWY3DPEHPK3PXP' + init_auth = ('\x15initializeAuth:example.com:alice:' + 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP') self.client.ping(init_auth) self.client.clear_session() # The wipe/add-account confirmations establish the stale-cache diff --git a/tests/test_msg_recoverydevice_cipher.py b/tests/test_msg_recoverydevice_cipher.py index 2dfab286..ab9d0ee8 100644 --- a/tests/test_msg_recoverydevice_cipher.py +++ b/tests/test_msg_recoverydevice_cipher.py @@ -19,6 +19,7 @@ # The script has been modified for KeepKey Device. import unittest +import time import common from keepkeylib import messages_pb2 as proto @@ -117,6 +118,7 @@ def test_nopin_nopassphrase(self): ret = self.client.call_raw(proto.ButtonAck()) mnemonic_words = mnemonic.split(' ') + captured_cipher = False for index, word in enumerate(mnemonic_words): for character in word: @@ -124,6 +126,12 @@ def test_nopin_nopassphrase(self): self.client._capture_oled_after_animation( 0.35, (76, 256, 0, 64)) cipher = self.client.debug.read_recovery_cipher() + if not captured_cipher: + # CharacterRequest is driven manually and never reaches + # callback_ButtonRequest(); capture only after DebugLink + # proves this is the active randomized cipher grid. + self.client.capture_oled() + captured_cipher = True encoded_character = cipher[ord(character) - 97] ret = self.client.call_raw(proto.CharacterAck(character=encoded_character)) @@ -180,7 +188,7 @@ def test_invalid_bip39_word_rejected(self): With enforce_wordlist=True, completing a word that isn't in the BIP-39 wordlist must return Failure immediately. - Requires firmware 7.15.0+ (per-word validation). + The canonical 7.15 product includes per-word validation. """ self.requires_firmware("7.15.0") ret = self.client.call_raw(proto.RecoveryDevice(word_count=12, @@ -313,16 +321,36 @@ def test_reset_and_recover(self): mnemonic = [] while isinstance(resp, proto.ButtonRequest): - mnemonic.append(self.client.debug.read_reset_word()) + words = self.client.debug.read_reset_word() + # The debug build raises one ButtonRequest per physical + # subpage, and every subpage of a word group reports the same + # reset_word. Whether a group spills onto a second subpage + # depends on glyph widths, so without this the random + # mnemonic made the test flaky ("Invalid mnemonic"). + if not mnemonic or mnemonic[-1] != words: + mnemonic.append(words) self.client.debug.press_yes() + # A subpage cannot finish on debug approval alone. Its own + # ButtonAck is required; a stale ack from the prior subpage + # must not release the next request prematurely. + time.sleep(0.2) + self.assertFalse(self.client.transport.ready_to_read(), + 'backup page completed before its ButtonAck') resp = self.client.call_raw(proto.ButtonAck()) + self.assertIsInstance(resp, proto.Success, + 'reset completion at %d bits' % strength) + self.assertEqual(resp.message, 'Device reset') mnemonic = ' '.join(mnemonic) + self.assertEqual(len(mnemonic.split()), strength // 32 * 3) # wipe device ret = self.client.call_raw(proto.WipeDevice()) + self.assertIsInstance(ret, proto.ButtonRequest) self.client.debug.press_yes() ret = self.client.call_raw(proto.ButtonAck()) + self.assertIsInstance(ret, proto.Success) + self.assertEqual(ret.message, 'Device wiped') # recover devce ret = self.client.call_raw(proto.RecoveryDevice(word_count=int(strength/32*3), @@ -367,8 +395,11 @@ def test_reset_and_recover(self): # wipe device ret = self.client.call_raw(proto.WipeDevice()) + self.assertIsInstance(ret, proto.ButtonRequest) self.client.debug.press_yes() ret = self.client.call_raw(proto.ButtonAck()) + self.assertIsInstance(ret, proto.Success) + self.assertEqual(ret.message, 'Device wiped') def test_vuln1971(self): self.setup_mnemonic_allallall() diff --git a/tests/test_msg_resetdevice.py b/tests/test_msg_resetdevice.py index 7525d6a2..dd34e2c3 100644 --- a/tests/test_msg_resetdevice.py +++ b/tests/test_msg_resetdevice.py @@ -25,8 +25,48 @@ import time from keepkeylib import messages_pb2 as proto +from keepkeylib import types_pb2 as proto_types from mnemonic import Mnemonic +# Dice derivations, restated here independently of the firmware so the tests +# check the published formula rather than whatever the device happens to do. +# The byte tags match lib/firmware/dice_input.c; the shape mirrors Coldcard's +# so its published verifier applies to ONLY mode unchanged. +DICE_TAG_USER = b'KK\x01D' +DICE_TAG_MIX = b'KK\x01SM' + + +def dice_only_seed(rolls): + """ONLY mode: seed = SHA256(rolls). Nothing else participates.""" + return hashlib.sha256(rolls.encode('ascii')).digest() + + +def dice_mixed_seed(device_entropy, rolls): + """MIXED mode: user = SHA256(tag || rolls); + seed = SHA256(SHA256(tag2 || device_entropy || user)).""" + user = hashlib.sha256(DICE_TAG_USER + rolls.encode('ascii')).digest() + inner = hashlib.sha256(DICE_TAG_MIX + device_entropy + user).digest() + return hashlib.sha256(inner).digest() + + +def bip39_words_to_entropy(words): + """Decode a 24-word BIP-39 sentence to its 32 entropy bytes, checking + the checksum. Written out rather than taken from the mnemonic library so + the words the device showed are decoded by code the device did not + write, and so it does not depend on the library version in the test + image.""" + wordlist = Mnemonic('english').wordlist + words = words.split() + if len(words) != 24: + raise ValueError('expected 24 words, got %d' % len(words)) + bits = ''.join('{:011b}'.format(wordlist.index(w)) for w in words) + entropy = bytes(int(bits[i:i + 8], 2) for i in range(0, 256, 8)) + checksum = '{:08b}'.format(hashlib.sha256(entropy).digest()[0]) + if bits[256:] != checksum: + raise ValueError('BIP-39 checksum mismatch') + return entropy + + def generate_entropy(strength, internal_entropy, external_entropy): ''' strength - length of produced seed. One of 128, 192, 256 @@ -154,6 +194,399 @@ def test_reset_device(self): resp = self.client.call_raw(proto.Ping(pin_protection=True)) self.assertIsInstance(resp, proto.Success) + def _inject_rolls(self, target): + """Inject rolls in max_size-40 chunks, exercising undo ('u') along + the way, and simulate the same rules host-side to know the string + the device saw. Extras past `target` are dropped, as on the device. + The pattern stays close to uniform so it passes the 30%-per-face + bias gate for both the 50- and 99-roll targets.""" + chunks = [ + "123456" * 6 + "1234", # 40 digits + "654321" * 6 + "43u2", # 39 digits + undo + "1234561234561234561u2u3", # more undo churn + "612345612345612345612345", # top up past target + ] + expected = [] + for chunk in chunks: + # Mirror dice_input_collect() exactly: it stops consuming a chunk + # the moment the target is reached -- undo included -- and leaves + # the roll screen at that moment. A chunk sent after that would + # arrive at the digest confirm as a "no" decision, so stop too. + for c in chunk: + if len(expected) >= target: + break + if c == 'u': + if expected: + expected.pop() + else: + expected.append(c) + self.client.debug.press_input(chunk) + time.sleep(0.2) + if len(expected) >= target: + break + expected = ''.join(expected) + self.assertEqual(len(expected), target) + return expected + + def _assert_private_dice_state(self): + state = self.client.debug._call(proto.DebugLinkGetState()) + self.assertEqual([], state.ListFields()) + + def _private_dice_reset(self, dice_only, strength, external_entropy): + """Exercise real wire phases without reading the device's contribution. + + Firmware native DiceCeremonyPrivacy fixtures independently prove MIXED + derivation and on-device pages from a known draw. This transport test + proves disclosure suppression, completion and ONLY derivation. Stored + mnemonic diagnostics resume only after the ceremony has committed. + """ + response = self.client.call_raw(proto.ResetDevice( + strength=strength, dice_entropy=True, dice_only=dice_only)) + prompts = 0 + # At roll entry ButtonAck arms input without sending another response. + # Use readiness, never a secret word or screenshot, to detect that wait. + while prompts < 32: + self.assertIsInstance(response, proto.ButtonRequest) + self.assertEqual(response.code, proto_types.ButtonRequest_DiceRoll) + self._assert_private_dice_state() + prompts += 1 + self.client.debug.press_yes() + self.client.transport.write(proto.ButtonAck()) + deadline = time.monotonic() + 1.0 + while not self.client.transport.ready_to_read() and time.monotonic() < deadline: + time.sleep(0.01) + if not self.client.transport.ready_to_read(): + break + response = self.client.transport.read_blocking() + self.assertLess(prompts, 32) + self.assertGreaterEqual(prompts, 2 if dice_only else 3) + self._assert_private_dice_state() + rolls = self._inject_rolls({128: 50, 192: 75, 256: 99}[strength]) + response = self.client.transport.read_blocking() + digest_pages = 0 + while isinstance(response, proto.ButtonRequest): + self.assertEqual(response.code, proto_types.ButtonRequest_DiceRoll) + self._assert_private_dice_state() + digest_pages += 1 + self.assertLess(digest_pages, 32) + self.client.debug.press_yes() + response = self.client.call_raw(proto.ButtonAck()) + self.assertGreater(digest_pages, 0) + self.assertIsInstance(response, proto.EntropyRequest) + self._assert_private_dice_state() + response = self.client.call_raw(proto.EntropyAck(entropy=external_entropy)) + backup_pages = 0 + while isinstance(response, proto.ButtonRequest): + self.assertIn(response.code, (proto_types.ButtonRequest_Other, + proto_types.ButtonRequest_ConfirmWord)) + self._assert_private_dice_state() + backup_pages += 1 + self.assertLess(backup_pages, 32) + self.client.debug.press_yes() + response = self.client.call_raw(proto.ButtonAck()) + self.assertGreater(backup_pages, 1) + self.assertIsInstance(response, proto.Success) + state = self.client.debug._call(proto.DebugLinkGetState()) + self.assertTrue(state.HasField('mnemonic')) + self.assertTrue(Mnemonic('english').check(state.mnemonic)) + return None, rolls, state.mnemonic, response + + def _dice_reset(self, dice_only, strength, external_entropy): + """Drive a dice ResetDevice in the mode the host selects. + + dice_entropy alone is MIXED; with dice_only it is ONLY. Returns + (device_words, rolls, mnemonic, final_resp); device_words is the + 24-word device-entropy sentence MIXED shows before rolling, else ''. + """ + # 7.15 keeps the ceremony private over DebugLink: nothing the device + # drew is readable, so drive the private flow there. + if self.firmware_at_least("7.15.0"): + return self._private_dice_reset(dice_only, strength, external_entropy) + + rolls_needed = {128: 50, 192: 75, 256: 99}[strength] + + previous_layout = self._current_layout_for_capture() + ret = self.client.call_raw(proto.ResetDevice(display_random=False, + strength=strength, + passphrase_protection=False, + pin_protection=False, + language='english', + label='dice', + dice_entropy=True, + dice_only=dice_only)) + + # The consent screen names the mode the host asked for. It is a + # plain confirm: holding proceeds, and the only "no" is cancelling + # the reset, which is the right answer to a mode the user did not + # choose. + self.assertIsInstance(ret, proto.ButtonRequest) + self.assertEqual(ret.code, proto_types.ButtonRequest_DiceRoll) + consent_layout = self._capture_after_stable_transition(previous_layout) + self.client.debug.press_yes() + resp = self.client.call_raw(proto.ButtonAck()) + + # MIXED shows the device-entropy words BEFORE the rolls, one DiceRoll + # request per page, readable over DebugLink. The roll screen reads + # back empty, which is how this loop knows the pages are over. + device_words = [] + while True: + self.assertIsInstance(resp, proto.ButtonRequest) + self.assertEqual(resp.code, proto_types.ButtonRequest_DiceRoll) + words = self.client.debug.read_reset_word() + if not words: + break + if not device_words or device_words[-1] != words: + device_words.append(words) + self.client.debug.press_yes() + resp = self.client.call_raw(proto.ButtonAck()) + dice_entry_layout = self._capture_after_stable_transition(consent_layout) + + self.client.transport.write(proto.ButtonAck()) + time.sleep(0.3) + rolls = self._inject_rolls(rolls_needed) + + # Rolls complete -> full-digest confirmation screen + resp = self.client.transport.read_blocking() + self.assertIsInstance(resp, proto.ButtonRequest) + self.assertEqual(resp.code, proto_types.ButtonRequest_DiceRoll) + self._capture_after_stable_transition(dice_entry_layout) + + # The device-computed digest must cover exactly the injected rolls + self.assertEqual(self.client.debug.read_dice_digest(), + hashlib.sha256(rolls.encode('ascii')).digest()) + + # The full digest pages locally under one request on hardware, but + # under DEBUG_LINK every subpage after a debug decision raises its own + # ButtonRequest, exactly as the backup pager does. Hold through all of + # them; how many there are depends on the digest's glyph widths. + ret = resp + while isinstance(ret, proto.ButtonRequest): + self.assertEqual(ret.code, proto_types.ButtonRequest_DiceRoll) + self.client.debug.press_yes() + ret = self.client.call_raw(proto.ButtonAck()) + + # The wire flow is unchanged: EntropyRequest is still sent and its ack + # consumed. Its bytes must not reach the seed, which the callers prove + # by computing the expected mnemonic without them. + self.assertIsInstance(ret, proto.EntropyRequest) + resp = self.client.call_raw(proto.EntropyAck(entropy=external_entropy)) + + # Explainer dialog, then the paginated backup + self.assertIsInstance(resp, proto.ButtonRequest) + self.client.debug.press_yes() + resp = self.client.call_raw(proto.ButtonAck()) + + mnemonic = [] + while isinstance(resp, proto.ButtonRequest): + words = self.client.debug.read_reset_word() + # Debug subpages repeat their logical word group, as in the + # normal reset collector above. Keep the final seed assertion. + if not mnemonic or mnemonic[-1] != words: + mnemonic.append(words) + self.client.debug.press_yes() + resp = self.client.call_raw(proto.ButtonAck()) + + return ' '.join(device_words), rolls, ' '.join(mnemonic), resp + + def test_reset_device_dice_mixed_is_verifiable(self): + self.requires_dice_modes() + + external_entropy = b'zlutoucky kun upel divoke ody' * 2 + strength = 256 # 99 rolls, 24 words + + device_words, rolls, mnemonic, resp = self._dice_reset( + False, strength, external_entropy) + self.assertIsInstance(resp, proto.Success) + + # The device committed its 32-byte draw as 24 valid BIP-39 words + # before it had seen a single roll. + if device_words is None: + # The device draw is deliberately unavailable to the host. Full + # known-draw derivation is checked by the native companion fixture. + self.assertEqual(24, len(mnemonic.split())) + return + self.assertEqual(24, len(device_words.split())) + device_entropy = bip39_words_to_entropy(device_words) + + # Recompute the seed from exactly what a user holds -- the words they + # copied and the rolls they made -- with the host's EntropyAck bytes + # nowhere in it. A match proves the device used both and ignored the + # host; the expected value comes from the formula, not the device. + seed = dice_mixed_seed(device_entropy, rolls) + self.assertEqual( + mnemonic, Mnemonic('english').to_mnemonic(seed[:strength // 8])) + self.assertEqual(24, len(mnemonic.split())) + + def test_reset_device_dice_only_is_verifiable(self): + self.requires_dice_modes() + + # A nonzero, known host contribution, so a device that mixed it in + # would produce a different sentence and fail below. + external_entropy = b'host bytes that must be ignored' * 2 + strength = 128 # 50 rolls, 12 words: the shorter target too + + device_words, rolls, mnemonic, resp = self._dice_reset( + True, strength, external_entropy) + self.assertIsInstance(resp, proto.Success) + + # Nothing to copy down: the rolls are the entire derivation. + self.assertIn(device_words, ('', None)) + seed = dice_only_seed(rolls) + self.assertEqual( + mnemonic, Mnemonic('english').to_mnemonic(seed[:strength // 8])) + self.assertEqual(12, len(mnemonic.split())) + + def test_reset_device_dice_rejects_biased_rolls(self): + self.requires_dice_modes() + + ret = self.client.call_raw(proto.ResetDevice(display_random=False, + strength=128, + passphrase_protection=False, + pin_protection=False, + language='english', + label='dice', + dice_entropy=True, + dice_only=True)) + self.assertIsInstance(ret, proto.ButtonRequest) + self.client.debug.press_yes() + resp = self.client.call_raw(proto.ButtonAck()) + self.assertIsInstance(resp, proto.ButtonRequest) + self.assertEqual(resp.code, proto_types.ButtonRequest_DiceRoll) + self.client.transport.write(proto.ButtonAck()) + time.sleep(0.3) + + # Fifty ones: one face on 100% of the rolls. Coldcard's rule refuses + # anything over 30%, and so does the device -- before it shows a + # digest, so a loaded die never becomes a wallet. + self.client.debug.press_input('1' * 40) + time.sleep(0.2) + self.client.debug.press_input('1' * 10) + time.sleep(0.2) + + resp = self.client.transport.read_blocking() + self.assertIsInstance(resp, proto.Failure) + self.assertEqual(resp.code, proto_types.Failure_SyntaxError) + + def test_reset_device_dice_only_requires_dice_entropy(self): + self.requires_dice_modes() + + # dice_only is a modifier of the dice ceremony, not a ceremony of its + # own. Refused before any screen, so a host cannot reach the + # rolls-only derivation without also asking for the rolls. + ret = self.client.call_raw(proto.ResetDevice(display_random=False, + strength=128, + passphrase_protection=False, + pin_protection=False, + language='english', + label='dice', + dice_entropy=False, + dice_only=True)) + self.assertIsInstance(ret, proto.Failure) + self.assertEqual(ret.code, proto_types.Failure_SyntaxError) + + def test_reset_device_dice_refuses_no_backup(self): + self.requires_dice_modes() + + # The dice modes exist to be checked against the backup words; a reset + # that never shows them has nothing to verify and would put seed + # material on the screen under a WARNING that recovery is impossible. + ret = self.client.call_raw(proto.ResetDevice(display_random=False, + strength=128, + passphrase_protection=False, + pin_protection=False, + language='english', + label='dice', + no_backup=True, + dice_entropy=True)) + self.assertIsInstance(ret, proto.Failure) + self.assertEqual(ret.code, proto_types.Failure_SyntaxError) + + def test_reset_device_dice_consent_cancel_aborts(self): + self.requires_dice_modes() + + # The consent screen's only "no" is the host's Cancel. It must abort + # the whole ceremony: nothing armed, nothing staged, device still + # uninitialized. + ret = self.client.call_raw(proto.ResetDevice(display_random=False, + strength=128, + passphrase_protection=False, + pin_protection=False, + language='english', + label='dice', + dice_entropy=True, + dice_only=True)) + self.assertIsInstance(ret, proto.ButtonRequest) + self.assertEqual(ret.code, proto_types.ButtonRequest_DiceRoll) + + resp = self.client.call_raw(proto.Cancel()) + self.assertIsInstance(resp, proto.Failure) + self.assertEqual(resp.code, proto_types.Failure_ActionCancelled) + + # An EntropyAck after the abort finds no armed ceremony to consume it. + resp = self.client.call_raw(proto.EntropyAck(entropy=b'\x42' * 32)) + self.assertIsInstance(resp, proto.Failure) + + features = self.client.call_raw(proto.Initialize()) + self.assertIsInstance(features, proto.Features) + self.assertFalse(features.initialized) + + def test_reset_reentry_disarms_entropy_ack(self): + """An abandoned reset must never leave EntropyAck armed. + + Regression this guards: reset_init aborts (dice cancel, PIN mismatch, + ...) left awaiting_entropy set from an earlier run while zeroing + int_entropy, so a following EntropyAck derived the seed from + sha256(0*32 || host_bytes) -- entirely host-chosen. + + The post-RC18 setup hardening closes it EARLIER and more strongly than + the original fix did. + #429 replaced the separate awaiting_entropy flag with a single armed + (kind) ceremony, and setup_stage() now REFUSES to open a second + ceremony on top of an armed one. So the re-entry this test used to + perform is rejected outright rather than being allowed and then + disarmed -- there is no second ceremony to leave armed. Both halves are + asserted below: the refusal, and then the original property. + """ + # 7.14.3: the bitcoin-only release line carries the same single-armed + # ceremony and the dice backport; see test_reset_device_dice. + self.requires_firmware("7.14.3") + self.client.wipe_device() + + # Arm a reset and walk away without acking the entropy request. + ret = self.client.call_raw(proto.ResetDevice(display_random=False, + strength=256, + passphrase_protection=False, + pin_protection=False, + language='english', + label='first')) + self.assertIsInstance(ret, proto.EntropyRequest) + + # Re-entry is REFUSED while a ceremony is armed. This is the #429 + # guard; before it, the second ResetDevice was accepted and the code + # had to remember to disarm the first one. + ret = self.client.call_raw(proto.ResetDevice(display_random=False, + strength=256, + passphrase_protection=False, + pin_protection=False, + language='english', + label='second', + dice_entropy=True)) + self.assertIsInstance(ret, proto.Failure) + self.assertIn('middle of setup', ret.message) + + # Abandon the FIRST ceremony the way the host is told to. + ret = self.client.call_raw(proto.Cancel()) + self.assertIsInstance(ret, proto.Failure) + + # The abandoned reset must be disarmed, so this cannot generate a seed. + ret = self.client.call_raw(proto.EntropyAck(entropy=b'H' * 32)) + self.assertIsInstance(ret, proto.Failure) + self.assertIn('Not in Reset mode', ret.message) + + # And the device must still be uninitialized. + ret = self.client.call_raw(proto.Initialize()) + self.assertFalse(ret.initialized) + def test_reset_device_18_words(self): resp = self._reset_without_pin_and_capture(192) self.assertFalse(resp.pin_protection) @@ -165,9 +598,15 @@ def test_reset_device_24_words(self): self.assertFalse(resp.passphrase_protection) def test_reset_device_pin(self): + self.requires_release_capability('safe-reset-ceremony') external_entropy = b'zlutoucky kun upel divoke ody' * 2 strength = 128 - hides_internal_entropy = self.firmware_at_least("7.14.2") + # display_random is ignored by every supported product. 7.14.2 always + # did; 7.14.3 and 7.15 now do too. The request below deliberately sets + # it to True so this test fails if any firmware starts honouring it + # again: the device half it would render is the exact 32 bytes whose + # complement this host supplies, so the screen plus our own + # external_entropy is the seed pre-image. ret = self.client.call_raw(proto.ResetDevice(display_random=True, strength=strength, @@ -176,15 +615,10 @@ def test_reset_device_pin(self): language='english', label='test')) - if not hides_internal_entropy: - # Legacy firmware exposed internal entropy when display_random was - # set. 7.14.2 keeps the wire field for compatibility but ignores - # it, so the host can never learn enough entropy to reconstruct - # the seed preimage. - self.assertIsInstance(ret, proto.ButtonRequest) - self.client.debug.press_yes() - ret = self.client.call_raw(proto.ButtonAck()) - + self.assertNotIsInstance( + ret, proto.ButtonRequest, + 'display_random must be ignored: firmware answered the reset with a ' + 'ButtonRequest, which means an internal-entropy screen was drawn') self.assertIsInstance(ret, proto.PinMatrixRequest) self.client._capture_oled_after_animation(1.05, (192, 256, 0, 64)) @@ -252,9 +686,15 @@ def test_reset_device_pin(self): self.client.call_raw(proto.Cancel()) def test_failed_pin(self): + self.requires_release_capability('safe-reset-ceremony') external_entropy = 'zlutoucky kun upel divoke ody' * 2 strength = 128 - hides_internal_entropy = self.firmware_at_least("7.14.2") + # display_random is ignored by every supported product. 7.14.2 always + # did; 7.14.3 and 7.15 now do too. The request below deliberately sets + # it to True so this test fails if any firmware starts honouring it + # again: the device half it would render is the exact 32 bytes whose + # complement this host supplies, so the screen plus our own + # external_entropy is the seed pre-image. ret = self.client.call_raw(proto.ResetDevice(display_random=True, strength=strength, @@ -263,11 +703,10 @@ def test_failed_pin(self): language='english', label='test')) - if not hides_internal_entropy: - self.assertIsInstance(ret, proto.ButtonRequest) - self.client.debug.press_yes() - ret = self.client.call_raw(proto.ButtonAck()) - + self.assertNotIsInstance( + ret, proto.ButtonRequest, + 'display_random must be ignored: firmware answered the reset with a ' + 'ButtonRequest, which means an internal-entropy screen was drawn') self.assertIsInstance(ret, proto.PinMatrixRequest) self.client._capture_oled_after_animation(1.05, (192, 256, 0, 64)) diff --git a/tests/test_msg_ripple_get_address.py b/tests/test_msg_ripple_get_address.py index 74e6d7a6..05b82577 100644 --- a/tests/test_msg_ripple_get_address.py +++ b/tests/test_msg_ripple_get_address.py @@ -39,10 +39,15 @@ def test_ripple_show_address(self): self.requires_fullFeature() self.requires_firmware("6.4.0") self.setup_mnemonic_allallall() - # The legacy display response is empty after the ButtonAck; address - # correctness is covered above. This case retains the actual OLED. - self.client.ripple_get_address( - parse_path("m/44'/144'/0'/0/0"), show_display=True) + address = self.assert_shows_address( + lambda: self.client.ripple_get_address( + parse_path("m/44'/144'/0'/0/0"), show_display=True), + "rNaqKtKrMSwpwZSzRckPf7S96DkimjkF4H") + # 7.14.2 and later preserve the response across DebugLinkGetState + # requests made during screenshot capture; the same fix is on all three + # products, so the floor is the release the fix first shipped in. + if self.firmware_at_least("7.14.2"): + self.assertEqual(address, "rNaqKtKrMSwpwZSzRckPf7S96DkimjkF4H") def test_ripple_get_address_other(self): self.requires_fullFeature() diff --git a/tests/test_msg_ripple_sign_tx.py b/tests/test_msg_ripple_sign_tx.py index aaeab6cd..655f00e0 100644 --- a/tests/test_msg_ripple_sign_tx.py +++ b/tests/test_msg_ripple_sign_tx.py @@ -100,23 +100,10 @@ def test_sign(self): ) - @unittest.skip( - "XRP memo is not a supported feature yet. A THORChain memo cannot " - "traverse hdwallet -> RippleSignTx: the protobuf has no memo field " - "(RippleSignTx carries 1-6, RipplePayment carries " - "amount/destination/destination_tag), and hdwallet's rippleSignTx " - "never reads tx.value.memo. The firmware therefore never receives it " - "and cannot serialize it. Tracked as keepkey/keepkey-vault#422.\n" - "\n" - "This assertion is CORRECT and is deliberately left intact: it " - "describes the behaviour the product needs. Do NOT make it pass by " - "asserting the memo is absent -- that would encode the bug as the " - "contract. Re-enable only when the signed serialization actually " - "preserves the memo." - ) def test_sign_with_thorchain_memo(self): self.requires_fullFeature() - self.requires_firmware("7.14.2") + self.requires_firmware("7.15.0") + self.requires_release_capability("ripple-memo-policy") self.setup_mnemonic_allallall() @@ -165,6 +152,36 @@ def test_sign_with_thorchain_memo(self): "plain send must not contain Memos array (0xF9 0xEA marker sequence)" ) + def test_memo_length_prefix_boundaries(self): + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.requires_release_capability("ripple-memo-policy") + self.setup_mnemonic_allallall() + + for length in (191, 192, 193, 199): + # A distinct suffix makes the last display page inspectable. + memo = "A" * (length - 8) + ("TAIL%04d" % length) + msg = messages.RippleSignTx( + address_n=parse_path("m/44'/144'/0'/0/0"), + payment=messages.RipplePayment( + amount=100000000, + destination="rBKz5MC2iXdoS3XgnNSYmF69K1Yo4NS3Ws" + ), + flags=0x80000000, + fee=100000, + sequence=25 + length, + memo=memo + ) + resp = self.client.call(msg) + prefix = (bytes([length]) if length <= 192 else + bytes([193, length - 193])) + expected_tail = (b'\xf9\xea\x7d' + prefix + + memo.encode('ascii') + b'\xe1\xf1') + self.assertTrue( + resp.serialized_tx.endswith(expected_tail), + "serialized Ripple memo has wrong prefix at length %d" % length + ) + def test_ripple_sign_invalid_fee(self): self.requires_fullFeature() self.requires_firmware("6.4.0") diff --git a/tests/test_msg_session_trust_lifetime.py b/tests/test_msg_session_trust_lifetime.py new file mode 100644 index 00000000..0da52fb7 --- /dev/null +++ b/tests/test_msg_session_trust_lifetime.py @@ -0,0 +1,558 @@ +""" +Session and Trust Lifetime — provider trust must die on its own. + +Two post-RC18 clear-sign lifetime claims have never been tested end to end: + + 1. AdvancedMode is SESSION state, never a flash bit. storage.c writes bit 12 + of the storage flags word as zero and ignores it on read (four sites: + storage_writeStorageV11, storage_readStorageV11, + storage_writeStorageV16Plaintext, storage_readStorageV16Plaintext), each + with a comment saying the policy is session-scoped now. The only proof of + that is a power cycle: enable it, restart the firmware, and it must be OFF + while everything else in the same flags word survives. + + 2. A runtime clear-sign signer (LoadClearsignSigner) lives in RAM only and is + revoked by session teardown. session_clear() calls + signed_metadata_clear_signers() unconditionally, so both Initialize + (clear_pin=false) and ClearSession (clear_pin=true) drop it, and a reboot + drops it by construction. + +MODELLING A POWER CYCLE. The emulator's flash is an mmap of `emulator.img` in +its working directory (lib/emulator/setup.c). Killing and relaunching the +process WITHOUT touching that file is a REBOOT: flash contents survive, RAM and +every session variable do not. Deleting the image first would be a FACTORY WIPE +instead, and a wipe proves nothing here — every policy reads back off on a blank +device whether or not it was ever persisted. _power_cycle() therefore keeps the +image, and each power-cycle test asserts a persisted control value came back to +prove the flash really did survive the restart. + +WHY THE POLICY CALLS ARE RAW. ProtocolMixin.apply_policy() sends Initialize +afterwards to refresh Features, and Initialize is itself one of the teardown +paths under test — using it would clear the signer as a side effect and make +every assertion below vacuous. _apply_policy_raw() sends the bare ApplyPolicies +and reads state back with GetFeatures, which touches no session state. + +test_msg_ethereum_clear_signing.py covers loading a signer, the persist=true +refusal and the wipe path. Nothing here duplicates that: this file is only +about how loaded trust DIES. +""" + +from __future__ import print_function + +import os +import shutil +import subprocess +import time +import unittest + +import common +import config + +from keepkeylib import messages_pb2 as proto +from keepkeylib import types_pb2 as proto_types +from keepkeylib.client import CallException, KeepKeyDebuglinkClient +from keepkeylib.transport_udp import UDPTransport +from keepkeylib.signed_metadata import ( + ARG_FORMAT_STRING, + CLASSIFICATION_MALFORMED, + CLASSIFICATION_VERIFIED, + serialize_metadata, + sign_metadata, + # aliased: pytest would otherwise collect the helper as a test function + test_signer_compressed_pubkey as signer_compressed_pubkey, +) + +# Same CI slot/alias the clear-sign suite uses. Phase-1 firmware ships with no +# built-in keys, so slot 3 is empty until LoadClearsignSigner fills it. +TEST_KEY_ID = 3 +CI_SIGNER_ALIAS = 'CI Test' + +# Aave V3 Pool proxy, matching AAVE_SUPPLY_SELECTOR below. Was the V2 +# LendingPool address, which does not expose supply(). +AAVE_V3_POOL = bytes.fromhex('87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2') +AAVE_SUPPLY_SELECTOR = bytes.fromhex('617ba037') +PROBE_ARGS = [ + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Aave V3'}, +] + + +def probe_blob(): + """A VERIFIED-classification blob signed by the CI test key for slot 3. + + Used only as an oracle for "is the signer still in the slot?": the device + answers VERIFIED while the slot holds the matching pubkey and MALFORMED once + it does not. No transaction is signed, so no tx_hash binding is needed. + """ + return sign_metadata(serialize_metadata( + chain_id=1, + contract_address=AAVE_V3_POOL, + selector=AAVE_SUPPLY_SELECTOR, + tx_hash=b'\x00' * 32, + method_name='supply', + args=PROBE_ARGS, + key_id=TEST_KEY_ID, + )) + + +# Names `ps -o comm=` reports for the emulator binary. Anything else bound to +# the port is not ours to kill -- see the guard in _emulator_process(). +_EMULATOR_EXE_NAMES = ('kkemu',) + + +def _emulator_process(port): + """(pid, exe, cwd) of the process BOUND to udp/port, or None. + + NOTE: subprocess.run(capture_output=/text=) is Python 3.7+. The CI test + container runs 3.6, where passing them raises TypeError inside subprocess + and this helper dies before any of its own logic runs -- which is why the + power-cycle tests FAILED in CI instead of skipping. PIPE plus + universal_newlines is the spelling both understand. + + Skips this test client's own connected socket, which lsof also reports on + the same port but as a `local->remote` pair rather than a bare bind. + """ + try: + out = subprocess.run(['lsof', '-nP', '-iUDP:%d' % port, '-Fpn'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, universal_newlines=True).stdout + except (FileNotFoundError, OSError): + # No lsof: this harness cannot identify, let alone restart, the + # emulator process -- the same situation as a remote one. Report "not + # found" so _power_cycle() skips with its explanation, rather than + # failing a green tree over a missing tool. + return None + pid = None + for line in out.splitlines(): + if line.startswith('p'): + pid = int(line[1:]) + elif line.startswith('n') and pid is not None: + name = line[1:] + if '->' in name or not name.endswith(':%d' % port): + continue + exe = subprocess.run(['ps', '-o', 'comm=', '-p', str(pid)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, universal_newlines=True).stdout.strip() + if os.path.basename(exe) not in _EMULATOR_EXE_NAMES: + # Whatever holds this port, it is not the firmware. Whenever the + # emulator runs in a container the bound process is the Docker + # port forwarder -- docker-proxy or dockerd on Linux, + # com.docker.backend on macOS -- in a different pid namespace + # from kkemu. Killing it does not reboot anything: it removes + # the port forward, and every later test in the run then blocks + # forever on a socket that will never answer again. Measured + # here: it took the whole Docker daemon down mid-suite. + # + # Fall through to "not found" so _power_cycle() takes its + # documented skip, which the report renders as WITHHELD rather + # than as a pass. + continue + cwd_out = subprocess.run( + ['lsof', '-a', '-p', str(pid), '-d', 'cwd', '-Fn'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, universal_newlines=True).stdout + cwd = None + for cwd_line in cwd_out.splitlines(): + if cwd_line.startswith('n'): + cwd = cwd_line[1:] + + # Resolve a RUNNABLE path before returning, because the caller + # kills this pid and then re-execs what we hand back. + # + # `ps -o comm=` gives the bare command name on Linux (`kkemu`, + # truncated to 15 chars), not a path -- only macOS returns an + # absolute one. Popen([name]) searches PATH, never cwd, and the + # emulator build directory is not on PATH. So on Linux the old + # code killed the emulator and then failed to restart it, leaving + # every later test in the run talking to a dead port. + # + # If no runnable path can be found, report "not found" so + # _power_cycle() takes its documented skip instead of killing an + # emulator it cannot bring back. + exe_path = _resolve_executable(pid, exe, cwd) + if exe_path is None: + continue + return pid, exe_path, cwd + return None + + +def _resolve_executable(pid, comm, cwd): + """An absolute, runnable path for `comm`, or None.""" + # Linux: the kernel knows exactly what is running. + try: + link = os.readlink('/proc/%d/exe' % pid) + if os.path.isfile(link) and os.access(link, os.X_OK): + return link + except (OSError, AttributeError): + pass + if os.path.isabs(comm) and os.access(comm, os.X_OK): + return comm + if cwd: + candidate = os.path.join(cwd, comm) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + found = shutil.which(comm) + if found: + return found + return None + + +class _RebootClient(KeepKeyDebuglinkClient): + """Reconnect without clearing the state whose reboot lifetime we test. + + ProtocolMixin.__init__ calls init_device(), so overriding it also prevents + the implicit Initialize sent by the normal client constructor. + """ + def init_device(self): + self.features = self.call(proto.GetFeatures()) + if not isinstance(self.features, proto.Features): + raise RuntimeError("GetFeatures did not return Features") + if str(self.features.vendor) not in self.VENDORS: + raise RuntimeError("Unsupported device") + + +class TestSessionTrustLifetime(common.KeepKeyTest): + + # Canonical 7.15 makes AdvancedMode and runtime signer trust session-only. + # Its release contract requires these lifetime checks. + MIN_FIRMWARE = "7.15.0" + + def setUp(self): + super(TestSessionTrustLifetime, self).setUp() + self.requires_firmware(self.MIN_FIRMWARE) + + # ── helpers ──────────────────────────────────────────────────────── + + def _apply_policy_raw(self, name, enabled): + """ApplyPolicies with NO trailing Initialize. See module docstring.""" + return self.client.call(proto.ApplyPolicies( + policy=[proto_types.PolicyType(policy_name=name, enabled=enabled)])) + + def _policy(self, name): + """Read a policy back with GetFeatures — touches no session state.""" + features = self.client.call(proto.GetFeatures()) + for policy in features.policies: + if policy.policy_name == name: + return policy.enabled + self.fail("no such policy: %s" % name) + + def _signer_still_loaded(self): + """VERIFIED => slot 3 still holds the CI signer; MALFORMED => empty. + + Requires AdvancedMode ON: fsm_msgEthereumTxMetadata refuses outright + without it, which is a different answer from "the slot is empty" and is + asserted separately where it matters. + """ + resp = self.client.ethereum_send_tx_metadata( + signed_payload=probe_blob(), metadata_version=1, + key_id=TEST_KEY_ID) + return resp.classification + + def _assertClassification(self, expected, why): + """assertEqual with a message. common.KeepKeyTest narrows assertEqual to + two positional args, so the reason a lifetime assertion matters would + otherwise be lost at the point it fails.""" + got = self._signer_still_loaded() + self.assertTrue(got == expected, + "%s (classification %d, expected %d)" % (why, got, expected)) + + def _arm_session(self): + """Seed the device, turn AdvancedMode on, load the CI signer, and prove + the signer really is live before anything tries to revoke it.""" + self.requires_fullFeature() + self.requires_release_capability("session-trust-lifetime") + self.setup_mnemonic_nopin_nopassphrase() + self._apply_policy_raw("AdvancedMode", True) + self.client.load_clearsign_signer( + key_id=TEST_KEY_ID, pubkey=signer_compressed_pubkey(), + alias=CI_SIGNER_ALIAS) + self._assertClassification( + CLASSIFICATION_VERIFIED, + "the CI signer did not take — nothing below can be evidence about " + "revoking trust that was never armed") + + def _persist_marker_across_all_sectors(self): + """Set the Experimental policy, then commit enough times that EVERY + storage sector holds a record written after it was set. + + This exists because of a real emulator/firmware interaction that would + otherwise make every power-cycle assertion below vacuous. + storage_commit() calls wear_leveling_shift(), so consecutive commits + land in FLASH_STORAGE1 -> 2 -> 3 -> 1, and each commit erases the + sector it leaves. On the emulator flash_erase_word() is compiled out + entirely (keepkey_flash.c is `#ifndef EMULATOR`), so the abandoned + sectors keep their "stor" magic — and find_active_storage() takes the + FIRST sector carrying that magic. A rebooted emulator therefore reads + whichever record last happened to land in STORAGE1, which can be two + commits stale. + + Consequence if ignored: an AdvancedMode bit written one commit before + the restart lands in STORAGE2 or STORAGE3, boot reads the older + STORAGE1 record, and the policy reads back OFF for a reason that has + nothing to do with it being session-scoped. The test would pass on a + firmware that persisted it. Padding the commits removes the ambiguity, + and the Experimental marker is what proves it was removed: it is set + AFTER AdvancedMode, so any record containing it was written while + AdvancedMode was on in RAM. Assert the marker came back before + asserting anything about AdvancedMode. + """ + for _ in range(4): + self._apply_policy_raw("Experimental", True) + + def _power_cycle(self): + """Kill and relaunch the firmware, KEEPING its flash image. + + This is a reboot, not a wipe: emulator.img is left alone, so anything + committed to flash comes back and anything that only lived in RAM does + not. There is no protocol message that reboots a KeepKey, so on a + transport that is not a local UDP emulator this fails loudly rather than + skipping — a skipped lifetime test is indistinguishable from a passing + one in the report, and that is exactly how a real defect stayed hidden + for a release. + """ + if config.TRANSPORT is not UDPTransport: + self.fail("power cycle requires the local UDP emulator; on real " + "hardware this is an operator step (unplug/replug) and " + "must be recorded as manual evidence, not skipped") + + port = int(str(config.TRANSPORT_ARGS[0]).split(':')[1]) + found = _emulator_process(port) + if found is None: + # The emulator is reachable over UDP but is NOT a process this + # harness can signal -- in CI it runs as a separate docker-compose + # service, so there is no pid here to kill and relaunch. That is an + # environmental limit, not a firmware result, and failing on it + # makes a green tree look red for a reason no code change can fix. + # + # Skipping is still not free: the report renders this section as + # WITHHELD, which the atlas guide defines as "carries no evidence". + # So the property stays unproven wherever the harness does not own + # the emulator, and is proven on every local run and in the manual + # hardware round. Both facts are visible; neither is silent. + self.skipTest( + "power cycle needs an emulator process this harness owns; " + "none is bound to udp/%d (CI runs it as a separate container). " + "Run locally, or record the unplug/replug as manual evidence." + % port) + pid, exe, cwd = found + + self.client.close() + subprocess.run(['kill', str(pid)]) + for _ in range(100): + if _emulator_process(port) is None: + break + time.sleep(0.1) + self.assertIsNone(_emulator_process(port), + "emulator pid %d did not exit" % pid) + + env = dict(os.environ) + env['KEEPKEY_UDP_PORT'] = str(port) + subprocess.Popen([exe], cwd=cwd, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + # Wait for the new instance to answer before reconnecting. + deadline = time.time() + 20 + while time.time() < deadline: + if _emulator_process(port) is not None: + break + time.sleep(0.1) + self.assertIsNotNone(_emulator_process(port), + "emulator did not come back on udp/%d" % port) + time.sleep(0.5) + + transport = config.TRANSPORT(*config.TRANSPORT_ARGS, + **config.TRANSPORT_KWARGS) + debug_transport = config.DEBUG_TRANSPORT(*config.DEBUG_TRANSPORT_ARGS, + **config.DEBUG_TRANSPORT_KWARGS) + client = _RebootClient(transport) + client.set_debuglink(debug_transport) + client.screenshot_dir = getattr(self.client, 'screenshot_dir', None) + client.screenshot_id = getattr(self.client, 'screenshot_id', 0) + self.client = client + + # ── 1. AdvancedMode lifetime ─────────────────────────────────────── + + def test_advanced_mode_is_off_after_power_cycle(self): + """AdvancedMode must not survive a reboot, and the control must. + + Experimental and AdvancedMode are neighbouring bits of the SAME storage + flags word (11 and 12), set by the SAME ApplyPolicies message, written + by the SAME storage_writeStorageV16Plaintext call. Turning both on and + rebooting separates a persisted policy from a session one: Experimental + comes back, AdvancedMode must not. Experimental is set AFTER + AdvancedMode, so the record it came back from was written while + AdvancedMode was armed — bit 12 was offered to the writer and dropped. + The seed and label surviving are the second control: without them a + reboot would be indistinguishable from a factory wipe, which turns every + policy off for the wrong reason. + """ + self.setup_mnemonic_nopin_nopassphrase() + self._apply_policy_raw("AdvancedMode", True) + self._persist_marker_across_all_sectors() + self.assertTrue(self._policy("AdvancedMode")) + self.assertTrue(self._policy("Experimental")) + + self._power_cycle() + + self.assertTrue(self.client.features.initialized, + "reboot lost the seed — this modelled a wipe, not a " + "power cycle, and proves nothing about persistence") + self.assertEqual(self.client.features.label, 'test') + self.assertTrue(self._policy("Experimental"), + "the marker policy did not come back, so the record " + "read at boot predates the AdvancedMode change and no " + "conclusion about bit 12 can be drawn from it") + self.assertFalse(self._policy("AdvancedMode"), + "AdvancedMode came back ON after a power cycle — it " + "is being persisted to flash, which storage.c " + "explicitly forbids (bit 12 is burned)") + + def test_advanced_mode_survives_initialize_but_not_clear_session(self): + """The asymmetry in session_clear() is deliberate; pin it down. + + session_clear_impl() disarms AdvancedMode only when clear_pin is set. + ClearSession passes true, Initialize passes false. Hosts send + Initialize before nearly every operation, so disarming there would cost + a fresh button press each time; ClearSession is an explicit lock and + must revoke the capability. If this ever inverts, blind signing either + becomes unusable or outlives the lock. + """ + self.requires_release_capability("session-trust-lifetime") + self.setup_mnemonic_nopin_nopassphrase() + self._apply_policy_raw("AdvancedMode", True) + + self.client.call(proto.Initialize()) + self.assertTrue(self._policy("AdvancedMode"), + "Initialize disarmed AdvancedMode — every host sends " + "it routinely, so the policy would be unusable") + + self.client.clear_session() + self.assertFalse(self._policy("AdvancedMode"), + "ClearSession left AdvancedMode armed — an explicit " + "lock must revoke the blind-signing capability") + + # ── 2. Loaded-signer lifetime ────────────────────────────────────── + + def test_signer_dropped_by_initialize(self): + """Session teardown revokes the signer while the policy stays armed. + + The MALFORMED here is unambiguous: AdvancedMode is asserted still ON + immediately before the probe, so the metadata gate cannot be what + refused it — the slot is empty. The GetFeatures probe first is the + negative control: merely exchanging messages must NOT drop a signer, or + this test would pass for the wrong reason. + """ + self._arm_session() + + self.client.call(proto.GetFeatures()) + self._assertClassification( + CLASSIFICATION_VERIFIED, + "an ordinary message dropped the signer; the teardown assertion " + "below would then prove nothing") + + self.client.call(proto.Initialize()) + self.assertTrue(self._policy("AdvancedMode")) + self._assertClassification( + CLASSIFICATION_MALFORMED, + "the signer survived session teardown — runtime trust must not " + "outlive the session that consented to it") + + def test_signer_dropped_by_clear_session(self): + """ClearSession revokes both halves of the trust. + + Right after the lock the metadata message is refused outright, because + ClearSession also disarmed AdvancedMode — that Failure is the policy + gate, not evidence about the slot. Re-arming the policy WITHOUT an + Initialize isolates the slot: MALFORMED then means the signer itself is + gone. + """ + self._arm_session() + + self.client.clear_session() + + with self.assertRaises(CallException) as ctx: + self._signer_still_loaded() + self.assertIn("AdvancedMode required", str(ctx.exception)) + + self._apply_policy_raw("AdvancedMode", True) + self._assertClassification( + CLASSIFICATION_MALFORMED, + "the signer survived ClearSession — an explicit lock left provider " + "trust loaded in RAM") + + def test_signer_dropped_by_power_cycle(self): + """Reboot drops the signer; the seed proves it was a reboot. + + Loaded signers are RAM only, so this should be true by construction — + but "by construction" is exactly the claim a persist=true bug would + break, and the report needs the reboot on record rather than inferred. + Storage is preserved (see _power_cycle), so the surviving seed, label + and marker policy rule out a wipe having done the work. The marker is + set after the signer is loaded, so the record the device boots into is + one that was written while the signer was live — if a build ever did + persist signers, this is the record it would have persisted them into. + """ + self._arm_session() + self._persist_marker_across_all_sectors() + + self._power_cycle() + + self.assertTrue(self.client.features.initialized, + "reboot lost the seed — this modelled a wipe, not a " + "power cycle") + self.assertEqual(self.client.features.label, 'test') + self.assertTrue(self._policy("Experimental"), + "the marker policy did not come back, so flash was not " + "preserved across the restart") + self.assertFalse(self._policy("AdvancedMode")) + + self._apply_policy_raw("AdvancedMode", True) + self._assertClassification( + CLASSIFICATION_MALFORMED, + "the signer came back after a power cycle — it was written to flash") + + def test_disabling_advanced_mode_revokes_the_signer(self): + """Turning the policy off DROPS the provider, it does not suspend it. + + Every consumer in signed_metadata.c already refuses a runtime slot + while AdvancedMode is off, so with the policy off the two behaviours + are indistinguishable — the metadata fails closed either way. The + difference only shows on the way back. + + Suspending would mean re-enabling the policy silently re-arms a + provider the user never re-loaded, on a confirmation screen that names + the policy and never names the signer. A user who disabled + AdvancedMode to drop a provider would not have dropped it. So + fsm_msgApplyPolicies calls signed_metadata_clear_signers() on disable, + and coming back costs a fresh LoadClearsignSigner consent — the screen + that names the alias and fingerprint, which is the screen that should + appear whenever trust begins. + + The re-enable is sent as the bare message with the exact expected + response list: one ApplyPolicies ButtonRequest and a Success. No trust + screen appears there, which is the point — trust cannot be restored by + a policy toggle at all. + """ + self._arm_session() + + self._apply_policy_raw("AdvancedMode", False) + with self.assertRaises(CallException) as ctx: + self._signer_still_loaded() + self.assertIn("AdvancedMode required", str(ctx.exception)) + + with self.client: + self.client.set_expected_responses([ + proto.ButtonRequest( + code=proto_types.ButtonRequest_ApplyPolicies), + proto.Success(), + ]) + self._apply_policy_raw("AdvancedMode", True) + + self._assertClassification( + CLASSIFICATION_MALFORMED, + "the signer survived disabling AdvancedMode — re-enabling the " + "policy re-armed a provider the user never re-loaded, on a screen " + "that never named it") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_signing_boundaries.py b/tests/test_msg_signing_boundaries.py index cc6855cd..ce56f8f2 100644 --- a/tests/test_msg_signing_boundaries.py +++ b/tests/test_msg_signing_boundaries.py @@ -1,6 +1,11 @@ -# This file is part of the KeepKey project. - -"""Signing state and decoded multisig lengths must fail closed.""" +# This file is part of the TREZOR project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. from __future__ import print_function @@ -8,47 +13,163 @@ import unittest import common - import keepkeylib.ckd_public as ckd_public -from keepkeylib import messages_pb2 as proto -from keepkeylib import types_pb2 as types +import keepkeylib.messages_pb2 as proto +import keepkeylib.types_pb2 as proto_types +from keepkeylib.client import CallException -XPUB = ( - "xpub661MyMwAqRbcF1zGijBb2K6x9YiJPh58xpcCeLvTxMX6spkY3PcpJ4ABcCy" - "Wfskq5DDxM3e6Ez5ePCqG5bnPUXR4wL8TZWyoDaUdiWW7bKy" -) +class TestSigningBoundaries(common.KeepKeyTest): + PREV_HASH = binascii.unhexlify( + 'd5f65ee80147b4bcc70b75e4bbf2d7382021b871bd8867ef8fa525ef50864882') + XPUB = ( + 'xpub661MyMwAqRbcF1zGijBb2K6x9YiJPh58xpcCeLvTxMX6spkY3PcpJ4ABcCyW' + 'fskq5DDxM3e6Ez5ePCqG5bnPUXR4wL8TZWyoDaUdiWW7bKy') + + def _input(self): + return proto_types.TxInputType( + address_n=[0], + prev_hash=self.PREV_HASH, + prev_index=0, + ) + def _ordinary_output(self): + return proto_types.TxOutputType( + address='1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1', + amount=380000, + script_type=proto_types.PAYTOADDRESS, + ) -class TestSigningBoundaries(common.KeepKeyTest): - def setUp(self): - super(TestSigningBoundaries, self).setUp() - self.requires_firmware("7.14.2") + @staticmethod + def _request_key(request): + return ( + request.request_type, + request.details.request_index, + bytes(request.details.tx_hash), + request.details.extra_data_offset, + request.details.extra_data_len, + ) + + def _assert_late_txack_rejected(self): + response = self.client.call_raw( + proto.TxAck(tx=proto_types.TransactionType())) + self.assertIsInstance(response, proto.Failure) + self.assertEqual(response.code, proto_types.Failure_UnexpectedMessage) + + def test_clear_session_aborts_every_txrequest_stage(self): self.setup_mnemonic_nopin_nopassphrase() + stage_trace = [] + + def record_stage(request, message): + stage_trace.append(self._request_key(request)) + return message + + signatures, serialized_tx = self.client.sign_tx( + 'Bitcoin', [self._input()], [self._ordinary_output()], + debug_processor=record_stage) + self.assertTrue(signatures[0]) + self.assertTrue(serialized_tx) + self.assertTrue(stage_trace) + self.assertIn(proto_types.TXMETA, + [stage[0] for stage in stage_trace]) + self.assertIn(proto_types.TXINPUT, + [stage[0] for stage in stage_trace]) + self.assertIn(proto_types.TXOUTPUT, + [stage[0] for stage in stage_trace]) + + for target_index, expected_stage in enumerate(stage_trace): + seen = [] + + def clear_at_target(request, message): + seen.append(self._request_key(request)) + if len(seen) - 1 == target_index: + response = self.client.call_raw(proto.ClearSession()) + self.assertIsInstance(response, proto.Success) + return message + + with self.assertRaises(CallException): + self.client.sign_tx( + 'Bitcoin', [self._input()], [self._ordinary_output()], + debug_processor=clear_at_target) + + self.assertEqual(seen[-1], expected_stage) + self.assertEqual(len(seen), target_index + 1) + self._assert_late_txack_rejected() + + def _invalid_multisig(self, m, n): + node = ckd_public.deserialize(self.XPUB) + return proto_types.MultisigRedeemScriptType( + pubkeys=[ + proto_types.HDNodePathType(node=node, address_n=[i + 1]) + for i in range(n) + ], + signatures=[b''] * n, + m=m, + ) + + def test_invalid_multisig_outputs_never_serialize_or_sign(self): + self.setup_mnemonic_nopin_nopassphrase() + + invalid_quorums = ( + (0, 1), # m == 0 + (1, 0), # n == 0 + (2, 1), # m > n + (16, 15), # m > 15 + (1, 16), # n > 15 + ) + + for internal in (False, True): + for m, n in invalid_quorums: + output = proto_types.TxOutputType( + address_n=[1] if internal else [], + amount=380000, + script_type=proto_types.PAYTOMULTISIG, + multisig=self._invalid_multisig(m, n), + ) + signed_material = [] + + def observe_response(request, message): + if request.HasField('serialized'): + serialized = request.serialized + if (serialized.HasField('serialized_tx') or + serialized.HasField('signature')): + signed_material.append(serialized) + return message + + with self.assertRaises(CallException): + self.client.sign_tx( + 'Bitcoin', [self._input()], [output], + debug_processor=observe_response) + + self.assertEqual(signed_material, []) + response = self.client.call_raw( + proto.TxAck(tx=proto_types.TransactionType())) + self.assertIsInstance(response, proto.Failure) + self.client.call_raw(proto.ClearSession()) + def test_multisig_signature_over_72_bytes_is_rejected(self): - """A decoder-sized 74-byte signature must never reach serialization.""" - node = ckd_public.deserialize(XPUB) - multisig = types.MultisigRedeemScriptType( + """An oversized signature must fail decoding and terminate signing.""" + self.requires_firmware("7.14.2") + self.setup_mnemonic_nopin_nopassphrase() + node = ckd_public.deserialize(self.XPUB) + multisig = proto_types.MultisigRedeemScriptType( pubkeys=[ - types.HDNodePathType(node=node, address_n=[1]), - types.HDNodePathType(node=node, address_n=[2]), - types.HDNodePathType(node=node, address_n=[3]), + proto_types.HDNodePathType(node=node, address_n=[1]), + proto_types.HDNodePathType(node=node, address_n=[2]), + proto_types.HDNodePathType(node=node, address_n=[3]), ], - # Nanopb's static decoder historically accepted this even though - # the schema storage is smaller because the repeated-element - # stride includes structure padding. signatures=[b"\x30" * 74, b"", b""], m=2, ) - tx_input = types.TxInputType( + tx_input = proto_types.TxInputType( address_n=[1], prev_hash=binascii.unhexlify( "c6091adf4c0c23982a35899a6e58ae11" "e703eacd7954f588ed4b9cdefc4dba52" ), prev_index=1, - script_type=types.SPENDMULTISIG, + script_type=proto_types.SPENDMULTISIG, multisig=multisig, ) @@ -57,34 +178,27 @@ def test_multisig_signature_over_72_bytes_is_rejected(self): )) self.assertIsInstance(first, proto.TxRequest) rejected = self.client.call_raw(proto.TxAck( - tx=types.TransactionType(inputs=[tx_input]) + tx=proto_types.TransactionType(inputs=[tx_input]) )) self.assertIsInstance(rejected, proto.Failure) - self.assertEqual(rejected.code, types.Failure_SyntaxError) - - # Rejection is terminal rather than leaving a partially initialized - # signer available to a follow-up TxAck. - stale = self.client.call_raw(proto.TxAck(tx=types.TransactionType())) - self.assertIsInstance(stale, proto.Failure) - self.assertEqual(stale.code, types.Failure_UnexpectedMessage) + self.assertEqual(rejected.code, proto_types.Failure_UnexpectedMessage) + self.assertEqual(rejected.message, "Could not parse protocol buffer message") + self._assert_late_txack_rejected() def test_clear_session_aborts_active_bitcoin_signing(self): - """Authorization loss must also destroy the in-flight signer.""" + """Authorization loss destroys a signer before its first TxAck.""" + self.requires_firmware("7.14.2") + self.setup_mnemonic_nopin_nopassphrase() first = self.client.call_raw(proto.SignTx( inputs_count=1, outputs_count=1, coin_name="Bitcoin" )) self.assertIsInstance(first, proto.TxRequest) - cleared = self.client.call_raw(proto.ClearSession()) self.assertIsInstance(cleared, proto.Success) - - stale = self.client.call_raw(proto.TxAck(tx=types.TransactionType())) - self.assertIsInstance(stale, proto.Failure) - self.assertEqual(stale.code, types.Failure_UnexpectedMessage) - + self._assert_late_txack_rejected() features = self.client.call_raw(proto.Initialize()) self.assertIsInstance(features, proto.Features) -if __name__ == "__main__": +if __name__ == '__main__': unittest.main() diff --git a/tests/test_msg_signtx_ethereum_erc20.py b/tests/test_msg_signtx_ethereum_erc20.py index 4c13aa4c..0b557f1e 100644 --- a/tests/test_msg_signtx_ethereum_erc20.py +++ b/tests/test_msg_signtx_ethereum_erc20.py @@ -25,6 +25,8 @@ import keepkeylib.messages_pb2 as proto import keepkeylib.types_pb2 as proto_types from keepkeylib.client import CallException +from oled_text import TITLE_FONT, find_line, shows +from test_msg_display_disclosure import ScreenRecorder from keepkeylib.tools import int_to_big_endian class TestMsgEthereumSigntxERC20(common.KeepKeyTest): @@ -67,34 +69,56 @@ def test_approve_some(self): self.assertEqual(binascii.hexlify(sig_r), 'a6898a6fec0b063ce2809d783ba5524216c49b27e6514d5ef703bc9bc3a152fd') self.assertEqual(binascii.hexlify(sig_s), '5b8b0e5b7b8f6d5269ce4dc266e6901f3284079fa1f0cd358d2987336dc8ba3a') - def test_approve_all(self): + def test_approve_all_refused(self): + # 7.14.2 through 7.15.x refuse an unlimited approve outright, until + # erc20-unlimited-approve-review: from then it is signed after an + # UNLIMITED warning (test_approve_all). self.requires_fullFeature() + self.requires_firmware("7.14.2") + self.requires_firmware_below("7.16.0") + self.requires_capability_absent("erc20-unlimited-approve-review") self.setup_mnemonic_nopin_nopassphrase() + with self.assertRaises(CallException): + self.client.ethereum_sign_tx( + n=[2147483692,2147483708,2147483648,0,0], + nonce=1, + gas_price=20, + gas_limit=20, + value=0, + to=binascii.unhexlify('41e5560054824ea6b0732e656e3ad64e20e94e45'), + chain_id=1, + data=binascii.unhexlify('095ea7b3000000000000000000000000' + '1d1c328764a41bda0492b66baa30c4a339ff85ef' + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + ) + + def test_approve_all(self): + self.requires_fullFeature() if self.firmware_at_least("7.14.2"): - with self.assertRaises(CallException): - self.client.ethereum_sign_tx( - n=[2147483692,2147483708,2147483648,0,0], - nonce=1, - gas_price=20, - gas_limit=20, - value=0, - to=binascii.unhexlify('41e5560054824ea6b0732e656e3ad64e20e94e45'), - chain_id=1, - data=binascii.unhexlify('095ea7b3000000000000000000000000' + '1d1c328764a41bda0492b66baa30c4a339ff85ef' + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), - ) - return + # Refused on 7.14.2-7.15.x (test_approve_all_refused) until + # erc20-unlimited-approve-review; signed after a warning since. + self.requires_firmware("7.15.0") + self.requires_release_capability("erc20-unlimited-approve-review") + self.setup_mnemonic_nopin_nopassphrase() - sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( - n=[2147483692,2147483708,2147483648,0,0], - nonce=1, - gas_price=20, - gas_limit=20, - value=0, - to=binascii.unhexlify('41e5560054824ea6b0732e656e3ad64e20e94e45'), - chain_id=1, - data=binascii.unhexlify('095ea7b3000000000000000000000000' + '1d1c328764a41bda0492b66baa30c4a339ff85ef' + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), - ) + recorder = ScreenRecorder(self.client) + with recorder: + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=[2147483692,2147483708,2147483648,0,0], + nonce=1, + gas_price=20, + gas_limit=20, + value=0, + to=binascii.unhexlify('41e5560054824ea6b0732e656e3ad64e20e94e45'), + chain_id=1, + data=binascii.unhexlify('095ea7b3000000000000000000000000' + '1d1c328764a41bda0492b66baa30c4a339ff85ef' + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'), + ) + if self.firmware_at_least("7.15.0"): + # The UNLIMITED warning is the first screen, full spender. + self.assertIsNotNone(find_line(recorder.screens[0], + 'UNLIMITED APPROVAL', TITLE_FONT)) + self.assertTrue(shows(recorder.screens[0], + 'Allow 0x1D1C328764A41BdA0492b66bAA30C4A339FF85EF ' + 'to spend ALL your CVC')) self.assertEqual(sig_v, 37) self.assertEqual(binascii.hexlify(sig_r), '3671acb6aed5241948de56635ef64554d5e834355e99d806c4ae30bf463eae57') diff --git a/tests/test_msg_signtx_segwit_amount_swap.py b/tests/test_msg_signtx_segwit_amount_swap.py new file mode 100644 index 00000000..32887b29 --- /dev/null +++ b/tests/test_msg_signtx_segwit_amount_swap.py @@ -0,0 +1,70 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +"""A segwit-v0 signature commits to one input's amount only, and the device +takes those amounts from the host. A host can therefore ask twice for the +same payment over the same two coins, tell the truth about a different coin +each time, and keep one valid signature from each: together they spend both +coins, and the difference from what the screens said goes to the fee. + +The device remembers the last payment it approved. The same payment over the +same coins with different claimed amounts is refused. +""" + +import unittest + +import common + +from keepkeylib import types_pb2 as proto_types +from keepkeylib.client import CallException +from keepkeylib.tools import parse_path + +# Made-up outpoints: the device never asks for a segwit input's previous +# transaction, which is the point, so no fixture is involved. +COIN_A = bytes([0xA1]) * 32 +COIN_B = bytes([0xB2]) * 32 +PAYMENT = 2000000000 + + +class TestMsgSigntxSegwitAmountSwap(common.KeepKeyTest): + + def _sign(self, amount_a, amount_b): + inputs = [ + proto_types.TxInputType( + address_n=parse_path("84'/1'/0'/0/0"), amount=amount_a, + prev_hash=COIN_A, prev_index=0, + script_type=proto_types.SPENDWITNESS), + proto_types.TxInputType( + address_n=parse_path("84'/1'/0'/0/1"), amount=amount_b, + prev_hash=COIN_B, prev_index=1, + script_type=proto_types.SPENDWITNESS), + ] + outputs = [proto_types.TxOutputType( + address='mhRx1CeVfaayqRwq5zgRQmD7W5aWBfD5mC', amount=PAYMENT, + script_type=proto_types.PAYTOADDRESS)] + return self.client.sign_tx('Testnet', inputs, outputs) + + def test_same_payment_with_swapped_input_amounts_is_refused(self): + self.requires_firmware("7.14.3") + self.setup_mnemonic_allallall() + + # Coin A really holds 15 BTC and coin B 20. First the host is honest + # about A and understates B so the fee reads as one satoshi. + self._sign(1500000000, 500000001) + + # Asking again with the same claims is an ordinary retry. + self._sign(1500000000, 500000001) + + # Now honest about B, understating A. The screens would be the same + # payment with the same one-satoshi fee. + with self.assertRaises(CallException): + self._sign(1, 2000000000) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_signtx_taproot.py b/tests/test_msg_signtx_taproot.py new file mode 100644 index 00000000..1f1d0fc4 --- /dev/null +++ b/tests/test_msg_signtx_taproot.py @@ -0,0 +1,366 @@ +# This file is part of the KeepKey project. +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the License along with this library. +# If not, see . + +import common +import unittest + +from binascii import hexlify, unhexlify + +from common import KeepKeyTest +from keepkeylib import messages_pb2 as proto +from keepkeylib import types_pb2 as proto_types +from keepkeylib.client import CallException +from keepkeylib.tools import parse_path +from keepkeylib.tx_api import TxApiBitcoin + + + +# Canonical fixture paying 100000 sat to the BIP-86 first receiving address of +# the "abandon abandon ... about" mnemonic. The device requests the previous +# transaction even though Taproot signing also receives the amount and derives +# the prevout script from the path, so this must name a real, hash-consistent +# transaction from txcache rather than a synthetic outpoint. The expected +# witnesses below were independently checked against BIP-341 sighashes with a +# BIP-340 verifier keyed from BIP-86's published xprv. +PREV_TXID = "3e1fdf082678a8a2f378995ffc0e4f853942c55c4ddbc2771b348413eeeca9a4" +IN_AMOUNT = 100000 +OUT_AMOUNT = 90000 +OUT_ADDRESS = "1BitcoinEaterAddressDontSendf59kuE" + +EXPECTED_WITNESS = ( + "e11beb22fd50225a4fa53a61738993cc742cc6a2361faea0313b0b456300669c" + "2d9ebd503e11855e5bc8018d7bfd5122d578470e1e4e65820705db595724f9eb" +) + +EXPECTED_CHANGE_WITNESS = ( + "ed37247c670b8b260f2c5a619c8f1443493ea6f1eb6230adce5fbafa4b3067e1" + "879a905d40db728c1771207843b2ec03a40484975653e29e583cfc0964d0c95c" +) +EXPECTED_CHANGE_SCRIPT = ( + "5120882d74e5d0572d5a816cef0041a96b6c1de832f6f9676d9605c44d5e9a97d3dc" +) + +MIXED_PREV_TXID = ( + "3e1fdf082678a8a2f378995ffc0e4f853942c55c4ddbc2771b348413eeeca9a4" +) +EXPECTED_MIXED_WITNESS = ( + "b596e1bbefb855af9852942797075d4f452b2d186cb17a76226892334a497a62" + "adb9a02f7c1b4573e4d48b92e2307bb0b2282c97e2c5350bb3c21619fab855a2" +) + + +# Full BIP-144 serializations, captured from the emulator and cross-checked +# against an independent derivation from this file's own inputs and the +# EXPECTED_* witnesses above. These pin the bytes the host would broadcast -- +# `signature` alone was populated correctly even while the witness and the +# locktime footer were being dropped on the wire. +EXPECTED_SERIALIZED_TX = ( + "01000000000101a4a9ecee1384341b77c2db4d5cc54239854f0efc5f9978f3a2a878" + "2608df1f3e0000000000ffffffff01905f0100000000001976a914759d6677091e973b" + "9e9d99f19c68fbf43e3f05f988ac0140e11beb22fd50225a4fa53a61738993cc742c" + "c6a2361faea0313b0b456300669c2d9ebd503e11855e5bc8018d7bfd5122d578470e" + "1e4e65820705db595724f9eb00000000" +) +EXPECTED_SERIALIZED_TX_CHANGE = ( + "01000000000101a4a9ecee1384341b77c2db4d5cc54239854f0efc5f9978f3a2a878" + "2608df1f3e0000000000ffffffff0250c30000000000001976a914759d6677091e973b" + "9e9d99f19c68fbf43e3f05f988ac409c000000000000225120882d74e5d0572d5a81" + "6cef0041a96b6c1de832f6f9676d9605c44d5e9a97d3dc0140ed37247c670b8b260f" + "2c5a619c8f1443493ea6f1eb6230adce5fbafa4b3067e1879a905d40db728c177120" + "7843b2ec03a40484975653e29e583cfc0964d0c95c00000000" +) +EXPECTED_SERIALIZED_TX_MIXED = ( + "01000000000102a4a9ecee1384341b77c2db4d5cc54239854f0efc5f9978f3a2a878" + "2608df1f3e0000000000ffffffffa4a9ecee1384341b77c2db4d5cc54239854f0efc" + "5f9978f3a2a8782608df1f3e010000006a47304402205aa50469308c21e9e1ba0299" + "cd235add026914e4406bcfa6d9c0403c8cc3cf580220764a5832ad1bc36ba6a21020" + "a253c2272bca5aa1643d9c41b12c318b0a38824e012103aaeb52dd7494c361049de6" + "7cc680e83ebcbbbdbeb13637d92cd845f70308af5effffffff01e022020000000000" + "1976a914759d6677091e973b9e9d99f19c68fbf43e3f05f988ac0140b596e1bbefb8" + "55af9852942797075d4f452b2d186cb17a76226892334a497a62adb9a02f7c1b4573" + "e4d48b92e2307bb0b2282c97e2c5350bb3c21619fab855a20000000000" +) + + +class TestMsgSigntxTaproot(KeepKeyTest): + + def assertCompleteSegwitTx(self, raw, signatures, n_in, n_out): + """Parse the serialized tx strictly; it must consume exactly len(raw). + + `signature` and `serialized_tx` are separate nanopb fields on + TxRequestSerializedType, each with its own presence flag. Asserting + only `signature` passes even when the device never transmits the + witness stack -- the host then gets a tx that declares the segwit + marker/flag, carries no witness and no locktime, and every node + rejects it. A structural parse catches that: the marker promises + witnesses, so the stream ends early and the offset check fails. + + Returns the witness stacks, one list per input. + """ + pos = [0] + + def take(n): + if len(raw) < pos[0] + n: + raise AssertionError( + "tx truncated at offset %d: wanted %d more byte(s) of %d " + "total: %s" + % (pos[0], n, len(raw), hexlify(raw).decode())) + out = raw[pos[0]:pos[0] + n] + pos[0] += n + return out + + def varint(): + first = take(1)[0] + if first < 0xfd: + return first + width = {0xfd: 2, 0xfe: 4, 0xff: 8}[first] + return int.from_bytes(take(width), "little") + + take(4) # nVersion + marker = take(2) + if marker != unhexlify("0001"): + raise AssertionError( + "missing segwit marker/flag: got %s" % hexlify(marker).decode()) + if varint() != n_in: + raise AssertionError("unexpected input count") + for _ in range(n_in): + take(32); take(4); take(varint()); take(4) # outpoint, sig, seq + if varint() != n_out: + raise AssertionError("unexpected output count") + for _ in range(n_out): + take(8); take(varint()) # value, scriptPubKey + witnesses = [[take(varint()) for _ in range(varint())] + for _ in range(n_in)] + take(4) # nLockTime footer + if pos[0] != len(raw): + raise AssertionError( + "trailing bytes: parsed %d of %d" % (pos[0], len(raw))) + + # Every BIP-340 signature the device reported must actually appear in + # the witness data it serialized. + flat = [item for stack in witnesses for item in stack] + for sig in signatures: + if len(sig) == 64 and sig not in flat: + raise AssertionError( + "schnorr signature absent from serialized_tx witnesses") + return witnesses + + def test_send_p2tr(self): + """Spend a P2TR input and compare the witness byte for byte. + + BIP-340 signing is deterministic given aux_rand, and the firmware + signs with an all-zero aux, so this is an equality check against a + signature computed independently of the firmware -- not a round trip + through our own verifier, which would pass even if the device + committed to the wrong transaction. + """ + self.requires_taproot() + self.setup_mnemonic_abandon() + self.client.set_tx_api(TxApiBitcoin) + + inp1 = proto_types.TxInputType( + address_n=parse_path("86'/0'/0'/0/0"), + amount=IN_AMOUNT, + prev_hash=unhexlify(PREV_TXID), + prev_index=0, + script_type=proto_types.SPENDTAPROOT, + ) + out1 = proto_types.TxOutputType( + address=OUT_ADDRESS, + amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS, + ) + + with self.client: + self.client.set_expected_responses([ + proto.TxRequest( + request_type=proto_types.TXINPUT, + details=proto_types.TxRequestDetailsType( + request_index=0)), + proto.TxRequest( + request_type=proto_types.TXOUTPUT, + details=proto_types.TxRequestDetailsType( + request_index=0)), + proto.ButtonRequest( + code=proto_types.ButtonRequest_ConfirmOutput), + proto.ButtonRequest( + code=proto_types.ButtonRequest_SignTx), + proto.TxRequest( + request_type=proto_types.TXINPUT, + details=proto_types.TxRequestDetailsType( + request_index=0)), + proto.TxRequest( + request_type=proto_types.TXOUTPUT, + details=proto_types.TxRequestDetailsType( + request_index=0)), + proto.TxRequest( + request_type=proto_types.TXINPUT, + details=proto_types.TxRequestDetailsType( + request_index=0)), + proto.TxRequest(request_type=proto_types.TXFINISHED), + ]) + (signatures, serialized) = self.client.sign_tx( + "Bitcoin", [inp1], [out1]) + + self.assertEqual(len(signatures), 1) + self.assertEqual(hexlify(signatures[0]).decode(), EXPECTED_WITNESS) + witnesses = self.assertCompleteSegwitTx(serialized, signatures, 1, 1) + # key-path spend: exactly one stack item, the bare 64-byte signature + self.assertEqual(witnesses[0], [signatures[0]]) + self.assertEqual(hexlify(serialized).decode(), EXPECTED_SERIALIZED_TX) + + def test_send_p2tr_with_change(self): + """P2TR change is device-derived and omitted from recipient prompts.""" + self.requires_taproot() + self.setup_mnemonic_abandon() + self.client.set_tx_api(TxApiBitcoin) + + inp1 = proto_types.TxInputType( + address_n=parse_path("86'/0'/0'/0/0"), + amount=IN_AMOUNT, + prev_hash=unhexlify(PREV_TXID), + prev_index=0, + script_type=proto_types.SPENDTAPROOT, + ) + recipient = proto_types.TxOutputType( + address=OUT_ADDRESS, + amount=50000, + script_type=proto_types.PAYTOADDRESS, + ) + change = proto_types.TxOutputType( + address_n=parse_path("86'/0'/0'/1/0"), + amount=40000, + script_type=proto_types.PAYTOTAPROOT, + ) + + (signatures, serialized) = self.client.sign_tx( + "Bitcoin", [inp1], [recipient, change]) + + self.assertEqual(hexlify(signatures[0]).decode(), + EXPECTED_CHANGE_WITNESS) + # EXPECTED_CHANGE_SCRIPT is a phase-1 output byte, which the device + # transmits regardless of whether the witness ever reaches the host. + # Assert the whole transaction, not just that prefix. + self.assertIn(unhexlify(EXPECTED_CHANGE_SCRIPT), serialized) + witnesses = self.assertCompleteSegwitTx(serialized, signatures, 1, 2) + self.assertEqual(witnesses[0], [signatures[0]]) + self.assertEqual(hexlify(serialized).decode(), + EXPECTED_SERIALIZED_TX_CHANGE) + + def test_send_mixed_p2tr_and_legacy(self): + """A P2TR signature commits to the legacy input's real prevout.""" + self.requires_taproot() + self.setup_mnemonic_abandon() + self.client.set_tx_api(TxApiBitcoin) + + taproot = proto_types.TxInputType( + address_n=parse_path("86'/0'/0'/0/0"), + amount=100000, + prev_hash=unhexlify(MIXED_PREV_TXID), + prev_index=0, + script_type=proto_types.SPENDTAPROOT, + ) + legacy = proto_types.TxInputType( + address_n=parse_path("44'/0'/0'/0/0"), + amount=50000, + prev_hash=unhexlify(MIXED_PREV_TXID), + prev_index=1, + script_type=proto_types.SPENDADDRESS, + ) + recipient = proto_types.TxOutputType( + address=OUT_ADDRESS, + amount=140000, + script_type=proto_types.PAYTOADDRESS, + ) + + (signatures, serialized) = self.client.sign_tx( + "Bitcoin", [taproot, legacy], [recipient]) + + self.assertEqual(len(signatures), 2) + self.assertEqual(hexlify(signatures[0]).decode(), + EXPECTED_MIXED_WITNESS) + self.assertTrue(signatures[1]) + witnesses = self.assertCompleteSegwitTx(serialized, signatures, 2, 1) + self.assertEqual(witnesses[0], [signatures[0]]) + # the legacy input must still serialize an EMPTY witness (0x00) + self.assertEqual(witnesses[1], []) + self.assertEqual(hexlify(serialized).decode(), + EXPECTED_SERIALIZED_TX_MIXED) + + def test_mixed_p2tr_requires_every_input_amount(self): + """Fail closed instead of signing an incomplete BIP-341 commitment.""" + self.requires_taproot() + self.setup_mnemonic_abandon() + self.client.set_tx_api(TxApiBitcoin) + + taproot = proto_types.TxInputType( + address_n=parse_path("86'/0'/0'/0/0"), + amount=100000, + prev_hash=unhexlify(MIXED_PREV_TXID), + prev_index=0, + script_type=proto_types.SPENDTAPROOT, + ) + incomplete_legacy = proto_types.TxInputType( + address_n=parse_path("44'/0'/0'/0/0"), + prev_hash=unhexlify(MIXED_PREV_TXID), + prev_index=1, + script_type=proto_types.SPENDADDRESS, + ) + recipient = proto_types.TxOutputType( + address=OUT_ADDRESS, + amount=140000, + script_type=proto_types.PAYTOADDRESS, + ) + + with self.assertRaisesRegex( + CallException, + "Taproot transaction input without amount"): + self.client.sign_tx( + "Bitcoin", [taproot, incomplete_legacy], [recipient]) + + def test_mixed_p2tr_rejects_wrong_legacy_amount(self): + """Reject a host amount that disagrees with the actual legacy prevout.""" + self.requires_taproot() + self.setup_mnemonic_abandon() + self.client.set_tx_api(TxApiBitcoin) + + taproot = proto_types.TxInputType( + address_n=parse_path("86'/0'/0'/0/0"), + amount=100000, + prev_hash=unhexlify(MIXED_PREV_TXID), + prev_index=0, + script_type=proto_types.SPENDTAPROOT, + ) + tampered_legacy = proto_types.TxInputType( + address_n=parse_path("44'/0'/0'/0/0"), + amount=50001, + prev_hash=unhexlify(MIXED_PREV_TXID), + prev_index=1, + script_type=proto_types.SPENDADDRESS, + ) + recipient = proto_types.TxOutputType( + address=OUT_ADDRESS, + amount=140000, + script_type=proto_types.PAYTOADDRESS, + ) + + with self.assertRaisesRegex( + CallException, + "Input amount or script does not match prevout"): + self.client.sign_tx( + "Bitcoin", [taproot, tampered_legacy], [recipient]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_solana_display_disclosure.py b/tests/test_msg_solana_display_disclosure.py index 8c3f56b1..6eaec243 100644 --- a/tests/test_msg_solana_display_disclosure.py +++ b/tests/test_msg_solana_display_disclosure.py @@ -57,6 +57,7 @@ def build_memo_tx(signer, memo): class TestSolanaDisplayDisclosure(common.KeepKeyTest): def setUp(self): super(TestSolanaDisplayDisclosure, self).setUp() + self.requires_fullFeature() self.requires_firmware("7.14.2") self.setup_mnemonic_allallall() diff --git a/tests/test_msg_solana_foreign_path.py b/tests/test_msg_solana_foreign_path.py new file mode 100644 index 00000000..e8887a61 --- /dev/null +++ b/tests/test_msg_solana_foreign_path.py @@ -0,0 +1,75 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +"""Solana's handlers sign with the key at the path the host names. + +Outside m/44'/501' that is another coin's key. A TON wallet authorizes a +transfer with a raw Ed25519 signature over a 32-byte hash, so a "Solana +message" of 32 bytes signed on m/44'/607'/0' is a TON signature, shown under +Solana's screens. The handlers refuse such a path in every mode. +""" + +import unittest + +import common + +from keepkeylib import messages_pb2 as proto +from keepkeylib import messages_solana_pb2 as solana_proto +from keepkeylib import types_pb2 as types +from keepkeylib.tools import parse_path + +REFUSAL = "Invalid Solana path (expected m/44'/501'/...)" +FOREIGN = ("m/44'/607'/0'", # TON + "m/44'/60'/0'/0/0", # Ethereum + "m/501'/0'/0/0") # no coin type at all + + +class TestMsgSolanaForeignPath(common.KeepKeyTest): + + def _ready(self): + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.requires_message("SolanaSignMessage") + self.requires_release_capability("solana-runtime-review") + self.setup_mnemonic_allallall() + + def _refused(self, msg): + # call_raw answers nothing: a ButtonRequest here would mean the + # device started a review, or showed only a warning. + resp = self.client.call_raw(msg) + self.assertIsInstance(resp, proto.Failure) + self.assertEqual(resp.code, types.Failure_SyntaxError) + self.assertEqual(resp.message, REFUSAL) + + def test_a_message_is_not_signed_with_another_coins_key(self): + self._ready() + printable = b"A" * 32 # needs no AdvancedMode on a Solana path + for advanced in (False, True): + if advanced: + self.client.apply_policy("AdvancedMode", 1) + for path in FOREIGN: + with self.subTest("%s advanced=%s" % (path, advanced)): + self._refused(solana_proto.SolanaSignMessage( + address_n=parse_path(path), message=printable)) + + def test_an_address_is_not_derived_from_another_coins_key(self): + self._ready() + for path in FOREIGN: + with self.subTest(path): + self._refused(solana_proto.SolanaGetAddress( + address_n=parse_path(path))) + + def test_a_solana_path_still_signs_a_plain_message(self): + self._ready() + resp = self.client.solana_sign_message( + parse_path("m/44'/501'/0'/0'"), b"A" * 32) + self.assertEqual(len(resp.signature), 64) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_solana_getaddress.py b/tests/test_msg_solana_getaddress.py index 8a5abfb7..db767fc9 100644 --- a/tests/test_msg_solana_getaddress.py +++ b/tests/test_msg_solana_getaddress.py @@ -72,13 +72,17 @@ def test_solana_show_address(self): self.requires_message("SolanaGetAddress") self.setup_mnemonic_allallall() - resp = self.client.call( - solana_proto.SolanaGetAddress( - address_n=[H + 44, H + 501, H + 0, H + 0], - show_display=True, - ) - ) + expected = self.client.solana_get_address( + [H + 44, H + 501, H + 0, H + 0]).address + resp = self.assert_shows_address( + lambda: self.client.call( + solana_proto.SolanaGetAddress( + address_n=[H + 44, H + 501, H + 0, H + 0], + show_display=True, + ) + ), expected) self.assertIsInstance(resp, solana_proto.SolanaAddress) + self.assertEqual(resp.address, expected) def test_solana_different_accounts(self): """Different account indices must produce different addresses.""" diff --git a/tests/test_msg_solana_instruction_disclosure.py b/tests/test_msg_solana_instruction_disclosure.py index 69e21ad6..c44b37c5 100644 --- a/tests/test_msg_solana_instruction_disclosure.py +++ b/tests/test_msg_solana_instruction_disclosure.py @@ -62,8 +62,12 @@ def build_tx(account_keys, required_signatures, instructions, class TestSolanaInstructionDisclosure(common.KeepKeyTest): def setUp(self): super(TestSolanaInstructionDisclosure, self).setUp() + self.requires_fullFeature() self.requires_firmware("7.14.2") self.setup_mnemonic_allallall() + # Opaque-policy tests must not inherit AdvancedMode from an earlier + # test because policies persist across Initialize/session setup. + self.client.apply_policy("AdvancedMode", 0) response = self.client.call(solana.SolanaGetAddress( address_n=PATH, show_display=False )) diff --git a/tests/test_msg_solana_lut_attestation.py b/tests/test_msg_solana_lut_attestation.py new file mode 100644 index 00000000..8455de8a --- /dev/null +++ b/tests/test_msg_solana_lut_attestation.py @@ -0,0 +1,246 @@ +"""KKSOLSW1 -- transaction-bound lookup-table account attestation. + +A Solana v0 message may source instruction accounts from an Address Lookup +Table. Those accounts are NOT in the bytes being signed, so the device cannot +derive them: it forces the whole transaction to SOL_TX_REVIEW_OPAQUE, refuses +it outright without AdvancedMode, and treats it as an explicit BLIND SIGN with +AdvancedMode on. The instruction's meaning is never shown. + +A clear-sign provider may attest the resolved account list for THIS exact +transaction, turning that blind sign into a described one. The attestation is: + + * DOMAIN-TAGGED -- "KeepKeySolanaTxAccounts/1", so a signature made for any + other purpose (an EVM metadata blob, a token definition) + cannot be replayed as one; + * TX-BOUND -- over sha256(raw_tx), so it cannot be replayed onto a + different transaction; + * ADDITIVE -- the blind-sign warning still follows it. A runtime signer + is annotation, never authority. + +These tests assert all three, and assert that every failure mode degrades to +exactly the flow that exists today rather than to something new. +""" +import struct +import unittest + +import common +import keepkeylib.messages_solana_pb2 as messages +import keepkeylib.types_pb2 as proto_types +from keepkeylib.client import CallException +from keepkeylib.tools import parse_path + +TAG = b"KeepKeySolanaTxAccounts/1" +SLOT = 3 + + +class TestSolanaLutAttestation(common.KeepKeyTest): + + SYSTEM_PROGRAM = b'\x00' * 32 + + def setUp(self): + super(TestSolanaLutAttestation, self).setUp() + self.requires_fullFeature() + # The wire fields exist before every release implements the trust and + # presentation path. The device capability—not its version—decides + # whether these positive provider-attestation tests apply. + self.requires_solana_lut_attestation() + self.requires_message("LoadClearsignSigner") + self.setup_mnemonic_allallall() + + # ---------------------------------------------------------------- helpers + + def _raw_pubkey(self): + """The device's Solana (ed25519) pubkey, decoded from the base58 + address. get_public_node() would hand back a secp256k1 key, which is + not what signs a Solana transaction -- the device would then reject the + tx with "Derived key is not a signer".""" + addr = self.client.call(messages.SolanaGetAddress( + address_n=parse_path("m/44'/501'/0'/0'"), + show_display=False)).address + ALPHABET = ('123456789ABCDEFGHJKLMNPQRSTUVWXYZ' + 'abcdefghijkmnopqrstuvwxyz') + n = 0 + for c in addr: + n = n * 58 + ALPHABET.index(c) + return n.to_bytes(32, 'big') + + def _build_lut_tx(self, from_pubkey): + """A v0 message carrying a lookup-table section. + + The ALT section is what forces the device opaque -- exactly the case + KKSOLSW1 exists for. Built by hand rather than reused from another test + so the shape under test is visible here. + """ + tx = bytearray() + tx.append(0x80) # versioned, v0 + tx.extend([1, 0, 1]) # header: 1 sig, 0 ro-signed, 1 ro-unsigned + tx.append(2) # 2 static accounts + tx.extend(from_pubkey) + tx.extend(self.SYSTEM_PROGRAM) + tx.extend(b'\xbb' * 32) # recent blockhash + tx.append(1) # 1 instruction + tx.extend(bytes([1])) # program index -> SYSTEM_PROGRAM + tx.append(1) # 1 account index + tx.append(2) # index 2: past the 2 static accounts -> the LUT entry + tx.append(4) # data len + tx.extend(struct.pack('I", 1)) + self.assertEqual(CERT_SCOPE1[42:75], CERT_501[42:75]) + request = self._join_request(soltoshi_join(self.signer), + [sdice_definition()]) + request.clearsign_certificate = CERT_SCOPE1 + self.assertRefused(request, INVALID_CERTIFICATE) + + def test_certified_short_schema_signature_refused(self): + """The delegate's signature without its last byte. That byte is 0x00, + so the firmware's zeroed 64-byte field holds the real signature + again; only the length check refuses it.""" + self.assertEqual(SOLTOSHI_SCHEMA_SIG[-1], 0) + request = self._join_request(soltoshi_join(self.signer), + [sdice_definition()]) + request.schema_signature = SOLTOSHI_SCHEMA_SIG[:63] + self.assertRefused(request, SCHEMA_MISMATCH) + + +class TestSolanaSchemaRuntime(SchemaReview): + + def setUp(self): + super(TestSolanaSchemaRuntime, self).setUp() + self.requires_message("LoadClearsignSigner") + self.requires_release_capability("solana-runtime-review") + signed_metadata.assert_test_key_matches_slot3() + self.client.apply_policy("AdvancedMode", True) + self.signer = self._signer() + + def _load_signers(self): + """Slots 2 and 3 hold the same CI key under different aliases.""" + for key_id, alias in ((3, "CI Test"), (2, "CI Two")): + self.client.load_clearsign_signer( + key_id=key_id, + pubkey=signed_metadata.test_signer_compressed_pubkey(), + alias=alias) + + def test_runtime_schema_refuses_transfer_companion(self): + self._load_signers() + request = solana.SolanaSignTx( + address_n=PATH, + raw_tx=relay_message(self.signer, [RELAY_IX, TRANSFER_IX]), + schema_payload=RELAY_SCHEMA, schema_signature=sign(RELAY_SCHEMA), + schema_signer_key_id=3) + with self.assertRaises(CallException) as refused: + self.client.call(request) + self.assertIn("Invalid Solana instruction schema", + str(refused.exception)) + + # Control: the same request without the Transfer is reviewed. + request.raw_tx = relay_message(self.signer, [RELAY_IX]) + response, screens = self._review(request, "runtime_relay") + self.assertEqual(len(response.signature), 64) + self.assertShows(screens, "AMOUNT", "0.996374000 SOL") + + def test_runtime_schema_cannot_rescue_malformed_transaction(self): + self._load_signers() + raw = relay_message(self.signer, [RELAY_IX]) + malformed = ( + raw + b"\x00", # trailing bytes after a complete legacy instruction + b"\x80" + raw, # v0 message missing its lookup-table count + b"\x80" + raw + b"\x01", # truncated lookup-table entry + ) + nonstandard_path = PATH[:3] + [0] + for payload in malformed: + for with_schema in (False, True): + for path in (PATH, nonstandard_path): + with self.subTest(payload=payload.hex(), schema=with_schema, + path=path): + request = solana.SolanaSignTx( + address_n=path, raw_tx=payload) + if with_schema: + request.schema_payload = RELAY_SCHEMA + request.schema_signature = sign(RELAY_SCHEMA) + request.schema_signer_key_id = 3 + recorder = ScreenRecorder(self.client, answer=False) + with recorder, self.assertRaises(CallException) as refused: + self.client.call(request) + self.assertEqual(refused.exception.args[0], + proto_types.Failure_SyntaxError) + self.assertIn("Malformed Solana transaction", + str(refused.exception)) + self.assertEqual(recorder.screens, []) + + def test_runtime_schema_requires_complete_valid_runtime_attestation(self): + self._load_signers() + valid = solana.SolanaSignTx( + address_n=PATH, raw_tx=relay_message(self.signer, [RELAY_IX]), + schema_payload=RELAY_SCHEMA, schema_signature=sign(RELAY_SCHEMA), + schema_signer_key_id=3) + variants = ( + ("schema_payload", None), + ("schema_signature", None), + ("schema_signer_key_id", None), + ("schema_signature", valid.schema_signature[:63]), + ("schema_signature", bytes([valid.schema_signature[0] ^ 1]) + + valid.schema_signature[1:]), + ("schema_payload", RELAY_SCHEMA + b"\x00"), + ("schema_signer_key_id", 0), + ("schema_signer_key_id", 256), + ) + for field, value in variants: + with self.subTest(field=field, value=value): + request = solana.SolanaSignTx() + request.CopyFrom(valid) + if value is None: + request.ClearField(field) + else: + setattr(request, field, value) + recorder = ScreenRecorder(self.client, answer=False) + with recorder, self.assertRaises(CallException) as refused: + self.client.call(request) + self.assertEqual(refused.exception.args[0], + proto_types.Failure_SyntaxError) + self.assertIn("Invalid Solana instruction schema", + str(refused.exception)) + self.assertEqual(recorder.screens, []) + + # Initialize invalidates the runtime signer even though AdvancedMode + # remains enabled; a previously valid schema cannot retain its trust. + self.client.init_device() + recorder = ScreenRecorder(self.client, answer=False) + with recorder, self.assertRaises(CallException) as refused: + self.client.call(valid) + self.assertIn("Invalid Solana instruction schema", str(refused.exception)) + self.assertEqual(recorder.screens, []) + + def _join(self): + """The join with account 0 set to this device's key and the Transfer + dropped: the runtime path refuses any Transfer companion.""" + msg = bytearray(SOLTOSHI_JOIN) + msg[4:36] = self.signer + at = 4 + 32 * msg[3] + 32 + self.assertEqual(msg[at], 3) + ixs, q = [], at + 1 + for _ in range(3): + start = q + q += 2 + msg[q + 1] + q += 1 + msg[q] + ixs.append(bytes(msg[start:q])) + self.assertEqual(q, len(msg)) + return bytes(msg[:at]) + b"\x02" + ixs[0] + ixs[2] + + def test_runtime_token_amount_trust_comes_from_the_schema_signer(self): + """The schema is signed by slot 2. Slot 2's definition names and + scales the three TOKEN_AMOUNTs and the mint stays on screen; the same + definition from slot 3 verifies but is ignored, and the review is the + one with no definition at all: raw base units and the mint.""" + raw = self._join() + mint = raw[4 + 8 * 32:4 + 9 * 32] + self.assertEqual(b58encode(mint), SDICE_MINT) + definition = (b"KeepKeySolanaTokenDef/1" + mint + + struct.pack("") before the amount — + the authenticated token identity cannot be pushed off-view by a + host-controlled symbol. The (unattested) host token_info symbol is + shown next to the amount, and decimals come from the signed + instruction, never from the host.""" + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.setup_mnemonic_allallall() + + from_pubkey = self._get_from_pubkey() + to_account = b'\x33' * 32 # destination token account + authority = b'\x44' * 32 # transfer authority + + # USDC mint (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v) + usdc_mint = bytes([ + 0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, + 0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31, + 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4, + 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61, + ]) + + # TransferChecked: opcode=12 (u8) + amount (LE u64) + decimals (u8); + # accounts [source, mint, destination, authority] + instr_data = bytes([12]) + struct.pack(' ' screen; decimals must + also match the signed instruction bytes or the symbol is not trusted. + Runtime identities require AdvancedMode.""" + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.requires_message("LoadClearsignSigner") + self.setup_mnemonic_allallall() + import hashlib + from ecdsa import SigningKey, SECP256k1 + from ecdsa.util import sigencode_string + from keepkeylib.signed_metadata import ( + TEST_PRIVATE_KEY, test_signer_compressed_pubkey, + assert_test_key_matches_slot3) + + # Load the CI signer into slot 3 through the production trust path + # (device confirm auto-acked by debuglink) — phase 1 has no built-ins. + assert_test_key_matches_slot3() + self.client.apply_policy('AdvancedMode', True) + self.client.load_clearsign_signer( + key_id=3, + pubkey=test_signer_compressed_pubkey(), + alias="CI Test", + ) + + from_pubkey = self._get_from_pubkey() + to_account = b'\x33' * 32 + authority = b'\x44' * 32 + usdc_mint = bytes([ + 0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, + 0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31, + 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4, + 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61, + ]) + decimals = 6 + symbol = "USDC" + + # TransferChecked with decimals matching the attested value. + instr_data = bytes([12]) + struct.pack(':420) + '000000000000000000000000000000000000000000000000000000000000003a' + # length of memo string in bytes (58: ADD:ETH.ETH::420; the 59th byte the old 0x3b counted was ABI padding) # ADD:ETH.ETH:0xc5b2608927ea95ed43f842f553e3a27b09c050e8:420 '4144443a4554482e4554483a3078633562323630383932376561393565643433' + '663834326635353365336132376230396330353065383a343230000000000000') - - ) - self.assertEqual(sig_v, 37) - self.assertEqual(hexlify(sig_r), '7adc5bda6e66b37a81962557c844509c4bfaa1e9217fc6d05968286d60b67dbf') - self.assertEqual(hexlify(sig_s), '613479150c4cfbcdc8243055aa5137afc89826c4176c420a60409f139171831b') + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=address_n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + value=value, to=to, address_type=0, chain_id=1, data=data) + # Verify the signature is over the EXACT transaction above and by + # THIS device's key. Length checks alone would also pass for a wrong + # router, wrong calldata or wrong sighash; recovery would not. + # + # 7.14.2 froze exact vectors for this calldata against the OLD `to` + # (0x41e5560054824ea6b0732e656e3ad64e20e94e45). `to` is an RLP field of + # the sighash, so they describe a different transaction. Kept as the + # oracle for that superseded fixture: + # sig_v 37 + # r 7adc5bda6e66b37a81962557c844509c4bfaa1e9217fc6d05968286d60b67dbf + # s 613479150c4cfbcdc8243055aa5137afc89826c4176c420a60409f139171831b + self.assertIn(sig_v, [37, 38]) # EIP-155 chain_id=1 + self.assertEqual(len(sig_r), 32) + self.assertEqual(len(sig_s), 32) + digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, + data, 1) + signer = recover_eth_signer(sig_r, sig_s, sig_v, digest, 1) + # NB: KeepKeyTest's assertEqual override takes no msg argument. + self.assertEqual(signer, self.client.ethereum_get_address(address_n)) def test_thorchain_remove_liquidity(self): self.requires_fullFeature() diff --git a/tests/test_msg_ton_getaddress.py b/tests/test_msg_ton_getaddress.py index c74cc62d..667cb232 100644 --- a/tests/test_msg_ton_getaddress.py +++ b/tests/test_msg_ton_getaddress.py @@ -45,24 +45,26 @@ def test_ton_get_address(self): self.assertTrue(len(address) > 0, "TON address must be non-empty") def test_ton_show_address(self): - """Display TON address on OLED (triggers ButtonRequest for screenshot). + """The whole TON address is on the screen, and in the response. - In screenshot mode, DebugLink read_layout() can race with the - show_display response. Known issue: raw_address field causes - UnicodeDecodeError. Address correctness verified by test_ton_get_address. + The handler builds its response before the confirm. Before 7.15.0 a + DebugLink read during the confirm wiped it (firmware 88ae1c3be fixed + that; this test used to swallow the resulting raw_address + UnicodeDecodeError), so it runs from 7.15.0. """ - self.requires_firmware("7.14.0") + self.requires_firmware("7.15.0") self.requires_message("TonGetAddress") self.setup_mnemonic_allallall() - try: - resp = self.client.ton_get_address( + expected = self.client.ton_get_address( + parse_path(TON_DEFAULT_PATH), show_display=False) + resp = self.assert_shows_address( + lambda: self.client.ton_get_address( parse_path(TON_DEFAULT_PATH), show_display=True - ) - self.assertIsNotNone(resp) - except (UnicodeDecodeError, Exception): - pass # raw_address proto bug or screenshot race + ), expected.address) + self.assertEqual(resp.address, expected.address) + self.assertEqual(resp.raw_address, expected.raw_address) def test_ton_different_accounts(self): """Different derivation paths must produce different addresses.""" diff --git a/tests/test_msg_ton_signtx.py b/tests/test_msg_ton_signtx.py index a01ebaa0..ce37299b 100644 --- a/tests/test_msg_ton_signtx.py +++ b/tests/test_msg_ton_signtx.py @@ -153,6 +153,11 @@ def test_ton_sign_missing_fields_rejected(self): """Test that incomplete structured fields are rejected.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + # Same 7.14.2 AdvancedMode gate as the signing tests above, but this is + # a reject-path test: with the gate CLOSED the firmware refuses every + # TonSignTx, so the assertion below would pass without the parser ever + # validating anything. Opt in so the rejection proves what it claims. + self.client.apply_policy("AdvancedMode", 1) msg = ton_messages.TonSignTx( address_n=parse_path(TON_PATH), @@ -201,6 +206,11 @@ def test_ton_sign_empty_raw_tx(self): """Empty raw_tx (0 bytes) should be rejected by firmware.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + # Same 7.14.2 AdvancedMode gate as the signing tests above, but this is + # a reject-path test: with the gate CLOSED the firmware refuses every + # TonSignTx, so the assertion below would pass without the parser ever + # validating anything. Opt in so the rejection proves what it claims. + self.client.apply_policy("AdvancedMode", 1) msg = ton_messages.TonSignTx( address_n=parse_path(TON_PATH), @@ -214,6 +224,11 @@ def test_ton_sign_oversized_raw_tx(self): """raw_tx of 1025 bytes exceeds proto max (1024) and should be rejected.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + # Same 7.14.2 AdvancedMode gate as the signing tests above, but this is + # a reject-path test: with the gate CLOSED the firmware refuses every + # TonSignTx, so the assertion below would pass without the parser ever + # validating anything. Opt in so the rejection proves what it claims. + self.client.apply_policy("AdvancedMode", 1) raw_tx = b'\xAB' * 1025 diff --git a/tests/test_msg_tron_getaddress.py b/tests/test_msg_tron_getaddress.py index 21bdabb4..da3dd089 100644 --- a/tests/test_msg_tron_getaddress.py +++ b/tests/test_msg_tron_getaddress.py @@ -45,23 +45,24 @@ def test_tron_get_address(self): self.assertTrue(address.startswith('T'), "Tron address must start with 'T', got '%s'" % address) def test_tron_show_address(self): - """Display TRON address on OLED (triggers ButtonRequest for screenshot). + """The whole TRON address is on the screen, and in the response. - In screenshot mode, DebugLink read_layout() can race with the - show_display response. Address correctness verified by test_tron_get_address. + The handler builds its response before the confirm. Before 7.15.0 a + DebugLink read during the confirm wiped it (fixed by firmware + 88ae1c3be), so it runs from 7.15.0. """ - self.requires_firmware("7.14.0") + self.requires_firmware("7.15.0") self.requires_message("TronGetAddress") self.setup_mnemonic_allallall() - try: - resp = self.client.tron_get_address( + expected = self.client.tron_get_address( + parse_path(TRON_DEFAULT_PATH)).address + resp = self.assert_shows_address( + lambda: self.client.tron_get_address( parse_path(TRON_DEFAULT_PATH), show_display=True - ) - self.assertIsNotNone(resp) - except Exception: - pass # Screenshot race -- OLED display still worked + ), expected) + self.assertEqual(resp.address, expected) def test_tron_different_accounts(self): """Different derivation paths must produce different addresses.""" diff --git a/tests/test_msg_tron_signtx.py b/tests/test_msg_tron_signtx.py index 026f5ab1..315e02ba 100644 --- a/tests/test_msg_tron_signtx.py +++ b/tests/test_msg_tron_signtx.py @@ -16,13 +16,66 @@ _has_tron = False import common import binascii +import hashlib import struct +from ecdsa import SECP256k1, VerifyingKey +from ecdsa.util import sigdecode_string + from keepkeylib import messages_pb2 as messages from keepkeylib import messages_tron_pb2 as tron_messages from keepkeylib import types_pb2 as types from keepkeylib.client import CallException -from keepkeylib.tools import parse_path +from keepkeylib.signed_metadata import keccak256 +from keepkeylib.tools import b58decode, b58encode, parse_path + + +USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" # tether.to supported-protocols +TRIGGER_URL = b"type.googleapis.com/protocol.TriggerSmartContract" + + +def _varint(value): + out = bytearray() + while value >= 0x80: + out.append((value & 0x7F) | 0x80) + value >>= 7 + out.append(value) + return bytes(out) + + +def _field(number, payload): + """Length-delimited protobuf field.""" + return _varint(number << 3 | 2) + _varint(len(payload)) + payload + + +def _uint(number, value): + return _varint(number << 3) + _varint(value) + + +def _tron_raw(address): + """21-byte TRON address from Base58Check.""" + raw = b58decode(address, None) + payload, checksum = raw[:-4], raw[-4:] + assert hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] == checksum + assert len(payload) == 21 and payload[0] == 0x41 + return payload + + +def _tron_address(raw21): + checksum = hashlib.sha256(hashlib.sha256(raw21).digest()).digest()[:4] + return b58encode(raw21 + checksum) + + +def _trc20_transfer_raw_data(owner, contract, to, amount, fee_limit): + """protocol.Transaction.raw for one TriggerSmartContract transfer().""" + calldata = (binascii.unhexlify("a9059cbb") + b"\0" * 12 + to[1:] + + amount.to_bytes(32, "big")) + trigger = _field(1, owner) + _field(2, contract) + _field(4, calldata) + contract_msg = _uint(1, 31) + _field(2, _field(1, TRIGGER_URL) + + _field(2, trigger)) + return (_field(1, b"\xab\xcd") + _field(4, b"\x42" * 8) + + _uint(8, 1700000000000) + _field(11, contract_msg) + + _uint(14, 1699999990000) + _uint(18, fee_limit)) @unittest.skipUnless(_has_tron, "TRON protobuf messages not available in this build") @@ -79,7 +132,11 @@ def test_tron_sign_transfer_structured(self): self.assertFalse(all(b == 0 for b in resp.signature)) def test_tron_sign_transfer_legacy_raw_data(self): - """Test legacy blind-sign with raw_data field.""" + """Test legacy blind-sign with raw_data field. + + This raw_data is a hand-rolled blob, not a real TransferContract, so + the raw_data clear-sign parser can't decode it — it falls to the + opaque blind-sign path, which requires AdvancedMode.""" self.requires_fullFeature() self.setup_mnemonic_allallall() # 7.14.2 gates TronSignTx behind AdvancedMode: this line has no raw_data @@ -98,7 +155,9 @@ def test_tron_sign_transfer_legacy_raw_data(self): address_n=parse_path("m/44'/195'/0'/0/0"), raw_data=raw_data, ) + self.client.apply_policy('AdvancedMode', True) resp = self.client.call(msg) + self.client.apply_policy('AdvancedMode', False) # Should have a 65-byte signature self.assertEqual(len(resp.signature), 65) @@ -158,6 +217,71 @@ def test_tron_sign_trc20_transfer(self): self.assertGreater(len(resp.serialized_tx), 0) + def _walk_sign(self, msg): + """Approve every screen, recording (title, body) as the device drew it.""" + screens = [] + response = self.client.call_raw(msg) + while isinstance(response, messages.ButtonRequest): + screens.append(self.client.debug.read_confirm_text()) + self.client.debug.press_yes() + response = self.client.call_raw(messages.ButtonAck()) + return response, screens + + def _trc20_request(self, contract): + path = parse_path("m/44'/195'/0'/0/0") + owner = self.client.call(tron_messages.TronGetAddress( + address_n=path, show_display=False)).address + to = b"\x41" + b"\x22" * 20 + raw_data = _trc20_transfer_raw_data(_tron_raw(owner), contract, to, + 1500000, 30000000) + return owner, to, raw_data, tron_messages.TronSignTx( + address_n=path, raw_data=raw_data) + + def test_tron_sign_trc20_usdt_clear_signs(self): + """USDT is in the firmware's trusted TRC-20 table: the transfer + clear-signs without AdvancedMode, in token units, and the signature + covers sha256(raw_data) under the account's own key.""" + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.requires_release_capability("tron-trc20-review") + self.setup_mnemonic_allallall() + self.client.apply_policy("AdvancedMode", 0) + + owner, to, raw_data, msg = self._trc20_request(_tron_raw(USDT)) + response, screens = self._walk_sign(msg) + + self.assertIsInstance(response, tron_messages.TronSignedTx) + self.assertEqual(screens[0], ("TRC-20 Transfer", + "Send 1.5 USDT to %s?" % _tron_address(to))) + self.assertEqual(screens[1][1], + "Energy fee limit 30 TRX\nBandwidth fees are extra") + self.assertEqual(len(screens), 2) + + signature = response.signature + self.assertEqual(len(signature), 65) + digest = hashlib.sha256(raw_data).digest() + candidates = VerifyingKey.from_public_key_recovery_with_digest( + signature[:64], digest, SECP256k1, sigdecode=sigdecode_string) + signers = set() + for key in candidates: + point = key.to_string() # 64-byte uncompressed X || Y + signers.add(_tron_address(b"\x41" + keccak256(point)[-20:])) + self.assertIn(owner, signers) + + def test_tron_sign_trc20_unknown_contract_needs_advanced_mode(self): + """A transfer() call to a contract outside the trusted table is not + clear-signed: without AdvancedMode it is refused before any screen.""" + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.requires_release_capability("tron-trc20-review") + self.setup_mnemonic_allallall() + self.client.apply_policy("AdvancedMode", 0) + + _, _, _, msg = self._trc20_request(b"\x41" + b"\x33" * 20) + response = self.client.call_raw(msg) + self.assertIsInstance(response, messages.Failure) + self.assertEqual(response.message, "Enable AdvancedMode to blind-sign") + def test_tron_sign_empty_raw_data(self): """Signing with empty raw_data should be rejected by firmware.""" self.requires_fullFeature() @@ -204,6 +328,8 @@ def test_tron_sign_deterministic(self): address_n=parse_path("m/44'/195'/0'/0/0"), raw_data=raw_data, ) + # Not a decodable TransferContract — opaque blind-sign, needs AdvancedMode. + self.client.apply_policy('AdvancedMode', True) resp1 = self.client.call(msg1) msg2 = tron_messages.TronSignTx( @@ -211,6 +337,7 @@ def test_tron_sign_deterministic(self): raw_data=raw_data, ) resp2 = self.client.call(msg2) + self.client.apply_policy('AdvancedMode', False) self.assertEqual(len(resp1.signature), 65) self.assertEqual(len(resp2.signature), 65) @@ -237,6 +364,8 @@ def test_tron_sign_different_accounts(self): address_n=parse_path("m/44'/195'/0'/0/0"), raw_data=raw_data, ) + # Not a decodable TransferContract — opaque blind-sign, needs AdvancedMode. + self.client.apply_policy('AdvancedMode', True) resp_acct0 = self.client.call(msg_acct0) msg_acct1 = tron_messages.TronSignTx( @@ -244,6 +373,7 @@ def test_tron_sign_different_accounts(self): raw_data=raw_data, ) resp_acct1 = self.client.call(msg_acct1) + self.client.apply_policy('AdvancedMode', False) self.assertEqual(len(resp_acct0.signature), 65) self.assertEqual(len(resp_acct1.signature), 65) diff --git a/tests/test_msg_zcash_display_address.py b/tests/test_msg_zcash_display_address.py index 2dfdef0e..53112d57 100644 --- a/tests/test_msg_zcash_display_address.py +++ b/tests/test_msg_zcash_display_address.py @@ -3,13 +3,16 @@ # Tests ZcashDisplayAddress message which verifies that a unified address # contains an Orchard receiver derived from this device's seed. # -# The host provides the unified address + FVK components (ak, nk, rivk). -# The device re-derives its own Orchard keys and compares them. +# The device derives its own Orchard unified address from address_n/account +# and returns it (ZcashAddress) after on-screen confirmation. It can also +# verify an expected_seed_fingerprint to pin the attestation to this device. import unittest import common from keepkeylib import messages_zcash_pb2 as zcash_proto +from keepkeylib import types_pb2 as proto_types +from keepkeylib.client import CallException from keepkeylib.tools import parse_path # Hardened offset @@ -37,43 +40,46 @@ def test_zcash_display_address_basic(self): self.assertIsNotNone(fvk_resp.nk) self.assertIsNotNone(fvk_resp.rivk) - # Use a placeholder unified address -- real address construction - # requires librustzcash (host-side). The firmware verifies the FVK - # matches its own derivation, not the address encoding. - # For a real test, construct a proper unified address externally. - resp = self.client.call( - zcash_proto.ZcashDisplayAddress( - address_n=[H + 32, H + 133, H + 0], - account=0, - address="u1placeholder", - ak=fvk_resp.ak, - nk=fvk_resp.nk, - rivk=fvk_resp.rivk, - ) - ) + # The device derives its OWN unified address from address_n/account + # (the host does not supply address/FVK — those fields are reserved). + # No message returns the UA without displaying it and there is no + # independent vector, so this proves only that the screen shows all of + # the address the device returns. + resp = self.assert_shows_address( + lambda: self.client.call( + zcash_proto.ZcashDisplayAddress( + address_n=[H + 32, H + 133, H + 0], + account=0, + ) + ), lambda resp: resp.address) - # Device should verify FVK matches and return the address + # Device returns the confirmed UA bound to its seed. self.assertIsInstance(resp, zcash_proto.ZcashAddress) + self.assertTrue(resp.address.startswith("u1")) + self.assertTrue(resp.HasField("seed_fingerprint")) + self.assertEqual(len(resp.seed_fingerprint), 32) - def test_zcash_display_address_wrong_fvk_rejected(self): - """Device rejects address when FVK doesn't match its own derivation.""" + def test_zcash_display_address_bad_path_rejected(self): + """A path that is neither m/32'/133'/account' nor an explicit account + is rejected with a SyntaxError (no silent wrong-account derivation).""" self.setup_mnemonic_allallall() - import pytest - from keepkeylib.client import CallException + # Without account=, the path itself selects the approved account. + valid = self.client.zcash_display_address( + address_n=[H + 32, H + 133, H + 0]) + explicit = self.client.zcash_display_address(account=0) + self.assertIsInstance(valid, zcash_proto.ZcashAddress) + self.assertEqual(valid.address, explicit.address) - # Send bogus FVK -- device should reject - with pytest.raises(CallException): + with self.assertRaises(CallException) as caught: self.client.call( zcash_proto.ZcashDisplayAddress( - address_n=[H + 32, H + 133, H + 0], - account=0, - address="u1placeholder", - ak=b'\x00' * 32, - nk=b'\x00' * 32, - rivk=b'\x00' * 32, + address_n=[H + 44, H + 133, H + 0], # wrong purpose (44') ) ) + self.assertEqual(caught.exception.args[0], proto_types.Failure_SyntaxError) + self.assertEqual(caught.exception.args[1], + "Require account field or ZIP-32 path m/32'/133'/account'") if __name__ == '__main__': diff --git a/tests/test_msg_zcash_orchard.py b/tests/test_msg_zcash_orchard.py index 082292e1..5f85648c 100644 --- a/tests/test_msg_zcash_orchard.py +++ b/tests/test_msg_zcash_orchard.py @@ -12,6 +12,9 @@ import unittest import common import binascii +import pytest +from keepkeylib.client import CallException +from keepkeylib import types_pb2 as proto_types # Pallas curve constants PALLAS_P = 0x40000000000000000000000000000000224698fc094cf91b992d30ed00000001 @@ -42,6 +45,30 @@ def setUp(self): self.requires_firmware("7.14.0") self.requires_message("ZcashGetOrchardFVK") + def test_fvk_export_requires_consent_even_when_display_is_false(self): + self.requires_firmware("7.15.0") + self.setup_mnemonic_allallall() + self.client.setup_debuglink(False, True) + try: + with pytest.raises(CallException) as caught: + self.client.zcash_get_orchard_fvk( + address_n=[], account=0, show_display=False) + self.assertEqual(caught.value.args[0], + proto_types.Failure_ActionCancelled) + finally: + self.client.setup_debuglink(True, True) + + def test_explicit_account_cannot_alias_across_hardened_bit(self): + self.requires_firmware("7.15.0") + self.setup_mnemonic_allallall() + for account in (0x80000000, 0xffffffff): + with self.subTest(account=account): + with pytest.raises(CallException) as caught: + self.client.zcash_get_orchard_fvk( + address_n=[], account=account, show_display=False) + self.assertEqual(caught.value.args[0], + proto_types.Failure_SyntaxError) + def test_fvk_field_ranges(self): """FVK components must be in valid field ranges. diff --git a/tests/test_msg_zcash_seed_fingerprint.py b/tests/test_msg_zcash_seed_fingerprint.py new file mode 100644 index 00000000..6f0810e0 --- /dev/null +++ b/tests/test_msg_zcash_seed_fingerprint.py @@ -0,0 +1,186 @@ +# Device-backed tests for ZIP-32 §6.1 seed_fingerprint binding. +# +# Pure-Python helper tests live in test_zcash_seed_fingerprint_helper.py +# (no common.KeepKeyTest dependency — runs offline). + +import unittest +import pytest + +import common + +from keepkeylib import messages_zcash_pb2 as zcash_proto +from keepkeylib import types_pb2 as proto_types +from keepkeylib.client import CallException +from keepkeylib.zcash import calculate_seed_fingerprint + +from test_msg_zcash_sign_pczt_device import ( + PADDING_BUNDLE, bundle_digest, fabricated_action, sign_kwargs, +) + +# Hardened offset +H = 0x80000000 + + +class TestMsgZcashSeedFingerprint(common.KeepKeyTest): + """Binding behavior on a real device. Wipes/initializes the device.""" + + def setUp(self): + super().setUp() + self.requires_firmware("7.15.0") + self.requires_message("ZcashGetOrchardFVK") + + def test_get_orchard_fvk_returns_seed_fingerprint(self): + """ZcashGetOrchardFVK response now includes a 32-byte seed_fingerprint.""" + self.setup_mnemonic_allallall() + + fvk = self.client.zcash_get_orchard_fvk( + address_n=[H + 32, H + 133, H + 0], + account=0, + ) + self.assertTrue(fvk.HasField("seed_fingerprint")) + self.assertEqual(len(fvk.seed_fingerprint), 32) + # Defensive: BLAKE2b should never produce all-zero output for a real seed + self.assertNotEqual(fvk.seed_fingerprint, b"\x00" * 32) + + def test_fingerprint_stable_across_accounts(self): + """Fingerprint is bound to the seed, not the account.""" + self.setup_mnemonic_allallall() + + fvk0 = self.client.zcash_get_orchard_fvk( + address_n=[H + 32, H + 133, H + 0], account=0) + fvk1 = self.client.zcash_get_orchard_fvk( + address_n=[H + 32, H + 133, H + 1], account=1) + self.assertEqual(fvk0.seed_fingerprint, fvk1.seed_fingerprint) + + # ── ZcashDisplayAddress: through client.zcash_display_address(...) ── + # These tests exercise the new expected_seed_fingerprint kwarg on the + # public client helper, not just raw protobuf. + + def test_display_address_helper_accepts_matching_fingerprint(self): + """Helper passes expected_seed_fingerprint through; matching fp succeeds.""" + self.setup_mnemonic_allallall() + + fvk = self.client.zcash_get_orchard_fvk( + address_n=[H + 32, H + 133, H + 0], account=0) + + resp = self.client.zcash_display_address( + address_n=[H + 32, H + 133, H + 0], + account=0, + expected_seed_fingerprint=fvk.seed_fingerprint, + ) + self.assertIsInstance(resp, zcash_proto.ZcashAddress) + self.assertTrue(resp.HasField("seed_fingerprint")) + self.assertEqual(resp.seed_fingerprint, fvk.seed_fingerprint) + + def test_display_address_helper_rejects_wrong_fingerprint(self): + """Helper passes expected_seed_fingerprint through; wrong fp rejected.""" + self.setup_mnemonic_allallall() + + fvk = self.client.zcash_get_orchard_fvk( + address_n=[H + 32, H + 133, H + 0], account=0) + + bad = bytearray(fvk.seed_fingerprint) + bad[0] ^= 0xFF + + with pytest.raises(CallException): + self.client.zcash_display_address( + address_n=[H + 32, H + 133, H + 0], + account=0, + expected_seed_fingerprint=bytes(bad), + ) + + def test_display_address_helper_backward_compat(self): + """Helper without expected_seed_fingerprint still works (existing flow).""" + self.setup_mnemonic_allallall() + + fvk = self.client.zcash_get_orchard_fvk( + address_n=[H + 32, H + 133, H + 0], account=0) + + resp = self.client.zcash_display_address( + address_n=[H + 32, H + 133, H + 0], + account=0, + ) + self.assertIsInstance(resp, zcash_proto.ZcashAddress) + # Device populates seed_fingerprint on responses regardless of request + self.assertTrue(resp.HasField("seed_fingerprint")) + self.assertEqual(resp.seed_fingerprint, fvk.seed_fingerprint) + + def test_device_fingerprint_matches_python_helper(self): + """Cross-check: device-derived fingerprint == calculate_seed_fingerprint(seed) + for the all-allallall mnemonic seed. Ties firmware C and python-keepkey + helper to the same byte-for-byte output.""" + self.setup_mnemonic_allallall() + + fvk = self.client.zcash_get_orchard_fvk( + address_n=[H + 32, H + 133, H + 0], account=0) + + # all-all-all mnemonic, empty passphrase, BIP-39 seed + from mnemonic import Mnemonic + seed = Mnemonic.to_seed("all all all all all all all all all all all all", "") + expected_fp = calculate_seed_fingerprint(seed) + self.assertEqual(fvk.seed_fingerprint, expected_fp) + + # ── ZcashSignPCZT: through client.zcash_sign_pczt(...) ────────────── + + def _signing_request(self): + """A complete wallet spend accepted by the current firmware.""" + actions = [fabricated_action(v) for v in PADDING_BUNDLE] + actions[1]['is_spend'] = False + kwargs = sign_kwargs(actions, fee=10000, orchard_value_balance=10000, + total_amount=90000) + kwargs['orchard_digest'] = bundle_digest( + actions, False, 5, value_balance=10000) + return kwargs + + def test_sign_pczt_helper_accepts_matching_fingerprint(self): + """The same complete request signs with this seed's fingerprint.""" + self.setup_mnemonic_allallall() + fvk = self.client.zcash_get_orchard_fvk( + address_n=[H + 32, H + 133, H], account=0) + result = self.client.zcash_sign_pczt( + **self._signing_request(), + expected_seed_fingerprint=fvk.seed_fingerprint, + ) + self.assertIsInstance(result, zcash_proto.ZcashSignedPCZT) + self.assertEqual(len(result.signatures), 1) + self.assertEqual(len(result.signatures[0]), 64) + + def test_sign_pczt_helper_rejects_wrong_fingerprint(self): + """A changed fingerprint alone causes the precise initial refusal.""" + self.setup_mnemonic_allallall() + fvk = self.client.zcash_get_orchard_fvk( + address_n=[H + 32, H + 133, H], account=0) + wrong_fp = bytearray(fvk.seed_fingerprint) + wrong_fp[0] ^= 0x01 + + responses = [] + original = self.client.call_raw + + def capture(request): + response = original(request) + responses.append(response) + return response + + self.client.call_raw = capture + try: + with pytest.raises(CallException) as caught: + self.client.zcash_sign_pczt( + **self._signing_request(), + expected_seed_fingerprint=bytes(wrong_fp), + ) + finally: + self.client.call_raw = original + + self.assertEqual(caught.value.args[0], proto_types.Failure_Other) + self.assertEqual(caught.value.args[1], + 'Seed fingerprint mismatch — wrong device') + self.assertFalse(any(isinstance(response, ( + zcash_proto.ZcashPCZTActionAck, + zcash_proto.ZcashSignedPCZT, + zcash_proto.ZcashTransparentSigned, + )) for response in responses), + 'a mismatched seed must not start action streaming or release signatures') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_zcash_sign_pczt.py b/tests/test_msg_zcash_sign_pczt.py index a61655aa..ccc06b46 100644 --- a/tests/test_msg_zcash_sign_pczt.py +++ b/tests/test_msg_zcash_sign_pczt.py @@ -1,198 +1,345 @@ -# Zcash Orchard PCZT signing protocol tests. -# -# Tests the ZcashSignPCZT / ZcashPCZTAction / ZcashPCZTActionAck flow -# via the zcash_sign_pczt() client helper against the emulator. +"""Offline contract tests for the firmware 7.15 Zcash PCZT client flow.""" import unittest -import common -import os - - -class TestZcashSignPCZT(common.KeepKeyTest): - """Test Zcash Orchard PCZT signing protocol.""" - - def setUp(self): - super().setUp() - self.requires_firmware("7.14.0") - self.requires_message("ZcashGetOrchardFVK") - - def _make_action(self, index, sighash=None, value=10000, is_spend=True): - """Build a minimal action dict for testing.""" - action = { - 'alpha': os.urandom(32), - 'value': value, - 'is_spend': is_spend, - } - if sighash is not None: - action['sighash'] = sighash - return action - - def test_single_action_legacy_sighash(self): - """Single-action signing with host-provided sighash (legacy mode).""" - self.setup_mnemonic_allallall() - - address_n = [0x80000000 + 32, 0x80000000 + 133, 0x80000000] - sighash = b'\xab' * 32 - - actions = [self._make_action(0, sighash=sighash)] - - resp = self.client.zcash_sign_pczt( - address_n=address_n, - actions=actions, - total_amount=10000, - fee=1000, - ) - - self.assertEqual(len(resp.signatures), 1) - self.assertEqual(len(resp.signatures[0]), 64) - - def test_multi_action_legacy_sighash(self): - """Multi-action signing with host-provided sighash.""" - self.setup_mnemonic_allallall() - - address_n = [0x80000000 + 32, 0x80000000 + 133, 0x80000000] - sighash = b'\xcd' * 32 - actions = [ - self._make_action(0, sighash=sighash, value=5000), - self._make_action(1, sighash=sighash, value=5000), - ] - - resp = self.client.zcash_sign_pczt( - address_n=address_n, - actions=actions, - total_amount=10000, - fee=1000, +from keepkeylib.client import ProtocolMixin +from keepkeylib import messages_zcash_pb2 as zcash_proto + + +H = 0x80000000 +ADDRESS_N = [H + 32, H + 133, H] +T_ADDRESS_N = [H + 44, H + 133, H, 0, 0] + + +class ScriptedTransport(object): + def __init__(self, reads=None): + self.reads = list(reads or []) + self.session_depth = 0 + + def session_begin(self): + self.session_depth += 1 + + def session_end(self): + self.session_depth -= 1 + + def read_blocking(self): + if not self.reads: + raise AssertionError("unexpected transport read") + return self.reads.pop(0) + + +class ScriptedClient(object): + zcash_sign_pczt = ProtocolMixin.zcash_sign_pczt + + def __init__(self, responses, reads=None): + self.responses = list(responses) + self.transport = ScriptedTransport(reads) + self.sent = [] + + def call(self, message): + self.sent.append(message) + if not self.responses: + raise AssertionError("unexpected device call: %s" % type(message)) + return self.responses.pop(0) + + +def action(index, is_spend): + return { + 'alpha': bytes([index + 1]) * 32, + 'cv_net': bytes([index + 11]) * 32, + 'value': 10000 + index, + 'is_spend': is_spend, + } + + +def sign_kwargs(actions): + return { + 'address_n': ADDRESS_N, + 'actions': actions, + 'account': 0, + 'total_amount': 50000, + 'fee': 15000, + 'branch_id': 0x5437F330, + 'header_digest': b'\x10' * 32, + 'transparent_digest': b'\x11' * 32, + 'orchard_digest': b'\x12' * 32, + 'orchard_flags': 3, + 'orchard_value_balance': -50000, + 'orchard_anchor': b'\x13' * 32, + 'tx_version': 5, + 'version_group_id': 0x26A7270A, + 'lock_time': 0, + 'expiry_height': 0, + } + + +def ironwood_sign_kwargs(actions): + kwargs = sign_kwargs(actions) + kwargs.update({ + 'branch_id': 0x37A5165B, + 'orchard_digest': b'\x14' * 32, + 'shielded_pool': zcash_proto.ZCASH_SHIELDED_POOL_IRONWOOD, + 'ironwood_digest': b'\x15' * 32, + 'orchard_value_balance': 0, + 'tx_version': 6, + 'version_group_id': 0xD884B698, + }) + return kwargs + + +class TestZcashSignPCZTClient(unittest.TestCase): + def test_ironwood_v6_metadata_is_forwarded_exactly(self): + actions = [action(0, False)] + client = ScriptedClient([ + zcash_proto.ZcashPCZTActionAck(next_index=0), + zcash_proto.ZcashSignedPCZT(signatures=[]), + ]) + + signed = client.zcash_sign_pczt(**ironwood_sign_kwargs(actions)) + + self.assertEqual(list(signed.signatures), []) + request = client.sent[0] + self.assertEqual(request.branch_id, 0x37A5165B) + self.assertEqual(request.tx_version, 6) + self.assertEqual(request.version_group_id, 0xD884B698) + self.assertEqual( + request.shielded_pool, + zcash_proto.ZCASH_SHIELDED_POOL_IRONWOOD, ) - - self.assertEqual(len(resp.signatures), 2) - for sig in resp.signatures: - self.assertEqual(len(sig), 64) - - def test_signatures_are_64_bytes(self): - """Every returned signature must be exactly 64 bytes.""" - self.setup_mnemonic_allallall() - - address_n = [0x80000000 + 32, 0x80000000 + 133, 0x80000000] - sighash = b'\xef' * 32 - - actions = [self._make_action(i, sighash=sighash) for i in range(3)] - - resp = self.client.zcash_sign_pczt( - address_n=address_n, - actions=actions, - total_amount=30000, - fee=1000, + self.assertEqual(request.orchard_digest, b'\x14' * 32) + self.assertEqual(request.ironwood_digest, b'\x15' * 32) + + def test_all_dummy_shield_streams_outputs_inputs_and_no_orchard_sigs(self): + actions = [action(0, False), action(1, False)] + responses = [ + zcash_proto.ZcashTransparentAck(next_output_index=0), + zcash_proto.ZcashTransparentAck(next_input_index=0), + zcash_proto.ZcashPCZTActionAck(next_index=0), + zcash_proto.ZcashPCZTActionAck(next_index=1), + zcash_proto.ZcashTransparentSigned(signatures=[b'\x30\x01']), + ] + final = zcash_proto.ZcashSignedPCZT(signatures=[]) + client = ScriptedClient(responses, reads=[final]) + + kwargs = sign_kwargs(actions) + kwargs.update({ + 'transparent_outputs': [{ + 'amount': 10000, + 'script_pubkey': b'\x76\xa9\x14' + b'\x21' * 20 + b'\x88\xac', + }], + 'transparent_inputs': [{ + 'address_n': T_ADDRESS_N, + 'amount': 75000, + 'prevout_txid': b'\x22' * 32, + 'prevout_index': 1, + 'sequence': 0xFFFFFFFF, + 'script_pubkey': b'\x76\xa9\x14' + b'\x23' * 20 + b'\x88\xac', + }], + 'return_transparent_signatures': True, + }) + + signed, transparent_sigs = client.zcash_sign_pczt(**kwargs) + + self.assertIs(signed, final) + self.assertEqual(list(signed.signatures), []) + self.assertEqual(transparent_sigs, [b'\x30\x01']) + self.assertEqual( + [type(message) for message in client.sent], + [ + zcash_proto.ZcashSignPCZT, + zcash_proto.ZcashTransparentOutput, + zcash_proto.ZcashTransparentInput, + zcash_proto.ZcashPCZTAction, + zcash_proto.ZcashPCZTAction, + ], ) - self.assertEqual(len(resp.signatures), 3) - for sig in resp.signatures: - self.assertEqual(len(sig), 64) - self.assertTrue(sig != b'\x00' * 64) - - def test_different_accounts_different_signatures(self): - """Same transaction with different accounts must produce different sigs.""" - self.setup_mnemonic_allallall() - - sighash = b'\x11' * 32 - alpha = b'\x01' * 31 + b'\x00' - - actions_0 = [{'alpha': alpha, 'sighash': sighash, - 'value': 10000, 'is_spend': True}] - actions_1 = [{'alpha': alpha, 'sighash': sighash, - 'value': 10000, 'is_spend': True}] - - resp0 = self.client.zcash_sign_pczt( - address_n=[0x80000000 + 32, 0x80000000 + 133, 0x80000000], - actions=actions_0, - total_amount=10000, - fee=1000, - ) - resp1 = self.client.zcash_sign_pczt( - address_n=[0x80000000 + 32, 0x80000000 + 133, 0x80000001], - actions=actions_1, - total_amount=10000, - fee=1000, + request = client.sent[0] + self.assertEqual(request.n_transparent_outputs, 1) + self.assertEqual(request.n_transparent_inputs, 1) + self.assertEqual(request.tx_version, 5) + self.assertEqual(request.version_group_id, 0x26A7270A) + self.assertFalse(request.HasField('sapling_digest')) + self.assertFalse(request.HasField('shielded_pool')) + self.assertFalse(request.HasField('ironwood_digest')) + self.assertFalse(client.sent[3].is_spend) + self.assertFalse(client.sent[4].is_spend) + self.assertFalse(client.sent[2].HasField('sighash')) + self.assertEqual(client.transport.session_depth, 0) + + def test_mixed_deshield_returns_only_real_spend_signature(self): + actions = [action(0, True), action(1, False)] + signature = b'\x40' * 64 + client = ScriptedClient([ + zcash_proto.ZcashPCZTActionAck(next_index=0), + zcash_proto.ZcashPCZTActionAck(next_index=1), + zcash_proto.ZcashSignedPCZT(signatures=[signature]), + ]) + + signed = client.zcash_sign_pczt(**sign_kwargs(actions)) + + self.assertEqual(list(signed.signatures), [signature]) + self.assertTrue(client.sent[1].is_spend) + self.assertFalse(client.sent[2].is_spend) + + def test_private_send_preserves_compact_real_spend_order(self): + actions = [action(0, True), action(1, False), action(2, True)] + signatures = [b'\x50' * 64, b'\x51' * 64] + client = ScriptedClient([ + zcash_proto.ZcashPCZTActionAck(next_index=0), + zcash_proto.ZcashPCZTActionAck(next_index=1), + zcash_proto.ZcashPCZTActionAck(next_index=2), + zcash_proto.ZcashSignedPCZT(signatures=signatures), + ]) + + signed = client.zcash_sign_pczt(**sign_kwargs(actions)) + + self.assertEqual(list(signed.signatures), signatures) + self.assertEqual( + [message.index for message in client.sent[1:]], + [0, 1, 2], ) - self.assertTrue(resp0.signatures[0] != resp1.signatures[0], - "Different accounts must produce different signatures") - - def test_transparent_shielding_single_input(self): - """Transparent-to-shielded: one Orchard action + one transparent input. + def test_documented_action_index_does_not_conflict_with_device_order(self): + supplied = action(0, False) + supplied['index'] = 9 + client = ScriptedClient([ + zcash_proto.ZcashPCZTActionAck(next_index=0), + zcash_proto.ZcashSignedPCZT(signatures=[]), + ]) + + client.zcash_sign_pczt(**sign_kwargs([supplied])) + + self.assertEqual(client.sent[1].index, 0) + self.assertEqual(supplied['index'], 9) + + def test_missing_is_spend_is_rejected_before_device_call(self): + malformed = action(0, True) + del malformed['is_spend'] + client = ScriptedClient([]) + + with self.assertRaisesRegex(ValueError, "explicitly set boolean is_spend"): + client.zcash_sign_pczt(**sign_kwargs([malformed])) + + self.assertEqual(client.sent, []) + self.assertEqual(client.transport.session_depth, 0) + + def test_host_transparent_sighash_is_rejected_before_device_call(self): + client = ScriptedClient([]) + kwargs = sign_kwargs([action(0, False)]) + kwargs['transparent_inputs'] = [{ + 'address_n': T_ADDRESS_N, + 'amount': 75000, + 'sighash': b'\x60' * 32, + }] - Exercises Phase 3 of the PCZT protocol where the device requests - transparent input signing after Orchard actions are complete. - This verifies the ZcashTransparentSig round-trip in zcash_sign_pczt(). + with self.assertRaisesRegex(ValueError, "Host-provided transparent sighash"): + client.zcash_sign_pczt(**kwargs) + + self.assertEqual(client.sent, []) + + def test_signature_count_must_match_real_spends(self): + actions = [action(0, True), action(1, False)] + client = ScriptedClient([ + zcash_proto.ZcashPCZTActionAck(next_index=0), + zcash_proto.ZcashPCZTActionAck(next_index=1), + zcash_proto.ZcashSignedPCZT(signatures=[]), + ]) + + with self.assertRaisesRegex(Exception, "0 Orchard signatures for 1 real spends"): + client.zcash_sign_pczt(**sign_kwargs(actions)) + + def _transparent_kwargs(self, actions, n_inputs): + """One transparent output plus n_inputs transparent inputs.""" + kwargs = sign_kwargs(actions) + kwargs.update({ + 'transparent_outputs': [{ + 'amount': 10000, + 'script_pubkey': b'\x76\xa9\x14' + b'\x21' * 20 + b'\x88\xac', + }], + 'transparent_inputs': [{ + 'address_n': T_ADDRESS_N, + 'amount': 75000 + i, + 'prevout_txid': bytes([0x22 + i]) * 32, + 'prevout_index': i, + 'sequence': 0xFFFFFFFF, + 'script_pubkey': b'\x76\xa9\x14' + b'\x23' * 20 + b'\x88\xac', + } for i in range(n_inputs)], + 'return_transparent_signatures': True, + }) + return kwargs + + def _transparent_acks(self, n_inputs): + return ([zcash_proto.ZcashTransparentAck(next_output_index=0)] + + [zcash_proto.ZcashTransparentAck(next_input_index=i) + for i in range(n_inputs)] + + [zcash_proto.ZcashPCZTActionAck(next_index=0)]) + + def test_short_transparent_signature_list_is_rejected(self): + """Two transparent inputs, one signature back. + + The unsignable input would otherwise reach the caller as success. """ - self.setup_mnemonic_allallall() + actions = [action(0, False)] + responses = self._transparent_acks(2) + [ + zcash_proto.ZcashTransparentSigned(signatures=[b'\x30\x01']), + ] + client = ScriptedClient( + responses, reads=[zcash_proto.ZcashSignedPCZT(signatures=[])]) + + with self.assertRaisesRegex( + Exception, "1 transparent signatures for 2 transparent inputs"): + client.zcash_sign_pczt(**self._transparent_kwargs(actions, 2)) + + def test_omitted_transparent_signed_message_is_rejected(self): + """The device jumps straight to ZcashSignedPCZT with inputs pending.""" + actions = [action(0, False)] + responses = self._transparent_acks(1) + [ + zcash_proto.ZcashSignedPCZT(signatures=[]), + ] + client = ScriptedClient(responses) + + with self.assertRaisesRegex( + Exception, "0 transparent signatures for 1 transparent inputs"): + client.zcash_sign_pczt(**self._transparent_kwargs(actions, 1)) - address_n = [0x80000000 + 32, 0x80000000 + 133, 0x80000000] - sighash = b'\xaa' * 32 + def test_empty_transparent_signature_is_rejected(self): + """A present-but-empty entry is a missing signature, not a signature.""" + actions = [action(0, False)] + responses = self._transparent_acks(1) + [ + zcash_proto.ZcashTransparentSigned(signatures=[b'']), + ] + client = ScriptedClient( + responses, reads=[zcash_proto.ZcashSignedPCZT(signatures=[])]) + + with self.assertRaisesRegex(Exception, "empty transparent signature"): + client.zcash_sign_pczt(**self._transparent_kwargs(actions, 1)) + + def test_transparent_signature_per_input_is_accepted(self): + """The matching-count case still succeeds, in device order.""" + actions = [action(0, False)] + sigs = [b'\x30\x01', b'\x30\x02'] + responses = self._transparent_acks(2) + [ + zcash_proto.ZcashTransparentSigned(signatures=sigs), + ] + final = zcash_proto.ZcashSignedPCZT(signatures=[]) + client = ScriptedClient(responses, reads=[final]) - actions = [self._make_action(0, sighash=sighash, value=50000)] + signed, transparent_sigs = client.zcash_sign_pczt( + **self._transparent_kwargs(actions, 2)) - # Transparent input: BIP-44 Zcash path m/44'/133'/0'/0/0 - transparent_inputs = [{ - 'address_n': [0x80000000 + 44, 0x80000000 + 133, 0x80000000, 0, 0], - 'amount': 100000, - 'sighash': sighash, - }] + self.assertIs(signed, final) + self.assertEqual(transparent_sigs, sigs) - try: - resp = self.client.zcash_sign_pczt( - address_n=address_n, - actions=actions, - total_amount=50000, - fee=1000, - transparent_inputs=transparent_inputs, - ) - - # Should get Orchard signatures + completion - self.assertGreaterEqual(len(resp.signatures), 1) - self.assertEqual(len(resp.signatures[0]), 64) - except Exception as e: - # If firmware doesn't support transparent shielding yet, - # the error should be protocol-level, not a client crash - self.assertNotIn("Unexpected response type", str(e), - "Client crashed on ZcashTransparentSig — " - "Phase 3 loop not working") - - def test_transparent_shielding_multiple_inputs(self): - """Two transparent inputs feeding into one Orchard action.""" - self.setup_mnemonic_allallall() - - address_n = [0x80000000 + 32, 0x80000000 + 133, 0x80000000] - sighash = b'\xbb' * 32 - - actions = [self._make_action(0, sighash=sighash, value=100000)] - - transparent_inputs = [ - { - 'address_n': [0x80000000 + 44, 0x80000000 + 133, 0x80000000, 0, 0], - 'amount': 60000, - 'sighash': sighash, - }, - { - 'address_n': [0x80000000 + 44, 0x80000000 + 133, 0x80000000, 0, 1], - 'amount': 50000, - 'sighash': sighash, - }, - ] + def test_duplicate_action_request_is_rejected(self): + actions = [action(0, True), action(1, False)] + client = ScriptedClient([ + zcash_proto.ZcashPCZTActionAck(next_index=0), + zcash_proto.ZcashPCZTActionAck(next_index=0), + ]) - try: - resp = self.client.zcash_sign_pczt( - address_n=address_n, - actions=actions, - total_amount=100000, - fee=10000, - transparent_inputs=transparent_inputs, - ) - self.assertGreaterEqual(len(resp.signatures), 1) - except Exception as e: - self.assertNotIn("Unexpected response type", str(e), - "Client crashed on ZcashTransparentSig — " - "Phase 3 loop not working") + with self.assertRaisesRegex(Exception, "Orchard action 0 twice"): + client.zcash_sign_pczt(**sign_kwargs(actions)) if __name__ == '__main__': diff --git a/tests/test_msg_zcash_sign_pczt_device.py b/tests/test_msg_zcash_sign_pczt_device.py new file mode 100644 index 00000000..605a03eb --- /dev/null +++ b/tests/test_msg_zcash_sign_pczt_device.py @@ -0,0 +1,1860 @@ +"""Device-level Zcash shielded signing. + +Every other PCZT test in this suite is an offline contract test: they drive a +ScriptedTransport with canned responses and never reach a device. That left the +on-device shielded path with no automated coverage at all -- and it is not a +quiet corner of the firmware. fsm_msg_zcash.h calls total_amount "a summary +prompt" and delegates verification of Orchard output *values* to the per-output +confirm screen, so that screen is the whole trust story for a shielded send. + +Nothing had ever rendered it. The RC run captured 1037 OLED frames and not one +came from a shielded flow, which is how a confirm that could not physically fit +its amount line shipped unnoticed. + +The note fixture is note 0 of the zcash-test-vectors Orchard note-encryption +vectors, with a real ciphertext, so the device's cmx recomputation and its +note-ciphertext check both accept it. Same note under both pools, with a +different commitment and ciphertext each -- which is what lets us prove the +device actually honours shielded_pool instead of ignoring it. +""" + +import hashlib +import struct +import time +import unittest + +import common +from confirmation_pages import ConfirmationPages + +from keepkeylib import messages_pb2 as proto +from keepkeylib import types_pb2 as proto_types +from keepkeylib import messages_zcash_pb2 as zcash_proto + + +H = 0x80000000 +ADDRESS_N = [H + 32, H + 133, H] + +# --- note 0 of zcash-test-vectors orchard_note_encryption.py --------------- +# As carried by the orchard 0.15.4 crate (src/test_vectors/note_encryption.rs). +# The device recomputes cmx AND decrypts the note ciphertext, so every field +# below must be a real encryption of this note; rho is the vector's nf_old. +# The Ironwood (V3, lead byte 0x03) cmx and ciphertext re-encrypt the same note +# with orchard 0.15.4's IronwoodNoteEncryption; epk is unchanged. +RECIPIENT = bytes.fromhex( + '56e84b1adc9423c3676c0463f7125df4836fd2816b024ee70efe09fb9a7b3863' + 'c6eacdf95e03894950692c') # d || pk_d, 43 bytes +RHO = bytes.fromhex('c596fbd32ebbcbadae60d285c7d75fa836f9d2fa86100ab858ea2de1f11c8306') +RSEED = bytes.fromhex('bf69b8250c18ef41294ca97993db546c1fe01f7e9c8e36d6a5e29d4e30a73594') +VALUE = 8567075990963576717 + +CMX_ORCHARD = bytes.fromhex('a5706f3d1b688e9dc634eee4e65b028a43eeaed2435bea2ae3d5160575c11a3b') +CMX_IRONWOOD = bytes.fromhex('07a8d0edab5130bcdb265d8efed333514c2c9b180df70ba3963ca8fc1166bd14') +EPK = bytes.fromhex('addb47b6ac5dfc16558923d3a8f376095c695c047c4e3266ae676987f7e31381') +# enc_ciphertext = compact (52) || memo (512) || AEAD tag (16). +C_ENC_ORCHARD = bytes.fromhex( + '1a9adb142498e3dcc76fed778614dd316c02fbb8ba9244ae4c2e32a07daeeca4' + '1226b98bfe74f9fcb228cfc100f3180f5775ece38be7ed45d94021f4401b2a4d' + '7582b428d49ec7f5b5a498973e60e38e74f5c3e577827c382857d8166b54e64f' + '66ef5c7e8c9baa2a3fa9e37d087717d5e96bc2f73d031450dc2432ba49d8b74d' + 'b213099ea9ba04eb63b6574d46c03ce7900d4ac4bb188ee9030d7f69c895a94f' + 'c182f225a94f0cde1b49886871a376341ea94171befd95a830fa18407097dca5' + '11025463d437e9695caa079a2f68cdc7f2c13267bff4195137fa8953252a81b2' + 'afa1582b9bfb4ac96037ed2991d3cbc7d54aff6e621b06a7b2b9caf2955efaf4' + 'ea8efcfd023a3c1748df3cbd43e0b9a8b0945688d52056c1d16eea37e798ba31' + 'dc3e5d4952bd51ec769d5788b6e35fe9042b95d4d21781400eaff58616ad5627' + '96636a50b8ed6c7f981dc7ba814eff152cb228a2ead2f832662fa4a4a50797b0' + 'f85b62d08b1dd2d8e43b4a5bfbb159ed578ef7475de0ada13e17ad87cc230567' + '2bcc55a8881317fdc1bfc459b68b2df70cad3770ed0fd02d64b96f2bbf6f8f63' + '2e866ca5d196d248ad05c3de644148a80b51ada95bd08d73cdbb45264f3bd113' + '835b46f9be7b6d23a43bddfe1e7408c97031e1a8214bab46391044b700d38f51' + '92c57fe6f87159b55512094e29d2cebab868c8f1adbad57077cbeb5e69658582' + 'bf98d19d64f44b0d50c7e2209ab3fc56b4f409123aaeb0263a22451bc14ed756' + 'd048385aedbb86a84677bb2d21c52cc9494147bf0fb102745282990909726228' + '186e02c8') +C_ENC_IRONWOOD = bytes.fromhex( + '1b9adb142498e3dcc76fed778614dd316c02fbb8ba9244ae4c2e32a07daeeca4' + '1226b98bfe74f9fcb228cfc100f3180f5775ece38be7ed45d94021f4401b2a4d' + '7582b428d49ec7f5b5a498973e60e38e74f5c3e577827c382857d8166b54e64f' + '66ef5c7e8c9baa2a3fa9e37d087717d5e96bc2f73d031450dc2432ba49d8b74d' + 'b213099ea9ba04eb63b6574d46c03ce7900d4ac4bb188ee9030d7f69c895a94f' + 'c182f225a94f0cde1b49886871a376341ea94171befd95a830fa18407097dca5' + '11025463d437e9695caa079a2f68cdc7f2c13267bff4195137fa8953252a81b2' + 'afa1582b9bfb4ac96037ed2991d3cbc7d54aff6e621b06a7b2b9caf2955efaf4' + 'ea8efcfd023a3c1748df3cbd43e0b9a8b0945688d52056c1d16eea37e798ba31' + 'dc3e5d4952bd51ec769d5788b6e35fe9042b95d4d21781400eaff58616ad5627' + '96636a50b8ed6c7f981dc7ba814eff152cb228a2ead2f832662fa4a4a50797b0' + 'f85b62d08b1dd2d8e43b4a5bfbb159ed578ef7475de0ada13e17ad87cc230567' + '2bcc55a8881317fdc1bfc459b68b2df70cad3770ed0fd02d64b96f2bbf6f8f63' + '2e866ca5d196d248ad05c3de644148a80b51ada95bd08d73cdbb45264f3bd113' + '835b46f9be7b6d23a43bddfe1e7408c97031e1a8214bab46391044b700d38f51' + '92c57fe6f87159b55512094e29d2cebab868c8f1adbad57077cbeb5e69658582' + 'bf98d19d64f44b0d50c7e2209ab3fc56b4f409123aaeb0263a22451bc14ed756' + 'd048385aedbb86a84677bb2d21c52cc9494147bfa197dd4326a43baa347044f2' + 'bdb49861') + +# RECIPIENT as an Orchard-only mainnet unified address, encoded with +# librustzcash's zcash_address 0.13.0. 106 characters -- three full body rows +# on their own, which is the entire reason the confirm needs two screens +# instead of one. +EXPECTED_UA = ('u17j4lvw84jd238ev9ukr0lvqhv4z32v98pxcglctaj3aqfqj7rr2wwvh73247ek' + 'czw4smyrvm2wf2v5nfxvn3sl0ycc6w4455yg49yf2m') + +ORCHARD_TX = dict(tx_version=5, version_group_id=0x26A7270A, branch_id=0x5437F330) +IRONWOOD_TX = dict(tx_version=6, version_group_id=0xD884B698, branch_id=0x37A5165B) + +ANCHOR = b'\x13' * 32 +FLAGS = 3 + + +# --- a real NU6.3 Orchard bundle ------------------------------------------- +# Built by the orchard 0.16.0 crate (Builder with BundleVersion::orchard_v3() +# and Flags::CROSS_ADDRESS_DISABLED, then build_for_pczt) for the "all" seed, +# account 0: a 100000-zat spend and a 90000-zat change output, fee 10000. +# With cross-address transfers disabled the builder pairs the spend with a +# fabricated zero-valued output to the spent note's own receiver whose +# enc_ciphertext is random bytes (ZIP 326), and the change with a fabricated +# zero-valued wallet spend. Both spends are the wallet's, so rk is this seed's +# ak re-randomized by alpha and the device signs both actions. +FABRICATED_BUNDLE = [ + { + 'nullifier': bytes.fromhex('aed05ebddc392dda842311207fbc3fa84fb454f43fcfac292d87a3e8300ee112'), + 'rk': bytes.fromhex('2c1b8779ac1aca3abec73dfe9b8635be2fae970dfe76237495042fed96c3849f'), + 'alpha': bytes.fromhex('f081a5920a807a9189bc48f6ef5916b48effd77745c905def34316c2af87290c'), + 'cv_net': bytes.fromhex('e80cb524dfe437bebaaeffec6c4543a7f64f48fa4bfa03e1847641b184c3da1d'), + 'cmx': bytes.fromhex('6685ca96b57a11cae02c5c562b3f999713d06fadc697444e4c34f022a1df752a'), + 'epk': bytes.fromhex('e56229ea7072ff2dac9e9d0d3545bc13511e55b849089d3244e3d6667466872c'), + 'enc': bytes.fromhex( + '8807db653fc5d70f3382f580c2283ed388a87134aec35d3f1108efea6df55ef0' + 'b61eaa33b79f8d53b6a3fb791c83b75876186f6e404ca22e3246bd3d6ee956f6' + '983d2f7efb6cbb27571832127afd4f48136bdab4ebc68bc2a7c667a4ab231030' + '19ecd43212a1b6e47307949de23f82998fb33d3f3d15abefaa8052026b17d172' + '1a508d8af11baa74df8f2f67c3be1a92bea530039b4920a0efedc8ee934485b2' + 'aecd88b5af781c8614d968236504d40d58d64875a808aae0995bb14577c89a26' + '564f7be2d8a8e871ca8caaeb04c7af0023b25d6930745b0fce3d1b01575d68b9' + '8965500c6de13f84b14052e24cca3bbe8aa0570ca4196b18a4b2cada1ddd039c' + '4d6038c809426d1eed7674efb6990b040035cc1b92952dae4d642f9c4c613572' + '59dc961c5f208a14387b7d8f9b4f5e502a09aa1d292d33e72b489bb5e5cf4ede' + '7e239d96d07139f9270ec5a3305da1013f0a5b6de6860af11a212c77fe5c1cb9' + '73888cbc1100e0e07423e879e40365844c2ca9a75b16ed4d032127470ca0e0bd' + '5de907a32b3aea36512929f5b83863bc1d63b40fce26dc4d58472ec15f2fadd2' + '24c16985bd5ab01e388502dd22596fe85012a9f74e2ea6c9b9aca2b7bc2298ed' + '779c61285753e6ba92f0a1a963f33750c105122c7e5ec92d95e2f454e0556682' + '25ebf956231f22a139c3c31fdb7e4a4a214d296765353e0e45269cef2eebdbf7' + 'e53e3743c4266eb705d30ea2cb567e370eda979b44f33ee2f6233dc8e1f5e844' + 'ff66891d37165675e63b2a10722381a55a92b006dd793ca832010a002d3021ab' + '5e526af9'), + 'out': bytes.fromhex( + 'a5fe4a96ac820c57db11db73741c6fee879c90635e16c5fd60967be70c58f27d' + '85e4a135ac87ecc858d88b34df846b2af6035683c138b97876c373328076e8ce' + 'f61c5989e4fee41ed2334b932f947f82'), + 'recipient': bytes.fromhex( + 'da973031634a8938ad1c480f978780693ec7709ba5caf58d8a7eb945586cbed6' + '45520f17387437bcfdc216'), + 'rseed': bytes.fromhex('c29d5ab216448f6ebf6388a6251160e7c9fe2413c85ac1cd2340363807ba8840'), + 'value': 0, + }, + { + 'nullifier': bytes.fromhex('31aafc80063db2af87ffdee21267ccd1f8a6eda982f7c5816b8f1d99ee580217'), + 'rk': bytes.fromhex('defbdcf42d021aa013d14a391b3cefa8ce94ac9cac96481bfddecab3f2065e06'), + 'alpha': bytes.fromhex('a16c066adf9a10e1696c4fa884a67f93b24d858e8cf06743304ad6cae69a1b06'), + 'cv_net': bytes.fromhex('fd260057cb02e71c9d37554c7fb496b48d5412c1c2707fcf0c8601946dd04c12'), + 'cmx': bytes.fromhex('e35568cf21a2befafa3d8bbd2bf6a19bcdbfe6b6234c303004ba38ba76afed3f'), + 'epk': bytes.fromhex('9b9efea983df2470cfc8d28bcf8c5bfd4f714e6bc08b0a15ab3611b0c7b56101'), + 'enc': bytes.fromhex( + 'b82f1b13e501868055a3f88433481172ee1173402227637b46be3e63cd3fa939' + '93af3074450b558a4d6ff8ee65bdb04962077060b5c258b3d0f7bc21ec7df69a' + 'a0898c47b6d913186a13fa21c420f0da48e72d167bc3e08bffa1f16d99a11aad' + '65cf98cc0a0ce37ec679c03dc7c6f1fe9feb619b4eca0c5823f6d413c7a5be12' + '2aee48f070770d163fde8ad4862df692d4433afe053e6b9e1a50e6cc8cbd37aa' + '54ce3e014e3dd342496855226cd65896442300161e242a8c4d7b2c19c83df04f' + 'f3ab7c7afcb9fb486d28bda503260b758ac473b618cce25b9d0daf8bafbe64ee' + '3d52f804c33ee78e25fe5bd6a105d4a1cde143e2d047f0fee8e9cb3645964688' + '2eb5ade60a1ff79284b81ee817d638ad5e1da8052266171b320a8bf4ec337df3' + 'ebcf087de768b46404f45a23208c8358c644e626152d67cf4e2efa94ff9ac2dd' + 'b8e3c670360523c238f1cc3b961dedade8e90de90927841745c5398f140ac053' + '8d48561bb372a367933147d239c500c238cf294d05e93c473378f59708f5aefb' + '375d23df2d0fc1a8f0de90c8fdb6cbb0036eef778c1bdca2731c7fe23a0331ee' + '4ceb357a52ce552ca0626891f5362c1983fb2656f91b372f0559de8943a9e555' + '30bc009ede7c222f8748ade9762270f937725a25401ad5a4fdc01a3f1f100289' + 'c5a291b9bfc3bc696e0af71e7199f6555b53ed875aa7ebdfeba1cd3f8c711190' + 'c6d5c64059483983834faf47283ca15682c4966070425d0f305cad882901d70f' + 'd222a8bfeb2077541449b73ce56a8d08aa623c06aa8b38e675eb17fd162b862d' + '5f777396'), + 'out': bytes.fromhex( + '28c960f201269936a006ae1cc2cd358b29f981dc52d10448a3c02479855bf89c' + 'ff2a654493f29035ff20daf3ce97d13d514bca41d0d298d0c40834248d1f617f' + '3db9516428eeca01be53af1da897071e'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('c53e0dc6ce5562fbf35fd57698514207df3cb42161093b070ba16bffcb93fdd9'), + 'value': 90000, + }, +] + + +# The same spend and change as an NU6.2 bundle (orchard 0.16.0, +# BundleVersion::orchard_v2(), Flags::ENABLED), as wallets build today. The +# builder pads it with a dummy spend (dummy_sk, host-signed) and a dummy +# zero-valued output to a random address whose ciphertext really decrypts. +PADDING_BUNDLE = [ + { + 'nullifier': bytes.fromhex('aed05ebddc392dda842311207fbc3fa84fb454f43fcfac292d87a3e8300ee112'), + 'rk': bytes.fromhex('2c1b8779ac1aca3abec73dfe9b8635be2fae970dfe76237495042fed96c3849f'), + 'alpha': bytes.fromhex('f081a5920a807a9189bc48f6ef5916b48effd77745c905def34316c2af87290c'), + 'cv_net': bytes.fromhex('ba35dd670b2f19d39a44f2fb871e35ec8b9a444d49bf84a8e9bf6c5556e69d0c'), + 'cmx': bytes.fromhex('087796ff6b2f7943ea69464410a8b2710c2b90316fd5c748f41e3a4793b59d0a'), + 'epk': bytes.fromhex('81f9bbeaf78f326630fd4526ab67289a44d0d09972ef30682d4f5ffeb9417798'), + 'enc': bytes.fromhex( + '7635af9d8406383ee8dd92f37ef87a8216b57a8004ea779251263d8665916e2c' + 'ad6139ad2266484d70f79806e431c7531de4c5d620b14b585070871e1c50aeca' + 'dda4ae9a45216132c33e3ec304c1edaa29d1a18687e546b95139bce8f57de0f1' + '1e200bad146f9580434d6471084df7b0cb8b54f09098923e821939c6769f33f4' + '1add1d82c3660dcf065c8bf43e3f69925270ef175fa2f595166dbeeb00d2eed8' + 'd19280580081a541ff0db45894f0af53c82f85c4d83e139a98dd99b5544ab23a' + '0ee74c33a416bc4ab28d28d5717463f9e100f7cbe2f7334d93a6f8715e49791f' + '3e1efb1a6c285b9e9aaca988ea3d4dd90ec5307a7e8067d7926bbc664c049968' + 'aac677fd0497c68555809c7f34888b5aae50ba8c4bc509a1a5773eecfaf57e32' + '7bbcc042059d61e60c37bbe6b9981f8c862c08714ced7143e480d33373bc2871' + '0a6cc670f85d68400fc49e5581b2a918bdac988ec720a9fc2196a0833db5026e' + '753924001effd0ca209bdf8fab61c4cbb8e666d408979ad351f9de8e3551a032' + 'a471b1842cb2534c8f98996d964b67344a96f5baa24db77ebc581a252d242933' + 'd413c4cfe3dc9b60b7821d4f02d95a09321cbd677e9311f0acfb6e2e97d54ca7' + '3280e64c2d216e3b190a44b5b6c26da1a9f8ec1dac3d5d494e4c650562ad5594' + '6736bb8ea447b4905a1602a67c988c5aba1fa81c03e33b1af82f60de2c23d1ac' + 'afcd6dcfb1dddbe12b695295a694e893f2279a271039ba3eb1ab48446047bef2' + '9efa519bb08cb6f0c0a753f9a70c2b683e55b783a8a79310e990cb9fbef3d1fa' + '255602bc'), + 'out': bytes.fromhex( + '72d9526061fc8f2088c225996dde720ab8f19502950b052f9c8d9e8e00f06532' + 'ba4ffff2ecd176e5836af72a44a1d58cefd03dd089b66b4dc0d92d3860e1d437' + '5595655e7abbb43afcea3a8b570f1987'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('c29d5ab216448f6ebf6388a6251160e7c9fe2413c85ac1cd2340363807ba8840'), + 'value': 90000, + }, + { + 'nullifier': bytes.fromhex('82b4ace6f48bca884543cca25633e137f3791e3ed655c775f8b6f84187c8082d'), + 'rk': bytes.fromhex('ffc4c2218a0382a530909566f44a7ab4421df95cd12e0590a19c01eb6e06b83e'), + 'alpha': bytes.fromhex('14de74940160f3450f3f8fad83c3d50dc9fc766bcdd4d5968a13ad8245613138'), + 'cv_net': bytes.fromhex('21f87ec22c92674f97585415535b5cdade848408fb729765bdcf2c36118e463f'), + 'cmx': bytes.fromhex('b46b4f684d39569dcd24db0db912406dff3fbc78466e590260d729313499230d'), + 'epk': bytes.fromhex('a7d08787142ac449a74203ccf11ad1e927b8fb4356f33ae8f81b1c9e7b3c01a6'), + 'enc': bytes.fromhex( + '43fed1e4afbd93923651a18623245c91b2717cbbf89ef9a4905bd0da58f3a578' + 'bd2330a347bc32526261273feeb11120746ce964aac12d82209183195eeafa8d' + '54ac7b02e4c107aee4ec1db2452a50bcf53ebb7902827bfef0ba144ca16c0f9a' + '1ef79116b5f86a740a68c46b28f5b1235235ed347244ff7beb65898e74f139d5' + '975965a2f786e60a9b081f96f576a087070bb7a8e1a182fbf7366467005a051e' + '28ed1509aa2177a684417e618e0a07a4f157f1152308ef7f6757533f5f45cd67' + 'a21cb1b26944fb266fd2bd6e892bc9bc672d2eed09afcd7119074b0c8639b13c' + '548df077595390a7aa4b4181341804c793644d9bd19b92e0e322d87b64075eac' + '7e6a908c9fee485175ec7c85ffd3e010a85fb67adf6435bd66f45464f7ace727' + '7471eed60135e19d420fb5b9af89957b23f33e458e09c393a02d9403eabef6c8' + 'f11f4002cd2c877197f573bc0146bd6eef0bc00283f29ad0ae500ea80c84e221' + '81f9470a4d1e8c60b4680efbd5dd47fa73cf82de930b304afe0686d11b963c8b' + 'e5a3731e7f74b245958c46c3e0b591278ec8e4206f291a80193ec325f06742ee' + '3de56bd11121df9432eb2456ae090f64e840d2ea17365abb6c6ec9fd51f94036' + 'c0ace903ae39ef5a0824799b1b4ba529c3403d240d7f18cb90559af4252f1fd2' + '27eefabfcb830b94c3c3e5114e41265cef100aad103bd25505279a39cb294db4' + '0ccfe4d4b8c13539b975564008c113da36d67d50568769751fb9c775f363c969' + '4d834ba1ea03e956a6036dc2a676918044a1fc76c777caad479de213ef856055' + '5407b7ca'), + 'out': bytes.fromhex( + 'e41a17506e1af63acd8edc563c195819e26e740b5f3fb4830242d804bcee8a56' + '2572f52e73d190ef270ecf7aaf309e93b2ab9eb6200b8466cd1aa7889b42cfcc' + '2fa9b853f9e136be5e04f9020ac82d4f'), + 'recipient': bytes.fromhex( + 'df8789f215ca59a4fc59ce4e9b694f87b354d01cc397c47b594db69dc0fd4ad0' + '0e11a41d7751189db93210'), + 'rseed': bytes.fromhex('983d2f7efb6cbb27571832127afd4f48136bdab4ebc68bc2a7c667a4ab231030'), + 'value': 0, + }, +] + + +# An NU6.2 bundle (orchard_v2, Flags::ENABLED) built the same way: one +# 100000-zat spend, fee 10000, and four outputs in action order -- 20000 zat +# to this account's own external address 0 (a self-send), 30000 zat to +# account 1's internal address 0 (another account's change), 40000 zat change +# to this account's internal address 0, and a zero-valued send to account 1's +# external address 0 whose memo reads "thanks for lunch!". Only action 0 +# spends a real note; the other spends are dummies. +MEMO_BUNDLE = [ + { + 'nullifier': bytes.fromhex('0b396499eafbba166a936684287c6a9a93c35684059661a365e715c6bdb80903'), + 'rk': bytes.fromhex('0a1d22eb81961326e4d6c5dc1208f1e166cde2d52d7a4ab65b26c74d6281cd0f'), + 'alpha': bytes.fromhex('3b977876ce497d64ec92c71e7e8c0ba25c6e831636d6f0942b7cb944cec18f35'), + 'cv_net': bytes.fromhex('bd26a17d837f604b0834a4f6c4e0b04128d72b98d0d783ff6ad7087feb2e4d10'), + 'cmx': bytes.fromhex('9e926d6a1e046c426c00e41c618e42040dbcb8a4122aeaec30a1558b56e3cb3d'), + 'epk': bytes.fromhex('5d8fe257f17e600c74f9ba9bcc221c85c7074e1ef6adaf23ad884bdb52eea72a'), + 'enc': bytes.fromhex( + '8b0075c256cacbebdf9dcf8db1c780c3149e9130d03fd8b509201130604d5848' + 'ac64ec54a133fb72f09083efedd825a3b08f5f7bf5f54a85a2390ab2e37eaad0' + '28a3a245ce7279f4afe13e5ade9e6eda947119b68ebdc9f86fff852cbe1f0217' + 'd4119815506d9bf3e4530c80c357e0a4910392d59b4d531ff6c318de528fb6b0' + '5fae9aee00fcfe88661ca4b181a96b4255a0a81ad38f21f629e47bbefe5efd1d' + '829d93542f4d6f6fc8e9cc9a5ae91adeeb657c0b3427a86654ee502c944ba189' + '95d9bb4d40de6fbf581d4a89e407e27a968cc0c168ae7f3dcf5afe3272f1dd0d' + 'd805f01b367d93ffd8557e69d39957540e1e70e8cacb7d7a844ff234bc49c403' + '1f39598fcd696606eacbb267172c90f5b438dd4652e6f2ac83913bce4ca7d2ef' + 'd1d04a4fa7eb66f8342bc2b84ff03e64c21139de5c6dc257518c0d20f48c705e' + '512a474d25185e54187af3d650b56ad12539b7797550bb117d12ac6d6ee9c691' + '684fd3c86bbfa004484d6f08574f0bb4031aa27e0599e15f8ce2b00711281f85' + 'cb92e63fb66d0794e4dee0ce15b7e0c3851266ba8c49c9cf2abd22a488e95578' + '693a9c0d5e27ca808de4fc81cafeadbbbff7f83dbc4debba908510279941613c' + 'f9ee909eb5b922b2333f48422cd0bceb61625d21c6b3107cb676e60ad681c4a1' + '51c1b24734ed1f3ec096785c45641fdad75697abdf66e9553ec51af468f90ec8' + 'aeec7cc04e1f6c03656da3389a4fdb287bde0e01bfde5f125803f604a5f38d12' + '482155475a651f76c56eff1777403c5a48499cb196fe7bb0c98e59af720c6090' + '48939155'), + 'out': bytes.fromhex( + '93d14b5a4bc9dd72c1559c0073dd621c184af991a87c75215fecc816f20b83bf' + '3673bab907166ef00ac8b8f67b2be681ebec1b8f49ea0918a0796bbe4ae02925' + '8a6fec6129eb71fc611a7944b9625b7f'), + 'recipient': bytes.fromhex( + 'da973031634a8938ad1c480f978780693ec7709ba5caf58d8a7eb945586cbed6' + '45520f17387437bcfdc216'), + 'rseed': bytes.fromhex('19d1dbfb752c1bbc9ec9a5c44fd2d8a9d79c404e299127d3ad5f34a2b52f4a94'), + 'value': 20000, + }, + { + 'nullifier': bytes.fromhex('f8ba50efc52be70daca605a52065c1f1f58feeff3fdb41ce608a18e529326905'), + 'rk': bytes.fromhex('7282993aed2a09e3d374bc473dd16ec6a760fd8a667b3cb32521f50b80fc3928'), + 'alpha': bytes.fromhex('1cb2601e62e5bf33186b33ea9acbe76d31509cc5e5826ab7b549189b55cc903f'), + 'cv_net': bytes.fromhex('34fd5c6923a81270493d8d8e517044305f7595217d8e3bea2f473b35b0a78017'), + 'cmx': bytes.fromhex('9b020d8c509de25cb2c04955725669284951e081ca602d27b6986f4c0349e93e'), + 'epk': bytes.fromhex('85f4e893d37fbbee4ea43e7cb447c38d1f31669930f8d800e39d3fcac48ae3a9'), + 'enc': bytes.fromhex( + '54d68d62720be92d6c4697751bde5ab39c3de61aaafab88161765cf4aa27a151' + 'b0f5be29d7fcc7f30c6a9301cbfbc4dc348a30a8821fe56c73c8e9c799cf9f50' + '64f8c5778a218123bcacd4b43d971d2a96452a69de0c48b3d9b6cb7a11967851' + '04c7077ae08d0be113696c6f34c41f339c5cee68611856f627553f41e511ff43' + '90a80c74fc31dcc4d1a10b8bc02706ecd187d02aef6fc39f166815c7a12afd39' + 'b6fbde42959672fb508df5432848e5061acf62263046784e3f252a57604f4d3c' + 'c8a295e5bacbaab6f4b0f5aa7b3790abf576260b7d40da6266818f22b4e75a53' + '1b6e565a87ceb0f2a01d70c764de4545c4d5707394af31752b2c30f0fcfd64c3' + '5102558160449a78e3887053d63f015650dcc7f1f56114739bd6f1e730eaf0d8' + 'e2a5755c3424ba6b205145ded3620702c853226945fd8dfddffb359016c24784' + 'c8ce7edac0f02345f6240b4a055b712777f344e4f176082ec27e07ada09473cc' + '7868836a98f7f1de99cade6bb9c1daa9e54c591202f83e90eee76054e76b45e9' + '89caac1005cc961af5111a46c11a41311f6e94540e67f2005d618e731d4792f0' + '5b31ef94ed6d73216991c1ffbd0645bfeafb64554ad082fad7286af5e88ca721' + 'ebb6e89a8aaa097c33c3d3c00b645a5b6e9d892417624d9b58e5157c4f9406e7' + 'ec81cd89028d8969f54feffb4825017676044d5e3ae6603f058f772953c714b1' + '1ed1523d2aa37d36f51bec1a36268bb0953f6836534df86ab196929d5b916781' + '071680fb04d6d27f38399a952bcdb7a459f4fb74d5dfd73c6a0a51c58ea00a53' + 'bc4fb482'), + 'out': bytes.fromhex( + 'b95c75498fd39fe8ffb67803469bb183e6746b7126550c0d56f248d5a3bb2485' + '19ffbac31035d28a29b5295e929bc026f8f321bab87245f47ad4c4604bdd6efe' + '4f77fcbe2fdaa78a5ebd58263a7f6d0e'), + 'recipient': bytes.fromhex( + 'e96953bda73d114cb7bc026036b695c23fb2b80b0c34f217ed3b4e6fd3766efc' + '090a17663191a15eba81ba'), + 'rseed': bytes.fromhex('b7a8c118ceb43470d6a78bcb0eabecdf96b3977bb99506442df3c1db52a0b58c'), + 'value': 30000, + }, + { + 'nullifier': bytes.fromhex('a75bf6e26992c5a04856020a69caa517ba5b0f6dc026e8e8713ae606f2be330e'), + 'rk': bytes.fromhex('b0aba46130d9dfa7e5a1260d134958d5caefed021551c1832fe07cea52ade4ba'), + 'alpha': bytes.fromhex('a16c4408414428dc4e614b56f9bcb7bff36ed395ff25f556327ed877556fef3d'), + 'cv_net': bytes.fromhex('7cc907eea8e6bb1eec198132f122fb9ff7f0797df56fecf63b7d2f2e4fd283b3'), + 'cmx': bytes.fromhex('15c4fafb44b3aa5ef021ec25208136a785139f61a2dc69c1bfa2280e7dc46f08'), + 'epk': bytes.fromhex('34737234bf9190c4a909a96b58f66843fc1ae968851d9dcfe8b9d8a0b1b8042f'), + 'enc': bytes.fromhex( + 'd2c52957269904d95aad1058335d2b431d46e3702b28496752c5d07fb5fdac1f' + 'bbb6e798057939f1b732416f0717f72a037402e6cb6c520c785f87c5eea9655a' + '654936d982329d9f637998e6909b947ede5f3c0844c72d291607bc2dfc0d37e0' + '1e32cb8637244aa02c33ffb95e9b9b99e2936614cc8c95bed046b44fe18c2aad' + 'bb2dfa950de507fd49774343f24ae5329904e7e4b526769a7c3dcebb473d2224' + '76c480d96335b9f3be0e8ae62137d43dc508c7dbe44cd402514aabc2fa8fa81f' + 'cec38e74aefe8ad0defbcdccb091b50b27c0f2abd5622f3cf9d6a0272b7700fe' + 'df22cdcd208c0b6eb94f7ecacbf6af61be4d581efae66b30b7b39d3806ec247c' + '4e16282839725d0eb0836f26500552c7bd98795325888f8626f0214eaf429c6a' + '55cfd7fc564e468103fc577bcf69a5d9f64894262d80731629977972ccf9c962' + '871720149b9db9e4088f061ccae62619827e31b7ae7a7d878bc51b9d63e73613' + 'dd0352e4dffc62fbcf6435b72ff1c2460bf9c5609db734a59ab3a274e0de8dd5' + '2002f787135b9120c02947e62c0fdc4d633248f0098cafecfc8b5ff165392292' + '205c68d13f6e8bc261b6863770c1fdf9715ab0553f8cacf07b20146f815af9a3' + '6664e90df7bacb0ad412e4cd363cbb2a0936d89afd5a228687f9b192b4043a54' + '1341ab18a82405a57e24986d227d8e5c285c0708843adbf2a29293d30e4e9b61' + 'ceddff354e24427e6d2a34cbc1bc6057ec6748387305ecb9ca7b70926e672104' + '900779a167ff00024e3cc6e1cf05636eb7cfb34a2c289bab49f411a9cee2701f' + 'd44d2d13'), + 'out': bytes.fromhex( + '8e6250a506ea4b52188f10ac0ee0e0210ced8f7f8ccda7069f70ed8ed675a953' + 'cae9a37666f191921050a3da74636c907e3c7c36a75ec459255c9ed828718a8c' + 'b31e6ffeb3946f1fbcee60568ccceffe'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('2a6913ff3dcb6d496a92b8d5cfccd29afba7b48fc1e21940b55739c5ff950d6d'), + 'value': 40000, + }, + { + 'nullifier': bytes.fromhex('9c8426bcbc3202267606069136500c06aa2e40c378ba22a898aeff0626c75036'), + 'rk': bytes.fromhex('9e74449a938707718574712d19923a0a48820aa17f53491007f873fc3f902902'), + 'alpha': bytes.fromhex('a6e1c02c22a34f14a0a82c221489f26136081ed39b3ef34ac2df17c08aa2e235'), + 'cv_net': bytes.fromhex('15fb882089e16d2f696b2964c8d13fcfbbe66a1e645bf08afdfef2a15352a3a4'), + 'cmx': bytes.fromhex('5d491d6e064e67343e179ee680182a04eca8a9c73b504e17439f695b989d8d35'), + 'epk': bytes.fromhex('266dcf5ffab1cadd4fbc6f030b702f535c49b0971c79f6d581491b5e727eb797'), + 'enc': bytes.fromhex( + '8c7b608859f1c6b4a1b3e304f9ac0319680d53950d98ad1e36216abf62870bc8' + '69195aae61e811cc559a3a06df605489027763cc10731e262dc477c40589c9ac' + '6350ead8f31d6f37d8eb698c7afdb8f835b108d554e97753a8838fb47f0fc1cd' + '989ed1190517a4f455267458b8c9329f846bed3993db930520ab71ade3e5256d' + 'ac93ebdb7abff16cf88030df72fa90db30d3745480c914d501579a6ab93b8472' + 'a52137f13917657037fc458546a1e41af7f12d8b5b492e69c9d595d4e886627c' + '6656b5a3ee37ebc3689290bc88707c91e026d95859d88c5e2187339e4b3e6bff' + '924a0934468e64b86dec35b26c6d2cb750d13f21101e1992cf64eae8547ab462' + '9ad4c7e88264c4a2f04adcbd2cd050038239498b306572494920bdfc7a1bbee9' + 'b190ba82f88e670d7c690b069bfd073c9c4222da760bd347c4e4d321fcddc057' + '88114a34b9bf3b4054381c8595c7ec80140968741d55f1939a9121b3369afaef' + '4c5ce10e662e13b2c0ecd8deb4469a92b66ee8ec2ddc0c600700f1e39cfca631' + 'fef6920a7af021715fe51c178b1665c5d0a261dbcc5ad171613402ff29d883f5' + '12f8a488d23bd4401781997e4dc0d7b62b8bb66f64ace822c08177544dab0a46' + '930ab73eb589460e9e19a77eed8f0a494b92f41dc1dc70572548e755a2023444' + '9ec6d30af18d58d1f0a2752bb8d8a4d201232d3bb3ec3d6fe3fff3cbbcc7e350' + 'e65808cb45bd1d367e447b728ac6a1445a6c7bae6eece7e2b3ecaab583a9ca58' + 'e41cca24d08366be4516fea9dbce82e4de6845b268708b96a572d619e808bb11' + '06ae8a55'), + 'out': bytes.fromhex( + 'eacafd6869e89e8312a0a7caff1e8baf61be383e17db1e61017eaa5842091a05' + 'ec776f4b7fdf85b1f90aa0197118d355ed1f73ac5795f8296afc5f4b87916b2a' + '7cad4d62b2f8c75acafec1ed5bc5248d'), + 'recipient': bytes.fromhex( + '8ee82cdff5120a00cb089ff8acdaa517958971d69d1852dca01fd25e88609ff9' + 'ca7b154dcb2c01a8bc18aa'), + 'rseed': bytes.fromhex('337ce21a2c63debee0bfed8ad8c373d7d5f3fbfd44bed30f2addd92130ef5605'), + 'value': 0, + }, +] +# The Orchard-only mainnet Unified Address of the memo send's receiver +# (zcash_address 0.13.0): the address the user entered. +MEMO_USER_ADDRESS = ( + 'u1u2e4d2ctdr0sq2rtglp3myfzpewgtslzjss0p4jtldylakd8dm5n9rtflehnykkq6c0g5rf' + 'zrswl5tx02uzulwa4xqdpmm9pnuuslzs8') + + +def fabricated_action(v): + return { + 'alpha': v['alpha'], + 'nullifier': v['nullifier'], + 'cmx': v['cmx'], + 'epk': v['epk'], + 'enc_compact': v['enc'][:52], + 'enc_memo': v['enc'][52:564], + 'enc_noncompact': v['enc'][564:], + 'cv_net': v['cv_net'], + 'rk': v['rk'], + 'out_ciphertext': v['out'], + 'is_spend': True, + 'value': v['value'], + 'recipient': v['recipient'], + 'rseed': v['rseed'], + } + + +# ZIP 318 migration transactions built by librustzcash's zcash_pool_migration +# (build_transfer_pczt, build_prep_tx; orchard 0.16.0) for the "all" seed, +# account 0: v6, NU6.3 branch, lock time 0, expiry 69120. The transfer moves a +# 1,015,000-zat Orchard note into 1,000,000 zat at the account's internal +# Ironwood address (fee 15,000): a 2-action Orchard bundle and a 1-action +# Ironwood bundle in one transaction. The preparation transaction splits one +# note into fifteen 1,015,000-zat notes (fee 80,000) in exactly 16 Orchard +# actions. is_spend is False for a protocol dummy, which the host signs; the +# sighash is librustzcash's (also checked by the firmware unit tests). +MIGRATION_TRANSFER = { + 'expiry': 69120, + 'sighash': bytes.fromhex('37d64f27ae739dcb68087da7d8a2d61abdb2fb1fb89e3d4f0e029d80965f4edf'), + 'orchard': { + 'flags': 0x03, 'value_balance': 1015000, + 'actions': [ + { + 'is_spend': False, + 'nullifier': bytes.fromhex('1864599aa7e56f8c0399922334bc322b1e5c0e9614056e705f5dca13652e3e30'), + 'rk': bytes.fromhex('8e0da71eb98174f038bcce8dd232ccf690e8fcdc68e2c571310626f44d897307'), + 'alpha': bytes.fromhex('d60207f13eebd372f8f9e79b966230dac87839f33a68c21b1a7adf5602b1c024'), + 'cv_net': bytes.fromhex('ccb4e6427d30b8b910fc733ba30a1a4a6f43bc339ba4e7d38e731aa8c79553ab'), + 'cmx': bytes.fromhex('e8a96150abbdd4a657639d23e8202c621eb422a46f115a4f9b329203783daa27'), + 'epk': bytes.fromhex('76b0f4ff80bf7a981e2f127086358c3eb65cf4ecf855dc7153f50d41421648b7'), + 'enc': bytes.fromhex( + '8087f8103c81159a18755a0dc6506283ee6872d2ec050051f9c52403b831aa74' + '2965708f3c466aedd08dd8db5d2df018ef5ba744a23ea84822b715e4b1c89524' + '49264e6d548e4cfabc9dee6d5ded01f80c137f5b08282ab151356382e8fa7c70' + '722d6d4f8de0503eca664b27bfe415a0f9413898b39215669179f4f2ec0a80d9' + '4c624179133386574e2aa4a4c9702c697493c95ae442996cbcae044eea1b1d0d' + 'df037aa81b779f894dc86c17965ce4362b60cb2e314634a49398e1f43ad80c94' + 'e64573bfb2bbc5d5f398c5c9dc57133a9b6d3ce5a7392ff9d0e716f8b6f0f3c2' + '5732e8c108f4bdfc316eccb9202d1455316b32d4a5bb5f427d2ad69a8f1d1a0c' + 'b87ae8ddd870ba00d37e246b758845e147ce19ebad1485617bf0e51d92f9f9fe' + '0aa9804091e0ae89dcb815f3238df56accf9b3a25703f481462d8ca43d3a1673' + 'a1e9053fed093ef5bc3c74e31c99cc0343d8aa6fb8ca56f63676b07e4ec898ae' + 'c8b82eb90194d47b914669435a4b3161e76724774cdf3bab2390a3d31fe18a16' + '06fb060728b6db876472f7d75cbfb6e2f75065ef7b8507a54a5fb219e01e2bf5' + '81af63ba475347a0a75bb8b6cc16652188fd315b73e8155492e6b1bf78ad79da' + '6ddb9b19599d9e2ed73b17ca2f5281c78bedcd215918290c3c3ee7bb44ab3ae3' + 'a88f5065a7585905bacf2c1af0ed7a98aac974b992945faddf8e07f11aa259ee' + 'c891e55cfc9924d44da5fef14b823e7c2c246c4daf1ac01e4e1ecfd00d1efdda' + 'bb181a2b9598ebb42edb69d116a77820b46482034ee7f54bf0f556bc9cccf2db' + '3d16f60a'), + 'out': bytes.fromhex( + '608c408a0545c24615106484531cfaea8fdd107f2ccd5007cc3a74640c1a058b' + 'b3e8e52a668aca85c8be3780496ef53bcc0a768496ef089c3db2382f75904882' + '9e1447576fc7df7626a1e76db2a620af'), + 'recipient': bytes.fromhex( + '937bcbdf0e9bbc3a056d4b840825fa3adf31c338eef5d0a57d56e432455cbdc5' + 'daa4f738338166c7b9ed07'), + 'rseed': bytes.fromhex('809144435bbd53c9d16a3944e5355a512a875cd5177d6f97ce291debc4684229'), + 'value': 0, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('489a7ca2cfced841420598f2dc0236f637652fc3fa410fbe448e28aadb5b9c0f'), + 'rk': bytes.fromhex('a618a687af32bb8d148bd5745c8b6f8b8f40d18d5b2bbe733d10e3b024966b01'), + 'alpha': bytes.fromhex('b3abb9d87a35fe5d9697cf96d9eca2e802988bd6bc2de2ebba30eda0eb10a31e'), + 'cv_net': bytes.fromhex('a122f0fc50ec9b8653a3ddcd4e12bd9d9910f879f6a4881f958852b0797f7e2d'), + 'cmx': bytes.fromhex('eb709cbf300754e5f17f1d6947d0854575305e487d96344b55ff2de9821b5f36'), + 'epk': bytes.fromhex('1374c5379cc8daa943f521558d7a4c0a7ac8ae21ab311f275b051a8631f799a4'), + 'enc': bytes.fromhex( + 'f714bbc0dc6f64674d579ff6a85de299a4a98788b2cf473d687b142e2501ae94' + '9309bd2295c8b93afa7d0607e8663f516e855c9104341f09dd649e406bf8f21c' + '687c5706b1d5afd74fd8e063921a05ebaa89cee7f7251d518aa271533c5ce752' + '4074b7f32dcabfb6fb4309bf86e9cc00b3cb2424b7a2ae6ecb83e14b4c4fc4bf' + '86d494b2d528d7d9281a8f7af1e78fb674327782b178d1469119199b3d9de1d9' + 'd381a4600b85323cdc93f43f89ffa9cdc94e0095eaed60ee68047b015b57c362' + '159f43a0bbfa21c4be1856dc1256a04b27d3ae52962bd9a8661b74f0bd7a4a9b' + 'f7e53fd02b7b33038f824e20a3d21f3d6b152df460bbb720f7597d267707cd00' + '14d4099952ad1195076d521da378de8b14261efac4e1bd2b3deae15c899de20f' + '090210d3ab853d7f25dc01522b725574282f45bbc78c0213fbe6f040f2b3355a' + 'ff909180bfdc21dd8c1cbcb7ace23d4a5575633bfe561ca018f23beba644761e' + '70e87d03280b6677d1e7af24783d23476a6e5958789eca3f738cdd6683a2b762' + '3606de12b9a75ce127169c02fb37e22549451ce3d1912d0f6d546b541c2a5e93' + '5e18d924d43629c5a0ae13b20498592cbf6daf2f180acb7c7cfe401b37e808dc' + 'e2fc54227a523fd8b973a4f261ee6dc3c48ea4ad10d2d4a07807723b6a8ade44' + 'fa3a236fbba8a0f7f8b7b62e82985b6a6e63ca95f01cf597eba7c839571d5d77' + '31eeeb9a90961537bb0301497c20ae735f21715bc37ca3ca1626aec076ec3b73' + 'a8001204d3d59ba1a078c996bcc97ad89f7ac7890b89145e4527bcd76355bef7' + '43eae3ac'), + 'out': bytes.fromhex( + 'fa9051ee2d97442f3618721452e5433399d53c5f3b42ecbbb6e5ab0cd4ca6457' + 'e7444ccece08e96d282fc184fe08e23d3b8084bf63a5061e21f7fae609987704' + 'd50c3954228af752f5e00abb61b65f3c'), + 'recipient': bytes.fromhex( + 'da973031634a8938ad1c480f978780693ec7709ba5caf58d8a7eb945586cbed6' + '45520f17387437bcfdc216'), + 'rseed': bytes.fromhex('259c76a04a44a10defc8b3de279aaa2f57f453c4843932e07ab89e8d08a7045d'), + 'value': 0, + }, + ], + }, + 'ironwood': { + 'flags': 0x07, 'value_balance': -1000000, + 'actions': [ + { + 'is_spend': False, + 'nullifier': bytes.fromhex('436effc6778e973a5cc77c202d0a396e5b932905346443ddaaadc8253ab4db27'), + 'rk': bytes.fromhex('6c05eb42cca028e4ed2740bb176d1bc6ea64a72d8012437d3ee4e488c0116e06'), + 'alpha': bytes.fromhex('ca3ea1b2ed99993558b786870350dd0b3a6bb2c096e9f3d9e6d744743ad7563f'), + 'cv_net': bytes.fromhex('8ea1ceccc9a86e011ecfabe68f68b6aaeff93e57d7fd16fef5befd15edc5a382'), + 'cmx': bytes.fromhex('a8cb867f1ae569fef09e6b6642f4795e37992ece4e4fbdc33f1354b379c97c20'), + 'epk': bytes.fromhex('ae9f613cf5a2bc3c5e480a4af39b36e6d40c8bf7a664edb0548c4fd2a5bf8414'), + 'enc': bytes.fromhex( + '75022c701b77054ebe6886efba031ceb9d0a48c46f34a58b9a9f4b4dbaaa63ef' + 'e9445158db413bf37b22901cf4334f1bdc8acf15626e8f442998fbd1ac7e42d5' + '6639800df728a431f3496d1235dc1888ef296d4f8b63db81721a8e4895b43886' + 'ed342c7226c0265281a7e5701d1946ce25e06f521ded2a9be3db6b926ec554f6' + '83821c46fd19a342d7b883c1bf7e385643e947ec0dc4d7f41de7f54131ea60f5' + 'fbc1c9f9e454dc1a5434bea7956f7a6e404aaaef73b481ce8184c44cb0fe9bd3' + '2ee18a8d7da8e2493fc88c9e6c6dcd601073f7ba8b0fc77cc6f6fb9ce74962ae' + '6dfd10fba03197e2a488633e479cebace7d99a5fb55c73114db764bc9ac54264' + '0e4747fc2d81ed02f8297cc4fe52fc1fa9e5402fbe9ba2f6ec74c59f1102d082' + '2d4d3c0a0a371dfacd541477f8c920e36cfe8f9e7d712b6bb2230963e07b16a6' + '439b116b6d510e5e4f3ca5af8da8597fde93dfe2bad94570786367da4de38937' + '797ade29d01a762edd85e01fae2cd8d8ce3161ae9dafa80121579e20076e6293' + 'c25de93a3b7ba07f3be7a305c6193ee53668d6039d4aebc56207ea22582f3d68' + '4c7df5fd48c726d9fe0179015cdf7f88572adfd1897150c38d059a02204b2b72' + 'ad982e83400f75a397eb2ba67a793a8d1a179c3355b2a9f4ce2bdc0124ceffe2' + '93fe0d00f9fcc072be2fb0b279b6bc6c67e03ca396dcd0827dd9485d1a518399' + '2ae4425ac5cf3f7629c6cd0cfdda397b1098a1a41e386c223182af6823e847fb' + '753a18610d24ffd130cc015d569cae682273e20498f7240972a9c26f26981159' + 'edf6104c'), + 'out': bytes.fromhex( + '86d9ffaf8df7203a5db69ec59ebeb544b34a5863dc7f4db848c70bb8678b959f' + 'f86c5224265c690e5ec56910c66dd513fba56a4c946f63d7009a0f86f9537b54' + '8bb3e19cb69d2ce349246b2c352adf88'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('7ecfb512ece0d14ff72733c55f85dcb3717ec0196cb6248fee63c590ebc6e8ba'), + 'value': 1000000, + }, + ], + }, +} + +MIGRATION_PREP = { + 'expiry': 69120, + 'sighash': bytes.fromhex('9ef92705d1598a058ba70b3254653e7c0c2efa108cf8cf611b0a71266d7d12de'), + 'orchard': { + 'flags': 0x03, 'value_balance': 80000, + 'actions': [ + { + 'is_spend': True, + 'nullifier': bytes.fromhex('054ec0e73431e55a5c9282a41ebd2af81a3dc0515c849dadb4817c7c397a9b24'), + 'rk': bytes.fromhex('49f1ba4aa6ac6a111632ec4c1a799988cd0f595d10379a2980f45b0ebad74f32'), + 'alpha': bytes.fromhex('e2b209a4c7ece0b87a6d0149b919dc636e89accc183c03489a75781e7abcac30'), + 'cv_net': bytes.fromhex('026b83c63c03b0eafa8bb82f70e2f557136ca082d35563bdf1e044d667c805b7'), + 'cmx': bytes.fromhex('5e2d87ac1d8a59bed84174c856e713acf1bcf806aefdff44725c88bc517d4025'), + 'epk': bytes.fromhex('9639beb15a85b79ae394cd9b394ed82364908b33a54908ede023f88aa1822280'), + 'enc': bytes.fromhex( + '405978bbdac91023104dd3885b96a8355108413bc16e82db5fb5c2728b941a5b' + '07447a4b52c1e3bde81eb3ebf44f2481598779059dec907d310a633703181059' + 'b52a010b2a0b4f8ecbdd1a04108bfd4971fbc326209229219bd951a8806428e8' + 'af01859da5a5941a5183b292cc03a7ba7f69bc4359e33bb4cb6d843e261ffb07' + '77bf92f6841e4fb646cd9e65d1443cf582138a2c8472dd4701a0a5ebe1396c42' + '3315b630ef409388a3db83ba35ff52ab7ecd3155048f05411c06d73aafc6d747' + 'f94e6806faec548fcab05a630217f520a2cdada7906d14406fa93211529c3edd' + 'd5f3803aca5fa86527668a5a19d01fa5f7b9091d09048b9292ea195fc001a002' + 'd709a57c94b7ff4e043337f392b59831a8467c61a9b358528f4427a1d78e0556' + '4e5a570d6afcc995ec737a62de866d8989c3fd652d7174444319dfa8e5493250' + 'c6018149fb15507f2a35f13d58bd8a695fabcc8a07174b3f6e675ab0ee95480d' + '2308aaa7a3c5e3a5affecece3375ce4fc2d0a6c6e156fa7764fa6a68fbf89c84' + '640daba42cf345408d18bb88eff1a13f3567688a46fba77720047f255a792af2' + 'c4046a50bb904b4e2bd885f82bb7085979d11b8502dc09ada9291ca9bcd1017f' + '0a5e6a09344bf783976d69ef787ba6b394a6ee2c6563fba02bc0ff7ae7d6f499' + 'da6ba692cc5eca6fb2a1eb6c1e9e2c9f05209675278d3e0d51302927dd6eca2e' + 'c10918646f9ffd5b1e57640464fb75d33034132933fd1526d8c28ce6d6e70667' + '8d044a52659bc654559e90d440b546f317b295b1183fdeb26dba6bd7ae7ed937' + '9fddd84a'), + 'out': bytes.fromhex( + '1e2d399e94df4bc9369536a5640049af19013052ca97c9c3446ab25da81cf049' + '8a0b71bb55c66ba5f435c190a3ea047f49e617dff4a6ee3e765bc9c696cf1361' + '23bc52f37fb084443d8c2ca5e52d7e73'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('4472e5d068978450f247eba631f080a34ca5265be231e1ea01d76b1fa0ad022f'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('1e9167c36ccddea1bfeae395089b3c9c084ec497f1cc1a6064163eca850c920c'), + 'rk': bytes.fromhex('0ba435bf58e44f0fc5df77904f7500ae6173bd5b115ec49a17b2948ba9c362a0'), + 'alpha': bytes.fromhex('99188cc6049c504b58fb3107a5095270083002032d24923353ea1b5ac238e721'), + 'cv_net': bytes.fromhex('ac8bc004cff0fa6ada12acd1c2b67629dccd83305f6292c1d46306d614b03b23'), + 'cmx': bytes.fromhex('6c8b9235b5ab488414e47cbb78a1471d5523b62228bc469980e8288d3e0bea34'), + 'epk': bytes.fromhex('6adcdd0c83ea1841e96ee48c1c820f4366e5672646742999810783c9da6d7b92'), + 'enc': bytes.fromhex( + '5b19ca83d72fe4914b5a0eb56508f186cfd3417d72764b09b2923da9554b4f44' + '8f76bcfcb2bbb1df591b1cc83d8d92a748cef4d8e401fd9ad62e824768bba2a1' + 'f72c54553365dcc119863ddb43b262948cfa19ede52c0159a3f7f5b6f8bd265d' + 'dd642fd50dffc2ab8e1767c6e94f278c2ad09583825388239f0230d0cbb50e83' + 'fe19c0da47b450a94ab7b3cc8ad03ba14ed6144249d61660e2ef177e1f2d1ffc' + 'bef9142ff2cd192ee059363bf67bf1d267b3db1616b238399c7829c95c914780' + '0fb6946a3d3560c85f4acee07b3dde868bc5068ee169a30bd9a1cb8be4e1451a' + '123036f6644fe5cf372ef9f14a5fdb5b7a22bc44b2bcd1fef077960b367247de' + 'd0f3e6f7b0a2bd59418059c03713d9458f9b2bbb1c01d13948c0ae2824017710' + 'ded86dea4f2e2bbb28ffcbee53b2cb2d9348998d62a99d7094e85b49ca5ba583' + 'f5394c7407bc8d8a356334993b13bf490c2fcb554dea2120e1724deb3fa2ecf4' + 'bfd120e6557f0ba0d3f5d0c8e6934357751b13dc3c2d301297d02f336eae4534' + '06bfae54262ad3e4abad97b00c0da2048acdb7686c4d3cd9057e9744e108b4dc' + 'c394579b602735ee1b4d3692057fff978e274f2ba9cd73b096999cc9336ac263' + '7c88542d087b3990881e3a92cd823fbaa497bd9af6017f0f32c82fea44c2c167' + 'af21df07910c2fc92c6fc6509695bc7b6f6b36e760c7b8f890f68f2143577bea' + '2b52b4e79e4e1a46ce46a7aebfc9e0694b4b27fde0ce0e369f8e072644ab1042' + 'dca5c6cd3c9a6d3e0e2c7f75f5d395bc5344dd11ae36da88e854c8d87d1faa30' + '9e1f4942'), + 'out': bytes.fromhex( + 'bffa9f4a10c74c0921509df29e7d2d48d33eb0bb9a4bccc39025fa02bc59d9fc' + '8ef211d81dea3a431c81d5f4d300144853e480ae179562bb3562ffad91ed1139' + 'ab45b62e3895ce45599de0ce4ed53c8e'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('df5ffe7fb55357586aff35971495391edc50b0f3aa13b2e86301e7e51a72b62f'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('bd279975aa116b7a6ab041d9b7025d5d7d711510657413469c4fec8482469d0c'), + 'rk': bytes.fromhex('3a3bbaf10cba4710f6b6aa292570b8618fc918a1bd6acce6a76cbb3fdf3fa1af'), + 'alpha': bytes.fromhex('aac41becf3358cd8f0c1e9833ba9303398436077ed870e471598e6f0a9af8325'), + 'cv_net': bytes.fromhex('7e741c1968cfe1a44879f786827d09bc8d4c1784090dbf15ca7dcc87f151babe'), + 'cmx': bytes.fromhex('10fd8bebba556d433f407718ac2753c2be129c8d76da8c56a484bb14503a7117'), + 'epk': bytes.fromhex('4ffdf1afd2e75c0e2104e18061f338a3d3071fd5af823c9b634264dde4a2b31d'), + 'enc': bytes.fromhex( + 'de190d24706ff2cbae88088d97490349ab087c40fc5a0785dea63b5cb5749f14' + 'a2fb2bdf3538c17d198d4486e7c0c2eb8cd4f39cbcb7aaa721c7608abcb6120e' + 'd9bceb204f0c5c67c25cc3caf0b77f099e95a9644adf9adae0ae7ceef8f5e343' + '1aa83a705bbfd751901e8ce9adb9c080130021caeab3150de2b10716791219a4' + '44dbe13e861d0c1c0eba7dc52b16d59ca84b7044e88917a7e0a5bd5c21ae814a' + '7b61754662bccc774ece3e83ae3dc40bbde4044601291fa4611be64122ba75c5' + '7c396b13745a097a5b0622423d35a53d81606a0e4e0d8a5508baeebade8e2a84' + '7a1292f18b7c8f763fa39ae54a725707efb1282fe2fc380550c8e201b304c2a4' + '28631f04602249f35cb04b92d89d3f5ee0687f15f581fd1421e5aa3504f4fa6c' + '560253bac75b3951df2b871090e077835dbbba5c083ad8fa6e16529d6cd764f0' + 'e970ab155e61789a3c95a9889639a3479766f5cb75c03133636cb915a5fa86be' + '71bcc1a4fa561074fd5b2f9a48a9654733a9d472901197c96b8a54e40cdd9f9e' + '4ae9a1c82af52b45f0b02c82956c250546f732cf1ed22abf01523714b21f3c1a' + 'f3868a236343cca4ad81d227f46eae69e95f966cbed0331bd195ff23451896bf' + '95405921fe1e0ba8cdf010807cb6c10d1ab22d0e2a0e6368af8c77dc266319e7' + '13b552df2c0f6912915e6cfacee9eef6c752897de533419ff0c1b6b2be63322a' + 'eaa6c246989916123d45aa7669487bc30576a9c2727c6bb6c07daa58d07a0931' + '3b5858d75b2d48b94fcab0a8d4fd2fb7ee6af73ca8d116f3353455f9600b70e8' + 'e36b3361'), + 'out': bytes.fromhex( + '465b8b2660a00bc32d67f26c501fa6ddc2457664c49e1f6e7fc09e8e371cc7b3' + '807a800fceb01c6afc79b0d153b23d6b85276c703b41e371d2f3f741964d5843' + 'e2f4cb7c403d75664c536acb6dfaff08'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('f3cc731a88be61db608e0e647d65669174778a9c7523d86e9ce96960af2404d1'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('46ec8306c0fe282e2ffebe4a7dfb99bb5d66594376402310e6d329ad1fa72039'), + 'rk': bytes.fromhex('9a7515f062b707fb218b763b2af09fcb696af3ee5e72acbfa569130c5e8bab8e'), + 'alpha': bytes.fromhex('134a9c395fb85dacaf5394c35be9df6f4ff69d5e967ac21b22fc1e2a1b06681b'), + 'cv_net': bytes.fromhex('32671fedf628dee6bdbcc4c861949b9dfb7aca84d2cb09f3b7276facd33166ae'), + 'cmx': bytes.fromhex('a6bf790e0eff686ef6561743880e609aa2ac9ac2fafb809cb536428387155034'), + 'epk': bytes.fromhex('38a2115c524ac490a5c656742e85ec3f4870a6c6207576b35ce35f5e9a304c00'), + 'enc': bytes.fromhex( + '4d653b68299ecaedc541dfe5e48498a7d8d363d56554e851030ac55781178d0e' + 'cbe7ba151f99195e49ea6898676ac4eda0e12f55e56bd6909fff7bd4c4a3de8f' + 'c34fa83472065f620db37d16f7efa36e024366062b11ef9e7cd33da638e906bf' + 'ea92031869fcbd8d4e37f3094484993e67574116033c1a80ee1212fd01cb938a' + '0e671134e42ef954a4568e23f672f58ef64d937273e7a6a0b8d0673c2fa9eba1' + 'f060db60cc7344cd81778e04a2049fbaf3dc160acd039c5c4fa1d2b93eb851e1' + 'ab34f64e9a562a72a5dcd7c8f2ae9b991760a26cf9f966fafb272a82e7da0c9e' + 'bfddba790abc62691bc6c9739224764911d941f68c5f9209f0047356f2c7d042' + '06156872a3ef82394739d5a7921ce81de2d2b4a5bacc84a864bf75d0dcdc8b1a' + 'ab6eb5eeccbaee53518c491dc88672ed1774b950e39cd26590d8bf7894ad5790' + '2cf4ffb611650fbda399dfca0049829a278a5de46921f6e8bf9c358be2d1dbfd' + '806e89bc619c2492ef13fd09ee925fdb0bfc54581bda16828415e3ce4873c05f' + '7f555382ea943125b673b233b9612a9b63e6e78ddb27c180f69fcbe60e2d9d64' + '29cf273c1245c2b4cc56c9daed238a962e283eb1bcb5b31a81b7e42d4da91f02' + '0e4b51f20232d77ffae9e387515e99daf456a61cfc141bf0390257380797b154' + '567d335ef6376101b2d9feff939768f81ffd3f97f34bb92f8d6439a51801af33' + '8dfabf9c9779de2cd587381f58835ce491704359e15e668a3606f80c51fb95fa' + '45524400320920ce3c32668bc9bea9000053e2de0607e37608d649630af0991d' + '2b26a11a'), + 'out': bytes.fromhex( + '17527eab850e901d0fca69dfd95d0bdabeeae5a05f20a8ba8ad6bce615f8a1f1' + '0166a0777aed3bb3536eaa639e9b9cac6056508a5fb8f585182b48b9c166beeb' + 'b0f30af96885078502cdc000181a698f'), + 'recipient': bytes.fromhex( + 'da973031634a8938ad1c480f978780693ec7709ba5caf58d8a7eb945586cbed6' + '45520f17387437bcfdc216'), + 'rseed': bytes.fromhex('b28fb566a83a3e62b49a4577d3a299d1c5e9692649917f4a495da4f590e45069'), + 'value': 0, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('84b1e3f25066dc3531ec282f8ea0a1fd2f8cbec31e0a5c60ce1b5eaeb820521d'), + 'rk': bytes.fromhex('88a5cc84f7483c9f775bb0d5c70409d05a44db5e6262f4a687468d4349a7d58c'), + 'alpha': bytes.fromhex('3527420fcbab8f9580730144813546c1cb50f608d06aab0c145f59634f7bdd2f'), + 'cv_net': bytes.fromhex('85c7f1af6a5226cf953e4dcc5ce531205fdc1594f8aab38ff62b497e80413f17'), + 'cmx': bytes.fromhex('94988df15b79910e7c217b069e8175588b3844c839b1cd07b4bb238552acc22b'), + 'epk': bytes.fromhex('6fff64e1409564630a7efddbe75f4f7f6a5411126cee98a6a184187b218a7793'), + 'enc': bytes.fromhex( + '9523ee3dfd23d9d418333d4d4f541d6e1ffb6708a57847858944ef9c043da78e' + 'b2a834dda0dd3c26704566cd823928438af12b279851a73204795d370efb3928' + '2e41ecec65eace5b96f7e79cf0bbf763bde3aa2672976899e487fea37fbcf250' + '179f895ba59ca2cb33699cabd073b05af5585e2f34c88d5432dd39c9e28622a3' + '12f2fb32fc7dcc3445b9f8044ceaac5e3828a36339576c502004e1b619cfa2b2' + '148f8b4d04c8f8df85b9e32496b15f2ba6c398b502f9bc1394fe99a4d3538b2e' + '5a22d86afc32d6704820a80495be317dcc97ac9a8932f8e2e5a1c7bbef99a613' + 'f51374ba41d6fa0d7a45f2a4cf9450a888e6b586c7371c91afae1c30067b6019' + '74ca0494458efd07b26fe402fa5e9fb2e9ed45072f0de676329d0acd34de67f0' + '0e5cb863798568991cfeb3a3647b516cbf80442ed830966035ced2e8153ef7bc' + '9e7c848c08f5106e507c198f4c463cf5cf33147d2eb0285ea23d978bbbb2f2e4' + '653c75c3a67f31048d2ad7458ec3bcfe65ccf33e08d7f869d4c10b5e8dc51797' + 'b4b4aba646ae031ce13503d37c23202322f68a1de641539ab8906ca1cc2116bb' + '02f3e23b2484a50ad557ca59beb12210ad6af7c3361a915baf8da33021893eb7' + '3ecfbaa020a5800332ce1ebdb3678d25dea61f32f04596ca6e672023288c59e5' + '61e451ba05cc7b382c25248d5ade7af1c7304f26b230f60fc73cb1655175a8af' + 'db4d0734f001ee35661433b2928526482079d11d485be8c8fa3a066f12e4b213' + '5d738667bdf461d78466d0f88f72d10163ca7e7f53f0e36ff4061e6a15824144' + 'ca80aee2'), + 'out': bytes.fromhex( + 'dc650d9efce2e1fdccce0e0631c7513ac9cdc3967d66b91de7899ef244f76c9d' + '211b2a01d51da32fd1cde8ad79e93b7f95e74125443a30c07e2a51f7c7f0c596' + 'd13b074003319a0d4d73869c06e6de24'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('fe195185577caffc9b3214d0e82bef90ff91a1e2164ffec23bfca96b4e2da6b7'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('2eecf6557ebe7d63d7e8c8e2d8c63e73a4a626a4ed7be4dcda98a5ecd3de0a08'), + 'rk': bytes.fromhex('c3bec9b4b5453deda20a3b8c88a4b1f99047b74ba2004aab2c93c243088da713'), + 'alpha': bytes.fromhex('5a02fc4ecc2201be9955971281892dfc183eeab06488d07fcdcbfd5afa8a462e'), + 'cv_net': bytes.fromhex('fafb1a6b93bb4868690dede7f7d76e3639ee37e9ed020e166c2eb20cdf3feebe'), + 'cmx': bytes.fromhex('982d0cec5df92480e143a33b672a5fcc9860ab0589919beb24072b102e01802e'), + 'epk': bytes.fromhex('d5d01af305d54fd71f9b0eb44f44b00b61bcbd8cb0811d70f97af5a9e9a09225'), + 'enc': bytes.fromhex( + '67884b80a71f55d3160a7e8026497c433aebd9f73b9499e3eebc19b48268c32f' + '3d2303bd2d47c298a29b0ca51678b7f416ae1527c9e297beb539333cbcbade4b' + '0358c9da9be0c8d23bebf6b01cfb292d9071307e41c07cc27027807dadd1b729' + '144ec0033a6d31a7a3969e5ce0b0217768a3a8a746df3c87967a378b98ae200b' + 'c398c17681916fb41c702966e51d61c09711405f2056053d98bbf97641b232e0' + 'bf789215e0f95d11e10fdb1a4c1893d370eaf1784750132d2a0206cf30f98d66' + 'e832fa03305f6f06dcd4f084dd5a70d62f5e9170062882ae165bfc8878842c03' + '64b97575e72df61bbcccefad9b85a68df47a984761e6a0f0c792ff89208bfd45' + '1ae6ca32eea30eef4f8e23e08246cd848d335d2d86cf44e6e3348e3cfb8d35f8' + '609609ff5fd4469d2dbb38a5e204f4065b3dbcb1e1835dafc9314a56aee71278' + '319ad917fe547eaf4b6105d8d3b9c7680e3a1ae426d952ea90fb8cc013d7ec47' + 'fad46d554c105726d27496d1af967675ef665922c2fb647b29bba606da802cd6' + '36a54b3dd42a19be2e6801003a7765b9a9b466ac85040fe1479b1fac30e47f7f' + '349dc867f0eecbd693128975dc1fd4015a4c253ddb5164fffb0556ffc77db5a2' + '5fbfc692ac6d710c4b96050bcf56a4a03ca881c2f7901cec7b878ea8c056e3eb' + 'bb8d40b9ad16ef66fb8a48793f27d54847b020a2922a41297475d2d48656ba2e' + 'dccad8140c68014eafc307bc1446ffc8549b03067cdfb52ea7763f4eb0e337eb' + 'd22093c41aea70fd5b5d8b35eeca6d15b876b8abf01c4a8192ef3961a01eefcb' + '70acfd14'), + 'out': bytes.fromhex( + 'c10219cf368ddfb680e526e11a1b5e7359215b84e4a64d57ba00b04512a717ae' + '4daba78f715883e538483a46907e77bbb113308e6d26bd69ce6ce069e055b685' + 'f3e36f9e0c14a5d7d1abb77b23a7f5e0'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('01dba523114310d50eb4e53c91820c72a69eb9152c87d5f109993481ba973971'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('93230114c4c52e75ec9dc038aaca803fb603e93a97d93fccee4828efb45a153e'), + 'rk': bytes.fromhex('9d66f8dc6fd1a77a333218e706d32bc39cec8762d1d03d1d1875618c654d0e80'), + 'alpha': bytes.fromhex('7cb83df4bace45f805b2b3fd11cc619e4fa3680bf02b1ffab81b9597307ba50d'), + 'cv_net': bytes.fromhex('e096705eb994c3a642b5ad9cf6101f97ae21f598f6df2dd92b4065e0aa410f8a'), + 'cmx': bytes.fromhex('1fcc601ca83b576c969a946cc0fc839fa5f4a5c79028e401e57ae28a876f0421'), + 'epk': bytes.fromhex('98e9e62857ecbf63fa5ac554771da098fbbc0916b229581454bf2ed4110e9812'), + 'enc': bytes.fromhex( + '3250ad1c60f1431694c2cf131eb22762386d0caaaeab3e8e3d204098f2ee9a7a' + '71b1c0c442b32812f09715fe328fa2be96a049d0551cecaab3f53fbf1e06c67f' + '506b68987986448df8fc52738722d4c6974b9ded1980488e7afe0ada81ff9ea7' + 'c9b1a487735c5c793b6942e61ccb4c60a32dc3c481112d781948db61130f4af8' + '21eb5cf087f18eb64899026a14d5841db228ce27c9496db2272c06b2d9f97fcf' + '9070358c7627a5711e34870510c92895761f7520a3d8e465e8536694f54a5352' + '73390a4aa53aa861edadeb6bd2cb490615c0c9a162ddd981f96d968ef7957e31' + '2c7da312c3038944aeeda1f111345f57557e103f8503d5d6144a0ebdd873115a' + '1bff4fcb83791d5c4f1c5d21c71b316f2daa868fd2bd100b82c3ed86505db9c3' + '9a2f2aa618eb77791b4a10a7e5a8e59c63aa5cfa211591651af39a2208fffb87' + '2b4d42e0183fd8a4a7c00575cbd5f3d6ef4e0ed10505d8af5eecc75bb8b576b7' + '7c8ea652f6454151666438bea8c325ef65a8c8d2bcf262be8d11e6e2aae117e2' + '8020b31faca1ea6b56ee0ed0eaa0d55e10242a77a3b95be92597ef8b2584d9a8' + '5f8f9f0df5c142ae50b106913c0a64fa2ff5eec1d2c475fd2e82f57fd4512160' + '60cb7030e4e5696d1bb5e33c5b2065474b596e55ab818cd7a5426493618f0775' + '7c13382f6c50931230a0e9fe5f1bbadc8713c057db04b7c8e3139a0eed550bfd' + '634533e279b9fe9dc3ecd6489119db4e3181dc912c567a29cb237f940ccefa77' + 'f01c3c5e835a1bdef8596348fd6070eadfc9773d875292a5adf3baacbe1321d6' + 'ab6c75e9'), + 'out': bytes.fromhex( + '9e205080da9edb1fd3b6e085573af34227aec4ecad864410de66490b60c8a62c' + 'd4a47033b4e4cd8588ab3179001ff4c2d740e0a6d3bf07e6876385a46a5ef69b' + '65d5a6779a8f93ade6be5d06e1b562e9'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('f6acf873f135d7e04bf21774693489e45932921041c3199618800927f88314a3'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('a8f517b0c5ad9c9661b5cbb54575e35debebf8494330d3de71b21d35f8f18735'), + 'rk': bytes.fromhex('d9580d672676562530ffb3c2af3c930d51486210c17ecbb0d2719f37fbac06a5'), + 'alpha': bytes.fromhex('b9400ad4f74f9b3bab7dba7490fe19e1658f376c4d05176f1f55a46c98ee6d17'), + 'cv_net': bytes.fromhex('434b0e3c18eccc0c9537b2182cdc06f37f567ca8ce83546bd00c5d2d0ad8f098'), + 'cmx': bytes.fromhex('7b45c8f7a62254eb30e6d0d650314276487a57d596e9fc2876f3768bc6cfea17'), + 'epk': bytes.fromhex('9dfbabd58f6d0efe50d452a99ef1e4641013f9e06927354f00de88939ce87486'), + 'enc': bytes.fromhex( + '432df485cecaeb63e5951044143da40415e2312a340e4d907b3466f7393f22c2' + '9aa7cc39d3f39fb0e33dd6ca0a888565f112c3bdcec136d585e399637384cc4b' + '3f98e55d2eedc76b13bf7b8eed1cdcbe06032a1be7a6c0fdff3d53b745a93e74' + '79aaf87585d9a7c05d8243be062d5a067e179fd1b1ca433317ac6152bc45b72a' + 'edb5cfed7cabbecdbf67f51636de3763d597b11c6c2ebfe15ab0879293e96320' + '652732a93fe627df9760c095bd72cec8e11c3a007148e4d6b787a1c4ebb990b8' + 'f5c2ee794b3feabdc443b21e50262798dfd4bc2e7e20be77daf4ac1a250c38ef' + '3fbf43514c62bfe2c58e17cdf3f6a3209ece9b38052a9bd5b396a2e817b80bb2' + '70646ff00cac2d6e03ce396809dd8f71d4f943f2d6c11cd9265c3a8d3f51503c' + '91d5f028b5cf68fec248621f38a17a71dfee52e25ad6660b8a1825ffb9f8fbbf' + '391e4038d4ba51e06e38972ef597ecfd62bba8a43fd92847b9bd56dc6a1370d0' + '463e1a70642098d68b673d530f8400ae5b083506db5b6b613740c371bb1e6180' + '9398b72e1859b3d723ca8bfd3ecfffa22f14838a0d9047be360aa795f0f60a9d' + '368ced599ca8e899e3c2313c08a2162a5e748168b44f5014564e34b4679db0d2' + '9f9ed884b31884ad561c137feab5e94f2abead3a4f7845293ecc8cb55c33e995' + '4b82fd4e5db3f3ae072e542049249aa55b6f9d4b779e22d305b6d722f11ea90f' + '47931532e763aac84f8475e3f16fd761c51a1c1c6c2f8d5607a9b8e878451825' + '5f1f492083ecfdb8ee096407cb960f85594908801d0e158bc1706fbce13d4435' + 'ad4b716e'), + 'out': bytes.fromhex( + 'b9e292eb7b5a72ef9ff2eb20781bea6cade7b2e08a48c76b16428ff68cd5958f' + 'a2fc90eaab442d6ca2308c79b4f6c06084fdfd95feeb6368eeb8104fde692e7c' + 'f4fc22fff2a6169052589a0e90717198'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('ea010bd93d0fc3dab9df9dda4c97d5eb97239ef14125990db577b7d2e6255a48'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('8be3f4d69956566229a1c9eb93706d6cfacea806d84c10917f74e188a9cc943c'), + 'rk': bytes.fromhex('80c9e3061bf4459060267edf1dae4c65ffef5d195877f0db23731543663f2f82'), + 'alpha': bytes.fromhex('f082eabd7c9c5f61b451344c08464e50b80b5e37aed95f2f91c4b72452e5f114'), + 'cv_net': bytes.fromhex('8921d8eb9dadbf0305ef3fc6181aab0eb5279349e6230a72fbdd3527c1a7e088'), + 'cmx': bytes.fromhex('366eba72bbf033716b9c8bddfcd0d1e1579beb762c75c20ebaa1889de454a307'), + 'epk': bytes.fromhex('4d70467ce11de44d408fa3d70615365116075dad52a88ced8b626652d10f692d'), + 'enc': bytes.fromhex( + 'd1c554ff7a8a890588b74863ced1524c9c8c70f7b40596869c694df71970ff09' + '5bf8033f956c3f89ca53ddc6fcd8b55335483a4a7c93431b739d66ed1531a792' + '0ba2a7067d21ebd12119560fe7de48aa729e96daec16e4b7873528252e2f14f9' + '06851bd05df7a80745fc83ba8658434054063536c93c9947cfdd143c9f44d02d' + 'ca9fd3cdc8379ba1fcbc985220ee2c91210f33f6a6f7190b4a48f9084a54ec55' + '0e6359acd7ec940d1434ed6c6b56d5e297592f4b03925e0e5fdd646052ab458b' + '85cad33b27ab5f5bfb719b92010f58b136e14672bf4e5ba5d864979fbb50fa60' + '965de41f9d605f20a42f23274dab99002bb469a72f6a89e77417abdaa1a59b7f' + '7ceb374d3876496359672a55ba8fb366273041d48713a06177eec5c9f5780184' + 'e4ce2ac57a6d54a4e2092ff8216786536d2e142bd67433d35278b459cbe40f58' + 'd4260691e19b5233f9ebac6f4f834c58fbccc3a6f2396f0644979a9b9098cacf' + '06b96fcf275df4b4e9dd927a8be3fbcd24c1e16e0fc8df973034691989d10057' + '99e2242496d3e1654015479eaa948dc5bae0c71f8b7f3105ce3c77020b7da5be' + '55e55d5ed3fcf764f0f5a405fe89f3470ad8b2d912efe99c8923a208fe6ab155' + '60a246bb18b01b1afe31d3902f2ccfae9c2fddd346058ba94b82f5baff187576' + '3b3befca7fead99325f80cf6dfcc23723356b855106fdf259bf7fb8504826ea4' + 'cd29ec1e695f3049c24f42cd919ac3553acfe75004febac20c35d92d1a21c12f' + 'b6d906e18741fb8bbfbd3dbd1293fbff197385f55adf1e6b3ef3af80edc63110' + 'caabf9c3'), + 'out': bytes.fromhex( + 'c17ba0c208ed0dd0411da64971b00cf404eb980599c45faebc6b3f1cbae311b7' + 'c262a8a222b20d386e990c657dcca6471e79fa5b36edd7acc40a6d5294ef7aa2' + 'f8b18f3f7566218067227cb7574ef6c0'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('f1d5593b77bb5d5918c0bdecc5651d301e6a7b435461809626a741d119bb0b35'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('1b54c34cc8292f6b79f51d2f91b5c9bda3627beb0e675add3fb37b3ab939c516'), + 'rk': bytes.fromhex('4e3b84aa7957fa0a0ba3c22782067599a2b70003878393f914f07f19eeadc832'), + 'alpha': bytes.fromhex('98e18b95741626e10f4872c434db7ec7390750689b51803adcf291d1318d4f0b'), + 'cv_net': bytes.fromhex('22ce1208b3d6638d0cecce265a815cdb57d5bfaec4bac5a14a2ef992417c303f'), + 'cmx': bytes.fromhex('9f65f0016b01015217c5abb05fcae0b1a1d2822057add19b35076b2e05f0192c'), + 'epk': bytes.fromhex('7ab81a60e6d8c6a2490f8ecd724c46711c542375dadfbcd1e5c70019420c2282'), + 'enc': bytes.fromhex( + '6c802a261b0941f99873b3e209466b249a272a139963bafadd71cc5b64afc98d' + 'b6f2706189467a74c05ae8a02ad328de8aa12d4e5dee060135e3b94e1cc0efbc' + '5866c91b3301724f7131ead81efd7a0c520cd1474028e3407488893b6b45b9de' + 'f8f753122f7ec2cd0c49a3eb06b64d4d2835e94c380051af5486012f401b8c81' + '4c6331b976b0b65527d56e136655766cd08084f4fd7399a17171c1879330bb98' + 'afbb5cc756168c44e4b0df9b52ab7fa35bc193c99f5ce6df6313e5da0b445d94' + 'fc12f631a3bd231fdf0c0be8fd2cc98df50b4fc5c4853177f4f22e270c59c6d0' + '7556ec5f5e82244bd5ee9bc148ca02daa3ae2bc460c65ac73b0d5a803878ec6e' + 'ba1e91ce0574b386d28a1738d004ac2bd93f4c9c1d5f90fa1e6ade99a87635c0' + '711a7f740dcc0c45210f06adb4ca90f54e8668fcd1a45260de2f3fd967ca4986' + 'ee9c90767f9734e448061d72886f800b20a783989a33b46c960078fd5beb0638' + 'db8d24907b5d8c15ee8eda056fc187d8eceb2d1245c5f7cba5b98b9bd1b56c2d' + 'f35757f87ed07dc5e6214ea25f1d8384afc00b60894fae3e17ee619975794cdd' + '6de4eb21c3d92d57d019b2bc3c98f08d35c1cd12382cab10f9f7532736767bb2' + 'a2e8cf5c6d51c8526652c00598e97695d8ba522dda0c50e8eb1b446e6d00c2ca' + '820fddde2c4c5b4902cb9aff51d7d102cfa7bcee0202fe0d958e88a00373d5b4' + '69af521e264d6b6a201c0f6559d89884acb4a481d9b56fabafb4e59c70d846c2' + '815a090dcc98cbe5a7d0cfa5287d9315056581cd89667a9c085c6e77ab7577af' + '30ed55b8'), + 'out': bytes.fromhex( + '9644bb917c9eebd52f057e1a863889f4992574bf57b5166073b245f2aa6bbcc8' + '4dca318ea3d0b80e193e382938c8b41fcec4757929dac9d443c67000b29f7eec' + '6c7ce44a19afab60dbed39300731fecd'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('b7de09450e2fed78853c39a0fc18af601066bccb870003c4db47aed37040bb00'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('762eba63803d81c90d7d38e08f9c62ae3de0ea98495cebfbc212fcb0b625f231'), + 'rk': bytes.fromhex('de1c617b2474661d10fa5e472538f2c289ca63a39c637f8988c3532172364816'), + 'alpha': bytes.fromhex('526f668e2286b53400e4f4bc12fa99559319be6d14f15ffc00086c7215b9a314'), + 'cv_net': bytes.fromhex('a062251a60672e947dcdb248efe064196c2f1ed962708672f67a28c0ea541d1a'), + 'cmx': bytes.fromhex('e0e01ee861405e4f11b27828183084a1a76006719679e8dfe05c06b2d9b9c81d'), + 'epk': bytes.fromhex('8f9acad54e4c4ceb63626ce76d7dccb975480934ae7348137d01f98636c4bdae'), + 'enc': bytes.fromhex( + '378ac9089167edecc577b1b28c02580ca55f8c64acd6a747eb21a9fb59375b70' + '7ab5ad0cd0a7b5c59fae6fd0b8047206d97c59fca7ec148cf31907bad0080204' + '72ea9a00c2df60df5ca53402671edd36902573d6a91d81b10486dcbfb5f49736' + '858f913f65233143b58bf06aea901994440b94d6b1dba85be0c453c45bd8a88b' + '4378559d4f68cd5e65ed2a57182adb0ae93728c912cb3ab513f037f7c5216095' + '4db465d49eaa6c6f3025fbe703c37b4c9bd0d354bf0016527a69c64c2adf4e43' + '0a4ceabd84b0f021df50cd5f40d197d07e414e9e9743ae9350f17d37abd688b6' + 'a2316a63d6863972f07b2a400969f3e558c9ebe375e31096fecc3c05c8e55f83' + '5d7d40a7e1f9b26d4f8a0669a5eda32e7c7317c5fc9799986acd96c52b565094' + '9d44932609978ef3ff85fb9f62072213d493b5a6e8343132aac03396c706de43' + '11ebece4ac91e1b6c361a1cc244e8af083657bf84bc9ebb09dc617c339c23e1d' + '7e763fd1413f0e5182ee523b6fc3438eaef2ba931574bd72dbd45ac6eba2d161' + '43ed70093c7e2fd7e1db14054c9cfb7ec6bc89f3bb7e99ab9aff08467c624072' + '5ed0bf560bc30687921500b4b4b030984a2d3b186517e7d19493d2ef6a566711' + '153c020b3b3859a4eef0929348016997139608167db4c84b5854869454548357' + 'f324dfd8ee8c4b19b0ce6dab70954c87160c0f9828abea8eb05841c08646d725' + '32ce65bae94ecd1a48f3a0114b65eeee4aec7003687ca8dd28800cb1ce80c759' + '0b1b62becf1be211375b4fe438710d38fbd7889096995e35347f91eca6b5e8e5' + 'fec6ba19'), + 'out': bytes.fromhex( + '88e413bcd8f75391cf3df89bd64ce8dc7a0d741679223f02c8546d50856a3cf4' + '48365f976b567eea5aa1219a66b30eb04ee2e3c472a6e45aff2ea22b0eb1d14b' + '9e6fbb2a1b5e699d05e3036b28d832a3'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('211558a82b0684b453222b1171beef68aeda93b143b9368b5a0c40b1df7322cb'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('05aa1878dfd335dbce602e1a8cd23986ee6a85c83e77e886c6acc5f2330f682c'), + 'rk': bytes.fromhex('4e54d29b80fa7f535d2a8c5a2784f7ad152c44a2a87b2d817ae3361fccb0d4a1'), + 'alpha': bytes.fromhex('f133a7eb65d87ff41ba8f909d0fac69cebf863f40d3225c559a823def2772b04'), + 'cv_net': bytes.fromhex('c3c2670c08676d45b580a8ccfa206e3683feb88a5f9406e20ed30eb355190409'), + 'cmx': bytes.fromhex('057543e8f38759e6cf094ccae696826f4eac83e64779ecf3f74ffba3b6a41c16'), + 'epk': bytes.fromhex('7aceaf5b50896ae5c8e5856313b0313b9257e89c4140feab46cfffd2261b2a81'), + 'enc': bytes.fromhex( + 'd0295cdb43aead4bafd89cd3e796ed7d205576af427f1665fcf890fddac1d94a' + '1b422fee2b1a39631fcc49056bf3fb333c2dd9aa7096bd8382c48a614be6b35a' + '5f236a6b3a7148d2c2259be05db67fbf008106e4277f39b8ccf479e4c132e4cb' + '6d38b956eedbf98b1f33ca2da57949dcc305460b7e279cba6ad6b189e0a74dbe' + '52ce18f32fc9096b3eb58a71637b472b1da4d0359a654101fa0ab40412212134' + '903ee4a996d877b43f292381c01919484a54d927dbb6bfab98fbaf4a5d655241' + '4ee223155459322855ba6b4dc8d28336ecc9b4ba66580bfd93541f0d2100ce6e' + '272ba130a05d73c7190f11148f4443181e6ef2214e96af0a4a79c5c61e84c946' + '2aa6a4b552f6364051a87c674e9306bd06256c2f3cfb382980b57cc9f542cf24' + 'b122763931c17ec1083b2056a55f50383421e56d9c293a919c40684cc9d2c60e' + '4d0b83a1b80abbd373e35028cb01e11bb2eb6a6dc1f4210090e1edb8ba318b12' + '56081c5f861ca3d005b72fb3fef89032703c12a4e0f4c314c214e77b87a12af4' + '2b72289d61334aac4e6abf13ee47ebe459ec2a5a14253a58687c7b52fe67d919' + 'c2484f933374e09d746da0d16929151a5fd22dd39955fd1feebc7d648f3924ef' + '93b309990d9ce740d388caef3e0f5935e34c17670241393702f1b8d9fc7efa38' + '83d67eed63b477887c3dc8356fdeabd11574b108a06695801457bd53eb9fcf2b' + '1360423b82eab0401b89fb956f8fd2615d037d2380b2c6e1cdbc33c3fe376601' + 'be0eefdc6e98e5ce2929cd7e7c70ee4ff8ef7988b57e4da5b5b17b5da29fd03a' + 'f6d75d99'), + 'out': bytes.fromhex( + '61a814cb4fbe7c77bc339a4f3bff21ce26a9dee3c8ea1ad8b886ee424601f976' + 'a956e84c22097081763ed29f3a8b11a8a88824015424c29958c5d8b7cacd0962' + 'd1e9310d25cf0a2694f883eb0007cb78'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('c857fac23f3c2410be8c8a1000915edd2e1b781493e0b4e4a07f539da5f9355f'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('80bd69243039fd9f893a1db9bfa2a90bb42b0435c68a4de69c44ee96397e7217'), + 'rk': bytes.fromhex('b75f29780411e873a54f66e2196e6dfbb2692b74f880a0c16622211ef2cf690c'), + 'alpha': bytes.fromhex('06509facde7313b6f42003afc3ada326f487c1bb51daede42037fd2a1c313106'), + 'cv_net': bytes.fromhex('84f5d51ecc7d7f6861c295d83b4cfd5a12cd25c97be1d194cbe4eb048d0764a7'), + 'cmx': bytes.fromhex('b3fd458b9111b4944613a695c8396c19be004562e41aeb9e2d091e1457d3f83f'), + 'epk': bytes.fromhex('be4d055f42218ccbe8be8bf130b950fe6672243d49d2ca91a9056e08e5554f2e'), + 'enc': bytes.fromhex( + '88b493cbe854943130f7d811e2003fd70fb606e12ebcd3fa5e920d949743dffd' + 'a421643863cf8e51c71c894173b213e8d59479674fc1a4493397b14bc81467c6' + 'ee4f97f29a938bae7646b89e8900f0d9ee043b1e19bd0aa56c14496ce88794a0' + '59723ef024431c0279b49fceaf6bb81370998520a5c710f1349e53dbc567463c' + '339553e375c575eea58dac685350c389b3b1658d0221432ed3722c94ccf5d6df' + '0fa7fe60a1b4615ef9b7667cbb9bb012f1a4fbdc54e47ade84393514fc4ac359' + '2159af250081a3a8317b9904928959553224017b709f7819e1bc676baf44dc51' + '1710988d90c2dd14f8fa30e81efea9ef7ff3d748ae2de43f0ff8726d88f140ef' + 'd178611768224388f62ad1cd4893650d352d1fdab6c7825e1e6fa08125f9cc98' + '862eb48fc4e5de2dfe7e5297a7ba2cf2ccc94b37d06fac064c4a4a20d4c61352' + '2ee7251fd22e8a3cf6ac9cbfdb631cca65ca201615d10637fc5326ebd1732e44' + '1f342bfc5fd510e99a83ecb10a1e5e1f4cb75e2062f851de7828f317af783321' + '0459a8784a52d01a5405ad28ae93b509d672d3376c7cf1386426992e32fe1191' + '69eb5ceab02f44d1bf2fb7c1d8f7d9cb5edf47fe68cd91091e34879a00a834e8' + '77d8ed8841ae776259ae2225f4d93614896630e74635655fff3eafaf3317039f' + '53afae9aed77b71cc33098ca84bf8df70d7e10133ac5b40f05fd1b5a81eb3772' + '1e3448d624ff301c69f69cdacbca8ec077033939af9fadb05b601514b024f476' + '373afe9dc31334eb2afffeb47bc1b79245d064324ef1e6185bdf865ea71e43a3' + 'f4d5a341'), + 'out': bytes.fromhex( + 'c5153d598e573e369a83048eeb7f7a33fbcae9fc38c01cc86450cbbb79ddc15a' + '38768fe782efbdc2d595c7ac047669c5da4f12ad60f8e83ac679c6931089304e' + '7e00ed59ef7aa2943ed9b57e6b5e6c48'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('fccf09a55f9a8bcd8cef78fda81e7327042fc4b5cb89f98d082e1536bdb134b8'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('9626584a16d66fe07ebf4f3c882686e408209f3b0466dc1acff98bdb5083881a'), + 'rk': bytes.fromhex('3d193701dd72c6e5b91c5ce173283391a9b838db3e322f8726e7b9f70f8f0830'), + 'alpha': bytes.fromhex('ff5e8f768ac3da0c5fe125323d57b107348dfae35f7814038c57bf9b72a50229'), + 'cv_net': bytes.fromhex('46877a8ce2afb3ded4da748f3165368d1e6161ac95fc40c3841a093107a3970f'), + 'cmx': bytes.fromhex('959c475be8bf479932a20fc31ea661c23c47d5f4ca6a9634e9efad8da528c70e'), + 'epk': bytes.fromhex('c5aa46b209c2bb664a085844288d2712b1358c0e134cd6de9e8341bbbbf3bd33'), + 'enc': bytes.fromhex( + 'f3cee24d5ea281f27a88653b3d81079e339e6c54427c88444b9843a4818a6dd4' + 'e2f0de12b80e02fe95801d3cc2032c68395625a84763daca66f4803405ac2633' + 'abd46fe0b5b2d1f1ddffe1b9e459e5ec55562ace3d4a1d282ffe55bdbe5edab1' + 'a09efb2556d3642922c25dc9827601a6257ee34d07f03ed5495e8f7a5e808e4d' + 'c63b8cae4755079f99775e7ac7242cd3793352a813c13381fb5758d798be1899' + 'ef901d6c715303e66ee7506ca15d84a78461518aec81cfcb820c998e73838213' + '5c99d9daac438d71d24f732a92d2e139c824650cc73a8cb0e3c0430cfe46d375' + '3ee7b8ba452fa54dafe52d84946b9bec8c42d2d9e434c2ca62fd6fba7f1decb5' + 'c14de077b2aabc1a83e80eb78c300fef35e0e781cb0d7727bcebe09efe1f0985' + 'ef9cf15ea4814d3a7836207ee6769ba72b5ca957100739587c1b9fe9473b0f74' + '05eb94e2c5d4b3f4ae886815da966ad7338529e05cb1a1733e6266a530c4302b' + '8a9c3601f7ba26ee9756938983b7bd41b3405794c2768c6c9dded0500f4dbdd4' + 'f0eaa9161e553674c0791c13ed7f8b233778b3a93ab366166bb0fb2ab3a03e47' + 'a5decfef4c224640458d5b4d85cc91edcb799e445b1331e49e0e8325c41103fb' + 'd54e4dd45a9f71c445323e99a65db21e9aace73e1cf8d9b91dcd481714bd8c3f' + '133132c30a3f3613eb6b08d905f104244c3952b953439c237c59dec6596164da' + '45d3d456d8c283b8759f7367ed232c7b3e7ea23c5d401dc09f3a8717b8e3a621' + '489c2095c1198268237b589ac9831f43dc082f14aea899d807ba60ffeb56e7d5' + '6256542e'), + 'out': bytes.fromhex( + '662b7206d372e142414db811f3bf16a63147976e8ebba60a006676c92639634d' + '65581d26fdeada20db03b26b6ad1dc617daf3c4b26b177eff9193ba6c8aa8304' + '3680feb5f3e25273658ccb5ec54207d6'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('6701cfd14370c25c7bf281f3ac777386621102d8ea16e0e124ca72c49a08943f'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('b717ab43b4030e502789837104b0f1d17c52fc85e9feb67597cc37ea6fefdb06'), + 'rk': bytes.fromhex('4b383511daa4696049c4a62841907f4b2a9f73391f399fb5028d8c4f3bea7117'), + 'alpha': bytes.fromhex('e621c7864db5823238401a62fcaa693cba9b0a8d207b0e43a34e9e9ec4d1b536'), + 'cv_net': bytes.fromhex('b7458fa5c8f2a3e9ca396d936220f7dedb5a8461d5964525179ccef4e39a6f09'), + 'cmx': bytes.fromhex('de7008c29a058bd3ca41759f3d3d45da1b7aff891e79abcd592004437a7a0617'), + 'epk': bytes.fromhex('d1136d16826a6ea26d99f2a54ff4b8f1c3c6c615d8bb325ca798eda9de93113b'), + 'enc': bytes.fromhex( + '39d375985cad35237c13c10db3d8b11f8670b49a9de9dcde98ce544129cb83da' + '345b5393e2949ac27d6efbb02b37e23a1db879fb4678f38e46c2e6b9925c4c04' + '8fd9193b4a46cfe0d6fb5d211e9f18955aa8c66ea362508abf10aa77b019da10' + '435c8a68dafab7a9647af4773a3dd01e36587d184c32efb6f4dc93a9f1ccec76' + '9c6307f1280ccca365cd736b298ece2212193de887ca3a79a064931ff0bf65f4' + 'dab1876205388fd4238f9a47f431d1534ba716de658671368260a1eec4606589' + 'c6a0727ab692d0cfddc5c4ca317d3e5e19109c2952629282d53f1b6fc6afd2bc' + 'f09e2d18a2510689dc087564813a8e27161a49569f311ae8672f648229cea4f3' + 'f8fe6f1e4b7f6b00c67c8ba9b392b66a3f275c6488c048e5cc50900551090a06' + 'b2432b717cf09a7b23dd9d872af1bc967dcaf03e50032ec55938aebe011fe6b0' + '27497fd69caeeffdd0b1dab2eadc28b362bc845a9a409b94ed428bd7601b6520' + 'e5992534ecbbff182de27914255658ac9f7cc6d30d5ec413c44a4f24f2d51425' + '47e0e27fda435b89008160eab76874016396c996a3a16d78a8903e1195bb8045' + '01a2053556cda4ae5ecfae92a02939f06b028727c09d874b5b403a17fe04880c' + 'e579b628e619af2f506e12207dd3a9cc5f0e08839945c42d912db74d325a1dc7' + '161e978b9ddb39fa6944f1776c946f2c7ac450e6e533cd8535a0150547746257' + 'dae1bae72232adf3eeab141f99d40aaacfd30f26714a1ac0f78d4793de75fff5' + '848b71d81440eb9e772dbde5cb3ca5d7c7b10fb39420b443a210abfe29d0ed77' + '1e44a561'), + 'out': bytes.fromhex( + 'aae26bb3b088a37a3442125fe2bc61d0699a77bc86b82a224096c82c60296711' + '12b3e888482afdd1085e11bfc08ac01fbf1cc359420bc7f99f9b47d1237e12f0' + '3757fa7bf2ffd0967c6e23b60009f9aa'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('e4ee1456a223dfa31cb714824dfeb95a2234ce811ea308b63a4fe4786407a33b'), + 'value': 1015000, + }, + { + 'is_spend': True, + 'nullifier': bytes.fromhex('e1e8dabd82a6bcba2107fc4a8be64f0ede30355e5eed5bd365db848908fda23b'), + 'rk': bytes.fromhex('64b31d813e4fa32ad2413109882805a1b6d825eed19dc58a86b4486a4671e39e'), + 'alpha': bytes.fromhex('f08196949dd1c2293d8f45a1919ba6d3d51e03e4a79ffce03df30a9aa6c6a527'), + 'cv_net': bytes.fromhex('0cb9920d9da9d1ab56dc2fe03fe7ba0e99d6680319dab7e79480b38ab781751c'), + 'cmx': bytes.fromhex('df52e0d1e9ef1a81c53c00dfdd98ec1063caeb40f796607dacc0a940ce3c2a0b'), + 'epk': bytes.fromhex('a9d9c634dea3e7077601a14ec78ed750260202728d517eae494670da0cbda6a8'), + 'enc': bytes.fromhex( + 'b01527e05660d0d77f0f23cd2192bf470ea7104ac9219ea44c9992ec5090cce3' + '0b930b56cf37e77c034b6ff110f09b8894796e5840309b92071896d5b44674ec' + '8af09965c555fe34eb6df782a992c3b1bcccbd6bafefda52095061d3f26ca90b' + '000e8096a350a00422d1f85b39c73ddb22b8138bb88ea1b5a69c71c51d487a7e' + '1c3b7be3e11c18341483e2df8151505c05eb3315d717b973874a22e2f9164d7b' + 'cca4cd6465845075084bc4647065df093ba4f521332196fc22488b53f22ce39e' + '8088cbff2b5a320c44191472c72d07d343e057c37ccfc2c30e44759ee53fab33' + 'c3c1a69a63567a862415b0116a6199b0d0a3155a72e9d67295955d835201b65d' + '3d5267bcafaaef444f4c7cabc2dfa444ca4589eb0b7a595370a8ffb172c47f7e' + '1a87745b78badc91840ec56ae11463b12b10450573891b2edbbec61c5e8b5443' + 'bf23feb0649c434860d01d2bc3d07a6879ef38a01d09de4b3a1b9ec173accb3b' + '682c6476e24c9accbd71d9aad42a1a52871d906b7878cd72d1e90b38665f1900' + '258d12da147e1ac2156af74bc8823b9fadb47ba20d369c5c54f575640abb383d' + '5723378a9357086241f43c01133081f7d6fdc3aeaaf4f763478c0f6ef20030a2' + 'a128bfef33b05ea8e3b9565c2b35b49745aeeb9ab43f543469c79ffb28f63673' + '645ff957db246104df1984b26de1ef5de5eb0e98562822c21750ff97efa716e4' + 'a050d027244fc2d76fb1e521547caf6f19f05ae5d6cbdbaef872a2cee284859a' + 'd173fe53d282c4598725a069c7490963b1524e1ca01bc9890fe42e58fcbdc80b' + '8d3af351'), + 'out': bytes.fromhex( + '892b762b0d42e42da9a64a52c82c1e609cb94efe8089c75c8b93cf85464748af' + '812446c0d38a737e2450d1ac9560b2a7bfca9835a3aad344c04ee19f3ab3e491' + '44af0486105188941f2e2294a05f915d'), + 'recipient': bytes.fromhex( + 'ddae703de8aee25d4e439b8a646b2a5a86f57f802890996e2b3c1f41ff2348a0' + 'f0bb27c032b2e7718c962e'), + 'rseed': bytes.fromhex('bdd6672d18bc9ddbe590b177c010c17470a5263b05c9b770448173b7a336debe'), + 'value': 1015000, + }, + ], + }, +} + + +def migration_action(v): + action = fabricated_action(v) + action['is_spend'] = v['is_spend'] + return action + + +def migration_kwargs(tx, fee): + lock_time, expiry = 0, tx['expiry'] + orchard = [migration_action(v) for v in tx['orchard']['actions']] + kwargs = { + 'address_n': ADDRESS_N, + 'actions': orchard, + 'account': 0, + 'fee': fee, + 'lock_time': lock_time, + 'expiry_height': expiry, + 'orchard_flags': tx['orchard']['flags'], + 'orchard_value_balance': tx['orchard']['value_balance'], + 'orchard_anchor': ANCHOR, + 'header_digest': header_digest(IRONWOOD_TX['tx_version'], + IRONWOOD_TX['version_group_id'], + IRONWOOD_TX['branch_id'], lock_time, expiry), + 'orchard_digest': bundle_digest( + orchard, False, 6, flags=tx['orchard']['flags'], + value_balance=tx['orchard']['value_balance']), + } + kwargs.update(IRONWOOD_TX) + if 'ironwood' in tx: + ironwood = [migration_action(v) for v in tx['ironwood']['actions']] + kwargs.update( + ironwood_actions=ironwood, + ironwood_flags=tx['ironwood']['flags'], + ironwood_value_balance=tx['ironwood']['value_balance'], + ironwood_digest=bundle_digest( + ironwood, True, 6, flags=tx['ironwood']['flags'], + value_balance=tx['ironwood']['value_balance'])) + return kwargs + + +def _b2b(person, data): + return hashlib.blake2b(data, digest_size=32, person=person).digest() + + +def header_digest(tx_version, version_group_id, branch_id, lock_time, expiry): + """BLAKE2b-256('ZTxIdHeadersHash', 20-byte LE header). zcash.c:840-857.""" + header = struct.pack(' 8: + response = self.client.call_raw(request) + else: + response = self.client.call(request) + return response, action + + def _assert_first_input_requested(self, response): + self.assertIsInstance(response, zcash_proto.ZcashTransparentAck) + self.assertTrue(response.HasField('next_input_index')) + self.assertEqual(response.next_input_index, 0) + + def _assert_path_rejected(self, bad_path, message): + self.setup_mnemonic_allallall() + response, _ = self._start_raw_session(1) + self._assert_first_input_requested(response) + item = self._make_transparent_input(address_n=bad_path, + script_pubkey=P2PKH_SCRIPT) + response = self.client.call_raw( + zcash_proto.ZcashTransparentInput(**item)) + self.assertIsInstance(response, proto.Failure) + self.assertIn(message.lower(), response.message.lower()) + + def test_hybrid_signature_verifies(self): + self.setup_mnemonic_allallall() + inputs = [self._make_transparent_input()] + pubkey = self._get_pubkey_for_path(ZEC_PATH) + + response, signatures, kwargs = self._sign(inputs) + + self.assertIsInstance(response, zcash_proto.ZcashSignedPCZT) + self.assertEqual(len(response.signatures), 0) + self.assertEqual(len(signatures), 1) + self._assert_signature(signatures[0], pubkey, kwargs, inputs, 0) + + def test_hybrid_multi_input_signatures_verify(self): + self.setup_mnemonic_allallall() + amounts = [VALUE // 2, VALUE - (VALUE // 2)] + inputs = [ + self._make_transparent_input(index=i, amount=amount) + for i, amount in enumerate(amounts) + ] + pubkey = self._get_pubkey_for_path(ZEC_PATH) + + response, signatures, kwargs = self._sign(inputs) + + self.assertIsInstance(response, zcash_proto.ZcashSignedPCZT) + self.assertEqual(len(signatures), 2) + self._assert_signature(signatures[0], pubkey, kwargs, inputs, 0) + self._assert_signature(signatures[1], pubkey, kwargs, inputs, 1) + + digest_1 = _transparent_sig_digest(inputs, [], 1) + person = b'ZcashTxHash_' + kwargs['branch_id'].to_bytes(4, 'little') + wrong_digest = _b2b( + person, + kwargs['header_digest'] + digest_1 + EMPTY_SAPLING_DIGEST + + kwargs['orchard_digest']) + self.assertFalse( + self._verify_der_signature(pubkey, wrong_digest, signatures[0]), + "input 0 signature must not verify for input 1") + + def test_wrong_key_does_not_verify(self): + self.setup_mnemonic_allallall() + path_0 = list(ZEC_PATH) + path_1 = list(ZEC_PATH[:-1]) + [1] + inputs = [self._make_transparent_input(address_n=path_0)] + pubkey_0 = self._get_pubkey_for_path(path_0) + pubkey_1 = self._get_pubkey_for_path(path_1) + self.assertNotEqual(pubkey_0, pubkey_1) + + _, signatures, kwargs = self._sign(inputs) + + self._assert_signature(signatures[0], pubkey_0, kwargs, inputs, 0) + transparent_digest = _transparent_sig_digest(inputs, [], 0) + person = b'ZcashTxHash_' + kwargs['branch_id'].to_bytes(4, 'little') + digest = _b2b( + person, + kwargs['header_digest'] + transparent_digest + + EMPTY_SAPLING_DIGEST + kwargs['orchard_digest']) + self.assertFalse( + self._verify_der_signature(pubkey_1, digest, signatures[0]), + "signature for account 0 path must not verify under another key") + + def test_rejects_wrong_purpose(self): + self._assert_path_rejected( + [H + 49, H + 133, H, 0, 0], "m/44'/133'") + + def test_rejects_wrong_coin_type(self): + self._assert_path_rejected( + [H + 44, H + 60, H, 0, 0], "m/44'/133'") + + def test_rejects_unhardened_account(self): + self._assert_path_rejected( + [H + 44, H + 133, 0, 0, 0], "account must be hardened") + + def test_rejects_wrong_account(self): + self._assert_path_rejected( + [H + 44, H + 133, H + 1, 0, 0], + "account does not match approved session") + + def test_rejects_short_path(self): + self._assert_path_rejected( + [H + 44, H + 133, H], + "m/44'/133'/account'/change/index") + + def test_rejects_bad_change(self): + self._assert_path_rejected( + [H + 44, H + 133, H, 7, 0], "change must be 0") + + def test_rejects_hardened_index(self): + self._assert_path_rejected( + [H + 44, H + 133, H, 0, H], "index must not be hardened") + + def test_orchard_before_transparent_rejected(self): + self.setup_mnemonic_allallall() + response, action = self._start_raw_session(1) + self._assert_first_input_requested(response) + + response = self.client.call_raw(zcash_proto.ZcashPCZTAction( + index=0, **action)) + + self.assertIsInstance(response, proto.Failure) + self.assertIn("transparent data not yet complete", + response.message.lower()) + + def test_rejects_out_of_order_transparent_index(self): + self.setup_mnemonic_allallall() + response, _ = self._start_raw_session(2) + self._assert_first_input_requested(response) + # Built mid-session: deriving the key here would end the session. + item = self._make_transparent_input(index=1, + script_pubkey=P2PKH_SCRIPT) + + response = self.client.call_raw( + zcash_proto.ZcashTransparentInput(**item)) + + self.assertIsInstance(response, proto.Failure) + self.assertIn("index", response.message.lower()) + + def test_rejects_too_many_transparent_inputs(self): + self.setup_mnemonic_allallall() + response, _ = self._start_raw_session(100) + + self.assertIsInstance(response, proto.Failure) + self.assertIn("too many transparent inputs", response.message.lower()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_multisig.py b/tests/test_multisig.py index 1f6e188f..57eadd13 100644 --- a/tests/test_multisig.py +++ b/tests/test_multisig.py @@ -240,5 +240,51 @@ def test_missing_pubkey(self): self.assertRaises(CallException, self.client.sign_tx, 'Bitcoin', [inp1, ], [out1, ]) + def test_oversized_signature_is_rejected(self): + """A multisig signature longer than a DER ECDSA signature is refused. + + MultisigRedeemScriptType.signatures is declared max_size:73, so the + decoder accepts 73 bytes -- but a DER-encoded ECDSA signature is at + most 72 (0x30 len, then two 0x02-tagged integers of at most 33 bytes). + The witness serializer used to append the sighash byte AT + signatures[i].size, so a 73-byte value wrote one past the end of + bytes[73]: onto signatures[i+1].size for i < 14, which can revive a + slot the host left empty and change the witness stack after the user + reviewed it, or onto has_m at i == 14. + + The declared max_size is a decoder bound, never a runtime one. This + asserts the device applies the real one. + """ + # The 72-byte runtime bound is part of the corrected 7.15 release. + self.requires_firmware("7.15.0") + self.setup_mnemonic_nopin_nopassphrase() + + node = ckd_public.deserialize('xpub661MyMwAqRbcF1zGijBb2K6x9YiJPh58xpcCeLvTxMX6spkY3PcpJ4ABcCyWfskq5DDxM3e6Ez5ePCqG5bnPUXR4wL8TZWyoDaUdiWW7bKy') + + multisig = proto_types.MultisigRedeemScriptType( + pubkeys=[proto_types.HDNodePathType(node=node, address_n=[1]), + proto_types.HDNodePathType(node=node, address_n=[2]), + proto_types.HDNodePathType(node=node, address_n=[3])], + # 73 bytes: one more than any real DER signature, + # and exactly the value that overflowed the write. + signatures=[b'\x30' * 73, b'', b''], + m=2, + ) + + inp1 = proto_types.TxInputType(address_n=[1], + prev_hash=binascii.unhexlify('c6091adf4c0c23982a35899a6e58ae11e703eacd7954f588ed4b9cdefc4dba52'), + prev_index=1, + script_type=proto_types.SPENDMULTISIG, + multisig=multisig, + ) + + out1 = proto_types.TxOutputType(address='12iyMbUb4R2K3gre4dHSrbu5azG5KaqVss', + amount=100000, + script_type=proto_types.PAYTOADDRESS) + + with self.client: + self.assertRaises(CallException, self.client.sign_tx, 'Bitcoin', [inp1, ], [out1, ]) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_network_policy.py b/tests/test_network_policy.py index e075f73e..52a41fb1 100644 --- a/tests/test_network_policy.py +++ b/tests/test_network_policy.py @@ -31,3 +31,36 @@ def test_non_emulator_local_transports_are_denied(): with pytest.raises(AssertionError): requests.get("http://127.0.0.1:1/forbidden") + + +@pytest.mark.parametrize('method', ['sendto', 'send', 'sendall']) +def test_owned_emulator_lease_allows_only_its_udp_pair_and_expires(method): + from emulator_endpoints import owned_emulator_pair + + receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + receiver.bind(("127.0.0.1", 0)) + receiver.settimeout(1) + port = receiver.getsockname()[1] + if port == 65535: + pytest.skip("allocated port has no adjacent debug port") + def send(payload): + if method == 'sendto': + return sender.sendto(payload, ("127.0.0.1", port)) + return getattr(sender, method)(payload) + + with owned_emulator_pair(port): + if method != 'sendto': + sender.connect(("127.0.0.1", port)) + send(b"owned") + assert receiver.recv(5) == b"owned" + with pytest.raises(AssertionError): + sender.sendto(b"forbidden", ("192.0.2.1", port)) + with pytest.raises(AssertionError): + sender.sendto(b"forbidden", ("127.0.0.1", 1)) + with pytest.raises(AssertionError): + send(b"expired") + finally: + sender.close() + receiver.close() diff --git a/tests/test_protection_levels.py b/tests/test_protection_levels.py index 40eb7643..0a1f377c 100644 --- a/tests/test_protection_levels.py +++ b/tests/test_protection_levels.py @@ -68,6 +68,7 @@ def test_ping(self): def test_get_entropy(self): with self.client: self.setup_mnemonic_pin_passphrase() + self.client.clear_session() self.client.set_expected_responses([proto.ButtonRequest(), proto.Entropy()]) self.client.get_entropy(10) diff --git a/tests/test_report_rendering.py b/tests/test_report_rendering.py new file mode 100644 index 00000000..492a8a46 --- /dev/null +++ b/tests/test_report_rendering.py @@ -0,0 +1,67 @@ +"""Regression checks for clipped report text and ambiguous product covers.""" +import importlib.util +import pathlib +import re +import tempfile +import unittest +import zlib +from unittest import mock + +SCRIPT = pathlib.Path(__file__).parents[1] / 'scripts/generate-test-report.py' +SPEC = importlib.util.spec_from_file_location('report_rendering', SCRIPT) +REPORT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(REPORT) + + +class TestReportRendering(unittest.TestCase): + def test_wide_unbroken_heading_wraps_without_losing_its_tail(self): + pdf = REPORT.PDF() + builder = REPORT.PB(pdf) + heading = 'W' * 128 + 'TAIL' + builder.text(20, heading) + lines = [item[2] for item in builder.lines] + self.assertGreater(len(lines), 1) + self.assertEqual(heading, ''.join(lines)) + # Helvetica W is 944/1000 em: only 28 fit in 532pt at 20pt. + self.assertTrue(all(len(line) <= 28 for line in lines)) + builder.finish() + self.assertIn('TAIL', pdf.pages[-1][0]) + + def test_wrapped_test_labels_preserve_their_outcome_and_identity(self): + label = 'Z99 ' + 'W' * 180 + 'TAIL' + for status, color in [('pass', REPORT.GREEN), ('fail', REPORT.RED), + ('skip', REPORT.GRAY), ('', REPORT.GRAY)]: + with self.subTest(status=status): + builder = REPORT.PB(REPORT.PDF()) + builder.check(9, label, status) + text = [item for item in builder.lines if item[3] != 'ding'] + self.assertGreater(len(text), 1) + rendered = re.sub(r'\s+', '', ''.join(item[2] for item in text)) + self.assertEqual(label.replace(' ', ''), + rendered[2:] if rendered.startswith('--') else rendered) + self.assertTrue(all(item[4] == color for item in text)) + symbols = [item for item in builder.lines if item[3] == 'ding'] + self.assertEqual(int(status in ('pass', 'fail')), len(symbols)) + + def test_rendered_cover_identifies_each_product(self): + section = ('B', 'Bitcoin', '0.0.0', 'Bitcoin protocol control.', [], + [('B1', 'test_control', 'test_sign', 'Signature control', + 'Requires the expected signature.', [])]) + with tempfile.TemporaryDirectory() as directory, \ + mock.patch.object(REPORT, 'SECTIONS', [section]), \ + mock.patch.dict(REPORT.JUNIT_CENSUS, ran=0, native=0, + skipped=0): + for variant, expected in [('full', 'Product: Full firmware'), + ('bitcoin-only', 'Product: Bitcoin-only')]: + target = pathlib.Path(directory) / (variant + '.pdf') + REPORT.render(str(target), '7.15.0', + {'test_control::test_sign': 'pass'}, + variant=variant) + streams = re.findall(rb'stream\n(.*?)\nendstream', + target.read_bytes(), re.S) + content = b'\n'.join(zlib.decompress(s) for s in streams) + self.assertIn(expected.encode('ascii'), content) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_report_variant_validation.py b/tests/test_report_variant_validation.py new file mode 100644 index 00000000..3846708a --- /dev/null +++ b/tests/test_report_variant_validation.py @@ -0,0 +1,159 @@ +import importlib.util +import os +import unittest + + +REPORT_SCRIPT = os.path.join( + os.path.dirname(__file__), '..', 'scripts', 'generate-test-report.py') +SPEC = importlib.util.spec_from_file_location('generate_test_report', + REPORT_SCRIPT) +REPORT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(REPORT) + + +def catalog_results_with_solana_lut_skipped(fw_version): + results = {} + for _, _, min_fw, _, _, tests in REPORT.SECTIONS: + if not REPORT.ver_ge(fw_version, min_fw): + continue + for _, module, method, _, _, _ in tests: + results['%s::%s' % (module, method)] = 'pass' + for key in list(results): + if key.startswith('test_msg_solana_lut_attestation::'): + results[key] = 'skip' + return results + + +class TestReportVariantValidation(unittest.TestCase): + + def test_full_7143_accepts_unimplemented_solana_lut_skip(self): + result = REPORT.validate_junit( + '7.14.3', catalog_results_with_solana_lut_skipped('7.14.3'), + 'full') + self.assertEqual((True, []), result) + + def test_full_715_requires_solana_lut_coverage(self): + ok, failures = REPORT.validate_junit( + '7.15.0', catalog_results_with_solana_lut_skipped('7.15.0'), + 'full') + self.assertFalse(ok) + self.assertEqual(4, len(failures)) + self.assertTrue(all(item[3] == 'skipped-but-required' + for item in failures)) + + def test_full_716_requires_solana_lut_coverage(self): + ok, failures = REPORT.validate_junit( + '7.16.0', catalog_results_with_solana_lut_skipped('7.16.0'), + 'full') + self.assertFalse(ok) + self.assertEqual(4, len(failures)) + self.assertTrue(all(item[3] == 'skipped-but-required' + for item in failures)) + + def test_bitcoin_only_accepts_absent_solana_lut_handlers(self): + result = REPORT.validate_junit( + '7.16.0', catalog_results_with_solana_lut_skipped('7.16.0'), + 'bitcoin-only') + self.assertEqual((True, []), result) + + def test_staged_capabilities_accept_only_their_mapped_controls(self): + results = catalog_results_with_solana_lut_skipped('7.15.0') + for method in ( + 'PinKdfRewrapsToActiveVersionAfterCorrectPin', + 'PinUnlocksAfterRebootUnderV17', + 'PinKdfV2FlagIsVersionedInV19'): + del results['Storage::' + method] + self.assertEqual( + (True, []), + REPORT.validate_junit('7.15.0', results, 'full', { + 'solana-lut-attestation', 'storage-v19-kdf'})) + + def test_staged_erc7730_runtime_skips_only_with_its_capability(self): + results = catalog_results_with_solana_lut_skipped('7.15.0') + runtime = [key for key in results + if key.startswith('test_msg_ethereum_erc7730_runtime::')] + self.assertTrue(runtime) + for key in runtime: + results[key] = 'skip' + self.assertEqual((True, []), REPORT.validate_junit( + '7.15.0', results, 'full', + {'solana-lut-attestation', 'erc7730-runtime-review'})) + ok, failures = REPORT.validate_junit( + '7.15.0', results, 'full', {'solana-lut-attestation'}) + self.assertFalse(ok) + self.assertEqual({'skipped-but-required'}, + {status for _, _, _, status in failures}) + + def test_complete_release_still_requires_staged_controls(self): + results = catalog_results_with_solana_lut_skipped('7.15.0') + for method in ( + 'PinKdfRewrapsToActiveVersionAfterCorrectPin', + 'PinUnlocksAfterRebootUnderV17', + 'PinKdfV2FlagIsVersionedInV19'): + del results['Storage::' + method] + ok, failures = REPORT.validate_junit('7.15.0', results, 'full') + self.assertFalse(ok) + failed = {(module, method, status) + for _, module, method, status in failures} + self.assertIn( + ('Storage', 'PinKdfV2FlagIsVersionedInV19', 'missing'), failed) + self.assertIn( + ('test_msg_solana_lut_attestation', + 'test_attestation_does_not_replay_onto_another_transaction', + 'skipped-but-required'), failed) + + def test_stack08_runtime_controls_are_required_on_full_only(self): + results = catalog_results_with_solana_lut_skipped('7.15.0') + lut = {'solana-lut-attestation'} + controls = [ + (module, method) for section, _, _, _, _, tests in REPORT.SECTIONS + if section == 'EX' for identifier, module, method, _, _, _ in tests + if identifier in ('EX40', 'EX41', 'EX42', 'EX43', 'EX44')] + self.assertEqual(5, len(controls)) + self.assertEqual((True, []), + REPORT.validate_junit('7.15.0', results, 'full', lut)) + for module, method in controls: + for status in ('missing', 'skip', 'fail'): + with self.subTest(method=method, status=status): + changed = dict(results) + key = module + '::' + method + if status == 'missing': + del changed[key] + else: + changed[key] = status + ok, failures = REPORT.validate_junit( + '7.15.0', changed, 'full', lut) + self.assertFalse(ok) + self.assertTrue(any(item[1:3] == (module, method) + for item in failures)) + changed = dict(results) + changed[module + '::' + method] = 'skip' + self.assertEqual((True, []), REPORT.validate_junit( + '7.15.0', changed, 'bitcoin-only', lut)) + + def test_authenticator_contracts_cannot_skip_on_either_variant(self): + results = catalog_results_with_solana_lut_skipped('7.15.0') + for key in results: + results[key] = 'pass' + owned = [key for key in results if key.startswith( + 'test_msg_authenticator_boundaries::test_block09_')] + self.assertEqual(4, len(owned)) + for variant in ('full', 'bitcoin-only'): + self.assertEqual((True, []), REPORT.validate_junit( + '7.15.0', results, variant)) + for key in owned: + for status in ('skip', 'fail', 'missing'): + with self.subTest(variant=variant, key=key, status=status): + changed = dict(results) + if status == 'missing': + del changed[key] + else: + changed[key] = status + ok, failures = REPORT.validate_junit( + '7.15.0', changed, variant) + self.assertFalse(ok) + self.assertEqual(1, len(failures)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_sign_typed_data.py b/tests/test_sign_typed_data.py index a35e3edb..72c5880f 100644 --- a/tests/test_sign_typed_data.py +++ b/tests/test_sign_typed_data.py @@ -28,35 +28,130 @@ from keepkeylib import tools class TestMsgEthereumSignTypedDataHash(common.KeepKeyTest): - + + def test_ethereum_sign_x402_eip3009(self): + """x402 EVM exact payments clear-sign the EIP-3009 authorization. + + This fixture follows the official v2 EVM shape: Base Sepolia USDC, + ``TransferWithAuthorization``, facilitator-paid gas, and the exact + recipient and value embedded in the signed EIP-712 message. + """ + self.requires_fullFeature() + # 7.15 retires the legacy JSON endpoint fail closed; the replacement + # streamed implementation is covered independently. + self.requires_firmware("7.15.0") + self.requires_message("Ethereum712TypesValues") + self.setup_mnemonic_allallall() + + typed_data = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "TransferWithAuthorization": [ + {"name": "from", "type": "address"}, + {"name": "to", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "validAfter", "type": "uint256"}, + {"name": "validBefore", "type": "uint256"}, + {"name": "nonce", "type": "bytes32"}, + ], + }, + "primaryType": "TransferWithAuthorization", + "domain": { + "name": "USDC", + "version": "2", + "chainId": 84532, + "verifyingContract": + "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + }, + "message": { + "from": "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8", + "to": "0x209693Bc6afc0C5328bA36FaF03C514EF312287C", + "value": "2000", + "validAfter": "0", + "validBefore": "2000000000", + "nonce": "0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480", + }, + } + + # Hardened firmware disables legacy structured EIP-712 outright, + # pending canonical + # display hardening: the device could not prove that what it rendered + # was what it hashed. This vector is the x402 EIP-3009 + # TransferWithAuthorization payment flow, and it is currently REFUSED + # rather than signed. + # + # The expected hashes are retained below the refusal, unused, because + # they are independent reference values from the EIP-712 V4 encoder and + # are what this test should assert again the day the display hardening + # lands. Deleting them would lose the only checked-in oracle for this + # vector. See docs/security/ for the 7.16 structured-EIP-712 item. + self.client.apply_policy('AdvancedMode', False) + with self.assertRaises(CallException) as ctx: + self.client.ethereum_sign_typed_data( + tools.parse_path("m/44'/60'/0'/0/0"), typed_data) + self.assertIn("Structured EIP-712 disabled", str(ctx.exception)) + + # Re-enable when structured EIP-712 returns: + # domain_separator_hash + # 71f17a3b2ff373b803d70a5a07c046c1a2bc8e89c09ef722fcb047abe94c9818 + # message_hash + # ccb8d59d2e8a63beafb02887b4c9dd2f79d3527df4167f8c6b36e3e43cf373be + # address 0x73d0385F4d8E00C5e6504C6030F47BF6212736A8, 65-byte signature + def test_ethereum_sign_typed_data_hash(self): self.requires_fullFeature() - self.requires_firmware("7.4.0") + self.requires_firmware("7.15.0") self.setup_mnemonic_allallall() # 7.14.2 gates precomputed typed hashes behind AdvancedMode: the device - # cannot bind the hash to any typed data it displayed. Opt in explicitly. + # cannot bind the hash to any typed data it displayed. Opt in explicitly + # rather than having the firmware relax the gate. self.client.apply_policy("AdvancedMode", 1) fixture_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'sign_typed_data.json') with open(fixture_path, encoding="utf-8") as f: txtests = json.load(f) - for test in txtests['tests']: - print("test: ", json.dumps(test['name'])) - if test['parameters']['message_hash'] != None: - retval = self.client.ethereum_sign_typed_data_hash( - n = tools.parse_path(test['parameters']['path']), - ds_hash = binascii.unhexlify(test['parameters']['domain_separator_hash'][2:]), - m_hash = binascii.unhexlify(test['parameters']['message_hash'][2:]) - ) - else: - retval = self.client.ethereum_sign_typed_data_hash( - n = tools.parse_path(test['parameters']['path']), - ds_hash = binascii.unhexlify(test['parameters']['domain_separator_hash'][2:]), - ) - - self.assertEqual(retval.address, test['result']['address']) - self.assertEqual(binascii.hexlify(retval.signature), test['result']['sig'][2:]) + def sign(test): + parameters = test['parameters'] + kwargs = { + 'n': tools.parse_path(parameters['path']), + 'ds_hash': binascii.unhexlify( + parameters['domain_separator_hash'][2:]), + } + if parameters['message_hash'] is not None: + kwargs['m_hash'] = binascii.unhexlify( + parameters['message_hash'][2:]) + return self.client.ethereum_sign_typed_data_hash(**kwargs) + + # This endpoint receives only precomputed hashes. It must fail closed + # unless the user explicitly opts in to blind signing. + self.client.apply_policy('AdvancedMode', False) + with self.assertRaises(CallException) as ctx: + sign(txtests['tests'][0]) + # The firmware names the remedy rather than just the refusal: + # "Enable AdvancedMode to blind-sign typed hashes". + message = str(ctx.exception) + self.assertTrue( + 'Enable AdvancedMode to blind-sign typed hashes' in message + or 'Typed-hash signing disabled by policy' in message, + 'unexpected typed-hash refusal: %s' % message) + + self.client.apply_policy('AdvancedMode', True) + try: + for test in txtests['tests']: + print("test: ", json.dumps(test['name'])) + retval = sign(test) + self.assertEqual(retval.address, test['result']['address']) + self.assertEqual( + binascii.hexlify(retval.signature), + test['result']['sig'][2:]) + finally: + self.client.apply_policy('AdvancedMode', False) if __name__ == '__main__': unittest.main() diff --git a/tests/test_storage_version_gate.py b/tests/test_storage_version_gate.py new file mode 100644 index 00000000..0950dca1 --- /dev/null +++ b/tests/test_storage_version_gate.py @@ -0,0 +1,1412 @@ +# This file is part of the KeepKey project. +# +# Storage version gate -- upgrade preservation and explicit downgrade policy. +# +# Policy, derived from the surrounding firmware tree, in two sentences: +# +# A signed UPGRADE must never wipe. An unreadable newer record must never be +# parsed; firmware either resets it or explicitly refuses it without writing. +# +# Nothing in this suite tested either half before this file. Both directions +# are release blockers: a wiping upgrade destroys every field wallet with no +# prompt, while loading an unreadable newer format can expose or corrupt it. +# +# How incompatibility is handled, mechanically (lib/firmware/storage.c): +# +# storage_init() -> storage_fromFlash() -> version_from_int(raw_version) +# Historical trees map an unrecognised version to StorageVersion_NONE and +# reset. Trees with SUS_TooNew intercept newer normal-band versions first and +# reset only the RAM shadow while leaving flash byte-identical. +# +# So "does this firmware recognise the version in flash?" IS the whole +# question, and every test below is a way of asking it. +# +# --------------------------------------------------------------------------- +# What runs where, and why the emulator can prove any of this at all +# --------------------------------------------------------------------------- +# +# The version gate only runs at BOOT. There is no host-driven reboot: the +# SoftReset message (messages.proto type 89) has no entry in +# lib/firmware/messagemap.def, and fsm_msgDebugLinkFlashDump() is compiled out +# under #ifndef EMULATOR, so the emulator can neither be rebooted nor have its +# flash read over the wire. The only way to cross the boot boundary is to own +# the emulator process and its flash image file. +# +# That is what TestStorageUpgradePreservation does: it starts its OWN kkemu on +# its OWN port pair in its OWN temp directory, so it never touches whichever +# emulator the rest of the suite is talking to. Killing the process and +# starting it again on the same emulator.img IS a power cycle -- lib/emulator/ +# setup.c mmaps that file as the flash array, so every flash write survives. +# +# Restamping the version word in that image is not "faking an upgrade". It +# reproduces exactly what an arriving device presents to the incoming +# firmware: a blob whose header says one version while the firmware compiled +# in says another. It does NOT exercise the layout migration chain, because +# the bytes under the stamp were written by this build -- see +# test_v16_blob_upgrades_without_wiping for how far that is taken, and the +# module docstring in the report section for what is still untested. +# +# TestStorageVersionGateSource needs no device at all: it reads the firmware +# sources and asserts the gate's own invariants. Those tests run everywhere, +# including CI, so this section is never completely dark. +# +# --------------------------------------------------------------------------- +# Why the source tests name no version number +# --------------------------------------------------------------------------- +# +# They used to. test_active_flash_format_is_v20 asserted STORAGE_VERSION == 20 +# and test_burned_versions_are_dispatched_to_the_wipe_path asserted the literal +# string "case StorageVersion_18:", because 7.16 writes V20 and burns 18/19. +# Both are true on the passkeys branch and both are FALSE on the 7.15 line, +# where STORAGE_VERSION is 17 and nothing is burned. python-keepkey is one +# submodule shared by every firmware branch, and CI now builds the emulator +# from whichever branch is under test, so a test pinned to one branch's version +# reports a failure whose only cause is which branch you are on. +# +# A test that reads a source file has to assert properties of what it read. +# What follows is derived, per tree: +# +# STORAGE_VERSION, STORAGE_VERSION_LAST_SHIPPED, include/.../storage.h +# STORAGE_VERSION_BTC_ONLY_BASE +# the version ladder lib/firmware/storage_versions.inc +# which versions are BURNED lib/firmware/storage_versions.inc +# which versions have a reader / hit the wipe path lib/firmware/storage.c +# +# The only version number still written down is +# STORAGE_VERSION_LAST_SHIPPED_FLOOR, and it is a FLOOR, not an equality -- see +# its comment for why that distinction is the whole argument. (The V16 numbers +# in the emulator section are a different thing: they describe the format 7.14.x +# shipped, which is finished history and cannot change. The flash offsets are +# unchanged by the V20 bump -- V20 keeps V17's layout and puts passkey state in +# its reserved area at +501 -- so the migration test reads the same way on both +# lines.) +# +# Decoupling is not the same as weakening. The property docs/StorageVersionGate +# .md exists to protect -- "a bump is a deliberate release act, never an +# accident" -- is enforced harder than before, because it no longer rests on +# somebody also editing a constant in this file. A bare `#define +# STORAGE_VERSION 18` now has to survive: +# +# * the ladder must be contiguous 1..N and END at STORAGE_VERSION, so the +# bump forces an append to storage_versions.inc; +# * every ladder version must be dispatched in storage_fromFlash, so the bump +# forces a case label; +# * the version this firmware WRITES must have a reader, so the bump forces a +# reader behind that label. +# +# Three files have to move together, and every one of them is a file that has +# to move anyway for the firmware to be correct. The old constant was the only +# artifact in the set that did not. + +from __future__ import print_function + +import glob +import os +import re +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) +_PYKEEPKEY = os.path.dirname(_HERE) +if _PYKEEPKEY not in sys.path: + sys.path.insert(0, _PYKEEPKEY) + + +# --------------------------------------------------------------------------- +# Flash layout constants +# --------------------------------------------------------------------------- +# Emulator flash file offsets. lib/emulator/setup.c mmaps emulator.img at +# FLASH_ORIGIN (0x08000000), so a flash address maps to file offset +# address - 0x08000000. The three storage sectors come from +# flash_sector_map[] in include/keepkey/board/memory.h. +SECTOR_OFFSETS = (0x4000, 0x8000, 0xC000) # FLASH_STORAGE1/2/3 +STORAGE_SECTOR_LEN = 0x4000 +STORAGE_GENERATION_OFFSET = 41 +STORAGE_GENERATION_LEN = 3 +STORAGE_RECORD_DATA_LEN = 2572 +STORAGE_RECORD_TRAILER_MAGIC = b"crc1" +STORAGE_RECORD_CRC_OFFSET = 2576 +STORAGE_RECORD_LEN = 2580 + +# STORAGE_MAGIC_STR, include/keepkey/board/keepkey_board.h +STORAGE_MAGIC = b"stor" + +# Metadata is 44 bytes; the Storage record starts right after it, and its +# first word is the version. Everything below is (44 + offset-within-Storage), +# with the inner offsets taken from storage_readStorageV16Plaintext() and +# storage_readStorageV17() in lib/firmware/storage.c -- NOT from docs/ +# Storage.md, whose V17 table has a stale byte count. +OFF_VERSION = 44 + 0 +OFF_FLAGS = 44 + 4 +OFF_AUTHDATA_FINGERPRINT = 44 + 469 # 32 bytes, V17 only +OFF_ENCSEC_VERSION = 44 + 1497 +OFF_ENCSEC = 44 + 1501 +V16_ENCSEC_SIZE = 512 # lib/firmware/storage.h +V17_ENCSEC_SIZE = 1024 + +FLAG_HAS_SEC_FINGERPRINT = 1 << 14 +FLAG_AUTHDATA_INITIALIZED = 1 << 18 +FLAG_AUTHDATA_ENCRYPTED = 1 << 19 + +# include/keepkey/firmware/storage.h. Cross-checked against the header by +# test_version_never_drops_below_a_shipped_release -- the emulator tests below +# stamp wallets into this band by hand, so a drift between the two would make +# them exercise a band the firmware does not use. +STORAGE_VERSION_BTC_ONLY_BASE = 10000 + +# The lowest value STORAGE_VERSION_LAST_SHIPPED may ever hold. 7.15 shipped +# storage V17; that is a fact about the past and cannot become false, so this +# is a RATCHET and not a version pin. Raise it when a later release actually +# ships (the same commit that raises the constant in storage.h); there is no +# branch on which it needs lowering, and lowering it is the edit this exists to +# stop. +# +# The distinction matters. `assertEqual(17, last_shipped)` is wrong the day +# 7.16 ships and wrong on any branch that has already bumped it, so it rots and +# gets "fixed" by whoever the failure inconveniences. `>= 17` is wrong only if +# somebody deletes history. It still catches the edit docs/StorageVersionGate.md +# calls the single highest-severity review item in the file: the static assert +# is STORAGE_VERSION >= STORAGE_VERSION_LAST_SHIPPED, so the way to make a +# LOWERED storage version compile is to lower LAST_SHIPPED to match it, and +# both numbers live in the same header where one commit reaches both. An +# independent witness is the only thing that sees it. +STORAGE_VERSION_LAST_SHIPPED_FLOOR = 17 + +MNEMONIC_ALL = " ".join(["all"] * 12) +LABEL = "storagegate" +PIN = "1234" +BIP44_ADDRESS_N = [2147483692, 2147483648, 2147483648, 0, 0] # m/44'/0'/0'/0/0 + + +def _storage_crc(record): + """STM32 CRC-32/MPEG-2 over native little-endian 32-bit words, covering + record bytes [4, 2572): everything but the magic, which storage_commit() + writes last (the record is staged without it).""" + data = record[len(STORAGE_MAGIC):STORAGE_RECORD_DATA_LEN] + if len(data) != STORAGE_RECORD_DATA_LEN - len(STORAGE_MAGIC) or len(data) % 4: + raise ValueError("storage CRC requires the complete word-aligned record") + crc = 0xFFFFFFFF + for pos in range(0, len(data), 4): + crc ^= struct.unpack(" */ + STORAGE_VERSION_ENTRY(18) + STORAGE_VERSION_ENTRY(19) + STORAGE_VERSION_LAST(20) + + The run ends at the first line that is not a bare STORAGE_VERSION_ENTRY -- + a blank line, another comment, or the STORAGE_VERSION_LAST line, which by + definition is the version being written and so can never be burned. + + Numbers inside the comment text are deliberately NOT scraped: that prose + mentions the commit that reverted the format and the version it reverted + TO, and reading V17 out of it would declare a shipped version burned. + Position is the annotation; the words are for humans. + + An unannotated version that turns out to be dispatched to the wipe path is + a mismatch, not a silent pass -- see + test_burned_versions_agree_between_the_ladder_and_the_dispatch. + """ + burned = set() + lines = inc.splitlines() + i = 0 + while i < len(lines): + if "/*" not in lines[i]: + i += 1 + continue + block = [] + while i < len(lines): + block.append(lines[i]) + if "*/" in lines[i]: + break + i += 1 + i += 1 + if not _BURNED_WORD.search("\n".join(block)): + continue + while i < len(lines): + m = _ENTRY_LINE.match(lines[i]) + if not m: + break + burned.add(int(m.group(1))) + i += 1 + return burned + + +# -- lib/firmware/storage.c -------------------------------------------------- + +_CASE_LABEL = re.compile(r"case\s+StorageVersion_(\w+)\s*:") +_READER_CALL = re.compile(r"\bstorage_read\w*\s*\(") +_WIPE_RETURN = re.compile(r"return\s+SUS_Invalid\b") + + +def _from_flash_arms(c): + """Map every StorageVersion_X label in storage_fromFlash to its arm text. + + Consecutive labels share one arm: `case 2: case 3: ... case 10:` is a + single body reached by nine versions, and each of them must be credited + with what that body does. So labels accumulate until one is followed by + something other than whitespace and comments, and the whole group is + assigned that text. + + Keys are the label suffixes as written -- "17", "BTC_ONLY", "NONE" -- so + the non-numeric arms stay visible to the tests that care about them. + """ + i = c.index("StorageUpdateStatus storage_fromFlash") + body = c[i:c.index("\n}", i)] + assert "case StorageVersion_NONE" in body, ( + "storage_fromFlash body was cut short before the end of its switch; " + "the parse below would under-report every arm") + + labels = list(_CASE_LABEL.finditer(body)) + assert labels, "no case StorageVersion_* labels in storage_fromFlash" + + arms = {} + group = [] + for idx, m in enumerate(labels): + group.append(m.group(1)) + end = labels[idx + 1].start() if idx + 1 < len(labels) else len(body) + own = body[m.end():end] + if _strip_c_comments(own).strip(): + for name in group: + arms[name] = own + group = [] + for name in group: # labels trailing the last statement: no body at all + arms[name] = "" + return arms + + +def _reads(arm): + """Does this arm call a storage_readVxx reader?""" + return bool(_READER_CALL.search(_strip_c_comments(arm))) + + +def _wipes(arm): + """Does this arm return SUS_Invalid -- the reset-and-commit path?""" + return bool(_WIPE_RETURN.search(_strip_c_comments(arm))) + + +# --------------------------------------------------------------------------- +# Emulator process management +# --------------------------------------------------------------------------- + +def _find_emulator(): + """Locate a kkemu binary this test can start and stop. + + KK_EMULATOR_BIN wins. Otherwise look where the two build recipes put it: + scripts/emulator/Dockerfile configures in-source (bin/kkemu at the repo + root), while local work uses an out-of-tree build-* directory. build-emu is + named before the generic glob on purpose -- a bitcoin-only build stamps its + own wallets into the reserved band, which is a different device under + test_bitcoin_only_band_refuses_without_wiping. + """ + env = os.environ.get("KK_EMULATOR_BIN") + if env: + return env if os.access(env, os.X_OK) else None + if not _ROOT: + return None + candidates = [os.path.join(_ROOT, "bin", "kkemu"), + os.path.join(_ROOT, "build-emu", "bin", "kkemu")] + candidates += sorted(glob.glob(os.path.join(_ROOT, "build*", "bin", "kkemu"))) + for c in candidates: + if os.access(c, os.X_OK): + return c + return None + + +_EMULATOR_BIN = _find_emulator() + +_NO_EMULATOR = ( + "no kkemu binary to start and stop (looked at $KK_EMULATOR_BIN, " + "/bin/kkemu, /build*/bin/kkemu). The version gate only runs at " + "boot, and there is no host-driven reboot -- SoftReset is unimplemented and " + "DebugLinkFlashDump is compiled out under EMULATOR -- so these tests must " + "own the emulator process. The release Compose harness mounts the exact " + "variant binary published by firmware-unit; a standalone python-keepkey " + "container has no such binary, so this section remains UNPROVEN there." +) + + +def _free_port_pair(): + """A UDP port p where p and p+1 are both free (kkemu uses p and p+1).""" + for _ in range(200): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.bind(("127.0.0.1", 0)) + p = s.getsockname()[1] + finally: + s.close() + if p % 2: + continue + t = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + t.bind(("127.0.0.1", p + 1)) + except socket.error: + continue + finally: + t.close() + return p + raise RuntimeError("no free UDP port pair for the emulator") + + +class _PortTaken(Exception): + pass + + +class Emulator(object): + """One kkemu process over one flash image, restartable. + + The image is the whole point: lib/emulator/setup.c mmaps emulator.img over + the firmware's flash array, so halting the process and booting it again + replays storage_init() against exactly the bytes the previous run left. + """ + + def __init__(self, workdir): + self.workdir = workdir + self.port = _free_port_pair() + self.img = os.path.join(workdir, "emulator.img") + self.proc = None + self._endpoint_lease = None + + # -- process ------------------------------------------------------------ + + def _ping(self): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.5) + try: + s.sendto(b"PINGPING", ("127.0.0.1", self.port)) + return s.recv(8) == b"PONGPONG" + except socket.error: + return False + finally: + s.close() + + def boot(self): + # _free_port_pair() can only say the pair was free a moment ago; + # another process may bind it before kkemu does. kkemu then exits + # with "Failed to bind socket", and the image is untouched, so try + # a fresh pair rather than fail the test on a port race. + for _ in range(5): + try: + return self._boot_once() + except _PortTaken: + self.port = _free_port_pair() + raise RuntimeError("emulator could not bind a UDP port pair") + + def _boot_once(self): + assert self.proc is None, "already booted" + log_path = os.path.join(self.workdir, "emu.log") + log_start = os.path.getsize(log_path) if os.path.exists(log_path) else 0 + env = dict(os.environ, KEEPKEY_UDP_PORT=str(self.port)) + with open(os.path.join(self.workdir, "emu.log"), "ab") as log: + self.proc = subprocess.Popen( + [_EMULATOR_BIN], cwd=self.workdir, env=env, stdout=log, + stderr=subprocess.STDOUT) + from emulator_endpoints import owned_emulator_pair + try: + self._endpoint_lease = owned_emulator_pair(self.port) + self._endpoint_lease.__enter__() + for _ in range(100): + time.sleep(0.1) + if self.proc.poll() is not None: + with open(log_path, "rb") as log: + log.seek(log_start) + if b"Failed to bind socket" in log.read(): + raise _PortTaken() + raise RuntimeError( + "emulator exited rc=%s before answering; see %s" + % (self.proc.returncode, os.path.join(self.workdir, "emu.log"))) + if self._ping(): + return + raise RuntimeError("emulator did not answer PINGPING on port %d" % self.port) + except BaseException: + self.halt() + raise + + def halt(self): + """Power cycle, not a graceful shutdown -- flash keeps whatever + storage_commit() already wrote, which is what a real yank does.""" + if self.proc is None: + return + if self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=10) + except Exception: + self.proc.kill() + self.proc.wait() + self.proc = None + if self._endpoint_lease is not None: + self._endpoint_lease.__exit__(None, None, None) + self._endpoint_lease = None + time.sleep(0.2) + + # -- client ------------------------------------------------------------- + + def client(self, method, pin=None): + """Debuglink client bound to THIS emulator. + + Deliberately does not go through tests/config.py: that module picks + HID/WebUSB when a real KeepKey is plugged in, which would send these + wipes at somebody's hardware wallet. + """ + from keepkeylib.client import KeepKeyDebuglinkClient + from keepkeylib.transport_udp import UDPTransport + + c = KeepKeyDebuglinkClient(UDPTransport("127.0.0.1:%d" % self.port)) + c.set_debuglink(UDPTransport("127.0.0.1:%d" % (self.port + 1))) + c.setup_debuglink(button=True, pin_correct=True) + _screenshots_to(c, method) + if pin: + _teach_pin(c, pin) + return c + + # -- flash image -------------------------------------------------------- + + def image(self): + with open(self.img, "rb") as f: + return f.read() + + def active_sector(self): + """Mirror find_active_storage(): newest verified record, else legacy.""" + img = self.image() + found = None + found_verified = False + newest_generation = 0 + for off in SECTOR_OFFSETS: + record = img[off:off + STORAGE_RECORD_LEN] + if record[:4] != STORAGE_MAGIC: + continue + trailer = record[STORAGE_RECORD_DATA_LEN:STORAGE_RECORD_CRC_OFFSET] + legacy = trailer == b"\xff" * 4 + verified = ( + trailer == STORAGE_RECORD_TRAILER_MAGIC + and struct.unpack( + "/), + and must be set before the first ButtonRequest: the wipe and load confirms + are captured by the client's own callback, and without this they land in + the SCREENSHOT_DIR root where _build_frame_census() cannot see them. + + Set here rather than by conftest.py because these tests do not inherit + common.KeepKeyTest -- its setUp() builds a client from config.py and wipes + whatever that resolves to -- so the conftest hook never fires for them. + """ + if os.environ.get("KEEPKEY_SCREENSHOT") != "1": + return + d = os.path.join(os.environ.get("SCREENSHOT_DIR", "screenshots"), + "storage_version_gate", method) + if not os.path.isdir(d): + os.makedirs(d) + client.screenshot_dir = d + client.screenshot_id = len(glob.glob(os.path.join(d, "btn*.png"))) + + +def _capture(client): + """Grab the OLED as it stands. The confirm screens capture themselves on + ButtonRequest; the home screen after a boot has no button behind it, so it + has to be asked for.""" + if os.environ.get("KEEPKEY_SCREENSHOT") != "1": + return + client._capture_oled() + + +def _get_displayed_address(client): + """Display, capture, approve, and return the canonical Bitcoin address. + + These restart tests own a standalone client and need the framebuffer while + the address confirmation is still active. Driving the short exchange + explicitly makes that evidence independent of the generic callback loop. + """ + from keepkeylib import messages_pb2 as proto + from keepkeylib import types_pb2 as types + + resp = client.call_raw(proto.GetAddress( + address_n=BIP44_ADDRESS_N, coin_name="Bitcoin", show_display=True, + script_type=types.SPENDADDRESS)) + if isinstance(resp, proto.PinMatrixRequest): + resp = client.call_raw(client.callback_PinMatrixRequest(resp)) + if not isinstance(resp, proto.ButtonRequest): + raise AssertionError( + "displayed address did not request confirmation: %r" % resp) + + client.capture_oled() + client.debug.press_yes() + resp = client.call_raw(proto.ButtonAck()) + if not isinstance(resp, proto.Address): + raise AssertionError( + "displayed address did not return Address: %r" % resp) + return resp.address + + +# --------------------------------------------------------------------------- +# The gate's own invariants, read out of the firmware sources +# --------------------------------------------------------------------------- + +class TestStorageVersionGateSource(unittest.TestCase): + """No device needed. These are the checks that survive a CI runner which + cannot restart an emulator, so the section is never entirely unmeasured. + + Every number these tests compare against is read out of the tree they are + run in, so one copy of this file states the same invariants on the 7.15 + line (STORAGE_VERSION 17, nothing burned) and on 7.16 (20, with 18 and 19 + burned). See the note at the top of the module for why that is a + strengthening rather than a relaxation. + """ + + def setUp(self): + self.h = _read_source("include/keepkey/firmware/storage.h") + self.c = _read_source("lib/firmware/storage.c") + self.inc = _read_source("lib/firmware/storage_versions.inc") + self.version = _define(self.h, "STORAGE_VERSION") + + # The gate is a FEATURE of the firmware, and this suite runs against + # whatever tree it is checked out beside -- including release branches + # that predate the gate entirely. Gate on the capability, not on a + # version string. + # + # The distinction that matters: a tree that never had the gate has + # nothing here to assert, and failing it would only teach people to + # ignore this file. A tree that USES the constant but no longer defines + # it is the regression this suite exists to catch, and it still fails -- + # so the skip cannot swallow the deletion it is meant to detect. + self.last_shipped = _define_opt(self.h, "STORAGE_VERSION_LAST_SHIPPED") + if self.last_shipped is None: + if "STORAGE_VERSION_LAST_SHIPPED" in self.c: + self.fail( + "lib/firmware/storage.c references " + "STORAGE_VERSION_LAST_SHIPPED but storage.h no longer " + "defines it. The floor was deleted out from under the " + "static assert that enforces it.") + raise unittest.SkipTest( + "this firmware tree predates the storage version gate: " + "storage.h defines no STORAGE_VERSION_LAST_SHIPPED, so there " + "is no shipped floor to check against") + + self.ladder = _ladder(self.inc) + self.burned = _burned_declared(self.inc) + self.arms = _from_flash_arms(self.c) + + + # -- helpers ------------------------------------------------------------ + + def _arm(self, version): + arm = self.arms.get(str(version)) + self.assertIsNotNone( + arm, + "storage_fromFlash has no `case StorageVersion_%d:` -- see " + "test_every_ladder_version_is_dispatched" % version) + return arm + + # -- the ladder --------------------------------------------------------- + + def test_version_ladder_is_contiguous_and_ends_at_storage_version(self): + """storage_versions.inc may only ever be APPENDED to. + + The enum is emitted in .inc order after StorageVersion_NONE = 0, so a + contiguous 1..N list is what makes StorageVersion_N == N. Deleting or + renumbering an entry silently drops a version from version_from_int() + and wipes every device carrying it. + + Ending AT StorageVersion is the half that makes a bare header bump + loud: raise STORAGE_VERSION without appending here and the two numbers + disagree. + """ + self.assertTrue(self.ladder, "no version entries parsed from the ladder") + self.assertEqual(list(range(1, len(self.ladder) + 1)), self.ladder, + "storage_versions.inc is not contiguous from 1") + self.assertEqual( + self.version, _ladder_last(self.inc), + "STORAGE_VERSION is %d but the ladder ends at %d. A version this " + "firmware writes and cannot enumerate is not recognised on the next " + "boot -- it wipes itself." % (self.version, _ladder_last(self.inc))) + + def test_version_never_drops_below_a_shipped_release(self): + """Lowering STORAGE_VERSION wipes every device upgrading FROM a shipped + release: its blob's version stops being recognised, so the gate maps it + to StorageVersion_NONE and storage_init() resets. The version must also + stay under the bitcoin-only band, or a multi-chain wallet would be + stamped into the band that multi-chain firmware refuses to load.""" + self.assertGreaterEqual(self.version, self.last_shipped) + band = _define(self.h, "STORAGE_VERSION_BTC_ONLY_BASE") + self.assertEqual( + STORAGE_VERSION_BTC_ONLY_BASE, band, + "the header moved the bitcoin-only band to %d; the emulator tests " + "in this file stamp wallets into %d by hand and would be measuring " + "a band the firmware no longer uses" + % (band, STORAGE_VERSION_BTC_ONLY_BASE)) + self.assertLess(self.version, band) + + def test_last_shipped_never_moves_backwards(self): + """STORAGE_VERSION_LAST_SHIPPED is a high-water mark of the FIELD. + + It records the newest format any signed release ever wrote, so it can + only rise, and only in the commit that ships. The compile-time assert + in storage.c is STORAGE_VERSION >= STORAGE_VERSION_LAST_SHIPPED, and + both operands live in the same header -- so the way to make a LOWERED + storage version build is to lower this to match, which is exactly the + edit that turns every upgrade in the field into a silent wipe. + docs/StorageVersionGate.md calls that the highest-severity review item + in the file. + + A floor asserted from outside the header is the independent witness. + It is not a version pin: it stays true when 7.16 raises the constant to + 20, and it is only ever raised, never corrected. + """ + self.assertGreaterEqual( + self.last_shipped, STORAGE_VERSION_LAST_SHIPPED_FLOOR, + "STORAGE_VERSION_LAST_SHIPPED is %d, below the %d that 7.15 shipped. " + "Either a signed release is being un-remembered to make a lowered " + "STORAGE_VERSION compile, or the ratchet in this file is wrong -- " + "and only one of those two has ever happened." + % (self.last_shipped, STORAGE_VERSION_LAST_SHIPPED_FLOOR)) + + # -- the dispatch ------------------------------------------------------- + + def test_every_ladder_version_is_dispatched(self): + """Every version in the ladder needs a case in storage_fromFlash(). + + This is the failure the static asserts do NOT cover. They pin the enum + to its own numbering; they say nothing about the switch. + + The switch has no default case, deliberately, so that -Werror=switch + names any version we forget -- which means on ARM this is also a build + failure. It is asserted anyway because the emulator and the unit tests + are built by other toolchains and other flag sets, and because the + message here says which device gets wiped, where the compiler says + which enumerator is unhandled. + """ + missing = [v for v in self.ladder if str(v) not in self.arms] + self.assertEqual( + [], missing, + "storage_fromFlash has no case for version(s) %s -- a device " + "carrying one is wiped at boot" % missing) + + def test_an_unrecognised_version_reaches_the_wipe_path(self): + """version_from_int() maps anything off the ladder to + StorageVersion_NONE, and that arm must return SUS_Invalid. + + This is the mechanism the downgrade half of the policy rests on: a + device that has run newer firmware carries a stamp older firmware + cannot read, and it must reset rather than load a blob it will + misparse. The emulator test test_unrecognised_version_wipes_on_boot + proves the tree's declared wipe-or-refuse policy end to end; this + proves the legacy invalid arm still exists on a runner with no emulator. + """ + arm = self.arms.get("NONE") + self.assertIsNotNone(arm, "storage_fromFlash has no StorageVersion_NONE case") + self.assertTrue( + _wipes(arm), + "StorageVersion_NONE no longer returns SUS_Invalid. An unknown " + "storage version would be accepted, and an attacker could roll back " + "to an older signed image with a known extraction bug and keep the " + "seed. Arm was:\n%s" % arm) + self.assertFalse( + _reads(arm), + "a reader behind StorageVersion_NONE parses a blob whose format is " + "by definition unknown. Arm was:\n%s" % arm) + + def test_every_dispatched_version_either_reads_or_refuses(self): + """An arm reads a blob or it refuses one. Never both, never neither. + + Neither means control reached a case that falls out of the switch -- + storage_fromFlash ends in `return SUS_Invalid`, so the device wipes, + and nothing in the source says that was meant. + + Both means the classification below cannot say what the arm is for, and + an arm that reads before refusing has already parsed the blob. If a + real reader ever needs an error return, this assertion is where that + design gets argued rather than assumed -- which is the point of the + gate. + """ + for version in self.ladder: + arm = self._arm(version) + reads, wipes = _reads(arm), _wipes(arm) + self.assertNotEqual( + reads, wipes, + "version %d %s. Arm was:\n%s" + % (version, + "both reads a blob and returns SUS_Invalid" if reads else + "neither reads a blob nor returns SUS_Invalid, so it falls " + "out of the switch and wipes without saying so", + arm)) + + def test_every_shipped_version_has_a_reader(self): + """THE upgrade-never-wipes property, for every device in the field. + + An upgrading device arrives carrying the format written by the release + it is leaving. STORAGE_VERSION_LAST_SHIPPED is the newest of those, so + 1..LAST_SHIPPED is the set of formats that exist on real hardware, and + every one of them must be read rather than refused. Lose a reader here + and every wallet carrying that version is erased at boot with no + prompt, while the build stays green. + + This is the test that carries the section on a release line with + nothing burned, and it is the reason a burned version can never be one + that shipped -- see test_no_shipped_version_is_burned. + """ + for version in range(1, self.last_shipped + 1): + arm = self._arm(version) + self.assertTrue( + _reads(arm), + "version %d has SHIPPED (STORAGE_VERSION_LAST_SHIPPED is %d) " + "but storage_fromFlash does not read it. Every device carrying " + "it is wiped on upgrade. Arm was:\n%s" + % (version, self.last_shipped, arm)) + self.assertFalse( + _wipes(arm), + "version %d has SHIPPED but its arm returns SUS_Invalid, which " + "is storage_reset() + storage_commit() at boot. Arm was:\n%s" + % (version, arm)) + + def test_the_version_this_firmware_writes_can_be_read_back(self): + """A device commits STORAGE_VERSION and reboots into the same firmware. + + If the arm for the version it just wrote does not read, storage_init() + resets on the very next boot -- the wallet does not survive a power + cycle of the build that created it. The emulator test + test_reboot_preserves_the_wallet proves this on a running device; here + it also makes a header bump carry a reader with it, because there is no + version so new that the firmware writing it may refuse to read it. + """ + arm = self._arm(self.version) + self.assertTrue( + _reads(arm), + "STORAGE_VERSION is %d and storage_fromFlash does not read version " + "%d. This firmware cannot load the blob it writes. Arm was:\n%s" + % (self.version, self.version, arm)) + self.assertNotIn( + self.version, self.burned, + "storage_versions.inc declares version %d BURNED and storage.h " + "writes it. A burned version is one no reader may exist for." + % self.version) + + # -- burned versions ---------------------------------------------------- + + def test_burned_versions_agree_between_the_ladder_and_the_dispatch(self): + """Two files, one answer. + + storage_versions.inc DECLARES which versions are burned; storage.c + DEMONSTRATES it by dispatching them to SUS_Invalid with no reader. + Neither file can be the only witness: + + * derived from storage.c alone, deleting the reader for a shipped + version would silently reclassify it as burned and the suite would + approve of it; + * declared in the .inc alone, a reader wired in behind a burned label + would parse a blob written by a build whose layout was abandoned, + and the declaration would sit there saying otherwise. + + Requiring the two to match catches both, and matching costs an edit in + two files -- which is what "a deliberate act" means here. On a line + with no burned versions both sides are empty and this test says so. + """ + dispatched = set( + v for v in self.ladder + if not _reads(self._arm(v)) and _wipes(self._arm(v))) + self.assertEqual( + sorted(self.burned), sorted(dispatched), + "storage_versions.inc declares %s BURNED; storage_fromFlash sends " + "%s to the wipe path. Whichever is right, the other is a lie about " + "what happens to a device carrying one of these blobs." + % (sorted(self.burned) or "nothing", sorted(dispatched) or "nothing")) + + def test_burned_versions_are_dispatched_to_the_wipe_path(self): + """A burned version must be listed, must refuse, and must have no reader. + + Burned means: a pre-release build wrote this format, devices carrying + it exist, and the number was then reused for something else -- so the + blob's bytes mean one thing and the stamp claims another. Refusing it + wipes, which is the documented behaviour for a format we do not + recognise and strictly better than misparsing one. + + LISTED, not defaulted. storage_fromFlash has no default case on + purpose, so an unlisted version fails the -Werror=switch build rather + than falling anywhere. + """ + if not self.burned: + self.skipTest( + "no version is declared BURNED in storage_versions.inc on this " + "line -- STORAGE_VERSION is %d and the whole ladder has " + "readers. Nothing to measure here; the upgrade path is carried " + "by test_every_shipped_version_has_a_reader." % self.version) + for version in sorted(self.burned): + self.assertIn( + version, self.ladder, + "version %d is declared BURNED but is not in the ladder. The " + "entry has to stay: the enum is positional, so removing one " + "renumbers every version after it." % version) + arm = self._arm(version) + self.assertTrue( + _wipes(arm), + "burned version %d does not return SUS_Invalid. Arm was:\n%s" + % (version, arm)) + self.assertFalse( + _reads(arm), + "a reader behind burned version %d would parse a blob written " + "by a build whose layout has nothing to do with the current " + "format, and would do it silently. Arm was:\n%s" % (version, arm)) + + def test_no_shipped_version_is_burned(self): + """Burning a version that SHIPPED wipes every device carrying it. + + This is what keeps the burned set from being a loophole. Burnedness is + declared, and a declaration can be written for any number -- so the one + thing it may never cover is a format that reached real hardware. + STORAGE_VERSION_LAST_SHIPPED is where the firmware records how far that + reaches, and STORAGE_VERSION_LAST_SHIPPED_FLOOR keeps that record from + being quietly walked back. + + A version may only be burned if it lives strictly above the last + shipped release: written by an alpha, never by anything signed. + """ + shipped_and_burned = sorted( + v for v in self.burned if v <= self.last_shipped) + self.assertEqual( + [], shipped_and_burned, + "version(s) %s are declared BURNED but are at or below " + "STORAGE_VERSION_LAST_SHIPPED (%d), so signed firmware wrote them " + "and devices in the field carry them. Burning one erases those " + "wallets at boot." + % (shipped_and_burned, self.last_shipped)) + + +# --------------------------------------------------------------------------- +# Behaviour across a real power cycle +# --------------------------------------------------------------------------- + +@unittest.skipIf(_EMULATOR_BIN is None, _NO_EMULATOR) +class TestStorageUpgradePreservation(unittest.TestCase): + + def setUp(self): + self.method = self.id().split(".")[-1] + self.workdir = tempfile.mkdtemp(prefix="kk-storage-gate-") + self.addCleanup(shutil.rmtree, self.workdir, True) + self.emu = Emulator(self.workdir) + self.addCleanup(self.emu.halt) + + # -- shared arrangement ------------------------------------------------- + + def _create_wallet(self): + """Boot a virgin device, load a known seed behind a PIN, record the + address, and power it off. Returns the address.""" + self.emu.boot() + c = self.emu.client(self.method) + try: + c.wipe_device() + c.load_device_by_mnemonic( + mnemonic=MNEMONIC_ALL, pin=PIN, passphrase_protection=False, + label=LABEL, language="english") + c.init_device() + self.assertTrue(c.features.initialized) + from keepkeylib import messages_pb2 as proto + self.bitcoin_only = c.call(proto.GetCoinTable()).num_coins == 2 + addr = c.get_address("Bitcoin", BIP44_ADDRESS_N) + finally: + c.close() + self.emu.halt() + + off = self.emu.active_sector() + self.assertIsNotNone( + off, "no storage sector carries the %r magic after a wallet was " + "created -- nothing was persisted" % STORAGE_MAGIC) + return addr, off + + def _make_v16_blob(self, off): + """Rewrite the committed V17 record as the V16 record a 7.14.x device + would be carrying when it arrives for this upgrade. + + Only the four things that actually differ between the two formats, + per storage_readStorageV17() vs storage_readStorageV16(): + + * the version stamp; + * flags bits 18/19 (authdata_initialized / authdata_encrypted) -- + V16 has no authenticator section, so both are clear; + * authdata_fingerprint at +469, reserved bytes in V16; + * encrypted_sec is 512 bytes in V16, 1024 in V17. The upper half is + the authenticator block, which a V16 device never wrote. + + Bit 14 (has_sec_fingerprint) is cleared too, and that is not cosmetic: + the fingerprint is taken over 1024 bytes when encrypted_sec_version > + 16 and over 512 when it is not, so a V17 fingerprint can never match a + V16 read. A real V16 blob carries a V16 fingerprint; we cannot forge + one without the storage key, so we present a device that never had + one -- storage_secMigrate() then recomputes and stores it, which is the + same path a genuinely older wallet takes. + """ + flags = self.emu.read_u32(off, OFF_FLAGS) + self.emu.write_u32(off, OFF_FLAGS, flags & ~( + FLAG_HAS_SEC_FINGERPRINT | FLAG_AUTHDATA_INITIALIZED + | FLAG_AUTHDATA_ENCRYPTED)) + self.emu.patch(off, OFF_AUTHDATA_FINGERPRINT, b"\x00" * 32) + self.emu.patch(off, OFF_ENCSEC + V16_ENCSEC_SIZE, + b"\x00" * (V17_ENCSEC_SIZE - V16_ENCSEC_SIZE)) + self.emu.write_u32(off, OFF_ENCSEC_VERSION, 16) + self.emu.write_u32(off, OFF_VERSION, 16) + # A shipped V16 record predates generation/CRC framing. Present it as + # legacy exactly as find_active_storage() expects; leaving the V20 + # trailer behind with edited payload bytes would correctly classify + # this synthetic record as corrupt and wipe it, testing CRC rejection + # instead of migration. + self.emu.patch( + off, STORAGE_GENERATION_OFFSET, b"\x00" * STORAGE_GENERATION_LEN) + self.emu.patch( + off, STORAGE_RECORD_DATA_LEN, + b"\xff" * (STORAGE_RECORD_LEN - STORAGE_RECORD_DATA_LEN)) + + # -- tests -------------------------------------------------------------- + + def test_reboot_preserves_the_wallet(self): + """The boundary docs/StorageVersionGate.md says the ordinary tests never + cross. Everything else in this suite lives inside one session, where the + wallet is a RAM shadow; only a power cycle re-runs storage_init() and + proves the bytes in flash were both written and readable. + + The PIN is load-bearing. The seed lives in encrypted_sec, and the key + that decrypts it is only ever stored wrapped by the PIN. An address + that still derives after the reboot proves the wrapped key, its + fingerprint and the ciphertext all round-tripped together. + """ + addr, off = self._create_wallet() + # The stamp in flash must be the version the header declares. This is + # not a tautology and it is not a version pin either: the emulator was + # built from _ROOT, so the two sides are the WRITER and the DECLARATION, + # and a writer that stamps anything else produces blobs the next boot + # does not recognise. Reading 17 or 20 out of this file instead would + # only record which branch the author was standing on. + declared = _define(_read_source("include/keepkey/firmware/storage.h"), + "STORAGE_VERSION") + if self.bitcoin_only: + declared += STORAGE_VERSION_BTC_ONLY_BASE + self.assertEqual( + declared, self.emu.read_u32(off, OFF_VERSION), + "the firmware committed a storage version other than the %d its " + "header declares" % declared) + + before = self.emu.image() + self.emu.boot() + c = self.emu.client(self.method, pin=PIN) + try: + c.init_device() + # Steady state: storage_fromFlash() returns SUS_Valid for a record + # already at STORAGE_VERSION, so storage_init() commits nothing. + # This is also the control for the migration test below, where the + # same comparison is what proves the V16 branch ran. + self.assertEqual(before, self.emu.image(), + "booting an already-current record rewrote flash") + _capture(c) + self.assertTrue(c.features.initialized, "the wallet did not survive") + self.assertEqual(LABEL, c.features.label) + self.assertTrue(c.features.pin_protection) + # show_display so the recovered address is ON SCREEN, not just on + # the wire: the OLED frame is the report's evidence that the same + # wallet came back. + self.assertEqual(addr, _get_displayed_address(c)) + finally: + c.close() + + def test_v16_blob_upgrades_without_wiping(self): + """A V16 wallet, booted by V17 firmware, keeps its seed. + + This is the whole policy in one test: the device arrives carrying the + format the release it is leaving wrote, and the incoming firmware must + read it rather than reset it. storage_fromFlash() takes + case StorageVersion_16, reads through storage_readV16(), restamps the + record V17 and reports SUS_Updated, which storage_init() answers with a + commit -- a migration, not a wipe. + + The same address, behind the same PIN, is the assertion. It can only + derive if the wrapped storage key unwrapped, the 512-byte V16 + ciphertext decrypted, and the seed came back byte-identical. + """ + self._check_v16_upgrade() + + def test_unframed_v16_blob_upgrades_without_wiping(self): + """Legacy V16 without the optional CRC envelope preserves its wallet.""" + self._check_v16_upgrade(unframed=True) + + def _check_v16_upgrade(self, unframed=False): + addr, off = self._create_wallet() + self._make_v16_blob(off) + if unframed: + self.emu.patch(off, 41, b"\x00" * 3) + self.emu.patch(off, 2572, b"\xff" * 8) + self.assertEqual(16, self.emu.read_u32(off, OFF_VERSION)) + + before = self.emu.image() + self.emu.boot() + c = self.emu.client(self.method, pin=PIN) + try: + c.init_device() + # A surviving wallet alone would not prove the V16 branch ran -- + # a V17 record decodes to the same wallet. The migration is what + # is under test, so assert the side effect only it has: SUS_Updated + # makes storage_init() commit at boot, where SUS_Valid writes + # nothing (asserted as the control in the reboot test above). + self.assertNotEqual( + before, self.emu.image(), + "nothing was written to flash at boot, so storage_fromFlash " + "did not report SUS_Updated and case StorageVersion_16 never " + "ran -- this test is not exercising the migration") + _capture(c) + self.assertTrue( + c.features.initialized, + "V17 firmware WIPED a V16 wallet at boot -- every device " + "upgrading from 7.14.x loses its seed") + self.assertEqual(LABEL, c.features.label) + self.assertEqual( + addr, _get_displayed_address(c), + "the V16 wallet survived the boot but derives a DIFFERENT " + "address -- the migration corrupted the seed, which is worse " + "than a wipe because nothing announces it") + finally: + c.close() + + def test_unrecognised_version_wipes_on_boot(self): + """A newer full-product version follows this firmware's declared policy. + + The historical test id is retained because release reports from several + firmware lines key on it. Older firmware deliberately wiped an unknown + normal-band version. Firmware with the SUS_TooNew capability instead + refuses it without modifying flash, so reinstalling the newer firmware + can recover the wallet. Canonical python-keepkey must verify whichever + policy the surrounding firmware tree actually implements. + + A device that has run newer firmware carries a newer stamp. Older + firmware cannot read it. Trees without SUS_TooNew reset; trees with + SUS_TooNew lock the RAM shadow and preserve the record byte-for-byte. + + One past the version this build just committed is the tightest + possible case, and it is measured from the device rather than read out + of the header: it is exactly what the next format bump will look like + to this firmware. + """ + addr, off = self._create_wallet() + unknown = self.emu.read_u32(off, OFF_VERSION) + 1 + self.emu.write_u32(off, OFF_VERSION, unknown) + before = self.emu.image() + + self.emu.boot() + c = self.emu.client(self.method) + try: + c.init_device() + _capture(c) + self.assertFalse( + c.features.initialized, + "a storage record stamped v%d -- which this firmware does not " + "recognise -- was loaded anyway. An older image must never " + "parse a newer wallet format." % unknown) + self.assertFalse(c.features.pin_protection) + self.assertNotEqual(LABEL, c.features.label) + source = _read_source("lib/firmware/storage.c") + preserves_too_new = "SUS_TooNew" in source + # Bitcoin-only versions and firmware that explicitly implements + # SUS_TooNew are refused without erasing the wallet. Older regular + # firmware retains its historical wipe policy. + if self.bitcoin_only or preserves_too_new: + self.assertEqual(before, self.emu.image()) + else: + self.assertNotEqual(before, self.emu.image()) + finally: + c.close() + + def test_bitcoin_only_band_refuses_without_wiping(self): + """A bitcoin-only wallet is refused, and REFUSING IS NOT WIPING. + + Seeds created under bitcoin-only firmware are stamped in a reserved + band (10000 + the normal version). Multi-chain firmware must not load + one -- the seed was never meant to be multi-chain-exposed -- but it + must also leave it alone: SUS_BitcoinOnlyLocked resets only the RAM + shadow, and storage_commit() returns early while btc_only_locked, so + flash is never touched. Reflashing bitcoin-only firmware recovers the + wallet; leaving requires an explicit wipe. + + Three assertions, in order of what they cost you if they fail: the + device is locked, the sector is byte-for-byte what it was, and the + wallet comes back once the stamp is the multi-chain one again. + """ + addr, off = self._create_wallet() + if self.bitcoin_only: + # This product must read its own band; the full-product branch below + # must refuse the same band while preserving its bytes. + declared = _define(_read_source("include/keepkey/firmware/storage.h"), + "STORAGE_VERSION") + self.assertEqual(STORAGE_VERSION_BTC_ONLY_BASE + declared, + self.emu.read_u32(off, OFF_VERSION)) + before = self.emu.image() + self.emu.boot() + c = self.emu.client(self.method, pin=PIN) + try: + c.init_device() + self.assertTrue(c.features.initialized) + self.assertEqual(before, self.emu.image()) + _capture(c) + self.assertEqual( + addr, _get_displayed_address(c), + "the bitcoin-only wallet did not return with the same " + "displayed address after its power cycle") + finally: + c.close() + return + self.assertLess( + self.emu.read_u32(off, OFF_VERSION), STORAGE_VERSION_BTC_ONLY_BASE, + "this emulator already stamps its wallets into the bitcoin-only " + "band, so it is not the multi-chain firmware this test is about") + self.emu.write_u32( + off, OFF_VERSION, + STORAGE_VERSION_BTC_ONLY_BASE + self.emu.read_u32(off, OFF_VERSION)) + before = self.emu.sector(off) + + self.emu.boot() + c = self.emu.client(self.method) + try: + c.init_device() + _capture(c) + self.assertFalse( + c.features.initialized, + "multi-chain firmware loaded a wallet stamped in the " + "bitcoin-only band") + finally: + c.close() + self.emu.halt() + + after = self.emu.sector(off) + self.assertEqual( + before, after, + "the locked boot MODIFIED the bitcoin-only record. The wallet is " + "supposed to stay recoverable by reflashing bitcoin-only firmware") + + self.emu.write_u32(off, OFF_VERSION, + self.emu.read_u32(off, OFF_VERSION) + - STORAGE_VERSION_BTC_ONLY_BASE) + self.emu.boot() + c = self.emu.client(self.method, pin=PIN) + try: + c.init_device() + self.assertTrue(c.features.initialized) + self.assertEqual( + addr, _get_displayed_address(c), + "the refused wallet did not come back intact, so 'refuse " + "rather than wipe' did not actually preserve anything") + finally: + c.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_taproot_screens.py b/tests/test_taproot_screens.py new file mode 100644 index 00000000..f81db8e3 --- /dev/null +++ b/tests/test_taproot_screens.py @@ -0,0 +1,45 @@ +"""Gate-3 OLED capture: long bech32 addresses on the verification screen.""" +import common +import unittest + +from common import KeepKeyTest +from keepkeylib import ckd_public as bip32 +from keepkeylib import types_pb2 as proto_types +from keepkeylib.tools import parse_path + + +class TestTaprootScreens(KeepKeyTest): + + def test_show_taproot_receive_address(self): + self.requires_taproot() + self.setup_mnemonic_abandon() + self.client.clear_session() + addr = self.client.get_address( + "Bitcoin", parse_path("86'/0'/0'/0/0"), True, None, + script_type=proto_types.SPENDTAPROOT) + self.assertEqual( + addr, + 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr') + + def test_show_p2wsh_multisig_address(self): + """Native segwit multisig: 62 chars, same as p2tr. Predates taproot.""" + self.setup_mnemonic_allallall() + self.client.clear_session() + nodes = [self.client.get_public_node(parse_path("999'/1'/%d'" % i)) + for i in range(1, 4)] + multisig = proto_types.MultisigRedeemScriptType( + pubkeys=[proto_types.HDNodePathType( + node=bip32.deserialize(n.xpub), address_n=[2, 0]) for n in nodes], + signatures=[b'', b'', b''], + m=2, + ) + addr = self.client.get_address( + "Testnet", parse_path("999'/1'/1'/2/0"), True, multisig, + script_type=proto_types.SPENDWITNESS) + self.assertEqual( + addr, + 'tb1qr6xa5v60zyt3ry9nmfew2fk5g9y3gerkjeu6xxdz7qga5kknz2ssld9z2z') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_token_table_generators.py b/tests/test_token_table_generators.py new file mode 100644 index 00000000..6a1db2c6 --- /dev/null +++ b/tests/test_token_table_generators.py @@ -0,0 +1,240 @@ +"""The firmware token table is generated BY THIS REPOSITORY. + +lib/firmware/CMakeLists.txt in keepkey-firmware builds `ethereum_tokens.def` +and `uniswap_tokens.def` by running + + python3 deps/python-keepkey/keepkeylib/eth/ethereum_tokens.py .def [--profile P] + python3 deps/python-keepkey/keepkeylib/eth/uniswap_tokens.py .def [--profile P] + +(7.15 passes no profile and gets the `fill` table; 7.16 passes +`--profile priority-only`, D-014.) + +and `kkfirmware` depends on that target. So a change in this repository CAN +break the firmware C++ build: if either generator crashes, emits a malformed +X(...) row, or emits an address that is not 20 bytes, the firmware does not +compile -- and `tokens[]` is what unittests/firmware/coins.cpp reads. + +.circleci/config.yml used to run the firmware's own C++ suite here and gated +this job on it. That gate was removed. This module is the replacement Copilot +asked for on that change: an equivalent generator/firmware contract gate that +lives where the change originates, runs in seconds, and does not fail this +repository for unrelated firmware C++ churn. + +It deliberately asserts the BUILD contract of each profile (the generators +run and emit a well-formed, budget-conforming table), not the individual token +SELECTION, which is policy and moves. +""" + +import ast +import importlib +import os +import re +import subprocess +import sys +import tempfile +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PYKEEPKEY = os.path.dirname(_HERE) +_ETH = os.path.join(_PYKEEPKEY, 'keepkeylib', 'eth') + +# X(chain_id, "<20 escaped bytes>", " SYMBOL", decimals) // comment +# +# END-ANCHORED. Without the trailing anchor a row like +# X(1, "...", " AAA", 18) ;;; garbage +# matches on its valid prefix, so this gate would pass a line the firmware +# preprocessor cannot consume -- which is the one thing it exists to catch. +# Only whitespace and a // comment may follow the closing paren. +_ROW = re.compile( + r'^X\((\d+),\s*"((?:[^"\\]|\\.)*)",\s*"\s*([^"]+)",\s*(\d+)\)' + r'\s*(?://.*)?$') + +GENERATORS = ( + ('ethereum_tokens.py', 'BUDGET_ETHEREUM_LISTS'), + ('uniswap_tokens.py', 'BUDGET_UNISWAP_LIST'), +) + + +def _vetted_source_present(): + """The ethereum-lists submodule must be checked out for the eth generator.""" + return os.path.isdir(os.path.join(_ETH, 'ethereum-lists', 'src', 'tokens')) + + +class TestTokenTableGenerators(unittest.TestCase): + + def test_ethereum_tokens_missing_source_fails_closed(self): + """A non-recursive checkout must stop the firmware build, not emit 0 rows.""" + module = importlib.import_module('keepkeylib.eth.ethereum_tokens') + original_here = module.HERE + with tempfile.TemporaryDirectory() as tmp: + module.HERE = tmp + try: + with self.assertRaisesRegex( + RuntimeError, 'ethereum-lists token source is missing'): + module.ETHTokenTable().build() + finally: + module.HERE = original_here + + def _run(self, script, profile=None): + """Run one generator into a temp file and return its rows.""" + with tempfile.TemporaryDirectory() as tmp: + out = os.path.join(tmp, 'out.def') + extra = ['--profile', profile] if profile else [] + proc = subprocess.run( + [sys.executable, os.path.join(_ETH, script), out] + extra, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True, cwd=_PYKEEPKEY) + self.assertEqual( + proc.returncode, 0, + '%s exited %d -- the firmware build runs this exact command ' + 'and would fail here.\nstdout: %s\nstderr: %s' + % (script, proc.returncode, proc.stdout[-2000:], + proc.stderr[-2000:])) + self.assertTrue(os.path.isfile(out), + '%s produced no output file' % script) + with open(out) as f: + text = f.read() + return text, (proc.stdout or '') + (proc.stderr or '') + + def _rows(self, text, script): + rows = [] + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + match = _ROW.match(line) + self.assertIsNotNone( + match, + '%s emitted a line the firmware preprocessor cannot consume: %r' + % (script, line[:120])) + chain_id, address, symbol, decimals = match.groups() + rows.append((int(chain_id), address, symbol.strip(), int(decimals))) + return rows + + def _check(self, script, budget_name, profile=None): + if script == 'ethereum_tokens.py' and not _vetted_source_present(): + self.skipTest('keepkeylib/eth/ethereum-lists submodule not checked out') + from keepkeylib.eth import token_policy + + text, log = self._run(script, profile) + rows = self._rows(text, script) + self.assertTrue(rows, '%s emitted an empty table' % script) + + budget = getattr(token_policy, budget_name) + self.assertTrue( + len(rows) <= budget, + '%s emitted %d rows, over its %s budget of %d -- this symbol is ' + 'the largest read-only object in the ARM image' + % (script, len(rows), budget_name, budget)) + + # The generator reports 'N of M kept (budget B)'. Checking N against + # min(M, B) catches SHRINKAGE too: a change that silently drops most + # candidates still respects the ceiling, so `<= budget` alone would + # let the device's token table quietly collapse. + kept = re.search( + r'(\d+) of (\d+) kept \(budget (\d+), profile ([\w-]+)\)', log) + self.assertIsNotNone( + kept, '%s no longer reports its keep/candidate counts: %r' + % (script, log[-500:])) + n_kept, n_candidates, reported_budget = ( + int(g) for g in kept.groups()[:3]) + expected_profile = profile or token_policy.DEFAULT_PROFILE + self.assertEqual(kept.group(4), expected_profile, + '%s ran profile %s, not %s' + % (script, kept.group(4), expected_profile)) + self.assertEqual(reported_budget, budget, + '%s reports a budget that is not %s' % (script, budget_name)) + if expected_profile == token_policy.PROFILE_FILL: + # 7.15: fill the budget when the source has the entries. + self.assertEqual( + n_kept, min(n_candidates, budget), + '%s kept %d of %d candidates against a budget of %d -- it must ' + 'fill the budget when the source has the entries' + % (script, n_kept, n_candidates, budget)) + else: + # D-014 (7.16): stablecoins plus what coins[] and the fixtures + # need -- priority symbols only, no fill to the budget (the budget + # is a ceiling). Everything else is named by signed ClearSign + # metadata. + self.assertLessEqual( + n_kept, min(n_candidates, budget), + '%s kept %d of %d candidates, over its budget of %d' + % (script, n_kept, n_candidates, budget)) + self.assertEqual( + len(rows), n_kept, + '%s reported %d kept but emitted %d rows' + % (script, n_kept, len(rows))) + + for chain_id, address, symbol, decimals in rows: + if expected_profile == token_policy.PROFILE_PRIORITY_ONLY: + self.assertIn( + symbol, + token_policy.PRIORITY_SYMBOLS[expected_profile], + '%s: %s is not a D-014 priority symbol (stablecoin or ' + 'required by coins[]/fixtures)' % (script, symbol)) + # The C string is 20 raw bytes; anything else silently shifts the + # packed token struct the firmware reads. + raw = ast.literal_eval('b"%s"' % address) + self.assertEqual( + len(raw), 20, + '%s: %s on chain %d has a %d-byte address, not 20' + % (script, symbol, chain_id, len(raw))) + self.assertTrue( + 0 <= decimals <= 32, + '%s: %s has implausible decimals %d' % (script, symbol, decimals)) + self.assertTrue( + symbol and '"' not in symbol, + '%s: unusable symbol %r' % (script, symbol)) + + def test_ethereum_tokens_generator_builds_a_valid_table(self): + self._check('ethereum_tokens.py', 'BUDGET_ETHEREUM_LISTS') + + def test_ethereum_tokens_generator_priority_only_profile(self): + self._check('ethereum_tokens.py', 'BUDGET_ETHEREUM_LISTS', + 'priority-only') + + def test_ethereum_tokens_closes_the_x_macro(self): + """ethereum_tokens.def is #included after a #define X; leaving the + macro defined leaks it into the next translation unit.""" + if not _vetted_source_present(): + self.skipTest('keepkeylib/eth/ethereum-lists submodule not checked out') + self.assertIn('#undef X', self._run('ethereum_tokens.py')[0]) + + def test_uniswap_tokens_generator_builds_a_valid_table(self): + self._check('uniswap_tokens.py', 'BUDGET_UNISWAP_LIST') + + def test_uniswap_tokens_generator_priority_only_profile(self): + self._check('uniswap_tokens.py', 'BUDGET_UNISWAP_LIST', + 'priority-only') + + def test_priority_symbol_on_several_chains_is_not_ambiguous(self): + from keepkeylib.eth import token_policy + records = [("USDC", "0x" + "a0" * 20, 1), ("USDC", "0x" + "0b" * 20, 10)] + def pick(rows): + return token_policy.select( + rows, 10, symbol_of=lambda r: r[0], address_of=lambda r: r[1], + chain_of=lambda r: r[2], + profile=token_policy.PROFILE_PRIORITY_ONLY) + + chosen, ambiguous = pick(records) + self.assertEqual(sorted(chosen), sorted(records)) + self.assertEqual([], ambiguous) + # Two addresses on the same chain stay ambiguous and are dropped. + clash = [("USDC", "0x" + "a0" * 20, 1), ("USDC", "0x" + "0b" * 20, 1)] + self.assertEqual(([], ["USDC"]), pick(clash)) + + def test_generators_are_deterministic(self): + """The firmware build compares digests to decide whether to rewrite the + .def; a non-deterministic generator would churn the table every build.""" + for script, _ in GENERATORS: + if script == 'ethereum_tokens.py' and not _vetted_source_present(): + continue + for profile in (None, 'priority-only'): + self.assertEqual(self._run(script, profile)[0], + self._run(script, profile)[0], + '%s (%s) is not deterministic' + % (script, profile)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_verify_typed_data.py b/tests/test_verify_typed_data.py index 89521868..ae8333a4 100644 --- a/tests/test_verify_typed_data.py +++ b/tests/test_verify_typed_data.py @@ -31,7 +31,7 @@ class TestMsgE712Verify(common.KeepKeyTest): def test_structured_eip712_is_refused(self): - """7.14.2 disables structured EIP-712 outright. + """Post-RC18 firmware disables legacy structured EIP-712 outright. ethereum_structured_eip712_enabled() returns false (lib/firmware/ethereum.c), so fsm_msgEthereum712TypesValues fails closed @@ -45,7 +45,7 @@ def test_structured_eip712_is_refused(self): replaced by test_verify below, not simply deleted. """ self.requires_fullFeature() - self.requires_firmware("7.14.2") + self.requires_firmware("7.15.0") self.setup_mnemonic_allallall() try: @@ -56,7 +56,7 @@ def test_structured_eip712_is_refused(self): value_prop='{"domain": {}}', typevals=1, ) - self.fail("Expected Failure -- structured EIP-712 is disabled in 7.14.2") + self.fail("Expected Failure -- legacy structured EIP-712 is disabled") except CallException as e: self.assertIn("Structured EIP-712 disabled", str(e)) diff --git a/tests/test_vuln1969.py b/tests/test_vuln1969.py index efe47570..5bdc1048 100644 --- a/tests/test_vuln1969.py +++ b/tests/test_vuln1969.py @@ -26,6 +26,7 @@ class TestVULN1969(common.KeepKeyTest): def test(self): + self.requires_release_capability("prompt-workflow-unwind") self.setup_mnemonic_pin_passphrase() self.client.clear_session() @@ -46,5 +47,11 @@ def test(self): self.assertIsInstance(ret, proto.Failure) self.assertEndsWith(ret.message, "Unknown message") + # Rejecting the interrupt must also unwind the abandoned protected + # Ping. Otherwise the next client remains blocked behind a prompt it + # cannot acknowledge, poisoning every later test in the process. + ret = self.client.call_raw(proto.Initialize()) + self.assertIsInstance(ret, proto.Features) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_zcash_seed_fingerprint_helper.py b/tests/test_zcash_seed_fingerprint_helper.py new file mode 100644 index 00000000..30cc99e2 --- /dev/null +++ b/tests/test_zcash_seed_fingerprint_helper.py @@ -0,0 +1,54 @@ +# Pure-Python tests for the ZIP-32 §6.1 seed fingerprint helper. +# +# This module deliberately does NOT import `common`, `keepkeylib.transport`, +# or any protobuf bindings — those would require a device/emulator to be +# wired up. Tests here run on any plain dev box: +# +# pytest tests/test_zcash_seed_fingerprint_helper.py + +import unittest + +from keepkeylib.zcash import calculate_seed_fingerprint + + +class TestSeedFingerprintHelper(unittest.TestCase): + + def test_reference_vector(self): + """Cross-check against keystone3-firmware + rust/keystore/src/algorithms/zcash/mod.rs::test_keystore_derive_zcash_ufvk: + + seed = 000102...1f (32 bytes) + fp = deff604c246710f7176dead02aa746f2fd8d5389f7072556dcb555fdbe5e3ae3 + """ + seed = bytes(range(32)) + fp = calculate_seed_fingerprint(seed) + self.assertEqual( + fp.hex(), + "deff604c246710f7176dead02aa746f2fd8d5389f7072556dcb555fdbe5e3ae3", + ) + + def test_rejects_trivial_seeds(self): + with self.assertRaises(ValueError): + calculate_seed_fingerprint(b"\x00" * 32) + with self.assertRaises(ValueError): + calculate_seed_fingerprint(b"\xff" * 32) + + def test_rejects_out_of_range(self): + with self.assertRaises(ValueError): + calculate_seed_fingerprint(b"\x01" * 31) # too short + with self.assertRaises(ValueError): + calculate_seed_fingerprint(b"\x01" * 253) # too long + + def test_length_prefix_domain_separation(self): + """Two seeds where one is a prefix of the other must produce + distinct fingerprints (this is what the I2LEBSP_8(len) prefix buys us).""" + seed_short = bytes(range(32)) + seed_long = bytes(range(33)) + self.assertNotEqual( + calculate_seed_fingerprint(seed_short), + calculate_seed_fingerprint(seed_long), + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/txcache/insight_bitcoin_tx_3e1fdf082678a8a2f378995ffc0e4f853942c55c4ddbc2771b348413eeeca9a4.json b/tests/txcache/insight_bitcoin_tx_3e1fdf082678a8a2f378995ffc0e4f853942c55c4ddbc2771b348413eeeca9a4.json new file mode 100644 index 00000000..7d999532 --- /dev/null +++ b/tests/txcache/insight_bitcoin_tx_3e1fdf082678a8a2f378995ffc0e4f853942c55c4ddbc2771b348413eeeca9a4.json @@ -0,0 +1,29 @@ +{ + "txid": "3e1fdf082678a8a2f378995ffc0e4f853942c55c4ddbc2771b348413eeeca9a4", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "0000000000000000000000000000000000000000000000000000000000000000", + "vout": 0, + "sequence": 4294967295, + "scriptSig": {"hex": ""} + } + ], + "vout": [ + { + "value": "0.00100000", + "n": 0, + "scriptPubKey": { + "hex": "5120a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c" + } + }, + { + "value": "0.00050000", + "n": 1, + "scriptPubKey": { + "hex": "76a914d986ed01b7a22225a70edbf2ba7cfb63a15cb3aa88ac" + } + } + ] +} diff --git a/tests/txcache/manifest.json b/tests/txcache/manifest.json index c146c15f..c3447aa9 100644 --- a/tests/txcache/manifest.json +++ b/tests/txcache/manifest.json @@ -25,6 +25,18 @@ "response_sha256": "b5d98b4b6c1f17c017e6aae385f18aef77de8d162714a7e92918899a18c66305", "txid": "39a29e954977662ab3879c66fb251ef753e0912223a83d1dcb009111d28265e5" }, + { + "algorithm": "sha256d", + "canonical_hex": "010000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff02a086010000000000225120a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c50c30000000000001976a914d986ed01b7a22225a70edbf2ba7cfb63a15cb3aa88ac00000000", + "canonical_sha256": "c0a02107b8e7a669e4872f7658e1fb92dbfcf30d14e1bbd1c52293b20332a394", + "network": "insight_bitcoin", + "required_by": [ + "test_msg_signtx_taproot.py" + ], + "response": "insight_bitcoin_tx_3e1fdf082678a8a2f378995ffc0e4f853942c55c4ddbc2771b348413eeeca9a4.json", + "response_sha256": "8d08e188010976b1625e887f1fdfb528edd1e31d8dbf3c58f2061adb42842a58", + "txid": "3e1fdf082678a8a2f378995ffc0e4f853942c55c4ddbc2771b348413eeeca9a4" + }, { "algorithm": "sha256d", "canonical_hex": "01000000017a1ebb08f129fb1cc814b59d63284286d58a7602708ae8be6dd073d8cbc7afbe000000006a47304402204ec6818b86591bbbc2abd5a10d203df49996c4bd5621eb2fa85345bb05458fa602202c9553fb00fc18199af82f4ec8f1055e9aeda6a5bbead1e02303a95a8bc91d31012103f54094da6a0b2e0799286268bb59ca7c83538e81c78e64f6333f40f9e0e222c0ffffffff0222bc0100000000001976a914902c642ba3a22f5c6cfa30a1790c133ddf15cc8888ac50c30000000000001976a914a6450f1945831a81912616691e721b787383f4ed88ac00000000", @@ -119,7 +131,9 @@ "canonical_sha256": "736027dec37c89ef66e5cba5a45f52977b78aff2bb63eb6e5dbd4ea4fc25eec9", "network": "insight_bitcoin", "required_by": [ + "test_msg_bitcoin_only_variant.py", "test_msg_mayachain_signtx.py", + "test_msg_signing_boundaries.py", "test_msg_signtx.py", "test_msg_signtx_raw.py", "test_msg_signtx_xfer.py", diff --git a/tests/unit/test_eos_updateauth_vector.py b/tests/unit/test_eos_updateauth_vector.py new file mode 100644 index 00000000..690a6220 --- /dev/null +++ b/tests/unit/test_eos_updateauth_vector.py @@ -0,0 +1,60 @@ +"""Independent EOS updateauth wire vector; no emulator or protobuf required. + +Public key: test mnemonic from common.py, path m/48'/4'/1'/0'/0'. +The authority contains one account and zero waits. The corrected case hashes +that authority unchanged; the legacy case appends one six-byte zero wait +(wait_sec=0, weight=0) and updates the action-data length. This models the old +firmware using accounts_count instead of waits_count for wait serialization. +""" +import hashlib +import struct +import unittest + +pub = bytes.fromhex("037eca30dbc22ecc6d38d95a7e4b49f6b77fa87be608250319b75e2564ccb60143") + +def name(s): + alphabet = '.12345abcdefghijklmnopqrstuvwxyz' + value = 0 + for x in range(13): + width = 5 if x < 12 else 4 + symbol = alphabet.index(s[x]) if x < len(s) else 0 + value = (value << width) | symbol + return struct.pack('= 128: + b.append((x & 127) | 128) + x >>= 7 + b.append(x) + return bytes(b) + + +auth = ( + struct.pack('