From 5a40246dee080ff61c35ab09eb26409871d3135f Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 02:47:23 -0300 Subject: [PATCH 01/40] build(7.15): release infrastructure, CI gates and dependency pins Sets the 7.15.0 project version and the build-time plumbing the rest of the release sits on: - KK_BITCOIN_ONLY option plus the derived KK_ZCASH_PRIVACY selection, so the product is two images (regular, bitcoin-only) rather than three. - Device builds route snprintf/vsnprintf to newlib's integer-only engine and emit per-function stack-usage data for the SRAM budget gate. - tools/check_sram_budget.py + tools/sram-budgets.json enforce the stack frame margin that the RC7 boot fault exposed. - CI: variant matrix across ARM/emulator builds and unit tests, a crypto job running the pinned fork's suites under Valgrind and ASan/UBSan, release invariant checks, action SHA pinning and digest-pinned builder images. - Release workflow supports RC tags and selective submodule checkout. - Dependency pins move to the reviewed heads: trezor-firmware fork, and the device-protocol / python-keepkey commits carrying the 7.15 messages. - Include paths follow the crypto fork's layout (trezor-firmware/crypto). Feature code and its per-feature CI gates land in the branches stacked on top of this one; nothing here compiles new firmware sources. --- .github/workflows/ci.yml | 280 ++++++++++++++---- .github/workflows/release.yml | 186 +++++++++--- .gitleaks.toml | 36 +++ .gitmodules | 10 +- CMakeLists.txt | 62 +++- Makefile | 42 +++ cmake/caches/device.cmake | 9 +- cmake/toolchains/mingw-w64-x86_64.cmake | 40 +++ deps/crypto/CMakeLists.txt | 1 - deps/crypto/trezor-firmware | 2 +- deps/device-protocol | 2 +- deps/python-keepkey | 2 +- docs/README.md | 1 + fuzzer/firmware/CMakeLists.txt | 2 +- .../transport/messages-ethereum.options | 3 + .../keepkey/transport/messages-ripple.options | 2 + .../keepkey/transport/messages-solana.options | 5 + .../transport/messages-thorchain.options | 1 + include/keepkey/transport/messages.options | 15 +- lib/board/CMakeLists.txt | 2 +- lib/emulator/CMakeLists.txt | 9 +- lib/firmware/CMakeLists.txt | 2 +- lib/firmware/fsm_msg_solana.h | 3 +- lib/firmware/solana.c | 5 +- lib/firmware/ton.c | 4 +- scripts/build/docker/device/debug.sh | 2 +- scripts/build/docker/device/release.sh | 9 +- scripts/build/docker/emulator/debug.sh | 2 +- scripts/emulator/Dockerfile | 3 +- scripts/emulator/python-keepkey-tests.sh | 45 ++- scripts/emulator/python-keepkey.Dockerfile | 2 +- scripts/generate-test-report.py | 30 +- tools/blupdater/CMakeLists.txt | 2 +- tools/bootloader/CMakeLists.txt | 2 +- tools/bootstrap/CMakeLists.txt | 2 +- tools/check_sram_budget.py | 124 ++++++++ tools/display_test/CMakeLists.txt | 2 +- tools/emulator/CMakeLists.txt | 46 ++- tools/firmware/CMakeLists.txt | 6 +- tools/sram-budgets.json | 9 + unittests/board/CMakeLists.txt | 4 +- unittests/crypto/CMakeLists.txt | 2 +- unittests/firmware/CMakeLists.txt | 2 +- 43 files changed, 856 insertions(+), 164 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 Makefile create mode 100644 cmake/toolchains/mingw-w64-x86_64.cmake create mode 100644 tools/check_sram_budget.py create mode 100644 tools/sram-budgets.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 784473e79..92d8aa7a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,27 +5,29 @@ # Stage 1: GATE (seconds, no Docker) # ├─ lint-format clang-format diff check # ├─ static-analysis cppcheck static analysis +# ├─ crypto-tests pinned fork crypto vectors + ASan/UBSan # ├─ secret-scan gitleaks credential detection # └─ check-submodules verify all deps present # # Stage 2: BUILD (parallel, gated by Stage 1) -# ├─ build-emulator Docker image → artifact -# └─ build-arm-firmware cross-compile → .bin/.elf (downloadable) +# ├─ build-emulator Docker image → artifact [matrix: full / bitcoin-only] +# └─ build-arm-firmware cross-compile → .bin/.elf (downloadable) [same matrix] # # Stage 3: TEST (parallel, gated by Stage 2) -# ├─ unit-tests GoogleTest (make xunit) -# └─ python-integration full test suite +# ├─ unit-tests GoogleTest (make xunit) [same matrix — proves each +# │ variant's coin/token gating actually compiles+passes] +# └─ python-integration full test suite (full/default variant only) # # Stage 4: PUBLISH (manual trigger, all tests must pass) -# └─ publish-emulator DockerHub push (workflow_dispatch only) +# └─ publish-emulator DockerHub push, full/default variant only (workflow_dispatch only) name: CI on: push: - branches: [master, develop, 'feature/**', 'fix/**', 'release/**', 'hotfix/**'] + branches: [master, develop, alpha, 'feature/**', 'fix/**', 'release/**', 'hotfix/**'] pull_request: - branches: [master, develop] + branches: [master, develop, alpha] workflow_dispatch: inputs: publish_emulator: @@ -35,7 +37,7 @@ on: default: false env: - BASE_IMAGE: kktech/firmware:v15 + BASE_IMAGE: kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 EMU_IMAGE: kkemu-ci jobs: @@ -48,7 +50,7 @@ jobs: timeout-minutes: 3 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -81,17 +83,21 @@ jobs: timeout-minutes: 2 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 - name: Install gitleaks run: | - GITLEAKS_VERSION=$(curl -sSf https://api.github.com/repos/gitleaks/gitleaks/releases/latest \ - | grep -oP '"tag_name":\s*"v\K[^"]+') - curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ - | tar -xz -C /usr/local/bin gitleaks + GITLEAKS_VERSION=8.30.1 + GITLEAKS_SHA256=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + GITLEAKS_ARCHIVE="${RUNNER_TEMP}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -sSfL --retry 3 --retry-all-errors \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + -o "${GITLEAKS_ARCHIVE}" + echo "${GITLEAKS_SHA256} ${GITLEAKS_ARCHIVE}" | sha256sum --check --strict + tar -xzf "${GITLEAKS_ARCHIVE}" -C /usr/local/bin gitleaks gitleaks version - name: Run gitleaks @@ -99,14 +105,17 @@ jobs: static-analysis: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: false + - name: Init crypto submodule + run: git submodule update --init deps/crypto/trezor-firmware + - name: Install cppcheck run: sudo apt-get update && sudo apt-get install -y cppcheck @@ -169,7 +178,7 @@ jobs: echo "cppcheck: clean — zero findings" - name: Upload cppcheck report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: cppcheck-report @@ -181,10 +190,13 @@ jobs: timeout-minutes: 2 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Init crypto submodule + run: git submodule update --init deps/crypto/trezor-firmware + - name: Verify submodules are declared run: | echo "Checking required submodule declarations..." @@ -207,17 +219,108 @@ jobs: done [ "$FAILED" = "0" ] || exit 1 + - name: Enforce RC18 security invariants + run: | + if git grep -n -E \ + 'uses:[[:space:]]+[^#[:space:]]+@(v[0-9]+|main|master)([[:space:]#]|$)' \ + -- .github/workflows; then + echo "::error::Every GitHub Action must be pinned to a full commit SHA" + exit 1 + fi + if git grep -n -E 'kktech/firmware:v[0-9]+' -- \ + .github/workflows scripts/build/docker scripts/emulator; then + echo "::error::The firmware builder must be pinned by manifest digest" + exit 1 + fi + if git grep -n -E 'return[[:space:]]+random\(\)' -- \ + lib/rand/rng.c; then + echo "::error::Emulator cryptography must not use libc random()" + exit 1 + fi + if git grep -n -E \ + 'storage_(get|upsert)ClearsignIdentity|persistent_identity_for' \ + -- lib include; then + echo "::error::Unauthenticated persistent clearsign trust is retired" + exit 1 + fi + if git grep -n -F 'option(KK_ZCASH_PRIVACY' -- CMakeLists.txt; then + echo "::error::Zcash privacy must not become a third release choice" + exit 1 + fi + grep -q 'set(KK_ZCASH_PRIVACY ON)' CMakeLists.txt + grep -q 'set(KK_ZCASH_PRIVACY OFF)' CMakeLists.txt + + crypto-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Init crypto submodule + run: git submodule update --init deps/crypto/trezor-firmware + + - name: Install crypto test dependencies + run: sudo apt-get update && sudo apt-get install -y check libssl-dev pkg-config valgrind + + - name: Run optimized crypto suite + working-directory: deps/crypto/trezor-firmware/crypto + run: | + make clean + make VALGRIND=0 tests/test_check tests/test_pallas_ct + ./tests/test_check + ./tests/test_pallas_ct + + - name: Verify Pallas secret flow with Valgrind + working-directory: deps/crypto/trezor-firmware/crypto + run: | + make clean + make VALGRIND=1 OPTFLAGS='-O2 -g' tests/test_pallas_ct + valgrind --quiet --error-exitcode=1 --track-origins=yes \ + ./tests/test_pallas_ct + + - name: Run crypto suite with ASan and UBSan + working-directory: deps/crypto/trezor-firmware/crypto + run: | + make clean + make CC='gcc -fsanitize=address,undefined' VALGRIND=0 \ + OPTFLAGS='-O1 -g -fno-omit-frame-pointer' \ + tests/test_check tests/test_pallas_ct + # Check's default per-test timeout is too short for the two exhaustive + # codepoint tests under sanitizer instrumentation on shared runners. + # Keep the job-level timeout as the hard upper bound. + CK_DEFAULT_TIMEOUT=30 \ + ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 \ + UBSAN_OPTIONS=halt_on_error=1 ./tests/test_check + ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 \ + UBSAN_OPTIONS=halt_on_error=1 ./tests/test_pallas_ct + # ═══════════════════════════════════════════════════════════ # STAGE 2: BUILD — compile only after gate passes # ═══════════════════════════════════════════════════════════ build-emulator: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + name: build-emulator${{ matrix.label }} + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + # Regular/full includes every supported chain, including Zcash + # shielded/Orchard. Bitcoin-only is the sole reduced build. + - variant: full + label: "" + cmake_flags: "" + - variant: bitcoin-only + label: " (bitcoin-only)" + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -231,11 +334,11 @@ jobs: git submodule update --init deps/sca-hardening/SecAESSTM32 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -250,30 +353,42 @@ jobs: if: steps.cache-base.outputs.cache-hit == 'true' run: docker load -i /tmp/base-image.tar - - name: Build emulator image + - name: Build emulator image (${{ matrix.variant }}) run: | docker build \ - -t ${{ env.EMU_IMAGE }} \ + -t ${{ env.EMU_IMAGE }}-${{ matrix.variant }} \ + --build-arg coinsupport="${{ matrix.cmake_flags }}" \ -f scripts/emulator/Dockerfile \ . - name: Save emulator image - run: docker save ${{ env.EMU_IMAGE }} -o /tmp/emu-image.tar + run: docker save ${{ env.EMU_IMAGE }}-${{ matrix.variant }} -o /tmp/emu-image.tar - name: Upload emulator image artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: emu-image + name: emu-image-${{ matrix.variant }} path: /tmp/emu-image.tar retention-days: 1 build-arm-firmware: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + name: build-arm-firmware${{ matrix.label }} + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - variant: full + label: "" + cmake_flags: "" + - variant: bitcoin-only + label: " (bitcoin-only)" + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -288,7 +403,7 @@ jobs: - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -312,7 +427,7 @@ jobs: echo "git_short=${GIT_SHORT}" >> "$GITHUB_OUTPUT" echo "Firmware version: ${FW_VERSION} (${GIT_SHORT})" - - name: Cross-compile firmware for ARM + - name: Cross-compile firmware for ARM (${{ matrix.variant }}) run: | docker run --rm \ -v ${{ github.workspace }}:/root/keepkey-firmware:z \ @@ -320,34 +435,55 @@ jobs: mkdir /root/build && cd /root/build && \ cmake -C /root/keepkey-firmware/cmake/caches/device.cmake /root/keepkey-firmware \ -DCMAKE_BUILD_TYPE=MinSizeRel \ - -DCMAKE_COLOR_MAKEFILE=ON && \ + -DCMAKE_COLOR_MAKEFILE=ON \ + ${{ matrix.cmake_flags }} && \ make && \ mkdir -p /root/keepkey-firmware/bin && \ cp bin/*.bin /root/keepkey-firmware/bin/ && \ cp bin/*.elf /root/keepkey-firmware/bin/ && \ + cp bin/*.map /root/keepkey-firmware/bin/ 2>/dev/null || true && \ + arm-none-eabi-size -A bin/firmware.keepkey.elf > /root/keepkey-firmware/bin/firmware.keepkey.size.txt 2>/dev/null || true && \ + find . -name '*.su' -print0 | tar czf /root/keepkey-firmware/bin/stack-usage.tgz --null -T - && \ chmod -R a+rw /root/keepkey-firmware/bin" + # SRAM budget gate — RC7's privacy-enabled build hard-faulted on boot + # because static SRAM left an 11.2 KB gap while msg_write() carried a + # 12.4 KB stack frame. keepkey.ld now ASSERTs a 16 KiB reserve at link + # time; this step reports the numbers and enforces the frame margin + # (tools/sram-budgets.json). + - name: SRAM budget gate (${{ matrix.variant }}) + run: | + pip install --quiet pyelftools + python3 tools/check_sram_budget.py \ + --elf bin/firmware.keepkey.elf \ + --su-tar bin/stack-usage.tgz \ + --budgets tools/sram-budgets.json \ + --variant "${{ matrix.variant }}" + - name: Rename firmware artifacts run: | cd bin for f in *.bin; do [ -f "$f" ] || continue - mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${f}" + mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${{ matrix.variant }}-${f}" done for f in *.elf; do [ -f "$f" ] || continue - mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${f}" + mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${{ matrix.variant }}-${f}" done ls -lh - echo "::notice::Firmware v${{ steps.version.outputs.fw_version }} built successfully" + echo "::notice::Firmware v${{ steps.version.outputs.fw_version }} (${{ matrix.variant }}) built successfully" - name: Upload firmware artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: firmware-v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }} + name: firmware-v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${{ matrix.variant }} path: | bin/*.bin bin/*.elf + bin/*.map + bin/*.size.txt + bin/stack-usage.tgz retention-days: 90 # ═══════════════════════════════════════════════════════════ @@ -355,47 +491,58 @@ jobs: # ═══════════════════════════════════════════════════════════ unit-tests: + name: unit-tests${{ matrix.label }} needs: build-emulator runs-on: ubuntu-latest timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - variant: full + label: "" + cmake_flags: "" + - variant: bitcoin-only + label: " (bitcoin-only)" + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Download emulator image - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: emu-image + name: emu-image-${{ matrix.variant }} path: /tmp - name: Load emulator image run: docker load -i /tmp/emu-image.tar - - name: Run unit tests + - name: Run unit tests (${{ matrix.variant }}) run: | # make xunit returns non-zero if any test fails — capture # exit code so JUnit XML still gets copied for reporting docker run --rm \ -v ${{ github.workspace }}/test-reports:/kkemu/test-reports \ --entrypoint /bin/sh \ - ${{ env.EMU_IMAGE }} \ + ${{ env.EMU_IMAGE }}-${{ matrix.variant }} \ -c "mkdir -p /kkemu/test-reports/firmware-unit && \ make xunit; RC=\$?; \ cp -r unittests/*.xml /kkemu/test-reports/firmware-unit/ 2>/dev/null; \ exit \$RC" - name: Upload unit test results - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: - name: unit-test-results + name: unit-test-results-${{ matrix.variant }} path: test-reports/firmware-unit/ retention-days: 30 python-integration-tests: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -442,7 +589,7 @@ jobs: [ "$FW_RC" -eq 0 ] && [ "$PY_RC" -eq 0 ] || exit 1 - name: Upload Python test results - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: python-test-results @@ -450,7 +597,7 @@ jobs: retention-days: 30 - name: Upload OLED screenshots - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: oled-screenshots @@ -495,12 +642,12 @@ jobs: # fsm_msgDebugLinkGetState is excluded from the build and any # read_layout() call hangs the test). python-dylib-tests: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: macos-latest timeout-minutes: 25 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -517,7 +664,7 @@ jobs: git submodule update --init deps/googletest - name: Setup Python 3.10 - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.10' @@ -624,9 +771,9 @@ jobs: # PR. Tagged with the short commit SHA so multiple PR pushes # don't overwrite each other when a reviewer downloads them. if: always() && env.DYLIB_PATH != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: libkkemu-${{ github.sha }} + name: libkkemu-${{ github.event.pull_request.head.sha || github.sha }} path: ${{ env.DYLIB_PATH }} retention-days: 30 if-no-files-found: error @@ -657,7 +804,7 @@ jobs: -v --tb=short --junit-xml=../../../test-reports/dylib-junit.xml - name: Upload dylib test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: python-dylib-test-results @@ -676,26 +823,28 @@ jobs: timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Download unit test results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 continue-on-error: true with: - name: unit-test-results + # Report covers the regular/full build. Bitcoin-only is built and + # unit-tested in its own matrix leg but does not get a PDF. + name: unit-test-results-full path: test-reports/firmware-unit/ - name: Download python test results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 continue-on-error: true with: name: python-test-results path: test-reports/python-keepkey/ - name: Download OLED screenshots - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 continue-on-error: true with: name: oled-screenshots @@ -717,7 +866,7 @@ jobs: run: python3 scripts/generate-test-report.py - name: Upload test report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: test-report @@ -737,14 +886,15 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Download emulator image - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: emu-image + # Publish the regular/full image, including Zcash privacy support. + name: emu-image-full path: /tmp - name: Load emulator image @@ -759,11 +909,11 @@ jobs: - name: Tag images for publish run: | - docker tag ${{ env.EMU_IMAGE }} kktech/kkemu:latest - docker tag ${{ env.EMU_IMAGE }} kktech/kkemu:v${{ steps.version.outputs.fw_version }} + docker tag ${{ env.EMU_IMAGE }}-full kktech/kkemu:latest + docker tag ${{ env.EMU_IMAGE }}-full kktech/kkemu:v${{ steps.version.outputs.fw_version }} - name: Login to DockerHub - uses: docker/login-action@v4 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 with: username: ${{ secrets.KK_DOCKERHUB_USER }} password: ${{ secrets.KK_DOCKERHUB_PASS }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee6c178c7..eb08d05f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,14 @@ # KeepKey Firmware Release Pipeline # -# Triggered by version tags (v*) on master. +# Triggered by version tags (v*). # Builds firmware, computes reproducible hashes, # and creates a draft GitHub Release with all artifacts. # -# Git-flow: tag master after merging a release/* or hotfix/* branch. +# Tag a commit only after its CI workflow has completed successfully. # # Usage: -# git tag v7.11.0 -# git push origin v7.11.0 +# git tag -a v7.15.0-rc17 -m "KeepKey firmware 7.15.0 RC17 test candidate" +# git push origin v7.15.0-rc17 name: Release @@ -18,7 +18,7 @@ on: - 'v*' env: - BASE_IMAGE: kktech/firmware:v15 + BASE_IMAGE: kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 permissions: contents: write @@ -29,19 +29,53 @@ jobs: timeout-minutes: 3 outputs: fw_version: ${{ steps.version.outputs.fw_version }} + tag_name: ${{ steps.version.outputs.tag_name }} + is_prerelease: ${{ steps.version.outputs.is_prerelease }} steps: - - uses: actions/checkout@v6 - with: - submodules: recursive + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Extract and verify version id: version run: | TAG_VERSION="${GITHUB_REF_NAME#v}" FW_VERSION=$(sed -n '/^project/,/)/p' CMakeLists.txt | grep -oP '\d+\.\d+\.\d+') - echo "fw_version=${FW_VERSION}" >> "$GITHUB_OUTPUT" + IS_PRERELEASE=false + RC_PREFIX="${FW_VERSION}-rc" + if [ "$TAG_VERSION" != "$FW_VERSION" ]; then - echo "::error::Tag (${TAG_VERSION}) != CMakeLists.txt (${FW_VERSION})" + if [[ "$TAG_VERSION" != "${RC_PREFIX}"* ]]; then + echo "::error::Tag (${TAG_VERSION}) must be ${FW_VERSION} or ${RC_PREFIX}" + exit 1 + fi + + RC_NUMBER="${TAG_VERSION#"${RC_PREFIX}"}" + if ! [[ "$RC_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid release-candidate tag (${TAG_VERSION}); expected ${RC_PREFIX}" + exit 1 + fi + IS_PRERELEASE=true + fi + + { + echo "fw_version=${FW_VERSION}" + echo "tag_name=${GITHUB_REF_NAME}" + echo "is_prerelease=${IS_PRERELEASE}" + } >> "$GITHUB_OUTPUT" + + - name: Require green CI on the tagged commit + env: + GH_TOKEN: ${{ github.token }} + run: | + # A tag on a red (or untested) commit must not produce release + # artifacts. The tagged SHA already ran the CI workflow on its + # branch push; require that run to exist and have succeeded. + CONCLUSION=$(gh run list --repo "$GITHUB_REPOSITORY" \ + --workflow CI --commit "$GITHUB_SHA" \ + --json status,conclusion \ + --jq '[.[] | select(.status == "completed")] | map(.conclusion) | first') + echo "CI conclusion for $GITHUB_SHA: ${CONCLUSION:-none}" + if [ "$CONCLUSION" != "success" ]; then + echo "::error::No successful CI run found for ${GITHUB_SHA} — refusing to release." exit 1 fi @@ -49,14 +83,29 @@ jobs: needs: validate runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - variant: full + cmake_flags: "" + - variant: bitcoin-only + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - - uses: actions/checkout@v6 - with: - submodules: recursive + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Init required submodules + run: | + git submodule update --init deps/crypto/trezor-firmware + git submodule update --init deps/device-protocol + git submodule update --init --recursive deps/python-keepkey + git submodule update --init deps/googletest + git submodule update --init deps/qrenc/QR-Code-generator + git submodule update --init deps/sca-hardening/SecAESSTM32 - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -71,7 +120,7 @@ jobs: if: steps.cache-base.outputs.cache-hit == 'true' run: docker load -i /tmp/base-image.tar - - name: Cross-compile firmware + - name: Cross-compile firmware (${{ matrix.variant }}) run: | docker run --rm \ -v ${{ github.workspace }}:/root/keepkey-firmware:z \ @@ -79,18 +128,31 @@ jobs: mkdir /root/build && cd /root/build && \ cmake -C /root/keepkey-firmware/cmake/caches/device.cmake /root/keepkey-firmware \ -DCMAKE_BUILD_TYPE=MinSizeRel \ - -DCMAKE_COLOR_MAKEFILE=ON && \ + -DCMAKE_COLOR_MAKEFILE=ON \ + ${{ matrix.cmake_flags }} && \ make && \ mkdir -p /root/keepkey-firmware/release && \ cp bin/firmware.keepkey.bin /root/keepkey-firmware/release/ && \ cp bin/firmware.keepkey.elf /root/keepkey-firmware/release/ && \ cp bin/bootloader.bin /root/keepkey-firmware/release/ 2>/dev/null || true && \ + find . -name '*.su' -print0 | tar czf /root/keepkey-firmware/release/stack-usage.tgz --null -T - && \ chmod -R a+rw /root/keepkey-firmware/release" + # Same SRAM budget gate CI enforces (rc8 boot-fault class): release + # artifacts must clear it too, not just the 16 KiB linker ASSERT. + - name: SRAM budget gate (${{ matrix.variant }}) + run: | + pip install --quiet pyelftools + python3 tools/check_sram_budget.py \ + --elf release/firmware.keepkey.elf \ + --su-tar release/stack-usage.tgz \ + --budgets tools/sram-budgets.json \ + --variant "${{ matrix.variant }}" + - name: Compute hashes working-directory: release run: | - echo "# KeepKey Firmware v${{ needs.validate.outputs.fw_version }} — Hash Manifest" > HASHES.txt + echo "# KeepKey Firmware v${{ needs.validate.outputs.fw_version }} (${{ matrix.variant }}) — Hash Manifest" > HASHES.txt echo "" >> HASHES.txt for f in *.bin; do [ -f "$f" ] || continue @@ -109,15 +171,33 @@ jobs: working-directory: release run: | VER="${{ needs.validate.outputs.fw_version }}" - [ -f firmware.keepkey.bin ] && mv firmware.keepkey.bin "firmware.keepkey.v${VER}.bin" - [ -f firmware.keepkey.elf ] && mv firmware.keepkey.elf "firmware.keepkey.v${VER}.elf" - [ -f bootloader.bin ] && mv bootloader.bin "bootloader.v${VER}.bin" + VARIANT="${{ matrix.variant }}" + [ -f firmware.keepkey.bin ] && mv firmware.keepkey.bin "firmware.keepkey.v${VER}-${VARIANT}.bin" + [ -f firmware.keepkey.elf ] && mv firmware.keepkey.elf "firmware.keepkey.v${VER}-${VARIANT}.elf" + [ -f bootloader.bin ] && mv bootloader.bin "bootloader.v${VER}-${VARIANT}.bin" + mv HASHES.txt "HASHES-${VARIANT}.txt" ls -lh + - name: Record build provenance + working-directory: release + run: | + VARIANT="${{ matrix.variant }}" + { + echo "firmware_commit=${GITHUB_SHA}" + echo "source_repository=${GITHUB_REPOSITORY}" + echo "source_ref=${GITHUB_REF}" + echo "workflow_ref=${GITHUB_WORKFLOW_REF}" + echo "builder_image=${BASE_IMAGE}" + echo "runner_image=${ImageOS:-unknown}" + echo "variant=${VARIANT}" + echo "cmake_flags=${{ matrix.cmake_flags }}" + } > "PROVENANCE-${VARIANT}.txt" + cat "PROVENANCE-${VARIANT}.txt" + - name: Upload release artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: release-firmware + name: release-firmware-${{ matrix.variant }} path: release/* retention-days: 90 @@ -125,14 +205,29 @@ jobs: needs: validate runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - variant: full + cmake_flags: "" + - variant: bitcoin-only + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - - uses: actions/checkout@v6 - with: - submodules: recursive + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Init required submodules + run: | + git submodule update --init deps/crypto/trezor-firmware + git submodule update --init deps/device-protocol + git submodule update --init --recursive deps/python-keepkey + git submodule update --init deps/googletest + git submodule update --init deps/qrenc/QR-Code-generator + git submodule update --init deps/sca-hardening/SecAESSTM32 - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -147,10 +242,12 @@ jobs: if: steps.cache-base.outputs.cache-hit == 'true' run: docker load -i /tmp/base-image.tar - - name: Build and test emulator + - name: Build and test emulator (${{ matrix.variant }}) run: | - docker build -t kkemu-release -f scripts/emulator/Dockerfile . - docker run --rm --entrypoint /bin/sh kkemu-release \ + docker build -t kkemu-release-${{ matrix.variant }} \ + --build-arg coinsupport="${{ matrix.cmake_flags }}" \ + -f scripts/emulator/Dockerfile . + docker run --rm --entrypoint /bin/sh kkemu-release-${{ matrix.variant }} \ -c "make xunit; RC=\$?; exit \$RC" create-release: @@ -158,35 +255,47 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download firmware artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: release-firmware + pattern: release-firmware-* path: artifacts + merge-multiple: true - name: Prepare release assets run: | mkdir -p release-assets - cp artifacts/*.bin artifacts/*.elf artifacts/HASHES.txt release-assets/ + cp artifacts/*.bin artifacts/*.elf artifacts/HASHES-*.txt \ + artifacts/PROVENANCE-*.txt release-assets/ ls -lh release-assets/ - name: Generate release body run: | VER="${{ needs.validate.outputs.fw_version }}" cat > release-body.md <.txt \`\`\` - > **DRAFT** — firmware must be signed by 3/5 key holders before publishing. + > **DRAFT TEST CANDIDATE** — RC artifacts are unsigned and intended for + > release-candidate testing. Firmware must be signed by 3/5 key holders + > before publishing a production release. - ### Signing Checklist + ### Signing Checklist (per variant) - [ ] Built on multiple machines, hashes match - [ ] Signed on air-gapped machine (3/5 signers) - [ ] Storage upgrade tested on production device @@ -195,10 +304,11 @@ jobs: EOF - name: Create draft release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: draft: true - name: "Firmware v${{ needs.validate.outputs.fw_version }}" + prerelease: ${{ needs.validate.outputs.is_prerelease }} + name: "Firmware ${{ needs.validate.outputs.tag_name }}" body_path: release-body.md files: release-assets/* fail_on_unmatched_files: true diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..87c0d8489 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,36 @@ +title = "KeepKey firmware Gitleaks configuration" + +[extend] +useDefault = true + +[[allowlists]] +description = "Published AES test vectors in current and historical trezor-crypto layouts" +targetRules = ["generic-api-key"] +condition = "AND" +regexTarget = "line" +paths = [ + '''^deps/crypto/trezor-firmware/crypto/aes/aestst\.c$''', + '''^deps/crypto/trezor-crypto/aes/aestst\.c$''', +] +regexes = ['''(?i)^[[:space:]]*//[[:space:]]*key[[:space:]]*=[[:space:]]*[0-9a-f]+[[:space:]]*$'''] + +[[allowlists]] +description = "Ed25519 C type names in current and historical trezor-crypto layouts" +targetRules = ["generic-api-key"] +condition = "AND" +regexTarget = "line" +paths = [ + '''^deps/crypto/trezor-firmware/crypto/ed25519-donna/ed25519-(blake2b|keccak|sha3)\.h$''', + '''^deps/crypto/trezor-firmware/crypto/ed25519-donna/ed25519\.[ch]$''', + '''^deps/crypto/trezor-crypto/ed25519-donna/ed25519-(blake2b|keccak|sha3)\.h$''', + '''^deps/crypto/trezor-crypto/ed25519-donna/ed25519\.[ch]$''', +] +regexes = ['''ed25519.*secret_key.*signature'''] + +[[allowlists]] +description = "RC21 release provenance names an exact public python-keepkey git commit" +targetRules = ["generic-api-key"] +condition = "AND" +regexTarget = "line" +paths = ['''^docs/security/7\.15\.0-rc21-clearsign-release-control\.md$'''] +regexes = ['''^[[:space:]]*-[[:space:]]*python-keepkey:[[:space:]]*`c406a1ba9120da410c356dbff7f4d4bd1e1758fa`\.[[:space:]]*$'''] diff --git a/.gitmodules b/.gitmodules index 2d6c4446a..a6fdbfb17 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,10 +1,10 @@ [submodule "deps/device-protocol"] path = deps/device-protocol -url = https://github.com/keepkey/device-protocol.git -branch = master + url = https://github.com/BitHighlander/device-protocol.git +branch = up/release-protocol [submodule "deps/trezor-firmware"] path = deps/crypto/trezor-firmware -url = https://github.com/keepkey/trezor-firmware.git +url = https://github.com/BitHighlander/trezor-firmware.git [submodule "googletest"] path = deps/googletest url = https://github.com/google/googletest.git @@ -13,8 +13,8 @@ path = code-signing-keys url = https://github.com/keepkey/code-signing-keys.git [submodule "deps/python-keepkey"] path = deps/python-keepkey -url = https://github.com/keepkey/python-keepkey.git -branch = master +url = https://github.com/BitHighlander/python-keepkey.git +branch = reconcile/upstream-sync [submodule "deps/qrenc/QR-Code-generator"] path = deps/qrenc/QR-Code-generator url = https://github.com/keepkey/QR-Code-generator.git diff --git a/CMakeLists.txt b/CMakeLists.txt index cee2e661e..dd7413452 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ endif() project( KeepKeyFirmware - VERSION 7.14.1 + VERSION 7.15.0 LANGUAGES C CXX ASM) set(BOOTLOADER_MAJOR_VERSION 2) @@ -20,6 +20,17 @@ option(KK_EMULATOR "Build the emulator" OFF) option(KK_BUILD_DYLIB "Build libkkemu shared library (.dylib/.so)" OFF) option(KK_DEBUG_LINK "Build with debug-link enabled" OFF) option(KK_BUILD_FUZZERS "Build the fuzzers?" OFF) +option(KK_BITCOIN_ONLY "Build Bitcoin-only firmware (strip all non-BTC coins)" OFF) +# Zcash shielded/Orchard support is part of the regular firmware. It is an +# internal compile selection, not a third release variant: bitcoin-only strips +# the Zcash coin and privacy engine; every regular device/emulator build ships +# both. The open constant-time Pallas audit finding remains a release gate for +# RC18, but it must not silently change the product being audited. +if(KK_BITCOIN_ONLY) + set(KK_ZCASH_PRIVACY OFF) +else() + set(KK_ZCASH_PRIVACY ON) +endif() # When building the dylib, every static lib it links (kkfirmware, kkboard, # trezorcrypto, kkrand, kktransport, qrcodegenerator, SecAESSTM32, ...) must @@ -62,7 +73,7 @@ endif() if(NOT EXISTS ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto/Makefile) message( FATAL_ERROR - " trezor-crypto missing. Need to 'git submodule update --init --recursive" + "trezor-crypto fork missing. Run 'git submodule update --init deps/crypto/trezor-firmware'" ) endif() @@ -84,6 +95,13 @@ endif() if(${KK_EMULATOR}) add_definitions(-DEMULATOR) add_definitions(-DCONFIDENTIAL=) + # macOS/BSD declare strlcpy/strlcat in ; glibc (Linux) and MinGW + # (Windows) do not. Force-include the prototypes so the ~20 call sites build + # without -Werror=implicit-function-declaration (definitions come from + # lib/board/strlcpy.c + strlcat.c). Apple already has them in . + if(NOT APPLE) + add_compile_options(-include ${CMAKE_SOURCE_DIR}/include/keepkey/board/bsd_compat.h) + endif() else() add_definitions(-DCONFIDENTIAL=__attribute__\(\(section\("confidential"\)\)\)) endif() @@ -97,13 +115,21 @@ add_definitions(-DED25519_FORCE_32BIT=1) add_definitions(-DUSE_PRECOMPUTED_CP=0) -add_definitions(-DUSE_ETHEREUM=1) +if(${KK_BITCOIN_ONLY}) + # Bitcoin-only: strip the coin-specific trezor-crypto primitives whose only + # callers (ethereum.c / nano.c) are compiled out below. KECCAK stays on -- + # marginal size win, and it is a generic hash we don't want to risk. + add_definitions(-DUSE_ETHEREUM=0) + add_definitions(-DUSE_NANO=0) +else() + add_definitions(-DUSE_ETHEREUM=1) + add_definitions(-DUSE_NANO=1) +endif() add_definitions(-DUSE_KECCAK=1) add_definitions(-DUSE_GRAPHENE=0) add_definitions(-DUSE_CARDANO=0) add_definitions(-DUSE_MONERO=0) add_definitions(-DUSE_NEM=0) -add_definitions(-DUSE_NANO=1) add_definitions(-DRAND_PLATFORM_INDEPENDENT=0) @@ -123,12 +149,34 @@ add_definitions(-DBIP39_WORDLIST_PADDED=1) add_definitions(-DAES_128=1) +# NOTE: AES table size is selected per release product below. The regular +# image includes Zcash and its Pallas curve arithmetic, so it uses the smaller +# AES tables to preserve flash headroom. Bitcoin-only keeps FOUR_TABLES AES. + if(${KK_DEBUG_LINK}) add_definitions(-DDEBUG_LINK=1) else() add_definitions(-DDEBUG_LINK=0) endif() +# Value macros (always defined 0/1) -- device builds use -Wundef -Werror, so an +# undefined identifier in `#if` is a hard error. Guard code with `#if FLAG`. +if(${KK_BITCOIN_ONLY}) + add_definitions(-DBITCOIN_ONLY=1) +else() + add_definitions(-DBITCOIN_ONLY=0) +endif() + +if(${KK_ZCASH_PRIVACY}) + add_definitions(-DZCASH_PRIVACY=1) + # The Orchard engine leaves the regular image tightest on flash; shrink the + # Gladman AES lookup tables from 4KB to 1KB each (-15,360 bytes ROM, + # slightly slower AES). Bitcoin-only keeps the fast FOUR_TABLES. + add_definitions(-DAES_SMALL_TABLES) +else() + add_definitions(-DZCASH_PRIVACY=0) +endif() + if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") add_definitions(-DDEBUG_ON) add_definitions(-DMEMORY_PROTECT=0) @@ -161,6 +209,12 @@ if(NOT ${KK_EMULATOR}) link_directories(${LIBOPENCM3_PATH}/lib) include_directories(${LIBOPENCM3_PATH}/include) + # Emit per-function stack-frame sizes (.su files) on device builds. CI's + # SRAM budget gate (tools/check_sram_budget.py) reports the largest frames + # and fails when the linker-asserted stack reserve minus the largest frame + # leaves less than the configured margin. See tools/firmware/keepkey.ld. + add_compile_options(-fstack-usage) + # Dummy empty libraries for stack smashing protection support, since we # implement __stack_chk_guard and __stack_chk_fail ourselves. file(WRITE ${CMAKE_BINARY_DIR}/ssp.c "") diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..a5a41ad99 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# Convenience targets — mirrors CI jobs so failures are caught locally. +# +# CI pins clang-format-20. Use that version if available, otherwise fall back. +# To install: brew install llvm@20 or apt-get install clang-format-20 +CLANG_FORMAT ?= $(shell command -v clang-format-20 2>/dev/null || echo clang-format) + +# Directories and exclusions must match .github/workflows/ci.yml lint-format job. +LINT_DIRS := include/keepkey lib/firmware lib/board lib/transport/src +LINT_SOURCES := $(shell find $(LINT_DIRS) -name '*.c' -o -name '*.h' 2>/dev/null \ + | grep -v generated | grep -v '\.pb\.') + +.PHONY: lint format help + +## lint: Check formatting (same rules as CI). Exits non-zero on any violation. +lint: + @echo "clang-format version: $$($(CLANG_FORMAT) --version)" + @FAILED=0; \ + for f in $(LINT_SOURCES); do \ + if ! $(CLANG_FORMAT) --style=file --dry-run --Werror "$$f" 2>/dev/null; then \ + echo " NEEDS FORMAT: $$f"; \ + FAILED=1; \ + fi; \ + done; \ + if [ "$$FAILED" = "1" ]; then \ + echo ""; \ + echo "Run 'make format' to fix all files."; \ + exit 1; \ + else \ + echo "All files pass clang-format check."; \ + fi + +## format: Auto-fix formatting in-place for all source files. +format: + @echo "Formatting $(LINT_DIRS)..." + @for f in $(LINT_SOURCES); do \ + $(CLANG_FORMAT) --style=file -i "$$f"; \ + done + @echo "Done. Review changes with: git diff" + +## help: List available targets. +help: + @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## / make /' diff --git a/cmake/caches/device.cmake b/cmake/caches/device.cmake index 436a23bce..8265191dc 100644 --- a/cmake/caches/device.cmake +++ b/cmake/caches/device.cmake @@ -45,7 +45,14 @@ set(WARN_FLAGS -Werror") -set(KK_C_FLAGS "${ARCH_FLAGS} -std=gnu99 ${WARN_FLAGS}" CACHE STRING "") +# Newlib's snprintf unconditionally links the float engine (_svfprintf_r, +# _dtoa_r, soft-double libgcc, malloc) — ~22 KB of ROM with zero %f users in +# the firmware. Route all callers to the integer-only siprintf family instead. +# %lld/%llu still work (this toolchain's libc.a compiles the integer engine +# with long-long support). Device builds only; host/emulator keep libc printf. +set(PRINTF_FLAGS "-Dsnprintf=sniprintf -Dvsnprintf=vsniprintf") + +set(KK_C_FLAGS "${ARCH_FLAGS} -std=gnu99 ${WARN_FLAGS} ${PRINTF_FLAGS}" CACHE STRING "") set(KK_CXX_FLAGS "${ARCH_FLAGS} -std=gnu++11 ${WARN_FLAGS} \ -fno-exceptions \ -fno-rtti \ diff --git a/cmake/toolchains/mingw-w64-x86_64.cmake b/cmake/toolchains/mingw-w64-x86_64.cmake new file mode 100644 index 000000000..974f1a49c --- /dev/null +++ b/cmake/toolchains/mingw-w64-x86_64.cmake @@ -0,0 +1,40 @@ +# MinGW-w64 cross-compile toolchain for the Windows emulator DLL (libkkemu.dll, +# x86_64). Lets us cross-build the Windows DLL from the existing macOS/Linux +# emulator build host — no Windows runner required. +# +# Usage: +# cmake -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64-x86_64.cmake \ +# -DKK_EMULATOR=ON -DKK_BUILD_DYLIB=ON -DKK_DEBUG_LINK=ON ... +# cmake --build --target kkemulator_dylib +# +# Install MinGW: `brew install mingw-w64` (macOS) / `apt-get install mingw-w64`. +# +# Only the kkemulator_dylib target is meant to cross-compile. The standalone +# UDP `kkemu` binary is gated out on Windows (tools/emulator/CMakeLists.txt). + +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_PROCESSOR x86_64) + +set(TOOLCHAIN_PREFIX x86_64-w64-mingw32) +find_program(CMAKE_C_COMPILER NAMES ${TOOLCHAIN_PREFIX}-gcc) +find_program(CMAKE_CXX_COMPILER NAMES ${TOOLCHAIN_PREFIX}-g++) +find_program(CMAKE_RC_COMPILER NAMES ${TOOLCHAIN_PREFIX}-windres) + +if(NOT CMAKE_C_COMPILER) + message(FATAL_ERROR + "${TOOLCHAIN_PREFIX}-gcc not found. Install MinGW-w64 " + "(brew install mingw-w64 / apt-get install mingw-w64).") +endif() + +# Derive the target sysroot from the compiler location so this works across +# Homebrew versions and Linux package layouts. +get_filename_component(_kk_cc "${CMAKE_C_COMPILER}" REALPATH) +get_filename_component(_kk_bin "${_kk_cc}" DIRECTORY) +get_filename_component(_kk_root "${_kk_bin}/.." ABSOLUTE) +set(CMAKE_FIND_ROOT_PATH "${_kk_root}/${TOOLCHAIN_PREFIX}") + +# Find host programs on the host; libraries/headers in the target sysroot. +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) diff --git a/deps/crypto/CMakeLists.txt b/deps/crypto/CMakeLists.txt index cd735668c..d726b39bc 100644 --- a/deps/crypto/CMakeLists.txt +++ b/deps/crypto/CMakeLists.txt @@ -68,7 +68,6 @@ set(sources include_directories( ${CMAKE_CURRENT_SOURCE_DIR}/trezor-firmware/crypto - ${CMAKE_CURRENT_SOURCE_DIR}/trezor-firmware/ ${CMAKE_CURRENT_SOURCE_DIR}/trezor-firmware/crypto/ed25519-donna ${OPENSSL_INCLUDE_DIR}) diff --git a/deps/crypto/trezor-firmware b/deps/crypto/trezor-firmware index 03d8a55a8..cdc05bebe 160000 --- a/deps/crypto/trezor-firmware +++ b/deps/crypto/trezor-firmware @@ -1 +1 @@ -Subproject commit 03d8a55a832fb61bb89477ef7239a80ecb367080 +Subproject commit cdc05bebe9e6989cf711e1b5bea6324fd09f848e diff --git a/deps/device-protocol b/deps/device-protocol index d637b7829..cf308fd5e 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit d637b78291a423fd8119df9935a9365be8a7758e +Subproject commit cf308fd5e76f3f2e02ba38347ceccaa382b08505 diff --git a/deps/python-keepkey b/deps/python-keepkey index fabd6c618..b44f1b367 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit fabd6c6189b7f1b3ea7cbd1d372fc13729761178 +Subproject commit b44f1b367e7b8ce98d88a49b3457443335365d8f diff --git a/docs/README.md b/docs/README.md index e456f6ad5..d2ccbb7ec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,3 +5,4 @@ * [Supported Coins](Coins.md) * [Host Communications](Host.md) * [Release Process](Release.md) +* [7.15.0 RC18 Release Shape](security/7.15.0-rc18-release-shape.md) diff --git a/fuzzer/firmware/CMakeLists.txt b/fuzzer/firmware/CMakeLists.txt index e38b4f8f5..99591be6a 100644 --- a/fuzzer/firmware/CMakeLists.txt +++ b/fuzzer/firmware/CMakeLists.txt @@ -2,7 +2,7 @@ include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/lib/firmware ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(libraries kkfirmware diff --git a/include/keepkey/transport/messages-ethereum.options b/include/keepkey/transport/messages-ethereum.options index 65b6a1a2f..e4a769b7d 100644 --- a/include/keepkey/transport/messages-ethereum.options +++ b/include/keepkey/transport/messages-ethereum.options @@ -49,4 +49,7 @@ Ethereum712TypesValues.eip712data max_size:2048 EthereumTxMetadata.signed_payload max_size:1024 EthereumMetadataAck.display_summary max_size:32 +LoadClearsignSigner.pubkey max_size:33 +LoadClearsignSigner.alias max_size:32 +LoadClearsignSigner.icon max_size:384 diff --git a/include/keepkey/transport/messages-ripple.options b/include/keepkey/transport/messages-ripple.options index b3f2e1987..219014bf6 100644 --- a/include/keepkey/transport/messages-ripple.options +++ b/include/keepkey/transport/messages-ripple.options @@ -6,5 +6,7 @@ RippleSignTx.address_n max_count:8 RipplePayment.destination max_size:36 +RippleSignTx.memo max_size:200 + RippleSignedTx.signature max_size:75 RippleSignedTx.serialized_tx max_size:1024 diff --git a/include/keepkey/transport/messages-solana.options b/include/keepkey/transport/messages-solana.options index ded9d68c1..0886fb61b 100644 --- a/include/keepkey/transport/messages-solana.options +++ b/include/keepkey/transport/messages-solana.options @@ -5,11 +5,16 @@ SolanaAddress.address max_size:64 SolanaTokenInfo.mint max_size:32 SolanaTokenInfo.symbol max_size:13 +SolanaTokenInfo.signature max_size:64 SolanaSignTx.address_n max_count:8 SolanaSignTx.coin_name max_size:21 SolanaSignTx.raw_tx max_size:2048 SolanaSignTx.token_info max_count:4 +SolanaSignTx.schema_payload max_size:256 +SolanaSignTx.schema_signature max_size:64 +SolanaSignTx.token_recipient_owner max_count:4 +SolanaSignTx.token_recipient_owner max_size:32 SolanaSignedTx.signature max_size:64 diff --git a/include/keepkey/transport/messages-thorchain.options b/include/keepkey/transport/messages-thorchain.options index 14cb39b18..c17a9368c 100644 --- a/include/keepkey/transport/messages-thorchain.options +++ b/include/keepkey/transport/messages-thorchain.options @@ -8,6 +8,7 @@ ThorchainSignTx.memo max_size:256 ThorchainMsgSend.from_address max_size:46 ThorchainMsgSend.to_address max_size:46 +ThorchainMsgSend.denom max_size:69 ThorchainMsgDeposit.asset max_size:20 ThorchainMsgDeposit.memo max_size:256 diff --git a/include/keepkey/transport/messages.options b/include/keepkey/transport/messages.options index 525b39161..71944508a 100644 --- a/include/keepkey/transport/messages.options +++ b/include/keepkey/transport/messages.options @@ -27,7 +27,7 @@ PinMatrixAck.pin max_size:10 PassphraseAck.passphrase max_size:51 -Entropy.entropy max_size:1024 +Entropy.entropy max_size:8192 GetPublicKey.address_n max_count:8 GetPublicKey.ecdsa_curve_name max_size:32 @@ -120,6 +120,11 @@ DebugLinkState.recovery_cipher max_size:27 DebugLinkState.recovery_auto_completed_word max_size:12 DebugLinkState.firmware_hash max_size:32 DebugLinkState.storage_hash max_size:32 +DebugLinkState.dice_digest max_size:32 + +# Sized so the decoded struct stays within MSG_TINY_BFR_SZ (64B): the tiny +# message path pb_decodes DebugLinkDecision straight into that buffer. +DebugLinkDecision.input max_size:41 DebugLinkFlashDumpResponse.data max_size:1024 @@ -133,3 +138,11 @@ FlashWrite.data max_size:1024 FlashHashResponse.data max_size:32 Bip85Mnemonic.mnemonic max_size:241 + +# ClearSign attestor. The payload cap is the largest +# KKSOLSC1 schema: magic(8)+version(1)+program(32)+disc(1+8)+2 names(2*21)+ +# args(1+4*18)+accounts(1+4*18) = 238 bytes. +ClearsignAttestorPublicKey.public_key max_size:33 +ClearsignAttestorSign.payload max_size:256 +ClearsignAttestorSignature.signature max_size:64 +ClearsignAttestorSignature.public_key max_size:33 diff --git a/lib/board/CMakeLists.txt b/lib/board/CMakeLists.txt index 10c30b424..bb2c376dc 100644 --- a/lib/board/CMakeLists.txt +++ b/lib/board/CMakeLists.txt @@ -51,7 +51,7 @@ endif() include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto ${CMAKE_CURRENT_SOURCE_DIR}) add_library(kkboard ${sources}) diff --git a/lib/emulator/CMakeLists.txt b/lib/emulator/CMakeLists.txt index fa5909a17..68cef0295 100644 --- a/lib/emulator/CMakeLists.txt +++ b/lib/emulator/CMakeLists.txt @@ -34,7 +34,14 @@ if(${KK_EMULATOR}) target_include_directories(kkemulator_dylib PRIVATE ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) + # libkkemu.c runs the firmware event loop on a dedicated thread (POSIX + # pthreads; Win32 CreateThread under MinGW). Threads::Threads is a no-op on + # the Win32 path and pulls in -lpthread on POSIX. Use the PLAIN signature — + # tools/emulator/CMakeLists.txt links this same target plainly, and CMake + # forbids mixing keyword (PRIVATE) and plain target_link_libraries calls. + find_package(Threads REQUIRED) + target_link_libraries(kkemulator_dylib Threads::Threads) set_target_properties(kkemulator_dylib PROPERTIES OUTPUT_NAME "kkemu" POSITION_INDEPENDENT_CODE ON) diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index f23ebcfb8..d9f0d7a19 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -49,7 +49,7 @@ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/scm_revision.h.in" include_directories( ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto ${CMAKE_SOURCE_DIR}/lib/firmware ${CMAKE_CURRENT_BINARY_DIR}) diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index ea3072b07..186810161 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -531,8 +531,7 @@ void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg) { /* Ed25519 sign */ uint8_t sig[SOL_SIG_SIZE]; - ed25519_sign(msg->message.bytes, msg->message.size, node->private_key, - node->public_key + 1, sig); + ed25519_sign(msg->message.bytes, msg->message.size, node->private_key, sig); resp->has_signature = true; resp->signature.size = SOL_SIG_SIZE; diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index abadf4cfe..94952a0f3 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -665,8 +665,7 @@ bool solana_signTx(const HDNode* node, const SolanaSignTx* msg, /* Ed25519 sign the raw transaction message directly * (Solana signs the serialized message, not a hash of it) */ uint8_t sig[SOL_SIG_SIZE]; - ed25519_sign(msg->raw_tx.bytes, msg->raw_tx.size, node->private_key, - node->public_key + 1, sig); + ed25519_sign(msg->raw_tx.bytes, msg->raw_tx.size, node->private_key, sig); resp->has_signature = true; resp->signature.size = SOL_SIG_SIZE; @@ -739,7 +738,7 @@ bool solana_offchain_message_sign(const HDNode* node, off += msg->message.size; uint8_t sig[SOL_SIG_SIZE]; - ed25519_sign(envelope, off, node->private_key, node->public_key + 1, sig); + ed25519_sign(envelope, off, node->private_key, sig); resp->has_public_key = true; resp->public_key.size = SOL_PUBKEY_SIZE; diff --git a/lib/firmware/ton.c b/lib/firmware/ton.c index 043e41c9d..354d7a4b4 100644 --- a/lib/firmware/ton.c +++ b/lib/firmware/ton.c @@ -240,7 +240,7 @@ bool ton_signTx(const HDNode* node, const TonSignTx* msg, TonSignedTx* resp) { // Ed25519 sign the transaction ed25519_signature signature; ed25519_sign(msg->raw_tx.bytes, msg->raw_tx.size, node->private_key, - &node->public_key[1], signature); + signature); // Copy signature to response (64 bytes) resp->has_signature = true; @@ -273,7 +273,7 @@ bool ton_message_sign(const HDNode* node, const TonSignMessage* msg, ed25519_signature signature; ed25519_sign(msg->message.bytes, msg->message.size, node->private_key, - &node->public_key[1], signature); + signature); resp->has_public_key = true; resp->public_key.size = 32; diff --git a/scripts/build/docker/device/debug.sh b/scripts/build/docker/device/debug.sh index 0150e9138..8e0ca528d 100755 --- a/scripts/build/docker/device/debug.sh +++ b/scripts/build/docker/device/debug.sh @@ -3,7 +3,7 @@ KEEPKEY_FIRMWARE="$(dirname "$(dirname "$(dirname "$(dirname "$( cd "$(dirname "$0")" ; pwd -P )")")")")" cd $KEEPKEY_FIRMWARE -IMAGETAG=kktech/firmware:v15 +IMAGETAG=kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 docker image inspect $IMAGETAG > /dev/null || docker pull $IMAGETAG diff --git a/scripts/build/docker/device/release.sh b/scripts/build/docker/device/release.sh index 18cfe53dc..86ce7dc1d 100755 --- a/scripts/build/docker/device/release.sh +++ b/scripts/build/docker/device/release.sh @@ -3,17 +3,22 @@ KEEPKEY_FIRMWARE="$(dirname "$(dirname "$(dirname "$(dirname "$( cd "$(dirname "$0")" ; pwd -P )")")")")" cd $KEEPKEY_FIRMWARE -IMAGETAG=kktech/firmware:v15 +IMAGETAG=kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 docker image inspect $IMAGETAG > /dev/null || docker pull $IMAGETAG +# Extra cmake flags pass straight through. The only alternate release product +# is bitcoin-only: ./release.sh -DKK_BITCOIN_ONLY=ON +EXTRA_CMAKE_FLAGS="$*" + docker run -t \ -v $(pwd):/root/keepkey-firmware:z \ $IMAGETAG /bin/sh -c "\ mkdir /root/build && cd /root/build && \ cmake -C /root/keepkey-firmware/cmake/caches/device.cmake /root/keepkey-firmware \ -DCMAKE_BUILD_TYPE=MinSizeRel \ - -DCMAKE_COLOR_MAKEFILE=ON &&\ + -DCMAKE_COLOR_MAKEFILE=ON \ + ${EXTRA_CMAKE_FLAGS} &&\ make && \ mkdir -p /root/keepkey-firmware/bin && \ cp -r /root/build /root/keepkey-firmware/bin/ && \ diff --git a/scripts/build/docker/emulator/debug.sh b/scripts/build/docker/emulator/debug.sh index 9fec8264d..e4ad8ace0 100755 --- a/scripts/build/docker/emulator/debug.sh +++ b/scripts/build/docker/emulator/debug.sh @@ -3,7 +3,7 @@ KEEPKEY_FIRMWARE="$(dirname "$(dirname "$(dirname "$(dirname "$( cd "$(dirname "$0")" ; pwd -P )")")")")" cd $KEEPKEY_FIRMWARE -IMAGETAG=kktech/firmware:v15 +IMAGETAG=kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 docker pull $IMAGETAG diff --git a/scripts/emulator/Dockerfile b/scripts/emulator/Dockerfile index 53c6bdd99..81e325ee8 100644 --- a/scripts/emulator/Dockerfile +++ b/scripts/emulator/Dockerfile @@ -1,4 +1,4 @@ -FROM kktech/firmware:v15 +FROM kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 WORKDIR /kkemu COPY ./ /kkemu @@ -17,4 +17,3 @@ RUN make -j EXPOSE 11044/udp 11045/udp EXPOSE 5000 CMD ["/kkemu/scripts/emulator/run.sh"] - diff --git a/scripts/emulator/python-keepkey-tests.sh b/scripts/emulator/python-keepkey-tests.sh index 28b122fc9..489a64674 100755 --- a/scripts/emulator/python-keepkey-tests.sh +++ b/scripts/emulator/python-keepkey-tests.sh @@ -17,6 +17,11 @@ done cd deps/python-keepkey/tests +# The tests run from this directory, while keepkeylib lives one level up. +# Make that package root explicit so direct imports work consistently in the +# standalone container (including tests collected before common.py is loaded). +export PYTHONPATH="..${PYTHONPATH:+:$PYTHONPATH}" + # Diagnostic: verify SCREENSHOT flag reaches Python echo "=== Pre-flight diagnostic ===" KEEPKEY_SCREENSHOT=1 python3 -c " @@ -41,9 +46,14 @@ echo "=== End diagnostic ===" # expression for every test with non-empty screenshot expectations. Adding screenshots # to a test in SECTIONS automatically includes it here — no manual filter maintenance. echo "=== Phase 1: Report-driven screenshot capture ===" -# Detect firmware version from CMakeLists if not set in env +# Detect firmware version from CMakeLists if not set in env. +# NOTE: grep -oE (POSIX ERE), NOT -oP — this runs in the Alpine/busybox +# python-keepkey container where grep has no -P (PCRE). With -P grep errored +# and the version silently fell back to 7.14.0, so every 7.15.0 section +# (Hive, EVM clear-signing) was excluded from screenshot capture. if [ -z "$FW_VERSION" ]; then - FW_VERSION=$(sed -n '/^project/,/)/p' /kkemu/CMakeLists.txt | grep -oP '\d+\.\d+\.\d+' || echo "7.14.0") + FW_VERSION=$(sed -n '/^project/,/)/p' /kkemu/CMakeLists.txt | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + [ -z "$FW_VERSION" ] && FW_VERSION="7.14.0" echo "Detected FW_VERSION=$FW_VERSION from CMakeLists.txt" fi export FW_VERSION @@ -55,6 +65,7 @@ fi echo "Filter: $SCREENSHOT_FILTER" KEEPKEY_SCREENSHOT=1 \ SCREENSHOT_DIR=/kkemu/test-reports/screenshots \ +KK_EXPECT_PERSIST_REJECTED=1 \ KK_TRANSPORT_MAIN=kkemu:11044 \ KK_TRANSPORT_DEBUG=kkemu:11045 \ pytest -v --tb=short \ @@ -82,18 +93,44 @@ fi # Tests that skip via requires_message/requires_firmware are OK. # Tests that fail or are missing from JUnit = CI failure. echo "=== Phase 2: Full test suite ===" +set +e +KK_EXPECT_PERSIST_REJECTED=1 \ +KK_EXPECT_ENTROPY_BUDGET=1 \ KK_TRANSPORT_MAIN=kkemu:11044 \ KK_TRANSPORT_DEBUG=kkemu:11045 \ pytest -v --junitxml=/kkemu/test-reports/python-keepkey/junit.xml PYTEST_RC=$? +echo "=== Phase 2: Validate report catalog ===" +python3 ../scripts/generate-test-report.py \ + --junit=/kkemu/test-reports/python-keepkey/junit.xml \ + ${FW_VERSION:+--fw-version=$FW_VERSION} \ + --validate-junit +CATALOG_RC=$? + echo "=== Phase 2: Generate test report ===" python3 ../scripts/generate-test-report.py \ --junit=/kkemu/test-reports/python-keepkey/junit.xml \ - ${FW_VERSION:+--fw-version=$FW_VERSION} || true + ${FW_VERSION:+--fw-version=$FW_VERSION} \ + --screenshots=/kkemu/test-reports/screenshots \ + --output=/kkemu/test-reports/test-report.pdf +REPORT_RC=$? +set -e -echo "$PYTEST_RC" > /kkemu/test-reports/python-keepkey/status +if [ "$PYTEST_RC" -eq 0 ] && [ "$CATALOG_RC" -eq 0 ] && [ "$REPORT_RC" -eq 0 ]; then + echo "0" > /kkemu/test-reports/python-keepkey/status +else + echo "1" > /kkemu/test-reports/python-keepkey/status +fi if [ "$PYTEST_RC" -ne 0 ]; then echo "pytest failed with exit code $PYTEST_RC" exit "$PYTEST_RC" fi +if [ "$CATALOG_RC" -ne 0 ]; then + echo "report catalog validation failed with exit code $CATALOG_RC" + exit "$CATALOG_RC" +fi +if [ "$REPORT_RC" -ne 0 ]; then + echo "test report generation failed with exit code $REPORT_RC" + exit "$REPORT_RC" +fi diff --git a/scripts/emulator/python-keepkey.Dockerfile b/scripts/emulator/python-keepkey.Dockerfile index f67f9ba0d..0ba90447e 100644 --- a/scripts/emulator/python-keepkey.Dockerfile +++ b/scripts/emulator/python-keepkey.Dockerfile @@ -1,4 +1,4 @@ -FROM kktech/firmware:v15 +FROM kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 # Extra Python deps needed by tests that aren't in the shared base image. # - rlp + eth-keys + eth-utils: build the canonical EIP-1559 type-2 pre-image diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 2803adce8..fe772f7d5 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -23,6 +23,15 @@ def main(): file=sys.stderr) sys.exit(1) + # The release report is evidence, not a best-effort decoration. The + # canonical Python JUnit must exist; otherwise rendering an empty catalog + # produces a dangerously plausible "all pending" PDF. + python_junit = 'test-reports/python-keepkey/junit.xml' + if not os.path.isfile(python_junit) or os.path.getsize(python_junit) == 0: + print("ERROR: required Python JUnit evidence missing: %s" % python_junit, + file=sys.stderr) + sys.exit(1) + # Collect JUnit XMLs from CI artifacts junit_files = ( glob.glob('test-reports/python-keepkey/junit*.xml') + @@ -65,9 +74,9 @@ def main(): print("Running: %s" % ' '.join(cmd)) result = subprocess.run(cmd) - # Don't exit non-zero -- let the report be uploaded even with partial results if result.returncode != 0: - print("WARN: report generator exited %d" % result.returncode, file=sys.stderr) + print("ERROR: report generator exited %d" % result.returncode, file=sys.stderr) + sys.exit(result.returncode) if os.path.exists('test-report.pdf'): size = os.path.getsize('test-report.pdf') @@ -76,6 +85,23 @@ def main(): print("ERROR: test-report.pdf not created", file=sys.stderr) sys.exit(1) + # Render first so a failed candidate still has a truthful diagnostic PDF, + # then fail the job if any catalog entry failed or is missing. Deliberate + # feature/policy skips remain valid per the report generator contract. + validate_cmd = [ + sys.executable, + REPORT_GENERATOR, + '--junit=%s' % python_junit, + '--validate-junit', + ] + if fw_version: + validate_cmd.append('--fw-version=%s' % fw_version) + print("Validating: %s" % ' '.join(validate_cmd)) + validation = subprocess.run(validate_cmd) + if validation.returncode != 0: + print("ERROR: report catalog validation failed", file=sys.stderr) + sys.exit(validation.returncode) + if __name__ == '__main__': main() diff --git a/tools/blupdater/CMakeLists.txt b/tools/blupdater/CMakeLists.txt index 990529f70..d5dc99764 100644 --- a/tools/blupdater/CMakeLists.txt +++ b/tools/blupdater/CMakeLists.txt @@ -9,7 +9,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/blupdater.ld) diff --git a/tools/bootloader/CMakeLists.txt b/tools/bootloader/CMakeLists.txt index 60b2c46a8..e1f4180ae 100644 --- a/tools/bootloader/CMakeLists.txt +++ b/tools/bootloader/CMakeLists.txt @@ -10,7 +10,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/bootloader.ld) diff --git a/tools/bootstrap/CMakeLists.txt b/tools/bootstrap/CMakeLists.txt index 9b2853b12..393dee36d 100644 --- a/tools/bootstrap/CMakeLists.txt +++ b/tools/bootstrap/CMakeLists.txt @@ -7,7 +7,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/bootstrap.ld) diff --git a/tools/check_sram_budget.py b/tools/check_sram_budget.py new file mode 100644 index 000000000..8481a2348 --- /dev/null +++ b/tools/check_sram_budget.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""SRAM budget gate for ARM firmware builds. + +Fails CI when the runtime stack/heap reserve — the gap between the end of +static allocation (_ebss) and the top-of-RAM stack (_stack) — drops below the +per-variant budget, or when the largest single stack frame (-fstack-usage) +leaves less than the configured margin inside that reserve. + +Why this exists: RC7's privacy-enabled build shipped with an 11,232-byte gap +while msg_write() carried a 12,416-byte automatic TrezorFrameBuffer — every +USB response overwrote static memory, hard-faulting on boot. The linker also +ASSERTs a 16 KiB floor (tools/firmware/keepkey.ld); this script is the +observability + frame-margin half of that gate. + +Usage: + check_sram_budget.py --elf bin/...firmware.keepkey.elf \ + --su-tar bin/stack-usage.tgz --budgets tools/sram-budgets.json \ + --variant full +""" + +import argparse +import json +import sys +import tarfile + +from elftools.elf.elffile import ELFFile # pip install pyelftools + + +def read_symbols(elf_path): + with open(elf_path, "rb") as f: + elf = ELFFile(f) + symtab = elf.get_section_by_name(".symtab") + if symtab is None: + sys.exit(f"ERROR: {elf_path} has no .symtab") + wanted = {} + for sym in symtab.iter_symbols(): + if sym.name in ("_ebss", "_stack"): + wanted[sym.name] = sym["st_value"] + missing = {"_ebss", "_stack"} - set(wanted) + if missing: + sys.exit(f"ERROR: {elf_path} missing symbols: {sorted(missing)}") + return wanted + + +def largest_frames(su_tar_path, top_n=15): + """Parse GCC -fstack-usage records from a tar of .su files. + + Record format: ":::\t\t" + """ + frames = [] + with tarfile.open(su_tar_path, "r:*") as tar: + for member in tar: + if not member.name.endswith(".su") or not member.isfile(): + continue + data = tar.extractfile(member).read().decode("utf-8", "replace") + for line in data.splitlines(): + parts = line.rsplit("\t", 2) + if len(parts) != 3: + continue + loc, size, qual = parts + try: + frames.append((int(size), loc.split("/")[-1], qual)) + except ValueError: + continue + frames.sort(reverse=True) + return frames[:top_n] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--elf", required=True) + ap.add_argument("--su-tar", required=True) + ap.add_argument("--budgets", required=True) + ap.add_argument("--variant", required=True) + args = ap.parse_args() + + budgets = json.load(open(args.budgets)) + reserve_min = budgets.get("variants", {}).get(args.variant, {}).get( + "reserve_min", budgets["reserve_min"]) + frame_margin = budgets.get("variants", {}).get(args.variant, {}).get( + "frame_margin", budgets["frame_margin"]) + + syms = read_symbols(args.elf) + gap = syms["_stack"] - syms["_ebss"] + + frames = largest_frames(args.su_tar) + if not frames: + # An empty .su archive means -fstack-usage generation broke (or the + # tar glob went stale). Treating it as "largest frame = 0" would let + # the margin check false-pass — fail loudly instead. + sys.exit("ERROR: no -fstack-usage records found in " + f"{args.su_tar} — stack-usage generation is broken; " + "refusing to pass the frame-margin gate without data") + largest = frames[0][0] + + print(f"SRAM budget report — variant: {args.variant}") + print(f" _ebss = 0x{syms['_ebss']:08x}") + print(f" _stack = 0x{syms['_stack']:08x}") + print(f" stack/heap reserve (gap) = {gap:,} B " + f"(budget: >= {reserve_min:,} B)") + print(f" largest stack frame = {largest:,} B " + f"(gap - largest must be >= {frame_margin:,} B)") + print(" top stack frames (-fstack-usage):") + for size, loc, qual in frames: + print(f" {size:7,} B {qual:14s} {loc}") + + failed = False + if gap < reserve_min: + print(f"::error::SRAM gate: reserve {gap:,} B < budget " + f"{reserve_min:,} B for {args.variant}") + failed = True + if gap - largest < frame_margin: + print(f"::error::SRAM gate: reserve minus largest frame " + f"({gap:,} - {largest:,} = {gap - largest:,} B) < margin " + f"{frame_margin:,} B for {args.variant}") + failed = True + + if failed: + sys.exit(1) + print("SRAM budget gate: PASS") + + +if __name__ == "__main__": + main() diff --git a/tools/display_test/CMakeLists.txt b/tools/display_test/CMakeLists.txt index 0d280ab80..86da3d16d 100644 --- a/tools/display_test/CMakeLists.txt +++ b/tools/display_test/CMakeLists.txt @@ -6,7 +6,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/display_test.ld) diff --git a/tools/emulator/CMakeLists.txt b/tools/emulator/CMakeLists.txt index 63b80bd88..2882f47a1 100644 --- a/tools/emulator/CMakeLists.txt +++ b/tools/emulator/CMakeLists.txt @@ -6,7 +6,7 @@ if(${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(FIRMWARE_LIBS kkfirmware @@ -21,21 +21,43 @@ if(${KK_EMULATOR}) SecAESSTM32 kkrand) - # Standalone emulator binary (uses UDP sockets) - add_executable(kkemu ${sources}) + # Standalone emulator binary — UDP sockets on :11044/:11045 (used by firmware + # CI: python-keepkey UDP tests + OLED screenshots). NOT built on Windows: it + # depends on BSD sockets + signal(); the vault never uses it — the vault loads + # the dylib/DLL below instead. + if(NOT WIN32) + add_executable(kkemu ${sources}) - # Add linker flags for ARM64 Mac compatibility - if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") - target_link_options(kkemu PRIVATE "-Wl,-no_fixup_chains") - endif() + # Add linker flags for ARM64 Mac compatibility + if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") + target_link_options(kkemu PRIVATE "-Wl,-no_fixup_chains") + endif() - target_link_libraries(kkemu ${FIRMWARE_LIBS} kkemulator) + target_link_libraries(kkemu ${FIRMWARE_LIBS} kkemulator) + endif() - # Shared library (ring buffers, no sockets) for in-process FFI (vault) + # Shared library (ring buffers, no sockets) for in-process FFI — this is what + # the vault loads via bun:ffi (libkkemu.dylib on macOS, .so on Linux, .dll on + # Windows). if(KK_BUILD_DYLIB) - target_link_libraries(kkemulator_dylib ${FIRMWARE_LIBS}) - if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") - target_link_options(kkemulator_dylib PRIVATE "-Wl,-no_fixup_chains") + if(WIN32) + # kkrand and trezorcrypto cross-reference each other (random32 / + # random_uniform). GNU/MinGW ld resolves static archives left-to-right in + # a single pass, so wrap FIRMWARE_LIBS in a linker group. Use the raw + # --start-group/--end-group flags rather than the LINK_GROUP genex: the + # genex needs CMake >= 3.24, but this repo's cmake_minimum_required is + # 3.7.2. macOS ld64 is multi-pass and needs neither. + # Also: MinGW exports nothing from a DLL by default (unlike Mach-O/ELF), + # so export the kkemu_* FFI entry points; and link the Windows CSPRNG + # (bcrypt, BCryptGenRandom) used by emulator/random.c on _WIN32. + target_link_libraries(kkemulator_dylib + -Wl,--start-group ${FIRMWARE_LIBS} -Wl,--end-group bcrypt) + target_link_options(kkemulator_dylib PRIVATE "-Wl,--export-all-symbols") + else() + target_link_libraries(kkemulator_dylib ${FIRMWARE_LIBS}) + if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") + target_link_options(kkemulator_dylib PRIVATE "-Wl,-no_fixup_chains") + endif() endif() endif() endif() diff --git a/tools/firmware/CMakeLists.txt b/tools/firmware/CMakeLists.txt index 18c50e411..0db0bf6e2 100644 --- a/tools/firmware/CMakeLists.txt +++ b/tools/firmware/CMakeLists.txt @@ -9,12 +9,14 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/keepkey.ld) + # Link map for the CI SRAM report/artifact (pairs with the .ld's 16 KiB + # stack-reserve ASSERT and the -fstack-usage frame report). set(CMAKE_EXE_LINKER_FLAGS - "${CMAKE_EXE_LINKER_FLAGS} -T${linker_script} -L${CMAKE_BINARY_DIR}/lib") + "${CMAKE_EXE_LINKER_FLAGS} -T${linker_script} -L${CMAKE_BINARY_DIR}/lib -Wl,-Map=${CMAKE_BINARY_DIR}/bin/firmware.keepkey.map,--cref") set(LINK_FLAGS kkfirmware diff --git a/tools/sram-budgets.json b/tools/sram-budgets.json new file mode 100644 index 000000000..f5b5b2277 --- /dev/null +++ b/tools/sram-budgets.json @@ -0,0 +1,9 @@ +{ + "_comment": "Per-product SRAM budgets enforced by tools/check_sram_budget.py in CI (and a 16 KiB linker ASSERT in tools/firmware/keepkey.ld). reserve_min = minimum bytes between _ebss and _stack; frame_margin = minimum bytes left after subtracting the largest -fstack-usage frame from the reserve. Initial limits chosen after the RC7 privacy-enabled overflow (11,232 B gap vs a 12,416 B msg_write frame); replace with measured worst-case high-water + margin once hardware instrumentation reports real numbers. Any change to these budgets, and any single-commit SRAM increase above 256 B, needs explicit review.", + "reserve_min": 16384, + "frame_margin": 4096, + "variants": { + "full": {}, + "bitcoin-only": {} + } +} diff --git a/unittests/board/CMakeLists.txt b/unittests/board/CMakeLists.txt index 81e294dec..65fb7c8a3 100644 --- a/unittests/board/CMakeLists.txt +++ b/unittests/board/CMakeLists.txt @@ -5,7 +5,7 @@ set(sources include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) add_executable(board-unit ${sources}) target_link_libraries(board-unit @@ -20,6 +20,6 @@ target_link_libraries(board-unit trezorcrypto qrcodegenerator SecAESSTM32 - kkemulator kkrand + kkemulator kktransport) diff --git a/unittests/crypto/CMakeLists.txt b/unittests/crypto/CMakeLists.txt index 782549f28..4f2390d70 100644 --- a/unittests/crypto/CMakeLists.txt +++ b/unittests/crypto/CMakeLists.txt @@ -5,7 +5,7 @@ set(sources include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) add_executable(crypto-unit ${sources}) target_link_libraries(crypto-unit diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 647d72571..5ef9e8489 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -14,7 +14,7 @@ include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/lib/firmware ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) add_executable(firmware-unit ${sources}) target_link_libraries(firmware-unit From ea20eaa1cd0c0d7c17602beae2792650d3a391df Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 15:33:26 -0300 Subject: [PATCH 02/40] ci: satisfy workflow shellcheck --- .github/workflows/ci.yml | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92d8aa7a1..7e0c5e4e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,15 +152,17 @@ jobs: : "${ERRORS:=0}" "${WARNINGS:=0}" "${STYLE:=0}" "${PERF:=0}" "${PORT:=0}" TOTAL=$((ERRORS + WARNINGS + STYLE + PERF + PORT)) - echo "## cppcheck summary" >> "$GITHUB_STEP_SUMMARY" - echo "| Severity | Count |" >> "$GITHUB_STEP_SUMMARY" - echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY" - echo "| error | $ERRORS |" >> "$GITHUB_STEP_SUMMARY" - echo "| warning | $WARNINGS |" >> "$GITHUB_STEP_SUMMARY" - echo "| style | $STYLE |" >> "$GITHUB_STEP_SUMMARY" - echo "| performance | $PERF |" >> "$GITHUB_STEP_SUMMARY" - echo "| portability | $PORT |" >> "$GITHUB_STEP_SUMMARY" - echo "| **total** | **$TOTAL** |" >> "$GITHUB_STEP_SUMMARY" + { + echo "## cppcheck summary" + echo "| Severity | Count |" + echo "|----------|-------|" + echo "| error | $ERRORS |" + echo "| warning | $WARNINGS |" + echo "| style | $STYLE |" + echo "| performance | $PERF |" + echo "| portability | $PORT |" + echo "| **total** | **$TOTAL** |" + } >> "$GITHUB_STEP_SUMMARY" # Print findings as GitHub annotations cat cppcheck_report.txt @@ -743,7 +745,8 @@ jobs: # it fsm_msgDebugLinkGetState is excluded from the build. # CMAKE_POLICY_VERSION_MINIMUM works around vendored # googletest's pre-3.5 policy declaration. - export PATH="$PATH:$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" + NANOPB_GENERATOR_DIR="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" + export PATH="$PATH:$NANOPB_GENERATOR_DIR" which protoc-gen-nanopb which nanopb_generator.py cmake \ @@ -755,15 +758,17 @@ jobs: - name: Build kkemulator_dylib run: | - export PATH="$PATH:$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" - cmake --build build-emu --target kkemulator_dylib -j$(sysctl -n hw.ncpu) + NANOPB_GENERATOR_DIR="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" + export PATH="$PATH:$NANOPB_GENERATOR_DIR" + CPU_COUNT="$(sysctl -n hw.ncpu)" + cmake --build build-emu --target kkemulator_dylib -j"$CPU_COUNT" ls -la build-emu/lib/libkkemu* || ls -la build-emu/lib/emulator/libkkemu* || true # Surface the resolved binary path for the run step. macOS # produces .dylib; .so is preserved as a fallback for when this # job goes cross-platform. - DYLIB=$(find build-emu -name 'libkkemu.dylib' -o -name 'libkkemu.so' | head -1) + DYLIB="$(find build-emu \( -name 'libkkemu.dylib' -o -name 'libkkemu.so' \) -print -quit)" test -f "$DYLIB" || (echo "::error::libkkemu artifact not found" && exit 1) - echo "DYLIB_PATH=$(pwd)/$DYLIB" >> $GITHUB_ENV + echo "DYLIB_PATH=$(pwd)/$DYLIB" >> "$GITHUB_ENV" - name: Upload libkkemu.dylib # Always upload, even on later test failure — the binary is From 6af73e95e225efe574869900c42af3a033e6ca5d Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:02:11 -0300 Subject: [PATCH 03/40] fix(board): show whole addresses, and drop the float printf engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display: - Long bech32/bech32m addresses were cut at the first 42 characters on the verify screen, so the QR code and the text disagreed about where funds were going. Addresses now wrap across the full screen, and Zcash unified addresses get a dedicated text-first confirmation layout. - Font, draw and layout helpers gained the measurement and wrapping support those screens need. ROM: - Device snprintf/vsnprintf are redirected to newlib's integer-only engine (see cmake/caches/device.cmake), which removes the float formatting path, soft-double libgcc and its malloc dependency from the image. Percent and amount rendering that used %f is reworked to integer formatting, so THORChain withdraw percentages and send amounts still display exactly as before — Gate-3 frames under docs/security/evidence/rom-printf-integer-percent. - keepkey.ld asserts the 16 KiB stack reserve the SRAM budget gate measures. --- .../01-thorchain-withdraw-25.05pct.png | Bin 0 -> 932 bytes .../02-thorchain-sending-eth.png | Bin 0 -> 470 bytes .../rom-printf-integer-percent/README.md | 17 ++ include/keepkey/board/bsd_compat.h | 28 +++ include/keepkey/board/confirm_sm.h | 4 + include/keepkey/board/draw.h | 25 +++ include/keepkey/board/font.h | 5 + include/keepkey/board/layout.h | 15 ++ include/keepkey/board/util.h | 5 +- include/keepkey/firmware/app_confirm.h | 14 ++ include/keepkey/firmware/app_layout.h | 10 +- lib/board/confirm_sm.c | 51 +++-- lib/board/draw.c | 85 ++++++++- lib/board/font.c | 44 +++-- lib/board/layout.c | 149 ++++++++++++++- lib/board/timer.c | 30 ++- lib/board/usb.c | 49 +++-- lib/board/util.c | 61 +++--- lib/firmware/app_confirm.c | 176 ++++++++++++++++-- lib/firmware/app_layout.c | 121 +++++++++++- scripts/emulator/capture-thor-percent.py | 99 ++++++++++ tools/firmware/keepkey.ld | 10 + unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/app_confirm.cpp | 42 +++++ 24 files changed, 943 insertions(+), 98 deletions(-) create mode 100644 docs/security/evidence/rom-printf-integer-percent/01-thorchain-withdraw-25.05pct.png create mode 100644 docs/security/evidence/rom-printf-integer-percent/02-thorchain-sending-eth.png create mode 100644 docs/security/evidence/rom-printf-integer-percent/README.md create mode 100644 include/keepkey/board/bsd_compat.h create mode 100644 scripts/emulator/capture-thor-percent.py create mode 100644 unittests/firmware/app_confirm.cpp diff --git a/docs/security/evidence/rom-printf-integer-percent/01-thorchain-withdraw-25.05pct.png b/docs/security/evidence/rom-printf-integer-percent/01-thorchain-withdraw-25.05pct.png new file mode 100644 index 0000000000000000000000000000000000000000..22d9f0735002a0fa0d1078df922ea3078088f408 GIT binary patch literal 932 zcmV;V16%xwP)X3m} z@msfi?W)pCvCXR%3;t=BE}#Q@O`@rbka1K-3(?ot$Isf$HFIq1hrUEWtJz~O6M$x` zp0e$m2uN)f4Btoq-X#J!a+v@OK zaB%)#BB1c6c4_Yt_y7X(eI`I?sw7UF%_b4xCYxaI`zybpkUMOt8NI+TmKwROR zCY;~LM`IH3bOI6s@Al_@JbnDkz`K2U51u~$iHQ-4Me&<~t|D{vslYudr`CI z*6O@w@$y)9cC?d#rBPANifTL_==M4S1SZG!AmXQ7zKv=hIIEH_t!6`bXiPwlEfm7- zjumNl6a+3Hpihnmxb~9EC`=uy^sOBez>XWOs?f7rq6t<(nL^+^j0q6gPrxFu1OsVc zFRJy3;`m_J^JC)@0+g<=65zKb0qW-2R3?DLy7{XJAf~$~1p$7fAp*m(X_r28aLCgi z7GWtnW#9sMR34CAa#S!eF)=YQ@nrPZIiz6pFG7@tlK*ePuXzPM$bCUCz3IIk-~B>< z$>{3&NbunZ0h9@b6jpo` zKs+rIpOx*wll%1&h_t|LN5UmpqL^@c0*T40$!81bBlLxF#=h<+AgrlK4@3aEbcg^! zxkx}Qxwh`<8kWx?0*a?Cs)kUGS9YU%VuwGt!}C8JECqSA$u;#`px2x4dr6p>n3$ND zcqaO*SRijzJIE!;_4lfd?(rQT+CBjxuf&?{>Rtk>`lA`zUEo|Q5|FkSOIz5<7uz5T zEB`&4fSgnItThZ!W5N&ZS<^B~%KRzkuCwwUNI4@lCQl^*{EHqV@!}B;duM9Tnm&&j zO|28qG-B&T5g=m%c!1I#Y8qO%2uMSSU5QBPz^w%E{!H4r0ekI(PX~1AjKQN;CE0E| zcurud!wBfsZjFF)322Uhuf`eAfUibXw*V6-BH5K;vwPmkmofZ7TGwG{wrE71F2 zP5}y_Zr2u|yDS6+a8B11AT1Pus{jQkEorVLk;$ z0bU*f-J=h#)_DgUy!ZR)$X0>(3P1=Ugb+fClAjRV-o>V-d!LpPx`gu6V4huaGMcXj zmlIh919>41+Zs-DS)+!>dhJfxF>*BUq*n2(EI|3&R@9@AkO!_0<|MKRfTihc@bwhHni0@lS9%8wtAHKwz|~{bRe=18A31LCJM5YkMF0Q* M07*qoM6N<$f^@LY;s5{u literal 0 HcmV?d00001 diff --git a/docs/security/evidence/rom-printf-integer-percent/README.md b/docs/security/evidence/rom-printf-integer-percent/README.md new file mode 100644 index 000000000..b54b7d4fa --- /dev/null +++ b/docs/security/evidence/rom-printf-integer-percent/README.md @@ -0,0 +1,17 @@ +# Integer percent rendering on the THOR/Maya withdraw confirm + +Emulator captures for the change that routes all device `snprintf` calls to +newlib's integer-only `sniprintf` and rewrites the last two float format +users (`%3.2f` in `thorchain.c` / `mayachain.c`) as integer basis-point math. + +- `01-thorchain-withdraw-25.05pct.png` — ETH router `deposit()` carrying memo + `WITHDRAW:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:2505`. + 2505 bps renders as `25.05%`: the integer path preserves the `%02d` + zero-padding of the fractional digits. +- `02-thorchain-sending-eth.png` — the amount screen from the same flow, + showing `%llu`-family rendering is unaffected. + +Reproduce with `scripts/emulator/capture-thor-percent.py` against kkemu +(`KEEPKEY_SCREENSHOT=1`, abandon test seed, no PIN). Emulator captures do not +satisfy Gate-3 on their own — an on-device pass of the THOR withdraw screen +and one Osmosis `%llu` amount screen is still owed before release. diff --git a/include/keepkey/board/bsd_compat.h b/include/keepkey/board/bsd_compat.h new file mode 100644 index 000000000..0e2b46495 --- /dev/null +++ b/include/keepkey/board/bsd_compat.h @@ -0,0 +1,28 @@ +#ifndef KEEPKEY_BOARD_BSD_COMPAT_H +#define KEEPKEY_BOARD_BSD_COMPAT_H + +/* + * Declarations for BSD libc extensions that macOS/BSD expose via + * but glibc (Linux) and MinGW (Windows) do not. The emulator build compiles + * lib/board/strlcpy.c + strlcat.c when the libc lacks the definitions + * (KK_HAVE_STRLCPY / KK_HAVE_STRLCAT), so only the prototypes are missing. + * + * Force-included for non-Apple emulator builds (see CMakeLists.txt) so every + * translation unit sees the prototypes without us having to chase down ~20 + * call sites — and without touching the real hardware (ARM) build at all. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +size_t strlcpy(char *dst, const char *src, size_t siz); +size_t strlcat(char *dst, const char *src, size_t siz); + +#ifdef __cplusplus +} +#endif + +#endif /* KEEPKEY_BOARD_BSD_COMPAT_H */ diff --git a/include/keepkey/board/confirm_sm.h b/include/keepkey/board/confirm_sm.h index fa74c829f..765a90125 100644 --- a/include/keepkey/board/confirm_sm.h +++ b/include/keepkey/board/confirm_sm.h @@ -95,6 +95,10 @@ bool confirm(ButtonRequestType type, const char* request_title, const char* request_body, ...) __attribute__((format(printf, 3, 4))); +bool confirm_with_icon(ButtonRequestType type, IconType iconNum, + const char* request_title, const char* request_body, ...) + __attribute__((format(printf, 4, 5))); + bool confirm_constant_power(ButtonRequestType type, const char* request_title, const char* request_body, ...) __attribute__((format(printf, 3, 4))); diff --git a/include/keepkey/board/draw.h b/include/keepkey/board/draw.h index f1e6f0627..a1a1c90ca 100644 --- a/include/keepkey/board/draw.h +++ b/include/keepkey/board/draw.h @@ -51,6 +51,31 @@ void draw_char_simple(Canvas* canvas, const Font* font, char c, uint8_t color, void draw_box(Canvas* canvas, BoxDrawableParams* p); void draw_box_simple(Canvas* canvas, uint8_t color, uint16_t x, uint16_t y, uint16_t width, uint16_t height); +/* + * draw_bitmap_mono_rle_valid() - Validate an RLE stream against a geometry. + * + * Pure and side-effect-free: decodes nothing, writes nothing, touches no + * canvas. Returns true iff the stream is EXACTLY well-formed for a w*h image: + * - every packet count is valid (never 0, never 0x80/-128 — the decoder's + * counter is int8_t and cannot represent a 128 literal), + * - no run straddles the end of the image, + * - exactly w*h pixels are produced, and + * - the whole input is consumed (no trailing packets). + * + * The drawing path is lenient by construction (it stops once the canvas is + * full), so callers that accept host-supplied streams MUST validate here at + * the trust boundary rather than infer validity from a successful draw. + * + * INPUT + * - data: RLE stream + * - length: stream length in bytes + * - w, h: target image geometry + * OUTPUT + * true iff the stream decodes exactly to w*h pixels + */ +bool draw_bitmap_mono_rle_valid(const uint8_t* data, uint32_t length, + uint16_t w, uint16_t h); + bool draw_bitmap_mono_rle(Canvas* canvas, const AnimationFrame* frame, bool erase); diff --git a/include/keepkey/board/font.h b/include/keepkey/board/font.h index 95933f2a5..1fe6eedf4 100644 --- a/include/keepkey/board/font.h +++ b/include/keepkey/board/font.h @@ -20,6 +20,7 @@ #ifndef FONT_H #define FONT_H +#include #include /* Data pertaining to the image of a character */ @@ -53,5 +54,9 @@ uint32_t font_width(const Font* font); uint32_t calc_str_width(const Font* font, const char* str); uint32_t calc_str_line(const Font* font, const char* str, uint16_t line_width); +uint32_t calc_str_line_n(const Font* font, const char* str, size_t str_len, + uint16_t line_width); +size_t calc_str_page(const Font* font, const char* str, size_t str_len, + uint16_t line_width, uint32_t max_lines); #endif diff --git a/include/keepkey/board/layout.h b/include/keepkey/board/layout.h index ed5ab8f33..08e7ab368 100644 --- a/include/keepkey/board/layout.h +++ b/include/keepkey/board/layout.h @@ -79,6 +79,11 @@ typedef enum { typedef enum { NO_ICON = 0, ETHEREUM_ICON, + VERIFIED_ICON, + /* A runtime-supplied 1bpp mono RLE bitmap (e.g. a loaded clear-sign identity + * logo). The frame is set via layout_set_runtime_icon() before the confirm; + * drawn by layout_add_icon(). */ + RUNTIME_ICON, } IconType; typedef void (*AnimateCallback)(void* data, uint32_t duration, @@ -111,6 +116,12 @@ void layout_constant_power_notification(const char* str1, const char* str2, NotificationType type); void layout_notification_icon(NotificationType type, DrawableParams* sp); void layout_add_icon(IconType type); + +/// \brief Set the frame drawn for RUNTIME_ICON on the next confirm. Pass NULL +/// to clear. The AnimationFrame + its Image must outlive the confirm +/// (typically file-static in the caller). +struct AnimationFrame_; +void layout_set_runtime_icon(const struct AnimationFrame_* frame); void layout_warning(const char* str); void layout_warning_static(const char* str); void layout_simple_message(const char* str); @@ -124,6 +135,10 @@ void animating_progress_handler(const char* desc, int permil); void layoutProgress(const char* desc, int permil); void layoutProgressForAuth(const char* otp, const char* desc, int permil); void layoutProgressSwipe(const char* desc, int permil); +void layoutProgressTrickle(const char* desc, int base_permil, + int target_permil); +void layoutProgressTrickleStop(void); +void layout_animate_poll(void); void layout_add_animation(AnimateCallback callback, void* data, uint32_t duration); void layout_animate_images(void* data, uint32_t duration, uint32_t elapsed); diff --git a/include/keepkey/board/util.h b/include/keepkey/board/util.h index 5272a41ea..f6fab624a 100644 --- a/include/keepkey/board/util.h +++ b/include/keepkey/board/util.h @@ -54,8 +54,7 @@ void dec64_to_str(uint64_t dec64_val, char* str); bool is_valid_ascii(const uint8_t* data, uint32_t size); -int base_to_precision(uint8_t* dest, const uint8_t* value, - const uint8_t dest_len, const uint8_t value_len, - const uint8_t precision); +int base_to_precision(uint8_t* dest, const uint8_t* value, size_t dest_len, + size_t value_len, uint8_t precision); #endif diff --git a/include/keepkey/firmware/app_confirm.h b/include/keepkey/firmware/app_confirm.h index 15fcd7c64..cf1630288 100644 --- a/include/keepkey/firmware/app_confirm.h +++ b/include/keepkey/firmware/app_confirm.h @@ -24,6 +24,7 @@ #include #include +#include #define CONFIRM_SIGN_IDENTITY_TITLE 32 #define CONFIRM_SIGN_IDENTITY_BODY 416 @@ -46,10 +47,23 @@ bool confirm_load_device(bool is_node); bool confirm_address(const char* desc, const char* address); bool confirm_xpub(const char* node_str, const char* xpub); bool confirm_sign_identity(const IdentityType* identity, const char* challenge); +/** + * Review every byte of a length-delimited payload. Printable ASCII and LF line + * breaks are paged as text; any payload containing another control/non-ASCII + * byte is paged as complete hexadecimal. Page boundaries use the OLED + * renderer's actual font and word-wrap budget, so no accepted byte can be + * clipped below the third row. + */ +bool confirm_bytes_is_text(const uint8_t* data, size_t size); +bool confirm_bytes(ButtonRequestType button_request, const char* title, + const uint8_t* data, size_t size); bool confirm_cosmos_address(const char* desc, const char* address); bool confirm_osmosis_address(const char* desc, const char* address); bool confirm_ethereum_address(const char* desc, const char* address); bool confirm_nano_address(const char* desc, const char* address); +#if ZCASH_PRIVACY +bool confirm_zcash_address(const char* desc, const char* address); +#endif bool confirm_omni(ButtonRequestType button_request, const char* title, const uint8_t* data, uint32_t size); bool confirm_data(ButtonRequestType button_request, const char* title, diff --git a/include/keepkey/firmware/app_layout.h b/include/keepkey/firmware/app_layout.h index fc6d9ca96..10e35b891 100644 --- a/include/keepkey/firmware/app_layout.h +++ b/include/keepkey/firmware/app_layout.h @@ -118,8 +118,16 @@ void layout_ethereum_address_notification(const char* desc, const char* address, NotificationType type); void layout_nano_address_notification(const char* desc, const char* address, NotificationType type); +#if ZCASH_PRIVACY +void layout_zcash_address_notification(const char* desc, const char* address, + NotificationType type); +void layout_zcash_address_text_notification(const char* desc, + const char* address, + NotificationType type); +#endif void layout_pin(const char* str, char* pin); -void layout_cipher(const char* current_word, const char* cipher); +void layout_cipher(const char* current_word, const char* cipher, + const char* prev_word_info); void layout_address(const char* address, QRSize qr_size); void set_leaving_handler(leaving_handler_t leaving_func); diff --git a/lib/board/confirm_sm.c b/lib/board/confirm_sm.c index 0669536ab..8804f5f24 100644 --- a/lib/board/confirm_sm.c +++ b/lib/board/confirm_sm.c @@ -315,6 +315,29 @@ bool confirm(ButtonRequestType type, const char* request_title, return ret; } +bool confirm_with_icon(ButtonRequestType type, IconType iconNum, + const char* request_title, const char* request_body, + ...) { + button_request_acked = false; + + va_list vl; + va_start(vl, request_body); + vsnprintf(strbuf, sizeof(strbuf), request_body, vl); + va_end(vl); + + ButtonRequest resp; + memset(&resp, 0, sizeof(ButtonRequest)); + resp.has_code = true; + resp.code = type; + msg_write(MessageType_MessageType_ButtonRequest, &resp); + + bool ret = + confirm_helper(request_title, strbuf, &layout_standard_notification, + false, iconNum, false); + memzero(strbuf, sizeof(strbuf)); + return ret; +} + bool confirm_constant_power(ButtonRequestType type, const char* request_title, const char* request_body, ...) { button_request_acked = false; @@ -414,10 +437,11 @@ bool review(ButtonRequestType type, const char* request_title, resp.code = type; msg_write(MessageType_MessageType_ButtonRequest, &resp); - (void)confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false); + bool ret = + confirm_helper(request_title, strbuf, &layout_standard_notification, + false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); - return true; + return ret; } bool review_without_button_request(const char* request_title, @@ -429,10 +453,11 @@ bool review_without_button_request(const char* request_title, vsnprintf(strbuf, sizeof(strbuf), request_body, vl); va_end(vl); - (void)confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false); + bool ret = + confirm_helper(request_title, strbuf, &layout_standard_notification, + false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); - return true; + return ret; } bool review_with_icon(ButtonRequestType type, IconType iconNum, @@ -452,10 +477,11 @@ bool review_with_icon(ButtonRequestType type, IconType iconNum, resp.code = type; msg_write(MessageType_MessageType_ButtonRequest, &resp); - (void)confirm_helper(request_title, strbuf, &layout_standard_notification, - false, iconNum, false); + bool ret = + confirm_helper(request_title, strbuf, &layout_standard_notification, + false, iconNum, false); memzero(strbuf, sizeof(strbuf)); - return true; + return ret; } bool review_immediate(ButtonRequestType type, const char* request_title, @@ -474,8 +500,9 @@ bool review_immediate(ButtonRequestType type, const char* request_title, resp.code = type; msg_write(MessageType_MessageType_ButtonRequest, &resp); - (void)confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, true); + bool ret = + confirm_helper(request_title, strbuf, &layout_standard_notification, + false, NO_ICON, true); memzero(strbuf, sizeof(strbuf)); - return true; + return ret; } diff --git a/lib/board/draw.c b/lib/board/draw.c index 3a51e145f..14c2a070f 100644 --- a/lib/board/draw.c +++ b/lib/board/draw.c @@ -287,6 +287,58 @@ void draw_box_simple(Canvas* canvas, uint8_t color, uint16_t x, uint16_t y, * OUTPUT * true/false whether image was drawn */ +/* + * draw_bitmap_mono_rle_valid() - see draw.h. Pure walk of the RLE grammar; + * writes nothing. The drawing path below stops as soon as the canvas is full, + * so it cannot tell a well-formed stream from one whose last run straddles the + * image or that carries trailing packets. Host-supplied icons must be checked + * here, at the trust boundary, before they are shown or cached for a session. + */ +bool draw_bitmap_mono_rle_valid(const uint8_t* data, uint32_t length, + uint16_t w, uint16_t h) { + if (!data || w == 0 || h == 0) { + return false; + } + + const uint32_t pixels = (uint32_t)w * (uint32_t)h; + uint32_t emitted = 0; + uint32_t i = 0; + + while (emitted < pixels) { + if (i >= length) { + return false; /* ran out of input mid-image */ + } + const uint8_t raw = data[i]; + if (raw == 0x80u || raw == 0u) { + return false; /* undecodable (int8_t counter) / not a packet */ + } + i++; + + uint32_t run; + if (raw > 127u) { + run = (uint32_t)(256u - raw); /* LITERAL: 1..127 distinct values */ + if (i + run > length) { + return false; /* literal body truncated */ + } + i += run; + } else { + run = raw; /* RUN: 1..127 copies of one value */ + if (i >= length) { + return false; /* missing the run's value byte */ + } + i++; + } + + if (emitted + run > pixels) { + return false; /* run straddles the end of the image */ + } + emitted += run; + } + + /* Exactly filled, and nothing left over. */ + return emitted == pixels && i == length; +} + bool draw_bitmap_mono_rle(Canvas* canvas, const AnimationFrame* frame, bool erase) { if (!frame || !canvas) { @@ -302,6 +354,16 @@ bool draw_bitmap_mono_rle(Canvas* canvas, const AnimationFrame* frame, return false; } + /* Validate the whole stream up front. The loop below fills the canvas and + * stops, so on its own it cannot reject a final run that straddles the image + * or trailing packets past the last pixel — it would draw and report success. + * Checking first makes the return value mean "this stream is well-formed AND + * was drawn", which is what callers gating on host-supplied icons need. + * (Verified: every bundled image stream terminates exactly.) */ + if (!draw_bitmap_mono_rle_valid(img->data, img->length, img->w, img->h)) { + return false; + } + int8_t sequence = 0; int8_t nonsequence = 0; uint32_t pixel_index = 0; @@ -315,12 +377,29 @@ bool draw_bitmap_mono_rle(Canvas* canvas, const AnimationFrame* frame, // sequence > 0 implies the next x pixels are the same // sequence < 0 implies the next -x pixels are all different if ((sequence == 0) && (nonsequence == 0)) { - sequence = img->data[pixel_index]; + /* Read the packet count. 0x80 (-128) is rejected: `nonsequence` below + * is int8_t, so -(-128) = 128 does not fit and wraps back to -128, + * breaking the `nonsequence > 0` invariant. Under NDEBUG the assert is + * compiled out and we would decode with a negative counter + * (signed-overflow UB). 0 is likewise not a valid packet: it leaves + * both counters at zero and breaks the same invariant. A host-supplied + * icon reaches here, so fail closed rather than trust the encoder. */ + const uint8_t raw = img->data[pixel_index]; + if (raw == 0x80u || raw == 0u) { + return false; + } pixel_index++; - if (sequence < 0) { - nonsequence = -sequence; + /* Explicit two's-complement read. Narrowing a uint8_t > 127 straight + * into an int8_t is implementation-defined, so spell the conversion + * out: 1..127 stay positive (RUN), 129..255 become -127..-1 (LITERAL). + */ + if (raw > 127u) { + nonsequence = (int8_t)((int)raw - 256); /* -127..-1 */ + nonsequence = (int8_t)(-nonsequence); /* 1..127, fits int8_t */ sequence = 0; + } else { + sequence = (int8_t)raw; /* 1..127 */ } } diff --git a/lib/board/font.c b/lib/board/font.c index 5fccfcb33..d00c215e7 100644 --- a/lib/board/font.c +++ b/lib/board/font.c @@ -21,6 +21,7 @@ #include "keepkey/board/font.h" #include +#include /* --- Image Font ------------------------------------------------------------ */ @@ -2598,29 +2599,31 @@ uint32_t calc_str_width(const Font* font, const char* str) { * OUTPUT * line count */ -uint32_t calc_str_line(const Font* font, const char* str, uint16_t line_width) { +uint32_t calc_str_line_n(const Font* font, const char* str, size_t str_len, + uint16_t line_width) { uint8_t line_count = 1; uint16_t x_offset = 0; + size_t offset = 0; - while (*str) { - uint8_t character_width = font_get_char(font, str[0])->width; + while (offset < str_len && str[offset]) { + uint8_t character_width = font_get_char(font, str[offset])->width; uint16_t word_width = character_width; - const char* next_character = str + 1; + size_t next_offset = offset + 1; /* Allow line breaks */ - if (*str == '\n') { + if (str[offset] == '\n') { line_count++; x_offset = 0; - str++; + offset++; continue; } /* Calculate next work width */ - if (*str == ' ') { - while (*next_character && *next_character != ' ' && - *next_character != '\n') { - word_width += font_get_char(font, *next_character)->width; - next_character++; + if (str[offset] == ' ') { + while (next_offset < str_len && str[next_offset] && + str[next_offset] != ' ' && str[next_offset] != '\n') { + word_width += font_get_char(font, str[next_offset])->width; + next_offset++; } } @@ -2631,14 +2634,27 @@ uint32_t calc_str_line(const Font* font, const char* str, uint16_t line_width) { } /* Remove leading spaces */ - if (x_offset == 0 && *str == ' ') { - str++; + if (x_offset == 0 && str[offset] == ' ') { + offset++; continue; } x_offset += character_width; - str++; + offset++; } return line_count; } + +uint32_t calc_str_line(const Font* font, const char* str, uint16_t line_width) { + return calc_str_line_n(font, str, strlen(str), line_width); +} + +size_t calc_str_page(const Font* font, const char* str, size_t str_len, + uint16_t line_width, uint32_t max_lines) { + size_t best = 0; + for (size_t take = 1; take <= str_len; take++) { + if (calc_str_line_n(font, str, take, line_width) <= max_lines) best = take; + } + return best; +} diff --git a/lib/board/layout.c b/lib/board/layout.c index 59e9dd9e2..7c4d44d66 100644 --- a/lib/board/layout.c +++ b/lib/board/layout.c @@ -324,12 +324,30 @@ void layout_standard_notification(const char* str1, const char* str2, * OUTPUT * none */ +/* Frame drawn for RUNTIME_ICON — a loaded clear-sign identity logo. Set by + * layout_set_runtime_icon() before the confirm; the caller owns the storage. */ +static const AnimationFrame* runtime_icon_frame = NULL; + +void layout_set_runtime_icon(const struct AnimationFrame_* frame) { + runtime_icon_frame = frame; +} + void layout_add_icon(IconType type) { switch (type) { case ETHEREUM_ICON: + /* ponytail: reuse the ETH glyph as the "verified" mark — it's an ETH tx. + * Swap in a dedicated checkmark bitmap if the trust mark needs to differ. + */ + case VERIFIED_ICON: draw_bitmap_mono_rle(canvas, get_ethereum_icon_frame(), false); break; + case RUNTIME_ICON: + if (runtime_icon_frame) { + draw_bitmap_mono_rle(canvas, runtime_icon_frame, false); + } + break; + default: /* no action requires */ break; @@ -706,11 +724,20 @@ static const char* _otpStr = ""; * OTP in large font desc - text to display permil - progress in units of 1 to * 1000 OUTPUT none */ -void animating_progress_handler(const char* desc, int permil) { +/* Render the progress bar into the framebuffer WITHOUT clearing the animation + * queue, so an animation callback (trickle_progress_callback) can redraw itself + * every frame without removing itself from the queue. + * + * marker_phase: 0..999 breathes a glint on the fill's leading segment (a + * perpetual "working" cue that keeps the display visibly moving even after + * the eased fill has pixel-saturated); pass -1 for no glint. */ +static void progress_render_ex(const char* desc, int permil, int marker_phase) { if (!canvas) return; - call_leaving_handler(); - layout_clear(); + layout_clear_static(); +#if DEBUG_LINK + layout_debuglink_watermark(); +#endif permil = permil >= 1000 ? 1000 : permil; permil = permil <= 0 ? 0 : permil; @@ -775,9 +802,43 @@ void animating_progress_handler(const char* desc, int permil) { draw_box(canvas, &bp); } + // Front glint: the fill's leading segment breathes (dim <-> bright) while + // the trickle is active. Activity always shows exactly at the progress + // front — unlike a marker sweeping the track, it cannot detach from the + // fill and open a gap, and it cannot run out of travel as the unfilled + // span shrinks near 100% (long final-action proofs). + if (marker_phase >= 0 && finished_width > 4) { + const uint32_t glint_max = 10; + uint32_t glint_w = + finished_width - 2 < glint_max ? finished_width - 2 : glint_max; + /* Triangle wave 0..500..0 over one breath period. */ + uint32_t tri = (uint32_t)marker_phase < 500 ? (uint32_t)marker_phase + : 1000 - (uint32_t)marker_phase; + bp.width = glint_w; + bp.height = height - 2; + bp.base.x = x + finished_width - glint_w; + bp.base.y = y + 1; + bp.base.color = (uint8_t)(0x44 + (tri * 0x66) / 500); + draw_box(canvas, &bp); + } + display_refresh(); } +static void progress_render(const char* desc, int permil) { + progress_render_ex(desc, permil, -1); +} + +/* One-shot progress draw: clears any queued animation (historical behaviour, so + * a stray animation cannot redraw over a static progress screen) then renders. + */ +void animating_progress_handler(const char* desc, int permil) { + if (!canvas) return; + call_leaving_handler(); + layout_clear_animations(); + progress_render(desc, permil); +} + void layoutProgress(const char* desc, int permil) { animating_progress_handler(desc, permil); } @@ -818,6 +879,87 @@ void layout_add_animation(AnimateCallback callback, void* data, animation_queue_push(&active_queue, animation); } +/* --- Trickle progress for long host-driven operations ----------------------- + * Shielded Zcash signing blocks on the host generating zk-proofs, so the device + * would otherwise sit on a frozen progress bar and look like it has failed. + * This ramps a "trickle" smoothly through most of the gap to the next real + * milestone over the expected host-proof duration, holding short of it (so it + * never falsely shows work done). It is driven off the animation timer, which + * layout_animate_poll() pumps from usbPoll() while the device blocks on host + * I/O. A dedicated flag gates that pump so no other flow is affected. */ +static volatile bool trickle_active = false; +static struct { + const char* desc; + int base; /* permil committed by the last real milestone */ + int target; /* permil to ease toward (the next milestone) */ +} trickle; + +static void trickle_progress_callback(void* data, uint32_t duration, + uint32_t elapsed) { + (void)data; + (void)duration; + /* Host proof windows between milestones run ~40-50s. Ramp linearly through + * 90% of the milestone span over that guessed duration, then hold — the + * last 10% is only crossed by the next REAL milestone, so the bar never + * claims work that hasn't happened. The breathing glint keeps signalling + * activity while the ramp holds. + * ponytail: EXPECTED_MS is a guess, not a measurement — retune if host + * proof times change materially. */ + const uint32_t EXPECTED_MS = 45000; + int span = trickle.target - trickle.base; + int cap = (span * 9) / 10; + int add = 0; + if (cap > 0) { + add = elapsed >= EXPECTED_MS + ? cap + : (int)(((uint64_t)cap * elapsed) / EXPECTED_MS); + } + /* Glint phase loops forever, so the display keeps changing even after the + * eased fill has stopped producing new pixels (long zk-proof waits). */ + const uint32_t BREATH_PERIOD = 1600; /* ms per dim<->bright breath cycle */ + int phase = (int)(((elapsed % BREATH_PERIOD) * 1000) / BREATH_PERIOD); + /* Draw via progress_render_ex (not animating_progress_handler) so redrawing + * the frame does not clear the animation queue and remove this callback. */ + progress_render_ex(trickle.desc, trickle.base + add, phase); +} + +/* (Re-)arm the trickle to ease from base_permil toward target_permil. Re-adding + * the callback resets its elapsed to 0 so the ease restarts from base_permil. + */ +void layoutProgressTrickle(const char* desc, int base_permil, + int target_permil) { + trickle.desc = desc; + trickle.base = base_permil; + trickle.target = target_permil; + trickle_active = true; + layout_add_animation(&trickle_progress_callback, NULL, 0 /* loop forever */); + force_animation_start(); + /* Draw the first frame now (at base, glint at its dimmest) so the bar + * appears immediately, before the animation timer next fires. + * progress_render_ex keeps the animation queue intact. */ + progress_render_ex(desc, base_permil, 0); +} + +void layoutProgressTrickleStop(void) { + trickle_active = false; + Animation* animation = + animation_queue_get(&active_queue, &trickle_progress_callback); + if (animation != NULL) { + animation_queue_push(&free_queue, animation); + } +} + +/* Advance a queued progress animation one step if the timer has ticked. Called + * from usbPoll() so the trickle keeps moving while we block on host I/O. Gated + * on trickle_active so it is a no-op for every other flow (confirm dialogs, + * PIN entry, etc. are untouched). */ +void layout_animate_poll(void) { + if (trickle_active && is_animating()) { + animate(); + display_refresh(); + } +} + /* * layout_clear_animations() - Clear all animation from queue * @@ -827,6 +969,7 @@ void layout_add_animation(AnimateCallback callback, void* data, * none */ void layout_clear_animations(void) { + trickle_active = false; Animation* animation = animation_queue_pop(&active_queue); while (animation != NULL) { diff --git a/lib/board/timer.c b/lib/board/timer.c index 3868b6d16..c66c62e4d 100644 --- a/lib/board/timer.c +++ b/lib/board/timer.c @@ -25,6 +25,11 @@ #else #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN /* exclude winsock.h — it declares \ + shutdown(SOCKET,int) */ +#include /* Sleep() */ +#endif #endif #include "keepkey/board/keepkey_board.h" @@ -229,11 +234,13 @@ void timer_init(void) { nvic_set_priority(NVIC_TIM4_IRQ, 16 * 2); timer_enable_counter(TIM4); -#else +#elif !defined(_WIN32) void tim4_sighandler(int sig); signal(SIGALRM, tim4_sighandler); ualarm(1000, 1000); #endif + /* _WIN32: no SIGALRM/ualarm — libkkemu's kkemu_poll() drives timerisr_usr(). + */ } uint32_t fi_defense_delay(volatile uint32_t value) { @@ -287,8 +294,21 @@ void delay_us(uint32_t us) { void delay_ms(uint32_t ms) { remaining_delay = ms; +#ifdef _WIN32 + /* No async SIGALRM timer on Windows, and kkemu_poll() drives timerisr_usr() + * only once per poll — so a plain spin here would never make progress when + * delay_ms() is reached from inside usbPoll() (e.g. PIN/U2F/authenticator + * flows). Advance the tick ourselves from wall-clock Sleep instead. Keeps + * timeSinceWakeup + the runnable queue moving exactly like the SIGALRM path, + * and stays single-threaded (no data races). */ while (remaining_delay > 0) { + Sleep(1); + timerisr_usr(); } +#else + while (remaining_delay > 0) { + } +#endif } /* @@ -310,6 +330,12 @@ void delay_ms_with_callback(uint32_t ms, callback_func_t callback_func, if (remaining_delay % frequency_ms == 0) { (*callback_func)(); } +#ifdef _WIN32 + /* See delay_ms(): drive the tick from wall-clock Sleep on Windows so this + * loop terminates when reached from inside usbPoll(). */ + Sleep(1); + timerisr_usr(); +#endif } } @@ -348,7 +374,7 @@ void timerisr_usr(void) { #endif } -#ifdef EMULATOR +#if defined(EMULATOR) && !defined(_WIN32) void tim4_sighandler(int sig) { timerisr_usr(); } #endif diff --git a/lib/board/usb.c b/lib/board/usb.c index 0db5e32c6..53fb07a89 100644 --- a/lib/board/usb.c +++ b/lib/board/usb.c @@ -414,6 +414,11 @@ void usbInit(const char* origin_url) { void usbPoll(void) { // poll read buffer usbd_poll(usbd_dev); + // Keep a queued progress animation moving while we block on host I/O (e.g. + // Zcash proof generation on the host), so the screen never looks frozen. + // No-op unless a trickle animation is active, so all other flows are + // unaffected. + layout_animate_poll(); } void usbReconnect(void) { @@ -435,28 +440,31 @@ bool msg_write(MessageType msg_id, const void* msg) { if (!fields) return false; - TrezorFrameBuffer framebuf; - memset(&framebuf, 0, sizeof(framebuf)); - framebuf.frame.usb_header.hid_type = '?'; - framebuf.frame.header.pre1 = '#'; - framebuf.frame.header.pre2 = '#'; - framebuf.frame.header.id = __builtin_bswap16(msg_id); + /* Encode into the shared frame arena instead of a 12 KB automatic — that + * stack frame overflowed the zcash-privacy variant's SRAM gap. Safe on the + * single-threaded transport; see the FrameArena contract in messages.c. */ + TrezorFrameBuffer* framebuf = frame_arena_tx(); + memset(framebuf, 0, sizeof(*framebuf)); + framebuf->frame.usb_header.hid_type = '?'; + framebuf->frame.header.pre1 = '#'; + framebuf->frame.header.pre2 = '#'; + framebuf->frame.header.id = __builtin_bswap16(msg_id); pb_ostream_t os = - pb_ostream_from_buffer(framebuf.buffer, sizeof(framebuf.buffer)); + pb_ostream_from_buffer(framebuf->buffer, sizeof(framebuf->buffer)); if (!pb_encode(&os, fields, msg)) return false; - framebuf.frame.header.len = __builtin_bswap32(os.bytes_written); + framebuf->frame.header.len = __builtin_bswap32(os.bytes_written); // Chunk out data - for (uint32_t pos = 1; pos < sizeof(framebuf.frame) + os.bytes_written; + for (uint32_t pos = 1; pos < sizeof(framebuf->frame) + os.bytes_written; pos += 64 - 1) { uint8_t tmp_buffer[64] = {0}; tmp_buffer[0] = '?'; - memcpy(tmp_buffer + 1, ((const uint8_t*)&framebuf) + pos, 64 - 1); + memcpy(tmp_buffer + 1, ((const uint8_t*)framebuf) + pos, 64 - 1); #ifndef EMULATOR while (usbd_ep_write_packet(usbd_dev, ENDPOINT_ADDRESS_IN, tmp_buffer, @@ -476,28 +484,29 @@ bool msg_debug_write(MessageType msg_id, const void* msg) { if (!fields) return false; - TrezorFrameBuffer framebuf; - memset(&framebuf, 0, sizeof(framebuf)); - framebuf.frame.usb_header.hid_type = '?'; - framebuf.frame.header.pre1 = '#'; - framebuf.frame.header.pre2 = '#'; - framebuf.frame.header.id = __builtin_bswap16(msg_id); + /* Same shared-arena encode as msg_write — see the FrameArena contract. */ + TrezorFrameBuffer* framebuf = frame_arena_tx(); + memset(framebuf, 0, sizeof(*framebuf)); + framebuf->frame.usb_header.hid_type = '?'; + framebuf->frame.header.pre1 = '#'; + framebuf->frame.header.pre2 = '#'; + framebuf->frame.header.id = __builtin_bswap16(msg_id); pb_ostream_t os = - pb_ostream_from_buffer(framebuf.buffer, sizeof(framebuf.buffer)); + pb_ostream_from_buffer(framebuf->buffer, sizeof(framebuf->buffer)); if (!pb_encode(&os, fields, msg)) return false; - framebuf.frame.header.len = __builtin_bswap32(os.bytes_written); + framebuf->frame.header.len = __builtin_bswap32(os.bytes_written); // Chunk out data - for (uint32_t pos = 1; pos < sizeof(framebuf.frame) + os.bytes_written; + for (uint32_t pos = 1; pos < sizeof(framebuf->frame) + os.bytes_written; pos += 64 - 1) { uint8_t tmp_buffer[64] = {0}; tmp_buffer[0] = '?'; - memcpy(tmp_buffer + 1, ((const uint8_t*)&framebuf) + pos, 64 - 1); + memcpy(tmp_buffer + 1, ((const uint8_t*)framebuf) + pos, 64 - 1); #ifndef EMULATOR while (usbd_ep_write_packet(usbd_dev, ENDPOINT_ADDRESS_DEBUG_IN, tmp_buffer, diff --git a/lib/board/util.c b/lib/board/util.c index 027f53522..f9948b967 100644 --- a/lib/board/util.c +++ b/lib/board/util.c @@ -103,33 +103,48 @@ bool is_valid_ascii(const uint8_t* data, uint32_t size) { } /* convert number in base units to specified decimal precision */ -int base_to_precision(uint8_t* dest, const uint8_t* value, - const uint8_t dest_len, const uint8_t value_len, - const uint8_t precision) { - if (!(dest && value)) { - // invalid pointer - return -1; +int base_to_precision(uint8_t* dest, const uint8_t* value, size_t dest_len, + size_t value_len, uint8_t precision) { + if (!dest || !value || dest_len == 0 || value_len == 0) return -1; + + // Decimal inputs are signed as strings. Accept only their unique canonical + // representation so the value shown on the OLED is byte-for-byte bound to + // the value placed in the transaction. + if ((value_len > 1 && value[0] == '0')) return -1; + for (size_t i = 0; i < value_len; i++) { + if (value[i] < '0' || value[i] > '9') return -1; } - if (value_len + 1 > dest_len) { - // value too large for output buffer - return -1; + + size_t rendered_len; + if (precision == 0) { + rendered_len = value_len; + } else if (value_len <= precision) { + rendered_len = (size_t)precision + 2; // "0." + precision digits + } else { + rendered_len = value_len + 1; // digits plus decimal point } - memset(dest, '0', dest_len); - uint8_t leading_digits = - ((value_len - precision) > 0) ? (value_len - precision) : 0; - - if (!leading_digits) { - memcpy(dest, "0.", 2); - uint8_t offset = - 2 + (((precision - value_len) > 0) ? (precision - value_len) : 0); - strlcpy((char*)&dest[offset], (char*)value, value_len); + if (rendered_len + 1 > dest_len) return -1; + + size_t offset = 0; + if (precision == 0) { + memcpy(dest, value, value_len); + offset = value_len; + } else if (value_len <= precision) { + dest[offset++] = '0'; + dest[offset++] = '.'; + const size_t zeroes = (size_t)precision - value_len; + memset(dest + offset, '0', zeroes); + offset += zeroes; + memcpy(dest + offset, value, value_len); + offset += value_len; } else { - uint8_t copy_len = MIN((value_len - leading_digits), precision); + const size_t leading_digits = value_len - precision; memcpy(dest, value, leading_digits); - dest[leading_digits] = '.'; - strlcpy((char*)&dest[leading_digits + 1], (char*)&value[leading_digits], - copy_len); + offset = leading_digits; + dest[offset++] = '.'; + memcpy(dest + offset, value + leading_digits, precision); + offset += precision; } - dest[dest_len] = '\0'; + dest[offset] = '\0'; return 0; } diff --git a/lib/firmware/app_confirm.c b/lib/firmware/app_confirm.c index d0b17e9bc..fdd9378a8 100644 --- a/lib/firmware/app_confirm.c +++ b/lib/firmware/app_confirm.c @@ -30,6 +30,7 @@ #include "keepkey/board/layout.h" #include "keepkey/board/messages.h" #include "keepkey/board/confirm_sm.h" +#include "keepkey/board/font.h" #include "keepkey/board/usb.h" #include "keepkey/board/util.h" @@ -321,6 +322,31 @@ bool confirm_nano_address(const char* desc, const char* address) { desc, "%s", address); } +/* + * confirm_zcash_address() - Show zcash address confirmation + * + * INPUT + * - desc: description (title) shown on both screens + * - address: zcash unified address — full text on the first screen, + * QR on the second + * OUTPUT + * true/false of confirmation + * + */ +#if ZCASH_PRIVACY +bool confirm_zcash_address(const char* desc, const char* address) { + if (!confirm_with_custom_layout(&layout_zcash_address_text_notification, + ButtonRequestType_ButtonRequest_Address, desc, + "%s", address)) { + return false; + } + + return confirm_with_custom_layout(&layout_zcash_address_notification, + ButtonRequestType_ButtonRequest_Address, + desc, "%s", address); +} +#endif + /* * confirm_address() - Show address confirmation * @@ -391,6 +417,142 @@ bool confirm_sign_identity(const IdentityType* identity, body); } +bool confirm_bytes_is_text(const uint8_t* data, size_t size) { + if (!data && size != 0) return false; + bool has_visible_character = false; + for (size_t i = 0; i < size; i++) { + if (data[i] == '\n') continue; + if (data[i] < 0x20 || data[i] > 0x7e) return false; + if (data[i] != ' ') has_visible_character = true; + } + return has_visible_character; +} + +static size_t confirm_bytes_render_page(const uint8_t* data, size_t size, + bool text, + char rendered[BODY_CHAR_MAX]) { + if (size == 0) return 0; + + const Font* font = get_body_font(); + size_t consumed = 0; + size_t written = 0; + uint32_t row = 1; + uint16_t x = 0; + + while (consumed < size) { + if (text && data[consumed] == '\n') { + // A page boundary already advances past the current third row. Consume + // its terminating LF without adding a blank row to the next page. + consumed++; + if (row == BODY_ROWS) break; + if (written + 1 >= BODY_CHAR_MAX) break; + rendered[written++] = '\n'; + row++; + x = 0; + continue; + } + + char chars[2]; + size_t char_count; + if (text) { + chars[0] = (char)data[consumed]; + char_count = 1; + } else { + static const char hex[] = "0123456789abcdef"; + chars[0] = hex[data[consumed] >> 4]; + chars[1] = hex[data[consumed] & 0x0f]; + char_count = 2; + } + + uint16_t width = 0; + for (size_t i = 0; i < char_count; i++) { + width += font_get_char(font, chars[i])->width; + } + + // draw_string() wraps only at spaces and otherwise clips overlong words. + // Pre-insert hard line breaks so long addresses, hashes and IBC denoms are + // actually visible rather than merely counted as one renderer line. + if (text && chars[0] == ' ') { + uint32_t word_width = width; + for (size_t i = consumed + 1; + i < size && data[i] != ' ' && data[i] != '\n'; i++) { + word_width += font_get_char(font, (char)data[i])->width; + } + if (x == 0) { + // The renderer discards a leading separator. Consume it here only + // after the preceding word has been disclosed on this or the prior + // page; the visual line/page boundary remains the separator. + consumed++; + continue; + } + if ((uint32_t)x + word_width > BODY_WIDTH) { + if (row == BODY_ROWS) break; + if (written + 1 >= BODY_CHAR_MAX) break; + rendered[written++] = '\n'; + row++; + x = 0; + consumed++; + continue; + } + } + + if ((uint32_t)x + width > BODY_WIDTH) { + if (row == BODY_ROWS) break; + if (written + 1 >= BODY_CHAR_MAX) break; + rendered[written++] = '\n'; + row++; + x = 0; + } + if (written + char_count >= BODY_CHAR_MAX) break; + memcpy(rendered + written, chars, char_count); + written += char_count; + x += width; + consumed++; + } + + rendered[written] = '\0'; + return consumed; +} + +bool confirm_bytes(ButtonRequestType button_request, const char* title, + const uint8_t* data, size_t size) { + if (!title || (!data && size != 0)) return false; + if (size == 0) return confirm(button_request, title, "(empty)"); + + const bool text = confirm_bytes_is_text(data, size); + size_t pages = 0; + size_t offset = 0; + while (offset < size) { + char rendered[BODY_CHAR_MAX]; + const size_t take = + confirm_bytes_render_page(data + offset, size - offset, text, rendered); + if (take == 0) return false; + offset += take; + pages++; + } + + offset = 0; + for (size_t page = 0; page < pages; page++) { + char rendered[BODY_CHAR_MAX]; + const size_t take = + confirm_bytes_render_page(data + offset, size - offset, text, rendered); + if (take == 0) return false; + + char page_title[TITLE_CHAR_MAX]; + if (pages > 1 || !text) { + snprintf(page_title, sizeof(page_title), + text ? "%s %u/%u" : "%s Hex %u/%u", title, (unsigned)(page + 1), + (unsigned)pages); + } else { + strlcpy(page_title, title, sizeof(page_title)); + } + + if (!confirm(button_request, page_title, "%s", rendered)) return false; + offset += take; + } + return true; +} + bool confirm_omni(ButtonRequestType button_request, const char* title, const uint8_t* data, uint32_t size) { uint32_t tx_type; @@ -428,17 +590,5 @@ bool confirm_omni(ButtonRequestType button_request, const char* title, bool confirm_data(ButtonRequestType button_request, const char* title, const uint8_t* data, uint32_t size) { - const char* str = (const char*)data; - char hex[50 * 2 + 1]; - if (!is_valid_ascii(data, size)) { - if (size > 50) size = 50; - memset(hex, 0, sizeof(hex)); - data2hex(data, size, hex); - if (size > 50) { - hex[50 * 2 - 1] = '.'; - hex[50 * 2 - 2] = '.'; - } - str = hex; - } - return confirm(button_request, title, "%s", str); + return confirm_bytes(button_request, title, data, size); } diff --git a/lib/firmware/app_layout.c b/lib/firmware/app_layout.c index 25b0db18b..82bff62b3 100644 --- a/lib/firmware/app_layout.c +++ b/lib/firmware/app_layout.c @@ -597,6 +597,81 @@ void layout_nano_address_notification(const char* desc, const char* address, layout_notification_icon(type, &sp); } +#if ZCASH_PRIVACY +/* + * layout_zcash_address_notification() - Display zcash unified address QR + * with title; the second confirm step in the view-on-device flow. + * + * INPUT + * - desc: title text (e.g. "Zcash #0 Orchard") + * - address: zcash unified address (rendered as QR only — full text is + * shown on the preceding confirm step) + * - type: notification type + * OUTPUT + * none + */ +void layout_zcash_address_notification(const char* desc, const char* address, + NotificationType type) { + DrawableParams sp; + Canvas* canvas = layout_get_canvas(); + + call_leaving_handler(); + layout_clear(); + + if (strcmp(desc, "") != 0) { + const Font* title_font = get_title_font(); + sp.y = TOP_MARGIN_FOR_TWO_LINES; + sp.x = LEFT_MARGIN + 65; + sp.color = BODY_COLOR; + draw_string(canvas, title_font, desc, &sp, TRANSACTION_WIDTH - 2, + font_height(title_font) + BODY_FONT_LINE_PADDING); + } + + layout_address(address, QR_LARGE); + layout_notification_icon(type, &sp); +} + +/* + * layout_zcash_address_text_notification() - Display full zcash unified + * address text with title; the first confirm step in the view-on-device flow. + * + * INPUT + * - desc: title text (e.g. "Zcash #0 Orchard") + * - address: zcash unified address to display as text (3 lines) + * - type: notification type + * OUTPUT + * none + */ +void layout_zcash_address_text_notification(const char* desc, + const char* address, + NotificationType type) { + DrawableParams sp; + Canvas* canvas = layout_get_canvas(); + const Font* address_font = get_body_font(); + + call_leaving_handler(); + layout_clear(); + + if (strcmp(desc, "") != 0) { + const Font* title_font = get_title_font(); + sp.y = TOP_MARGIN_FOR_THREE_LINES; + sp.x = LEFT_MARGIN; + sp.color = BODY_COLOR; + draw_string(canvas, title_font, desc, &sp, TRANSACTION_WIDTH - 2, + font_height(title_font) + BODY_FONT_LINE_PADDING); + } + + /* Full UA below the title; -25 leaves the right column for confirm icons. */ + sp.y = TOP_MARGIN_FOR_THREE_LINES + ADDRESS_XPUB_TOP_MARGIN; + sp.x = LEFT_MARGIN; + sp.color = BODY_COLOR; + draw_string(canvas, address_font, address, &sp, TRANSACTION_WIDTH - 25, + font_height(address_font) + BODY_FONT_LINE_PADDING); + + layout_notification_icon(type, &sp); +} +#endif // ZCASH_PRIVACY + /* * layout_address_notification() - Display address notification * @@ -628,8 +703,35 @@ void layout_address_notification(const char* desc, const char* address, sp.y += font_height(address_font) + ADDRESS_TOP_MARGIN; sp.x = LEFT_MARGIN; sp.color = BODY_COLOR; + + /* Bech32 addresses longer than one line (p2wsh and p2tr are both 62 chars) + did not fit: draw_string() stops at the bottom of the canvas and drops the + remainder SILENTLY, so the user verified a prefix while the QR beside it + encoded the whole address. + Close the padding between lines rather than moving the block up -- the QR + is drawn last and would overwrite the start of a raised first line. */ + uint16_t address_line_height = + font_height(address_font) + BODY_FONT_LINE_PADDING; + { + const uint32_t lines = + calc_str_line(address_font, address, TRANSACTION_WIDTH); + if (lines > ONE_LINE) { + /* Close the inter-line padding first: raising the block is what collides + with the QR, which is drawn afterwards and would overwrite the start of + the first line. */ + address_line_height = font_height(address_font); + const uint16_t bottom = + sp.y + (lines - 1) * address_line_height + font_height(address_font); + if (bottom > KEEPKEY_DISPLAY_HEIGHT) { + /* Still short: raise by the minimum that fits, no more. */ + const uint16_t overflow = bottom - KEEPKEY_DISPLAY_HEIGHT; + sp.y = (sp.y > overflow) ? sp.y - overflow : 0; + } + } + } + draw_string(canvas, address_font, address, &sp, TRANSACTION_WIDTH, - font_height(address_font) + BODY_FONT_LINE_PADDING); + address_line_height); /* Draw description */ if (strcmp(desc, "") != 0) { @@ -693,7 +795,8 @@ void layout_pin(const char* str, char pin[]) { * OUTPUT * none */ -void layout_cipher(const char* current_word, const char* cipher) { +void layout_cipher(const char* current_word, const char* cipher, + const char* prev_word_info) { DrawableParams sp; const Font* title_font = get_body_font(); Canvas* canvas = layout_get_canvas(); @@ -701,8 +804,18 @@ void layout_cipher(const char* current_word, const char* cipher) { call_leaving_handler(); layout_clear(); - /* Draw prompt */ - sp.y = 11; + /* Draw previous word info at top-left -- must be x < 76 to avoid + * being wiped by cipher animation which clears x >= CIPHER_START_X */ + if (prev_word_info && prev_word_info[0]) { + sp.y = 2; + sp.x = 4; + sp.color = CIPHER_FONT_COLOR; /* gray -- less prominent than current word */ + draw_string(canvas, title_font, prev_word_info, &sp, 68, + font_height(title_font)); + } + + /* Draw prompt -- push down when prev word is shown */ + sp.y = (prev_word_info && prev_word_info[0]) ? 14 : 11; sp.x = 4; sp.color = BODY_COLOR; draw_string(canvas, title_font, "Recovery Cipher:", &sp, 58, diff --git a/scripts/emulator/capture-thor-percent.py b/scripts/emulator/capture-thor-percent.py new file mode 100644 index 000000000..bac5d6b56 --- /dev/null +++ b/scripts/emulator/capture-thor-percent.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Capture the THOR/Maya LP-withdraw percent confirm screens from kkemu. + +Evidence tool for the integer-percent rendering change (no float formats). +""" + +import os +import sys +import time +from pathlib import Path + +os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") +os.environ.setdefault("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK", "true") + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "deps" / "python-keepkey")) +sys.path.insert(0, str(ROOT / "scripts" / "zoo")) + +from keepkeylib.client import KeepKeyDebuglinkClient +from keepkeylib.transport_udp import UDPTransport +from keepkeylib import messages_pb2 as proto +from keepkeylib.tools import parse_path + + +def dump_layout(debug_client, filename): + """Save the raw 2048-byte 1bpp OLED layout; converted to PNG on the host.""" + state = debug_client._call(proto.DebugLinkGetState()) + if not state.layout: + return False + with open(filename, "wb") as f: + f.write(state.layout) + return True + +OUT = Path(sys.argv[1]).resolve() +OUT.mkdir(parents=True, exist_ok=True) + +main_ep = os.environ.get("KK_TRANSPORT_MAIN", "127.0.0.1:11044") +debug_ep = os.environ.get("KK_TRANSPORT_DEBUG", "127.0.0.1:11045") + +client = KeepKeyDebuglinkClient(UDPTransport(main_ep)) +client.set_debuglink(UDPTransport(debug_ep)) + +client.auto_button = True +client.wipe_device() +client.load_device_by_mnemonic( + mnemonic=("all " * 11 + "all").strip(), + pin="", + passphrase_protection=False, + label="percent evidence", + language="english", +) + +counter = {"n": 0} +real_press_yes = client.debug.press_yes + + +def capturing_press_yes(): + counter["n"] += 1 + time.sleep(0.2) + path = OUT / ("thor-withdraw-%02d.layout" % counter["n"]) + dump_layout(client.debug, str(path)) + print(path) + real_press_yes() + + +client.debug.press_yes = capturing_press_yes + + +THOR_ROUTER = "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146" + + +def _build_deposit_calldata(memo): + selector = bytes.fromhex("1fece7b4") + vault = bytes(12) + bytes.fromhex(THOR_ROUTER) + asset = bytes(32) + amount = (500000000000000000).to_bytes(32, "big") + memo_offset = (4 * 32).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 + return selector + vault + asset + amount + memo_offset + memo_len + \ + memo_bytes + bytes(pad - len(memo_bytes)) + + +from binascii import unhexlify + +client.ethereum_sign_tx( + n=parse_path("m/44'/60'/0'/0/0"), + nonce=1, + gas_price=50000000000, + gas_limit=300000, + to=unhexlify(THOR_ROUTER), + value=500000000000000000, + chain_id=1, + data=_build_deposit_calldata( + "WITHDRAW:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:2505" + ), +) +print("captured %d screens" % counter["n"]) diff --git a/tools/firmware/keepkey.ld b/tools/firmware/keepkey.ld index 964b43676..9538bc4cd 100644 --- a/tools/firmware/keepkey.ld +++ b/tools/firmware/keepkey.ld @@ -71,3 +71,13 @@ _buttonusr_isr = _comram_end - 4; _data_size = SIZEOF(.data); _codelen = SIZEOF(.text) + SIZEOF(.data) + SIZEOF(.ARM.exidx) + SIZEOF(.version); + +/* Runtime SRAM gate: everything between the end of static allocation (.bss) + * and the top-of-RAM stack is the ONLY memory the running firmware has for + * call frames. RC7's privacy-enabled build shipped with an 11.2 KB gap while + * msg_write() put a 12.4 KB frame on the stack — a guaranteed boot-path + * overwrite of static memory. Never again: require a 16 KiB reserve at link + * time, for every variant. (Initial limit — replace with measured worst-case + * high-water + margin once the -fstack-usage CI reporting has data.) */ +ASSERT((_stack - _ebss) >= 0x4000, + "Insufficient runtime SRAM: require 16 KiB stack/heap reserve between _ebss and _stack"); diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 5ef9e8489..6c7262aeb 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -1,4 +1,5 @@ set(sources + app_confirm.cpp coins.cpp cosmos.cpp eos.cpp diff --git a/unittests/firmware/app_confirm.cpp b/unittests/firmware/app_confirm.cpp new file mode 100644 index 000000000..fdec4316d --- /dev/null +++ b/unittests/firmware/app_confirm.cpp @@ -0,0 +1,42 @@ +extern "C" { +#include "keepkey/firmware/app_confirm.h" +} + +#include "gtest/gtest.h" + +TEST(AppConfirm, MultilineAsciiMessageUsesTextMode) { + static const char message[] = + "Welcome to DegenQuest!\n" + "\n" + "Sign this message to authenticate your wallet.\n" + "\n" + "This request will not trigger a blockchain transaction or cost any gas " + "fees.\n" + "\n" + "Nonce: d2d8d32b-a7fc-4129-a60b-e0664f0b2169"; + + ASSERT_EQ(193U, sizeof(message) - 1); + EXPECT_TRUE(confirm_bytes_is_text(reinterpret_cast(message), + sizeof(message) - 1)); +} + +TEST(AppConfirm, UnsafeControlsAndBinaryBytesUseHexMode) { + static const uint8_t spaces[] = {' ', ' ', ' '}; + static const uint8_t blank_lines[] = {'\n', '\n'}; + static const uint8_t nul[] = {'a', 0x00, 'b'}; + static const uint8_t tab[] = {'a', '\t', 'b'}; + static const uint8_t carriage_return[] = {'a', '\r', 'b'}; + static const uint8_t escape[] = {'a', 0x1b, 'b'}; + static const uint8_t del[] = {'a', 0x7f, 'b'}; + static const uint8_t utf8[] = {0xc3, 0xa9}; + + EXPECT_FALSE(confirm_bytes_is_text(spaces, sizeof(spaces))); + EXPECT_FALSE(confirm_bytes_is_text(blank_lines, sizeof(blank_lines))); + EXPECT_FALSE(confirm_bytes_is_text(nul, sizeof(nul))); + EXPECT_FALSE(confirm_bytes_is_text(tab, sizeof(tab))); + EXPECT_FALSE(confirm_bytes_is_text(carriage_return, sizeof(carriage_return))); + EXPECT_FALSE(confirm_bytes_is_text(escape, sizeof(escape))); + EXPECT_FALSE(confirm_bytes_is_text(del, sizeof(del))); + EXPECT_FALSE(confirm_bytes_is_text(utf8, sizeof(utf8))); + EXPECT_FALSE(confirm_bytes_is_text(nullptr, 1)); +} From 7dc66f786e1bd70a457283ae5c378bd89cd8aac7 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:11:12 -0300 Subject: [PATCH 04/40] fix(transport): share one frame arena instead of a 12 KB stack frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit msg_write() encoded outbound frames into a MAX_FRAME_SIZE TrezorFrameBuffer declared as an automatic — a ~12.4 KB stack frame. With the Orchard engine linked in, static SRAM left an ~11 KB gap and the device hard-faulted on boot during RC7 bring-up. Inbound reassembly, outbound encode and the recovery-cipher wordlist scratch are mutually exclusive on a strictly cooperative single-threaded transport (there is no USB ISR; usbd_poll runs only from explicit usbPoll call sites), so they now share one static union. Acquiring the arena for TX or scratch drops any partially reassembled inbound frame; only a host that pipelines a second request before reading the first response can observe it, and it gets a Failure on its next continuation frame rather than silent corruption. The reassembly state moves to file scope so arena acquisition can invalidate it. --- include/keepkey/board/messages.h | 6 ++ lib/board/messages.c | 130 +++++++++++++++++++++---------- 2 files changed, 96 insertions(+), 40 deletions(-) diff --git a/include/keepkey/board/messages.h b/include/keepkey/board/messages.h index fd0c6e4c3..9b6dbbf97 100644 --- a/include/keepkey/board/messages.h +++ b/include/keepkey/board/messages.h @@ -102,6 +102,12 @@ typedef void (*raw_msg_handler_t)(RawMessage* msg, uint32_t frame_length); const pb_field_t* message_fields(MessageMapType type, MessageType msg_id, MessageMapDirection dir); +/* Shared frame arena (defined in messages.c). Acquiring the arena for TX or + * scratch drops any partially reassembled inbound frame — see the FrameArena + * contract in messages.c. Single-threaded transport only. */ +TrezorFrameBuffer* frame_arena_tx(void); +uint16_t* frame_arena_scratch2049(void); + bool msg_write(MessageType msg_id, const void* msg); #if DEBUG_LINK diff --git a/lib/board/messages.c b/lib/board/messages.c index ffa5f3079..6e111f9e6 100644 --- a/lib/board/messages.c +++ b/lib/board/messages.c @@ -43,6 +43,66 @@ static msg_debug_link_get_state_t msg_debug_link_get_state; */ bool reset_msg_stack = false; +/* ── Shared frame arena ────────────────────────────────────────────────── + * One MAX_FRAME_SIZE-class buffer shared by three mutually-exclusive users: + * + * 1. Inbound frame reassembly (usb_rx_helper writes frame_arena.rx). + * 2. Outbound wire encode (msg_write / msg_debug_write encode into + * frame_arena.tx via frame_arena_tx()) — previously a 12 KB automatic + * TrezorFrameBuffer on the msg_write stack, which is what overflowed + * the zcash-privacy variant's 11 KB stack gap on the STM32F205. + * 3. Large transient in-handler scratch (frame_arena_scratch2049 for the + * recovery-cipher wordlist permutation). + * + * Why this is safe: the transport is strictly cooperative/single-threaded. + * usbd_poll() runs only from explicit usbPoll() call sites (main loop, the + * tiny-message pump, u2f) — there is no USB ISR — so RX can never preempt + * a TX encode or an executing handler. Tiny-mode RX (button/pin/cancel + * during a handler wait) goes through msg_read_tiny's own 64-byte buffer + * and never touches this arena. RAW dispatch hands handlers the 64-byte + * packet buffer, not the arena. + * + * Contract: acquiring the arena for TX or scratch DROPS any partially + * reassembled inbound frame. Only a host that pipelines a second request + * before reading the first response can hit this; it gets a Failure on its + * next continuation frame instead of silent corruption (the protocol is + * strict request-response). + */ +typedef union { + uint8_t rx[MAX_FRAME_SIZE]; + TrezorFrameBuffer tx; + uint16_t scratch_u16[2049]; +} FrameArena; + +static FrameArena frame_arena; + +/* Inbound reassembly state — file scope so arena acquisition can reset it. */ +static bool rxFirstFrame = true; +static uint16_t rxMsgId = 0xffff; +static uint32_t rxMsgSize = 0; +static size_t + rxCursor; //< Index into frame_arena.rx where the next frame lands. +static const MessagesMap_t* rxEntry = NULL; + +static void frame_arena_rx_reset(void) { + rxMsgId = 0xffff; + rxMsgSize = 0; + memset(frame_arena.rx, 0, sizeof(frame_arena.rx)); + rxCursor = 0; + rxFirstFrame = true; + rxEntry = NULL; +} + +TrezorFrameBuffer* frame_arena_tx(void) { + frame_arena_rx_reset(); + return &frame_arena.tx; +} + +uint16_t* frame_arena_scratch2049(void) { + frame_arena_rx_reset(); + return frame_arena.scratch_u16; +} + /* * message_map_entry() - Finds a requested message map entry * @@ -193,21 +253,15 @@ static void raw_dispatch(const MessagesMap_t* entry, const uint8_t* msg, /// Common helper that handles USB messages from host void usb_rx_helper(const uint8_t* buf, size_t length, MessageMapType type) { - static bool firstFrame = true; - - static uint16_t msgId; - static uint32_t msgSize; - static uint8_t msg[MAX_FRAME_SIZE]; - static size_t - cursor; //< Index into msg where the current frame is to be written. - static const MessagesMap_t* entry; - - if (firstFrame) { - msgId = 0xffff; - msgSize = 0; - memset(msg, 0, sizeof(msg)); - cursor = 0; - entry = NULL; + /* Reassembly state + buffer live at file scope (frame_arena.rx) so that + * frame_arena_tx()/frame_arena_scratch2049() can invalidate a partial + * inbound frame — see the FrameArena contract above. */ + if (rxFirstFrame) { + rxMsgId = 0xffff; + rxMsgSize = 0; + memset(frame_arena.rx, 0, sizeof(frame_arena.rx)); + rxCursor = 0; + rxEntry = NULL; } assert(buf != NULL); @@ -222,7 +276,7 @@ void usb_rx_helper(const uint8_t* buf, size_t length, MessageMapType type) { goto reset; } - if (firstFrame && (buf[1] != '#' || buf[2] != '#')) { + if (rxFirstFrame && (buf[1] != '#' || buf[2] != '#')) { (*msg_failure)(FailureType_Failure_UnexpectedMessage, "Malformed packet"); goto reset; } @@ -231,25 +285,25 @@ void usb_rx_helper(const uint8_t* buf, size_t length, MessageMapType type) { const uint8_t* frame; size_t frameSize; - if (firstFrame) { + if (rxFirstFrame) { // Reset the buffer that we're writing fragments into. - memset(msg, 0, sizeof(msg)); + memset(frame_arena.rx, 0, sizeof(frame_arena.rx)); // Then fish out the id / size, which are big-endian uint16 / // uint32's respectively. - msgId = buf[4] | ((uint16_t)buf[3]) << 8; - msgSize = buf[8] | ((uint32_t)buf[7]) << 8 | ((uint32_t)buf[6]) << 16 | - ((uint32_t)buf[5]) << 24; + rxMsgId = buf[4] | ((uint16_t)buf[3]) << 8; + rxMsgSize = buf[8] | ((uint32_t)buf[7]) << 8 | ((uint32_t)buf[6]) << 16 | + ((uint32_t)buf[5]) << 24; // Determine callback handler and message map type. - entry = message_map_entry(type, msgId, IN_MSG); + rxEntry = message_map_entry(type, rxMsgId, IN_MSG); // And reset the cursor. - cursor = 0; + rxCursor = 0; // Then take note of the fragment boundaries. frame = &buf[9]; - frameSize = MIN(length - 9, msgSize); + frameSize = MIN(length - 9, rxMsgSize); } else { // Otherwise it's a continuation/fragment. frame = &buf[1]; @@ -257,49 +311,45 @@ void usb_rx_helper(const uint8_t* buf, size_t length, MessageMapType type) { } // If the msgId wasn't in our map, bail. - if (!entry) { + if (!rxEntry) { (*msg_failure)(FailureType_Failure_UnexpectedMessage, "Unknown message"); goto reset; } - if (entry->dispatch == RAW) { + if (rxEntry->dispatch == RAW) { /* Call dispatch for every segment since we are not buffering and parsing, * and assume the raw dispatched callbacks will handle their own state and * buffering internally */ - raw_dispatch(entry, frame, frameSize, msgSize); - firstFrame = false; + raw_dispatch(rxEntry, frame, frameSize, rxMsgSize); + rxFirstFrame = false; return; } size_t end; - if (check_uadd_overflow(cursor, frameSize, &end) || sizeof(msg) < end) { + if (check_uadd_overflow(rxCursor, frameSize, &end) || + sizeof(frame_arena.rx) < end) { (*msg_failure)(FailureType_Failure_UnexpectedMessage, "Malformed message"); goto reset; } // Copy content to frame buffer. - memcpy(&msg[cursor], frame, frameSize); + memcpy(&frame_arena.rx[rxCursor], frame, frameSize); // Advance the cursor. - cursor = end; + rxCursor = end; // Only parse and message map if all segments have been buffered. - bool last_segment = cursor >= msgSize; + bool last_segment = rxCursor >= rxMsgSize; if (!last_segment) { - firstFrame = false; + rxFirstFrame = false; return; } - dispatch(entry, msg, msgSize); + dispatch(rxEntry, frame_arena.rx, rxMsgSize); reset: - msgId = 0xffff; - msgSize = 0; - memset(msg, 0, sizeof(msg)); - cursor = 0; - firstFrame = true; - entry = NULL; + frame_arena_rx_reset(); } /* Tiny messages */ From c74233f6a7953d39339ca8d00604d767e8289523 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 16:22:05 -0300 Subject: [PATCH 05/40] docs: map release dependency review ranges --- deps/python-keepkey | 2 +- docs/security/7.15.0-dependency-review-map.md | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 docs/security/7.15.0-dependency-review-map.md diff --git a/deps/python-keepkey b/deps/python-keepkey index b44f1b367..fabd6c618 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit b44f1b367e7b8ce98d88a49b3457443335365d8f +Subproject commit fabd6c6189b7f1b3ea7cbd1d372fc13729761178 diff --git a/docs/security/7.15.0-dependency-review-map.md b/docs/security/7.15.0-dependency-review-map.md new file mode 100644 index 000000000..3dd638fa3 --- /dev/null +++ b/docs/security/7.15.0-dependency-review-map.md @@ -0,0 +1,26 @@ +# 7.15.0 dependency review map + +The firmware repository records submodules as commit pointers, so GitHub's +parent-repository diff cannot show the code inside a dependency range. Reviewers +must audit the ranges below in the dependency repositories as part of the 7.15 +release review. + +| Dependency | Old pointer | Release pointer | Range size | Review anchor | Stack placement | +| --- | --- | --- | ---: | --- | --- | +| `deps/crypto/trezor-firmware` | `03d8a55a832fb61bb89477ef7239a80ecb367080` | `cdc05bebe9e6989cf711e1b5bea6324fd09f848e` | 23 commits; 57 files; +6,329/-384 | [keepkey/trezor-firmware#8](https://github.com/keepkey/trezor-firmware/pull/8) | Foundation; required by the firmware crypto features | +| `deps/device-protocol` | `d637b78291a423fd8119df9935a9365be8a7758e` | `cf308fd5e76f3f2e02ba38347ceccaa382b08505` | 33 commits; 18 files; +1,089/-76 | [keepkey/device-protocol#119](https://github.com/keepkey/device-protocol/pull/119) | Foundation; required to generate the feature message types | +| `deps/python-keepkey` | `fabd6c6189b7f1b3ea7cbd1d372fc13729761178` | `b44f1b367e7b8ce98d88a49b3457443335365d8f` | 128 commits; 56 files; +9,868/-1,028 | [keepkey/python-keepkey#211](https://github.com/keepkey/python-keepkey/pull/211) | Final integration bundle; deliberately deferred so foundation CI does not run future-feature tests | + +The crypto and protocol ranges are reviewed as dependency baselines rather than +being presented as CI-only changes. The Python client range is applied only +after all firmware feature bundles are present, making its integration suite a +final-stack gate instead of a false failure on the foundation PR. + +To reproduce a range review from an initialized checkout, run the following for +each dependency: + +```sh +git -C deps/ log --oneline .. +git -C deps/ diff --stat .. +git -C deps/ diff .. +``` From ef822cfff16a08b39b98f7b90cbcac012a919795 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 8 Aug 2026 18:56:21 -0300 Subject: [PATCH 06/40] ci: gate the stacked release train, and don't assert a fix two PRs early Two problems with how this workflow gated the 7.15 audit stack. The pull_request trigger listed only master/develop/alpha, so every PR in the train except the first -- each based on the previous release branch -- matched nothing and reported "no checks" in the GitHub UI. The push trigger already covers 'release/**', so those branches were being built and were red; the PRs just never showed it. A reviewer saw a clean PR sitting on a failing build. Add 'release/**' to the pull_request branches so the checks attach to the PR. Second, the RC18 invariant forbidding libc random() in lib/rand/rng.c was introduced here, in the foundation slice, but the emulatorRandom() change that satisfies it arrives three slices later with the rest of the RNG work. The result was that this branch and the clear-signing branch failed check-submodules and skipped every build and test job behind it -- nothing was compiled or run on either. Move the invariant to the commit that makes it true; a gate that fails for code its own PR does not contain teaches reviewers to ignore the gate. No invariant is lost: it comes back, unchanged, in the RNG slice. --- .github/workflows/ci.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e0c5e4e8..a3fc8d337 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,12 @@ name: CI on: push: branches: [master, develop, alpha, 'feature/**', 'fix/**', 'release/**', 'hotfix/**'] + # 'release/**' is here so a stacked release train is actually gated. A PR whose + # base is another release branch does not match master/develop/alpha, so it + # reported "no checks" in the UI while the push-triggered run on the same + # branch was red -- a reviewer saw a clean PR over a failing build. pull_request: - branches: [master, develop, alpha] + branches: [master, develop, alpha, 'release/**'] workflow_dispatch: inputs: publish_emulator: @@ -234,11 +238,6 @@ jobs: echo "::error::The firmware builder must be pinned by manifest digest" exit 1 fi - if git grep -n -E 'return[[:space:]]+random\(\)' -- \ - lib/rand/rng.c; then - echo "::error::Emulator cryptography must not use libc random()" - exit 1 - fi if git grep -n -E \ 'storage_(get|upsert)ClearsignIdentity|persistent_identity_for' \ -- lib include; then From 31cda988f36bbe0bb4c725e944b13107e03915af Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:16:05 -0300 Subject: [PATCH 07/40] feat(clearsign): verify signed transaction metadata on device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the signed-metadata engine: a host may supply a description of what an EVM transaction does (contract alias, method, argument labels), and the device displays it only if the blob carries a signature from a trust anchor it already holds. Unsigned or wrongly signed metadata is ignored — it can never turn a blind-signing prompt into a friendly one. - Anchor verification over secp256k1 with the schema bound to the request, so a signature captured from one transaction cannot dress up another. - Runtime signer table only; nothing is persisted to public flash. A rogue entry surviving a reboot would suppress the raw-data screen, which is exactly the failure this design refuses. - tiny-json gains the bounds needed to parse metadata safely on device. - unittests/firmware/signed_metadata.cpp covers the accept/reject matrix, including malformed blobs and signature/schema mismatches. Message plumbing and the Ethereum call sites arrive in the clear-sign EVM branch; this commit is the engine and its tests only. --- include/keepkey/firmware/signed_metadata.h | 231 +++ include/keepkey/firmware/tiny-json.h | 5 +- lib/firmware/CMakeLists.txt | 1 + lib/firmware/signed_metadata.c | 1036 ++++++++++++ lib/firmware/tiny-json.c | 8 +- unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/signed_metadata.cpp | 1780 ++++++++++++++++++++ 7 files changed, 3058 insertions(+), 4 deletions(-) create mode 100644 include/keepkey/firmware/signed_metadata.h create mode 100644 lib/firmware/signed_metadata.c create mode 100644 unittests/firmware/signed_metadata.cpp diff --git a/include/keepkey/firmware/signed_metadata.h b/include/keepkey/firmware/signed_metadata.h new file mode 100644 index 000000000..179c4c2d5 --- /dev/null +++ b/include/keepkey/firmware/signed_metadata.h @@ -0,0 +1,231 @@ +#ifndef KEEPKEY_FIRMWARE_SIGNED_METADATA_H +#define KEEPKEY_FIRMWARE_SIGNED_METADATA_H + +#include +#include +#include + +typedef struct _EthereumSignTx EthereumSignTx; + +#define METADATA_MAX_ARGS 8 +#define METADATA_MAX_METHOD_LEN 64 +#define METADATA_MAX_ARG_NAME_LEN 32 +/* Sized for TOKEN_AMOUNT: decimals(1) + symbol_len(1) + symbol(<=10) + + * amount(<=32). Other formats remain capped at 32 by their own guards. */ +#define METADATA_MAX_ARG_VALUE_LEN 44 +#define METADATA_MAX_TOKEN_SYMBOL_LEN 10 +#define METADATA_MAX_KEYS 4 +#define METADATA_ALIAS_MAX_LEN 31 +/* Identity icon cap (1bpp mono RLE). Must equal the device-protocol + * LoadClearsignSigner.icon max_size and storage.h CLEARSIGN_ICON_MAX + * (static-asserted in signed_metadata.c). */ +#define METADATA_ICON_MAX 384 +/* hex(first 4 bytes of sha256(pubkey)) + NUL */ +#define METADATA_FINGERPRINT_LEN 9 + +typedef enum { + METADATA_OPAQUE = 0, + METADATA_VERIFIED = 1, + METADATA_MALFORMED = 2, +} MetadataClassification; + +/* + * Blob format versions (the first payload byte). + * + * LEGACY (v1): per-transaction. The blob carries a committed tx_hash and the + * pre-decoded argument VALUES; the host is trusted for the decode and the + * device only binds it to the signed digest (signed_metadata_enforce). This is + * the format that requires an online, per-tx signer holding the attestation + * key. + * + * SCHEMA (v2): static. The blob carries NO tx_hash and NO values — only how to + * decode the call: (chainId, contract, selector, method, per-arg name + display + * format [+ static decimals/symbol]). The DEVICE decodes the argument values + * from the exact calldata it is about to sign, so the display is bound to the + * signature by construction. No tx_hash, no per-tx signing: the catalog is + * signed ONCE, offline, and can be served from a host CDN (no hot key). + */ +#define METADATA_VERSION_LEGACY 0x01 +#define METADATA_VERSION_SCHEMA 0x02 + +/* + * Argument display formats. The goal of clear-signing is that the device + * answers WHO the user is dealing with (validated contract address, protocol + * name), WHAT the transaction does (method + human-readable typed args: + * recipient, "Amount: 1,000 USDC"), and WHY the decode can be trusted + * (signer attestation bound to the exact tx hash). RAW/BYTES hex dumps are + * the fallback, not the product. + */ +typedef enum { + ARG_FORMAT_RAW = 0, /* hex dump (first 16 bytes) */ + ARG_FORMAT_ADDRESS = 1, /* 20 bytes -> full EIP-55 address, never truncated */ + ARG_FORMAT_AMOUNT = 2, /* big-endian uint256 -> raw integer, "wei" */ + ARG_FORMAT_BYTES = 3, /* hex dump (first 16 bytes) */ + /* Attested printable label, e.g. protocol: "Uniswap V2". Same character + * rules as the signer alias minus length (printable subset, no '%'). */ + ARG_FORMAT_STRING = 4, + /* decimals(1) + symbol_len(1) + symbol(<=10, [A-Za-z0-9]) + amount(1..32 + * big-endian). Rendered as a decimal-scaled amount with the symbol, e.g. + * "1000 USDC"; all-0xFF 32-byte amounts render "UNLIMITED ". */ + ARG_FORMAT_TOKEN_AMOUNT = 5, +} ArgFormat; + +typedef struct { + char name[METADATA_MAX_ARG_NAME_LEN + 1]; + ArgFormat format; + uint8_t value[METADATA_MAX_ARG_VALUE_LEN]; + uint16_t value_len; +} MetadataArg; + +typedef struct { + uint8_t version; + uint32_t chain_id; + uint8_t contract_address[20]; + uint8_t selector[4]; + uint8_t tx_hash[32]; + char method_name[METADATA_MAX_METHOD_LEN + 1]; + uint8_t num_args; + MetadataArg args[METADATA_MAX_ARGS]; + MetadataClassification classification; + uint32_t timestamp; + uint8_t key_id; + uint8_t signature[64]; + uint8_t recovery; +} SignedMetadata; + +bool signed_metadata_available(void); + +/* True when the stored v2 (schema) metadata was decoded from the current tx's + * calldata by the most recent signed_metadata_matches_tx() call. Reset at the + * top of every matches_tx() so it reflects only that call (never a stale prior + * match). The v2 enforce path requires it; exported for unit testing. */ +bool signed_metadata_schema_decoded(void); + +/* True when the matched schema is v2 AND the transaction moves native value. + * A v2 schema cannot express a value binding, so the caller MUST still show + * the amount/recipient screen; only the raw-calldata screen may be replaced + * by the decoded display. */ +bool signed_metadata_schema_moves_value(void); + +void signed_metadata_clear(void); + +/* + * Runtime-loaded clearsign signers (phase 1: the ONLY verification path). + * + * A signer is a compressed secp256k1 pubkey + display alias loaded into a + * key slot at the host's request, gated by a mandatory on-device confirm + * (see fsm_msgLoadClearsignSigner). Loaded signers live in RAM only and are + * gone on reboot. Metadata verified by a loaded signer always shows a + * warning screen naming the alias before any clearsign page — only the + * built-in (phase 2) keys sign warning-free. + */ + +/* Pure validation: slot in range and not occupied by a built-in key, pubkey a + * valid compressed secp256k1 point, alias non-empty printable ASCII within + * METADATA_ALIAS_MAX_LEN. No state, no I/O. */ +bool signed_metadata_signer_valid(uint8_t key_id, const uint8_t* pubkey, + size_t pubkey_len, const char* alias); + +/* Store a signer into a slot. Caller (the FSM handler) MUST have passed + * signed_metadata_signer_valid() and obtained on-device user confirmation + * first — this function is the post-consent write, nothing more. + * + * icon (optional, icon_len<=384, 1bpp mono RLE) is kept as the session icon for + * the slot; icon_len==0 => text-only identity. RC18 rejects persist=true before + * changing the session slot because public storage lacks authenticated + * integrity. */ +bool signed_metadata_store_signer(uint8_t key_id, const uint8_t* pubkey, + const char* alias, const uint8_t* icon, + uint8_t icon_w, uint8_t icon_h, + uint16_t icon_len, bool persist); + +/* Resolve a slot's alias / icon from the RAM session copy. alias returns NULL + * and icon returns false when the slot has no signer / no icon (text-only). + * Used by the per-tx confirm. */ +const char* signed_metadata_signer_alias(uint8_t key_id); +bool signed_metadata_signer_icon(uint8_t key_id, const uint8_t** icon_out, + uint8_t* w_out, uint8_t* h_out, + uint16_t* len_out); + +/* The LoadClearsignSigner consent screen: leads with the identity's logo (if + * any) + alias + fingerprint. Returns true iff the user confirmed. The FSM + * handler calls this before storing the signer. */ +bool signed_metadata_confirm_load(const char* alias, const char* fingerprint, + const uint8_t* icon, uint8_t icon_w, + uint8_t icon_h, uint16_t icon_len); + +/* Drop all runtime-loaded signers (and any metadata they verified). */ +void signed_metadata_clear_signers(void); + +/* out = hex of the first 4 bytes of sha256(pubkey[33]), NUL-terminated. + * Shown at load-confirm and on the per-tx warning screen so the user can + * correlate the two. */ +void signed_metadata_pubkey_fingerprint(const uint8_t pubkey[33], + char out[METADATA_FINGERPRINT_LEN]); + +/* True when the currently stored metadata was verified by a runtime-loaded + * signer (=> its confirm flow is warning-first, never "Insight Verified"). */ +bool signed_metadata_from_loaded_signer(void); +/* True when key_id currently resolves to a runtime-loaded signer. This lets + * non-EVM callers preserve their normal Advanced-mode review after showing an + * additive schema decode. */ +bool signed_metadata_signer_is_runtime(uint8_t key_id); +MetadataClassification signed_metadata_process(const uint8_t* payload, + size_t payload_len, + uint8_t key_id); + +/* Generic attestation check reusing the (chain-agnostic) clear-sign signer + * keyring: returns true iff a signer is loaded/pinned for `key_id` AND the + * 64-byte compact ECDSA signature `sig` verifies over sha256(data). Used by + * non-EVM paths (e.g. Solana signed token definitions) that want to trust + * host-supplied data only when a loaded signer attests to it. */ +bool signed_metadata_verify_attestation(uint8_t key_id, const uint8_t* data, + size_t data_len, const uint8_t* sig, + size_t sig_len); + +/* Fingerprint (hex of sha256(pubkey)[0:4]) of the signer loaded/pinned in + * `key_id`, written NUL-terminated to `out`. Returns false if no signer is + * present. Lets non-EVM callers disambiguate signers (aliases are not unique) + * the same way the EVM per-tx warning does. */ +bool signed_metadata_signer_fingerprint(uint8_t key_id, + char out[METADATA_FINGERPRINT_LEN]); +/* Display gate: does this metadata plausibly describe `msg`? Binds contract + * address, selector and chain id so the wrong method is never shown. The + * authoritative full-tx binding is enforced later by signed_metadata_enforce(). + */ +bool signed_metadata_matches_tx(const EthereumSignTx* msg); +bool signed_metadata_confirm(void); + +/* True once a verified confirm has suppressed the raw-data confirmation, i.e. + * the signature is now gated on the metadata matching the final tx hash. */ +bool signed_metadata_relied(void); + +/* Authoritative binding, called after the real Ethereum sighash is finalized + * (in send_signature, the only point it exists). Returns true if signing may + * proceed: either no metadata was relied upon, or the relied-upon metadata's + * committed tx_hash equals `hash`. Fail-closed on any mismatch. */ +bool signed_metadata_enforce(const uint8_t hash[32]); + +/* Pure enforcement decision, exported for unit testing. Given the module flags + * and the metadata's committed tx hash, decides whether signing may proceed for + * the just-finalized `hash`. signed_metadata_enforce() is a thin wrapper that + * feeds the module state into this function. No state, no I/O. */ +bool signed_metadata_enforce_decision(bool relied, bool available, + int classification, + const uint8_t* stored_hash, + const uint8_t* hash); + +/* Pure enforcement decision for v2 (static schema) blobs, exported for unit + * testing. v2 has no committed tx_hash; the binding is structural (args decoded + * from the signed calldata), so signing proceeds when the relied-upon metadata + * is available, VERIFIED, and was actually decoded (`decoded`) — no digest + * comparison. `decoded` must be the recorded result of decode_v2_args() for + * this signing operation, not inferred from call order. + * signed_metadata_enforce() dispatches here when the stored blob's version is + * METADATA_VERSION_SCHEMA. */ +bool signed_metadata_enforce_schema_decision(bool relied, bool available, + bool decoded, int classification); + +const SignedMetadata* signed_metadata_get(void); + +#endif diff --git a/include/keepkey/firmware/tiny-json.h b/include/keepkey/firmware/tiny-json.h index 7ba75ec38..d4a76a019 100644 --- a/include/keepkey/firmware/tiny-json.h +++ b/include/keepkey/firmware/tiny-json.h @@ -35,6 +35,10 @@ #include #include +#ifdef __cplusplus +extern "C" { +#endif + #define json_containerOf(ptr, type, member) \ ((type*)((char*)ptr - offsetof(type, member))) @@ -66,7 +70,6 @@ typedef struct json_s { jsonType_t type; } json_t; -extern int errno; /** Parse a string to get a json. * @param str String pointer with a JSON object. It will be modified. * @param mem Array of json properties to allocate. diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index d9f0d7a19..dcf21b9d4 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -31,6 +31,7 @@ set(sources reset.c ripple.c ripple_base58.c + signed_metadata.c signing.c signtx_tendermint.c solana.c diff --git a/lib/firmware/signed_metadata.c b/lib/firmware/signed_metadata.c new file mode 100644 index 000000000..25d064fe0 --- /dev/null +++ b/lib/firmware/signed_metadata.c @@ -0,0 +1,1036 @@ +#include "keepkey/firmware/signed_metadata.h" + +#include "keepkey/board/confirm_sm.h" +#include "keepkey/board/draw.h" // draw_bitmap_mono_rle_valid +#include "keepkey/board/layout.h" // RUNTIME_ICON + layout_set_runtime_icon +#include "keepkey/board/variant.h" // Image / AnimationFrame +#include "keepkey/board/util.h" +#include "keepkey/firmware/ethereum.h" +#include "keepkey/firmware/storage.h" +#include "trezor/crypto/address.h" +#include "trezor/crypto/bignum.h" +#include "trezor/crypto/ecdsa.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" + +#include +#include + +#define _(X) (X) + +static bool metadata_available = false; +static bool relied_on_metadata = false; +static bool metadata_signer_loaded = false; +/* v2 only: set true once decode_v2_args() has decoded this metadata's args from + * the tx calldata. The v2 enforce path REQUIRES it — v2 has no committed + * tx_hash, so this is the explicit proof (not an implicit call-order + * assumption) that the displayed values came from the calldata being signed. */ +/* Set during matching: this tx carries native value, so the amount screen + * must NOT be suppressed even though the schema matched. */ +static bool metadata_schema_moves_value = false; +static bool metadata_schema_decoded = false; +static SignedMetadata stored_metadata; + +/* Phase 1 ships with NO built-in verification keys: every clearsign signer is + * loaded at runtime via LoadClearsignSigner. Phase 2 restores the production + * key. */ + +/* Runtime-loaded signers. RAM only — cleared on reboot by construction. RC18 + * deliberately rejects persistent trust anchors: the public storage section + * has no authenticated integrity against physical flash modification. */ +static uint8_t loaded_pubkeys[METADATA_MAX_KEYS][33]; +static char loaded_aliases[METADATA_MAX_KEYS][METADATA_ALIAS_MAX_LEN + 1]; +/* Per-slot session icon (1bpp mono RLE). icon_len==0 => text-only identity. */ +#if !ZCASH_PRIVACY +static uint8_t loaded_icons[METADATA_MAX_KEYS][METADATA_ICON_MAX]; +static uint8_t loaded_icon_w[METADATA_MAX_KEYS]; +static uint8_t loaded_icon_h[METADATA_MAX_KEYS]; +static uint16_t loaded_icon_len[METADATA_MAX_KEYS]; +#endif + +static bool read_u8(const uint8_t** cursor, const uint8_t* end, uint8_t* out) { + if ((size_t)(end - *cursor) < 1) { + return false; + } + + *out = **cursor; + *cursor += 1; + return true; +} + +static bool read_be_u16(const uint8_t** cursor, const uint8_t* end, + uint16_t* out) { + if ((size_t)(end - *cursor) < 2) { + return false; + } + + *out = ((uint16_t)(*cursor)[0] << 8) | (*cursor)[1]; + *cursor += 2; + return true; +} + +static bool read_be_u32(const uint8_t** cursor, const uint8_t* end, + uint32_t* out) { + if ((size_t)(end - *cursor) < 4) { + return false; + } + + *out = ((uint32_t)(*cursor)[0] << 24) | ((uint32_t)(*cursor)[1] << 16) | + ((uint32_t)(*cursor)[2] << 8) | (*cursor)[3]; + *cursor += 4; + return true; +} + +static bool read_bytes(const uint8_t** cursor, const uint8_t* end, uint8_t* out, + size_t size) { + if ((size_t)(end - *cursor) < size) { + return false; + } + + memcpy(out, *cursor, size); + *cursor += size; + return true; +} + +/* method_name and arg names render through confirm() bodies exactly like + * STRING values and signer aliases do — hold them to the same allowlist + * (printable ASCII, '%' excluded) so no metadata-carried text can embed + * control bytes or format specifiers. Only a trusted signer could author + * such a blob, but the charset rule should not depend on who signs. */ +static bool display_text_ok(const uint8_t* text, size_t len) { + for (size_t i = 0; i < len; i++) { + if (text[i] < 0x20 || text[i] > 0x7e || text[i] == '%') { + return false; + } + } + return true; +} + +static bool read_string(const uint8_t** cursor, const uint8_t* end, char* out, + size_t max_len) { + uint16_t value_len = 0; + if (!read_be_u16(cursor, end, &value_len) || value_len == 0 || + value_len > max_len || (size_t)(end - *cursor) < value_len) { + return false; + } + if (!display_text_ok(*cursor, value_len)) { + return false; + } + + memcpy(out, *cursor, value_len); + out[value_len] = '\0'; + *cursor += value_len; + return true; +} + +static bool read_arg_name(const uint8_t** cursor, const uint8_t* end, char* out, + size_t max_len) { + uint8_t value_len = 0; + if (!read_u8(cursor, end, &value_len) || value_len == 0 || + value_len > max_len || (size_t)(end - *cursor) < value_len) { + return false; + } + if (!display_text_ok(*cursor, value_len)) { + return false; + } + + memcpy(out, *cursor, value_len); + out[value_len] = '\0'; + *cursor += value_len; + return true; +} + +/* Per-format value validation, fail-closed at parse time. STRING and + * TOKEN_AMOUNT carry display semantics, so their byte layout is enforced + * before anything is stored; legacy formats keep their original 32-byte cap + * (METADATA_MAX_ARG_VALUE_LEN grew only to fit TOKEN_AMOUNT). */ +static bool arg_value_ok(uint8_t format, const uint8_t* value, uint16_t len) { + switch (format) { + case ARG_FORMAT_STRING: { + /* Attested printable label ("protocol: Uniswap V2"). Rendered through + * confirm() bodies: printable ASCII only, '%' excluded. */ + if (len == 0 || len > 32) { + return false; + } + for (uint16_t i = 0; i < len; i++) { + if (value[i] < 0x20 || value[i] > 0x7e || value[i] == '%') { + return false; + } + } + return true; + } + case ARG_FORMAT_TOKEN_AMOUNT: { + /* decimals(1) + symbol_len(1) + symbol + amount(1..32 BE) */ + if (len < 4) { + return false; + } + uint8_t decimals = value[0]; + uint8_t symlen = value[1]; + if (decimals > 36 || symlen == 0 || + symlen > METADATA_MAX_TOKEN_SYMBOL_LEN || + (uint16_t)(2 + symlen) >= len || len - 2 - symlen > 32) { + return false; + } + for (uint8_t i = 0; i < symlen; i++) { + char c = (char)value[2 + i]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9'); + if (!ok) { + return false; + } + } + return true; + } + default: + return len <= 32; + } +} + +/* chain_id(4) + contract(20) + selector(4) — shared by both blob versions. */ +static bool parse_common_head(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + return read_be_u32(cursor, end, &out->chain_id) && + read_bytes(cursor, end, out->contract_address, + sizeof(out->contract_address)) && + read_bytes(cursor, end, out->selector, sizeof(out->selector)); +} + +/* classification(1) + timestamp(4) + key_id(1) + sig(64) + recovery(1), then + * the cursor must land exactly on `end` — identical for v1 and v2. */ +static bool parse_trailer(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + uint8_t classification = 0; + if (!read_u8(cursor, end, &classification) || classification > 2 || + !read_be_u32(cursor, end, &out->timestamp) || + !read_u8(cursor, end, &out->key_id) || + !read_bytes(cursor, end, out->signature, sizeof(out->signature)) || + !read_u8(cursor, end, &out->recovery) || *cursor != end) { + return false; + } + out->classification = (MetadataClassification)classification; + return true; +} + +/* v1 args: name + format + explicit (host-decoded) value. */ +static bool parse_v1_args(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + for (uint8_t i = 0; i < out->num_args; i++) { + uint8_t format = 0; + uint16_t value_len = 0; + MetadataArg* arg = &out->args[i]; + + if (!read_arg_name(cursor, end, arg->name, METADATA_MAX_ARG_NAME_LEN) || + !read_u8(cursor, end, &format) || format > ARG_FORMAT_TOKEN_AMOUNT || + !read_be_u16(cursor, end, &value_len) || + value_len > METADATA_MAX_ARG_VALUE_LEN || + !read_bytes(cursor, end, arg->value, value_len) || + !arg_value_ok(format, arg->value, value_len)) { + return false; + } + arg->format = (ArgFormat)format; + arg->value_len = value_len; + } + return true; +} + +/* v2 args: name + display format only (NO value — decoded from calldata later). + * TOKEN_AMOUNT additionally carries its static decimals + symbol, pre-stored as + * the value prefix [decimals, symlen, symbol...] so decode_v2_args() only has + * to append the 32-byte amount word. v2 supports the fixed single-word ABI + * types ADDRESS / AMOUNT / TOKEN_AMOUNT; anything else is out of scope -> blind + * sign. */ +static bool parse_v2_args(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + for (uint8_t i = 0; i < out->num_args; i++) { + uint8_t format = 0; + MetadataArg* arg = &out->args[i]; + + if (!read_arg_name(cursor, end, arg->name, METADATA_MAX_ARG_NAME_LEN) || + !read_u8(cursor, end, &format)) { + return false; + } + switch (format) { + case ARG_FORMAT_ADDRESS: + case ARG_FORMAT_AMOUNT: + /* BYTES covers an opaque fixed word — an order/request id, say — which + * a router genuinely cannot render as an address or an amount. It still + * consumes exactly one 32-byte ABI word, so structural completeness is + * unaffected; only the rendering differs (hex, first 16 bytes). */ + case ARG_FORMAT_BYTES: + arg->value_len = 0; /* filled from the tx calldata at decode time */ + break; + case ARG_FORMAT_TOKEN_AMOUNT: { + uint8_t decimals = 0, symlen = 0; + if (!read_u8(cursor, end, &decimals) || + !read_u8(cursor, end, &symlen) || decimals > 36 || symlen == 0 || + symlen > METADATA_MAX_TOKEN_SYMBOL_LEN || + (size_t)(end - *cursor) < symlen) { + return false; + } + arg->value[0] = decimals; + arg->value[1] = symlen; + for (uint8_t j = 0; j < symlen; j++) { + char c = (char)(*cursor)[j]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9'); + if (!ok) { + return false; + } + arg->value[2 + j] = (uint8_t)c; + } + *cursor += symlen; + arg->value_len = (uint16_t)(2 + symlen); + break; + } + default: + return false; + } + arg->format = (ArgFormat)format; + } + return true; +} + +static bool parse_metadata_binary(const uint8_t* payload, size_t payload_len, + SignedMetadata* out) { + const uint8_t* cursor = payload; + const uint8_t* end = payload + payload_len; + memset(out, 0, sizeof(*out)); + + if (!read_u8(&cursor, end, &out->version)) { + return false; + } + + if (out->version == METADATA_VERSION_LEGACY) { + /* Min: version(1)+chain_id(4)+contract(20)+selector(4)+tx_hash(32)+ + * method_len(2)+method(1)+num_args(1)+trailer(71) = 136 */ + if (payload_len < 136 || !parse_common_head(&cursor, end, out) || + !read_bytes(&cursor, end, out->tx_hash, sizeof(out->tx_hash)) || + !read_string(&cursor, end, out->method_name, METADATA_MAX_METHOD_LEN) || + !read_u8(&cursor, end, &out->num_args) || + out->num_args > METADATA_MAX_ARGS || + !parse_v1_args(&cursor, end, out)) { + return false; + } + } else if (out->version == METADATA_VERSION_SCHEMA) { + /* Min (0 args): version(1)+chain_id(4)+contract(20)+selector(4)+ + * method_len(2)+method(1)+num_args(1)+trailer(71) = 104 (no tx_hash) */ + if (payload_len < 104 || !parse_common_head(&cursor, end, out) || + !read_string(&cursor, end, out->method_name, METADATA_MAX_METHOD_LEN) || + !read_u8(&cursor, end, &out->num_args) || + out->num_args > METADATA_MAX_ARGS || + !parse_v2_args(&cursor, end, out)) { + return false; + } + } else { + return false; + } + + return parse_trailer(&cursor, end, out); +} + +/* + * v2 decode: fill each schema arg's value from the transaction calldata. + * + * All v2 args are fixed single 32-byte ABI head words, laid out sequentially + * from offset 4 (right after the selector). We require the ENTIRE calldata to + * be exactly selector + num_args words, wholly present in the initial chunk — + * so the device decodes, displays, AND signs the same bytes with nothing hidden + * in a later chunk or trailing the words. That structural completeness is what + * binds the displayed decode to the signature; v2 has no tx_hash. + */ +static bool decode_v2_args(SignedMetadata* md, const EthereumSignTx* msg) { + uint32_t expected = 4u + 32u * (uint32_t)md->num_args; + uint32_t initsz = msg->data_initial_chunk.size; + uint32_t total = msg->has_data_length ? msg->data_length : initsz; + if (total != expected || initsz != expected) { + return false; + } + + for (uint8_t i = 0; i < md->num_args; i++) { + const uint8_t* word = msg->data_initial_chunk.bytes + 4 + 32u * i; + MetadataArg* arg = &md->args[i]; + + switch (arg->format) { + case ARG_FORMAT_ADDRESS: + /* ABI address is a left-zero-padded 20-byte value; reject dirty high + * bytes rather than silently truncate (they could hide meaning). */ + for (int j = 0; j < 12; j++) { + if (word[j] != 0) { + return false; + } + } + memcpy(arg->value, word + 12, 20); + arg->value_len = 20; + break; + case ARG_FORMAT_AMOUNT: + case ARG_FORMAT_BYTES: + memcpy(arg->value, word, 32); + arg->value_len = 32; + break; + case ARG_FORMAT_TOKEN_AMOUNT: { + /* value holds [decimals, symlen, symbol] from parse; append the amount. + * Derive the prefix from symlen (value[1]), NOT the current value_len, + * so a repeated decode of the same arg is idempotent (value_len already + * includes a previously-appended amount; value[1] does not change). */ + uint16_t prefix = (uint16_t)(2 + arg->value[1]); + if ((size_t)prefix + 32 > METADATA_MAX_ARG_VALUE_LEN) { + return false; + } + memcpy(arg->value + prefix, word, 32); + arg->value_len = (uint16_t)(prefix + 32); + break; + } + default: + return false; + } + } + return true; +} + +static void bn_from_metadata_bytes(const uint8_t* value, size_t value_len, + bignum256* out) { + uint8_t padded[32] = {0}; + if (value_len > sizeof(padded)) { + value_len = sizeof(padded); + } + memcpy(padded + (sizeof(padded) - value_len), value, value_len); + bn_read_be(padded, out); + memzero(padded, sizeof(padded)); +} + +bool signed_metadata_available(void) { return metadata_available; } + +bool signed_metadata_schema_decoded(void) { return metadata_schema_decoded; } + +bool signed_metadata_schema_moves_value(void) { + return metadata_schema_moves_value; +} + +void signed_metadata_clear(void) { + memzero(&stored_metadata, sizeof(stored_metadata)); + metadata_available = false; + relied_on_metadata = false; + metadata_signer_loaded = false; + metadata_schema_decoded = false; +} + +void signed_metadata_clear_signers(void) { + memzero(loaded_pubkeys, sizeof(loaded_pubkeys)); + memzero(loaded_aliases, sizeof(loaded_aliases)); +#if !ZCASH_PRIVACY + memzero(loaded_icons, sizeof(loaded_icons)); + memzero(loaded_icon_w, sizeof(loaded_icon_w)); + memzero(loaded_icon_h, sizeof(loaded_icon_h)); + memzero(loaded_icon_len, sizeof(loaded_icon_len)); +#endif + /* Metadata verified by a now-dropped signer must not outlive it. */ + signed_metadata_clear(); +} + +bool signed_metadata_signer_valid(uint8_t key_id, const uint8_t* pubkey, + size_t pubkey_len, const char* alias) { + curve_point point; + size_t alias_len; + + if (key_id >= METADATA_MAX_KEYS || !pubkey || pubkey_len != 33 || !alias) { + return false; + } + + /* Alias is rendered INSIDE quotes on the load screen and the per-tx warning + * ("Trust signer '%s' ..."). Restrict to a strict allowlist — letters, + * digits, space, '-' and '_' — so a host-chosen alias cannot break out of + * its quoted region or inject a semantic trust claim (e.g. a quote to close + * the quotes, or "." / "(" to append "verified by KeepKey."). '%' is also + * excluded so it can never reach the format string as a specifier. */ + alias_len = strlen(alias); + if (alias_len == 0 || alias_len > METADATA_ALIAS_MAX_LEN) { + return false; + } + for (size_t i = 0; i < alias_len; i++) { + char c = alias[i]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == ' ' || c == '-' || c == '_'; + if (!ok) { + return false; + } + } + + /* Compressed form only — ecdsa_read_pubkey would read 65 bytes for an + * uncompressed 0x04 prefix, past our 33-byte buffer. Requiring 0x02/0x03 + * also excludes the all-zero "empty slot" sentinel. */ + if (pubkey[0] != 0x02 && pubkey[0] != 0x03) { + return false; + } + return ecdsa_read_pubkey(&secp256k1, pubkey, &point) == 1; +} + +bool signed_metadata_store_signer(uint8_t key_id, const uint8_t* pubkey, + const char* alias, const uint8_t* icon, + uint8_t icon_w, uint8_t icon_h, + uint16_t icon_len, bool persist) { + /* Fail before changing the RAM slot. A caller asking for persistence must + * never receive a session-only downgrade it could mistake for durable trust. + * Persistence can return only after authenticated storage binding exists. */ + if (persist || key_id >= METADATA_MAX_KEYS) { + return false; + } + memcpy(loaded_pubkeys[key_id], pubkey, sizeof(loaded_pubkeys[key_id])); + strlcpy(loaded_aliases[key_id], alias, sizeof(loaded_aliases[key_id])); + + /* A load without an icon clears any prior one for the slot (icon_len + * already validated <= max by the caller — belt-and-braces here). */ + bool has_icon = icon && icon_len > 0 && icon_len <= METADATA_ICON_MAX; + + /* Session icon into the RAM working slot. The Orchard build omits this + * cosmetic cache to preserve its tight SRAM margin; signers remain usable + * and render text-only after the mandatory load confirmation. */ +#if !ZCASH_PRIVACY + memzero(loaded_icons[key_id], sizeof(loaded_icons[key_id])); + if (has_icon) { + memcpy(loaded_icons[key_id], icon, icon_len); + loaded_icon_w[key_id] = icon_w; + loaded_icon_h[key_id] = icon_h; + loaded_icon_len[key_id] = icon_len; + } else { + loaded_icon_w[key_id] = 0; + loaded_icon_h[key_id] = 0; + loaded_icon_len[key_id] = 0; + } +#else + (void)has_icon; + (void)icon_w; + (void)icon_h; +#endif + + /* Replacing a signer invalidates anything the old one verified. */ + signed_metadata_clear(); + return true; +} + +/* Resolve the alias for a session slot. */ +const char* signed_metadata_signer_alias(uint8_t key_id) { + if (key_id >= METADATA_MAX_KEYS) return NULL; + if (loaded_pubkeys[key_id][0] != 0x00) return loaded_aliases[key_id]; + return NULL; +} + +/* Resolve the icon for a session slot. Returns false for a text-only slot. */ +/* An icon is renderable only if its geometry fits the confirm's icon column + * AND its RLE stream decodes exactly to that geometry. This is the single + * choke point for session icons: signed_metadata_signer_icon() is what both the + * load-confirm and the per-tx identity screen call, and the per-tx screen + * stages the frame itself (it never goes through stage_runtime_icon). Fail + * closed to a text-only identity: a missing logo is cosmetic, an over-wide one + * erases the alias, fingerprint and the "NOT verified by KeepKey" warning. */ +#if !ZCASH_PRIVACY +static bool icon_renderable(const uint8_t* icon, uint16_t icon_len, + uint8_t icon_w, uint8_t icon_h) { + if (!icon || icon_len == 0) return false; + if (icon_w == 0 || icon_w > LEFT_MARGIN_WITH_ICON) return false; + if (icon_h == 0 || icon_h > 64) return false; + return draw_bitmap_mono_rle_valid(icon, (uint32_t)icon_len, icon_w, icon_h); +} +#endif + +bool signed_metadata_signer_icon(uint8_t key_id, const uint8_t** icon_out, + uint8_t* w_out, uint8_t* h_out, + uint16_t* len_out) { + if (key_id >= METADATA_MAX_KEYS) return false; + if (loaded_pubkeys[key_id][0] != 0x00) { +#if ZCASH_PRIVACY + (void)icon_out; + (void)w_out; + (void)h_out; + (void)len_out; + return false; +#else + if (loaded_icon_len[key_id] == 0) return false; + if (!icon_renderable(loaded_icons[key_id], loaded_icon_len[key_id], + loaded_icon_w[key_id], loaded_icon_h[key_id])) { + return false; + } + if (icon_out) *icon_out = loaded_icons[key_id]; + if (w_out) *w_out = loaded_icon_w[key_id]; + if (h_out) *h_out = loaded_icon_h[key_id]; + if (len_out) *len_out = loaded_icon_len[key_id]; + return true; +#endif + } + return false; +} + +/* Render an AnimationFrame from a stored icon into the confirm's left column. + * Image + frame are the CALLER's (must outlive the synchronous confirm); this + * only wires them up. Returns RUNTIME_ICON when an icon was set, else NO_ICON. + * Positioning tuned on device — icon column is ~40px, height 64px. */ +static IconType stage_runtime_icon(Image* img, AnimationFrame* frame, + const uint8_t* icon, uint8_t icon_w, + uint8_t icon_h, uint16_t icon_len) { + if (!icon || icon_len == 0) return NO_ICON; + /* Fail closed on an over-wide icon rather than drawing it at x=0: text begins + * at x=40 and the icon is drawn AFTER the text, so a wider icon would paint + * over the alias, fingerprint and the "NOT verified by KeepKey" warning. + * The load handler already checks this, but enforce it again at the point of + * use. Dropping the logo degrades to a text-only identity; letting it erase + * the warning does not. */ + if (icon_w == 0 || icon_w > LEFT_MARGIN_WITH_ICON || icon_h == 0 || + icon_h > 64) { + return NO_ICON; + } + img->w = icon_w; + img->h = icon_h; + img->length = icon_len; + img->data = icon; + /* Center inside the confirm's left icon column (LEFT_MARGIN_WITH_ICON=40px). + * Vertically center in the 64px height. */ + frame->x = (uint16_t)((LEFT_MARGIN_WITH_ICON - icon_w) / 2); + frame->y = (icon_h < 64) ? (uint16_t)((64 - icon_h) / 2) : 0; + frame->duration = 0; + /* Decoder does value*color/100; color=100 => data bytes are direct 0-255. */ + frame->color = 100; + frame->image = img; + layout_set_runtime_icon(frame); + return RUNTIME_ICON; +} + +bool signed_metadata_confirm_load(const char* alias, const char* fingerprint, + const uint8_t* icon, uint8_t icon_w, + uint8_t icon_h, uint16_t icon_len) { + Image icon_img; + AnimationFrame icon_frame; + IconType id_icon = stage_runtime_icon(&icon_img, &icon_frame, icon, icon_w, + icon_h, icon_len); + + char body[160]; + memset(body, 0, sizeof(body)); + /* Lead with the identity (its logo + alias + fingerprint). The trust model + * hangs on this consent; the fingerprint reappears on every per-tx screen. */ + snprintf(body, sizeof(body), + "Trust '%s' (%s) for this session to describe transactions? NOT " + "verified by KeepKey.", + alias, fingerprint); + bool ok = confirm_with_icon(ButtonRequestType_ButtonRequest_Other, id_icon, + _("Load Clearsigner"), "%s", body); + layout_set_runtime_icon(NULL); + return ok; +} + +void signed_metadata_pubkey_fingerprint(const uint8_t pubkey[33], + char out[METADATA_FINGERPRINT_LEN]) { + uint8_t digest[32]; + sha256_Raw(pubkey, 33, digest); + data2hex(digest, 4, out); + memzero(digest, sizeof(digest)); +} + +bool signed_metadata_from_loaded_signer(void) { + return metadata_available && metadata_signer_loaded; +} + +/* Resolve the verification key for a slot. */ +static const uint8_t* metadata_pubkey_for(uint8_t key_id, bool* is_loaded) { + *is_loaded = false; + if (key_id >= METADATA_MAX_KEYS) { + return NULL; + } + if (loaded_pubkeys[key_id][0] != 0x00) { + *is_loaded = true; + return loaded_pubkeys[key_id]; + } + return NULL; +} + +bool signed_metadata_signer_is_runtime(uint8_t key_id) { + bool is_loaded = false; + return metadata_pubkey_for(key_id, &is_loaded) != NULL && is_loaded; +} + +bool signed_metadata_signer_fingerprint(uint8_t key_id, + char out[METADATA_FINGERPRINT_LEN]) { + bool is_loaded = false; + const uint8_t* pubkey = metadata_pubkey_for(key_id, &is_loaded); + if (!pubkey || (is_loaded && !storage_isPolicyEnabled("AdvancedMode"))) { + return false; + } + signed_metadata_pubkey_fingerprint(pubkey, out); + return true; +} + +bool signed_metadata_verify_attestation(uint8_t key_id, const uint8_t* data, + size_t data_len, const uint8_t* sig, + size_t sig_len) { + if (!data || data_len == 0 || !sig || sig_len != 64) { + return false; + } + bool is_loaded = false; + const uint8_t* pubkey = metadata_pubkey_for(key_id, &is_loaded); + if (!pubkey || (is_loaded && !storage_isPolicyEnabled("AdvancedMode"))) { + return false; + } + uint8_t digest[32]; + sha256_Raw(data, data_len, digest); + bool ok = ecdsa_verify_digest(&secp256k1, pubkey, sig, digest) == 0; + memzero(digest, sizeof(digest)); + return ok; +} + +MetadataClassification signed_metadata_process(const uint8_t* payload, + size_t payload_len, + uint8_t key_id) { + uint8_t digest[32]; + size_t signed_len; + bool is_loaded = false; + const uint8_t* pubkey; + + signed_metadata_clear(); + + pubkey = metadata_pubkey_for(key_id, &is_loaded); + if (!pubkey || (is_loaded && !storage_isPolicyEnabled("AdvancedMode")) || + !payload || payload_len < 65) { + return METADATA_MALFORMED; + } + + if (!parse_metadata_binary(payload, payload_len, &stored_metadata) || + stored_metadata.key_id != key_id) { + signed_metadata_clear(); + return METADATA_MALFORMED; + } + + signed_len = payload_len - sizeof(stored_metadata.signature) - 1; + sha256_Raw(payload, signed_len, digest); + + if (ecdsa_verify_digest(&secp256k1, pubkey, stored_metadata.signature, + digest) != 0) { + signed_metadata_clear(); + return METADATA_MALFORMED; + } + + metadata_available = true; + metadata_signer_loaded = is_loaded; + return stored_metadata.classification; +} + +bool signed_metadata_matches_tx(const EthereumSignTx* msg) { + /* Reset the v2 decode proof up front: it must reflect ONLY the current call. + * Any early return below (unavailable, wrong contract/selector/chain) leaves + * it false, so a stale `true` from a prior successful match can never let + * signed_metadata_enforce() pass for a v2 blob that did not decode this tx. + */ + metadata_schema_decoded = false; + + if (!metadata_available || !msg || + stored_metadata.classification != METADATA_VERIFIED || + msg->to.size != sizeof(stored_metadata.contract_address) || + msg->data_initial_chunk.size < sizeof(stored_metadata.selector)) { + return false; + } + + /* Contract address binding */ + if (memcmp(stored_metadata.contract_address, msg->to.bytes, + sizeof(stored_metadata.contract_address)) != 0) { + return false; + } + + /* Function selector binding */ + if (memcmp(stored_metadata.selector, msg->data_initial_chunk.bytes, + sizeof(stored_metadata.selector)) != 0) { + return false; + } + + /* Chain ID binding */ + if ((msg->has_chain_id ? msg->chain_id : 0) != stored_metadata.chain_id) { + return false; + } + + if (stored_metadata.version == METADATA_VERSION_SCHEMA) { + /* v2 commits to calldata only — never to msg->value. A v2 match otherwise + * suppresses the native-value confirm screen in ethereum.c, which would + * let a payable method clear-sign an ETH transfer whose amount is never + * shown. Rather than refuse every payable call (which forced blind-signing + * on exactly the routes that most need review), record that this tx moves + * value; ethereum.c keeps the amount/recipient screen when it does. The + * device reads that amount from the transaction it is signing, so nothing + * unattested is displayed and the schema stays transaction-independent. */ + metadata_schema_moves_value = false; + for (uint32_t i = 0; i < msg->value.size; i++) { + if (msg->value.bytes[i] != 0) { + metadata_schema_moves_value = true; + break; + } + } + /* v2 has no committed values or tx_hash: decode the args straight from the + * calldata this tx will sign. Success here means the schema fully accounts + * for the calldata (decode_v2_args enforces exact length + presence), so + * the display is bound to the signature structurally — nothing is enforced + * later against a digest (there is no tx_hash). A decode failure falls + * through to the normal blind-sign path. Record the decode explicitly: + * signed_metadata_enforce() requires it for v2, so a signature can never be + * emitted for a v2 blob whose args were not decoded from this tx. */ + metadata_schema_decoded = decode_v2_args(&stored_metadata, msg); + return metadata_schema_decoded; + } + + /* v1 only gates what we DISPLAY (so a benign-looking method screen can't be + * shown for the wrong call). The metadata commits to the full tx hash; that + * is enforced against the real signed digest in signed_metadata_enforce() + * because the digest does not exist until send_signature() finalizes it. */ + return true; +} + +/* Renders the clearsign screens in sequence. When a signer with an icon is + * loaded, its logo (the compass) is set as RUNTIME_ICON and STAYS set for the + * whole flow, so every screen — identity, method, contract, each arg — carries + * it. The caller (signed_metadata_confirm) clears the runtime icon once on + * return, covering every early-exit path. */ +static bool signed_metadata_confirm_screens(void) { + char body[128]; + /* Compass shown on every screen once a signer with an icon is loaded. */ + IconType screen_icon = NO_ICON; + Image icon_img; + AnimationFrame icon_frame; + + if (metadata_signer_loaded) { + /* Lead with the loaded IDENTITY (logo, if any, + alias + fingerprint) + * BEFORE any clearsign page. The user approved this identity as their + * trust anchor, so showing it — not a scary "NOT verified by KeepKey" + * banner — is the honest framing. The fingerprint stays reachable so a + * swapped provider is still detectable. */ + uint8_t key_id = stored_metadata.key_id; + bool is_loaded = false; + const uint8_t* pk = metadata_pubkey_for(key_id, &is_loaded); + const char* alias = signed_metadata_signer_alias(key_id); + char fingerprint[METADATA_FINGERPRINT_LEN]; + if (pk) { + signed_metadata_pubkey_fingerprint(pk, fingerprint); + } else { + strlcpy(fingerprint, "????????", sizeof(fingerprint)); + } + if (!alias) alias = "unknown"; + + /* Draw the identity logo in the confirm's left icon column if one was + * loaded. Image + frame are local — valid for the synchronous confirm + * call, then the runtime icon is cleared. (Positioning tuned on device.) */ + const uint8_t* icon_data; + uint8_t icon_w, icon_h; + uint16_t icon_len; + if (signed_metadata_signer_icon(key_id, &icon_data, &icon_w, &icon_h, + &icon_len)) { + icon_img.w = icon_w; + icon_img.h = icon_h; + icon_img.length = icon_len; + icon_img.data = icon_data; + icon_frame.x = 0; + icon_frame.y = (icon_h < 52) ? (uint16_t)((52 - icon_h) / 2 + 6) : 6; + icon_frame.duration = 0; + /* Decoder computes pixel = data * color / 100, so color=100 makes the + * icon's data bytes direct 0-255 intensities (matches the built-in + * icons). color=0xff would overflow uint8 and corrupt every pixel. */ + icon_frame.color = 100; + icon_frame.image = &icon_img; + layout_set_runtime_icon(&icon_frame); + screen_icon = RUNTIME_ICON; + } + + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "%s (%s)\ndescribes this tx.", alias, + fingerprint); + /* Runtime icon stays set from here on — every subsequent screen shows the + * compass. Cleared once by the caller. */ + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, "Identity", "%s", body)) { + return false; + } + + /* Method screen — same identity compass, no "Insight Verified" branding + * (that presentation is reserved for the built-in phase-2 keys). */ + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "Call:\n%s", stored_metadata.method_name); + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, "Clearsign", "%s", body)) { + return false; + } + } else { + /* Screen 1: Verified method — use review_with_icon for trust indicator */ + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "Verified call:\n%s", + stored_metadata.method_name); + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + VERIFIED_ICON, "Insight Verified", "%s", body)) { + return false; + } + } + + /* Screen 2: Contract address — ALWAYS show full address, never truncate. + * Truncation is a spoofing vector (attacker crafts matching prefix+suffix). + */ + char contract_addr[43] = "0x"; + ethereum_address_checksum(stored_metadata.contract_address, contract_addr + 2, + false, stored_metadata.chain_id); + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "Contract:\n%s", contract_addr); + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, stored_metadata.method_name, "%s", + body)) { + return false; + } + + /* Screen 3..N: Each decoded argument */ + for (uint8_t i = 0; i < stored_metadata.num_args; i++) { + MetadataArg* arg = &stored_metadata.args[i]; + memset(body, 0, sizeof(body)); + + switch (arg->format) { + case ARG_FORMAT_ADDRESS: { + char addr_full[43] = "0x"; + if (arg->value_len != 20) { + return false; + } + ethereum_address_checksum(arg->value, addr_full + 2, false, + stored_metadata.chain_id); + snprintf(body, sizeof(body), "%s:\n%s", arg->name, addr_full); + break; + } + case ARG_FORMAT_AMOUNT: { + bignum256 amount; + bn_from_metadata_bytes(arg->value, arg->value_len, &amount); + /* Check for MAX_UINT256 (unlimited approval) */ + bool is_max = true; + for (uint16_t j = 0; j < arg->value_len; j++) { + if (arg->value[j] != 0xFF) { + is_max = false; + break; + } + } + if (is_max && arg->value_len == 32) { + snprintf(body, sizeof(body), "%s:\nUNLIMITED", arg->name); + } else { + char formatted[48]; + bn_format(&amount, NULL, " wei", 0, 0, false, formatted, + sizeof(formatted)); + snprintf(body, sizeof(body), "%s:\n%s", arg->name, formatted); + } + break; + } + case ARG_FORMAT_STRING: { + /* Attested printable label, validated at parse (arg_value_ok). */ + char text[33]; + memcpy(text, arg->value, arg->value_len); + text[arg->value_len] = '\0'; + snprintf(body, sizeof(body), "%s:\n%s", arg->name, text); + break; + } + case ARG_FORMAT_TOKEN_AMOUNT: { + /* decimals + symbol + big-endian amount, validated at parse. + * This is the "Amount: 1,000 USDC" the clear-signing plan calls for + * instead of a raw wei integer. */ + uint8_t decimals = arg->value[0]; + uint8_t symlen = arg->value[1]; + char suffix[METADATA_MAX_TOKEN_SYMBOL_LEN + 2]; + suffix[0] = ' '; + memcpy(suffix + 1, arg->value + 2, symlen); + suffix[1 + symlen] = '\0'; + + const uint8_t* amt = arg->value + 2 + symlen; + uint16_t amt_len = arg->value_len - 2 - symlen; + bool is_max = amt_len == 32; + for (uint16_t j = 0; j < amt_len && is_max; j++) { + if (amt[j] != 0xFF) { + is_max = false; + } + } + if (is_max) { + snprintf(body, sizeof(body), "%s:\nUNLIMITED%s", arg->name, suffix); + } else { + bignum256 amount; + bn_from_metadata_bytes(amt, amt_len, &amount); + char formatted[48]; + bn_format(&amount, NULL, suffix, decimals, 0, false, formatted, + sizeof(formatted)); + snprintf(body, sizeof(body), "%s:\n%s", arg->name, formatted); + } + break; + } + case ARG_FORMAT_BYTES: + case ARG_FORMAT_RAW: + default: { + char hex[(METADATA_MAX_ARG_VALUE_LEN * 2) + 1]; + size_t display_len = arg->value_len > 16 ? 16 : (size_t)arg->value_len; + data2hex(arg->value, display_len, hex); + snprintf(body, sizeof(body), "%s:\n%s%s", arg->name, hex, + arg->value_len > 16 ? "..." : ""); + break; + } + } + + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, stored_metadata.method_name, "%s", + body)) { + return false; + } + } + + /* User approved the decoded who/what/why. From here the raw-data confirm is + * suppressed, so the signature MUST be bound to this metadata's tx hash. */ + relied_on_metadata = true; + return true; +} + +bool signed_metadata_confirm(void) { + if (!metadata_available || + stored_metadata.classification != METADATA_VERIFIED) { + return false; + } + bool ok = signed_metadata_confirm_screens(); + /* Single cleanup for every screen-flow exit — the runtime icon frame lives on + * the helper's stack, so it must not outlive this call. */ + layout_set_runtime_icon(NULL); + return ok; +} + +bool signed_metadata_relied(void) { return relied_on_metadata; } + +bool signed_metadata_enforce_decision(bool relied, bool available, + int classification, + const uint8_t* stored_hash, + const uint8_t* hash) { + if (!relied) { + return true; /* signature was not gated by metadata */ + } + /* Fail closed: relied on metadata but it's gone, not verified, or the signed + * digest differs from what was displayed → refuse to emit a signature. + * tx_hash is 32 bytes (see SignedMetadata). */ + return hash != NULL && stored_hash != NULL && available && + classification == METADATA_VERIFIED && + memcmp(stored_hash, hash, 32) == 0; +} + +bool signed_metadata_enforce_schema_decision(bool relied, bool available, + bool decoded, int classification) { + /* v2 (static schema) has no committed tx_hash. Its binding is structural: the + * args were decoded from the exact calldata being signed, and that calldata + * cannot change between decode and sign within one signing operation. So if + * we relied on a verified v2 decode, signing may proceed; there is no digest + * to compare. `decoded` is the explicit proof that decode_v2_args() ran and + * succeeded for this signing operation — required rather than inferred from + * call order, since v2 has no digest fallback. If we did not rely on the + * metadata, signing was never gated by it. */ + return !relied || + (available && decoded && classification == METADATA_VERIFIED); +} + +bool signed_metadata_enforce(const uint8_t hash[32]) { + if (metadata_available && + stored_metadata.version == METADATA_VERSION_SCHEMA) { + return signed_metadata_enforce_schema_decision( + relied_on_metadata, metadata_available, metadata_schema_decoded, + stored_metadata.classification); + } + return signed_metadata_enforce_decision( + relied_on_metadata, metadata_available, stored_metadata.classification, + stored_metadata.tx_hash, hash); +} + +const SignedMetadata* signed_metadata_get(void) { + return metadata_available ? &stored_metadata : NULL; +} diff --git a/lib/firmware/tiny-json.c b/lib/firmware/tiny-json.c index c915eff58..80f0e9e21 100644 --- a/lib/firmware/tiny-json.c +++ b/lib/firmware/tiny-json.c @@ -33,7 +33,9 @@ // #include -int errno = 0; +/* Renamed from `errno` to avoid colliding with the libc macro on + * glibc/MinGW (where errno expands to (*_errno())). Write-only, never read. */ +int json_errno = 0; /** Structure to handle a heap of JSON properties. */ typedef struct jsonStaticPool_s { @@ -73,7 +75,7 @@ static bool isEndOfPrimitive(char ch); json_t const* json_createWithPool(char* str, jsonPool_t* pool) { char* ptr = goBlank(str); if (!ptr || (*ptr != '{' && *ptr != '[')) { - errno = -1; + json_errno = -1; return 0; } json_t* obj = pool->init(pool); @@ -82,7 +84,7 @@ json_t const* json_createWithPool(char* str, jsonPool_t* pool) { obj->u.c.child = 0; ptr = objValue(ptr, obj, pool); if (!ptr) { - errno = -2; + json_errno = -2; return 0; } return obj; diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 6c7262aeb..b2c14c81d 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -6,6 +6,7 @@ set(sources ethereum.cpp nano.cpp recovery.cpp + signed_metadata.cpp ripple.cpp storage.cpp usb_rx.cpp diff --git a/unittests/firmware/signed_metadata.cpp b/unittests/firmware/signed_metadata.cpp new file mode 100644 index 000000000..db609de40 --- /dev/null +++ b/unittests/firmware/signed_metadata.cpp @@ -0,0 +1,1780 @@ +/* + * Unit tests for the EVM clear-signing ("Insight") signed-metadata module. + * + * Phase 1 ships with NO built-in verification keys: every signer is loaded + * at runtime (signed_metadata_store_signer, + * reached in production through the user-confirmed LoadClearsignSigner FSM + * handler). The fixture loads the CI test key (02e3b3015c...ab5107) into + * slot 3 with alias "CI Test"; all vectors are signed in-process with the + * matching private key (f6d19e15...068a260) and embed key_id=3. + * + * No OLED/button I/O is exercised: signed_metadata_process() and + * signed_metadata_matches_tx() never draw, and signed_metadata_confirm() is + * only called on its no-I/O early-return guards. The relied-path enforce truth + * table is tested through the pure, exported signed_metadata_enforce_decision() + * (see SECTION 2), since relied_on_metadata is only set inside confirm()'s + * interactive tail. + */ + +extern "C" { +#include "messages-ethereum.pb.h" /* full EthereumSignTx definition */ +#include "keepkey/board/draw.h" /* draw_bitmap_mono_rle (icon decoder) */ +#include "keepkey/board/layout.h" /* LEFT_MARGIN_WITH_ICON */ +#include "keepkey/firmware/signed_metadata.h" +#include "keepkey/firmware/solana.h" /* SolanaTokenInfo, solana_token_info_trusted */ +#include "keepkey/firmware/storage.h" +#include "trezor/crypto/ecdsa.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" + +void setup(void); +} + +#include "gtest/gtest.h" + +#include +#include +#include +#include +#include + +namespace { + +/* Test signing key. Its compressed pubkey is loaded into slot 3 by the fixture. + */ +const uint8_t TEST_PRIV[32] = {0xf6, 0xd1, 0x9e, 0x15, 0xa4, 0x38, 0x5f, 0x03, + 0xb7, 0x8b, 0x5a, 0x1e, 0x16, 0x14, 0xe7, 0xd9, + 0xa1, 0x04, 0xd8, 0x1f, 0x73, 0x24, 0x49, 0x87, + 0x56, 0xe5, 0x71, 0x90, 0x40, 0x68, 0xa2, 0x60}; + +/* Compressed pubkey of TEST_PRIV; loaded into slot 3 by the fixture. */ +const uint8_t EXPECTED_SLOT3_PUB[33] = { + 0x02, 0xe3, 0xb3, 0x01, 0x5c, 0x47, 0xdd, 0xca, 0xab, 0xe4, 0xf8, + 0xe8, 0x72, 0xf1, 0xed, 0x8f, 0x09, 0xca, 0x14, 0x5a, 0x8d, 0x81, + 0x77, 0x0d, 0x92, 0x21, 0x3d, 0x56, 0xda, 0x31, 0xab, 0x51, 0x07}; + +const uint8_t TEST_KEY_ID = 3; + +/* Deterministic, opaque test data. Only internal consistency matters. */ +const uint8_t CONTRACT_A[20] = {0xa0, 0xb8, 0x69, 0x91, 0xc6, 0x21, 0x8b, + 0x36, 0xc1, 0xd1, 0x9d, 0x4a, 0x2e, 0x9e, + 0xb0, 0xce, 0x36, 0x06, 0xeb, 0x48}; +const uint8_t CONTRACT_B[20] = {0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11}; +const uint8_t SEL_TRANSFER[4] = {0xa9, 0x05, 0x9c, 0xbb}; +const uint8_t SEL_APPROVE[4] = {0x09, 0x5e, 0xa7, 0xb3}; +const uint8_t TX_HASH[32] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20}; +const uint8_t RECIPIENT[20] = {0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, + 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53}; +const uint8_t AMOUNT32[32] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0x03, 0xe8}; + +/* ---- byte writers ------------------------------------------------------- */ + +void put_u8(std::vector& v, uint8_t x) { v.push_back(x); } +void put_be16(std::vector& v, uint16_t x) { + v.push_back((uint8_t)(x >> 8)); + v.push_back((uint8_t)(x & 0xff)); +} +void put_be32(std::vector& v, uint32_t x) { + v.push_back((uint8_t)(x >> 24)); + v.push_back((uint8_t)(x >> 16)); + v.push_back((uint8_t)(x >> 8)); + v.push_back((uint8_t)(x & 0xff)); +} +void put_bytes(std::vector& v, const uint8_t* b, size_t n) { + v.insert(v.end(), b, b + n); +} + +/* ---- metadata builder --------------------------------------------------- */ + +struct Arg { + std::string name; + uint8_t format; + std::vector value; + int value_len_override; // -1 => use value.size() +}; + +Arg mk_arg(const std::string& name, uint8_t format, const uint8_t* value, + size_t value_len) { + Arg a; + a.name = name; + a.format = format; + a.value.assign(value, value + value_len); + a.value_len_override = -1; + return a; +} + +struct Spec { + uint8_t version; + uint32_t chain_id; + std::vector contract; + std::vector selector; + std::vector tx_hash; + std::string method; + std::vector args; + uint8_t classification; + uint32_t timestamp; + uint8_t key_id; + int method_len_override; // -1 => use method.size() + int num_args_override; // -1 => use args.size() +}; + +/* Canonical VERIFIED metadata: transfer(to:ADDRESS, amount:AMOUNT) on chain 1. + */ +Spec base_spec() { + Spec s; + s.version = 0x01; + s.chain_id = 1; + s.contract.assign(CONTRACT_A, CONTRACT_A + 20); + s.selector.assign(SEL_TRANSFER, SEL_TRANSFER + 4); + s.tx_hash.assign(TX_HASH, TX_HASH + 32); + s.method = "transfer"; + s.args.push_back(mk_arg("to", ARG_FORMAT_ADDRESS, RECIPIENT, 20)); + s.args.push_back(mk_arg("amount", ARG_FORMAT_AMOUNT, AMOUNT32, 32)); + s.classification = METADATA_VERIFIED; + s.timestamp = 0; + s.key_id = TEST_KEY_ID; + s.method_len_override = -1; + s.num_args_override = -1; + return s; +} + +/* Serialize the signed region (version .. key_id), exactly matching + * parse_metadata_binary() / serialize_metadata(). */ +std::vector build_body(const Spec& s) { + std::vector b; + put_u8(b, s.version); + put_be32(b, s.chain_id); + put_bytes(b, s.contract.data(), s.contract.size()); + put_bytes(b, s.selector.data(), s.selector.size()); + put_bytes(b, s.tx_hash.data(), s.tx_hash.size()); + + uint16_t mlen = s.method_len_override >= 0 ? (uint16_t)s.method_len_override + : (uint16_t)s.method.size(); + put_be16(b, mlen); + put_bytes(b, (const uint8_t*)s.method.data(), s.method.size()); + + uint8_t na = s.num_args_override >= 0 ? (uint8_t)s.num_args_override + : (uint8_t)s.args.size(); + put_u8(b, na); + for (const Arg& a : s.args) { + put_u8(b, (uint8_t)a.name.size()); + put_bytes(b, (const uint8_t*)a.name.data(), a.name.size()); + put_u8(b, a.format); + uint16_t vl = a.value_len_override >= 0 ? (uint16_t)a.value_len_override + : (uint16_t)a.value.size(); + put_be16(b, vl); + put_bytes(b, a.value.data(), a.value.size()); + } + + put_u8(b, s.classification); + put_be32(b, s.timestamp); + put_u8(b, s.key_id); + return b; +} + +/* sha256(body) -> ecdsa sign with TEST_PRIV -> append sig(64) + recovery(1). + * Mirrors signed_metadata_process(): signed_len = payload_len - 64 - 1. */ +std::vector sign_body(std::vector body) { + uint8_t digest[32]; + sha256_Raw(body.data(), body.size(), digest); + uint8_t sig[64]; + uint8_t pby = 0; + int rc = ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, &pby, NULL); + EXPECT_EQ(rc, 0); + body.insert(body.end(), sig, sig + 64); + body.push_back((uint8_t)(27 + pby)); + return body; +} + +std::vector base_blob() { return sign_body(build_body(base_spec())); } + +void make_msg(EthereumSignTx* msg, const uint8_t contract[20], + const uint8_t* data, size_t data_len, bool has_chain, + uint32_t chain) { + memset(msg, 0, sizeof(*msg)); + msg->has_to = true; + msg->to.size = 20; + memcpy(msg->to.bytes, contract, 20); + msg->has_data_initial_chunk = true; + msg->data_initial_chunk.size = (pb_size_t)data_len; + memcpy(msg->data_initial_chunk.bytes, data, data_len); + msg->has_chain_id = has_chain; + msg->chain_id = chain; +} + +/* A standard transfer() calldata chunk that matches base_spec(). */ +void make_matching_msg(EthereumSignTx* msg) { + uint8_t data[68]; + memcpy(data, SEL_TRANSFER, 4); + memset(data + 4, 0, sizeof(data) - 4); + make_msg(msg, CONTRACT_A, data, sizeof(data), /*has_chain=*/true, 1); +} + +const char* TEST_ALIAS = "CI Test"; + +void set_advanced_mode_for_test(bool enabled) { + /* The full xunit binary may already have initialized emulator flash in an + * earlier fixture (notably Authenticator). Re-running storage_init() then + * attempts to migrate/decrypt an already-live shadow store. The allocation + * is the shared source of truth, and also keeps this suite runnable alone. */ + if (storage_getLocation() == FLASH_INVALID) { + setup(); + storage_init(); + } + ASSERT_TRUE(storage_setPolicy("AdvancedMode", enabled)); +} + +class SignedMetadataTest : public ::testing::Test { + protected: + void SetUp() override { + set_advanced_mode_for_test(true); + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + NULL, 0, 0, 0, false); + } + void TearDown() override { + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); + } + + void ExpectMalformed(const std::vector& blob, uint8_t key_id) { + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), key_id), + METADATA_MALFORMED); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_EQ(signed_metadata_get(), nullptr); + } +}; + +/* ===================================================================== * + * signed_metadata_process — happy path via a runtime-loaded signer + * ===================================================================== */ + +TEST_F(SignedMetadataTest, DerivedPubkeyMatchesSlot3) { + uint8_t pub[33]; + ecdsa_get_public_key33(&secp256k1, TEST_PRIV, pub); + EXPECT_EQ(memcmp(pub, EXPECTED_SLOT3_PUB, sizeof(pub)), 0) + << "TEST_PRIV must derive the loaded slot-3 test pubkey"; +} + +TEST_F(SignedMetadataTest, ValidVerifiedSlot3) { + std::vector blob = base_blob(); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_TRUE(signed_metadata_available()); + const SignedMetadata* m = signed_metadata_get(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->classification, METADATA_VERIFIED); + EXPECT_EQ(m->chain_id, 1u); + EXPECT_STREQ(m->method_name, "transfer"); + EXPECT_EQ(m->num_args, 2); + EXPECT_EQ(memcmp(m->contract_address, CONTRACT_A, 20), 0); + EXPECT_EQ(memcmp(m->selector, SEL_TRANSFER, 4), 0); + EXPECT_EQ(memcmp(m->tx_hash, TX_HASH, 32), 0); + EXPECT_EQ(m->key_id, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, RuntimeMetadataIsInertOutsideAdvancedMode) { + std::vector blob = base_blob(); + set_advanced_mode_for_test(false); + ExpectMalformed(blob, TEST_KEY_ID); + + const uint8_t data[] = "advanced-mode-gate"; + uint8_t digest[32]; + uint8_t sig[64]; + sha256_Raw(data, sizeof(data) - 1, digest); + ASSERT_EQ(ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, NULL, NULL), + 0); + EXPECT_FALSE(signed_metadata_verify_attestation( + TEST_KEY_ID, data, sizeof(data) - 1, sig, sizeof(sig))); + + set_advanced_mode_for_test(true); +} + +TEST_F(SignedMetadataTest, ValidOpaqueClassification) { + Spec s = base_spec(); + s.classification = METADATA_OPAQUE; // 0 + std::vector blob = sign_body(build_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_OPAQUE); + EXPECT_TRUE(signed_metadata_available()); // available, but not VERIFIED + EXPECT_NE(signed_metadata_get(), nullptr); +} + +TEST_F(SignedMetadataTest, SelfDeclaredMalformedWithValidSignature) { + /* A trusted signer can self-declare MALFORMED(2). Signature verifies, so + * process() returns MALFORMED but leaves the (inert) metadata available. It + * must never be displayed or relied upon. */ + Spec s = base_spec(); + s.classification = METADATA_MALFORMED; // 2 + std::vector blob = sign_body(build_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_MALFORMED); + EXPECT_TRUE(signed_metadata_available()); + EXPECT_NE(signed_metadata_get(), nullptr); + + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); // gated on VERIFIED + EXPECT_FALSE(signed_metadata_confirm()); // gated on VERIFIED +} + +/* ===================================================================== * + * signed_metadata_process — key-slot guards + * ===================================================================== */ + +TEST_F(SignedMetadataTest, KeyIdOutOfRange) { + ExpectMalformed(base_blob(), /*key_id=*/4); // >= METADATA_MAX_KEYS +} + +TEST_F(SignedMetadataTest, EmptyRotationSlot) { + Spec s = base_spec(); + s.key_id = 1; // slot 1: no built-in key, nothing loaded + ExpectMalformed(sign_body(build_body(s)), /*key_id=*/1); +} + +TEST_F(SignedMetadataTest, NullPayload) { + EXPECT_EQ(signed_metadata_process(nullptr, 200, TEST_KEY_ID), + METADATA_MALFORMED); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_EQ(signed_metadata_get(), nullptr); +} + +TEST_F(SignedMetadataTest, EmbeddedKeyIdMismatch) { + Spec s = base_spec(); + s.key_id = 2; // embedded != protocol key_id (3) + ExpectMalformed(sign_body(build_body(s)), /*key_id=*/3); +} + +TEST_F(SignedMetadataTest, SignatureVerificationFails) { + std::vector blob = base_blob(); + blob[146] ^= 0x01; // flip first signature byte (sig starts after 146B body) + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* ===================================================================== * + * signed_metadata_process — length guards + * ===================================================================== */ + +TEST_F(SignedMetadataTest, PayloadShorterThan65) { + std::vector blob = base_blob(); + blob.resize(64); // process() early guard: payload_len < 65 + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, PayloadBetween65And135) { + std::vector blob = base_blob(); + blob.resize(100); // passes <65 guard, fails parser <136 minimum + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, TrailingByteAfterRecovery) { + std::vector blob = base_blob(); + blob.push_back(0x00); // cursor != end + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, MissingRecoveryByte) { + std::vector blob = base_blob(); + blob.pop_back(); // truncated tail: read recovery fails + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* ===================================================================== * + * parse_metadata_binary — field guards (all re-signed so the PARSE guard, + * not the signature check, is what rejects the blob) + * ===================================================================== */ + +TEST_F(SignedMetadataTest, BadVersion) { + Spec s = base_spec(); + s.version = 0x02; + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, EmptyMethodName) { + Spec s = base_spec(); + s.method = ""; // 2-byte length prefix == 0 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, MethodNameTooLong) { + Spec s = base_spec(); + s.method = std::string(65, 'A'); // > METADATA_MAX_METHOD_LEN (64) + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, MethodNameLengthOverrun) { + /* Length prefix claims 64 but only "transfer" (8B) is the method; the read + * consumes downstream bytes and parsing misaligns -> MALFORMED. The clean + * read_string short-read guard is unreachable under the >=136 floor (after + * the 63-byte fixed prefix at least 73 bytes always remain), so this pins + * the observable contract rather than a specific internal branch. The + * corrupted signature byte makes rejection deterministic even in the + * vanishingly unlikely event the misaligned parse re-aligns to the end. */ + Spec s = base_spec(); + s.method_len_override = 64; + std::vector blob = sign_body(build_body(s)); + blob[blob.size() - 2] ^= 0xFF; // ensure verify cannot pass + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, NumArgsTooMany) { + Spec s = base_spec(); + s.num_args_override = 9; // > METADATA_MAX_ARGS (8) + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgNameEmpty) { + Spec s = base_spec(); + s.args[0] = mk_arg("", ARG_FORMAT_ADDRESS, RECIPIENT, 20); // name_len == 0 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgNameTooLong) { + Spec s = base_spec(); + std::string long_name(33, 'x'); // > METADATA_MAX_ARG_NAME_LEN (32) + s.args[0] = mk_arg(long_name, ARG_FORMAT_ADDRESS, RECIPIENT, 20); + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgFormatOutOfRange) { + Spec s = base_spec(); + s.args[0].format = 6; // > ARG_FORMAT_TOKEN_AMOUNT (5) + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +/* ---- ARG_FORMAT_STRING (attested printable label) ----------------------- */ + +TEST_F(SignedMetadataTest, StringArgAccepted) { + Spec s = base_spec(); + const char* label = "Uniswap V2"; + s.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, (const uint8_t*)label, + strlen(label)); + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + const SignedMetadata* m = signed_metadata_get(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->args[0].format, ARG_FORMAT_STRING); + EXPECT_EQ(memcmp(m->args[0].value, label, strlen(label)), 0); +} + +TEST_F(SignedMetadataTest, StringArgRejectsUnprintableAndPercent) { + const uint8_t nl[] = {'a', '\n', 'b'}; + Spec s = base_spec(); + s.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, nl, sizeof(nl)); + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); + + const uint8_t pct[] = {'a', '%', 's'}; + Spec s2 = base_spec(); + s2.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, pct, sizeof(pct)); + ExpectMalformed(sign_body(build_body(s2)), TEST_KEY_ID); + + Spec s3 = base_spec(); + s3.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, pct, 0); // empty string + ExpectMalformed(sign_body(build_body(s3)), TEST_KEY_ID); +} + +/* ---- ARG_FORMAT_TOKEN_AMOUNT (decimals + symbol + amount) --------------- */ + +std::vector token_amount_value(uint8_t decimals, + const std::string& symbol, + const std::vector& amount) { + std::vector v; + v.push_back(decimals); + v.push_back((uint8_t)symbol.size()); + v.insert(v.end(), symbol.begin(), symbol.end()); + v.insert(v.end(), amount.begin(), amount.end()); + return v; +} + +TEST_F(SignedMetadataTest, TokenAmountAccepted) { + /* 1.00 USDC: 1000000 raw, 6 decimals */ + std::vector amt = {0x0F, 0x42, 0x40}; + std::vector val = token_amount_value(6, "USDC", amt); + Spec s = base_spec(); + s.args[1] = mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, val.data(), val.size()); + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + const SignedMetadata* m = signed_metadata_get(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->args[1].format, ARG_FORMAT_TOKEN_AMOUNT); + EXPECT_EQ(m->args[1].value_len, val.size()); +} + +TEST_F(SignedMetadataTest, TokenAmountUnlimited32BytesAccepted) { + /* UNLIMITED approve: 32 x 0xFF + symbol -> value_len 38 (> old 32 cap) */ + std::vector amt(32, 0xFF); + std::vector val = token_amount_value(6, "USDC", amt); + EXPECT_EQ(val.size(), 38u); // 1+1+4+32 + Spec s = base_spec(); + s.args[1] = mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, val.data(), val.size()); + std::vector blob = sign_body(build_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); +} + +TEST_F(SignedMetadataTest, TokenAmountRejectsBadLayout) { + Spec s = base_spec(); + /* symbol chars outside [A-Za-z0-9] */ + std::vector bad_sym = token_amount_value(6, "US-C", {0x01}); + s.args[1] = + mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, bad_sym.data(), bad_sym.size()); + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); + + /* decimals > 36 */ + Spec s2 = base_spec(); + std::vector bad_dec = token_amount_value(37, "USDC", {0x01}); + s2.args[1] = + mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, bad_dec.data(), bad_dec.size()); + ExpectMalformed(sign_body(build_body(s2)), TEST_KEY_ID); + + /* symbol_len runs past the value (no amount bytes left) */ + Spec s3 = base_spec(); + std::vector no_amt = {6, 4, 'U', 'S', 'D', 'C'}; + s3.args[1] = + mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, no_amt.data(), no_amt.size()); + ExpectMalformed(sign_body(build_body(s3)), TEST_KEY_ID); + + /* legacy formats must NOT accept the larger 44-byte cap */ + Spec s4 = base_spec(); + std::vector big(40, 0xAB); + s4.args[1] = mk_arg("amount", ARG_FORMAT_AMOUNT, big.data(), big.size()); + ExpectMalformed(sign_body(build_body(s4)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgValueTooLong) { + Spec s = base_spec(); + uint8_t big[33] = {0}; + s.args[0] = mk_arg("to", ARG_FORMAT_BYTES, big, 33); // > 32 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgValueLengthOverrun) { + /* value_len prefix claims 32 but only 4 value bytes follow; the read eats + * into the fixed tail and parsing misaligns -> MALFORMED. As with the method + * case, the read_bytes short-read guard is dominated by the >=71-byte fixed + * tail, so this asserts the observable MALFORMED outcome. */ + Spec s = base_spec(); + uint8_t four[4] = {0xde, 0xad, 0xbe, 0xef}; + Arg a = mk_arg("amount", ARG_FORMAT_AMOUNT, four, 4); + a.value_len_override = 32; + s.args[1] = a; + std::vector blob = sign_body(build_body(s)); + blob[blob.size() - 2] ^= 0xFF; // ensure verify cannot pass + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ClassificationOutOfRange) { + Spec s = base_spec(); + s.classification = 3; // > 2 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +/* ===================================================================== * + * signed_metadata_matches_tx — display gate + * ===================================================================== */ + +TEST_F(SignedMetadataTest, MatchesTxAllBindingsMatch) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxNotAvailable) { + signed_metadata_clear(); + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxNullMsg) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_FALSE(signed_metadata_matches_tx(nullptr)); +} + +TEST_F(SignedMetadataTest, MatchesTxNotVerifiedClassification) { + Spec s = base_spec(); + s.classification = METADATA_OPAQUE; + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_OPAQUE); + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongToSize) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + make_matching_msg(&msg); + msg.to.size = 19; // not 20 (e.g. contract-create has 0) + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxDataTooShortForSelector) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + make_matching_msg(&msg); + msg.data_initial_chunk.size = 3; // < 4 + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongContract) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + uint8_t data[68]; + memcpy(data, SEL_TRANSFER, 4); + memset(data + 4, 0, sizeof(data) - 4); + EthereumSignTx msg; + make_msg(&msg, CONTRACT_B, data, sizeof(data), true, 1); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongSelector) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + uint8_t data[68]; + memcpy(data, SEL_APPROVE, 4); // approve, not transfer + memset(data + 4, 0, sizeof(data) - 4); + EthereumSignTx msg; + make_msg(&msg, CONTRACT_A, data, sizeof(data), true, 1); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongChainId) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + uint8_t data[68]; + memcpy(data, SEL_TRANSFER, 4); + memset(data + 4, 0, sizeof(data) - 4); + + EthereumSignTx wrong_chain; + make_msg(&wrong_chain, CONTRACT_A, data, sizeof(data), true, 137); + EXPECT_FALSE(signed_metadata_matches_tx(&wrong_chain)); + + EthereumSignTx no_chain; + make_msg(&no_chain, CONTRACT_A, data, sizeof(data), false, + 0); // treated as 0 + EXPECT_FALSE(signed_metadata_matches_tx(&no_chain)); +} + +/* ===================================================================== * + * signed_metadata_confirm — no-I/O early guards + * ===================================================================== */ + +TEST_F(SignedMetadataTest, ConfirmNotAvailable) { + signed_metadata_clear(); + EXPECT_FALSE(signed_metadata_confirm()); +} + +TEST_F(SignedMetadataTest, ConfirmNotVerified) { + Spec s = base_spec(); + s.classification = METADATA_OPAQUE; + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_OPAQUE); + EXPECT_FALSE(signed_metadata_confirm()); +} + +/* ===================================================================== * + * signed_metadata_enforce — module-level not-relied path (reachable + * without confirm()'s interactive tail) and clear() reset + * ===================================================================== */ + +TEST_F(SignedMetadataTest, EnforceNotReliedAlwaysAllows) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + ASSERT_FALSE(signed_metadata_relied()); // process() never sets relied + + uint8_t wrong[32]; + memcpy(wrong, TX_HASH, 32); + wrong[0] ^= 0xFF; + EXPECT_TRUE(signed_metadata_enforce(TX_HASH)); + EXPECT_TRUE(signed_metadata_enforce(wrong)); + EXPECT_TRUE(signed_metadata_enforce(nullptr)); +} + +TEST_F(SignedMetadataTest, ClearResetsAllState) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + ASSERT_TRUE(signed_metadata_available()); + + signed_metadata_clear(); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_FALSE(signed_metadata_relied()); + EXPECT_EQ(signed_metadata_get(), nullptr); + EXPECT_TRUE(signed_metadata_enforce(TX_HASH)); // not relied +} + +/* ===================================================================== * + * Runtime signer loading — the phase-1 trust path + * ===================================================================== */ + +TEST_F(SignedMetadataTest, NoSignerLoadedRejects) { + signed_metadata_clear_signers(); // undo the fixture's load + ExpectMalformed(base_blob(), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, FromLoadedSignerTracksMetadata) { + EXPECT_FALSE(signed_metadata_from_loaded_signer()); // nothing processed + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_TRUE(signed_metadata_from_loaded_signer()); + signed_metadata_clear(); + EXPECT_FALSE(signed_metadata_from_loaded_signer()); +} + +TEST_F(SignedMetadataTest, ClearSignersDropsKeyAndMetadata) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + signed_metadata_clear_signers(); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_EQ(signed_metadata_get(), nullptr); + ExpectMalformed(blob, TEST_KEY_ID); // the key itself is gone too +} + +TEST_F(SignedMetadataTest, StoreSignerReplacementInvalidatesOldKey) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + uint8_t priv2[32]; + memcpy(priv2, TEST_PRIV, sizeof(priv2)); + priv2[31] ^= 0x5a; // a different valid scalar + uint8_t pub2[33]; + ecdsa_get_public_key33(&secp256k1, priv2, pub2); + signed_metadata_store_signer(TEST_KEY_ID, pub2, "Replacement", NULL, 0, 0, 0, + false); + + /* Replacing a signer drops metadata the old one verified... */ + EXPECT_FALSE(signed_metadata_available()); + /* ...and the old key no longer verifies anything. */ + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* ---- signed_metadata_signer_valid (pure) ------------------------------ */ + +/* + * ── Identity-icon decoder hardening ──────────────────────────────────────── + * + * The clearsign identity icon is HOST-SUPPLIED and is rendered on the trust + * screen, so the decoder is an attack surface reachable before the user has + * approved anything. Regression guards for two review findings: + * + * (1) A 0x80 (n = -128) packet is undecodable: draw_bitmap_mono_rle's counter + * is int8_t, so -(-128) wraps back to -128 and breaks its `> 0` invariant. + * Under NDEBUG the assert is compiled out and decoding proceeded with a + * negative counter (signed-overflow UB). Must fail closed instead. + * (2) icon_width must not exceed LEFT_MARGIN_WITH_ICON: text starts at x=40 + * and the icon is drawn AFTER the text, so a wider icon overwrites the + * alias / fingerprint / "NOT verified by KeepKey" warning. + */ +namespace { + +struct IconCanvas { + uint8_t buf[64 * 256]; + Canvas canvas; + IconCanvas() { + memset(buf, 0, sizeof(buf)); + canvas.buffer = buf; + canvas.width = 256; + canvas.height = 64; + canvas.dirty = false; + } +}; + +bool decode_icon(const std::vector& data, uint16_t w, uint16_t h, + IconCanvas* ic) { + Image img; + img.w = w; + img.h = h; + img.length = (uint32_t)data.size(); + img.data = data.data(); + AnimationFrame frame; + frame.x = 0; + frame.y = 0; + frame.duration = 0; + frame.color = 100; /* value*100/100 => data bytes land verbatim */ + frame.image = &img; + return draw_bitmap_mono_rle(&ic->canvas, &frame, /*erase=*/false); +} + +} // namespace + +TEST(SignedMetadataIcon, GoldenVectorDecodes) { + /* The vector published in messages-ethereum.proto: 03 FF FF 00 (w=2,h=2). */ + IconCanvas ic; + ASSERT_TRUE(decode_icon({0x03, 0xFF, 0xFF, 0x00}, 2, 2, &ic)); + EXPECT_EQ(ic.buf[0 * 256 + 0], 0xFF); + EXPECT_EQ(ic.buf[0 * 256 + 1], 0xFF); + EXPECT_EQ(ic.buf[1 * 256 + 0], 0xFF); + EXPECT_EQ(ic.buf[1 * 256 + 1], 0x00); +} + +TEST(SignedMetadataIcon, LiteralOf128IsRejected) { + /* n = 0x80 = -128. Spec-valid under the old doc, undecodable in fact: + * previously asserted (debug) or decoded with a negative counter (NDEBUG). */ + std::vector data; + data.push_back(0x80); + for (int i = 0; i < 128; i++) data.push_back(0xAA); + IconCanvas ic; + EXPECT_FALSE(decode_icon(data, 128, 1, &ic)); +} + +TEST(SignedMetadataIcon, ZeroCountIsRejected) { + /* n == 0 leaves both counters at 0 and hits the same broken invariant. */ + IconCanvas ic; + EXPECT_FALSE(decode_icon({0x00, 0xFF}, 1, 1, &ic)); +} + +TEST(SignedMetadataIcon, MaxLiteralOf127Decodes) { + /* The boundary that IS valid: n = -127 (0x81). */ + std::vector data; + data.push_back(0x81); + for (int i = 0; i < 127; i++) data.push_back((uint8_t)i); + IconCanvas ic; + ASSERT_TRUE(decode_icon(data, 127, 1, &ic)); + EXPECT_EQ(ic.buf[0], 0x00); + EXPECT_EQ(ic.buf[126], 126); +} + +TEST(SignedMetadataIcon, MaxRunOf127Decodes) { + std::vector data{0x7F, 0x5A}; + IconCanvas ic; + ASSERT_TRUE(decode_icon(data, 127, 1, &ic)); + EXPECT_EQ(ic.buf[0], 0x5A); + EXPECT_EQ(ic.buf[126], 0x5A); +} + +TEST(SignedMetadataIcon, TruncatedStreamIsRejected) { + IconCanvas ic; + EXPECT_FALSE(decode_icon({0x08, 0xFF}, 4, 4, &ic)); /* claims 8, has 2 */ +} + +/* ── Exact-validation guards (review round 2) ────────────────────────────── + * The render path is lenient by construction: it fills the canvas and stops, + * so it cannot reject a final run that straddles the image or trailing packets. + * Callers gate on the validator, so the validator must be exact. */ + +TEST(SignedMetadataIcon, StraddlingRunIsRejected) { + /* 05 FF for a 2x2: a RUN of 5 into a 4-pixel image. The draw loop would fill + * 4 and report success; the stream is not well-formed. */ + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x05\xFF", 2, 2, 2)); + IconCanvas ic; + EXPECT_FALSE(decode_icon({0x05, 0xFF}, 2, 2, &ic)); +} + +TEST(SignedMetadataIcon, TrailingPacketsAreRejected) { + /* Exactly fills 2x2, then carries an unread packet. */ + EXPECT_FALSE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\x04\xFF\x01\xAA", 4, 2, 2)); +} + +TEST(SignedMetadataIcon, TruncatedLiteralBodyIsRejected) { + /* n = -3 promises 3 value bytes, only 2 present. */ + EXPECT_FALSE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\xFD\x01\x02", 3, 3, 1)); +} + +TEST(SignedMetadataIcon, MissingRunValueByteIsRejected) { + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x04", 1, 4, 1)); +} + +TEST(SignedMetadataIcon, ValidatorAcceptsExactStreams) { + /* The golden vector, and the valid boundaries. */ + EXPECT_TRUE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\x03\xFF\xFF\x00", 4, 2, 2)); + EXPECT_TRUE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\x7F\x5A", 2, 127, 1)); + std::vector lit; + lit.push_back(0x81); + for (int i = 0; i < 127; i++) lit.push_back((uint8_t)i); + EXPECT_TRUE( + draw_bitmap_mono_rle_valid(lit.data(), (uint32_t)lit.size(), 127, 1)); +} + +TEST(SignedMetadataIcon, ValidatorRejectsUndecodableAndZeroCounts) { + std::vector lit128; + lit128.push_back(0x80); + for (int i = 0; i < 128; i++) lit128.push_back(0xAA); + EXPECT_FALSE(draw_bitmap_mono_rle_valid(lit128.data(), + (uint32_t)lit128.size(), 128, 1)); + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x00\xFF", 2, 1, 1)); + /* The 1x1 accept-and-persist case: 80 FF was previously stored despite never + * rendering, because only size+dims were checked at the trust boundary. */ + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x80\xFF", 2, 1, 1)); +} + +TEST(SignedMetadataIcon, ValidatorRejectsDegenerateGeometry) { + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x01\xFF", 2, 0, 1)); + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x01\xFF", 2, 1, 0)); + EXPECT_FALSE(draw_bitmap_mono_rle_valid(NULL, 0, 1, 1)); +} + +TEST(SignedMetadataIcon, IconColumnCapIsNarrowerThanTheIconHeight) { + /* The width cap is the 40px text column, NOT the 64px height. A 64px-wide + * icon at x=0 would span into the text that begins at x=40 and, because the + * icon is drawn after the text, erase the "NOT verified" warning. */ + EXPECT_EQ(LEFT_MARGIN_WITH_ICON, 40); + EXPECT_LT(LEFT_MARGIN_WITH_ICON, 64); +} + +TEST(SignedMetadataSignerValid, AcceptsValidCompressedKeyAllSlots) { + for (uint8_t slot = 0; slot < METADATA_MAX_KEYS; slot++) { + EXPECT_TRUE( + signed_metadata_signer_valid(slot, EXPECTED_SLOT3_PUB, 33, "CI Test")) + << "slot " << (int)slot; + } +} + +TEST(SignedMetadataSignerValid, RejectsKeyIdOutOfRange) { + EXPECT_FALSE(signed_metadata_signer_valid(METADATA_MAX_KEYS, + EXPECTED_SLOT3_PUB, 33, "CI Test")); +} + +TEST(SignedMetadataSignerValid, RejectsWrongPubkeyLength) { + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 32, "CI Test")); + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 65, "CI Test")); + EXPECT_FALSE(signed_metadata_signer_valid(0, nullptr, 33, "CI Test")); +} + +TEST(SignedMetadataSignerValid, RejectsNonCompressedPrefix) { + /* 0x04 would make ecdsa_read_pubkey read 65 bytes from a 33-byte buffer — + * the prefix guard must reject it before the parser ever runs. */ + uint8_t bad[33]; + memcpy(bad, EXPECTED_SLOT3_PUB, sizeof(bad)); + bad[0] = 0x04; + EXPECT_FALSE(signed_metadata_signer_valid(0, bad, 33, "CI Test")); + bad[0] = 0x00; // the "empty slot" sentinel must never load as a key + EXPECT_FALSE(signed_metadata_signer_valid(0, bad, 33, "CI Test")); +} + +TEST(SignedMetadataSignerValid, RejectsBadAlias) { + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, nullptr)); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "")); + std::string too_long(METADATA_ALIAS_MAX_LEN + 1, 'a'); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, + too_long.c_str())); + std::string max_len(METADATA_ALIAS_MAX_LEN, 'a'); + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, max_len.c_str())); + /* Realistic aliases (letters/digits/space/-/_) are accepted. */ + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "Pioneer")); + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "KeepKey Swap")); + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "my-signer_1")); + /* Rendered inside quotes on the trust screen — control chars, '%', and + * semantic-injection punctuation (quote breakout, "." / "(" appending a + * false "verified by KeepKey." claim) are all rejected. */ + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "a\nb")); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "a%sb")); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, + "a\x7f" + "b")); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, + "x' verified by KeepKey. Safe (")); + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "safe.KeepKey")); + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "trust(me)")); +} + +TEST(SignedMetadataSignerStore, RejectsPersistenceBeforeSessionMutation) { + signed_metadata_clear_signers(); + EXPECT_FALSE(signed_metadata_store_signer( + TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, nullptr, 0, 0, 0, true)); + EXPECT_EQ(signed_metadata_signer_alias(TEST_KEY_ID), nullptr); + char fingerprint[METADATA_FINGERPRINT_LEN]; + EXPECT_FALSE(signed_metadata_signer_fingerprint(TEST_KEY_ID, fingerprint)); + signed_metadata_clear_signers(); +} + +/* ---- signed_metadata_pubkey_fingerprint -------------------------------- */ + +TEST(SignedMetadataFingerprint, IsSha256Prefix) { + char fp[METADATA_FINGERPRINT_LEN]; + signed_metadata_pubkey_fingerprint(EXPECTED_SLOT3_PUB, fp); + + uint8_t digest[32]; + sha256_Raw(EXPECTED_SLOT3_PUB, 33, digest); + char expected[METADATA_FINGERPRINT_LEN]; + snprintf(expected, sizeof(expected), "%02X%02X%02X%02X", digest[0], digest[1], + digest[2], digest[3]); + EXPECT_STREQ(fp, expected); +} + +/* ===================================================================== * + * signed_metadata_enforce_decision — pure enforce truth table (SECTION 2). + * Exercises the relied==true cases that confirm()'s OLED/button I/O makes + * unreachable from the module-state API in a unit test. + * ===================================================================== */ + +TEST(SignedMetadataEnforce, NotReliedAlwaysAllow) { + uint8_t h[32] = {0}; + uint8_t hw[32] = {1}; + EXPECT_TRUE( + signed_metadata_enforce_decision(false, true, METADATA_VERIFIED, h, h)); + EXPECT_TRUE(signed_metadata_enforce_decision(false, false, METADATA_OPAQUE, + nullptr, nullptr)); + EXPECT_TRUE( + signed_metadata_enforce_decision(false, true, METADATA_VERIFIED, h, hw)); +} + +TEST(SignedMetadataEnforce, ReliedHashMatches) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_TRUE( + signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, h, h)); +} + +TEST(SignedMetadataEnforce, ReliedHashMismatch) { + uint8_t stored[32]; + memcpy(stored, TX_HASH, 32); + uint8_t got[32]; + memcpy(got, TX_HASH, 32); + got[0] ^= 0x01; + EXPECT_FALSE(signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, + stored, got)); +} + +TEST(SignedMetadataEnforce, ReliedHashNull) { + uint8_t stored[32]; + memcpy(stored, TX_HASH, 32); + EXPECT_FALSE(signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, + stored, nullptr)); +} + +TEST(SignedMetadataEnforce, ReliedNotAvailable) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_FALSE( + signed_metadata_enforce_decision(true, false, METADATA_VERIFIED, h, h)); +} + +TEST(SignedMetadataEnforce, ReliedNotVerified) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_FALSE( + signed_metadata_enforce_decision(true, true, METADATA_OPAQUE, h, h)); + EXPECT_FALSE( + signed_metadata_enforce_decision(true, true, METADATA_MALFORMED, h, h)); +} + +TEST(SignedMetadataEnforce, ReliedStoredHashNull) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_FALSE(signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, + nullptr, h)); +} + +/* ===================================================================== * + * SECTION 3 — v2 static-schema blobs + on-device calldata decode + * + * v2 carries NO tx_hash and NO argument values. The blob attests only the + * static schema (chainId, contract, selector, method, per-arg name + display + * format [+ static decimals/symbol]); the device decodes the actual argument + * values from the calldata it is about to sign. These tests drive the full + * parse -> verify -> signed_metadata_matches_tx (which decodes) path and check + * the decoded MetadataArg values, plus the malformed/rejection cases. + * ===================================================================== */ + +struct V2Arg { + std::string name; + uint8_t format; + uint8_t decimals; /* TOKEN_AMOUNT only */ + std::string symbol; /* TOKEN_AMOUNT only */ +}; + +V2Arg v2_addr(const std::string& name) { + return V2Arg{name, ARG_FORMAT_ADDRESS, 0, ""}; +} +V2Arg v2_token(const std::string& name, uint8_t decimals, + const std::string& symbol) { + return V2Arg{name, ARG_FORMAT_TOKEN_AMOUNT, decimals, symbol}; +} + +struct V2Spec { + uint32_t chain_id; + std::vector contract; + std::vector selector; + std::string method; + std::vector args; + uint8_t classification; + uint8_t key_id; + int num_args_override; // -1 => use args.size() +}; + +V2Spec v2_base_spec() { + V2Spec s; + s.chain_id = 1; + s.contract.assign(CONTRACT_A, CONTRACT_A + 20); + s.selector.assign(SEL_TRANSFER, SEL_TRANSFER + 4); + s.method = "transfer"; + s.args.push_back(v2_addr("to")); + s.args.push_back(v2_token("amount", 6, "USDC")); + s.classification = METADATA_VERIFIED; + s.key_id = TEST_KEY_ID; + s.num_args_override = -1; + return s; +} + +std::vector build_v2_body(const V2Spec& s) { + std::vector b; + put_u8(b, METADATA_VERSION_SCHEMA); + put_be32(b, s.chain_id); + put_bytes(b, s.contract.data(), s.contract.size()); + put_bytes(b, s.selector.data(), s.selector.size()); + put_be16(b, (uint16_t)s.method.size()); + put_bytes(b, (const uint8_t*)s.method.data(), s.method.size()); + put_u8(b, s.num_args_override >= 0 ? (uint8_t)s.num_args_override + : (uint8_t)s.args.size()); + for (const V2Arg& a : s.args) { + put_u8(b, (uint8_t)a.name.size()); + put_bytes(b, (const uint8_t*)a.name.data(), a.name.size()); + put_u8(b, a.format); + if (a.format == ARG_FORMAT_TOKEN_AMOUNT) { + put_u8(b, a.decimals); + put_u8(b, (uint8_t)a.symbol.size()); + put_bytes(b, (const uint8_t*)a.symbol.data(), a.symbol.size()); + } + } + put_u8(b, s.classification); + put_be32(b, 0); // timestamp + put_u8(b, s.key_id); + return b; +} + +std::vector v2_base_blob() { + return sign_body(build_v2_body(v2_base_spec())); +} + +/* ABI calldata: selector + one 32-byte head word per arg. */ +void put_addr_word(std::vector& d, const uint8_t addr[20]) { + for (int i = 0; i < 12; i++) d.push_back(0); + d.insert(d.end(), addr, addr + 20); +} + +/* Canonical transfer(to=RECIPIENT, amount=AMOUNT32) calldata (4 + 64 = 68). */ +std::vector v2_transfer_calldata() { + std::vector d(SEL_TRANSFER, SEL_TRANSFER + 4); + put_addr_word(d, RECIPIENT); + d.insert(d.end(), AMOUNT32, AMOUNT32 + 32); + return d; +} + +void make_v2_msg(EthereumSignTx* msg, const uint8_t contract[20], + const std::vector& data, bool has_len, + uint32_t data_length) { + memset(msg, 0, sizeof(*msg)); + msg->has_to = true; + msg->to.size = 20; + memcpy(msg->to.bytes, contract, 20); + msg->has_data_initial_chunk = true; + msg->data_initial_chunk.size = (pb_size_t)data.size(); + memcpy(msg->data_initial_chunk.bytes, data.data(), data.size()); + msg->has_chain_id = true; + msg->chain_id = 1; + msg->has_data_length = has_len; + msg->data_length = has_len ? data_length : 0; +} + +/* Happy path: parse+verify a v2 blob, then matches_tx decodes the args from the + * transfer calldata and populates stored_metadata. */ +TEST_F(SignedMetadataTest, V2SchemaDecodesTransferArgs) { + std::vector blob = v2_base_blob(); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_TRUE(signed_metadata_available()); + + const SignedMetadata* md = signed_metadata_get(); + ASSERT_NE(md, nullptr); + EXPECT_EQ(md->version, METADATA_VERSION_SCHEMA); + EXPECT_EQ(md->num_args, 2); + EXPECT_EQ(md->args[0].value_len, 0); // undecoded before matches_tx + + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + + EXPECT_EQ(md->args[0].format, ARG_FORMAT_ADDRESS); + EXPECT_EQ(md->args[0].value_len, 20); + EXPECT_EQ(memcmp(md->args[0].value, RECIPIENT, 20), 0); + + EXPECT_EQ(md->args[1].format, ARG_FORMAT_TOKEN_AMOUNT); + EXPECT_EQ(md->args[1].value_len, 2 + 4 + 32); + EXPECT_EQ(md->args[1].value[0], 6); // decimals + EXPECT_EQ(md->args[1].value[1], 4); // symlen + EXPECT_EQ(memcmp(md->args[1].value + 2, "USDC", 4), 0); + EXPECT_EQ(memcmp(md->args[1].value + 6, AMOUNT32, 32), 0); +} + +/* THE v2 drain preventer, restated. + * + * A v2 schema commits to calldata only — never to msg->value — so it cannot + * bind a payable call's amount. The original guard refused any nonzero value, + * which meant every value-bearing route (a Relay ETH->SOL bridge deposit, for + * one) was forced to blind-sign: precisely the transactions most worth + * reviewing. Refusing was not what kept funds safe; SHOWING the amount is. + * + * So the match now succeeds and the schema reports that the tx moves value. + * ethereum.c consumes that to keep the native amount/recipient screen instead + * of suppressing it, so the user sees the decoded call AND the ETH leaving. + * The amount is read from the transaction being signed, so nothing unattested + * reaches the screen and the schema stays transaction-independent. */ +TEST_F(SignedMetadataTest, V2SchemaPayableKeepsValueScreen) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + msg.has_value = true; + msg.value.size = 1; + msg.value.bytes[0] = 0x01; // 1 wei — any nonzero value is "payable" + + /* Clear-signs, AND flags that the amount screen must still run. */ + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_TRUE(signed_metadata_schema_moves_value()); + + /* Zero value: same match, but no extra screen is demanded — proving the + * flag tracks the value rather than being always-on. */ + msg.value.size = 0; + msg.has_value = false; + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_FALSE(signed_metadata_schema_moves_value()); +} + +/* A large, realistic value must set the flag too — not just a 1-wei probe. */ +TEST_F(SignedMetadataTest, V2SchemaPayableFlagsRealisticValue) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + msg.has_value = true; + /* 0.00798 ETH = 0x1c5145d9b6b3ff — the Relay ETH->SOL deposit from a real + * quote, whose blind-signing prompted this change. */ + const uint8_t kValue[] = {0x1c, 0x51, 0x45, 0xd9, 0xb6, 0xb3, 0xff}; + msg.value.size = sizeof(kValue); + memcpy(msg.value.bytes, kValue, sizeof(kValue)); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_TRUE(signed_metadata_schema_moves_value()); +} + +/* THE transaction this whole path exists for: a real Relay ETH->SOL bridge + * deposit, captured from api.relay.link on 2026-07-27. + * + * to 0x4cd00e387622c35bddb9b4c962c136462338bc31 + * value 7980129999999999 wei (0.00798 ETH) <-- PAYABLE + * calldata 0x49290c1c + address(depositor) + bytes32(orderId) = 68 bytes + * + * Three things had to be true for this to clear-sign, and each was a real + * blocker: the call is payable (was refused outright), one arg is an opaque + * word (BYTES was not accepted in the v2 arg parser), and 4 + 2*32 must + * exactly equal the calldata length (structural completeness). */ +TEST_F(SignedMetadataTest, V2SchemaDecodesRelayEthToSolanaDeposit) { + const uint8_t RELAY_ROUTER[20] = {0x4c, 0xd0, 0x0e, 0x38, 0x76, 0x22, 0xc3, + 0x5b, 0xdd, 0xb9, 0xb4, 0x96, 0x2c, 0x13, + 0x64, 0x62, 0x33, 0x8b, 0xc3, 0x31}; + const uint8_t SEL[4] = {0x49, 0x29, 0x0c, 0x1c}; + /* depositor 0x909Ef6B32DfDc12CA86aA710b54c991af3C5F82E */ + const uint8_t DEPOSITOR[20] = {0x90, 0x9e, 0xf6, 0xb3, 0x2d, 0xfd, 0xc1, + 0x2c, 0xa8, 0x6a, 0xa7, 0x10, 0xb5, 0x4c, + 0x99, 0x1a, 0xf3, 0xc5, 0xf8, 0x2e}; + /* orderId 0x8a2c1211...cb1, verbatim from the quote */ + const uint8_t ORDER_ID[32] = {0x8a, 0x2c, 0x12, 0x11, 0x97, 0xef, 0xc9, 0x5c, + 0x42, 0xf5, 0x31, 0x42, 0xab, 0x40, 0x97, 0x35, + 0xee, 0x35, 0x32, 0x87, 0xf8, 0x77, 0xed, 0x4d, + 0x35, 0x1f, 0x63, 0x09, 0x4d, 0x5b, 0xfc, 0xb1}; + + V2Spec s = v2_base_spec(); + s.contract.assign(RELAY_ROUTER, RELAY_ROUTER + 20); + s.selector.assign(SEL, SEL + 4); + s.method = "bridgeDeposit"; + s.args.clear(); + s.args.push_back(v2_addr("depositor")); + s.args.push_back(V2Arg{"orderId", ARG_FORMAT_BYTES, 0, ""}); + + std::vector blob = sign_body(build_v2_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + std::vector data(SEL, SEL + 4); + put_addr_word(data, DEPOSITOR); + data.insert(data.end(), ORDER_ID, ORDER_ID + 32); + ASSERT_EQ(data.size(), 68u); /* 4 + 2*32, exactly — no remainder */ + + EthereumSignTx msg; + make_v2_msg(&msg, RELAY_ROUTER, data, /*has_len=*/true, + (uint32_t)data.size()); + /* 0.00798 ETH — the payable part that used to force blind-signing. */ + const uint8_t VALUE[] = {0x1c, 0x51, 0x45, 0xd9, 0xb6, 0xb3, 0xff}; + msg.has_value = true; + msg.value.size = sizeof(VALUE); + memcpy(msg.value.bytes, VALUE, sizeof(VALUE)); + + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + /* ...and the ETH amount screen must still run, since the schema cannot + * bind the value. */ + EXPECT_TRUE(signed_metadata_schema_moves_value()); + + const SignedMetadata* md = signed_metadata_get(); + ASSERT_NE(md, nullptr); + EXPECT_EQ(md->num_args, 2); + EXPECT_EQ(md->args[0].format, ARG_FORMAT_ADDRESS); + EXPECT_EQ(md->args[0].value_len, 20); + EXPECT_EQ(memcmp(md->args[0].value, DEPOSITOR, 20), 0); + EXPECT_EQ(md->args[1].format, ARG_FORMAT_BYTES); + EXPECT_EQ(md->args[1].value_len, 32); + EXPECT_EQ(memcmp(md->args[1].value, ORDER_ID, 32), 0); +} + +/* Relay solver swap: selector 0x02d5f05f(token address, amount, requestId) — + * three fixed single words, EXACTLY the shape pulled from real relay traffic + * (100-byte calldata: 4 + 3*32, zero remainder, verified across 22 live + * samples). Proves a v2 static schema clear-signs a relay swap: the device + * decodes token+amount+id from the very calldata it is about to sign — no + * tx_hash, no per-tx online signer, schema signed once offline. This is the + * "add a new service via a signed payload" path for a NON-native contract + * (relay is not in ethereum_contractHandled). */ +TEST_F(SignedMetadataTest, V2SchemaDecodesRelaySolverArgs) { + const uint8_t RELAY_SOLVER[20] = {0x4c, 0xd0, 0x0e, 0x38, 0x76, 0x22, 0xc3, + 0x5b, 0xdd, 0xb9, 0xb4, 0x96, 0x2c, 0x13, + 0x64, 0x62, 0x33, 0x8b, 0xc3, 0x31}; + const uint8_t SEL_RELAY[4] = {0x02, 0xd5, 0xf0, 0x5f}; + uint8_t REQ_ID[32] = {0}; // requestId 0x...cd7c from a real sample + REQ_ID[30] = 0xcd; + REQ_ID[31] = 0x7c; + + V2Spec s = v2_base_spec(); + s.contract.assign(RELAY_SOLVER, RELAY_SOLVER + 20); + s.selector.assign(SEL_RELAY, SEL_RELAY + 4); + s.method = "relaySwap"; + s.args.clear(); + s.args.push_back(v2_addr("token")); + s.args.push_back(v2_token("amount", 6, "USDC")); + s.args.push_back(V2Arg{"requestId", ARG_FORMAT_AMOUNT, 0, ""}); + + std::vector blob = sign_body(build_v2_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + const SignedMetadata* md = signed_metadata_get(); + ASSERT_NE(md, nullptr); + EXPECT_EQ(md->version, METADATA_VERSION_SCHEMA); + EXPECT_EQ(md->num_args, 3); + + // Real relay calldata: selector + token(USDC=CONTRACT_A) + amount + + // requestId. + std::vector data(SEL_RELAY, SEL_RELAY + 4); + put_addr_word(data, CONTRACT_A); + data.insert(data.end(), AMOUNT32, AMOUNT32 + 32); + data.insert(data.end(), REQ_ID, REQ_ID + 32); + EXPECT_EQ(data.size(), 100u); + + EthereumSignTx msg; + make_v2_msg(&msg, RELAY_SOLVER, data, /*has_len=*/true, + (uint32_t)data.size()); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + + // token → full 20-byte USDC address (never truncated). + EXPECT_EQ(md->args[0].format, ARG_FORMAT_ADDRESS); + EXPECT_EQ(md->args[0].value_len, 20); + EXPECT_EQ(memcmp(md->args[0].value, CONTRACT_A, 20), 0); + + // amount → TOKEN_AMOUNT [decimals=6, "USDC", 32-byte amount]. + EXPECT_EQ(md->args[1].format, ARG_FORMAT_TOKEN_AMOUNT); + EXPECT_EQ(md->args[1].value[0], 6); + EXPECT_EQ(md->args[1].value[1], 4); + EXPECT_EQ(memcmp(md->args[1].value + 2, "USDC", 4), 0); + EXPECT_EQ(memcmp(md->args[1].value + 6, AMOUNT32, 32), 0); + + // requestId → raw 32-byte AMOUNT word. + EXPECT_EQ(md->args[2].format, ARG_FORMAT_AMOUNT); + EXPECT_EQ(md->args[2].value_len, 32); + EXPECT_EQ(memcmp(md->args[2].value, REQ_ID, 32), 0); +} + +/* has_data_length omitted but the initial chunk IS the whole calldata: allowed. + */ +TEST_F(SignedMetadataTest, V2AcceptsNoDataLengthWhenChunkComplete) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/false, 0); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); +} + +/* Reject when the tx claims MORE calldata than the schema accounts for. */ +TEST_F(SignedMetadataTest, V2RejectsExtraCalldataLength) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); // 68 bytes + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, 100); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* Reject a partial initial chunk (rest would stream later). */ +TEST_F(SignedMetadataTest, V2RejectsPartialInitialChunk) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + data.resize(40); // selector + partial first word + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, 68); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* Reject an ABI address word with non-zero high bytes. */ +TEST_F(SignedMetadataTest, V2RejectsDirtyAddressWord) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + data[4] = 0x01; // first (should-be-zero) byte of the address word + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* Wrong selector in calldata -> matches_tx fails before decode. */ +TEST_F(SignedMetadataTest, V2RejectsSelectorMismatch) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + memcpy(data.data(), SEL_APPROVE, 4); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* An unsupported display format -> MALFORMED. + * + * v2 renders fixed single ABI words only: ADDRESS, AMOUNT, BYTES and + * TOKEN_AMOUNT. STRING is dynamic (offset + length + payload), so it cannot be + * read from one 32-byte word and must stay out of scope — accepting it would + * break the "declared widths equal the calldata length" rule that makes a + * schema safe without a tx_hash. An out-of-range format byte must fail too. */ +TEST_F(SignedMetadataTest, V2RejectsUnsupportedFormat) { + V2Spec s = v2_base_spec(); + s.args[1] = V2Arg{"data", ARG_FORMAT_STRING, 0, ""}; + std::vector blob = sign_body(build_v2_body(s)); + ExpectMalformed(blob, TEST_KEY_ID); + + V2Spec bogus = v2_base_spec(); + bogus.args[1] = V2Arg{"data", (ArgFormat)0x7f, 0, ""}; + std::vector blob2 = sign_body(build_v2_body(bogus)); + ExpectMalformed(blob2, TEST_KEY_ID); +} + +/* BYTES IS supported in v2: an opaque fixed word (a router's order id) still + * occupies exactly one ABI word, so it neither breaks structural completeness + * nor needs a dynamic decoder. */ +TEST_F(SignedMetadataTest, V2AcceptsBytesArg) { + V2Spec s = v2_base_spec(); + s.args[1] = V2Arg{"orderId", ARG_FORMAT_BYTES, 0, ""}; + std::vector blob = sign_body(build_v2_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); +} + +/* Tampered v2 body must fail the signature check. */ +TEST_F(SignedMetadataTest, V2RejectsTamperedBody) { + std::vector blob = v2_base_blob(); + blob[5] ^= 0xFF; // flip a contract-address byte in the signed region + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* Zero-arg v2 schema (selector-only call): valid, decodes nothing. */ +TEST_F(SignedMetadataTest, V2ZeroArgsSelectorOnly) { + V2Spec s = v2_base_spec(); + s.args.clear(); + s.method = "poke"; + std::vector blob = sign_body(build_v2_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data(SEL_TRANSFER, SEL_TRANSFER + 4); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, 4); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_EQ(signed_metadata_get()->num_args, 0); +} + +/* matches_tx() must be idempotent: a second call decodes to the same values + * (regression for the TOKEN_AMOUNT prefix that used to grow on each call). */ +TEST_F(SignedMetadataTest, V2MatchesTxIsIdempotent) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + const SignedMetadata* md = signed_metadata_get(); + uint16_t len_addr = md->args[0].value_len, len_tok = md->args[1].value_len; + + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); // second call + EXPECT_EQ(md->args[0].value_len, len_addr); + EXPECT_EQ(md->args[1].value_len, len_tok); + EXPECT_EQ(md->args[1].value_len, 2 + 4 + 32); + EXPECT_EQ(memcmp(md->args[0].value, RECIPIENT, 20), 0); + EXPECT_EQ(memcmp(md->args[1].value + 6, AMOUNT32, 32), 0); +} + +/* The v2 decode flag must reflect ONLY the latest matches_tx() call: a + * successful decode followed by a mismatching tx must leave it false, so a + * stale "decoded" proof can never survive into enforce. */ +TEST_F(SignedMetadataTest, V2SchemaDecodedFlagNotStaleAfterMismatch) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_FALSE(signed_metadata_schema_decoded()); // not decoded yet + + EthereumSignTx ok; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&ok, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + ASSERT_TRUE(signed_metadata_matches_tx(&ok)); + EXPECT_TRUE(signed_metadata_schema_decoded()); // decoded this tx + + /* Now a tx that fails an EARLY binding (wrong contract) — before the decode + * branch. The flag must be cleared, not left over from the match above. */ + EthereumSignTx bad; + make_v2_msg(&bad, CONTRACT_B, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_FALSE(signed_metadata_matches_tx(&bad)); + EXPECT_FALSE(signed_metadata_schema_decoded()); +} + +/* ---- v2 enforce truth table (pure, no I/O) ------------------------------ */ +/* Signature: (relied, available, decoded, classification). */ + +TEST(SignedMetadataEnforceSchema, NotReliedAlwaysAllow) { + EXPECT_TRUE(signed_metadata_enforce_schema_decision(false, true, true, + METADATA_VERIFIED)); + EXPECT_TRUE(signed_metadata_enforce_schema_decision(false, false, false, + METADATA_OPAQUE)); +} + +TEST(SignedMetadataEnforceSchema, ReliedVerifiedDecodedAllow) { + EXPECT_TRUE(signed_metadata_enforce_schema_decision(true, true, true, + METADATA_VERIFIED)); +} + +TEST(SignedMetadataEnforceSchema, ReliedButNotDecodedFails) { + /* The core hardening: relied + available + VERIFIED but decode never ran. */ + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, true, false, + METADATA_VERIFIED)); +} + +TEST(SignedMetadataEnforceSchema, ReliedButUnavailableOrUnverifiedFails) { + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, false, true, + METADATA_VERIFIED)); + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, true, true, + METADATA_OPAQUE)); + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, true, true, + METADATA_MALFORMED)); +} + +// Generic attestation primitive (used by the Solana signed-token-definition +// path): a valid signature from a loaded signer verifies; tampering, an +// unloaded key_id, or a wrong signature length are all rejected. +TEST(SignedMetadataAttestation, VerifiesValidRejectsTampered) { + set_advanced_mode_for_test(true); + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + nullptr, 0, 0, 0, false); + + const uint8_t data[] = "KeepKeySolanaTokenDef/1|mint|decimals|USDC"; + const size_t len = sizeof(data) - 1; + uint8_t digest[32]; + sha256_Raw(data, len, digest); + uint8_t sig[64]; + uint8_t pby; + ASSERT_EQ( + 0, ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, &pby, nullptr)); + + EXPECT_TRUE(signed_metadata_verify_attestation(TEST_KEY_ID, data, len, sig, + sizeof(sig))); + + std::vector bad(data, data + len); + bad[0] ^= 0x01; + EXPECT_FALSE(signed_metadata_verify_attestation(TEST_KEY_ID, bad.data(), len, + sig, sizeof(sig))); + EXPECT_FALSE(signed_metadata_verify_attestation((uint8_t)(TEST_KEY_ID + 1), + data, len, sig, sizeof(sig))); + EXPECT_FALSE( + signed_metadata_verify_attestation(TEST_KEY_ID, data, len, sig, 63)); + + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); +} + +// End-to-end test of the production Solana token-definition path: builds the +// exact domain-separated preimage solana_token_info_trusted() reconstructs, +// signs it, and checks acceptance + every rejection branch. +TEST(SolanaTokenDef, TrustedOnlyWithValidAttestation) { + set_advanced_mode_for_test(true); + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + nullptr, 0, 0, 0, false); + + SolanaTokenInfo ti; + memset(&ti, 0, sizeof(ti)); + ti.has_mint = true; + ti.mint.size = 32; + memset(ti.mint.bytes, 0xAB, 32); + ti.has_symbol = true; + strcpy(ti.symbol, "USDC"); + ti.has_decimals = true; + ti.decimals = 6; + ti.has_signer_key_id = true; + ti.signer_key_id = TEST_KEY_ID; + + // Canonical preimage: tag || mint(32) || decimals(le32) || symbol. + std::vector pre; + const char* tag = "KeepKeySolanaTokenDef/1"; + pre.insert(pre.end(), tag, tag + strlen(tag)); + pre.insert(pre.end(), ti.mint.bytes, ti.mint.bytes + 32); + pre.push_back(6); + pre.push_back(0); + pre.push_back(0); + pre.push_back(0); + pre.insert(pre.end(), ti.symbol, ti.symbol + strlen(ti.symbol)); + + uint8_t digest[32]; + sha256_Raw(pre.data(), pre.size(), digest); + uint8_t sig[64]; + uint8_t pby; + ASSERT_EQ( + 0, ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, &pby, nullptr)); + ti.has_signature = true; + ti.signature.size = 64; + memcpy(ti.signature.bytes, sig, 64); + + EXPECT_TRUE(solana_token_info_trusted(&ti)); + + // Attested-tuple disagreement: a different decimals no longer matches the + // sig. + ti.decimals = 9; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + ti.decimals = 6; + EXPECT_TRUE(solana_token_info_trusted(&ti)); + + // Corrupted signature. + ti.signature.bytes[10] ^= 0x40; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + ti.signature.bytes[10] ^= 0x40; + + // Out-of-range signer slot (256 would narrow to slot 0 without the guard). + ti.signer_key_id = 256; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + ti.signer_key_id = TEST_KEY_ID; + + // No attestation -> not trusted (the caller falls back to unsigned display). + ti.has_signature = false; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); +} + +/* ===================================================================== * + * Clearsign attestor: the issuer/verifier digest contract + * + * fsm_msgClearsignAttestorSign signs sha256(payload) as a 64-byte compact + * ECDSA signature; verifying devices check it through + * signed_metadata_verify_attestation. Those two constructions living in + * different files is exactly how SignIdentity ended up unusable for this + * (Bitcoin message header + double hash, 65 bytes). This pins the contract + * so a change on either side fails here rather than in the field. + * ===================================================================== */ + +TEST(ClearsignAttestor, SignedSchemaVerifiesOnTheVerifyingDevice) { + set_advanced_mode_for_test(true); + /* Smallest valid KKSOLSC1 payload: no args, no accounts. What matters here + * is the digest construction, not the schema body. */ + std::vector payload; + auto push = [&](const void* p, size_t n) { + const uint8_t* b = static_cast(p); + payload.insert(payload.end(), b, b + n); + }; + push("KKSOLSC1", 8); + payload.push_back(1); /* version */ + payload.insert(payload.end(), 32, 0x42); + payload.push_back(1); /* disc_len */ + payload.push_back(0x0d); /* discriminator */ + payload.push_back(5); + push("Relay", 5); + payload.push_back(7); + push("deposit", 7); + payload.push_back(0); /* no args */ + payload.push_back(0); /* no accounts */ + + SolanaInstrSchema schema; + ASSERT_TRUE(solana_parseInstrSchema(payload.data(), payload.size(), &schema)) + << "the attestor refuses to sign what it cannot parse"; + + /* Issuer side, byte for byte what the handler does. */ + uint8_t digest[32]; + sha256_Raw(payload.data(), payload.size(), digest); + uint8_t sig[64]; + ASSERT_EQ(ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, NULL, NULL), + 0); + + /* Verifier side. */ + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + NULL, 0, 0, 0, false); + EXPECT_TRUE(signed_metadata_verify_attestation( + TEST_KEY_ID, payload.data(), payload.size(), sig, sizeof(sig))); + + /* A schema the attestor never saw must not ride the same signature. */ + payload[9] ^= 0x01; /* first byte of the program id */ + EXPECT_FALSE(signed_metadata_verify_attestation( + TEST_KEY_ID, payload.data(), payload.size(), sig, sizeof(sig))); + + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); +} + +} // namespace From 9f625f2a9142a8586e25837f98e11d15d7a7926b Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:36:43 -0300 Subject: [PATCH 08/40] feat(solana): clear-sign v0 transactions with reusable instruction schemas Solana signing moves from "confirm this blob" to describing the actual instructions, using schemas that are signed once and reused: - v0 (versioned) transactions and address-table lookups are parsed, and the instruction set is rendered per program: transfers, token transfers, memo bodies and offchain messages. - A schema describes a program's instruction layout (discriminator, argument types and labels) and carries a signature with no transaction hash bound to it, so one blob covers every call of that shape instead of needing a fresh signature per transaction. - x402 payment requests are clear-signed through the same path: the payment amount, asset and recipient are shown from schema-described fields rather than trusting host-supplied text. - Anything without a verified schema still reaches the raw-data screen. - unittests/firmware/solana.cpp covers v0 parsing, schema decode, argument rendering and the reject paths for malformed or unsigned schemas. --- include/keepkey/firmware/solana.h | 136 +++++ lib/firmware/fsm_msg_solana.h | 759 +++++++++++++++++++++----- lib/firmware/solana.c | 518 ++++++++++++++++-- unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/solana.cpp | 863 +++++++++++++++++++++++++++++- 5 files changed, 2092 insertions(+), 185 deletions(-) diff --git a/include/keepkey/firmware/solana.h b/include/keepkey/firmware/solana.h index a56611dad..f0fd6380c 100644 --- a/include/keepkey/firmware/solana.h +++ b/include/keepkey/firmware/solana.h @@ -156,6 +156,18 @@ typedef struct { uint8_t mint[SOL_PUBKEY_SIZE]; bool has_mint; uint8_t extra_u8; + /* Instruction payload (memo body display). Points into the raw message + * buffer passed to solana_inspectTx — valid only while that buffer is. */ + const uint8_t* data; + uint16_t data_len; + /* Account index list, same lifetime as `data`. Needed to resolve a + * KKSOLSC1 schema's labelled accounts back to real pubkeys. */ + const uint8_t* acct_indices; + uint8_t num_acct_indices; + /* True when this instruction reaches into an address-lookup table, so its + * accounts are NOT present in the signed message. A schema must never be + * applied to one: the pubkeys it would display are unknowable on-device. */ + bool external; } SolanaParsedInstruction; /* Parsed transaction header */ @@ -177,6 +189,98 @@ typedef enum { SOL_TX_REVIEW_VERIFIED, } SolanaTxReview; +/* Firmware-owned token definitions. These are intentionally tiny and only + * cover identities whose mint and decimals are stable enough to be part of + * the device's trusted display policy. */ +typedef struct { + uint8_t mint[SOL_PUBKEY_SIZE]; + const char* symbol; + uint8_t decimals; +} SolanaKnownToken; + +/* ── KKSOLSC1: reusable instruction schemas ─────────────────────────── + * + * A schema says how to READ one program instruction — it carries no amounts + * and no transaction hash. A trusted clearsign signer attests it ONCE per + * (program, discriminator); every later transaction reuses the same blob and + * the device decodes the values straight out of the bytes it is signing. + * + * Safety rests on structural completeness, not on binding to a transaction: + * - discriminator + the declared arg widths must equal the instruction + * data length EXACTLY, so no unaccounted byte can carry a second effect; + * - every account index the schema displays must exist in the instruction; + * - the instruction must not reach into a lookup table (see `external`); + * - and every OTHER instruction in the transaction must be one firmware + * already recognises, so a schema can never green-light a message whose + * real effect sits in an instruction nobody described. + * + * Canonical payload (all integers big-endian, text printable ASCII, no '%'): + * magic 8 "KKSOLSC1" + * version 1 = 1 + * program_id 32 + * disc_len 1 1..8 + * discriminator disc_len + * program name 1 + 1..SOL_SCHEMA_NAME_MAX + * instr name 1 + 1..SOL_SCHEMA_NAME_MAX + * n_args 1 0..SOL_SCHEMA_MAX_ARGS + * per arg: type(1) label_len(1) label + * n_accounts 1 0..SOL_SCHEMA_MAX_ACCOUNTS + * per account: index(1) label_len(1) label + * No bytes may follow. Args are laid out sequentially from the end of the + * discriminator, in declaration order. + */ +#define SOL_SCHEMA_NAME_MAX 20 +#define SOL_SCHEMA_LABEL_MAX 16 +#define SOL_SCHEMA_MAX_ARGS 4 +#define SOL_SCHEMA_MAX_ACCOUNTS 4 +#define SOL_SCHEMA_DISC_MAX 8 + +typedef enum { + SOL_SCHEMA_ARG_U64 = 1, /* 8 bytes, shown as a decimal integer */ + SOL_SCHEMA_ARG_U8 = 2, /* 1 byte */ + SOL_SCHEMA_ARG_PUBKEY = 3, /* 32 bytes, shown base58 */ + SOL_SCHEMA_ARG_OPAQUE32 = 4, /* 32 bytes, shown truncated hex */ +} SolanaSchemaArgType; + +typedef struct { + SolanaSchemaArgType type; + char label[SOL_SCHEMA_LABEL_MAX + 1]; +} SolanaSchemaArg; + +typedef struct { + uint8_t index; + char label[SOL_SCHEMA_LABEL_MAX + 1]; +} SolanaSchemaAccount; + +typedef struct { + uint8_t program_id[SOL_PUBKEY_SIZE]; + uint8_t disc[SOL_SCHEMA_DISC_MAX]; + uint8_t disc_len; + char program_name[SOL_SCHEMA_NAME_MAX + 1]; + char instruction_name[SOL_SCHEMA_NAME_MAX + 1]; + SolanaSchemaArg args[SOL_SCHEMA_MAX_ARGS]; + uint8_t num_args; + SolanaSchemaAccount accounts[SOL_SCHEMA_MAX_ACCOUNTS]; + uint8_t num_accounts; +} SolanaInstrSchema; + +/* Parse a KKSOLSC1 payload. Validates every length and text field and + * requires the payload to be consumed exactly. */ +/* Byte width one schema arg consumes in the instruction data. 0 = unknown + * type, which the parser rejects. */ +uint16_t solana_schemaArgWidth(SolanaSchemaArgType t); + +bool solana_parseInstrSchema(const uint8_t* payload, size_t payload_len, + SolanaInstrSchema* out); + +/* Find the instruction this schema describes and prove it may be trusted: + * program id + discriminator match, the schema accounts for the instruction + * data exactly, its account indices are in range, the instruction is not + * lookup-table backed, and every other instruction in `tx` is a program + * firmware already decodes. Returns the matching index via `out_index`. */ +bool solana_schemaApplies(const SolanaInstrSchema* schema, + const SolanaParsedTx* tx, uint8_t* out_index); + /* Inspect a raw Solana transaction and classify it for signing UX */ SolanaTxReview solana_inspectTx(const uint8_t* raw, size_t raw_len, SolanaParsedTx* tx); @@ -191,10 +295,42 @@ void solana_formatAmount(char* buf, size_t len, uint64_t lamports); void solana_formatTokenAmount(char* buf, size_t len, uint64_t amount, const char* symbol, uint8_t decimals); +/* Look up a firmware-owned token identity by its signed mint account. */ +const SolanaKnownToken* solana_findKnownToken( + const uint8_t mint[SOL_PUBKEY_SIZE]); + +/* Derive the canonical SPL associated token account for + * (owner, token_program, mint), using Solana's find_program_address rules. */ +bool solana_deriveAssociatedTokenAddress( + const uint8_t owner[SOL_PUBKEY_SIZE], + const uint8_t token_program[SOL_PUBKEY_SIZE], + const uint8_t mint[SOL_PUBKEY_SIZE], uint8_t out[SOL_PUBKEY_SIZE]); + +/* Match a host-provided candidate owner only after deriving its ATA and + * comparing it to the destination that is present in the signed instruction. + * Returns the verified owner through out, or false without modifying out. */ +bool solana_findTokenRecipientOwner( + const SolanaSignTx* msg, const uint8_t token_program[SOL_PUBKEY_SIZE], + const uint8_t mint[SOL_PUBKEY_SIZE], + const uint8_t destination[SOL_PUBKEY_SIZE], uint8_t out[SOL_PUBKEY_SIZE]); + /* Look up token info from the host-provided list */ const SolanaTokenInfo* solana_findTokenInfo( const SolanaSignTx* msg, const uint8_t mint[SOL_PUBKEY_SIZE]); +/* True iff `ti` carries a valid attestation: an ECDSA signature (by a clearsign + * signer the user loaded) over a domain-separated (mint, decimals, symbol) + * digest. Range-checks signer_key_id before narrowing it. Verifies only the + * attested tuple — the caller must additionally confirm the attested decimals + * match the signed instruction before trusting the amount. */ +bool solana_token_info_trusted(const SolanaTokenInfo* ti); + +/* ceil(price * limit / 1,000,000) priority-fee lamports, overflow-safe. Returns + * false (and leaves *out untouched) if the true value exceeds UINT64_MAX — the + * caller must then refuse to sign rather than display a wrapped figure. */ +bool solana_priority_fee_lamports(uint64_t price, uint64_t limit, + uint64_t* out); + /* Sign transaction */ bool solana_signTx(const HDNode* node, const SolanaSignTx* msg, SolanaSignedTx* resp); diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 186810161..18977f0a3 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -40,6 +40,86 @@ static void solana_pubkeyToStr(const uint8_t key[SOL_PUBKEY_SIZE], char* out, key[31]); } +/* Confirm one labelled account address on its own screen. Factoring this keeps + * the many "which account is being acted on" disclosures small (ROM matters on + * the zcash-privacy variant). Returns false if the user rejects. */ +static bool solana_confirm_account(const char* title, const char* label, + const uint8_t key[SOL_PUBKEY_SIZE]) { + char s[45]; + solana_pubkeyToStr(key, s, sizeof(s)); + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, "%s\n%s", + label, s); +} + +/* A host-supplied token symbol is untrusted and only length-capped by the + * proto. Reject anything but printable ASCII so it cannot inject newlines or + * control bytes that push the mint or recipient off the confirm screen. */ +static bool solana_symbol_is_safe(const char* sym) { + if (!sym || sym[0] == '\0') return false; + for (const char* p = sym; *p; p++) { + if ((uint8_t)*p < 0x20 || (uint8_t)*p > 0x7e) return false; + } + return true; +} + +static bool solana_confirm_memo(const char* title, const uint8_t* s, + uint16_t len) { + return confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, title, s, + len); +} + +/* Priority fee = ceil(cu_price_micro_lamports * cu_limit / 1e6) lamports, and + * it is charged even if the transaction fails. Compute-budget instructions show + * only raw CU price/limit with no units, so a malicious host could bury a large + * SOL loss there. When the tx sets a CU price, show the fee payer and the + * MAXIMUM priority fee in SOL (using the 1.4M-CU protocol cap when no explicit + * limit is set, so the figure is never an understatement). Returns false on + * user reject. */ +static bool solana_confirm_priority_fee(const SolanaParsedTx* tx, + const uint8_t* fee_payer) { + uint64_t price = 0; + bool have_price = false; + uint64_t cu_limit = 0; + bool have_limit = false; + for (uint8_t i = 0; i < tx->num_instructions; i++) { + const SolanaParsedInstruction* pi = &tx->instructions[i]; + if (pi->type == SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE) { + price = pi->extra_value; + have_price = true; + } else if (pi->type == SOL_INSTR_COMPUTE_BUDGET_UNIT_LIMIT) { + cu_limit = pi->extra_value; + have_limit = true; + } + } + if (!have_price || price == 0) { + return true; /* no priority fee to disclose */ + } + const uint64_t kMaxCuLimit = 1400000u; /* Solana per-tx CU cap */ + uint64_t limit = have_limit ? cu_limit : kMaxCuLimit; + + /* Overflow-safe ceil(price*limit/1e6); false => the fee exceeds u64 lamports + * (>1.8e10 SOL) — refuse to sign rather than display a wrapped/zero figure. + */ + uint64_t lamports = 0; + if (!solana_priority_fee_lamports(price, limit, &lamports)) { + (void)confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Fee", + "Priority fee too large to display. Refusing to sign."); + return false; + } + char fee_str[40]; + solana_formatAmount(fee_str, sizeof(fee_str), lamports); + if (fee_payer) { + char payer_str[45]; + solana_pubkeyToStr(fee_payer, payer_str, sizeof(payer_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Fee", + "Fee payer\n%s", payer_str)) { + return false; + } + } + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Fee", + "Max priority fee\n%s", fee_str); +} + /* Confirm a single parsed instruction */ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, const SolanaSignTx* msg, uint8_t idx, @@ -65,10 +145,14 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } case SOL_INSTR_SYSTEM_ADVANCE_NONCE: - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Advance nonce account?"); + return solana_confirm_account(title, "Advance nonce account", pi->from); case SOL_INSTR_SYSTEM_WITHDRAW_NONCE: { + /* Withdrawing the full balance can destroy the nonce account — show it. + */ + if (!solana_confirm_account(title, "Nonce account", pi->from)) { + return false; + } char amount_str[32]; solana_formatAmount(amount_str, sizeof(amount_str), pi->lamports); char to_str[45]; @@ -77,11 +161,23 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, "Withdraw nonce %s to %s?", amount_str, to_str); } - case SOL_INSTR_SYSTEM_INITIALIZE_NONCE: + case SOL_INSTR_SYSTEM_INITIALIZE_NONCE: { + if (!solana_confirm_account(title, "Initialize nonce account", + pi->from)) { + return false; + } + /* Show the nonce authority being set — it can later advance/withdraw. */ + char auth_str[45]; + solana_pubkeyToStr(pi->authority, auth_str, sizeof(auth_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Initialize nonce account?"); + "Nonce authority %s?", auth_str); + } case SOL_INSTR_SYSTEM_AUTHORIZE_NONCE: { + /* Show WHICH nonce account is rekeyed, not just the new authority. */ + if (!solana_confirm_account(title, "Nonce account", pi->from)) { + return false; + } char auth_str[45]; solana_pubkeyToStr(pi->extra, auth_str, sizeof(auth_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, @@ -89,41 +185,189 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } case SOL_INSTR_SYSTEM_ASSIGN: { - char prog_str[45]; - solana_pubkeyToStr(pi->extra, prog_str, sizeof(prog_str)); - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Assign account to %s?", prog_str); + /* Assign hands control of an account to a program — show WHICH account, + * not just the new owner. */ + if (!solana_confirm_account(title, "Assign account", pi->from)) { + return false; + } + return solana_confirm_account(title, "to owner program", pi->extra); } - case SOL_INSTR_SYSTEM_ALLOCATE: + case SOL_INSTR_SYSTEM_ALLOCATE: { + if (!solana_confirm_account(title, "Allocate for account", pi->from)) { + return false; + } return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, "Allocate %llu bytes?", (unsigned long long)pi->extra_value); + } - case SOL_INSTR_TOKEN_TRANSFER: - case SOL_INSTR_TOKEN_TRANSFER_CHECKED: { + case SOL_INSTR_TOKEN_TRANSFER: { char to_str[45]; solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); - /* Try to find token info from host-provided metadata */ const SolanaTokenInfo* ti = NULL; - if (pi->has_mint) { + if (pi->has_mint && msg) { ti = solana_findTokenInfo(msg, pi->mint); } - if (ti && ti->has_symbol && ti->has_decimals) { + /* The mint is the only authenticated token identity. Show it on its own + * screen — a host-controlled symbol shares no line with it, so it cannot + * push the mint off-view. */ + if (pi->has_mint) { + char mint_str[45]; + solana_pubkeyToStr(pi->mint, mint_str, sizeof(mint_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Token mint\n%s", mint_str)) { + return false; + } + } + + /* Use the claimed symbol only when it is safe printable text; otherwise a + * raw token count, so an unvalidated symbol cannot manipulate the amount + * screen (the mint above still identifies the token). */ + if (ti && ti->has_symbol && ti->has_decimals && + solana_symbol_is_safe(ti->symbol)) { char amount_str[48]; solana_formatTokenAmount(amount_str, sizeof(amount_str), pi->amount, ti->symbol, (uint8_t)ti->decimals); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, "Send %s to %s?", amount_str, to_str); - } else { - char amount_str[32]; - snprintf(amount_str, sizeof(amount_str), "%llu tokens", - (unsigned long long)pi->amount); + } + char amount_str[32]; + snprintf(amount_str, sizeof(amount_str), "%llu tokens", + (unsigned long long)pi->amount); + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Send %s to %s?", amount_str, to_str); + } + + case SOL_INSTR_TOKEN_TRANSFER_CHECKED: { + /* For TransferChecked, decimals come from the signed instruction + * bytes (pi->extra_u8) — host-supplied ti->decimals is untrusted. */ + const SolanaTokenInfo* ti = NULL; + const SolanaKnownToken* known = NULL; + if (pi->has_mint && msg) { + ti = solana_findTokenInfo(msg, pi->mint); + } + if (pi->has_mint) { + known = solana_findKnownToken(pi->mint); + } + + /* A known mint has firmware-owned decimals. A signed TransferChecked + * that claims a different scale would turn 0.002 USDC into (for example) + * 20.00 USDC. Refuse it instead of rendering an attacker-chosen scale. */ + if (known && known->decimals != pi->extra_u8) { + (void)confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Blocked", + "%s decimals mismatch. Refusing to sign.", known->symbol); + return false; + } + + /* Decide symbol trust before drawing the mint screen so its label can say + * whether the symbol is attested. Trust rules: + * - attestation present + verifies against a loaded signer -> trusted; + * - attestation present + INVALID -> reject the symbol entirely (an + * attacker offered a bad signature; never fall back to the claim); + * - no attestation (today's hosts) -> show the symbol next to the + * always-authenticated mint (unchanged behavior). */ + const char* symbol = NULL; + bool symbol_verified = false; + if (known) { + symbol = known->symbol; + } else if (ti && ti->has_symbol && solana_symbol_is_safe(ti->symbol)) { + if (ti->has_signature) { + /* Trust the symbol only if the attestation verifies AND the attested + * decimals equal the signed instruction's decimals (pi->extra_u8) — + * otherwise the attested (mint,decimals,symbol) tuple disagrees with + * the transaction being signed and must not earn "verified". */ + if (solana_token_info_trusted(ti) && ti->decimals == pi->extra_u8) { + symbol = ti->symbol; + symbol_verified = true; + } + } else { + symbol = ti->symbol; + } + } + + /* Mint on its own screen (see TOKEN_TRANSFER): the authenticated identity + * cannot be pushed off-view by a host-controlled symbol. */ + if (pi->has_mint) { + char mint_str[45]; + solana_pubkeyToStr(pi->mint, mint_str, sizeof(mint_str)); + const bool mint_ok = + known + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Known token %s\n%s", known->symbol, mint_str) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Token mint\n%s", mint_str); + if (!mint_ok) { + return false; + } + } + + /* x402 names the merchant owner while the transaction names that + * owner's associated token account. Display the owner only after the + * device derives ATA(owner, token_program, mint) and matches the signed + * destination. No RPC or host assertion participates in this check. */ + uint8_t recipient_owner[SOL_PUBKEY_SIZE]; + const bool recipient_verified = + pi->has_mint && + solana_findTokenRecipientOwner(msg, pi->program_id, pi->mint, pi->to, + recipient_owner); + if (recipient_verified) { + if (!solana_confirm_account(title, "Verified recipient owner", + recipient_owner)) { + return false; + } + } else if (msg && msg->token_recipient_owner_count > 0) { + /* A supplied payTo that does not own the signed ATA is suspicious. + * Warn, then keep the existing honest fallback: show the raw token + * account rather than the unverified owner claim. */ + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Warning", + "Recipient owner does not match signed token account.")) { + return false; + } + } + + /* Name WHO attested the symbol, with the signer's fingerprint — aliases + * are host-chosen and not unique, so the fingerprint is what actually + * identifies the key. symbol_verified implies a signer is loaded for this + * key_id (solana_token_info_trusted verified against it), so both + * resolve; there is no "unknown" verified case. */ + if (symbol_verified) { + const char* alias = signed_metadata_signer_alias(ti->signer_key_id); + char fp[METADATA_FINGERPRINT_LEN] = {0}; + signed_metadata_signer_fingerprint((uint8_t)ti->signer_key_id, fp); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Token \"%s\"\nby %s %s", symbol, alias ? alias : "", + fp)) { + return false; + } + } + + if (symbol) { + char amount_str[48]; + solana_formatTokenAmount(amount_str, sizeof(amount_str), pi->amount, + symbol, pi->extra_u8); + if (recipient_verified) { + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Send %s?", amount_str); + } + char to_str[45]; + solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, "Send %s to %s?", amount_str, to_str); } + char amount_str[48]; + solana_formatTokenAmount(amount_str, sizeof(amount_str), pi->amount, + "tokens", pi->extra_u8); + if (recipient_verified) { + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Send %s?", amount_str); + } + char to_str[45]; + solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Send %s to %s?", amount_str, to_str); } case SOL_INSTR_TOKEN_APPROVE: { @@ -135,8 +379,8 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } case SOL_INSTR_TOKEN_REVOKE: - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Revoke token approval?"); + return solana_confirm_account(title, "Revoke approval on account", + pi->from); case SOL_INSTR_TOKEN_SET_AUTHORITY: { char auth_str[45]; @@ -145,92 +389,190 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, "Set token authority to %s?", auth_str); } - case SOL_INSTR_TOKEN_MINT_TO: + case SOL_INSTR_TOKEN_MINT_TO: { + /* Show the mint (which token) and the recipient, not just the amount. */ + char mint_str[45]; + char to_str[45]; + solana_pubkeyToStr(pi->mint, mint_str, sizeof(mint_str)); + solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Mint token\n%s", mint_str)) { + return false; + } return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Mint %llu tokens?", (unsigned long long)pi->amount); + "Mint %llu\nto %s?", (unsigned long long)pi->amount, + to_str); + } - case SOL_INSTR_TOKEN_BURN: + case SOL_INSTR_TOKEN_BURN: { + /* Show the mint (which token) and the source account burned from. */ + char mint_str[45]; + char from_str[45]; + solana_pubkeyToStr(pi->mint, mint_str, sizeof(mint_str)); + solana_pubkeyToStr(pi->from, from_str, sizeof(from_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Burn token\n%s", mint_str)) { + return false; + } return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Burn %llu tokens?", (unsigned long long)pi->amount); + "Burn %llu\nfrom %s?", (unsigned long long)pi->amount, + from_str); + } - case SOL_INSTR_TOKEN_CLOSE_ACCOUNT: - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Close token account?"); + case SOL_INSTR_TOKEN_CLOSE_ACCOUNT: { + /* Closing sweeps the account's ENTIRE lamport balance (which the device + * cannot see, e.g. wrapped SOL) to the destination — show both the + * account being closed and where its balance goes. */ + if (!solana_confirm_account(title, "Close token account", pi->from)) { + return false; + } + return solana_confirm_account(title, "send balance to", pi->to); + } - case SOL_INSTR_TOKEN_FREEZE_ACCOUNT: - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Freeze token account?"); + case SOL_INSTR_TOKEN_FREEZE_ACCOUNT: { + /* Show the account frozen AND its mint (freeze authority is per-mint). */ + if (!solana_confirm_account(title, "Freeze token account", pi->from)) { + return false; + } + return solana_confirm_account(title, "of mint", pi->mint); + } - case SOL_INSTR_TOKEN_THAW_ACCOUNT: - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Thaw token account?"); + case SOL_INSTR_TOKEN_THAW_ACCOUNT: { + if (!solana_confirm_account(title, "Thaw token account", pi->from)) { + return false; + } + return solana_confirm_account(title, "of mint", pi->mint); + } case SOL_INSTR_TOKEN_SYNC_NATIVE: - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Sync wrapped SOL?"); + return solana_confirm_account(title, "Sync wrapped SOL account", + pi->from); case SOL_INSTR_STAKE_DELEGATE: { - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Delegate stake?"); + /* Show which stake account is delegated, not just the vote account — a + * host could delegate a different stake account of the same authority. */ + if (!solana_confirm_account(title, "Delegate stake account", pi->from)) { + return false; + } + return solana_confirm_account(title, "to vote account", pi->to); } case SOL_INSTR_STAKE_WITHDRAW: { + /* Show WHICH stake account is drained (a host could substitute another of + * the same authority) and the recipient. */ + if (!solana_confirm_account(title, "Withdraw from stake", pi->from)) { + return false; + } char amount_str[32]; solana_formatAmount(amount_str, sizeof(amount_str), pi->lamports); + char to_str[45]; + solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Withdraw %s from stake?", amount_str); + "Withdraw %s\nto %s?", amount_str, to_str); } case SOL_INSTR_STAKE_AUTHORIZE: { + /* Show WHICH stake account is rekeyed (a host could substitute another of + * the same signer) and which power is handed over (staker vs withdrawer). + */ + if (!solana_confirm_account(title, "Stake account", pi->from)) { + return false; + } char auth_str[45]; solana_pubkeyToStr(pi->extra, auth_str, sizeof(auth_str)); + const char* role = pi->extra_u8 == 0 ? "staker" : "withdrawer"; return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Authorize stake to %s?", auth_str); + "Authorize %s\nto %s?", role, auth_str); } case SOL_INSTR_STAKE_SPLIT: { + /* Show the source stake account being split, and the destination. */ + if (!solana_confirm_account(title, "Split from stake", pi->from)) { + return false; + } char amount_str[32]; solana_formatAmount(amount_str, sizeof(amount_str), pi->lamports); + char to_str[45]; + solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Split stake by %s?", amount_str); + "Split %s\nto %s?", amount_str, to_str); } case SOL_INSTR_STAKE_DEACTIVATE: - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Deactivate stake?"); + return solana_confirm_account(title, "Deactivate stake account", + pi->from); - case SOL_INSTR_STAKE_MERGE: + case SOL_INSTR_STAKE_MERGE: { + /* Show source and destination — merge moves the source's stake into the + * destination account. */ + char from_str[45]; + char to_str[45]; + solana_pubkeyToStr(pi->from, from_str, sizeof(from_str)); + solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Merge stake from\n%s", from_str)) { + return false; + } return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Merge stake accounts?"); + "Merge stake into\n%s?", to_str); + } case SOL_INSTR_VOTE_AUTHORIZE: { + /* Show WHICH vote account is rekeyed; Voter vs Withdrawer both matter + * (the withdrawer can move the vote account's SOL). */ + if (!solana_confirm_account(title, "Vote account", pi->from)) { + return false; + } char auth_str[45]; solana_pubkeyToStr(pi->extra, auth_str, sizeof(auth_str)); + const char* role = pi->extra_u8 == 0 ? "voter" : "withdrawer"; return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Authorize vote to %s?", auth_str); + "Authorize vote %s\nto %s?", role, auth_str); } case SOL_INSTR_VOTE_WITHDRAW: { + /* Show the source vote account and the recipient. */ + if (!solana_confirm_account(title, "Withdraw from vote", pi->from)) { + return false; + } char amount_str[32]; solana_formatAmount(amount_str, sizeof(amount_str), pi->lamports); + char to_str[45]; + solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Withdraw vote %s?", amount_str); + "Withdraw vote %s\nto %s?", amount_str, to_str); } case SOL_INSTR_VOTE_UPDATE_VALIDATOR: { - char validator_str[45]; - solana_pubkeyToStr(pi->extra, validator_str, sizeof(validator_str)); - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Update validator to %s?", validator_str); + /* The new validator is the account (pi->extra now holds account index 1, + * not fabricated instruction bytes); show the vote account too. */ + if (!solana_confirm_account(title, "Vote account", pi->from)) { + return false; + } + return solana_confirm_account(title, "New validator identity", pi->extra); } - case SOL_INSTR_VOTE_UPDATE_COMMISSION: + case SOL_INSTR_VOTE_UPDATE_COMMISSION: { + if (!solana_confirm_account(title, "Vote account", pi->from)) { + return false; + } return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, "Set vote commission to %u%%?", pi->extra_u8); + } - case SOL_INSTR_ATA_CREATE: + case SOL_INSTR_ATA_CREATE: { + /* Show the wallet owner and the token mint the new account is for. */ + char owner_str[45]; + char mint_str[45]; + solana_pubkeyToStr(pi->authority, owner_str, sizeof(owner_str)); + solana_pubkeyToStr(pi->mint, mint_str, sizeof(mint_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Create token account\nfor %s", owner_str)) { + return false; + } return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Create associated token account?"); + "Token account mint\n%s?", mint_str); + } case SOL_INSTR_COMPUTE_BUDGET_HEAP_FRAME: return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, @@ -253,8 +595,11 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, (unsigned long long)pi->extra_value); case SOL_INSTR_MEMO: - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Memo attached"); + /* Page the FULL memo — swap intents (e.g. THORChain '=:ETH.ETH:...') ride + * in the memo, so a byte-count summary would hide where the funds go. + * Printable memos page as text, binary memos page as hex; nothing is + * hidden and the tx stays clear-signable regardless of length. */ + return solana_confirm_memo(title, pi->data, pi->data_len); case SOL_INSTR_UNKNOWN: default: { @@ -287,6 +632,104 @@ static bool solana_signerInTx(const uint8_t* pubkey, const SolanaParsedTx* tx) { return false; } +/* The single verified-transaction confirmation flow shared by BOTH + * SolanaSignTx and SolanaSignMessage (transaction-shaped messages are equally + * broadcastable), so their security screens — per-instruction disclosure AND + * the priority-fee screen — cannot drift apart. `msg` is NULL on the + * SignMessage path (host token symbols are unavailable there). Returns false if + * the user rejects any screen. */ +/* Render a schema-decoded instruction: who attested the schema, then the + * program/instruction it describes, then every labelled arg and account with + * values read from the transaction being signed. */ +static bool solana_confirm_schema(const SolanaInstrSchema* schema, + const SolanaParsedTx* parsed, + uint8_t ix_index, uint8_t signer_key_id) { + const SolanaParsedInstruction* ix = &parsed->instructions[ix_index]; + + /* Aliases are host-chosen and not unique; the fingerprint identifies the + * key that actually vouched for this decode. */ + const char* alias = signed_metadata_signer_alias(signer_key_id); + char fp[METADATA_FINGERPRINT_LEN] = {0}; + if (!signed_metadata_signer_fingerprint(signer_key_id, fp)) return false; + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Schema Signer", + "%s\n%s", alias ? alias : "", fp)) { + return false; + } + + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + schema->program_name, "%s", schema->instruction_name)) { + return false; + } + + /* Args sit sequentially after the discriminator, in declaration order. */ + uint16_t off = schema->disc_len; + for (uint8_t a = 0; a < schema->num_args; a++) { + const SolanaSchemaArg* arg = &schema->args[a]; + char value[64] = {0}; + switch (arg->type) { + case SOL_SCHEMA_ARG_U64: { + uint64_t v = 0; + for (int b = 0; b < 8; b++) + v |= ((uint64_t)ix->data[off + b]) << (8 * b); + snprintf(value, sizeof(value), "%" PRIu64, v); + break; + } + case SOL_SCHEMA_ARG_U8: + snprintf(value, sizeof(value), "%u", (unsigned)ix->data[off]); + break; + case SOL_SCHEMA_ARG_PUBKEY: { + size_t enc = sizeof(value); + if (!solana_base58_encode(ix->data + off, SOL_PUBKEY_SIZE, value, + &enc)) { + return false; + } + break; + } + case SOL_SCHEMA_ARG_OPAQUE32: + /* Opaque bytes have no meaning to show — abbreviate so the screen + * stays readable while still binding the user to a distinct value. */ + snprintf(value, sizeof(value), "%02x%02x%02x%02x…%02x%02x%02x%02x", + ix->data[off], ix->data[off + 1], ix->data[off + 2], + ix->data[off + 3], ix->data[off + 28], ix->data[off + 29], + ix->data[off + 30], ix->data[off + 31]); + break; + } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arg->label, + "%s", value)) { + return false; + } + off += solana_schemaArgWidth(arg->type); + } + + for (uint8_t a = 0; a < schema->num_accounts; a++) { + const SolanaSchemaAccount* sa = &schema->accounts[a]; + const uint8_t* pubkey = parsed->accounts[ix->acct_indices[sa->index]]; + char addr[64]; + size_t enc = sizeof(addr); + if (!solana_base58_encode(pubkey, SOL_PUBKEY_SIZE, addr, &enc)) + return false; + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, sa->label, "%s", + addr)) { + return false; + } + } + + return solana_confirm_priority_fee( + parsed, parsed->num_accounts > 0 ? parsed->accounts[0] : NULL); +} + +static bool solana_confirm_verified_tx(const SolanaParsedTx* parsed, + const SolanaSignTx* msg) { + for (uint8_t i = 0; i < parsed->num_instructions; i++) { + if (!solana_confirmInstruction(&parsed->instructions[i], msg, i, + parsed->num_instructions)) { + return false; + } + } + return solana_confirm_priority_fee( + parsed, parsed->num_accounts > 0 ? parsed->accounts[0] : NULL); +} + void fsm_msgSolanaGetAddress(const SolanaGetAddress* msg) { RESP_INIT(SolanaAddress); @@ -385,17 +828,71 @@ void fsm_msgSolanaSignTx(const SolanaSignTx* msg) { } } + /* KKSOLSC1: a signed, REUSABLE instruction schema can rescue a transaction + * that is opaque only because one program is unrecognised. The schema names + * no amounts and no transaction — the device reads the values out of the + * bytes it is about to sign — so one attestation covers every future + * transaction to that program. Present-but-invalid schema material fails + * the request; it never silently degrades to blind signing. */ + SolanaInstrSchema schema; + uint8_t schema_ix = 0; + bool schema_verified = false; + bool has_any_schema = msg->has_schema_payload || msg->has_schema_signature || + msg->has_schema_signer_key_id; + if (has_any_schema) { + if (!msg->has_schema_payload || !msg->has_schema_signature || + !msg->has_schema_signer_key_id || + msg->schema_signer_key_id >= METADATA_MAX_KEYS || + !solana_parseInstrSchema(msg->schema_payload.bytes, + msg->schema_payload.size, &schema) || + !signed_metadata_verify_attestation( + (uint8_t)msg->schema_signer_key_id, msg->schema_payload.bytes, + msg->schema_payload.size, msg->schema_signature.bytes, + msg->schema_signature.size) || + !solana_schemaApplies(&schema, &parsed, &schema_ix)) { + memzero(node, sizeof(*node)); + memzero(&schema, sizeof(schema)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Solana instruction schema")); + layoutHome(); + return; + } + schema_verified = true; + } + if (tx_review == SOL_TX_REVIEW_VERIFIED) { - /* Per-instruction confirmation for fully verified messages */ - for (uint8_t i = 0; i < parsed.num_instructions; i++) { - if (!solana_confirmInstruction(&parsed.instructions[i], msg, i, - parsed.num_instructions)) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, - _("Signing cancelled")); - layoutHome(); - return; - } + /* Per-instruction disclosure + priority fee, shared with SignMessage. */ + if (!solana_confirm_verified_tx(&parsed, msg)) { + memzero(node, sizeof(*node)); + memzero(&schema, sizeof(schema)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } else if (schema_verified) { + /* Opaque only because of the schema'd program: first show the attested + * decode. Runtime/self-service signers are annotation-only, so the normal + * Advanced-mode blind-sign warning still follows the decoded screens. */ + if (!solana_confirm_schema(&schema, &parsed, schema_ix, + (uint8_t)msg->schema_signer_key_id)) { + memzero(node, sizeof(*node)); + memzero(&schema, sizeof(schema)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + if (signed_metadata_signer_is_runtime((uint8_t)msg->schema_signer_key_id) && + !confirm(ButtonRequestType_ButtonRequest_SignTx, "Blind Sign", + "Sign unverified Solana transaction? " + "The device cannot fully verify the contents.")) { + memzero(node, sizeof(*node)); + memzero(&schema, sizeof(schema)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; } } else if (tx_review == SOL_TX_REVIEW_OPAQUE) { /* Unsupported or opaque message: allow explicit blind-sign only. */ @@ -458,13 +955,27 @@ void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg) { return; } - /* AdvancedMode gate: Solana message signing has no domain separation. - * A signed message is indistinguishable from a signed transaction on - * the Solana network (both are raw Ed25519 over arbitrary bytes). - * A malicious dApp could craft a message that is also a valid tx. + /* Solana "message" signing has no domain separation: the signed bytes + * are indistinguishable from a transaction message on the network. + * If the payload actually parses as a fully-verifiable Solana + * transaction, treat it as one — clear-sign it per instruction instead + * of blind-signing a hex blob. Wallet integrations sign versioned (v0) + * swap transactions through this message, so this is the path that + * turns swap blind-signing into clear-signing. */ + /* Note: solana_inspectTx tolerates a 0x00 signature-count prefix, but + * here the signature covers the exact message bytes — only a payload + * that IS a tx message from byte 0 may be displayed as one. */ + SolanaParsedTx parsed; + bool is_verified_tx = msg->message.bytes[0] != 0 && + solana_inspectTx(msg->message.bytes, msg->message.size, + &parsed) == SOL_TX_REVIEW_VERIFIED; + + /* AdvancedMode gate for anything we cannot verify: a malicious dApp + * could craft a "message" that is also a valid tx. * See: https://github.com/trezor/trezor-firmware/issues/4371 - * Require AdvancedMode to proceed — same gate as ETH blind-signing. */ - if (!storage_isPolicyEnabled("AdvancedMode")) { + * Same gate as ETH blind-signing. Fully verified transactions are + * clear-signed below and need no gate — the user sees the contents. */ + if (!is_verified_tx && !storage_isPolicyEnabled("AdvancedMode")) { (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", "Solana message signing is experimental. " "Enable AdvancedMode in device settings."); @@ -489,44 +1000,43 @@ void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg) { if (!node) return; hdnode_fill_public_key(node); - /* Always require on-device confirmation (matches Ethereum behavior). - * Display message content if printable, hex preview otherwise. */ - { - char msgBuf[129] = {0}; - const char* typeLabel; - bool printable = true; - for (unsigned i = 0; i < msg->message.size; i++) { - if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { - printable = false; - break; - } + if (is_verified_tx) { + /* Clear-sign path: same rules as SolanaSignTx. */ + if (!solana_signerInTx(node->public_key + 1, &parsed)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, + _("Derived key is not a signer for this tx")); + layoutHome(); + return; } - if (printable && msg->message.size <= sizeof(msgBuf) - 1) { - typeLabel = "Sign Message"; - memcpy(msgBuf, msg->message.bytes, msg->message.size); - msgBuf[msg->message.size] = '\0'; - } else { - typeLabel = "Sign Bytes"; - /* Show hex preview (up to 64 hex chars = 32 bytes) */ - unsigned show = msg->message.size; - if (show > 32) show = 32; - for (unsigned i = 0; i < show; i++) { - snprintf(&msgBuf[2 * i], 3, "%02x", msg->message.bytes[i]); - } - msgBuf[2 * show] = '\0'; - if (msg->message.size > 32) { - snprintf(&msgBuf[64], sizeof(msgBuf) - 64, "... (%u bytes)", - (unsigned)msg->message.size); - } + /* Same verified-tx flow as SolanaSignTx (incl. the priority-fee screen), so + * a broadcastable transaction-shaped message can't dodge a security screen. + * msg=NULL: host token symbols aren't provided on the message path. */ + if (!solana_confirm_verified_tx(&parsed, NULL)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; } - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeLabel), - "%s", msgBuf)) { + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Solana", + "Sign this Solana transaction?")) { memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Signing cancelled")); layoutHome(); return; } + } else if (!confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign Solana Message", msg->message.bytes, + msg->message.size)) { + /* AdvancedMode permits the opaque primitive, but every signed byte still + * has to be reviewable; previews recreate the hidden-suffix bug. */ + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; } /* Ed25519 sign */ @@ -603,45 +1113,14 @@ void fsm_msgSolanaSignOffchainMessage(const SolanaSignOffchainMessage* msg) { if (!node) return; hdnode_fill_public_key(node); - /* Confirm dialog. Format 0 (ASCII) is always renderable; format 1 - * (UTF-8 limited) we render as printable bytes only — non-printable - * sequences fall through to a hex preview to avoid encoding - * surprises on the OLED. */ - { - char msgBuf[129] = {0}; - const char* typeLabel; - bool printable = true; - for (unsigned i = 0; i < msg->message.size; i++) { - if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { - printable = false; - break; - } - } - if (printable && msg->message.size <= sizeof(msgBuf) - 1) { - typeLabel = "Off-chain Message"; - memcpy(msgBuf, msg->message.bytes, msg->message.size); - msgBuf[msg->message.size] = '\0'; - } else { - typeLabel = "Off-chain Bytes"; - unsigned show = msg->message.size; - if (show > 32) show = 32; - for (unsigned i = 0; i < show; i++) { - snprintf(&msgBuf[2 * i], 3, "%02x", msg->message.bytes[i]); - } - msgBuf[2 * show] = '\0'; - if (msg->message.size > 32) { - snprintf(&msgBuf[64], sizeof(msgBuf) - 64, "... (%u bytes)", - (unsigned)msg->message.size); - } - } - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeLabel), - "%s", msgBuf)) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, - _("Signing cancelled")); - layoutHome(); - return; - } + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign Solana Off-chain Message", msg->message.bytes, + msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; } if (!solana_offchain_message_sign(node, msg, resp)) { diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index 94952a0f3..724db5fd2 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -19,7 +19,10 @@ #include "keepkey/firmware/solana.h" +#include "keepkey/firmware/signed_metadata.h" +#include "trezor/crypto/ed25519-donna/ed25519-donna.h" #include "trezor/crypto/memzero.h" +#include "trezor/crypto/sha2.h" #include #include @@ -73,6 +76,18 @@ const uint8_t SOL_MEMO_PROGRAM[SOL_PUBKEY_SIZE] = { 0x71, 0x60, 0xda, 0x38, 0x7c, 0x7c, 0x35, 0xb5, 0xdd, 0xbc, 0x92, 0xbb, 0x81, 0xe4, 0x1f, 0xa8, 0x40, 0x41, 0x05, 0x44, 0x8d}; +/* Circle's mainnet SPL USDC mint: + * EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v. */ +static const SolanaKnownToken SOL_KNOWN_TOKENS[] = {{ + {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}, + "USDC", + 6, +}}; + +static const char SOL_PDA_MARKER[] = "ProgramDerivedAddress"; + /* ------------------------------------------------------------------ */ /* Compact-u16 decoder (Solana transaction format) */ /* ------------------------------------------------------------------ */ @@ -122,10 +137,17 @@ static void copy_account(uint8_t out[SOL_PUBKEY_SIZE], const SolanaParsedTx* tx, } } +/* allow_external_indices: versioned (v0) messages may reference accounts + * loaded from address lookup tables — indices at or beyond the static + * account list. Those accounts are not present in the message, so an + * instruction touching them cannot be verified on-device: it is left + * SOL_INSTR_UNKNOWN and the whole tx is forced opaque instead of being + * rejected as malformed. Legacy messages must never contain such indices. */ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, size_t* pos_io, SolanaParsedTx* tx, uint16_t num_accounts, bool* has_unknown, - bool* force_opaque) { + bool* force_opaque, + bool allow_external_indices) { size_t pos = *pos_io; uint16_t num_instructions; int n = read_compact_u16(raw + pos, raw_len - pos, &num_instructions); @@ -133,11 +155,10 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, pos += n; if (num_instructions > SOL_MAX_INSTRUCTIONS) { + /* Too many to display — opaque. Keep walking the section so the + * structural checks (and any trailing sections) stay meaningful. */ *force_opaque = true; tx->num_instructions = 0; - /* Don't attempt to parse instruction data — treat as opaque. */ - *pos_io = raw_len; - return 0; } else { tx->num_instructions = (uint8_t)num_instructions; } @@ -145,7 +166,11 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, for (uint16_t i = 0; i < num_instructions; i++) { if (pos >= raw_len) return -1; uint8_t program_idx = raw[pos++]; - if (program_idx >= num_accounts) return -1; + bool external = false; + if (program_idx >= num_accounts) { + if (!allow_external_indices) return -1; + external = true; + } uint16_t num_acct_indices; n = read_compact_u16(raw + pos, raw_len - pos, &num_acct_indices); @@ -157,7 +182,10 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, pos += num_acct_indices; for (uint16_t j = 0; j < num_acct_indices; j++) { - if (acct_indices[j] >= num_accounts) return -1; + if (acct_indices[j] >= num_accounts) { + if (!allow_external_indices) return -1; + external = true; + } } uint16_t data_len; @@ -169,11 +197,31 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, const uint8_t* instr_data = raw + pos; pos += data_len; - if (i >= SOL_MAX_INSTRUCTIONS) { + if (i >= SOL_MAX_INSTRUCTIONS || tx->num_instructions == 0) { continue; } SolanaParsedInstruction* pi = &tx->instructions[i]; + + /* Retain the raw payload and account index list for EVERY instruction: a + * KKSOLSC1 schema reads its args out of `data` and resolves its labelled + * accounts through `acct_indices`. Both point into the caller's raw + * message buffer and share its lifetime. (The memo path below also sets + * data/data_len; assigning here first is harmless and covers the rest.) */ + pi->data = instr_data; + pi->data_len = data_len; + pi->acct_indices = acct_indices; + pi->num_acct_indices = + num_acct_indices > 255 ? 255 : (uint8_t)num_acct_indices; + + if (external) { + /* Accounts resolved via lookup tables: unverifiable on-device. */ + pi->type = SOL_INSTR_UNKNOWN; + pi->external = true; + *force_opaque = true; + continue; + } + memcpy(pi->program_id, tx->accounts[program_idx], SOL_PUBKEY_SIZE); /* Classify and decode */ @@ -191,6 +239,12 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, pi->lamports = read_le64(instr_data + 4); copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); copy_account(pi->to, tx, acct_indices, num_acct_indices, 1); + /* CreateAccount also assigns the new account's space and OWNER + * program (bytes not parsed here); the owner controls the account, so + * a partial "amount only" screen is unsafe. Require AdvancedMode + * until a full screen (destination + amount + owner + space) exists. + */ + *force_opaque = true; } else if (instr_type == SOL_SYS_ADVANCE_NONCE) { pi->type = SOL_INSTR_SYSTEM_ADVANCE_NONCE; copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); @@ -230,6 +284,12 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, 0 || memcmp(pi->program_id, SOL_TOKEN_2022_PROGRAM, SOL_PUBKEY_SIZE) == 0) { + /* Token-2022 transfers can invoke a configured transfer-hook program with + * extra accounts and arbitrary logic (and levy transfer fees) that we can + * neither authenticate nor display. Treat them as opaque (AdvancedMode) + * rather than clear-sign only source/mint/dest/amount. */ + const bool is_token2022 = + memcmp(pi->program_id, SOL_TOKEN_2022_PROGRAM, SOL_PUBKEY_SIZE) == 0; if (data_len >= 1) { uint8_t token_instr = instr_data[0]; if (token_instr == SOL_TOKEN_TRANSFER_IX && data_len >= 9) { @@ -238,22 +298,41 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); copy_account(pi->to, tx, acct_indices, num_acct_indices, 1); copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); + /* Unchecked Transfer carries no signed mint, so the device cannot + * prove which token is moving — a host can pick any signer-controlled + * account. Force the AdvancedMode blind-sign gate; only the *Checked + * variant (mint signed + displayed) clear-signs. */ + *force_opaque = true; } else if (token_instr == SOL_TOKEN_TRANSFER_CHECKED_IX && - data_len >= 9) { + data_len == 10 && num_acct_indices >= 4) { + /* Canonical TransferChecked ONLY: opcode + amount(8) + decimals(1) + * and all four accounts [source, mint, dest, authority]. A 9-byte + * encoding (no decimals) or a short account list would otherwise + * classify VERIFIED while skipping the mint screen and showing a + * zeroed destination — such non-canonical shapes fall through to + * UNKNOWN and force the whole tx opaque. */ pi->type = SOL_INSTR_TOKEN_TRANSFER_CHECKED; pi->amount = read_le64(instr_data + 1); copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); copy_account(pi->mint, tx, acct_indices, num_acct_indices, 1); - pi->has_mint = (num_acct_indices >= 2); + pi->has_mint = true; copy_account(pi->to, tx, acct_indices, num_acct_indices, 2); copy_account(pi->authority, tx, acct_indices, num_acct_indices, 3); - pi->extra_u8 = data_len >= 10 ? instr_data[9] : 0; + pi->extra_u8 = instr_data[9]; + /* Token-2022 checked transfers may carry an undisclosed transfer hook + * / fee — do not clear-sign them. */ + if (is_token2022) { + *force_opaque = true; + } } else if (token_instr == SOL_TOKEN_APPROVE_IX && data_len >= 9) { pi->type = SOL_INSTR_TOKEN_APPROVE; pi->amount = read_le64(instr_data + 1); copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); copy_account(pi->to, tx, acct_indices, num_acct_indices, 1); copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); + /* Unchecked Approve hides the mint (which token is being delegated), + * same as unchecked Transfer — require AdvancedMode. */ + *force_opaque = true; } else if (token_instr == SOL_TOKEN_REVOKE_IX) { pi->type = SOL_INSTR_TOKEN_REVOKE; copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); @@ -266,6 +345,12 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, if (data_len >= 35 && instr_data[2] == 1) { memcpy(pi->extra, instr_data + 3, SOL_PUBKEY_SIZE); } + /* Authority handover (owner/close/mint/freeze) is an account-takeover + * vector, and the "set to None" (clear) case is not distinguished + * from an all-zero authority in the parsed struct. Require + * AdvancedMode until a full screen (authority type + target + + * new/None) exists. */ + *force_opaque = true; } else if ((token_instr == SOL_TOKEN_MINT_TO_IX || token_instr == SOL_TOKEN_MINT_TO_CHECKED_IX) && data_len >= 9) { @@ -335,7 +420,10 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); copy_account(pi->to, tx, acct_indices, num_acct_indices, 1); copy_account(pi->authority, tx, acct_indices, num_acct_indices, 4); - } else if (stake_instr == SOL_STAKE_AUTHORIZE_IX && data_len >= 36) { + } else if (stake_instr == SOL_STAKE_AUTHORIZE_IX && data_len >= 40) { + /* new_authority(32) at +4 then authorize_type(le32) at +36, so the + * instruction needs >= 40 bytes — reading extra_u8 at +36 with only + * 36 bytes was a 4-byte over-read. */ pi->type = SOL_INSTR_STAKE_AUTHORIZE; memcpy(pi->extra, instr_data + 4, SOL_PUBKEY_SIZE); copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); @@ -380,11 +468,16 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, copy_account(pi->to, tx, acct_indices, num_acct_indices, 1); copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); } else if (vote_instr == SOL_VOTE_UPDATE_VALIDATOR_IX && - data_len >= 36) { + data_len == 4) { + /* UpdateValidatorIdentity has NO data payload: the new validator is + * account index 1. Reading 32 bytes from the data would display + * attacker-supplied trailing bytes instead of the account actually + * used, so require the canonical 4-byte encoding and read account 1. + */ pi->type = SOL_INSTR_VOTE_UPDATE_VALIDATOR; - memcpy(pi->extra, instr_data + 4, SOL_PUBKEY_SIZE); copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); - copy_account(pi->authority, tx, acct_indices, num_acct_indices, 1); + copy_account(pi->extra, tx, acct_indices, num_acct_indices, 1); + copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); } else if (vote_instr == SOL_VOTE_UPDATE_COMMISSION_IX && data_len >= 5) { pi->type = SOL_INSTR_VOTE_UPDATE_COMMISSION; @@ -400,7 +493,15 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, *has_unknown = true; } } else if (memcmp(pi->program_id, SOL_ATA_PROGRAM, SOL_PUBKEY_SIZE) == 0) { - if (data_len == 0 || (data_len == 1 && instr_data[0] == 0)) { + /* 0 = Create, 1 = CreateIdempotent, and empty data is the legacy + * encoding of Create. Idempotent takes the SAME accounts in the same + * order and creates the same account — it merely succeeds instead of + * failing when one already exists — so it displays identically. Wallets + * emit it by default (a token transfer whose recipient may lack an ATA), + * and rejecting it forced the whole transaction opaque: an SPL transfer + * that is otherwise fully decodable would blind-sign. */ + if (data_len == 0 || + (data_len == 1 && (instr_data[0] == 0 || instr_data[0] == 1))) { pi->type = SOL_INSTR_ATA_CREATE; copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); copy_account(pi->to, tx, acct_indices, num_acct_indices, 1); @@ -438,6 +539,8 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, } } else if (memcmp(pi->program_id, SOL_MEMO_PROGRAM, SOL_PUBKEY_SIZE) == 0) { pi->type = SOL_INSTR_MEMO; + pi->data = instr_data; + pi->data_len = data_len; } else { pi->type = SOL_INSTR_UNKNOWN; *has_unknown = true; @@ -487,7 +590,8 @@ static SolanaTxReview solana_parseLegacyTx(const uint8_t* raw, size_t raw_len, pos += SOL_PUBKEY_SIZE; n = parse_instruction_section(raw, raw_len, &pos, tx, num_accounts, - &has_unknown, &force_opaque); + &has_unknown, &force_opaque, + /*allow_external_indices=*/false); if (n < 0) return SOL_TX_REVIEW_MALFORMED; /* Reject if there are unconsumed bytes — prevents hidden trailing data */ @@ -505,7 +609,7 @@ static SolanaTxReview solana_parseVersionedTx(const uint8_t* raw, memset(tx, 0, sizeof(*tx)); size_t pos = 0; bool has_unknown = false; - bool force_opaque = true; + bool force_opaque = false; if (raw_len < 1) return SOL_TX_REVIEW_MALFORMED; uint8_t version_prefix = raw[pos++]; @@ -536,13 +640,20 @@ static SolanaTxReview solana_parseVersionedTx(const uint8_t* raw, pos += SOL_PUBKEY_SIZE; n = parse_instruction_section(raw, raw_len, &pos, tx, num_accounts, - &has_unknown, &force_opaque); + &has_unknown, &force_opaque, + /*allow_external_indices=*/true); if (n < 0) return SOL_TX_REVIEW_MALFORMED; uint16_t lookup_table_count; n = read_compact_u16(raw + pos, raw_len - pos, &lookup_table_count); if (n < 0) return SOL_TX_REVIEW_MALFORMED; pos += n; + if (lookup_table_count != 0) { + /* Clear-signing is intentionally limited to self-contained v0 messages. + * Even if current instructions appear to use only static accounts, an ALT + * section requires chain state that this firmware does not resolve. */ + force_opaque = true; + } for (uint16_t i = 0; i < lookup_table_count; i++) { uint16_t writable_count, readonly_count; @@ -563,7 +674,30 @@ static SolanaTxReview solana_parseVersionedTx(const uint8_t* raw, } if (pos != raw_len) return SOL_TX_REVIEW_MALFORMED; - return SOL_TX_REVIEW_OPAQUE; + + /* A zero-LUT v0 message is self-contained and can be verified like legacy. + * Any lookup-table section remains available only through the AdvancedMode + * opaque path until firmware can resolve and authenticate chain state. */ + if (tx->num_instructions == 0 || has_unknown || force_opaque) { + return SOL_TX_REVIEW_OPAQUE; + } + return SOL_TX_REVIEW_VERIFIED; +} + +/* Normalize the bytes that are actually signed. Solana signs the serialized + * MESSAGE. Clients may send either the bare message (byte 0 = num_required_sigs + * >= 1) or a full unsigned transaction whose byte 0 is a compact-u16 signature + * count of 0. Strip that single prefix byte so parsing (solana_inspectTx) and + * signing (solana_signTx) operate on the IDENTICAL slice — otherwise the device + * would display one message but sign 0x00||message, which never verifies. */ +static void solana_message_slice(const uint8_t* raw, size_t raw_len, + const uint8_t** msg_out, size_t* len_out) { + if (raw_len > 1 && raw[0] == 0) { + raw++; + raw_len--; + } + *msg_out = raw; + *len_out = raw_len; } SolanaTxReview solana_inspectTx(const uint8_t* raw, size_t raw_len, @@ -573,24 +707,179 @@ SolanaTxReview solana_inspectTx(const uint8_t* raw, size_t raw_len, return SOL_TX_REVIEW_MALFORMED; } - /* Skip signature count prefix if present. - * Clients may send either the raw message (header starts at byte 0) - * or a full unsigned transaction (compact-u16 sig count = 0 at byte 0). - * A valid legacy message header has num_required_sigs >= 1 at byte 0. - * If byte 0 is 0, it's a signature count prefix — skip it. */ - if (raw[0] == 0 && raw_len > 1) { - raw++; - raw_len--; - } + const uint8_t* msg; + size_t msg_len; + solana_message_slice(raw, raw_len, &msg, &msg_len); /* Versioned Solana messages set the top bit in byte 0. * Parse them structurally so malformed v0/ALT payloads fail closed, * but keep the result opaque until the firmware can verify semantics. */ - if (raw[0] & SOL_VERSION_FLAG) { - return solana_parseVersionedTx(raw, raw_len, tx); + if (msg[0] & SOL_VERSION_FLAG) { + return solana_parseVersionedTx(msg, msg_len, tx); } - return solana_parseLegacyTx(raw, raw_len, tx); + return solana_parseLegacyTx(msg, msg_len, tx); +} + +/* ------------------------------------------------------------------ */ +/* KKSOLSC1 reusable instruction schemas */ +/* ------------------------------------------------------------------ */ + +/* Display-safe: printable ASCII, and no '%' so a label can never smuggle a + * conversion specifier into a format string. */ +static bool schema_text_ok(const uint8_t* v, size_t len) { + if (len == 0) return false; + for (size_t i = 0; i < len; i++) { + if (v[i] < 0x20 || v[i] > 0x7e || v[i] == '%') return false; + } + return true; +} + +static bool schema_read_text(const uint8_t** cur, const uint8_t* end, char* out, + size_t max_len) { + if (*cur >= end) return false; + uint8_t len = *(*cur)++; + if (len == 0 || len > max_len || (size_t)(end - *cur) < len || + !schema_text_ok(*cur, len)) { + return false; + } + memcpy(out, *cur, len); + out[len] = '\0'; + *cur += len; + return true; +} + +/* Byte width an arg consumes in the instruction data. */ +uint16_t solana_schemaArgWidth(SolanaSchemaArgType t) { + switch (t) { + case SOL_SCHEMA_ARG_U64: + return 8; + case SOL_SCHEMA_ARG_U8: + return 1; + case SOL_SCHEMA_ARG_PUBKEY: + case SOL_SCHEMA_ARG_OPAQUE32: + return 32; + } + return 0; /* unknown type — caller rejects */ +} + +bool solana_parseInstrSchema(const uint8_t* payload, size_t payload_len, + SolanaInstrSchema* out) { + static const uint8_t magic[8] = {'K', 'K', 'S', 'O', 'L', 'S', 'C', '1'}; + if (!payload || !out || + payload_len < sizeof(magic) + 1 + SOL_PUBKEY_SIZE + 1) { + return false; + } + memset(out, 0, sizeof(*out)); + const uint8_t* cur = payload; + const uint8_t* end = payload + payload_len; + + if (memcmp(cur, magic, sizeof(magic)) != 0) return false; + cur += sizeof(magic); + if (*cur++ != 1) return false; /* version */ + + if ((size_t)(end - cur) < SOL_PUBKEY_SIZE + 1) return false; + memcpy(out->program_id, cur, SOL_PUBKEY_SIZE); + cur += SOL_PUBKEY_SIZE; + + out->disc_len = *cur++; + if (out->disc_len == 0 || out->disc_len > SOL_SCHEMA_DISC_MAX || + (size_t)(end - cur) < out->disc_len) { + return false; + } + memcpy(out->disc, cur, out->disc_len); + cur += out->disc_len; + + if (!schema_read_text(&cur, end, out->program_name, SOL_SCHEMA_NAME_MAX) || + !schema_read_text(&cur, end, out->instruction_name, + SOL_SCHEMA_NAME_MAX) || + cur >= end) { + return false; + } + + out->num_args = *cur++; + if (out->num_args > SOL_SCHEMA_MAX_ARGS) return false; + for (uint8_t i = 0; i < out->num_args; i++) { + if (cur >= end) return false; + uint8_t type = *cur++; + if (solana_schemaArgWidth((SolanaSchemaArgType)type) == 0) return false; + out->args[i].type = (SolanaSchemaArgType)type; + if (!schema_read_text(&cur, end, out->args[i].label, + SOL_SCHEMA_LABEL_MAX)) { + return false; + } + } + + if (cur >= end) return false; + out->num_accounts = *cur++; + if (out->num_accounts > SOL_SCHEMA_MAX_ACCOUNTS) return false; + for (uint8_t i = 0; i < out->num_accounts; i++) { + if (cur >= end) return false; + out->accounts[i].index = *cur++; + if (!schema_read_text(&cur, end, out->accounts[i].label, + SOL_SCHEMA_LABEL_MAX)) { + return false; + } + } + + return cur == end; /* no trailing bytes */ +} + +bool solana_schemaApplies(const SolanaInstrSchema* schema, + const SolanaParsedTx* tx, uint8_t* out_index) { + if (!schema || !tx || !out_index) return false; + + bool found = false; + uint8_t match = 0; + for (uint8_t i = 0; i < tx->num_instructions; i++) { + const SolanaParsedInstruction* ix = &tx->instructions[i]; + if (ix->external) continue; /* accounts not in the signed message */ + if (memcmp(ix->program_id, schema->program_id, SOL_PUBKEY_SIZE) != 0) { + continue; + } + if (!ix->data || ix->data_len < schema->disc_len || + memcmp(ix->data, schema->disc, schema->disc_len) != 0) { + continue; + } + + /* Structural completeness: the discriminator plus every declared arg must + * account for the instruction data EXACTLY. Leftover bytes could carry an + * effect the screens never mention. */ + uint32_t consumed = schema->disc_len; + for (uint8_t a = 0; a < schema->num_args; a++) { + consumed += solana_schemaArgWidth(schema->args[a].type); + } + if (consumed != ix->data_len) continue; + + /* Every displayed account must actually exist in this instruction. */ + bool accounts_ok = true; + for (uint8_t a = 0; a < schema->num_accounts; a++) { + if (schema->accounts[a].index >= ix->num_acct_indices) { + accounts_ok = false; + break; + } + } + if (!accounts_ok) continue; + + if (found) return false; /* ambiguous: two instructions match */ + found = true; + match = i; + } + if (!found) return false; + + /* A schema explains ONE instruction. Every other instruction must be one + * firmware already decodes, or the message could move funds through a path + * no screen described. */ + for (uint8_t i = 0; i < tx->num_instructions; i++) { + if (i == match) continue; + if (tx->instructions[i].external || + tx->instructions[i].type == SOL_INSTR_UNKNOWN) { + return false; + } + } + + *out_index = match; + return true; } bool solana_parseTx(const uint8_t* raw, size_t raw_len, SolanaParsedTx* tx) { @@ -601,6 +890,41 @@ bool solana_parseTx(const uint8_t* raw, size_t raw_len, SolanaParsedTx* tx) { /* Formatting */ /* ------------------------------------------------------------------ */ +bool solana_priority_fee_lamports(uint64_t price, uint64_t limit, + uint64_t* out) { + /* ceil(price * limit / 1e6) with no overflow and no silent wrap. price/limit + * are u64; the product can exceed u64, and even ceil(product/1e6) can exceed + * u64. Split price = q*D + r and accumulate so every step is checked; return + * false (do NOT saturate) if the true lamport value exceeds UINT64_MAX. */ + const uint64_t D = 1000000u; + uint64_t q = price / D; + uint64_t r = price % D; + if (limit != 0 && r > UINT64_MAX / limit) { + return false; /* r*limit overflows (only for absurd limits) */ + } + uint64_t rl = r * limit; + uint64_t lamports = rl / D; + bool ceil_up = (rl % D) != 0; + if (q != 0 && limit != 0) { + if (q > UINT64_MAX / limit) { + return false; + } + uint64_t ql = q * limit; + if (ql > UINT64_MAX - lamports) { + return false; + } + lamports += ql; + } + if (ceil_up) { + if (lamports == UINT64_MAX) { + return false; + } + lamports++; + } + *out = lamports; + return true; +} + void solana_formatAmount(char* buf, size_t len, uint64_t lamports) { uint64_t whole = lamports / SOL_LAMPORTS_DIVISOR; uint64_t frac = lamports % SOL_LAMPORTS_DIVISOR; @@ -624,9 +948,6 @@ void solana_formatTokenAmount(char* buf, size_t len, uint64_t amount, /* Format with appropriate decimal places (max 9 shown) */ uint8_t show_dec = decimals > SOL_MAX_DISPLAY_DECIMALS ? SOL_MAX_DISPLAY_DECIMALS : decimals; - uint64_t show_div = 1; - for (uint8_t i = 0; i < show_dec; i++) show_div *= 10; - (void)show_div; uint64_t show_frac = frac; if (decimals > SOL_MAX_DISPLAY_DECIMALS) { for (uint8_t i = 0; i < decimals - SOL_MAX_DISPLAY_DECIMALS; i++) @@ -639,7 +960,74 @@ void solana_formatTokenAmount(char* buf, size_t len, uint64_t amount, show_frac /= 10; } frac_str[show_dec] = '\0'; - snprintf(buf, len, "%llu.%s %s", (unsigned long long)whole, frac_str, symbol); + while (show_dec > 0 && frac_str[show_dec - 1] == '0') { + frac_str[--show_dec] = '\0'; + } + if (show_dec == 0) { + snprintf(buf, len, "%llu %s", (unsigned long long)whole, symbol); + } else { + snprintf(buf, len, "%llu.%s %s", (unsigned long long)whole, frac_str, + symbol); + } +} + +const SolanaKnownToken* solana_findKnownToken( + const uint8_t mint[SOL_PUBKEY_SIZE]) { + for (size_t i = 0; i < sizeof(SOL_KNOWN_TOKENS) / sizeof(SOL_KNOWN_TOKENS[0]); + i++) { + if (memcmp(SOL_KNOWN_TOKENS[i].mint, mint, SOL_PUBKEY_SIZE) == 0) { + return &SOL_KNOWN_TOKENS[i]; + } + } + return NULL; +} + +bool solana_deriveAssociatedTokenAddress( + const uint8_t owner[SOL_PUBKEY_SIZE], + const uint8_t token_program[SOL_PUBKEY_SIZE], + const uint8_t mint[SOL_PUBKEY_SIZE], uint8_t out[SOL_PUBKEY_SIZE]) { + /* Solana find_program_address searches bump seeds from 255 down. A valid PDA + * is SHA256(seeds..., bump, program_id, "ProgramDerivedAddress") that does + * NOT decompress to an Ed25519 curve point. */ + for (int bump = 255; bump >= 0; bump--) { + SHA256_CTX ctx = {0}; + uint8_t candidate[SHA256_DIGEST_LENGTH]; + uint8_t bump_seed = (uint8_t)bump; + sha256_Init(&ctx); + sha256_Update(&ctx, owner, SOL_PUBKEY_SIZE); + sha256_Update(&ctx, token_program, SOL_PUBKEY_SIZE); + sha256_Update(&ctx, mint, SOL_PUBKEY_SIZE); + sha256_Update(&ctx, &bump_seed, 1); + sha256_Update(&ctx, SOL_ATA_PROGRAM, SOL_PUBKEY_SIZE); + sha256_Update(&ctx, (const uint8_t*)SOL_PDA_MARKER, + sizeof(SOL_PDA_MARKER) - 1); + sha256_Final(&ctx, candidate); + + ge25519 point; + if (ge25519_unpack_vartime(&point, candidate) == 0) { + memcpy(out, candidate, SOL_PUBKEY_SIZE); + return true; + } + } + return false; +} + +bool solana_findTokenRecipientOwner( + const SolanaSignTx* msg, const uint8_t token_program[SOL_PUBKEY_SIZE], + const uint8_t mint[SOL_PUBKEY_SIZE], + const uint8_t destination[SOL_PUBKEY_SIZE], uint8_t out[SOL_PUBKEY_SIZE]) { + if (!msg) return false; + for (size_t i = 0; i < msg->token_recipient_owner_count; i++) { + if (msg->token_recipient_owner[i].size != SOL_PUBKEY_SIZE) continue; + uint8_t derived[SOL_PUBKEY_SIZE]; + if (solana_deriveAssociatedTokenAddress(msg->token_recipient_owner[i].bytes, + token_program, mint, derived) && + memcmp(derived, destination, SOL_PUBKEY_SIZE) == 0) { + memcpy(out, msg->token_recipient_owner[i].bytes, SOL_PUBKEY_SIZE); + return true; + } + } + return false; } const SolanaTokenInfo* solana_findTokenInfo( @@ -654,6 +1042,43 @@ const SolanaTokenInfo* solana_findTokenInfo( return NULL; } +bool solana_token_info_trusted(const SolanaTokenInfo* ti) { + if (!ti || !ti->has_signature || !ti->has_signer_key_id || !ti->has_mint || + ti->mint.size != SOL_PUBKEY_SIZE || !ti->has_symbol || + !ti->has_decimals) { + return false; + } + /* uint32 field: reject out-of-range slots BEFORE narrowing to the uint8 the + * keyring uses, so key_id 256 can't alias slot 0. */ + if (ti->signer_key_id >= METADATA_MAX_KEYS) { + return false; + } + size_t sym_len = strnlen(ti->symbol, sizeof(ti->symbol)); + if (sym_len == 0) { + return false; + } + /* Domain tag prevents a signature made for any other purpose (e.g. an EVM + * metadata blob signed by the same key) from being replayed as a token def. + * Preimage: tag || mint(32) || decimals(le32) || symbol. */ + static const char kTag[] = "KeepKeySolanaTokenDef/1"; + uint8_t blob[sizeof(kTag) - 1 + SOL_PUBKEY_SIZE + 4 + sizeof(ti->symbol)]; + size_t n = 0; + memcpy(blob + n, kTag, sizeof(kTag) - 1); + n += sizeof(kTag) - 1; + memcpy(blob + n, ti->mint.bytes, SOL_PUBKEY_SIZE); + n += SOL_PUBKEY_SIZE; + uint32_t dec = ti->decimals; + blob[n++] = (uint8_t)dec; + blob[n++] = (uint8_t)(dec >> 8); + blob[n++] = (uint8_t)(dec >> 16); + blob[n++] = (uint8_t)(dec >> 24); + memcpy(blob + n, ti->symbol, sym_len); + n += sym_len; + return signed_metadata_verify_attestation((uint8_t)ti->signer_key_id, blob, n, + ti->signature.bytes, + ti->signature.size); +} + /* ------------------------------------------------------------------ */ /* Signing */ /* ------------------------------------------------------------------ */ @@ -662,10 +1087,27 @@ bool solana_signTx(const HDNode* node, const SolanaSignTx* msg, SolanaSignedTx* resp) { if (!msg->has_raw_tx || msg->raw_tx.size == 0) return false; - /* Ed25519 sign the raw transaction message directly - * (Solana signs the serialized message, not a hash of it) */ + /* Sign the exact same message slice that solana_inspectTx parsed and the user + * approved (Solana signs the serialized message, not a hash of it). */ + const uint8_t* message; + size_t message_len; + solana_message_slice(msg->raw_tx.bytes, msg->raw_tx.size, &message, + &message_len); + uint8_t sig[SOL_SIG_SIZE]; - ed25519_sign(msg->raw_tx.bytes, msg->raw_tx.size, node->private_key, sig); + ed25519_sign(message, message_len, node->private_key, sig); + +#if !ZCASH_PRIVACY + /* Defense-in-depth: refuse to emit a signature that does not verify over + * those exact bytes. solana_message_slice() already guarantees parsing and + * signing operate on the identical message, so this is a redundant check; + * it is compiled out on the ROM-tight zcash-privacy variant, where pulling in + * the ed25519 verification path would overflow flash. */ + if (ed25519_sign_open(message, message_len, node->public_key + 1, sig) != 0) { + memzero(sig, sizeof(sig)); + return false; + } +#endif resp->has_signature = true; resp->signature.size = SOL_SIG_SIZE; diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index b2c14c81d..ca8041b9b 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -8,6 +8,7 @@ set(sources recovery.cpp signed_metadata.cpp ripple.cpp + solana.cpp storage.cpp usb_rx.cpp u2f.cpp) diff --git a/unittests/firmware/solana.cpp b/unittests/firmware/solana.cpp index 36ccef252..3c5f108a5 100644 --- a/unittests/firmware/solana.cpp +++ b/unittests/firmware/solana.cpp @@ -19,6 +19,71 @@ TEST(Solana, FormatAmount) { EXPECT_STREQ(buf, "2.500000000 SOL"); } +TEST(Solana, FormatTokenAmountUsesSignedDecimalsAndTrimsZeros) { + char buf[48]; + + solana_formatTokenAmount(buf, sizeof(buf), 2000, "USDC", 6); + EXPECT_STREQ(buf, "0.002 USDC"); + + solana_formatTokenAmount(buf, sizeof(buf), 1000000, "USDC", 6); + EXPECT_STREQ(buf, "1 USDC"); + + solana_formatTokenAmount(buf, sizeof(buf), 2000, "tokens", 2); + EXPECT_STREQ(buf, "20 tokens"); +} + +TEST(Solana, MainnetUsdcIsFirmwareKnown) { + const uint8_t usdc_mint[32] = { + 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}; + const SolanaKnownToken* token = solana_findKnownToken(usdc_mint); + ASSERT_NE(token, nullptr); + EXPECT_STREQ(token->symbol, "USDC"); + EXPECT_EQ(token->decimals, 6); + + uint8_t unknown[32] = {0}; + EXPECT_EQ(solana_findKnownToken(unknown), nullptr); +} + +TEST(Solana, DerivesAndMatchesAssociatedTokenRecipientOwner) { + /* Vector independently produced by @solana/web3.js + * PublicKey.findProgramAddressSync with bump 251. */ + const uint8_t owner[32] = {0xea, 0x4a, 0x6c, 0x63, 0xe2, 0x9c, 0x52, 0x0a, + 0xbe, 0xf5, 0x50, 0x7b, 0x13, 0x2e, 0xc5, 0xf9, + 0x95, 0x47, 0x76, 0xae, 0xbe, 0xbe, 0x7b, 0x92, + 0x42, 0x1e, 0xea, 0x69, 0x14, 0x46, 0xd2, 0x2c}; + const uint8_t mint[32] = {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}; + const uint8_t expected_ata[32] = { + 0x67, 0x30, 0x2e, 0x49, 0x18, 0x94, 0xd7, 0x49, 0x2e, 0xa6, 0xbe, + 0x4f, 0x91, 0x4e, 0xa4, 0xf4, 0x5f, 0xa1, 0x42, 0xe6, 0x45, 0x86, + 0x7c, 0x91, 0x64, 0xa2, 0x76, 0xd5, 0xdd, 0x76, 0xf0, 0x76}; + + uint8_t derived[32] = {0}; + ASSERT_TRUE(solana_deriveAssociatedTokenAddress(owner, SOL_TOKEN_PROGRAM, + mint, derived)); + EXPECT_EQ(memcmp(derived, expected_ata, sizeof(derived)), 0); + + SolanaSignTx msg = SolanaSignTx_init_zero; + msg.token_recipient_owner_count = 1; + msg.token_recipient_owner[0].size = sizeof(owner); + memcpy(msg.token_recipient_owner[0].bytes, owner, sizeof(owner)); + uint8_t matched[32] = {0}; + ASSERT_TRUE(solana_findTokenRecipientOwner(&msg, SOL_TOKEN_PROGRAM, mint, + expected_ata, matched)); + EXPECT_EQ(memcmp(matched, owner, sizeof(matched)), 0); + + uint8_t wrong_destination[32]; + memset(wrong_destination, 0x44, sizeof(wrong_destination)); + memset(matched, 0xaa, sizeof(matched)); + EXPECT_FALSE(solana_findTokenRecipientOwner(&msg, SOL_TOKEN_PROGRAM, mint, + wrong_destination, matched)); + for (uint8_t byte : matched) EXPECT_EQ(byte, 0xaa); +} + TEST(Solana, ParseSystemTransfer) { /* Construct a minimal Solana transaction with a system transfer. * @@ -225,14 +290,138 @@ TEST(Solana, ParseSPLTokenTransfer) { raw[pos++] = 0x00; SolanaParsedTx tx; - EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); - ASSERT_TRUE(solana_parseTx(raw, pos, &tx)); + /* Unchecked SPL Transfer carries no signed mint (the token being moved is not + * provable), so the transaction is now OPAQUE — it requires AdvancedMode + * blind-signing rather than clear-signing. The instruction is still parsed. + */ + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); EXPECT_EQ(tx.num_instructions, 1); EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_TRANSFER); EXPECT_EQ(tx.instructions[0].amount, 1000000ULL); } +TEST(Solana, Token2022TransferCheckedIsOpaque) { + /* A Token-2022 TransferChecked can invoke an undisclosed transfer hook / fee, + * so it must NOT clear-sign (only legacy SPL Token TransferChecked does). */ + uint8_t raw[512]; + size_t pos = 0; + raw[pos++] = 1; + raw[pos++] = 0; + raw[pos++] = 1; + raw[pos++] = 5; /* source, mint, dest, authority, token-2022 program */ + memset(raw + pos, 0x11, 32); + pos += 32; + memset(raw + pos, 0x22, 32); + pos += 32; + memset(raw + pos, 0x33, 32); + pos += 32; + memset(raw + pos, 0x44, 32); + pos += 32; + memcpy(raw + pos, SOL_TOKEN_2022_PROGRAM, 32); + pos += 32; + memset(raw + pos, 0xBB, 32); + pos += 32; + raw[pos++] = 1; /* 1 instruction */ + raw[pos++] = 4; /* program index = token-2022 */ + raw[pos++] = 4; /* 4 accounts */ + raw[pos++] = 0; + raw[pos++] = 1; + raw[pos++] = 2; + raw[pos++] = 3; + raw[pos++] = 10; /* data length */ + raw[pos++] = 12; /* TransferChecked */ + raw[pos++] = 0x40; + raw[pos++] = 0x42; + raw[pos++] = 0x0F; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 6; /* decimals */ + + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); +} + +/* Helper: build a Vote UpdateValidatorIdentity tx with the given instruction + * data length (4 = canonical; >4 = trailing bytes). Accounts: vote(0), + * new-validator(1), authority(2), vote-program. */ +static size_t build_vote_update_validator(uint8_t* raw, uint16_t data_len) { + size_t pos = 0; + raw[pos++] = 1; + raw[pos++] = 0; + raw[pos++] = 1; + raw[pos++] = 4; + memset(raw + pos, 0x11, 32); + pos += 32; /* vote account (idx 0) */ + memset(raw + pos, 0x22, 32); + pos += 32; /* new validator (idx 1) */ + memset(raw + pos, 0x33, 32); + pos += 32; /* authority (idx 2) */ + memcpy(raw + pos, SOL_VOTE_PROGRAM, 32); + pos += 32; + memset(raw + pos, 0xBB, 32); + pos += 32; /* blockhash */ + raw[pos++] = 1; + raw[pos++] = 3; /* program index = vote */ + raw[pos++] = 3; /* 3 accounts */ + raw[pos++] = 0; + raw[pos++] = 1; + raw[pos++] = 2; + raw[pos++] = (uint8_t)data_len; + raw[pos++] = 4; /* UpdateValidatorIdentity discriminator (le32) */ + raw[pos++] = 0; + raw[pos++] = 0; + raw[pos++] = 0; + for (uint16_t i = 4; i < data_len; i++) raw[pos++] = 0x77; /* trailing */ + return pos; +} + +TEST(Solana, VoteUpdateValidatorReadsAccountNotData) { + uint8_t raw[512]; + size_t pos = build_vote_update_validator(raw, 4); /* canonical */ + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_VOTE_UPDATE_VALIDATOR); + /* The new validator must be account index 1 (0x22..), never fabricated data. + */ + uint8_t expected[32]; + memset(expected, 0x22, 32); + EXPECT_EQ(0, memcmp(tx.instructions[0].extra, expected, 32)); +} + +TEST(Solana, VoteUpdateValidatorRejectsTrailingBytes) { + uint8_t raw[512]; + /* 4-byte discriminator + 32 fabricated bytes — used to be displayed as a + * fake validator; now non-canonical, so the tx is opaque (blind-sign only). + */ + size_t pos = build_vote_update_validator(raw, 36); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); +} + +TEST(Solana, PriorityFeeOverflowSafe) { + uint64_t fee = 0; + /* The wrap-to-zero case: price=UINT64_MAX, limit=1. A naive + * (price*limit + 999999)/1e6 wraps to 0; the real fee is 18446.744073710 SOL + * (= 18446744073710 lamports) and must be shown, not hidden. */ + EXPECT_TRUE(solana_priority_fee_lamports(UINT64_MAX, 1, &fee)); + EXPECT_EQ(fee, 18446744073710ULL); + + /* Typical fee: 1000 micro-lamports/CU * 200000 CU / 1e6 = 200 lamports. */ + EXPECT_TRUE(solana_priority_fee_lamports(1000, 200000, &fee)); + EXPECT_EQ(fee, 200ULL); + + /* Sub-lamport fee rounds UP (fees are charged even for one CU). */ + EXPECT_TRUE(solana_priority_fee_lamports(1, 1, &fee)); + EXPECT_EQ(fee, 1ULL); + + /* A fee that truly exceeds u64 lamports is rejected, never saturated. */ + EXPECT_FALSE(solana_priority_fee_lamports(UINT64_MAX, UINT64_MAX, &fee)); +} + TEST(Solana, ParseAssociatedTokenAccountCreate) { uint8_t raw[512]; size_t pos = 0; @@ -480,15 +669,27 @@ TEST(Solana, RejectsExcessInstructions) { memset(raw + pos, 0xBB, 32); pos += 32; - /* 9 instructions (exceeds limit of 8) */ + /* 9 instructions (exceeds limit of 8), each minimal but well-formed: + * program_idx + zero account indices + zero data bytes */ raw[pos++] = 9; + for (int i = 0; i < 9; i++) { + raw[pos++] = 1; /* program = account 1 */ + raw[pos++] = 0; /* no account indices */ + raw[pos++] = 0; /* no data */ + } SolanaParsedTx tx; EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); EXPECT_FALSE(solana_parseTx(raw, pos, &tx)); + + /* A claimed instruction count with truncated bodies is malformed */ + uint8_t truncated[256]; + memcpy(truncated, raw, pos - 27); + EXPECT_EQ(solana_inspectTx(truncated, pos - 27, &tx), + SOL_TX_REVIEW_MALFORMED); } -TEST(Solana, VersionedMessageIsOpaque) { +TEST(Solana, VersionedMessageNoLookupTablesIsVerified) { uint8_t raw[256]; size_t pos = 0; @@ -529,12 +730,114 @@ TEST(Solana, VersionedMessageIsOpaque) { raw[pos++] = 0; /* zero lookup tables */ + /* A v0 message whose instructions touch only static accounts is as + * verifiable as a legacy message — swap providers build these. */ SolanaParsedTx tx; - EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_FALSE(solana_parseTx(raw, pos, &tx)); + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); + EXPECT_TRUE(solana_parseTx(raw, pos, &tx)); + ASSERT_EQ(tx.num_instructions, 1); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_SYSTEM_TRANSFER); + EXPECT_EQ(tx.instructions[0].lamports, 1000000000ULL); + uint8_t expected_to[32]; + memset(expected_to, 0x22, 32); + EXPECT_EQ(memcmp(tx.instructions[0].to, expected_to, 32), 0); } -TEST(Solana, VersionedMessageWithLookupTableIsOpaque) { +TEST(Solana, X402ZeroLookupV0UsdcPaymentIsVerified) { + /* Self-contained x402 shape: sponsor fee payer + user authority, compute + * limit, compute price, SPL TransferChecked, memo, and zero ALT entries. */ + const uint8_t usdc_mint[32] = { + 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}; + const uint8_t destination_ata[32] = { + 0x67, 0x30, 0x2e, 0x49, 0x18, 0x94, 0xd7, 0x49, 0x2e, 0xa6, 0xbe, + 0x4f, 0x91, 0x4e, 0xa4, 0xf4, 0x5f, 0xa1, 0x42, 0xe6, 0x45, 0x86, + 0x7c, 0x91, 0x64, 0xa2, 0x76, 0xd5, 0xdd, 0x76, 0xf0, 0x76}; + uint8_t raw[512]; + size_t pos = 0; + raw[pos++] = 0x80; /* v0 */ + raw[pos++] = 2; /* sponsor + token authority */ + raw[pos++] = 0; + raw[pos++] = 3; /* compute, token and memo programs are readonly */ + + raw[pos++] = 8; + memset(raw + pos, 0x10, 32); /* sponsor / fee payer */ + pos += 32; + memset(raw + pos, 0x20, 32); /* user token authority */ + pos += 32; + memset(raw + pos, 0x30, 32); /* source token account */ + pos += 32; + memcpy(raw + pos, destination_ata, 32); + pos += 32; + memcpy(raw + pos, usdc_mint, 32); + pos += 32; + memcpy(raw + pos, SOL_COMPUTE_BUDGET_PROGRAM, 32); + pos += 32; + memcpy(raw + pos, SOL_TOKEN_PROGRAM, 32); + pos += 32; + memcpy(raw + pos, SOL_MEMO_PROGRAM, 32); + pos += 32; + memset(raw + pos, 0xbb, 32); /* recent blockhash */ + pos += 32; + + raw[pos++] = 4; /* instructions */ + + raw[pos++] = 5; /* ComputeBudget::SetComputeUnitLimit */ + raw[pos++] = 0; + raw[pos++] = 5; + raw[pos++] = SOL_CB_SET_COMPUTE_UNIT_LIMIT; + raw[pos++] = 0xc0; + raw[pos++] = 0xd4; + raw[pos++] = 0x01; + raw[pos++] = 0x00; /* 120000 */ + + raw[pos++] = 5; /* ComputeBudget::SetComputeUnitPrice */ + raw[pos++] = 0; + raw[pos++] = 9; + raw[pos++] = SOL_CB_SET_COMPUTE_UNIT_PRICE; + raw[pos++] = 0xe8; + raw[pos++] = 0x03; + for (int i = 0; i < 6; i++) raw[pos++] = 0; /* 1000 micro-lamports */ + + raw[pos++] = 6; /* SPL Token::TransferChecked */ + raw[pos++] = 4; + raw[pos++] = 2; /* source */ + raw[pos++] = 4; /* mint */ + raw[pos++] = 3; /* destination ATA */ + raw[pos++] = 1; /* authority */ + raw[pos++] = 10; + raw[pos++] = SOL_TOKEN_TRANSFER_CHECKED_IX; + raw[pos++] = 0xd0; + raw[pos++] = 0x07; + for (int i = 0; i < 6; i++) raw[pos++] = 0; /* amount 2000 */ + raw[pos++] = 6; /* decimals */ + + raw[pos++] = 7; /* Memo */ + raw[pos++] = 1; + raw[pos++] = 1; /* authority signer */ + const char* x402_memo = "00112233445566778899aabbccddeeff"; + const size_t x402_memo_len = strlen(x402_memo); + raw[pos++] = (uint8_t)x402_memo_len; + memcpy(raw + pos, x402_memo, x402_memo_len); + pos += x402_memo_len; + + raw[pos++] = 0; /* zero address-lookup tables */ + + SolanaParsedTx tx; + ASSERT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); + ASSERT_EQ(tx.num_instructions, 4); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_COMPUTE_BUDGET_UNIT_LIMIT); + EXPECT_EQ(tx.instructions[1].type, SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE); + ASSERT_EQ(tx.instructions[2].type, SOL_INSTR_TOKEN_TRANSFER_CHECKED); + EXPECT_EQ(tx.instructions[2].amount, 2000); + EXPECT_EQ(tx.instructions[2].extra_u8, 6); + EXPECT_EQ(memcmp(tx.instructions[2].mint, usdc_mint, 32), 0); + EXPECT_EQ(memcmp(tx.instructions[2].to, destination_ata, 32), 0); + EXPECT_EQ(tx.instructions[3].type, SOL_INSTR_MEMO); +} + +TEST(Solana, VersionedMessageWithUnreferencedLookupTableIsOpaque) { uint8_t raw[256]; size_t pos = 0; @@ -582,11 +885,128 @@ TEST(Solana, VersionedMessageWithLookupTableIsOpaque) { raw[pos++] = 1; raw[pos++] = 2; + /* x402 clear-sign support is deliberately zero-LUT only. Even an + * unreferenced table keeps the message behind the opaque AdvancedMode gate + * until the device can resolve and authenticate lookup-table state. */ + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); + EXPECT_FALSE(solana_parseTx(raw, pos, &tx)); +} + +TEST(Solana, VersionedInstructionUsingLookupAccountIsOpaque) { + uint8_t raw[256]; + size_t pos = 0; + + raw[pos++] = 0x80; /* v0 prefix */ + raw[pos++] = 1; + raw[pos++] = 0; + raw[pos++] = 1; + + raw[pos++] = 3; /* static accounts */ + memset(raw + pos, 0x11, 32); + pos += 32; + memset(raw + pos, 0x22, 32); + pos += 32; + memset(raw + pos, 0x00, 32); + pos += 32; + + memset(raw + pos, 0xBB, 32); + pos += 32; + + raw[pos++] = 1; /* instructions */ + raw[pos++] = 2; /* program = system (static) */ + raw[pos++] = 2; + raw[pos++] = 0; + raw[pos++] = 3; /* index 3 = first lookup-table account */ + raw[pos++] = 12; + raw[pos++] = 2; + raw[pos++] = 0; + raw[pos++] = 0; + raw[pos++] = 0; + raw[pos++] = 0x00; + raw[pos++] = 0xCA; + raw[pos++] = 0x9A; + raw[pos++] = 0x3B; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + + raw[pos++] = 1; /* one lookup table */ + memset(raw + pos, 0x55, 32); + pos += 32; + raw[pos++] = 1; + raw[pos++] = 0; + raw[pos++] = 0; + + /* The recipient lives in a lookup table the device cannot resolve — + * must be opaque (blind-signable under AdvancedMode), NOT malformed, + * and NEVER verified. */ SolanaParsedTx tx; EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); EXPECT_FALSE(solana_parseTx(raw, pos, &tx)); } +TEST(Solana, MemoBodyCaptured) { + /* Legacy tx: system transfer + memo instruction (THORChain-style swap + * memo). The parser must expose the memo bytes for display. */ + const char* memo = "=:ETH.ETH:0x1234:0/1/0:kk:75"; + uint8_t raw[512]; + size_t pos = 0; + + raw[pos++] = 1; + raw[pos++] = 0; + raw[pos++] = 2; /* system + memo programs readonly */ + + raw[pos++] = 4; /* accounts: sender, recipient, system, memo */ + memset(raw + pos, 0x11, 32); + pos += 32; + memset(raw + pos, 0x22, 32); + pos += 32; + memset(raw + pos, 0x00, 32); /* system program */ + pos += 32; + memcpy(raw + pos, SOL_MEMO_PROGRAM, 32); + pos += 32; + + memset(raw + pos, 0xBB, 32); /* blockhash */ + pos += 32; + + raw[pos++] = 2; /* two instructions */ + + /* transfer */ + raw[pos++] = 2; + raw[pos++] = 2; + raw[pos++] = 0; + raw[pos++] = 1; + raw[pos++] = 12; + raw[pos++] = 2; + raw[pos++] = 0; + raw[pos++] = 0; + raw[pos++] = 0; + raw[pos++] = 0x00; + raw[pos++] = 0xCA; + raw[pos++] = 0x9A; + raw[pos++] = 0x3B; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + + /* memo */ + raw[pos++] = 3; /* program = memo */ + raw[pos++] = 0; /* no accounts */ + raw[pos++] = (uint8_t)strlen(memo); + memcpy(raw + pos, memo, strlen(memo)); + pos += strlen(memo); + + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); + ASSERT_EQ(tx.num_instructions, 2); + EXPECT_EQ(tx.instructions[1].type, SOL_INSTR_MEMO); + ASSERT_EQ(tx.instructions[1].data_len, strlen(memo)); + EXPECT_EQ(memcmp(tx.instructions[1].data, memo, strlen(memo)), 0); +} + TEST(Solana, MalformedVersionedLookupTableRejects) { uint8_t raw[256]; size_t pos = 0; @@ -616,3 +1036,432 @@ TEST(Solana, MalformedVersionedLookupTableRejects) { EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_MALFORMED); EXPECT_FALSE(solana_parseTx(raw, pos, &tx)); } + +/* ===================================================================== + * Review-round-12 regression tests: the forced-opaque set and the + * canonical-shape guards. A future refactor that silently drops any of + * these gates fails here, not in the field. + * ===================================================================== */ + +/* Build a single-instruction tx over `program`, with `n_accounts` distinct + * accounts fed to the instruction, plus a fee-payer signer and the program + * account. instr_data holds the opcode + operands. Returns the byte length. */ +static size_t build_single_instr_tx(uint8_t* raw, const uint8_t* program, + int n_accounts, const uint8_t* instr_data, + uint8_t data_len) { + size_t pos = 0; + raw[pos++] = 1; /* num_required_sigs */ + raw[pos++] = 0; /* num_readonly_signed */ + raw[pos++] = 1; /* num_readonly_unsigned (program) */ + const int total_accts = n_accounts + 1 /* program */; + raw[pos++] = (uint8_t)total_accts; /* compact-u16 account count */ + for (int i = 0; i < n_accounts; i++) { /* instruction accounts */ + memset(raw + pos, 0x11 + i, 32); + pos += 32; + } + memcpy(raw + pos, program, 32); /* program account (last) */ + pos += 32; + memset(raw + pos, 0xBB, 32); /* recent blockhash */ + pos += 32; + raw[pos++] = 1; /* 1 instruction */ + raw[pos++] = (uint8_t)n_accounts; /* program index (last account) */ + raw[pos++] = (uint8_t)n_accounts; /* account-index count */ + for (int i = 0; i < n_accounts; i++) { /* account indices 0..n-1 */ + raw[pos++] = (uint8_t)i; + } + raw[pos++] = data_len; + memcpy(raw + pos, instr_data, data_len); + pos += data_len; + return pos; +} + +/* Legacy SPL TransferChecked with the canonical 10-byte data (opcode + amount + * + decimals) and all four accounts clear-signs. */ +TEST(Solana, TransferCheckedCanonicalIsVerified) { + uint8_t d[10] = { + SOL_TOKEN_TRANSFER_CHECKED_IX, 0x40, 0x42, 0x0F, 0, 0, 0, 0, 6}; + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, SOL_TOKEN_PROGRAM, 4, d, sizeof(d)); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); +} + +/* A 9-byte TransferChecked (no decimals byte) is non-canonical: it must NOT + * classify VERIFIED (which would skip the mint screen) — force opaque. */ +TEST(Solana, TransferCheckedShortDataIsOpaque) { + uint8_t d[9] = {SOL_TOKEN_TRANSFER_CHECKED_IX, 0x40, 0x42, 0x0F, 0, 0, 0, 0}; + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, SOL_TOKEN_PROGRAM, 4, d, sizeof(d)); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); +} + +/* A TransferChecked with fewer than 4 accounts would read a zeroed mint / + * destination (displayed as 1111..) — force opaque instead of clear-signing a + * fabricated recipient. */ +TEST(Solana, TransferCheckedShortAccountsIsOpaque) { + uint8_t d[10] = { + SOL_TOKEN_TRANSFER_CHECKED_IX, 0x40, 0x42, 0x0F, 0, 0, 0, 0, 6}; + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, SOL_TOKEN_PROGRAM, 3, d, sizeof(d)); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); +} + +/* StakeAuthorize needs >= 40 data bytes (type(4) + new-authority(32) + + * role(4)); a 36-byte encoding would read the role word out of bounds, so it + * must not be accepted as a canonical authorize. */ +TEST(Solana, StakeAuthorizeShortDataIsOpaque) { + uint8_t d[36] = {SOL_STAKE_AUTHORIZE_IX, 0, 0, 0}; + memset(d + 4, 0x77, 32); /* new authority, role word missing */ + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, SOL_STAKE_PROGRAM, 3, d, sizeof(d)); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); +} + +/* The same StakeAuthorize with the full 40-byte canonical encoding clear-signs + * (role = staker), proving the rejection above is the length guard. */ +TEST(Solana, StakeAuthorizeCanonicalIsVerified) { + uint8_t d[40] = {SOL_STAKE_AUTHORIZE_IX, 0, 0, 0}; + memset(d + 4, 0x77, 32); /* new authority */ + /* d[36..39] = role 0 (staker), already zero */ + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, SOL_STAKE_PROGRAM, 3, d, sizeof(d)); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); +} + +/* ── KKSOLSC1 reusable instruction schemas ──────────────────────────── + * + * Vector is the real Relay bridge deposit captured from api.relay.link on + * 2026-07-27: program 99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2, 48 bytes + * of data = 8-byte discriminator + u64 amount + 32-byte order id. The amount + * word tracked the requested input exactly across three different quotes. + */ +static const uint8_t kRelayDisc[8] = {0x0d, 0x9e, 0x0d, 0xdf, + 0x5f, 0xd5, 0x1c, 0x06}; + +/* Build a KKSOLSC1 payload: one u64 arg ("Amount") and one account ("Vault"). + */ +static size_t build_relay_schema(uint8_t* out, const uint8_t* program, + uint8_t n_args = 1) { + size_t p = 0; + memcpy(out + p, "KKSOLSC1", 8); + p += 8; + out[p++] = 1; /* version */ + memcpy(out + p, program, 32); + p += 32; + out[p++] = 8; /* disc_len */ + memcpy(out + p, kRelayDisc, 8); + p += 8; + out[p++] = 5; + memcpy(out + p, "Relay", 5); + p += 5; /* program name */ + out[p++] = 7; + memcpy(out + p, "deposit", 7); + p += 7; /* instruction name */ + out[p++] = n_args; + if (n_args >= 1) { + out[p++] = SOL_SCHEMA_ARG_U64; + out[p++] = 6; + memcpy(out + p, "Amount", 6); + p += 6; + } + if (n_args >= 2) { + out[p++] = SOL_SCHEMA_ARG_OPAQUE32; + out[p++] = 5; + memcpy(out + p, "Order", 5); + p += 5; + } + out[p++] = 1; /* one displayed account */ + out[p++] = 0; /* index 0 */ + out[p++] = 5; + memcpy(out + p, "Vault", 5); + p += 5; + return p; +} + +/* Relay's instruction data: discriminator + amount + 32-byte order id. */ +static void build_relay_data(uint8_t* d, uint64_t amount) { + memcpy(d, kRelayDisc, 8); + for (int i = 0; i < 8; i++) d[8 + i] = (uint8_t)(amount >> (8 * i)); + memset(d + 16, 0xAB, 32); +} + +TEST(Solana, SchemaParsesCanonicalPayload) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t blob[256]; + size_t len = build_relay_schema(blob, program, 2); + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + EXPECT_EQ(s.disc_len, 8); + EXPECT_EQ(s.num_args, 2); + EXPECT_EQ(s.num_accounts, 1); + EXPECT_STREQ(s.program_name, "Relay"); + EXPECT_STREQ(s.instruction_name, "deposit"); + EXPECT_STREQ(s.args[0].label, "Amount"); +} + +TEST(Solana, SchemaRejectsTrailingBytes) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t blob[256]; + size_t len = build_relay_schema(blob, program, 2); + blob[len] = 0x00; /* one byte too many */ + SolanaInstrSchema s; + EXPECT_FALSE(solana_parseInstrSchema(blob, len + 1, &s)); +} + +TEST(Solana, SchemaRejectsUnsafeLabel) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t blob[256]; + size_t len = build_relay_schema(blob, program, 1); + /* Corrupt the "Amount" label with a format specifier. */ + for (size_t i = 0; i + 6 <= len; i++) { + if (memcmp(blob + i, "Amount", 6) == 0) { + blob[i] = '%'; + break; + } + } + SolanaInstrSchema s; + EXPECT_FALSE(solana_parseInstrSchema(blob, len, &s)); +} + +/* The core safety property: a schema that does not account for every byte of + * the instruction data must NOT apply. Here the data is Relay's real 48 bytes + * but the schema declares only the 8-byte amount, leaving 32 bytes unexplained. + */ +TEST(Solana, SchemaRejectsIncompleteCoverage) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t d[48]; + build_relay_data(d, 526490980ULL); + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, program, 2, d, sizeof(d)); + SolanaParsedTx tx; + ASSERT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); + + uint8_t blob[256]; + size_t len = + build_relay_schema(blob, program, 1); /* amount only: 8+8 != 48 */ + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + uint8_t idx = 0xFF; + EXPECT_FALSE(solana_schemaApplies(&s, &tx, &idx)); +} + +/* Full coverage (8 disc + 8 amount + 32 order = 48) applies, and the amount is + * readable straight out of the signed bytes. */ +TEST(Solana, SchemaAppliesWithFullCoverage) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t d[48]; + build_relay_data(d, 526490980ULL); + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, program, 2, d, sizeof(d)); + SolanaParsedTx tx; + ASSERT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); + + uint8_t blob[256]; + size_t len = build_relay_schema(blob, program, 2); + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + uint8_t idx = 0xFF; + ASSERT_TRUE(solana_schemaApplies(&s, &tx, &idx)); + EXPECT_EQ(idx, 0); + + uint64_t amount = 0; + const SolanaParsedInstruction* ix = &tx.instructions[idx]; + for (int i = 0; i < 8; i++) { + amount |= ((uint64_t)ix->data[s.disc_len + i]) << (8 * i); + } + EXPECT_EQ(amount, 526490980ULL); +} + +/* A schema for a different program must never match. */ +TEST(Solana, SchemaRejectsProgramMismatch) { + uint8_t program[32], other[32]; + memset(program, 0x42, sizeof(program)); + memset(other, 0x43, sizeof(other)); + uint8_t d[48]; + build_relay_data(d, 1ULL); + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, program, 2, d, sizeof(d)); + SolanaParsedTx tx; + solana_inspectTx(raw, pos, &tx); + + uint8_t blob[256]; + size_t len = build_relay_schema(blob, other, 2); + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + uint8_t idx = 0xFF; + EXPECT_FALSE(solana_schemaApplies(&s, &tx, &idx)); +} + +/* An account index the instruction doesn't have must not be displayable. */ +TEST(Solana, SchemaRejectsOutOfRangeAccount) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t d[48]; + build_relay_data(d, 1ULL); + uint8_t raw[512]; + /* Only ONE instruction account, but the schema displays index 0..; bump the + * schema's account index past the end. */ + size_t pos = build_single_instr_tx(raw, program, 1, d, sizeof(d)); + SolanaParsedTx tx; + solana_inspectTx(raw, pos, &tx); + + uint8_t blob[256]; + size_t len = build_relay_schema(blob, program, 2); + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + s.accounts[0].index = 9; /* beyond this instruction's account list */ + uint8_t idx = 0xFF; + EXPECT_FALSE(solana_schemaApplies(&s, &tx, &idx)); +} + +/* Cross-language parity: these exact bytes are emitted by the KeepKey SDK's + * KKSOLSC1 serializer (keepkey-sdk tests/fixtures/solana-schema.js, catalog + * entries relayDepositNative / relayDepositToken). The SDK and this parser are + * independent implementations of the same format — if either drifts, the host + * ships a schema the device refuses, or worse renders differently than the + * signer intended. Regenerate with: + * node -e "const f=require('./tests/fixtures/solana-schema'); + * console.log(f.serializeSchema(f.CATALOG.relayDepositNative).toString('hex'))" + */ +static size_t hex_to_bytes(const char* hex, uint8_t* out, size_t out_max) { + size_t n = strlen(hex) / 2; + if (n > out_max) return 0; + for (size_t i = 0; i < n; i++) { + unsigned v = 0; + sscanf(hex + 2 * i, "%2x", &v); + out[i] = (uint8_t)v; + } + return n; +} + +TEST(Solana, SchemaParsesSdkSerializedPayloadNative) { + /* Verbatim output of the SDK serializer — do not hand-edit. */ + const char* kSdkHex = + "4b4b534f4c53433101792689378ecd51d80406eb0caa3b62795beb10b6c5dc96bc2e0df0" + "3cbfee1abf" + "080d9e0ddf5fd51c06" + "0c52656c617920427269646765" + "0d6465706f7369744e6174697665" + "020106416d6f756e7404054f7264657201" + "03055661756c74"; + uint8_t blob[256]; + size_t len = hex_to_bytes(kSdkHex, blob, sizeof(blob)); + ASSERT_EQ(len, 101u); + + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + EXPECT_STREQ(s.program_name, "Relay Bridge"); + EXPECT_STREQ(s.instruction_name, "depositNative"); + EXPECT_EQ(s.disc_len, 8); + EXPECT_EQ(s.num_args, 2); + EXPECT_EQ(s.args[0].type, SOL_SCHEMA_ARG_U64); + EXPECT_STREQ(s.args[0].label, "Amount"); + EXPECT_EQ(s.args[1].type, SOL_SCHEMA_ARG_OPAQUE32); + EXPECT_STREQ(s.args[1].label, "Order"); + EXPECT_EQ(s.num_accounts, 1); + EXPECT_EQ(s.accounts[0].index, 3); + EXPECT_STREQ(s.accounts[0].label, "Vault"); + + /* Coverage must equal Relay's real 48-byte instruction data. */ + uint32_t covered = s.disc_len; + for (uint8_t i = 0; i < s.num_args; i++) { + covered += solana_schemaArgWidth(s.args[i].type); + } + EXPECT_EQ(covered, 48u); +} + +/* An SPL token transfer whose recipient may not have an associated token + * account: wallets prepend CreateAssociatedTokenAccountIdempotent (data [1]), + * then TransferChecked. This is what Pioneer builds for a USDT swap deposit, + * and it is the ordinary shape of a token send to a fresh address. + * + * Idempotent takes the SAME accounts as Create in the same order and creates + * the same account — it only declines to fail when one already exists — so it + * displays identically. Rejecting it made ONE unrecognised instruction force + * the entire transaction opaque, so a fully decodable SPL transfer + * blind-signed ("Enable AdvancedMode to blind-sign"). + */ +static size_t build_ata_then_transfer_tx(uint8_t* raw, uint8_t ata_ix_byte, + bool include_ata_byte) { + /* accounts: 0..3 instruction accounts, 4 = ATA program, 5 = token program */ + const int n_accounts = 4; + size_t pos = 0; + raw[pos++] = 1; /* num_required_sigs */ + raw[pos++] = 0; + raw[pos++] = 2; /* two readonly unsigned (programs) */ + raw[pos++] = (uint8_t)(n_accounts + 2); /* total accounts */ + for (int i = 0; i < n_accounts; i++) { + memset(raw + pos, 0x11 + i, 32); + pos += 32; + } + memcpy(raw + pos, SOL_ATA_PROGRAM, 32); + pos += 32; + memcpy(raw + pos, SOL_TOKEN_PROGRAM, 32); + pos += 32; + memset(raw + pos, 0xBB, 32); /* recent blockhash */ + pos += 32; + + raw[pos++] = 2; /* two instructions */ + + /* 1) ATA create (idempotent or classic) — accounts 0..3 */ + raw[pos++] = (uint8_t)n_accounts; /* ATA program index */ + raw[pos++] = (uint8_t)n_accounts; + for (int i = 0; i < n_accounts; i++) raw[pos++] = (uint8_t)i; + if (include_ata_byte) { + raw[pos++] = 1; /* data_len */ + raw[pos++] = ata_ix_byte; + } else { + raw[pos++] = 0; /* empty data = legacy Create */ + } + + /* 2) TransferChecked: [12, amount u64 LE, decimals] over 4 accounts */ + raw[pos++] = (uint8_t)(n_accounts + 1); /* token program index */ + raw[pos++] = (uint8_t)n_accounts; + for (int i = 0; i < n_accounts; i++) raw[pos++] = (uint8_t)i; + raw[pos++] = 10; /* data_len */ + raw[pos++] = SOL_TOKEN_TRANSFER_CHECKED_IX; + for (int i = 0; i < 8; i++) raw[pos++] = (i == 0) ? 0x40 : 0x00; /* amount */ + raw[pos++] = 6; /* decimals (USDT) */ + return pos; +} + +TEST(Solana, AtaCreateIdempotentThenTransferIsVerified) { + uint8_t raw[1024]; + size_t len = + build_ata_then_transfer_tx(raw, 1, true); /* 1 = CreateIdempotent */ + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); + ASSERT_EQ(tx.num_instructions, 2); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_ATA_CREATE); + EXPECT_EQ(tx.instructions[1].type, SOL_INSTR_TOKEN_TRANSFER_CHECKED); +} + +TEST(Solana, AtaCreateClassicStillVerified) { + uint8_t raw[1024]; + size_t len = build_ata_then_transfer_tx(raw, 0, true); /* 0 = Create */ + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_ATA_CREATE); + + len = build_ata_then_transfer_tx(raw, 0, false); /* legacy empty data */ + EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_ATA_CREATE); +} + +/* RecoverNested (2) and anything else stays unknown: different accounts and + * different meaning, so it must not borrow the create screens. */ +TEST(Solana, AtaUnknownInstructionStillOpaque) { + uint8_t raw[1024]; + size_t len = build_ata_then_transfer_tx(raw, 2, true); /* RecoverNested */ + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); +} From d54e8fc3465b9194c87e2ee86d219deb03011e5b Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:30:21 -0300 Subject: [PATCH 09/40] feat(clearsign): describe EVM transactions the device can verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses the signed-metadata engine on the Ethereum path so a user sees what a contract call actually does instead of a hex blob: - EthereumTxMetadata carries the signed description and EthereumMetadataAck acknowledges it; LoadClearsignSigner installs a trust anchor for the current session only. Metadata that fails verification is dropped and the transaction falls back to raw display — it never degrades to a friendlier screen. - EIP-712 typed data: domain/primary-type handling is corrected, the policy gate decides when a typed hash may be signed at all, and the recursion and length limits are enforced rather than assumed. - EIP-7702 authorization lists are recognised and shown. - ERC-20 and the bundled contract decoders (THORChain router, liquidity and swap proxies) check the selector against the argument shape before claiming to know what a call means. - A factory reset clears loaded signers. - unittests/firmware/eip712.cpp and the extended ethereum.cpp cover the typed data limits, selector mismatches and the metadata accept/reject paths. --- include/keepkey/firmware/eip712.h | 14 +- include/keepkey/firmware/ethereum.h | 3 + .../firmware/ethereum_contracts/thortx.h | 28 +- .../firmware/ethereum_contracts/zxliquidtx.h | 3 + include/keepkey/firmware/ethereum_tokens.h | 10 +- include/keepkey/firmware/fsm.h | 2 + lib/firmware/eip712.c | 600 +++++++++++------- lib/firmware/ethereum.c | 245 +++++-- lib/firmware/ethereum_contracts.c | 24 +- lib/firmware/ethereum_contracts/saproxy.c | 3 +- lib/firmware/ethereum_contracts/thortx.c | 232 +++++-- lib/firmware/ethereum_contracts/zxappliquid.c | 196 +++--- lib/firmware/ethereum_contracts/zxliquidtx.c | 258 ++++---- lib/firmware/ethereum_contracts/zxswap.c | 20 +- .../ethereum_contracts/zxtransERC20.c | 4 + lib/firmware/ethereum_tokens.c | 5 +- lib/firmware/fsm.c | 1 + lib/firmware/fsm_msg_common.h | 3 + lib/firmware/fsm_msg_ethereum.h | 252 +++++--- lib/firmware/messagemap.def | 4 + unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/eip712.cpp | 67 ++ unittests/firmware/ethereum.cpp | 216 ++++++- 23 files changed, 1578 insertions(+), 613 deletions(-) create mode 100644 unittests/firmware/eip712.cpp diff --git a/include/keepkey/firmware/eip712.h b/include/keepkey/firmware/eip712.h index 686acd6de..165a52fd6 100644 --- a/include/keepkey/firmware/eip712.h +++ b/include/keepkey/firmware/eip712.h @@ -25,9 +25,9 @@ Parser wants to see C strings, not javascript strings: requires all complete json message strings to be enclosed by braces, i.e., { ... } Cannot have entire json string quoted, i.e., "{ ... }" will not - work. Remove all quote escape chars, e.g., {"types": not {\"types\": int - values must be hex. Negative sign indicates negative value, e.g., -5, -8a67 - Note: Do not prefix ints or uints with 0x + work. Remove all quote escape chars, e.g., {"types": not {\"types\": + Integer values must use canonical base-10 digits. Negative values use a + leading minus sign. Do not prefix ints or uints with 0x. All hex and byte strings must be big-endian Byte strings and address should be prefixed by 0x */ @@ -95,10 +95,16 @@ typedef enum { DOMAIN = 1, MESSAGE } dm; #define JSON_TYPE_T_NOVAL 31 #define ADDR_STRING_NULL 32 #define JSON_TYPE_WNOVAL 33 +#define USER_CANCELLED 34 -#define LAST_ERROR JSON_TYPE_WNOVAL +#define LAST_ERROR USER_CANCELLED int encode(const json_t* jsonTypes, const json_t* jsonVals, const char* typeS, uint8_t* hashRet); +/* Exposed for strict-value regression tests. */ +int encAddress(const char* string, uint8_t* encoded); +int encodeBytes(const char* string, uint8_t* encoded); +int encodeBytesN(const char* typeT, const char* string, uint8_t* encoded); + #endif diff --git a/include/keepkey/firmware/ethereum.h b/include/keepkey/firmware/ethereum.h index 77c8f4775..e9ccbbb91 100644 --- a/include/keepkey/firmware/ethereum.h +++ b/include/keepkey/firmware/ethereum.h @@ -36,6 +36,7 @@ typedef struct _CoinType CoinType; void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, bool needs_confirm); void ethereum_signing_abort(void); +bool ethereum_signing_isInProgress(void); void ethereum_signing_txack(EthereumTxAck* tx); void format_ethereum_address(const uint8_t* to, char* destination_str, uint32_t destination_str_len); @@ -70,6 +71,8 @@ void bn_from_bytes(const uint8_t* value, size_t value_len, bignum256* val); void ethereum_typed_hash_sign(const EthereumSignTypedHash* msg, const HDNode* node, EthereumTypedDataSignature* resp); +bool ethereum_typed_hash_policy_allows(bool advanced_mode); +bool ethereum_eip712_is_domain_primary_type(const char* primary_type); bool ethereum_path_check(uint32_t address_n_count, const uint32_t* address_n, bool pubkey_export, uint64_t chain); void e712_types_values(Ethereum712TypesValues* msg, diff --git a/include/keepkey/firmware/ethereum_contracts/thortx.h b/include/keepkey/firmware/ethereum_contracts/thortx.h index 8f61c27f3..8f71c7fcc 100644 --- a/include/keepkey/firmware/ethereum_contracts/thortx.h +++ b/include/keepkey/firmware/ethereum_contracts/thortx.h @@ -30,7 +30,31 @@ "\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee" \ "\xee\xee" -#define THOR_ROUTER "42a5ed456650a09dc10ebc6361a7480fdd61f27b" +/* THORChain ETH router (mainnet), current v4.1.1. + * NOTE: THORChain migrates this router periodically (v1 42a5ed.. -> v3 + * 3624525.. -> v4 d37bbe..). A hardcoded pin must be updated on each migration; + * the durable path is the signed-metadata clear-sign protocol (host-signed, + * key-pinned) which needs no firmware update per router change. */ +#define THOR_ROUTER "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146" + +/* THORChain deploys its Router at a DIFFERENT address on every EVM chain, so + * the pin must be chain-scoped (see thor_router_for_chain): a deposit on any + * chain but mainnet can never match THOR_ROUTER and would fall to the + * blind-sign gate. Avalanche C-Chain router, verified live against THORChain + * /inbound_addresses via a Pioneer quote (2026-07). Lowercase, no 0x, to match + * thor_format_to_addr's output. Same migration caveat as THOR_ROUTER. + * ponytail: BSC (chainId 56) and Base (8453) routers also exist on-chain but + * are omitted until verified against a live node — the shipped Pioneer catalog + * lists STALE addresses (its AVAX entry 8f66c4ae.. is already wrong vs the live + * 00dc6100..), and Pioneer currently routes BSC/Base swaps via Relay, not a + * THORChain deposit, so no such tx reaches the device today. Add each here once + * verified live. */ +#define THOR_ROUTER_AVAX "00dc6100103bc402d490aee3f9a5560cbd91f1d4" + +/* Maya Protocol ETH router v4 (mainnet), verified on Etherscan + * (0xe3985e6b61b814f7cdb188766562ba71b446b46d). The prior pin + * d89dce57.. has never held contract code on mainnet. */ +#define MAYA_ROUTER "e3985e6b61b814f7cdb188766562ba71b446b46d" /* deposit(address,address,uint256,string) — legacy selector */ #define THOR_SELECTOR_DEPOSIT "\x1f\xec\xe7\xb4" @@ -43,6 +67,8 @@ typedef struct _EthereumSignTx EthereumSignTx; bool thor_has_deposit_selector(const EthereumSignTx* msg); bool thor_is_expiry_variant(const EthereumSignTx* msg); bool thor_isThorchainTx(const EthereumSignTx* msg); +bool thor_isMayachainTx(const EthereumSignTx* msg); bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg); +bool thor_confirmMayaTx(uint32_t data_total, const EthereumSignTx* msg); #endif diff --git a/include/keepkey/firmware/ethereum_contracts/zxliquidtx.h b/include/keepkey/firmware/ethereum_contracts/zxliquidtx.h index 6287da2bd..1a4c6046d 100644 --- a/include/keepkey/firmware/ethereum_contracts/zxliquidtx.h +++ b/include/keepkey/firmware/ethereum_contracts/zxliquidtx.h @@ -22,6 +22,7 @@ #include #include +#include #define UNISWAP_ROUTER_ADDRESS \ "\x7a\x25\x0d\x56\x30\xB4\xcF\x53\x97\x39\xdF\x2C\x5d\xAc\xb4\xc6\x59\xF2" \ @@ -31,5 +32,7 @@ typedef struct _EthereumSignTx EthereumSignTx; bool zx_isZxLiquidTx(const EthereumSignTx* msg); bool zx_confirmZxLiquidTx(uint32_t data_total, const EthereumSignTx* msg); +bool zx_formatZxLiquidityPrimaryAmount(const EthereumSignTx* msg, char* out, + size_t out_len); #endif diff --git a/include/keepkey/firmware/ethereum_tokens.h b/include/keepkey/firmware/ethereum_tokens.h index 7e26bd164..0f5c040f8 100644 --- a/include/keepkey/firmware/ethereum_tokens.h +++ b/include/keepkey/firmware/ethereum_tokens.h @@ -25,6 +25,9 @@ #include #include +#if BITCOIN_ONLY +#define TOKENS_COUNT 0 // no ERC-20 tokens in the bitcoin-only image +#else enum { #define X(CHAIN_ID, CONTRACT_ADDR, TICKER, DECIMALS) \ CONCAT(TokenIndex, __COUNTER__), @@ -35,11 +38,12 @@ enum { }; #define TOKENS_COUNT ((int)TokenIndexLast - (int)TokenIndexFirst) +#endif typedef struct _TokenType { const char* const address; const char* const ticker; - uint8_t chain_id; + uint32_t chain_id; uint8_t decimals; } TokenType; @@ -51,7 +55,7 @@ extern const TokenType* UnknownToken; const TokenType* tokenIter(int32_t* ctr); -const TokenType* tokenByChainAddress(uint8_t chain_id, const uint8_t* address); +const TokenType* tokenByChainAddress(uint32_t chain_id, const uint8_t* address); /// Tokens don't have unique tickers, so this might not return the one you're /// looking for :/ @@ -64,7 +68,7 @@ const TokenType* tokenByChainAddress(uint8_t chain_id, const uint8_t* address); /// \param[out] token The found token, assuming it was uniquely determinable. /// \returns true iff the token can be uniquely found in the list of known /// tokens. -bool tokenByTicker(uint8_t chain_id, const char* ticker, +bool tokenByTicker(uint32_t chain_id, const char* ticker, const TokenType** token); void coinFromToken(CoinType* coin, const TokenType* token); diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index 656f0c127..4de0813f6 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -85,6 +85,8 @@ void fsm_msgEthereumSignMessage(EthereumSignMessage* msg); void fsm_msgEthereumVerifyMessage(const EthereumVerifyMessage* msg); void fsm_msgEthereumSignTypedHash(const EthereumSignTypedHash* msg); void fsm_msgEthereum712TypesValues(Ethereum712TypesValues* msg); +void fsm_msgEthereumTxMetadata(const EthereumTxMetadata* msg); +void fsm_msgLoadClearsignSigner(const LoadClearsignSigner* msg); void fsm_msgNanoGetAddress(NanoGetAddress* msg); void fsm_msgNanoSignTx(NanoSignTx* msg); diff --git a/lib/firmware/eip712.c b/lib/firmware/eip712.c index 1fec75f4b..84da58083 100644 --- a/lib/firmware/eip712.c +++ b/lib/firmware/eip712.c @@ -31,11 +31,13 @@ strings and address should be prefixed by 0x */ +#include #include #include #include #include "keepkey/board/confirm_sm.h" #include "keepkey/board/memory.h" +#include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/eip712.h" #include "keepkey/firmware/ethereum_tokens.h" #include "keepkey/firmware/tiny-json.h" @@ -47,38 +49,117 @@ static dm confirmProp; static const char* nameForValue; +static bool append_type_string(char* dest, const char* value) { + if (!dest || !value) return false; + const size_t used = strnlen(dest, STRBUFSIZE + 1); + const size_t added = strlen(value); + if (used > STRBUFSIZE || added > STRBUFSIZE - used) return false; + memcpy(dest + used, value, added + 1); + return true; +} + +static bool type_array_suffix_is_valid(const char* suffix) { + if (*suffix == '\0') return true; + if (*suffix++ != '[') return false; + while (*suffix >= '0' && *suffix <= '9') suffix++; + return suffix[0] == ']' && suffix[1] == '\0'; +} + +static bool type_matches(const char* type, const char* base) { + const size_t len = strlen(base); + return strncmp(type, base, len) == 0 && + type_array_suffix_is_valid(type + len); +} + +static bool type_is_integer(const char* type, const char* prefix) { + const size_t prefix_len = strlen(prefix); + if (strncmp(type, prefix, prefix_len) != 0) return false; + const char* p = type + prefix_len; + unsigned bits = 0; + bool has_bits = false; + while (*p >= '0' && *p <= '9') { + has_bits = true; + bits = bits * 10 + (unsigned)(*p++ - '0'); + } + if (has_bits && (bits < 8 || bits > 256 || (bits % 8) != 0)) return false; + return type_array_suffix_is_valid(p); +} + +static unsigned integer_type_width(const char* type, const char* prefix) { + const char* p = type + strlen(prefix); + if (*p < '0' || *p > '9') return 256; + unsigned bits = 0; + while (*p >= '0' && *p <= '9') { + bits = bits * 10 + (unsigned)(*p++ - '0'); + } + return bits; +} + +static bool type_is_bytes(const char* type, unsigned* byte_size, + bool* dynamic) { + if (strncmp(type, "bytes", 5) != 0) return false; + const char* p = type + 5; + if (*p == '\0' || *p == '[') { + if (!type_array_suffix_is_valid(p)) return false; + *byte_size = 0; + *dynamic = true; + return true; + } + unsigned size = 0; + bool has_size = false; + while (*p >= '0' && *p <= '9') { + has_size = true; + size = size * 10 + (unsigned)(*p++ - '0'); + } + if (!has_size || size == 0 || size > 32 || !type_array_suffix_is_valid(p)) + return false; + *byte_size = size; + *dynamic = false; + return true; +} + +static int hex_nibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +static bool hex_string_is_valid(const char* string, size_t expected_bytes, + bool exact_size) { + if (!string || string[0] != '0' || string[1] != 'x') return false; + const size_t chars = strlen(string + 2); + if ((chars & 1) != 0 || (exact_size && chars != 2 * expected_bytes)) + return false; + for (size_t i = 0; i < chars; i++) { + if (hex_nibble(string[i + 2]) < 0) return false; + } + return true; +} + int encodableType(const char* typeStr) { int ctr; - if (0 == strncmp(typeStr, "address", sizeof("address") - 1)) { + if (!typeStr || typeStr[0] == '\0') return NOT_ENCODABLE; + + if (type_matches(typeStr, "address")) { return ADDRESS; } - if (0 == strncmp(typeStr, "string", sizeof("string") - 1)) { + if (type_matches(typeStr, "string")) { return STRING; } - if (0 == strncmp(typeStr, "int", sizeof("int") - 1)) { - // This could be 'int8', 'int16', ..., 'int256' + if (type_is_integer(typeStr, "int")) { return INT; } - if (0 == strncmp(typeStr, "uint", sizeof("uint") - 1)) { - // This could be 'uint8', 'uint16', ..., 'uint256' + if (type_is_integer(typeStr, "uint")) { return UINT; } - if (0 == strncmp(typeStr, "bytes", sizeof("bytes") - 1)) { - // This could be 'bytes', 'bytes1', ..., 'bytes32' - if (0 == strcmp(typeStr, "bytes")) { - return BYTES; - } else { - // parse out the length val - uint8_t byteTypeSize = (uint8_t)(strtol((typeStr + 5), NULL, 10)); - if (byteTypeSize > 32) { - return NOT_ENCODABLE; - } else { - return BYTES_N; - } - } + unsigned byte_size = 0; + bool dynamic = false; + if (type_is_bytes(typeStr, &byte_size, &dynamic)) { + return dynamic ? BYTES : BYTES_N; } - if (0 == strcmp(typeStr, "bool")) { + if (type_matches(typeStr, "bool")) { return BOOL; } @@ -90,10 +171,11 @@ int encodableType(const char* typeStr) { strtok(typeNoArrTok, "["); // eliminate the array tokens if there if (udefList[ctr] != 0) { - if (0 == strncmp(udefList[ctr], typeNoArrTok, - strlen(udefList[ctr]) - strlen(typeNoArrTok))) { + const size_t previous_len = strcspn(udefList[ctr], "["); + const size_t candidate_len = strlen(typeNoArrTok); + if (previous_len == candidate_len && + strncmp(udefList[ctr], typeNoArrTok, candidate_len) == 0) { return PREV_USERDEF; - } else { } } else { @@ -134,8 +216,10 @@ int parseType(const json_t* eip712Types, const char* typeS, char* typeStr) { return JSON_TYPE_S_NAMEERR; } - strncat(typeStr, nameTest, STRBUFSIZE - strlen((const char*)typeStr)); - strncat(typeStr, "(", STRBUFSIZE - strlen((const char*)typeStr)); + if (!append_type_string(typeStr, nameTest) || + !append_type_string(typeStr, "(")) { + return UDEF_NAME_ERROR; + } tarray = json_getChild(jType); while (tarray != 0) { @@ -189,10 +273,12 @@ int parseType(const json_t* eip712Types, const char* typeS, char* typeStr) { if (NULL == pVal) { return JSON_NOPAIRVAL; } - strncat(typeStr, typeType, STRBUFSIZE - strlen((const char*)typeStr)); - strncat(typeStr, " ", STRBUFSIZE - strlen((const char*)typeStr)); - strncat(typeStr, pVal, STRBUFSIZE - strlen((const char*)typeStr)); - strncat(typeStr, ",", STRBUFSIZE - strlen((const char*)typeStr)); + if (!append_type_string(typeStr, typeType) || + !append_type_string(typeStr, " ") || + !append_type_string(typeStr, pVal) || + !append_type_string(typeStr, ",")) { + return UDEF_NAME_ERROR; + } } tarray = json_getSibling(tarray); } @@ -202,32 +288,28 @@ int parseType(const json_t* eip712Types, const char* typeS, char* typeStr) { typeStr[strlen(typeStr) - 1] = ')'; } else { // append paren, there are no parameters - strncat(typeStr, ")", STRBUFSIZE - 1); + if (!append_type_string(typeStr, ")")) return UDEF_NAME_ERROR; } if (strlen(append) > 0) { - strncat(typeStr, append, STRBUFSIZE - strlen((const char*)append)); + if (!append_type_string(typeStr, append)) return UDEF_NAME_ERROR; } return SUCCESS; } int encAddress(const char* string, uint8_t* encoded) { - unsigned ctr; - char byteStrBuf[3] = {0}; - - if (string == NULL) { + if (!string) { return ADDR_STRING_NULL; } - if (ADDRESS_SIZE < strlen(string)) { + if (strlen(string) != ADDRESS_SIZE || + !hex_string_is_valid(string, 20, true)) { return ADDR_STRING_VFLOW; } - for (ctr = 0; ctr < 12; ctr++) { - encoded[ctr] = '\0'; - } - for (ctr = 12; ctr < 32; ctr++) { - strncpy(byteStrBuf, &string[2 * ((ctr - 12)) + 2], 2); - encoded[ctr] = (uint8_t)(strtol(byteStrBuf, NULL, 16)); + memset(encoded, 0, 12); + for (size_t i = 0; i < 20; i++) { + encoded[12 + i] = (uint8_t)((hex_nibble(string[2 + 2 * i]) << 4) | + hex_nibble(string[3 + 2 * i])); } return SUCCESS; } @@ -242,17 +324,15 @@ int encString(const char* string, uint8_t* encoded) { } int encodeBytes(const char* string, uint8_t* encoded) { + if (!hex_string_is_valid(string, 0, false)) return GENERAL_ERROR; struct SHA3_CTX byteCtx; const char* valStrPtr = string + 2; - uint8_t valByte[1]; - char byteStrBuf[3] = {0}; sha3_256_Init(&byteCtx); while (*valStrPtr != '\0') { - strncpy(byteStrBuf, valStrPtr, 2); - valByte[0] = (uint8_t)(strtol(byteStrBuf, NULL, 16)); - sha3_Update(&byteCtx, (const unsigned char*)valByte, - (size_t)sizeof(uint8_t)); + const uint8_t valByte = + (uint8_t)((hex_nibble(valStrPtr[0]) << 4) | hex_nibble(valStrPtr[1])); + sha3_Update(&byteCtx, &valByte, sizeof(valByte)); valStrPtr += 2; } keccak_Final(&byteCtx, encoded); @@ -260,44 +340,39 @@ int encodeBytes(const char* string, uint8_t* encoded) { } int encodeBytesN(const char* typeT, const char* string, uint8_t* encoded) { - char byteStrBuf[3] = {0}; - unsigned ctr; - - if (MAX_ENCBYTEN_SIZE < strlen(string)) { - return BYTESN_STRING_ERROR; - } - - // parse out the length val - uint8_t byteTypeSize = (uint8_t)(strtol((typeT + 5), NULL, 10)); - if (32 < byteTypeSize) { + unsigned byteTypeSize = 0; + bool dynamic = false; + if (!type_is_bytes(typeT, &byteTypeSize, &dynamic) || dynamic) { return BYTESN_SIZE_ERROR; } - for (ctr = 0; ctr < 32; ctr++) { - // zero padding - encoded[ctr] = 0; + if (!hex_string_is_valid(string, byteTypeSize, true)) { + return BYTESN_STRING_ERROR; } - unsigned zeroFillLen = 32 - ((strlen(string) - 2 /* skip '0x' */) / 2); - // bytesN are zero padded on the right - for (ctr = zeroFillLen; ctr < 32; ctr++) { - strncpy(byteStrBuf, &string[2 + 2 * (ctr - zeroFillLen)], 2); - encoded[ctr - zeroFillLen] = (uint8_t)(strtol(byteStrBuf, NULL, 16)); + memset(encoded, 0, 32); + for (size_t i = 0; i < byteTypeSize; i++) { + encoded[i] = (uint8_t)((hex_nibble(string[2 + 2 * i]) << 4) | + hex_nibble(string[3 + 2 * i])); } return SUCCESS; } int confirmName(const char* name, bool valAvailable) { - if (valAvailable) { - nameForValue = name; - } else { - (void)review(ButtonRequestType_ButtonRequest_Other, "MESSAGE DATA", - "Press button to continue for\n\"%s\" values", name); + (void)valAvailable; + if (!name) return GENERAL_ERROR; + nameForValue = name; + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "EIP-712 Field", + (const uint8_t*)name, strlen(name))) { + return USER_CANCELLED; } return SUCCESS; } int confirmValue(const char* value) { - (void)review(ButtonRequestType_ButtonRequest_Other, "MESSAGE DATA", "%s %s", - nameForValue, value); + if (!value || !confirm_bytes(ButtonRequestType_ButtonRequest_Other, + nameForValue ? "EIP-712 Value" : "MESSAGE DATA", + (const uint8_t*)value, strlen(value))) { + return USER_CANCELLED; + } return SUCCESS; } @@ -320,15 +395,18 @@ void marshallDsVals(const char* value) { return; } -void dsConfirm(void) { +static int confirmTypedValue(bool ds_vals, const char* value) { + if (ds_vals) marshallDsVals(value); + return confirmValue(value); +} + +int dsConfirm(void) { // First check if we recognize the contract - const TokenType* assetToken; uint8_t addrHexStr[20] = {0}; char name[41] = {0}; char version[11] = {0}; uint32_t chainInt; bool noChain = true; - int ctr; IconType iconNum = NO_ICON; char title[64] = {0}; char* fillerStr = ""; @@ -343,9 +421,13 @@ void dsConfirm(void) { } if (dsverifyingContract != NULL) { - for (ctr = 2; ctr < 42; ctr += 2) { - sscanf((char*)&dsverifyingContract[ctr], "%2hhx", - &addrHexStr[(ctr - 2) / 2]); + // Same two-chars-then-strtol idiom as encAddress(). sscanf("%2hhx") did + // this before, and it was the firmware's only caller of newlib's scanf + // engine — ~6KB of ROM on a part with none to spare. + char byteStrBuf[3] = {0}; + for (int ctr = 2; ctr < 42; ctr += 2) { + strncpy(byteStrBuf, (char*)&dsverifyingContract[ctr], 2); + addrHexStr[(ctr - 2) / 2] = (uint8_t)strtol(byteStrBuf, NULL, 16); } strcat(verifyingContract, "Verifying Contract: "); strncat(verifyingContract, dsverifyingContract, @@ -354,11 +436,7 @@ void dsConfirm(void) { if (NULL != dschainId) { noChain = false; -#ifdef EMULATOR - sscanf((char*)dschainId, "%u", &chainInt); -#else - sscanf((char*)dschainId, "%ld", &chainInt); -#endif + chainInt = (uint32_t)strtoul((const char*)dschainId, NULL, 10); // As more chains are supported, add icon choice below // TBD: not implemented for first release // if (chainInt == 1) { @@ -366,7 +444,8 @@ void dsConfirm(void) { // } } if (noChain == false && dsverifyingContract != NULL) { - assetToken = tokenByChainAddress(chainInt, (uint8_t*)addrHexStr); + const TokenType* assetToken = + tokenByChainAddress(chainInt, (uint8_t*)addrHexStr); (void)assetToken; fillerStr = ""; } @@ -380,12 +459,17 @@ void dsConfirm(void) { snprintf(chainStr, 32, "chain %s, ", dschainId); } // snprintf(contractStr, 64, "verifyingContract: %s", verifyingContract); - (void)review_with_icon(ButtonRequestType_ButtonRequest_Other, iconNum, title, - "%s %s%s", chainStr, verifyingContract, fillerStr); + bool approved = + review_with_icon(ButtonRequestType_ButtonRequest_Other, iconNum, title, + "%s %s%s", chainStr, verifyingContract, fillerStr); dsname = NULL; dsversion = NULL; dschainId = NULL; dsverifyingContract = NULL; + if (!approved) { + return USER_CANCELLED; + } + return SUCCESS; } /* @@ -438,7 +522,6 @@ int parseVals(const json_t* eip712Types, const json_t* jType, walkVals = nextVal; while (0 != walkVals) { if (0 == strcmp(json_getName(walkVals), typeName)) { - valStr = json_getValue(walkVals); break; } else { // keep looking for val @@ -446,28 +529,35 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } } - bool hasValue = (JSON_TEXT == json_getType(walkVals) || - JSON_INTEGER == json_getType(walkVals)); - confirmName(typeName, hasValue); - if (walkVals == 0) { return JSON_TYPE_WNOVAL; - } else { - if (0 == strncmp("address", typeType, strlen("address") - 1)) { + } + const jsonType_t value_type = json_getType(walkVals); + const bool hasValue = value_type == JSON_TEXT || + value_type == JSON_INTEGER || + value_type == JSON_BOOLEAN; + valStr = hasValue ? json_getValue(walkVals) : NULL; + if (SUCCESS != (errRet = confirmName(typeName, hasValue))) { + return errRet; + } + + { + if (type_matches(typeType, "address")) { if (']' == typeType[strlen(typeType) - 1]) { // array of addresses + if (value_type != JSON_ARRAY) return GENERAL_ERROR; json_t const* addrVals = json_getChild(walkVals); sha3_256_Init(&valCtx); // hash of concatenated encoded strings while (0 != addrVals) { + if (json_getType(addrVals) != JSON_TEXT) return GENERAL_ERROR; + const char* address = json_getValue(addrVals); // just walk the string values assuming, for fixed sizes, all // values are there. - if (ds_vals) { - marshallDsVals(json_getValue(addrVals)); - } else { - confirmValue(json_getValue(addrVals)); + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, address))) { + return errRet; } - errRet = encAddress(json_getValue(addrVals), encBytes); + errRet = encAddress(address, encBytes); if (SUCCESS != errRet) { return errRet; } @@ -476,10 +566,9 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } keccak_Final(&valCtx, encBytes); } else { - if (ds_vals) { - marshallDsVals(valStr); - } else { - confirmValue(valStr); + if (value_type != JSON_TEXT) return GENERAL_ERROR; + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { + return errRet; } errRet = encAddress(valStr, encBytes); if (SUCCESS != errRet) { @@ -487,21 +576,23 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } } - } else if (0 == strncmp("string", typeType, strlen("string") - 1)) { + } else if (type_matches(typeType, "string")) { if (']' == typeType[strlen(typeType) - 1]) { // array of strings + if (value_type != JSON_ARRAY) return GENERAL_ERROR; json_t const* stringVals = json_getChild(walkVals); uint8_t strEncBytes[32]; sha3_256_Init(&valCtx); // hash of concatenated encoded strings while (0 != stringVals) { + if (json_getType(stringVals) != JSON_TEXT) return GENERAL_ERROR; + const char* string_value = json_getValue(stringVals); // just walk the string values assuming, for fixed sizes, all // values are there. - if (ds_vals) { - marshallDsVals(json_getValue(stringVals)); - } else { - confirmValue(json_getValue(stringVals)); + if (SUCCESS != + (errRet = confirmTypedValue(ds_vals, string_value))) { + return errRet; } - errRet = encString(json_getValue(stringVals), strEncBytes); + errRet = encString(string_value, strEncBytes); if (SUCCESS != errRet) { return errRet; } @@ -510,10 +601,9 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } keccak_Final(&valCtx, encBytes); } else { - if (ds_vals) { - marshallDsVals(valStr); - } else { - confirmValue(valStr); + if (value_type != JSON_TEXT) return GENERAL_ERROR; + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { + return errRet; } errRet = encString(valStr, encBytes); if (SUCCESS != errRet) { @@ -521,18 +611,19 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } } - } else if ((0 == strncmp("uint", typeType, strlen("uint") - 1)) || - (0 == strncmp("int", typeType, strlen("int") - 1))) { + } else if (type_is_integer(typeType, "uint") || + type_is_integer(typeType, "int")) { if (']' == typeType[strlen(typeType) - 1]) { return INT_ARRAY_ERROR; } else { - if (ds_vals) { - marshallDsVals(valStr); - } else { - confirmValue(valStr); + if (value_type != JSON_TEXT && value_type != JSON_INTEGER) + return GENERAL_ERROR; + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { + return errRet; } + const bool is_uint = type_is_integer(typeType, "uint"); uint8_t negInt = 0; // 0 is positive, 1 is negative - if (0 == strncmp("int", typeType, strlen("int") - 1)) { + if (!is_uint) { if (*valStr == '-') { negInt = 1; } @@ -548,147 +639,184 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } } // all int strings are assumed to be base 10 and fit into 64 bits - long long intVal = strtoll(valStr, NULL, 10); + const char* digits = valStr + (negInt ? 1 : 0); + if (*digits == '\0') return GENERAL_ERROR; + for (const char* p = digits; *p; p++) { + if (*p < '0' || *p > '9') return GENERAL_ERROR; + } + errno = 0; + char* endptr = NULL; + long long intVal = strtoll(valStr, &endptr, 10); + if (errno == ERANGE || endptr == valStr || *endptr != '\0') { + return GENERAL_ERROR; + } + if (is_uint && intVal < 0) { + return GENERAL_ERROR; + } + const unsigned declared_bits = + integer_type_width(typeType, is_uint ? "uint" : "int"); + if (declared_bits < 64) { + if (is_uint) { + const uint64_t max_value = (UINT64_C(1) << declared_bits) - 1; + if ((uint64_t)intVal > max_value) return GENERAL_ERROR; + } else { + const int64_t min_value = -(INT64_C(1) << (declared_bits - 1)); + const int64_t max_value = + (INT64_C(1) << (declared_bits - 1)) - 1; + if (intVal < min_value || intVal > max_value) + return GENERAL_ERROR; + } + } // Needs to be big endian, so add to encBytes appropriately - encBytes[24] = (intVal >> 56) & 0xff; - encBytes[25] = (intVal >> 48) & 0xff; - encBytes[26] = (intVal >> 40) & 0xff; - encBytes[27] = (intVal >> 32) & 0xff; - encBytes[28] = (intVal >> 24) & 0xff; - encBytes[29] = (intVal >> 16) & 0xff; - encBytes[30] = (intVal >> 8) & 0xff; - encBytes[31] = (intVal) & 0xff; + const uint64_t intBits = (uint64_t)intVal; + encBytes[24] = (intBits >> 56) & 0xff; + encBytes[25] = (intBits >> 48) & 0xff; + encBytes[26] = (intBits >> 40) & 0xff; + encBytes[27] = (intBits >> 32) & 0xff; + encBytes[28] = (intBits >> 24) & 0xff; + encBytes[29] = (intBits >> 16) & 0xff; + encBytes[30] = (intBits >> 8) & 0xff; + encBytes[31] = intBits & 0xff; } - } else if (0 == strncmp("bytes", typeType, strlen("bytes"))) { - if (']' == typeType[strlen(typeType) - 1]) { - return BYTESN_ARRAY_ERROR; - } else { - // This could be 'bytes', 'bytes1', ..., 'bytes32' - if (ds_vals) { - marshallDsVals(valStr); + } else { + unsigned byte_size = 0; + bool dynamic_bytes = false; + if (type_is_bytes(typeType, &byte_size, &dynamic_bytes)) { + if (']' == typeType[strlen(typeType) - 1]) { + return BYTESN_ARRAY_ERROR; } else { - confirmValue(valStr); - } - if (0 == strcmp(typeType, "bytes")) { - errRet = encodeBytes(valStr, encBytes); - if (SUCCESS != errRet) { + if (value_type != JSON_TEXT) return GENERAL_ERROR; + // This could be 'bytes', 'bytes1', ..., 'bytes32' + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { return errRet; } + if (dynamic_bytes) { + errRet = encodeBytes(valStr, encBytes); + if (SUCCESS != errRet) { + return errRet; + } - } else { - errRet = encodeBytesN(typeType, valStr, encBytes); - if (SUCCESS != errRet) { - return errRet; + } else { + errRet = encodeBytesN(typeType, valStr, encBytes); + if (SUCCESS != errRet) { + return errRet; + } } } - } - } else if (0 == strncmp("bool", typeType, strlen(typeType))) { - if (']' == typeType[strlen(typeType) - 1]) { - return BOOL_ARRAY_ERROR; - } else { - if (ds_vals) { - marshallDsVals(valStr); + } else if (type_matches(typeType, "bool")) { + if (']' == typeType[strlen(typeType) - 1]) { + return BOOL_ARRAY_ERROR; } else { - confirmValue(valStr); - } - for (ctr = 0; ctr < 32; ctr++) { - // leading zeros in bool - encBytes[ctr] = 0; - } - if (0 == strncmp(valStr, "true", sizeof("true"))) { - encBytes[31] = 0x01; + if (value_type != JSON_BOOLEAN && value_type != JSON_TEXT) + return GENERAL_ERROR; + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { + return errRet; + } + if (strcmp(valStr, "true") != 0 && strcmp(valStr, "false") != 0) + return GENERAL_ERROR; + for (ctr = 0; ctr < 32; ctr++) { + // leading zeros in bool + encBytes[ctr] = 0; + } + if (strcmp(valStr, "true") == 0) { + encBytes[31] = 0x01; + } } - } - - } else { - // encode user defined type - char encSubTypeStr[STRBUFSIZE + 1] = {0}; - // clear out the user-defined types list - for (ctr = 0; ctr < MAX_USERDEF_TYPES; ctr++) { - udefList[ctr] = NULL; - } - char typeNoArrTok[MAX_TYPESTRING] = {0}; - // need to get typehash of type first - if (']' == typeType[strlen(typeType) - 1]) { - // array of structs. To parse name, remove array tokens. - strncpy(typeNoArrTok, typeType, sizeof(typeNoArrTok) - 1); - if (strlen(typeNoArrTok) < strlen(typeType)) { - return UDEF_ARRAY_NAME_ERR; - } - strtok(typeNoArrTok, "["); - if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { - return errRet; - } - if (SUCCESS != (errRet = parseType(eip712Types, typeNoArrTok, - encSubTypeStr))) { - return errRet; - } } else { - if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { - return errRet; - } - if (SUCCESS != - (errRet = parseType(eip712Types, typeType, encSubTypeStr))) { - return errRet; + // encode user defined type + char encSubTypeStr[STRBUFSIZE + 1] = {0}; + // clear out the user-defined types list + for (ctr = 0; ctr < MAX_USERDEF_TYPES; ctr++) { + udefList[ctr] = NULL; } - } - sha3_256_Init(&valCtx); - sha3_Update(&valCtx, (const unsigned char*)encSubTypeStr, - (size_t)strlen(encSubTypeStr)); - keccak_Final(&valCtx, encBytes); - if (']' == typeType[strlen(typeType) - 1]) { - // array of udefs - struct SHA3_CTX eleCtx = {0}; // local hash context - struct SHA3_CTX arrCtx = {0}; // array elements hash context - uint8_t eleHashBytes[32]; + char typeNoArrTok[MAX_TYPESTRING] = {0}; + // need to get typehash of type first + if (']' == typeType[strlen(typeType) - 1]) { + // array of structs. To parse name, remove array tokens. + if (value_type != JSON_ARRAY) return GENERAL_ERROR; + strncpy(typeNoArrTok, typeType, sizeof(typeNoArrTok) - 1); + if (strlen(typeNoArrTok) < strlen(typeType)) { + return UDEF_ARRAY_NAME_ERR; + } + strtok(typeNoArrTok, "["); + if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { + return errRet; + } + if (SUCCESS != (errRet = parseType(eip712Types, typeNoArrTok, + encSubTypeStr))) { + return errRet; + } + } else { + if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { + return errRet; + } + if (SUCCESS != + (errRet = parseType(eip712Types, typeType, encSubTypeStr))) { + return errRet; + } + } + sha3_256_Init(&valCtx); + sha3_Update(&valCtx, (const unsigned char*)encSubTypeStr, + (size_t)strlen(encSubTypeStr)); + keccak_Final(&valCtx, encBytes); - sha3_256_Init(&arrCtx); + if (']' == typeType[strlen(typeType) - 1]) { + // array of udefs + struct SHA3_CTX eleCtx = {0}; // local hash context + struct SHA3_CTX arrCtx = {0}; // array elements hash context + uint8_t eleHashBytes[32]; + + sha3_256_Init(&arrCtx); + + json_t const* udefVals = json_getChild(walkVals); + while (0 != udefVals) { + if (json_getType(udefVals) != JSON_OBJ) return GENERAL_ERROR; + sha3_256_Init(&eleCtx); + sha3_Update(&eleCtx, (const unsigned char*)encBytes, 32); + if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { + return errRet; + } + if (SUCCESS != + (errRet = parseVals( + eip712Types, + json_getProperty(eip712Types, + strtok(typeNoArrTok, "]")), + json_getChild(udefVals), // where to get the values + &eleCtx // encode hash happens in parse, this is the + // return + ))) { + return errRet; + } + keccak_Final(&eleCtx, eleHashBytes); + sha3_Update(&arrCtx, (const unsigned char*)eleHashBytes, 32); + // just walk the udef values assuming, for fixed sizes, all + // values are there. + udefVals = json_getSibling(udefVals); + } + keccak_Final(&arrCtx, encBytes); - json_t const* udefVals = json_getChild(walkVals); - while (0 != udefVals) { - sha3_256_Init(&eleCtx); - sha3_Update(&eleCtx, (const unsigned char*)encBytes, 32); + } else { + if (value_type != JSON_OBJ) return GENERAL_ERROR; + sha3_256_Init(&valCtx); + sha3_Update(&valCtx, (const unsigned char*)encBytes, + (size_t)sizeof(encBytes)); if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { return errRet; } if (SUCCESS != (errRet = parseVals( - eip712Types, - json_getProperty(eip712Types, strtok(typeNoArrTok, "]")), - json_getChild(udefVals), // where to get the values - &eleCtx // encode hash happens in parse, this is the - // return + eip712Types, json_getProperty(eip712Types, typeType), + json_getChild(walkVals), // where to get the values + &valCtx // val hash happens in parse, this is the return ))) { return errRet; } - keccak_Final(&eleCtx, eleHashBytes); - sha3_Update(&arrCtx, (const unsigned char*)eleHashBytes, 32); - // just walk the udef values assuming, for fixed sizes, all values - // are there. - udefVals = json_getSibling(udefVals); + keccak_Final(&valCtx, encBytes); } - keccak_Final(&arrCtx, encBytes); - - } else { - sha3_256_Init(&valCtx); - sha3_Update(&valCtx, (const unsigned char*)encBytes, - (size_t)sizeof(encBytes)); - if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { - return errRet; - } - if (SUCCESS != - (errRet = parseVals( - eip712Types, json_getProperty(eip712Types, typeType), - json_getChild(walkVals), // where to get the values - &valCtx // val hash happens in parse, this is the return - ))) { - return errRet; - } - keccak_Final(&valCtx, encBytes); } } } @@ -699,7 +827,9 @@ int parseVals(const json_t* eip712Types, const json_t* jType, tarray = json_getSibling(tarray); } if (ds_vals) { - dsConfirm(); + if (SUCCESS != (errRet = dsConfirm())) { + return errRet; + } } return SUCCESS; diff --git a/lib/firmware/ethereum.c b/lib/firmware/ethereum.c index b7c12c1b5..37cae37e3 100644 --- a/lib/firmware/ethereum.c +++ b/lib/firmware/ethereum.c @@ -33,6 +33,7 @@ #include "keepkey/firmware/eip712.h" #include "keepkey/firmware/ethereum_contracts.h" #include "keepkey/firmware/ethereum_contracts/makerdao.h" +#include "keepkey/firmware/signed_metadata.h" #include "keepkey/firmware/ethereum_tokens.h" #include "keepkey/firmware/storage.h" #include "keepkey/firmware/thorchain.h" @@ -48,6 +49,14 @@ #define _(X) (X) +bool ethereum_typed_hash_policy_allows(bool advanced_mode) { + return advanced_mode; +} + +bool ethereum_eip712_is_domain_primary_type(const char* primary_type) { + return primary_type && strcmp(primary_type, "EIP712Domain") == 0; +} + #define MAX_CHAIN_ID 2147483630 #define ETHEREUM_TX_TYPE_LEGACY 0UL @@ -204,6 +213,20 @@ static void hash_rlp_number(uint32_t number) { hash_rlp_field(data + offset, 4 - offset); } +/* Strip leading zero bytes before RLP-encoding an integer field. + * Per the Ethereum yellow paper, integer fields (nonce, gas, value, etc.) + * must not have leading zeros. Addresses are NOT integers and must not use + * this function. */ +static void hash_rlp_bytes_stripped(const uint8_t* buf, size_t size) { + size_t offset = 0; + while (offset < size && buf[offset] == 0) offset++; + if (offset == size) { + hash_rlp_field(buf, 0); + } else { + hash_rlp_field(buf + offset, size - offset); + } +} + /* * Calculate the number of bytes needed for an RLP length header. * NOTE: supports up to 16MB of data (how unlikely...) @@ -223,6 +246,21 @@ static int rlp_calculate_length(int length, uint8_t firstbyte) { } } +/* Length of an RLP-encoded integer field AFTER stripping leading zero bytes. + * MUST mirror hash_rlp_bytes_stripped(): the Stage-1 list-length header + * (hash_rlp_list_length) and the Stage-2 bytes actually hashed have to agree, + * or the keccak pre-image is malformed and the signature recovers to a garbage + * address (looks like a "random signer" / dropped tx). Any integer field whose + * big-endian form has a leading zero byte hits this. */ +static int rlp_calculate_length_stripped(const uint8_t* buf, size_t size) { + size_t offset = 0; + while (offset < size && buf[offset] == 0) offset++; + if (offset == size) { + return rlp_calculate_length(0, 0); + } + return rlp_calculate_length(size - offset, buf[offset]); +} + static int rlp_calculate_number_length(uint32_t number) { if (number <= 0x7f) { return 1; @@ -265,6 +303,19 @@ static void send_signature(void) { } keccak_Final(&keccak_ctx, hash); + + /* Insight clear-signing binding. If a verified metadata blob suppressed the + * raw-data confirmation, the actual signed digest MUST equal the tx hash the + * metadata committed to. This is the first point that digest exists, so the + * check reuses it rather than re-deriving the RLP pre-image. Fail closed — + * never emit a signature the displayed decoded screen did not cover. */ + if (!signed_metadata_enforce(hash)) { + fsm_sendFailure(FailureType_Failure_Other, + "Metadata does not match signed transaction"); + ethereum_signing_abort(); + return; + } + if (ecdsa_sign_digest(&secp256k1, privkey, hash, sig, &v, ethereum_is_canonic) != 0) { fsm_sendFailure(FailureType_Failure_Other, "Signing failed"); @@ -388,6 +439,9 @@ void ethereumFormatAmount(const bignum256* amnt, const TokenType* token, case 137: suffix = " MATIC"; break; // Polygon Mainnet + case 43114: + suffix = " AVAX"; + break; // Avalanche C-Chain } } } @@ -585,8 +639,13 @@ static bool ethereum_signing_check(const EthereumSignTx* msg) { return false; } - if (msg->gas_price.size + msg->gas_limit.size > 30) { - // sanity check that fee doesn't overflow + // Sanity-bound the fee field that this tx type actually uses, so the + // on-screen fee (fee_per_gas * gas_limit) cannot overflow into the modular + // bn_multiply and display a wrong value. EIP-1559 uses max_fee_per_gas; + // legacy uses gas_price (which is 0 for EIP-1559 and vice versa). + size_t fee_per_gas_size = msg->has_max_fee_per_gas ? msg->max_fee_per_gas.size + : msg->gas_price.size; + if (fee_per_gas_size + msg->gas_limit.size > 30) { return false; } @@ -648,17 +707,30 @@ void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, ethereum_tx_type = ETHEREUM_TX_TYPE_LEGACY; } - if (ethereum_tx_type == ETHEREUM_TX_TYPE_EIP_1559 && chain_id == 0) { - fsm_sendFailure(FailureType_Failure_SyntaxError, - _("EIP-1559 transactions require chain_id")); - ethereum_signing_abort(); - return; - } - - if (ethereum_tx_type == ETHEREUM_TX_TYPE_EIP_1559 && - !msg->has_max_fee_per_gas) { + /* The typed prefix (0x02) and access list are emitted based on + * ethereum_tx_type, while the fee fields are selected by has_max_fee_per_gas. + * If those two disagree, Stage 1 (rlp_length) and Stage 2 (hashed bytes) + * describe different field lists and the signature recovers to a wrong + * address. Enforce a consistent shape up front. */ + if (ethereum_tx_type == ETHEREUM_TX_TYPE_EIP_1559) { + if (chain_id == 0) { + /* chain_id is the mandatory first RLP field of an EIP-1559 tx; absent + * chain_id is counted (1 byte) in Stage 1 but hash_rlp_number(0) hashes + * nothing in Stage 2. */ + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("EIP-1559 transactions require chain_id")); + ethereum_signing_abort(); + return; + } + if (!msg->has_max_fee_per_gas) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("EIP-1559 transactions require max_fee_per_gas")); + ethereum_signing_abort(); + return; + } + } else if (msg->has_max_fee_per_gas) { fsm_sendFailure(FailureType_Failure_SyntaxError, - _("EIP-1559 transactions require max_fee_per_gas")); + _("max_fee_per_gas requires an EIP-1559 (type 2) tx")); ethereum_signing_abort(); return; } @@ -718,6 +790,40 @@ void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, data_needs_confirm = false; } + // Signed metadata clear signing (backwards compatible). + // Only fires if host sent EthereumTxMetadata before this EthereumSignTx. + if (data_needs_confirm && data_total > 0 && signed_metadata_available()) { + if (signed_metadata_matches_tx(msg)) { + if (signed_metadata_confirm()) { + if (signed_metadata_from_loaded_signer()) { + /* A self-service signer is annotation-only. Its decoded screens are + * followed by the same amount and raw-calldata review an Advanced + * transaction would have received without metadata. A lying runtime + * schema therefore cannot conceal transaction bytes. */ + needs_confirm = true; + data_needs_confirm = true; + } else { + /* A future firmware-pinned signer may replace the raw-data screen. + * Payable calls still show amount/recipient because a v2 schema + * describes calldata only and cannot bind msg->value. */ + needs_confirm = signed_metadata_schema_moves_value(); + data_needs_confirm = false; + } + } else { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "Signing cancelled by user"); + ethereum_signing_abort(); // clears metadata + return; + } + } + } + // Drop metadata now UNLESS we relied on it to suppress the raw-data confirm + // (then it must survive to bind the signature). Prevents stale reuse when the + // contractHandled / ERC-20 paths bypass the metadata check above. + if (!signed_metadata_relied()) { + signed_metadata_clear(); + } + // detect ERC-20 token if (data_total == 68 && ethereum_isStandardERC20Transfer(msg)) { token = tokenByChainAddress(chain_id, msg->to.bytes); @@ -763,15 +869,15 @@ void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, memset(confirm_body_message, 0, sizeof(confirm_body_message)); if (token == NULL && data_total > 0 && data_needs_confirm) { - // KeepKey custom: warn the user that they're trying to do something - // that is potentially dangerous. People (generally) aren't great at - // parsing raw transaction data, and we can't effectively show them - // what they're about to do in the general case. + // AdvancedMode policy: hard gate for blind-signing arbitrary contract data if (!storage_isPolicyEnabled("AdvancedMode")) { - (void)review( - ButtonRequestType_ButtonRequest_Other, "Warning", - "Signing of arbitrary ETH contract data is recommended only for " - "experienced users. Enable 'AdvancedMode' policy to dismiss."); + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "Blind signing requires AdvancedMode. " + "Enable in device settings."); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "Blind signing disabled by policy"); + ethereum_signing_abort(); + return; } layoutEthereumData(msg->data_initial_chunk.bytes, @@ -808,24 +914,24 @@ void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, rlp_length += rlp_calculate_number_length(chain_id); } - rlp_length += rlp_calculate_length(msg->nonce.size, msg->nonce.bytes[0]); - if (ethereum_tx_type == ETHEREUM_TX_TYPE_EIP_1559) { + rlp_length += + rlp_calculate_length_stripped(msg->nonce.bytes, msg->nonce.size); + if (msg->has_max_fee_per_gas) { rlp_length += - rlp_calculate_length(msg->max_priority_fee_per_gas.size, - msg->max_priority_fee_per_gas.size - ? msg->max_priority_fee_per_gas.bytes[0] - : 0); - rlp_length += rlp_calculate_length(msg->max_fee_per_gas.size, - msg->max_fee_per_gas.bytes[0]); + rlp_calculate_length_stripped(msg->max_priority_fee_per_gas.bytes, + msg->max_priority_fee_per_gas.size); + rlp_length += rlp_calculate_length_stripped(msg->max_fee_per_gas.bytes, + msg->max_fee_per_gas.size); } else { - rlp_length += - rlp_calculate_length(msg->gas_price.size, msg->gas_price.bytes[0]); + rlp_length += rlp_calculate_length_stripped(msg->gas_price.bytes, + msg->gas_price.size); } rlp_length += - rlp_calculate_length(msg->gas_limit.size, msg->gas_limit.bytes[0]); + rlp_calculate_length_stripped(msg->gas_limit.bytes, msg->gas_limit.size); rlp_length += rlp_calculate_length(msg->to.size, msg->to.bytes[0]); - rlp_length += rlp_calculate_length(msg->value.size, msg->value.bytes[0]); + rlp_length += + rlp_calculate_length_stripped(msg->value.bytes, msg->value.size); rlp_length += rlp_calculate_length(data_total, msg->data_initial_chunk.bytes[0]); @@ -872,19 +978,26 @@ void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, hash_rlp_number(chain_id); } - hash_rlp_field(msg->nonce.bytes, msg->nonce.size); + hash_rlp_bytes_stripped(msg->nonce.bytes, msg->nonce.size); - if (ethereum_tx_type == ETHEREUM_TX_TYPE_EIP_1559) { - hash_rlp_field(msg->max_priority_fee_per_gas.bytes, - msg->max_priority_fee_per_gas.size); - hash_rlp_field(msg->max_fee_per_gas.bytes, msg->max_fee_per_gas.size); + if (msg->has_max_fee_per_gas) { + /* max_priority_fee_per_gas is a mandatory EIP-1559 field; when absent it + * encodes as the empty integer (0x80). Stage 1 always counts it + * (unconditionally, above), so Stage 2 must always hash it too -- guarding + * on has_max_priority_fee_per_gas here would under-hash and leave the list + * header over-declared (the same wrong-signer class this commit fixes). + * .size is 0 when unset, which hash_rlp_bytes_stripped emits as 0x80. */ + hash_rlp_bytes_stripped(msg->max_priority_fee_per_gas.bytes, + msg->max_priority_fee_per_gas.size); + hash_rlp_bytes_stripped(msg->max_fee_per_gas.bytes, + msg->max_fee_per_gas.size); } else { - hash_rlp_field(msg->gas_price.bytes, msg->gas_price.size); + hash_rlp_bytes_stripped(msg->gas_price.bytes, msg->gas_price.size); } - hash_rlp_field(msg->gas_limit.bytes, msg->gas_limit.size); - hash_rlp_field(msg->to.bytes, msg->to.size); - hash_rlp_field(msg->value.bytes, msg->value.size); + hash_rlp_bytes_stripped(msg->gas_limit.bytes, msg->gas_limit.size); + hash_rlp_field(msg->to.bytes, msg->to.size); /* address: no strip */ + hash_rlp_bytes_stripped(msg->value.bytes, msg->value.size); hash_rlp_length(data_total, msg->data_initial_chunk.bytes[0]); hash_data(msg->data_initial_chunk.bytes, msg->data_initial_chunk.size); data_left = data_total - msg->data_initial_chunk.size; @@ -932,11 +1045,14 @@ void ethereum_signing_txack(EthereumTxAck* tx) { void ethereum_signing_abort(void) { if (ethereum_signing) { memzero(privkey, sizeof(privkey)); + signed_metadata_clear(); layoutHome(); ethereum_signing = false; } } +bool ethereum_signing_isInProgress(void) { return ethereum_signing; } + static void ethereum_message_hash(const uint8_t* message, size_t message_len, uint8_t hash[32]) { struct SHA3_CTX ctx; @@ -1089,6 +1205,19 @@ void ethereum_typed_hash_sign(const EthereumSignTypedHash* msg, resp->signature.bytes[64] = 27 + v; resp->signature.size = 65; + /* Populate response-only fields after every confirmation. Emulator debug + * requests (including screenshot capture) share msg_resp and can clear data + * prepared before the confirmation callbacks complete. */ + uint8_t pubkeyhash[20] = {0}; + if (!hdnode_get_ethereum_pubkeyhash(node, pubkeyhash)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Ethereum address derivation failed")); + return; + } + resp->address[0] = '0'; + resp->address[1] = 'x'; + ethereum_address_checksum(pubkeyhash, resp->address + 2, false, 0); + msg_write(MessageType_MessageType_EthereumTypedDataSignature, resp); } @@ -1126,9 +1255,14 @@ const char* failMsgReturn[LAST_ERROR - 2] = { "EIP-712 typeType has no name in parseVals", "EIP-712 address string is NULL", "EIP-712 no value for type during walkVals", // 33 + "EIP-712 cancelled", // 34 (USER_CANCELLED) }; void failMessage(int err) { + if (err == USER_CANCELLED) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + return; + } if (err < GENERAL_ERROR || err > LAST_ERROR) { // unknown error number fsm_sendFailure(FailureType_Failure_Other, _("EIP-712 unknown failure")); @@ -1205,14 +1339,23 @@ void e712_types_values(Ethereum712TypesValues* msg, failMessage(JSON_PTYPENAMEERR); return; } + if (json_getType(obTest) != JSON_TEXT) { + failMessage(JSON_PTYPEVALERR); + return; + } const char* primeType; - if (0 == (primeType = json_getValue(obTest))) { + if (0 == (primeType = json_getValue(obTest)) || primeType[0] == '\0') { failMessage(JSON_PTYPEVALERR); return; } - if (0 != strncmp(primeType, "EIP712Domain", - strlen(primeType))) { // if primaryType is "EIP712Domain", - // message hash is NULL + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "EIP-712 Primary Type", (const uint8_t*)primeType, + strlen(primeType))) { + failMessage(USER_CANCELLED); + return; + } + if (!ethereum_eip712_is_domain_primary_type( + primeType)) { // domain-only signatures have no message hash errRet = encode(jsonT, jsonV, primeType, resp->message_hash.bytes); if (!(SUCCESS == errRet || NULL_MSG_HASH == errRet)) { failMessage(errRet); @@ -1251,5 +1394,17 @@ void e712_types_values(Ethereum712TypesValues* msg, have_ds = false; } + /* Debug-link reads during confirmation reuse msg_resp, so populate the + * returned address only after the final confirmation has completed. */ + uint8_t pubkeyhash[20] = {0}; + if (!hdnode_get_ethereum_pubkeyhash(node, pubkeyhash)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Ethereum address derivation failed")); + return; + } + resp->address[0] = '0'; + resp->address[1] = 'x'; + ethereum_address_checksum(pubkeyhash, resp->address + 2, false, 0); + msg_write(MessageType_MessageType_EthereumTypedDataSignature, resp); } diff --git a/lib/firmware/ethereum_contracts.c b/lib/firmware/ethereum_contracts.c index 87fb61488..deec3b4eb 100644 --- a/lib/firmware/ethereum_contracts.c +++ b/lib/firmware/ethereum_contracts.c @@ -20,6 +20,7 @@ #include "keepkey/firmware/ethereum_contracts.h" +#include "keepkey/firmware/ethereum.h" // completes EthereumSignTx (msg fields) #include "keepkey/firmware/ethereum_contracts/saproxy.h" #include "keepkey/firmware/ethereum_contracts/thortx.h" #include "keepkey/firmware/ethereum_contracts/zxappliquid.h" @@ -32,12 +33,32 @@ bool ethereum_contractHandled(uint32_t data_total, const EthereumSignTx* msg, const HDNode* node) { (void)node; - if (sa_isWithdrawFromSalary(msg)) return true; + /* Only a CALL to a contract may be clear-signed, never a CREATE + * (to.size == 0 must reach the deploy screen). */ + if (msg->to.size != 20) { + return false; + } + + /* 0x transformERC20 is pinned to the ExchangeProxy and bounded by its + * displayed input/min-output amounts, so it is safe to clear-sign at ANY + * calldata size; its transformations[] tail legitimately exceeds one 1024- + * byte chunk. (It guards its own fixed-offset reads against + * data_initial_chunk.size.) */ if (zx_isZxTransformERC20(msg)) return true; + + /* Every other handler must have the ENTIRE calldata in the first chunk, so + * the fields it parses and displays are the whole transaction and nothing + * unshown streams in afterwards. */ + if (data_total != msg->data_initial_chunk.size) { + return false; + } + + if (sa_isWithdrawFromSalary(msg)) return true; if (zx_isZxSwap(msg)) return true; if (zx_isZxLiquidTx(msg)) return true; if (zx_isZxApproveLiquid(msg)) return true; + if (thor_isMayachainTx(msg)) return true; if (thor_isThorchainTx(msg)) return true; if (makerdao_isMakerDAO(data_total, msg)) return true; @@ -62,6 +83,7 @@ bool ethereum_contractConfirmed(uint32_t data_total, const EthereumSignTx* msg, if (zx_isZxApproveLiquid(msg)) return zx_confirmApproveLiquidity(data_total, msg); + if (thor_isMayachainTx(msg)) return thor_confirmMayaTx(data_total, msg); if (thor_isThorchainTx(msg)) return thor_confirmThorTx(data_total, msg); if (makerdao_isMakerDAO(data_total, msg)) diff --git a/lib/firmware/ethereum_contracts/saproxy.c b/lib/firmware/ethereum_contracts/saproxy.c index ccab30ba6..c7514995f 100644 --- a/lib/firmware/ethereum_contracts/saproxy.c +++ b/lib/firmware/ethereum_contracts/saproxy.c @@ -45,7 +45,8 @@ bool sa_isWithdrawFromSalary(const EthereumSignTx* msg) { bool sa_confirmWithdrawFromSalary(uint32_t data_total, const EthereumSignTx* msg) { - (void)data_total; + /* reads selector + 2 32-byte words (salaryId, withdrawAmount) */ + if (data_total < 4 + 2 * 32) return false; char confStr[41]; bignum256 salaryId, withdrawAmount; diff --git a/lib/firmware/ethereum_contracts/thortx.c b/lib/firmware/ethereum_contracts/thortx.c index 6f48d1ab5..d86e98e52 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -41,28 +41,123 @@ bool thor_is_expiry_variant(const EthereumSignTx* msg) { THOR_SELECTOR_DEPOSIT_WITH_EXPIRY, 4) == 0; } -bool thor_isThorchainTx(const EthereumSignTx* msg) { - if (msg->has_to && msg->to.size == 20 && thor_has_deposit_selector(msg)) { - return true; +/* Format msg->to as lowercase hex string (40 chars + NUL) */ +static void thor_format_to_addr(const EthereumSignTx* msg, char out[41]) { + for (uint32_t i = 0; i < 20; i++) { + snprintf(&out[i * 2], 3, "%02x", msg->to.bytes[i]); } - return false; + out[40] = '\0'; } -bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { +bool thor_isMayachainTx(const EthereumSignTx* msg) { + if (!msg->has_to || msg->to.size != 20) return false; + /* MAYA_ROUTER is an Ethereum-mainnet identity; the same address on another + * EVM chain may hold unrelated attacker code. Bind to mainnet so a + * host-selected chain_id cannot borrow the trusted router UX. */ + if (!msg->has_chain_id || msg->chain_id != 1) return false; + if (!thor_has_deposit_selector(msg)) return false; + char toStr[41]; + thor_format_to_addr(msg, toStr); + return strncmp(toStr, MAYA_ROUTER, 40) == 0; +} + +/* The THORChain router address for this tx's chain, or NULL if the chain has + * no pinned router (then the deposit is not clear-signed and falls to the + * blind-sign gate). Each router address is a per-chain identity — the same + * address on another chain may hold unrelated attacker code — so the pin is + * (chain_id, address) together. A tx with NO chain_id gets no router at all: + * ethereum.c would default it to mainnet for hashing, but an identity pin + * must never be inherited from a default the host simply omitted. */ +static const char* thor_router_for_chain(const EthereumSignTx* msg) { + if (!msg->has_chain_id) return NULL; + switch (msg->chain_id) { + case 1: + return THOR_ROUTER; /* Ethereum */ + case 43114: + return THOR_ROUTER_AVAX; /* Avalanche C-Chain */ + default: + return NULL; + } +} + +bool thor_isThorchainTx(const EthereumSignTx* msg) { + if (!msg->has_to || msg->to.size != 20) return false; + if (!thor_has_deposit_selector(msg)) return false; + /* Pin to the THORChain router FOR THIS CHAIN. Without the pin, ANY contract + * carrying the deposit selector would get the THORChain clear-sign UX and + * bypass the AdvancedMode blind-sign gate, letting an attacker contract + * drain while the device shows a benign deposit. Without the chain scope, + * only mainnet deposits ever match (the AVAX->ETH blind-sign bug). */ + const char* router = thor_router_for_chain(msg); + if (!router) return false; + char toStr[41]; + thor_format_to_addr(msg, toStr); + return strncmp(toStr, router, 40) == 0; +} + +static bool thor_confirm_deposit_tx(uint32_t data_total, + const EthereumSignTx* msg, + const char* protocol_label, + const char* router_label) { (void)data_total; - /* Minimum calldata: selector(4) + vault(32) + asset(32) + amount(32) + - * memo_offset(32) + memo_length(32) = 164 bytes for deposit(), - * + expiry(32) = 196 bytes for depositWithExpiry(). */ + /* Minimum calldata to read the fixed head through the memo_length word: + * selector(4) + vault(32) + asset(32) + amount(32) + memo_offset(32) + + * memo_length(32) = 164 bytes for deposit(), + expiry(32) = 196 for + * depositWithExpiry(). The exact memo bounds are enforced below from the ABI + * memo length, so a short memo (e.g. "ADD:ETH.ETH") still clear-signs rather + * than being rejected by an over-tight fixed floor. */ const bool is_expiry = thor_is_expiry_variant(msg); - const size_t min_chunk = is_expiry ? 260 : 228; + const size_t min_chunk = is_expiry ? 196 : 164; if (msg->data_initial_chunk.size < min_chunk) return false; - char confStr[41], *conf; - const TokenType* assetToken; + /* The memo is a dynamic `string`; its ABI head pointer (word 3, offset + * 4+3*32) must be canonical (0x80 for deposit's 4 head words, 0xa0 for + * depositWithExpiry's 5), else abi.decode on the router reads the memo from a + * different location than we display from the fixed offset below -> the + * executed swap destination can differ from what the user approved. */ + { + static const uint8_t MEMO_OFF_DEPOSIT[32] = {[31] = 0x80}; + static const uint8_t MEMO_OFF_EXPIRY[32] = {[31] = 0xa0}; + const uint8_t* expected = is_expiry ? MEMO_OFF_EXPIRY : MEMO_OFF_DEPOSIT; + if (memcmp(msg->data_initial_chunk.bytes + 4 + 3 * 32, expected, 32) != 0) { + return false; + } + } + + /* The memo is a dynamic `string`: read its ABI length word instead of + * assuming a fixed 64 bytes. A longer memo places router-executed fields + * (destination, affiliate, aggregator, min-out) past byte 64 that a fixed + * parse never displays. Reject dirty high bytes, cap at THORChain's 256-byte + * memo max, require the whole calldata to be in this chunk, and require the + * padded memo to end exactly at the calldata end so no trailing bytes hide. + */ + const uint8_t* memo_len_word = + msg->data_initial_chunk.bytes + 4 + (is_expiry ? 5 : 4) * 32; + for (int i = 0; i < 28; i++) { + if (memo_len_word[i] != 0) return false; + } + const uint32_t memo_len = ((uint32_t)memo_len_word[28] << 24) | + ((uint32_t)memo_len_word[29] << 16) | + ((uint32_t)memo_len_word[30] << 8) | + (uint32_t)memo_len_word[31]; + if (memo_len > 256) return false; + const size_t memo_off = (size_t)(4 + (is_expiry ? 6 : 5) * 32); + const size_t memo_padded = ((memo_len + 31u) / 32u) * 32u; + if (msg->has_data_length && + msg->data_length != msg->data_initial_chunk.size) { + return false; /* whole calldata must be in the initial chunk to bound it */ + } + if (memo_off + memo_padded != msg->data_initial_chunk.size) { + return false; /* trailing bytes after the memo would be executed but hidden + */ + } + + char confStr[41]; + const char* conf; uint8_t* thorchainData; const uint8_t* contractAssetAddress; - const uint8_t *vaultAddress, *assetAddress; + const uint8_t* vaultAddress; uint32_t ctr; bignum256 Amount; @@ -75,15 +170,16 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { (uint8_t*)(msg->data_initial_chunk.bytes + 4 + (is_expiry ? 6 : 5) * 32); // Start confirmations - for (ctr = 0; ctr < 20; ctr++) { - snprintf(&confStr[ctr * 2], 3, "%02x", msg->to.bytes[ctr]); - } - if (strncmp(confStr, THOR_ROUTER, sizeof(THOR_ROUTER)) == 0) { + thor_format_to_addr(msg, confStr); + const char* thor_router = thor_router_for_chain(msg); + if (thor_router && strncmp(confStr, thor_router, 40) == 0) { conf = "Thorchain router"; + } else if (strncmp(confStr, MAYA_ROUTER, 40) == 0) { + conf = router_label; } else { conf = confStr; } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data", + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, "Routing through %s", conf)) { return false; } @@ -92,50 +188,92 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { for (ctr = 0; ctr < 20; ctr++) { snprintf(&confStr[ctr * 2], 3, "%02x", vaultAddress[ctr]); } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data", + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, "Using Asgard vault %s", confStr)) { return false; } - if (memcmp(contractAssetAddress, ETH_ADDRESS, sizeof(ETH_ADDRESS)) == 0) { - assetAddress = (const uint8_t*) - ETH_NATIVE; // get eth native parameters if asset is not a token - } else { - assetAddress = contractAssetAddress; - } - - assetToken = tokenByChainAddress(msg->chain_id, assetAddress); + /* Both pinned routers treat ONLY address(0) as native (and require + * msg.value == 0 for any other asset), so the 0xEeee..Ee sentinel is NOT + * native here — accepting it would clear-sign a tx that reverts on-chain and + * burns gas. Match address(0) exactly (20 bytes, not sizeof, whose literal + * NUL would over-read into the amount word). */ + const bool is_native = memcmp(contractAssetAddress, ETH_ADDRESS, 20) == 0; + bignum256 Value; + bn_from_bytes(msg->value.bytes, msg->value.size, &Value); + if (is_native) { + /* Display msg.value — the amount the router actually forwards — not the ABI + * amount word it ignores. That alone closes the "display 0.01 while sending + * 100" gap; we do NOT additionally require amount == value, since the ABI + * amount is a router-ignored hint that legitimately differs. Format with a + * NULL token so the ticker is the CHAIN's native asset (ETH on mainnet, + * AVAX on Avalanche); the 0xEE pseudo-token entry is pinned to " ETH" and + * would mislabel every other chain's native deposit. */ + ethereumFormatAmount(&Value, NULL, msg->chain_id, confStr, sizeof(confStr)); - if (strncmp(assetToken->ticker, " UNKN", 5) == 0) { - // just display token address and amount as string - for (ctr = 0; ctr < 20; ctr++) { - snprintf(&confStr[ctr * 2], 3, "%02x", assetAddress[ctr]); - } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "from asset %s", confStr)) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, + "Confirm sending %s", confStr)) { return false; } - // We don't know what the exponent should be so just confirm raw unformatted - // number - bn_format(&Amount, NULL, " unformatted", 0, 0, false, confStr, - sizeof(confStr)); - - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "amount %s", confStr)) { + } else { + /* A token deposit must not also carry native value (the router pulls tokens + * via transferFrom); nonzero msg.value would be swept and never shown. */ + if (!bn_is_zero(&Value)) { return false; } + const uint8_t* assetAddress = contractAssetAddress; - } else { - ethereumFormatAmount(&Amount, assetToken, msg->chain_id, confStr, - sizeof(confStr)); + const TokenType* assetToken = + tokenByChainAddress(msg->chain_id, assetAddress); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "Confirm sending %s", confStr)) { - return false; + if (strncmp(assetToken->ticker, " UNKN", 5) == 0) { + // just display token address and amount as string + for (ctr = 0; ctr < 20; ctr++) { + snprintf(&confStr[ctr * 2], 3, "%02x", assetAddress[ctr]); + } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + protocol_label, "from asset %s", confStr)) { + return false; + } + // We don't know what the exponent should be so just confirm raw + // unformatted number + bn_format(&Amount, NULL, " unformatted", 0, 0, false, confStr, + sizeof(confStr)); + + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + protocol_label, "amount %s", confStr)) { + return false; + } + + } else { + ethereumFormatAmount(&Amount, assetToken, msg->chain_id, confStr, + sizeof(confStr)); + + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + protocol_label, "Confirm sending %s", confStr)) { + return false; + } } } - if (!thorchain_parseConfirmMemo((const char*)thorchainData, 64)) return false; + if (!thorchain_parseConfirmMemo((const char*)thorchainData, memo_len)) + return false; + + /* Page the complete raw memo as the authoritative disclosure: a long + * structured field (dest/affiliate/aggregator) would otherwise truncate in + * its single confirm and hide the tail that the router still executes. */ + if (!thorchain_confirm_full_memo("Memo", (const char*)thorchainData, + memo_len)) + return false; return true; } + +bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { + return thor_confirm_deposit_tx(data_total, msg, "Thorchain data", + "Thorchain router"); +} + +bool thor_confirmMayaTx(uint32_t data_total, const EthereumSignTx* msg) { + return thor_confirm_deposit_tx(data_total, msg, "Maya data", "Maya router"); +} diff --git a/lib/firmware/ethereum_contracts/zxappliquid.c b/lib/firmware/ethereum_contracts/zxappliquid.c index 76d7d3cda..841fd5fe2 100644 --- a/lib/firmware/ethereum_contracts/zxappliquid.c +++ b/lib/firmware/ethereum_contracts/zxappliquid.c @@ -7,108 +7,138 @@ * 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 . */ #include "keepkey/firmware/ethereum_contracts/zxappliquid.h" #include "keepkey/firmware/ethereum_contracts/zxliquidtx.h" #include "keepkey/board/confirm_sm.h" +#include "keepkey/board/font.h" +#include "keepkey/board/layout.h" #include "keepkey/board/util.h" -#include "keepkey/firmware/app_confirm.h" -#include "keepkey/firmware/coins.h" #include "keepkey/firmware/ethereum.h" #include "keepkey/firmware/ethereum_tokens.h" -#include "keepkey/firmware/fsm.h" -#include "keepkey/firmware/storage.h" -#include "trezor/crypto/address.h" -#include "trezor/crypto/bip32.h" -#include "trezor/crypto/curves.h" -#include "trezor/crypto/memzero.h" +#include "trezor/crypto/bignum.h" #include "trezor/crypto/sha3.h" -bool zx_confirmApproveLiquidity(uint32_t data_total, - const EthereumSignTx *msg) { - (void)data_total; - const char *to, *tikstr, *poolstr, *allowance, *amt; - unsigned char data[40]; - uint8_t digest[SHA3_256_DIGEST_LENGTH] = {0}; - uint8_t tokdigest[SHA3_256_DIGEST_LENGTH] = {0}; - char digestStr[2 * SHA3_256_DIGEST_LENGTH + 1], amtStr[2 * 32 + 1] = {0}; - int32_t ctr, tokctr; - uint32_t wethord; - const TokenType *WETH, *ttoken; - - if (!tokenByTicker(msg->chain_id, "WETH", &WETH)) return false; - wethord = read_be((const uint8_t *)WETH->address); - to = (const char *)msg->to.bytes; - tokctr = 0; - while (tokctr != -1) { - ttoken = tokenIter(&tokctr); - - // https://uniswap.org/docs/v2/smart-contract-integration/getting-pair-addresses/ - uint32_t ttokenord = read_be((const uint8_t *)ttoken->address); - if (ttokenord < wethord) { - memcpy(data, ttoken->address, 20); - memcpy(&data[20], WETH->address, 20); - } else { - memcpy(data, WETH->address, 20); - memcpy(&data[20], ttoken->address, 20); - } - keccak_256(data, sizeof(data), tokdigest); - SHA3_CTX ctx = {0}; - keccak_256_Init(&ctx); - keccak_Update(&ctx, (unsigned char *)"\xff", 1); - keccak_Update(&ctx, (unsigned char *)"\x5C\x69\xbE\xe7\x01\xef\x81\x4a\x2B\x6a\x3E\xDD\x4B\x16\x52\xCB\x9c\xc5\xaA\x6f", 20); - keccak_Update(&ctx, tokdigest, sizeof(tokdigest)); - keccak_Update(&ctx, (unsigned char *)"\x96\xe8\xac\x42\x77\x19\x8f\xf8\xb6\xf7\x85\x47\x8a\xa9\xa3\x9f\x40\x3c\xb7\x68\xdd\x02\xcb\xee\x32\x6c\x3e\x7d\xa3\x48\x84\x5f", 32); - keccak_Final(&ctx, digest); - if (memcmp(to, &digest[12], 20) == 0) break; +#include +#include + +#define UNISWAP_APPROVE_CALL_SIZE (4 + 2 * 32) +#define UNISWAP_AMOUNT_TEXT_SIZE 96 + +static const uint8_t UNISWAP_FACTORY_ADDRESS[20] = { + 0x5c, 0x69, 0xbe, 0xe7, 0x01, 0xef, 0x81, 0x4a, 0x2b, 0x6a, + 0x3e, 0xdd, 0x4b, 0x16, 0x52, 0xcb, 0x9c, 0xc5, 0xaa, 0x6f}; +static const uint8_t UNISWAP_PAIR_INIT_CODE_HASH[32] = { + 0x96, 0xe8, 0xac, 0x42, 0x77, 0x19, 0x8f, 0xf8, 0xb6, 0xf7, 0x85, + 0x47, 0x8a, 0xa9, 0xa3, 0x9f, 0x40, 0x3c, 0xb7, 0x68, 0xdd, 0x02, + 0xcb, 0xee, 0x32, 0x6c, 0x3e, 0x7d, 0xa3, 0x48, 0x84, 0x5f}; +static const uint8_t WETH_MAINNET_ADDRESS[20] = { + 0xc0, 0x2a, 0xaa, 0x39, 0xb2, 0x23, 0xfe, 0x8d, 0x0a, 0x0e, + 0x5c, 0x4f, 0x27, 0xea, 0xd9, 0x08, 0x3c, 0x75, 0x6c, 0xc2}; + +static bool tx_value_is_zero(const EthereumSignTx* msg) { + if (!msg->has_value && msg->value.size != 0) return false; + for (size_t i = 0; i < msg->value.size; i++) { + if (msg->value.bytes[i] != 0) return false; } + return true; +} + +static bool spender_word_is_router(const EthereumSignTx* msg) { + const uint8_t* word = msg->data_initial_chunk.bytes + 4; + for (size_t i = 0; i < 12; i++) { + if (word[i] != 0) return false; + } + return memcmp(word + 12, UNISWAP_ROUTER_ADDRESS, 20) == 0; +} - if (tokctr != -1) { - for (ctr = 0; ctr < SHA3_256_DIGEST_LENGTH; ctr++) { - snprintf(&digestStr[ctr * 2], 3, "%02x", digest[ctr]); - } - tikstr = ttoken->ticker; - poolstr = &digestStr[12 * 2]; +static void derive_pair_address(const uint8_t* token_a, const uint8_t* token_b, + uint8_t pair[20]) { + uint8_t ordered[40]; + if (memcmp(token_a, token_b, 20) < 0) { + memcpy(ordered, token_a, 20); + memcpy(ordered + 20, token_b, 20); } else { - for (ctr = 0; ctr < 20; ctr++) { - snprintf(&digestStr[ctr * 2], 3, "%02x", to[ctr]); - } - tikstr = ""; - poolstr = digestStr; + memcpy(ordered, token_b, 20); + memcpy(ordered + 20, token_a, 20); } - allowance = (char *)(msg->data_initial_chunk.bytes + 4 + 32); - if (memcmp(allowance, (uint8_t *)&MAX_ALLOWANCE, 32) == 0) { - amt = "full balance"; + uint8_t salt[SHA3_256_DIGEST_LENGTH]; + uint8_t digest[SHA3_256_DIGEST_LENGTH]; + keccak_256(ordered, sizeof(ordered), salt); + SHA3_CTX ctx = {0}; + keccak_256_Init(&ctx); + const uint8_t prefix = 0xff; + keccak_Update(&ctx, &prefix, 1); + keccak_Update(&ctx, UNISWAP_FACTORY_ADDRESS, sizeof(UNISWAP_FACTORY_ADDRESS)); + keccak_Update(&ctx, salt, sizeof(salt)); + keccak_Update(&ctx, UNISWAP_PAIR_INIT_CODE_HASH, + sizeof(UNISWAP_PAIR_INIT_CODE_HASH)); + keccak_Final(&ctx, digest); + memcpy(pair, digest + 12, 20); +} + +static const TokenType* pool_underlying_token(const EthereumSignTx* msg) { + int32_t token_index = 0; + while (token_index >= 0) { + const TokenType* token = tokenIter(&token_index); + if (token == UnknownToken) break; + if (token->chain_id != 1 || + memcmp(token->address, WETH_MAINNET_ADDRESS, 20) == 0) + continue; + uint8_t pair[20]; + derive_pair_address((const uint8_t*)token->address, WETH_MAINNET_ADDRESS, + pair); + if (memcmp(msg->to.bytes, pair, 20) == 0) return token; + } + return NULL; +} + +static bool approve_shape_is_clear_signable(const EthereumSignTx* msg) { + if (!msg->has_chain_id || msg->chain_id != 1 || !msg->has_to || + msg->to.size != 20 || !msg->has_data_initial_chunk || + msg->data_initial_chunk.size != UNISWAP_APPROVE_CALL_SIZE || + memcmp(msg->data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4) != 0 || + msg->value.size > 32 || !tx_value_is_zero(msg) || + !spender_word_is_router(msg)) + return false; + return pool_underlying_token(msg) != NULL; +} + +bool zx_confirmApproveLiquidity(uint32_t data_total, + const EthereumSignTx* msg) { + if (data_total != UNISWAP_APPROVE_CALL_SIZE || + !approve_shape_is_clear_signable(msg)) + return false; + + const TokenType* token = pool_underlying_token(msg); + const uint8_t* allowance = msg->data_initial_chunk.bytes + 4 + 32; + char amount_text[UNISWAP_AMOUNT_TEXT_SIZE]; + if (memcmp(allowance, (const uint8_t*)MAX_ALLOWANCE, 32) == 0) { + strlcpy(amount_text, "full LP balance", sizeof(amount_text)); } else { - for (ctr = 0; ctr < 32; ctr++) { - snprintf(&amtStr[ctr * 2], 3, "%02x", allowance[ctr]); - } - amt = amtStr; + bignum256 amount; + bn_from_bytes(allowance, 32, &amount); + if (bn_format(&amount, NULL, " LP", 18, 0, false, amount_text, + sizeof(amount_text)) == 0 || + calc_str_line(get_body_font(), amount_text, BODY_WIDTH) > BODY_ROWS) + return false; } - const char *appStr = "uniswap approve liquidity"; - confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, "Amount: %s", - amt); - confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, - "approve for pool %s %s", tikstr, poolstr); - return true; + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Uniswap LP Approval", "%s", amount_text)) + return false; + + char pair_text[43] = {'0', 'x', '\0'}; + for (size_t i = 0; i < 20; i++) { + snprintf(pair_text + 2 + i * 2, 3, "%02x", msg->to.bytes[i]); + } + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Uniswap LP Pool", "%s\n%s", token->ticker, pair_text); } -bool zx_isZxApproveLiquid(const EthereumSignTx *msg) { - if (memcmp(msg->data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4) == 0) - if (memcmp((uint8_t *)(msg->data_initial_chunk.bytes + 4 + 32 - 20), - UNISWAP_ROUTER_ADDRESS, 20) == 0) - return true; - return false; +bool zx_isZxApproveLiquid(const EthereumSignTx* msg) { + return approve_shape_is_clear_signable(msg); } diff --git a/lib/firmware/ethereum_contracts/zxliquidtx.c b/lib/firmware/ethereum_contracts/zxliquidtx.c index e70ccd4e3..568080722 100644 --- a/lib/firmware/ethereum_contracts/zxliquidtx.c +++ b/lib/firmware/ethereum_contracts/zxliquidtx.c @@ -20,164 +20,200 @@ #include "keepkey/firmware/ethereum_contracts/zxliquidtx.h" #include "keepkey/board/confirm_sm.h" -#include "keepkey/board/util.h" -#include "keepkey/firmware/app_confirm.h" -#include "keepkey/firmware/coins.h" +#include "keepkey/board/font.h" +#include "keepkey/board/layout.h" #include "keepkey/firmware/ethereum.h" #include "keepkey/firmware/ethereum_tokens.h" -#include "keepkey/firmware/fsm.h" #include "keepkey/firmware/storage.h" #include "trezor/crypto/address.h" +#include "trezor/crypto/bignum.h" #include "trezor/crypto/bip32.h" #include "trezor/crypto/curves.h" #include "trezor/crypto/memzero.h" -#include "trezor/crypto/sha3.h" -#include +#include +#include -static HDNode* zx_getDerivedNode(const char* curve, const uint32_t* address_n, - size_t address_n_count, - uint32_t* fingerprint) { - static HDNode CONFIDENTIAL node; - if (fingerprint) { - *fingerprint = 0; - } +#define UNISWAP_LIQUIDITY_CALL_SIZE (4 + 6 * 32) +#define UNISWAP_TOKEN_WORD 0 +#define UNISWAP_PRIMARY_AMOUNT_WORD 1 +#define UNISWAP_TOKEN_MIN_WORD 2 +#define UNISWAP_NATIVE_MIN_WORD 3 +#define UNISWAP_RECIPIENT_WORD 4 +#define UNISWAP_DEADLINE_WORD 5 +#define UNISWAP_AMOUNT_TEXT_SIZE 96 - if (!get_curve_by_name(curve)) { - return 0; - } +static const uint8_t* abi_word(const EthereumSignTx* msg, size_t word) { + return msg->data_initial_chunk.bytes + 4 + word * 32; +} - if (!storage_getRootNode(curve, true, &node)) { - return 0; +static bool abi_address_is_canonical(const uint8_t* word) { + for (size_t i = 0; i < 12; i++) { + if (word[i] != 0) return false; } + return true; +} - if (!address_n || address_n_count == 0) { - return &node; +static bool uint256_fits_u64(const uint8_t* word) { + for (size_t i = 0; i < 24; i++) { + if (word[i] != 0) return false; } + return true; +} - if (hdnode_private_ckd_cached(&node, address_n, address_n_count, - fingerprint) == 0) { - return 0; +static bool tx_value_is_zero(const EthereumSignTx* msg) { + if (!msg->has_value && msg->value.size != 0) return false; + for (size_t i = 0; i < msg->value.size; i++) { + if (msg->value.bytes[i] != 0) return false; } - - return &node; + return true; } static bool isAddLiquidityEthCall(const EthereumSignTx* msg) { - if (memcmp(msg->data_initial_chunk.bytes, "\xf3\x05\xd7\x19", 4) == 0) - return true; - - return false; + return memcmp(msg->data_initial_chunk.bytes, "\xf3\x05\xd7\x19", 4) == 0; } static bool isRemoveLiquidityEthCall(const EthereumSignTx* msg) { - if (memcmp(msg->data_initial_chunk.bytes, "\x02\x75\x1c\xec", 4) == 0) - return true; + return memcmp(msg->data_initial_chunk.bytes, "\x02\x75\x1c\xec", 4) == 0; +} - return false; +static const TokenType* liquidity_token(const EthereumSignTx* msg) { + const uint8_t* token_address = abi_word(msg, UNISWAP_TOKEN_WORD) + 12; + const TokenType* token = tokenByChainAddress(1, token_address); + return token == UnknownToken ? NULL : token; } -static bool confirmFromAccountMatch(const EthereumSignTx* msg, - const char* addremStr) { - // Determine withdrawal address - char addressStr[43] = {'0', 'x', '\0'}; - const char* fromSrc; - const uint8_t* fromAddress; - uint8_t addressBytes[20]; +static bool liquidity_shape_is_clear_signable(const EthereumSignTx* msg) { + if (!msg->has_chain_id || msg->chain_id != 1 || !msg->has_to || + msg->to.size != 20 || + memcmp(msg->to.bytes, UNISWAP_ROUTER_ADDRESS, 20) != 0 || + !msg->has_data_initial_chunk || + msg->data_initial_chunk.size != UNISWAP_LIQUIDITY_CALL_SIZE || + msg->value.size > 32 || (!msg->has_value && msg->value.size != 0)) + return false; - HDNode* node = zx_getDerivedNode(SECP256K1_NAME, msg->address_n, - msg->address_n_count, NULL); - if (!node) return false; + if (!isAddLiquidityEthCall(msg) && !isRemoveLiquidityEthCall(msg)) + return false; + if (!abi_address_is_canonical(abi_word(msg, UNISWAP_TOKEN_WORD)) || + !abi_address_is_canonical(abi_word(msg, UNISWAP_RECIPIENT_WORD)) || + !uint256_fits_u64(abi_word(msg, UNISWAP_DEADLINE_WORD))) + return false; + if (liquidity_token(msg) == NULL) return false; + if (isRemoveLiquidityEthCall(msg) && !tx_value_is_zero(msg)) return false; + return true; +} - if (!hdnode_get_ethereum_pubkeyhash(node, addressBytes)) { - memzero(node, sizeof(*node)); +static bool format_amount(const bignum256* amount, const char* suffix, + unsigned int decimals, char* out, size_t out_len) { + if (bn_format(amount, NULL, suffix, decimals, 0, false, out, out_len) == 0) + return false; + return calc_str_line(get_body_font(), out, BODY_WIDTH) <= BODY_ROWS; +} + +bool zx_formatZxLiquidityPrimaryAmount(const EthereumSignTx* msg, char* out, + size_t out_len) { + if (!out || out_len == 0 || !liquidity_shape_is_clear_signable(msg)) + return false; + + bignum256 amount; + bn_from_bytes(abi_word(msg, UNISWAP_PRIMARY_AMOUNT_WORD), 32, &amount); + if (isAddLiquidityEthCall(msg)) { + const TokenType* token = liquidity_token(msg); + return format_amount(&amount, token->ticker, token->decimals, out, out_len); } + return format_amount(&amount, " LP", 18, out, out_len); +} - fromAddress = - (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 5 * 32 - 20); +static HDNode* zx_getDerivedNode(const char* curve, const uint32_t* address_n, + size_t address_n_count, + uint32_t* fingerprint) { + static HDNode CONFIDENTIAL node; + if (fingerprint) *fingerprint = 0; + if (!get_curve_by_name(curve)) return NULL; + if (!storage_getRootNode(curve, true, &node)) return NULL; + if (!address_n || address_n_count == 0) return &node; + if (hdnode_private_ckd_cached(&node, address_n, address_n_count, + fingerprint) == 0) + return NULL; + return &node; +} + +static bool confirmFromAccountMatch(const EthereumSignTx* msg) { + char address_str[43] = {'0', 'x', '\0'}; + uint8_t address_bytes[20]; - if (memcmp(fromAddress, addressBytes, 20) == 0) { - fromSrc = "self"; - } else { - fromSrc = "NOT this wallet"; + HDNode* node = zx_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return false; + if (!hdnode_get_ethereum_pubkeyhash(node, address_bytes)) { + memzero(node, sizeof(*node)); + return false; } + memzero(node, sizeof(*node)); - for (uint32_t ctr = 0; ctr < 20; ctr++) { - snprintf(&addressStr[2 + ctr * 2], 3, "%02x", fromAddress[ctr]); + const uint8_t* recipient = abi_word(msg, UNISWAP_RECIPIENT_WORD) + 12; + bool is_self = memcmp(recipient, address_bytes, 20) == 0; + for (uint32_t i = 0; i < 20; i++) { + snprintf(&address_str[2 + i * 2], 3, "%02x", recipient[i]); } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, addremStr, - "Confirming ETH address is %s: %s", fromSrc, addressStr)) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Uniswap Recipient", "%s\n%s", + is_self ? "this wallet" : "NOT this wallet", address_str)) return false; - } - return true; + return is_self; } bool zx_isZxLiquidTx(const EthereumSignTx* msg) { - if (memcmp(msg->to.bytes, UNISWAP_ROUTER_ADDRESS, 20) == - 0) { // correct contract address? - - if (isAddLiquidityEthCall(msg)) return true; - - if (isRemoveLiquidityEthCall(msg)) return true; - } - return false; + return liquidity_shape_is_clear_signable(msg); } bool zx_confirmZxLiquidTx(uint32_t data_total, const EthereumSignTx* msg) { - (void)data_total; - const TokenType* token; - char constr1[40], constr2[40], tokbuf[32]; - const char* arStr = ""; - const uint8_t *tokenAddress, *deadlineBytes; - bignum256 Amount; - uint64_t deadline; + if (data_total != UNISWAP_LIQUIDITY_CALL_SIZE || + !liquidity_shape_is_clear_signable(msg)) + return false; - if (isAddLiquidityEthCall(msg)) { - arStr = "uniswap add liquidity"; - } else if (isRemoveLiquidityEthCall(msg)) { - arStr = "uniswap remove liquidity"; - } else { + const TokenType* token = liquidity_token(msg); + bignum256 amount; + char amount_text[UNISWAP_AMOUNT_TEXT_SIZE]; + + if (!zx_formatZxLiquidityPrimaryAmount(msg, amount_text, + sizeof(amount_text)) || + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + isAddLiquidityEthCall(msg) ? "Uniswap Token" : "Uniswap LP Burn", + "%s", amount_text)) return false; - } - tokenAddress = (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 32 - 20); - token = tokenByChainAddress(msg->chain_id, tokenAddress); - deadlineBytes = - (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 6 * 32 - 8); - deadline = ((uint64_t)deadlineBytes[0] << 8 * 7) | - ((uint64_t)deadlineBytes[1] << 8 * 6) | - ((uint64_t)deadlineBytes[2] << 8 * 5) | - ((uint64_t)deadlineBytes[3] << 8 * 4) | - ((uint64_t)deadlineBytes[4] << 8 * 3) | - ((uint64_t)deadlineBytes[5] << 8 * 2) | - ((uint64_t)deadlineBytes[6] << 8 * 1) | - ((uint64_t)deadlineBytes[7]); - - bn_from_bytes(msg->data_initial_chunk.bytes + 4 + 32, 32, - &Amount); // token amount - ethereumFormatAmount(&Amount, token, msg->chain_id, tokbuf, sizeof(tokbuf)); - snprintf(constr1, 32, "%s", tokbuf); - bn_from_bytes(msg->data_initial_chunk.bytes + 4 + 2 * 32, 32, - &Amount); // token min amount - ethereumFormatAmount(&Amount, token, msg->chain_id, tokbuf, sizeof(tokbuf)); - snprintf(constr2, 32, "%s", tokbuf); - confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, - "%s\nMinimum %s", constr1, constr2); - if (!confirmFromAccountMatch(msg, arStr)) { + bn_from_bytes(abi_word(msg, UNISWAP_TOKEN_MIN_WORD), 32, &amount); + if (!format_amount(&amount, token->ticker, token->decimals, amount_text, + sizeof(amount_text)) || + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Uniswap Token Min", "%s", amount_text)) return false; - } - bn_from_bytes(msg->data_initial_chunk.bytes + 4 + 3 * 32, 32, - &Amount); // eth min amount - ethereumFormatAmount(&Amount, NULL, msg->chain_id, tokbuf, sizeof(tokbuf)); + if (!confirmFromAccountMatch(msg)) return false; - snprintf(constr1, 32, "%s", tokbuf); - confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, "Minimum %s", - constr1); + if (isAddLiquidityEthCall(msg)) { + bn_from_bytes(msg->value.bytes, msg->value.size, &amount); + if (!format_amount(&amount, " ETH", 18, amount_text, sizeof(amount_text)) || + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Uniswap ETH", + "%s", amount_text)) + return false; + } - confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, "Deadline %s", - ctime((const time_t*)&deadline)); + bn_from_bytes(abi_word(msg, UNISWAP_NATIVE_MIN_WORD), 32, &amount); + if (!format_amount(&amount, " ETH", 18, amount_text, sizeof(amount_text)) || + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Uniswap ETH Min", + "%s", amount_text)) + return false; - return true; + const uint8_t* deadline_word = abi_word(msg, UNISWAP_DEADLINE_WORD); + uint64_t deadline = 0; + for (size_t i = 24; i < 32; i++) { + deadline = (deadline << 8) | deadline_word[i]; + } + char deadline_text[21]; + snprintf(deadline_text, sizeof(deadline_text), "%" PRIu64, deadline); + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Uniswap Deadline", "%s", deadline_text); } diff --git a/lib/firmware/ethereum_contracts/zxswap.c b/lib/firmware/ethereum_contracts/zxswap.c index 5d033e804..15dccb699 100644 --- a/lib/firmware/ethereum_contracts/zxswap.c +++ b/lib/firmware/ethereum_contracts/zxswap.c @@ -44,7 +44,22 @@ bool zx_isZxSwap(const EthereumSignTx* msg) { } bool zx_confirmZxSwap(uint32_t data_total, const EthereumSignTx* msg) { - (void)data_total; + /* fixed reads run through the fromAddress word at offset 4 + 5*32 + 12 .. +20 + * (== 4 + 6*32); the toAddress word is bounds-checked below once its + * position is known from numOfTokens. */ + if (data_total < 4 + 6 * 32) return false; + + /* The first head word is the ABI offset pointer to the dynamic `tokens[]` + * array. We read numOfTokens / tokens[] at FIXED offsets that are only + * correct when this pointer is canonical (0x80 = 4 head words). If it is + * not, the Solidity decoder follows it elsewhere, so what we display would + * differ from what executes (a drain via display/execution mismatch). Reject + * the non-canonical encoding -> falls through to the blind-sign path. */ + static const uint8_t TOKENS_OFFSET_CANON[32] = {[31] = 0x80}; + if (memcmp(msg->data_initial_chunk.bytes + 4, TOKENS_OFFSET_CANON, 32) != 0) { + return false; + } + const TokenType *from, *to; const uint8_t *fromAddress, *toAddress; char constr1[40], constr2[40]; @@ -71,6 +86,9 @@ bool zx_confirmZxSwap(uint32_t data_total, const EthereumSignTx* msg) { break; } + /* toAddress word ends at offset 4 + (6 + adder + 1) * 32 */ + if (data_total < 4 + (7 + adder) * 32) return false; + fromAddress = (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 5 * 32 + 12); toAddress = (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + diff --git a/lib/firmware/ethereum_contracts/zxtransERC20.c b/lib/firmware/ethereum_contracts/zxtransERC20.c index 75f64c160..83074443e 100644 --- a/lib/firmware/ethereum_contracts/zxtransERC20.c +++ b/lib/firmware/ethereum_contracts/zxtransERC20.c @@ -45,6 +45,10 @@ bool zx_isZxTransformERC20(const EthereumSignTx* msg) { bool zx_confirmZxTransERC20(uint32_t data_total, const EthereumSignTx* msg) { (void)data_total; + /* Reads selector + 4 static head words (in/out token, in/out amount). This + * handler is allowed at any data_total (large transformations[] tail), so + * guard the read extent against the RECEIVED chunk, not the total length. */ + if (msg->data_initial_chunk.size < 4 + 4 * 32) return false; const TokenType *in, *out; const uint8_t *inAddress, *outAddress; char constr1[40], constr2[40]; diff --git a/lib/firmware/ethereum_tokens.c b/lib/firmware/ethereum_tokens.c index 15e5f0678..dd7f57f60 100644 --- a/lib/firmware/ethereum_tokens.c +++ b/lib/firmware/ethereum_tokens.c @@ -42,7 +42,8 @@ const TokenType* tokenIter(int32_t* ctr) { return &(tokens[*ctr - 1]); } -const TokenType* tokenByChainAddress(uint8_t chain_id, const uint8_t* address) { +const TokenType* tokenByChainAddress(uint32_t chain_id, + const uint8_t* address) { if (!address) return 0; for (int i = 0; i < TOKENS_COUNT; i++) { if (chain_id == tokens[i].chain_id && @@ -57,7 +58,7 @@ const TokenType* tokenByChainAddress(uint8_t chain_id, const uint8_t* address) { return UnknownToken; } -bool tokenByTicker(uint8_t chain_id, const char* ticker, +bool tokenByTicker(uint32_t chain_id, const char* ticker, const TokenType** token) { *token = NULL; diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index d9d8f4a1f..e37eaef3d 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -55,6 +55,7 @@ #include "keepkey/firmware/ripple.h" #include "keepkey/firmware/signing.h" #include "keepkey/firmware/signtx_tendermint.h" +#include "keepkey/firmware/signed_metadata.h" #include "keepkey/firmware/solana.h" #include "keepkey/firmware/storage.h" #include "keepkey/firmware/tendermint.h" diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 44173a24c..558f6673f 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -460,6 +460,9 @@ void fsm_msgWipeDevice(WipeDevice* msg) { storage_reset(); storage_resetUuid(); storage_commit(); + /* Factory reset drops runtime trust anchors too: loaded clearsign + * signers (and any metadata they verified) must not survive a wipe. */ + signed_metadata_clear_signers(); fsm_sendSuccess("Device wiped"); layoutHome(); diff --git a/lib/firmware/fsm_msg_ethereum.h b/lib/firmware/fsm_msg_ethereum.h index 5c9827164..46ad0fcf9 100644 --- a/lib/firmware/fsm_msg_ethereum.h +++ b/lib/firmware/fsm_msg_ethereum.h @@ -19,6 +19,136 @@ * along with this library. If not, see . */ +#include "keepkey/firmware/signed_metadata.h" + +void fsm_msgEthereumTxMetadata(const EthereumTxMetadata* msg) { + CHECK_INITIALIZED + CHECK_PIN + + /* Metadata must arrive before signing starts. signed_metadata_process() + * clears the binding on entry, so accepting metadata mid-signing would + * drop the tx<->metadata binding without aborting: a host could approve a + * benign decode (suppressing the blind-sign gate), then inject metadata to + * clear the binding and stream attacker-chosen calldata for the rest. + * Refuse and abort any in-progress signing session. */ + if (ethereum_signing_isInProgress()) { + ethereum_signing_abort(); + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Metadata not allowed during signing")); + layoutHome(); + return; + } + + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), + _("AdvancedMode required for clearsign metadata")); + + RESP_INIT(EthereumMetadataAck); + + MetadataClassification result = signed_metadata_process( + msg->signed_payload.bytes, msg->signed_payload.size, + msg->has_key_id ? msg->key_id : 0); + + resp->classification = (uint32_t)result; + resp->has_display_summary = true; + + switch (result) { + case METADATA_VERIFIED: + strlcpy(resp->display_summary, "Verified", sizeof(resp->display_summary)); + break; + case METADATA_OPAQUE: + strlcpy(resp->display_summary, "Unverified", + sizeof(resp->display_summary)); + break; + case METADATA_MALFORMED: + default: + strlcpy(resp->display_summary, "Invalid", sizeof(resp->display_summary)); + break; + } + + msg_write(MessageType_MessageType_EthereumMetadataAck, resp); +} + +void fsm_msgLoadClearsignSigner(const LoadClearsignSigner* msg) { + CHECK_INITIALIZED + CHECK_PIN + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), + _("AdvancedMode required for clearsign signers")); + + CHECK_PARAM(msg->has_key_id && msg->has_pubkey && msg->has_alias, + _("key_id, pubkey and alias required")); + /* Range-check as uint32 BEFORE narrowing: (uint8_t)256 would alias slot 0 */ + CHECK_PARAM(msg->key_id < METADATA_MAX_KEYS, _("key_id out of range")); + CHECK_PARAM( + signed_metadata_signer_valid((uint8_t)msg->key_id, msg->pubkey.bytes, + msg->pubkey.size, msg->alias), + _("Invalid clearsign signer")); + + /* Optional identity icon (1bpp mono RLE). The proto caps icon at 384 bytes; + * bound the dims too so the render path never scans a bogus geometry. An icon + * with zero/oversized dims is rejected rather than silently dropped so a + * malformed upload is visible, not a mystery text-only identity. */ + const uint8_t* icon = NULL; + uint16_t icon_len = 0; + uint8_t icon_w = 0, icon_h = 0; + if (msg->has_icon && msg->icon.size > 0) { + CHECK_PARAM(msg->icon.size <= METADATA_ICON_MAX, _("icon too large")); + /* Width is capped at the confirm screen's icon column + * (LEFT_MARGIN_WITH_ICON = 40), NOT at the 64px height. Title/body text + * begins at x=40 and the icon is drawn AFTER the text, so a wider + * host-supplied icon would paint over the alias, fingerprint and the + * "NOT verified by KeepKey" warning — on the very screen that exists to + * carry that warning. This is the trust boundary for icons arriving on the + * wire; signed_metadata_signer_icon() rechecks the session copy at use. */ + CHECK_PARAM(msg->has_icon_width && msg->has_icon_height && + msg->icon_width > 0 && + msg->icon_width <= LEFT_MARGIN_WITH_ICON && + msg->icon_height > 0 && msg->icon_height <= 64, + _("icon dimensions out of range")); + /* Reject a malformed RLE stream HERE rather than discovering it at draw + * time. The render path returns a bool that layout_add_icon() discards, so + * an undecodable icon would otherwise show no logo while still returning + * Success — the user would consent to an identity + * whose logo silently does not exist. Validation is exact (every packet + * well-formed, no run straddling the image, whole input consumed) and + * side-effect-free. + */ + CHECK_PARAM(draw_bitmap_mono_rle_valid( + msg->icon.bytes, (uint32_t)msg->icon.size, + (uint16_t)msg->icon_width, (uint16_t)msg->icon_height), + _("invalid icon encoding")); + icon = msg->icon.bytes; + icon_len = (uint16_t)msg->icon.size; + icon_w = (uint8_t)msg->icon_width; + icon_h = (uint8_t)msg->icon_height; + } + bool persist = msg->has_persist && msg->persist; + CHECK_PARAM(!persist, _("Persistent clearsign signers are disabled")); + + /* Mandatory on-device consent — leads with the identity's logo (if any) + + * alias + fingerprint. The whole trust model hangs on this confirm; the same + * fingerprint reappears on every per-tx identity screen. */ + char fingerprint[METADATA_FINGERPRINT_LEN]; + signed_metadata_pubkey_fingerprint(msg->pubkey.bytes, fingerprint); + if (!signed_metadata_confirm_load(msg->alias, fingerprint, icon, icon_w, + icon_h, icon_len)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Load clearsign signer cancelled")); + layoutHome(); + return; + } + + if (!signed_metadata_store_signer((uint8_t)msg->key_id, msg->pubkey.bytes, + msg->alias, icon, icon_w, icon_h, icon_len, + persist)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Clearsign signer could not be loaded")); + layoutHome(); + return; + } + fsm_sendSuccess(_("Clearsign signer loaded")); + layoutHome(); +} + static int process_ethereum_xfer(const CoinType* coin, EthereumSignTx* msg) { if (!ethereum_isStandardERC20Transfer(msg) && msg->data_length != 0) return TXOUT_COMPILE_ERROR; @@ -191,44 +321,16 @@ void fsm_msgEthereumGetAddress(EthereumGetAddress* msg) { layoutHome(); } -#define MSG_MAX (38 * 3) // 38 chars per line, three lines max void fsm_msgEthereumSignMessage(EthereumSignMessage* msg) { - char msgBuf[MSG_MAX + 1] = {0}; - const char* typeIndicator; - unsigned ctr; - unsigned msgLen = 0; - bool canPrint = true; - RESP_INIT(EthereumMessageSignature); CHECK_INITIALIZED CHECK_PIN - // truncate to display size if too long - msgLen = msg->message.size * 2; - if (msgLen > MSG_MAX) { - msgLen = MSG_MAX; - } - for (ctr = 0; ctr < msg->message.size; ctr++) { - if (isprint(msg->message.bytes[ctr]) == false) { - canPrint = false; - break; - } - } - if (canPrint) { - typeIndicator = "Sign Message"; - strncpy(msgBuf, (char*)msg->message.bytes, MSG_MAX + 1); - msgBuf[MSG_MAX] = '\0'; - } else { - typeIndicator = "Sign Bytes"; - for (ctr = 0; ctr < msgLen / 2; ctr++) { - snprintf(&msgBuf[2 * ctr], 3, "%02x", msg->message.bytes[ctr]); - } - } - - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeIndicator), - "%s", msgBuf)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign Ethereum Message", msg->message.bytes, + msg->message.size)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; @@ -244,12 +346,6 @@ void fsm_msgEthereumSignMessage(EthereumSignMessage* msg) { } void fsm_msgEthereumVerifyMessage(const EthereumVerifyMessage* msg) { - char msgBuf[MSG_MAX + 1] = {0}; - const char* typeIndicator; - unsigned ctr; - unsigned msgLen = 0; - bool canPrint = true; - CHECK_PARAM(msg->has_address, _("No address provided")); CHECK_PARAM(msg->has_message, _("No message provided")); @@ -266,29 +362,9 @@ void fsm_msgEthereumVerifyMessage(const EthereumVerifyMessage* msg) { return; } - // truncate to display size if too long - msgLen = msg->message.size; - if (msgLen > MSG_MAX) { - msgLen = MSG_MAX; - } - for (ctr = 0; ctr < msgLen; ctr++) { - if (isprint(msg->message.bytes[ctr]) == false) { - canPrint = false; - break; - } - } - if (canPrint) { - typeIndicator = "Message Verified"; - strncpy(msgBuf, (char*)msg->message.bytes, MSG_MAX + 1); - msgBuf[MSG_MAX] = '\0'; - } else { - typeIndicator = "Bytes Verified"; - for (ctr = 0; ctr < msgLen / 2; ctr++) { - snprintf(&msgBuf[2 * ctr], 3, "%02x", msg->message.bytes[ctr]); - } - } - if (!confirm(ButtonRequestType_ButtonRequest_Other, _(typeIndicator), "%s", - msgBuf)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Ethereum Message Verified", msg->message.bytes, + msg->message.size)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; @@ -305,6 +381,20 @@ void fsm_msgEthereumSignTypedHash(const EthereumSignTypedHash* msg) { CHECK_PIN + /* This endpoint receives only precomputed hashes, so the device cannot bind + * them to the typed data the host claims they represent. Treat it exactly + * like every other blind-signing path. */ + if (!ethereum_typed_hash_policy_allows( + storage_isPolicyEnabled("AdvancedMode"))) { + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "Typed-hash signing requires AdvancedMode. " + "Enable in device settings."); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Typed-hash signing disabled by policy")); + layoutHome(); + return; + } + if (msg->domain_separator_hash.size != 32 || (msg->has_message_hash && msg->message_hash.size != 32)) { fsm_sendFailure(FailureType_Failure_Other, @@ -331,24 +421,40 @@ void fsm_msgEthereumSignTypedHash(const EthereumSignTypedHash* msg) { char str[64 + 1]; int ctr; - confirm(ButtonRequestType_ButtonRequest_Other, "Verify Address", - "Confirm address: %s", resp->address); + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Verify Address", + "Confirm address: %s", resp->address)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } for (ctr = 0; ctr < 64 / 2; ctr++) { snprintf(&str[2 * ctr], 3, "%02x", msg->domain_separator_hash.bytes[ctr]); } - confirm(ButtonRequestType_ButtonRequest_Other, "Typed Data domain", - "Confirm hash digest: %s", str); + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Typed Data domain", + "Confirm hash digest: %s", str)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } if (msg->has_message_hash) { for (ctr = 0; ctr < 64 / 2; ctr++) { snprintf(&str[2 * ctr], 3, "%02x", msg->message_hash.bytes[ctr]); } - confirm(ButtonRequestType_ButtonRequest_Other, "Typed Data message", - "Confirm hash digest: %s", str); + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Typed Data message", + "Confirm hash digest: %s", str)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } } else { - confirm(ButtonRequestType_ButtonRequest_Other, "Typed Data message", - "Confirm: No message"); + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Typed Data message", + "Confirm: No message")) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } } ethereum_typed_hash_sign(msg, node, resp); @@ -372,16 +478,6 @@ void fsm_msgEthereum712TypesValues(Ethereum712TypesValues* msg) { msg->address_n_count, NULL); if (!node) return; - uint8_t pubkeyhash[20] = {0}; - if (!hdnode_get_ethereum_pubkeyhash(node, pubkeyhash)) { - layoutHome(); - return; - } - - resp->address[0] = '0'; - resp->address[1] = 'x'; - ethereum_address_checksum(pubkeyhash, resp->address + 2, false, 0); - e712_types_values(msg, resp, node); layoutHome(); diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index ee82cc2bd..42add93e0 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -178,3 +178,7 @@ DEBUG_OUT(MessageType_MessageType_DebugLinkLog, DebugLinkLog, NO_PROCESS_FUNC) DEBUG_OUT(MessageType_MessageType_DebugLinkFlashDumpResponse, DebugLinkFlashDumpResponse, NO_PROCESS_FUNC) #endif + + MSG_IN(MessageType_MessageType_EthereumTxMetadata, EthereumTxMetadata, fsm_msgEthereumTxMetadata) + MSG_OUT(MessageType_MessageType_EthereumMetadataAck, EthereumMetadataAck, NO_PROCESS_FUNC) + MSG_IN(MessageType_MessageType_LoadClearsignSigner, LoadClearsignSigner, fsm_msgLoadClearsignSigner) diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index ca8041b9b..b997e1738 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -3,6 +3,7 @@ set(sources coins.cpp cosmos.cpp eos.cpp + eip712.cpp ethereum.cpp nano.cpp recovery.cpp diff --git a/unittests/firmware/eip712.cpp b/unittests/firmware/eip712.cpp new file mode 100644 index 000000000..c1c8d4447 --- /dev/null +++ b/unittests/firmware/eip712.cpp @@ -0,0 +1,67 @@ +extern "C" { +#include "keepkey/firmware/eip712.h" +} + +#include "gtest/gtest.h" + +#include + +TEST(EIP712, AddressRequiresCanonicalTwentyByteHex) { + uint8_t encoded[32] = {0}; + ASSERT_EQ(SUCCESS, + encAddress("0x00112233445566778899aabbccddeeff00112233", encoded)); + for (size_t i = 0; i < 12; i++) EXPECT_EQ(0, encoded[i]); + EXPECT_EQ(0x00, encoded[12]); + EXPECT_EQ(0x11, encoded[13]); + EXPECT_EQ(0x33, encoded[31]); + + EXPECT_NE(SUCCESS, encAddress("0x112233", encoded)); + EXPECT_NE(SUCCESS, + encAddress("00112233445566778899aabbccddeeff00112233", encoded)); + EXPECT_NE(SUCCESS, + encAddress("0x00112233445566778899aabbccddeeff0011223g", encoded)); + EXPECT_NE(SUCCESS, encAddress("0x00112233445566778899aabbccddeeff0011223344", + encoded)); +} + +TEST(EIP712, DynamicBytesRequireCompleteHexOctets) { + uint8_t encoded[32] = {0}; + EXPECT_EQ(SUCCESS, encodeBytes("0x", encoded)); + EXPECT_EQ(SUCCESS, encodeBytes("0x00a1FF", encoded)); + EXPECT_NE(SUCCESS, encodeBytes("00a1", encoded)); + EXPECT_NE(SUCCESS, encodeBytes("0x0", encoded)); + EXPECT_NE(SUCCESS, encodeBytes("0x0z", encoded)); +} + +TEST(EIP712, FixedBytesRequireExactDeclaredLength) { + uint8_t encoded[32]; + memset(encoded, 0xa5, sizeof(encoded)); + ASSERT_EQ(SUCCESS, encodeBytesN("bytes4", "0x0011aAff", encoded)); + EXPECT_EQ(0x00, encoded[0]); + EXPECT_EQ(0x11, encoded[1]); + EXPECT_EQ(0xaa, encoded[2]); + EXPECT_EQ(0xff, encoded[3]); + for (size_t i = 4; i < sizeof(encoded); i++) EXPECT_EQ(0, encoded[i]); + + EXPECT_NE(SUCCESS, encodeBytesN("bytes4", "0x0011aa", encoded)); + EXPECT_NE(SUCCESS, encodeBytesN("bytes4", "0x0011aaff00", encoded)); + EXPECT_NE(SUCCESS, encodeBytesN("bytes0", "0x", encoded)); + EXPECT_NE(SUCCESS, encodeBytesN("bytes33", "0x", encoded)); + EXPECT_NE(SUCCESS, encodeBytesN("bytes4x", "0x0011aaff", encoded)); +} + +TEST(EIP712, MissingTypedValueFailsWithoutDereferencingNull) { + char types_json[] = + "{\"types\":{\"Mail\":[{\"name\":\"from\",\"type\":\"address\"}," + "{\"name\":\"note\",\"type\":\"string\"}]}}"; + char values_json[] = "{\"message\":{\"note\":\"hello\"}}"; + json_t type_nodes[16] = {}; + json_t value_nodes[8] = {}; + const json_t* types = json_create(types_json, type_nodes, 16); + const json_t* values = json_create(values_json, value_nodes, 8); + ASSERT_NE(nullptr, types); + ASSERT_NE(nullptr, values); + + uint8_t hash[32] = {}; + EXPECT_EQ(JSON_TYPE_WNOVAL, encode(types, values, "Mail", hash)); +} diff --git a/unittests/firmware/ethereum.cpp b/unittests/firmware/ethereum.cpp index f8329a416..83e67af23 100644 --- a/unittests/firmware/ethereum.cpp +++ b/unittests/firmware/ethereum.cpp @@ -1,11 +1,19 @@ extern "C" { +#include "keepkey/firmware/ethereum.h" +#include "keepkey/firmware/ethereum_contracts/zxappliquid.h" +#include "keepkey/firmware/ethereum_contracts/zxliquidtx.h" +#include "keepkey/firmware/ethereum_tokens.h" #include "trezor/crypto/address.h" } #include "gtest/gtest.h" +#include #include +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); + static uint8_t bin_from_ascii(char c) { if ('a' <= c && c <= 'f') return c - 'a' + 0xa; @@ -16,7 +24,7 @@ static uint8_t bin_from_ascii(char c) { __builtin_unreachable(); } -static void test_checksum(const std::string &addr) { +static void test_checksum(const std::string& addr) { uint8_t addr_bin[20]; for (size_t i = 0; i < addr.size(); i += 2) { addr_bin[i / 2] = bin_from_ascii(addr[i + 1]) | bin_from_ascii(addr[i]) @@ -38,3 +46,209 @@ TEST(Ethereum, AddressChecksum) { test_checksum("dbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB"); test_checksum("D1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"); } + +TEST(Ethereum, TypedHashSigningRequiresAdvancedMode) { + EXPECT_FALSE(ethereum_typed_hash_policy_allows(false)); + EXPECT_TRUE(ethereum_typed_hash_policy_allows(true)); +} + +TEST(Ethereum, DomainOnlyPrimaryTypeRequiresExactMatch) { + EXPECT_TRUE(ethereum_eip712_is_domain_primary_type("EIP712Domain")); + EXPECT_FALSE(ethereum_eip712_is_domain_primary_type("EIP")); + EXPECT_FALSE(ethereum_eip712_is_domain_primary_type("EIP712Domain[]")); + EXPECT_FALSE(ethereum_eip712_is_domain_primary_type("")); + EXPECT_FALSE(ethereum_eip712_is_domain_primary_type(nullptr)); +} + +static const uint8_t DAI_MAINNET_ADDRESS[20] = { + 0x6b, 0x17, 0x54, 0x74, 0xe8, 0x90, 0x94, 0xc4, 0x4d, 0xa9, + 0x8b, 0x95, 0x4e, 0xed, 0xea, 0xc4, 0x95, 0x27, 0x1d, 0x0f}; +static const uint8_t USDC_MAINNET_ADDRESS[20] = { + 0xa0, 0xb8, 0x69, 0x91, 0xc6, 0x21, 0x8b, 0x36, 0xc1, 0xd1, + 0x9d, 0x4a, 0x2e, 0x9e, 0xb0, 0xce, 0x36, 0x06, 0xeb, 0x48}; + +static EthereumSignTx liquidity_tx( + bool known_token, bool add = true, + const uint8_t* token_address = DAI_MAINNET_ADDRESS) { + EthereumSignTx msg; + memset(&msg, 0, sizeof(msg)); + msg.has_chain_id = true; + msg.chain_id = 1; + msg.has_to = true; + msg.to.size = 20; + memcpy(msg.to.bytes, UNISWAP_ROUTER_ADDRESS, 20); + msg.has_data_initial_chunk = true; + msg.data_initial_chunk.size = 4 + 6 * 32; + memcpy(msg.data_initial_chunk.bytes, + add ? "\xf3\x05\xd7\x19" : "\x02\x75\x1c\xec", 4); + + const TokenType* token = tokenByChainAddress(1, token_address); + EXPECT_NE(UnknownToken, token); + if (token == UnknownToken) return msg; + uint8_t unknown[20]; + memset(unknown, 0xa5, sizeof(unknown)); + memcpy( + msg.data_initial_chunk.bytes + 4 + 32 - 20, + known_token ? reinterpret_cast(token->address) : unknown, + 20); + + // Token desired/minimum and native minimum. + msg.data_initial_chunk.bytes[4 + 2 * 32 - 1] = 1; + msg.data_initial_chunk.bytes[4 + 3 * 32 - 1] = 1; + msg.data_initial_chunk.bytes[4 + 4 * 32 - 1] = 1; + // Recipient and deadline. + memset(msg.data_initial_chunk.bytes + 4 + 5 * 32 - 20, 0x11, 20); + msg.data_initial_chunk.bytes[4 + 6 * 32 - 1] = 1; + msg.has_value = true; + if (add) { + msg.value.size = 1; + msg.value.bytes[0] = 1; + } + return msg; +} + +static void set_word_u64(EthereumSignTx& msg, size_t word, uint64_t value) { + uint8_t* out = msg.data_initial_chunk.bytes + 4 + word * 32; + memset(out, 0, 32); + for (size_t i = 0; i < 8; i++) { + out[31 - i] = static_cast(value); + value >>= 8; + } +} + +static EthereumSignTx approve_liquidity_tx() { + EthereumSignTx msg; + memset(&msg, 0, sizeof(msg)); + msg.has_chain_id = true; + msg.chain_id = 1; + msg.has_to = true; + msg.to.size = 20; + // Canonical mainnet DAI/WETH Uniswap V2 pair. + const uint8_t pair[20] = {0xa4, 0x78, 0xc2, 0x97, 0x5a, 0xb1, 0xea, + 0x89, 0xe8, 0x19, 0x68, 0x11, 0xf5, 0x1a, + 0x7b, 0x7a, 0xde, 0x33, 0xeb, 0x11}; + memcpy(msg.to.bytes, pair, sizeof(pair)); + msg.has_data_initial_chunk = true; + msg.data_initial_chunk.size = 4 + 2 * 32; + memcpy(msg.data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4); + memcpy(msg.data_initial_chunk.bytes + 4 + 12, UNISWAP_ROUTER_ADDRESS, 20); + msg.data_initial_chunk.bytes[4 + 2 * 32 - 1] = 1; + msg.has_value = true; + return msg; +} + +TEST(Ethereum, LiquiditySelectorChecksDeclaredCalldataLength) { + EthereumSignTx msg; + memset(&msg, 0, sizeof(msg)); + msg.has_to = true; + msg.to.size = 20; + memcpy(msg.to.bytes, UNISWAP_ROUTER_ADDRESS, 20); + msg.has_data_initial_chunk = true; + msg.data_initial_chunk.size = 3; + memcpy(msg.data_initial_chunk.bytes, "\xf3\x05\xd7", 3); + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); + + msg.data_initial_chunk.size = 4; + memcpy(msg.data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4); + EXPECT_FALSE(zx_isZxApproveLiquid(&msg)); + + msg.data_initial_chunk.size = 4 + 2 * 32 + 1; + memcpy(msg.data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4); + memcpy(msg.data_initial_chunk.bytes + 4 + 32 - 20, UNISWAP_ROUTER_ADDRESS, + 20); + EXPECT_FALSE(zx_isZxApproveLiquid(&msg)); + + msg.data_initial_chunk.size = 4 + 6 * 32 + 1; + memcpy(msg.data_initial_chunk.bytes, "\xf3\x05\xd7\x19", 4); + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); +} + +TEST(Ethereum, LiquidityCancellationFailsClosed) { + EthereumSignTx msg = liquidity_tx(true); + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_FALSE(zx_confirmZxLiquidTx(msg.data_initial_chunk.size, &msg)); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Ethereum, LiquidityRejectsUnknownTokenBeforeConfirmation) { + EthereumSignTx msg = liquidity_tx(false); + EXPECT_FALSE(zx_confirmZxLiquidTx(msg.data_initial_chunk.size, &msg)); +} + +TEST(Ethereum, LiquidityClearSigningIsMainnetOnly) { + EthereumSignTx msg = liquidity_tx(true); + EXPECT_TRUE(zx_isZxLiquidTx(&msg)); + + msg.chain_id = 137; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); + msg.chain_id = 1; + msg.has_chain_id = false; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); +} + +TEST(Ethereum, LiquidityRejectsTruncatedDeadlineAndNoncanonicalAddresses) { + EthereumSignTx msg = liquidity_tx(true); + msg.data_initial_chunk.bytes[4 + 5 * 32] = 1; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); + EXPECT_FALSE(zx_confirmZxLiquidTx(msg.data_initial_chunk.size, &msg)); + + msg = liquidity_tx(true); + msg.data_initial_chunk.bytes[4] = 1; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); + + msg = liquidity_tx(true); + msg.data_initial_chunk.bytes[4 + 4 * 32] = 1; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); +} + +TEST(Ethereum, RemoveLiquidityRejectsNativeValue) { + EthereumSignTx msg = liquidity_tx(true, false); + EXPECT_TRUE(zx_isZxLiquidTx(&msg)); + msg.value.size = 1; + msg.value.bytes[0] = 1; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); +} + +TEST(Ethereum, RemoveLiquidityFormatsPrimaryAmountAsLpTokens) { + EthereumSignTx add = liquidity_tx(true, true, USDC_MAINNET_ADDRESS); + set_word_u64(add, 1, UINT64_C(1000000000000000000)); + char formatted[96]; + ASSERT_TRUE( + zx_formatZxLiquidityPrimaryAmount(&add, formatted, sizeof(formatted))); + EXPECT_STREQ("1000000000000 USDC", formatted); + + EthereumSignTx remove = liquidity_tx(true, false, USDC_MAINNET_ADDRESS); + set_word_u64(remove, 1, UINT64_C(1000000000000000000)); + ASSERT_TRUE( + zx_formatZxLiquidityPrimaryAmount(&remove, formatted, sizeof(formatted))); + EXPECT_STREQ("1 LP", formatted); +} + +TEST(Ethereum, LiquidityFormatsFullUint256WithoutBlankConfirmation) { + EthereumSignTx msg = liquidity_tx(true); + memset(msg.data_initial_chunk.bytes + 4 + 32, 0xff, 32); + char formatted[96]; + ASSERT_TRUE( + zx_formatZxLiquidityPrimaryAmount(&msg, formatted, sizeof(formatted))); + EXPECT_GT(strlen(formatted), 32u); + + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_FALSE(zx_confirmZxLiquidTx(msg.data_initial_chunk.size, &msg)); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Ethereum, LpApprovalRequiresMainnetDerivedPairAndCanonicalSpender) { + EthereumSignTx msg = approve_liquidity_tx(); + EXPECT_TRUE(zx_isZxApproveLiquid(&msg)); + + msg.to.bytes[0] ^= 1; + EXPECT_FALSE(zx_isZxApproveLiquid(&msg)); + + msg = approve_liquidity_tx(); + msg.chain_id = 137; + EXPECT_FALSE(zx_isZxApproveLiquid(&msg)); + + msg = approve_liquidity_tx(); + msg.data_initial_chunk.bytes[4] = 1; + EXPECT_FALSE(zx_isZxApproveLiquid(&msg)); +} From 9b9faac3ea2eb1e49c14ef6462699924da438b04 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:37:19 -0300 Subject: [PATCH 10/40] feat(clearsign): KeepKey as the schema attestor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds messages 1700-1703 so a KeepKey can issue the schema signatures the clear-sign path verifies, instead of every schema having to come from an offline signing ceremony. - ClearsignAttestorGetPublicKey exports the attestor key; ClearsignAttestorSign signs a schema after showing the user what they are attesting to — program identity, discriminator, and each argument label with its type and width. - Self-service attestation is gated behind advanced mode, so a default device cannot be talked into minting trust for an attacker's schema. - The signed variant ships the built-in anchor table, so schemas issued this way verify on a stock device with nothing loaded. - scripts/emulator/capture-clearsign-attestor.py reproduces the OLED sequence, and docs/security/evidence/7.15.0-rc21-clearsign-attestor holds the captured frames including the 44-character program id and 16-character label bounds. --- .../7.15.0-rc21-clearsign-release-control.md | 99 ++++++++ .../01-schema-identity.png | Bin 0 -> 1655 bytes .../02-program-id-44chars.png | Bin 0 -> 1727 bytes .../03-discriminator-8bytes.png | Bin 0 -> 1409 bytes .../04-arg-u64-le-16char-label.png | Bin 0 -> 1592 bytes .../05-arg-u8-16char-label.png | Bin 0 -> 1575 bytes .../06-arg-public-key-16char-label.png | Bin 0 -> 1631 bytes .../07-arg-bytes32-hex-16char-label.png | Bin 0 -> 1652 bytes .../08-account-16char-label.png | Bin 0 -> 1586 bytes .../7.15.0-rc21-clearsign-attestor/README.md | 32 +++ .../manifest.json | 51 ++++ include/keepkey/firmware/fsm.h | 4 + lib/board/signatures.c | 48 +++- lib/firmware/fsm.c | 2 + lib/firmware/fsm_msg_clearsign_attestor.h | 206 ++++++++++++++++ lib/firmware/messagemap.def | 4 + .../emulator/capture-clearsign-attestor.py | 220 ++++++++++++++++++ 17 files changed, 657 insertions(+), 9 deletions(-) create mode 100644 docs/security/7.15.0-rc21-clearsign-release-control.md create mode 100644 docs/security/evidence/7.15.0-rc21-clearsign-attestor/01-schema-identity.png create mode 100644 docs/security/evidence/7.15.0-rc21-clearsign-attestor/02-program-id-44chars.png create mode 100644 docs/security/evidence/7.15.0-rc21-clearsign-attestor/03-discriminator-8bytes.png create mode 100644 docs/security/evidence/7.15.0-rc21-clearsign-attestor/04-arg-u64-le-16char-label.png create mode 100644 docs/security/evidence/7.15.0-rc21-clearsign-attestor/05-arg-u8-16char-label.png create mode 100644 docs/security/evidence/7.15.0-rc21-clearsign-attestor/06-arg-public-key-16char-label.png create mode 100644 docs/security/evidence/7.15.0-rc21-clearsign-attestor/07-arg-bytes32-hex-16char-label.png create mode 100644 docs/security/evidence/7.15.0-rc21-clearsign-attestor/08-account-16char-label.png create mode 100644 docs/security/evidence/7.15.0-rc21-clearsign-attestor/README.md create mode 100644 docs/security/evidence/7.15.0-rc21-clearsign-attestor/manifest.json create mode 100644 lib/firmware/fsm_msg_clearsign_attestor.h create mode 100644 scripts/emulator/capture-clearsign-attestor.py diff --git a/docs/security/7.15.0-rc21-clearsign-release-control.md b/docs/security/7.15.0-rc21-clearsign-release-control.md new file mode 100644 index 000000000..352a50eee --- /dev/null +++ b/docs/security/7.15.0-rc21-clearsign-release-control.md @@ -0,0 +1,99 @@ +# Firmware 7.15.0 RC21 ClearSign Release Control + +Date: 2026-07-29 + +RC21 is the opt-in proving release for self-service ClearSign. It deliberately +does not pin a KeepKey production signer. The regular firmware contains the +constrained `KKSOLSC1` attestor, but its public-key and signing messages, +runtime signer loading, and runtime metadata verification are usable only while +the user has enabled `AdvancedMode`. + +Loaded signer identities remain RAM-only. They are cleared by session teardown, +`ClearSession`, reboot, and disabling `AdvancedMode`. Metadata from a runtime +signer is annotation-only: EVM and Solana retain the baseline raw/unverified +review after the decoded screens. + +## Human-attestation gate + +The attestor must show every security-relevant declaration before it signs: + +1. program and instruction labels; +2. the complete base58 program ID on its own confirmation; +3. the complete discriminator on its own confirmation; +4. every argument's ordinal, ABI type, and label; and +5. every displayed account index and label. + +Program ID and discriminator may not share one standard notification. A +44-character base58 program ID consumes two body rows, and an 8-byte +discriminator cannot reliably fit in the remaining row. Argument types may not +be omitted: two different ordered type declarations can have the same total +width while assigning the same labels to different byte offsets. + +Before RC21 is tagged, release evidence must include OLED captures from a +schema with a 44-character program ID, an 8-byte discriminator, all four +argument types (`u64 LE`, `u8`, `public key`, and `bytes32 hex`), a 16-character +argument label, and a 16-character account label. Every value must be complete +and readable. CI success alone does not prove this display boundary. + +The candidate's framebuffer captures and exact input manifest are recorded in +[`evidence/7.15.0-rc21-clearsign-attestor`](evidence/7.15.0-rc21-clearsign-attestor/README.md). +Regenerate that evidence if any attestor parser or confirmation code changes. + +## Host compatibility + +The canonical RC21 protocol uses `SolanaSignTx` tags 9, 10, and 11 for +`schema_payload`, `schema_signature`, and `schema_signer_key_id`. Tags 5 through +8 are reserved for the transaction-bound `KKSOLSW1` descriptor and one-request +opaque-signing consent. Removing that reservation or assigning those tags is a +protocol-review event. + +Hosts built against the earlier experimental schema contract used tags 5, 6, +and 7. Protobuf treats those fields as unknown on RC21, so the transaction falls +back to the ordinary unverified review without a transport error. This is safe +but operationally silent. Vault, SDK, and python-keepkey release notes must +therefore state that reusable schemas require the RC21 protocol contract; old +hardware-test fixtures are not compatible evidence. + +## Dependency provenance + +The selected RC21 firmware commit must pin and record the exact reachable +commits for both submodules. The reviewed candidate pins: + +- device-protocol: `4cc8b717517c79ee3ac436161141dd033db286fd`; +- python-keepkey: `c406a1ba9120da410c356dbff7f4d4bd1e1758fa`. + +The PR body, gitlinks, CI checkout, and release provenance must agree before the +tag is created. + +## Production-key provisioning runbook + +The attestor key derives from the active seed/passphrase session. A hidden +wallet therefore has a different attestor identity from the standard wallet. +This is valid for self-service use, but it is not acceptable ambiguity when +selecting KeepKey's future firmware-pinned production identity. + +Provision that identity only as follows: + +1. install a signed firmware release that contains the attestor; +2. enter the standard wallet with an empty passphrase; +3. enable `AdvancedMode` and read the compressed attestor public key; +4. record the complete public key, its SHA-256 fingerprint, device identity, + firmware version/hash, and the signed release provenance; +5. clear the session, reconnect to the standard wallet with an empty + passphrase, and derive the key again; and +6. accept the candidate only if both complete public keys match byte-for-byte. + +Do not provision while a hidden wallet is active. Do not promote a key from +RC21 into firmware merely because it was derived successfully; promotion is a +separate reviewed release decision. + +## Merge and tag gate + +- Both firmware variants build and satisfy the SRAM reserve. +- Full and Bitcoin-only unit suites pass. +- Python integration passes with inherited `PYTHONPATH` preserved. +- Protocol generation succeeds from the exact pinned device-protocol commit. +- Maximum-boundary OLED captures satisfy the human-attestation gate above. +- The PR body names the exact gitlinks in the selected candidate. +- No RC21 artifact contains a pinned production ClearSign public key or a + persistent runtime signer. diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/01-schema-identity.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/01-schema-identity.png new file mode 100644 index 0000000000000000000000000000000000000000..40e3a8a59f4b4ae8126c06731b153070aa71f0bd GIT binary patch literal 1655 zcma)6c~H|w7~KGY2m!G~pvt9StwkZNG6LZijj<*bfrwH$lyC~7f&@zrNUUNkw%Vd1 zLqe$ug%CvK6d@osS}ye`u@y<6j9daiNsL4aO`H;7#+i2i*xA{geec`%y?xnx2v}po zRfYfnW86;vy#VN{mq3WnQO_O&mnH$w_rm%629fi}d)tox;%W-t0|m^@wY3Y(J8tnU z>}rSjpR~XcLE{hZCls=6HJ3j;I3jT>3J9fdD06P2YP=w1w%yYj2vsN_;A6GI65NP= zof>A&)zt&70RRS$`9}cZ=!2*7jk^sH;A|1l0e_tp0=mc{yz(a6+78&|=&1MYb#y_& zc{IpG6V2R7ilRy&Flw2h6 z!Z$Hf=b3UW)(|E+eTT*D7vA?(o}oa%(~9GjS>XqrD;&7V;odF3^=ywplLLcMzQ3z` zOAD4Z1bfRenHOT@yf8ZPR4S`c&KvCgRyr5rV01gJm=S()zb;1Rv0T-ud%mYr%6|E~ zbV{qBh-FH9#reCy$#RrYpbuo7E!7osun|pH-b>nw;B!gJM1I+ulg(Mpg@ThjJf!Ed z)4Uq5>L}ON=zsu1t07V(G&krw-@*@D&DB+U&F$pkLmrDbx$kc4FE)v(ANe1vwR)lDlUr-Dg*iVGQjuwc5n7)b`8$ z86_^;LfXP8Js5AoriQzGNi|6latnj5=y}y=WYZW4G?M*-j zhDN!q5xQ%g)w$ByFXL*nViyb1j*)zR4D2q{YDvggaj0X*K?=6xaax(t4)Tj_SCalr zr^y4YpF5Tf-UR_#l$;4=AiS~3X)+h)nyr25igg>yj*0|WqXyC<8~vy5-^Z3tKWvmW zv13c!yK#;74QZAX!wX6>MuZu^t;}=VgtUM7D%+aJ;h=xQV`1ofY&RYZkWDl{Xb6&z z_-&h&n2u)Sd^^lAvR}o_gc+HcV^kzuBGSsdVUMt3qv>&&4kaWFv{b_WMxmf0o}`EKMV>0Dl_pF92160in^t7z)^&5`a+ z(Vq1+t7c|VUYpw@CaS5sj3&LbXfY|yHx%Jp*JH@;bsqsp+phda7HiCNV~vyGF12q< zWuvEy;;Hge_^%YjaU`M?DY^E*E4CQ)FKDihxl~r|Z2_znK?Fxc)gR|=0#^u2%}Gk~ue1a{>(xnYiq+Ext)xYXs;4jP*-g71*+0om=#KKFV69y! w{Ao`D6tJ_^g5( z!vO%R{Cv^70nkz&fj}Fo++9Y_PXVwz!w>Bl^4(SWvw?(>9l9{VfuY647&*S$P`;L= zu%$`5qX}1PEeCSlzMNKkxUE9?bfLq4<^u4ZIyfUX?Q^In%-R1b1E7Nkpy1J(QxK53 zF-mb`tCEb3$$_AF#**No64sb$*<-k7>ga{6qq#pwLr^}y7B zg{nu$GEj6L1^w|h+1BwuZed;0BP6iVVAF4<``~ZzggES#VCf5I%J$xE2H?z5*4s>j z8auRlq8(B^<(Rwpy7g)>_}aBUEL)S&jaS!|viSNPS4e~h;_UCI{@S>27%kyH+S&US zGKCH6cNjB4t4p!U0-*KsCFZKdfzOuEKYj22@ujYkV=!}KaZ)d$ub)fUn-^;6N_8~Y zRTq~bZtF2kDetf!OpMD#i50DdGuK_@mRSqWoyGCpxJ}~moaZp?=)%2}+`DX|w0bCi z`xBFe$F2~-kbIy5r*MVn_nJxh$yd|YN>hw39kF$wTq~x9i_*j=X^3}QaB(pY5YCy+ zRgv$p>i+iVpQ=doJ>2_-D@BaEFG}-Esdv_8mB znAK;h*#Co4AHKXp`u(YO>+z^7_=$*->f7$~7}mN)KCWzvYnS{mF`FeRXlzng%@!a= zS+VbHLz7t@QF=2NwFp=vj$Y}tsCA~f*_7G9HgLfrmNC94%i4oF}z5jYLk#=|E9hA zaEQq0;Bh%Cwp>uLb;%bw&-4JJiEza4D{o_S(S3Go4-SdVlGw0_nz<{pO&!OdtJ>Tp zDY>ALgcVeaG3%J7<7n+d>TK|D&8V_dKa1?N01S=g>ShjhP3YE`Tn~dS2fFK_T7TQR z>|;-7Q0(dc&B9?UQ9^R9;j#CB4-`Wqc7u<-qFiffi^_T!f4uZ|#ePxTmE1@058 O{`h$ZpewziQ~v^^$d?!Z literal 0 HcmV?d00001 diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/03-discriminator-8bytes.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/03-discriminator-8bytes.png new file mode 100644 index 0000000000000000000000000000000000000000..6a2bbd0847f63a1a7beabebb9cf10d6ac76c8e42 GIT binary patch literal 1409 zcmeAS@N?(olHy`uVBq!ia0y~yU~ z3!8%f|37^$X;Rb{Np=Co*Yh^#3T{!GJiVNGUmQp;cHq#xHdBP*K>qK2``>NMovS@< z{=V(Z3=#90I2byVnV1>GeD}ROeW;{?p}~SnfkB~L&f5elko6$r+*#Nd z&iL$GmXe4fRATmNbzCmO@gVJ3Oau$X{0d=bn2;9#X2RiZ@B2`Fw?}7-P@5`679i!(47%mT&n z=XY{vV|tkPm9FbvHOtS<$ z&s&`ZjJ0>wi*GHH_&Ix?Mx@)x-;qxW86@1`(QM+DpxS=7*XG*#>vijZ8m`~oA@M?{ zJ>2)=H<4U!>%#kH2N=8A6IkJ4zZ@8jjhwXe74(p9l*X4ii$Ph|@5 zgByIsHQ{ynkK188{uN)GIA59po+pCE|8AMfdi~qMWe0Y8xs}HIUlqwMzPo*cYhB(< zsRNH+K=UCPCphb9NA z=C65mQ-9w^Vf&}knHXx=;ko%jWOU4>X-cL2X}8{8&R%C0+8L@Z{u~k3z)VxINAu+M zDKA!qpF$}rF4UcQ&~TVjd}hhJX&c}DJ`GDs;L>KA9mmGs;%CJ!eoHjneSm>Ms`CJ_ zECL#)pvQd~9+5!)KHu-ky#8(BXG14_-O_8dYF9<}t+2998-AA^n0qG96rRpe{C*%? aww~eZt7e@Ab-Rv$WISE{T-G@yGywp@pYFf_ literal 0 HcmV?d00001 diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/04-arg-u64-le-16char-label.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/04-arg-u64-le-16char-label.png new file mode 100644 index 0000000000000000000000000000000000000000..29040e62266b9c3707bd341c41e26f6b47f15724 GIT binary patch literal 1592 zcma)6dsLDK6u;mD#dmgalk90Hn=Wl7xHQewijs6IU!4qGrc2c243pLr*D%+rEjv>~ zg{ITBMB~VEHuK$E9yth{miX8P)G$*V1|fy{vo&Q|v>jvEY(qH?=%FQ-oy ztbTHZg_IP3+`kRQ3!6}&Own(Clv#%ptzdjW|6=Cq&8*0V_!)ky2XxFV+yt8zxx5A3 zgA>$5Os$0_T>k(NkQGjbjZl;x+ntEQO_KkVg9oqy?}_ zk2$0yZ7}7rat7zEh`AJ|)D^Nv2!PNt;yv{%ANL%l13dt33bwI__^UgH73?v7*DI?j zw>NIWZoK*eo(J*ppH*W#t<)`pII&AC&|m~ zx`BMx_a^qWHh10s-aQhp%M1u$1+uA%Ty*V;=$6EN(aN*9nmxQh#||w`o*Ny#&F7+OB2bN!bo$;I8lp(E{ZEhg z%4v|-i!jnPi=on`j}vyeM7bkB?H}Cp=Uf7&Dd*yGQgMPs%UH0JPH1r|upxx~SSX)- zy|7_4RI%})p9Cea26-jJW4{6Ms`zyqPVE8hL5{kqh1@w5edMzriA!8zSwi1h6~5hZ zZUSeQ0%Y$kWe^SJsJU^ySsDM&O5bxU>T9k3g$V1B@yo9Q8o%70DoxHC6KmUaYkciz z($CqV=--6o=Uc+<3nsN(OR4cZ9?GW%j~?8f(XftEg`)+weEtBU!_0!(9YN+bvY1v0 z{&$3&z5|B}`ihgeDX|FpdvhHZi7^FJHShAOs(gEnGI5(9hXDz*Qcw1j5b?%iIzm^x>v*kyogmutYV4nI#{B zQOOw^FHsU{vBb><6WpvSU*lL2?D1jOXLPc21O>rwi_!45JL*;H2K)>p$%Sn7Nv7WVT`C!g6Q}m(v);ty>&f z-SOGYNyGm5ilFJt_w$|}ixHgiZ`F3b?R{vW^~+mCt6vNdDRnE=)NB)1pKsIikH)fV zucwuCrka<^bOD0~i~pby4*Po_5Su|S_XDLoWxP;ei>E33SMC?fO6SF-x4MjITm`t^ zBV1+-8KBa5HfPVNb17qo7sdYZ) b5dE}M6tcZyf%aP$(;q^_&hUoK}U}&&V zGGKUMc=GxR9UpcMhK|JyK=F=;zhnSr{4=ZYQq2 zru+JI3CI{O1qOva4jzUb#nTVnmp^;v5JHp*W-i#iutr7(bDe*?$}JJ5uk1eH6&1Aj z+>aM;ntP?{-dE>eHoJQ6;qk!G>pcI%^(xzPi|uBzF*xs&P)|v;0(k`FisdVuug=Wd zvNz#*P1?QeXElC2{0qaR>mDs}kD8j*TV>7gezU*Liq*bXr*DA!W24|B27WF(x4%*U zn%CUl%$i^)3vxKfiJy6UL|29J$WCVs<(;3ob!Azdhn0H#rg^Kg+WP;UVgAq#Hzc#? zfSYVpY4^4-%Tw|X-?!J9)Oy$Y>N)`GpTk-!o!~Es;I{W$A&%W=0>j7H)zv4or zzwokO3zjn;*u8nf+V}PfaLXr4G)~>}rcOG0^;^A)Ma8RD@4dXLUX8(N(}vs&=KJBM zOjKZCNRFA_%KLvVJ4`S3ltG+mL(uKz$M(JdU$b!SyQRx4XMWx^x&BcxTLb&goz=j^ zx>*wxwb7wU#gq0=iP^lyRqg+;=R6GE7vQPq{_D7364zwJ&1H+{xQgXY_1T)T!+@JX zYzZ?g(Y#XHFsbI%E}Io^->DuJj|?oGBr3O_kzp1*QO?=Lu|{`(RDJnD*Jn9zB^b^u zgzMG2!}0N=mAMlOL)bF7U|8n?uWt-*Og>N9xNUo~vJ1luXL!oB$xIEtJ*|y_q5cId z!OnTQX-VpN5oU%sxH5uaqi`{-=;FTjqU(3$*1P@vch={)rM!CCBa7$T0#%K3c3(f~ z>)&?2di~7BZS@Qc6_e-hR%c)^m<>)uYj^!}+N$1AD!*_} z@m1@!Uas!FQvVnk9{9quhhHp<*{Kh%4yuRWmri=4ll@mB<8M00fmv_`=gOT*LQ4yu zD=pA6-u^h-dtzFial}%U6V~^#L;jd+`Oo>VfFWWsJc;Z~S3XiL{=Vc`Q0ULQAKg_K zTa~}cp8fDl_N`C516N|G&P$U_SHMzHUz2-Z= z$|@%3nP*Dw_pIAFu67B)Ge_fn-kEFW|zGch+Duf8yIMs<9Nsa7gV z5tng4t>DnJmhL-W?x`jpzCInE`angZ&R)SsAB4|}T`b!$D>Q+Dq1Pn=QdSC}m7V#< zHQt9OKMeh@b+YP~n`~(89#dUy>zOx7wIBGsiz|gY1tozsoVWVNEX%cgu8Uk{2uQ}$ L)z4*}Q$iB}Ca*oo literal 0 HcmV?d00001 diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/06-arg-public-key-16char-label.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/06-arg-public-key-16char-label.png new file mode 100644 index 0000000000000000000000000000000000000000..87a889b67ed3816f1a4be4b0cdac27ef82092876 GIT binary patch literal 1631 zcma)6ZBUX~6uw}B1CiR8R+Fi~?0z5G`CUrO1-StzSKxZtW+@;Cn>aTM#sLguIcx{p*VwNFh96(2#XEAXmVl?xmU}`n z5Kmock$CMvnsMp@9K75FL0F)tgbX;?UZLa1YuPi@Knsku9k7d7f&kA=Y^gpp;l^hf z{(XWifH#?GrIY8f^(S%87D^U8h2#1D_J+f{N$){qb@uEp11erdV_u<4`LTDh=?vlM zT4T2mhXLHV=zxaf}vq+G=leVs0z;{g8Y<2!wq$F z5dDY?0LZcu9r5!yYZt2eb!Ys~+hZp}w-}ipwZ#I#z635#GvltNohOpAf*SQf%;q&{ zaQWOvjUIqRqNRW)8On=|@jEu!Hu1XU+F2cFkVBfp*^}#}f%Rb%4}QIKpK}lId@i=r zD7u@KS(d22QGM~Qw-)YFI}Fdne^GUUTR}bINs&mNwk#A26K-SO2vM$U+39c?hO1p> zv6Ln4(Gu~yH}}HXgT5OGB{I_tCq^Jy>HtIw3b^(ZW5dq2OPt(d#%Hg{q)g)qfGdH* zDI4IP6HfRsRrfQFV;4r1KaQT{jQY@R(G;@Uw}){D&|wg%sfH>dDVp zCTUaL-mYCgWmLwxagIK`B$1<8rzG1tDE_xIrbVsFq%4Fu<};n_$w`~NgLx}u1VpHU z)_22Sg=?9c(@MJoe*Q4AWEFY^BoEPp_1 zop_39<0BKCKzTi6<`7N6YUc1sO!}xFv$Aj6)VFDAU;sh!cTLC32~`&YEapjb8XDY+ zDqjLvi_tqt=Or;p^(sv6im^}a^GRpJapf~$SkA1*<8^KUojX#xBhR4ggvqzHiQZ47 z(ZuRdWoZb#3pKag_mg!d*O@irVz-V*R&pbJm>OXYQg(ksD~Fot1(n=9D090Qy+0Jb zC)4~qsfz<)9S)4Kx<{!0@2*HLe@ErU>US*-EumL-e6{g8Om z>?*tO+pXVUd`mxkcpd0FpR@jy>Guu!=UOuv1U?UmeLE`RAB0e8z|*A7NGks6o3+yAiN+@H>UpvDU1 zV^Dz2Ek6{oF!Shz(#U|z4~^fNT|KCO^1+krYsIE#KU}K6>mi;ry#rMs=&o(ct`3>IfsIOl)bN^?!>9YDxfBMZ8W3oz&4NE<{62fVJ>YH|Mad12Yy<+lz#FFK@Ne}87V^p^LxKNZf+x*zDj$DTpw z%jTj2h6Yh^+IYX>%>&1uPQE7&?Ui|^@W zZ23-S=Vkjkf2p;|dc48+o2>MnWK;i0F*bww2tz$z2e`21F)_5j!GqISW`TEWsXi!#qK zym5fJV@a@R+3E`hkOnwQ~t(Up(hI}Ho&I# z3P$`;+LWzkRPk&X$7217nX_&rED_r0rf;)fx`xF&jpIQ0C0O!%d8Xq)TAuJVl~=75 ztrvgCz5f2f_Bz|+6P)_7JzYspQyI=Lg$MLZ!A9Q=(GrF`UWU&5qk4WS(*{w5rPY(K zde&Myn97$XD-&~j=|I4}U4z8+Pr)eNdIxm-SP#l~MIs$qd_ z#}U2)mSqnmdKI;Vst0Hvj1f&c&j literal 0 HcmV?d00001 diff --git a/docs/security/evidence/7.15.0-rc21-clearsign-attestor/08-account-16char-label.png b/docs/security/evidence/7.15.0-rc21-clearsign-attestor/08-account-16char-label.png new file mode 100644 index 0000000000000000000000000000000000000000..0c5d66280dd63b59b8809add06e39c2d965d6aff GIT binary patch literal 1586 zcmeAS@N?(olHy`uVBq!ia0y~yUme$IAEJDn!3R7!mKH;j(t1N57LbtTu6Rx#m(@*|M$NCG0C^t z!gcm1+w(Cz_$T7P;2L(JoUl>g5d%M$o!j5- zf7o}{pBHQR3HS9mb%U^sS%xKZ#IBV5@+k*8K>y^)gV)bI&ysq$c>iaHKaTKVEOAR% zY_>OQk^bM6k+(nmZl9f!nB89W$=fI^;d<@4=6#chuCq9Jh;8Hjb#^^GgjF~g z4oF;2O+5Vnd^5~w>`8+-QHQl}mml5tzW(omwQmqg>kZ?%esQQP92M%ypfq+yqsbELbyG8JqLc7o!|9tO)}fXlC^oNVa*L<&RbOWQ!bTUKs&grn?3;W*xm*1P4-nY~5;VWO}-*Poup6r{q;^pm6 zUu}1M;Ob)+x7+Y+P1Dx<=?v?>RsR%aV90X=<&B?f~=Btof#hNM5N>%j+~QMRwYj> zEK)pj=%8@ejc6nPwJ)P;`D=bGnsQj#i6PDto|Z#g6Ix3ir*@y-`&duzZHmrSaY3=T zbu)M#OhuU2y7@rdw7!pa+v=D*mcm056l*8rYj11u-tSrYp;3MM{@1r{Uhmr6w|#R; z`szUbJuiQLc^l8>paY8=Y)SHgR+&4~1Jz&KSoh=|K6Pj-*KT34zRX*Eo6g4>9y%1M zUM^@WSNy3{^ad9*a@oW7y4(9#(2|E2eeOT+*%dZ_*^#U%ldoTb7gtIK3>AA+PhOw& zA}iz+A`F2A)P?KmuH9 z?m#U?D`r3RmJ;*J-xcP2?Q;3Gz{>}tuW#9O=)k(!jNv<~E^mc}9jce>-aWQ|diL3p S%. + */ + +/* Clearsign attestor: let a KeepKey issue clear-sign schema attestations from + * its seed. It ships in the regular firmware, but every operation is gated by + * AdvancedMode. This lets builders prove the self-service workflow before a + * future release pins a KeepKey production identity. + * + * The attestor NEVER signs arbitrary bytes. It parses the submitted payload + * with the same validator verifying devices run (solana_parseInstrSchema for + * KKSOLSC1) and refuses anything malformed. A fully compromised host can + * therefore only obtain attestations over well-formed, user-confirmed + * descriptors — never a general secp256k1 signing oracle. That is the single + * most important property of this design; do not add a "raw" mode. + * + * Key custody: the attestation key is derived from the device seed at + * ATTESTOR_PATH (a dedicated hardened path outside every coin space), so PIN + * unlock gates its availability, seed backup is key backup, and wipe destroys + * it. + * + * ponytail: KKSOLSC1 only. EVM v2 metadata blobs are attestable in principle + * but sign a different range (payload minus the 65-byte signature trailer, see + * signed_metadata_process) and their parser is static in signed_metadata.c; + * add a second branch here plus an exported pure parser when EVM schemas need + * device-issued signatures. + */ + +/* The attestation key path: purpose 0x4B4B ("KK"), then 0x4353 ("CS") for + * clearsign, then account 0. All hardened, and far outside any SLIP-44 coin + * range, so an attestation key can never collide with a funds key. */ +#define ATTESTOR_PATH_LEN 3 +static const uint32_t ATTESTOR_PATH[ATTESTOR_PATH_LEN] = { + 0x80000000 | 0x4B4B, + 0x80000000 | 0x4353, + 0x80000000u, +}; + +/* Derive the attestation node. Returns NULL and sends the failure itself. */ +static HDNode* attestor_getNode(void) { + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, ATTESTOR_PATH, + ATTESTOR_PATH_LEN, NULL); + if (!node) return NULL; + hdnode_fill_public_key(node); + return node; +} + +/* Human-readable ABI type names for the attestation review. The type is part + * of the security boundary, not decoration: U64+PUBKEY and OPAQUE32+U64 have + * the same total width but assign labels to different byte offsets. Never ask + * an operator to attest an argument label without also showing its type. */ +static const char* attestor_schemaArgTypeName(SolanaSchemaArgType type) { + switch (type) { + case SOL_SCHEMA_ARG_U64: + return "u64 LE"; + case SOL_SCHEMA_ARG_U8: + return "u8"; + case SOL_SCHEMA_ARG_PUBKEY: + return "public key"; + case SOL_SCHEMA_ARG_OPAQUE32: + return "bytes32 hex"; + } + return "invalid"; /* Parser rejects unknown values; defense in depth. */ +} + +void fsm_msgClearsignAttestorGetPublicKey( + const ClearsignAttestorGetPublicKey* msg) { + (void)msg; + RESP_INIT(ClearsignAttestorPublicKey); + + CHECK_INITIALIZED + CHECK_PIN + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), + _("AdvancedMode required for clearsign attestation")); + + HDNode* node = attestor_getNode(); + if (!node) return; + + resp->has_public_key = true; + resp->public_key.size = 33; + memcpy(resp->public_key.bytes, node->public_key, 33); + memzero(node, sizeof(*node)); + + msg_write(MessageType_MessageType_ClearsignAttestorPublicKey, resp); + layoutHome(); +} + +void fsm_msgClearsignAttestorSign(const ClearsignAttestorSign* msg) { + RESP_INIT(ClearsignAttestorSignature); + + CHECK_INITIALIZED + CHECK_PIN + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), + _("AdvancedMode required for clearsign attestation")); + + CHECK_PARAM(msg->has_payload && msg->payload.size > 0, "Missing payload"); + + /* Validate before attesting. The payload must be a descriptor this firmware + * can itself parse — the same code path fsm_msgSolanaSignTx runs — so a + * compromised host cannot use the attestor as a raw signing oracle. */ + SolanaInstrSchema schema; + if (msg->payload.size < 8 || memcmp(msg->payload.bytes, "KKSOLSC1", 8) != 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, "Unsupported descriptor"); + layoutHome(); + return; + } + if (!solana_parseInstrSchema(msg->payload.bytes, msg->payload.size, + &schema)) { + memzero(&schema, sizeof(schema)); + fsm_sendFailure(FailureType_Failure_SyntaxError, "Invalid schema"); + layoutHome(); + return; + } + + char program_id[45]; + char disc_hex[2 * SOL_SCHEMA_DISC_MAX + 1]; + solana_pubkeyToStr(schema.program_id, program_id, sizeof(program_id)); + for (uint8_t i = 0; i < schema.disc_len; i++) { + snprintf(disc_hex + 2 * i, sizeof(disc_hex) - 2 * i, "%02x", + schema.disc[i]); + } + + /* Program IDs may consume two body rows, while an 8-byte discriminator plus + * its label consumes another two. They therefore get separate confirmations: + * combining them can silently clip the discriminator, which is precisely the + * field the operator must compare against the contract ABI. */ + bool confirmed = + confirm(ButtonRequestType_ButtonRequest_SignTx, "Attest Schema", "%s\n%s", + schema.program_name, schema.instruction_name) && + confirm(ButtonRequestType_ButtonRequest_SignTx, "Program ID", "%s", + program_id) && + confirm(ButtonRequestType_ButtonRequest_SignTx, "Discriminator", "%s", + disc_hex); + + /* One label per screen. A structurally valid schema can still lie by + * labelling the wrong offset ("Amount" over the order id), so the operator + * has to read every label — and confirm()'s body is three rendered rows with + * no pagination, so a batched list of max-length labels scrolls off. A label + * nobody saw is a label nobody checked. */ + for (uint8_t i = 0; confirmed && i < schema.num_args; i++) { + confirmed = confirm(ButtonRequestType_ButtonRequest_SignTx, "Attest Schema", + "Arg %u: %s\n%s", (unsigned)(i + 1), + attestor_schemaArgTypeName(schema.args[i].type), + schema.args[i].label); + } + for (uint8_t i = 0; confirmed && i < schema.num_accounts; i++) { + confirmed = + confirm(ButtonRequestType_ButtonRequest_SignTx, "Attest Schema", + "Account #%u shows\n%s", (unsigned)schema.accounts[i].index, + schema.accounts[i].label); + } + memzero(&schema, sizeof(schema)); + if (!confirmed) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + HDNode* node = attestor_getNode(); + if (!node) return; + + /* Plain ECDSA over SHA256(payload): exactly what + * signed_metadata_verify_attestation() checks on the verifying device. */ + uint8_t digest[32]; + sha256_Raw(msg->payload.bytes, msg->payload.size, digest); + + uint8_t sig[64]; + int ret = + ecdsa_sign_digest(&secp256k1, node->private_key, digest, sig, NULL, NULL); + memzero(digest, sizeof(digest)); + if (ret != 0) { + memzero(node, sizeof(*node)); + memzero(sig, sizeof(sig)); + fsm_sendFailure(FailureType_Failure_Other, "Attestation failed"); + layoutHome(); + return; + } + + resp->has_signature = true; + resp->signature.size = sizeof(sig); + memcpy(resp->signature.bytes, sig, sizeof(sig)); + resp->has_public_key = true; + resp->public_key.size = 33; + memcpy(resp->public_key.bytes, node->public_key, 33); + + memzero(sig, sizeof(sig)); + memzero(node, sizeof(*node)); + + msg_write(MessageType_MessageType_ClearsignAttestorSignature, resp); + layoutHome(); +} diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 42add93e0..7f749e91a 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -182,3 +182,7 @@ MSG_IN(MessageType_MessageType_EthereumTxMetadata, EthereumTxMetadata, fsm_msgEthereumTxMetadata) MSG_OUT(MessageType_MessageType_EthereumMetadataAck, EthereumMetadataAck, NO_PROCESS_FUNC) MSG_IN(MessageType_MessageType_LoadClearsignSigner, LoadClearsignSigner, fsm_msgLoadClearsignSigner) + MSG_IN(MessageType_MessageType_ClearsignAttestorGetPublicKey, ClearsignAttestorGetPublicKey, fsm_msgClearsignAttestorGetPublicKey) + MSG_OUT(MessageType_MessageType_ClearsignAttestorPublicKey, ClearsignAttestorPublicKey, NO_PROCESS_FUNC) + MSG_IN(MessageType_MessageType_ClearsignAttestorSign, ClearsignAttestorSign, fsm_msgClearsignAttestorSign) + MSG_OUT(MessageType_MessageType_ClearsignAttestorSignature, ClearsignAttestorSignature, NO_PROCESS_FUNC) diff --git a/scripts/emulator/capture-clearsign-attestor.py b/scripts/emulator/capture-clearsign-attestor.py new file mode 100644 index 000000000..2d5f3dfc3 --- /dev/null +++ b/scripts/emulator/capture-clearsign-attestor.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Capture the maximum-boundary ClearSign attestor screens from kkemu. + +This is an emulator evidence tool, not a hardware provisioning tool. It wipes +and initializes the emulator connected at the supplied UDP endpoints. +""" + +import argparse +import importlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + + +# The pinned python-keepkey uses legacy generated descriptors, while the +# current device protocol is generated on demand below. Both compatibility +# switches must be set before either protobuf module is imported. +os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") +os.environ.setdefault("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK", "true") + +ROOT = Path(__file__).resolve().parents[2] +PYTHON_KEEPKEY = ROOT / "deps" / "python-keepkey" +DEVICE_PROTOCOL = ROOT / "deps" / "device-protocol" +ZOO_SCRIPTS = ROOT / "scripts" / "zoo" + +PROGRAM_ID = "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2" +PROGRAM_BYTES = bytes.fromhex( + "792689378ecd51d80406eb0caa3b62795beb10b6c5dc96bc2e0df03cbfee1abf" +) +DISCRIMINATOR = bytes.fromhex("0d9e0ddf5fd51c06") +PROGRAM_NAME = "Boundary Program 123" +INSTRUCTION_NAME = "Review All Types 123" +ARGUMENTS = ( + (1, "u64 LE", "Amount1234567890"), + (2, "u8", "Flag123456789012"), + (3, "public key", "RecipientPubKey1"), + (4, "bytes32 hex", "OrderHash1234567"), +) +ACCOUNT_INDEX = 7 +ACCOUNT_LABEL = "VaultAccount1234" +SCREEN_NAMES = ( + "01-schema-identity.png", + "02-program-id-44chars.png", + "03-discriminator-8bytes.png", + "04-arg-u64-le-16char-label.png", + "05-arg-u8-16char-label.png", + "06-arg-public-key-16char-label.png", + "07-arg-bytes32-hex-16char-label.png", + "08-account-16char-label.png", +) + + +def generate_current_protocol(): + generated = tempfile.TemporaryDirectory(prefix="kk-attestor-proto-") + subprocess.run( + [ + "protoc", + "-I", + str(DEVICE_PROTOCOL), + "--python_out=" + generated.name, + str(DEVICE_PROTOCOL / "types.proto"), + str(DEVICE_PROTOCOL / "messages.proto"), + ], + check=True, + ) + sys.path.insert(0, generated.name) + module = importlib.import_module("messages_pb2") + return generated, module + + +def length_prefixed_text(value): + raw = value.encode("ascii") + if not 1 <= len(raw) <= 255: + raise ValueError("schema text length out of bounds") + return bytes([len(raw)]) + raw + + +def boundary_schema(): + payload = bytearray(b"KKSOLSC1") + payload.append(1) + payload.extend(PROGRAM_BYTES) + payload.append(len(DISCRIMINATOR)) + payload.extend(DISCRIMINATOR) + if len(PROGRAM_NAME) != 20 or len(INSTRUCTION_NAME) != 20: + raise ValueError( + "boundary program and instruction names must be 20 characters" + ) + payload.extend(length_prefixed_text(PROGRAM_NAME)) + payload.extend(length_prefixed_text(INSTRUCTION_NAME)) + payload.append(len(ARGUMENTS)) + for arg_type, _display_type, label in ARGUMENTS: + if len(label) != 16: + raise ValueError("boundary argument labels must be 16 characters") + payload.append(arg_type) + payload.extend(length_prefixed_text(label)) + if len(ACCOUNT_LABEL) != 16: + raise ValueError("boundary account label must be 16 characters") + payload.append(1) + payload.append(ACCOUNT_INDEX) + payload.extend(length_prefixed_text(ACCOUNT_LABEL)) + return bytes(payload) + + +def git_revision(path): + return subprocess.check_output( + ["git", "-C", str(path), "rev-parse", "HEAD"], text=True + ).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, help="directory for PNG evidence") + parser.add_argument( + "--main", + default=os.environ.get("KK_TRANSPORT_MAIN", "127.0.0.1:11044"), + help="kkemu main UDP endpoint", + ) + parser.add_argument( + "--debug", + default=os.environ.get("KK_TRANSPORT_DEBUG", "127.0.0.1:11045"), + help="kkemu debug UDP endpoint", + ) + args = parser.parse_args() + + generated, attestor_proto = generate_current_protocol() + try: + sys.path.insert(0, str(PYTHON_KEEPKEY)) + sys.path.insert(0, str(ZOO_SCRIPTS)) + + from keepkeylib import mapping + from keepkeylib import messages_pb2 as proto + from keepkeylib.client import KeepKeyDebuglinkClient + from keepkeylib.transport_udp import UDPTransport + from screenshot import capture_screenshot + + # The deliberately older pinned host package has no attestor wrappers. + # Register only the new request/response wire classes; framing, + # confirmations, and DebugLink remain the pinned host implementation. + mapping.map_class_to_type[attestor_proto.ClearsignAttestorSign] = 1702 + mapping.map_type_to_class[1703] = attestor_proto.ClearsignAttestorSignature + + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + client = KeepKeyDebuglinkClient(UDPTransport(args.main)) + client.set_debuglink(UDPTransport(args.debug)) + + client.auto_button = True + client.wipe_device() + client.load_device_by_mnemonic( + mnemonic=("all " * 11 + "all").strip(), + pin="", + passphrase_protection=False, + label="RC21 OLED Gate", + language="english", + ) + client.apply_policy("AdvancedMode", 1) + client.auto_button = False + + response = client.call_raw( + attestor_proto.ClearsignAttestorSign(payload=boundary_schema()) + ) + for index, name in enumerate(SCREEN_NAMES): + if not isinstance(response, proto.ButtonRequest): + raise RuntimeError( + "screen %d expected ButtonRequest, got %s" + % (index + 1, type(response).__name__) + ) + time.sleep(0.2) + path = output / name + if not capture_screenshot(client.debug, str(path), scale=3): + raise RuntimeError("failed to capture " + name) + print(path) + client.debug.press_yes() + response = client.call_raw(proto.ButtonAck()) + + if not isinstance(response, attestor_proto.ClearsignAttestorSignature): + raise RuntimeError( + "expected attestor signature, got " + type(response).__name__ + ) + if len(response.signature) != 64 or len(response.public_key) != 33: + raise RuntimeError("attestor returned malformed key or signature") + + manifest = { + "firmware_commit": git_revision(ROOT), + "device_protocol_commit": git_revision(DEVICE_PROTOCOL), + "program_name": PROGRAM_NAME, + "program_name_characters": len(PROGRAM_NAME), + "instruction_name": INSTRUCTION_NAME, + "instruction_name_characters": len(INSTRUCTION_NAME), + "program_id": PROGRAM_ID, + "program_id_characters": len(PROGRAM_ID), + "discriminator_hex": DISCRIMINATOR.hex(), + "discriminator_bytes": len(DISCRIMINATOR), + "arguments": [ + {"type": display_type, "label": label, "label_characters": len(label)} + for _arg_type, display_type, label in ARGUMENTS + ], + "account": { + "index": ACCOUNT_INDEX, + "label": ACCOUNT_LABEL, + "label_characters": len(ACCOUNT_LABEL), + }, + "screens": list(SCREEN_NAMES), + "attestation_signature_bytes": len(response.signature), + "attestation_public_key_bytes": len(response.public_key), + } + with (output / "manifest.json").open("w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + print("captured %d screens; attestation completed" % len(SCREEN_NAMES)) + finally: + generated.cleanup() + + +if __name__ == "__main__": + main() From 6c27e119e8ee67e73f39125e336606d617741bd5 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 16:16:37 -0300 Subject: [PATCH 11/40] fix(eip712): reject overflow widths and wrong array sizes --- lib/firmware/eip712.c | 91 ++++++++++++++++++++++++++--------- unittests/firmware/eip712.cpp | 41 ++++++++++++++++ 2 files changed, 110 insertions(+), 22 deletions(-) diff --git a/lib/firmware/eip712.c b/lib/firmware/eip712.c index 84da58083..39497c7b1 100644 --- a/lib/firmware/eip712.c +++ b/lib/firmware/eip712.c @@ -58,11 +58,64 @@ static bool append_type_string(char* dest, const char* value) { return true; } -static bool type_array_suffix_is_valid(const char* suffix) { +static bool parse_bounded_decimal(const char** cursor, size_t limit, + size_t* value) { + const char* p = *cursor; + if (*p < '0' || *p > '9') return false; + + size_t parsed = 0; + while (*p >= '0' && *p <= '9') { + const size_t digit = (size_t)(*p - '0'); + if (parsed > (limit - digit) / 10) return false; + parsed = parsed * 10 + digit; + p++; + } + *cursor = p; + *value = parsed; + return true; +} + +static bool parse_array_suffix(const char* suffix, bool* fixed, + size_t* expected) { + *fixed = false; + *expected = 0; if (*suffix == '\0') return true; if (*suffix++ != '[') return false; - while (*suffix >= '0' && *suffix <= '9') suffix++; - return suffix[0] == ']' && suffix[1] == '\0'; + if (*suffix == ']') return suffix[1] == '\0'; + + size_t count = 0; + if (!parse_bounded_decimal(&suffix, (size_t)-1, &count) || count == 0 || + suffix[0] != ']' || suffix[1] != '\0') { + return false; + } + *fixed = true; + *expected = count; + return true; +} + +static bool type_array_suffix_is_valid(const char* suffix) { + bool fixed = false; + size_t expected = 0; + return parse_array_suffix(suffix, &fixed, &expected); +} + +static bool fixed_array_cardinality_matches(const char* type, + const json_t* value) { + const char* suffix = strchr(type, '['); + if (!suffix) return true; + + bool fixed = false; + size_t expected = 0; + if (!parse_array_suffix(suffix, &fixed, &expected)) return false; + if (!fixed) return true; + if (json_getType(value) != JSON_ARRAY) return false; + + size_t actual = 0; + for (const json_t* element = json_getChild(value); element; + element = json_getSibling(element)) { + if (++actual > expected) return false; + } + return actual == expected; } static bool type_matches(const char* type, const char* base) { @@ -75,12 +128,9 @@ static bool type_is_integer(const char* type, const char* prefix) { const size_t prefix_len = strlen(prefix); if (strncmp(type, prefix, prefix_len) != 0) return false; const char* p = type + prefix_len; - unsigned bits = 0; - bool has_bits = false; - while (*p >= '0' && *p <= '9') { - has_bits = true; - bits = bits * 10 + (unsigned)(*p++ - '0'); - } + size_t bits = 0; + const bool has_bits = *p >= '0' && *p <= '9'; + if (has_bits && !parse_bounded_decimal(&p, 256, &bits)) return false; if (has_bits && (bits < 8 || bits > 256 || (bits % 8) != 0)) return false; return type_array_suffix_is_valid(p); } @@ -88,11 +138,9 @@ static bool type_is_integer(const char* type, const char* prefix) { static unsigned integer_type_width(const char* type, const char* prefix) { const char* p = type + strlen(prefix); if (*p < '0' || *p > '9') return 256; - unsigned bits = 0; - while (*p >= '0' && *p <= '9') { - bits = bits * 10 + (unsigned)(*p++ - '0'); - } - return bits; + size_t bits = 0; + if (!parse_bounded_decimal(&p, 256, &bits)) return 256; + return (unsigned)bits; } static bool type_is_bytes(const char* type, unsigned* byte_size, @@ -105,15 +153,11 @@ static bool type_is_bytes(const char* type, unsigned* byte_size, *dynamic = true; return true; } - unsigned size = 0; - bool has_size = false; - while (*p >= '0' && *p <= '9') { - has_size = true; - size = size * 10 + (unsigned)(*p++ - '0'); - } - if (!has_size || size == 0 || size > 32 || !type_array_suffix_is_valid(p)) + size_t size = 0; + if (!parse_bounded_decimal(&p, 32, &size) || size == 0 || + !type_array_suffix_is_valid(p)) return false; - *byte_size = size; + *byte_size = (unsigned)size; *dynamic = false; return true; } @@ -533,6 +577,9 @@ int parseVals(const json_t* eip712Types, const json_t* jType, return JSON_TYPE_WNOVAL; } const jsonType_t value_type = json_getType(walkVals); + if (!fixed_array_cardinality_matches(typeType, walkVals)) { + return GENERAL_ERROR; + } const bool hasValue = value_type == JSON_TEXT || value_type == JSON_INTEGER || value_type == JSON_BOOLEAN; diff --git a/unittests/firmware/eip712.cpp b/unittests/firmware/eip712.cpp index c1c8d4447..2b5c093cd 100644 --- a/unittests/firmware/eip712.cpp +++ b/unittests/firmware/eip712.cpp @@ -47,9 +47,50 @@ TEST(EIP712, FixedBytesRequireExactDeclaredLength) { EXPECT_NE(SUCCESS, encodeBytesN("bytes4", "0x0011aaff00", encoded)); EXPECT_NE(SUCCESS, encodeBytesN("bytes0", "0x", encoded)); EXPECT_NE(SUCCESS, encodeBytesN("bytes33", "0x", encoded)); + EXPECT_NE(SUCCESS, encodeBytesN("bytes4294967297", "0x00", encoded)); EXPECT_NE(SUCCESS, encodeBytesN("bytes4x", "0x0011aaff", encoded)); } +TEST(EIP712, IntegerWidthsCannotWrapIntoValidTypes) { + char types_json[] = + "{\"types\":{\"Test\":[{\"name\":\"value\"," + "\"type\":\"uint4294967552\"}]}}"; + char values_json[] = "{\"message\":{\"value\":\"1\"}}"; + json_t type_nodes[12] = {}; + json_t value_nodes[8] = {}; + const json_t* types = json_create(types_json, type_nodes, 12); + const json_t* values = json_create(values_json, value_nodes, 8); + ASSERT_NE(nullptr, types); + ASSERT_NE(nullptr, values); + + uint8_t hash[32] = {}; + EXPECT_NE(SUCCESS, encode(types, values, "Test", hash)); +} + +TEST(EIP712, FixedStructArraysRequireExactCardinality) { + char types_json[] = + "{\"types\":{" + "\"Person\":[{\"name\":\"name\",\"type\":\"string\"}]," + "\"Group\":[{\"name\":\"members\",\"type\":\"Person[2]\"}]}}"; + char too_few_json[] = "{\"message\":{\"members\":[{\"name\":\"Alice\"}]}}"; + char too_many_json[] = + "{\"message\":{\"members\":[{\"name\":\"Alice\"}," + "{\"name\":\"Bob\"},{\"name\":\"Carol\"}]}}"; + json_t type_nodes[24] = {}; + json_t too_few_nodes[12] = {}; + json_t too_many_nodes[20] = {}; + const json_t* types = json_create(types_json, type_nodes, 24); + const json_t* too_few = json_create(too_few_json, too_few_nodes, 12); + const json_t* too_many = json_create(too_many_json, too_many_nodes, 20); + ASSERT_NE(nullptr, types); + ASSERT_NE(nullptr, too_few); + ASSERT_NE(nullptr, too_many); + + uint8_t hash[32] = {}; + EXPECT_NE(SUCCESS, encode(types, too_few, "Group", hash)); + EXPECT_NE(SUCCESS, encode(types, too_many, "Group", hash)); +} + TEST(EIP712, MissingTypedValueFailsWithoutDereferencingNull) { char types_json[] = "{\"types\":{\"Mail\":[{\"name\":\"from\",\"type\":\"address\"}," From e109404ee359375063a7024c240365d636984552 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:24:05 -0300 Subject: [PATCH 12/40] feat(storage): PIN KDF hardening, seed lock, BIP-85 and recovery fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage: - The PIN key-derivation function is versioned so its cost can be raised without breaking existing wallets, with the migration path documented in docs/security/pin-kdf-v19-migration.md. - A wallet created by bitcoin-only firmware is stamped, and multi-chain firmware refuses to touch it (storage_isBitcoinOnlyLocked) rather than silently operating on a wallet whose owner chose a reduced attack surface. - Storage layout reserves the clear-sign identity block and zeroes it on read and on wipe, so nothing there can outlive a factory reset. - Orchard key derivation and seed fingerprinting are reachable through storage with progress reporting, guarded by the privacy build flag. - docs/security/anti-rollback-security-epoch-rfc.md records the proposed security-epoch scheme; it is a design note, nothing here implements it. BIP-85: - GetBip85Mnemonic derives a child mnemonic and displays it under constant power, with PIN and initialization checks. Always available — it is a seed derivation, not a coin engine. Recovery and authenticator: - The cipher-recovery wordlist permutation now borrows the shared frame arena instead of another multi-kilobyte stack buffer. - wipeAuthData reports failure instead of always claiming success, a cancelled authenticator action is distinguishable from an error, and a cancelled passphrase prompt aborts the request instead of continuing without one. --- .../anti-rollback-security-epoch-rfc.md | 103 ++++++ docs/security/pin-kdf-v19-migration.md | 78 +++++ include/keepkey/firmware/authenticator.h | 5 +- include/keepkey/firmware/bip85.h | 22 ++ include/keepkey/firmware/fsm.h | 2 + include/keepkey/firmware/storage.h | 20 +- lib/firmware/CMakeLists.txt | 1 + lib/firmware/authenticator.c | 105 ++++-- lib/firmware/bip85.c | 105 ++++++ lib/firmware/fsm.c | 2 + lib/firmware/fsm_msg_bip85.h | 142 ++++++++ lib/firmware/fsm_msg_common.h | 12 +- lib/firmware/messagemap.def | 2 + lib/firmware/recovery_cipher.c | 81 ++++- lib/firmware/storage.c | 307 ++++++++++++++++-- lib/firmware/storage.h | 44 ++- lib/firmware/storage_versions.inc | 4 +- unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/authenticator.cpp | 111 +++++++ unittests/firmware/storage.cpp | 220 ++++++++++++- 20 files changed, 1267 insertions(+), 100 deletions(-) create mode 100644 docs/security/anti-rollback-security-epoch-rfc.md create mode 100644 docs/security/pin-kdf-v19-migration.md create mode 100644 include/keepkey/firmware/bip85.h create mode 100644 lib/firmware/bip85.c create mode 100644 lib/firmware/fsm_msg_bip85.h create mode 100644 unittests/firmware/authenticator.cpp diff --git a/docs/security/anti-rollback-security-epoch-rfc.md b/docs/security/anti-rollback-security-epoch-rfc.md new file mode 100644 index 000000000..4b9922be6 --- /dev/null +++ b/docs/security/anti-rollback-security-epoch-rfc.md @@ -0,0 +1,103 @@ +# RFC: OTP-backed firmware security epochs + +Status: design required; no production implementation is authorized by this +document. + +## Security invariant + +After a device accepts an official firmware image in security epoch `N`, no +officially signed image with an epoch lower than `N` may be installed or booted. +A power loss must leave the device able to boot either the previous accepted +image or the new accepted image; it must never advance the floor before the new +image has passed all integrity and signature checks. + +Semantic versions are not the monotonic value. Patch and release-candidate +numbers are allowed to move independently; the security epoch advances only +when an older signed image must be permanently revoked. + +## Why ordinary flash is insufficient + +The bootloader can erase and rewrite application flash, and the attacker in +this threat model is deliberately installing an older valid image. A floor +stored beside mutable firmware or normal storage can be restored with the old +image and does not establish monotonicity. + +The STM32F2 OTP region exposes sixteen 32-byte blocks. Current source assigns +manufacturing data to block 0, model data to block 1, and hardware entropy to +block 3. Before choosing any remaining block, manufacturing images and all +shipping board revisions must be audited; absence of a source reference is not +proof that a factory process never programmed it. + +## Proposed representation + +Reserve one audited OTP block as a 256-step unary counter. Epoch `N` is encoded +by programming the first `N` bits from 1 to 0. The decoded epoch is the length +of the contiguous programmed prefix. + +Reject the OTP state if a programmed bit appears after an unprogrammed bit. +This catches torn or non-canonical values instead of interpreting them as a +lower floor. Do not lock the block after each update; the OTP 1-to-0 property is +the monotonic mechanism. + +The signed application metadata needs a dedicated epoch field covered by the +existing firmware signatures. Reusing undocumented `meta_flags` bits is only +acceptable after confirming every bootloader generation parses and signs the +same bytes. A new metadata format with an explicit compatibility version is +preferred. + +## Update state machine + +1. Parse the candidate metadata without trusting it. +2. Verify image bounds, hash, and the complete 3-of-N signature policy. +3. Decode the current OTP floor and reject malformed OTP. +4. Reject `candidate_epoch < floor` before erasing the installed image. +5. Write the candidate while preserving the existing storage-protection + contract. +6. Re-read and verify the flashed image from flash. +7. If `candidate_epoch > floor`, program and verify each required OTP bit. +8. Install the application magic only after image and epoch verification. +9. At every boot, reject an installed image whose epoch is below the OTP floor. + +Unsigned/user-approved firmware must never advance the official floor. The RFC +must decide whether such firmware may boot at all once a floor is active; either +choice needs an explicit user-facing recovery story. + +## Fault-injection requirements + +- Accumulate signature results and validate sentinels as the current verifier + does; do not add a single skippable epoch branch after signature validation. +- Read the OTP floor more than once with independent control-flow checks before + an irreversible write. +- Verify every programmed bit and halt on disagreement. +- Ensure a glitch cannot turn malformed OTP into epoch zero. +- Include the epoch in the host-visible bootloader features and release + evidence so operators can diagnose state without trusting firmware. + +## Compatibility and rollout + +This requires a bootloader campaign. Application-only deployment cannot protect +devices whose installed bootloader ignores epochs. + +1. Inventory bootloader versions in the field and their update paths. +2. Prototype with a non-production test block on sacrificial devices. +3. Ship epoch-aware bootloader code with floor zero and no OTP advancement. +4. Confirm update, downgrade, unsigned-firmware, storage-preservation, and + recovery behavior on each hardware revision. +5. Audit factory OTP contents and permanently reserve the selected block. +6. Only a later release may advance epoch one. + +## Required tests + +- candidate epoch below/equal/above floor; +- malformed non-contiguous OTP patterns; +- exhausted 256-step counter; +- signature failure with a higher claimed epoch; +- unsigned firmware with a higher claimed epoch; +- hash mismatch after flash write; +- power loss before erase, during image write, after image verification, during + OTP programming, and before application magic installation; +- boot of an installed image below the floor; and +- recovery-mode behavior when no eligible application remains. + +The implementation PR must include a negative control showing that removing the +floor comparison permits a signed lower-epoch image. diff --git a/docs/security/pin-kdf-v19-migration.md b/docs/security/pin-kdf-v19-migration.md new file mode 100644 index 000000000..245bbb37b --- /dev/null +++ b/docs/security/pin-kdf-v19-migration.md @@ -0,0 +1,78 @@ +# PIN KDF v19 migration + +Status: draft implementation for review and hardware benchmarking + +Baseline: `BitHighlander/keepkey-firmware` `develop` at +`21d6a9d100b16566a1e48899abbbb7bab9366187` + +## Security goal + +Storage v16 reduced the production PBKDF2 work factor used to wrap the storage +key from 100,000 iterations to 10. A flash image therefore leaves a short PIN +with almost no cryptographic work factor if readout protection is bypassed. + +Storage v19 restores the production PIN work factor to 100,000 iterations. The +emulator and debug configurations use 1,000 iterations so the unit suite stays +practical. The change only covers the user PIN wrapping key; wipe-code and +authdata derivation remain on their existing parameters and need separate, +versioned migrations. + +## Compatibility invariant + +Existing wallets must always be unwrapped with the parameters that originally +wrapped them. The firmware must not rewrite a wallet until a correct PIN has +successfully authenticated the decrypted storage key. + +V19 therefore adds an explicit `pin_kdf_v2` storage flag instead of changing +the meaning of the existing v15/v16 flag: + +| Persistent state | KDF used to verify PIN | Action after correct PIN | +| --- | --- | --- | +| `pin_kdf_v2` | v19 | none | +| v16 transition flag only | v16 | rewrap with v19 and set `pin_kdf_v2` | +| neither flag | v15 | rewrap with v19 and set both transition flags | + +An incorrect PIN never changes the wrapped key or migration flags. New PINs +are wrapped directly with the v19 parameters. + +The v19 flag occupies bit 20 of the existing public-storage flags word. The +serialized byte length is unchanged. A v18 reader deliberately ignores this +bit; a v19 reader restores it. + +## Release ordering + +Do not ship this migration in a production release until the downgrade policy +is enforced. Older firmware does not understand storage version 19 or its KDF +flag. Allowing a device to boot an older signed image after migration risks a +wallet lockout, destructive recovery behavior, or accidental reinterpretation +of the storage record. + +The intended order is: + +1. Agree on and implement the anti-rollback security-epoch design in the + bootloader. +2. Prove the bootloader update and interruption behavior on real devices. +3. Benchmark the 100,000-iteration PIN path on supported KeepKey hardware. +4. Exercise v15, v16, and v18 migrations through wrong PIN, correct PIN, + interrupted commit, reboot, and recovery flows. +5. Enable v19 only in a release whose minimum security epoch rejects firmware + that cannot read it. + +## Required evidence + +- Unit tests prove the production v16-to-v19 rewrap path and the v19 selector. +- A negative control that disables rewrapping makes the regression test fail. +- A wrong PIN leaves the wrapped key and all migration flags unchanged. +- V19 round-trips the new flag; the V18 reader ignores it. +- Full emulator unit suites pass from a clean build. +- Hardware timing includes minimum, median, and maximum unlock latency across + supported board revisions and temperature/power conditions. +- Power-loss testing covers every write boundary during the rewrap commit. +- Downgrade attempts after migration fail closed without modifying storage. + +## Non-goals + +This change does not make short PINs equivalent to high-entropy secrets, add a +secure element, or prevent offline guessing after arbitrary flash extraction. +It restores a material software work factor while the hardware architecture +continues to rely on STM32 readout protection and write protection. diff --git a/include/keepkey/firmware/authenticator.h b/include/keepkey/firmware/authenticator.h index bb9981f68..a2fdf8775 100644 --- a/include/keepkey/firmware/authenticator.h +++ b/include/keepkey/firmware/authenticator.h @@ -26,6 +26,7 @@ #define ACCOUNT_SIZE 12 // allow 11 chars for account string #define AUTHSECRET_SIZE_MAX \ 20 // 128-bit key len is the recommended minimum, this is room for 160-bit +#define AUTHSECRET_SIZE_MIN 16 // reject brute-forceable TOTP secrets #define AUTHDATA_SIZE \ 10 // WARNING: This value must be coordinated with the size of uint8_t // encrypted_sec[] in in lib/firmware/storage.h and the storage version @@ -41,6 +42,8 @@ enum AUTH_ERR_TYPE { LARGESEED, BADPASS, UNKERR, + DUPLICATE, + AUTH_CANCELLED, NUM_AUTHERRS }; @@ -68,7 +71,7 @@ unsigned generateOTP(char* accountWithMsg, char otpStr[]); unsigned addAuthAccount(char* accountWithSeed); unsigned getAuthAccount(const char* slotStr, char acc[]); unsigned removeAuthAccount(char* domAcc); -void wipeAuthData(void); +unsigned wipeAuthData(void); #if DEBUG_LINK void getAuthSlot(char* authSlotData); #endif diff --git a/include/keepkey/firmware/bip85.h b/include/keepkey/firmware/bip85.h new file mode 100644 index 000000000..73c197995 --- /dev/null +++ b/include/keepkey/firmware/bip85.h @@ -0,0 +1,22 @@ +#ifndef BIP85_H +#define BIP85_H + +#include +#include +#include + +/** + * Derive a child BIP-39 mnemonic via BIP-85. + * + * Path: m/83696968'/39'/0'/'/' + * + * @param word_count Number of words: 12, 18, or 24. + * @param index Child index (0-based). + * @param mnemonic Output buffer (must be at least 241 bytes). + * @param mnemonic_len Size of the output buffer. + * @return true on success, false on error. + */ +bool bip85_derive_mnemonic(uint32_t word_count, uint32_t index, char *mnemonic, + size_t mnemonic_len); + +#endif diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index 5e5e437c9..35a53251e 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -148,4 +148,6 @@ void fsm_msgFlashWrite(FlashWrite* msg); void fsm_msgFlashHash(FlashHash* msg); void fsm_msgSoftReset(SoftReset* msg); +void fsm_msgGetBip85Mnemonic(const GetBip85Mnemonic* msg); + #endif diff --git a/include/keepkey/firmware/storage.h b/include/keepkey/firmware/storage.h index 9fc8c1954..1df5f7fa1 100644 --- a/include/keepkey/firmware/storage.h +++ b/include/keepkey/firmware/storage.h @@ -26,7 +26,19 @@ #include "keepkey/firmware/authenticator.h" #define STORAGE_VERSION \ - 17 /* Must add case fallthrough in storage_fromFlash after increment*/ + 19 /* Must add case fallthrough in storage_fromFlash after increment*/ + +/* A seed CREATED under bitcoin-only firmware is stamped with a version in a + * reserved band (base + the normal version). Multi-chain firmware that knows + * the band refuses to load it and requires an explicit wipe; older multi-chain + * firmware treats it as an unknown version and resets. Either way a seed born + * on bitcoin-only firmware is never usable by multi-chain code. A pre-existing + * multi-chain wallet keeps its normal version and stays portable (it was + * already multi-chain-exposed). Multi-chain versions MUST stay below the band + * forever (static-asserted in storage.c). */ +#define STORAGE_VERSION_BTC_ONLY_BASE 10000 +#define STORAGE_VERSION_BTC_ONLY \ + (STORAGE_VERSION_BTC_ONLY_BASE + STORAGE_VERSION) #define STORAGE_RETRIES 3 #define RANDOM_SALT_LEN 32 @@ -39,6 +51,12 @@ /// \brief Validate storage content and copy data to shadow memory. void storage_init(void); +/// \brief True iff flash holds storage written by bitcoin-only firmware that +/// this (multi-chain) firmware refuses to load. The device must be +/// wiped before it can be used; the seed stays intact in flash so +/// reflashing bitcoin-only firmware recovers the wallet. +bool storage_isBitcoinOnlyLocked(void); + /// \brief Reset configuration UUID with random numbers. void storage_resetUuid(void); diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index dcf21b9d4..7416b9524 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -3,6 +3,7 @@ set(sources app_layout.c authenticator.c binance.c + bip85.c coins.c crypto.c eip712.c diff --git a/lib/firmware/authenticator.c b/lib/firmware/authenticator.c index 851a48ad2..e07b0007c 100644 --- a/lib/firmware/authenticator.c +++ b/lib/firmware/authenticator.c @@ -57,6 +57,16 @@ static bool getAuthData(void) { static void setAuthData(void) { storage_setAuthData(authData); } +static bool authDisplayFieldValid(const char* value, size_t max_len) { + size_t len = strnlen(value, max_len + 1); + if (len == 0 || len > max_len) return false; + for (size_t i = 0; i < len; i++) { + uint8_t ch = (uint8_t)value[i]; + if (ch < 0x20 || ch > 0x7e) return false; + } + return true; +} + #if DEBUG_LINK static unsigned _otpSlot = 0; void getAuthSlot(char* authSlotData) { @@ -77,29 +87,31 @@ void getAuthSlot(char* authSlotData) { } #endif -void wipeAuthData(void) { - confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Wipe Authdata", - "Do you want to PERMANENTLY delete all authenticator accounts?\n If " - "not, unplug Keepkey now."); +unsigned wipeAuthData(void) { + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Wipe Authdata", + "Do you want to PERMANENTLY delete all authenticator accounts?")) + return AUTH_CANCELLED; // wipe storage and reset authdata encryption flag storage_wipeAuthData(); // wipe local copy memzero(authData, sizeof(authData)); localAuthdataUpdate = true; - return; + return NOERR; } unsigned addAuthAccount(char* accountWithSeed) { char *domain, *account, *seedStr; - unsigned slot; - char authSecret[AUTHSECRET_SIZE_MAX]; // 128-bit key len is the recommended - // minimum, this is room for 160-bit + unsigned slot = AUTHDATA_SIZE; + char authSecret[AUTHSECRET_SIZE_MAX] = { + 0}; // 128-bit key len is the recommended minimum, this is room for + // 160-bit size_t authSecretLen; + unsigned result = UNKERR; // accountWithSeed should be of the form "domain:account:seedStr" domain = strtok(accountWithSeed, ":"); // get the domain string token - if (NULL == domain) { + if (NULL == domain || !authDisplayFieldValid(domain, DOMAIN_SIZE - 1)) { return TOKERR; } @@ -107,7 +119,7 @@ unsigned addAuthAccount(char* accountWithSeed) { if (NULL == account) { return TOKERR; } - if (0 == strlen(account)) { + if (!authDisplayFieldValid(account, ACCOUNT_SIZE - 1)) { return TOKERR; } @@ -120,6 +132,9 @@ unsigned addAuthAccount(char* accountWithSeed) { } authSecretLen = base32_decoded_length(strlen(seedStr)); + if (authSecretLen < AUTHSECRET_SIZE_MIN) { + return BADSECRET; + } if (AUTHSECRET_SIZE_MAX < authSecretLen) { return LARGESEED; } @@ -128,11 +143,14 @@ unsigned addAuthAccount(char* accountWithSeed) { return BADPASS; // fingerprint did not match, passphrase incorrect } - // look for first empty slot - for (slot = 0; slot < AUTHDATA_SIZE; slot++) { - if (authData[slot].secretSize == 0) { - break; - } + // Reject duplicate identities and remember the first empty slot. Legacy + // duplicates are removed together by removeAuthAccount(). + for (unsigned i = 0; i < AUTHDATA_SIZE; i++) { + if (authData[i].secretSize != 0 && + strncmp(authData[i].domain, domain, DOMAIN_SIZE) == 0 && + strncmp(authData[i].account, account, ACCOUNT_SIZE) == 0) + return DUPLICATE; + if (slot == AUTHDATA_SIZE && authData[i].secretSize == 0) slot = i; } if (slot == AUTHDATA_SIZE) { return NOSLOT; // no empty slots @@ -141,12 +159,21 @@ unsigned addAuthAccount(char* accountWithSeed) { if (NULL == base32_decode((const char*)seedStr, strlen(seedStr), (uint8_t*)authSecret, sizeof(authSecret), BASE32_ALPHABET_RFC4648)) { - return BADSECRET; // bad decode + result = BADSECRET; + goto cleanup; } - confirm(ButtonRequestType_ButtonRequest_Other, "Confirm add account", - "Domain: %.*s\nAccount: %.*s\nSecret: %s", DOMAIN_SIZE, domain, - ACCOUNT_SIZE, account, seedStr); + // Keep the secret on its own screen. A 32-character base32 secret appended + // after domain/account can wrap past the OLED's three body rows, leaving the + // tail signed into storage but invisible to the user. + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Auth Account", + "Domain: %.*s\nAccount: %.*s", DOMAIN_SIZE, domain, ACCOUNT_SIZE, + account) || + !confirm(ButtonRequestType_ButtonRequest_Other, "TOTP Secret", "%s", + seedStr)) { + result = AUTH_CANCELLED; + goto cleanup; + } authData[slot].secretSize = authSecretLen; memcpy(authData[slot].authSecret, authSecret, authData[slot].secretSize); @@ -154,8 +181,11 @@ unsigned addAuthAccount(char* accountWithSeed) { strlcpy(authData[slot].account, account, ACCOUNT_SIZE); setAuthData(); + result = NOERR; - return NOERR; // success +cleanup: + memzero(authSecret, sizeof(authSecret)); + return result; } unsigned generateOTP(char* accountWithMsg, char otpStr[]) { @@ -297,18 +327,18 @@ unsigned getAuthAccount(const char* slotStr, char acc[]) { unsigned removeAuthAccount(char* domAcc) { char *domain, *account; - unsigned slot; + bool found = false; // accountWithSeed should be of the form "domain:account" domain = strtok(domAcc, ":"); // get the domain string token - if (NULL == domain) { + if (NULL == domain || !authDisplayFieldValid(domain, DOMAIN_SIZE - 1)) { return TOKERR; } account = strtok(NULL, ""); // get the account string token if (NULL == account) { return TOKERR; } - if (0 == strlen(account)) { + if (!authDisplayFieldValid(account, ACCOUNT_SIZE - 1)) { return TOKERR; } @@ -316,23 +346,30 @@ unsigned removeAuthAccount(char* domAcc) { return BADPASS; // fingerprint did not match, passphrase incorrect } - // find slot for account - for (slot = 0; slot < AUTHDATA_SIZE; slot++) { - if ((0 == strncmp(authData[slot].domain, domain, DOMAIN_SIZE - 1)) && - (0 == strncmp(authData[slot].account, account, ACCOUNT_SIZE - 1))) { - break; - } + // Find every matching slot. Older firmware allowed duplicate identities, so + // a confirmed deletion must remove all copies atomically. + for (unsigned slot = 0; slot < AUTHDATA_SIZE; slot++) { + if (authData[slot].secretSize != 0 && + strncmp(authData[slot].domain, domain, DOMAIN_SIZE) == 0 && + strncmp(authData[slot].account, account, ACCOUNT_SIZE) == 0) + found = true; } - if (slot == AUTHDATA_SIZE) { + if (!found) { return NOACC; // account not found } - confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Delete Account", - "Do you want to PERMANENTLY delete account %.*s:%.*s?", - DOMAIN_SIZE - 1, domain, ACCOUNT_SIZE - 1, account); + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Delete Account", + "Do you want to PERMANENTLY delete account %.*s:%.*s?", + DOMAIN_SIZE - 1, domain, ACCOUNT_SIZE - 1, account)) + return AUTH_CANCELLED; - memzero((void*)&authData[slot], sizeof(authType)); + for (unsigned slot = 0; slot < AUTHDATA_SIZE; slot++) { + if (authData[slot].secretSize != 0 && + strncmp(authData[slot].domain, domain, DOMAIN_SIZE) == 0 && + strncmp(authData[slot].account, account, ACCOUNT_SIZE) == 0) + memzero((void*)&authData[slot], sizeof(authType)); + } setAuthData(); return NOERR; // success } diff --git a/lib/firmware/bip85.c b/lib/firmware/bip85.c new file mode 100644 index 000000000..7d2ef6c81 --- /dev/null +++ b/lib/firmware/bip85.c @@ -0,0 +1,105 @@ +#include "keepkey/firmware/bip85.h" +#include "keepkey/firmware/storage.h" +#include "trezor/crypto/bip32.h" +#include "trezor/crypto/bip39.h" +#include "trezor/crypto/curves.h" +#include "trezor/crypto/hmac.h" +#include "trezor/crypto/memzero.h" + +#include + +/* + * BIP-85: Deterministic Entropy From BIP32 Keychains + * + * For BIP-39 mnemonic derivation: + * path = m / 83696968' / 39' / 0' / ' / ' + * k = derived_node.private_key (32 bytes) + * hmac = HMAC-SHA512(key="bip-entropy-from-k", msg=k) + * entropy = hmac[0 : entropy_bytes] + * 12 words -> 16 bytes, 18 words -> 24 bytes, 24 words -> 32 bytes + * mnemonic = bip39_from_entropy(entropy) + */ + +/* BIP-85 application number for deriving entropy from a key */ +static const uint8_t BIP85_HMAC_KEY[] = "bip-entropy-from-k"; +#define BIP85_HMAC_KEY_LEN 18 + +bool bip85_derive_mnemonic(uint32_t word_count, uint32_t index, char *mnemonic, + size_t mnemonic_len) { + /* Reject index >= 0x80000000 to avoid hardened-bit collision */ + if (index & 0x80000000) { + return false; + } + + /* Validate word count and compute entropy length */ + int entropy_bytes; + switch (word_count) { + case 12: + entropy_bytes = 16; + break; + case 18: + entropy_bytes = 24; + break; + case 24: + entropy_bytes = 32; + break; + default: + return false; + } + + /* BIP-85 derivation path: m/83696968'/39'/0'/'/' */ + uint32_t address_n[5]; + address_n[0] = 0x80000000 | 83696968; /* purpose (hardened) */ + address_n[1] = 0x80000000 | 39; /* BIP-39 app (hardened) */ + address_n[2] = 0x80000000; /* English language 0 (hardened) */ + address_n[3] = 0x80000000 | word_count; /* word count (hardened) */ + address_n[4] = 0x80000000 | index; /* child index (hardened) */ + + /* Get the master node from storage (respects passphrase) */ + static CONFIDENTIAL HDNode node; + if (!storage_getRootNode(SECP256K1_NAME, true, &node)) { + memzero(&node, sizeof(node)); + return false; + } + + /* Derive to the BIP-85 path */ + for (int i = 0; i < 5; i++) { + if (hdnode_private_ckd(&node, address_n[i]) == 0) { + memzero(&node, sizeof(node)); + return false; + } + } + + /* HMAC-SHA512(key="bip-entropy-from-k", msg=private_key) */ + static CONFIDENTIAL uint8_t hmac_out[64]; + hmac_sha512(BIP85_HMAC_KEY, BIP85_HMAC_KEY_LEN, node.private_key, 32, + hmac_out); + + /* We no longer need the derived node */ + memzero(&node, sizeof(node)); + + /* Truncate HMAC output to the required entropy length */ + static CONFIDENTIAL uint8_t entropy[32]; + memcpy(entropy, hmac_out, entropy_bytes); + memzero(hmac_out, sizeof(hmac_out)); + + /* Convert entropy to BIP-39 mnemonic */ + const char *words = mnemonic_from_data(entropy, entropy_bytes); + memzero(entropy, sizeof(entropy)); + + if (!words) { + return false; + } + + /* Copy to output buffer */ + size_t words_len = strlen(words); + if (words_len >= mnemonic_len) { + mnemonic_clear(); + return false; + } + + memcpy(mnemonic, words, words_len + 1); + mnemonic_clear(); + + return true; +} diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index a230deadb..7adaf0173 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -35,6 +35,7 @@ #include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/app_layout.h" #include "keepkey/firmware/authenticator.h" +#include "keepkey/firmware/bip85.h" #include "keepkey/firmware/coins.h" #include "keepkey/firmware/cosmos.h" #include "keepkey/firmware/binance.h" @@ -283,6 +284,7 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_nano.h" #include "fsm_msg_crypto.h" #include "fsm_msg_debug.h" +#include "fsm_msg_bip85.h" #include "fsm_msg_eos.h" #include "fsm_msg_cosmos.h" #include "fsm_msg_osmosis.h" diff --git a/lib/firmware/fsm_msg_bip85.h b/lib/firmware/fsm_msg_bip85.h new file mode 100644 index 000000000..48b343303 --- /dev/null +++ b/lib/firmware/fsm_msg_bip85.h @@ -0,0 +1,142 @@ +void fsm_msgGetBip85Mnemonic(const GetBip85Mnemonic *msg) { + CHECK_INITIALIZED + + /* Validate word count (required field, always present in nanopb) */ + if (msg->word_count != 12 && msg->word_count != 18 && msg->word_count != 24) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + "word_count must be 12, 18, or 24"); + layoutHome(); + return; + } + + /* Reject index >= 0x80000000 (hardened-bit collision) */ + if (msg->index & 0x80000000) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + "index must be less than 2147483648"); + layoutHome(); + return; + } + + CHECK_PIN + + /* User confirmation */ + char desc[80]; + snprintf(desc, sizeof(desc), "Derive %lu-word child seed at index %lu?", + (unsigned long)msg->word_count, (unsigned long)msg->index); + + if (!confirm(ButtonRequestType_ButtonRequest_Other, "BIP-85 Derive Seed", + "%s", desc)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "BIP-85 derivation cancelled"); + layoutHome(); + return; + } + + layout_simple_message("Deriving child seed..."); + + /* Derive the mnemonic */ + static CONFIDENTIAL char mnemonic_buf[241]; + if (!bip85_derive_mnemonic(msg->word_count, msg->index, mnemonic_buf, + sizeof(mnemonic_buf))) { + memzero(mnemonic_buf, sizeof(mnemonic_buf)); + fsm_sendFailure(FailureType_Failure_Other, "BIP-85 derivation failed"); + layoutHome(); + return; + } + + /* + * Display mnemonic on device screen only — never send over USB. + * Uses the same paginated display as the backup flow in reset.c. + */ + uint32_t word_count = 0, page_count = 0; + + /* Display scratch shared with the backup flow — see reset.h. Zero the whole + * set at entry per the sharing contract (a prior user may have aborted). */ + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + + strlcpy(mnemonic_scratch_tokened, mnemonic_buf, TOKENED_MNEMONIC_BUF); + memzero(mnemonic_buf, sizeof(mnemonic_buf)); + + const char *tok = strtok(mnemonic_scratch_tokened, " "); + + while (tok) { + snprintf(mnemonic_scratch_word, MAX_WORD_LEN + ADDITIONAL_WORD_PAD, + (word_count & 1) ? "%lu.%s\n" : "%lu.%s", + (unsigned long)(word_count + 1), tok); + + /* Check that we have enough room on display to show word */ + snprintf(mnemonic_scratch_display, FORMATTED_MNEMONIC_BUF, "%s %s", + mnemonic_scratch_formatted[page_count], mnemonic_scratch_word); + + if (calc_str_line(get_body_font(), mnemonic_scratch_display, BODY_WIDTH) > + 3) { + page_count++; + + if (MAX_PAGES <= page_count) { + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + fsm_sendFailure(FailureType_Failure_Other, + "Too many pages of mnemonic words"); + layoutHome(); + return; + } + + snprintf(mnemonic_scratch_display, FORMATTED_MNEMONIC_BUF, "%s %s", + mnemonic_scratch_formatted[page_count], mnemonic_scratch_word); + } + + strlcpy(mnemonic_scratch_formatted[page_count], mnemonic_scratch_display, + FORMATTED_MNEMONIC_BUF); + + tok = strtok(NULL, " "); + word_count++; + } + + /* Switch from 0-indexing to 1-indexing */ + page_count++; + + display_constant_power(true); + + /* Show each page of the mnemonic on screen */ + for (uint32_t current_page = 0; current_page < page_count; current_page++) { + char title[MEDIUM_STR_BUF]; + + if (page_count > 1) { + snprintf(title, MEDIUM_STR_BUF, "BIP-85 Seed %" PRIu32 "/%" PRIu32, + current_page + 1, page_count); + } else { + snprintf(title, MEDIUM_STR_BUF, "BIP-85 Seed"); + } + + if (!confirm_constant_power(ButtonRequestType_ButtonRequest_ConfirmWord, + title, "%s", + mnemonic_scratch_formatted[current_page])) { + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + display_constant_power(false); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "BIP-85 display cancelled"); + layoutHome(); + return; + } + } + + display_constant_power(false); + + /* Wipe all sensitive buffers */ + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + + /* Send success — mnemonic is NOT sent over the wire */ + fsm_sendSuccess("BIP-85 seed displayed on device"); + layoutHome(); +} diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 558f6673f..d1ba0eed9 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -166,13 +166,14 @@ static bool isValidModelNumber(const char* model) { return false; } -void checkPassphrase(void) { +static bool checkPassphrase(void) { if (!passphrase_protect()) { fsm_sendFailure(FailureType_Failure_ActionCancelled, "authenticator needs passphrase"); layoutHome(); - return; + return false; } + return true; } void fsm_msgPing(Ping* msg) { @@ -198,6 +199,8 @@ void fsm_msgPing(Ping* msg) { "Authenticator secret seed too large", "passphrase incorrect for authdata", "Auth secret unknown error", + "Authenticator account already exists", + "Authenticator action cancelled", }; typedef enum _AUTH_MSG_TYPE { @@ -238,7 +241,7 @@ void fsm_msgPing(Ping* msg) { 0}; // allow room for domain + ":" + account CHECK_PIN - checkPassphrase(); + if (!checkPassphrase()) return; switch (authMsg) { case INITAUTH: @@ -277,8 +280,7 @@ void fsm_msgPing(Ping* msg) { break; case WIPEADATA: - wipeAuthData(); - errcode = NOERR; + errcode = wipeAuthData(); resp->has_message = false; break; diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 7f749e91a..12b50bf18 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -73,6 +73,8 @@ MSG_IN(MessageType_MessageType_MayachainSignTx, MayachainSignTx, fsm_msgMayachainSignTx) MSG_IN(MessageType_MessageType_MayachainMsgAck, MayachainMsgAck, fsm_msgMayachainMsgAck) + MSG_IN(MessageType_MessageType_GetBip85Mnemonic, GetBip85Mnemonic, fsm_msgGetBip85Mnemonic) + /* Normal Out Messages */ MSG_OUT(MessageType_MessageType_Success, Success, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_Failure, Failure, NO_PROCESS_FUNC) diff --git a/lib/firmware/recovery_cipher.c b/lib/firmware/recovery_cipher.c index 0db3d1616..acfade212 100644 --- a/lib/firmware/recovery_cipher.c +++ b/lib/firmware/recovery_cipher.c @@ -53,6 +53,13 @@ static CONFIDENTIAL char mnemonic[MNEMONIC_BUF]; static char english_alphabet[ENGLISH_ALPHABET_BUF] = "abcdefghijklmnopqrstuvwxyz"; static CONFIDENTIAL char cipher[ENGLISH_ALPHABET_BUF]; +/* Accumulators for the word currently being entered. File-scope so + * recovery_delete_character() can keep them in sync with backspaces — + * otherwise stale bytes make a re-entered word fail validation and wipe a + * real recovery. last_completed_word backs the "previous word" indicator. */ +static CONFIDENTIAL char coded_word[12]; +static CONFIDENTIAL char decoded_word[12]; +static CONFIDENTIAL char last_completed_word[12]; #if DEBUG_LINK static char auto_completed_word[CURRENT_WORD_BUF]; @@ -74,6 +81,9 @@ void recovery_cipher_abort(void) { word_count = 0; memzero(mnemonic, sizeof(mnemonic)); memzero(cipher, sizeof(cipher)); + memzero(coded_word, sizeof(coded_word)); + memzero(decoded_word, sizeof(decoded_word)); + memzero(last_completed_word, sizeof(last_completed_word)); } /// Formats the passed word to show position in mnemonic as well as characters @@ -181,7 +191,11 @@ bool attempt_auto_complete(char* partial_word) { return false; } - static uint16_t CONFIDENTIAL permute[2049]; + /* 4 KB permutation table lives in the shared frame arena: too big for the + * stack, wasteful as its own static. Transient within this call (memzero'd + * on every exit), and this function never encodes a USB response while the + * table is live — see the FrameArena contract in messages.c. */ + uint16_t* permute = frame_arena_scratch2049(); for (int i = 0; i < 2049; i++) { permute[i] = i; } @@ -215,18 +229,18 @@ bool attempt_auto_complete(char* partial_word) { } if (precise_match) { - memzero(permute, sizeof(permute)); + memzero(permute, 2049 * sizeof(*permute)); return true; } /* Autocomplete if we can */ if (match == 1) { strlcpy(partial_word, words[permute[found]], CURRENT_WORD_BUF); - memzero(permute, sizeof(permute)); + memzero(permute, 2049 * sizeof(*permute)); return true; } - memzero(permute, sizeof(permute)); + memzero(permute, 2049 * sizeof(*permute)); return false; } @@ -376,8 +390,16 @@ void next_character(void) { format_current_word(word_pos, current_word, auto_completed, &formatted_word); memzero(current_word, sizeof(current_word)); + /* Format previous word indicator (e.g. "(1.alcohol)" when entering word 2) */ + static char prev_info[32]; + prev_info[0] = '\0'; + if (word_pos > 0 && last_completed_word[0]) { + snprintf(prev_info, sizeof(prev_info), "(%" PRIu32 ".%s)", word_pos, + last_completed_word); + } + /* Show cipher and partial word */ - layout_cipher(formatted_word, cipher); + layout_cipher(formatted_word, cipher, prev_info); memzero(formatted_word, sizeof(formatted_word)); } @@ -420,14 +442,13 @@ void recovery_character(const char* character) { // Count of words we think the user has entered without using the cipher: static int uncyphered_word_count = 0; static bool definitely_using_cipher = false; - static CONFIDENTIAL char coded_word[12]; - static CONFIDENTIAL char decoded_word[12]; if (!mnemonic[0]) { uncyphered_word_count = 0; definitely_using_cipher = false; memzero(coded_word, sizeof(coded_word)); memzero(decoded_word, sizeof(decoded_word)); + memzero(last_completed_word, sizeof(last_completed_word)); } char decoded_character[2] = " "; @@ -462,6 +483,30 @@ void recovery_character(const char* character) { } } } else { + /* Per-word BIP39 validation: reject immediately if the decoded word + * doesn't match any entry in the wordlist. decoded_word is kept in sync + * with backspaces by recovery_delete_character(), so a corrected word is + * validated on its real (post-edit) value. */ + if (strlen(decoded_word) > 0) { + static CONFIDENTIAL char check_word[CURRENT_WORD_BUF]; + strlcpy(check_word, decoded_word, sizeof(check_word)); + bool valid = attempt_auto_complete(check_word); + if (enforce_wordlist && !valid) { + memzero(check_word, sizeof(check_word)); + memzero(coded_word, sizeof(coded_word)); + memzero(decoded_word, sizeof(decoded_word)); + recovery_cipher_abort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Word not found in BIP39 wordlist"); + layout_warning_static("Word not in wordlist"); + return; + } + /* Record the just-completed (auto-expanded) word for the "previous + * word" indicator — only at a real word boundary, never mid-word. */ + strlcpy(last_completed_word, check_word, sizeof(last_completed_word)); + memzero(check_word, sizeof(check_word)); + } + memzero(coded_word, sizeof(coded_word)); memzero(decoded_word, sizeof(decoded_word)); @@ -512,6 +557,22 @@ void recovery_delete_character(void) { mnemonic[len - 1] = '\0'; } + /* Resync the current-word accumulators with the edited mnemonic so a + * corrected word is validated on its real value (stale bytes here would + * fail validation and trigger a storage_reset on a real recovery). + * decoded_word is the typed prefix of the current word; coded_word is its + * reverse-cipher form (session cipher is fixed, so it is reconstructable). */ + char cur[CURRENT_WORD_BUF]; + get_current_word(cur); + strlcpy(decoded_word, cur, sizeof(decoded_word)); + memzero(cur, sizeof(cur)); + size_t wlen = strlen(decoded_word); + for (size_t i = 0; i < wlen && i + 1 < sizeof(coded_word); i++) { + char d = decoded_word[i]; + coded_word[i] = (d >= 'a' && d <= 'z') ? cipher[d - 'a'] : d; + } + coded_word[wlen < sizeof(coded_word) ? wlen : sizeof(coded_word) - 1] = '\0'; + next_character(); } @@ -575,7 +636,11 @@ void recovery_cipher_finalize(void) { } memzero(temp_word, sizeof(temp_word)); - if (!auto_completed && !enforce_wordlist) { + /* Cipher recovery decodes to BIP-39 words, so every word must + * auto-complete regardless of enforce_wordlist. Failing only when + * enforce_wordlist was set left the default (host-omitted) path storing a + * mistyped/garbage phrase as the seed and reporting success. */ + if (!auto_completed) { if (!dry_run) { storage_reset(); } diff --git a/lib/firmware/storage.c b/lib/firmware/storage.c index c1cd77d12..08b52dd5e 100644 --- a/lib/firmware/storage.c +++ b/lib/firmware/storage.c @@ -44,7 +44,9 @@ #include "keepkey/firmware/fsm.h" #include "keepkey/firmware/passphrase_sm.h" #include "keepkey/firmware/policy.h" +#include "keepkey/firmware/signed_metadata.h" #include "keepkey/firmware/u2f.h" +#include "keepkey/firmware/zcash.h" #include "keepkey/rand/rng.h" #include "keepkey/transport/interface.h" #include "trezor/crypto/aes/aes.h" @@ -60,22 +62,26 @@ #include /* -The PIN_ITER defines below changed between storage version 15 and 16 to -eliminate the unacceptable multi-second wait while the pin was being stretched -for a dubious claim to better security. The defines help during upgrades from -v15 to v16 -*/ + * PIN wrapping-key parameters are part of the persistent storage format. + * Never change an existing set in place: old wallets must first unwrap with + * their original parameters, then rewrap after a correct PIN. V19 restores a + * meaningful offline-work factor after V16 reduced it to ten iterations. + */ #if defined(EMULATOR) || defined(DEBUG_ON) #define PIN_ITER_COUNT_v15 1000 #define PIN_ITER_CHUNK_v15 10 #define PIN_ITER_COUNT_v16 10 #define PIN_ITER_CHUNK_v16 1 +#define PIN_ITER_COUNT_v19 1000 +#define PIN_ITER_CHUNK_v19 10 #else #define PIN_ITER_COUNT_v15 100000 #define PIN_ITER_CHUNK_v15 1000 #define PIN_ITER_COUNT_v16 10 #define PIN_ITER_CHUNK_v16 1 +#define PIN_ITER_COUNT_v19 100000 +#define PIN_ITER_CHUNK_v19 1000 #endif #define U2F_KEY_PATH 0x80553246 @@ -90,6 +96,28 @@ _Static_assert(sizeof(ConfigFlash) <= FLASH_STORAGE_LEN, "ConfigFlash struct is too large for storage partition"); static ConfigFlash CONFIDENTIAL shadow_config; +/* This firmware found storage in flash it must refuse to load or overwrite + * until the user explicitly wipes: a bitcoin-only wallet seen by multi-chain + * firmware, or (on bitcoin-only firmware) a newer in-band wallet than this + * build understands. Set from the SUS_BitcoinOnlyLocked path in either build. + */ +static bool btc_only_locked = false; + +bool storage_isBitcoinOnlyLocked(void) { return btc_only_locked; } + +// Stamp a newly-created seed into the reserved bitcoin-only version band so +// multi-chain firmware refuses it (see storage_fromFlash). Called only from +// seed-creation paths, so a pre-existing multi-chain wallet migrated under +// bitcoin-only firmware keeps its normal, portable version. No-op (but still +// referenced, so no -Wunused) in multi-chain builds. +#if BITCOIN_ONLY +static void storage_stampBitcoinOnlySeed(void) { + shadow_config.storage.version = STORAGE_VERSION_BTC_ONLY; +} +#else +static void storage_stampBitcoinOnlySeed(void) {} +#endif + #if DEBUG_LINK // These won't survive resets like the stuff in flash would, but thats a // reasonable compromise given how testing works. @@ -184,8 +212,14 @@ enum StorageVersion { StorageVersion_NONE, #define STORAGE_VERSION_ENTRY(VAL) StorageVersion_##VAL, #include "storage_versions.inc" + StorageVersion_BTC_ONLY, // reserved band, never in storage_versions.inc }; +// The normal storage version must stay below the bitcoin-only band, or a +// bitcoin-only wallet would become loadable by multi-chain firmware. +_Static_assert(STORAGE_VERSION < STORAGE_VERSION_BTC_ONLY_BASE, + "storage version must stay below the bitcoin-only band"); + static enum StorageVersion version_from_int(int version) { #define STORAGE_VERSION_LAST(VAL) \ _Static_assert(VAL == STORAGE_VERSION, \ @@ -193,6 +227,12 @@ static enum StorageVersion version_from_int(int version) { "storage_versions.inc"); #include "storage_versions.inc" + // Any version in the reserved bitcoin-only band maps here regardless of + // build; storage_fromFlash decides load-vs-refuse from the exact value, so + // an in-band firmware downgrade refuses rather than silently wiping a newer + // bitcoin-only wallet. + if (version >= STORAGE_VERSION_BTC_ONLY_BASE) return StorageVersion_BTC_ONLY; + switch (version) { #define STORAGE_VERSION_ENTRY(VAL) \ case VAL: \ @@ -280,20 +320,27 @@ void storage_writeHDNode(char* ptr, size_t len, const HDNodeType* node) { } void storage_deriveWrappingKey(const char* pin, uint8_t wrapping_key[64], - bool sca_hardened, bool v15_16_trans, + bool sca_hardened, + pin_kdf_version_t pin_kdf_version, const uint8_t random_salt[RANDOM_SALT_LEN], const char* message) { size_t pin_len = strlen(pin); if (sca_hardened && pin_len > 0) { uint8_t salt[HW_ENTROPY_LEN + RANDOM_SALT_LEN]; - int iterCount, iterChunk; - - if (v15_16_trans) { // can use new counts - iterCount = PIN_ITER_COUNT_v16; - iterChunk = PIN_ITER_CHUNK_v16; - } else { // need to use storage version 15 counts to derive wrap key - iterCount = PIN_ITER_COUNT_v15; - iterChunk = PIN_ITER_CHUNK_v15; + int iterCount = PIN_ITER_COUNT_v19; + int iterChunk = PIN_ITER_CHUNK_v19; + + switch (pin_kdf_version) { + case PIN_KDF_V15: + iterCount = PIN_ITER_COUNT_v15; + iterChunk = PIN_ITER_CHUNK_v15; + break; + case PIN_KDF_V16: + iterCount = PIN_ITER_COUNT_v16; + iterChunk = PIN_ITER_CHUNK_v16; + break; + case PIN_KDF_V19: + break; } memset(salt, 0, sizeof(salt)); @@ -362,7 +409,7 @@ void storage_keyFingerprint(const uint8_t key[64], uint8_t fingerprint[32]) { pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], const uint8_t fingerprint[32], bool* sca_hardened, bool* v15_16_trans, - uint8_t key[64], + bool* pin_kdf_v2, uint8_t key[64], uint8_t random_salt[RANDOM_SALT_LEN]) { /* This function tests whether the PIN is correct. It will return @@ -377,7 +424,13 @@ pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], required to update the flash with a storage_commit(). */ uint8_t wrapping_key[64]; - storage_deriveWrappingKey(pin, wrapping_key, *sca_hardened, *v15_16_trans, + pin_kdf_version_t pin_kdf_version = PIN_KDF_V15; + if (*pin_kdf_v2) { + pin_kdf_version = PIN_KDF_V19; + } else if (*v15_16_trans) { + pin_kdf_version = PIN_KDF_V16; + } + storage_deriveWrappingKey(pin, wrapping_key, *sca_hardened, pin_kdf_version, random_salt, _("Verifying PIN")); // unwrap the storage key for fingerprint test @@ -396,16 +449,16 @@ pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], if (memcmp_s(fp, fingerprint, 32) == 0) ret = PIN_GOOD; if (ret == PIN_GOOD) { - if (!*sca_hardened || !*v15_16_trans) { + if (!*sca_hardened || !*v15_16_trans || !*pin_kdf_v2) { // PIN is correct but: // 1. wrapping key needs to be regenerated using stretched key // 2. storage key needs a rewrap with new wrapping key and algorithm storage_deriveWrappingKey(pin, wrapping_key, true /* sca_hardened */, - true /* v15_16_trans */, random_salt, - _("Verifying PIN")); + PIN_KDF_V19, random_salt, _("Verifying PIN")); storage_wrapStorageKey(wrapping_key, key, wrapped_key); *sca_hardened = true; *v15_16_trans = true; + *pin_kdf_v2 = true; ret = PIN_REWRAP; } } @@ -422,8 +475,8 @@ pintest_t storage_isWipeCodeCorrect_impl(const char* wipe_code, uint8_t key[64], uint8_t random_salt[RANDOM_SALT_LEN]) { uint8_t wrapping_key[64]; - storage_deriveWrappingKey(wipe_code, wrapping_key, true, true, random_salt, - _("Verifying PIN")); + storage_deriveWrappingKey(wipe_code, wrapping_key, true, PIN_KDF_V16, + random_salt, _("Verifying PIN")); // unwrap the storage key for fingerprint test storage_unwrapStorageKey(wrapping_key, wrapped_key, key); @@ -559,8 +612,7 @@ void storage_secMigrate(SessionState* ss, Storage* storage, bool encrypt) { void storage_deriveAuthdataKey(const char* passphrase, uint8_t authdataKey[64]) { storage_deriveWrappingKey(passphrase, authdataKey, - /*sca_hardened*/ true, - /*v15_16_trans*/ true, + /*sca_hardened*/ true, PIN_KDF_V16, shadow_config.storage.pub.random_salt, "deriving authdata key"); return; @@ -993,6 +1045,7 @@ void storage_readStorageV16Plaintext(Storage* storage, const char* ptr, storage->pub.sca_hardened = flags & (1u << 15); storage->pub.has_wipe_code = flags & (1u << 16); storage->pub.v15_16_trans = flags & (1u << 17); + storage->pub.pin_kdf_v2 = false; storage->pub.policies_count = POLICY_COUNT; @@ -1080,6 +1133,41 @@ void storage_readStorageV17(Storage* storage, const char* ptr, size_t len) { memcpy(storage->encrypted_sec, ptr + 1501, sizeof(storage->encrypted_sec)); } +// V18 appended a clear-sign identity block immediately after encrypted_sec. +// RC18 retains the byte layout for compatibility but retires those records: +// public storage has no authenticated integrity, so they are zeroed on both +// read and write and are never consulted as trust anchors. +// One identity serializes to CLEARSIGN_IDENTITY_SERIALIZED_LEN bytes: +// +0 present(u8) +1 key_id(u8) +2 pubkey[33] +35 alias[32] +67 icon_w(u8) +// +68 icon_h(u8) +69 icon_len(u16 le) +71 icon[CLEARSIGN_ICON_MAX] = 71+384 +#define CLEARSIGN_IDENTITY_BLOCK_OFF (1501 + V17_ENCSEC_SIZE) // 2525 +#define CLEARSIGN_IDENTITY_SERIALIZED_LEN (71 + CLEARSIGN_ICON_MAX) // 455 + +void storage_writeStorageV18(char* ptr, size_t len, const Storage* storage) { + storage_writeStorageV17(ptr, len, storage); + memzero(ptr + CLEARSIGN_IDENTITY_BLOCK_OFF, + PERSISTENT_IDENTITY_COUNT * CLEARSIGN_IDENTITY_SERIALIZED_LEN); +} + +void storage_readStorageV18(Storage* storage, const char* ptr, size_t len) { + storage_readStorageV17(storage, ptr, len); + memzero(storage->pub.clearsign_identities, + sizeof(storage->pub.clearsign_identities)); +} + +void storage_writeStorageV19(char* ptr, size_t len, const Storage* storage) { + storage_writeStorageV18(ptr, len, storage); + uint32_t flags = read_u32_le(ptr + 4); + flags |= storage->pub.pin_kdf_v2 ? (1u << 20) : 0; + write_u32_le(ptr + 4, flags); +} + +void storage_readStorageV19(Storage* storage, const char* ptr, size_t len) { + storage_readStorageV18(storage, ptr, len); + uint32_t flags = read_u32_le(ptr + 4); + storage->pub.pin_kdf_v2 = flags & (1u << 20); +} + void storage_readCacheV1(Cache* cache, const char* ptr, size_t len) { if (len < 65 + 10) return; cache->root_seed_cache_status = read_u8(ptr); @@ -1148,12 +1236,37 @@ void storage_writeV17(char* flash, size_t len, const ConfigFlash* src) { storage_writeStorageV17(flash + 44, 852, &src->storage); } +void storage_readV18(ConfigFlash* dst, const char* flash, size_t len) { + if (len < 1024) return; + storage_readMeta(&dst->meta, flash, 44); + storage_readStorageV18(&dst->storage, flash + 44, 852); +} + +void storage_writeV18(char* flash, size_t len, const ConfigFlash* src) { + if (len < 1024) return; + storage_writeMeta(flash, 44, &src->meta); + storage_writeStorageV18(flash + 44, 852, &src->storage); +} + +void storage_readV19(ConfigFlash* dst, const char* flash, size_t len) { + if (len < 1024) return; + storage_readMeta(&dst->meta, flash, 44); + storage_readStorageV19(&dst->storage, flash + 44, 852); +} + +void storage_writeV19(char* flash, size_t len, const ConfigFlash* src) { + if (len < 1024) return; + storage_writeMeta(flash, 44, &src->meta); + storage_writeStorageV19(flash + 44, 852, &src->storage); +} + StorageUpdateStatus storage_fromFlash(SessionState* ss, ConfigFlash* dst, const char* flash) { memzero(dst, sizeof(*dst)); // Load config values from active config node. - enum StorageVersion version = version_from_int(read_u32_le(flash + 44)); + uint32_t raw_version = read_u32_le(flash + 44); + enum StorageVersion version = version_from_int(raw_version); switch (version) { case StorageVersion_1: @@ -1199,9 +1312,60 @@ StorageUpdateStatus storage_fromFlash(SessionState* ss, ConfigFlash* dst, dst->storage.version = STORAGE_VERSION; return dst->storage.version == version ? SUS_Valid : SUS_Updated; case StorageVersion_17: + // Migrate up: the V17 reader leaves clearsign_identities zeroed (the + // memzero(dst) at the top => present=false), so no data loss. Stamping + // STORAGE_VERSION (18) makes this SUS_Updated, and the re-commit writes + // the V18 layout (empty identities block). storage_readV17(dst, flash, STORAGE_SECTOR_LEN); dst->storage.version = STORAGE_VERSION; return dst->storage.version == version ? SUS_Valid : SUS_Updated; + case StorageVersion_18: + storage_readV18(dst, flash, STORAGE_SECTOR_LEN); + dst->storage.version = STORAGE_VERSION; + return dst->storage.version == version ? SUS_Valid : SUS_Updated; + case StorageVersion_19: + storage_readV19(dst, flash, STORAGE_SECTOR_LEN); + dst->storage.version = STORAGE_VERSION; + return dst->storage.version == version ? SUS_Valid : SUS_Updated; + + case StorageVersion_BTC_ONLY: +#if BITCOIN_ONLY + { + // Our own bitcoin-only wallet. The stored wire version is the multi-chain + // storage version plus the band base, so recover the underlying layout + // version and load it through the normal migration chain. Exact-matching + // STORAGE_VERSION_BTC_ONLY here would lock every existing bitcoin-only + // wallet out of its own firmware on the next STORAGE_VERSION bump. + uint32_t underlying = raw_version - STORAGE_VERSION_BTC_ONLY_BASE; + if (underlying > (uint32_t)STORAGE_VERSION) { + // A newer bitcoin-only wallet than this firmware understands: refuse + // rather than wipe, so a firmware downgrade never destroys it. + return SUS_BitcoinOnlyLocked; + } + // Read via the reader matching the underlying version (same mapping as + // the multi-chain path above), then keep the band stamp so multi-chain + // firmware still refuses it. + if (underlying <= 15) { + storage_readV11(dst, flash, STORAGE_SECTOR_LEN); + } else if (underlying == 16) { + storage_readV16(dst, flash, STORAGE_SECTOR_LEN); + } else if (underlying == 17) { + storage_readV17(dst, flash, STORAGE_SECTOR_LEN); + } else if (underlying == 18) { + storage_readV18(dst, flash, STORAGE_SECTOR_LEN); + } else { + storage_readV19(dst, flash, STORAGE_SECTOR_LEN); + } + dst->storage.version = STORAGE_VERSION_BTC_ONLY; + return (underlying == (uint32_t)STORAGE_VERSION) ? SUS_Valid + : SUS_Updated; + } +#else + // Written by bitcoin-only firmware: refuse to load. The wallet stays + // intact in flash (reflash bitcoin-only firmware to recover it); using + // multi-chain firmware requires an explicit wipe. + return SUS_BitcoinOnlyLocked; +#endif case StorageVersion_NONE: return SUS_Invalid; @@ -1340,6 +1504,13 @@ void storage_init(void) { // that it's available on next boot without conversion. storage_commit(); break; + case SUS_BitcoinOnlyLocked: + // Bitcoin-only wallet in flash: act as an uninitialized, locked device. + // Do NOT commit -- flash stays untouched so reflashing bitcoin-only + // firmware recovers the wallet; leaving requires an explicit wipe. + btc_only_locked = true; + storage_reset(); + break; } if (!storage_hasPin()) { @@ -1368,6 +1539,9 @@ void storage_resetUuid_impl(ConfigFlash* cfg) { void storage_reset(void) { storage_reset_impl(&session, &shadow_config); } void storage_reset_impl(SessionState* ss, ConfigFlash* cfg) { + bip32_cache_clear(); + bip39_cache_clear(); + memset(&cfg->storage, 0, sizeof(cfg->storage)); storage_resetPolicies(&cfg->storage); @@ -1386,6 +1560,9 @@ void storage_wipe(void) { flash_erase_word(FLASH_STORAGE1); flash_erase_word(FLASH_STORAGE2); flash_erase_word(FLASH_STORAGE3); + + // The bitcoin-only wallet (if any) is gone; the device may be used freely. + btc_only_locked = false; } void storage_clearKeys(void) { @@ -1401,6 +1578,9 @@ void storage_clearKeys(void) { } void session_clear(bool clear_pin) { + /* Runtime ClearSign trust belongs to the unlocked device session. Any path + * that tears that session down must also revoke its RAM-only signer slots. */ + signed_metadata_clear_signers(); if (PIN_REWRAP == session_clear_impl(&session, &shadow_config.storage, clear_pin)) { storage_commit(); @@ -1423,6 +1603,9 @@ pintest_t session_clear_impl(SessionState* ss, Storage* storage, */ pintest_t ret = PIN_WRONG; + bip32_cache_clear(); + bip39_cache_clear(); + ss->seedCached = false; memset(&ss->seed, 0, sizeof(ss->seed)); @@ -1430,11 +1613,11 @@ pintest_t session_clear_impl(SessionState* ss, Storage* storage, memset(&ss->passphrase, 0, sizeof(ss->passphrase)); if (!storage_hasPin_impl(storage)) { - ret = storage_isPinCorrect_impl("", storage->pub.wrapped_storage_key, - storage->pub.storage_key_fingerprint, - &storage->pub.sca_hardened, - &storage->pub.v15_16_trans, ss->storageKey, - shadow_config.storage.pub.random_salt); + ret = storage_isPinCorrect_impl( + "", storage->pub.wrapped_storage_key, + storage->pub.storage_key_fingerprint, &storage->pub.sca_hardened, + &storage->pub.v15_16_trans, &storage->pub.pin_kdf_v2, ss->storageKey, + shadow_config.storage.pub.random_salt); if (ret == PIN_WRONG) { ss->pinCached = false; @@ -1460,9 +1643,18 @@ pintest_t session_clear_impl(SessionState* ss, Storage* storage, } void storage_commit(void) { + // Never overwrite a bitcoin-only wallet from multi-chain firmware; the + // only way out is storage_wipe() (which clears the lock). This is the + // backstop behind the per-handler checks. + if (btc_only_locked) return; + // Temporary storage for marshalling secrets in & out of flash. - // Size of v17 storage layout (2525 bytes) + size of meta (44 bytes) + 1 - static char flash_temp[2570]; + // V19 storage layout = V18 (same byte length) with a versioned PIN-KDF flag. + // V18 = V17 (2525 bytes) + retired identity block + // (PERSISTENT_IDENTITY_COUNT * CLEARSIGN_IDENTITY_SERIALIZED_LEN = 2*455 = + // 910) = 3435; + meta (44) = 3479. Rounded up to a multiple of 4 (the CRC + // below iterates uint32_t words) => 3480 (1 byte of slack). + static char flash_temp[3480]; memzero(flash_temp, sizeof(flash_temp)); @@ -1472,7 +1664,7 @@ void storage_commit(void) { // commit what was in storage->encrypted_sec } - storage_writeV17(flash_temp, sizeof(flash_temp), &shadow_config); + storage_writeV19(flash_temp, sizeof(flash_temp), &shadow_config); memcpy(&shadow_config, STORAGE_MAGIC_STR, STORAGE_MAGIC_LEN); @@ -1624,6 +1816,10 @@ void storage_loadDevice(LoadDevice* msg) { memset(&session.seed, 0, sizeof(session.seed)); } + if (msg->has_node || msg->has_mnemonic) { + storage_stampBitcoinOnlySeed(); + } + if (msg->has_language) { storage_setLanguage(msg->language); } @@ -1685,7 +1881,8 @@ bool storage_isPinCorrect(const char* pin) { pin, shadow_config.storage.pub.wrapped_storage_key, shadow_config.storage.pub.storage_key_fingerprint, &shadow_config.storage.pub.sca_hardened, - &shadow_config.storage.pub.v15_16_trans, session.storageKey, + &shadow_config.storage.pub.v15_16_trans, + &shadow_config.storage.pub.pin_kdf_v2, session.storageKey, shadow_config.storage.pub.random_salt); switch (ret) { @@ -1731,7 +1928,7 @@ void storage_setPin_impl(SessionState* ss, Storage* storage, const char* pin) { // Derive the wrapping key for the new pin uint8_t wrapping_key[64]; storage_deriveWrappingKey(pin, wrapping_key, /*sca_hardened=*/true, - /*v15_16_trans=*/true, storage->pub.random_salt, + PIN_KDF_V19, storage->pub.random_salt, _("Encrypting Secrets")); // Derive a new storageKey. @@ -1742,6 +1939,7 @@ void storage_setPin_impl(SessionState* ss, Storage* storage, const char* pin) { storage->pub.wrapped_storage_key); storage->pub.sca_hardened = true; storage->pub.v15_16_trans = true; + storage->pub.pin_kdf_v2 = true; // Fingerprint the storageKey. storage_keyFingerprint(ss->storageKey, storage->pub.storage_key_fingerprint); @@ -1791,7 +1989,7 @@ void storage_setWipeCode_impl(SessionState* ss, Storage* storage, // Derive the wrapping key for the new wipe code uint8_t wrapping_key[64]; storage_deriveWrappingKey(wipe_code, wrapping_key, /*sca_hardened=*/true, - /*v15_16_trans=*/true, storage->pub.random_salt, + PIN_KDF_V16, storage->pub.random_salt, _("Updating Wipe Code")); // Derive a new wipe code key . @@ -1866,6 +2064,43 @@ const uint8_t* storage_getSeed(const ConfigFlash* cfg, bool usePassphrase) { return NULL; } +/* ── Zcash storage-scoped wrappers ─────────────────────────────────── + * + * ZIP-32 Orchard derives keys directly from the raw 64-byte BIP-39 seed + * (not the BIP-32 master node). Rather than expose a generic + * "give me the seed" function, storage owns the seed access and only + * returns derived material — Orchard keys or the 32-byte fingerprint. + * The seed pointer never leaves this translation unit. + */ + +#if ZCASH_PRIVACY +static void storage_zcash_orchard_progress(uint32_t completed, uint32_t total, + void* context) { + (void)context; + if (total == 0) return; + animating_progress_handler(_("Deriving Zcash"), + (int)((completed * 1000u) / total)); +} + +bool storage_zcashOrchardKeys(uint32_t account, bool usePassphrase, + ZcashOrchardKeys* keys_out) { + if (!keys_out) return false; + const uint8_t* seed = storage_getSeed(&shadow_config, usePassphrase); + if (!seed) return false; + animating_progress_handler(_("Deriving Zcash"), 0); + return zcash_derive_orchard_keys_with_progress( + seed, 64, account, keys_out, storage_zcash_orchard_progress, NULL); +} + +bool storage_zcashSeedFingerprint(bool usePassphrase, + uint8_t fingerprint_out[32]) { + if (!fingerprint_out) return false; + const uint8_t* seed = storage_getSeed(&shadow_config, usePassphrase); + if (!seed) return false; + return zcash_calculate_seed_fingerprint(seed, 64, fingerprint_out); +} +#endif + bool storage_getRootNode(const char* curve, bool usePassphrase, HDNode* node) { // if storage has node, decrypt and use it if (shadow_config.storage.pub.has_node && @@ -1994,6 +2229,7 @@ void storage_setMnemonicFromWords(const char (*words)[12], shadow_config.storage.pub.has_mnemonic = true; shadow_config.storage.has_sec = true; + storage_stampBitcoinOnlySeed(); storage_compute_u2froot(&session, shadow_config.storage.sec.mnemonic, &shadow_config.storage.pub.u2froot); @@ -2011,6 +2247,7 @@ void storage_setMnemonic(const char* m) { #endif shadow_config.storage.pub.has_mnemonic = true; shadow_config.storage.has_sec = true; + storage_stampBitcoinOnlySeed(); storage_compute_u2froot(&session, shadow_config.storage.sec.mnemonic, &shadow_config.storage.pub.u2froot); diff --git a/lib/firmware/storage.h b/lib/firmware/storage.h index 10b84217b..c814c530d 100644 --- a/lib/firmware/storage.h +++ b/lib/firmware/storage.h @@ -32,6 +32,31 @@ #define V16_ENCSEC_SIZE 512 // for reading old encrypted sec size #define V17_ENCSEC_SIZE 1024 +/* Retired V18 clear-sign identity record. The fixed-size fields remain in the + * in-memory/storage layout for backward compatibility, but RC18 never trusts, + * returns, or writes their contents: this public section lacks authenticated + * integrity against physical flash modification. + * pubkey : 33-byte compressed secp256k1 (matches signed_metadata slots) + * alias : METADATA_ALIAS_MAX_LEN(31)+1, printable [A-Za-z0-9 _-] + * icon : 1bpp mono row-major bitmap, <= CLEARSIGN_ICON_MAX bytes, + * icon_len==0 => text-only identity (no logo) + * Serialized size is fixed (CLEARSIGN_IDENTITY_SERIALIZED_LEN) — appended after + * encrypted_sec in the V18 storage layout; never reorder existing fields. */ +#define CLEARSIGN_ICON_MAX 384 +#define CLEARSIGN_IDENTITY_ALIAS_SIZE 32 /* METADATA_ALIAS_MAX_LEN(31) + 1 */ +#define PERSISTENT_IDENTITY_COUNT 2 +typedef struct _ClearsignIdentity { + bool present; + uint8_t key_id; // the signer slot (1..METADATA_MAX_KEYS-1) this identity + // reloads into; the per-tx blob's key_id selects it + uint8_t pubkey[33]; + char alias[CLEARSIGN_IDENTITY_ALIAS_SIZE]; + uint8_t icon_w; + uint8_t icon_h; + uint16_t icon_len; + uint8_t icon[CLEARSIGN_ICON_MAX]; +} ClearsignIdentity; + typedef struct _authBlockType { authType authData[AUTHDATA_SIZE]; // 450 uint8_t reserved[512 - sizeof(authType) * AUTHDATA_SIZE]; // 62 @@ -66,10 +91,13 @@ typedef struct _Storage { bool no_backup; bool sca_hardened; bool v15_16_trans; + bool pin_kdf_v2; bool authdata_initialized; bool authdata_encrypted; uint8_t random_salt[32]; uint8_t authdata_fingerprint[32]; + /* V18 legacy clear-sign records. Always scrubbed on read and write. */ + ClearsignIdentity clearsign_identities[PERSISTENT_IDENTITY_COUNT]; } pub; bool has_sec; @@ -112,13 +140,20 @@ typedef enum { PIN_REWRAP // PIN correct but storage key rewrapped, requires storage update } pintest_t; +typedef enum { + PIN_KDF_V15, + PIN_KDF_V16, + PIN_KDF_V19, +} pin_kdf_version_t; + #define MAX_MNEMONIC_LEN 240 void storage_loadNode(HDNode* dst, const HDNodeType* src); /// Derive the wrapping key from the user's pin. void storage_deriveWrappingKey(const char* pin, uint8_t wrapping_key[64], - bool sca_hardened, bool v15_16_trans, + bool sca_hardened, + pin_kdf_version_t pin_kdf_version, const uint8_t random_salt[RANDOM_SALT_LEN], const char* message); @@ -145,7 +180,7 @@ void storage_keyFingerprint(const uint8_t key[64], uint8_t fingerprint[32]); pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], const uint8_t fingerprint[32], bool* sca_hardened, bool* v15_16_trans, - uint8_t key[64], + bool* pin_kdf_v2, uint8_t key[64], uint8_t random_salt[RANDOM_SALT_LEN]); pintest_t storage_isWipeCodeCorrect_impl(const char* wipe_code, @@ -185,6 +220,7 @@ typedef enum { SUS_Invalid, SUS_Valid, SUS_Updated, + SUS_BitcoinOnlyLocked, // written by bitcoin-only firmware; refuse to load } StorageUpdateStatus; /// \brief Copy configuration from storage partition in flash memory to shadow @@ -203,8 +239,12 @@ void storage_readV2(SessionState* ss, ConfigFlash* dst, const char* flash, size_t len); void storage_readV11(ConfigFlash* dst, const char* flash, size_t len); void storage_readV16(ConfigFlash* dst, const char* flash, size_t len); +void storage_readV18(ConfigFlash* dst, const char* flash, size_t len); +void storage_readV19(ConfigFlash* dst, const char* flash, size_t len); void storage_writeV11(char* flash, size_t len, const ConfigFlash* src); void storage_writeV16(char* flash, size_t len, const ConfigFlash* src); +void storage_writeV18(char* flash, size_t len, const ConfigFlash* src); +void storage_writeV19(char* flash, size_t len, const ConfigFlash* src); void storage_readMeta(Metadata* meta, const char* ptr, size_t len); void storage_readPolicyV1(PolicyType* policy, const char* ptr, size_t len); diff --git a/lib/firmware/storage_versions.inc b/lib/firmware/storage_versions.inc index a2e6eda30..c622336cd 100644 --- a/lib/firmware/storage_versions.inc +++ b/lib/firmware/storage_versions.inc @@ -22,7 +22,9 @@ STORAGE_VERSION_ENTRY(13) STORAGE_VERSION_ENTRY(14) STORAGE_VERSION_ENTRY(15) STORAGE_VERSION_ENTRY(16) -STORAGE_VERSION_LAST(17) +STORAGE_VERSION_ENTRY(17) +STORAGE_VERSION_ENTRY(18) +STORAGE_VERSION_LAST(19) #undef STORAGE_VERSION_ENTRY diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index b997e1738..5e828e1ae 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -1,4 +1,5 @@ set(sources + authenticator.cpp app_confirm.cpp coins.cpp cosmos.cpp diff --git a/unittests/firmware/authenticator.cpp b/unittests/firmware/authenticator.cpp new file mode 100644 index 000000000..e5615cb61 --- /dev/null +++ b/unittests/firmware/authenticator.cpp @@ -0,0 +1,111 @@ +extern "C" { +#include + +#include "trezor/crypto/sha2.h" +#include "keepkey/firmware/authenticator.h" +#include "keepkey/firmware/storage.h" + +void setup(void); +} + +#include "gtest/gtest.h" + +// Shared emulator confirmation driver from thorchain.cpp. +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); + +static void ensure_auth_storage_initialized(void) { + static bool initialized = false; + if (!initialized) { + setup(); + storage_init(); + initialized = true; + } +} + +TEST(Authenticator, WipeCancellationFailsClosed) { + ensure_auth_storage_initialized(); + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_EQ(AUTH_CANCELLED, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Authenticator, AddAndRemoveCancellationFailsClosed) { + ensure_auth_storage_initialized(); + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); + + char cancelled_add[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_EQ(AUTH_CANCELLED, addAuthAccount(cancelled_add)); + EXPECT_EQ(0, kkconfirm_drain()); + + char account[DOMAIN_SIZE + ACCOUNT_SIZE + 2] = {0}; + EXPECT_EQ(NOACC, getAuthAccount("0", account)); + + char accepted_add[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_EQ(NOERR, addAuthAccount(accepted_add)); + EXPECT_EQ(0, kkconfirm_drain()); + + char cancelled_remove[] = "example:alice"; + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_EQ(AUTH_CANCELLED, removeAuthAccount(cancelled_remove)); + EXPECT_EQ(0, kkconfirm_drain()); + EXPECT_EQ(NOERR, getAuthAccount("0", account)); + EXPECT_STREQ("example:alice", account); + + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Authenticator, RejectsAmbiguousDisplayFieldsBeforeMutation) { + char long_domain[] = "domain-is-too-long:alice:JBSWY3DPEHPK3PXP"; + EXPECT_EQ(TOKERR, addAuthAccount(long_domain)); + + char control_domain[] = "bad\ndomain:alice:JBSWY3DPEHPK3PXP"; + EXPECT_EQ(TOKERR, addAuthAccount(control_domain)); + + char long_account[] = "example:account-is-too-long:JBSWY3DPEHPK3PXP"; + EXPECT_EQ(TOKERR, addAuthAccount(long_account)); + + char remove_long[] = "example:account-is-too-long"; + EXPECT_EQ(TOKERR, removeAuthAccount(remove_long)); + + char remove_control[] = "example:bad\naccount"; + EXPECT_EQ(TOKERR, removeAuthAccount(remove_control)); +} + +TEST(Authenticator, RejectsWeakAndDuplicateSecrets) { + ensure_auth_storage_initialized(); + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); + + char weak[] = "example:weak:MY"; + EXPECT_EQ(BADSECRET, addAuthAccount(weak)); + + // The final invalid block fails after earlier blocks have decoded; the + // implementation must still take its cleanup path. + char partially_decoded[] = "example:invalid:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PX!"; + EXPECT_EQ(BADSECRET, addAuthAccount(partially_decoded)); + + char first[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_EQ(NOERR, addAuthAccount(first)); + EXPECT_EQ(0, kkconfirm_drain()); + + char duplicate[] = "example:alice:KRSXG5DSNFXGOIDBKRSXG5DSNFXGOIDB"; + EXPECT_EQ(DUPLICATE, addAuthAccount(duplicate)); + + char account[DOMAIN_SIZE + ACCOUNT_SIZE + 2] = {0}; + EXPECT_EQ(NOACC, getAuthAccount("1", account)); + + char remove[] = "example:alice"; + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_EQ(NOERR, removeAuthAccount(remove)); + EXPECT_EQ(0, kkconfirm_drain()); + EXPECT_EQ(NOACC, getAuthAccount("0", account)); +} diff --git a/unittests/firmware/storage.cpp b/unittests/firmware/storage.cpp index b04af1ebe..8886dbbcc 100644 --- a/unittests/firmware/storage.cpp +++ b/unittests/firmware/storage.cpp @@ -196,7 +196,10 @@ TEST(Storage, ReadStorageV1) { // Decrypt upgraded storage. uint8_t wrapping_key[64]; storage_deriveWrappingKey("123456789", wrapping_key, dst.pub.sca_hardened, - dst.pub.v15_16_trans, + dst.pub.pin_kdf_v2 + ? PIN_KDF_V19 + : (dst.pub.v15_16_trans ? PIN_KDF_V16 + : PIN_KDF_V15), dst.pub.random_salt, ""); // strongest pin evar storage_unwrapStorageKey(wrapping_key, dst.pub.wrapped_storage_key, session.storageKey); @@ -466,7 +469,9 @@ TEST(Storage, StorageUpgrade_Normal) { uint8_t wrapping_key[64]; storage_deriveWrappingKey( "123456789", wrapping_key, shadow.storage.pub.sca_hardened, - shadow.storage.pub.v15_16_trans, + shadow.storage.pub.pin_kdf_v2 + ? PIN_KDF_V19 + : (shadow.storage.pub.v15_16_trans ? PIN_KDF_V16 : PIN_KDF_V15), shadow.storage.pub.random_salt, ""); // strongest pin evar storage_unwrapStorageKey(wrapping_key, shadow.storage.pub.wrapped_storage_key, session.storageKey); @@ -497,6 +502,70 @@ TEST(Storage, StorageUpgrade_Normal) { EXPECT_EQ(shadow.storage.pub.policies[1].enabled, true); } +#if !BITCOIN_ONLY +// A seed created under bitcoin-only firmware is stamped in a reserved version +// band. Multi-chain firmware must REFUSE it (SUS_BitcoinOnlyLocked), not load +// it and not silently reset it here -- the seed stays intact in flash until an +// explicit wipe. This is the core anti-downgrade guarantee. +TEST(Storage, BitcoinOnlyBandRefused) { + // storage_fromFlash always reads STORAGE_SECTOR_LEN from `flash` (in the + // firmware it points to a full flash sector), so the buffer must be a full + // sector or the version-17 read below runs off the end. + static char flash[STORAGE_SECTOR_LEN]; + memset(flash, 0, sizeof(flash)); + memcpy(flash, "stor", 4); // STORAGE_MAGIC_STR + uint32_t v = STORAGE_VERSION_BTC_ONLY; + flash[44] = (char)(v & 0xff); + flash[45] = (char)((v >> 8) & 0xff); + flash[46] = (char)((v >> 16) & 0xff); + flash[47] = (char)((v >> 24) & 0xff); + + SessionState session; + memset(&session, 0, sizeof(session)); + ConfigFlash shadow; + EXPECT_EQ(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); + + // A normal (below-band) version is still handled as before. + flash[44] = 17; + flash[45] = flash[46] = flash[47] = 0; + EXPECT_NE(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); +} +#endif + +#if BITCOIN_ONLY +// On bitcoin-only firmware, an in-band wallet stamped at an OLDER underlying +// version (which is exactly what an existing wallet looks like after a +// STORAGE_VERSION bump) must still load and migrate — never be refused, which +// would lock the user out of their own wallet. A NEWER in-band version is +// refused (downgrade guard), never wiped. +TEST(Storage, BitcoinOnlyBandMigrates) { + static char flash[STORAGE_SECTOR_LEN]; + SessionState session; + ConfigFlash shadow; + + // Older in-band version (underlying < STORAGE_VERSION): migrate, not refuse. + memset(flash, 0, sizeof(flash)); + memcpy(flash, "stor", 4); + uint32_t older = STORAGE_VERSION_BTC_ONLY_BASE + (STORAGE_VERSION - 1); + memcpy(flash + 44, &older, + 4); // test host is little-endian, matches read_u32_le + memset(&session, 0, sizeof(session)); + EXPECT_NE(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); + + // Our own current in-band version: loads (not refused). + uint32_t current = STORAGE_VERSION_BTC_ONLY; + memcpy(flash + 44, ¤t, 4); + memset(&session, 0, sizeof(session)); + EXPECT_NE(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); + + // A newer in-band version than this firmware understands: refuse. + uint32_t newer = STORAGE_VERSION_BTC_ONLY_BASE + (STORAGE_VERSION + 1); + memcpy(flash + 44, &newer, 4); + memset(&session, 0, sizeof(session)); + EXPECT_EQ(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); +} +#endif + TEST(Storage, StorageRoundTrip) { ConfigFlash start; memset(&start, 0xAB, sizeof(start)); @@ -530,7 +599,7 @@ TEST(Storage, StorageRoundTrip) { uint8_t wrapping_key[64]; storage_deriveWrappingKey("", wrapping_key, start.storage.pub.sca_hardened, - start.storage.pub.v15_16_trans, + PIN_KDF_V15, start.storage.pub.random_salt, ""); storage_unwrapStorageKey(wrapping_key, start.storage.pub.wrapped_storage_key, session.storageKey); @@ -556,6 +625,7 @@ TEST(Storage, StorageRoundTrip) { printf("\n"); #endif + // clang-format off const uint8_t expected_flash[] = { 0x73, 0x74, 0x6f, 0x72, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, @@ -653,7 +723,7 @@ TEST(Storage, StorageRoundTrip) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, 0xe4, 0x8d, 0xfe, 0xcf, 0xd0, 0x54, 0x71, + 0x00, 0x00, 0x00, 0x00, 0x00, STORAGE_VERSION, 0x00, 0x00, 0x00, 0xe4, 0x8d, 0xfe, 0xcf, 0xd0, 0x54, 0x71, 0x50, 0xcb, 0x12, 0x84, 0xfa, 0x5f, 0xbf, 0xcb, 0x09, 0xca, 0x00, 0xf1, 0x37, 0xe4, 0x8f, 0x5e, 0xf9, 0x81, 0x57, 0x26, 0xb6, 0x7b, 0x8e, 0x03, 0x44, 0x9a, 0x2a, 0x7c, 0xf4, 0x3c, 0x79, 0x87, 0x5d, 0x26, 0xae, 0x9b, 0x4b, 0xb4, 0xd2, 0xc4, 0x67, 0x97, 0xe7, 0x6b, 0x6c, 0x4c, 0xbe, 0x68, @@ -719,6 +789,7 @@ TEST(Storage, StorageRoundTrip) { 0x7c, 0x20, 0x50, 0x7c, 0x85, 0xc1, 0x44, 0xaa, 0xfb, 0xf8, 0xeb, 0x20, 0x16, 0x8d, 0x72, 0x8c, 0xd2, 0xbe, 0xc2, 0xea, 0x44, 0xed, 0x7b, 0x94, 0x21, 0x00, }; + // clang-format on // If storage isn't correct, let's get an idea of where the failure is for (int i=0; ipresent = true; + a->key_id = 1; + memset(a->pubkey, 0x42, sizeof(a->pubkey)); + strcpy(a->alias, "CI Test"); + a->icon_w = 32; + a->icon_h = 32; + a->icon_len = 2; + a->icon[0] = 0x01; + a->icon[1] = 0xFF; + + std::vector flash(3480, 0); + storage_writeV18((char*)&flash[0], flash.size(), &start); + const size_t identity_block_off = 44 + 1501 + V17_ENCSEC_SIZE; + const size_t identity_block_len = + PERSISTENT_IDENTITY_COUNT * (71 + CLEARSIGN_ICON_MAX); + for (size_t i = 0; i < identity_block_len; i++) { + ASSERT_EQ(0, flash[identity_block_off + i]) << "byte " << i; + } + + // Simulate attacker-controlled legacy flash. Deserialization must scrub the + // full in-memory block rather than parse or expose any of it. + memset(&flash[identity_block_off], 0xA5, identity_block_len); + ConfigFlash end; + memset(&end, 0xCC, sizeof(end)); + storage_readV18(&end, (const char*)&flash[0], flash.size()); + const uint8_t* retired = + reinterpret_cast(end.storage.pub.clearsign_identities); + for (size_t i = 0; i < sizeof(end.storage.pub.clearsign_identities); i++) { + ASSERT_EQ(0, retired[i]) << "byte " << i; + } + for (int k = 0; k < PERSISTENT_IDENTITY_COUNT; k++) { + const ClearsignIdentity* r = &end.storage.pub.clearsign_identities[k]; + ASSERT_FALSE(r->present) << "present " << k; + } +} + +TEST(Storage, PinKdfV2FlagIsVersionedInV19) { + ConfigFlash start; + memset(&start, 0, sizeof(start)); + memcpy(start.meta.magic, "stor", 4); + start.storage.version = STORAGE_VERSION; + start.storage.pub.pin_kdf_v2 = true; + + std::vector flash(3480, 0); + storage_writeV19((char*)&flash[0], flash.size(), &start); + + ConfigFlash end; + memset(&end, 0, sizeof(end)); + storage_readV19(&end, (const char*)&flash[0], flash.size()); + EXPECT_TRUE(end.storage.pub.pin_kdf_v2); + + memset(&end, 0xCC, sizeof(end)); + storage_readV18(&end, (const char*)&flash[0], flash.size()); + EXPECT_FALSE(end.storage.pub.pin_kdf_v2); +} From 320f0eb5d731aba575ef086b1ec5e9688f3d4c23 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:58:56 -0300 Subject: [PATCH 13/40] feat(rng): auditable entropy source, on-device dice, and no entropy display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RNG: - The RNG source selection is explicit and reportable, and a host can pull bulk samples for a health audit: a per-boot 64 KB budget replaces the press-per-kilobyte prompt that made auditing real hardware impossible. Scope is stated honestly in the code and docs — bulk output supports HEALTH testing (stuck/biased output, repeated buffers, transport caching, gross correlation), not a bound on the entropy of the generator's internal state. - On an uninitialized device, one press unlocks the bulk audit; on an initialized device the confirm still gates entropy after the budget is spent. - The emulator gets a real CSPRNG (lib/emulator/random.c, BCryptGenRandom on Windows) instead of libc random(), which CI now enforces. Dice: - ResetDevice can request on-device dice entropy: short press advances 1-6, long press confirms, undo is available, and the roll digest is confirmed before the seed is mixed. Pre-initialization only. - The abort path disarms EntropyAck, so an aborted reset can no longer leave the device accepting host-supplied entropy for the seed. - Evidence frames under docs/security/evidence/dice-entropy-reset, reproducible with scripts/emulator/capture-dice-flow.py. Internal entropy is no longer displayed or returned: it is seed pre-image material. display_random stays in the wire schema for host compatibility and is ignored. --- .../01-dice-screen-initial.png | Bin 0 -> 750 bytes .../02-after-three-rolls.png | Bin 0 -> 709 bytes .../dice-entropy-reset/03-after-undo.png | Bin 0 -> 692 bytes .../dice-entropy-reset/04-digest-confirm.png | Bin 0 -> 544 bytes .../05-postmix-internal-entropy.png | Bin 0 -> 719 bytes .../06-backup-explainer.png | Bin 0 -> 897 bytes .../evidence/dice-entropy-reset/README.md | 37 ++ include/keepkey/emulator/libkkemu.h | 78 +++- include/keepkey/firmware/dice_input.h | 49 +++ include/keepkey/firmware/reset.h | 19 +- lib/board/udp.c | 6 + lib/emulator/CMakeLists.txt | 4 +- lib/emulator/libkkemu.c | 337 +++++++++++++-- lib/emulator/random.c | 69 ++++ lib/emulator/setup.c | 43 +- lib/emulator/udp.c | 10 +- lib/firmware/CMakeLists.txt | 1 + lib/firmware/dice_input.c | 387 ++++++++++++++++++ lib/firmware/fsm_msg_common.h | 129 +++++- lib/firmware/reset.c | 136 ++++-- lib/rand/rng.c | 73 +++- scripts/emulator/capture-dice-flow.py | 101 +++++ unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/dice.cpp | 64 +++ 24 files changed, 1395 insertions(+), 149 deletions(-) create mode 100644 docs/security/evidence/dice-entropy-reset/01-dice-screen-initial.png create mode 100644 docs/security/evidence/dice-entropy-reset/02-after-three-rolls.png create mode 100644 docs/security/evidence/dice-entropy-reset/03-after-undo.png create mode 100644 docs/security/evidence/dice-entropy-reset/04-digest-confirm.png create mode 100644 docs/security/evidence/dice-entropy-reset/05-postmix-internal-entropy.png create mode 100644 docs/security/evidence/dice-entropy-reset/06-backup-explainer.png create mode 100644 docs/security/evidence/dice-entropy-reset/README.md create mode 100644 include/keepkey/firmware/dice_input.h create mode 100644 lib/emulator/random.c create mode 100644 lib/firmware/dice_input.c create mode 100644 scripts/emulator/capture-dice-flow.py create mode 100644 unittests/firmware/dice.cpp diff --git a/docs/security/evidence/dice-entropy-reset/01-dice-screen-initial.png b/docs/security/evidence/dice-entropy-reset/01-dice-screen-initial.png new file mode 100644 index 0000000000000000000000000000000000000000..52dbe28bbe2bde530bdd74eb3da3c3839d1c6ceb GIT binary patch literal 750 zcmVVn|Z*C7jofYQ-(3{%>P-n&203f|*DL1l_;%t^f^LYS}-m_HZ zO2{RB<1MhZOmQLr1b_e#00O{a07h^zfJK4;pa2luRJY^{NPi1C07rP1PanBC;LQPn z0w6+cHyz9%c1SIuet`*Ry0ElYOA^oRX?6f?R!Utke*v&rDRnIw0$@9z2Y{Am zu&AAqWn}_*)u(TTk$_F*MU1k*; zc_J48OZzdPa)217YT024!=o1g+^y%o&H-YVswKmaH1s2YyLJ6fU~SS%XP;HW5P*>g z;7Ijz0Q#pRU?0GK1lUzh-WJH@`8la3*p#q826KQ~Wdak#Ct?%Tbmc9;2EqOq$pLFi zcXh5P0U!VbfB+Bx0zd!=00AHX1b_e#03He8GshPR1Ayw2MLwuA0CJ0TUtFdEQ1_iJK<|uzaR9H&0c1`@OYNWT gzw9k=vix@dKj#LjP%e$J&;S4c07*qoM6N<$f-;LVO8@`> literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/02-after-three-rolls.png b/docs/security/evidence/dice-entropy-reset/02-after-three-rolls.png new file mode 100644 index 0000000000000000000000000000000000000000..cc8f5ca95241640a791c4f4d9e1907974d8fa990 GIT binary patch literal 709 zcmV;$0y_PPP)pY}}~$Q862HLV`bY!WGP&!)C+h>J^DT_^=gWjj({DowPq4&4Bl14a*u zjGS_h)L{gG-v=rKK$O}J0|5L!P#FNC)OMHwAS3{k%VYqY1G%RIFkVb?0s!MRx%2|i z91H-uD>%zjMI!*s!2qBvz*(LuS^?;hZIx;LV*%)qZIx;LqXEF&PF}uce2;>7Ewg9&0oYEL{aaKn-E&K<`9uEdca4i|4)^gabZHWseMX#!Ah^*R9g@1u(V ztnaY^PG><2;H*cH$x{KCZGXf6Gxe|QS742y8u+PPfg5SbeC#~BWYf4yM9DoCG0F9Ze zdNzQl>UIDbhpHS|9@9CU^byeO?y+8Jn%=W|2`B+4004z>=mlVPRsjGC;V=dO*?ipG z$;{tq?g2nHA2)Y0;fucoK*Asu$!8M;!w_IPy#NvhsYpJXAQ*-Kr_c>R1SMU2258>? z1OR|NaLCO2hd|7p06J#ZrU4Lz*|pmM?15M)4+=XqL@T-DP73@V-J_ZJ7?033h=Z~zX#0XP5$-~b$e19&HZJ<1-8?7%;lQyXqs++$i= z1^`Hs@+~ynE6BG`BJQJn8vwQ;0Q-KcT>T7ys5e+6i~)QCBJa3<41lr^hCux06L1~C r=jMQ@R{{JxCFaL3t^%FqzXSLK$mXh0b!;`W00000NkvXXu0mjf2kasK literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/03-after-undo.png b/docs/security/evidence/dice-entropy-reset/03-after-undo.png new file mode 100644 index 0000000000000000000000000000000000000000..ea5a39d1e67decb506b650ae0a98ba68dc13e872 GIT binary patch literal 692 zcmV;l0!#ggP)m zCnS@BiWXup1Hk74nF4@iYcK)8=L4AnfMsiNEdXBmWdL~PuLiKSLku{j^8pmcR7Yb7 zfaah9#4;!=&5i-k95jGf24$t$K>+$>R{-RJaUuYHvMT`cz&I5E#CF!k%59F6`_EOW z#V7!X?W~27af>8H%7y`~m2nRofCF#<4!{A705FP+0W4BD00khZ#}37!1Si)N0QT^N zr>y~R4k#3Wq{Mn-U^=l~YE9S|7;vV0ZN)x-o_^AcL+7AH?Ogz5XQ8j3LjdZn9tWWE zez>D3>TV0583iqXmPL`tT>+TM+X$!oEyutXftv!T1aDAN$v@Si(9=_5z-a*L<);Ct zG;0E=8r%(lTHoe+7g`iev^)zyb@tN4jG|4dT`UJsIn-y%RsI^qB7cq0YHjDt%y3JYul51T6qFuLHCjiQxg-i{sIN@HH1|Z#0Xz+X#sVuS|q(qrNVW0FbDjc*F_CiI}Lb%f|t%l^H%b00-az z9DoCG01m(bH~g-j__2oqvs1teKy*of zJ-94@M$81*Yzh1|0~ow9U{eK*>(w4G6kwk{U{(yhJO%m-JZ%1G4@UFPL-zR>MNt$* zQAD_Z;1NA^< zv^eZr6+kzK!*P_ughARB=aU484jKx8=6y79rvR&w+q>!0OxtMx!tCB)a&u+hJ-P6SyBANU#b?-x%RT=_WVE56!yDbay()mY zTiy*``|a3|U(8i^&=&#B9T}@kTs!^2Ys}vZVDcbFk(hBI=(mKKLfY8%xoap8GeGQ^ z0pw?fmc0e&32jQbma1CSVI~KQSpqi9l!>G|t#b+>3l8avEcuoc#T5OMsu50j{5E4~QQz ixOx`aC!PYuNBjWx^;6{_ferZp0000Tf6)R{>u|u-go*-3WY+U@CK6m+pMUiIv^-|a#*(o zVjbN6pOT*RAW5gbL+O1U0q}gZMlsAiRGG!%49hYt;Ia#4KCjH4-yu&eesTg14Rz8@ zC-Kt=kOfY;1P)tckfRyBN7IA}h!wLsoz&6r+oU}jS~>$kCz6mvfZhg^2#A3XlXy0s zzGj^OUi^+_ry?%XUhvBV%=Un!_jv+P5(Ljd+@$psaENeB0Wg|^e|id}DPZ*ML*CaE zMO?2?D6Ed8%rw|t9x_cbR2(+|)e1J;Lnr%zGRAJ>|1JS2V_R4}#_r=k9nExDLk?gB z_*SFc1}~?8-!lTFz0w{qt7GBs2mN`+Q=sq>egH7}-*tI{@z($V002ovPDHLkV1ghN BN#Fng literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/06-backup-explainer.png b/docs/security/evidence/dice-entropy-reset/06-backup-explainer.png new file mode 100644 index 0000000000000000000000000000000000000000..74ce8c14ce969c1a1bd07ca1df77ba6838140917 GIT binary patch literal 897 zcmV-{1AhF8P)Nkl|CN2;~2Nnw#8hVR+TY(Q0BRSyx*?I;9nqXklSt;VWRl)1Ev7=5I5={A~u9O9t9C z!$0-E_G^7?8daN&fXJcs!mL`KJbt_(ctg>*y01_ssszkm{lBoneji#(0w%1h`Y@qKusUSYhHAgv7vKBVgJd;7*PId6;{0 zJONkj0e1rjJz4_(DbNN20W4DomUlb_BB6oa`w2)dJ^~JS+M~%>1bm%bCXzAij%u=alX~6-T!mypPVM=M_jybWAK*mAb`a#9uK+5 zokL-+7eCM&XeMT)55$aOQ|a@s(t2OD!l*9|*ebR}hSlFQvCQ&A<`|pFoHN`JT`$CP z0<0XT`lVG*8mVnV<8cDWuo^Z9R;k`@BS5-D;3NSsy&8C11dws|90A5?G6~`Uia^|C zDrX3A9_}Q-9BJ>X2D1cg1i%Xq2@VMjA8oo$*&drOeT}9wn_E$Hjl}*!2HKo08;83H z_&ct-&n$1R#x=?HO3@b<78V|XK4bDI1OtAtsVD;i`a8 z{xzAN#c8?-y(V#&WUMQ>!VW4)s0cMDwz>5@0x+{KGkK{8Vy`h)r|ex0D@l`!&nG}8 z#HamQk>OaxW7peHw+Vns(j@v1MnFF6IAI9^t{2qD1i+@lsAUP5ml^9U!h&=a8P;EI z`k}Ws5Rus)(xMI5yoMi1tGX~_@UH~~)dz7jF$wybrJAl{=`$8N<0iT0JOYq&TjcE}AUI%- zJPd&m0VpFwkcmu8Y!Hw{0Ht1=ncXH}Joi0*#{`5`#IOc10@_O*YeOd>`$C5|GM@*D z!m5j(3&D9T4o!?k2cp>x0tzN00mLB!UWY^8U9kte3gbOLmFK?cn5dN&I40yQd>j7& XT5qzjlOj}X00000NkvXXu0mjfut}4q literal 0 HcmV?d00001 diff --git a/docs/security/evidence/dice-entropy-reset/README.md b/docs/security/evidence/dice-entropy-reset/README.md new file mode 100644 index 000000000..88dbed9f5 --- /dev/null +++ b/docs/security/evidence/dice-entropy-reset/README.md @@ -0,0 +1,37 @@ +# On-device dice entropy in the ResetDevice flow + +Emulator captures of `ResetDevice(dice_entropy=true, display_random=true, +strength=256)` driven by `scripts/emulator/capture-dice-flow.py` via +DebugLinkDecision.input injection. + +The screen runs with `display_constant_power(true)` (PIN-matrix precedent: +dice rolls are seed material, and OLED supply current correlates with lit +pixels). The display driver fills x<128 with the inverse of x>=128, which is +why the left half of every capture shows a readable inverse copy — the user +faces the right half. + +- `01-dice-screen-initial.png` — entry screen: `ROLL 1/99` counter, seven + selector cells (digits 1–6 + `<` undo), active cell rendered inverse-video + (white box, black glyph), `PRESS next HOLD ok` hint. Inactive digits are + legible on hardware (white on 0x22 gray) but collapse to solid white in the + 1bpp DebugLink threshold; the inverse half documents them. +- `02-after-three-rolls.png` — after injecting `123`: counter `ROLL 4/99`, + status `Entered 3 (3)`. +- `03-after-undo.png` — after injecting `u`: counter back to `ROLL 3/99`, + status `Removed #3`. +- `04-digest-confirm.png` — completion screen: `99 rolls recorded. Digest: + 6CFC611198F53A73` = the first 8 bytes of SHA-256 of the ASCII roll string, + independently recomputed host-side from the injected chunks (append/undo + rules simulated) and matching exactly. +- `05-postmix-internal-entropy.png` — the standard Internal Entropy screen + now shows the POST-dice-mix value: the displayed commitment is + `SHA256(rng32 || rolls)`, produced before EntropyRequest is sent, so + `sha256(displayed || external)` still reproduces the mnemonic (asserted by + `test_msg_resetdevice.py::test_reset_device_dice`). +- `06-backup-explainer.png` — flow continues into the unchanged backup path. + +Emulator captures do not satisfy Gate-3 on their own: an on-device pass of +the entry screen (short-press advance, 800 ms hold commit, undo, digest +match against physically entered rolls) is still owed before release. The +hardware press/release/debounce path (`dice_on_press`/`dice_on_release`) +does not execute in the emulator at all. diff --git a/include/keepkey/emulator/libkkemu.h b/include/keepkey/emulator/libkkemu.h index ec75ff957..6e5b8f336 100644 --- a/include/keepkey/emulator/libkkemu.h +++ b/include/keepkey/emulator/libkkemu.h @@ -3,7 +3,16 @@ * * The host process provides a pre-allocated 1MB flash buffer. * All I/O goes through ring buffers (no UDP sockets). - * Single-threaded: call kkemu_poll() from your event loop. + * + * Two drive modes: + * - Host-driven (default): call kkemu_poll() from your event loop. Purely + * single-threaded — used by the FFI/python test harnesses. + * - Thread-driven: call kkemu_start() once after kkemu_init() and let a + * dedicated dylib thread own the event loop. Required for screen-first + * confirm gating (confirm_helper can block in C without freezing the host + * event loop). The host then never calls kkemu_poll(); it interacts only + * through the lock-free rings (kkemu_write/read/pop_frame) and brackets + * flash snapshots with kkemu_lock()/kkemu_unlock(). */ #ifndef LIBKKEMU_H #define LIBKKEMU_H @@ -77,20 +86,16 @@ int kkemu_read(uint8_t* buf, size_t len, int iface); int kkemu_poll(void); /** - * Get the OLED framebuffer (256x64, 1-bit per pixel = 2048 bytes). + * Snapshot the current OLED framebuffer (256x64, 1-bit, 2048 bytes) into + * internal scratch and return a pointer to it (valid until the next call). * - * This returns a pointer to internal scratch storage containing a snapshot - * of the current display in packed SSD1306 page format. + * WARNING: host-driven mode ONLY. Reads the live canvas with no synchronization + * against the poll thread — do NOT call it once kkemu_start() is running. In + * thread-driven mode use kkemu_pop_frame() (the lock-free SPSC ring) instead. + * Returns NULL if the emulator is not initialized. * * @param width Receives 256. * @param height Receives 64. - * @return Pointer to framebuffer data. The pointer remains valid only until - * the next call to kkemu_get_display(), which overwrites the same - * scratch buffer. Calling kkemu_poll() may update the emulator's - * display state, but it does not refresh previously returned data - * in place; call kkemu_get_display() again after kkemu_poll() to - * obtain an updated framebuffer snapshot. Returns NULL if emulator - * is not initialized. */ const uint8_t* kkemu_get_display(int* width, int* height); @@ -98,14 +103,15 @@ const uint8_t* kkemu_get_display(int* width, int* height); * Pop the next captured framebuffer from the display capture ring. * * Every display_refresh() inside the firmware (including those that fire - * inside confirm_helper's busy loop within a single kkemu_poll() call) - * snapshots the canvas into a ring buffer. Adjacent identical frames - * are deduplicated. This lets the host see intermediate screen states - * (confirm dialogs, cipher prompts, recovery screens) that would - * otherwise be invisible — they exist only inside synchronous C calls. + * inside confirm_helper's busy loop) snapshots the canvas into a lock-free + * SPSC ring. Adjacent identical frames are deduplicated. This is the canonical + * way to observe intermediate screen states (confirm dialogs, cipher prompts, + * recovery screens) — and the only display path that is safe to call while the + * poll thread runs. * * @param out_packed Buffer of at least 2048 bytes (256x64, 1-bit packed - * SSD1306 page format — same as kkemu_get_display). + * SSD1306 page format: byte index = x + (y/8)*256, + * bit within byte = y%8). * @return 1 if a frame was popped, 0 if the ring is empty. */ int kkemu_pop_frame(uint8_t* out_packed); @@ -115,6 +121,44 @@ int kkemu_pop_frame(uint8_t* out_packed); */ int kkemu_is_running(void); +/** + * Start the dedicated poll thread (thread-driven mode). + * + * After this returns 0, a dylib-internal thread owns the firmware event loop + * and the host MUST NOT call kkemu_poll() anymore. Idempotent. Requires + * kkemu_init() to have succeeded. + * + * @return 0 on success (or already started), -1 on error. + */ +int kkemu_start(void); + +/** + * Stop + join the poll thread. Injects a Cancel first so a confirm_helper + * parked waiting for a button decision unblocks and the thread can exit. + * Idempotent; a no-op if the thread was never started. kkemu_shutdown() + * calls this automatically. + */ +void kkemu_stop(void); + +/** + * Bracket a host-side read of the flash buffer (e.g. before encrypting and + * persisting it) so it can't tear a concurrent storage_commit() on the poll + * thread. No-op in host-driven mode. Must be balanced with kkemu_unlock(). + * + * kkemu_lock() BLOCKS and must not be used from a host loop that also has to + * stay alive to deliver a confirm decision — use kkemu_trylock() there. + */ +void kkemu_lock(void); +void kkemu_unlock(void); + +/** + * Non-blocking acquire of the firmware lock. Returns 1 if acquired (balance + * with kkemu_unlock()), 0 if currently held by the poll thread (e.g. during a + * pending confirm) — yield the host event loop and retry. Returns 1 as a no-op + * when the poll thread isn't running. + */ +int kkemu_trylock(void); + #ifdef __cplusplus } #endif diff --git a/include/keepkey/firmware/dice_input.h b/include/keepkey/firmware/dice_input.h new file mode 100644 index 000000000..3ef80c7aa --- /dev/null +++ b/include/keepkey/firmware/dice_input.h @@ -0,0 +1,49 @@ +/* + * 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 . + */ + +#ifndef KEEPKEY_FIRMWARE_DICE_INPUT_H +#define KEEPKEY_FIRMWARE_DICE_INPUT_H + +#include +#include + +/* d6 carries log2(6) = 2.585 bits per roll; targets follow the Coldcard + * convention of 50 rolls per 128-bit seed and 99 per 256-bit. */ +#define DICE_MAX_ROLLS 99 + +/// Number of rolls required for a given seed strength (128/192/256). +uint32_t dice_rolls_for_strength(uint32_t strength_bits); + +/// Collect `target` dice rolls on the device with the single button: +/// short press advances the 1-6/UNDO selector, holding the button commits +/// the selection. Announces itself with ButtonRequest_DiceRoll and accepts +/// input only after the host's ButtonAck. Under DEBUG_LINK, characters +/// '1'-'6' and 'u' (undo) arriving in DebugLinkDecision.input are treated +/// as committed selections. +/// +/// Fills `rolls` with `target` ASCII digits '1'-'6' (no terminator is +/// appended past target; the caller owns zeroization). Returns false if the +/// host cancelled (Cancel/Initialize). +bool dice_input_collect(char *rolls, uint32_t target); + +/// entropy = SHA256(entropy[32] || rolls[count]); the caller displays or +/// commits only the post-mix value. +void dice_mix(uint8_t entropy[32], const char *rolls, uint32_t count); + +#endif diff --git a/include/keepkey/firmware/reset.h b/include/keepkey/firmware/reset.h index a41cb101d..87af323fa 100644 --- a/include/keepkey/firmware/reset.h +++ b/include/keepkey/firmware/reset.h @@ -33,12 +33,23 @@ MAX_WORDS*(MAX_WORD_LEN + ADDITIONAL_WORD_PAD) + 1 #define MNEMONIC_BY_SCREEN_BUF WORDS_PER_SCREEN*(MAX_WORD_LEN + 1) + 1 -void reset_init(bool display_random, uint32_t _strength, - bool passphrase_protection, bool pin_protection, - const char* language, const char* label, bool _no_backup, - uint32_t _auto_lock_delay_ms, uint32_t _u2f_counter); +/* Paginated-mnemonic display scratch, shared between the backup flow here and + * the BIP-85 display flow (fsm_msg_bip85.h) — one ~2.8 KB set instead of two. + * Both flows are modal and single-threaded: each formats and displays inside + * its own handler call. Every user MUST memzero the set at entry AND on every + * exit path. Defined in reset.c (.confidential). */ +extern char mnemonic_scratch_tokened[TOKENED_MNEMONIC_BUF]; +extern char mnemonic_scratch_formatted[MAX_PAGES][FORMATTED_MNEMONIC_BUF]; +extern char mnemonic_scratch_display[FORMATTED_MNEMONIC_BUF]; +extern char mnemonic_scratch_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; + +void reset_init(uint32_t _strength, bool passphrase_protection, + bool pin_protection, const char* language, const char* label, + bool _no_backup, uint32_t _auto_lock_delay_ms, + uint32_t _u2f_counter, bool dice_entropy); void reset_entropy(const uint8_t* ext_entropy, uint32_t len); uint32_t reset_get_int_entropy(uint8_t* entropy); const char* reset_get_word(void); +uint32_t reset_get_dice_digest(uint8_t* digest); #endif diff --git a/lib/board/udp.c b/lib/board/udp.c index ba35d15ec..1240c2155 100644 --- a/lib/board/udp.c +++ b/lib/board/udp.c @@ -21,6 +21,7 @@ #include "keepkey/board/usb.h" #include "keepkey/board/timer.h" +#include "keepkey/board/layout.h" #include "keepkey/emulator/emulator.h" #include @@ -62,6 +63,11 @@ void usbPoll(void) { // msg_read_tiny(msg.message, sizeof(msg.message)); } } + + // Keep a queued progress animation moving while we block on host I/O (e.g. + // Zcash proof generation on the host), matching device usbPoll(). No-op + // unless a trickle animation is active. + layout_animate_poll(); } bool usb_tx(const uint8_t* msg, uint32_t len) { diff --git a/lib/emulator/CMakeLists.txt b/lib/emulator/CMakeLists.txt index 68cef0295..7d7eef5f8 100644 --- a/lib/emulator/CMakeLists.txt +++ b/lib/emulator/CMakeLists.txt @@ -3,7 +3,8 @@ if(${KK_EMULATOR}) set(sources oled.c udp.c - setup.c) + setup.c + random.c) @@ -19,6 +20,7 @@ if(${KK_EMULATOR}) oled.c udp.c setup.c + random.c ringbuf.c libkkemu.c) diff --git a/lib/emulator/libkkemu.c b/lib/emulator/libkkemu.c index be45bf608..e09d14ad6 100644 --- a/lib/emulator/libkkemu.c +++ b/lib/emulator/libkkemu.c @@ -24,11 +24,58 @@ #include #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN /* exclude winsock.h — it declares \ + shutdown(SOCKET,int) */ +#include +#else #include +#include +#include +#endif /* Defined in firmware — we just need the declaration */ extern void fsm_init(void); +/* ── Poll thread (Approach B: reactive confirm) ────────────────────────── + * + * Optional: the host calls kkemu_start() to run the firmware event loop on a + * dedicated thread inside the dylib. This lets confirm_helper's blocking C + * busy-loop wait for a button decision IN C without freezing the host's event + * loop — so the vault can render the real OLED confirm frame, HOLD it, and + * deliver the DebugLinkDecision only when the user clicks (screen-first gating, + * like a physical device). + * + * Only the poll thread ever drives firmware execution (kkemu_poll_body). The + * host interacts solely through the lock-free SPSC rings (kkemu_write/read, + * kkemu_pop_frame). g_fw_lock serializes the poll body against host-side flash + * snapshots (kkemu_lock/unlock) so storage_commit can't tear a saveFlash read. + * + * When the thread is NOT started (g_poll_running == 0) the dylib stays purely + * single-threaded and host-driven via kkemu_poll() — exactly as the FFI test + * suite and python-keepkey tests use it. The lock helpers no-op in that mode. + */ +#ifdef _WIN32 +static CRITICAL_SECTION g_fw_lock; +static HANDLE g_poll_thread = NULL; +#define FW_LOCK() EnterCriticalSection(&g_fw_lock) +#define FW_UNLOCK() LeaveCriticalSection(&g_fw_lock) +#else +static pthread_mutex_t g_fw_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_t g_poll_thread; +#define FW_LOCK() pthread_mutex_lock(&g_fw_lock) +#define FW_UNLOCK() pthread_mutex_unlock(&g_fw_lock) +#endif + +/* Cross-thread poll-running flag. _Atomic (not volatile — volatile is not a + * synchronization primitive in C): the poll thread reads it each loop while + * start/stop write it from the host thread. acquire/release publishes the + * surrounding firmware/ring state alongside the flag. */ +#include +static _Atomic int g_poll_running = 0; +#define POLL_RUNNING() atomic_load_explicit(&g_poll_running, memory_order_acquire) +#define POLL_SET(v) atomic_store_explicit(&g_poll_running, (v), memory_order_release) + /* ── Ring buffers (replace UDP sockets) ─────────────────────────────── */ static RingBuf rb_main_in; /* host → firmware (main interface) */ @@ -45,18 +92,27 @@ static int libkkemu_initialized = 0; * The host drains via kkemu_pop_frame(). Adjacent identical frames are * skipped so an idle firmware doesn't spam the ring. * - * Sized for ~4 seconds at 16ms refresh; if the host falls behind the - * oldest frames are dropped (write advances past read). + * Sized for ~4 seconds at 16ms refresh. Cross-thread in thread-driven mode: + * the poll thread is the sole producer, the host (kkemu_pop_frame) the sole + * consumer — a lock-free SPSC ring with the same atomic discipline as the HID + * rings (ringbuf.c). When the ring is full the producer drops the NEW frame + * (it must NOT overwrite a slot the consumer may be mid-copy on, and it must + * NOT write the consumer-owned read index). */ #define FRAME_PACKED_SIZE 2048 #define FRAME_RING_SIZE 64 +/* Host poll cadence (the vault's setInterval is ~16ms). kkemu_poll() ticks the + * firmware ms-timer this many times per call so animations advance at ~real + * speed without relying on the (host-runtime-unreliable) SIGALRM timer. */ +#define KKEMU_POLL_INTERVAL_MS 16 + static uint8_t frame_ring[FRAME_RING_SIZE][FRAME_PACKED_SIZE]; -static uint8_t last_packed[FRAME_PACKED_SIZE]; -static int last_packed_valid = 0; -static uint32_t frame_write_idx = - 0; /* monotonic, mod FRAME_RING_SIZE for slot */ -static uint32_t frame_read_idx = 0; /* monotonic */ +static uint8_t last_packed[FRAME_PACKED_SIZE]; /* producer-only (poll thread) */ +static int last_packed_valid = 0; /* producer-only */ +static uint8_t capture_scratch[FRAME_PACKED_SIZE]; /* producer-only pack buffer */ +static _Atomic uint32_t frame_write_idx = 0; /* written by producer ONLY */ +static _Atomic uint32_t frame_read_idx = 0; /* written by consumer ONLY */ /* * Scratch returned by kkemu_get_display(). File-scope (not function-static) @@ -107,28 +163,38 @@ size_t libkkemu_socketWrite(int iface, const void* buffer, size_t size) { static void libkkemu_capture_frame(const uint8_t* canvas_buf) { if (!canvas_buf) return; - uint8_t* slot = frame_ring[frame_write_idx % FRAME_RING_SIZE]; - memset(slot, 0, FRAME_PACKED_SIZE); + /* Pack into a producer-private scratch — NOT a ring slot. When the ring is + * full the next write slot still holds an unread frame the consumer may be + * copying, so we must decide to publish/drop before touching it. */ + memset(capture_scratch, 0, FRAME_PACKED_SIZE); for (int x = 0; x < 256; x++) { for (int y = 0; y < 64; y++) { if (canvas_buf[y * 256 + x] > 0) { - slot[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); + capture_scratch[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); } } } - /* Dedup: skip if identical to last captured */ - if (last_packed_valid && memcmp(slot, last_packed, FRAME_PACKED_SIZE) == 0) { + /* Dedup against the last captured frame (producer-only state). */ + if (last_packed_valid && + memcmp(capture_scratch, last_packed, FRAME_PACKED_SIZE) == 0) { return; } - memcpy(last_packed, slot, FRAME_PACKED_SIZE); - last_packed_valid = 1; - frame_write_idx++; - /* Drop oldest if host fell behind */ - if (frame_write_idx - frame_read_idx > FRAME_RING_SIZE) { - frame_read_idx = frame_write_idx - FRAME_RING_SIZE; - } + /* SPSC publish, drop-on-full (same discipline as ringbuf.c). The producer + * writes only frame_write_idx; the consumer writes only frame_read_idx. When + * not full, write%SIZE != read%SIZE (their distance is in [1, SIZE-1]), so + * producer and consumer never touch the same slot. last_packed is updated + * ONLY on a real publish, so a frame dropped while full can still be captured + * on a later tick. */ + uint32_t w = atomic_load_explicit(&frame_write_idx, memory_order_relaxed); + uint32_t r = atomic_load_explicit(&frame_read_idx, memory_order_acquire); + if (w - r >= FRAME_RING_SIZE) return; /* full → drop the new frame */ + + memcpy(frame_ring[w % FRAME_RING_SIZE], capture_scratch, FRAME_PACKED_SIZE); + memcpy(last_packed, capture_scratch, FRAME_PACKED_SIZE); + last_packed_valid = 1; + atomic_store_explicit(&frame_write_idx, w + 1, memory_order_release); } /* ── Public API ─────────────────────────────────────────────────────── */ @@ -151,12 +217,21 @@ int kkemu_init(uint8_t* flash_buf, size_t flash_len) { * Production hosts of libkkemu should treat a logged failure as a * security warning and refuse to load secrets. */ +#ifdef _WIN32 + if (!VirtualLock(flash_buf, flash_len)) { + fprintf(stderr, + "[libkkemu] VirtualLock(%zu bytes) failed (err %lu) — flash buffer " + "may be paged to disk; do not load production secrets\n", + flash_len, (unsigned long)GetLastError()); + } +#else if (mlock(flash_buf, flash_len) != 0) { fprintf(stderr, "[libkkemu] mlock(%zu bytes) failed: %s — flash buffer may be " "swapped to disk; do not load production secrets\n", flash_len, strerror(errno)); } +#endif /* Initialize ring buffers (replaces UDP socket init) */ libkkemu_socketInit(); @@ -193,6 +268,11 @@ int kkemu_init(uint8_t* flash_buf, size_t flash_len) { void kkemu_shutdown(void) { if (!libkkemu_initialized) return; + /* Stop + join the poll thread FIRST so nothing drives firmware execution + * while we commit storage and zero the rings below (idempotent if the host + * never started the thread). */ + kkemu_stop(); + /* Flush any pending storage to the flash buffer */ storage_commit(); @@ -220,6 +300,7 @@ void kkemu_shutdown(void) { memzero(&rb_debug_out, sizeof(rb_debug_out)); memzero(frame_ring, sizeof(frame_ring)); memzero(last_packed, sizeof(last_packed)); + memzero(capture_scratch, sizeof(capture_scratch)); memzero(display_packed_scratch, sizeof(display_packed_scratch)); last_packed_valid = 0; frame_write_idx = 0; @@ -231,7 +312,11 @@ void kkemu_shutdown(void) { * want to inspect / persist post-mortem state. Documented contract. */ if (emulator_flash_base) { +#ifdef _WIN32 + VirtualUnlock(emulator_flash_base, KKEMU_FLASH_SIZE); +#else munlock(emulator_flash_base, KKEMU_FLASH_SIZE); +#endif emulator_flash_base = NULL; } @@ -254,35 +339,198 @@ int kkemu_read(uint8_t* buf, size_t len, int iface) { return ringbuf_pop(rb, buf, KKEMU_PACKET_SIZE) ? KKEMU_PACKET_SIZE : 0; } -int kkemu_poll(void) { - if (!libkkemu_initialized) return -1; +/* + * One iteration of the firmware event loop. Same as exec() in main.cpp: + * usbPoll() — reads input, dispatches through FSM + * animate() — updates screen animations + * display_refresh() — renders framebuffer + * + * usbPoll() internally calls emulatorSocketRead() which we've replaced with + * libkkemu_socketRead() via the ring buffers. + * + * Drive the firmware millisecond timer from the poll on EVERY platform. The + * dylib is caller-driven; relying on the SIGALRM/ualarm timer (which the + * standalone kkemu binary uses) is unreliable inside the host runtime — Bun + * does not deliver the firmware's SIGALRM, so animate_flag never flips and + * every animation (boot logo, screensaver) stays frozen → a blank OLED at + * rest. Tick ~one poll-interval of milliseconds so the periodic animation + * runnable fires and animations + delay_ms() advance at roughly real speed. + */ +static void kkemu_poll_body(void) { + for (int t = 0; t < KKEMU_POLL_INTERVAL_MS; t++) timerisr_usr(); - /* - * This is the same as exec() in main.cpp: - * usbPoll() — reads input, dispatches through FSM - * animate() — updates screen animations - * display_refresh() — renders framebuffer - * - * usbPoll() internally calls emulatorSocketRead() which we've - * replaced with libkkemu_socketRead() via the ring buffers. - */ usbPoll(); animate(); display_refresh(); +} +int kkemu_poll(void) { + if (!libkkemu_initialized) return -1; + /* When the poll thread owns execution, the host must not also poll — + * that would be two threads driving the single-threaded firmware core. + * Treat a stray host poll as a no-op rather than a data race. */ + if (POLL_RUNNING()) return 0; + kkemu_poll_body(); return 0; } +static void kkemu_sleep_ms(int ms) { +#ifdef _WIN32 + Sleep((DWORD)ms); +#else + struct timespec ts = {ms / 1000, (long)(ms % 1000) * 1000000L}; + nanosleep(&ts, NULL); +#endif +} + +/* The poll thread holds g_fw_lock across each body call, releasing it during + * the inter-poll sleep. While confirm_helper busy-waits for a decision the body + * does not return, so the lock stays held for the whole confirm — but that must + * NOT block the host: the decision is delivered through the lock-free rings, and + * the host acquires the lock for flash snapshots via kkemu_trylock() (which + * never blocks the host event loop). The host must never take g_fw_lock with a + * blocking call while a confirm may be pending, or it would deadlock against the + * very loop that needs the host alive to deliver the decision. */ +static void kkemu_poll_loop(void) { + while (POLL_RUNNING()) { + FW_LOCK(); + if (POLL_RUNNING()) kkemu_poll_body(); + FW_UNLOCK(); + kkemu_sleep_ms(KKEMU_POLL_INTERVAL_MS); + } +} + +#ifdef _WIN32 +static DWORD WINAPI kkemu_poll_thread_fn(LPVOID arg) { + (void)arg; + kkemu_poll_loop(); + return 0; +} +#else +static void* kkemu_poll_thread_fn(void* arg) { + (void)arg; + kkemu_poll_loop(); + return NULL; +} +#endif + +/* Push a Cancel (MessageType 20) into the main input ring so a confirm_helper + * blocked on the poll thread reads it, returns false, and lets the thread exit + * its loop — otherwise kkemu_stop() would join a thread parked forever waiting + * for a button decision that will never arrive. + * + * This injected Cancel is the ONLY firmware-side wakeup for a parked confirm + * (confirm_helper has no idle timeout in EMULATOR builds), and kkemu_stop() + * then joins the thread with no deadline — so a SILENTLY dropped Cancel would + * freeze the (single-threaded) host forever, beyond any watchdog's reach. The + * push can only fail if rb_main_in is full; the parked confirm drains one input + * frame per spin, so a slot frees within ~a poll tick. Retry briefly, and shout + * loudly if it somehow never takes rather than dropping it. */ +static void kkemu_inject_cancel(void) { + uint8_t frame[KKEMU_PACKET_SIZE]; + memset(frame, 0, sizeof(frame)); + frame[0] = 0x3F; /* '?' HID report marker */ + frame[1] = 0x23; /* '#' */ + frame[2] = 0x23; /* '#' */ + frame[3] = 0x00; /* MessageType_Cancel high */ + frame[4] = 0x14; /* MessageType_Cancel low (20) */ + /* payload length 0 (bytes 5-8 already zero) */ + for (int i = 0; i < 200; i++) { + if (ringbuf_push(&rb_main_in, frame, sizeof(frame))) return; + kkemu_sleep_ms(1); + } + fprintf(stderr, + "[libkkemu] FATAL: could not inject Cancel to wake a parked confirm " + "before join — rb_main_in stayed full for ~200ms; the poll thread may " + "not exit\n"); +} + +int kkemu_start(void) { + if (!libkkemu_initialized) return -1; + if (POLL_RUNNING()) return 0; /* idempotent */ + +#ifdef _WIN32 + InitializeCriticalSection(&g_fw_lock); + POLL_SET(1); + g_poll_thread = CreateThread(NULL, 0, kkemu_poll_thread_fn, NULL, 0, NULL); + if (!g_poll_thread) { + POLL_SET(0); + DeleteCriticalSection(&g_fw_lock); + return -1; + } +#else + POLL_SET(1); + if (pthread_create(&g_poll_thread, NULL, kkemu_poll_thread_fn, NULL) != 0) { + POLL_SET(0); + return -1; + } +#endif + return 0; +} + +void kkemu_stop(void) { + if (!POLL_RUNNING()) return; + + POLL_SET(0); + /* Unblock any confirm_helper currently parked on the thread, then join. */ + kkemu_inject_cancel(); +#ifdef _WIN32 + if (g_poll_thread) { + WaitForSingleObject(g_poll_thread, INFINITE); + CloseHandle(g_poll_thread); + g_poll_thread = NULL; + } + DeleteCriticalSection(&g_fw_lock); +#else + pthread_join(g_poll_thread, NULL); +#endif +} + +/* Host-side guard for reading the shared flash buffer (saveFlash) without + * tearing a concurrent storage_commit on the poll thread. No-op when the + * thread isn't running (single-threaded test path needs no lock, and on + * Windows the CRITICAL_SECTION only exists between start and stop). + * + * WARNING: kkemu_lock() BLOCKS, and the poll thread can hold g_fw_lock for the + * whole duration of a pending confirm. The host must therefore NOT call + * kkemu_lock() from a thread/loop that also has to stay alive to deliver the + * confirm decision (it would deadlock). Use kkemu_trylock() + an event-loop + * yield there instead. kkemu_lock() is retained for paths with no pending + * confirm. */ +void kkemu_lock(void) { + if (POLL_RUNNING()) FW_LOCK(); +} + +void kkemu_unlock(void) { + if (POLL_RUNNING()) FW_UNLOCK(); +} + +/* Non-blocking acquire. Returns 1 if the firmware lock is now held by the + * caller (balance with kkemu_unlock()), 0 if it is currently held by the poll + * thread (e.g. mid-confirm) — the caller should yield its event loop and retry, + * which keeps the loop alive to deliver the decision that releases the lock. + * No-op success (returns 1, nothing to unlock) when the thread isn't running. */ +int kkemu_trylock(void) { + if (!POLL_RUNNING()) return 1; +#ifdef _WIN32 + return TryEnterCriticalSection(&g_fw_lock) ? 1 : 0; +#else + return pthread_mutex_trylock(&g_fw_lock) == 0 ? 1 : 0; +#endif +} + +/* + * Snapshot the current OLED canvas into packed SSD1306 format (byte index = + * x + (y/8)*256, bit = y%8). Host-driven convenience used by the python + * screenshot harness, which drives the firmware single-threaded via kkemu_poll. + * + * WARNING: NOT thread-safe. It reads the live firmware canvas directly with no + * synchronization against the poll thread, so it is only safe in HOST-DRIVEN + * mode (no kkemu_start). In thread-driven mode the canonical, race-free way to + * observe the display is the SPSC capture ring via kkemu_pop_frame(); do not + * wire kkemu_get_display into a threaded host. + */ const uint8_t* kkemu_get_display(int* width, int* height) { - /* - * Pack the firmware's 8-bpp grayscale canvas (256×64 = 16384 bytes) into - * the 1-bit packed layout vault expects (2048 bytes). Same format - * DebugLinkGetState.layout uses: byte index = x + (y/8)*256, - * bit within byte = y%8 (LSB = top row of the 8-pixel column). - * - * Output goes into the file-scope `display_packed_scratch` so - * kkemu_shutdown() can zero it on teardown alongside the frame ring. - */ if (!libkkemu_initialized) { if (width) *width = 0; if (height) *height = 0; @@ -312,10 +560,13 @@ const uint8_t* kkemu_get_display(int* width, int* height) { int kkemu_pop_frame(uint8_t* out_packed) { if (!libkkemu_initialized || !out_packed) return 0; - if (frame_read_idx == frame_write_idx) return 0; - const uint8_t* slot = frame_ring[frame_read_idx % FRAME_RING_SIZE]; - memcpy(out_packed, slot, FRAME_PACKED_SIZE); - frame_read_idx++; + /* SPSC consume: read frame_read_idx (we own it) and frame_write_idx (acquire, + * to see the producer's slot write). Empty when the indices are equal. */ + uint32_t r = atomic_load_explicit(&frame_read_idx, memory_order_relaxed); + uint32_t w = atomic_load_explicit(&frame_write_idx, memory_order_acquire); + if (r == w) return 0; + memcpy(out_packed, frame_ring[r % FRAME_RING_SIZE], FRAME_PACKED_SIZE); + atomic_store_explicit(&frame_read_idx, r + 1, memory_order_release); return 1; } diff --git a/lib/emulator/random.c b/lib/emulator/random.c new file mode 100644 index 000000000..f05fdb1fd --- /dev/null +++ b/lib/emulator/random.c @@ -0,0 +1,69 @@ +/* + * This file is part of the TREZOR project, https://trezor.io/ + * + * Copyright (C) 2017 Saleem Rashid + * + * 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. + */ + +#include "keepkey/emulator/emulator.h" +#include "keepkey/emulator/setup.h" + +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#else +#include +#include + +static int urandom = -1; + +static void setup_urandom(void) { + if (urandom >= 0) return; + + urandom = open("/dev/urandom", O_RDONLY); + if (urandom < 0) { + perror("Failed to open /dev/urandom"); + exit(1); + } +} +#endif + +void setup_urandom_only(void) { +#ifndef _WIN32 + setup_urandom(); +#endif +} + +void emulatorRandom(void* buffer, size_t size) { +#ifdef _WIN32 + /* Windows has no /dev/urandom — use the system CSPRNG. */ + if (BCryptGenRandom(NULL, (PUCHAR)buffer, (ULONG)size, + BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + fprintf(stderr, "BCryptGenRandom failed\n"); + exit(1); + } +#else + setup_urandom(); + unsigned char* out = (unsigned char*)buffer; + size_t remaining = size; + while (remaining > 0) { + ssize_t n = read(urandom, out, remaining); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { + perror("Failed to read /dev/urandom"); + exit(1); + } + out += (size_t)n; + remaining -= (size_t)n; + } +#endif +} diff --git a/lib/emulator/setup.c b/lib/emulator/setup.c index 1b4d657d4..58ad9933a 100644 --- a/lib/emulator/setup.c +++ b/lib/emulator/setup.c @@ -19,49 +19,36 @@ #include "keepkey/board/memory.h" #include "keepkey/board/timer.h" -#include "keepkey/rand/rng.h" +#include "keepkey/emulator/setup.h" -#include -#include #include #include #include +#ifndef _WIN32 +#include #include #include +#endif #define EMULATOR_FLASH_FILE "emulator.img" -uint32_t __stack_chk_guard; - -static int urandom = -1; +/* __stack_chk_guard is defined once in lib/board/keepkey_board.c (as + * uintptr_t). It used to be redefined here as uint32_t, which is (a) the wrong + * size on 64-bit hosts and (b) a duplicate strong symbol. Apple's ld silently + * merged the two; GNU/MinGW ld rejects it ("multiple definition"), which + * blocked the Linux .so and Windows .dll builds. Removed — the board copy is + * canonical. */ -static void setup_urandom(void); +#ifndef _WIN32 static void setup_flash(void); void setup(void) { - setup_urandom(); + setup_urandom_only(); setup_flash(); } +#endif -/* For libkkemu: init RNG only (flash buffer provided by host) */ -void setup_urandom_only(void) { setup_urandom(); } - -void emulatorRandom(void* buffer, size_t size) { - ssize_t n = read(urandom, buffer, size); - if (n < 0 || ((size_t)n) != size) { - perror("Failed to read /dev/urandom"); - exit(1); - } -} - -static void setup_urandom(void) { - urandom = open("/dev/urandom", O_RDONLY); - if (urandom < 0) { - perror("Failed to open /dev/urandom"); - exit(1); - } -} - +#ifndef _WIN32 static void setup_flash(void) { int fd = open(EMULATOR_FLASH_FILE, O_RDWR | O_SYNC | O_CREAT, 0644); if (fd < 0) { @@ -92,3 +79,5 @@ static void setup_flash(void) { memset(emulator_flash_base, 0xff, FLASH_TOTAL_SIZE); } } +#endif /* !_WIN32 — setup_flash is standalone-UDP only; the dylib/DLL host \ + owns flash */ diff --git a/lib/emulator/udp.c b/lib/emulator/udp.c index 671c6437b..265886c63 100644 --- a/lib/emulator/udp.c +++ b/lib/emulator/udp.c @@ -17,17 +17,22 @@ * along with this library. If not, see . */ -#include #include #include #include #include -#include #ifndef KEEPKEY_UDP_PORT #define KEEPKEY_UDP_PORT 11044 #endif +#ifndef KKEMU_DYLIB +/* Sockets are only used by the standalone UDP binary. In dylib/DLL mode all + * I/O goes through ring buffers (below), so skip the BSD socket headers and + * helpers entirely — they don't exist on MinGW/Windows. */ +#include +#include + struct usb_socket { int fd; struct sockaddr_in from; @@ -95,6 +100,7 @@ static size_t socket_read(struct usb_socket* sock, void* buffer, size_t size) { return n; } +#endif /* !KKEMU_DYLIB — socket helpers are standalone-UDP only */ #ifdef KKEMU_DYLIB /* diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index 7416b9524..da893616f 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -20,6 +20,7 @@ set(sources ethereum_contracts/zxtransERC20.c ethereum_contracts/zxswap.c ethereum_tokens.c + dice_input.c fsm.c home_sm.c mayachain.c diff --git a/lib/firmware/dice_input.c b/lib/firmware/dice_input.c new file mode 100644 index 000000000..996ef540b --- /dev/null +++ b/lib/firmware/dice_input.c @@ -0,0 +1,387 @@ +/* + * 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 . + */ + +#include "keepkey/firmware/dice_input.h" + +#include "keepkey/board/draw.h" +#include "keepkey/board/font.h" +#include "keepkey/board/keepkey_button.h" +#include "keepkey/board/keepkey_display.h" +#include "keepkey/board/layout.h" +#include "keepkey/board/messages.h" +#include "keepkey/board/supervise.h" +#include "keepkey/board/timer.h" +#include "keepkey/transport/interface.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/sha2.h" + +#include +#include + +#define _(X) (X) + +/* Selector positions 0-5 are digits '1'-'6'; 6 is UNDO. */ +#define DICE_POSITIONS 7 +#define DICE_UNDO_POS 6 + +/* Holding this long commits the selection; edges closer together than the + * debounce window are contact bounce. Distinct from CONFIRM_TIMEOUT_MS on + * purpose: a 1200ms hold per roll makes 99 rolls a slog. */ +#define DICE_HOLD_MS 800 +#define DICE_DEBOUNCE_MS 30 + +/* The screen runs with display_constant_power(true): the display driver + * fills x<128 with the INVERSE of x>=128 at refresh time so total lit + * pixels stay constant (OLED power side-channel defense — same reason the + * PIN matrix lives on the right half). All drawing must stay in x>=128. */ +#define DICE_LEFT 130 +#define DICE_CELL_SIZE 15 +#define DICE_CELL_GAP 2 +#define DICE_GRID_Y 14 +#define DICE_STATUS_Y 33 +#define DICE_BAR_X DICE_LEFT +#define DICE_BAR_Y 48 +#define DICE_BAR_W (7 * DICE_CELL_SIZE + 6 * DICE_CELL_GAP) +#define DICE_BAR_H 6 + +extern bool reset_msg_stack; + +/* Button state shared with the ISR. Every classification decision (short vs + * hold) is made exactly once per press cycle and guarded by dice_committed, + * so a press can never produce both an advance and a commit. The UI loop + * reads and drains these under masked interrupts. */ +static volatile bool dice_accept; /* host has ButtonAck'd the screen */ +static volatile bool dice_pressed; +static volatile bool dice_committed; /* this press cycle already classified */ +static volatile uint32_t dice_press_start; +static volatile uint32_t dice_release_time; +static volatile bool dice_have_release; +static volatile uint8_t dice_short_events; +static volatile uint8_t dice_hold_events; + +#ifndef EMULATOR +static void dice_on_press(void *context) { + (void)context; + uint32_t now = getSysTime(); + /* Mirror confirm_sm: input is dead until the host acks the request, so a + * press begun before the ack cannot accrue hold time toward a commit. */ + if (!dice_accept || dice_pressed) { + return; + } + dice_pressed = true; + if (dice_have_release && now - dice_release_time < DICE_DEBOUNCE_MS) { + /* Release-edge bounce: the release that just queued an event was not a + * real one. Retract it and continue the original press cycle — the UI + * loop is barred from consuming events until the line has settled for + * DICE_DEBOUNCE_MS, so it cannot have acted on it yet. */ + if (!dice_committed && dice_short_events > 0) { + dice_short_events--; + } + return; + } + dice_press_start = now; + dice_committed = false; +} + +static void dice_on_release(void *context) { + (void)context; + uint32_t now = getSysTime(); + if (!dice_accept || !dice_pressed) { + return; + } + dice_pressed = false; + dice_release_time = now; + dice_have_release = true; + if (dice_committed) { + return; /* the UI loop already committed this hold while it was held */ + } + uint32_t held = now - dice_press_start; + if (held >= DICE_HOLD_MS) { + /* A hold completed inside the UI-loop poll gap still counts. */ + dice_committed = true; + if (dice_hold_events < 8) { + dice_hold_events++; + } + } else if (held >= DICE_DEBOUNCE_MS && dice_short_events < 8) { + dice_short_events++; + } +} +#endif + +uint32_t dice_rolls_for_strength(uint32_t strength_bits) { + switch (strength_bits) { + case 128: + return 50; + case 192: + return 75; + default: + return 99; /* 256 */ + } +} + +void dice_mix(uint8_t entropy[32], const char *rolls, uint32_t count) { + SHA256_CTX ctx; + sha256_Init(&ctx); + sha256_Update(&ctx, entropy, 32); + sha256_Update(&ctx, (const uint8_t *)rolls, count); + sha256_Final(&ctx, entropy); + memzero(&ctx, sizeof(ctx)); +} + +static void dice_draw_screen(uint32_t count, uint32_t target, uint8_t position, + const char *status, uint16_t hold_permil) { + Canvas *canvas = layout_get_canvas(); + char line[32]; + + layout_clear(); + display_constant_power(true); + + DrawableParams p = {.color = 0xFF, .x = DICE_LEFT, .y = 0}; + /* Clamped: the final commit redraws before the loop re-tests its + * condition, which would otherwise render an impossible "ROLL 100/99". */ + snprintf(line, sizeof(line), "ROLL %lu/%lu", + (unsigned long)(count < target ? count + 1 : target), + (unsigned long)target); + draw_string(canvas, get_title_font(), line, &p, 0, 10); + + for (uint8_t i = 0; i < DICE_POSITIONS; i++) { + uint16_t cx = DICE_LEFT + i * (DICE_CELL_SIZE + DICE_CELL_GAP); + bool active = (i == position); + /* Inverse video marks the active cell: white box, ink-black glyph. + * Gray levels collapse to white in the 1bpp DebugLink capture, so the + * machine-checkable signal must be geometry, not shade. */ + draw_box_simple(canvas, active ? 0xFF : 0x22, cx, DICE_GRID_Y, + DICE_CELL_SIZE, DICE_CELL_SIZE); + uint8_t ink = active ? 0x00 : 0xFF; + if (i < DICE_UNDO_POS) { + /* pin_font '1' is 4px wide where '2'-'6' are 8px (font.c) — center + * each on its own metric rather than on the common case. */ + uint16_t glyph_w = (i == 0) ? 4 : 8; + draw_char_simple(canvas, get_pin_font(), (char)('1' + i), ink, + cx + (DICE_CELL_SIZE - glyph_w) / 2, DICE_GRID_Y + 2); + } else { + draw_char_simple(canvas, get_title_font(), '<', ink, cx + 5, + DICE_GRID_Y + 3); + } + } + + p.color = 0xFF; + p.x = DICE_LEFT; + p.y = DICE_STATUS_Y; + draw_string(canvas, get_body_font(), status, &p, DICE_BAR_W, 10); + + if (hold_permil > 0) { + draw_box_simple(canvas, 0xCC, DICE_BAR_X, DICE_BAR_Y, DICE_BAR_W, + DICE_BAR_H); + draw_box_simple(canvas, 0x00, DICE_BAR_X + 1, DICE_BAR_Y + 1, + DICE_BAR_W - 2, DICE_BAR_H - 2); + uint16_t fill = + (uint16_t)(((uint32_t)(DICE_BAR_W - 2) * hold_permil) / 1000); + if (fill > 0) { + draw_box_simple(canvas, 0xFF, DICE_BAR_X + 1, DICE_BAR_Y + 1, fill, + DICE_BAR_H - 2); + } + } + + display_refresh(); +} + +bool dice_input_collect(char *rolls, uint32_t target) { + uint32_t count = 0; + uint8_t position = 0; + bool ret = false; + bool redraw = true; + uint16_t last_bar_permil = 0; + char status[48]; + static CONFIDENTIAL uint8_t msg_tiny_buf[MSG_TINY_BFR_SZ]; + +#if DEBUG_LINK + _Static_assert(sizeof(DebugLinkDecision) <= MSG_TINY_BFR_SZ, + "DebugLinkDecision must fit the tiny message buffer"); +#endif + + if (target > DICE_MAX_ROLLS) { + return false; + } + + reset_msg_stack = false; + + dice_accept = false; + dice_pressed = false; + dice_committed = false; + dice_press_start = 0; + dice_release_time = 0; + dice_have_release = false; + dice_short_events = 0; + dice_hold_events = 0; + + call_leaving_handler(); + + snprintf(status, sizeof(status), _("PRESS next HOLD ok")); + +#ifndef EMULATOR + keepkey_button_set_on_press_handler(&dice_on_press, NULL); + keepkey_button_set_on_release_handler(&dice_on_release, NULL); +#endif + + ButtonRequest br; + memset(&br, 0, sizeof(br)); + br.has_code = true; + br.code = ButtonRequestType_ButtonRequest_DiceRoll; + msg_write(MessageType_MessageType_ButtonRequest, &br); + + while (count < target) { + bool pressed; + uint32_t held = 0; + uint8_t shorts = 0; + uint8_t holds; + + /* One critical section performs the whole read-classify-drain step, so + * the in-flight hold below cannot also be classified by the release ISR + * (and vice versa): whoever gets there first sets dice_committed. */ +#ifndef EMULATOR + svc_disable_interrupts(); +#endif + { + uint32_t now = getSysTime(); + pressed = dice_pressed; + if (pressed) { + held = now - dice_press_start; + if (!dice_committed && held >= DICE_HOLD_MS) { + dice_committed = true; + if (dice_hold_events < 8) { + dice_hold_events++; + } + } + } + /* Queued short presses stay queued until a debounce window has passed + * since the release that produced them, giving dice_on_press the + * chance to retract a bounce-generated one before it is acted on. + * Deliberately NOT conditioned on the button being up: a retraction + * can only happen inside that window, so once it closes the count is + * final. Waiting for the button to be released instead would let a + * tap-then-hold commit the digit the tap was meant to move off of. */ + if (dice_have_release && now - dice_release_time >= DICE_DEBOUNCE_MS) { + shorts = dice_short_events; + dice_short_events = 0; + } + holds = dice_hold_events; + dice_hold_events = 0; + } +#ifndef EMULATOR + svc_enable_interrupts(); +#endif + + uint16_t tiny_msg = check_for_tiny_msg(msg_tiny_buf); + switch (tiny_msg) { + case MessageType_MessageType_ButtonAck: + dice_accept = true; /* arms the button ISRs and debug injection */ + break; + + case MessageType_MessageType_Cancel: + case MessageType_MessageType_Initialize: + if (tiny_msg == MessageType_MessageType_Initialize) { + reset_msg_stack = true; + } + goto dice_exit; + +#if DEBUG_LINK + case MessageType_MessageType_DebugLinkDecision: { + const DebugLinkDecision *dld = (const DebugLinkDecision *)msg_tiny_buf; + if (dice_accept && dld->has_input) { + for (const char *c = dld->input; *c != '\0' && count < target; c++) { + if (*c >= '1' && *c <= '6') { + rolls[count++] = *c; + snprintf(status, sizeof(status), _("Entered %c (%lu)"), *c, + (unsigned long)count); + } else if (*c == 'u' && count > 0) { + count--; + snprintf(status, sizeof(status), _("Removed #%lu"), + (unsigned long)(count + 1)); + } + } + redraw = true; + } + break; + } + + case MessageType_MessageType_DebugLinkGetState: + call_msg_debug_link_get_state_handler( + (DebugLinkGetState *)msg_tiny_buf); + break; +#endif + + default: + break; + } + + if (shorts > 0) { + position = (uint8_t)((position + shorts) % DICE_POSITIONS); + redraw = true; + } + + /* Commits arrive either from the in-flight check above or from a release + * that completed inside the poll gap; both funnel through here, and + * dice_committed guarantees at most one per press. */ + while (holds-- > 0 && count < target) { + if (position < DICE_UNDO_POS) { + rolls[count++] = (char)('1' + position); + snprintf(status, sizeof(status), _("Entered %c (%lu)"), + (char)('1' + position), (unsigned long)count); + } else if (count > 0) { + count--; + snprintf(status, sizeof(status), _("Removed #%lu"), + (unsigned long)(count + 1)); + } else { + snprintf(status, sizeof(status), _("Nothing to undo")); + } + redraw = true; + } + + uint16_t bar_permil = 0; + if (pressed && held < DICE_HOLD_MS) { + bar_permil = (uint16_t)((held * 1000) / DICE_HOLD_MS); + } else if (pressed) { + bar_permil = 1000; /* held past the threshold: keep the bar full */ + } + + /* Quantize the bar so idle passes stay refresh-free. */ + bar_permil = (uint16_t)(bar_permil - (bar_permil % 50)); + if (redraw || bar_permil != last_bar_permil) { + dice_draw_screen(count, target, position, status, bar_permil); + last_bar_permil = bar_permil; + redraw = false; + } + + animate(); + display_refresh(); + } + + ret = true; + +dice_exit: + dice_accept = false; +#ifndef EMULATOR + keepkey_button_set_on_press_handler(NULL, NULL); + keepkey_button_set_on_release_handler(NULL, NULL); +#endif + memzero(status, sizeof(status)); + memzero(msg_tiny_buf, sizeof(msg_tiny_buf)); + return ret; +} diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index d1ba0eed9..3f26fc2db 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -482,22 +482,126 @@ void fsm_msgFirmwareUpload(FirmwareUpload* msg) { "Not in bootloader mode"); } +/* Bytes of entropy a host may collect per boot without a button press. + * + * Auditing the RNG (bias tests, birthday/collision scans) needs bulk + * samples, and a press per kilobyte made that impossible on real hardware + * -- so nobody ever checked. The returned bytes are drawn fresh and + * discarded; they are never reused as key material, and the STM32 RNG is a + * free-running noise source rather than a seeded DRBG, so observing output + * reveals nothing about past or future draws. + * + * What the press did still buy is a cap on bias characterization: random32() + * returns RNG_DR raw with no whitening, and unlimited raw output lets a + * hostile host measure that bias precisely. A per-boot budget keeps that + * cap against a remote malicious host (which cannot replug) while leaving + * an audit plenty of room. Once spent, the confirm comes back; replug to + * refresh. */ +#define ENTROPY_FREE_BUDGET (64 * 1024) + +/* 64 KB is NOT "plenty of room" for the health test this enables. + * + * Scope first, because it is easy to overclaim: bulk output supports RNG HEALTH + * testing, not entropy measurement. No amount of output analysis can bound the + * entropy of an RNG's internal state -- a good expander seeded with 40 bits + * emits a stream that passes every test below, by construction. What this + * catches is stuck/biased output, repeated buffers, transport caching, gross + * correlation, and a broken test harness. That is worth having and was + * previously impossible on hardware; it is not proof of unpredictability. + * + * The size is set by the POSITIVE control, not by a detection threshold. A + * zero-collision result proves nothing on its own -- a detector that never + * fires also returns zero -- so the scan must also be run at a width where + * collisions are EXPECTED and their count checked against theory. 32-bit + * collisions over N blocks expect N^2/2^33: at 64 KB that is 0.03 (the control + * cannot run at all), at 1 MB it is 8, at 8 MB it is 512, tight enough that a + * broken or no-op detector is obvious. 8 MB is the first size at which the + * result means anything. + * + * For reference, since it invites misreading: a 64-bit scan over N=2^20 expects + * one collision at a 39-bit support, but P(0 collisions) is then e^-1 = 37%. + * Zero collisions excludes only <=37.4 bits at 95% confidence, and says nothing + * whatsoever about a low-entropy state behind a strong PRNG. + * + * The per-boot cap was also asymmetric in the wrong direction: it never stopped + * a patient remote attacker (host malware simply waits for the natural replugs + * that happen anyway and accumulates 64 KB at a time over days), while it fully + * priced out the honest auditor, who needs one contiguous run and otherwise + * faces 128 manual replugs. + * + * So the bulk path is gated on a single explicit press instead of a byte count, + * and only before initialization: + * + * - one confirm per boot unlocks unmetered draws. A remote host cannot forge + * it, which is the property the byte cap was only approximating. + * - uninitialized only. No seed exists, so there is no key material to + * correlate against; the 32 bytes that DO become a seed are drawn later, in + * reset.c, from noise that has not happened yet, and are SHA-256'd with + * host-supplied entropy before use. + * + * Initialized devices are untouched: ENTROPY_FREE_BUDGET, then a press every + * time, exactly as before. The unlock re-locks the instant ResetDevice + * completes, because storage_isInitialized() is re-read on every call. + * + * A wiped device returns to uninitialized and can be audited again. That is + * intended -- it still holds no seed, and re-auditing before re-seeding is + * precisely the supported flow. */ + +/* Whether the budget above may be spent without a press. + * + * GetEntropy has no PIN or initialization gate -- the button press WAS the + * human gate. Dropping it unconditionally would let someone holding a locked + * device harvest raw RNG output silently, and replug to repeat, so restrict + * the press-free path to states where there is either nothing to protect or + * a user demonstrably present: + * + * - uninitialized: no seed exists yet. This is the case that matters -- + * auditing the RNG *before* trusting it to generate a seed. + * - no PIN configured: nothing is locked, so the press guards nothing that + * physical possession does not already defeat. + * - PIN already entered this session: the user is right there. + * + * An initialized, PIN-protected, locked device is the stolen / evil-maid + * case and falls back to the confirm exactly as before. */ +static bool entropy_press_free_allowed(void) { + if (!storage_isInitialized()) return true; + if (!storage_hasPin()) return true; + return session_isPinCached(); +} + void fsm_msgGetEntropy(GetEntropy* msg) { - if (!confirm(ButtonRequestType_ButtonRequest_GetEntropy, "Generate Entropy", - "Do you want to generate and return entropy using the hardware " - "RNG?")) { - fsm_sendFailure(FailureType_Failure_ActionCancelled, "Entropy cancelled"); - layoutHome(); - return; - } + static uint32_t free_budget = ENTROPY_FREE_BUDGET; + /* Set by one confirm on an uninitialized device; unlocks unmetered draws for + * the rest of the boot. Re-checked against storage_isInitialized() on every + * call, so completing ResetDevice re-locks it without needing a replug. */ + static bool bulk_audit_unlocked = false; - RESP_INIT(Entropy); uint32_t len = msg->size; if (len > ENTROPY_BUF) { len = ENTROPY_BUF; } + if (bulk_audit_unlocked && !storage_isInitialized()) { + /* Already authorized for bulk audit this boot. */ + } else if (len <= free_budget && entropy_press_free_allowed()) { + free_budget -= len; + } else if (!confirm(ButtonRequestType_ButtonRequest_GetEntropy, + "Generate Entropy", + "Do you want to generate and return entropy using the " + "hardware RNG?")) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, "Entropy cancelled"); + layoutHome(); + return; + } else if (!storage_isInitialized()) { + /* The press just paid for a bulk RNG audit on a seedless device — don't + * charge for it again. An initialized device deliberately falls through: + * it keeps confirming every draw once its budget is spent. */ + bulk_audit_unlocked = true; + } + + RESP_INIT(Entropy); + resp->entropy.size = len; random_buffer(resp->entropy.bytes, len); msg_write(MessageType_MessageType_Entropy, resp); @@ -535,8 +639,10 @@ void fsm_msgLoadDevice(LoadDevice* msg) { void fsm_msgResetDevice(ResetDevice* msg) { CHECK_NOT_INITIALIZED - reset_init(msg->has_display_random && msg->display_random, - msg->has_strength ? msg->strength : 128, + // display_random remains in the wire schema for host compatibility, but is + // intentionally ignored: internal entropy is seed pre-image material and + // must never be rendered or returned by production firmware. + reset_init(msg->has_strength ? msg->strength : 128, msg->has_passphrase_protection && msg->passphrase_protection, msg->has_pin_protection && msg->pin_protection, msg->has_language ? msg->language : 0, @@ -544,7 +650,8 @@ void fsm_msgResetDevice(ResetDevice* msg) { msg->has_no_backup ? msg->no_backup : false, msg->has_auto_lock_delay_ms ? msg->auto_lock_delay_ms : STORAGE_DEFAULT_SCREENSAVER_TIMEOUT, - msg->has_u2f_counter ? msg->u2f_counter : 0); + msg->has_u2f_counter ? msg->u2f_counter : 0, + msg->has_dice_entropy && msg->dice_entropy); } void fsm_msgEntropyAck(EntropyAck* msg) { diff --git a/lib/firmware/reset.c b/lib/firmware/reset.c index 362b3b49e..91923edec 100644 --- a/lib/firmware/reset.c +++ b/lib/firmware/reset.c @@ -21,6 +21,7 @@ #include "keepkey/board/keepkey_board.h" #include "keepkey/board/messages.h" #include "keepkey/board/util.h" +#include "keepkey/firmware/dice_input.h" #include "keepkey/firmware/fsm.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/pin_sm.h" @@ -43,10 +44,38 @@ static bool awaiting_entropy = false; static char CONFIDENTIAL current_words[MNEMONIC_BY_SCREEN_BUF]; static bool no_backup; -void reset_init(bool display_random, uint32_t _strength, - bool passphrase_protection, bool pin_protection, - const char* language, const char* label, bool _no_backup, - uint32_t _auto_lock_delay_ms, uint32_t _u2f_counter) { +/* SHA-256 of the ASCII roll string, shown to the user and exposed over + * DebugLink. A digest of secret input is not the input, but it is a + * verification oracle for a 99-symbol space, so it is treated as + * confidential and cleared as soon as the reset that produced it ends. */ +static uint8_t CONFIDENTIAL dice_digest[32]; +static bool has_dice_digest = false; + +static void dice_digest_clear(void) { + memzero(dice_digest, sizeof(dice_digest)); + has_dice_digest = false; +} + +/* Shared paginated-mnemonic display scratch — see reset.h for the contract + * (also used by the BIP-85 flow; each user zeroes at entry and exit). */ +char CONFIDENTIAL mnemonic_scratch_tokened[TOKENED_MNEMONIC_BUF]; +char CONFIDENTIAL mnemonic_scratch_formatted[MAX_PAGES][FORMATTED_MNEMONIC_BUF]; +char CONFIDENTIAL mnemonic_scratch_display[FORMATTED_MNEMONIC_BUF]; +char CONFIDENTIAL mnemonic_scratch_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; + +void reset_init(uint32_t _strength, bool passphrase_protection, + bool pin_protection, const char* language, const char* label, + bool _no_backup, uint32_t _auto_lock_delay_ms, + uint32_t _u2f_counter, bool dice_entropy) { + /* Disarm any half-finished reset before doing anything else. Nothing else + * clears this flag on an abort (fsm_msgCancel has no reset abort), and + * CHECK_NOT_INITIALIZED still admits ResetDevice while a previous one is + * mid-flight, so a stale armed flag would let a later EntropyAck run + * reset_entropy against whatever int_entropy this invocation leaves + * behind -- including the zeroed buffer an aborted dice step produces, + * which would make the seed a pure function of host-supplied bytes. */ + awaiting_entropy = false; + if (_strength != 128 && _strength != 192 && _strength != 256) { fsm_sendFailure( FailureType_Failure_SyntaxError, @@ -58,13 +87,6 @@ void reset_init(bool display_random, uint32_t _strength, strength = _strength; no_backup = _no_backup; - if (display_random && no_backup) { - fsm_sendFailure(FailureType_Failure_SyntaxError, - _("Can't show internal entropy when backup is skipped")); - layoutHome(); - return; - } - if (no_backup) { // Double confirm, since this is a feature for advanced users only, and // there is risk of loss of funds if this mode is used incorrectly @@ -85,23 +107,53 @@ void reset_init(bool display_random, uint32_t _strength, random_buffer(int_entropy, 32); - if (display_random) { - static char CONFIDENTIAL ent_str[4][17]; - data2hex(int_entropy, 8, ent_str[0]); - data2hex(int_entropy + 8, 8, ent_str[1]); - data2hex(int_entropy + 16, 8, ent_str[2]); - data2hex(int_entropy + 24, 8, ent_str[3]); - - if (!confirm(ButtonRequestType_ButtonRequest_ResetDevice, - _("Internal Entropy"), "%s %s %s %s", ent_str[0], ent_str[1], - ent_str[2], ent_str[3])) { - memzero(ent_str, sizeof(ent_str)); + /* Dice fold in before EntropyRequest, so the host contribution arrives + * strictly after the device has committed to its own. + * + * They are deliberately NOT displayed. An earlier version of this code + * showed the mixed internal entropy on the OLED and called it a + * verifiable commitment; that was wrong. A host that supplies + * ext_entropy and reads that screen once computes + * SHA256(shown || ext_entropy) -- the seed pre-image -- and dice change + * nothing about it, because the displayed value is already post-mix. The + * roll digest below is safe by contrast: it is a hash of the user's own + * input, not of seed material. */ + dice_digest_clear(); + if (dice_entropy) { + static char CONFIDENTIAL dice_rolls[DICE_MAX_ROLLS]; + static char CONFIDENTIAL digest_hex[17]; + uint32_t rolls_needed = dice_rolls_for_strength(strength); + + if (!dice_input_collect(dice_rolls, rolls_needed)) { + memzero(dice_rolls, sizeof(dice_rolls)); + memzero(int_entropy, sizeof(int_entropy)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Reset cancelled")); + layoutHome(); + return; + } + + sha256_Raw((const uint8_t*)dice_rolls, rolls_needed, dice_digest); + has_dice_digest = true; + + data2hex(dice_digest, 8, digest_hex); + bool confirmed = + confirm(ButtonRequestType_ButtonRequest_DiceRoll, _("Dice Rolls"), + _("%lu rolls recorded.\nDigest: %s"), + (unsigned long)rolls_needed, digest_hex); + memzero(digest_hex, sizeof(digest_hex)); + if (!confirmed) { + memzero(dice_rolls, sizeof(dice_rolls)); + memzero(int_entropy, sizeof(int_entropy)); + dice_digest_clear(); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Reset cancelled")); layoutHome(); return; } - memzero(ent_str, sizeof(ent_str)); + + dice_mix(int_entropy, dice_rolls, rolls_needed); + memzero(dice_rolls, sizeof(dice_rolls)); } if (pin_protection) { @@ -167,16 +219,23 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { } /* - * Format mnemonic for user review + * Format mnemonic for user review. Display scratch is the set shared with + * the BIP-85 flow (see reset.h) — zero it at entry: the format loop below + * depends on empty page strings, and a prior user may have aborted. */ uint32_t word_count = 0, page_count = 0; - static char CONFIDENTIAL tokened_mnemonic[TOKENED_MNEMONIC_BUF]; static char CONFIDENTIAL mnemonic_by_screen[MAX_PAGES][MNEMONIC_BY_SCREEN_BUF]; - static char CONFIDENTIAL - formatted_mnemonic[MAX_PAGES][FORMATTED_MNEMONIC_BUF]; - static char CONFIDENTIAL mnemonic_display[FORMATTED_MNEMONIC_BUF]; - static char CONFIDENTIAL formatted_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; + char* tokened_mnemonic = mnemonic_scratch_tokened; + char (*formatted_mnemonic)[FORMATTED_MNEMONIC_BUF] = + mnemonic_scratch_formatted; + char* mnemonic_display = mnemonic_scratch_display; + char* formatted_word = mnemonic_scratch_word; + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + memzero(mnemonic_by_screen, sizeof(mnemonic_by_screen)); strlcpy(tokened_mnemonic, temp_mnemonic, TOKENED_MNEMONIC_BUF); @@ -256,12 +315,15 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { fsm_sendSuccess(_("Device reset")); exit: + /* The digest only describes the reset that produced it; leaving it live + * would keep serving it over DebugLink for the rest of the boot. */ + dice_digest_clear(); memzero(&ctx, sizeof(ctx)); - memzero(tokened_mnemonic, sizeof(tokened_mnemonic)); + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); memzero(mnemonic_by_screen, sizeof(mnemonic_by_screen)); - memzero(formatted_mnemonic, sizeof(formatted_mnemonic)); - memzero(mnemonic_display, sizeof(mnemonic_display)); - memzero(formatted_word, sizeof(formatted_word)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); layoutHome(); } @@ -272,4 +334,12 @@ uint32_t reset_get_int_entropy(uint8_t* entropy) { } const char* reset_get_word(void) { return current_words; } + +uint32_t reset_get_dice_digest(uint8_t* digest) { + if (!has_dice_digest) { + return 0; + } + memcpy(digest, dice_digest, 32); + return 32; +} #endif diff --git a/lib/rand/rng.c b/lib/rand/rng.c index 57c8c75b9..71948958c 100644 --- a/lib/rand/rng.c +++ b/lib/rand/rng.c @@ -21,12 +21,42 @@ #include "trezor/crypto/rand.h" +#ifdef EMULATOR +#include "keepkey/emulator/emulator.h" +#endif + #ifndef EMULATOR #include #include #include #endif +/* random32() has two implementations selected by a build flag: the STM32 + * hardware RNG, and -- under EMULATOR -- the host OS CSPRNG. Neither is a + * weak PRNG today, and the emulator branch deliberately aborts rather than + * degrading to libc random(). + * + * This assertion guards the *selection*, not either implementation. The + * July 2026 COLDCARD incident was not a broken RNG: a board config left + * the hardware-RNG macro defined-but-zero, the supporting library tested + * only whether that macro was *defined* rather than enabled, and seed + * generation silently used the wrong source for five years (~1,367 BTC + * drained across 4,585 addresses). Nothing about the output looked wrong + * -- the substituted generator passed every statistical test, it was just + * seeded with ~40 bits -- so no amount of host-side entropy testing could + * have caught it. Only the build configuration was wrong. + * + * The lesson is that "which RNG did we actually compile in" deserves a + * check the build cannot silently get wrong. __arm__ comes from the + * compiler's own target definition rather than from any board config or + * CMake option, so a mistaken -DEMULATOR cannot satisfy both conditions: + * firmware targeting the STM32 can only ever compile the RNG_DR path. + * Hosted emulator builds (x86_64 / __aarch64__) are unaffected. */ +#if defined(EMULATOR) && defined(__arm__) +#error \ + "EMULATOR selects the host-CSPRNG random32(); ARM firmware must use the STM32 hardware RNG" +#endif + void reset_rng(void) { #ifndef EMULATOR /* disable RNG */ @@ -78,24 +108,45 @@ uint32_t random32(void) { last = new; return new; #else - return random(); + /* Emulator cryptography must use the host OS CSPRNG. emulatorRandom() is + * backed by /dev/urandom on POSIX and BCryptGenRandom on Windows and aborts + * the process on failure; never fall back to libc random(). */ + uint32_t v = 0; + emulatorRandom(&v, sizeof(v)); + return v; #endif } +#if defined(EMULATOR) && !defined(__APPLE__) +/* trezor-crypto declares random_buffer() as a weak symbol so platforms can + * supply their own. GNU/MinGW ld will NOT extract a weak definition from a + * static archive to satisfy a strong reference (fsm.c/reset.c/storage.c), + * which breaks the Linux .so and Windows .dll links. Provide a strong + * definition here — identical to trezor-crypto's, built on our random32(). + * macOS ld64 resolves the weak one fine, so it's left untouched there. */ +void random_buffer(uint8_t* buf, size_t len) { + uint32_t r = 0; + for (size_t i = 0; i < len; i++) { + if (i % 4 == 0) r = random32(); + buf[i] = (r >> ((i % 4) * 8)) & 0xff; + } +} +#endif + // I miss C++ templates sooo bad. -#define RANDOM_PERMUTE(BUFF, COUNT) \ - do { \ - for (size_t i = (COUNT)-1; i >= 1; i--) { \ - size_t j = random_uniform(i + 1); \ - typeof(*(BUFF)) t = (BUFF)[j]; \ - (BUFF)[j] = (BUFF)[i]; \ - (BUFF)[i] = t; \ - } \ +#define RANDOM_PERMUTE(BUFF, COUNT) \ + do { \ + for (size_t i = (COUNT) - 1; i >= 1; i--) { \ + size_t j = random_uniform(i + 1); \ + typeof(*(BUFF)) t = (BUFF)[j]; \ + (BUFF)[j] = (BUFF)[i]; \ + (BUFF)[i] = t; \ + } \ } while (0) -void random_permute_char(char *str, size_t len) { RANDOM_PERMUTE(str, len); } +void random_permute_char(char* str, size_t len) { RANDOM_PERMUTE(str, len); } -void random_permute_u16(uint16_t *buf, size_t count) { +void random_permute_u16(uint16_t* buf, size_t count) { RANDOM_PERMUTE(buf, count); } diff --git a/scripts/emulator/capture-dice-flow.py b/scripts/emulator/capture-dice-flow.py new file mode 100644 index 000000000..47821a819 --- /dev/null +++ b/scripts/emulator/capture-dice-flow.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Capture the on-device dice-entry screens from kkemu. + +Evidence tool for the dice_entropy ResetDevice flow: drives a full reset with +device-side dice collection via DebugLinkDecision.input injection and saves +the OLED at each interesting state. +""" + +import hashlib +import os +import sys +import time +from pathlib import Path + +os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") +os.environ.setdefault("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK", "true") + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "deps" / "python-keepkey")) + +from keepkeylib.client import KeepKeyDebuglinkClient, _write_png +from keepkeylib.transport_udp import UDPTransport +from keepkeylib import messages_pb2 as proto + +OUT = Path(sys.argv[1]).resolve() +OUT.mkdir(parents=True, exist_ok=True) + +client = KeepKeyDebuglinkClient( + UDPTransport(os.environ.get("KK_TRANSPORT_MAIN", "127.0.0.1:11044"))) +client.set_debuglink( + UDPTransport(os.environ.get("KK_TRANSPORT_DEBUG", "127.0.0.1:11045"))) + + +def snap(name): + time.sleep(0.3) + layout = client.debug.read_layout() + rows = [] + for y in range(64): + row = bytearray(256) + for x in range(256): + b = layout[x + (y // 8) * 256] + if isinstance(b, str): + b = ord(b) + if (b >> (y % 8)) & 1: + row[x] = 255 + rows.append(bytes(row)) + path = OUT / name + with open(path, "wb") as f: + f.write(_write_png(str(path), 256, 64, rows)) + print(path) + + +client.auto_button = True +client.wipe_device() +client.auto_button = False + +ret = client.call_raw(proto.ResetDevice( + display_random=True, strength=256, passphrase_protection=False, + pin_protection=False, language='english', label='dice evidence', + dice_entropy=True)) +assert isinstance(ret, proto.ButtonRequest), ret + +client.transport.write(proto.ButtonAck()) +time.sleep(0.3) +snap("01-dice-screen-initial.png") + +client.debug.press_input("123") +snap("02-after-three-rolls.png") + +client.debug.press_input("u") +snap("03-after-undo.png") + +rolls = "123456" * 17 # 102, extras past 99 dropped; net = 2 + 99 capped +client.debug.press_input(rolls[:40]) +time.sleep(0.2) +client.debug.press_input(rolls[40:80]) +time.sleep(0.2) +client.debug.press_input(rolls[80:]) +resp = client.transport.read_blocking() +assert isinstance(resp, proto.ButtonRequest), resp +snap("04-digest-confirm.png") + +client.debug.press_yes() +ret = client.call_raw(proto.ButtonAck()) +assert isinstance(ret, proto.ButtonRequest), ret # post-mix entropy display +snap("05-postmix-internal-entropy.png") + +client.debug.press_yes() +ret = client.call_raw(proto.ButtonAck()) +assert isinstance(ret, proto.EntropyRequest), ret +ret = client.call_raw(proto.EntropyAck(entropy=b'E' * 32)) + +assert isinstance(ret, proto.ButtonRequest), ret +snap("06-backup-explainer.png") +client.debug.press_yes() +ret = client.call_raw(proto.ButtonAck()) +while isinstance(ret, proto.ButtonRequest): + client.debug.press_yes() + ret = client.call_raw(proto.ButtonAck()) +assert isinstance(ret, proto.Success), ret +print("flow complete:", ret.message) diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 5e828e1ae..cb01b7aa3 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -3,6 +3,7 @@ set(sources app_confirm.cpp coins.cpp cosmos.cpp + dice.cpp eos.cpp eip712.cpp ethereum.cpp diff --git a/unittests/firmware/dice.cpp b/unittests/firmware/dice.cpp new file mode 100644 index 000000000..93654062e --- /dev/null +++ b/unittests/firmware/dice.cpp @@ -0,0 +1,64 @@ +extern "C" { +#include "keepkey/firmware/dice_input.h" +} + +#include "gtest/gtest.h" + +#include +#include + +static std::string hexlify(const uint8_t *bytes, size_t len) { + static const char *alph = "0123456789abcdef"; + std::string out; + for (size_t i = 0; i < len; i++) { + out += alph[bytes[i] >> 4]; + out += alph[bytes[i] & 0xF]; + } + return out; +} + +TEST(Dice, RollsForStrength) { + // d6 = 2.585 bits/roll; Coldcard-convention targets. + EXPECT_EQ(dice_rolls_for_strength(128), 50u); + EXPECT_EQ(dice_rolls_for_strength(192), 75u); + EXPECT_EQ(dice_rolls_for_strength(256), 99u); +} + +TEST(Dice, MixZeroEntropyVector) { + // SHA256(0x00*32 || "123456") + uint8_t entropy[32]; + memset(entropy, 0, sizeof(entropy)); + dice_mix(entropy, "123456", 6); + EXPECT_EQ(hexlify(entropy, 32), + "16ba88244e0230b0fc84868b703a0e32c344be1b0284f2e67e59715f123748d6"); +} + +TEST(Dice, MixNonZeroEntropyVector) { + // SHA256(0x00..0x1f || "654321165243") + uint8_t entropy[32]; + for (int i = 0; i < 32; i++) entropy[i] = (uint8_t)i; + dice_mix(entropy, "654321165243", 12); + EXPECT_EQ(hexlify(entropy, 32), + "d1ab5a0b7f106313b6ba44d6863c5d1b90397d9e4a0f87a0a6baa25bad00ae97"); +} + +TEST(Dice, MixDependsOnRolls) { + uint8_t a[32], b[32]; + memset(a, 0xAB, sizeof(a)); + memset(b, 0xAB, sizeof(b)); + dice_mix(a, "111111", 6); + dice_mix(b, "111112", 6); + EXPECT_NE(0, memcmp(a, b, 32)); +} + +TEST(Dice, MixUsesExactCount) { + // Only `count` bytes of the roll buffer may contribute. + uint8_t a[32], b[32]; + memset(a, 0, sizeof(a)); + memset(b, 0, sizeof(b)); + const char rolls_a[8] = {'1', '2', '3', '4', '5', '6', '1', '2'}; + const char rolls_b[8] = {'1', '2', '3', '4', '5', '6', '6', '5'}; + dice_mix(a, rolls_a, 6); + dice_mix(b, rolls_b, 6); + EXPECT_EQ(0, memcmp(a, b, 32)); +} From 09c417f7527ef07a0bbe5b43ec17a223d44360b1 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 15:33:26 -0300 Subject: [PATCH 14/40] fix: clear dice digest on PIN cancellation --- lib/firmware/reset.c | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/firmware/reset.c b/lib/firmware/reset.c index 91923edec..7a99a5f51 100644 --- a/lib/firmware/reset.c +++ b/lib/firmware/reset.c @@ -158,6 +158,7 @@ void reset_init(uint32_t _strength, bool passphrase_protection, if (pin_protection) { if (!change_pin()) { + dice_digest_clear(); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("PINs do not match")); layoutHome(); From 39555bd06d991cf2a15b1de9f7e33201e8ac851d Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 8 Aug 2026 19:08:16 -0300 Subject: [PATCH 15/40] ci: assert the emulator RNG source alongside the change that provides it Restores the RC18 invariant that fails the build if lib/rand/rng.c falls back to libc random(). It was previously declared in the foundation slice, three PRs before the emulatorRandom() implementation landed, so it only ever fired on code its own PR could not fix. Here it guards a change that is present in the same commit range, which is the whole point of a build-config invariant: the July 2026 COLDCARD loss came from a build selecting the wrong RNG silently, and a gate that cries wolf is one reviewers learn to skip. --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3fc8d337..e02527bdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,6 +244,11 @@ jobs: echo "::error::Unauthenticated persistent clearsign trust is retired" exit 1 fi + if git grep -n -E 'return[[:space:]]+random\(\)' -- \ + lib/rand/rng.c; then + echo "::error::Emulator cryptography must not use libc random()" + exit 1 + fi if git grep -n -F 'option(KK_ZCASH_PRIVACY' -- CMakeLists.txt; then echo "::error::Zcash privacy must not become a third release choice" exit 1 From 9bf3d57b0d9a3ffd133673f57c8c0763392984c0 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:23:09 -0300 Subject: [PATCH 16/40] feat(zcash): Orchard shielded signing engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device-side Orchard engine behind Zcash shielded sends: Orchard key derivation from the wallet seed, note/action handling, RedPallas signing over the Pallas curve, and unified-address encoding (ZIP-316). - Signing is constant-time on the secret scalar path; the Pallas arithmetic comes from the pinned trezor-firmware fork and is compiled in only when the privacy engine is enabled, so the bitcoin-only image carries none of it. - Progress is reported to the host during action signing — multi-action transactions take long enough that a silent device looks hung. - messages-zcash proto/options are registered with the transport generator. Handlers, host-visible messages, unit tests, the constant-time CI gates and the coin-integration docs land in the Zcash wiring branch; this commit adds the engine and its build wiring only. --- deps/crypto/CMakeLists.txt | 12 + include/keepkey/firmware/zcash.h | 430 ++++++ include/keepkey/transport/interface.h | 1 + .../keepkey/transport/messages-zcash.options | 54 + lib/firmware/CMakeLists.txt | 5 + lib/firmware/zcash.c | 1228 +++++++++++++++++ lib/transport/CMakeLists.txt | 9 + 7 files changed, 1739 insertions(+) create mode 100644 include/keepkey/firmware/zcash.h create mode 100644 include/keepkey/transport/messages-zcash.options create mode 100644 lib/firmware/zcash.c diff --git a/deps/crypto/CMakeLists.txt b/deps/crypto/CMakeLists.txt index d726b39bc..dde1951f2 100644 --- a/deps/crypto/CMakeLists.txt +++ b/deps/crypto/CMakeLists.txt @@ -57,6 +57,18 @@ set(sources #trezor-firmware/crypto/aes/aestst.c trezor-firmware/crypto/aes/aestab.c) +# Pallas/Orchard curve arithmetic (~2.4k LOC) -- only the Zcash shielded engine +# uses it. Excluded from the bitcoin-only image. +if(${KK_ZCASH_PRIVACY}) + list(APPEND sources + trezor-firmware/crypto/pallas.c + trezor-firmware/crypto/pallas_ct.c + trezor-firmware/crypto/pallas_sinsemilla.c + trezor-firmware/crypto/pallas_swu.c + trezor-firmware/crypto/redpallas.c + trezor-firmware/crypto/zcash_zip316.c) +endif() + # Clang 5.0 in the docker image (kktech/firmware:v7) is missing # , which breaks these. Until they're needed, we'll just elide # them. diff --git a/include/keepkey/firmware/zcash.h b/include/keepkey/firmware/zcash.h new file mode 100644 index 000000000..b331f52b1 --- /dev/null +++ b/include/keepkey/firmware/zcash.h @@ -0,0 +1,430 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2025 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 . + */ + +#ifndef KEEPKEY_FIRMWARE_ZCASH_H +#define KEEPKEY_FIRMWARE_ZCASH_H + +#include +#include +#include + +/* Orchard spending keys derived via ZIP-32. + * cppcheck doesn't see these used because the consumers live in + * fsm_msg_zcash.h which is #include'd into fsm.c rather than compiled + * separately, so the struct members appear "unused" in this TU. */ +typedef struct { + // cppcheck-suppress unusedStructMember + uint8_t sk[32]; /* Spending key (master secret at this level) */ + // cppcheck-suppress unusedStructMember + uint8_t ask[32]; /* Spend authorizing key (scalar) */ + // cppcheck-suppress unusedStructMember + uint8_t ak[32]; /* Public spend validating key (compressed, even y) */ + // cppcheck-suppress unusedStructMember + uint8_t nk[32]; /* Nullifier deriving key */ + // cppcheck-suppress unusedStructMember + uint8_t rivk[32]; /* Commitment randomness key */ + // cppcheck-suppress unusedStructMember + uint8_t dk[32]; /* Diversifier key */ +} ZcashOrchardKeys; + +typedef void (*ZcashOrchardProgressCallback)(uint32_t completed, uint32_t total, + void* context); + +typedef struct { + bool has_header_digest; + size_t header_digest_size; + bool has_transparent_digest; + size_t transparent_digest_size; + bool has_sapling_digest; + size_t sapling_digest_size; + bool has_orchard_digest; + size_t orchard_digest_size; + bool is_ironwood; + bool has_ironwood_digest; + size_t ironwood_digest_size; + bool has_orchard_flags; + uint32_t orchard_flags; + bool has_orchard_value_balance; + bool has_orchard_anchor; + size_t orchard_anchor_size; + bool has_header_fields; + uint32_t n_transparent_inputs; + uint32_t n_transparent_outputs; +} ZcashPCZTSigningRequestMeta; + +typedef enum { + ZCASH_PCZT_SIGNING_REQUEST_OK = 0, + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS, + ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE, + ZCASH_PCZT_SIGNING_REQUEST_MISSING_HEADER_FIELDS, + ZCASH_PCZT_SIGNING_REQUEST_UNSUPPORTED_SAPLING_COMPONENT, + ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA, + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST, +} ZcashPCZTSigningRequestStatus; + +typedef struct { + const uint8_t* prevout_txid; + uint32_t prevout_index; + uint32_t sequence; + uint64_t value; + const uint8_t* script_pubkey; + size_t script_pubkey_size; +} ZcashTransparentInputDigestInfo; + +typedef struct { + uint64_t value; + const uint8_t* script_pubkey; + size_t script_pubkey_size; +} ZcashTransparentOutputDigestInfo; + +#define ZCASH_ORCHARD_RAW_RECEIVER_SIZE 43 +#define ZCASH_ORCHARD_UNIFIED_ADDRESS_SIZE 128 + +/** + * Validate the clear-signing metadata required before Orchard signatures. + * + * This rejects the legacy flow where the host supplied only a per-action + * sighash. The firmware must assemble the ZIP-244 sighash from transaction + * component digests and verify the Orchard digest against streamed action data + * before returning signatures. + */ +ZcashPCZTSigningRequestStatus zcash_pczt_signing_request_status( + const ZcashPCZTSigningRequestMeta* meta); + +bool zcash_pczt_signing_request_is_clear( + const ZcashPCZTSigningRequestMeta* meta); + +/** + * Derive Orchard spending keys from the device seed via ZIP-32. + * Path: m_orchard / 32' / 133' / account' + * + * Uses BLAKE2b with personalization "ZcashIP32Orchard" for key derivation. + * + * @param seed BIP-39 master seed + * @param seed_len Seed length (typically 64 bytes) + * @param account Account index (0-based, will be hardened) + * @param keys Output: derived Orchard keys + * @return true on success + */ +bool zcash_derive_orchard_keys(const uint8_t* seed, uint32_t seed_len, + uint32_t account, ZcashOrchardKeys* keys); + +/** + * Progress-reporting Orchard key derivation for interactive device flows. + * Progress is driven by the fixed public scalar-multiplication schedule and + * does not depend on the derived secret key. + */ +bool zcash_derive_orchard_keys_with_progress( + const uint8_t* seed, uint32_t seed_len, uint32_t account, + ZcashOrchardKeys* keys, ZcashOrchardProgressCallback progress, + void* progress_context); + +/** + * Compute the ZIP 244 shielded sighash for Orchard spend authorization. + * + * For shielded-only transactions, transparent_sig_digest uses the "no inputs" + * form. For mixed transactions, transparent data must be provided separately. + * + * @param header_digest 32-byte pre-computed header digest + * @param transparent_digest 32-byte transparent sig digest (or empty hash) + * @param sapling_digest 32-byte sapling digest (or empty hash) + * @param orchard_digest 32-byte orchard digest + * @param branch_id Consensus branch ID (LE) + * @param sighash_out 32-byte output sighash + * @return true on success + */ +bool zcash_compute_shielded_sighash(const uint8_t header_digest[32], + const uint8_t transparent_digest[32], + const uint8_t sapling_digest[32], + const uint8_t orchard_digest[32], + uint32_t branch_id, + uint8_t sighash_out[32]); + +/** Compute the five-component ZIP-229 transaction-v6 sighash. */ +bool zcash_compute_v6_shielded_sighash(const uint8_t header_digest[32], + const uint8_t transparent_digest[32], + const uint8_t sapling_digest[32], + const uint8_t orchard_digest[32], + const uint8_t ironwood_digest[32], + uint32_t branch_id, + uint8_t sighash_out[32]); + +/** + * Compute ZIP-244 T.1 header_digest from plaintext transaction header fields. + */ +bool zcash_compute_header_digest(uint32_t version, uint32_t version_group_id, + uint32_t branch_id, uint32_t lock_time, + uint32_t expiry_height, + uint8_t digest_out[32]); + +/** + * Compute ZIP-244 T.2 transparent_digest from plaintext transparent data. + * + * This is the digest mixed into the Orchard/Sapling signing commitment. It is + * not the same as the per-input transparent signature digest. + */ +bool zcash_compute_transparent_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint8_t digest_out[32]); + +/** + * Compute ZIP-244 §4.9 transparent_sig_digest for Orchard spend authorization. + * + * Uses the S.2 form with EMPTY txin_sig_digest when n_inputs > 0 (shield txs), + * or falls back to T.1 when n_inputs == 0 (deshield / private-send). This is + * what the Zcash consensus node uses to verify Orchard spend auth sigs and the + * binding signature in a hybrid (transparent + Orchard) transaction. + */ +bool zcash_compute_orchard_transparent_sig_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint8_t digest_out[32]); + +/** + * Compute ZIP-244 S.2 per-input transparent signature digest. + * + * This currently accepts SIGHASH_ALL only, matching the existing transparent + * signing flow. + */ +bool zcash_compute_transparent_sighash_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint32_t signable_input_index, uint8_t sighash_type, + uint8_t digest_out[32]); + +/** + * Encode a raw Orchard receiver (d || pk_d) as an Orchard-only ZIP-316 Unified + * Address for display. This is for recipient review; it does not derive or + * prove ownership of the receiver. + */ +bool zcash_orchard_receiver_to_unified_address( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], const char* hrp, + char* address_out, size_t address_out_len); + +/** + * Recompute an Orchard output note commitment x-coordinate (cmx). + * + * cmx = Extract_P(NoteCommit_rcm^Orchard(g_d, pk_d, v, rho, psi)) + * where receiver = d || pk_d, rho is the action nullifier, and rseed is the + * output note seed. This binds the user-displayed receiver/value to the action + * commitment before any authorization signature is emitted. + */ +bool zcash_orchard_compute_cmx( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32]); + +/** ZIP-2005 V3 note commitment used by the Ironwood pool. */ +bool zcash_ironwood_compute_cmx( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32]); + +bool zcash_ironwood_compute_cmx_with_progress( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32], + ZcashOrchardProgressCallback progress, void* progress_context); + +/** + * Progress-reporting note-commitment verification for interactive PCZT flows. + * The callback exposes only the public Sinsemilla word index and count. + */ +bool zcash_orchard_compute_cmx_with_progress( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32], + ZcashOrchardProgressCallback progress, void* progress_context); + +/** + * Derive an Orchard diversifier from a diversifier key and 88-bit index. + * + * ZIP-32 defines Orchard diversifiers as: + * d_j = FF1-AES256.Encrypt(dk, "", I2LEBSP_88(j)) + * + * Both index_le and diversifier_out are 11-byte LEBS2OSP encodings of the + * 88-bit bitstrings. + * + * @param dk 32-byte Orchard diversifier key + * @param index_le 11-byte little-endian diversifier index bitstring + * @param diversifier_out 11-byte output diversifier + * @return true on success + */ +bool zcash_orchard_derive_diversifier(const uint8_t dk[32], + const uint8_t index_le[11], + uint8_t diversifier_out[11]); + +/** + * Compute DiversifyHash^Orchard(d) as a serialized Pallas point. + * + * g_d = GroupHash^Pallas("z.cash:Orchard-gd", d) + * + * If the group hash ever returns the identity, Orchard falls back to hashing + * the empty message under the same domain. + * + * @param diversifier 11-byte Orchard diversifier + * @param gd_out 32-byte compressed Pallas point + * @return true on success + */ +bool zcash_orchard_diversify_hash(const uint8_t diversifier[11], + uint8_t gd_out[32]); + +/** + * Derive an Orchard diversified transmission key. + * + * g_d = DiversifyHash^Orchard(d) + * pk_d = KA^Orchard.DerivePublic(ivk, g_d) = [ivk] g_d + * + * @param ivk 32-byte nonzero Orchard incoming viewing key encoding + * @param diversifier 11-byte Orchard diversifier + * @param gd_out optional 32-byte compressed g_d output, may be NULL + * @param pkd_out 32-byte compressed diversified transmission key + * @return true on success + */ +bool zcash_orchard_derive_transmission_key(const uint8_t ivk[32], + const uint8_t diversifier[11], + uint8_t gd_out[32], + uint8_t pkd_out[32]); + +/** + * Derive the external Orchard incoming viewing key from FVK components. + * + * ivk = Commit^ivk.Output(ExtractP(ak), nk, rivk) + * + * @param ak 32-byte Orchard spend validating key encoding, sign bit clear + * @param nk 32-byte Orchard nullifier deriving key + * @param rivk 32-byte Orchard IVK commitment randomness + * @param ivk_out 32-byte nonzero Orchard incoming viewing key + * @return true on success + */ +bool zcash_orchard_derive_ivk(const uint8_t ak[32], const uint8_t nk[32], + const uint8_t rivk[32], uint8_t ivk_out[32]); + +/** + * Derive a raw Orchard receiver from external FVK components and index. + * + * d_j = DiversifierKey(dk).get(j) + * ivk = Commit^ivk.Output(ExtractP(ak), nk, rivk) + * pk_dj = KA^Orchard.DerivePublic(ivk, DiversifyHash(d_j)) + * + * @param ak 32-byte Orchard spend validating key encoding + * @param nk 32-byte Orchard nullifier deriving key + * @param rivk 32-byte Orchard IVK commitment randomness + * @param dk 32-byte Orchard diversifier key + * @param index_le 11-byte little-endian diversifier index bitstring + * @param receiver_out 43-byte raw receiver: d_j || pk_dj + * @return true on success + */ +bool zcash_orchard_derive_receiver(const uint8_t ak[32], const uint8_t nk[32], + const uint8_t rivk[32], const uint8_t dk[32], + const uint8_t index_le[11], + uint8_t receiver_out[43]); + +/** + * Derive an Orchard-only ZIP-316 Unified Address from derived Orchard keys. + * + * ak = [ask] G_spendauth + * receiver = d_j || pk_dj + * address = Bech32m(HRP, F4Jumble(Orchard receiver payload)) + * + * @param keys ZIP-32-derived Orchard key material + * @param index_le 11-byte little-endian diversifier index bitstring + * @param hrp ZIP-316 HRP ("u" for mainnet, "utest" for testnet) + * @param address_out NUL-terminated output address + * @param address_out_len Size of address_out + * @return true on success + */ +bool zcash_orchard_derive_unified_address(const ZcashOrchardKeys* keys, + const uint8_t index_le[11], + const char* hrp, char* address_out, + size_t address_out_len); + +/** + * 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. + * + * 32-byte stable identifier of a seed. Used by host wallets and PCZTs + * (zip32_derivation.seed_fingerprint) to confirm which device seed produced + * a given key, address, or signature. Trivial seeds (all-zero, all-0xFF) + * and seeds outside [32, 252] bytes are rejected per ZIP-32 §6.1. + * + * @param seed Seed bytes (BIP-39 seed or BIP-32 master seed) + * @param seed_len Seed length, must be in [32, 252] + * @param fingerprint_out 32-byte output fingerprint + * @return true on success, false if seed is invalid + */ +bool zcash_calculate_seed_fingerprint(const uint8_t* seed, uint32_t seed_len, + uint8_t fingerprint_out[32]); + +/** + * Validate the wire shape of an optional asserted seed fingerprint. Omission is + * valid; a present assertion must be exactly 32 bytes. + */ +bool zcash_seed_fingerprint_request_valid(bool present, size_t size); + +/* ── Storage-scoped wrappers ─────────────────────────────────────────── + * + * The two functions below own the seed access. Implementations live in + * lib/firmware/storage.c so the raw 64-byte BIP-39 seed never escapes + * that translation unit. Callers (FSM handlers) get only the derived + * material — Orchard keys or the 32-byte fingerprint — never a pointer + * to the seed itself. This is the only sanctioned way for production + * firmware code to consume seed-derived Zcash material. + * + * The bare zcash_derive_orchard_keys() / zcash_calculate_seed_fingerprint() + * functions above remain in the header for unit tests, which feed them + * known test vectors directly. + */ + +/** + * Derive Orchard keys for an account using the device's session seed. + * + * @param account Account index (0-based, will be hardened) + * @param usePassphrase Whether to apply the passphrase (prompts if needed) + * @param keys_out Output: derived Orchard keys + * @return true on success, false if seed unavailable or derivation fails + */ +bool storage_zcashOrchardKeys(uint32_t account, bool usePassphrase, + ZcashOrchardKeys* keys_out); + +/** + * Compute the ZIP-32 §6.1 seed fingerprint for the device's session seed. + * + * @param usePassphrase Whether to apply the passphrase (prompts if needed) + * @param fingerprint_out 32-byte output fingerprint + * @return true on success, false if seed unavailable + */ +bool storage_zcashSeedFingerprint(bool usePassphrase, + uint8_t fingerprint_out[32]); + +/** + * Tear down any in-progress Zcash signing session. + * + * Wipes the static signing state (active flag, derived Orchard keys, + * accumulated signatures, sub-digest contexts, transparent-input + * counters) so a host cannot resume streaming PCZTAction or + * TransparentInput messages against a previously-approved session + * after Initialize, Cancel, or ClearSession. Safe to call when no + * session is active. + */ +void zcash_signing_abort(void); + +#endif diff --git a/include/keepkey/transport/interface.h b/include/keepkey/transport/interface.h index 45e5a09e6..ab435baf8 100644 --- a/include/keepkey/transport/interface.h +++ b/include/keepkey/transport/interface.h @@ -38,6 +38,7 @@ #include "messages-tron.pb.h" #include "messages-ton.pb.h" #include "messages-solana.pb.h" +#include "messages-zcash.pb.h" #include "types.pb.h" #include "trezor_transport.h" diff --git a/include/keepkey/transport/messages-zcash.options b/include/keepkey/transport/messages-zcash.options new file mode 100644 index 000000000..63d1035b4 --- /dev/null +++ b/include/keepkey/transport/messages-zcash.options @@ -0,0 +1,54 @@ +ZcashSignPCZT.address_n max_count:10 +ZcashSignPCZT.pczt_data max_size:0 +ZcashSignPCZT.total_amount int_size:IS_64 +ZcashSignPCZT.fee int_size:IS_64 +ZcashSignPCZT.header_digest max_size:32 +ZcashSignPCZT.transparent_digest max_size:32 +ZcashSignPCZT.sapling_digest max_size:32 +ZcashSignPCZT.orchard_digest max_size:32 +ZcashSignPCZT.ironwood_digest max_size:32 +ZcashSignPCZT.orchard_value_balance int_size:IS_64 +ZcashSignPCZT.orchard_anchor max_size:32 +ZcashSignPCZT.expected_seed_fingerprint max_size:32 + +ZcashPCZTAction.alpha max_size:32 +ZcashPCZTAction.sighash max_size:32 +ZcashPCZTAction.cv_net max_size:32 +ZcashPCZTAction.value int_size:IS_64 +ZcashPCZTAction.nullifier max_size:32 +ZcashPCZTAction.cmx max_size:32 +ZcashPCZTAction.epk max_size:32 +ZcashPCZTAction.enc_compact max_size:52 +ZcashPCZTAction.enc_memo max_size:512 +ZcashPCZTAction.enc_noncompact max_size:564 +ZcashPCZTAction.rk max_size:32 +ZcashPCZTAction.out_ciphertext max_size:80 +ZcashPCZTAction.recipient max_size:43 +ZcashPCZTAction.rseed max_size:32 + +ZcashSignedPCZT.signatures max_count:16, max_size:64 +ZcashSignedPCZT.txid max_size:32 + +ZcashGetOrchardFVK.address_n max_count:10 + +ZcashOrchardFVK.ak max_size:32 +ZcashOrchardFVK.nk max_size:32 +ZcashOrchardFVK.rivk max_size:32 +ZcashOrchardFVK.seed_fingerprint max_size:32 + +ZcashTransparentOutput.amount int_size:IS_64 +ZcashTransparentOutput.script_pubkey max_size:128 + +ZcashTransparentInput.sighash max_size:32 +ZcashTransparentInput.address_n max_count:8 +ZcashTransparentInput.amount int_size:IS_64 +ZcashTransparentInput.prevout_txid max_size:32 +ZcashTransparentInput.script_pubkey max_size:128 + +ZcashTransparentSigned.signatures max_count:8, max_size:73 + +ZcashDisplayAddress.address_n max_count:8 +ZcashDisplayAddress.expected_seed_fingerprint max_size:32 + +ZcashAddress.address max_size:128 +ZcashAddress.seed_fingerprint max_size:32 diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index da893616f..4666060b8 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -47,6 +47,11 @@ set(sources txin_check.c u2f.c) +# Zcash shielded/Orchard engine -- transparent Zcash needs none of this. +if(${KK_ZCASH_PRIVACY}) + list(APPEND sources zcash.c) +endif() + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/scm_revision.h.in" "${CMAKE_CURRENT_BINARY_DIR}/scm_revision.h" @ONLY) diff --git a/lib/firmware/zcash.c b/lib/firmware/zcash.c new file mode 100644 index 000000000..b9379d040 --- /dev/null +++ b/lib/firmware/zcash.c @@ -0,0 +1,1228 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2025 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 . + */ + +#include "keepkey/firmware/zcash.h" + +#include +#include + +#include "trezor/crypto/aes/aes.h" +#include "trezor/crypto/bignum.h" +#include "trezor/crypto/blake2b.h" +#include "trezor/crypto/hasher.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/pallas.h" +#include "trezor/crypto/pallas_ct.h" +#include "trezor/crypto/pallas_sinsemilla.h" +#include "trezor/crypto/pallas_swu.h" +#include "trezor/crypto/redpallas.h" +#include "trezor/crypto/zcash_zip316.h" + +/* + * ZIP-32 Orchard key derivation. + * + * Master key: + * I = BLAKE2b-512("ZcashIP32Orchard", seed) + * sk = I[0..32], chain_code = I[32..64] + * + * Child derivation (hardened only): + * I = BLAKE2b-512("ZcashIP32Orchard", chain_code, + * 0x11 || sk || i_be) + * where 0x11 indicates hardened derivation with Orchard, + * and i_be is the 4-byte big-endian child index with the hardened bit set. + * + * From the spending key sk, subkeys are derived using PRF^expand: + * PRF^expand(sk, t) = BLAKE2b-512("Zcash_ExpandSeed", sk || t) + * + * ask = ToScalar(PRF^expand(sk, [0x06])) + * nk = ToBase(PRF^expand(sk, [0x07])) + * rivk = ToScalar(PRF^expand(sk, [0x08])) + * + * ToScalar: interpret 64 bytes as LE integer, reduce mod order + * ToBase: interpret 64 bytes as LE integer, reduce mod prime + */ + +/* + * BLAKE2b-512 with personalization "ZcashIP32Orchard" — master key only. + * Used for: I = BLAKE2b-512("ZcashIP32Orchard", seed) + * NOT used for child derivation (which uses PRF^expand). + */ +static void zip32_orchard_master(const uint8_t* seed, size_t seed_len, + uint8_t out[64]) { + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 64, "ZcashIP32Orchard", 16); + blake2b_Update(&ctx, seed, seed_len); + blake2b_Final(&ctx, out, 64); +} + +/* PRF^expand(sk, t) = BLAKE2b-512("Zcash_ExpandSeed", sk || t) */ +static void prf_expand(const uint8_t sk[32], const uint8_t* t, size_t t_len, + uint8_t out[64]) { + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 64, "Zcash_ExpandSeed", 16); + blake2b_Update(&ctx, sk, 32); + blake2b_Update(&ctx, t, t_len); + blake2b_Final(&ctx, out, 64); +} + +/* + * 2^256 mod q (Pallas scalar field order), little-endian. + * q = 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000001 + * R = 0x3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF992C350BE34205675B2B3E9CFFFFFFFD + * Verified: R + 3*q == 2^256. + */ +static const uint8_t two_256_mod_q[32] = { + 0xfd, 0xff, 0xff, 0xff, 0x9c, 0x3e, 0x2b, 0x5b, 0x67, 0x05, 0x42, + 0xe3, 0x0b, 0x35, 0x2c, 0x99, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, +}; + +/* + * 2^256 mod p (Pallas base field prime), little-endian. + * p = 0x40000000000000000000000000000000224698fc094cf91b992d30ed00000001 + * R = 0x3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF992C350BE41914AD34786D38FFFFFFFD + * Verified: R + 3*p == 2^256. + */ +static const uint8_t two_256_mod_p[32] = { + 0xfd, 0xff, 0xff, 0xff, 0x38, 0x6d, 0x78, 0x34, 0xad, 0x14, 0x19, + 0xe4, 0x0b, 0x35, 0x2c, 0x99, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, +}; + +/* + * ToScalar: reduce a 512-bit LE integer mod Pallas scalar order. + * + * Uses wide reduction matching the orchard crate's from_uniform_bytes: + * result = (lo + hi * 2^256) mod q + * where lo = input[0..31], hi = input[32..63] (little-endian). + */ +static void to_scalar(const uint8_t input[64], uint8_t output[32]) { + bignum256 lo, hi, t256, result; + + bn_read_le(input, &lo); + pallas_ct_mod_q(&lo); + + bn_read_le(input + 32, &hi); + pallas_ct_mod_q(&hi); + + bn_read_le(two_256_mod_q, &t256); + + /* result = hi * (2^256 mod q) mod q */ + bn_copy(&hi, &result); + pallas_ct_mul_mod_q(&result, &t256); + + /* result = result + lo mod q */ + pallas_ct_add_mod_q(&result, &lo); + + bn_write_le(&result, output); + + memzero(&lo, sizeof(lo)); + memzero(&hi, sizeof(hi)); + memzero(&result, sizeof(result)); +} + +/* + * ToBase: reduce a 512-bit LE integer mod Pallas base field prime. + * + * Uses wide reduction: + * result = (lo + hi * 2^256) mod p + * where lo = input[0..31], hi = input[32..63] (little-endian). + */ +static void to_base(const uint8_t input[64], uint8_t output[32]) { + bignum256 lo, hi, t256, result; + + bn_read_le(input, &lo); + pallas_ct_mod_p(&lo); + + bn_read_le(input + 32, &hi); + pallas_ct_mod_p(&hi); + + bn_read_le(two_256_mod_p, &t256); + + /* result = hi * (2^256 mod p) mod p */ + bn_copy(&hi, &result); + pallas_ct_mul_mod_p(&result, &t256); + + /* result = result + lo mod p */ + bignum256 sum; + pallas_ct_add_mod_p(&result, &lo, &sum); + bn_copy(&sum, &result); + memzero(&sum, sizeof(sum)); + + bn_write_le(&result, output); + + memzero(&lo, sizeof(lo)); + memzero(&hi, sizeof(hi)); + memzero(&result, sizeof(result)); +} + +/* Hardened child index */ +#define ZIP32_HARDENED 0x80000000 + +/* + * ZIP-32 Orchard diversifiers use FF1-AES256 over an 88-bit binary numeral + * string. Parameters are fixed by the Zcash protocol: + * + * radix = 2, minlen = maxlen = n = 88, tweak = "", rounds = 10 + * + * The input and output byte arrays are LEBS2OSP encodings of the 88-bit + * strings, but FF1's NUM/STR operations interpret each half in numeral-string + * order. Keep the bit-order conversion explicit to avoid silently turning this + * into a radix-256 construction, which would be a different permutation. + */ +#define ZCASH_FF1_BITS 88 +#define ZCASH_FF1_HALF_BITS 44 +#define ZCASH_FF1_MASK44 ((UINT64_C(1) << ZCASH_FF1_HALF_BITS) - 1) + +static uint8_t bit_get_le(const uint8_t* bytes, uint32_t bit) { + return (bytes[bit >> 3] >> (bit & 7)) & 1; +} + +static void bit_set_le(uint8_t* bytes, uint32_t bit, uint8_t value) { + if (value) { + bytes[bit >> 3] |= (uint8_t)(1u << (bit & 7)); + } +} + +static uint64_t ff1_bits_to_num(const uint8_t bits[11], uint32_t offset, + uint32_t len) { + uint64_t n = 0; + for (uint32_t i = 0; i < len; i++) { + n = (n << 1) | bit_get_le(bits, offset + i); + } + return n; +} + +static void ff1_num_to_bits(uint64_t n, uint8_t bits[11], uint32_t offset, + uint32_t len) { + for (uint32_t i = 0; i < len; i++) { + uint32_t shift = len - 1 - i; + bit_set_le(bits, offset + i, (uint8_t)((n >> shift) & 1)); + } +} + +static void ff1_store_be48(uint64_t n, uint8_t out[6]) { + for (int i = 5; i >= 0; i--) { + out[i] = (uint8_t)(n & 0xff); + n >>= 8; + } +} + +static bool aes256_encrypt_block(const aes_encrypt_ctx* ctx, + const uint8_t in[16], uint8_t out[16]) { + return aes_encrypt(in, out, ctx) == EXIT_SUCCESS; +} + +static bool ff1_round_y_mod_2_44(const aes_encrypt_ctx* ctx, uint8_t round, + uint64_t b, uint64_t* y_mod) { + static const uint8_t P[16] = { + 0x01, 0x02, 0x01, 0x00, 0x00, 0x02, 0x0a, 0x2c, + 0x00, 0x00, 0x00, 0x58, 0x00, 0x00, 0x00, 0x00, + }; + + uint8_t q[16] = {0}; + uint8_t y[16]; + uint8_t block[16]; + uint8_t r[16]; + + /* + * Q = T || [0]^{(-t-b-1) mod 16} || [i]_1 || [NUM(B)]_b + * Here t = 0 and b = ceil(44 / 8) = 6, so padding is 9 bytes. + */ + q[9] = round; + ff1_store_be48(b, q + 10); + + /* PRF(P || Q) = CBC-MAC_AES(P || Q), IV = 0. */ + if (!aes256_encrypt_block(ctx, P, y)) return false; + for (int i = 0; i < 16; i++) { + block[i] = y[i] ^ q[i]; + } + if (!aes256_encrypt_block(ctx, block, r)) return false; + + /* + * d = 4 * ceil(6 / 4) + 4 = 12, so S is the first 12 bytes of R. + * We only need NUM(S) modulo 2^44, i.e. the low 44 bits of R[0..11]. + */ + uint64_t low48 = 0; + for (int i = 6; i < 12; i++) { + low48 = (low48 << 8) | r[i]; + } + *y_mod = low48 & ZCASH_FF1_MASK44; + + memzero(q, sizeof(q)); + memzero(y, sizeof(y)); + memzero(block, sizeof(block)); + memzero(r, sizeof(r)); + return true; +} + +bool zcash_orchard_derive_diversifier(const uint8_t dk[32], + const uint8_t index_le[11], + uint8_t diversifier_out[11]) { + if (!dk || !index_le || !diversifier_out) return false; + + aes_encrypt_ctx ctx; + if (aes_encrypt_key256(dk, &ctx) != EXIT_SUCCESS) { + memzero(&ctx, sizeof(ctx)); + return false; + } + + uint64_t A = + ff1_bits_to_num(index_le, 0, ZCASH_FF1_HALF_BITS) & ZCASH_FF1_MASK44; + uint64_t B = + ff1_bits_to_num(index_le, ZCASH_FF1_HALF_BITS, ZCASH_FF1_HALF_BITS) & + ZCASH_FF1_MASK44; + + for (uint8_t round = 0; round < 10; round++) { + uint64_t y; + if (!ff1_round_y_mod_2_44(&ctx, round, B, &y)) { + memzero(&ctx, sizeof(ctx)); + return false; + } + uint64_t C = (A + y) & ZCASH_FF1_MASK44; + A = B; + B = C; + } + + memset(diversifier_out, 0, 11); + ff1_num_to_bits(A, diversifier_out, 0, ZCASH_FF1_HALF_BITS); + ff1_num_to_bits(B, diversifier_out, ZCASH_FF1_HALF_BITS, ZCASH_FF1_HALF_BITS); + + memzero(&ctx, sizeof(ctx)); + return true; +} + +static bool orchard_diversify_point(const uint8_t diversifier[11], + curve_point* gd) { + if (!diversifier || !gd) return false; + static const char domain[] = "z.cash:Orchard-gd"; + + if (pallas_group_hash(domain, diversifier, 11, gd) != 0) { + return false; + } + + if (pallas_point_is_identity(gd)) { + if (pallas_group_hash(domain, NULL, 0, gd) != 0 || + pallas_point_is_identity(gd)) { + memzero(gd, sizeof(*gd)); + return false; + } + } + + return true; +} + +bool zcash_orchard_diversify_hash(const uint8_t diversifier[11], + uint8_t gd_out[32]) { + if (!gd_out) return false; + + curve_point gd; + if (!orchard_diversify_point(diversifier, &gd)) { + return false; + } + + pallas_point_encode(&gd, gd_out); + memzero(&gd, sizeof(gd)); + return true; +} + +bool zcash_orchard_derive_transmission_key(const uint8_t ivk[32], + const uint8_t diversifier[11], + uint8_t gd_out[32], + uint8_t pkd_out[32]) { + if (!ivk || !pkd_out) return false; + + bignum256 ivk_scalar; + bn_read_le(ivk, &ivk_scalar); + bn_normalize(&ivk_scalar); + if (bn_is_zero(&ivk_scalar) || !bn_is_less(&ivk_scalar, &pallas_prime)) { + memzero(&ivk_scalar, sizeof(ivk_scalar)); + return false; + } + + curve_point gd; + if (!orchard_diversify_point(diversifier, &gd)) { + memzero(&ivk_scalar, sizeof(ivk_scalar)); + return false; + } + + curve_point pkd; + /* ivk is private viewing-key material. Do not use the variable-time + * public-data multiplier that Sinsemilla note verification relies on. */ + pallas_ct_point_mult(&ivk_scalar, &gd, &pkd); + if (pallas_point_is_identity(&pkd)) { + memzero(&ivk_scalar, sizeof(ivk_scalar)); + memzero(&gd, sizeof(gd)); + memzero(&pkd, sizeof(pkd)); + return false; + } + + if (gd_out) { + pallas_point_encode(&gd, gd_out); + } + pallas_point_encode(&pkd, pkd_out); + + memzero(&ivk_scalar, sizeof(ivk_scalar)); + memzero(&gd, sizeof(gd)); + memzero(&pkd, sizeof(pkd)); + return true; +} + +bool zcash_orchard_derive_ivk(const uint8_t ak[32], const uint8_t nk[32], + const uint8_t rivk[32], uint8_t ivk_out[32]) { + if (!ak || !nk || !rivk || !ivk_out) return false; + if ((ak[31] & 0x80) != 0) return false; + + if (pallas_sinsemilla_commit_ivk(ak, nk, rivk, ivk_out) != 0) { + return false; + } + + bignum256 ivk; + bn_read_le(ivk_out, &ivk); + bn_normalize(&ivk); + bool ok = !bn_is_zero(&ivk) && bn_is_less(&ivk, &pallas_prime); + memzero(&ivk, sizeof(ivk)); + if (!ok) { + memzero(ivk_out, 32); + } + return ok; +} + +bool zcash_orchard_derive_receiver(const uint8_t ak[32], const uint8_t nk[32], + const uint8_t rivk[32], const uint8_t dk[32], + const uint8_t index_le[11], + uint8_t receiver_out[43]) { + if (!receiver_out) return false; + + uint8_t diversifier[11]; + uint8_t ivk[32]; + uint8_t pkd[32]; + bool ok = zcash_orchard_derive_diversifier(dk, index_le, diversifier) && + zcash_orchard_derive_ivk(ak, nk, rivk, ivk) && + zcash_orchard_derive_transmission_key(ivk, diversifier, NULL, pkd); + + if (ok) { + memcpy(receiver_out, diversifier, sizeof(diversifier)); + memcpy(receiver_out + sizeof(diversifier), pkd, sizeof(pkd)); + } else { + memzero(receiver_out, 43); + } + + memzero(diversifier, sizeof(diversifier)); + memzero(ivk, sizeof(ivk)); + memzero(pkd, sizeof(pkd)); + return ok; +} + +bool zcash_orchard_derive_unified_address(const ZcashOrchardKeys* keys, + const uint8_t index_le[11], + const char* hrp, char* address_out, + size_t address_out_len) { + if (!keys || !index_le || !hrp || !address_out) return false; + + bignum256 ask_scalar; + curve_point ak_point; + bignum256 ak_x; + uint8_t ak[32]; + uint8_t receiver[43]; + + bn_read_le(keys->ask, &ask_scalar); + redpallas_scalar_mult_spendauth_G(&ask_scalar, &ak_point); + bn_copy(&ak_point.x, &ak_x); + bn_write_le(&ak_x, ak); + + bool ok = zcash_orchard_derive_receiver(ak, keys->nk, keys->rivk, keys->dk, + index_le, receiver); + if (ok) { + ok = zcash_zip316_encode_orchard_unified_address(hrp, receiver, address_out, + address_out_len) == 0; + } + if (!ok && address_out_len > 0) { + address_out[0] = '\0'; + } + + memzero(&ask_scalar, sizeof(ask_scalar)); + memzero(&ak_point, sizeof(ak_point)); + memzero(&ak_x, sizeof(ak_x)); + memzero(ak, sizeof(ak)); + memzero(receiver, sizeof(receiver)); + return ok; +} + +bool zcash_orchard_receiver_to_unified_address( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], const char* hrp, + char* address_out, size_t address_out_len) { + if (!receiver || !hrp || !address_out) return false; + return zcash_zip316_encode_orchard_unified_address(hrp, receiver, address_out, + address_out_len) == 0; +} + +static bool zcash_pack_orchard_note_commit_msg(const uint8_t receiver[43], + uint64_t value, + const uint8_t rho[32], + const uint8_t psi[32], + uint8_t msg[136]) { + memset(msg, 0, 136); + + /* bits 0..255: repr_P(g_d) */ + curve_point gd; + if (!orchard_diversify_point(receiver, &gd)) { + memzero(&gd, sizeof(gd)); + return false; + } + pallas_point_encode(&gd, msg); + memzero(&gd, sizeof(gd)); + + /* bits 256..511: repr_P(pk_d) */ + memcpy(msg + 32, receiver + 11, 32); + + /* bits 512..575: I2LEBSP_64(value) */ + for (int i = 0; i < 8; i++) { + msg[64 + i] = (uint8_t)((value >> (8 * i)) & 0xff); + } + + /* bits 576..830: I2LEBSP_255(rho) */ + memcpy(msg + 72, rho, 31); + msg[103] = rho[31] & 0x7f; + + /* bits 831..1085: I2LEBSP_255(psi), packed at bit offset 831. */ + uint8_t psi255[32]; + memcpy(psi255, psi, 32); + psi255[31] &= 0x7f; + for (int i = 0; i < 32; i++) { + msg[103 + i] |= (uint8_t)(psi255[i] << 7); + msg[104 + i] |= (uint8_t)(psi255[i] >> 1); + } + memzero(psi255, sizeof(psi255)); + return true; +} + +static bool zcash_orchard_family_compute_cmx_with_progress( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32], + bool ironwood, ZcashOrchardProgressCallback progress, + void* progress_context) { + if (!receiver || !rho || !rseed || !cmx_out) return false; + + uint8_t msg[136]; + uint8_t prf_in[137]; + uint8_t prf_out[64]; + uint8_t rcm[32]; + uint8_t psi[32]; + curve_point gd, q, r; + bool ok = false; + + /* psi is unchanged between V2 (Orchard) and V3 (Ironwood) notes. */ + memcpy(prf_in + 1, rho, 32); + prf_in[0] = 0x09; + prf_expand(rseed, prf_in, 33, prf_out); + to_base(prf_out, psi); + + if (ironwood) { + /* ZIP-2005 H_rcm binds V3 randomness to every note field. */ + if (!orchard_diversify_point(receiver, &gd)) goto cleanup; + prf_in[0] = 0x0B; + pallas_point_encode(&gd, prf_in + 1); + memcpy(prf_in + 33, receiver + 11, 32); + for (size_t i = 0; i < 8; i++) { + prf_in[65 + i] = (uint8_t)((value >> (8 * i)) & 0xff); + } + memcpy(prf_in + 73, rho, 32); + memcpy(prf_in + 105, psi, 32); + prf_expand(rseed, prf_in, sizeof(prf_in), prf_out); + } else { + prf_in[0] = 0x05; + prf_expand(rseed, prf_in, 33, prf_out); + } + to_scalar(prf_out, rcm); + + ok = zcash_pack_orchard_note_commit_msg(receiver, value, rho, psi, msg) && + pallas_group_hash("z.cash:SinsemillaQ", + (const uint8_t*)"z.cash:Orchard-NoteCommit-M", + strlen("z.cash:Orchard-NoteCommit-M"), &q) == 0 && + pallas_group_hash("z.cash:Orchard-NoteCommit-r", NULL, 0, &r) == 0 && + pallas_sinsemilla_short_commit_progress(&q, &r, msg, 1086, rcm, cmx_out, + progress, progress_context) == 0; + +cleanup: + if (!ok) memzero(cmx_out, 32); + memzero(msg, sizeof(msg)); + memzero(prf_in, sizeof(prf_in)); + memzero(prf_out, sizeof(prf_out)); + memzero(rcm, sizeof(rcm)); + memzero(psi, sizeof(psi)); + memzero(&gd, sizeof(gd)); + memzero(&q, sizeof(q)); + memzero(&r, sizeof(r)); + return ok; +} + +bool zcash_orchard_compute_cmx_with_progress( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32], + ZcashOrchardProgressCallback progress, void* progress_context) { + return zcash_orchard_family_compute_cmx_with_progress( + receiver, value, rho, rseed, cmx_out, false, progress, progress_context); +} + +bool zcash_orchard_compute_cmx( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32]) { + return zcash_orchard_compute_cmx_with_progress(receiver, value, rho, rseed, + cmx_out, NULL, NULL); +} + +bool zcash_ironwood_compute_cmx_with_progress( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32], + ZcashOrchardProgressCallback progress, void* progress_context) { + return zcash_orchard_family_compute_cmx_with_progress( + receiver, value, rho, rseed, cmx_out, true, progress, progress_context); +} + +bool zcash_ironwood_compute_cmx( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32]) { + return zcash_ironwood_compute_cmx_with_progress(receiver, value, rho, rseed, + cmx_out, NULL, NULL); +} + +bool zcash_derive_orchard_keys_with_progress( + const uint8_t* seed, uint32_t seed_len, uint32_t account, + ZcashOrchardKeys* keys, ZcashOrchardProgressCallback progress, + void* progress_context) { + uint8_t I[64]; + uint8_t sk[32], chain_code[32]; + + /* Step 1: Master key from seed + * I = BLAKE2b-512("ZcashIP32Orchard", seed) */ + zip32_orchard_master(seed, seed_len, I); + memcpy(sk, I, 32); + memcpy(chain_code, I + 32, 32); + + /* Step 2: Derive path m_orchard / 32' / 133' / account' + * + * CKDOrchard child derivation (ZIP-32 hardened-only): + * I = PRF^expand(chain_code, [0x81] || sk || I2LEOSP32(index)) + * + * PRF^expand(sk, t) = BLAKE2b-512("Zcash_ExpandSeed", sk || t) + * + * So: I = BLAKE2b-512("Zcash_ExpandSeed", + * chain_code || 0x81 || sk || index_le) + */ + const uint32_t path[3] = { + 32 | ZIP32_HARDENED, /* Purpose (Orchard) */ + 133 | ZIP32_HARDENED, /* Coin type (Zcash) */ + account | ZIP32_HARDENED /* Account */ + }; + + for (int i = 0; i < 3; i++) { + /* Build PRF^expand input: [0x81] || sk || I2LEOSP32(index) */ + uint8_t child_input[1 + 32 + 4]; + child_input[0] = 0x81; /* ORCHARD_ZIP32_CHILD domain separator */ + memcpy(child_input + 1, sk, 32); + /* Little-endian index (I2LEOSP32) */ + uint32_t idx = path[i]; + child_input[33] = idx & 0xff; + child_input[34] = (idx >> 8) & 0xff; + child_input[35] = (idx >> 16) & 0xff; + child_input[36] = (idx >> 24) & 0xff; + + /* PRF^expand(chain_code, child_input) */ + prf_expand(chain_code, child_input, sizeof(child_input), I); + memcpy(sk, I, 32); + memcpy(chain_code, I + 32, 32); + + memzero(child_input, sizeof(child_input)); + } + + /* Step 3: Derive subkeys from final spending key */ + memcpy(keys->sk, sk, 32); + + uint8_t expanded[64]; + + /* ask = ToScalar(PRF^expand(sk, [0x06])) */ + uint8_t t_ask = 0x06; + prf_expand(sk, &t_ask, 1, expanded); + to_scalar(expanded, keys->ask); + uint8_t ak_bytes[32]; + + /* + * Zcash spec (§ 4.2.3): If [ask]*G_spendauth has odd y (ỹ = 1), + * negate ask so that the resulting ak always has ỹ = 0. + * This matches the orchard crate's SpendAuthorizingKey::from() behavior. + */ + { + bignum256 ask_test; + bn_read_le(keys->ask, &ask_test); + curve_point ak_test; + redpallas_scalar_mult_spendauth_G_progress(&ask_test, &ak_test, progress, + progress_context); + bignum256 ak_x; + bn_copy(&ak_test.x, &ak_x); + bn_write_le(&ak_x, ak_bytes); + if (bn_is_odd(&ak_test.y)) { + /* ask = order - ask (negate mod q) */ + bignum256 ask_val, neg_ask; + bn_read_le(keys->ask, &ask_val); + bn_subtract(&pallas_order, &ask_val, &neg_ask); + bn_write_le(&neg_ask, keys->ask); + memzero(&neg_ask, sizeof(neg_ask)); + memzero(&ask_val, sizeof(ask_val)); + } + memzero(&ask_test, sizeof(ask_test)); + memzero(&ak_test, sizeof(ak_test)); + memzero(&ak_x, sizeof(ak_x)); + } + /* Cache the public key produced by the normalization multiplication. This + * avoids repeating the same expensive secret-scalar operation for FVK + * export and lets signing derive rk from public ak + public alpha. */ + memcpy(keys->ak, ak_bytes, sizeof(keys->ak)); + + /* nk = ToBase(PRF^expand(sk, [0x07])) */ + uint8_t t_nk = 0x07; + prf_expand(sk, &t_nk, 1, expanded); + to_base(expanded, keys->nk); + + /* rivk = ToScalar(PRF^expand(sk, [0x08])) */ + uint8_t t_rivk = 0x08; + prf_expand(sk, &t_rivk, 1, expanded); + to_scalar(expanded, keys->rivk); + + /* + * dk = truncate_32(PRF^expand(rivk, [0x82] || I2LEOSP_256(ak) + * || I2LEOSP_256(nk))) + */ + uint8_t dk_input[1 + 32 + 32]; + dk_input[0] = 0x82; + memcpy(dk_input + 1, ak_bytes, 32); + memcpy(dk_input + 33, keys->nk, 32); + prf_expand(keys->rivk, dk_input, sizeof(dk_input), expanded); + memcpy(keys->dk, expanded, 32); + + /* Clean up */ + memzero(I, sizeof(I)); + memzero(sk, sizeof(sk)); + memzero(chain_code, sizeof(chain_code)); + memzero(expanded, sizeof(expanded)); + memzero(ak_bytes, sizeof(ak_bytes)); + memzero(dk_input, sizeof(dk_input)); + + return true; +} + +bool zcash_derive_orchard_keys(const uint8_t* seed, uint32_t seed_len, + uint32_t account, ZcashOrchardKeys* keys) { + return zcash_derive_orchard_keys_with_progress(seed, seed_len, account, keys, + NULL, NULL); +} + +static bool zcash_compute_shielded_sighash_inner( + const uint8_t header_digest[32], const uint8_t transparent_digest[32], + const uint8_t sapling_digest[32], const uint8_t orchard_digest[32], + const uint8_t* ironwood_digest, uint32_t branch_id, + uint8_t sighash_out[32]) { + if (!header_digest || !transparent_digest || !sapling_digest || + !orchard_digest || !sighash_out) { + return false; + } + Hasher h; + uint8_t personal[16]; + + memcpy(personal, "ZcashTxHash_", 12); + memcpy(personal + 12, &branch_id, 4); + + hasher_InitParam(&h, HASHER_BLAKE2B_PERSONAL, personal, 16); + hasher_Update(&h, header_digest, 32); + hasher_Update(&h, transparent_digest, 32); + hasher_Update(&h, sapling_digest, 32); + hasher_Update(&h, orchard_digest, 32); + if (ironwood_digest) hasher_Update(&h, ironwood_digest, 32); + hasher_Final(&h, sighash_out); + memzero(personal, sizeof(personal)); + return true; +} + +bool zcash_compute_shielded_sighash(const uint8_t header_digest[32], + const uint8_t transparent_digest[32], + const uint8_t sapling_digest[32], + const uint8_t orchard_digest[32], + uint32_t branch_id, + uint8_t sighash_out[32]) { + return zcash_compute_shielded_sighash_inner(header_digest, transparent_digest, + sapling_digest, orchard_digest, + NULL, branch_id, sighash_out); +} + +bool zcash_compute_v6_shielded_sighash(const uint8_t header_digest[32], + const uint8_t transparent_digest[32], + const uint8_t sapling_digest[32], + const uint8_t orchard_digest[32], + const uint8_t ironwood_digest[32], + uint32_t branch_id, + uint8_t sighash_out[32]) { + if (!ironwood_digest) return false; + return zcash_compute_shielded_sighash_inner( + header_digest, transparent_digest, sapling_digest, orchard_digest, + ironwood_digest, branch_id, sighash_out); +} + +static void zcash_write_u32_le(uint32_t value, uint8_t out[4]) { + out[0] = (uint8_t)(value & 0xff); + out[1] = (uint8_t)((value >> 8) & 0xff); + out[2] = (uint8_t)((value >> 16) & 0xff); + out[3] = (uint8_t)((value >> 24) & 0xff); +} + +static void zcash_write_u64_le(uint64_t value, uint8_t out[8]) { + for (size_t i = 0; i < 8; i++) { + out[i] = (uint8_t)((value >> (8 * i)) & 0xff); + } +} + +static size_t zcash_write_compact_size(size_t value, uint8_t out[9]) { + if (value < 253) { + out[0] = (uint8_t)value; + return 1; + } + + if (value <= 0xffff) { + out[0] = 0xfd; + out[1] = (uint8_t)(value & 0xff); + out[2] = (uint8_t)((value >> 8) & 0xff); + return 3; + } + + if (value <= 0xffffffff) { + out[0] = 0xfe; + out[1] = (uint8_t)(value & 0xff); + out[2] = (uint8_t)((value >> 8) & 0xff); + out[3] = (uint8_t)((value >> 16) & 0xff); + out[4] = (uint8_t)((value >> 24) & 0xff); + return 5; + } + + out[0] = 0xff; + uint64_t v = (uint64_t)value; + for (size_t i = 0; i < 8; i++) { + out[i + 1] = (uint8_t)((v >> (8 * i)) & 0xff); + } + return 9; +} + +static void zcash_blake2b_personal_256(const char personal[16], + const uint8_t* data, size_t data_len, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 32, personal, 16); + if (data_len > 0) { + blake2b_Update(&ctx, data, data_len); + } + blake2b_Final(&ctx, digest_out, 32); +} + +bool zcash_compute_header_digest(uint32_t version, uint32_t version_group_id, + uint32_t branch_id, uint32_t lock_time, + uint32_t expiry_height, + uint8_t digest_out[32]) { + if (!digest_out) return false; + + uint8_t header[20]; + zcash_write_u32_le(version | 0x80000000u, header); + zcash_write_u32_le(version_group_id, header + 4); + zcash_write_u32_le(branch_id, header + 8); + zcash_write_u32_le(lock_time, header + 12); + zcash_write_u32_le(expiry_height, header + 16); + + zcash_blake2b_personal_256("ZTxIdHeadersHash", header, sizeof(header), + digest_out); + memzero(header, sizeof(header)); + return true; +} + +static bool zcash_validate_transparent_digest_info( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs) { + if (n_inputs > 0 && !inputs) return false; + if (n_outputs > 0 && !outputs) return false; + + for (size_t i = 0; i < n_inputs; i++) { + if (!inputs[i].prevout_txid || + (inputs[i].script_pubkey_size > 0 && !inputs[i].script_pubkey)) { + return false; + } + } + + for (size_t i = 0; i < n_outputs; i++) { + if (outputs[i].script_pubkey_size > 0 && !outputs[i].script_pubkey) { + return false; + } + } + + return true; +} + +static void zcash_hash_transparent_prevouts( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + uint8_t le[4]; + blake2b_InitPersonal(&ctx, 32, "ZTxIdPrevoutHash", 16); + for (size_t i = 0; i < n_inputs; i++) { + blake2b_Update(&ctx, inputs[i].prevout_txid, 32); + zcash_write_u32_le(inputs[i].prevout_index, le); + blake2b_Update(&ctx, le, sizeof(le)); + } + blake2b_Final(&ctx, digest_out, 32); + memzero(le, sizeof(le)); +} + +static void zcash_hash_transparent_sequences( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + uint8_t le[4]; + blake2b_InitPersonal(&ctx, 32, "ZTxIdSequencHash", 16); + for (size_t i = 0; i < n_inputs; i++) { + zcash_write_u32_le(inputs[i].sequence, le); + blake2b_Update(&ctx, le, sizeof(le)); + } + blake2b_Final(&ctx, digest_out, 32); + memzero(le, sizeof(le)); +} + +static void zcash_hash_transparent_amounts( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + uint8_t le[8]; + blake2b_InitPersonal(&ctx, 32, "ZTxTrAmountsHash", 16); + for (size_t i = 0; i < n_inputs; i++) { + zcash_write_u64_le(inputs[i].value, le); + blake2b_Update(&ctx, le, sizeof(le)); + } + blake2b_Final(&ctx, digest_out, 32); + memzero(le, sizeof(le)); +} + +static void zcash_hash_transparent_scripts( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + uint8_t compact_size[9]; + blake2b_InitPersonal(&ctx, 32, "ZTxTrScriptsHash", 16); + for (size_t i = 0; i < n_inputs; i++) { + size_t compact_size_len = + zcash_write_compact_size(inputs[i].script_pubkey_size, compact_size); + blake2b_Update(&ctx, compact_size, compact_size_len); + if (inputs[i].script_pubkey_size > 0) { + blake2b_Update(&ctx, inputs[i].script_pubkey, + inputs[i].script_pubkey_size); + } + } + blake2b_Final(&ctx, digest_out, 32); + memzero(compact_size, sizeof(compact_size)); +} + +static void zcash_hash_transparent_outputs( + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + uint8_t le[8]; + uint8_t compact_size[9]; + blake2b_InitPersonal(&ctx, 32, "ZTxIdOutputsHash", 16); + for (size_t i = 0; i < n_outputs; i++) { + zcash_write_u64_le(outputs[i].value, le); + blake2b_Update(&ctx, le, sizeof(le)); + size_t compact_size_len = + zcash_write_compact_size(outputs[i].script_pubkey_size, compact_size); + blake2b_Update(&ctx, compact_size, compact_size_len); + if (outputs[i].script_pubkey_size > 0) { + blake2b_Update(&ctx, outputs[i].script_pubkey, + outputs[i].script_pubkey_size); + } + } + blake2b_Final(&ctx, digest_out, 32); + memzero(le, sizeof(le)); + memzero(compact_size, sizeof(compact_size)); +} + +static bool zcash_hash_transparent_input( + const ZcashTransparentInputDigestInfo* input, uint8_t digest_out[32]) { + if (!input) return false; + + BLAKE2B_CTX ctx; + uint8_t le4[4]; + uint8_t le8[8]; + uint8_t compact_size[9]; + blake2b_InitPersonal(&ctx, 32, "Zcash___TxInHash", 16); + blake2b_Update(&ctx, input->prevout_txid, 32); + zcash_write_u32_le(input->prevout_index, le4); + blake2b_Update(&ctx, le4, sizeof(le4)); + zcash_write_u64_le(input->value, le8); + blake2b_Update(&ctx, le8, sizeof(le8)); + size_t compact_size_len = + zcash_write_compact_size(input->script_pubkey_size, compact_size); + blake2b_Update(&ctx, compact_size, compact_size_len); + if (input->script_pubkey_size > 0) { + blake2b_Update(&ctx, input->script_pubkey, input->script_pubkey_size); + } + zcash_write_u32_le(input->sequence, le4); + blake2b_Update(&ctx, le4, sizeof(le4)); + blake2b_Final(&ctx, digest_out, 32); + memzero(le4, sizeof(le4)); + memzero(le8, sizeof(le8)); + memzero(compact_size, sizeof(compact_size)); + return true; +} + +bool zcash_compute_transparent_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint8_t digest_out[32]) { + if (!digest_out || !zcash_validate_transparent_digest_info( + inputs, n_inputs, outputs, n_outputs)) { + return false; + } + + if (n_inputs == 0 && n_outputs == 0) { + zcash_blake2b_personal_256("ZTxIdTranspaHash", NULL, 0, digest_out); + return true; + } + + uint8_t prevouts_digest[32], sequence_digest[32], outputs_digest[32]; + zcash_hash_transparent_prevouts(inputs, n_inputs, prevouts_digest); + zcash_hash_transparent_sequences(inputs, n_inputs, sequence_digest); + zcash_hash_transparent_outputs(outputs, n_outputs, outputs_digest); + + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 32, "ZTxIdTranspaHash", 16); + blake2b_Update(&ctx, prevouts_digest, 32); + blake2b_Update(&ctx, sequence_digest, 32); + blake2b_Update(&ctx, outputs_digest, 32); + blake2b_Final(&ctx, digest_out, 32); + + memzero(prevouts_digest, sizeof(prevouts_digest)); + memzero(sequence_digest, sizeof(sequence_digest)); + memzero(outputs_digest, sizeof(outputs_digest)); + return true; +} + +/* ZIP-244 §4.9 / §4.10b: transparent_sig_digest for Orchard spend + * authorization. + * + * When n_inputs > 0, the Orchard sighash uses the S.2 form: + * BLAKE2b("ZTxIdTranspaHash", + * hash_type(0x01) || prevouts || amounts || scripts || sequences || + * outputs || empty_txin_digest) + * where empty_txin_digest = BLAKE2b("Zcash___TxInHash", ""). + * + * When n_inputs == 0 (deshield / private-send), falls back to T.1 form + * (no hash_type, amounts, scripts, or txin digest) — same as txid form. + * + * This differs from zcash_compute_transparent_sighash_digest which uses a + * per-input txin_sig_digest for transparent ECDSA signatures. + */ +bool zcash_compute_orchard_transparent_sig_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint8_t digest_out[32]) { + if (!digest_out || !zcash_validate_transparent_digest_info( + inputs, n_inputs, outputs, n_outputs)) { + return false; + } + + /* Empty-vin case (deshield, private): T.1 form is correct per §4.10b. */ + if (n_inputs == 0) { + return zcash_compute_transparent_digest(inputs, n_inputs, outputs, + n_outputs, digest_out); + } + + /* Non-empty vin (shield): S.2 form with empty txin_sig_digest. */ + const uint8_t sighash_type = 0x01; /* SIGHASH_ALL */ + uint8_t prevouts_digest[32], amounts_digest[32], scripts_digest[32]; + uint8_t sequence_digest[32], outputs_digest[32], empty_txin_digest[32]; + + zcash_hash_transparent_prevouts(inputs, n_inputs, prevouts_digest); + zcash_hash_transparent_amounts(inputs, n_inputs, amounts_digest); + zcash_hash_transparent_scripts(inputs, n_inputs, scripts_digest); + zcash_hash_transparent_sequences(inputs, n_inputs, sequence_digest); + zcash_hash_transparent_outputs(outputs, n_outputs, outputs_digest); + + /* Empty txin_sig_digest: BLAKE2b("Zcash___TxInHash", "") */ + zcash_blake2b_personal_256("Zcash___TxInHash", NULL, 0, empty_txin_digest); + + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 32, "ZTxIdTranspaHash", 16); + blake2b_Update(&ctx, &sighash_type, 1); + blake2b_Update(&ctx, prevouts_digest, 32); + blake2b_Update(&ctx, amounts_digest, 32); + blake2b_Update(&ctx, scripts_digest, 32); + blake2b_Update(&ctx, sequence_digest, 32); + blake2b_Update(&ctx, outputs_digest, 32); + blake2b_Update(&ctx, empty_txin_digest, 32); + blake2b_Final(&ctx, digest_out, 32); + + memzero(prevouts_digest, sizeof(prevouts_digest)); + memzero(amounts_digest, sizeof(amounts_digest)); + memzero(scripts_digest, sizeof(scripts_digest)); + memzero(sequence_digest, sizeof(sequence_digest)); + memzero(outputs_digest, sizeof(outputs_digest)); + memzero(empty_txin_digest, sizeof(empty_txin_digest)); + return true; +} + +bool zcash_compute_transparent_sighash_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint32_t signable_input_index, uint8_t sighash_type, + uint8_t digest_out[32]) { + if (!digest_out || !zcash_validate_transparent_digest_info( + inputs, n_inputs, outputs, n_outputs)) { + return false; + } + + if (sighash_type != 0x01 || signable_input_index >= n_inputs) { + return false; + } + + uint8_t prevouts_digest[32], amounts_digest[32], scripts_digest[32]; + uint8_t sequence_digest[32], outputs_digest[32], txin_sig_digest[32]; + zcash_hash_transparent_prevouts(inputs, n_inputs, prevouts_digest); + zcash_hash_transparent_amounts(inputs, n_inputs, amounts_digest); + zcash_hash_transparent_scripts(inputs, n_inputs, scripts_digest); + zcash_hash_transparent_sequences(inputs, n_inputs, sequence_digest); + zcash_hash_transparent_outputs(outputs, n_outputs, outputs_digest); + + zcash_hash_transparent_input(&inputs[signable_input_index], txin_sig_digest); + + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 32, "ZTxIdTranspaHash", 16); + blake2b_Update(&ctx, &sighash_type, 1); + blake2b_Update(&ctx, prevouts_digest, 32); + blake2b_Update(&ctx, amounts_digest, 32); + blake2b_Update(&ctx, scripts_digest, 32); + blake2b_Update(&ctx, sequence_digest, 32); + blake2b_Update(&ctx, outputs_digest, 32); + blake2b_Update(&ctx, txin_sig_digest, 32); + blake2b_Final(&ctx, digest_out, 32); + + memzero(prevouts_digest, sizeof(prevouts_digest)); + memzero(amounts_digest, sizeof(amounts_digest)); + memzero(scripts_digest, sizeof(scripts_digest)); + memzero(sequence_digest, sizeof(sequence_digest)); + memzero(outputs_digest, sizeof(outputs_digest)); + memzero(txin_sig_digest, sizeof(txin_sig_digest)); + return true; +} + +ZcashPCZTSigningRequestStatus zcash_pczt_signing_request_status( + const ZcashPCZTSigningRequestMeta* meta) { + if (!meta || !meta->has_header_digest || !meta->has_orchard_digest) { + return ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS; + } + + if (meta->header_digest_size != 32 || meta->orchard_digest_size != 32) { + return ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE; + } + + if (meta->is_ironwood && + (!meta->has_ironwood_digest || meta->ironwood_digest_size != 32)) { + return ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS; + } + + if (meta->has_transparent_digest && meta->transparent_digest_size != 32) { + return ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE; + } + + (void)meta->sapling_digest_size; + if (meta->has_sapling_digest) { + return ZCASH_PCZT_SIGNING_REQUEST_UNSUPPORTED_SAPLING_COMPONENT; + } + + if (!meta->has_header_fields) { + return ZCASH_PCZT_SIGNING_REQUEST_MISSING_HEADER_FIELDS; + } + + if ((meta->n_transparent_inputs > 0 || meta->n_transparent_outputs > 0) && + (!meta->has_transparent_digest || meta->transparent_digest_size != 32)) { + return ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST; + } + + if (!meta->has_orchard_flags || meta->orchard_flags > 0xff || + !meta->has_orchard_value_balance || !meta->has_orchard_anchor || + meta->orchard_anchor_size != 32) { + return ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA; + } + + return ZCASH_PCZT_SIGNING_REQUEST_OK; +} + +bool zcash_pczt_signing_request_is_clear( + const ZcashPCZTSigningRequestMeta* meta) { + return zcash_pczt_signing_request_status(meta) == + ZCASH_PCZT_SIGNING_REQUEST_OK; +} + +/* + * 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. + * + * Trivial seeds (all-zero, all-0xFF) and seeds outside [32, 252] bytes are + * rejected — these are nominally seeds but provide no security and are + * almost certainly bugs in the caller. + */ +bool zcash_seed_fingerprint_request_valid(bool present, size_t size) { + return !present || size == 32; +} + +bool zcash_calculate_seed_fingerprint(const uint8_t* seed, uint32_t seed_len, + uint8_t fingerprint_out[32]) { + if (!seed || !fingerprint_out) return false; + if (seed_len < 32 || seed_len > 252) return false; + + bool all_zero = true; + bool all_ff = true; + for (uint32_t i = 0; i < seed_len; i++) { + if (seed[i] != 0x00) all_zero = false; + if (seed[i] != 0xFF) all_ff = false; + if (!all_zero && !all_ff) break; + } + if (all_zero || all_ff) return false; + + BLAKE2B_CTX ctx; + if (blake2b_InitPersonal(&ctx, 32, "Zcash_HD_Seed_FP", 16) != 0) { + return false; + } + uint8_t len_byte = (uint8_t)seed_len; + blake2b_Update(&ctx, &len_byte, 1); + blake2b_Update(&ctx, seed, seed_len); + if (blake2b_Final(&ctx, fingerprint_out, 32) != 0) { + memzero(&ctx, sizeof(ctx)); + return false; + } + + memzero(&ctx, sizeof(ctx)); + return true; +} diff --git a/lib/transport/CMakeLists.txt b/lib/transport/CMakeLists.txt index d42d3e113..805b27762 100644 --- a/lib/transport/CMakeLists.txt +++ b/lib/transport/CMakeLists.txt @@ -18,6 +18,7 @@ set(protoc_pb_sources ${DEVICE_PROTOCOL}/messages-solana.proto ${DEVICE_PROTOCOL}/messages-tron.proto ${DEVICE_PROTOCOL}/messages-ton.proto + ${DEVICE_PROTOCOL}/messages-zcash.proto ${DEVICE_PROTOCOL}/messages.proto) set(protoc_pb_options @@ -35,6 +36,7 @@ set(protoc_pb_options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-solana.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-tron.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-ton.options + ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-zcash.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages.options) set(protoc_c_sources @@ -52,6 +54,7 @@ set(protoc_c_sources ${CMAKE_BINARY_DIR}/lib/transport/messages-solana.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages-tron.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages-ton.pb.c + ${CMAKE_BINARY_DIR}/lib/transport/messages-zcash.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages.pb.c) set(protoc_c_headers @@ -69,6 +72,7 @@ set(protoc_c_headers ${CMAKE_BINARY_DIR}/include/messages-solana.pb.h ${CMAKE_BINARY_DIR}/include/messages-tron.pb.h ${CMAKE_BINARY_DIR}/include/messages-ton.pb.h + ${CMAKE_BINARY_DIR}/include/messages-zcash.pb.h ${CMAKE_BINARY_DIR}/include/messages.pb.h) set(protoc_pb_sources_moved @@ -86,6 +90,7 @@ set(protoc_pb_sources_moved ${CMAKE_BINARY_DIR}/lib/transport/messages-solana.proto ${CMAKE_BINARY_DIR}/lib/transport/messages-tron.proto ${CMAKE_BINARY_DIR}/lib/transport/messages-ton.proto + ${CMAKE_BINARY_DIR}/lib/transport/messages-zcash.proto ${CMAKE_BINARY_DIR}/lib/transport/messages.proto) add_custom_command( @@ -163,6 +168,10 @@ add_custom_command( ${PROTOC_BINARY} -I. -I/usr/include --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb "--nanopb_out=-f messages-ton.options:." messages-ton.proto + COMMAND + ${PROTOC_BINARY} -I. -I/usr/include + --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb + "--nanopb_out=-f messages-zcash.options:." messages-zcash.proto COMMAND ${PROTOC_BINARY} -I. -I/usr/include --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb From 161a38f332590353e3bfde82bd1251a70eb6924a Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:26:57 -0300 Subject: [PATCH 17/40] feat(zcash): shielded message handlers, tests and constant-time gates Wires the Orchard engine to the host and puts it under test: - ZcashSignPCZT / ZcashPCZTAction / ZcashGetOrchardFVK / transparent input and output / ZcashDisplayAddress handlers, registered privacy-guarded so the bitcoin-only image exposes none of them. - unittests/firmware/zcash.cpp covers PCZT parsing, action signing and unified address encoding; a second binary links the engine against the host crypto so the Orchard paths run under the normal unit-test job. - unittests/crypto/pallas_ct.cpp plus tools/check_pallas_api_boundary.py and tools/check_pallas_ct_disassembly.py enforce the constant-time contract at three levels: operation counters in the test build, an API boundary check on the sources, and a disassembly scan of the shipped ELF. CI runs the pinned fork's Pallas suite under Valgrind and ASan/UBSan as well. - docs/coin-integration documents the PCZT flow, on-device unified addresses and the clear-sign handoff. --- .github/workflows/ci.yml | 4 + CMakeLists.txt | 10 + docs/coin-integration/README.md | 305 +++ .../zcash-clearsign-handoff.md | 297 +++ docs/coin-integration/zcash-on-device-ua.md | 425 ++++ docs/coin-integration/zcash-pczt-clearsign.md | 322 +++ docs/zoo/reports/zcash-report.md | 146 ++ include/keepkey/firmware/fsm.h | 9 + lib/firmware/fsm.c | 11 + lib/firmware/fsm_msg_common.h | 1 + lib/firmware/fsm_msg_zcash.h | 1511 +++++++++++++ lib/firmware/messagemap.def | 18 + tools/check_pallas_api_boundary.py | 202 ++ tools/check_pallas_ct_disassembly.py | 301 +++ unittests/crypto/CMakeLists.txt | 34 + unittests/crypto/pallas_ct.cpp | 273 +++ unittests/firmware/CMakeLists.txt | 6 + unittests/firmware/zcash.cpp | 1957 +++++++++++++++++ 18 files changed, 5832 insertions(+) create mode 100644 docs/coin-integration/README.md create mode 100644 docs/coin-integration/zcash-clearsign-handoff.md create mode 100644 docs/coin-integration/zcash-on-device-ua.md create mode 100644 docs/coin-integration/zcash-pczt-clearsign.md create mode 100644 docs/zoo/reports/zcash-report.md create mode 100644 lib/firmware/fsm_msg_zcash.h create mode 100644 tools/check_pallas_api_boundary.py create mode 100644 tools/check_pallas_ct_disassembly.py create mode 100644 unittests/crypto/pallas_ct.cpp create mode 100644 unittests/firmware/zcash.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e02527bdc..560f78d80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,6 +227,7 @@ jobs: - name: Enforce RC18 security invariants run: | + python3 tools/check_pallas_api_boundary.py if git grep -n -E \ 'uses:[[:space:]]+[^#[:space:]]+@(v[0-9]+|main|master)([[:space:]#]|$)' \ -- .github/workflows; then @@ -444,6 +445,9 @@ jobs: -DCMAKE_COLOR_MAKEFILE=ON \ ${{ matrix.cmake_flags }} && \ make && \ + python3 /root/keepkey-firmware/tools/check_pallas_ct_disassembly.py \ + --elf bin/firmware.keepkey.elf \ + --variant '${{ matrix.variant }}' && \ mkdir -p /root/keepkey-firmware/bin && \ cp bin/*.bin /root/keepkey-firmware/bin/ && \ cp bin/*.elf /root/keepkey-firmware/bin/ && \ diff --git a/CMakeLists.txt b/CMakeLists.txt index dd7413452..542b6027d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -243,6 +243,10 @@ if(${KK_EMULATOR}) add_test(test-firmware ${CMAKE_BINARY_DIR}/bin/firmware-unit) add_test(test-board ${CMAKE_BINARY_DIR}/bin/board-unit) add_test(test-crypto ${CMAKE_BINARY_DIR}/bin/crypto-unit) + if(${KK_ZCASH_PRIVACY}) + add_test(test-pallas-ct ${CMAKE_BINARY_DIR}/bin/pallas-ct-unit) + add_test(test-zcash-crypto ${CMAKE_BINARY_DIR}/bin/zcash-crypto-unit) + endif() add_custom_target( xunit @@ -253,4 +257,10 @@ if(${KK_EMULATOR}) COMMAND ${CMAKE_BINARY_DIR}/bin/crypto-unit --gtest_output=xml:${CMAKE_BINARY_DIR}/unittests/crypto.xml) + if(${KK_ZCASH_PRIVACY}) + add_custom_command(TARGET xunit POST_BUILD + COMMAND ${CMAKE_BINARY_DIR}/bin/pallas-ct-unit + --gtest_output=xml:${CMAKE_BINARY_DIR}/unittests/pallas-ct.xml) + endif() + endif() diff --git a/docs/coin-integration/README.md b/docs/coin-integration/README.md new file mode 100644 index 000000000..5110255dc --- /dev/null +++ b/docs/coin-integration/README.md @@ -0,0 +1,305 @@ +# Coin Integration Guide for KeepKey Firmware + +This guide explains the submodule-first workflow required when adding new coin +support to the firmware. If you skip or reorder these steps, CI will fail +because the firmware's submodule commits won't resolve. + +## Repository Map + +The firmware repo has 7 submodules. Three matter for coin integration: + +| Submodule | Path | Upstream URL | Fork URL | Purpose | +|-----------|------|-------------|----------|---------| +| device-protocol | `deps/device-protocol` | `keepkey/device-protocol` | `BitHighlander/device-protocol` | Protobuf message definitions | +| python-keepkey | `deps/python-keepkey` | `keepkey/python-keepkey` | `BitHighlander/python-keepkey` | Python client + integration tests | +| trezor-firmware | `deps/crypto/trezor-firmware` | `keepkey/trezor-firmware` | (keepkey org) | Crypto primitives (curves, hashing) | + +**Critical**: `.gitmodules` points to upstream `keepkey/*` URLs. For development +branches with commits that don't exist upstream yet, you must temporarily change +the URLs to point to your fork. + +## The Submodule-First Workflow + +### Why Order Matters + +When CI clones the firmware, it runs `git submodule update --init`. The submodule +commits referenced in the firmware tree must exist in the repos that `.gitmodules` +URLs point to. If you add a new proto file to device-protocol and update the +firmware's submodule pointer, but that commit only exists on your fork and +`.gitmodules` still points to upstream — CI fails. + +### Dependency Order + +``` +1. device-protocol fork → proto messages +2. trezor-firmware fork → crypto primitives (if new curve/algo needed) +3. python-keepkey fork → client methods + wire ID registration + tests +4. firmware → FSM handlers, derivation, signing + unit tests +``` + +Each step must be pushed to the correct fork and the commit hash noted before +the next step can reference it. + +### Step-by-Step + +#### Phase 0: Verify Fork Remotes + +For each submodule that needs changes, ensure the fork remote exists: + +```bash +cd modules/keepkey-firmware + +# device-protocol: check if your fork remote exists +cd deps/device-protocol +git remote -v +# If only 'origin' pointing to keepkey/, add your fork: +git remote add fork https://github.com/BitHighlander/device-protocol.git +git fetch fork +cd ../.. + +# python-keepkey: same pattern +cd deps/python-keepkey +git remote add fork https://github.com/BitHighlander/python-keepkey.git +git fetch fork +cd ../.. + +# trezor-firmware (crypto): usually keepkey org has push access +cd deps/crypto/trezor-firmware +git remote -v +cd ../../.. +``` + +#### Phase 1: Device Protocol (Proto Messages) + +```bash +cd deps/device-protocol + +# Create branch from upstream master +git fetch origin +git checkout -b feature/-proto origin/master + +# Add your proto file +# Example: messages-zcash.proto, messages-solana.proto, etc. +# Also update messages.proto with new MessageType enum values + +git add messages-.proto +git commit -m "feat: add protocol messages (IDs XXXX-XXXX)" + +# Push to YOUR FORK (not upstream!) +git push fork feature/-proto + +# Note the commit hash — you'll need it for firmware +git rev-parse HEAD +# → abc1234... +``` + +**Wire ID conventions**: Check existing ranges in `messages.proto` to avoid collisions. +Current allocations: +- Zcash: 1300-1307 +- Tron: 1400-1403 +- TON: 1500-1503 +- Solana: 1200-1205 + +#### Phase 2: Crypto Primitives (If Needed) + +Only needed if the coin requires a new elliptic curve or hash function not +already in trezor-firmware/crypto. + +```bash +cd deps/crypto/trezor-firmware + +# Check current state +git status + +# Create branch from current HEAD +git checkout -b feature/-crypto + +# Add new crypto files +git add crypto/.c crypto/.h +git commit -m "feat: add primitives for " + +# Push (usually to keepkey/trezor-firmware directly) +git push origin feature/-crypto +``` + +**Examples of coin-specific crypto**: +- Zcash: `pallas.c/h`, `redpallas.c/h` (Pallas curve + RedPallas signatures) +- Solana: `ed25519` (already in repo) + +#### Phase 3: Python-KeepKey (Client + Tests) + +```bash +cd deps/python-keepkey + +git fetch origin +git checkout -b feature/-tests origin/master + +# Add/update: +# 1. keepkeylib/messages__pb2.py — protobuf bindings (or generate from proto) +# 2. keepkeylib/client.py — add client method(s) +# 3. keepkeylib/mapping.py — register wire IDs +# 4. tests/test_msg_.py — integration tests + +git add -A +git commit -m "feat: add client methods and tests" + +# Push to YOUR FORK +git push fork feature/-tests +``` + +**Important**: python-keepkey uses hand-written `_pb2.py` files for protobuf 3.x +compatibility. Do NOT regenerate all proto bindings — only add the new coin's +`_pb2.py` and register wire IDs in `mapping.py`. + +#### Phase 4: Firmware (Core Implementation) + +Now create the firmware branch with updated submodule pointers: + +```bash +cd modules/keepkey-firmware + +# Branch from develop +git checkout -b feature/ origin/develop + +# UPDATE .gitmodules TO POINT TO YOUR FORKS +# This is the critical step most people miss! +git config -f .gitmodules submodule.deps/device-protocol.url \ + https://github.com/BitHighlander/device-protocol.git +git config -f .gitmodules submodule.deps/python-keepkey.url \ + https://github.com/BitHighlander/python-keepkey.git + +# Update submodule pointers to your fork branch commits +cd deps/device-protocol +git fetch fork +git checkout +cd ../.. + +cd deps/python-keepkey +git fetch fork +git checkout +cd ../.. + +# If crypto was changed: +cd deps/crypto/trezor-firmware +git checkout +cd ../../.. + +# Stage submodule pointer updates + .gitmodules +git add .gitmodules deps/device-protocol deps/python-keepkey deps/crypto/trezor-firmware + +# Now add firmware code: +# - include/keepkey/firmware/.h +# - lib/firmware/.c +# - lib/firmware/fsm_msg_.h +# - include/keepkey/transport/messages-.options +# - lib/firmware/messagemap.def (add message registrations) +# - lib/firmware/fsm.c (add #include and declarations) +# - lib/firmware/CMakeLists.txt (add source files) +# - lib/transport/CMakeLists.txt (add proto build steps) +# - unittests/firmware/.cpp (add unit tests) +# - unittests/firmware/CMakeLists.txt (register test file) + +git add -A +git commit -m "feat: add support" +git push origin feature/ +``` + +#### Phase 5: Before Merging Upstream + +When your firmware PR is ready to merge into the main `keepkey/keepkey-firmware`: + +1. First merge device-protocol changes into upstream `keepkey/device-protocol` +2. First merge python-keepkey changes into upstream `keepkey/python-keepkey` +3. First merge crypto changes into upstream `keepkey/trezor-firmware` +4. **Then** update `.gitmodules` URLs back to `keepkey/*` upstream +5. Update submodule pointers to the upstream merge commits +6. Push the firmware PR + +## CI Pipeline + +The firmware uses CircleCI with docker-compose: + +- **emulator-build-test**: Builds emulator, runs `firmware-unit` and `python-keepkey` tests +- Tests must produce a status file with "0" to pass +- Both unit tests (GoogleTest C++) and integration tests (Python) run + +### Making CI Pass + +For each PR, ensure: +1. All submodule URLs in `.gitmodules` resolve (fork URLs during development) +2. All submodule commits exist in the repos the URLs point to +3. Firmware builds clean with `cmake` + `make` +4. `firmware-unit` tests pass (GoogleTest, `unittests/firmware/`) +5. `python-keepkey` tests pass (pytest, `deps/python-keepkey/tests/`) + +## Common Mistakes + +### 1. Forgetting to update .gitmodules URLs +**Symptom**: CI fails with "Could not find remote branch" or "reference is not a tree" +**Fix**: Change `.gitmodules` URLs to point to your fork before pushing + +### 2. Pushing submodule changes to upstream instead of fork +**Symptom**: Unauthorized push failure, or accidentally landing unreviewed proto changes +**Fix**: Always add your fork as a separate remote named `fork`, push there + +### 3. Not initializing the device-protocol submodule +**Symptom**: `deps/device-protocol` shows as `-` prefix in `git submodule status` +**Fix**: `git submodule init deps/device-protocol && git submodule update deps/device-protocol` + +### 4. Regenerating all python-keepkey proto bindings +**Symptom**: Massive diff touching files you didn't mean to change +**Fix**: Only add the new coin's `_pb2.py` file and register wire IDs in `mapping.py` + +### 5. Working in detached HEAD without realizing it +**Symptom**: Commits exist locally but can't be pushed, "branch not found" +**Fix**: Always create a named branch before committing in submodules + +## File Layout for New Coin + +``` +keepkey-firmware/ +├── deps/ +│ ├── device-protocol/ +│ │ └── messages-.proto ← Proto messages +│ ├── crypto/trezor-firmware/ +│ │ └── crypto/.{c,h} ← Crypto primitives (if needed) +│ └── python-keepkey/ +│ ├── keepkeylib/messages__pb2.py +│ ├── keepkeylib/client.py ← Add client method(s) +│ ├── keepkeylib/mapping.py ← Register wire IDs +│ └── tests/test_msg_.py ← Integration tests +├── include/keepkey/ +│ ├── firmware/.h ← Public API +│ └── transport/messages-.options ← Nanopb options +├── lib/firmware/ +│ ├── .c ← Core implementation +│ ├── fsm_msg_.h ← FSM message handlers +│ ├── fsm.c ← #include + declarations +│ ├── messagemap.def ← Message registrations +│ └── CMakeLists.txt ← Add source file +├── lib/transport/ +│ └── CMakeLists.txt ← Add proto build steps +└── unittests/firmware/ + ├── .cpp ← Unit tests (GoogleTest) + └── CMakeLists.txt ← Register test file +``` + +## Existing Coin References + +When implementing a new coin, study these existing implementations: + +| Coin | Proto | Firmware | Tests | Complexity | +|------|-------|----------|-------|------------| +| Mayachain | messages-mayachain.proto | mayachain.c | mayachain.cpp | Simple (address + sign) | +| Cosmos | messages-cosmos.proto | cosmos.c | cosmos.cpp | Moderate (amino encoding) | +| Ethereum | messages-ethereum.proto | ethereum.c | ethereum.cpp | Complex (EIP-155, tokens) | +| Zcash | messages-zcash.proto | zcash.c | (in progress) | Complex (ZIP-32, Orchard, RedPallas) | + +## Branch Naming Conventions + +| Repo | Branch Pattern | Example | +|------|---------------|---------| +| device-protocol | `feature/-proto` | `feature/zcash-proto` | +| trezor-firmware | `feature/-crypto` | `feature/zcash-crypto` | +| python-keepkey | `feature/-tests` | `feature/zcash-orchard-tests` | +| keepkey-firmware | `feature/` | `feature/zcash` | diff --git a/docs/coin-integration/zcash-clearsign-handoff.md b/docs/coin-integration/zcash-clearsign-handoff.md new file mode 100644 index 000000000..0bfca1376 --- /dev/null +++ b/docs/coin-integration/zcash-clearsign-handoff.md @@ -0,0 +1,297 @@ +# Zcash Clear-Signing Handoff + +Date: 2026-05-20 +Branch: `feature/clearsign-txs` +Base: `origin/release/7.15.0` +Repo: `/Users/highlander/WebstormProjects/keepkey-stack/projects/keepkey-firmware` + +## Goal + +Prepare a PR to the 7.15.0 firmware branch for Zcash PCZT clear-signing work, +then test the clear-signing flow on device. + +Scope is Orchard plus transparent clear-signing. Sapling is explicitly out of +scope for this branch. + +## Decisions + +- Do not accept host-provided action sighashes. Firmware must assemble the + signing digest from checked transaction components. +- Sapling is out of scope. Any provided `sapling_digest` is rejected with + `Sapling not supported`; firmware uses the ZIP-244 empty Sapling digest + internally. +- Header digest is no longer blindly trusted. The host must send plaintext + `tx_version`, `version_group_id`, `branch_id`, `lock_time`, and + `expiry_height`; firmware recomputes ZIP-244 `header_digest` and rejects on + mismatch. +- Transparent plaintext streaming is wired for standard P2PKH/P2SH + transparent scripts. Firmware recomputes the transparent digest from streamed + outputs and inputs before emitting any transparent or Orchard signature. +- Orchard privacy outputs are displayed from plaintext `recipient = d || pk_d`, + `value`, and `rseed` only after firmware recomputes `cmx` and verifies it + matches the action commitment. +- The device computes `fee = transparent_in - transparent_out + + orchard_value_balance`, compares it to the requested fee, and requires final + fee confirmation before signatures are returned. +- `wh00hw/libzcash-orchard-c` is used as implementation guidance and test-vector + shape, not as a wholesale dependency. + +## Implemented + +- Added `docs/coin-integration/zcash-pczt-clearsign.md` with threat model, + Keystone comparison, `libzcash-orchard-c` review, crypto inventory, and phased + plan. +- Added clear-signing request policy helpers in + `include/keepkey/firmware/zcash.h` and `lib/firmware/zcash.c`. +- Added ZIP-244 helpers: + - `zcash_compute_header_digest` + - `zcash_compute_transparent_digest` + - `zcash_compute_transparent_sighash_digest` +- Updated `fsm_msg_zcash.h` to: + - reject legacy host-only sighash signing + - require component digests and Orchard metadata + - require plaintext header fields + - verify `header_digest` from plaintext header fields + - reject any Sapling component + - always use the empty Sapling digest internally + - verify Orchard digest from streamed action fields before returning signatures +- Updated Zcash protocol definitions with plaintext header fields. +- Updated Zcash protocol definitions with transparent output streaming, + transparent input plaintext fields, and transparent ack/signed messages. +- Updated `fsm_msg_zcash.h` to: + - request transparent outputs before inputs + - display standard transparent output address/amount before signatures + - reject unknown transparent scripts + - reject host-provided transparent input sighashes + - verify `transparent_digest` from streamed transparent plaintext + - derive per-input transparent sighashes locally +- Added Orchard output clear-signing metadata to `ZcashPCZTAction`: + `recipient` and `rseed`. +- Added firmware Orchard output verification/display: + - recompute `cmx` from `recipient`, `value`, `rseed`, and action nullifier + - reject `cmx` mismatch before signing + - encode the raw Orchard receiver as a ZIP-316 Orchard-only Unified Address + - display the privacy address and amount on-device +- Added final fee verification/display from transparent totals plus + `orchard_value_balance`. +- Updated python-keepkey client/tests to stream transparent outputs/inputs and + expect `ZcashTransparentSigned`. +- Updated python-keepkey test client/tests to send header fields and test: + - legacy sighash rejection + - header digest mismatch rejection + - Sapling digest rejection + - Orchard digest field requirements + - transparent digest mismatch rejection + - host-provided transparent sighash rejection + - Orchard recipient/value metadata mismatch rejection + +## Verified + +Commands run from the firmware repo: + +```sh +cmake --build build --target zcash-crypto-unit +PATH=/private/tmp/kk-python-shim:$PATH cmake --build build --target kkfirmware +PATH=/private/tmp/kk-python-shim:$PATH cmake --build build --target kkfirmware.keepkey +build/bin/zcash-crypto-unit +git diff --check +PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python python3 -c "import sys; sys.path.insert(0, 'deps/python-keepkey'); from keepkeylib import messages_zcash_pb2 as z; a=z.ZcashPCZTAction(index=0, recipient=b'1'*43, rseed=b'2'*32, value=1); assert a.HasField('recipient') and a.HasField('rseed'); print('zcash orchard metadata protobuf smoke ok')" +PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python python3 -c "import sys; sys.path.insert(0, 'deps/python-keepkey'); from keepkeylib import mapping; from keepkeylib import messages_zcash_pb2 as z; assert mapping.get_type(z.ZcashTransparentOutput(index=0)) == 1310; assert mapping.get_type(z.ZcashTransparentAck(next_input_index=0)) == 1311; assert mapping.get_type(z.ZcashTransparentSigned(signatures=[b'0'])) == 1307; msg=z.ZcashSignPCZT(n_transparent_outputs=1,n_transparent_inputs=1); assert msg.HasField('n_transparent_outputs'); print('zcash transparent protobuf smoke ok')" +PYTHONPYCACHEPREFIX=/private/tmp/kk-pycache python3 -m py_compile deps/python-keepkey/tests/test_msg_zcash_sign_pczt.py +PYTHONPYCACHEPREFIX=/private/tmp/kk-pycache python3 -m py_compile ../python-keepkey/keepkeylib/client.py +cd scripts/emulator +docker compose build python-keepkey +docker compose up -d kkemu +docker compose run --rm --no-deps --entrypoint pytest -e FW_VERSION=7.15.0 -e KEEPKEY_SCREENSHOT=1 -e SCREENSHOT_DIR=/kkemu/test-reports/screenshots -e KK_TRANSPORT_MAIN=kkemu:11044 -e KK_TRANSPORT_DEBUG=kkemu:11045 --workdir /kkemu/deps/python-keepkey/tests python-keepkey -v --tb=short -s test_msg_zcash_sign_pczt.py::TestZcashSignPCZT::test_multi_action_device_sighash test_msg_zcash_sign_pczt.py::TestZcashSignPCZT::test_signatures_are_64_bytes test_msg_zcash_sign_pczt.py::TestZcashSignPCZT::test_transparent_shielding_single_input test_msg_zcash_sign_pczt.py::TestZcashSignPCZT::test_transparent_shielding_multiple_inputs +``` + +Results: + +- Full `zcash-crypto-unit`: 56 tests passed. +- `kkfirmware`: builds. +- `kkfirmware.keepkey`: builds. +- `git diff --check`: clean. +- Python Zcash Orchard metadata protobuf smoke test passes. +- Python Zcash transparent protobuf smoke test passes. +- Python test file syntax check passes with bytecode cache redirected to + `/private/tmp/kk-pycache`. +- python-keepkey client syntax check passes with bytecode cache redirected to + `/private/tmp/kk-pycache`. +- Focused Docker PCZT screenshot run passes: 4 tests passed and 24 PNGs were + captured. + +## Published Dependencies + +- `BitHighlander/device-protocol` + `feat/zcash-clearsign-protocol` -> `6ec974e` +- `BitHighlander/python-keepkey` + `feature/zcash-clearsign-tests` -> `41bf86a` +- Firmware submodules now point at those commits: + - `deps/device-protocol` -> `6ec974e` + - `deps/python-keepkey` -> `41bf86a` + +The firmware GitHub Actions PDF report is generated through the checked-out +`deps/python-keepkey` submodule. The submodule report script now includes the +7.15 Zcash clear-signing section with the new digest rejection, transparent +streaming, cmx binding, and privacy-output tests. Its screenshot filter selects +the positive display flows: + +- `test_multi_action_device_sighash` +- `test_signatures_are_64_bytes` +- `test_transparent_shielding_single_input` +- `test_transparent_shielding_multiple_inputs` + +Screenshot capture is still driven by `KEEPKEY_SCREENSHOT=1` and ButtonRequest +callbacks; `scripts/generate-test-report.py --screenshots ` embeds the +captured `btn*.png` frames for tests with screenshot labels. + +## Firmware CI Handoff + +Firmware GitHub Actions on `BitHighlander/keepkey-firmware` are authoritative +for this branch. Do not treat standalone python-keepkey CI as the target; the +firmware workflow checks out `deps/python-keepkey` at the submodule SHA and uses +that test/report tooling inside the firmware run. + +Current pushed state: + +- Firmware: `BitHighlander/keepkey-firmware` + `feature/clearsign-txs` +- Device protocol submodule: `BitHighlander/device-protocol` + `feat/zcash-clearsign-protocol` -> + `6ec974eef1fecb713be0916436ec31fefe4f094e` +- Python test/report submodule: `BitHighlander/python-keepkey` + `feature/zcash-clearsign-tests` -> + `41bf86a534e6cdd0ba0532cbf6bc23f84d2e03e7` + +Failed runs that diagnosed the protobuf break: + +- `https://github.com/BitHighlander/keepkey-firmware/actions/runs/26192217728` +- Event: push +- Branch: `feature/clearsign-txs` +- Head SHA: `4531d4b984f4c5c44d271f6d215061ef98450a36` +- Failure: `python-integration-tests` failed before test execution because + `keepkeylib/messages_zcash_pb2.py` imported + `google.protobuf.internal.builder`, which is not available in the firmware CI + Python/protobuf runtime. +- `https://github.com/BitHighlander/keepkey-firmware/actions/runs/26193294071` +- Event: push +- Branch: `feature/clearsign-txs` +- Head SHA: `98b4fd9e7570ef9010993ccb600c5cbca9670e35` +- Failure: `python-integration-tests` still failed before test execution because + descriptor-pool style output assigned `DESCRIPTOR` from + `AddSerializedFile(...)`, which returns `None` under the firmware CI runtime. + That confirmed the fix must preserve python-keepkey's legacy + `_descriptor.FileDescriptor` plus explicit descriptor/reflection layout. + +CI fixes already applied in this branch: + +- `lint-format`: clang-format fixes for generated clear-signing C changes. +- `python-dylib-tests`: CMake now uses the nanopb plugin wrapper so macOS can + find the protobuf dylib while generating nanopb sources. +- `python-integration-tests`: the temporary Docker protobuf pin was removed, + and the Zcash Python protobuf was restored to python-keepkey's legacy + checked-in style: `_descriptor.FileDescriptor`, explicit + `_descriptor.Descriptor` / `_descriptor.FieldDescriptor` blocks, and + `_sym_db.RegisterFileDescriptor(DESCRIPTOR)`. No protoc/runtime version + change is part of the fix. +- `oled-screenshots`: the Docker-side screenshot phase now extracts + `FW_VERSION` with BusyBox-compatible `grep -Eo '[0-9]+\.[0-9]+\.[0-9]+'`. + The previous `grep -oP` form failed inside the base image, fell back to + `7.14.0`, and filtered out the 7.15.0 Zcash PCZT screenshot tests even + though the final Ubuntu `generate-test-report` job produced the 7.15.0 PDF + rows. + +Last inspected successful run before the screenshot-filter fix: + +- `https://github.com/BitHighlander/keepkey-firmware/actions/runs/26194803188` +- Head SHA: `1cb48d64687cc98a25f3da806782ced9d855ac93` +- Result: all required jobs passed; `python-integration-tests` reported + `405 passed, 34 skipped`. +- PDF artifact: `test-report.pdf` contained + `Zcash Orchard Clear-Signing [NEW] -- 17/17 passed`. +- Gap found during artifact audit: `oled-screenshots` uploaded 301 PNGs, but + only legacy `msg_signtx_zcash` screenshots appeared for Zcash because the + Docker screenshot phase detected `FW_VERSION=7.14.0`. The current branch + fixes that detection. + +Last inspected run after the screenshot-filter fix: + +- `https://github.com/BitHighlander/keepkey-firmware/actions/runs/26195503040` +- Head SHA: `c5ee9453618ef6a65b57d3bfe4d574c17ac4bcae` +- Result: cancelled before artifacts were copied out of the Docker test + container, so the generated PDF was not useful. +- Useful signal: the Docker screenshot phase detected `FW_VERSION=7.15.0` and + selected all four PCZT screenshot tests. +- Failure mode reproduced locally: with `KEEPKEY_SCREENSHOT=1`, + `test_transparent_shielding_multiple_inputs` stalled after the internal + transparent-input `ButtonRequest_SignTx` prompts and before + `ZcashTransparentSigned`. Without screenshot capture the same test passed. +- Fix applied in python-keepkey `41bf86a`: suppress OLED capture only while the + host streams transparent inputs. The output and fee confirmation + `ButtonRequest` screens still capture screenshots for the report. +- Local verification after the fix: the four focused PCZT screenshot tests pass + in Docker and capture 24 PNGs across the expected screenshot directories. + +Artifact validation checklist: + +- `python-integration-tests` must pass in firmware CI, because this is where + the emulator-backed Python clear-signing tests run. +- The run must upload `python-test-results` and `oled-screenshots`. +- The run must then execute `generate-test-report` and upload `test-report` + containing `test-report.pdf`. +- The PDF should contain Zcash 7.15.0 rows `Z5` through `Z17`. +- The PDF should embed screenshots for: + - `test_multi_action_device_sighash` + - `test_signatures_are_64_bytes` + - `test_transparent_shielding_single_input` + - `test_transparent_shielding_multiple_inputs` +- The screenshot/PDF values must match the firmware-computed displays: + - Orchard privacy outputs show a ZIP-316 Orchard-only Unified Address and + amount derived only after firmware verifies `recipient = d || pk_d`, + `value`, `rseed`, action nullifier, and `cmx`. + - Transparent outputs show the derived transparent address/script target and + amount from streamed plaintext. + - Shielding flows show the expected transparent input/output totals and the + final computed fee confirmation before signatures are released. + +## Local Tooling Notes + +- Nanopb generation invokes `env python`. This machine only had `python3`, so a + temporary shim was created: + `/private/tmp/kk-python-shim/python -> /opt/homebrew/bin/python3`. +- `PATH=/private/tmp/kk-python-shim:$PATH` is needed for local firmware rebuilds + unless a real `python` executable is installed. +- Do not use local `protoc`, `grpc-tools`, or CI dependency pins to update + checked-in python-keepkey protobuf files for this branch. python-keepkey keeps + `_pb2.py` files in a legacy hand-maintained protobuf 3.x-compatible layout. + Preserve the existing release/develop style and add only the new fields or + messages needed for the protocol surface. Modern `builder` output and + descriptor-pool `AddSerializedFile(...)` output both fail in the firmware + integration runtime. +- `PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python` is useful for local imports + with the older checked-in protobuf files. + +## Remaining CI/HW Follow-up + +- Run a hardware/emulator signing flow if available, because the Python tests + exercise the client shape but were not run against a device in this handoff. +- CI should run the pushed firmware and python-keepkey branches and produce the + PDF report with screenshots. +- Keep `.claude/` out of the PR unless explicitly requested. + +## Device Test Plan + +- Flash `kkfirmware.keepkey` build to a test device. +- Confirm happy-path Orchard-only PCZT signing. +- Confirm Sapling PCZT is rejected. +- Confirm header digest mismatch is rejected. +- Confirm Orchard digest mismatch is rejected. +- Confirm transparent output address/amount appears on device. +- Confirm transparent output mutation causes digest mismatch/rejection. +- Confirm transparent input sighash mutation cannot produce a signature. +- Confirm Orchard privacy output UA/amount appears on device. +- Confirm Orchard recipient, value, rseed, or cmx mutation is rejected before + signing. +- Confirm fee mismatch is rejected. +- Confirm final computed fee confirmation appears before signatures are + returned. diff --git a/docs/coin-integration/zcash-on-device-ua.md b/docs/coin-integration/zcash-on-device-ua.md new file mode 100644 index 000000000..be28c203d --- /dev/null +++ b/docs/coin-integration/zcash-on-device-ua.md @@ -0,0 +1,425 @@ +# Zcash on-device unified address derivation (Phase 2) + +**Status:** design — not yet implemented. +**Owner:** firmware (this repo) +**Companions:** `hdwallet`, `keepkey-vault`, `device-protocol`. + +## 1. Why this exists + +The flow shipped in PR #142 / `feature-zcash` (PR #220) verifies that a host-supplied +`(ak, nk, rivk)` matches the device's seed-derived FVK, then displays the +**host-supplied** `u1...` string on the OLED. That is **not** an attestation +that the displayed unified address is spendable by the device. A malicious +host that knows the correct FVK can submit any UA string and the device will +faithfully render it. + +The only way the device can promise "this address is spendable by my seed at +this account" is to **derive the unified address itself** from material it +controls (the seed-rooted FVK + a diversifier index it accepts as input) and +display the device-derived bytes. The user then compares the device-shown +`u1...` to the wallet's claim before publishing. + +Half measures (FVK match only, fingerprint binding, "trust the wallet to +display the same UA we sent") are not interchangeable with on-device +derivation. They catch a different, smaller set of attacks. This document +specifies what's actually required to make the strong claim. + +## 2. Threat model + +The device is asked to attest: + +> The unified address rendered on the OLED is a valid encoding of an Orchard +> receiver `(d_j, pk_d_j)` where `pk_d_j = [ivk] · g_d_j`, `g_d_j` is the +> Pallas group element derived from a diversifier `d_j = FF1.Decrypt(dk, j)`, +> and `(ak, nk, rivk, dk)` is the FVK derived from the device's seed at the +> requested ZIP-32 Orchard account. + +Adversaries: + +1. **Compromised host process** — bun, hdwallet, sidecar, browser, USB driver. May fabricate or substitute UAs, FVKs, indexes, addresses. Cannot tamper with the device's OLED. +2. **Compromised wallet UI** — same observation surface as the host. User's only honest read is the OLED + the wallet's claim, side by side. +3. **Bit-flip / glitch** — out of scope for this document; addressed by the existing fault-injection hardening. + +Properties we want against (1) and (2): + +- The bytes rendered on the OLED must be a function of `(seed, account, j)` and nothing else. No host input flows into the displayed string. +- A user who reads the OLED and sees the same string in their wallet has cryptographic assurance that the UA's Orchard receiver is spendable by the device. + +Properties we explicitly do **not** claim: + +- Anything about non-Orchard receivers (transparent, Sapling) bundled into a multi-receiver UA. A UA can carry arbitrary other receivers; only the Orchard one is bound to this device. Display copy must scope the claim accordingly. +- Privacy / linkability of the displayed UA. ZIP-32 §6.1 fingerprinting is a separate (already-shipped) concern. + +## 3. Why this is large + +KeepKey firmware is C on Cortex-M3. Embedded Rust is not in this repo's +toolchain (Trezor Model T and Keystone3 both run Rust embedded; we don't). +Every primitive needed for Orchard UA derivation has to be implemented or +ported in C. The crates `orchard`, `pasta_curves`, `sinsemilla`, `f4jumble`, +`fpe`, and `zcash_keys` are the reference implementations; they don't run +here. + +What we already have, verified against the active Trezor firmware crypto +submodule at `deps/crypto/trezor-firmware` commit `376c64bcf`: + +- BLAKE2b (`deps/crypto/trezor-firmware/crypto/blake2b.{c,h}`) +- AES block cipher (`deps/crypto/trezor-firmware/crypto/aes/`) +- Pallas curve arithmetic (`pallas.{c,h}`) — point ops, scalar mult, modular ops +- RedPallas signatures (`redpallas.{c,h}`) +- A hard-coded RedPallas SpendAuth basepoint, useful for `ak = [ask]G_spendauth` + but not a general `GroupHash^Pallas` implementation +- Generic Bech32/Bech32m checksum support in + `deps/crypto/trezor-firmware/crypto/segwit_addr.{c,h}`. This still needs a + ZIP-316-specific wrapper and may need its BIP-173 90-character output guard + relaxed for UA strings. + +What we **don't** have and must build or adapt: + +| Primitive | Source / spec | Approx C LOC | Risk | +|---|---|---|---| +| FF1-AES256 (NIST SP 800-38G) for diversifier derivation | NIST SP 800-38G; `fpe` Rust crate | ~300 | Medium — AES exists, but FF1/FPE does not | +| `expand_message_xmd_blake2b` | RFC 9380 §5.3.1 | ~80 | Low | +| Pallas Simplified SWU map (`map_to_curve_simple_swu`) + isogeny | Pasta paper / Halo 2 reference | ~500 | High — algebraic, easy to get wrong, needs cross-vectors | +| `GroupHash^Pallas` + `DiversifyHash^Orchard` | ZIP-32 §5.4.2.1 / Orchard book §5.4 | ~80 | Low (composition of above) | +| Sinsemilla hash + commitment | Halo 2 spec §5.4.1.9; Orchard book §5.4 | ~500 | High — chunked commitment, easy off-by-one | +| `Commit^ivk` (specifically `ivk = SinsemillaShortCommit("z.cash:Orchard-CommitIvk", ak ‖ nk; rivk)`) | Orchard book §5.4 | ~100 | Med | +| F4Jumble (4-round Feistel-like permutation) | ZIP-316 §4.2 | ~150 | Low — well-specified | +| ZIP-316 Bech32m adapter | BIP-350 / ZIP-316 | ~50 | Low — checksum exists, but UA sizing/padding glue does not | +| ZIP-316 UA encoding (single-receiver, Orchard) | ZIP-316 §4 | ~150 | Low | +| Cross-language test harness | — | ~500 | — | +| **Subtotal — production primitives** | | **~2,500 LOC** | | + +This is conservative and excludes the FSM handler, layout code, proto changes, +and unit tests for everything (probably another 1,500 LOC). + +First-pass feasibility conclusion: the Trezor firmware dependency gives us the +generic base layer (BLAKE2b, AES, Pallas point/scalar arithmetic, RedPallas, and +Bech32m), but it does not give us the Orchard unified-address derivation stack. +There is no checked-in Trezor C implementation of FF1, Sinsemilla, +`expand_message_xmd_blake2b`, Pallas SWU/isogeny, Orchard `GroupHash`, F4Jumble, +or ZIP-316 UA assembly. Phase 2 therefore remains a real crypto port, not just +wiring existing Trezor libraries together. + +### 3.1. Online library survey + +Surveyed 2026-04-30. The pattern is clear: only FF1 has a plausible C source +to adapt. The rest should be treated as spec-driven C implementations with +Rust upstreams used for goldens. + +| Needed primitive | Best upstream candidate | Language / license | Fit for KeepKey firmware | Estimated firmware LOC | Effect on plan | +|---|---|---|---|---:|---| +| FF1-AES256 for `d_j = FF1.Decrypt(dk, j)` | [`0NG/Format-Preserving-Encryption`](https://github.com/0NG/Format-Preserving-Encryption) | C / MIT | Useful algorithm reference, not drop-in. It depends on OpenSSL BIGNUM/AES; firmware should replace this with existing Trezor AES and a fixed-size radix-256 path for the 11-byte diversifier. | 300-500 | Reduces spec ambiguity, but does not avoid a port. Still needs NIST vectors and Orchard vectors. | +| FF1 specification and test source | [NIST SP 800-38G Rev. 1 draft](https://csrc.nist.gov/pubs/sp/800/38/g/r1/2pd) | Spec | Normative source. NIST's 2025 draft keeps FF1, removes FF3, and disallows floating point. | 0 production / 50-100 tests | Hard requirement for validation. Also tells us not to port code that uses floating-point `log2` or general decimal FPE assumptions blindly. | +| `expand_message_xmd_blake2b` | [RFC 9380](https://www.rfc-editor.org/rfc/rfc9380.html) | Spec | Directly implement against existing BLAKE2b. No C library needed. | 80-120 | Low-risk self-contained helper. | +| Pallas Simplified SWU + isogeny | [`zcash/pasta_curves`](https://github.com/zcash/pasta_curves) / [docs.rs source](https://docs.rs/pasta_curves/latest/src/pasta_curves/pallas.rs.html) | Rust / MIT or Apache-2.0 | Authoritative reference and test-vector source only. No usable C port found. Existing KeepKey `pallas.{c,h}` gives field and point ops, but not SWU/isogeny. | 500-800 | Highest algebraic risk. Rust vectors are mandatory before using in address derivation. | +| Orchard `GroupHash^Pallas` / `DiversifyHash` | [`zcash/orchard`](https://github.com/zcash/orchard) plus `pasta_curves` | Rust / MIT or Apache-2.0 | Composition layer over `expand_message_xmd_blake2b` and SWU/isogeny. No standalone C implementation found. | 80-150 | Small LOC, but correctness depends entirely on SWU/isogeny. | +| Sinsemilla hash and commitment | [`zcash/sinsemilla`](https://github.com/zcash/sinsemilla) and Halo 2 Sinsemilla docs | Rust / MIT or Apache-2.0 | Reference only. No C implementation found. Needs fixed generators/constants and chunking rules ported carefully. | 700-1,200 | Main risk after SWU. Larger than the first estimate if constants/tables are checked in rather than generated. | +| `Commit^ivk` / Orchard IVK | [`zcash/orchard`](https://github.com/zcash/orchard) | Rust / MIT or Apache-2.0 | Thin wrapper over Sinsemilla plus scalar handling. Not useful until Sinsemilla exists. | 100-180 | Medium risk; mostly test-vector coverage. | +| F4Jumble | [`f4jumble` crate](https://docs.rs/f4jumble) / [ZIP-316](https://zips.z.cash/zip-0316) | Rust / MIT or Apache-2.0, spec MIT | Implement directly from ZIP-316 using existing BLAKE2b. No C library found; Rust crate is good for goldens. | 150-250 | Low-risk. Does not block crypto receiver derivation; needed for final `u1...` string. | +| Bech32m for ZIP-316 strings | Existing Trezor `segwit_addr.{c,h}`; optional reference [`whitslack/libbech32`](https://github.com/whitslack/libbech32) | C / existing vendored license; libbech32 MIT-style | We already have Bech32m checksum support. Need ZIP-316 use that ignores BIP-173's 90-character cap, per ZIP-316. `libbech32` is useful only if we want a comparison implementation. | 50-120 | Reduces previous estimate: no new checksum implementation, only wrapper/length-policy work. | +| ZIP-316 UA item assembly | [`zcash_address::unified`](https://docs.rs/zcash_address/latest/zcash_address/unified/index.html) / [ZIP-316](https://zips.z.cash/zip-0316) | Rust / MIT or Apache-2.0, spec MIT | Reference only. Implement compactSize item encoding for a single Orchard receiver, padding, F4Jumble, Bech32m. | 150-250 | Low-to-medium risk; mostly parser/encoding edge cases and exact HRP behavior. | + +Net effect: expected new production C stays roughly **2,100-3,500 LOC** before +FSM/UI/proto/test glue. The best-case reduction from online libraries is mostly +around Bech32m and FF1; there is no library discovery that changes the hard +parts: SWU/isogeny and Sinsemilla still need first-party C ports verified +byte-for-byte against upstream Rust. + +## 4. Cryptographic recipe (the actual algorithm) + +References: + +- Zcash Protocol Spec (NU6) §4.2.3, §5.4.1.6, §5.4.8.5 +- ZIP-32 §5.4 (Orchard key derivation) +- ZIP-316 (Unified Addresses) +- Orchard book — `https://zcash.github.io/orchard/` +- Pasta paper — `https://github.com/zcash/pasta_curves` + +Given device seed `S`, account index `a`: + +``` +# Already implemented in zcash.c +sk = ZIP-32 Orchard derivation (S, a) +ask = ToScalar(PRF^expand(sk, [0x06])) +nk = ToBase (PRF^expand(sk, [0x07])) +rivk = ToScalar(PRF^expand(sk, [0x08])) +ak = [ask]·G_spendauth # already serialised by current code + +# NEW — must be implemented +dk = PRF^expand(sk, [0x09])[0..32] # diversifier key (uses existing PRF^expand) +ivk = SinsemillaShortCommit( + "z.cash:Orchard-CommitIvk", + I2LEBSP_l_ivk(ak) ‖ I2LEBSP_l_ivk(nk), + rivk + ) + +# Per-address (single diversifier index j; default j = 0) +d_j = FF1-AES256.Decrypt(dk, /* tweak = */ "", I2LEBSP_88(j)) # 11 bytes +g_d_j = DiversifyHash^Orchard(d_j) = GroupHash^Pallas("z.cash:Orchard-gd", d_j) +pk_d_j = [ivk] · g_d_j # uses existing Pallas scalar mult + +raw_addr = bytes(d_j) ‖ bytes(pk_d_j) # 11 + 32 = 43 bytes + +# UA encoding (single receiver, Orchard) +receiver = uint8(0x03) ‖ uint8(43) ‖ raw_addr # ZIP-316 typecode 0x03 = Orchard +hrp = "u" +encoded = bech32m(hrp, F4Jumble(receiver ‖ padding_for_hrp("u"))) +``` + +The displayed string is `encoded`. Every byte that goes into it is a +deterministic function of `(S, a, j)` — the host contributes only the choice +of `j`. + +## 5. API design + +### 5.1 New proto messages (`device-protocol`) + +```proto +// Request: device derives the canonical Orchard UA for (account, j) from its +// own seed and renders it on the OLED for user confirmation. The host does +// NOT supply any address bytes. +// +// @next ZcashUnifiedAddress +// @next Failure +message ZcashGetUnifiedAddress { + repeated uint32 address_n = 1; // m/32'/133'/account' + optional uint32 account = 2; // alternative to address_n + optional uint64 diversifier_index = 3; // 88-bit; default 0 + optional bool show_display = 4; // when true, render OLED + require confirm + optional bytes expected_seed_fingerprint = 5; // optional ZIP-32 §6.1 binding +} + +// Response. +// +// @prev ZcashGetUnifiedAddress +message ZcashUnifiedAddress { + optional string address = 1; // device-derived "u1..." (max_size: 256) + optional bytes raw_receiver = 2; // d || pk_d, 43 bytes — for cross-check + optional bytes seed_fingerprint = 3; // ZIP-32 §6.1 fingerprint of attesting device + optional uint64 diversifier_index = 4; // echoed for clarity +} +``` + +The existing `ZcashDisplayAddress` (host supplies the string) **stays** but +is renamed in copy to "Display address (FVK match)" to reflect what it +actually proves. The new message is the strong-attestation path. + +### 5.2 hdwallet wrapper + +```ts +wallet.zcashGetUnifiedAddress({ + addressNList, + account?: number, + diversifierIndex?: bigint, // default 0n + showDisplay?: boolean, // default true + expectedSeedFingerprint?: Uint8Array, +}): Promise<{ + address: string, // device-derived UA — trustable + rawReceiver: Uint8Array, // 43 bytes + seedFingerprint?: Uint8Array, + diversifierIndex: bigint, +}> +``` + +### 5.3 Vault privacy tab + +Replace the current "Verify on device" button (which calls +`ZcashDisplayAddress`) with a **two-step** flow: + +1. "Show device address" → calls `ZcashGetUnifiedAddress`. UI displays the + returned UA prominently next to the device-rendered version. User reads + both side by side. +2. Optional "Verify host address (FVK match only)" button retained for the + weaker check, with copy that names the limitation. This is the existing + PR #141 button with the wording fixed. + +## 6. Implementation phases + +Each phase is mergeable on its own. Each closes with a cross-language test +harness verifying outputs against the upstream `orchard` Rust crate via +test vectors checked into the firmware repo. + +### Phase 2.1 — Foundation primitives + +**Goal:** ship FF1-AES256 + Pallas SWU + GroupHash + their tests. No new +firmware behavior — these compile into the binary and have unit tests. + +**Files (estimate):** +- `deps/crypto/trezor-firmware/crypto/ff1.{c,h}` — FF1-AES256 +- `deps/crypto/trezor-firmware/crypto/pallas_swu.{c,h}` — SWU map + isogeny +- Extend `pallas.{c,h}` with `expand_message_xmd_blake2b` +- `unittests/firmware/zcash_phase2.cpp` + +**Test vectors needed:** +- FF1-AES256: NIST CAVP vectors + `fpe` Rust crate vectors +- SWU: `pasta_curves::pallas::map_to_curve_simple_swu` golden outputs +- GroupHash: `orchard::keys` test fixtures + +**Effort:** 3–5 days dev, 2 days vectors + tests. **High-risk** on SWU isogeny. + +### Phase 2.2 — Sinsemilla + Orchard receiver + +**Goal:** derive `(d_j, pk_d_j)` on device. New FSM internal helper, no proto +changes yet. + +**Files:** +- `deps/crypto/trezor-firmware/crypto/sinsemilla.{c,h}` +- Extend `lib/firmware/zcash.{c,h}`: + - Add `dk` to `ZcashOrchardKeys` + - `zcash_orchard_ivk(rivk, ak, nk, ivk_out)` — uses Sinsemilla + - `zcash_orchard_diversifier(dk, j, d_out)` — uses FF1 + - `zcash_orchard_receiver(account, j, raw_out_43)` — composes everything + +**Test vectors:** `orchard::keys::FullViewingKey::default_address` for many +`(seed, account, j)` tuples. We must agree byte-for-byte. + +**Effort:** 5–7 days. **Highest-risk** phase — Sinsemilla is involved and +under-documented relative to its complexity. + +### Phase 2.3 — F4Jumble + bech32m + UA encoding + +**Goal:** turn 43 raw receiver bytes into a `u1...` string. + +**Files:** +- `lib/firmware/zip316.{c,h}` — F4Jumble + UA assembly +- Extend bech32 helper to support the `m` constant (BIP-350) + +**Test vectors:** ZIP-316 has explicit test vectors. Plus +`zcash_address::unified::Address::encode` outputs. + +**Effort:** 2–3 days. Low risk. + +### Phase 2.4 — Proto + FSM handler + UI + +**Goal:** wire the new message end-to-end through firmware → hdwallet → vault. + +**Files:** +- `device-protocol/messages-zcash.proto` — `ZcashGetUnifiedAddress` / + `ZcashUnifiedAddress` +- `lib/firmware/fsm_msg_zcash.h` — `fsm_msgZcashGetUnifiedAddress` +- `lib/firmware/messagemap.def` + `fsm.h` — message routing +- `hdwallet-keepkey` — wrapper +- `keepkey-vault` — UI flow update + bun handler + +**Effort:** 2–3 days. Mostly mechanical given primitives are tested. + +### Phase 2.5 — Hardening, fault injection, hardware test + +**Goal:** confirm on real hardware, scrub side channels, add fault-injection +mitigations consistent with the rest of the firmware. + +**Effort:** 2–3 days + hardware time. + +**Total: 14–21 working days** for a single contributor focused on this. Add +~50% for unknowns and review feedback → call it 4–5 weeks of calendar time +including review, hardware verification, and the cross-language test +infrastructure. + +## 7. Testing strategy + +### 7.1 Cross-language goldens + +Generate goldens from the `orchard` Rust crate, check them into +`unittests/firmware/zcash_orchard_vectors.h` as C arrays: + +```rust +// Generator (host-side, Rust) — run once, commit output +let seed = hex!("000102...1f"); +for account in [0, 1, 7, 100, 0x7fffffff] { + for j in [0u128, 1, 256, 0xdeadbeef, (1u128 << 88) - 1] { + let sk = ExtendedSpendingKey::master(&seed); + let osk = sk.derive_internal(account); + let fvk = FullViewingKey::from(&osk); + let addr = fvk.address_at(j, Scope::External); + let ua = unified::Address::try_from_items(vec![Receiver::Orchard(addr.to_raw_address_bytes())]); + emit_c_vector(seed, account, j, addr, ua); + } +} +``` + +The firmware unit test imports the header and asserts byte equality at every +intermediate stage (ivk, d, g_d, pk_d, raw_receiver, encoded UA). + +### 7.2 Phase boundaries + +Each phase ships with its own `unittests/firmware/zcash_phaseN.cpp` that +runs in CI via the existing `firmware-unit` target. A phase can't merge +without its goldens. + +### 7.3 On-device sanity + +Once Phase 2.4 lands, manual hardware test: +- Initialize a known seed (the all-allallall mnemonic) +- Call `ZcashGetUnifiedAddress(account=0, j=0)` +- Confirm OLED renders the same `u1...` string the upstream Rust crate + computes for that mnemonic + j=0 +- Repeat for j={1, 100, 2^32, 2^88-1} +- User-cancel test: long-press Cancel → device returns `Failure_ActionCancelled` +- Wrong-FVK injection: skip — there is no host-supplied FVK in this flow + +## 8. Open design questions + +1. **Diversifier index bounds.** Specced as 88-bit. Practical wallets use + small indexes (≤ 2^31). Do we accept full 88-bit on the wire and reject + ranges with no real users, or cap at 2^32 to fit a `uint32`? **Tentative:** + accept full `uint64` on the wire (caller passes 0..2^64 range; spec + technically allows up to 2^88 but no wallet uses that), reject any value + > 2^64-1 with `Failure_SyntaxError`. +2. **Multi-receiver UAs.** This design only ever displays a single-receiver + Orchard UA. Wallets that present users with `u1...` containing additional + transparent or Sapling receivers will have a UA the device won't match. + **Position:** the device-attested string is the *Orchard-only* UA derived + from this device. Wallets that bundle more receivers should present + *both* — "your full UA" and "the device-attested Orchard receiver" — and + the user verifies the latter against the OLED. +3. **Diversifier index display.** Show `j` on the OLED alongside the UA, or + omit? **Tentative:** show; at minimum show `j == 0` vs `j > 0` and the + account number, so users can spot if they ask for default but the host + slipped in a non-default index. +4. **bech32m vs raw-receiver display.** OLED is 256x64. A `u1...` Orchard + UA fits across multiple lines (already works in PR #142). Raw receiver + would be 86 hex chars — also displayable, less natural. **Tentative:** + show `u1...` (consistent with what wallets show); QR encodes the full UA. +5. **Should `dk` ship in `ZcashOrchardFVK`?** Currently FVK is `(ak, nk, rivk)`. + `dk` is part of the FVK in ZIP-32 (FVK = `(ak, nk, rivk, dk)`). Wallets + that want to derive their own diversifiers without round-tripping the + device need it. **Tentative:** add `dk` to `ZcashOrchardFVK` as + `optional bytes dk = 5;`. It's not secret beyond what FVK already exposes. + +## 9. Migration path + +The existing `ZcashDisplayAddress` flow stays; its UI copy is downgraded to +"Display address (FVK match) — proves the FVK belongs to this device, not +that the displayed UA is spendable." The new +`ZcashGetUnifiedAddress` flow is the recommended path going forward. Wallets +that want strong attestation use the new message; older code paths continue +to work. + +Once Phase 2 ships: + +- Vault: replace the "Verify on device" button's primary action to call + `ZcashGetUnifiedAddress`, drop the host-supplied address from the FSM call. +- Documentation: update `docs/zoo/reports/zcash-report.md` with the strong- + attestation flow as the canonical "verify address" procedure. + +## 10. References + +- Zcash Protocol Specification, NU6 — https://zips.z.cash/protocol/protocol.pdf +- ZIP-32 — https://zips.z.cash/zip-0032 +- ZIP-316 — https://zips.z.cash/zip-0316 +- Orchard book — https://zcash.github.io/orchard/ +- Pasta paper / curves — https://github.com/zcash/pasta_curves +- Halo 2 spec (Sinsemilla) — https://zcash.github.io/halo2/design/gadgets/sinsemilla.html +- BIP-350 (bech32m) — https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki +- NIST SP 800-38G (FF1) — https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38G.pdf +- RFC 9380 (hash-to-curve) — https://datatracker.ietf.org/doc/rfc9380/ + +## 11. What this document is not + +- A schedule. Effort estimates are working-days for a focused contributor; + calendar time depends on review velocity and hardware availability. +- Final spec for diversifier-index UX. Section 8 calls out tentative + positions; final UI copy and OLED layout are part of Phase 2.4 review. +- A claim that anything in Phase 2.1–2.3 is small. Sinsemilla and the SWU + isogeny are the high-risk pieces and may take longer than the estimates. + The estimates assume one contributor familiar with finite-field crypto. diff --git a/docs/coin-integration/zcash-pczt-clearsign.md b/docs/coin-integration/zcash-pczt-clearsign.md new file mode 100644 index 000000000..e03b3b612 --- /dev/null +++ b/docs/coin-integration/zcash-pczt-clearsign.md @@ -0,0 +1,322 @@ +# Zcash PCZT Clear Signing + +**Status:** implemented on `feature/clearsign-txs`. +**Base:** `release/7.15.0`. +**Scope:** Orchard PCZT signing. + +## Threat Model + +The firmware must not sign a host-provided Orchard sighash by itself. If the +companion app can choose the sighash directly, a compromised companion can turn +the hardware wallet into a blank-check signer: the user confirms one summary, +but the signature authorizes a different transaction digest. + +The firmware therefore has to build the signing digest from transaction data it +can validate. The current PCZT protocol does this in layers: + +1. The host sends ZIP-244 component digests and Orchard bundle metadata. +2. The device assembles the final ZIP-244 sighash from those component digests. +3. The device recomputes the Orchard digest from streamed action fields. +4. Signatures are returned only after the recomputed Orchard digest matches the + Orchard digest used in the device-computed sighash. + +## Firmware Policy + +`ZcashSignPCZT` is rejected before user confirmation unless it includes: + +- `header_digest`, exactly 32 bytes. +- Plaintext header fields: `tx_version`, `version_group_id`, `branch_id`, + `lock_time`, and `expiry_height`. Firmware recomputes ZIP-244 + `header_digest` from these fields and rejects on mismatch. +- `orchard_digest`, exactly 32 bytes. +- Orchard flags, value balance, and 32-byte anchor. +- A 32-byte transparent digest when transparent inputs or outputs are present. +- Optional transparent digest, if present, must be exactly 32 bytes. + +Sapling is out of scope for this signing path. Any host-provided +`sapling_digest` is rejected; firmware uses the ZIP-244 empty Sapling digest +internally. + +`ZcashPCZTAction` is rejected unless each action includes the fields needed to +recompute the Orchard digest: + +- `nullifier`, `cmx`, `epk`, `cv_net`, and `rk`, each 32 bytes. +- `enc_compact`, 52 bytes. +- `enc_memo`, 512 bytes. +- non-empty `enc_noncompact`. +- `out_ciphertext`, 80 bytes. +- `value`, 43-byte `recipient` (`d || pk_d`), and 32-byte `rseed` for trusted + Orchard output display. + +The legacy path where `ZcashPCZTAction.sighash` was accepted as the signing +digest is intentionally rejected. + +## What Is Verified + +The device now verifies `header_digest` from plaintext header fields and +recomputes `transparent_digest` from streamed transparent inputs/outputs before +emitting any transparent or Orchard signature. Sapling is not accepted in this +path. For shielded-only Orchard transactions, the transparent digest defaults to +the ZIP-244 empty transparent digest, so there is no host-provided transparent +component. + +The device verifies the Orchard action digest from the action plaintext fields +available in PCZT. Each action must also carry the plaintext Orchard output +metadata needed for trusted display: raw receiver `recipient = d || pk_d`, +`value`, and `rseed`. Firmware recomputes the action `cmx` from that metadata +and the action nullifier before displaying the ZIP-316 Orchard-only Unified +Address and amount. + +The device also computes the fee as: + +```text +fee = transparent_input_total - transparent_output_total + orchard_value_balance +``` + +The computed fee must match the requested fee and must be confirmed on-device +before any final signatures are returned. + +## UI Behavior + +Current signing screens show: + +- Shielded-only: total amount, fee, and Orchard action count. +- Transparent shielding: total amount, fee, transparent input count, and + transparent output count, and Orchard action count. +- Transparent input signing: per-input amount and BIP-44 path validation. +- Transparent outputs: each standard P2PKH/P2SH t-address and amount. +- Orchard outputs: each ZIP-316 Orchard-only Unified Address and amount after + note commitment binding. +- Final fee confirmation: computed fee after digest/output verification. + +## Outputs + +If we can derive a digest from plaintext transaction fields, we should do so for +outputs too. + +Transparent outputs are streamed as recipient scripts and values. Firmware +computes the transparent digest, displays transparent destination/address and +amount, and rejects non-standard scripts until an explicit raw-script review +policy exists. + +Orchard outputs are displayed from the supplied raw receiver/value/rseed +metadata only after firmware recomputes `cmx = NoteCommit(...)` and verifies it +matches the action `cmx`. This binds the displayed privacy recipient and amount +to the signed Orchard action. + +## libzcash-orchard-c Review + +Reviewed: https://github.com/wh00hw/libzcash-orchard-c + +This repo is applicable as implementation guidance, not as a wholesale firmware +dependency. It is a pure C11 static library under MIT, but it overlaps heavily +with primitives already present in this firmware tree: BLAKE2b, Pallas, +Sinsemilla, RedPallas, secp256k1, BIP32/BIP39, Base58, and ZIP-316 helpers. The +useful part for KeepKey is its transaction-signing shape: + +- Separate ZIP-244 `T.2 transparent_digest` from ZIP-244 `S.2` per-input + transparent signature digest. These are different commitments and must not be + collapsed into a single helper. +- Stream transparent inputs and outputs into independent BLAKE2b component + hashers: prevouts, sequences, outputs, amounts, scripts, and per-input + `txin_sig_digest`. +- Track transparent input/output value totals while hashing so the device can + compute `fee = transparent_in - transparent_out + orchard_value_balance` + locally and show that fee on-device. +- Treat Sapling as unsupported. A Sapling component would be a hidden value sink + until the firmware has Sapling parsing and display, so this firmware path + rejects host-provided Sapling data and uses the ZIP-244 empty Sapling digest. +- Capture every transparent output `(value, script_pubkey)` and render standard + P2PKH/P2SH scripts as Zcash t-addresses for user review. +- For Orchard outputs, `cmx` binding is the first target: require plaintext + `(d, pk_d, value, rseed)` metadata and recompute the note commitment against + the action `cmx`. The stronger follow-up is memo binding: recompute + `enc_ciphertext` and `epk` from `(recipient, value, rseed, memo)` using + Orchard KDF + ChaCha20-Poly1305. + +Reference role: KeepKey uses the transaction-signing structure and test-vector +shape while keeping the existing firmware primitives and protocol surfaces. + +Key files reviewed: + +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/include/zip244.h +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/src/zip244.c +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/include/orchard_signer.h +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/src/orchard_signer.c +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/include/base58.h +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/SECURITY.md + +## Updated Implementation Plan + +### Phase 1: digest helpers and policy + +Status: implemented for header/transparent helpers and clear-signing policy. + +- Require component digests and Orchard metadata before signing. +- Reject the legacy host-only action `sighash` path. +- Compute the ZIP-244 root sighash on-device from component digests. +- Verify the Orchard digest from streamed action fields. +- Add pure helpers for: + - `header_digest` from plaintext header fields. + - `transparent_digest` from plaintext transparent inputs/outputs. + - per-input transparent `SIGHASH_ALL` digest. + +### Phase 2: plaintext header and transparent streaming + +Status: implemented. + +- Extend the protocol with plaintext header fields and verify + `header_digest` locally. Implemented. +- Extend transparent input messages with raw ZIP-244 digest fields: + `prevout_txid`, `prevout_index`, `sequence`, `amount`, and `script_pubkey`. +- Add transparent output streaming: + `index`, `amount`, and `script_pubkey`. +- Compute `transparent_digest` locally and compare it to the companion-provided + digest during the migration period. +- Compute per-input transparent sighashes locally before ECDSA signing, instead + of signing `ZcashTransparentInput.sighash`. +- Track transparent input and output totals, compute the fee, compare it to the + requested fee, and display the computed fee. +- Reject transactions with transparent outputs that cannot be rendered on-device + until the UI has an explicit "unknown script" review policy. + +### Phase 3: transparent output UI + +Status: implemented for standard P2PKH/P2SH scripts. + +- Add a Zcash transparent script renderer for standard P2PKH and P2SH: + - mainnet P2PKH: `t1` + - mainnet P2SH: `t3` + - testnet P2PKH: `tm` + - testnet P2SH: `t2` +- Display each transparent output address and amount on the trusted screen. +- Require every displayed transparent output and the computed fee to be + confirmed before any signature is produced. + +### Phase 4: stronger Orchard output metadata binding + +Status: implemented for recipient/value display via `cmx` binding. + +- Extend `ZcashPCZTAction` with raw Orchard output metadata: + `recipient` (`d || pk_d`), `rseed`, and explicit output value. +- Recompute Orchard note commitment `cmx` from `(d, pk_d, value, rho, rseed)`, + where `rho` is the action nullifier, and reject on mismatch. +- Display the recipient as a ZIP-316 Orchard-only UA and display the output + value. Require per-output confirmation. +- Memo display remains a separate hardening step: add ChaCha20-Poly1305 support + if it is not linked into the firmware image, then recompute `enc_ciphertext` + and `epk` for memo binding. +- Render empty/text/opaque memos on-device once memo binding is available. + +## Crypto Library Inventory + +Already available in this firmware tree: + +- BLAKE2b with personalization. +- AES-256. +- Pallas field and point arithmetic. +- Pallas SWU / group hash support. +- Sinsemilla / Orchard IVK support. +- RedPallas signing. +- ZIP-316 Orchard-only unified-address helpers. +- Zcash transparent Base58Check plus standard P2PKH/P2SH script-to-address + rendering. +- ChaCha20-Poly1305 source exists under trezor-crypto, but it is currently not + linked into the firmware crypto target. Memo binding will need that target + wiring plus Orchard note-encryption KDF glue. + +No new primitive is required for the implemented PCZT clear-signing policy. The +remaining optional hardening work is parsing and digest construction for more +transaction components: + +- Sapling parsing only if Sapling is ever intentionally added to this firmware + path; current policy is to reject it. +- Orchard memo binding/display by recomputing note encryption from + recipient/value/rseed/memo. + +## Keystone Comparison + +Keystone is a useful architecture comparison, but the audit alone is not enough +evidence. I inspected the local Keystone firmware repo: + +- Path: `/Users/highlander/keepkey/keystone3-firmware` +- Branch: `master` +- Commit: `2a48ba022ac24d3b343fa4b9e59251a5de3e1160` + +The actual Keystone signing path does derive the signing digest from PCZT data: + +- `rust/rust_c/src/zcash/mod.rs::sign_zcash_tx` extracts a `ZcashPczt` UR and + calls `app_zcash::sign_pczt`. +- `rust/apps/zcash/src/pczt/sign.rs::sign_pczt` builds a low-level PCZT signer, + then calls `pczt_ext::sign_transparent` and/or `pczt_ext::sign_orchard`. +- `rust/zcash_vendor/src/pczt_ext.rs::shielded_sig_commitment` constructs the + ZIP-244-style commitment from locally computed component digests: + `digest_header`, `transparent_sig_digest`, `digest_sapling`, and + `digest_orchard`. +- `digest_orchard` recomputes the Orchard digest from action fields: + nullifier, cmx, ephemeral key, encrypted memo/ciphertext slices, cv_net, rk, + out ciphertext, bundle flags, value balance, and anchor. +- `transparent_sig_digest` computes transparent prevouts, amounts, scripts, + sequence, outputs, and per-input data for `SIGHASH_ALL`. + +Keystone still has broader in-firmware PCZT parsing and Orchard output +decryption support. KeepKey now covers the same no-bare-sighash signing rule for +the implemented scope: header digest, transparent digest, Orchard digest, +displayed transparent outputs, displayed Orchard receiver/value metadata, and +the miner fee are all verified before signatures are released. + +Keystone also does more UI-side output parsing than our current firmware: + +- `rust/apps/zcash/src/pczt/parse.rs::parse_orchard_output` tries to decrypt + Orchard outputs with external/internal OVKs, validates decoded recipient data, + verifies a supplied `user_address` matches the decoded Orchard receiver, and + treats undecryptable non-zero Orchard outputs as invalid. +- `src/ui/gui_chain/multi/gui_zcash.c::GuiZcashOverviewTo` displays parsed + output value, address, change tag, and memo. +- The checker validates Orchard `cv_net`, value balance, nullifier/rk for owned + spends, and note commitment consistency before the UI/sign flow. + +This gives us a concrete target, not just an FYI: + +1. KeepKey branch: reject bare host sighashes, compute the final sighash from + required component digests, verify the Orchard digest from streamed action + fields, and compute header/transparent digests from streamed plaintext. +2. Output UI: transparent outputs are shown directly from verified script/value. + Orchard outputs are shown from directly supplied PCZT receiver/value/rseed + metadata that is cryptographically checked against the action `cmx`. +3. Remaining parity step: memo binding/display by recomputing Orchard note + encryption from recipient/value/rseed/memo. + +Sources: + +- Local Keystone firmware at the commit above. +- Public audit context: + https://leastauthority.com/wp-content/uploads/2025/03/Least-Authority-ZCG-Keystone-Hardware-Wallet-Final-Audit-Report.pdf + +## Tests + +The TDD coverage for this policy lives in: + +- `unittests/firmware/zcash.cpp` +- `deps/python-keepkey/tests/test_msg_zcash_sign_pczt.py` + +The firmware unit tests cover the pure policy helper, ZIP-244 digest helpers, +transparent digest/sighash helpers, Orchard receiver encoding, and Orchard note +commitment recomputation. The Python protocol tests cover the emulator-facing +behavior: legacy host-sighash requests are rejected, verified PCZT requests +sign, missing Orchard action digest fields abort signing, transparent digests +must match streamed plaintext, host transparent sighashes are rejected, and +Orchard recipient/value tampering is rejected. + +Current local verification: + +- `build/bin/zcash-crypto-unit` passes 56 tests, including: + - `ComputeHeaderDigest_FromPlaintextFields` + - `ComputeTransparentDigest_DistinctFromPerInputSighash` + - `ComputeTransparentDigest_EmptyBundle` + - `ComputeTransparentSighash_RejectsUnsupportedRequest` + - `OrchardNoteCommitment_KnownVector` + - `OrchardReceiverToUnifiedAddress_KnownVector` +- `cmake --build build --target kkfirmware` passes. +- `cmake --build build --target kkfirmware.keepkey` passes. +- `git diff --check` is clean. diff --git a/docs/zoo/reports/zcash-report.md b/docs/zoo/reports/zcash-report.md new file mode 100644 index 000000000..252df9ee9 --- /dev/null +++ b/docs/zoo/reports/zcash-report.md @@ -0,0 +1,146 @@ +# Zcash Orchard Device Screen Review + +**Firmware PR**: #95 +**Feature**: Zcash Orchard shielded transaction support (ZIP-32 / PCZT) + +--- + +## FVK Derivation + +**Path**: `[32', 133', account']` (ZIP-32, exactly 3 levels) + +The device derives the Full Viewing Key components from the master seed using ZIP-32 key derivation. The FVK is safe to export because it grants view-only access and cannot authorize spending. + +**Returned components**: +- `ak` (authorizing key, public) +- `nk` (nullifier key) +- `rivk` (randomized internal viewing key) + +**Reference FVK** (mnemonic: "all" x12): +- `ak = 057ab051...` +- `nk = 34a35a0b...` +- `rivk = 46bd2bd5...` + +### Screen Description + +The device displays the derived FVK components for user verification against a known-good reference. + +### Verification Checklist + +- [ ] FVK derivation returns ak, nk, rivk +- [ ] Path enforced to exactly 3 levels (Orchard spec) +- [ ] Reference mnemonic ("all" x12) produces expected FVK values +- [ ] Seed never leaves device (storage_getRawSeed internal only) + +--- + +## Shielded Sign (PCZT) + +Multi-phase streaming protocol for signing shielded transactions using the Partially Created Zcash Transaction format. + +### Screen Description + +``` +Sign shielded transaction? +Amount: X ZEC +Fee: Y ZEC +Actions: N +``` + +The device presents a single confirmation screen summarizing the full shielded transaction before signing begins. + +### Progress Bar + +`layoutProgress` renders a progress bar on the OLED during Orchard action streaming, giving the user visual feedback as each action is processed. + +### Verification Checklist + +- [ ] Confirmation screen shows amount, fee, and action count +- [ ] Progress bar updates during action streaming +- [ ] Device recomputes Orchard digest from streamed actions +- [ ] Transaction aborts if recomputed digest does not match host-provided digest + +--- + +## Transparent Shielding + +Per-input confirmation for transparent inputs being shielded. + +### Screen Description + +``` +Sign transparent input? +Input N: X.XXXXXXXX ZEC +``` + +Each transparent input is confirmed individually on-device before inclusion. + +### Verification Checklist + +- [ ] Each transparent input gets its own confirmation screen +- [ ] Input index (N) and amount displayed per input +- [ ] Transparent paths enforced to exactly 5 levels (BIP-44) + +--- + +## Path Enforcement + +| Path Type | Required Depth | Format | +|---------------|----------------|---------------------------------| +| Orchard | Exactly 3 | `[32', 133', account']` | +| Transparent | Exactly 5 | BIP-44 `m/44'/133'/a'/c/i` | + +Invalid path depths are rejected before any key material is derived. + +### Verification Checklist + +- [ ] Orchard paths with fewer or more than 3 levels rejected +- [ ] Transparent paths with fewer or more than 5 levels rejected + +--- + +## Digest Verification + +The device independently recomputes the Orchard digest from the streamed action data. If the locally computed digest does not match the digest provided by the host, the transaction is aborted. + +This prevents a malicious host from substituting actions after the user has confirmed the transaction summary. + +### Verification Checklist + +- [ ] Device recomputes digest from streamed actions +- [ ] Mismatch between host digest and device digest aborts signing +- [ ] No partial signatures are released on digest mismatch + +--- + +## Constraints + +| Limit | Value | +|--------------------------|-------| +| Max Orchard actions | 16 | +| Max transparent inputs | 8 | + +Transactions exceeding these limits are rejected. + +### Verification Checklist + +- [ ] Transaction with >16 Orchard actions rejected +- [ ] Transaction with >8 transparent inputs rejected + +--- + +## Security Properties + +| Property | Detail | +|---------------------------|---------------------------------------------------------------| +| Curve | Pallas / RedPallas | +| Seed access | `storage_getRawSeed()` -- seed never leaves device | +| Account path pinning | Prevents host from pivoting to a different account after FVK | +| Key derivation | ZIP-32 HMAC-based hierarchical derivation | + +### Verification Checklist + +- [ ] Seed material never appears in USB responses +- [ ] Account path pinning prevents host pivot attacks +- [ ] Pallas curve operations execute correctly on-device +- [ ] FVK export does not leak spending authority diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index 35a53251e..7d61dcf71 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -137,6 +137,15 @@ void fsm_msgSolanaSignTx(const SolanaSignTx* msg); void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg); void fsm_msgSolanaSignOffchainMessage(const SolanaSignOffchainMessage* msg); +#if ZCASH_PRIVACY +void fsm_msgZcashSignPCZT(const ZcashSignPCZT* msg); +void fsm_msgZcashPCZTAction(const ZcashPCZTAction* msg); +void fsm_msgZcashGetOrchardFVK(const ZcashGetOrchardFVK* msg); +void fsm_msgZcashTransparentOutput(const ZcashTransparentOutput* msg); +void fsm_msgZcashTransparentInput(const ZcashTransparentInput* msg); +void fsm_msgZcashDisplayAddress(const ZcashDisplayAddress* msg); +#endif + #if DEBUG_LINK // void fsm_msgDebugLinkDecision(DebugLinkDecision *msg); void fsm_msgDebugLinkGetState(DebugLinkGetState* msg); diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index 7adaf0173..3653ea7b0 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -58,6 +58,7 @@ #include "keepkey/firmware/signtx_tendermint.h" #include "keepkey/firmware/signed_metadata.h" #include "keepkey/firmware/solana.h" +#include "keepkey/firmware/zcash.h" #include "keepkey/firmware/storage.h" #include "keepkey/firmware/tendermint.h" #include "keepkey/firmware/thorchain.h" @@ -92,6 +93,7 @@ #include "messages-tron.pb.h" #include "messages-ton.pb.h" #include "messages-solana.pb.h" +#include "messages-zcash.pb.h" #include @@ -274,6 +276,7 @@ void fsm_sendFailure(FailureType code, const char* text) { void fsm_msgClearSession(ClearSession* msg) { (void)msg; + zcash_signing_abort(); session_clear(/*clear_pin=*/true); fsm_sendSuccess("Session cleared"); } @@ -298,3 +301,11 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_solana.h" /* After fsm_msg_solana.h: reuses its base58 helper and the KKSOLSC1 parser. */ #include "fsm_msg_clearsign_attestor.h" +#if ZCASH_PRIVACY +#include "fsm_msg_zcash.h" +#else +// Zcash shielded/Orchard engine compiled out. The always-on +// Initialize/ClearSession/Cancel handlers still call zcash_signing_abort(); +// with no privacy state to reset, a no-op is correct. +void zcash_signing_abort(void) {} +#endif diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 3f26fc2db..9b6e4cdeb 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -5,6 +5,7 @@ void fsm_msgInitialize(Initialize* msg) { ethereum_signing_abort(); tendermint_signAbort(); eos_signingAbort(); + zcash_signing_abort(); session_clear(false); // do not clear PIN layoutHome(); fsm_msgGetFeatures(0); diff --git a/lib/firmware/fsm_msg_zcash.h b/lib/firmware/fsm_msg_zcash.h new file mode 100644 index 000000000..9616354c8 --- /dev/null +++ b/lib/firmware/fsm_msg_zcash.h @@ -0,0 +1,1511 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2025 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 . + */ + +/* Zcash-specific headers — included here because fsm_msg_zcash.h + * is #include'd inside fsm.c, not compiled separately. */ +#include + +#include "keepkey/firmware/zcash.h" +#include "trezor/crypto/blake2b.h" +#include "trezor/crypto/pallas.h" +#include "trezor/crypto/redpallas.h" +#include "trezor/crypto/memzero.h" + +/* Precomputed empty digest constants for shielded-only transactions. + * These are BLAKE2b-256 with the respective personalizations over empty input. + * Verified against Keystone3 test vectors. */ +static const uint8_t EMPTY_TRANSPARENT_DIGEST[32] = { + 0xc3, 0x3f, 0x2e, 0x95, 0x70, 0x5f, 0xaa, 0xb3, 0x5f, 0x8d, 0x53, + 0x3f, 0xa6, 0x1e, 0x95, 0xc3, 0xb7, 0xaa, 0xba, 0x07, 0x76, 0xb8, + 0x74, 0xa9, 0xf7, 0x4f, 0xc1, 0x27, 0x84, 0x37, 0x6a, 0x59}; + +static const uint8_t EMPTY_SAPLING_DIGEST[32] = { + 0x6f, 0x2f, 0xc8, 0xf9, 0x8f, 0xea, 0xfd, 0x94, 0xe7, 0x4a, 0x0d, + 0xf4, 0xbe, 0xd7, 0x43, 0x91, 0xee, 0x0b, 0x5a, 0x69, 0x94, 0x5e, + 0x4c, 0xed, 0x8c, 0xa8, 0xa0, 0x95, 0x20, 0x6f, 0x00, 0xae}; + +#define ZCASH_MAX_ACTIONS 16 +#define ZCASH_MAX_TRANSPARENT_INPUTS 8 +#define ZCASH_MAX_TRANSPARENT_OUTPUTS 8 +#define ZCASH_MAX_TRANSPARENT_SCRIPT_PUBKEY 128 + +typedef struct { + bool received; + uint8_t prevout_txid[32]; + uint32_t prevout_index; + uint32_t sequence; + uint64_t amount; + uint8_t script_pubkey[ZCASH_MAX_TRANSPARENT_SCRIPT_PUBKEY]; + size_t script_pubkey_size; + uint32_t address_n[8]; + uint32_t address_n_count; +} ZcashTransparentInputState; + +typedef struct { + bool received; + uint64_t amount; + uint8_t script_pubkey[ZCASH_MAX_TRANSPARENT_SCRIPT_PUBKEY]; + size_t script_pubkey_size; +} ZcashTransparentOutputState; + +/* Zcash shielded signing state */ +static struct { + bool active; + uint32_t account; + uint32_t n_actions; + uint32_t current_action; + uint64_t total_amount; + uint64_t fee; + uint32_t branch_id; + ZcashOrchardKeys keys; + uint8_t header_digest[32]; + uint8_t sighash[32]; + bool transaction_v6; + bool is_ironwood; + uint8_t orchard_component_digest[32]; + uint8_t ironwood_component_digest[32]; + /* Phase 2a: on-device sighash computation */ + bool has_device_sighash; + /* Phase 2b: incremental orchard digest verification */ + bool verify_orchard_digest; + uint8_t expected_orchard_digest[32]; + BLAKE2B_CTX compact_ctx; + BLAKE2B_CTX memos_ctx; + BLAKE2B_CTX noncompact_ctx; + uint8_t orchard_flags; + int64_t orchard_value_balance; + uint8_t orchard_anchor[32]; + /* Compact signatures buffer: one 64-byte sig per real Orchard spend. + * Dummy spends are signed by the PCZT finalizer and must not be signed with + * the device's Orchard key. */ + uint8_t signatures[ZCASH_MAX_ACTIONS][64]; + uint32_t signature_count; + /* Phase 3: transparent shielding state */ + bool has_expected_transparent_digest; + uint8_t expected_transparent_digest[32]; + bool transparent_digest_verified; + uint32_t n_transparent_outputs; + uint32_t current_transparent_output; + uint32_t n_transparent_inputs; + uint32_t current_transparent_input; + ZcashTransparentOutputState + transparent_outputs[ZCASH_MAX_TRANSPARENT_OUTPUTS]; + ZcashTransparentInputState transparent_inputs[ZCASH_MAX_TRANSPARENT_INPUTS]; + /* Deferred transparent ECDSA sigs — buffered until Orchard/fee final gate */ + bool has_pending_transparent; + ZcashTransparentSigned pending_transparent; +} zcash_signing; + +/* Public API; declared in keepkey/firmware/zcash.h. */ +void zcash_signing_abort(void) { + /* Centralized cleanup: stop the trickle progress animation here so every + * abort path (Cancel, ClearSession, failures) kills it even when the caller + * does not go through layoutHome()/layout_clear_animations(). */ + layoutProgressTrickleStop(); + memzero(&zcash_signing, sizeof(zcash_signing)); +} + +static bool zcash_script_is_p2pkh(const uint8_t* script, size_t script_size) { + return script && script_size == 25 && script[0] == 0x76 && + script[1] == 0xa9 && script[2] == 0x14 && script[23] == 0x88 && + script[24] == 0xac; +} + +static bool zcash_script_is_p2sh(const uint8_t* script, size_t script_size) { + return script && script_size == 23 && script[0] == 0xa9 && + script[1] == 0x14 && script[22] == 0x87; +} + +static bool zcash_script_is_standard_transparent(const uint8_t* script, + size_t script_size) { + return zcash_script_is_p2pkh(script, script_size) || + zcash_script_is_p2sh(script, script_size); +} + +static bool zcash_transparent_script_to_address(const uint8_t* script, + size_t script_size, char* out, + size_t out_size) { + if (!script || !out || out_size == 0) return false; + + const CoinType* coin = fsm_getCoin(true, "Zcash"); + if (!coin) return false; + + uint32_t address_type; + const uint8_t* hash160; + if (zcash_script_is_p2pkh(script, script_size)) { + if (!coin->has_address_type) return false; + address_type = coin->address_type; + hash160 = script + 3; + } else if (zcash_script_is_p2sh(script, script_size)) { + if (!coin->has_address_type_p2sh) return false; + address_type = coin->address_type_p2sh; + hash160 = script + 2; + } else { + return false; + } + + uint8_t raw[4 + 20] = {0}; + size_t prefix_len = address_prefix_bytes_len(address_type); + if (prefix_len == 0 || prefix_len + 20 > sizeof(raw)) return false; + address_write_prefix_bytes(address_type, raw); + memcpy(raw + prefix_len, hash160, 20); + return base58_encode_check(raw, (int)(prefix_len + 20), HASHER_SHA2D, out, + (int)out_size) != 0; +} + +static void zcash_format_amount(uint64_t amount, char* out, size_t out_size) { + snprintf(out, out_size, "%llu.%08llu ZEC", + (unsigned long long)(amount / 100000000ULL), + (unsigned long long)(amount % 100000000ULL)); +} + +/* Determine account — require explicit account or strict ZIP-32 path + * m/32'/133'/account' (all hardened, exactly 3 elements). Shared by + * ZcashSignPCZT / ZcashGetOrchardFVK / ZcashDisplayAddress so a malformed + * host path cannot silently resolve to an unintended account. */ +static bool zcash_resolve_account(bool has_account, uint32_t account_field, + const uint32_t* address_n, + uint32_t address_n_count, + uint32_t* account_out) { + if (has_account) { + *account_out = account_field; + return true; + } + if (address_n_count == 3 && address_n[0] == (0x80000000 | 32) && + address_n[1] == (0x80000000 | 133) && (address_n[2] & 0x80000000)) { + *account_out = address_n[2] & 0x7FFFFFFF; + return true; + } + fsm_sendFailure( + FailureType_Failure_SyntaxError, + _("Require account field or ZIP-32 path m/32'/133'/account'")); + return false; +} + +/* Optional seed_fingerprint binding (ZIP-32 §6.1). If the host asserts a + * seed identity, verify it matches this device's seed before proceeding. + * Catches "wrong device" attacks where the host accidentally targets a + * different seed than the one it built the request against. */ +static bool zcash_check_seed_fingerprint(bool has_expected, + const uint8_t* expected, + size_t expected_size) { + if (!zcash_seed_fingerprint_request_valid(has_expected, expected_size)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Seed fingerprint must be 32 bytes")); + return false; + } + if (!has_expected) return true; + + uint8_t actual_fp[32]; + if (!storage_zcashSeedFingerprint(true, actual_fp)) { + fsm_sendFailure(FailureType_Failure_NotInitialized, + _("Device not initialized or seed unavailable")); + return false; + } + bool match = memcmp(actual_fp, expected, 32) == 0; + memzero(actual_fp, sizeof(actual_fp)); + if (!match) { + fsm_sendFailure(FailureType_Failure_Other, + _("Seed fingerprint mismatch — wrong device")); + return false; + } + return true; +} + +static bool zcash_verify_and_confirm_orchard_output( + const ZcashPCZTAction* msg, ZcashOrchardProgressCallback progress, + void* progress_context) { + if (!msg->has_value || !msg->has_recipient || + msg->recipient.size != ZCASH_ORCHARD_RAW_RECEIVER_SIZE || + !msg->has_rseed || msg->rseed.size != 32) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing Orchard output metadata")); + return false; + } + + uint8_t computed_cmx[32]; + bool cmx_ok = + zcash_signing.is_ironwood + ? zcash_ironwood_compute_cmx_with_progress( + msg->recipient.bytes, msg->value, msg->nullifier.bytes, + msg->rseed.bytes, computed_cmx, progress, progress_context) + : zcash_orchard_compute_cmx_with_progress( + msg->recipient.bytes, msg->value, msg->nullifier.bytes, + msg->rseed.bytes, computed_cmx, progress, progress_context); + if (!cmx_ok || memcmp(computed_cmx, msg->cmx.bytes, 32) != 0) { + memzero(computed_cmx, sizeof(computed_cmx)); + fsm_sendFailure(FailureType_Failure_Other, + _("Shielded note commitment mismatch")); + return false; + } + memzero(computed_cmx, sizeof(computed_cmx)); + + char address[ZCASH_ORCHARD_UNIFIED_ADDRESS_SIZE]; + if (!zcash_orchard_receiver_to_unified_address(msg->recipient.bytes, "u", + address, sizeof(address))) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Orchard recipient")); + return false; + } + + char amount_str[32]; + zcash_format_amount(msg->value, amount_str, sizeof(amount_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Zcash Output", + "Send shielded ZEC?\n%s\nAmount: %s", address, amount_str)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + memzero(address, sizeof(address)); + return false; + } + + memzero(address, sizeof(address)); + return true; +} + +static bool zcash_compute_verified_fee(uint64_t* fee_out) { + if (!fee_out) return false; + + int64_t net_transparent = 0; + for (uint32_t i = 0; i < zcash_signing.n_transparent_inputs; i++) { + const uint64_t amount = zcash_signing.transparent_inputs[i].amount; + if (amount > (uint64_t)INT64_MAX || + net_transparent > INT64_MAX - (int64_t)amount) { + return false; + } + net_transparent += (int64_t)amount; + } + + for (uint32_t i = 0; i < zcash_signing.n_transparent_outputs; i++) { + const uint64_t amount = zcash_signing.transparent_outputs[i].amount; + if (amount > (uint64_t)INT64_MAX || + net_transparent < INT64_MIN + (int64_t)amount) { + return false; + } + net_transparent -= (int64_t)amount; + } + + const int64_t value_balance = zcash_signing.orchard_value_balance; + if ((value_balance > 0 && net_transparent > INT64_MAX - value_balance) || + (value_balance < 0 && net_transparent < INT64_MIN - value_balance)) { + return false; + } + + const int64_t signed_fee = net_transparent + value_balance; + if (signed_fee < 0) return false; + + *fee_out = (uint64_t)signed_fee; + return true; +} + +static bool zcash_verify_and_confirm_fee(void) { + uint64_t verified_fee = 0; + if (!zcash_compute_verified_fee(&verified_fee)) { + fsm_sendFailure(FailureType_Failure_Other, _("Invalid transaction fee")); + return false; + } + + if (verified_fee != zcash_signing.fee) { + fsm_sendFailure(FailureType_Failure_Other, _("Fee mismatch")); + return false; + } + + char fee_str[32]; + zcash_format_amount(verified_fee, fee_str, sizeof(fee_str)); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Zcash Fee", + "Confirm transaction fee?\n%s", fee_str)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + return false; + } + + return true; +} + +typedef struct { + uint32_t base; + uint32_t span; + uint32_t last; +} ZcashActionProgress; + +static void zcash_action_progress(uint32_t completed, uint32_t total, + void* context) { + ZcashActionProgress* progress = (ZcashActionProgress*)context; + if (!progress || total == 0) return; + + /* completed/total is a public loop schedule: either the fixed scalar round + * count or the fixed-size PCZT note-commitment word count. It never depends + * on ask, the nonce, or a secret message bit. Update only when the visible + * permil changes to avoid redundant OLED transfers while preserving a smooth + * bar. */ + uint32_t permil = progress->base + (progress->span * completed) / total; + if (permil != progress->last) { + progress->last = permil; + layoutProgress(_("Signing Zcash"), (int)permil); + } +} + +static void zcash_send_action_ack(uint32_t next_index) { + ZcashPCZTActionAck* resp_ack = (ZcashPCZTActionAck*)msg_resp; + memset(resp_ack, 0, sizeof(ZcashPCZTActionAck)); + resp_ack->has_next_index = true; + resp_ack->next_index = next_index; + msg_write(MessageType_MessageType_ZcashPCZTActionAck, resp_ack); + + /* The device now blocks until the host generates the (slow) Orchard proof for + * this action. Ease the progress bar from the milestone already reached + * toward the one this action will complete, so the screen keeps moving + * instead of looking stuck at a frozen value. Stopped again when the action + * arrives. */ + uint32_t n = zcash_signing.n_actions; + if (n > 0) { + int base = (int)((next_index * 1000) / n); + int target = (int)(((next_index + 1) * 1000) / n); + layoutProgressTrickle(_("Signing Zcash"), base, target); + } +} + +static void zcash_send_transparent_output_ack(uint32_t next_index) { + ZcashTransparentAck* resp = (ZcashTransparentAck*)msg_resp; + memset(resp, 0, sizeof(ZcashTransparentAck)); + resp->has_next_output_index = true; + resp->next_output_index = next_index; + msg_write(MessageType_MessageType_ZcashTransparentAck, resp); +} + +static void zcash_send_transparent_input_ack(uint32_t next_index) { + ZcashTransparentAck* resp = (ZcashTransparentAck*)msg_resp; + memset(resp, 0, sizeof(ZcashTransparentAck)); + resp->has_next_input_index = true; + resp->next_input_index = next_index; + msg_write(MessageType_MessageType_ZcashTransparentAck, resp); +} + +static bool zcash_build_transparent_digest_info( + ZcashTransparentInputDigestInfo inputs[ZCASH_MAX_TRANSPARENT_INPUTS], + ZcashTransparentOutputDigestInfo outputs[ZCASH_MAX_TRANSPARENT_OUTPUTS]) { + for (uint32_t i = 0; i < zcash_signing.n_transparent_inputs; i++) { + const ZcashTransparentInputState* stored = + &zcash_signing.transparent_inputs[i]; + if (!stored->received) return false; + inputs[i].prevout_txid = stored->prevout_txid; + inputs[i].prevout_index = stored->prevout_index; + inputs[i].sequence = stored->sequence; + inputs[i].value = stored->amount; + inputs[i].script_pubkey = stored->script_pubkey; + inputs[i].script_pubkey_size = stored->script_pubkey_size; + } + + for (uint32_t i = 0; i < zcash_signing.n_transparent_outputs; i++) { + const ZcashTransparentOutputState* stored = + &zcash_signing.transparent_outputs[i]; + if (!stored->received) return false; + outputs[i].value = stored->amount; + outputs[i].script_pubkey = stored->script_pubkey; + outputs[i].script_pubkey_size = stored->script_pubkey_size; + } + + return true; +} + +static bool zcash_compute_active_sighash(const uint8_t transparent_digest[32], + uint8_t sighash[32]) { + if (zcash_signing.transaction_v6) { + return zcash_compute_v6_shielded_sighash( + zcash_signing.header_digest, transparent_digest, EMPTY_SAPLING_DIGEST, + zcash_signing.orchard_component_digest, + zcash_signing.ironwood_component_digest, zcash_signing.branch_id, + sighash); + } + return zcash_compute_shielded_sighash( + zcash_signing.header_digest, transparent_digest, EMPTY_SAPLING_DIGEST, + zcash_signing.orchard_component_digest, zcash_signing.branch_id, sighash); +} + +static bool zcash_finalize_transparent_digest(void) { + if (!zcash_signing.has_expected_transparent_digest) return false; + + ZcashTransparentInputDigestInfo inputs[ZCASH_MAX_TRANSPARENT_INPUTS] = {0}; + ZcashTransparentOutputDigestInfo outputs[ZCASH_MAX_TRANSPARENT_OUTPUTS] = {0}; + uint8_t transparent_digest[32] = {0}; + + if (!zcash_build_transparent_digest_info(inputs, outputs) || + !zcash_compute_orchard_transparent_sig_digest( + inputs, zcash_signing.n_transparent_inputs, outputs, + zcash_signing.n_transparent_outputs, transparent_digest)) { + memzero(transparent_digest, sizeof(transparent_digest)); + memzero(inputs, sizeof(inputs)); + memzero(outputs, sizeof(outputs)); + return false; + } + + if (memcmp(transparent_digest, zcash_signing.expected_transparent_digest, + 32) != 0) { + memzero(transparent_digest, sizeof(transparent_digest)); + memzero(inputs, sizeof(inputs)); + memzero(outputs, sizeof(outputs)); + return false; + } + + if (!zcash_compute_active_sighash(transparent_digest, + zcash_signing.sighash)) { + memzero(transparent_digest, sizeof(transparent_digest)); + memzero(inputs, sizeof(inputs)); + memzero(outputs, sizeof(outputs)); + return false; + } + zcash_signing.has_device_sighash = true; + zcash_signing.transparent_digest_verified = true; + + memzero(transparent_digest, sizeof(transparent_digest)); + memzero(inputs, sizeof(inputs)); + memzero(outputs, sizeof(outputs)); + return true; +} + +static bool zcash_sign_transparent_inputs(bool* cancelled) { + if (!zcash_signing.transparent_digest_verified) return false; + if (cancelled) *cancelled = false; + + bool ok = false; + ZcashTransparentInputDigestInfo inputs[ZCASH_MAX_TRANSPARENT_INPUTS] = {0}; + ZcashTransparentOutputDigestInfo outputs[ZCASH_MAX_TRANSPARENT_OUTPUTS] = {0}; + if (!zcash_build_transparent_digest_info(inputs, outputs)) goto cleanup; + + const CoinType* coin = fsm_getCoin(true, "Zcash"); + if (!coin) goto cleanup; + + memset(&zcash_signing.pending_transparent, 0, sizeof(ZcashTransparentSigned)); + zcash_signing.pending_transparent.signatures_count = + zcash_signing.n_transparent_inputs; + + for (uint32_t i = 0; i < zcash_signing.n_transparent_inputs; i++) { + const ZcashTransparentInputState* stored = + &zcash_signing.transparent_inputs[i]; + + char input_str[64]; + char amount_str[32]; + zcash_format_amount(stored->amount, amount_str, sizeof(amount_str)); + snprintf(input_str, sizeof(input_str), "Input %lu: %s", + (unsigned long)(i + 1), amount_str); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Input", + "Sign transparent input?\n%s", input_str)) { + if (cancelled) *cancelled = true; + goto cleanup; + } + + HDNode* node = fsm_getDerivedNode(coin->curve_name, stored->address_n, + stored->address_n_count, NULL); + if (!node) goto cleanup; + + /* ZIP-244/229: bind the transparent ECDSA signature to every transaction + * component, including Ironwood for transaction v6. */ + uint8_t t_sig_digest[32] = {0}; + uint8_t full_sighash[32] = {0}; + uint8_t sig[64] = {0}; + uint8_t der_sig[73] = {0}; + + bool sign_ok = zcash_compute_transparent_sighash_digest( + inputs, zcash_signing.n_transparent_inputs, outputs, + zcash_signing.n_transparent_outputs, i, 0x01, t_sig_digest); + if (sign_ok) { + sign_ok = zcash_compute_active_sighash(t_sig_digest, full_sighash); + } + sign_ok = + sign_ok && hdnode_sign_digest(node, full_sighash, sig, NULL, NULL) == 0; + + memzero(node, sizeof(*node)); + memzero(t_sig_digest, sizeof(t_sig_digest)); + memzero(full_sighash, sizeof(full_sighash)); + + if (!sign_ok) { + memzero(sig, sizeof(sig)); + goto cleanup; + } + + int der_len = ecdsa_sig_to_der(sig, der_sig); + zcash_signing.pending_transparent.signatures[i].size = der_len; + memcpy(zcash_signing.pending_transparent.signatures[i].bytes, der_sig, + der_len); + + memzero(sig, sizeof(sig)); + memzero(der_sig, sizeof(der_sig)); + } + + zcash_signing.has_pending_transparent = true; + ok = true; + +cleanup: + memzero(inputs, sizeof(inputs)); + memzero(outputs, sizeof(outputs)); + return ok; +} + +void fsm_msgZcashSignPCZT(const ZcashSignPCZT* msg) { + RESP_INIT(ZcashPCZTActionAck); + + CHECK_INITIALIZED + + CHECK_PIN + + /* Validate parameters */ + if (!msg->has_n_actions || msg->n_actions == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("No actions specified")); + layoutHome(); + return; + } + + if (msg->n_actions > ZCASH_MAX_ACTIONS) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Too many Orchard actions")); + layoutHome(); + return; + } + + uint32_t account; + if (!zcash_resolve_account(msg->has_account, msg->account, msg->address_n, + msg->address_n_count, &account)) { + layoutHome(); + return; + } + + uint32_t n_tinputs = + msg->has_n_transparent_inputs ? msg->n_transparent_inputs : 0; + if (n_tinputs > ZCASH_MAX_TRANSPARENT_INPUTS) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Too many transparent inputs")); + layoutHome(); + return; + } + + uint32_t n_toutputs = + msg->has_n_transparent_outputs ? msg->n_transparent_outputs : 0; + if (n_toutputs > ZCASH_MAX_TRANSPARENT_OUTPUTS) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Too many transparent outputs")); + layoutHome(); + return; + } + + uint32_t branch_id = msg->has_branch_id ? msg->branch_id : 0; + bool is_ironwood = + msg->has_shielded_pool && + msg->shielded_pool == ZcashShieldedPool_ZCASH_SHIELDED_POOL_IRONWOOD; + if (msg->has_shielded_pool && + msg->shielded_pool != ZcashShieldedPool_ZCASH_SHIELDED_POOL_ORCHARD && + msg->shielded_pool != ZcashShieldedPool_ZCASH_SHIELDED_POOL_IRONWOOD) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unknown shielded pool")); + layoutHome(); + return; + } + if (is_ironwood && + (!msg->has_tx_version || msg->tx_version != 6 || + !msg->has_version_group_id || msg->version_group_id != 0xD884B698 || + branch_id != 0x37A5165B)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Ironwood transaction")); + layoutHome(); + return; + } + + ZcashPCZTSigningRequestMeta signing_meta = {0}; + signing_meta.has_header_digest = msg->has_header_digest; + signing_meta.header_digest_size = msg->header_digest.size; + signing_meta.has_transparent_digest = msg->has_transparent_digest; + signing_meta.transparent_digest_size = msg->transparent_digest.size; + signing_meta.has_sapling_digest = msg->has_sapling_digest; + signing_meta.sapling_digest_size = msg->sapling_digest.size; + signing_meta.has_orchard_digest = msg->has_orchard_digest; + signing_meta.orchard_digest_size = msg->orchard_digest.size; + signing_meta.is_ironwood = is_ironwood; + signing_meta.has_ironwood_digest = msg->has_ironwood_digest; + signing_meta.ironwood_digest_size = msg->ironwood_digest.size; + signing_meta.has_orchard_flags = msg->has_orchard_flags; + signing_meta.orchard_flags = msg->orchard_flags; + signing_meta.has_orchard_value_balance = msg->has_orchard_value_balance; + signing_meta.has_orchard_anchor = msg->has_orchard_anchor; + signing_meta.orchard_anchor_size = msg->orchard_anchor.size; + signing_meta.has_header_fields = + msg->has_tx_version && msg->has_version_group_id && msg->has_branch_id && + msg->has_lock_time && msg->has_expiry_height; + signing_meta.n_transparent_inputs = n_tinputs; + signing_meta.n_transparent_outputs = n_toutputs; + + const ZcashPCZTSigningRequestStatus status = + zcash_pczt_signing_request_status(&signing_meta); + if (status != ZCASH_PCZT_SIGNING_REQUEST_OK) { + static const char* const status_msgs[] = { + [ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS] = + "Missing transaction digests", + [ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE] = + "Invalid transaction digest", + [ZCASH_PCZT_SIGNING_REQUEST_MISSING_HEADER_FIELDS] = + "Missing transaction header", + [ZCASH_PCZT_SIGNING_REQUEST_UNSUPPORTED_SAPLING_COMPONENT] = + "Sapling not supported", + [ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST] = + "Missing transparent digest", + [ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA] = + "Missing Orchard metadata", + }; + const char* status_msg = + ((size_t)status < sizeof(status_msgs) / sizeof(status_msgs[0]) && + status_msgs[status]) + ? status_msgs[status] + : "Missing Orchard metadata"; + fsm_sendFailure(FailureType_Failure_SyntaxError, _(status_msg)); + layoutHome(); + return; + } + + uint8_t header_digest[32]; + if (!zcash_compute_header_digest(msg->tx_version, msg->version_group_id, + branch_id, msg->lock_time, + msg->expiry_height, header_digest) || + memcmp(header_digest, msg->header_digest.bytes, 32) != 0) { + fsm_sendFailure(FailureType_Failure_Other, _("Header digest mismatch")); + layoutHome(); + return; + } + + /* Confirm with user */ + char amount_str[32]; + char fee_str[32]; + uint64_t total = msg->has_total_amount ? msg->total_amount : 0; + uint64_t fee = msg->has_fee ? msg->fee : 0; + + /* Format amounts (1 ZEC = 100,000,000 zatoshis) */ + zcash_format_amount(total, amount_str, sizeof(amount_str)); + zcash_format_amount(fee, fee_str, sizeof(fee_str)); + + /* Display confirmation — different text for shielded-only vs hybrid */ + if (n_tinputs > 0) { + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Zcash Shield", + "Shield transparent ZEC?\n" + "Amount: %s\nFee: %s\nInputs: %lu\nOutputs: %lu\nActions: %lu", + amount_str, fee_str, (unsigned long)n_tinputs, + (unsigned long)n_toutputs, (unsigned long)msg->n_actions)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } else if (n_toutputs > 0) { + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Zcash Shielded", + "Sign transaction with transparent outputs?\n" + "Amount: %s\nFee: %s\nOutputs: %lu\nActions: %lu", + amount_str, fee_str, (unsigned long)n_toutputs, + (unsigned long)msg->n_actions)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } else { + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Zcash Shielded", + "Sign shielded transaction?\n" + "Amount: %s\nFee: %s\nActions: %lu", + amount_str, fee_str, (unsigned long)msg->n_actions)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } + + /* Approved — show the signing screen before Orchard key derivation, which + * is seconds of Pallas math with nothing else drawing. */ + layoutProgress(_("Signing Zcash"), 0); + + if (!zcash_check_seed_fingerprint(msg->has_expected_seed_fingerprint, + msg->expected_seed_fingerprint.bytes, + msg->expected_seed_fingerprint.size)) { + layoutHome(); + return; + } + + /* Clear any stale state from a prior (possibly abandoned) session before + * starting a new one, so buffered transparent signatures or Orchard state + * from an earlier PCZT can never leak into this transaction. */ + zcash_signing_abort(); + + /* Derive Orchard keys via storage; the seed never leaves storage.c. */ + if (!storage_zcashOrchardKeys(account, true, &zcash_signing.keys)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Orchard key derivation failed")); + layoutHome(); + return; + } + + /* Initialize signing state */ + zcash_signing.active = true; + zcash_signing.account = account; + zcash_signing.n_actions = msg->n_actions; + zcash_signing.current_action = 0; + zcash_signing.total_amount = total; + zcash_signing.fee = fee; + zcash_signing.branch_id = branch_id; + zcash_signing.transaction_v6 = msg->tx_version == 6; + zcash_signing.is_ironwood = is_ironwood; + memcpy(zcash_signing.header_digest, header_digest, 32); + memcpy(zcash_signing.orchard_component_digest, msg->orchard_digest.bytes, 32); + if (is_ironwood) { + memcpy(zcash_signing.ironwood_component_digest, msg->ironwood_digest.bytes, + 32); + } + zcash_signing.has_device_sighash = false; + zcash_signing.verify_orchard_digest = false; + zcash_signing.n_transparent_outputs = + msg->has_n_transparent_outputs ? msg->n_transparent_outputs : 0; + zcash_signing.current_transparent_output = 0; + zcash_signing.n_transparent_inputs = + msg->has_n_transparent_inputs ? msg->n_transparent_inputs : 0; + zcash_signing.current_transparent_input = 0; + zcash_signing.has_expected_transparent_digest = false; + zcash_signing.transparent_digest_verified = false; + + /* Phase 2a: Compute sighash on-device from validated sub-digests. + * + * TRUST MODEL: + * + * What the device verifies: + * - Active shielded-pool digest: recomputed from streamed action data + * (Phase 2b) + * covering nullifiers, commitments, ephemeral keys, ciphertexts, + * value commitments, randomized keys, flags, value balance, anchor. + * - Orchard outputs: each displayed receiver/value is bound to cmx by + * recomputing the note commitment from recipient/value/rseed/rho before + * any authorization signature is emitted. + * - Transaction fee: computed from streamed transparent totals plus + * orchard_value_balance and compared to the requested fee before final + * user confirmation. + * - Sighash: assembled on-device from all v5 or v6 sub-digests. + * - transparent_digest: recomputed from streamed transparent outputs and + * inputs before any transparent or Orchard signature is emitted. + * - header_digest: recomputed from plaintext transaction header fields + * and compared to the supplied component digest. + * - Sapling: explicitly unsupported in this signing path. The device + * always uses the ZIP-244 empty Sapling digest and rejects any + * host-provided Sapling component. + * + * total_amount is a summary prompt. Transparent recipients, Orchard output + * recipients, Orchard output values, and the transaction fee all have their + * own verification gates before signatures are released. + * + * For shielded-only transactions (no transparent inputs): + * transparent_digest defaults to the well-known empty hash, + * so no trust assumption is needed for that component. + * + * For mixed transactions: + * transparent_digest is mandatory and verified against plaintext + * transparent metadata before local sighash derivation. */ + uint8_t t_digest[32]; + + if (n_tinputs == 0 && n_toutputs == 0) { + memcpy(t_digest, EMPTY_TRANSPARENT_DIGEST, 32); + zcash_compute_active_sighash(t_digest, zcash_signing.sighash); + zcash_signing.has_device_sighash = true; + zcash_signing.transparent_digest_verified = true; + } else { + memcpy(zcash_signing.expected_transparent_digest, + msg->transparent_digest.bytes, 32); + zcash_signing.has_expected_transparent_digest = true; + } + memzero(t_digest, sizeof(t_digest)); + + /* Phase 2b: the active shielded-pool digest is mandatory for signing. + * The device incrementally hashes each action's data and verifies the + * computed digest matches the one used for sighash. */ + memcpy(zcash_signing.expected_orchard_digest, + is_ironwood ? msg->ironwood_digest.bytes : msg->orchard_digest.bytes, + 32); + zcash_signing.orchard_flags = (uint8_t)msg->orchard_flags; + zcash_signing.orchard_value_balance = msg->orchard_value_balance; + memcpy(zcash_signing.orchard_anchor, msg->orchard_anchor.bytes, 32); + + blake2b_InitPersonal(&zcash_signing.compact_ctx, 32, + is_ironwood ? "ZTxIdIrnActCH_v6" : "ZTxIdOrcActCHash", + 16); + blake2b_InitPersonal(&zcash_signing.memos_ctx, 32, + is_ironwood ? "ZTxIdIrnActMH_v6" : "ZTxIdOrcActMHash", + 16); + blake2b_InitPersonal(&zcash_signing.noncompact_ctx, 32, + is_ironwood ? "ZTxIdIrnActNH_v6" : "ZTxIdOrcActNHash", + 16); + zcash_signing.verify_orchard_digest = true; + + /* Draw the initial static progress BEFORE requesting the first component: + * for the actions-only path zcash_send_action_ack() arms the trickle, and a + * layoutProgress() after it would clear the animation queue and freeze it. */ + layoutProgress(_("Signing Zcash"), 0); + + /* Request the first plaintext component. Transparent outputs are reviewed + * before any transparent input or Orchard signature can be emitted. */ + if (zcash_signing.n_transparent_outputs > 0) { + zcash_send_transparent_output_ack(0); + } else if (zcash_signing.n_transparent_inputs > 0) { + zcash_send_transparent_input_ack(0); + } else { + zcash_send_action_ack(0); + } +} + +void fsm_msgZcashGetOrchardFVK(const ZcashGetOrchardFVK* msg) { + RESP_INIT(ZcashOrchardFVK); + + CHECK_INITIALIZED + + CHECK_PIN + + uint32_t account; + if (!zcash_resolve_account(msg->has_account, msg->account, msg->address_n, + msg->address_n_count, &account)) { + layoutHome(); + return; + } + + if (msg->has_show_display && msg->show_display && + !confirm(ButtonRequestType_ButtonRequest_ProtectCall, + "Export Zcash View Key", + "Export Orchard viewing key for account %u?\nReveals Zcash " + "activity.", + (unsigned)account)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Cancelled")); + layoutHome(); + return; + } + + /* Derive Orchard keys via storage; the seed never leaves storage.c. */ + layoutProgress(_("Deriving Zcash"), 0); + ZcashOrchardKeys keys; + if (!storage_zcashOrchardKeys(account, true, &keys)) { + fsm_sendFailure(FailureType_Failure_NotInitialized, + _("Orchard key derivation failed (seed unavailable?)")); + layoutHome(); + return; + } + + /* Build response */ + resp->has_ak = true; + resp->ak.size = 32; + memcpy(resp->ak.bytes, keys.ak, 32); + + resp->has_nk = true; + resp->nk.size = 32; + memcpy(resp->nk.bytes, keys.nk, 32); + + resp->has_rivk = true; + resp->rivk.size = 32; + memcpy(resp->rivk.bytes, keys.rivk, 32); + + /* Seed identity (ZIP-32 §6.1). Lets the host pin this FVK to a + * specific device-seed identity for later signing/display flows. */ + uint8_t fp[32]; + if (storage_zcashSeedFingerprint(true, fp)) { + resp->has_seed_fingerprint = true; + resp->seed_fingerprint.size = 32; + memcpy(resp->seed_fingerprint.bytes, fp, 32); + memzero(fp, sizeof(fp)); + } + + /* Clean up sensitive data */ + memzero(&keys, sizeof(keys)); + + msg_write(MessageType_MessageType_ZcashOrchardFVK, resp); + layoutHome(); +} + +void fsm_msgZcashDisplayAddress(const ZcashDisplayAddress* msg) { + RESP_INIT(ZcashAddress); + + CHECK_INITIALIZED + + CHECK_PIN + + uint32_t account; + if (!zcash_resolve_account(msg->has_account, msg->account, msg->address_n, + msg->address_n_count, &account)) { + layoutHome(); + return; + } + + if (!zcash_check_seed_fingerprint(msg->has_expected_seed_fingerprint, + msg->expected_seed_fingerprint.bytes, + msg->expected_seed_fingerprint.size)) { + layoutHome(); + return; + } + + /* Derive Orchard keys via storage; the seed never leaves storage.c. */ + layoutProgress(_("Deriving Zcash"), 0); + ZcashOrchardKeys keys; + if (!storage_zcashOrchardKeys(account, true, &keys)) { + fsm_sendFailure(FailureType_Failure_NotInitialized, + _("Orchard key derivation failed (seed unavailable?)")); + layoutHome(); + return; + } + + layoutProgress(_("Deriving address"), 650); + char derived_address[sizeof(resp->address)]; + const uint8_t default_receiver_index[11] = {0}; + if (!zcash_orchard_derive_unified_address(&keys, default_receiver_index, "u", + derived_address, + sizeof(derived_address))) { + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_Other, + _("Orchard address derivation failed")); + layoutHome(); + return; + } + + /* Clean up sensitive key material BEFORE display prompt. */ + memzero(&keys, sizeof(keys)); + + layoutProgress(_("Loading address"), 1000); + + char desc[48]; + snprintf(desc, sizeof(desc), "Zcash #%lu Orchard", (unsigned long)account); + if (!confirm_zcash_address(desc, derived_address)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Address display cancelled")); + layoutHome(); + return; + } + + /* User confirmed — return the address bound to this device's seed. */ + resp->has_address = true; + strlcpy(resp->address, derived_address, sizeof(resp->address)); + + /* Seed identity (ZIP-32 §6.1) — pin the attestation to this device. */ + uint8_t fp[32]; + if (storage_zcashSeedFingerprint(true, fp)) { + resp->has_seed_fingerprint = true; + resp->seed_fingerprint.size = 32; + memcpy(resp->seed_fingerprint.bytes, fp, 32); + memzero(fp, sizeof(fp)); + } + + msg_write(MessageType_MessageType_ZcashAddress, resp); + layoutHome(); +} + +void fsm_msgZcashPCZTAction(const ZcashPCZTAction* msg) { + if (!zcash_signing.active) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Not in Zcash signing mode")); + layoutHome(); + return; + } + + /* An action arrived: stop the trickle so the exact per-action milestone (and + * the fee confirm reached at completion) draws cleanly. Re-armed by the next + * zcash_send_action_ack() if more actions remain. */ + layoutProgressTrickleStop(); + + /* Enforce transparent phase completion: if the session declared any + * transparent data, all plaintext must be streamed and verified before + * Orchard actions. + * This prevents a malicious host from skipping transparent-input + * confirmations and jumping straight to Orchard signing. */ + if (zcash_signing.current_transparent_output < + zcash_signing.n_transparent_outputs || + zcash_signing.current_transparent_input < + zcash_signing.n_transparent_inputs || + ((zcash_signing.n_transparent_outputs > 0 || + zcash_signing.n_transparent_inputs > 0) && + !zcash_signing.transparent_digest_verified)) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Transparent data not yet complete")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Validate action index */ + if (!msg->has_index || msg->index != zcash_signing.current_action) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unexpected action index")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Validate required fields */ + if (!msg->has_alpha || msg->alpha.size != 32) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing or invalid alpha randomizer")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Phase 2a: a device-computed sighash is mandatory. */ + if (!zcash_signing.has_device_sighash) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing transaction digests")); + zcash_signing_abort(); + layoutHome(); + return; + } + + const bool has_orchard_action_data = + zcash_signing.verify_orchard_digest && msg->has_is_spend && + msg->has_nullifier && msg->nullifier.size == 32 && msg->has_cmx && + msg->cmx.size == 32 && msg->has_epk && msg->epk.size == 32 && + msg->has_enc_compact && msg->enc_compact.size == 52 && + msg->has_enc_memo && msg->enc_memo.size == 512 && + msg->has_enc_noncompact && + /* 580-byte enc_ciphertext = compact(52) + memo(512) + noncompact(16); + * pin the exact size like every sibling field so a host serializer bug + * fails fast per-action instead of as an end-of-flow digest mismatch. */ + msg->enc_noncompact.size == 16 && msg->has_cv_net && + msg->cv_net.size == 32 && msg->has_rk && msg->rk.size == 32 && + msg->has_out_ciphertext && msg->out_ciphertext.size == 80; + + if (!has_orchard_action_data) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing Orchard action data")); + zcash_signing_abort(); + layoutHome(); + return; + } + + const uint32_t action_base = + (zcash_signing.current_action * 1000) / zcash_signing.n_actions; + const uint32_t action_target = + ((zcash_signing.current_action + 1) * 1000) / zcash_signing.n_actions; + const uint32_t action_span = action_target - action_base; + const uint32_t verification_target = + msg->is_spend ? action_base + action_span / 3 : action_target; + ZcashActionProgress verification_progress = { + action_base, verification_target - action_base, action_base}; + + /* The 1086-bit Orchard note commitment takes 109 public Sinsemilla rounds. + * Draw their real progress instead of freezing the prior trickle at 0%. */ + layoutProgress(_("Signing Zcash"), action_base); + if (!zcash_verify_and_confirm_orchard_output(msg, zcash_action_progress, + &verification_progress)) { + zcash_signing_abort(); + layoutHome(); + return; + } + + /* The user just approved — restore the progress screen at the verified + * milestone. Real spends continue through RedPallas below; dummy spends + * complete without an authorization signature. */ + layoutProgress(_("Signing Zcash"), verification_target); + + /* Phase 2b: feed action data into incremental BLAKE2b contexts */ + blake2b_Update(&zcash_signing.compact_ctx, msg->nullifier.bytes, 32); + blake2b_Update(&zcash_signing.compact_ctx, msg->cmx.bytes, 32); + blake2b_Update(&zcash_signing.compact_ctx, msg->epk.bytes, 32); + blake2b_Update(&zcash_signing.compact_ctx, msg->enc_compact.bytes, 52); + + blake2b_Update(&zcash_signing.memos_ctx, msg->enc_memo.bytes, 512); + + blake2b_Update(&zcash_signing.noncompact_ctx, msg->cv_net.bytes, 32); + blake2b_Update(&zcash_signing.noncompact_ctx, msg->rk.bytes, 32); + blake2b_Update(&zcash_signing.noncompact_ctx, msg->enc_noncompact.bytes, + msg->enc_noncompact.size); + blake2b_Update(&zcash_signing.noncompact_ctx, msg->out_ciphertext.bytes, 80); + + const uint8_t* sighash = zcash_signing.sighash; + + /* Orchard actions always contain a spend and an output, but the spend can be + * a dummy. finalize_io() has already signed dummy spends with their ephemeral + * key; replacing that signature with one from the device key is invalid and + * can never satisfy the action's rk. Stream and verify every action above, + * but return compact signatures only for real spends, in action order. */ + if (msg->is_spend) { + ZcashActionProgress signing_progress = {verification_target, + action_target - verification_target, + verification_target}; + if (redpallas_sign_digest_for_rk( + zcash_signing.keys.ask, msg->alpha.bytes, msg->rk.bytes, sighash, + zcash_signing.signatures[zcash_signing.signature_count], + zcash_action_progress, &signing_progress) != 0) { + fsm_sendFailure(FailureType_Failure_Other, + _("Orchard spend authorization failed")); + zcash_signing_abort(); + layoutHome(); + return; + } + zcash_signing.signature_count++; + } + + zcash_signing.current_action++; + + /* Update progress */ + uint32_t progress = + (zcash_signing.current_action * 1000) / zcash_signing.n_actions; + layoutProgress(_("Signing Zcash"), progress); + + /* Check if all actions are signed */ + if (zcash_signing.current_action >= zcash_signing.n_actions) { + /* Phase 2b: verify orchard digest before returning signatures */ + if (zcash_signing.verify_orchard_digest) { + uint8_t compact_hash[32], memos_hash[32], noncompact_hash[32]; + + blake2b_Final(&zcash_signing.compact_ctx, compact_hash, 32); + blake2b_Final(&zcash_signing.memos_ctx, memos_hash, 32); + blake2b_Final(&zcash_signing.noncompact_ctx, noncompact_hash, 32); + + /* V5 Orchard commits the anchor in the txid component. Transaction-v6 + * Ironwood moves the anchor to the authorizing-data digest (ZIP-229), + * so the device deliberately omits it here. */ + BLAKE2B_CTX orchard_ctx; + blake2b_InitPersonal( + &orchard_ctx, 32, + zcash_signing.is_ironwood ? "ZTxIdIronwd_H_v6" : "ZTxIdOrchardHash", + 16); + blake2b_Update(&orchard_ctx, compact_hash, 32); + blake2b_Update(&orchard_ctx, memos_hash, 32); + blake2b_Update(&orchard_ctx, noncompact_hash, 32); + blake2b_Update(&orchard_ctx, &zcash_signing.orchard_flags, 1); + blake2b_Update(&orchard_ctx, + (const uint8_t*)&zcash_signing.orchard_value_balance, 8); + if (!zcash_signing.transaction_v6) { + blake2b_Update(&orchard_ctx, zcash_signing.orchard_anchor, 32); + } + + uint8_t computed_orchard_digest[32]; + blake2b_Final(&orchard_ctx, computed_orchard_digest, 32); + + /* Verify computed matches expected */ + if (memcmp(computed_orchard_digest, zcash_signing.expected_orchard_digest, + 32) != 0) { + fsm_sendFailure(FailureType_Failure_Other, + _("Shielded digest mismatch: transaction data " + "does not match sighash")); + zcash_signing_abort(); + layoutHome(); + return; + } + } + + if (!zcash_verify_and_confirm_fee()) { + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Release deferred transparent ECDSA sigs at the same gate as Orchard sigs + * — both are sent only after Orchard digest verification and fee + * confirmation. */ + if (zcash_signing.has_pending_transparent) { + ZcashTransparentSigned* t_resp = (ZcashTransparentSigned*)msg_resp; + memcpy(t_resp, &zcash_signing.pending_transparent, + sizeof(ZcashTransparentSigned)); + msg_write(MessageType_MessageType_ZcashTransparentSigned, t_resp); + } + + /* All done - send the collected Orchard signatures */ + ZcashSignedPCZT* resp_signed = (ZcashSignedPCZT*)msg_resp; + memset(resp_signed, 0, sizeof(ZcashSignedPCZT)); + + resp_signed->signatures_count = zcash_signing.signature_count; + for (uint32_t i = 0; i < zcash_signing.signature_count; i++) { + resp_signed->signatures[i].size = 64; + memcpy(resp_signed->signatures[i].bytes, zcash_signing.signatures[i], 64); + } + + /* Clean up */ + zcash_signing_abort(); + + msg_write(MessageType_MessageType_ZcashSignedPCZT, resp_signed); + layoutHome(); + } else { + /* Request next action */ + zcash_send_action_ack(zcash_signing.current_action); + } +} + +void fsm_msgZcashTransparentOutput(const ZcashTransparentOutput* msg) { + if (!zcash_signing.active) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Not in Zcash signing mode")); + layoutHome(); + return; + } + + if (zcash_signing.n_transparent_outputs == 0) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("No transparent outputs expected")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (zcash_signing.current_transparent_input != 0) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Transparent outputs must come first")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (msg->index != zcash_signing.current_transparent_output) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unexpected transparent output index")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (!msg->has_amount || !msg->has_script_pubkey || + msg->script_pubkey.size == 0 || + msg->script_pubkey.size > ZCASH_MAX_TRANSPARENT_SCRIPT_PUBKEY) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid transparent output script")); + zcash_signing_abort(); + layoutHome(); + return; + } + + char address[64]; + if (!zcash_transparent_script_to_address(msg->script_pubkey.bytes, + msg->script_pubkey.size, address, + sizeof(address))) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unsupported transparent output script")); + zcash_signing_abort(); + layoutHome(); + return; + } + + char amount_str[32]; + zcash_format_amount(msg->amount, amount_str, sizeof(amount_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Zcash Output", + "Send transparent ZEC?\n%s\nAmount: %s", address, amount_str)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + zcash_signing_abort(); + layoutHome(); + return; + } + + ZcashTransparentOutputState* stored = + &zcash_signing.transparent_outputs[msg->index]; + stored->received = true; + stored->amount = msg->amount; + stored->script_pubkey_size = msg->script_pubkey.size; + memcpy(stored->script_pubkey, msg->script_pubkey.bytes, + msg->script_pubkey.size); + + zcash_signing.current_transparent_output++; + + /* Static draw before the dispatch: the actions transition below arms the + * trickle, and a layoutProgress() after it would clear and freeze it. */ + layoutProgress(_("Signing Zcash"), 0); + + if (zcash_signing.current_transparent_output < + zcash_signing.n_transparent_outputs) { + zcash_send_transparent_output_ack(zcash_signing.current_transparent_output); + } else if (zcash_signing.n_transparent_inputs > 0) { + zcash_send_transparent_input_ack(0); + } else { + if (!zcash_finalize_transparent_digest()) { + fsm_sendFailure(FailureType_Failure_Other, + _("Transparent digest mismatch")); + zcash_signing_abort(); + layoutHome(); + return; + } + zcash_send_action_ack(0); + } +} + +/* Phase 3: Transparent plaintext streaming for hybrid shielding + * transactions. The host streams all outputs first, then all inputs. Only after + * the firmware verifies transparent_digest from the streamed plaintext does it + * derive per-input ZIP-244 sighashes and emit ECDSA signatures. */ +void fsm_msgZcashTransparentInput(const ZcashTransparentInput* msg) { + if (!zcash_signing.active) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Not in Zcash signing mode")); + layoutHome(); + return; + } + + if (zcash_signing.n_transparent_inputs == 0) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("No transparent inputs expected")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (zcash_signing.current_transparent_output < + zcash_signing.n_transparent_outputs) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Transparent outputs not yet complete")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (msg->index != zcash_signing.current_transparent_input) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unexpected transparent input index")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (msg->has_sighash) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Host transparent sighash rejected")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (!msg->has_amount || !msg->has_prevout_txid || + msg->prevout_txid.size != 32 || !msg->has_prevout_index || + !msg->has_sequence || !msg->has_script_pubkey || + msg->script_pubkey.size == 0 || + msg->script_pubkey.size > ZCASH_MAX_TRANSPARENT_SCRIPT_PUBKEY) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid transparent input data")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (!zcash_script_is_standard_transparent(msg->script_pubkey.bytes, + msg->script_pubkey.size)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unsupported transparent input script")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* PATH ENFORCEMENT: transparent inputs must use exactly + * m/44'/133'/account'/change/index where: + * - account' is hardened and matches the session account + * - change is 0 (external) or 1 (internal) + * - index is unhardened + * + * This prevents a compromised host from pivoting a shielding approval + * into signing with arbitrary secp256k1 keys on the device. */ + if (msg->address_n_count != 5) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Path must be m/44'/133'/account'/change/index")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (msg->address_n[0] != (0x80000000 | 44) || + msg->address_n[1] != (0x80000000 | 133)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Path must start with m/44'/133'")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Account must be hardened and match the approved session */ + if (!(msg->address_n[2] & 0x80000000)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Account must be hardened")); + zcash_signing_abort(); + layoutHome(); + return; + } + + uint32_t path_account = msg->address_n[2] & 0x7FFFFFFF; + if (path_account != zcash_signing.account) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Account does not match approved session")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Change must be 0 (external) or 1 (internal), unhardened */ + if (msg->address_n[3] > 1) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Change must be 0 or 1")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Index must be unhardened */ + if (msg->address_n[4] & 0x80000000) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Index must not be hardened")); + zcash_signing_abort(); + layoutHome(); + return; + } + + ZcashTransparentInputState* stored = + &zcash_signing.transparent_inputs[msg->index]; + stored->received = true; + stored->amount = msg->amount; + memcpy(stored->prevout_txid, msg->prevout_txid.bytes, 32); + stored->prevout_index = msg->prevout_index; + stored->sequence = msg->sequence; + stored->script_pubkey_size = msg->script_pubkey.size; + memcpy(stored->script_pubkey, msg->script_pubkey.bytes, + msg->script_pubkey.size); + stored->address_n_count = msg->address_n_count; + memcpy(stored->address_n, msg->address_n, + msg->address_n_count * sizeof(msg->address_n[0])); + + zcash_signing.current_transparent_input++; + + if (zcash_signing.current_transparent_input < + zcash_signing.n_transparent_inputs) { + zcash_send_transparent_input_ack(zcash_signing.current_transparent_input); + layoutProgress(_("Signing Zcash"), 0); + return; + } + + if (!zcash_finalize_transparent_digest()) { + fsm_sendFailure(FailureType_Failure_Other, + _("Transparent digest mismatch")); + zcash_signing_abort(); + layoutHome(); + return; + } + + bool cancelled = false; + if (!zcash_sign_transparent_inputs(&cancelled)) { + fsm_sendFailure(cancelled ? FailureType_Failure_ActionCancelled + : FailureType_Failure_Other, + cancelled ? _("Signing cancelled") + : _("Transparent input signing failed")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Transparent ECDSA sigs are buffered in zcash_signing.pending_transparent. + * They are released at the same final gate as Orchard sigs, after Orchard + * digest verification and fee confirmation. */ + /* Static draw before arming: zcash_send_action_ack() arms the trickle, so a + * layoutProgress() after it would clear the animation queue and freeze it. */ + layoutProgress(_("Signing Zcash"), 0); + zcash_send_action_ack(0); +} diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 12b50bf18..1d057579b 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -166,6 +166,24 @@ MSG_OUT(MessageType_MessageType_SolanaMessageSignature, SolanaMessageSignature, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_SolanaOffchainMessageSignature, SolanaOffchainMessageSignature, NO_PROCESS_FUNC) + /* Zcash shielded/Orchard (privacy engine). Transparent t-address Zcash uses + the generic SignTx/GetAddress rows above and needs none of these. */ +#if ZCASH_PRIVACY + MSG_IN(MessageType_MessageType_ZcashSignPCZT, ZcashSignPCZT, fsm_msgZcashSignPCZT) + MSG_IN(MessageType_MessageType_ZcashPCZTAction, ZcashPCZTAction, fsm_msgZcashPCZTAction) + MSG_IN(MessageType_MessageType_ZcashGetOrchardFVK, ZcashGetOrchardFVK, fsm_msgZcashGetOrchardFVK) + MSG_IN(MessageType_MessageType_ZcashTransparentOutput, ZcashTransparentOutput, fsm_msgZcashTransparentOutput) + MSG_IN(MessageType_MessageType_ZcashTransparentInput, ZcashTransparentInput, fsm_msgZcashTransparentInput) + MSG_IN(MessageType_MessageType_ZcashDisplayAddress, ZcashDisplayAddress, fsm_msgZcashDisplayAddress) + + MSG_OUT(MessageType_MessageType_ZcashPCZTActionAck, ZcashPCZTActionAck, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_ZcashSignedPCZT, ZcashSignedPCZT, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_ZcashOrchardFVK, ZcashOrchardFVK, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_ZcashTransparentSigned, ZcashTransparentSigned, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_ZcashTransparentAck, ZcashTransparentAck, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_ZcashAddress, ZcashAddress, NO_PROCESS_FUNC) +#endif + #if DEBUG_LINK /* Debug Messages */ DEBUG_IN(MessageType_MessageType_DebugLinkDecision, DebugLinkDecision, NO_PROCESS_FUNC) diff --git a/tools/check_pallas_api_boundary.py b/tools/check_pallas_api_boundary.py new file mode 100644 index 000000000..db54c6d07 --- /dev/null +++ b/tools/check_pallas_api_boundary.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Enforce the RC18 split between public and secret Pallas operations.""" + +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def source(path): + return (ROOT / path).read_text(encoding="utf-8") + + +def function_body(text, name): + match = re.search(r"\b" + re.escape(name) + r"\s*\([^;]*?\)\s*\{", text, re.S) + if not match: + raise AssertionError("function not found: " + name) + start = match.end() - 1 + depth = 0 + for index in range(start, len(text)): + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + if depth == 0: + return text[start + 1:index] + raise AssertionError("unterminated function: " + name) + + +def code_only(text): + return re.sub(r"/\*.*?\*/|//[^\n]*", "", text, flags=re.S) + + +def require(body, token, where): + if token not in body: + raise AssertionError("{} must call {}".format(where, token)) + + +def forbid(body, token, where): + if token in body: + raise AssertionError("{} must not call {}".format(where, token)) + + +def main(): + pallas = source("deps/crypto/trezor-firmware/crypto/pallas.c") + sinsemilla = source("deps/crypto/trezor-firmware/crypto/pallas_sinsemilla.c") + redpallas = source("deps/crypto/trezor-firmware/crypto/redpallas.c") + zcash = source("lib/firmware/zcash.c") + zcash_fsm = source("lib/firmware/fsm_msg_zcash.h") + storage = source("lib/firmware/storage.c") + + # Public transaction data needs the fast compatibility implementation. + forbid(pallas, '"pallas_ct.h"', "pallas.c public compatibility path") + hash_to_point = code_only(function_body( + sinsemilla, "pallas_sinsemilla_hash_to_point_progress")) + require(hash_to_point, "sinsemilla_incomplete_add", + "Sinsemilla public hash path") + forbid(hash_to_point, "pallas_ct_", "Sinsemilla public hash path") + require(hash_to_point, "progress(", "Sinsemilla public hash progress") + incomplete_add = code_only(function_body(sinsemilla, + "sinsemilla_incomplete_add")) + require(incomplete_add, "pallas_point_add", "Sinsemilla public hash add") + forbid(incomplete_add, "pallas_ct_", "Sinsemilla public hash add") + + # Transaction note rcm is host-known; use the fast public path. The IVK's + # device-secret rivk has a separate helper that remains fixed-schedule. + commit = code_only(function_body( + sinsemilla, "pallas_sinsemilla_commit_progress")) + require(commit, "pallas_point_mult", "public Sinsemilla blinding") + require(commit, "pallas_point_add", "public Sinsemilla blinding") + forbid(commit, "pallas_ct_", "public Sinsemilla blinding") + require(commit, "pallas_sinsemilla_commit_prepare", + "public Sinsemilla progress propagation") + secret_commit = code_only(function_body( + sinsemilla, "pallas_sinsemilla_commit_secret_blind")) + require(secret_commit, "pallas_ct_point_mult", "secret IVK blinding") + require(secret_commit, "pallas_ct_point_add", "secret IVK blinding") + forbid(secret_commit, "pallas_point_mult(", "secret IVK blinding") + forbid(secret_commit, "pallas_point_add(", "secret IVK blinding") + commit_ivk = code_only(function_body(sinsemilla, + "pallas_sinsemilla_commit_ivk")) + require(commit_ivk, "pallas_sinsemilla_commit_secret_blind", + "IVK commitment") + forbid(commit_ivk, "pallas_sinsemilla_short_commit", "IVK commitment") + + # Authorization scalars, nonces, and randomized keys must never fall back + # to the variable-time public-data API. + spendauth = code_only(function_body(redpallas, "pallas_scalar_mult_spendauth")) + require(spendauth, "pallas_ct_point_mult", "RedPallas scalar multiplication") + forbid(spendauth, "pallas_point_mult(", "RedPallas scalar multiplication") + spendauth_progress = code_only(function_body( + redpallas, "redpallas_scalar_mult_spendauth_G_progress")) + require(spendauth_progress, "pallas_ct_point_mult_progress", + "progress-reporting RedPallas scalar multiplication") + forbid(spendauth_progress, "pallas_point_mult(", + "progress-reporting RedPallas scalar multiplication") + public_spendauth = code_only(function_body( + redpallas, "pallas_scalar_mult_spendauth_public")) + require(public_spendauth, "pallas_point_mult", + "public alpha scalar multiplication") + forbid(public_spendauth, "pallas_ct_", + "public alpha scalar multiplication") + + sign = code_only(function_body(redpallas, "redpallas_sign_digest")) + require(sign, "pallas_ct_add_mod_q", "redpallas_sign_digest") + for token in ("pallas_add_mod_q(", "pallas_mod_q(", "pallas_mul_mod_q("): + forbid(sign, token, "redpallas_sign_digest") + + sign_core = code_only(function_body(redpallas, + "redpallas_sign_with_rsk")) + for token in ("pallas_ct_add_mod_q", "pallas_ct_mod_q", + "pallas_ct_mul_mod_q", "pallas_ct_scalar_replace_zero_with_one"): + require(sign_core, token, "RedPallas signing core") + for token in ("pallas_add_mod_q(", "pallas_mod_q(", "pallas_mul_mod_q("): + forbid(sign_core, token, "RedPallas signing core") + + optimized_sign = code_only(function_body( + redpallas, "redpallas_sign_digest_with_ak")) + require(optimized_sign, "redpallas_derive_rk_from_ak", + "optimized RedPallas signing") + require(optimized_sign, "pallas_ct_add_mod_q", + "optimized RedPallas signing") + forbid(optimized_sign, "pallas_scalar_mult_spendauth_public", + "optimized RedPallas signing") + + pczt_sign = code_only(function_body( + redpallas, "redpallas_sign_digest_for_rk")) + require(pczt_sign, "pallas_ct_add_mod_q", "PCZT RedPallas signing") + require(pczt_sign, "redpallas_sign_with_rsk", "PCZT RedPallas signing") + forbid(pczt_sign, "pallas_point_mult(", "PCZT RedPallas signing") + forbid(pczt_sign, "pallas_scalar_mult_spendauth_public", + "PCZT RedPallas signing") + action_handler = code_only(function_body(zcash_fsm, + "fsm_msgZcashPCZTAction")) + require(action_handler, "msg->has_is_spend", "PCZT action handler") + require(action_handler, "if (msg->is_spend)", "PCZT action handler") + require(action_handler, "redpallas_sign_digest_for_rk", + "PCZT action handler") + require(action_handler, "signatures[zcash_signing.signature_count]", + "compact PCZT signature collection") + require(action_handler, "zcash_signing.signature_count++", + "compact PCZT signature collection") + require(action_handler, + "resp_signed->signatures_count = zcash_signing.signature_count", + "compact PCZT signature response") + forbid(action_handler, "redpallas_sign_digest_with_ak", + "PCZT action handler") + output_verification = code_only(function_body( + zcash_fsm, "zcash_verify_and_confirm_orchard_output")) + require(output_verification, "zcash_orchard_compute_cmx_with_progress", + "interactive Orchard note verification") + forbid(output_verification, "zcash_orchard_compute_cmx(", + "interactive Orchard note verification") + # Orchard V2 and Ironwood V3 share the public Sinsemilla commitment path; + # only their rcm derivation differs. Keep the expensive implementation in + # one helper, and ensure both interactive wrappers route through it. + note_commitment = code_only(function_body( + zcash, "zcash_orchard_family_compute_cmx_with_progress")) + require(note_commitment, "pallas_sinsemilla_short_commit_progress", + "Orchard-family note verification progress") + for name in ("zcash_orchard_compute_cmx_with_progress", + "zcash_ironwood_compute_cmx_with_progress"): + wrapper = code_only(function_body(zcash, name)) + require(wrapper, "zcash_orchard_family_compute_cmx_with_progress", + name) + + derive_rk = code_only(function_body(redpallas, "redpallas_derive_rk")) + require(derive_rk, "pallas_ct_add_mod_q", "redpallas_derive_rk") + + # ZIP-32 key reduction and transmission-key derivation also process + # device-secret viewing/spending material. + for name in ("to_scalar", "to_base"): + body = code_only(function_body(zcash, name)) + require(body, "pallas_ct_", name) + key_derivation = code_only(function_body( + zcash, "zcash_derive_orchard_keys_with_progress")) + require(key_derivation, "redpallas_scalar_mult_spendauth_G_progress", + "Orchard key derivation") + forbid(key_derivation, "redpallas_scalar_mult_spendauth_G(", + "Orchard key derivation") + stored_key_derivation = code_only(function_body( + storage, "storage_zcashOrchardKeys")) + require(stored_key_derivation, "zcash_derive_orchard_keys_with_progress", + "interactive Orchard key derivation") + forbid(stored_key_derivation, "zcash_derive_orchard_keys(", + "interactive Orchard key derivation") + transmission = code_only(function_body(zcash, "zcash_orchard_derive_transmission_key")) + require(transmission, "pallas_ct_point_mult", "Orchard transmission-key derivation") + forbid(transmission, "pallas_point_mult(", "Orchard transmission-key derivation") + + print("Pallas API boundary: public Sinsemilla fast path and secret CT path verified") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except AssertionError as error: + print("Pallas API boundary violation: {}".format(error), file=sys.stderr) + sys.exit(1) diff --git a/tools/check_pallas_ct_disassembly.py b/tools/check_pallas_ct_disassembly.py new file mode 100644 index 000000000..fa95a5ee1 --- /dev/null +++ b/tools/check_pallas_ct_disassembly.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Enforce the fixed-schedule shape of the ARM Pallas scalar multiplier.""" + +import argparse +import re +import subprocess +import sys +from pathlib import Path + + +SYMBOL_RE = re.compile(r"^([0-9a-fA-F]+) <([^>]+)>:$") +CONDITIONAL_BRANCHES = { + "beq", + "bne", + "bcs", + "bcc", + "bmi", + "bpl", + "bvs", + "bvc", + "bhi", + "bls", + "bge", + "blt", + "bgt", + "ble", + "cbz", + "cbnz", +} +FORBIDDEN_VARIABLE_LATENCY = {"umull", "umlal", "smull", "smlal", "udiv", "sdiv"} + + +def parse_instruction(line): + parts = [part.strip() for part in line.split("\t") if part.strip()] + if len(parts) < 3 or not parts[0].endswith(":"): + return None + try: + address = int(parts[0][:-1], 16) + except ValueError: + return None + mnemonic = parts[2].split(".", 1)[0] + operands = parts[3] if len(parts) > 3 else "" + return address, mnemonic, operands + + +def symbol_instructions(disassembly, symbol): + lines = disassembly.splitlines() + start = None + for index, line in enumerate(lines): + match = SYMBOL_RE.match(line) + if match and match.group(2) == symbol: + start = index + 1 + break + if start is None: + return None + + instructions = [] + for line in lines[start:]: + if SYMBOL_RE.match(line): + break + instruction = parse_instruction(line) + if instruction is not None: + instructions.append(instruction) + return instructions + + +def branch_target(operands): + match = re.match(r"([0-9a-fA-F]+)", operands) + return int(match.group(1), 16) if match else None + + +def verify_full(disassembly): + symbol = "pallas_ct_point_mult" + instructions = symbol_instructions(disassembly, symbol) + if instructions is None: + raise ValueError(f"missing required ARM symbol: {symbol}") + + non_canary_branches = [] + for index, instruction in enumerate(instructions): + address, mnemonic, operands = instruction + if mnemonic not in CONDITIONAL_BRANCHES: + continue + + next_instruction = ( + instructions[index + 1] if index + 1 < len(instructions) else None + ) + if ( + next_instruction is not None + and next_instruction[1] == "bl" + and "<__stack_chk_fail>" in next_instruction[2] + ): + continue + non_canary_branches.append((address, mnemonic, branch_target(operands))) + + if len(non_canary_branches) != 1: + raise ValueError( + f"{symbol} must have one fixed loop branch; found " + f"{non_canary_branches}" + ) + + branch_address, mnemonic, target = non_canary_branches[0] + if target is None or target >= branch_address: + raise ValueError(f"{symbol} loop branch is not backward: {non_canary_branches[0]}") + + required_calls = { + "ct_point_double": None, + "ct_point_add_internal": None, + "ct_point_select": None, + } + for address, instruction_mnemonic, operands in instructions: + if instruction_mnemonic != "bl": + continue + for required in required_calls: + if f"<{required}>" in operands: + required_calls[required] = address + + missing = [name for name, address in required_calls.items() if address is None] + if missing: + raise ValueError(f"{symbol} is missing fixed-round calls: {missing}") + outside_loop = [ + name + for name, address in required_calls.items() + if not target <= address < branch_address + ] + if outside_loop: + raise ValueError(f"fixed-round calls moved outside scalar loop: {outside_loop}") + + progress_symbol = "pallas_ct_point_mult_progress" + progress_instructions = symbol_instructions(disassembly, progress_symbol) + if progress_instructions is None: + raise ValueError(f"missing required ARM symbol: {progress_symbol}") + progress_branches = [] + for index, instruction in enumerate(progress_instructions): + address, instruction_mnemonic, operands = instruction + if instruction_mnemonic not in CONDITIONAL_BRANCHES: + continue + next_instruction = ( + progress_instructions[index + 1] + if index + 1 < len(progress_instructions) + else None + ) + if ( + next_instruction is not None + and next_instruction[1] == "bl" + and "<__stack_chk_fail>" in next_instruction[2] + ): + continue + progress_branches.append( + (address, instruction_mnemonic, branch_target(operands)) + ) + if len(progress_branches) != 1: + raise ValueError( + f"{progress_symbol} must have one fixed loop branch; found " + f"{progress_branches}" + ) + progress_branch_address, _, progress_target = progress_branches[0] + if progress_target is None or progress_target >= progress_branch_address: + raise ValueError( + f"{progress_symbol} loop branch is not backward: " + f"{progress_branches[0]}" + ) + progress_required_calls = { + "ct_point_double": None, + "ct_point_add_internal": None, + "ct_point_select": None, + } + for address, instruction_mnemonic, operands in progress_instructions: + if instruction_mnemonic != "bl": + continue + for required in progress_required_calls: + if f"<{required}>" in operands: + progress_required_calls[required] = address + progress_missing = [ + name for name, address in progress_required_calls.items() if address is None + ] + if progress_missing: + raise ValueError( + f"{progress_symbol} is missing fixed-round calls: {progress_missing}" + ) + progress_outside_loop = [ + name + for name, address in progress_required_calls.items() + if not progress_target <= address < progress_branch_address + ] + if progress_outside_loop: + raise ValueError( + f"{progress_symbol} fixed-round calls moved outside scalar loop: " + f"{progress_outside_loop}" + ) + + # Secret-dependent selects are written as masks. On the pinned ARM build, + # every remaining conditional branch in this module must therefore be a + # backward, fixed-bound loop (apart from stack-canary failure branches). + unexpected_branches = [] + ct_symbols = [] + for line in disassembly.splitlines(): + match = SYMBOL_RE.match(line) + if match and match.group(2).startswith(("ct_", "pallas_ct_")): + ct_symbols.append(match.group(2)) + for ct_symbol in ct_symbols: + ct_instructions = symbol_instructions(disassembly, ct_symbol) + for index, instruction in enumerate(ct_instructions): + address, instruction_mnemonic, operands = instruction + if instruction_mnemonic not in CONDITIONAL_BRANCHES: + continue + next_instruction = ( + ct_instructions[index + 1] + if index + 1 < len(ct_instructions) + else None + ) + if ( + next_instruction is not None + and next_instruction[1] == "bl" + and "<__stack_chk_fail>" in next_instruction[2] + ): + continue + ct_target = branch_target(operands) + if ct_target is None or ct_target >= address: + unexpected_branches.append((ct_symbol, instruction)) + if unexpected_branches: + raise ValueError( + "secret arithmetic contains a non-loop conditional branch: " + f"{unexpected_branches}" + ) + + current_symbol = None + forbidden_instructions = [] + forbidden_conditional_execution = [] + for line in disassembly.splitlines(): + symbol_match = SYMBOL_RE.match(line) + if symbol_match: + current_symbol = symbol_match.group(2) + continue + if current_symbol is None or not current_symbol.startswith(("ct_", "pallas_ct_")): + continue + instruction = parse_instruction(line) + if instruction is None: + continue + if instruction[1] in FORBIDDEN_VARIABLE_LATENCY: + forbidden_instructions.append((current_symbol, instruction)) + if instruction[1].startswith("it") and current_symbol != "ct_fe_to_bn": + forbidden_conditional_execution.append((current_symbol, instruction)) + if forbidden_instructions: + raise ValueError( + "secret arithmetic contains variable-latency long multiply/divide: " + f"{forbidden_instructions}" + ) + if forbidden_conditional_execution: + raise ValueError( + "secret arithmetic contains conditional execution: " + f"{forbidden_conditional_execution}" + ) + + print( + "Pallas ARM disassembly gate: PASS " + f"(regular + progress multipliers each have one backward {mnemonic} " + "loop; double/add/select all inside; " + "only fixed backward loops; no secret IT; no long multiply/divide)" + ) + + +def verify_bitcoin_only(disassembly): + forbidden = ("pallas_ct_", "redpallas_", "pallas_point_") + present = [name for name in forbidden if f"<{name}" in disassembly] + if present: + raise ValueError(f"bitcoin-only image contains privacy symbols: {present}") + print("Pallas ARM disassembly gate: PASS (privacy code absent from bitcoin-only)") + + +def main(): + parser = argparse.ArgumentParser() + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--elf", type=Path) + source.add_argument("--disassembly", type=Path) + parser.add_argument("--objdump", default="arm-none-eabi-objdump") + parser.add_argument("--variant", choices=("full", "bitcoin-only"), required=True) + args = parser.parse_args() + + if args.disassembly: + disassembly = args.disassembly.read_text(encoding="utf-8") + else: + result = subprocess.run( + [args.objdump, "-d", str(args.elf)], + check=True, + stdout=subprocess.PIPE, + universal_newlines=True, + ) + disassembly = result.stdout + + if args.variant == "full": + verify_full(disassembly) + else: + verify_bitcoin_only(disassembly) + + +if __name__ == "__main__": + try: + main() + except (OSError, subprocess.CalledProcessError, ValueError) as error: + print(f"Pallas ARM disassembly gate: FAIL: {error}", file=sys.stderr) + sys.exit(1) diff --git a/unittests/crypto/CMakeLists.txt b/unittests/crypto/CMakeLists.txt index 4f2390d70..77cec793d 100644 --- a/unittests/crypto/CMakeLists.txt +++ b/unittests/crypto/CMakeLists.txt @@ -22,3 +22,37 @@ target_link_libraries(crypto-unit kkrand trezorcrypto kktransport) + +# Orchard/Pallas engine + its unit test only exist when privacy is built in. +if(${KK_ZCASH_PRIVACY}) + # Compile the constant-time implementation a second time with test-only + # operation counters. Keeping this separate from trezorcrypto ensures the + # counters are never present in firmware or the normal emulator binary. + add_executable(pallas-ct-unit + pallas_ct.cpp + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto/pallas_ct.c + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto/memzero.c) + target_compile_definitions(pallas-ct-unit PRIVATE PALLAS_CT_TESTING) + target_include_directories(pallas-ct-unit PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_BINARY_DIR}/include + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) + target_link_libraries(pallas-ct-unit gtest_main) + + add_executable(zcash-crypto-unit + ../firmware/zcash.cpp + ${CMAKE_SOURCE_DIR}/lib/firmware/zcash.c + ${CMAKE_SOURCE_DIR}/lib/emulator/random.c) + target_include_directories(zcash-crypto-unit PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/lib/firmware + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto/ed25519-donna) + target_link_libraries(zcash-crypto-unit + gtest_main + trezorcrypto + kkrand) + if(WIN32) + target_link_libraries(zcash-crypto-unit bcrypt) + endif() +endif() diff --git a/unittests/crypto/pallas_ct.cpp b/unittests/crypto/pallas_ct.cpp new file mode 100644 index 000000000..e738c6a9b --- /dev/null +++ b/unittests/crypto/pallas_ct.cpp @@ -0,0 +1,273 @@ +extern "C" { +#include "pallas_ct.h" +} + +#include +#include +#include + +#include "gtest/gtest.h" + +namespace { + +const curve_point kPallasGenerator = { + {/* x = p - 1 */ {0x00000000, 0x09698768, 0x133e46e6, 0x0d31f812, + 0x00000224, 0x00000000, 0x00000000, 0x00000000, + 0x00400000}}, + {/* y = 2 */ {0x00000002, 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000}}, +}; + +const bignum256 kPallasPrime = {{0x00000001, 0x09698768, 0x133e46e6, 0x0d31f812, + 0x00000224, 0x00000000, 0x00000000, 0x00000000, + 0x00400000}}; + +const bignum256 kPallasOrder = {{0x00000001, 0x02375908, 0x052a3763, 0x0d31f813, + 0x00000224, 0x00000000, 0x00000000, 0x00000000, + 0x00400000}}; + +bignum256 ScalarWithBit(unsigned bit) { + bignum256 scalar = {{0}}; + scalar.val[bit / BN_BITS_PER_LIMB] = UINT32_C(1) << (bit % BN_BITS_PER_LIMB); + return scalar; +} + +bignum256 DenseScalar() { + bignum256 scalar; + for (size_t i = 0; i < BN_LIMBS - 1; ++i) { + scalar.val[i] = BN_LIMB_MASK; + } + scalar.val[BN_LIMBS - 1] = (UINT32_C(1) << 22) - 1; + return scalar; +} + +bignum256 Max256() { + bignum256 value; + for (size_t i = 0; i < BN_LIMBS - 1; ++i) { + value.val[i] = BN_LIMB_MASK; + } + value.val[BN_LIMBS - 1] = UINT32_C(0x00ffffff); + return value; +} + +bool PointsEqual(const curve_point& lhs, const curve_point& rhs) { + return std::memcmp(&lhs, &rhs, sizeof(lhs)) == 0; +} + +bool BignumsEqual(const bignum256& lhs, const bignum256& rhs) { + return std::memcmp(&lhs, &rhs, sizeof(lhs)) == 0; +} + +void ExpectCountsEqual(const pallas_ct_counts& lhs, + const pallas_ct_counts& rhs) { + EXPECT_EQ(lhs.field_add, rhs.field_add); + EXPECT_EQ(lhs.field_sub, rhs.field_sub); + EXPECT_EQ(lhs.field_mul, rhs.field_mul); + EXPECT_EQ(lhs.field_select, rhs.field_select); + EXPECT_EQ(lhs.point_add, rhs.point_add); + EXPECT_EQ(lhs.point_double, rhs.point_double); + EXPECT_EQ(lhs.scalar_round, rhs.scalar_round); +} + +pallas_ct_counts MultiplyAndCount(const bignum256& scalar, + curve_point* result) { + pallas_ct_counts counts; + pallas_ct_test_reset_counts(); + pallas_ct_point_mult(&scalar, &kPallasGenerator, result); + pallas_ct_test_get_counts(&counts); + return counts; +} + +struct ProgressCapture { + uint32_t calls = 0; + uint32_t last_completed = 0; + uint32_t last_total = 0; + bool monotonic = true; +}; + +void CaptureProgress(uint32_t completed, uint32_t total, void* context) { + auto* capture = static_cast(context); + if (completed <= capture->last_completed) capture->monotonic = false; + capture->calls++; + capture->last_completed = completed; + capture->last_total = total; +} + +TEST(PallasConstantTime, ScalarScheduleDoesNotDependOnSecretBits) { + const bignum256 zero = {{0}}; + const bignum256 one = ScalarWithBit(0); + const bignum256 low_sparse = ScalarWithBit(17); + const bignum256 high_sparse = ScalarWithBit(254); + const bignum256 dense = DenseScalar(); + const bignum256 max_256 = Max256(); + bignum256 order_plus_one = kPallasOrder; + ++order_plus_one.val[0]; + const std::array scalars = { + zero, one, low_sparse, high_sparse, + dense, max_256, kPallasOrder, order_plus_one, + }; + + curve_point result; + const pallas_ct_counts baseline = MultiplyAndCount(scalars[0], &result); + EXPECT_EQ(255u, baseline.scalar_round); + EXPECT_EQ(255u, baseline.point_add); + EXPECT_EQ(510u, baseline.point_double); + + for (size_t i = 1; i < scalars.size(); ++i) { + const pallas_ct_counts counts = MultiplyAndCount(scalars[i], &result); + ExpectCountsEqual(baseline, counts); + } +} + +TEST(PallasConstantTime, ProgressVariantMatchesAndReportsEveryFixedRound) { + const bignum256 scalar = DenseScalar(); + curve_point expected, actual; + pallas_ct_point_mult(&scalar, &kPallasGenerator, &expected); + + ProgressCapture progress; + pallas_ct_point_mult_progress(&scalar, &kPallasGenerator, &actual, + CaptureProgress, &progress); + + EXPECT_TRUE(PointsEqual(expected, actual)); + EXPECT_TRUE(progress.monotonic); + EXPECT_EQ(255u, progress.calls); + EXPECT_EQ(255u, progress.last_completed); + EXPECT_EQ(255u, progress.last_total); +} + +TEST(PallasConstantTime, ZeroAndOneScalarResultsAreCanonical) { + const bignum256 zero = {{0}}; + const bignum256 one = ScalarWithBit(0); + const curve_point identity = {{{0}}, {{0}}}; + curve_point result; + + MultiplyAndCount(zero, &result); + EXPECT_TRUE(PointsEqual(identity, result)); + + MultiplyAndCount(one, &result); + EXPECT_TRUE(PointsEqual(kPallasGenerator, result)); +} + +TEST(PallasConstantTime, PointAdditionHandlesExceptionalCases) { + const curve_point identity = {{{0}}, {{0}}}; + curve_point result; + + pallas_ct_point_add(&identity, &kPallasGenerator, &result); + EXPECT_TRUE(PointsEqual(kPallasGenerator, result)); + + pallas_ct_point_add(&kPallasGenerator, &identity, &result); + EXPECT_TRUE(PointsEqual(kPallasGenerator, result)); + + curve_point inverse = kPallasGenerator; + inverse.y.val[0] = 0x1fffffff; + inverse.y.val[1] = 0x09698767; + inverse.y.val[2] = 0x133e46e6; + inverse.y.val[3] = 0x0d31f812; + inverse.y.val[4] = 0x00000224; + inverse.y.val[8] = 0x00400000; + pallas_ct_point_add(&kPallasGenerator, &inverse, &result); + EXPECT_TRUE(PointsEqual(identity, result)); +} + +TEST(PallasConstantTime, FieldArithmeticCanonicalizesBoundaryValues) { + const bignum256 zero = {{0}}; + const bignum256 one = ScalarWithBit(0); + bignum256 prime_plus_one = kPallasPrime; + bignum256 prime_minus_one = kPallasPrime; + bignum256 prime_minus_two = kPallasPrime; + const bignum256 max_256 = Max256(); + const bignum256 max_reduced = {{0x1ffffffc, 0x03c369c7, 0x06452b4d, + 0x186a17c8, 0x1ffff992, 0x1fffffff, + 0x1fffffff, 0x1fffffff, 0x003fffff}}; + bignum256 result; + ++prime_plus_one.val[0]; + --prime_minus_one.val[0]; + prime_minus_two.val[0] = BN_LIMB_MASK; + --prime_minus_two.val[1]; + + result = kPallasPrime; + pallas_ct_mod_p(&result); + EXPECT_TRUE(BignumsEqual(zero, result)); + + result = prime_plus_one; + pallas_ct_mod_p(&result); + EXPECT_TRUE(BignumsEqual(one, result)); + + result = max_256; + pallas_ct_mod_p(&result); + EXPECT_TRUE(BignumsEqual(max_reduced, result)); + + result = prime_minus_one; + pallas_ct_mul_mod_p(&result, &prime_minus_one); + EXPECT_TRUE(BignumsEqual(one, result)); + + result = prime_minus_one; + pallas_ct_inv_mod_p(&result); + EXPECT_TRUE(BignumsEqual(prime_minus_one, result)); + + pallas_ct_add_mod_p(&prime_minus_one, &prime_minus_one, &result); + EXPECT_TRUE(BignumsEqual(prime_minus_two, result)); + + pallas_ct_sub_mod_p(&zero, &one, &result); + EXPECT_TRUE(BignumsEqual(prime_minus_one, result)); +} + +TEST(PallasConstantTime, ScalarArithmeticAndMultiplicationReduceModOrder) { + const bignum256 zero = {{0}}; + const bignum256 one = ScalarWithBit(0); + bignum256 order_plus_one = kPallasOrder; + bignum256 order_minus_one = kPallasOrder; + const bignum256 max_256 = Max256(); + const bignum256 max_reduced = {{0x1ffffffc, 0x1959f4e7, 0x108159d6, + 0x186a17c6, 0x1ffff992, 0x1fffffff, + 0x1fffffff, 0x1fffffff, 0x003fffff}}; + bignum256 result; + curve_point point; + const curve_point identity = {{{0}}, {{0}}}; + ++order_plus_one.val[0]; + --order_minus_one.val[0]; + + result = kPallasOrder; + pallas_ct_mod_q(&result); + EXPECT_TRUE(BignumsEqual(zero, result)); + + result = order_plus_one; + pallas_ct_mod_q(&result); + EXPECT_TRUE(BignumsEqual(one, result)); + + result = max_256; + pallas_ct_mod_q(&result); + EXPECT_TRUE(BignumsEqual(max_reduced, result)); + + result = order_minus_one; + pallas_ct_mul_mod_q(&result, &order_minus_one); + EXPECT_TRUE(BignumsEqual(one, result)); + + result = order_minus_one; + pallas_ct_add_mod_q(&result, &one); + EXPECT_TRUE(BignumsEqual(zero, result)); + + MultiplyAndCount(kPallasOrder, &point); + EXPECT_TRUE(PointsEqual(identity, point)); + + MultiplyAndCount(order_plus_one, &point); + EXPECT_TRUE(PointsEqual(kPallasGenerator, point)); +} + +TEST(PallasConstantTime, NonzeroScalarNormalizationIsBranchlessAndCanonical) { + bignum256 zero = {{0}}; + bignum256 one = ScalarWithBit(0); + bignum256 max_256 = Max256(); + const bignum256 expected_one = one; + const bignum256 expected_max = max_256; + + pallas_ct_scalar_replace_zero_with_one(&zero); + pallas_ct_scalar_replace_zero_with_one(&one); + pallas_ct_scalar_replace_zero_with_one(&max_256); + + EXPECT_TRUE(BignumsEqual(expected_one, zero)); + EXPECT_TRUE(BignumsEqual(expected_one, one)); + EXPECT_TRUE(BignumsEqual(expected_max, max_256)); +} + +} // namespace diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index cb01b7aa3..49122bac9 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -16,6 +16,12 @@ set(sources usb_rx.cpp u2f.cpp) +# zcash.cpp exercises the Orchard engine (lib/firmware/zcash.c), which is only +# compiled into kkfirmware when the privacy flag is on. +if(${KK_ZCASH_PRIVACY}) + list(APPEND sources zcash.cpp) +endif() + include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/lib/firmware diff --git a/unittests/firmware/zcash.cpp b/unittests/firmware/zcash.cpp new file mode 100644 index 000000000..e77b2abc8 --- /dev/null +++ b/unittests/firmware/zcash.cpp @@ -0,0 +1,1957 @@ +extern "C" { +#include "keepkey/firmware/zcash.h" +#include "trezor/crypto/bignum.h" +#include "trezor/crypto/blake2b.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/pallas.h" +#include "trezor/crypto/pallas_sinsemilla.h" +#include "trezor/crypto/pallas_swu.h" +#include "trezor/crypto/redpallas.h" +#include "trezor/crypto/zcash_zip316.h" +} + +#include "gtest/gtest.h" +#include + +/* ── Pallas curve constants ──────────────────────────────────────── */ + +/* Pallas base field prime p (LE) */ +static const uint8_t PALLAS_P_LE[32] = { + 0x01, 0x00, 0x00, 0x00, 0xed, 0x30, 0x2d, 0x99, 0x1b, 0xf9, 0x4c, + 0x09, 0xfc, 0x98, 0x46, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, +}; + +/* Pallas scalar field order q (LE) */ +static const uint8_t PALLAS_Q_LE[32] = { + 0x01, 0x00, 0x00, 0x00, 0x21, 0xeb, 0x46, 0x8c, 0xdd, 0xa8, 0x94, + 0x09, 0xfc, 0x98, 0x46, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, +}; + +/* Sinsemilla primitive vectors generated with sinsemilla 0.1.0. */ +static const uint8_t SINSEMILLA_COMMIT_IVK_Q_X[32] = { + 0xf2, 0x82, 0x0f, 0x79, 0x92, 0x2f, 0xcb, 0x6b, 0x32, 0xa2, 0x28, + 0x51, 0x24, 0xcc, 0x1b, 0x42, 0xfa, 0x41, 0xa2, 0x5a, 0xb8, 0x81, + 0xcc, 0x7d, 0x11, 0xc8, 0xa9, 0x4a, 0xf1, 0x0c, 0xbc, 0x05, +}; + +static const uint8_t SINSEMILLA_COMMIT_IVK_Q_Y[32] = { + 0xbe, 0xde, 0xad, 0xcf, 0xce, 0xe5, 0x5a, 0xbe, 0xf1, 0xa5, 0x6d, + 0xc9, 0x1d, 0x35, 0xc4, 0x46, 0x4b, 0x05, 0xde, 0x20, 0x46, 0x07, + 0x59, 0xef, 0xe6, 0xbe, 0x1a, 0xd4, 0xf6, 0x4c, 0x01, 0x1b, +}; + +static const uint8_t SINSEMILLA_COMMIT_IVK_R_X[32] = { + 0x18, 0xa1, 0xf8, 0x5f, 0x6e, 0x48, 0x23, 0x98, 0xc7, 0xed, 0x1a, + 0xd3, 0xe2, 0x7f, 0x95, 0x02, 0x48, 0x89, 0x80, 0x40, 0x0a, 0x29, + 0x34, 0x16, 0x4e, 0x13, 0x70, 0x50, 0xcd, 0x2c, 0xa2, 0x25, +}; + +static const uint8_t SINSEMILLA_COMMIT_IVK_R_Y[32] = { + 0xa9, 0xdd, 0x7f, 0xe3, 0xb3, 0x93, 0xe7, 0x3f, 0xc7, 0xa6, 0x58, + 0x1b, 0xfb, 0x42, 0x44, 0x6b, 0x94, 0x57, 0x4b, 0x28, 0xc4, 0x90, + 0xc8, 0xc2, 0xeb, 0xfa, 0xa2, 0x66, 0x99, 0xd2, 0xcf, 0x29, +}; + +static const uint8_t SINSEMILLA_MSG_ONE_BIT[1] = {0x01}; +static const uint8_t SINSEMILLA_MSG_TEN_BITS[2] = {0xa5, 0x02}; +static const uint8_t SINSEMILLA_MSG_TWENTY_THREE_BITS[3] = {0x5a, 0xc3, 0x3f}; + +static const uint8_t SINSEMILLA_ZERO_BLIND[32] = {0}; +static const uint8_t SINSEMILLA_NONZERO_BLIND[32] = { + 0x21, 0x43, 0x65, 0x87, 0xa9, 0xcb, 0xed, 0x0f, 0x10, 0x32, 0x54, + 0x76, 0x98, 0xba, 0xdc, 0xfe, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef, 0xf0, 0xde, 0xbc, 0x9a, 0x78, 0x56, 0x34, 0x12, +}; + +#define SINSEMILLA_EMPTY_HASH_POINT SINSEMILLA_COMMIT_IVK_Q_X +#define SINSEMILLA_EMPTY_HASH SINSEMILLA_COMMIT_IVK_Q_X + +static const uint8_t SINSEMILLA_ONE_BIT_HASH_POINT[32] = { + 0xa6, 0x59, 0xf2, 0xb8, 0xa8, 0x92, 0xba, 0x43, 0x86, 0xca, 0x91, + 0x01, 0x6d, 0x68, 0xa8, 0xa4, 0xd2, 0x51, 0x38, 0x55, 0xaf, 0x29, + 0x15, 0x90, 0xd8, 0x2c, 0x50, 0xb9, 0x02, 0x26, 0x94, 0xb2, +}; + +static const uint8_t SINSEMILLA_ONE_BIT_HASH[32] = { + 0xa6, 0x59, 0xf2, 0xb8, 0xa8, 0x92, 0xba, 0x43, 0x86, 0xca, 0x91, + 0x01, 0x6d, 0x68, 0xa8, 0xa4, 0xd2, 0x51, 0x38, 0x55, 0xaf, 0x29, + 0x15, 0x90, 0xd8, 0x2c, 0x50, 0xb9, 0x02, 0x26, 0x94, 0x32, +}; + +static const uint8_t SINSEMILLA_TEN_BITS_HASH_POINT[32] = { + 0x16, 0xad, 0xea, 0x6c, 0xce, 0x33, 0x1c, 0xb2, 0x5c, 0xcb, 0x62, + 0x3e, 0x55, 0x61, 0x96, 0x98, 0x2c, 0xbb, 0xa0, 0x30, 0x18, 0xd9, + 0x49, 0x53, 0x5b, 0x4a, 0x56, 0x3b, 0x05, 0x73, 0x04, 0x85, +}; + +static const uint8_t SINSEMILLA_TEN_BITS_HASH[32] = { + 0x16, 0xad, 0xea, 0x6c, 0xce, 0x33, 0x1c, 0xb2, 0x5c, 0xcb, 0x62, + 0x3e, 0x55, 0x61, 0x96, 0x98, 0x2c, 0xbb, 0xa0, 0x30, 0x18, 0xd9, + 0x49, 0x53, 0x5b, 0x4a, 0x56, 0x3b, 0x05, 0x73, 0x04, 0x05, +}; + +static const uint8_t SINSEMILLA_TWENTY_THREE_BITS_HASH_POINT[32] = { + 0x1b, 0x2f, 0x70, 0x0a, 0x30, 0xc4, 0x5a, 0x5e, 0x7f, 0x98, 0x6e, + 0x13, 0xf9, 0xe8, 0xec, 0x5e, 0x95, 0xc9, 0xb1, 0xf0, 0x77, 0x3b, + 0x76, 0x39, 0x81, 0xbb, 0x59, 0x9a, 0x2e, 0xd7, 0xab, 0xb5, +}; + +static const uint8_t SINSEMILLA_TWENTY_THREE_BITS_HASH[32] = { + 0x1b, 0x2f, 0x70, 0x0a, 0x30, 0xc4, 0x5a, 0x5e, 0x7f, 0x98, 0x6e, + 0x13, 0xf9, 0xe8, 0xec, 0x5e, 0x95, 0xc9, 0xb1, 0xf0, 0x77, 0x3b, + 0x76, 0x39, 0x81, 0xbb, 0x59, 0x9a, 0x2e, 0xd7, 0xab, 0x35, +}; + +static const uint8_t SINSEMILLA_TWENTY_THREE_BITS_COMMIT_POINT[32] = { + 0x38, 0x2f, 0xe5, 0xd4, 0x2a, 0xe2, 0x0b, 0x82, 0x21, 0x6f, 0x86, + 0xb5, 0xba, 0xd0, 0xa4, 0xce, 0x14, 0x8a, 0x5f, 0x1a, 0x8e, 0xae, + 0xc0, 0x30, 0x67, 0xae, 0xaa, 0x2c, 0x67, 0xdd, 0xc1, 0x0a, +}; + +#define SINSEMILLA_TWENTY_THREE_BITS_SHORT_COMMIT \ + SINSEMILLA_TWENTY_THREE_BITS_COMMIT_POINT + +/* F4Jumble vectors from f4jumble 0.1.1 / zcash-test-vectors. */ +static const uint8_t F4JUMBLE_48_NORMAL[48] = { + 0x5d, 0x7a, 0x8f, 0x73, 0x9a, 0x2d, 0x9e, 0x94, 0x5b, 0x0c, 0xe1, 0x52, + 0xa8, 0x04, 0x9e, 0x29, 0x4c, 0x4d, 0x6e, 0x66, 0xb1, 0x64, 0x93, 0x9d, + 0xaf, 0xfa, 0x2e, 0xf6, 0xee, 0x69, 0x21, 0x48, 0x1c, 0xdd, 0x86, 0xb3, + 0xcc, 0x43, 0x18, 0xd9, 0x61, 0x4f, 0xc8, 0x20, 0x90, 0x5d, 0x04, 0x2b, +}; + +static const uint8_t F4JUMBLE_48_JUMBLED[48] = { + 0x03, 0x04, 0xd0, 0x29, 0x14, 0x1b, 0x99, 0x5d, 0xa5, 0x38, 0x7c, 0x12, + 0x59, 0x70, 0x67, 0x35, 0x04, 0xd6, 0xc7, 0x64, 0xd9, 0x1e, 0xa6, 0xc0, + 0x82, 0x12, 0x37, 0x70, 0xc7, 0x13, 0x9c, 0xcd, 0x88, 0xee, 0x27, 0x36, + 0x8c, 0xd0, 0xc0, 0x92, 0x1a, 0x04, 0x44, 0xc8, 0xe5, 0x85, 0x8d, 0x22, +}; + +static const uint8_t F4JUMBLE_64_NORMAL[64] = { + 0xb1, 0xef, 0x9c, 0xa3, 0xf2, 0x49, 0x88, 0xc7, 0xb3, 0x53, 0x42, + 0x01, 0xcf, 0xb1, 0xcd, 0x8d, 0xbf, 0x69, 0xb8, 0x25, 0x0c, 0x18, + 0xef, 0x41, 0x29, 0x4c, 0xa9, 0x79, 0x93, 0xdb, 0x54, 0x6c, 0x1f, + 0xe0, 0x1f, 0x7e, 0x9c, 0x8e, 0x36, 0xd6, 0xa5, 0xe2, 0x9d, 0x4e, + 0x30, 0xa7, 0x35, 0x94, 0xbf, 0x50, 0x98, 0x42, 0x1c, 0x69, 0x37, + 0x8a, 0xf1, 0xe4, 0x0f, 0x64, 0xe1, 0x25, 0x94, 0x6f, +}; + +static const uint8_t F4JUMBLE_64_JUMBLED[64] = { + 0x52, 0x71, 0xfa, 0x33, 0x21, 0xf3, 0xad, 0xbc, 0xfb, 0x07, 0x51, + 0x96, 0x88, 0x3d, 0x54, 0x2b, 0x43, 0x8e, 0xc6, 0x33, 0x91, 0x76, + 0x53, 0x7d, 0xaf, 0x85, 0x98, 0x41, 0xfe, 0x6a, 0x56, 0x22, 0x2b, + 0xff, 0x76, 0xd1, 0x66, 0x2b, 0x55, 0x09, 0xa9, 0xe1, 0x07, 0x9e, + 0x44, 0x6e, 0xee, 0xdd, 0x2e, 0x68, 0x3c, 0x31, 0xaa, 0xe3, 0xee, + 0x18, 0x51, 0xd7, 0x95, 0x43, 0x28, 0x52, 0x6b, 0xe1, +}; + +/* Compare two 32-byte LE values: return -1 if a < b, 0 if equal, 1 if a > b */ +static int cmp_le256(const uint8_t a[32], const uint8_t b[32]) { + for (int i = 31; i >= 0; i--) { + if (a[i] < b[i]) return -1; + if (a[i] > b[i]) return 1; + } + return 0; +} + +/* ── Reference Test Vectors ──────────────────────────────────────── */ + +/* + * Mnemonic: "all all all all all all all all all all all all" + * BIP-39 seed (PBKDF2, no passphrase), 64 bytes: + */ +static const uint8_t SEED_ALL[64] = { + 0xc7, 0x6c, 0x4a, 0xc4, 0xf4, 0xe4, 0xa0, 0x0d, 0x6b, 0x27, 0x4d, + 0x5c, 0x39, 0xc7, 0x00, 0xbb, 0x4a, 0x7d, 0xdc, 0x04, 0xfb, 0xc6, + 0xf7, 0x8e, 0x85, 0xca, 0x75, 0x00, 0x7b, 0x5b, 0x49, 0x5f, 0x74, + 0xa9, 0x04, 0x3e, 0xeb, 0x77, 0xbd, 0xd5, 0x3a, 0xa6, 0xfc, 0x3a, + 0x0e, 0x31, 0x46, 0x22, 0x70, 0x31, 0x6f, 0xa0, 0x4b, 0x8c, 0x19, + 0x11, 0x4c, 0x87, 0x98, 0x70, 0x6c, 0xd0, 0x2a, 0xc8, +}; + +/* + * Expected FVK for "all" mnemonic, account 0. + * Generated by the orchard Rust crate (authoritative ZIP-32). + */ +static const uint8_t EXPECTED_AK_ALL_0[32] = { + 0x05, 0x7a, 0xb0, 0x51, 0xd4, 0xfb, 0xb0, 0x20, 0x5d, 0x28, 0x64, + 0x8b, 0xac, 0xbc, 0x64, 0x71, 0xb5, 0x33, 0x47, 0x6c, 0x27, 0xbe, + 0xca, 0x33, 0xe5, 0xb9, 0xf5, 0x11, 0xd8, 0x55, 0x67, 0x2b, +}; + +static const uint8_t EXPECTED_NK_ALL_0[32] = { + 0x34, 0xa3, 0x5a, 0x0b, 0xda, 0x50, 0x27, 0x3b, 0x03, 0x19, 0xaf, + 0xa7, 0xa7, 0x0f, 0x86, 0xb6, 0xb1, 0x62, 0xeb, 0x31, 0x1d, 0x26, + 0x3d, 0x8f, 0x63, 0x21, 0xde, 0xf0, 0x02, 0x28, 0xba, 0x25, +}; + +static const uint8_t EXPECTED_RIVK_ALL_0[32] = { + 0x46, 0xbd, 0x2b, 0xd5, 0xe6, 0xec, 0xa5, 0xef, 0x03, 0xe1, 0x8c, + 0xd7, 0x65, 0x95, 0x51, 0x9e, 0xa9, 0x67, 0x06, 0xc5, 0x82, 0x6a, + 0x93, 0xba, 0x4d, 0xca, 0x94, 0x7d, 0x71, 0x1a, 0x7c, 0x0a, +}; + +static const uint8_t EXPECTED_IVK_ALL_0[32] = { + 0xa8, 0xe2, 0xea, 0x36, 0x48, 0x8b, 0x9e, 0xb4, 0x61, 0x47, 0x60, + 0x5b, 0xa1, 0x50, 0x40, 0x37, 0xd0, 0x88, 0x1e, 0x98, 0x1b, 0x6e, + 0x58, 0x47, 0xb9, 0xf5, 0xc1, 0xbe, 0xb5, 0xd0, 0x43, 0x35, +}; + +static const uint8_t EXPECTED_DK_ALL_0[32] = { + 0xe8, 0x52, 0xed, 0xd7, 0x82, 0xd6, 0xeb, 0x92, 0x12, 0x82, 0x21, + 0x9b, 0x8a, 0x9c, 0x38, 0x0e, 0x03, 0xfc, 0xc4, 0x76, 0x60, 0xfe, + 0x67, 0xaf, 0x1b, 0xa4, 0x77, 0x80, 0x2b, 0xb0, 0x6c, 0xe7, +}; + +static const uint8_t EXPECTED_DIVERSIFIER_ALL_0[11] = { + 0xda, 0x97, 0x30, 0x31, 0x63, 0x4a, 0x89, 0x38, 0xad, 0x1c, 0x48, +}; + +/* FF1-AES256 Orchard diversifier vectors generated with zcash-test-vectors. + * Parameters: radix = 2, n = 88, tweak = "", rounds = 10. + * Inputs and outputs are LEBS2OSP_88 byte encodings. + */ +struct OrchardFf1Vector { + uint8_t dk[32]; + uint8_t index[11]; + uint8_t diversifier[11]; +}; + +static const OrchardFf1Vector ORCHARD_FF1_VECTORS[] = { + {{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xdc, 0xe7, 0x7e, 0xbc, 0xec, 0x0a, 0x26, 0xaf, 0xd6, 0x99, 0x8c}}, + {{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x63, 0x73, 0x8a, 0xa5, 0xf7, 0xbe, 0x22, 0xe1, 0xac, 0xdc, 0x0b}}, + {{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, + 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, + 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xd7, 0x39, 0xcc, 0xc2, 0xb8, 0x4d, 0x5d, 0x1a, 0xe5, 0x4a, 0x95}}, + {{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, + 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, + 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}, + {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a}, + {0xc8, 0xff, 0x0b, 0x01, 0x96, 0x01, 0x30, 0x12, 0x76, 0x38, 0xc7}}, +}; + +static const uint8_t XMD_ABC_96[96] = { + 0x48, 0x50, 0x5e, 0x62, 0xfe, 0x0c, 0xe6, 0x64, 0xb6, 0x80, 0xf1, 0xf9, + 0xe6, 0x37, 0x43, 0x91, 0xa6, 0x09, 0x57, 0x5e, 0x53, 0x5c, 0xfd, 0x55, + 0xea, 0xd4, 0x49, 0xa4, 0x18, 0x43, 0xc7, 0x0d, 0x65, 0x3a, 0x08, 0x5d, + 0x09, 0xb1, 0x9f, 0x3f, 0x8d, 0x4d, 0x0a, 0xe4, 0x4f, 0x6a, 0xcf, 0x48, + 0xca, 0xfd, 0xb2, 0x8b, 0x8e, 0xea, 0x01, 0xe3, 0x6a, 0xf4, 0xf5, 0xfc, + 0xda, 0xcc, 0xf1, 0x45, 0x2a, 0x87, 0xc0, 0x8c, 0xc1, 0x0c, 0x9a, 0x03, + 0x7f, 0x3f, 0x03, 0x69, 0xf6, 0xb0, 0x43, 0xfb, 0xfc, 0x59, 0x81, 0xb6, + 0x0d, 0x50, 0xd7, 0xbd, 0x00, 0x4a, 0x59, 0x71, 0x3b, 0x1e, 0xcc, 0x25, +}; + +static const uint8_t SWU_0_X_LE[32] = { + 0x6e, 0x09, 0x9b, 0x51, 0x33, 0x34, 0xca, 0x85, 0xf4, 0x27, 0xa7, + 0xde, 0x25, 0x25, 0xf4, 0xf5, 0x8a, 0x9a, 0x12, 0x39, 0xb3, 0x95, + 0x52, 0xe2, 0x52, 0x6c, 0xf5, 0x34, 0xa5, 0xa6, 0xc1, 0x28, +}; + +static const uint8_t SWU_0_Y_LE[32] = { + 0x8d, 0xae, 0xc5, 0x6a, 0xee, 0xa1, 0x4f, 0x08, 0xc7, 0xb7, 0x07, + 0x02, 0x27, 0x9c, 0xd2, 0x15, 0xd3, 0x3f, 0x08, 0x27, 0x09, 0x7f, + 0x7d, 0x3c, 0xc6, 0x53, 0x66, 0xee, 0x8b, 0x65, 0xfc, 0x3b, +}; + +static const uint8_t SWU_0_Z_LE[32] = { + 0x36, 0xef, 0xcd, 0xd8, 0x0c, 0x25, 0x5f, 0x8a, 0x6f, 0x74, 0x7d, + 0xda, 0x72, 0x54, 0x11, 0x5d, 0x9d, 0xa1, 0x34, 0x85, 0x31, 0xb1, + 0x57, 0x41, 0x10, 0xdc, 0x16, 0x04, 0xa1, 0x3b, 0x4b, 0x05, +}; + +static const uint8_t SWU_1_X_LE[32] = { + 0x05, 0x15, 0x56, 0xa3, 0xa5, 0xb9, 0x13, 0x79, 0x83, 0x80, 0x82, + 0x06, 0x71, 0xb0, 0x64, 0x6d, 0x85, 0xa1, 0x26, 0xc0, 0x67, 0xe9, + 0xf5, 0x4a, 0x53, 0x76, 0xe8, 0x57, 0x59, 0xba, 0x0c, 0x01, +}; + +static const uint8_t SWU_1_Y_LE[32] = { + 0x81, 0x9c, 0xcc, 0x5d, 0x51, 0x6d, 0xfa, 0x76, 0xe9, 0x78, 0x80, + 0xb0, 0xd6, 0x14, 0x75, 0x54, 0x6a, 0xf4, 0xeb, 0x65, 0xa0, 0x65, + 0x6e, 0x7d, 0x8e, 0x11, 0xd3, 0x9c, 0x1f, 0xc6, 0x2f, 0x06, +}; + +static const uint8_t SWU_1_Z_LE[32] = { + 0x88, 0x36, 0xa7, 0x29, 0x9a, 0xbc, 0x75, 0x7c, 0x3a, 0x75, 0xe1, + 0x3d, 0x62, 0xf5, 0xcf, 0x5c, 0x60, 0x93, 0x77, 0x3e, 0x52, 0x4e, + 0x1c, 0x10, 0xc3, 0x50, 0x12, 0x31, 0x8c, 0xcb, 0x86, 0x3f, +}; + +static const uint8_t HASH_ZCASH_TEST_TRANS_RIGHTS[32] = { + 0xd3, 0x6b, 0x0b, 0x64, 0x9b, 0x5c, 0x69, 0x36, 0x02, 0x7a, 0x18, + 0x0f, 0x7d, 0x25, 0x40, 0x23, 0x95, 0x6f, 0xc2, 0x88, 0x3d, 0xdf, + 0x23, 0xff, 0xc3, 0xc8, 0xfd, 0x1f, 0xa3, 0xcd, 0x18, 0x18, +}; + +static const uint8_t ORCHARD_GD_EMPTY[32] = { + 0x3f, 0x90, 0xd3, 0xe5, 0x80, 0xd5, 0x6a, 0x66, 0x2b, 0x27, 0x36, + 0x91, 0xd8, 0xd1, 0xe3, 0x34, 0x75, 0x30, 0x83, 0xe9, 0xbf, 0x4c, + 0x17, 0x2e, 0x7d, 0xae, 0xfc, 0x0f, 0x06, 0x08, 0xcf, 0x97, +}; + +static const uint8_t ORCHARD_GD_ALL_ACCOUNT0_J0[32] = { + 0x26, 0x8e, 0xd9, 0xf9, 0x01, 0xfd, 0xb4, 0xe9, 0xb3, 0xf0, 0x70, + 0xd9, 0x5f, 0x1b, 0x8d, 0x98, 0x35, 0x3c, 0xb8, 0xa2, 0x02, 0xac, + 0x1c, 0x97, 0xbd, 0xb1, 0x26, 0x9f, 0x85, 0x93, 0xd6, 0x30, +}; + +static const uint8_t ORCHARD_GD_FF1_ZERO_ZERO[32] = { + 0xa4, 0x58, 0x99, 0x84, 0x3c, 0xde, 0x1f, 0xaf, 0x52, 0x42, 0x6e, + 0x27, 0xd4, 0x17, 0x96, 0xb5, 0x2a, 0xaf, 0x39, 0xf1, 0x47, 0x9c, + 0xe0, 0x69, 0xd7, 0xa9, 0xda, 0x4e, 0xef, 0xc3, 0xf8, 0x3d, +}; + +/* Orchard ivk/d/g_d/pk_d vectors generated with orchard 0.12.0. */ +struct OrchardReceiverVector { + uint8_t ivk[32]; + uint8_t diversifier[11]; + uint8_t gd[32]; + uint8_t pkd[32]; +}; + +static const OrchardReceiverVector ORCHARD_RECEIVER_VECTORS[] = { + {{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xd8, 0xe1, 0x01, 0x7d, 0x45, 0x32, 0xab, 0x65, 0xe0, 0xe5, 0x38}, + {0x7d, 0x70, 0x35, 0xca, 0x4a, 0x40, 0x9d, 0xe0, 0x65, 0x40, 0xdf, + 0xd1, 0x6e, 0x8c, 0x2d, 0xd9, 0xa9, 0x34, 0xee, 0x17, 0xfa, 0xfb, + 0x8e, 0xd0, 0xd7, 0x85, 0x6d, 0x16, 0x1c, 0x9a, 0x02, 0x2b}, + {0x7d, 0x70, 0x35, 0xca, 0x4a, 0x40, 0x9d, 0xe0, 0x65, 0x40, 0xdf, + 0xd1, 0x6e, 0x8c, 0x2d, 0xd9, 0xa9, 0x34, 0xee, 0x17, 0xfa, 0xfb, + 0x8e, 0xd0, 0xd7, 0x85, 0x6d, 0x16, 0x1c, 0x9a, 0x02, 0x2b}}, + {{0x42, 0x7a, 0x1d, 0xb3, 0x94, 0x6f, 0x20, 0xe5, 0x88, 0x30, 0xc2, + 0x91, 0x76, 0x11, 0x5d, 0x04, 0xf8, 0xbc, 0x9a, 0x21, 0x0e, 0x73, + 0xd5, 0x4c, 0x06, 0x9b, 0xa8, 0x17, 0x2e, 0x45, 0x00, 0x10}, + {0xe3, 0x63, 0x1b, 0x5e, 0xdd, 0x66, 0x95, 0xf0, 0xf0, 0x0d, 0x8d}, + {0xe7, 0xb6, 0x5d, 0xda, 0x4b, 0xc5, 0x39, 0xc0, 0xf4, 0x0c, 0x6a, + 0xdf, 0xaa, 0x41, 0xaa, 0x11, 0xd2, 0xf5, 0x27, 0xc8, 0x8a, 0xd0, + 0x10, 0xec, 0xb5, 0xe3, 0x8c, 0xbe, 0x38, 0x18, 0xdd, 0x31}, + {0x36, 0xc5, 0x49, 0x3f, 0x2b, 0x53, 0xaf, 0x23, 0x7b, 0x86, 0x5a, + 0xe1, 0x17, 0xc3, 0x05, 0x14, 0x8b, 0x78, 0xb2, 0x10, 0x84, 0x7c, + 0x86, 0xa5, 0xce, 0x24, 0xfa, 0x12, 0xa9, 0x1f, 0xf5, 0x87}}, + {{0xfe, 0xff, 0xff, 0xff, 0x38, 0x6d, 0x78, 0x34, 0xad, 0x14, 0x19, + 0xe4, 0x0b, 0x35, 0x2c, 0x99, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f}, + {0x65, 0x92, 0x89, 0x70, 0xbe, 0x78, 0x36, 0x96, 0xe0, 0x2f, 0xd1}, + {0xc9, 0xb4, 0xb5, 0x0a, 0x61, 0x9d, 0xc3, 0x4c, 0x60, 0xd4, 0xa8, + 0x30, 0x0d, 0x56, 0x60, 0x12, 0x77, 0xd7, 0x02, 0xa7, 0x5e, 0xb5, + 0xcf, 0xe1, 0x77, 0x22, 0xa7, 0x1d, 0xb7, 0x3f, 0x36, 0x32}, + {0x17, 0xcb, 0x58, 0x55, 0x9a, 0xf4, 0xd2, 0xcc, 0x6e, 0x1f, 0x24, + 0xa7, 0xe5, 0xab, 0x4c, 0x83, 0x33, 0x3c, 0x25, 0x16, 0xd3, 0x64, + 0x00, 0x6f, 0x9c, 0xee, 0x24, 0x70, 0x3c, 0xe4, 0xfc, 0xba}}, +}; + +/* Orchard ak/nk/rivk -> ivk vectors generated with orchard 0.12.0. */ +struct OrchardIvkVector { + uint8_t ak[32]; + uint8_t nk[32]; + uint8_t rivk[32]; + uint8_t ivk[32]; +}; + +static const OrchardIvkVector ORCHARD_IVK_VECTORS[] = { + {{0x87, 0x77, 0xe2, 0x15, 0x10, 0x1d, 0xf4, 0x5a, 0xa4, 0x68, 0xbb, + 0x10, 0xb2, 0xf9, 0x3f, 0xfe, 0x08, 0xa2, 0xf7, 0x9e, 0xbf, 0xf0, + 0x95, 0xaa, 0xeb, 0x74, 0x73, 0xc7, 0x71, 0x34, 0x96, 0x21}, + {0xbb, 0xca, 0x15, 0x2c, 0xfb, 0xf9, 0x81, 0x18, 0x19, 0xcc, 0x62, + 0x44, 0x34, 0xd1, 0x23, 0x75, 0x77, 0xc1, 0x38, 0x05, 0xcc, 0x3d, + 0xed, 0x44, 0x4e, 0x75, 0x5a, 0x6b, 0x78, 0xfa, 0xcd, 0x16}, + {0x8c, 0xa7, 0xfb, 0xba, 0x26, 0x47, 0x0f, 0xea, 0x0b, 0x10, 0xd3, + 0x0d, 0xb2, 0x73, 0x66, 0xec, 0x65, 0x04, 0x0c, 0x72, 0xa0, 0x9a, + 0xd8, 0x42, 0x58, 0x88, 0xef, 0x26, 0xf1, 0xc0, 0x79, 0x3f}, + {0xa1, 0xf8, 0x75, 0x87, 0x29, 0x73, 0xea, 0x49, 0x2d, 0xe3, 0xbe, + 0x5c, 0xce, 0xcf, 0xe5, 0x56, 0x79, 0x10, 0x24, 0x4c, 0xb6, 0x02, + 0x99, 0x4c, 0x58, 0x00, 0xf6, 0x8c, 0x64, 0x38, 0xb9, 0x1b}}, + {{0x6e, 0xbb, 0x83, 0x3c, 0x1d, 0x2f, 0x84, 0x33, 0x08, 0x0a, 0xbc, + 0xea, 0xbe, 0x47, 0x90, 0x60, 0x97, 0xf9, 0x06, 0x78, 0xd6, 0x03, + 0xf5, 0x77, 0xd0, 0x48, 0x6c, 0x91, 0x11, 0x73, 0x7b, 0x07}, + {0xf2, 0x26, 0xa3, 0xf8, 0x79, 0xeb, 0xe2, 0x1a, 0xbf, 0xaf, 0xcc, + 0xb6, 0xc5, 0x21, 0xca, 0x74, 0x9e, 0x63, 0xac, 0x17, 0xfd, 0x2c, + 0xd1, 0x78, 0x70, 0xaa, 0x72, 0xde, 0x12, 0xd8, 0x33, 0x0d}, + {0x04, 0x7c, 0x00, 0xab, 0x5e, 0x0f, 0xec, 0xa6, 0x1a, 0x46, 0x18, + 0x58, 0xbb, 0x0b, 0x15, 0xd5, 0x5f, 0x29, 0x76, 0x3a, 0x0a, 0x28, + 0x28, 0x25, 0xac, 0xeb, 0xd5, 0x86, 0x98, 0x93, 0x7d, 0x24}, + {0xa1, 0x75, 0x8f, 0x83, 0xad, 0xbd, 0x24, 0x89, 0x87, 0xc3, 0x6b, + 0xbf, 0x52, 0x41, 0xc1, 0x29, 0x9e, 0xfa, 0x96, 0xf2, 0x4c, 0x8c, + 0xfb, 0xb5, 0x51, 0x17, 0x23, 0x90, 0x9c, 0xc1, 0xe2, 0x02}}, + {{0xa4, 0x1c, 0xc0, 0xc3, 0x80, 0x0f, 0xf8, 0x9a, 0x88, 0xd7, 0xae, + 0x02, 0xff, 0x33, 0x6f, 0xdb, 0xd5, 0xbc, 0xe8, 0x9d, 0x9e, 0x8d, + 0xd4, 0xeb, 0x27, 0x8b, 0x4c, 0xd5, 0xc3, 0x7e, 0xc7, 0x20}, + {0x41, 0x5e, 0x75, 0x22, 0x27, 0xcb, 0x69, 0x65, 0x2e, 0x2a, 0xfa, + 0x94, 0x81, 0x6f, 0x63, 0x0d, 0xce, 0xc1, 0xac, 0xdf, 0x3c, 0x3f, + 0xb0, 0x2e, 0x1e, 0x6b, 0x04, 0x6e, 0x12, 0xa4, 0x31, 0x11}, + {0x92, 0x76, 0xa5, 0xb7, 0x55, 0xa1, 0x54, 0x63, 0xab, 0x59, 0xf0, + 0xe7, 0x22, 0x1f, 0x65, 0x80, 0x65, 0x7c, 0x05, 0x3f, 0xdb, 0x74, + 0x40, 0x12, 0xb3, 0xc1, 0x64, 0x8c, 0x75, 0x78, 0xd1, 0x22}, + {0xa8, 0x4f, 0x85, 0xd1, 0x57, 0xba, 0x71, 0x66, 0x5b, 0x31, 0x0b, + 0xd2, 0x12, 0x15, 0xad, 0x58, 0x82, 0x3b, 0x29, 0x8f, 0x44, 0x98, + 0xd5, 0x0d, 0x63, 0xad, 0xc9, 0x4d, 0x34, 0xeb, 0x93, 0x0a}}, +}; + +/* Orchard raw receiver vectors generated with orchard 0.12.0. */ +struct OrchardReceiverAssemblyVector { + const uint8_t* ak; + const uint8_t* nk; + const uint8_t* rivk; + const uint8_t* dk; + uint8_t index[11]; + uint8_t receiver[43]; +}; + +static const uint8_t ORCHARD_ASSEMBLY_DK_2[32] = { + 0x6c, 0x50, 0x3c, 0x95, 0x19, 0x0a, 0x74, 0x1d, 0x5f, 0x54, 0x87, + 0x59, 0xeb, 0x46, 0x4a, 0xa5, 0x36, 0x3b, 0xcd, 0xbc, 0x91, 0xa6, + 0x98, 0x7b, 0xd0, 0x7f, 0x67, 0x7b, 0x37, 0x59, 0xc2, 0x08, +}; + +static const uint8_t ORCHARD_ASSEMBLY_DK_3[32] = { + 0x41, 0xb7, 0x06, 0x56, 0xe2, 0x02, 0xaa, 0xcd, 0x0d, 0x92, 0x3b, + 0x7c, 0x95, 0xc0, 0xfc, 0x17, 0xa2, 0x13, 0xaf, 0x97, 0x3a, 0xd4, + 0xf8, 0x3f, 0xeb, 0x47, 0xdd, 0xf8, 0x3b, 0xb1, 0x68, 0xe4, +}; + +static const OrchardReceiverAssemblyVector ORCHARD_RECEIVER_ASSEMBLY_VECTORS[] = + { + {EXPECTED_AK_ALL_0, + EXPECTED_NK_ALL_0, + EXPECTED_RIVK_ALL_0, + EXPECTED_DK_ALL_0, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xda, 0x97, 0x30, 0x31, 0x63, 0x4a, 0x89, 0x38, 0xad, 0x1c, 0x48, + 0x0f, 0x97, 0x87, 0x80, 0x69, 0x3e, 0xc7, 0x70, 0x9b, 0xa5, 0xca, + 0xf5, 0x8d, 0x8a, 0x7e, 0xb9, 0x45, 0x58, 0x6c, 0xbe, 0xd6, 0x45, + 0x52, 0x0f, 0x17, 0x38, 0x74, 0x37, 0xbc, 0xfd, 0xc2, 0x16}}, + {EXPECTED_AK_ALL_0, + EXPECTED_NK_ALL_0, + EXPECTED_RIVK_ALL_0, + EXPECTED_DK_ALL_0, + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xbb, 0x0c, 0x08, 0xc2, 0x0f, 0x07, 0x8f, 0x59, 0x89, 0x39, 0x1c, + 0x36, 0x91, 0xb8, 0x97, 0xea, 0xcf, 0x28, 0x9a, 0x02, 0x02, 0x2f, + 0x45, 0xb3, 0xb1, 0x3f, 0x5f, 0xa1, 0xaa, 0xd5, 0x95, 0x9f, 0xaa, + 0x29, 0x01, 0x56, 0xc2, 0x40, 0xb8, 0xae, 0x1c, 0x07, 0x25}}, + {ORCHARD_IVK_VECTORS[1].ak, + ORCHARD_IVK_VECTORS[1].nk, + ORCHARD_IVK_VECTORS[1].rivk, + ORCHARD_ASSEMBLY_DK_2, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x45, 0x59, 0x02, 0x9c, 0x0b, 0x5d, 0xbf, 0x94, 0x1c, 0x5a, 0xd1, + 0x81, 0xa5, 0xfe, 0x8f, 0x45, 0xb3, 0x46, 0x30, 0xf2, 0x9d, 0x0c, + 0x8d, 0xd8, 0xdc, 0x1c, 0xc3, 0x57, 0x33, 0x86, 0xf4, 0x16, 0xcb, + 0x32, 0x41, 0x33, 0x15, 0x6d, 0x72, 0x3d, 0xf5, 0xe6, 0x2d}}, + {ORCHARD_IVK_VECTORS[2].ak, + ORCHARD_IVK_VECTORS[2].nk, + ORCHARD_IVK_VECTORS[2].rivk, + ORCHARD_ASSEMBLY_DK_3, + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xcb, 0xd5, 0xfc, 0x34, 0xc7, 0x26, 0x1d, 0x3f, 0xdb, 0x23, 0xd2, + 0xb8, 0x14, 0xad, 0xcb, 0xfc, 0x2d, 0x8e, 0x17, 0x2c, 0x79, 0xee, + 0x8e, 0x2e, 0x3f, 0xe7, 0xd8, 0xb1, 0xda, 0xd5, 0xb6, 0x67, 0x8e, + 0x22, 0x6c, 0xa7, 0xa3, 0x99, 0x6b, 0x1e, 0x62, 0x4f, 0x35}}, +}; + +/* Orchard-only unified address vectors generated with zcash_address 0.10.1. */ +static const char ORCHARD_ONLY_UA_MAINNET_0[] = + "u1uzslnccvrw4r2y2kgjz7fm477xcnzge9z45scm4e6l6c63ren0ru29teedxw5vxu7c8xch" + "p3ec2pu3wkgldc5zphwtm4w3fchcwrl26c"; +static const char ORCHARD_ONLY_UA_TESTNET_0[] = + "utest1deyej6qvxfnewfhgdc987fgpq407u374vzvtvgjuv86vj0gs9tcej04hk7nr5msm5fzg" + "335j70mddjnqj48zjsj5zl2362w4zcd2ks8c"; +static const char ORCHARD_ONLY_UA_MAINNET_1[] = + "u19whtuck5ry2d53xa348ecvfgsudtk8vt2qexe9w50lzwkzxx3lxcn60ztjfe2m33e0jz4xd" + "4kxe3yhz65xq9jzvjcrtjrhvrf5mzat26"; +static const char ORCHARD_ONLY_UA_TESTNET_1[] = + "utest1ff5jzt4pr5hzgz8688052pjtq0plzk3va9hgssprp3ps2lluhy3u6ej7eh3njfgqp3" + "ar4lm8muxu352nmuqt2c5n92w4ngf44qwtjl0p"; + +/* ── ZIP-32 Derivation Tests ─────────────────────────────────────── */ + +TEST(Zcash, DeriveOrchardKeys_ReferenceVector_Account0) { + /* + * Reference vector test: derive keys from known "all" mnemonic seed + * and compare against values from the orchard Rust crate. + */ + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + /* nk must match reference */ + EXPECT_TRUE(memcmp(keys.nk, EXPECTED_NK_ALL_0, 32) == 0) + << "nk mismatch for all-mnemonic account 0"; + + EXPECT_TRUE(memcmp(keys.ak, EXPECTED_AK_ALL_0, 32) == 0) + << "cached ak mismatch for all-mnemonic account 0"; + + /* rivk must match reference */ + EXPECT_TRUE(memcmp(keys.rivk, EXPECTED_RIVK_ALL_0, 32) == 0) + << "rivk mismatch for all-mnemonic account 0"; + + uint8_t ivk[32]; + ASSERT_TRUE( + zcash_orchard_derive_ivk(EXPECTED_AK_ALL_0, keys.nk, keys.rivk, ivk)); + EXPECT_TRUE(memcmp(ivk, EXPECTED_IVK_ALL_0, 32) == 0) + << "ivk mismatch for all-mnemonic account 0"; + + EXPECT_TRUE(memcmp(keys.dk, EXPECTED_DK_ALL_0, 32) == 0) + << "dk mismatch for all-mnemonic account 0"; + + uint8_t diversifier[11]; + uint8_t index0[11] = {0}; + ASSERT_TRUE(zcash_orchard_derive_diversifier(keys.dk, index0, diversifier)); + EXPECT_TRUE(memcmp(diversifier, EXPECTED_DIVERSIFIER_ALL_0, 11) == 0) + << "default diversifier mismatch for all-mnemonic account 0"; + + /* Compute ak = [ask]*G and verify against reference */ + bignum256 ask_scalar; + bn_read_le(keys.ask, &ask_scalar); + curve_point ak_point; + redpallas_scalar_mult_spendauth_G(&ask_scalar, &ak_point); + + uint8_t ak_bytes[32]; + bignum256 x_copy; + bn_copy(&ak_point.x, &x_copy); + bn_write_le(&x_copy, ak_bytes); + EXPECT_EQ(ak_bytes[31] & 0x80, 0) + << "ak sign bit must be 0 after ask normalization"; + + EXPECT_TRUE(memcmp(ak_bytes, EXPECTED_AK_ALL_0, 32) == 0) + << "ak mismatch for all-mnemonic account 0"; + + memzero(diversifier, sizeof(diversifier)); + memzero(ivk, sizeof(ivk)); + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, DeriveOrchardKeys_DifferentAccounts) { + ZcashOrchardKeys keys0, keys1; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys0)); + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 1, &keys1)); + + /* Different accounts must produce different spending keys */ + EXPECT_TRUE(memcmp(keys0.sk, keys1.sk, 32) != 0) + << "Account 0 and 1 must have different sk"; + EXPECT_TRUE(memcmp(keys0.ask, keys1.ask, 32) != 0) + << "Account 0 and 1 must have different ask"; + EXPECT_TRUE(memcmp(keys0.nk, keys1.nk, 32) != 0) + << "Account 0 and 1 must have different nk"; + + memzero(&keys0, sizeof(keys0)); + memzero(&keys1, sizeof(keys1)); +} + +TEST(Zcash, DeriveOrchardKeys_DifferentSeeds) { + /* Use a different seed (all zeros) */ + uint8_t zero_seed[64]; + memset(zero_seed, 0, sizeof(zero_seed)); + + ZcashOrchardKeys keys_all, keys_zero; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys_all)); + ASSERT_TRUE(zcash_derive_orchard_keys(zero_seed, 64, 0, &keys_zero)); + + EXPECT_TRUE(memcmp(keys_all.sk, keys_zero.sk, 32) != 0) + << "Different seeds must produce different sk"; + + memzero(&keys_all, sizeof(keys_all)); + memzero(&keys_zero, sizeof(keys_zero)); +} + +TEST(Zcash, DeriveOrchardKeys_Deterministic) { + ZcashOrchardKeys keys1, keys2; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys1)); + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys2)); + + EXPECT_TRUE(memcmp(keys1.sk, keys2.sk, 32) == 0); + EXPECT_TRUE(memcmp(keys1.ask, keys2.ask, 32) == 0); + EXPECT_TRUE(memcmp(keys1.ak, keys2.ak, 32) == 0); + EXPECT_TRUE(memcmp(keys1.nk, keys2.nk, 32) == 0); + EXPECT_TRUE(memcmp(keys1.rivk, keys2.rivk, 32) == 0); + EXPECT_TRUE(memcmp(keys1.dk, keys2.dk, 32) == 0); + + memzero(&keys1, sizeof(keys1)); + memzero(&keys2, sizeof(keys2)); +} + +TEST(Zcash, DeriveOrchardKeys_DerivesDiversifierKey) { + ZcashOrchardKeys keys0, keys1; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys0)); + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 1, &keys1)); + + uint8_t zero[32] = {0}; + EXPECT_TRUE(memcmp(keys0.dk, zero, 32) != 0) + << "Diversifier key must be populated"; + EXPECT_TRUE(memcmp(keys0.dk, keys1.dk, 32) != 0) + << "Different accounts must produce different diversifier keys"; + + memzero(&keys0, sizeof(keys0)); + memzero(&keys1, sizeof(keys1)); +} + +TEST(Zcash, OrchardDiversifier_FF1ReferenceVectors) { + for (const auto& tv : ORCHARD_FF1_VECTORS) { + uint8_t actual[11]; + ASSERT_TRUE(zcash_orchard_derive_diversifier(tv.dk, tv.index, actual)); + EXPECT_TRUE(memcmp(actual, tv.diversifier, sizeof(actual)) == 0); + memzero(actual, sizeof(actual)); + } +} + +TEST(Zcash, OrchardDiversifier_DeterministicAndDistinct) { + const uint8_t index0[11] = {0}; + const uint8_t index1[11] = {1}; + uint8_t d0[11], d0_again[11], d1[11]; + + ASSERT_TRUE( + zcash_orchard_derive_diversifier(ORCHARD_FF1_VECTORS[2].dk, index0, d0)); + ASSERT_TRUE(zcash_orchard_derive_diversifier(ORCHARD_FF1_VECTORS[2].dk, + index0, d0_again)); + ASSERT_TRUE( + zcash_orchard_derive_diversifier(ORCHARD_FF1_VECTORS[2].dk, index1, d1)); + + EXPECT_TRUE(memcmp(d0, d0_again, sizeof(d0)) == 0); + EXPECT_TRUE(memcmp(d0, d1, sizeof(d0)) != 0); + + memzero(d0, sizeof(d0)); + memzero(d0_again, sizeof(d0_again)); + memzero(d1, sizeof(d1)); +} + +TEST(Zcash, ExpandMessageXmdBlake2b_ReferenceVector) { + const uint8_t msg[] = {'a', 'b', 'c'}; + const uint8_t dst[] = "z.cash:test-pallas_XMD:BLAKE2b_SSWU_RO_"; + uint8_t out[96]; + + ASSERT_EQ(pallas_expand_message_xmd_blake2b( + msg, sizeof(msg), dst, sizeof(dst) - 1, out, sizeof(out)), + 0); + EXPECT_TRUE(memcmp(out, XMD_ABC_96, sizeof(out)) == 0); +} + +static void expect_bn_le(const bignum256* value, const uint8_t expected[32]) { + uint8_t actual[32]; + bignum256 tmp; + bn_copy(value, &tmp); + bn_write_le(&tmp, actual); + EXPECT_TRUE(memcmp(actual, expected, 32) == 0); + memzero(actual, sizeof(actual)); + memzero(&tmp, sizeof(tmp)); +} + +static void load_curve_point_from_xy(const uint8_t x[32], const uint8_t y[32], + curve_point* out) { + bn_read_le(x, &out->x); + bn_read_le(y, &out->y); + bn_normalize(&out->x); + bn_normalize(&out->y); +} + +TEST(Zcash, PallasSimpleSwu_ReferenceVectors) { + uint8_t u0[32] = {0}; + uint8_t u1[32] = {0}; + u1[0] = 1; + + pallas_jacobian_point p0, p1; + ASSERT_EQ(pallas_map_to_curve_simple_swu(u0, &p0), 0); + ASSERT_EQ(pallas_map_to_curve_simple_swu(u1, &p1), 0); + + expect_bn_le(&p0.x, SWU_0_X_LE); + expect_bn_le(&p0.y, SWU_0_Y_LE); + expect_bn_le(&p0.z, SWU_0_Z_LE); + expect_bn_le(&p1.x, SWU_1_X_LE); + expect_bn_le(&p1.y, SWU_1_Y_LE); + expect_bn_le(&p1.z, SWU_1_Z_LE); + + memzero(&p0, sizeof(p0)); + memzero(&p1, sizeof(p1)); +} + +TEST(Zcash, PallasGroupHash_ReferenceVector) { + const uint8_t msg[] = "Trans rights now!"; + curve_point p; + uint8_t encoded[32]; + + ASSERT_EQ(pallas_group_hash("z.cash:test", msg, sizeof(msg) - 1, &p), 0); + pallas_point_encode(&p, encoded); + EXPECT_TRUE(memcmp(encoded, HASH_ZCASH_TEST_TRANS_RIGHTS, sizeof(encoded)) == + 0); + + memzero(&p, sizeof(p)); + memzero(encoded, sizeof(encoded)); +} + +struct SinsemillaPrimitiveVector { + const uint8_t* msg; + size_t msg_bits; + const uint8_t* blind; + const uint8_t* hash_point; + const uint8_t* hash; + const uint8_t* commit_point; + const uint8_t* short_commit; +}; + +TEST(Zcash, SinsemillaPrimitives_ReferenceVectors) { + const SinsemillaPrimitiveVector vectors[] = { + {nullptr, 0, SINSEMILLA_ZERO_BLIND, SINSEMILLA_EMPTY_HASH_POINT, + SINSEMILLA_EMPTY_HASH, SINSEMILLA_EMPTY_HASH_POINT, + SINSEMILLA_EMPTY_HASH}, + {SINSEMILLA_MSG_ONE_BIT, 1, SINSEMILLA_ZERO_BLIND, + SINSEMILLA_ONE_BIT_HASH_POINT, SINSEMILLA_ONE_BIT_HASH, + SINSEMILLA_ONE_BIT_HASH_POINT, SINSEMILLA_ONE_BIT_HASH}, + {SINSEMILLA_MSG_TEN_BITS, 10, SINSEMILLA_ZERO_BLIND, + SINSEMILLA_TEN_BITS_HASH_POINT, SINSEMILLA_TEN_BITS_HASH, + SINSEMILLA_TEN_BITS_HASH_POINT, SINSEMILLA_TEN_BITS_HASH}, + {SINSEMILLA_MSG_TWENTY_THREE_BITS, 23, SINSEMILLA_NONZERO_BLIND, + SINSEMILLA_TWENTY_THREE_BITS_HASH_POINT, + SINSEMILLA_TWENTY_THREE_BITS_HASH, + SINSEMILLA_TWENTY_THREE_BITS_COMMIT_POINT, + SINSEMILLA_TWENTY_THREE_BITS_SHORT_COMMIT}, + }; + + curve_point q, r; + load_curve_point_from_xy(SINSEMILLA_COMMIT_IVK_Q_X, SINSEMILLA_COMMIT_IVK_Q_Y, + &q); + load_curve_point_from_xy(SINSEMILLA_COMMIT_IVK_R_X, SINSEMILLA_COMMIT_IVK_R_Y, + &r); + + for (const auto& vector : vectors) { + curve_point hash_point, commit_point; + uint8_t encoded[32]; + uint8_t hash[32]; + uint8_t short_commit[32]; + + ASSERT_EQ(pallas_sinsemilla_hash_to_point(&q, vector.msg, vector.msg_bits, + &hash_point), + 0); + pallas_point_encode(&hash_point, encoded); + EXPECT_TRUE(memcmp(encoded, vector.hash_point, sizeof(encoded)) == 0); + + ASSERT_EQ(pallas_sinsemilla_hash(&q, vector.msg, vector.msg_bits, hash), 0); + EXPECT_TRUE(memcmp(hash, vector.hash, sizeof(hash)) == 0); + + ASSERT_EQ(pallas_sinsemilla_commit(&q, &r, vector.msg, vector.msg_bits, + vector.blind, &commit_point), + 0); + pallas_point_encode(&commit_point, encoded); + EXPECT_TRUE(memcmp(encoded, vector.commit_point, sizeof(encoded)) == 0); + + ASSERT_EQ( + pallas_sinsemilla_short_commit(&q, &r, vector.msg, vector.msg_bits, + vector.blind, short_commit), + 0); + EXPECT_TRUE( + memcmp(short_commit, vector.short_commit, sizeof(short_commit)) == 0); + + memzero(&hash_point, sizeof(hash_point)); + memzero(&commit_point, sizeof(commit_point)); + memzero(encoded, sizeof(encoded)); + memzero(hash, sizeof(hash)); + memzero(short_commit, sizeof(short_commit)); + } + + memzero(&q, sizeof(q)); + memzero(&r, sizeof(r)); +} + +TEST(Zcash, SinsemillaPrimitives_RejectInvalidInputs) { + curve_point q, r, out; + load_curve_point_from_xy(SINSEMILLA_COMMIT_IVK_Q_X, SINSEMILLA_COMMIT_IVK_Q_Y, + &q); + load_curve_point_from_xy(SINSEMILLA_COMMIT_IVK_R_X, SINSEMILLA_COMMIT_IVK_R_Y, + &r); + + EXPECT_EQ( + pallas_sinsemilla_hash_to_point(&q, SINSEMILLA_MSG_ONE_BIT, + PALLAS_SINSEMILLA_MAX_BITS + 1, &out), + -1); + + curve_point identity = {}; + EXPECT_EQ(pallas_sinsemilla_hash_to_point(&identity, SINSEMILLA_MSG_ONE_BIT, + 1, &out), + -1); + EXPECT_EQ(pallas_sinsemilla_commit(&q, &identity, SINSEMILLA_MSG_ONE_BIT, 1, + SINSEMILLA_ZERO_BLIND, &out), + -1); + EXPECT_EQ(pallas_sinsemilla_commit(&q, &r, SINSEMILLA_MSG_ONE_BIT, 1, + PALLAS_Q_LE, &out), + -1); + + memzero(&q, sizeof(q)); + memzero(&r, sizeof(r)); + memzero(&out, sizeof(out)); + memzero(&identity, sizeof(identity)); +} + +TEST(Zcash, Zip316F4Jumble_ReferenceVectors) { + uint8_t buf48[sizeof(F4JUMBLE_48_NORMAL)]; + memcpy(buf48, F4JUMBLE_48_NORMAL, sizeof(buf48)); + ASSERT_EQ(zcash_zip316_f4jumble(buf48, sizeof(buf48)), 0); + EXPECT_TRUE(memcmp(buf48, F4JUMBLE_48_JUMBLED, sizeof(buf48)) == 0); + ASSERT_EQ(zcash_zip316_f4jumble_inv(buf48, sizeof(buf48)), 0); + EXPECT_TRUE(memcmp(buf48, F4JUMBLE_48_NORMAL, sizeof(buf48)) == 0); + + uint8_t buf64[sizeof(F4JUMBLE_64_NORMAL)]; + memcpy(buf64, F4JUMBLE_64_NORMAL, sizeof(buf64)); + ASSERT_EQ(zcash_zip316_f4jumble(buf64, sizeof(buf64)), 0); + EXPECT_TRUE(memcmp(buf64, F4JUMBLE_64_JUMBLED, sizeof(buf64)) == 0); + ASSERT_EQ(zcash_zip316_f4jumble_inv(buf64, sizeof(buf64)), 0); + EXPECT_TRUE(memcmp(buf64, F4JUMBLE_64_NORMAL, sizeof(buf64)) == 0); + + memzero(buf48, sizeof(buf48)); + memzero(buf64, sizeof(buf64)); +} + +TEST(Zcash, Zip316F4Jumble_RejectsInvalidLengths) { + uint8_t too_short[ZCASH_ZIP316_F4JUMBLE_MIN_LEN - 1] = {0}; + EXPECT_EQ(zcash_zip316_f4jumble(too_short, sizeof(too_short)), -1); + EXPECT_EQ(zcash_zip316_f4jumble_inv(too_short, sizeof(too_short)), -1); + EXPECT_EQ(zcash_zip316_f4jumble(nullptr, ZCASH_ZIP316_F4JUMBLE_MIN_LEN), -1); +} + +TEST(Zcash, Zip316OrchardOnlyUnifiedAddress_ReferenceVectors) { + char address[ZCASH_ZIP316_ORCHARD_ONLY_MAX_ADDRESS_SIZE]; + + ASSERT_EQ(zcash_zip316_encode_orchard_unified_address( + "u", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, address, + sizeof(address)), + 0); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_MAINNET_0); + + ASSERT_EQ(zcash_zip316_encode_orchard_unified_address( + "utest", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, address, + sizeof(address)), + 0); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_0); + + ASSERT_EQ(zcash_zip316_encode_orchard_unified_address( + "u", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[1].receiver, address, + sizeof(address)), + 0); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_MAINNET_1); + + ASSERT_EQ(zcash_zip316_encode_orchard_unified_address( + "utest", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[1].receiver, address, + sizeof(address)), + 0); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_1); + + memzero(address, sizeof(address)); +} + +TEST(Zcash, Zip316OrchardOnlyUnifiedAddress_RejectsInvalidInputs) { + char address[ZCASH_ZIP316_ORCHARD_ONLY_MAX_ADDRESS_SIZE]; + char too_small[16]; + char long_hrp[ZCASH_ZIP316_PADDING_LEN + 2]; + memset(long_hrp, 'a', sizeof(long_hrp) - 1); + long_hrp[sizeof(long_hrp) - 1] = 0; + + EXPECT_EQ(zcash_zip316_encode_orchard_unified_address( + "u", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, too_small, + sizeof(too_small)), + -1); + EXPECT_EQ(zcash_zip316_encode_orchard_unified_address( + long_hrp, ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, + address, sizeof(address)), + -1); + EXPECT_EQ(zcash_zip316_encode_orchard_unified_address( + "U", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, address, + sizeof(address)), + -1); + + memzero(address, sizeof(address)); + memzero(too_small, sizeof(too_small)); + memzero(long_hrp, sizeof(long_hrp)); +} + +TEST(Zcash, OrchardUnifiedAddress_FromDerivedKeys) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + char address[ZCASH_ZIP316_ORCHARD_ONLY_MAX_ADDRESS_SIZE]; + const uint8_t index0[11] = {0}; + const uint8_t index1[11] = {1}; + + ASSERT_TRUE(zcash_orchard_derive_unified_address(&keys, index0, "u", address, + sizeof(address))); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_MAINNET_0); + + ASSERT_TRUE(zcash_orchard_derive_unified_address(&keys, index0, "utest", + address, sizeof(address))); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_0); + + ASSERT_TRUE(zcash_orchard_derive_unified_address(&keys, index1, "u", address, + sizeof(address))); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_MAINNET_1); + + ASSERT_TRUE(zcash_orchard_derive_unified_address(&keys, index1, "utest", + address, sizeof(address))); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_1); + + memzero(address, sizeof(address)); + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, OrchardUnifiedAddress_RejectsInvalidInputs) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + char address[ZCASH_ZIP316_ORCHARD_ONLY_MAX_ADDRESS_SIZE]; + char too_small[16]; + const uint8_t index0[11] = {0}; + + EXPECT_FALSE(zcash_orchard_derive_unified_address(nullptr, index0, "u", + address, sizeof(address))); + EXPECT_FALSE(zcash_orchard_derive_unified_address(&keys, nullptr, "u", + address, sizeof(address))); + EXPECT_FALSE(zcash_orchard_derive_unified_address(&keys, index0, nullptr, + address, sizeof(address))); + EXPECT_FALSE(zcash_orchard_derive_unified_address(&keys, index0, "u", nullptr, + sizeof(address))); + EXPECT_FALSE(zcash_orchard_derive_unified_address( + &keys, index0, "u", too_small, sizeof(too_small))); + + memzero(address, sizeof(address)); + memzero(too_small, sizeof(too_small)); + memzero(&keys, sizeof(keys)); +} + +struct OrchardNoteProgressCapture { + uint32_t calls = 0; + uint32_t last = 0; + uint32_t total = 0; + bool monotonic = true; +}; + +static void capture_orchard_note_progress(uint32_t completed, uint32_t total, + void* context) { + auto* capture = static_cast(context); + if (capture->calls > 0 && completed < capture->last) { + capture->monotonic = false; + } + capture->calls++; + capture->last = completed; + capture->total = total; +} + +TEST(Zcash, OrchardNoteCommitment_KnownVectorAndProgress) { + const uint8_t recipient[ZCASH_ORCHARD_RAW_RECEIVER_SIZE] = { + 0x3c, 0x15, 0x0e, 0x60, 0x98, 0xb8, 0x61, 0x71, 0x6c, 0xc7, 0xf6, + 0x28, 0x35, 0xf6, 0x9f, 0xeb, 0x30, 0x21, 0x93, 0xc9, 0x26, 0x60, + 0x44, 0x4f, 0x26, 0x62, 0x4f, 0xd1, 0x3e, 0x00, 0xea, 0x7a, 0xc7, + 0x74, 0xcd, 0x55, 0x07, 0x4d, 0x63, 0x67, 0xef, 0xef, 0x37}; + const uint64_t value = 12345678; + const uint8_t rho[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00}; + const uint8_t rseed[32] = {0xca, 0xfe, 0xba, 0xbe, 0xde, 0xad, 0xbe, 0xef, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}; + const uint8_t expected_cmx[32] = { + 0x02, 0xde, 0xfb, 0x39, 0xc8, 0xf2, 0xe1, 0xec, 0xc9, 0x45, 0x18, + 0x93, 0x73, 0xcf, 0x2a, 0x8e, 0x21, 0xd4, 0xe1, 0x54, 0x39, 0x8e, + 0xfa, 0x16, 0x21, 0xd5, 0xfb, 0x98, 0x9e, 0x1d, 0xeb, 0x36}; + + uint8_t cmx[32]; + OrchardNoteProgressCapture progress; + ASSERT_TRUE(zcash_orchard_compute_cmx_with_progress( + recipient, value, rho, rseed, cmx, capture_orchard_note_progress, + &progress)); + EXPECT_TRUE(memcmp(cmx, expected_cmx, sizeof(cmx)) == 0); + EXPECT_TRUE(progress.monotonic); + EXPECT_EQ(109u, progress.calls); + EXPECT_EQ(109u, progress.last); + EXPECT_EQ(109u, progress.total); + + uint8_t tampered[ZCASH_ORCHARD_RAW_RECEIVER_SIZE]; + memcpy(tampered, recipient, sizeof(tampered)); + tampered[0] ^= 0x01; + ASSERT_TRUE(zcash_orchard_compute_cmx(tampered, value, rho, rseed, cmx)); + EXPECT_TRUE(memcmp(cmx, expected_cmx, sizeof(cmx)) != 0); + + memzero(cmx, sizeof(cmx)); + memzero(tampered, sizeof(tampered)); +} + +TEST(Zcash, IronwoodNoteCommitment_V3KnownVector) { + const uint8_t recipient[ZCASH_ORCHARD_RAW_RECEIVER_SIZE] = { + 0x3c, 0x15, 0x0e, 0x60, 0x98, 0xb8, 0x61, 0x71, 0x6c, 0xc7, 0xf6, + 0x28, 0x35, 0xf6, 0x9f, 0xeb, 0x30, 0x21, 0x93, 0xc9, 0x26, 0x60, + 0x44, 0x4f, 0x26, 0x62, 0x4f, 0xd1, 0x3e, 0x00, 0xea, 0x7a, 0xc7, + 0x74, 0xcd, 0x55, 0x07, 0x4d, 0x63, 0x67, 0xef, 0xef, 0x37}; + const uint8_t rho[32] = { + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00}; + const uint8_t rseed[32] = { + 0xca, 0xfe, 0xba, 0xbe, 0xde, 0xad, 0xbe, 0xef, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}; + const uint8_t expected_cmx[32] = { + 0x89, 0x6e, 0xe3, 0x45, 0xd8, 0xb0, 0x40, 0x98, + 0x72, 0x17, 0x25, 0x37, 0x66, 0x6a, 0x48, 0x24, + 0x09, 0x66, 0x1a, 0x22, 0xad, 0x77, 0xc0, 0x98, + 0x96, 0xa3, 0xe7, 0x17, 0x65, 0xf1, 0x86, 0x33}; + + uint8_t cmx[32] = {0}; + ASSERT_TRUE( + zcash_ironwood_compute_cmx(recipient, 12345678, rho, rseed, cmx)); + EXPECT_TRUE(memcmp(cmx, expected_cmx, sizeof(cmx)) == 0); + memzero(cmx, sizeof(cmx)); +} + +TEST(Zcash, OrchardReceiverToUnifiedAddress_KnownVector) { + const uint8_t recipient[ZCASH_ORCHARD_RAW_RECEIVER_SIZE] = { + 0x3c, 0x15, 0x0e, 0x60, 0x98, 0xb8, 0x61, 0x71, 0x6c, 0xc7, 0xf6, + 0x28, 0x35, 0xf6, 0x9f, 0xeb, 0x30, 0x21, 0x93, 0xc9, 0x26, 0x60, + 0x44, 0x4f, 0x26, 0x62, 0x4f, 0xd1, 0x3e, 0x00, 0xea, 0x7a, 0xc7, + 0x74, 0xcd, 0x55, 0x07, 0x4d, 0x63, 0x67, 0xef, 0xef, 0x37}; + char address[ZCASH_ORCHARD_UNIFIED_ADDRESS_SIZE]; + + ASSERT_TRUE(zcash_orchard_receiver_to_unified_address(recipient, "u", address, + sizeof(address))); + EXPECT_STREQ(address, + "u1ut4h93zg5670tyqss7tneru3t7h6dk62r9hhyxyrpv3nwwe9dnyj5l0ruwygf" + "74gp5f3zklj5xly4h8h54un3asugt9mn6gwfqsq3wq7"); + + EXPECT_FALSE( + zcash_orchard_receiver_to_unified_address(recipient, "u", address, 16)); + memzero(address, sizeof(address)); +} + +TEST(Zcash, OrchardDiversifyHash_ReferenceVectors) { + uint8_t gd[32]; + ASSERT_TRUE(zcash_orchard_diversify_hash(EXPECTED_DIVERSIFIER_ALL_0, gd)); + EXPECT_TRUE(memcmp(gd, ORCHARD_GD_ALL_ACCOUNT0_J0, sizeof(gd)) == 0); + + ASSERT_TRUE( + zcash_orchard_diversify_hash(ORCHARD_FF1_VECTORS[0].diversifier, gd)); + EXPECT_TRUE(memcmp(gd, ORCHARD_GD_FF1_ZERO_ZERO, sizeof(gd)) == 0); + + curve_point empty; + ASSERT_EQ(pallas_group_hash("z.cash:Orchard-gd", NULL, 0, &empty), 0); + pallas_point_encode(&empty, gd); + EXPECT_TRUE(memcmp(gd, ORCHARD_GD_EMPTY, sizeof(gd)) == 0); + + memzero(gd, sizeof(gd)); + memzero(&empty, sizeof(empty)); +} + +TEST(Zcash, OrchardTransmissionKey_ReferenceVectors) { + for (const auto& vector : ORCHARD_RECEIVER_VECTORS) { + uint8_t gd[32]; + uint8_t pkd[32]; + ASSERT_TRUE(zcash_orchard_derive_transmission_key( + vector.ivk, vector.diversifier, gd, pkd)); + EXPECT_TRUE(memcmp(gd, vector.gd, sizeof(gd)) == 0); + EXPECT_TRUE(memcmp(pkd, vector.pkd, sizeof(pkd)) == 0); + + uint8_t pkd_without_gd[32]; + ASSERT_TRUE(zcash_orchard_derive_transmission_key( + vector.ivk, vector.diversifier, nullptr, pkd_without_gd)); + EXPECT_TRUE(memcmp(pkd_without_gd, vector.pkd, sizeof(pkd_without_gd)) == + 0); + + memzero(gd, sizeof(gd)); + memzero(pkd, sizeof(pkd)); + memzero(pkd_without_gd, sizeof(pkd_without_gd)); + } +} + +TEST(Zcash, OrchardTransmissionKey_RejectsZeroIvk) { + uint8_t zero_ivk[32] = {0}; + uint8_t gd[32]; + uint8_t pkd[32]; + EXPECT_FALSE(zcash_orchard_derive_transmission_key( + zero_ivk, ORCHARD_RECEIVER_VECTORS[0].diversifier, gd, pkd)); +} + +TEST(Zcash, OrchardIvk_ReferenceVectors) { + for (const auto& vector : ORCHARD_IVK_VECTORS) { + uint8_t ivk[32]; + ASSERT_TRUE( + zcash_orchard_derive_ivk(vector.ak, vector.nk, vector.rivk, ivk)); + EXPECT_TRUE(memcmp(ivk, vector.ivk, sizeof(ivk)) == 0); + memzero(ivk, sizeof(ivk)); + } +} + +TEST(Zcash, OrchardIvk_RejectsInvalidAkEncoding) { + uint8_t bad_ak[32]; + memcpy(bad_ak, ORCHARD_IVK_VECTORS[0].ak, sizeof(bad_ak)); + bad_ak[31] |= 0x80; + + uint8_t ivk[32]; + EXPECT_FALSE(zcash_orchard_derive_ivk(bad_ak, ORCHARD_IVK_VECTORS[0].nk, + ORCHARD_IVK_VECTORS[0].rivk, ivk)); + memzero(bad_ak, sizeof(bad_ak)); + memzero(ivk, sizeof(ivk)); +} + +TEST(Zcash, OrchardReceiver_ReferenceVectors) { + for (const auto& vector : ORCHARD_RECEIVER_ASSEMBLY_VECTORS) { + uint8_t receiver[43]; + ASSERT_TRUE(zcash_orchard_derive_receiver( + vector.ak, vector.nk, vector.rivk, vector.dk, vector.index, receiver)); + EXPECT_TRUE(memcmp(receiver, vector.receiver, sizeof(receiver)) == 0); + memzero(receiver, sizeof(receiver)); + } +} + +TEST(Zcash, OrchardReceiver_RejectsInvalidAkEncoding) { + uint8_t bad_ak[32]; + memcpy(bad_ak, ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].ak, sizeof(bad_ak)); + bad_ak[31] |= 0x80; + + uint8_t receiver[43]; + EXPECT_FALSE(zcash_orchard_derive_receiver( + bad_ak, ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].nk, + ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].rivk, + ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].dk, + ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].index, receiver)); + memzero(bad_ak, sizeof(bad_ak)); + memzero(receiver, sizeof(receiver)); +} + +/* ── Field Range Tests ───────────────────────────────────────────── */ + +TEST(Zcash, DeriveOrchardKeys_FieldRanges) { + /* Test multiple accounts to increase coverage of edge cases */ + for (uint32_t account = 0; account < 5; account++) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, account, &keys)); + + /* nk must be < Pallas base field prime p */ + EXPECT_LT(cmp_le256(keys.nk, PALLAS_P_LE), 0) + << "nk must be < p for account " << account; + + /* rivk must be < Pallas scalar field order q */ + EXPECT_LT(cmp_le256(keys.rivk, PALLAS_Q_LE), 0) + << "rivk must be < q for account " << account; + + /* ask must be < Pallas scalar field order q */ + EXPECT_LT(cmp_le256(keys.ask, PALLAS_Q_LE), 0) + << "ask must be < q for account " << account; + + /* ask must be nonzero (astronomically unlikely, but verify) */ + uint8_t zero[32] = {0}; + EXPECT_TRUE(memcmp(keys.ask, zero, 32) != 0) + << "ask must be nonzero for account " << account; + + memzero(&keys, sizeof(keys)); + } +} + +TEST(Zcash, AkSignBit_AlwaysClear) { + /* + * For every account, compute ak = [ask]*G and verify the sign bit + * is always clear. This is the invariant that the ask negation + * in zcash_derive_orchard_keys() is supposed to enforce. + */ + for (uint32_t account = 0; account < 10; account++) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, account, &keys)); + + bignum256 ask_scalar; + bn_read_le(keys.ask, &ask_scalar); + curve_point ak_point; + redpallas_scalar_mult_spendauth_G(&ask_scalar, &ak_point); + + /* Check y parity: must be even (sign bit = 0) */ + EXPECT_FALSE(bn_is_odd(&ak_point.y)) + << "ak y-coordinate must be even for account " << account; + + /* Check serialized sign bit */ + uint8_t ak_bytes[32]; + bignum256 x_copy; + bn_copy(&ak_point.x, &x_copy); + bn_write_le(&x_copy, ak_bytes); + + EXPECT_EQ(ak_bytes[31] & 0x80, 0) + << "ak sign bit must be clear for account " << account; + + memzero(&keys, sizeof(keys)); + } +} + +/* ── PCZT Signing Policy Tests ───────────────────────────────────── */ + +static ZcashPCZTSigningRequestMeta clear_pczt_meta(void) { + ZcashPCZTSigningRequestMeta meta = {}; + meta.has_header_digest = true; + meta.header_digest_size = 32; + meta.has_orchard_digest = true; + meta.orchard_digest_size = 32; + meta.has_orchard_flags = true; + meta.has_orchard_value_balance = true; + meta.has_orchard_anchor = true; + meta.orchard_anchor_size = 32; + meta.has_header_fields = true; + meta.n_transparent_inputs = 0; + meta.n_transparent_outputs = 0; + return meta; +} + +TEST(Zcash, PCZTSigningPolicy_AcceptsVerifiedShieldedOnlyRequest) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_OK); + EXPECT_TRUE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsMissingTransactionDigests) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + meta.has_header_digest = false; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); + + meta = clear_pczt_meta(); + meta.orchard_digest_size = 31; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RequiresIronwoodDigestForV6Pool) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + meta.is_ironwood = true; + + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS); + + meta.has_ironwood_digest = true; + meta.ironwood_digest_size = 32; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_OK); + + meta.ironwood_digest_size = 31; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsMissingPlaintextHeaderFields) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + meta.has_header_fields = false; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_HEADER_FIELDS); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsMissingOrchardMetadata) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + meta.has_orchard_anchor = false; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); + + meta = clear_pczt_meta(); + meta.has_orchard_flags = false; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsInvalidOptionalDigests) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + meta.has_transparent_digest = true; + meta.transparent_digest_size = 31; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsSaplingComponent) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + meta.has_sapling_digest = true; + meta.sapling_digest_size = 32; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_UNSUPPORTED_SAPLING_COMPONENT); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsTransparentComponentsWithoutDigest) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + meta.n_transparent_inputs = 1; + + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); + + meta.has_transparent_digest = true; + meta.transparent_digest_size = 32; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_OK); + EXPECT_TRUE(zcash_pczt_signing_request_is_clear(&meta)); + + meta = clear_pczt_meta(); + meta.n_transparent_outputs = 1; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); + + meta.has_transparent_digest = true; + meta.transparent_digest_size = 32; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_OK); + EXPECT_TRUE(zcash_pczt_signing_request_is_clear(&meta)); +} + +static const uint8_t ZIP244_EXPECTED_HEADER_DIGEST[32] = { + 0x44, 0x4b, 0xe9, 0x38, 0x88, 0x1d, 0xc9, 0xf2, 0x0a, 0xed, 0x88, + 0x0c, 0x3a, 0x05, 0x94, 0xe5, 0xc1, 0x22, 0x3e, 0xff, 0xc5, 0x75, + 0xef, 0x05, 0xda, 0xae, 0xe3, 0x45, 0x1b, 0xa2, 0xf4, 0x93}; + +static const uint8_t ZIP244_EXPECTED_EMPTY_TRANSPARENT_DIGEST[32] = { + 0xc3, 0x3f, 0x2e, 0x95, 0x70, 0x5f, 0xaa, 0xb3, 0x5f, 0x8d, 0x53, + 0x3f, 0xa6, 0x1e, 0x95, 0xc3, 0xb7, 0xaa, 0xba, 0x07, 0x76, 0xb8, + 0x74, 0xa9, 0xf7, 0x4f, 0xc1, 0x27, 0x84, 0x37, 0x6a, 0x59}; + +static const uint8_t ZIP244_EXPECTED_TRANSPARENT_DIGEST[32] = { + 0xfa, 0xe5, 0x37, 0x7f, 0xa9, 0x3c, 0xc0, 0xc3, 0x1d, 0x30, 0x39, + 0x42, 0x21, 0x57, 0xce, 0x4b, 0x9e, 0x7b, 0x12, 0x57, 0x00, 0x9f, + 0x15, 0x90, 0xe1, 0x62, 0x95, 0x62, 0x55, 0xbb, 0x2e, 0x84}; + +static const uint8_t ZIP244_EXPECTED_TRANSPARENT_SIGHASH_0[32] = { + 0x37, 0xa9, 0xc4, 0xec, 0x61, 0x87, 0x07, 0x20, 0x5b, 0xcb, 0x47, + 0x7b, 0xea, 0x4f, 0xda, 0x6d, 0x61, 0x01, 0x62, 0xea, 0xaa, 0x5c, + 0x9f, 0x33, 0xe5, 0x59, 0x69, 0x02, 0x6e, 0x47, 0x6f, 0x23}; + +static const uint8_t ZIP244_EXPECTED_TRANSPARENT_SIGHASH_1[32] = { + 0x29, 0x4d, 0xb7, 0xaa, 0xf1, 0x65, 0x37, 0x4e, 0x02, 0xda, 0xe1, + 0x6f, 0xf3, 0xdd, 0x97, 0x78, 0x8f, 0x4f, 0x5e, 0x2d, 0xc4, 0xe1, + 0xb3, 0xf6, 0x62, 0x73, 0x9e, 0xd3, 0x5b, 0x82, 0x08, 0x2f}; + +static const uint8_t ZIP244_P2PKH_SCRIPT_11[25] = { + 0x76, 0xa9, 0x14, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x88, 0xac}; + +static const uint8_t ZIP244_P2SH_SCRIPT_22[23] = { + 0xa9, 0x14, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x87}; + +static const uint8_t ZIP244_P2PKH_SCRIPT_33[25] = { + 0x76, 0xa9, 0x14, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x88, 0xac}; + +static const uint8_t ZIP244_P2SH_SCRIPT_44[23] = { + 0xa9, 0x14, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, + 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x87}; + +static void fill_zip244_txids(uint8_t txid0[32], uint8_t txid1[32]) { + for (size_t i = 0; i < 32; i++) { + txid0[i] = (uint8_t)i; + txid1[i] = (uint8_t)(i + 32); + } +} + +static void make_zip244_transparent_fixture( + ZcashTransparentInputDigestInfo inputs[2], + ZcashTransparentOutputDigestInfo outputs[2], uint8_t txid0[32], + uint8_t txid1[32]) { + fill_zip244_txids(txid0, txid1); + + inputs[0].prevout_txid = txid0; + inputs[0].prevout_index = 2; + inputs[0].sequence = 0xfffffffe; + inputs[0].value = 1234567890ULL; + inputs[0].script_pubkey = ZIP244_P2PKH_SCRIPT_11; + inputs[0].script_pubkey_size = sizeof(ZIP244_P2PKH_SCRIPT_11); + + inputs[1].prevout_txid = txid1; + inputs[1].prevout_index = 7; + inputs[1].sequence = 0xfffffffd; + inputs[1].value = 987654321ULL; + inputs[1].script_pubkey = ZIP244_P2SH_SCRIPT_22; + inputs[1].script_pubkey_size = sizeof(ZIP244_P2SH_SCRIPT_22); + + outputs[0].value = 2000000000ULL; + outputs[0].script_pubkey = ZIP244_P2PKH_SCRIPT_33; + outputs[0].script_pubkey_size = sizeof(ZIP244_P2PKH_SCRIPT_33); + + outputs[1].value = 1111111ULL; + outputs[1].script_pubkey = ZIP244_P2SH_SCRIPT_44; + outputs[1].script_pubkey_size = sizeof(ZIP244_P2SH_SCRIPT_44); +} + +TEST(Zcash, ComputeHeaderDigest_FromPlaintextFields) { + uint8_t digest[32] = {0}; + + ASSERT_TRUE(zcash_compute_header_digest(5, 0x26a7270a, 0xc2d6d0b4, 123456, + 987654, digest)); + EXPECT_TRUE(memcmp(digest, ZIP244_EXPECTED_HEADER_DIGEST, 32) == 0); +} + +TEST(Zcash, ComputeTransparentDigest_DistinctFromPerInputSighash) { + ZcashTransparentInputDigestInfo inputs[2] = {}; + ZcashTransparentOutputDigestInfo outputs[2] = {}; + uint8_t txid0[32], txid1[32]; + make_zip244_transparent_fixture(inputs, outputs, txid0, txid1); + + uint8_t digest[32] = {0}; + uint8_t sighash0[32] = {0}; + uint8_t sighash1[32] = {0}; + + ASSERT_TRUE(zcash_compute_transparent_digest(inputs, 2, outputs, 2, digest)); + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 0, + 0x01, sighash0)); + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 1, + 0x01, sighash1)); + + EXPECT_TRUE(memcmp(digest, ZIP244_EXPECTED_TRANSPARENT_DIGEST, 32) == 0); + EXPECT_TRUE(memcmp(sighash0, ZIP244_EXPECTED_TRANSPARENT_SIGHASH_0, 32) == 0); + EXPECT_TRUE(memcmp(sighash1, ZIP244_EXPECTED_TRANSPARENT_SIGHASH_1, 32) == 0); + EXPECT_TRUE(memcmp(digest, sighash0, 32) != 0); + EXPECT_TRUE(memcmp(sighash0, sighash1, 32) != 0); +} + +TEST(Zcash, ComputeTransparentDigest_EmptyBundle) { + uint8_t digest[32] = {0}; + + ASSERT_TRUE(zcash_compute_transparent_digest(NULL, 0, NULL, 0, digest)); + EXPECT_TRUE(memcmp(digest, ZIP244_EXPECTED_EMPTY_TRANSPARENT_DIGEST, 32) == + 0); +} + +TEST(Zcash, ComputeTransparentSighash_RejectsUnsupportedRequest) { + ZcashTransparentInputDigestInfo inputs[2] = {}; + ZcashTransparentOutputDigestInfo outputs[2] = {}; + uint8_t txid0[32], txid1[32], digest[32]; + make_zip244_transparent_fixture(inputs, outputs, txid0, txid1); + + EXPECT_FALSE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, + 2, 0x01, digest)); + EXPECT_FALSE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, + 0, 0x02, digest)); +} + +TEST(Zcash, ComputeTransparentSighash_CommitsToOutputScriptAndValue) { + ZcashTransparentInputDigestInfo inputs[2] = {}; + ZcashTransparentOutputDigestInfo outputs[2] = {}; + uint8_t txid0[32], txid1[32]; + make_zip244_transparent_fixture(inputs, outputs, txid0, txid1); + + uint8_t original[32] = {0}; + uint8_t changed_script[32] = {0}; + uint8_t changed_value[32] = {0}; + + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 0, + 0x01, original)); + + outputs[0].script_pubkey = ZIP244_P2SH_SCRIPT_44; + outputs[0].script_pubkey_size = sizeof(ZIP244_P2SH_SCRIPT_44); + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 0, + 0x01, changed_script)); + EXPECT_TRUE(memcmp(original, changed_script, 32) != 0); + + outputs[0].script_pubkey = ZIP244_P2PKH_SCRIPT_33; + outputs[0].script_pubkey_size = sizeof(ZIP244_P2PKH_SCRIPT_33); + outputs[0].value++; + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 0, + 0x01, changed_value)); + EXPECT_TRUE(memcmp(original, changed_value, 32) != 0); +} + +/* ── Sighash Computation Tests ───────────────────────────────────── */ + +TEST(Zcash, ComputeShieldedSighash_Deterministic) { + uint8_t header[32], transparent[32], sapling[32], orchard[32]; + memset(header, 0x01, 32); + memset(transparent, 0x02, 32); + memset(sapling, 0x03, 32); + memset(orchard, 0x04, 32); + + uint32_t branch_id = 0x37519621; /* NU5 */ + + uint8_t sighash1[32], sighash2[32]; + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash1)); + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash2)); + + EXPECT_TRUE(memcmp(sighash1, sighash2, 32) == 0) + << "Sighash must be deterministic"; +} + +TEST(Zcash, ComputeV6ShieldedSighash_KnownVector) { + uint8_t header[32], transparent[32], sapling[32], orchard[32], ironwood[32]; + memset(header, 0x11, sizeof(header)); + memset(transparent, 0x22, sizeof(transparent)); + memset(sapling, 0x33, sizeof(sapling)); + memset(orchard, 0x44, sizeof(orchard)); + memset(ironwood, 0x55, sizeof(ironwood)); + const uint8_t expected[32] = { + 0xdc, 0x07, 0x66, 0x98, 0xdb, 0xe0, 0x8b, 0x6d, + 0xcd, 0x23, 0xf5, 0xa1, 0xb6, 0xbb, 0xae, 0x41, + 0xf7, 0xb1, 0x23, 0xd8, 0xb2, 0x47, 0xf3, 0x88, + 0x7f, 0x7c, 0xa2, 0xbb, 0x68, 0xb5, 0xdc, 0xaa}; + + uint8_t sighash[32] = {0}; + ASSERT_TRUE(zcash_compute_v6_shielded_sighash( + header, transparent, sapling, orchard, ironwood, 0x37a5165b, + sighash)); + EXPECT_TRUE(memcmp(sighash, expected, sizeof(sighash)) == 0); +} + +TEST(Zcash, ComputeShieldedSighash_DifferentInputs) { + uint8_t header[32], transparent[32], sapling[32], orchard[32]; + memset(header, 0x01, 32); + memset(transparent, 0x02, 32); + memset(sapling, 0x03, 32); + memset(orchard, 0x04, 32); + + uint32_t branch_id = 0x37519621; + uint8_t sighash_a[32], sighash_b[32]; + + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash_a)); + + /* Change one byte in the orchard digest */ + orchard[0] ^= 0xff; + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash_b)); + + EXPECT_TRUE(memcmp(sighash_a, sighash_b, 32) != 0) + << "Different orchard digests must produce different sighashes"; +} + +TEST(Zcash, ComputeShieldedSighash_DifferentBranchId) { + uint8_t header[32], transparent[32], sapling[32], orchard[32]; + memset(header, 0x01, 32); + memset(transparent, 0x02, 32); + memset(sapling, 0x03, 32); + memset(orchard, 0x04, 32); + + uint8_t sighash_nu5[32], sighash_nu6[32]; + + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, 0x37519621, sighash_nu5)); + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, 0xC4D97411, sighash_nu6)); + + EXPECT_TRUE(memcmp(sighash_nu5, sighash_nu6, 32) != 0) + << "Different branch IDs must produce different sighashes"; +} + +TEST(Zcash, ComputeShieldedSighash_KnownVector) { + /* + * ZIP-244 sighash test vector. + * + * The sighash personalization is "ZcashTxHash_" || branch_id_LE. + * For NU5 (branch_id = 0x37519621): + * personalization = "ZcashTxHash_" || 0x21965137 + * + * Input: BLAKE2b-256(personalization, header || transparent || sapling || + * orchard) where each digest is 32 bytes of zeros. + */ + uint8_t header[32] = {0}; + uint8_t transparent[32] = {0}; + uint8_t sapling[32] = {0}; + uint8_t orchard[32] = {0}; + uint32_t branch_id = 0x37519621; + + uint8_t sighash[32]; + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash)); + + /* + * Independently verified: BLAKE2b-256 with personalization + * "ZcashTxHash_\x21\x96\x51\x37" over 128 zero bytes. + * + * This is a self-consistency check — the value was computed by + * running the same BLAKE2b-256 offline. If the sighash function + * changes its algorithm, this test will catch it. + */ + uint8_t expected[32]; + BLAKE2B_CTX ctx; + uint8_t personal[16]; + memcpy(personal, "ZcashTxHash_", 12); + memcpy(personal + 12, &branch_id, 4); + blake2b_InitPersonal(&ctx, 32, personal, 16); + blake2b_Update(&ctx, header, 32); + blake2b_Update(&ctx, transparent, 32); + blake2b_Update(&ctx, sapling, 32); + blake2b_Update(&ctx, orchard, 32); + blake2b_Final(&ctx, expected, 32); + + EXPECT_TRUE(memcmp(sighash, expected, 32) == 0) + << "Sighash must match direct BLAKE2b computation"; +} + +/* ── RedPallas Signing Smoke Test ────────────────────────────────── */ + +struct RedPallasProgressCapture { + uint32_t calls = 0; + uint32_t last = 0; + uint32_t total = 0; + bool monotonic = true; +}; + +static void capture_redpallas_progress(uint32_t completed, uint32_t total, + void* context) { + auto* capture = static_cast(context); + if (capture->calls > 0 && completed < capture->last) { + capture->monotonic = false; + } + capture->calls++; + capture->last = completed; + capture->total = total; +} + +TEST(Zcash, OrchardKeyDerivationReportsFixedProgress) { + ZcashOrchardKeys keys; + RedPallasProgressCapture progress; + + ASSERT_TRUE(zcash_derive_orchard_keys_with_progress( + SEED_ALL, 64, 0, &keys, capture_redpallas_progress, &progress)); + EXPECT_TRUE(progress.monotonic); + EXPECT_EQ(255u, progress.calls); + EXPECT_EQ(255u, progress.last); + EXPECT_EQ(255u, progress.total); + EXPECT_EQ(0, memcmp(keys.ak, EXPECTED_AK_ALL_0, sizeof(keys.ak))); + + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, RedPallasPublicRkPathMatchesAndReportsFixedProgress) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t alpha[32]; + memset(alpha, 0x01, sizeof(alpha)); + alpha[31] = 0; + uint8_t sighash[32]; + memset(sighash, 0xA5, sizeof(sighash)); + + uint8_t public_rk[32], secret_reference_rk[32]; + ASSERT_EQ(redpallas_derive_rk_from_ak(keys.ak, alpha, public_rk), 0); + ASSERT_EQ(redpallas_derive_rk(keys.ask, alpha, secret_reference_rk), 0); + EXPECT_EQ(memcmp(public_rk, secret_reference_rk, sizeof(public_rk)), 0); + + RedPallasProgressCapture progress; + uint8_t signature[64]; + ASSERT_EQ(redpallas_sign_digest_with_ak( + keys.ask, keys.ak, alpha, public_rk, sighash, signature, + capture_redpallas_progress, &progress), + 0); + EXPECT_TRUE(progress.monotonic); + EXPECT_EQ(257u, progress.calls); + EXPECT_EQ(1000u, progress.last); + EXPECT_EQ(1000u, progress.total); + EXPECT_EQ(redpallas_verify_digest(public_rk, sighash, signature), 0); + + uint8_t wrong_rk[32]; + memcpy(wrong_rk, public_rk, sizeof(wrong_rk)); + wrong_rk[0] ^= 1; + EXPECT_NE(redpallas_sign_digest_with_ak(keys.ask, keys.ak, alpha, wrong_rk, + sighash, signature, nullptr, nullptr), + 0); + + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, RedPallasPcztPathUsesBoundRkAndReportsFixedProgress) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t alpha[32]; + memset(alpha, 0x31, sizeof(alpha)); + alpha[31] = 0; + uint8_t sighash[32]; + memset(sighash, 0x5A, sizeof(sighash)); + uint8_t rk[32]; + ASSERT_EQ(redpallas_derive_rk(keys.ask, alpha, rk), 0); + + RedPallasProgressCapture progress; + uint8_t signature[64]; + ASSERT_EQ( + redpallas_sign_digest_for_rk(keys.ask, alpha, rk, sighash, signature, + capture_redpallas_progress, &progress), + 0); + EXPECT_TRUE(progress.monotonic); + EXPECT_EQ(256u, progress.calls); + EXPECT_EQ(1000u, progress.last); + EXPECT_EQ(1000u, progress.total); + EXPECT_EQ(redpallas_verify_digest(rk, sighash, signature), 0); + + uint8_t wrong_rk[32]; + memcpy(wrong_rk, rk, sizeof(wrong_rk)); + wrong_rk[0] ^= 1; + ASSERT_EQ(redpallas_sign_digest_for_rk(keys.ask, alpha, wrong_rk, sighash, + signature, nullptr, nullptr), + 0); + EXPECT_NE(redpallas_verify_digest(rk, sighash, signature), 0); + + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, RedPallasSign_ProducesVerifiableSignature) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + /* Construct a fake sighash and alpha */ + uint8_t sighash[32]; + memset(sighash, 0xAB, 32); + + uint8_t alpha[32]; + memset(alpha, 0x01, 32); + /* Ensure alpha is a valid scalar (< q) */ + alpha[31] = 0x00; + + uint8_t signature[64]; + int ret = redpallas_sign_digest(keys.ask, alpha, sighash, signature); + EXPECT_EQ(ret, 0) << "RedPallas signing must succeed"; + + /* Signature must be nonzero */ + uint8_t zero[64] = {0}; + EXPECT_TRUE(memcmp(signature, zero, 64) != 0) << "Signature must be nonzero"; + + /* + * Verify the signature against the randomized verification key rk. + * rk = [ask + alpha]*G_spendauth (Pallas SpendAuth basepoint) + */ + bignum256 ask_scalar, alpha_scalar, rk_scalar; + bn_read_le(keys.ask, &ask_scalar); + bn_read_le(alpha, &alpha_scalar); + + /* rk_scalar = ask + alpha mod q */ + bn_copy(&ask_scalar, &rk_scalar); + pallas_add_mod_q(&rk_scalar, &alpha_scalar); + + curve_point rk_point; + redpallas_scalar_mult_spendauth_G(&rk_scalar, &rk_point); + + /* Serialize rk as Pallas point (LE x-coord + sign bit) */ + uint8_t rk_bytes[32]; + bignum256 rk_x; + bn_copy(&rk_point.x, &rk_x); + bn_write_le(&rk_x, rk_bytes); + if (bn_is_odd(&rk_point.y)) { + rk_bytes[31] |= 0x80; + } + + /* Verify: redpallas_verify_digest(rk, sighash, sig) == 0 */ + EXPECT_EQ(redpallas_verify_digest(rk_bytes, sighash, signature), 0) + << "Signature must verify against rk = [ask+alpha]*G"; + + /* Verify fails with wrong sighash */ + uint8_t wrong_sighash[32]; + memset(wrong_sighash, 0xCC, 32); + EXPECT_NE(redpallas_verify_digest(rk_bytes, wrong_sighash, signature), 0) + << "Signature must NOT verify with wrong sighash"; + + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, RedPallasSign_MultipleCallsSucceed) { + /* + * RedPallas uses randomized nonces — signatures are intentionally + * non-deterministic. Verify that multiple calls all succeed and + * produce valid (nonzero) 64-byte signatures. + */ + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t sighash[32]; + memset(sighash, 0xCD, 32); + uint8_t alpha[32]; + memset(alpha, 0x02, 32); + alpha[31] = 0x00; + + uint8_t zero[64] = {0}; + for (int i = 0; i < 3; i++) { + uint8_t sig[64]; + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha, sighash, sig), 0) + << "Signing must succeed on call " << i; + EXPECT_TRUE(memcmp(sig, zero, 64) != 0) + << "Signature must be nonzero on call " << i; + } + + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, RedPallasSign_DifferentSighash) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t alpha[32]; + memset(alpha, 0x01, 32); + alpha[31] = 0x00; + + uint8_t sighash_a[32], sighash_b[32]; + memset(sighash_a, 0xAA, 32); + memset(sighash_b, 0xBB, 32); + + uint8_t sig_a[64], sig_b[64]; + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha, sighash_a, sig_a), 0); + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha, sighash_b, sig_b), 0); + + EXPECT_TRUE(memcmp(sig_a, sig_b, 64) != 0) + << "Different sighash must produce different signatures"; + + memzero(&keys, sizeof(keys)); +} + +/* ─── Seed Fingerprint (ZIP-32 §6.1) ─────────────────────────────── */ + +/* Reference vector: matches keystone3-firmware + * rust/keystore/src/algorithms/zcash/mod.rs test_keystore_derive_zcash_ufvk + * Seed: 000102...1f (32 bytes) + * Fingerprint: deff604c246710f7176dead02aa746f2fd8d5389f7072556dcb555fdbe5e3ae3 + */ +TEST(Zcash, SeedFingerprint_ReferenceVector) { + uint8_t seed[32]; + for (int i = 0; i < 32; i++) seed[i] = (uint8_t)i; + + uint8_t expected[32] = { + 0xde, 0xff, 0x60, 0x4c, 0x24, 0x67, 0x10, 0xf7, 0x17, 0x6d, 0xea, + 0xd0, 0x2a, 0xa7, 0x46, 0xf2, 0xfd, 0x8d, 0x53, 0x89, 0xf7, 0x07, + 0x25, 0x56, 0xdc, 0xb5, 0x55, 0xfd, 0xbe, 0x5e, 0x3a, 0xe3, + }; + + uint8_t fp[32]; + ASSERT_TRUE(zcash_calculate_seed_fingerprint(seed, 32, fp)); + EXPECT_EQ(memcmp(fp, expected, 32), 0); +} + +TEST(Zcash, SeedFingerprintRequestRequiresExactSizeWhenPresent) { + EXPECT_TRUE(zcash_seed_fingerprint_request_valid(false, 0)); + EXPECT_TRUE(zcash_seed_fingerprint_request_valid(true, 32)); + EXPECT_FALSE(zcash_seed_fingerprint_request_valid(true, 0)); + EXPECT_FALSE(zcash_seed_fingerprint_request_valid(true, 31)); + EXPECT_FALSE(zcash_seed_fingerprint_request_valid(true, 33)); +} + +TEST(Zcash, SeedFingerprint_RejectAllZero) { + uint8_t seed[32] = {0}; + uint8_t fp[32]; + EXPECT_FALSE(zcash_calculate_seed_fingerprint(seed, 32, fp)); +} + +TEST(Zcash, SeedFingerprint_RejectAllFF) { + uint8_t seed[32]; + memset(seed, 0xFF, 32); + uint8_t fp[32]; + EXPECT_FALSE(zcash_calculate_seed_fingerprint(seed, 32, fp)); +} + +TEST(Zcash, SeedFingerprint_RejectShortSeed) { + uint8_t seed[31]; + for (int i = 0; i < 31; i++) seed[i] = (uint8_t)(i + 1); + uint8_t fp[32]; + EXPECT_FALSE(zcash_calculate_seed_fingerprint(seed, 31, fp)); +} + +TEST(Zcash, SeedFingerprint_RejectLongSeed) { + uint8_t seed[253]; + for (int i = 0; i < 253; i++) seed[i] = (uint8_t)(i & 0xFF); + uint8_t fp[32]; + EXPECT_FALSE(zcash_calculate_seed_fingerprint(seed, 253, fp)); +} + +TEST(Zcash, SeedFingerprint_DeterministicAcrossCalls) { + uint8_t seed[64]; + for (int i = 0; i < 64; i++) seed[i] = (uint8_t)(0xAA ^ i); + + uint8_t fp_a[32], fp_b[32]; + ASSERT_TRUE(zcash_calculate_seed_fingerprint(seed, 64, fp_a)); + ASSERT_TRUE(zcash_calculate_seed_fingerprint(seed, 64, fp_b)); + EXPECT_EQ(memcmp(fp_a, fp_b, 32), 0); +} + +TEST(Zcash, SeedFingerprint_DiffersForDifferentSeeds) { + uint8_t seed_a[64]; + uint8_t seed_b[64]; + for (int i = 0; i < 64; i++) { + seed_a[i] = (uint8_t)i; + seed_b[i] = (uint8_t)(i + 1); + } + + uint8_t fp_a[32], fp_b[32]; + ASSERT_TRUE(zcash_calculate_seed_fingerprint(seed_a, 64, fp_a)); + ASSERT_TRUE(zcash_calculate_seed_fingerprint(seed_b, 64, fp_b)); + EXPECT_NE(memcmp(fp_a, fp_b, 32), 0); +} + +/* ===================================================================== * + * zcash_compute_orchard_transparent_sig_digest — ZIP-244 S.2/T.1 + * + * This is the site of the historical shield-fix (S.2 vs T.1 selection by + * vin count). It had no test caller, so a refactor could silently swap the + * two forms. These lock in: (a) empty-vin -> T.1 (equals the standalone + * transparent digest), (b) non-empty-vin -> S.2 (distinct from T.1 and + * stable), and (c) validation refusal on malformed info. + * ===================================================================== */ + +// A P2PKH-shaped script_pubkey (25 bytes) for the fixtures. +static const uint8_t kScriptPubkey[25] = { + 0x76, 0xa9, 0x14, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x88, 0xac}; +static const uint8_t kPrevoutTxid[32] = { + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00}; + +// Empty vin (deshield): the Orchard transparent-sig digest is the T.1 form, +// which is exactly zcash_compute_transparent_digest over the same components. +TEST(Zcash, OrchardTransparentSigDigest_EmptyVinMatchesT1) { + ZcashTransparentOutputDigestInfo out = {/*value=*/50000, kScriptPubkey, + sizeof(kScriptPubkey)}; + + uint8_t s2_digest[32], t1_digest[32]; + ASSERT_TRUE(zcash_compute_orchard_transparent_sig_digest(nullptr, 0, &out, 1, + s2_digest)); + ASSERT_TRUE(zcash_compute_transparent_digest(nullptr, 0, &out, 1, t1_digest)); + EXPECT_EQ(memcmp(s2_digest, t1_digest, 32), 0); +} + +// Non-empty vin (shield): the S.2 form is used and MUST differ from the T.1 +// form over the identical inputs/outputs (the whole point of the shield fix), +// and it must be deterministic. +TEST(Zcash, OrchardTransparentSigDigest_NonEmptyVinIsS2NotT1) { + ZcashTransparentInputDigestInfo in = { + kPrevoutTxid, /*prevout_index=*/0, /*sequence=*/0xffffffff, + /*value=*/100000, kScriptPubkey, sizeof(kScriptPubkey)}; + ZcashTransparentOutputDigestInfo out = {/*value=*/50000, kScriptPubkey, + sizeof(kScriptPubkey)}; + + uint8_t s2_digest[32], t1_digest[32], s2_again[32]; + ASSERT_TRUE( + zcash_compute_orchard_transparent_sig_digest(&in, 1, &out, 1, s2_digest)); + ASSERT_TRUE(zcash_compute_transparent_digest(&in, 1, &out, 1, t1_digest)); + ASSERT_TRUE( + zcash_compute_orchard_transparent_sig_digest(&in, 1, &out, 1, s2_again)); + EXPECT_NE(memcmp(s2_digest, t1_digest, 32), 0); // S.2 != T.1 (the fix) + EXPECT_EQ(memcmp(s2_digest, s2_again, 32), 0); // deterministic +} + +// Malformed digest info (a nonzero script with a NULL pointer) is refused. +TEST(Zcash, OrchardTransparentSigDigest_RejectsMalformedInfo) { + ZcashTransparentOutputDigestInfo bad = {/*value=*/1, + /*script_pubkey=*/nullptr, + /*script_pubkey_size=*/25}; + uint8_t digest[32]; + EXPECT_FALSE(zcash_compute_orchard_transparent_sig_digest(nullptr, 0, &bad, 1, + digest)); +} From 5312b292e83864128af05b2edb38290fbc7c02b2 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:48:09 -0300 Subject: [PATCH 18/40] feat(chains): Tendermint-family signing policy and custom denoms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THORChain, MayaChain, Osmosis, Cosmos, Binance, Ripple and EOS: - A per-chain router decides how each message family is displayed and what may be signed, replacing the single shared path where one chain's assumptions leaked into another's confirmation screens. - RUJI/TCY and other custom denominations sign correctly instead of being rendered as an unknown asset with a raw base amount. - THORChain memos are shown in full — a truncated memo hides the swap target. - Osmosis denom paging handles pools whose denom lists exceed one screen. - XRP THORChain memos use the XRPL binary Memos format so the destination actually parses on-chain. - unittests cover the router decisions, denom formatting and memo rendering for each family, including the new binance.cpp and osmosis.cpp suites. --- include/keepkey/firmware/binance.h | 4 + include/keepkey/firmware/eos.h | 3 + include/keepkey/firmware/mayachain.h | 9 + include/keepkey/firmware/osmosis.h | 25 +- include/keepkey/firmware/signtx_tendermint.h | 15 +- include/keepkey/firmware/tendermint.h | 6 + include/keepkey/firmware/thorchain.h | 20 +- lib/firmware/binance.c | 76 ++- lib/firmware/eos.c | 26 +- lib/firmware/fsm_msg_binance.h | 45 +- lib/firmware/fsm_msg_cosmos.h | 31 +- lib/firmware/fsm_msg_mayachain.h | 99 ++- lib/firmware/fsm_msg_osmosis.h | 261 ++++---- lib/firmware/fsm_msg_ripple.h | 15 + lib/firmware/fsm_msg_tendermint.h | 75 ++- lib/firmware/fsm_msg_thorchain.h | 95 ++- lib/firmware/mayachain.c | 213 ++++-- lib/firmware/osmosis.c | 100 ++- lib/firmware/ripple.c | 19 + lib/firmware/signtx_tendermint.c | 94 ++- lib/firmware/tendermint.c | 42 ++ lib/firmware/thorchain.c | 292 +++++++-- unittests/firmware/CMakeLists.txt | 4 + unittests/firmware/binance.cpp | 83 +++ unittests/firmware/cosmos.cpp | 46 +- unittests/firmware/eos.cpp | 17 + unittests/firmware/mayachain.cpp | 176 ++++- unittests/firmware/osmosis.cpp | 158 +++++ unittests/firmware/thorchain.cpp | 644 +++++++++++++++++-- 29 files changed, 2239 insertions(+), 454 deletions(-) create mode 100644 unittests/firmware/binance.cpp create mode 100644 unittests/firmware/osmosis.cpp diff --git a/include/keepkey/firmware/binance.h b/include/keepkey/firmware/binance.h index 9f42dcc60..035b37094 100644 --- a/include/keepkey/firmware/binance.h +++ b/include/keepkey/firmware/binance.h @@ -12,6 +12,10 @@ typedef struct _BinanceTransferMsg BinanceTransferMsg; typedef struct _BinanceTransferMsg_BinanceInputOutput BinanceInputOutput; typedef struct _BinanceTransferMsg_BinanceCoin BinanceCoin; +#define BINANCE_MAX_DENOM_LEN 31 + +bool binance_isValidDenom(const char* denom); +bool binance_validateTransfer(const BinanceTransferMsg* transfer); bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg); bool binance_serializeCoin(const BinanceCoin* coin); bool binance_serializeInputOutput(const BinanceInputOutput* io); diff --git a/include/keepkey/firmware/eos.h b/include/keepkey/firmware/eos.h index 3f925c9b8..10c0cf4d6 100644 --- a/include/keepkey/firmware/eos.h +++ b/include/keepkey/firmware/eos.h @@ -86,6 +86,9 @@ uint32_t eos_actionsRemaining(void); bool eos_hasActionUnknownDataRemaining(void); +bool eos_isSupportedAction(const EosActionCommon* common); +bool eos_unknownActionPolicyAllows(bool advanced_mode); + /// \returns true iff successful. bool eos_compileActionUnknown(const EosActionCommon* common, const EosActionUnknown* action); diff --git a/include/keepkey/firmware/mayachain.h b/include/keepkey/firmware/mayachain.h index c3d40380d..9b541ac41 100644 --- a/include/keepkey/firmware/mayachain.h +++ b/include/keepkey/firmware/mayachain.h @@ -10,6 +10,15 @@ typedef struct _MayachainSignTx MayachainSignTx; typedef struct _MayachainMsgDeposit MayachainMsgDeposit; +// Returns true iff `denom` is a plausible MAYAChain denom: non-empty, +// and contains only lowercase alpha, digits, '.', '/', or '-'. +bool mayachain_isValidDenom(const char* denom); + +// Deposit asset grammar: as above but uppercase alpha also allowed. +bool mayachain_isValidAsset(const char* asset); +// Deposit signer must be bech32 with the active network's HRP. +bool mayachain_isValidSigner(const char* signer); + bool mayachain_signTxInit(const HDNode* _node, const MayachainSignTx* _msg); bool mayachain_signTxUpdateMsgSend(const uint64_t amount, const char* to_address, const char* denom); diff --git a/include/keepkey/firmware/osmosis.h b/include/keepkey/firmware/osmosis.h index 330c4b9a1..74325a125 100644 --- a/include/keepkey/firmware/osmosis.h +++ b/include/keepkey/firmware/osmosis.h @@ -5,6 +5,7 @@ #include "trezor/crypto/bip32.h" #include +#include #include typedef struct _OsmosisSignTx OsmosisSignTx; @@ -16,7 +17,8 @@ void debug_intermediate_hash(void); bool osmosis_signTxInit(const HDNode* _node, const OsmosisSignTx* _msg); -bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address); +bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address, + const char* denom); bool osmosis_signTxUpdateMsgDelegate(const char* amount, const char* delegator_address, @@ -66,6 +68,27 @@ bool osmosis_signTxUpdateMsgSwap(const uint64_t pool_id, const char* token_in_denom, const char* token_out_min_amount); +#define OSMOSIS_PRECISION 6 +#define OSMOSIS_MAX_AMOUNT_DIGITS 32 +#define OSMOSIS_MAX_DENOM_LEN 68 + +// Longest amount a confirm screen renders: the digits, a point, a space and +// the longest denom a message can carry. +#define OSMOSIS_AMOUNT_STR_LEN 103 + +/** + * Render an integer base-unit amount for a confirm screen: + * ("1500000", "uosmo") -> "1.500000 OSMO". + * + * Only uosmo is scaled — any other denom is shown exactly as the chain states + * it, because the device does not know its precision. Returns false unless the + * amount is a canonical, schema-bounded unsigned decimal and the denomination + * is a schema-bounded Cosmos asset identifier. Native uosmo additionally must + * fit uint64, which is the range accepted by the native-asset display policy. + */ +bool osmosis_formatAmount(char* out, size_t out_len, const char* value, + const char* denom); + bool osmosis_signTxFinalize(uint8_t* public_key, uint8_t* signature); bool osmosis_signingIsInited(void); bool osmosis_signingIsFinished(void); diff --git a/include/keepkey/firmware/signtx_tendermint.h b/include/keepkey/firmware/signtx_tendermint.h index 9277a370f..082fb4970 100644 --- a/include/keepkey/firmware/signtx_tendermint.h +++ b/include/keepkey/firmware/signtx_tendermint.h @@ -9,8 +9,15 @@ typedef struct _TendermintSignTx TendermintSignTx; +typedef enum { + TENDERMINT_SIGNING_NONE = 0, + TENDERMINT_SIGNING_COSMOS, + TENDERMINT_SIGNING_GENERIC, +} TendermintSigningType; + bool tendermint_signTxInit(const HDNode* _node, const void* _msg, - const size_t msgsize, const char* denom); + const size_t msgsize, const char* denom, + TendermintSigningType type); bool tendermint_signTxUpdateMsgSend(const uint64_t amount, const char* to_address, const char* chainstr, const char* denom, @@ -41,9 +48,11 @@ bool tendermint_signTxUpdateMsgIBCTransfer( const char* revision_number, const char* revision_height, const char* chainstr, const char* denom, const char* msgTypePrefix); bool tendermint_signTxFinalize(uint8_t* public_key, uint8_t* signature); -bool tendermint_signingIsInited(void); +bool tendermint_signingIsInited(TendermintSigningType type); +bool tendermint_signingConfigMatches(const char* chain_name, const char* denom, + const char* message_type_prefix); bool tendermint_signingIsFinished(void); void tendermint_signAbort(void); const void* tendermint_getSignTx(void); -#endif \ No newline at end of file +#endif diff --git a/include/keepkey/firmware/tendermint.h b/include/keepkey/firmware/tendermint.h index aa29399d1..67cad3ff2 100644 --- a/include/keepkey/firmware/tendermint.h +++ b/include/keepkey/firmware/tendermint.h @@ -28,6 +28,12 @@ bool tendermint_pathMismatched(const CoinType* coin, const uint32_t* address_n, bool tendermint_getAddress(const HDNode* node, const char* prefix, char* address); +bool tendermint_isValidDenom(const char* denom); + +bool tendermint_isValidAsset(const char* asset); + +bool tendermint_isValidSigner(const char* signer, const char* hrp); + void tendermint_sha256UpdateEscaped(SHA256_CTX* ctx, const char* s, size_t len); bool tendermint_snprintf(SHA256_CTX* ctx, char* temp, size_t len, diff --git a/include/keepkey/firmware/thorchain.h b/include/keepkey/firmware/thorchain.h index 5ebb2a993..398495863 100644 --- a/include/keepkey/firmware/thorchain.h +++ b/include/keepkey/firmware/thorchain.h @@ -10,9 +10,18 @@ typedef struct _ThorchainSignTx ThorchainSignTx; typedef struct _ThorchainMsgDeposit ThorchainMsgDeposit; +// Returns true iff denom contains only chars safe in JSON without escaping. +// Valid: [a-z0-9./\-]. Rejects empty string, quotes, backslashes, whitespace. +bool thorchain_isValidDenom(const char* denom); + +// Deposit asset grammar: as above but uppercase alpha also allowed. +bool thorchain_isValidAsset(const char* asset); +// Deposit signer must be bech32 with the active network's HRP. +bool thorchain_isValidSigner(const char* signer); + bool thorchain_signTxInit(const HDNode* _node, const ThorchainSignTx* _msg); bool thorchain_signTxUpdateMsgSend(const uint64_t amount, - const char* to_address); + const char* to_address, const char* denom); bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg); bool thorchain_signTxFinalize(uint8_t* public_key, uint8_t* signature); bool thorchain_signingIsInited(void); @@ -28,4 +37,13 @@ const ThorchainSignTx* thorchain_getThorchainSignTx(void); // true if thorchain data parsed and confirmed by user, false otherwise bool thorchain_parseConfirmMemo(const char* swapStr, size_t size); +// Pages the COMPLETE raw memo (ASCII as text pages, binary as hex pages) so no +// byte is ever truncated behind confirm()'s body budget. Native THOR/MAYA +// deposit/send handlers call this as the authoritative disclosure after their +// best-effort structured summary, so a field the structured view omits (or a +// long field that would truncate) can never be signed unseen. Returns false if +// the user rejects any page. Shared by the MAYA path (same memo grammar). +bool thorchain_confirm_full_memo(const char* title, const char* memo, + size_t len); + #endif diff --git a/lib/firmware/binance.c b/lib/firmware/binance.c index 868301db4..aac7f8aba 100644 --- a/lib/firmware/binance.c +++ b/lib/firmware/binance.c @@ -16,12 +16,49 @@ static BinanceSignTx msg; const BinanceSignTx* binance_getBinanceSignTx(void) { return &msg; } +bool binance_isValidDenom(const char* denom) { + if (!denom) return false; + const size_t len = strnlen(denom, BINANCE_MAX_DENOM_LEN + 1); + if (len == 0 || len > BINANCE_MAX_DENOM_LEN) return false; + for (size_t i = 0; i < len; i++) { + const char c = denom[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-')) + return false; + } + return true; +} + +bool binance_validateTransfer(const BinanceTransferMsg* transfer) { + if (!transfer || transfer->inputs_count != 1 || + transfer->inputs[0].coins_count != 1 || transfer->outputs_count != 1 || + transfer->outputs[0].coins_count != 1) + return false; + + const BinanceInputOutput* input = &transfer->inputs[0]; + const BinanceInputOutput* output = &transfer->outputs[0]; + const BinanceCoin* input_coin = &input->coins[0]; + const BinanceCoin* output_coin = &output->coins[0]; + if (!input->has_address || !output->has_address || !input_coin->has_amount || + !output_coin->has_amount || !input_coin->has_denom || + !output_coin->has_denom || input_coin->amount <= 0 || + output_coin->amount <= 0 || input_coin->amount != output_coin->amount || + strcmp(input_coin->denom, output_coin->denom) != 0 || + !binance_isValidDenom(input_coin->denom)) + return false; + + return true; +} + bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg) { - initialized = true; + binance_signAbort(); + if (!_node || !_msg || !_msg->has_msg_count || _msg->msg_count == 0 || + !_msg->has_account_number || _msg->account_number < 0 || + !_msg->has_chain_id || _msg->chain_id[0] == '\0' || !_msg->has_sequence || + _msg->sequence < 0 || !_msg->has_source || _msg->source < 0) + return false; + msgs_remaining = _msg->msg_count; - has_message = false; - memzero(&node, sizeof(node)); memcpy(&node, _node, sizeof(node)); memcpy(&msg, _msg, sizeof(msg)); @@ -32,7 +69,7 @@ bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg) { success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "{\"account_number\":\"%" PRIu64 "\"", - msg.account_number); + (uint64_t)msg.account_number); const char* const chainid_prefix = ",\"chain_id\":\""; sha256_Update(&ctx, (uint8_t*)chainid_prefix, strlen(chainid_prefix)); @@ -45,16 +82,25 @@ bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg) { } sha256_Update(&ctx, (const uint8_t*)"\",\"msgs\":[", 10); - return success; + if (!success) { + binance_signAbort(); + return false; + } + initialized = true; + return true; } bool binance_serializeCoin(const BinanceCoin* coin) { + if (!coin || !coin->has_amount || coin->amount <= 0 || !coin->has_denom || + !binance_isValidDenom(coin->denom)) + return false; + bool success = true; char buffer[64 + 1]; success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "{\"amount\":%" PRIu64 ",\"denom\":\"%s\"}", - coin->amount, coin->denom); + (uint64_t)coin->amount, coin->denom); return success; } @@ -83,6 +129,9 @@ bool binance_serializeInputOutput(const BinanceInputOutput* io) { } bool binance_signTxUpdateTransfer(const BinanceTransferMsg* _msg) { + if (!initialized || msgs_remaining == 0 || !binance_validateTransfer(_msg)) + return false; + bool success = true; sha256_Update(&ctx, (const uint8_t*)"{\"inputs\":[", 11); @@ -103,18 +152,23 @@ bool binance_signTxUpdateTransfer(const BinanceTransferMsg* _msg) { sha256_Update(&ctx, (const uint8_t*)"]}", 2); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } bool binance_signTxFinalize(uint8_t* public_key, uint8_t* signature) { + if (!initialized || msgs_remaining != 0 || !has_message || !public_key || + !signature) + return false; char buffer[64 + 1]; if (!tendermint_snprintf(&ctx, buffer, sizeof(buffer), "],\"sequence\":\"%" PRIu64 "\",\"source\":\"%" PRIu64 "\"}", - msg.sequence, msg.source)) + (uint64_t)msg.sequence, (uint64_t)msg.source)) return false; hdnode_fill_public_key(&node); @@ -128,7 +182,9 @@ bool binance_signTxFinalize(uint8_t* public_key, uint8_t* signature) { bool binance_signingIsInited(void) { return initialized; } -bool binance_signingIsFinished(void) { return msgs_remaining == 0; } +bool binance_signingIsFinished(void) { + return initialized && msgs_remaining == 0 && has_message; +} void binance_signAbort(void) { initialized = false; diff --git a/lib/firmware/eos.c b/lib/firmware/eos.c index 45e7acacc..ab40aa4bf 100644 --- a/lib/firmware/eos.c +++ b/lib/firmware/eos.c @@ -385,7 +385,7 @@ bool eos_compilePermissionLevel(const EosPermissionLevel* auth) { bool eos_hasActionUnknownDataRemaining(void) { return 0 < unknown_remaining; } -static bool isSupportedAction(const EosActionCommon* common) { +bool eos_isSupportedAction(const EosActionCommon* common) { if (common->account == EOS_eosio || common->account == EOS_eosio_token) { switch (common->name) { case EOS_Transfer: @@ -402,15 +402,18 @@ static bool isSupportedAction(const EosActionCommon* common) { case EOS_DeleteAuth: case EOS_LinkAuth: case EOS_UnlinkAuth: + case EOS_NewAccount: return true; } } return false; } +bool eos_unknownActionPolicyAllows(bool advanced_mode) { return advanced_mode; } + bool eos_compileActionUnknown(const EosActionCommon* common, const EosActionUnknown* action) { - if (isSupportedAction(common)) { + if (eos_isSupportedAction(common)) { fsm_sendFailure( FailureType_Failure_SyntaxError, "EosActionUnknown cannot be used with supported contract actions"); @@ -418,10 +421,15 @@ bool eos_compileActionUnknown(const EosActionCommon* common, return false; } - if (!storage_isPolicyEnabled("AdvancedMode")) { - (void)review(ButtonRequestType_ButtonRequest_Other, "Warning", - "Signing of arbitrary EOS actions is recommended only for " - "experienced users. Enable 'AdvancedMode' policy to dismiss."); + if (!eos_unknownActionPolicyAllows(storage_isPolicyEnabled("AdvancedMode"))) { + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "Arbitrary EOS actions require AdvancedMode. " + "Enable in device settings."); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "Arbitrary EOS action signing disabled by policy"); + eos_signingAbort(); + layoutHome(); + return false; } if (unknown_remaining == 0) { @@ -534,7 +542,13 @@ bool eos_signTx(EosSignedTx* tx) { time_t expiry = header.expiration; char expiry_str[26]; +#ifdef _WIN32 + // asctime_s is the bounds-checked Windows variant; output truncated below. + // cppcheck-suppress asctime_sCalled + asctime_s(expiry_str, sizeof(expiry_str), gmtime(&expiry)); +#else asctime_r(gmtime(&expiry), expiry_str); +#endif expiry_str[24] = 0; // cut off the '\n' uint32_t delay = header.delay_sec; if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", diff --git a/lib/firmware/fsm_msg_binance.h b/lib/firmware/fsm_msg_binance.h index 0adf00c62..72f75b2e5 100644 --- a/lib/firmware/fsm_msg_binance.h +++ b/lib/firmware/fsm_msg_binance.h @@ -95,30 +95,38 @@ static void binance_response(void); void fsm_msgBinanceTransferMsg(const BinanceTransferMsg* msg) { CHECK_PARAM(binance_signingIsInited(), "Signing not in progress?"); - CHECK_PARAM(msg->inputs_count == 1, "Malformed BinanceTransferMsg") - CHECK_PARAM(msg->inputs[0].coins_count == 1, "Malformed BinanceTransferMsg") - CHECK_PARAM(msg->outputs_count == 1, "Malformed BinanceTransferMsg") - CHECK_PARAM(msg->outputs[0].coins_count == 1, "Malformed BinanceTransferMsg") - CHECK_PARAM(msg->inputs[0].coins[0].amount == msg->outputs[0].coins[0].amount, - "Malformed BinanceTransferMsg") - CHECK_PARAM(strcmp(msg->inputs[0].coins[0].denom, - msg->outputs[0].coins[0].denom) == 0, - "Malformed BinanceTransferMsg") + if (!binance_validateTransfer(msg)) { + binance_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Malformed BinanceTransferMsg"); + layoutHome(); + return; + } const CoinType* coin = fsm_getCoin(true, "Binance"); if (!coin) { + binance_signAbort(); + layoutHome(); return; } switch (msg->outputs[0].address_type) { case OutputAddressType_TRANSFER: default: { - char amount_str[42]; - char denom_str[14]; - snprintf(denom_str, strlen(msg->outputs[0].coins[0].denom) + 2, " %s", - msg->outputs[0].coins[0].denom); - bn_format_uint64(msg->outputs[0].coins[0].amount, NULL, denom_str, 8, 0, - false, amount_str, sizeof(amount_str)); + char amount_str[64]; + char denom_str[BINANCE_MAX_DENOM_LEN + 2]; + const int denom_len = snprintf(denom_str, sizeof(denom_str), " %s", + msg->outputs[0].coins[0].denom); + if (denom_len <= 0 || (size_t)denom_len >= sizeof(denom_str) || + !bn_format_uint64((uint64_t)msg->outputs[0].coins[0].amount, NULL, + denom_str, 8, 0, false, amount_str, + sizeof(amount_str))) { + binance_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid Binance transfer amount"); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->outputs[0].address)) { @@ -151,13 +159,16 @@ static void binance_response(void) { const CoinType* coin = fsm_getCoin(true, "Binance"); if (!coin) { + binance_signAbort(); + layoutHome(); return; } const BinanceSignTx* sign_tx = binance_getBinanceSignTx(); - if (sign_tx->has_memo && !confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, - _("Memo"), "%s", sign_tx->memo)) { + if (sign_tx->has_memo && + !confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), + (const uint8_t*)sign_tx->memo, strlen(sign_tx->memo))) { binance_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); diff --git a/lib/firmware/fsm_msg_cosmos.h b/lib/firmware/fsm_msg_cosmos.h index 082fe2eed..b7aff7560 100644 --- a/lib/firmware/fsm_msg_cosmos.h +++ b/lib/firmware/fsm_msg_cosmos.h @@ -92,7 +92,8 @@ void fsm_msgCosmosSignTx(const CosmosSignTx* msg) { RESP_INIT(CosmosMsgRequest); - if (!tendermint_signTxInit(node, (void*)msg, sizeof(CosmosSignTx), "uatom")) { + if (!tendermint_signTxInit(node, (void*)msg, sizeof(CosmosSignTx), "uatom", + TENDERMINT_SIGNING_COSMOS)) { tendermint_signAbort(); memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_FirmwareError, @@ -108,7 +109,8 @@ void fsm_msgCosmosSignTx(const CosmosSignTx* msg) { void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { // Confirm transaction basics - CHECK_PARAM(tendermint_signingIsInited(), "Signing not in progress"); + CHECK_PARAM(tendermint_signingIsInited(TENDERMINT_SIGNING_COSMOS), + "Cosmos signing not in progress"); const CoinType* coin = fsm_getCoin(true, "Cosmos"); if (!coin) { @@ -375,12 +377,13 @@ void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { } } else if (msg->has_ibc_transfer) { /** Confirm required transaction parameters exist */ - if (!msg->ibc_transfer.has_sender || + if (!msg->ibc_transfer.has_receiver || !msg->ibc_transfer.has_sender || !msg->ibc_transfer.has_source_channel || !msg->ibc_transfer.has_source_port || !msg->ibc_transfer.has_revision_height || !msg->ibc_transfer.has_revision_number || - !msg->ibc_transfer.has_denom) { + !msg->ibc_transfer.has_denom || !msg->ibc_transfer.has_amount || + strcmp(msg->ibc_transfer.denom, "uatom") != 0) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -393,7 +396,19 @@ void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { amount_str, sizeof(amount_str)); if (!confirm(ButtonRequestType_ButtonRequest_Other, "IBC Transfer", - "Transfer %s to %s?", amount_str, msg->ibc_transfer.sender)) { + "Transfer %s via IBC?", amount_str)) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "IBC Sender", + (const uint8_t*)msg->ibc_transfer.sender, + strlen(msg->ibc_transfer.sender)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "IBC Receiver", + (const uint8_t*)msg->ibc_transfer.receiver, + strlen(msg->ibc_transfer.receiver))) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -462,8 +477,8 @@ void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { } if (sign_tx->has_memo && (strlen(sign_tx->memo) > 0)) { - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), "%s", - sign_tx->memo)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), + (const uint8_t*)sign_tx->memo, strlen(sign_tx->memo))) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -509,4 +524,4 @@ void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { tendermint_signAbort(); layoutHome(); msg_write(MessageType_MessageType_CosmosSignedTx, resp); -} \ No newline at end of file +} diff --git a/lib/firmware/fsm_msg_mayachain.h b/lib/firmware/fsm_msg_mayachain.h index be9229665..4b2daaf2c 100644 --- a/lib/firmware/fsm_msg_mayachain.h +++ b/lib/firmware/fsm_msg_mayachain.h @@ -141,15 +141,38 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { const MayachainSignTx* sign_tx = mayachain_getMayachainSignTx(); + // Default to "cacao" for backward compatibility; validate all non-default + // denoms before any display so untrusted strings never reach the UI or + // the signing JSON. + const char* coin_denom = + (msg->has_send && msg->send.has_denom && msg->send.denom[0]) + ? msg->send.denom + : "cacao"; + if (msg->has_send) { + if (!mayachain_isValidDenom(coin_denom)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, "Invalid denom"); + layoutHome(); + return; + } + switch (msg->send.address_type) { case OutputAddressType_TRANSFER: default: { + // Amount (no denom suffix) must fit amount_str[32]; a long denom + // appended here would overflow bn_format and blank the amount while + // the real value is still signed. Confirm the denom on its own + // screen instead (matches the THORChain send path). char amount_str[32]; - char denom_str[71]; - sprintf(denom_str, " %s", msg->send.denom); - bn_format_uint64(msg->send.amount, NULL, denom_str, 10, 0, false, - amount_str, sizeof(amount_str)); + if (!bn_format_uint64(msg->send.amount, NULL, NULL, 10, 0, false, + amount_str, sizeof(amount_str))) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to format amount")); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->send.to_address)) { @@ -158,12 +181,19 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { layoutHome(); return; } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Asset", + "%s", coin_denom)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } break; } } if (!mayachain_signTxUpdateMsgSend(msg->send.amount, msg->send.to_address, - msg->send.denom)) { + coin_denom)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, "Failed to include send message in transaction"); @@ -172,8 +202,21 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } } else if (msg->has_deposit) { - char amount_str[32]; - char asset_str[21]; + // Validate before any display so untrusted strings never reach the UI + // or the sign bytes. + if (!mayachain_isValidAsset(msg->deposit.asset) || + !mayachain_isValidSigner(msg->deposit.signer)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid deposit asset or signer"); + layoutHome(); + return; + } + // Long-form assets (e.g. + // ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7) are ~50 chars; + // amount_str must fit amount + asset suffix or bn_format zeroes it out. + char amount_str[96]; + char asset_str[64]; asset_str[0] = ' '; strlcpy(&(asset_str[1]), msg->deposit.asset, sizeof(asset_str) - 1); bn_format_uint64(msg->deposit.amount, NULL, asset_str, 10, 0, false, @@ -188,17 +231,16 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } if (msg->deposit.has_memo) { - // See if we can parse the memo - if (!mayachain_parseConfirmMemo(msg->deposit.memo, - sizeof(msg->deposit.memo))) { - // Memo not recognizable, ask to confirm it - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), - "%s", msg->deposit.memo)) { - mayachain_signAbort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); - layoutHome(); - return; - } + size_t memo_len = strnlen(msg->deposit.memo, sizeof(msg->deposit.memo)); + // Page the complete raw memo as the sole, authoritative disclosure (no + // structured pre-parse: its bool return conflates unrecognized with user + // reject, so a reject must not be followed by these pages then signing). + if (!thorchain_confirm_full_memo(_("Memo"), msg->deposit.memo, + memo_len)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } @@ -218,17 +260,14 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } if (sign_tx->has_memo && !msg->deposit.has_memo) { - // See if we can parse the tx memo. This memo ignored if deposit msg has - // memo - if (!mayachain_parseConfirmMemo(sign_tx->memo, sizeof(sign_tx->memo))) { - // Memo not recognizable, ask to confirm it - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), "%s", - sign_tx->memo)) { - mayachain_signAbort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); - layoutHome(); - return; - } + // Ignored if the deposit msg has a memo. Page the full raw memo as the sole + // gate (see the deposit path above for why there is no structured pass). + size_t memo_len = strnlen(sign_tx->memo, sizeof(sign_tx->memo)); + if (!thorchain_confirm_full_memo(_("Memo"), sign_tx->memo, memo_len)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } @@ -245,7 +284,7 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { if (!confirm(ButtonRequestType_ButtonRequest_SignTx, node_str, "Sign this %s transaction on %s? " "Additional network fees apply.", - msg->has_send ? msg->send.denom : "CACAO", sign_tx->chain_id)) { + msg->has_send ? coin_denom : "CACAO", sign_tx->chain_id)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); diff --git a/lib/firmware/fsm_msg_osmosis.h b/lib/firmware/fsm_msg_osmosis.h index c654f744d..b64daf9a8 100644 --- a/lib/firmware/fsm_msg_osmosis.h +++ b/lib/firmware/fsm_msg_osmosis.h @@ -1,7 +1,16 @@ -#include -#define OSMOSIS_PRECISION 6 #define OSMOSIS_LP_ASSET_PRECISION 18 +static bool osmosis_formatAmountOrFail(char* out, size_t out_len, + const char* value, const char* denom) { + if (osmosis_formatAmount(out, out_len, value, denom)) return true; + + osmosis_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid Osmosis amount or denomination"); + layoutHome(); + return false; +} + void fsm_msgOsmosisGetAddress(const OsmosisGetAddress* msg) { RESP_INIT(OsmosisAddress); @@ -132,7 +141,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { /** Confirm required transaction parameters exist */ if (msg->has_send) { - if (!msg->send.has_to_address || !msg->send.has_amount) { + if (!msg->send.has_to_address || !msg->send.has_amount || + !msg->send.has_denom) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -140,27 +150,28 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->send.amount); - const char* denom = msg->send.denom; - if (!strcmp(msg->send.denom, "uosmo")) { - amount /= pow(10, OSMOSIS_PRECISION); - denom = "OSMO"; + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->send.amount, msg->send.denom)) { + return; } - char amount_str[103]; - snprintf(amount_str, sizeof(amount_str) - 1, "%.6f %s", amount, denom); - - /** Confirm transaction parameters on screen */ - if (!confirm_transaction_output( - ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, - msg->send.to_address)) { + // Amount and destination are independent renderer-measured disclosures. + // A single wrapped "Send ... to ..." body could hide the destination. + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Send Amount", (const uint8_t*)amount_str, + strlen(amount_str)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send To", + (const uint8_t*)msg->send.to_address, + strlen(msg->send.to_address))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!osmosis_signTxUpdateMsgSend(msg->send.amount, msg->send.to_address)) { + if (!osmosis_signTxUpdateMsgSend(msg->send.amount, msg->send.to_address, + msg->send.denom)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, "Failed to include send message in transaction"); @@ -171,7 +182,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } else if (msg->has_delegate) { /** Confirm required transaction parameters exist */ if (!msg->delegate.has_delegator_address || - !msg->delegate.has_validator_address || !msg->delegate.has_amount) { + !msg->delegate.has_validator_address || !msg->delegate.has_amount || + !msg->delegate.has_denom) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -179,11 +191,11 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->delegate.amount); - const char* denom = msg->delegate.denom; - if (!strcmp(msg->delegate.denom, "uosmo")) { - amount /= pow(10, OSMOSIS_PRECISION); - denom = "OSMO"; + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->delegate.amount, + msg->delegate.denom)) { + return; } /** Confirm transaction parameters on-screen */ @@ -203,8 +215,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", - "%.6f %s", amount, denom)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -223,7 +235,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } else if (msg->has_undelegate) { /** Confirm required transaction parameters exist */ if (!msg->undelegate.has_delegator_address || - !msg->undelegate.has_validator_address || !msg->undelegate.has_amount) { + !msg->undelegate.has_validator_address || !msg->undelegate.has_amount || + !msg->undelegate.has_denom) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -231,11 +244,11 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->undelegate.amount); - const char* denom = msg->undelegate.denom; - if (!strcmp(msg->undelegate.denom, "uosmo")) { - amount /= pow(10, OSMOSIS_PRECISION); - denom = "OSMO"; + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->undelegate.amount, + msg->undelegate.denom)) { + return; } /** Confirm transaction parameters on-screen */ @@ -255,8 +268,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", - "%.6f %s", amount, denom)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -285,44 +298,45 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - char insoamt[33] = {0}; - uint8_t outsoamt[34] = {0}; - strlcpy(insoamt, msg->lp_add.share_out_amount, - sizeof(msg->lp_add.share_out_amount)); - - if (base_to_precision(outsoamt, (uint8_t*)insoamt, sizeof(outsoamt), - strlen(insoamt), OSMOSIS_LP_ASSET_PRECISION) < 0) { + char outsoamt[34] = {0}; + if (base_to_precision( + (uint8_t*)outsoamt, (const uint8_t*)msg->lp_add.share_out_amount, + sizeof(outsoamt), strlen(msg->lp_add.share_out_amount), + OSMOSIS_LP_ASSET_PRECISION) < 0) { osmosis_signAbort(); - fsm_sendFailure(FailureType_Failure_Other, NULL); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid LP share amount"); layoutHome(); return; } - float amount_in_max_b = atof(msg->lp_add.amount_in_max_b); - const char* denom_in_max_b = msg->lp_add.denom_in_max_b; - if (!strcmp(msg->lp_add.denom_in_max_b, "uosmo")) { - amount_in_max_b /= pow(10, OSMOSIS_PRECISION); - denom_in_max_b = "OSMO"; + char amount_in_max_b_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_in_max_b_str, sizeof(amount_in_max_b_str), + msg->lp_add.amount_in_max_b, msg->lp_add.denom_in_max_b)) { + return; } - float amount_in_max_a = atof(msg->lp_add.amount_in_max_a); - const char* denom_in_max_a = msg->lp_add.denom_in_max_a; - if (!strcmp(msg->lp_add.denom_in_max_a, "uosmo")) { - amount_in_max_a /= pow(10, OSMOSIS_PRECISION); - denom_in_max_a = "OSMO"; + char amount_in_max_a_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_in_max_a_str, sizeof(amount_in_max_a_str), + msg->lp_add.amount_in_max_a, msg->lp_add.denom_in_max_a)) { + return; } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Liquidity", - "Deposit %.6f %s and...", amount_in_max_b, denom_in_max_b)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Max Deposit A", + (const uint8_t*)amount_in_max_a_str, + strlen(amount_in_max_a_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Liquidity", - "... %.6f %s?", amount_in_max_a, denom_in_max_a)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Max Deposit B", + (const uint8_t*)amount_in_max_b_str, + strlen(amount_in_max_b_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -330,16 +344,16 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Pool ID", - "%lld", msg->lp_add.pool_id)) { + "%" PRIu64, msg->lp_add.pool_id)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, - "Confirm Share Out Amount", "Receive %s GAMM-%lld shares?", - outsoamt, msg->lp_add.pool_id)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum LP Shares", (const uint8_t*)outsoamt, + strlen(outsoamt))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -372,45 +386,45 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - char insoamt[33] = {0}; - uint8_t outsoamt[34] = {0}; - strlcpy(insoamt, msg->lp_remove.share_in_amount, - sizeof(msg->lp_remove.share_in_amount)); - - if (base_to_precision(outsoamt, (uint8_t*)insoamt, sizeof(outsoamt), - strlen(insoamt), OSMOSIS_LP_ASSET_PRECISION) < 0) { + char outsoamt[34] = {0}; + if (base_to_precision( + (uint8_t*)outsoamt, (const uint8_t*)msg->lp_remove.share_in_amount, + sizeof(outsoamt), strlen(msg->lp_remove.share_in_amount), + OSMOSIS_LP_ASSET_PRECISION) < 0) { osmosis_signAbort(); - fsm_sendFailure(FailureType_Failure_Other, NULL); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid LP share amount"); layoutHome(); return; } - float amount_out_min_b = atof(msg->lp_remove.amount_out_min_b); - const char* denom_out_min_b = msg->lp_remove.denom_out_min_b; - if (!strcmp(msg->lp_remove.denom_out_min_b, "uosmo")) { - amount_out_min_b /= pow(10, OSMOSIS_PRECISION); - denom_out_min_b = "OSMO"; + char amount_out_min_b_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_out_min_b_str, sizeof(amount_out_min_b_str), + msg->lp_remove.amount_out_min_b, msg->lp_remove.denom_out_min_b)) { + return; } - float amount_out_min_a = atof(msg->lp_remove.amount_out_min_a); - const char* denom_out_min_a = msg->lp_remove.denom_out_min_a; - if (!strcmp(msg->lp_remove.denom_out_min_a, "uosmo")) { - amount_out_min_a /= pow(10, OSMOSIS_PRECISION); - denom_out_min_a = "OSMO"; + char amount_out_min_a_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_out_min_a_str, sizeof(amount_out_min_a_str), + msg->lp_remove.amount_out_min_a, msg->lp_remove.denom_out_min_a)) { + return; } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Remove Liquidity", - "Withdraw %.6f %s and...", amount_out_min_b, - denom_out_min_b)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum Output A", (const uint8_t*)amount_out_min_a_str, + strlen(amount_out_min_a_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Remove Liquidity", - "... %.6f %s ?", amount_out_min_a, denom_out_min_a)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum Output B", (const uint8_t*)amount_out_min_b_str, + strlen(amount_out_min_b_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -418,16 +432,16 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Pool ID", - "%lld", msg->lp_remove.pool_id)) { + "%" PRIu64, msg->lp_remove.pool_id)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Pool share amount", - "Redeem %s GAMM-%lld shares?", outsoamt, - msg->lp_remove.pool_id)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "LP Shares to Redeem", (const uint8_t*)outsoamt, + strlen(outsoamt))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -450,7 +464,7 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { if (!msg->redelegate.has_delegator_address || !msg->redelegate.has_validator_src_address || !msg->redelegate.has_validator_dst_address || - !msg->redelegate.has_amount) { + !msg->redelegate.has_amount || !msg->redelegate.has_denom) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -458,11 +472,24 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->redelegate.amount) / pow(10, OSMOSIS_PRECISION); + if (strcmp(msg->redelegate.denom, "uosmo") != 0) { + osmosis_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Only uosmo is supported for Osmosis redelegation"); + layoutHome(); + return; + } + + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->redelegate.amount, + msg->redelegate.denom)) { + return; + } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Redelegate", - "Redelegate %.6f OSMO?", amount)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Redelegate", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -559,24 +586,27 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float token_in_amount = atof(msg->swap.token_in_amount); - const char* token_in_denom = msg->swap.token_in_denom; - if (!strcmp(msg->swap.token_in_denom, "uosmo")) { - token_in_amount /= pow(10, OSMOSIS_PRECISION); - token_in_denom = "OSMO"; + char token_in_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(token_in_str, sizeof(token_in_str), + msg->swap.token_in_amount, + msg->swap.token_in_denom)) { + return; } - float token_out_min_amount = atof(msg->swap.token_out_min_amount); - const char* token_out_denom = msg->swap.token_out_denom; - if (!strcmp(msg->swap.token_out_denom, "uosmo")) { - token_out_min_amount /= pow(10, OSMOSIS_PRECISION); - token_out_denom = "OSMO"; + char token_out_min_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + token_out_min_str, sizeof(token_out_min_str), + msg->swap.token_out_min_amount, msg->swap.token_out_denom)) { + return; } - /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Swap", - "Swap %.6f %s for at least %.6f %s?", token_in_amount, - token_in_denom, token_out_min_amount, token_out_denom)) { + // Each signed asset is paged independently so neither the input denom nor + // the minimum output can fall below the OLED's three visible body rows. + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Swap Input", + (const uint8_t*)token_in_str, strlen(token_in_str)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Minimum Output", + (const uint8_t*)token_out_min_str, + strlen(token_out_min_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -584,7 +614,7 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Pool ID", - "%lld", msg->swap.pool_id)) { + "%" PRIu64, msg->swap.pool_id)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -609,7 +639,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { !msg->ibc_transfer.has_source_port || !msg->ibc_transfer.has_revision_height || !msg->ibc_transfer.has_revision_number || - !msg->ibc_transfer.has_denom) { + !msg->ibc_transfer.has_denom || !msg->ibc_transfer.has_amount || + !msg->ibc_transfer.has_receiver) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -617,16 +648,16 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->ibc_transfer.amount); - const char* denom = msg->ibc_transfer.denom; - if (!strcmp(msg->ibc_transfer.denom, "uosmo")) { - amount /= pow(10, OSMOSIS_PRECISION); - denom = "OSMO"; + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->ibc_transfer.amount, + msg->ibc_transfer.denom)) { + return; } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "IBC Transfer", - "Transfer %.6f %s?", amount, denom)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "IBC Transfer", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -702,8 +733,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } if (sign_tx->has_memo && (strlen(sign_tx->memo) > 0)) { - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), "%s", - sign_tx->memo)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), + (const uint8_t*)sign_tx->memo, strlen(sign_tx->memo))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -748,4 +779,4 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { osmosis_signAbort(); layoutHome(); msg_write(MessageType_MessageType_OsmosisSignedTx, resp); -} \ No newline at end of file +} diff --git a/lib/firmware/fsm_msg_ripple.h b/lib/firmware/fsm_msg_ripple.h index ddd25af35..bbe7fa6cf 100644 --- a/lib/firmware/fsm_msg_ripple.h +++ b/lib/firmware/fsm_msg_ripple.h @@ -109,6 +109,21 @@ void fsm_msgRippleSignTx(RippleSignTx* msg) { } } + if (msg->has_memo && msg->memo[0] != '\0') { + /* Page the COMPLETE memo (72-char ASCII / 40-byte hex pages) like every + * other memo surface. A single unpaged confirm renders only 3 OLED lines, + * silently drops the overflow, and honors embedded newlines — so a memo + * whose visible first line looks benign could carry ~180 signed-but-unseen + * bytes into the Memos field that exchanges and bridges use for deposit + * routing. */ + if (!thorchain_confirm_full_memo("Memo", msg->memo, strlen(msg->memo))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); + layoutHome(); + return; + } + } + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Transaction", "Really send %s, with a transaction fee of %s?", amount_string, fee_string)) { diff --git a/lib/firmware/fsm_msg_tendermint.h b/lib/firmware/fsm_msg_tendermint.h index 42a031e38..320acc55d 100644 --- a/lib/firmware/fsm_msg_tendermint.h +++ b/lib/firmware/fsm_msg_tendermint.h @@ -94,7 +94,7 @@ void fsm_msgTendermintSignTx(const TendermintSignTx* msg) { RESP_INIT(TendermintMsgRequest); if (!tendermint_signTxInit(node, (void*)msg, sizeof(TendermintSignTx), - msg->denom)) { + msg->denom, TENDERMINT_SIGNING_GENERIC)) { tendermint_signAbort(); memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_FirmwareError, @@ -110,7 +110,20 @@ void fsm_msgTendermintSignTx(const TendermintSignTx* msg) { void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { // Confirm transaction basics - CHECK_PARAM(tendermint_signingIsInited(), "Signing not in progress"); + CHECK_PARAM(tendermint_signingIsInited(TENDERMINT_SIGNING_GENERIC), + "Tendermint signing not in progress"); + const TendermintSignTx* sign_tx = + (const TendermintSignTx*)tendermint_getSignTx(); + if (!msg->has_chain_name || !msg->has_denom || + !msg->has_message_type_prefix || + !tendermint_signingConfigMatches(msg->chain_name, msg->denom, + msg->message_type_prefix)) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Tendermint ACK does not match signing session"); + layoutHome(); + return; + } if (!msg->has_send || !msg->send.has_to_address || !msg->send.has_amount) { tendermint_signAbort(); // 8 + ^14 + 13 + 1 = 36 @@ -124,23 +137,28 @@ void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { } const CoinType* coin = fsm_getCoin(true, msg->chain_name); - if (!coin || !coin->has_coin_shortcut || !coin->has_decimals) { + if (!coin) { + tendermint_signAbort(); + layoutHome(); return; } - const TendermintSignTx* sign_tx = (TendermintSignTx*)tendermint_getSignTx(); - switch (msg->send.address_type) { case OutputAddressType_TRANSFER: default: { - char amount_str[32]; - char suffix[sizeof(coin->coin_shortcut) + - 1]; // sizeof(coin->coin_shortcut) includes space for the - // terminator - strlcpy(suffix, " ", sizeof(suffix)); - strlcat(suffix, coin->coin_shortcut, sizeof(suffix)); - bn_format_uint64(msg->send.amount, NULL, suffix, coin->decimals, 0, false, - amount_str, sizeof(amount_str)); + /* The host-supplied denomination is part of the signed Amino JSON. Do + * not relabel or rescale it using unrelated coin metadata. */ + char amount_str[48]; + const int amount_len = + snprintf(amount_str, sizeof(amount_str), "%" PRIu64 " %s", + msg->send.amount, msg->denom); + if (amount_len <= 0 || (size_t)amount_len >= sizeof(amount_str)) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid Tendermint amount display"); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->send.to_address)) { @@ -170,8 +188,29 @@ void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { return; } - if (sign_tx->has_memo && !confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, - _("Memo"), "%s", sign_tx->memo)) { + if (sign_tx->has_memo && + !confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), + (const uint8_t*)sign_tx->memo, strlen(sign_tx->memo))) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + /* Review the exact session values that bind the signed JSON. These fields + * are host supplied, so a friendly chain label must never substitute for + * the denomination or message-type prefix actually hashed. */ + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Chain ID", + (const uint8_t*)sign_tx->chain_id, + strlen(sign_tx->chain_id)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Chain Name", + (const uint8_t*)sign_tx->chain_name, + strlen(sign_tx->chain_name)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Denomination", + (const uint8_t*)sign_tx->denom, strlen(sign_tx->denom)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Message Type", + (const uint8_t*)sign_tx->message_type_prefix, + strlen(sign_tx->message_type_prefix))) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -189,10 +228,8 @@ void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_SignTx, node_str, - "Sign %s transaction on %s? " - "It includes a fee of %" PRIu32 " %s and %" PRIu32 " gas.", - msg->chain_name, sign_tx->chain_id, sign_tx->fee_amount, - msg->denom, sign_tx->gas)) { + "Sign transaction? Fee: %" PRIu32 " %s. Gas: %" PRIu32 ".", + sign_tx->fee_amount, sign_tx->denom, sign_tx->gas)) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); diff --git a/lib/firmware/fsm_msg_thorchain.h b/lib/firmware/fsm_msg_thorchain.h index 801af7872..25912a383 100644 --- a/lib/firmware/fsm_msg_thorchain.h +++ b/lib/firmware/fsm_msg_thorchain.h @@ -141,12 +141,31 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { const ThorchainSignTx* sign_tx = thorchain_getThorchainSignTx(); if (msg->has_send) { + const char* coin_denom = + (msg->send.has_denom && msg->send.denom[0]) ? msg->send.denom : "rune"; + + // Validate before any display so untrusted strings never reach the UI. + if (!thorchain_isValidDenom(coin_denom)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, "Invalid denom"); + layoutHome(); + return; + } + switch (msg->send.address_type) { case OutputAddressType_TRANSFER: default: { + // amount_str only needs to hold the numeric part (no denom suffix). + // Denom is confirmed on a separate screen so no truncation is possible. char amount_str[32]; - bn_format_uint64(msg->send.amount, NULL, " RUNE", 8, 0, false, - amount_str, sizeof(amount_str)); + if (!bn_format_uint64(msg->send.amount, NULL, NULL, 8, 0, false, + amount_str, sizeof(amount_str))) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to format amount")); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->send.to_address)) { @@ -155,12 +174,20 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { layoutHome(); return; } + // Confirm the asset denom on its own screen. + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Asset", + "%s", coin_denom)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } break; } } - if (!thorchain_signTxUpdateMsgSend(msg->send.amount, - msg->send.to_address)) { + if (!thorchain_signTxUpdateMsgSend(msg->send.amount, msg->send.to_address, + coin_denom)) { thorchain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, "Failed to include send message in transaction"); @@ -169,8 +196,21 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } } else if (msg->has_deposit) { - char amount_str[32]; - char asset_str[21]; + // Validate before any display so untrusted strings never reach the UI + // or the sign bytes. + if (!thorchain_isValidAsset(msg->deposit.asset) || + !thorchain_isValidSigner(msg->deposit.signer)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid deposit asset or signer"); + layoutHome(); + return; + } + // Long-form assets (e.g. + // ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7) are ~50 chars; + // amount_str must fit amount + asset suffix or bn_format zeroes it out. + char amount_str[96]; + char asset_str[64]; asset_str[0] = ' '; strlcpy(&(asset_str[1]), msg->deposit.asset, sizeof(asset_str) - 1); bn_format_uint64(msg->deposit.amount, NULL, asset_str, 8, 0, false, @@ -185,17 +225,17 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } if (msg->deposit.has_memo) { - // See if we can parse the memo - if (!thorchain_parseConfirmMemo(msg->deposit.memo, - sizeof(msg->deposit.memo))) { - // Memo not recognizable, ask to confirm it - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), - "%s", msg->deposit.memo)) { - thorchain_signAbort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); - layoutHome(); - return; - } + size_t memo_len = strnlen(msg->deposit.memo, sizeof(msg->deposit.memo)); + // Page the complete raw memo as the sole, authoritative disclosure. No + // structured pre-parse here: its bool return conflates "unrecognized" + // with "user rejected a screen", so a reject could be followed by these + // pages and then signing. The raw pager's own reject aborts. + if (!thorchain_confirm_full_memo(_("Memo"), msg->deposit.memo, + memo_len)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } @@ -215,17 +255,14 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } if (sign_tx->has_memo && !msg->deposit.has_memo) { - // See if we can parse the tx memo. This memo ignored if deposit msg has - // memo - if (!thorchain_parseConfirmMemo(sign_tx->memo, sizeof(sign_tx->memo))) { - // Memo not recognizable, ask to confirm it - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), "%s", - sign_tx->memo)) { - thorchain_signAbort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); - layoutHome(); - return; - } + // Ignored if the deposit msg has a memo. Page the full raw memo as the sole + // gate (see the deposit path above for why there is no structured pass). + size_t memo_len = strnlen(sign_tx->memo, sizeof(sign_tx->memo)); + if (!thorchain_confirm_full_memo(_("Memo"), sign_tx->memo, memo_len)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } @@ -240,7 +277,7 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_SignTx, node_str, - "Sign this RUNE transaction on %s? " + "Sign this THORChain transaction on %s? " "Additional network fees apply.", sign_tx->chain_id)) { thorchain_signAbort(); diff --git a/lib/firmware/mayachain.c b/lib/firmware/mayachain.c index a4613ad11..b48f48d8b 100644 --- a/lib/firmware/mayachain.c +++ b/lib/firmware/mayachain.c @@ -29,8 +29,17 @@ #include "trezor/crypto/segwit_addr.h" #include +#include #include +bool mayachain_isValidDenom(const char* denom) { + return tendermint_isValidDenom(denom); +} + +bool mayachain_isValidAsset(const char* asset) { + return tendermint_isValidAsset(asset); +} + static CONFIDENTIAL HDNode node; static SHA256_CTX ctx; static bool initialized; @@ -38,6 +47,10 @@ static uint32_t msgs_remaining; static MayachainSignTx msg; static bool testnet; +bool mayachain_isValidSigner(const char* signer) { + return tendermint_isValidSigner(signer, testnet ? "smaya" : "maya"); +} + const MayachainSignTx* mayachain_getMayachainSignTx(void) { return &msg; } bool mayachain_signTxInit(const HDNode* _node, const MayachainSignTx* _msg) { @@ -119,16 +132,27 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, return false; } + // Default to "cacao" for backward compatibility; validate all non-default + // denoms. Defended here too (not just by the FSM caller) so this signing + // path is safe even if called directly or reused elsewhere later. + const char* coin_denom = (denom && denom[0]) ? denom : "cacao"; + if (!mayachain_isValidDenom(coin_denom)) { + return false; + } + bool success = true; const char* const prelude = "{\"type\":\"mayachain/MsgSend\",\"value\":{"; sha256_Update(&ctx, (uint8_t*)prelude, strlen(prelude)); - // 21 + ^20 + 11 + ^69 + 3 = ^124 - success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), - "\"amount\":[{\"amount\":\"%" PRIu64 - "\",\"denom\":\"%s\"}]", - amount, denom); + // Write amount prefix: 21 + ^20 = ^41 + success &= tendermint_snprintf( + &ctx, buffer, sizeof(buffer), + "\"amount\":[{\"amount\":\"%" PRIu64 "\",\"denom\":\"", amount); + // Use escaping as defense-in-depth; valid denoms have no escapable chars + tendermint_sha256UpdateEscaped(&ctx, coin_denom, strlen(coin_denom)); + // Close coins array: 3 bytes + sha256_Update(&ctx, (uint8_t*)"\"}]", 3); // 17 + 45 + 1 = 63 success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), @@ -145,6 +169,13 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, bool mayachain_signTxUpdateMsgDeposit(const MayachainMsgDeposit* depmsg) { char buffer[64 + 1]; + // Defended here too (not just by the FSM caller) so this signing path is + // safe even if called directly or reused elsewhere later. + if (!mayachain_isValidAsset(depmsg->asset) || + !mayachain_isValidSigner(depmsg->signer)) { + return false; + } + bool success = true; const char* const prelude = "{\"type\":\"mayachain/MsgDeposit\",\"value\":{"; @@ -155,9 +186,11 @@ bool mayachain_signTxUpdateMsgDeposit(const MayachainMsgDeposit* depmsg) { "\"coins\":[{\"amount\":\"%" PRIu64 "\"", depmsg->amount); - // 10 + ^20 + 3 = ^33 - success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), - ",\"asset\":\"%s\"}]", depmsg->asset); + // Use escaping as defense-in-depth; valid assets have no escapable chars + const char* const asset_prefix = ",\"asset\":\""; + sha256_Update(&ctx, (uint8_t*)asset_prefix, strlen(asset_prefix)); + tendermint_sha256UpdateEscaped(&ctx, depmsg->asset, strlen(depmsg->asset)); + sha256_Update(&ctx, (uint8_t*)"\"}]", 3); // const char* const memo_prefix = ",\"memo\":\""; @@ -205,98 +238,120 @@ bool mayachain_parseConfirmMemo(const char* swapStr, size_t size) { Input: swapStr is candidate mayachain data size is the size of swapStr (<= 256) Memos should be of the form: - transaction:chain.ticker-id:destination:limit + transaction:chain.ticker-id:destination:limit:affiliate:fee_bps ^^^^^^^^^^^^^^----------asset - So, swap USDT to dest address 0x41e55..., limit 420 - SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420 + So, swap USDT to dest address 0x41e55..., limit 420, affiliate "kk" + skimming 75 basis points: + SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75 Swap transactions can be indicated by "SWAP" or "s" or "=" + + Fields are split on ':' PRESERVING empty fields so a blank field (e.g. + an empty limit in "=:ETH.ETH:0xdest::kk:75") can never shift a later + field (e.g. the affiliate) into an earlier display slot. */ - char* parseTokPtrs[7] = {NULL, NULL, NULL, NULL, - NULL, NULL, NULL}; // we can parse up to 7 tokens - char* tok; - char memoBuf[256]; - uint16_t ctr; + char* fields[8] = {NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL}; + /* Memos are documented/accepted up to 256 bytes; memoBuf reserves one + * extra byte so a full 256-byte memo still leaves a guaranteed NUL + * terminator, instead of the copy silently dropping its last byte. */ + enum { MEMO_MAX = 256 }; + char memoBuf[MEMO_MAX + 1]; + size_t nfields, i; + char *chain, *asset; // check if memo data is recognized - if (size > sizeof(memoBuf)) return false; + if (size > MEMO_MAX) return false; memzero(memoBuf, sizeof(memoBuf)); - strlcpy(memoBuf, swapStr, size); - memoBuf[255] = '\0'; // ensure null termination - tok = strtok(memoBuf, ":"); - - // get transaction and asset - for (ctr = 0; ctr < 3; ctr++) { - if (tok != NULL) { - parseTokPtrs[ctr] = tok; - tok = strtok(NULL, ":."); - } else { - break; + /* size is a byte count, not necessarily including a NUL: the BTC + * OP_RETURN caller passes raw memo bytes with no terminator. strlcpy + * would copy only size-1 bytes and silently drop the memo's last + * character (turning an affiliate fee of "75" bps into "7"). Copy the + * bytes exactly (size <= MEMO_MAX < sizeof(memoBuf), so this never + * overflows and always leaves at least one zeroed terminator byte); + * the zeroed buffer provides termination. */ + memcpy(memoBuf, swapStr, size); + + // Split on ':', keeping empty fields + nfields = 0; + fields[nfields++] = memoBuf; + for (i = 0; memoBuf[i] != '\0' && nfields < 8; i++) { + if (memoBuf[i] == ':') { + memoBuf[i] = '\0'; + fields[nfields++] = &memoBuf[i + 1]; } } - if (ctr != 3) { - // Must have three tokens at this point: transaction, chain, asset. If - // not, just confirm data + if (nfields < 2) { + // Must have at least transaction and chain.asset. If not, just confirm + // data + return false; + } + + // Split chain.asset at the first '.' + chain = fields[1]; + asset = strchr(chain, '.'); + if (asset == NULL) { + // No chain.asset pair; not recognizable mayachain data, just confirm data return false; } + *asset = '\0'; + asset++; // Check for swap - if (strncmp(parseTokPtrs[0], "SWAP", 4) == 0 || *parseTokPtrs[0] == 's' || - *parseTokPtrs[0] == '=') { + if (strncmp(fields[0], "SWAP", 4) == 0 || *fields[0] == 's' || + *fields[0] == '=') { // This is a swap, set up destination and limit - // This is the dest, may be blank which means swap to self - parseTokPtrs[3] = "self"; - parseTokPtrs[4] = "none"; - if (tok != NULL) { - if ((uint32_t)(tok - (parseTokPtrs[2] + strlen(parseTokPtrs[2]))) == 1) { - // has dest address - parseTokPtrs[3] = tok; - tok = strtok(NULL, ":"); - } - if (tok != NULL) { - // has limit - parseTokPtrs[4] = tok; - } - } + // The dest may be blank which means swap to self + const char* dest = + (nfields > 2 && fields[2][0] != '\0') ? fields[2] : "self"; + const char* limit = + (nfields > 3 && fields[3][0] != '\0') ? fields[3] : "none"; + const char* affiliate = + (nfields > 4 && fields[4][0] != '\0') ? fields[4] : NULL; + const char* fee_bps = + (nfields > 5 && fields[5][0] != '\0') ? fields[5] : "unspecified"; if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain swap", "Confirm swap asset %s\n on chain %s", - parseTokPtrs[2], parseTokPtrs[1])) { + "Mayachain swap", "Confirm swap asset %s\n on chain %s", asset, + chain)) { return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain swap", "Confirm to %s", parseTokPtrs[3])) { + "Mayachain swap", "Confirm to %s", dest)) { return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain swap", "Confirm limit %s", parseTokPtrs[4])) { + "Mayachain swap", "Confirm limit %s", limit)) { return false; } + // Never hide the affiliate fee skim from the user + if (affiliate != NULL) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Mayachain swap", "Affiliate fee %s bps to %s", fee_bps, + affiliate)) { + return false; + } + } return true; } // Check for add liquidity - else if (strncmp(parseTokPtrs[0], "ADD", 3) == 0 || *parseTokPtrs[0] == 'a' || - *parseTokPtrs[0] == '+') { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; - } + else if (strncmp(fields[0], "ADD", 3) == 0 || *fields[0] == 'a' || + *fields[0] == '+') { + // add liquidity pool address (optional) + const char* pool = (nfields > 2 && fields[2][0] != '\0') ? fields[2] : NULL; if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Mayachain add liquidity", - "Confirm add asset %s\n on chain %s pool", parseTokPtrs[2], - parseTokPtrs[1])) { + "Confirm add asset %s\n on chain %s pool", asset, chain)) { return false; } - if (tok != NULL) { + if (pool != NULL) { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain add liquidity", "Confirm to %s", - parseTokPtrs[3])) { + "Mayachain add liquidity", "Confirm to %s", pool)) { return false; } } @@ -304,22 +359,38 @@ bool mayachain_parseConfirmMemo(const char* swapStr, size_t size) { } // Check for withdraw liquidity - else if (strncmp(parseTokPtrs[0], "WITHDRAW", 8) == 0 || - strncmp(parseTokPtrs[0], "wd", 2) == 0 || *parseTokPtrs[0] == '-') { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; - } else { + else if (strncmp(fields[0], "WITHDRAW", 8) == 0 || + strncmp(fields[0], "wd", 2) == 0 || *fields[0] == '-') { + if (nfields < 3 || fields[2][0] == '\0') { return false; // malformed memo } + /* WD:POOL:BPS[:ASSET] — refuse only genuinely-unknown structure (>4 + * fields), mirroring thorchain.c. */ + if (nfields > 4) { + return false; + } - float percent = (float)(atoi(parseTokPtrs[3])) / 100; + /* BPS rendered with integer math: snprintf is the integer-only sniprintf + * on the device, so no float formats. Negative BPS is a malformed memo. */ + int bps = atoi(fields[2]); + if (bps < 0) { + return false; + } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Mayachain withdraw liquidity", - "Confirm withdraw %3.2f%% of asset %s on chain %s", percent, - parseTokPtrs[2], parseTokPtrs[1])) { + "Confirm withdraw %d.%02d%% of asset %s on chain %s", + bps / 100, bps % 100, asset, chain)) { return false; } + /* Field 4 selects an ASYMMETRIC (single-sided) withdrawal payout asset — + * it directs money and must never sign unseen (see thorchain.c). */ + if (nfields > 3 && fields[3][0] != '\0') { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Mayachain withdraw liquidity", + "Withdraw single-sided as %s", fields[3])) { + return false; + } + } return true; } else { diff --git a/lib/firmware/osmosis.c b/lib/firmware/osmosis.c index 6d0657257..0348c6d15 100644 --- a/lib/firmware/osmosis.c +++ b/lib/firmware/osmosis.c @@ -96,7 +96,52 @@ bool osmosis_signTxInit(const HDNode* _node, const OsmosisSignTx* _msg) { return success; } -bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address) { +static bool osmosis_isCanonicalAmount(const char* value) { + if (!value) return false; + const size_t len = strlen(value); + if (len == 0 || len > OSMOSIS_MAX_AMOUNT_DIGITS || + (len > 1 && value[0] == '0')) { + return false; + } + for (size_t i = 0; i < len; i++) { + if (value[i] < '0' || value[i] > '9') return false; + } + return true; +} + +static bool osmosis_isCanonicalUint64(const char* value) { + if (!osmosis_isCanonicalAmount(value)) return false; + + uint64_t parsed = 0; + for (size_t i = 0; value[i]; i++) { + const uint8_t digit = (uint8_t)(value[i] - '0'); + if (parsed > (UINT64_MAX - digit) / 10) return false; + parsed = parsed * 10 + digit; + } + return true; +} + +static bool osmosis_isValidDenom(const char* denom) { + if (!denom) return false; + const size_t len = strlen(denom); + if (len == 0 || len > OSMOSIS_MAX_DENOM_LEN) return false; + + // Cosmos/Osmosis denominations are printable identifiers, not arbitrary + // JSON. This includes native, IBC and factory-style paths while excluding + // whitespace, quotes, backslashes and control bytes. + for (size_t i = 0; i < len; i++) { + const char c = denom[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '/' || c == ':' || c == '.' || + c == '_' || c == '-')) { + return false; + } + } + return true; +} + +bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address, + const char* denom) { const char mainnetp[] = "osmo"; const char testnetp[] = "tosmo"; const char* pfix; @@ -105,7 +150,8 @@ bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address) { size_t decoded_len; char hrp[45] = {0}; uint8_t decoded[38] = {0}; - if (!bech32_decode(hrp, decoded, &decoded_len, to_address)) { + if (!osmosis_isCanonicalUint64(amount) || !osmosis_isValidDenom(denom) || + !bech32_decode(hrp, decoded, &decoded_len, to_address)) { return false; } @@ -125,10 +171,16 @@ bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address) { const char* const prelude = "{\"type\":\"cosmos-sdk/MsgSend\",\"value\":{"; sha256_Update(&ctx, (uint8_t*)prelude, strlen(prelude)); - // 21 + ^20 + 19 = ^60 - success &= tendermint_snprintf( - &ctx, buffer, sizeof(buffer), - "\"amount\":[{\"amount\":\"%s\",\"denom\":\"uosmo\"}]", amount); + // IBC and factory denoms may exceed the fixed 64-byte scratch buffer. + // These values are canonical and JSON-safe, so hash the field in segments. + static const char amount_prefix[] = "\"amount\":[{\"amount\":\""; + static const char denom_prefix[] = "\",\"denom\":\""; + static const char coin_suffix[] = "\"}]"; + sha256_Update(&ctx, (const uint8_t*)amount_prefix, sizeof(amount_prefix) - 1); + sha256_Update(&ctx, (const uint8_t*)amount, strlen(amount)); + sha256_Update(&ctx, (const uint8_t*)denom_prefix, sizeof(denom_prefix) - 1); + sha256_Update(&ctx, (const uint8_t*)denom, strlen(denom)); + sha256_Update(&ctx, (const uint8_t*)coin_suffix, sizeof(coin_suffix) - 1); // 17 + 45 + 1 = 63 success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), @@ -618,6 +670,42 @@ bool osmosis_signTxFinalize(uint8_t* public_key, uint8_t* signature) { NULL) == 0; } +/* + * Cosmos amounts arrive as integer base-unit strings. These screens used to + * render them with atof() + "%.6f", which rounds anything past ~7 significant + * digits — on the very screen the user approves — and linked newlib's floating + * point engine into a ROM budget with no room for it. bn_format_uint64 places + * the decimal point in integer math, the same way the Hive and Ethereum + * confirm screens do. + */ +bool osmosis_formatAmount(char* out, size_t out_len, const char* value, + const char* denom) { + if (!out || out_len == 0) return false; + out[0] = '\0'; + if (!osmosis_isCanonicalAmount(value) || !osmosis_isValidDenom(denom) || + (strcmp(denom, "uosmo") == 0 && !osmosis_isCanonicalUint64(value))) { + return false; + } + + int written; + if (strcmp(denom, "uosmo") == 0) { + char scaled[OSMOSIS_MAX_AMOUNT_DIGITS + 2]; + if (base_to_precision((uint8_t*)scaled, (const uint8_t*)value, + sizeof(scaled), strlen(value), + OSMOSIS_PRECISION) < 0) { + return false; + } + written = snprintf(out, out_len, "%s OSMO", scaled); + } else { + written = snprintf(out, out_len, "%s %s", value, denom); + } + if (written < 0 || (size_t)written >= out_len) { + out[0] = '\0'; + return false; + } + return true; +} + bool osmosis_signingIsInited(void) { return initialized; } bool osmosis_signingIsFinished(void) { return msgs_remaining == 0; } diff --git a/lib/firmware/ripple.c b/lib/firmware/ripple.c index eba787ee8..a78106769 100644 --- a/lib/firmware/ripple.c +++ b/lib/firmware/ripple.c @@ -223,6 +223,25 @@ bool ripple_serialize(uint8_t** buf, const uint8_t* end, const RippleSignTx* tx, if (tx->payment.has_destination) ripple_serializeAddress(&ok, buf, end, &RFM_destination, tx->payment.destination); + // Memos array (ARRAY type=15 key=9) comes last per XRPL canonical ordering. + // Layout: 0xF9 [Memos start] 0xEA [Memo object start] + // 0x7D [MemoData VL] + // 0xE1 [object end] 0xF1 [array end] + if (tx->has_memo && tx->memo[0] != '\0') { + size_t memo_len = strlen(tx->memo); + append_u8(&ok, buf, end, 0xF9); // STArray[9] = Memos + append_u8(&ok, buf, end, 0xEA); // STObject[10] = Memo + append_u8(&ok, buf, end, 0x7D); // VL[13] = MemoData + ripple_serializeVarint(&ok, buf, end, (int)memo_len); + if (ok && *buf + memo_len <= end) { + memcpy(*buf, tx->memo, memo_len); + *buf += memo_len; + } else { + ok = false; + } + append_u8(&ok, buf, end, 0xE1); // end STObject + append_u8(&ok, buf, end, 0xF1); // end STArray + } return ok; } diff --git a/lib/firmware/signtx_tendermint.c b/lib/firmware/signtx_tendermint.c index 0f849d64b..92491f877 100644 --- a/lib/firmware/signtx_tendermint.c +++ b/lib/firmware/signtx_tendermint.c @@ -36,19 +36,19 @@ static CONFIDENTIAL HDNode node; static SHA256_CTX ctx; static bool has_message; static bool initialized; +static TendermintSigningType signing_type; static uint32_t msgs_remaining; static TendermintSignTx tmsg; const void* tendermint_getSignTx(void) { return (void*)&tmsg; } bool tendermint_signTxInit(const HDNode* _node, const void* _msg, - const size_t msgsize, const char* denom) { - initialized = true; - msgs_remaining = ((TendermintSignTx*)_msg)->msg_count; - has_message = false; - - memzero(&node, sizeof(node)); - memcpy(&node, _node, sizeof(node)); + const size_t msgsize, const char* denom, + TendermintSigningType type) { + tendermint_signAbort(); + if (!_node || !_msg || !denom || + (type != TENDERMINT_SIGNING_COSMOS && type != TENDERMINT_SIGNING_GENERIC)) + return false; /* _msg is expected to be of type TendermintSignTx, CosmosSignTx or @@ -65,6 +65,11 @@ bool tendermint_signTxInit(const HDNode* _node, const void* _msg, return false; } + const TendermintSignTx* common = (const TendermintSignTx*)_msg; + if (!common->has_msg_count || common->msg_count == 0) return false; + + msgs_remaining = common->msg_count; + memcpy(&node, _node, sizeof(node)); memcpy((void*)&tmsg, _msg, msgsize); bool success = true; @@ -103,13 +108,24 @@ bool tendermint_signTxInit(const HDNode* _node, const void* _msg, // 10 sha256_Update(&ctx, (uint8_t*)"\",\"msgs\":[", 10); - return success; + if (!success) { + tendermint_signAbort(); + return false; + } + initialized = true; + signing_type = type; + return true; +} + +static bool tendermint_canUpdate(void) { + return initialized && msgs_remaining > 0; } bool tendermint_signTxUpdateMsgSend(const uint64_t amount, const char* to_address, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -163,8 +179,10 @@ bool tendermint_signTxUpdateMsgSend(const uint64_t amount, success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "%s\"}}", to_address); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } @@ -173,6 +191,7 @@ bool tendermint_signTxUpdateMsgDelegate(const uint64_t amount, const char* validator_address, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -226,8 +245,10 @@ bool tendermint_signTxUpdateMsgDelegate(const uint64_t amount, success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "%s\"}}", validator_address); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } bool tendermint_signTxUpdateMsgUndelegate(const uint64_t amount, @@ -236,6 +257,7 @@ bool tendermint_signTxUpdateMsgUndelegate(const uint64_t amount, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -289,8 +311,10 @@ bool tendermint_signTxUpdateMsgUndelegate(const uint64_t amount, success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "%s\"}}", validator_address); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } @@ -298,6 +322,7 @@ bool tendermint_signTxUpdateMsgRedelegate( const uint64_t amount, const char* delegator_address, const char* validator_src_address, const char* validator_dst_address, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -359,8 +384,10 @@ bool tendermint_signTxUpdateMsgRedelegate( success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "%s\"}}", validator_src_address); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } @@ -369,6 +396,7 @@ bool tendermint_signTxUpdateMsgRewards(const uint64_t* amount, const char* validator_address, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -425,8 +453,10 @@ bool tendermint_signTxUpdateMsgRewards(const uint64_t* amount, success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "%s\"}}", validator_address); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } @@ -435,6 +465,7 @@ bool tendermint_signTxUpdateMsgIBCTransfer( const char* source_channel, const char* source_port, const char* revision_number, const char* revision_height, const char* chainstr, const char* denom, const char* msgTypePrefix) { + if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; char hrp[45]; @@ -505,12 +536,15 @@ bool tendermint_signTxUpdateMsgIBCTransfer( "\",\"denom\":\"%s\"}}}", amount, denom); - has_message = true; - msgs_remaining--; + if (success) { + has_message = true; + msgs_remaining--; + } return success; } bool tendermint_signTxFinalize(uint8_t* public_key, uint8_t* signature) { + if (!initialized || msgs_remaining != 0 || !has_message) return false; char buffer[128]; // 14 + ^20 + 2 = ^36 @@ -528,14 +562,28 @@ bool tendermint_signTxFinalize(uint8_t* public_key, uint8_t* signature) { NULL) == 0; } -bool tendermint_signingIsInited(void) { return initialized; } +bool tendermint_signingIsInited(TendermintSigningType type) { + return initialized && signing_type == type; +} + +bool tendermint_signingConfigMatches(const char* chain_name, const char* denom, + const char* message_type_prefix) { + return tendermint_signingIsInited(TENDERMINT_SIGNING_GENERIC) && chain_name && + denom && message_type_prefix && + strcmp(chain_name, tmsg.chain_name) == 0 && + strcmp(denom, tmsg.denom) == 0 && + strcmp(message_type_prefix, tmsg.message_type_prefix) == 0; +} -bool tendermint_signingIsFinished(void) { return msgs_remaining == 0; } +bool tendermint_signingIsFinished(void) { + return initialized && msgs_remaining == 0; +} void tendermint_signAbort(void) { initialized = false; + signing_type = TENDERMINT_SIGNING_NONE; has_message = false; msgs_remaining = 0; memzero(&tmsg, sizeof(tmsg)); memzero(&node, sizeof(node)); -} \ No newline at end of file +} diff --git a/lib/firmware/tendermint.c b/lib/firmware/tendermint.c index 60d50a281..5d10bb23c 100644 --- a/lib/firmware/tendermint.c +++ b/lib/firmware/tendermint.c @@ -6,6 +6,7 @@ #include #include +#include static int convert_bits(uint8_t* out, size_t* outlen, int outbits, const uint8_t* in, size_t inlen, int inbits, int pad) { @@ -64,6 +65,47 @@ bool tendermint_getAddress(const HDNode* node, const char* prefix, BECH32_ENCODING_BECH32) == 1; } +// Allow lowercase alpha, digits, and the punctuation used in Cosmos-style +// asset identifiers (e.g. "eth.eth", "btc/btc", cross-chain synthetic +// prefixes). Rejects anything that needs JSON escaping (backslash, quote). +bool tendermint_isValidDenom(const char* denom) { + if (!denom || !denom[0]) return false; + for (size_t i = 0; denom[i]; i++) { + char c = denom[i]; + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || + c == '/' || c == '-')) { + return false; + } + } + return true; +} + +// Deposit assets share the denom grammar but are conventionally uppercase +// (e.g. ETH.USDT-0XDAC1...); allow both cases, digits, and . / - only. +bool tendermint_isValidAsset(const char* asset) { + if (!asset || !asset[0]) return false; + for (size_t i = 0; asset[i]; i++) { + char c = asset[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.' || c == '/' || c == '-')) { + return false; + } + } + return true; +} + +// Deposit signer is host-supplied; require a valid bech32 address with the +// expected HRP before it is displayed or signed. +bool tendermint_isValidSigner(const char* signer, const char* hrp) { + size_t decoded_len; + char decoded_hrp[45]; + uint8_t decoded[38]; + if (!signer || !bech32_decode(decoded_hrp, decoded, &decoded_len, signer)) { + return false; + } + return 0 == strcmp(decoded_hrp, hrp); +} + void tendermint_sha256UpdateEscaped(SHA256_CTX* ctx, const char* s, size_t len) { for (size_t i = 0; i != len; i++) { diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index 92b075d4f..01ee82d69 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -20,6 +20,7 @@ #include "keepkey/firmware/thorchain.h" #include "keepkey/board/confirm_sm.h" #include "keepkey/board/util.h" +#include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/storage.h" #include "keepkey/firmware/tendermint.h" @@ -29,8 +30,17 @@ #include "trezor/crypto/segwit_addr.h" #include +#include #include +bool thorchain_isValidDenom(const char* denom) { + return tendermint_isValidDenom(denom); +} + +bool thorchain_isValidAsset(const char* asset) { + return tendermint_isValidAsset(asset); +} + static CONFIDENTIAL HDNode node; static SHA256_CTX ctx; static bool initialized; @@ -38,6 +48,10 @@ static uint32_t msgs_remaining; static ThorchainSignTx msg; static bool testnet; +bool thorchain_isValidSigner(const char* signer) { + return tendermint_isValidSigner(signer, testnet ? "tthor" : "thor"); +} + const ThorchainSignTx* thorchain_getThorchainSignTx(void) { return &msg; } bool thorchain_signTxInit(const HDNode* _node, const ThorchainSignTx* _msg) { @@ -95,7 +109,7 @@ bool thorchain_signTxInit(const HDNode* _node, const ThorchainSignTx* _msg) { } bool thorchain_signTxUpdateMsgSend(const uint64_t amount, - const char* to_address) { + const char* to_address, const char* denom) { const char mainnetp[] = "thor"; const char testnetp[] = "tthor"; const char* pfix; @@ -119,15 +133,26 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, return false; } + // Default to "rune" for backward compatibility; validate all non-default + // denoms + const char* coin_denom = (denom && denom[0]) ? denom : "rune"; + if (!thorchain_isValidDenom(coin_denom)) { + return false; + } + bool success = true; const char* const prelude = "{\"type\":\"thorchain/MsgSend\",\"value\":{"; sha256_Update(&ctx, (uint8_t*)prelude, strlen(prelude)); - // 21 + ^20 + 19 = ^60 + // Write amount prefix: 21 + ^20 = ^41 success &= tendermint_snprintf( &ctx, buffer, sizeof(buffer), - "\"amount\":[{\"amount\":\"%" PRIu64 "\",\"denom\":\"rune\"}]", amount); + "\"amount\":[{\"amount\":\"%" PRIu64 "\",\"denom\":\"", amount); + // Use escaping as defense-in-depth; valid denoms have no escapable chars + tendermint_sha256UpdateEscaped(&ctx, coin_denom, strlen(coin_denom)); + // Close coins array: 3 bytes + sha256_Update(&ctx, (uint8_t*)"\"}]", 3); // 17 + 45 + 1 = 63 success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), @@ -144,6 +169,13 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg) { char buffer[64 + 1]; + // Defended here too (not just by the FSM caller) so this signing path is + // safe even if called directly or reused elsewhere later. + if (!thorchain_isValidAsset(depmsg->asset) || + !thorchain_isValidSigner(depmsg->signer)) { + return false; + } + bool success = true; const char* const prelude = "{\"type\":\"thorchain/MsgDeposit\",\"value\":{"; @@ -154,9 +186,11 @@ bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg) { "\"coins\":[{\"amount\":\"%" PRIu64 "\"", depmsg->amount); - // 10 + ^20 + 3 = ^33 - success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), - ",\"asset\":\"%s\"}]", depmsg->asset); + // Use escaping as defense-in-depth; valid assets have no escapable chars + const char* const asset_prefix = ",\"asset\":\""; + sha256_Update(&ctx, (uint8_t*)asset_prefix, strlen(asset_prefix)); + tendermint_sha256UpdateEscaped(&ctx, depmsg->asset, strlen(depmsg->asset)); + sha256_Update(&ctx, (uint8_t*)"\"}]", 3); // const char* const memo_prefix = ",\"memo\":\""; @@ -199,103 +233,206 @@ void thorchain_signAbort(void) { memzero(&node, sizeof(node)); } +/* Page the COMPLETE raw memo so nothing is truncated behind confirm()'s body + * budget. THORChain memos are ASCII; a non-printable byte gets a hex page so + * even a malformed memo is fully disclosed rather than hidden. Shared with the + * MAYA path (mayachain memos use the same grammar) and the native signing + * handlers, which page this as the authoritative disclosure after any + * best-effort structured summary. */ +bool thorchain_confirm_full_memo(const char* title, const char* memo, + size_t len) { + return confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + (const uint8_t*)memo, len); +} + bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { /* Input: swapStr is candidate thorchain data size is the size of swapStr (<= 256) Memos should be of the form: - transaction:chain.ticker-id:destination:limit + transaction:chain.ticker-id:destination:limit:affiliate:fee_bps ^^^^^^^^^^^^^^----------asset - So, swap USDT to dest address 0x41e55..., limit 420 - SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420 + So, swap USDT to dest address 0x41e55..., limit 420, affiliate "kk" + skimming 75 basis points: + SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75 Swap transactions can be indicated by "SWAP" or "s" or "=" + + Fields are split on ':' PRESERVING empty fields so a blank field (e.g. + an empty limit in "=:ETH.ETH:0xdest::kk:75") can never shift a later + field (e.g. the affiliate) into an earlier display slot. */ - char* parseTokPtrs[7] = {NULL, NULL, NULL, NULL, - NULL, NULL, NULL}; // we can parse up to 7 tokens - char* tok; - char memoBuf[256]; - uint16_t ctr; + /* Up to 9 fields for a DEX-aggregator swap + * (SWAP:ASSET:DEST:LIM:AFFILIATE:FEE:AGGREGATOR:FINALTOKEN:MINOUT); the 10th + * slot lets us detect (and reject) a memo with more fields than any known + * grammar rather than silently merging the tail into a displayed field. */ + char* fields[10] = {NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL}; + /* Memos are documented/accepted up to 256 bytes; memoBuf reserves one + * extra byte so a full 256-byte memo still leaves a guaranteed NUL + * terminator, instead of the copy silently dropping its last byte. */ + enum { MEMO_MAX = 256 }; + char memoBuf[MEMO_MAX + 1]; + size_t nfields, i; + char *chain, *asset; // check if memo data is recognized - if (size > sizeof(memoBuf)) return false; + if (size > MEMO_MAX) return false; memzero(memoBuf, sizeof(memoBuf)); - strlcpy(memoBuf, swapStr, size); - memoBuf[255] = '\0'; // ensure null termination - tok = strtok(memoBuf, ":"); - - // get transaction and asset - for (ctr = 0; ctr < 3; ctr++) { - if (tok != NULL) { - parseTokPtrs[ctr] = tok; - tok = strtok(NULL, ":."); - } else { - break; + /* size is a byte count, not necessarily including a NUL: the BTC + * OP_RETURN caller passes raw memo bytes with no terminator. strlcpy + * would copy only size-1 bytes and silently drop the memo's last + * character (turning an affiliate fee of "75" bps into "7"). Copy the + * bytes exactly (size <= MEMO_MAX < sizeof(memoBuf), so this never + * overflows and always leaves at least one zeroed terminator byte); + * the zeroed buffer provides termination. */ + memcpy(memoBuf, swapStr, size); + + // Split on ':', keeping empty fields + nfields = 0; + fields[nfields++] = memoBuf; + for (i = 0; memoBuf[i] != '\0' && nfields < 10; i++) { + if (memoBuf[i] == ':') { + memoBuf[i] = '\0'; + fields[nfields++] = &memoBuf[i + 1]; } } - if (ctr != 3) { - // Must have three tokens at this point: transaction, chain, asset. If - // not, just confirm data + if (nfields < 2) { + // Must have at least transaction and chain.asset. If not, just confirm + // data + return false; + } + + // Split chain.asset at the first '.' + chain = fields[1]; + asset = strchr(chain, '.'); + if (asset == NULL) { + // No chain.asset pair; not recognizable thorchain data, just confirm data return false; } + *asset = '\0'; + asset++; // Check for swap - if (strncmp(parseTokPtrs[0], "SWAP", 4) == 0 || *parseTokPtrs[0] == 's' || - *parseTokPtrs[0] == '=') { - // This is a swap, set up destination and limit - // This is the dest, may be blank which means swap to self - parseTokPtrs[3] = "self"; - parseTokPtrs[4] = "none"; - if (tok != NULL) { - if ((uint32_t)(tok - (parseTokPtrs[2] + strlen(parseTokPtrs[2]))) == 1) { - // has dest address - parseTokPtrs[3] = tok; - tok = strtok(NULL, ":"); - } - if (tok != NULL) { - // has limit - parseTokPtrs[4] = tok; + if (strncmp(fields[0], "SWAP", 4) == 0 || *fields[0] == 's' || + *fields[0] == '=') { + /* Aggregator outbound memo: field 8 is MinAmountOut|OUTBOUND_MEMO, and + * everything after '|' is forwarded to the outbound contract. That suffix + * can itself contain ':' which our ':'-split would scatter (or overflow + * past field 9), so a single confirm could truncate it. When a '|' is + * present, skip structured field display and page the COMPLETE raw memo so + * every signed byte is shown. */ + if (memchr(swapStr, '|', size) != NULL) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Confirm swap asset %s\n on chain %s", + asset, chain)) { + return false; } + return thorchain_confirm_full_memo("Swap memo", swapStr, size); + } + // This is a swap, set up destination and limit + // The dest may be blank which means swap to self + const char* dest = + (nfields > 2 && fields[2][0] != '\0') ? fields[2] : "self"; + const char* limit = + (nfields > 3 && fields[3][0] != '\0') ? fields[3] : "none"; + const char* affiliate = + (nfields > 4 && fields[4][0] != '\0') ? fields[4] : NULL; + const char* fee_bps = + (nfields > 5 && fields[5][0] != '\0') ? fields[5] : "unspecified"; + /* DEX-aggregator swap-out fields — all router-executed, so all displayed. + */ + const char* agg_addr = + (nfields > 6 && fields[6][0] != '\0') ? fields[6] : NULL; + const char* final_token = + (nfields > 7 && fields[7][0] != '\0') ? fields[7] : NULL; + const char* min_out = + (nfields > 8 && fields[8][0] != '\0') ? fields[8] : NULL; + + /* Refuse only genuinely-unknown structure — more fields than any THORChain + * swap grammar defines (>9), which we cannot label and must not hide. */ + if (nfields > 9) { + return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain swap", "Confirm swap asset %s\n on chain %s", - parseTokPtrs[2], parseTokPtrs[1])) { + "Thorchain swap", "Confirm swap asset %s\n on chain %s", asset, + chain)) { return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain swap", "Confirm to %s", parseTokPtrs[3])) { + "Thorchain swap", "Confirm to %s", dest)) { return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain swap", "Confirm limit %s", parseTokPtrs[4])) { + "Thorchain swap", "Confirm limit %s", limit)) { + return false; + } + // Never hide the affiliate fee skim from the user + if (affiliate != NULL) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Affiliate fee %s bps to %s", fee_bps, + affiliate)) { + return false; + } + } + // DEX-aggregator routing: the router forwards the output through this + // aggregator to a final token, so both must be visible. + if (agg_addr != NULL && + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "DEX aggregator %s", agg_addr)) { + return false; + } + if (final_token != NULL && + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Final token %s", final_token)) { + return false; + } + if (min_out != NULL && + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Min output %s", min_out)) { return false; } return true; } // Check for add liquidity - else if (strncmp(parseTokPtrs[0], "ADD", 3) == 0 || *parseTokPtrs[0] == 'a' || - *parseTokPtrs[0] == '+') { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; + else if (strncmp(fields[0], "ADD", 3) == 0 || *fields[0] == 'a' || + *fields[0] == '+') { + // ADD:POOL:PAIREDADDR:AFFILIATE:FEE — paired address, affiliate and fee are + // all optional but router-executed, so none may be hidden. + const char* pool = (nfields > 2 && fields[2][0] != '\0') ? fields[2] : NULL; + const char* affiliate = + (nfields > 3 && fields[3][0] != '\0') ? fields[3] : NULL; + const char* fee_bps = + (nfields > 4 && fields[4][0] != '\0') ? fields[4] : "unspecified"; + + /* ADD grammar defines at most 5 fields; more than that is structure we + * cannot label and must not sign hidden, so refuse it. */ + if (nfields > 5) { + return false; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain add liquidity", - "Confirm add asset %s\n on chain %s pool", parseTokPtrs[2], - parseTokPtrs[1])) { + "Confirm add asset %s\n on chain %s pool", asset, chain)) { return false; } - if (tok != NULL) { + if (pool != NULL) { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain add liquidity", "Confirm to %s", - parseTokPtrs[3])) { + "Thorchain add liquidity", "Confirm to %s", pool)) { + return false; + } + } + // Never hide the affiliate fee skim from the user + if (affiliate != NULL) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain add liquidity", "Affiliate fee %s bps to %s", + fee_bps, affiliate)) { return false; } } @@ -303,26 +440,45 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { } // Check for withdraw liquidity - else if (strncmp(parseTokPtrs[0], "WITHDRAW", 8) == 0 || - strncmp(parseTokPtrs[0], "wd", 2) == 0 || *parseTokPtrs[0] == '-') { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; - } else { + else if (strncmp(fields[0], "WITHDRAW", 8) == 0 || + strncmp(fields[0], "wd", 2) == 0 || *fields[0] == '-') { + if (nfields < 3 || fields[2][0] == '\0') { return false; // malformed memo } + /* WD:POOL:BPS[:ASSET] — refuse only genuinely-unknown structure (>4 + * fields), mirroring the SWAP (>9) and ADD (>5) caps. */ + if (nfields > 4) { + return false; + } - float percent = (float)(atoi(parseTokPtrs[3])) / 100; + /* BPS rendered with integer math: snprintf is the integer-only sniprintf + * on the device, so no float formats. Negative BPS is a malformed memo. */ + int bps = atoi(fields[2]); + if (bps < 0) { + return false; + } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain withdraw liquidity", - "Confirm withdraw %3.2f%% of asset %s on chain %s", percent, - parseTokPtrs[2], parseTokPtrs[1])) { + "Confirm withdraw %d.%02d%% of asset %s on chain %s", + bps / 100, bps % 100, asset, chain)) { return false; } + /* Field 4 is the ASYMMETRIC-withdrawal asset selector: WD:POOL:BPS:ASSET + * pays the whole withdrawal out single-sided in ASSET instead of the + * symmetric split. It directs money, so it must never sign unseen — + * otherwise the screens for the asymmetric form are identical to the + * symmetric one. */ + if (nfields > 3 && fields[3][0] != '\0') { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain withdraw liquidity", + "Withdraw single-sided as %s", fields[3])) { + return false; + } + } return true; } else { // Just confirm whatever coin data if no thorchain intention data parsable return false; } -} \ No newline at end of file +} diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 49122bac9..624358f59 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -2,18 +2,22 @@ set(sources authenticator.cpp app_confirm.cpp coins.cpp + binance.cpp cosmos.cpp dice.cpp eos.cpp eip712.cpp ethereum.cpp + mayachain.cpp nano.cpp + osmosis.cpp recovery.cpp signed_metadata.cpp ripple.cpp solana.cpp storage.cpp usb_rx.cpp + thorchain.cpp u2f.cpp) # zcash.cpp exercises the Orchard engine (lib/firmware/zcash.c), which is only diff --git a/unittests/firmware/binance.cpp b/unittests/firmware/binance.cpp new file mode 100644 index 000000000..66789e3a7 --- /dev/null +++ b/unittests/firmware/binance.cpp @@ -0,0 +1,83 @@ +extern "C" { +#include "keepkey/transport/interface.h" +#include "keepkey/firmware/binance.h" +} + +#include "gtest/gtest.h" + +#include +#include "trezor/crypto/secp256k1.h" + +static BinanceTransferMsg transfer(const char* denom, int64_t amount) { + BinanceTransferMsg msg = {}; + msg.inputs_count = 1; + msg.outputs_count = 1; + msg.inputs[0].coins_count = 1; + msg.outputs[0].coins_count = 1; + msg.inputs[0].has_address = true; + msg.outputs[0].has_address = true; + strcpy(msg.inputs[0].address, "tbnb1hgm0p7khfk85zpz5v0j8wnej3a90w709zzlffd"); + strcpy(msg.outputs[0].address, "tbnb1ss57e8sa7xnwq030k2ctr775uac9gjzglqhvpy"); + msg.inputs[0].coins[0].has_amount = true; + msg.outputs[0].coins[0].has_amount = true; + msg.inputs[0].coins[0].amount = amount; + msg.outputs[0].coins[0].amount = amount; + msg.inputs[0].coins[0].has_denom = true; + msg.outputs[0].coins[0].has_denom = true; + strcpy(msg.inputs[0].coins[0].denom, denom); + strcpy(msg.outputs[0].coins[0].denom, denom); + return msg; +} + +TEST(Binance, DenomBoundsAndGrammar) { + EXPECT_TRUE(binance_isValidDenom("BNB")); + EXPECT_TRUE(binance_isValidDenom("RUNE-B1A")); + EXPECT_TRUE(binance_isValidDenom("ABCDEFGH-123")); + EXPECT_TRUE(binance_isValidDenom("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + EXPECT_FALSE(binance_isValidDenom("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); + EXPECT_FALSE(binance_isValidDenom("bnb")); + EXPECT_FALSE(binance_isValidDenom("BNB\"")); + EXPECT_FALSE(binance_isValidDenom("BN B")); + EXPECT_FALSE(binance_isValidDenom("")); +} + +TEST(Binance, TransferValidationFailsClosed) { + BinanceTransferMsg msg = transfer("RUNE-B1A", 1000000000); + EXPECT_TRUE(binance_validateTransfer(&msg)); + + msg = transfer("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 1000000000); + EXPECT_TRUE(binance_validateTransfer(&msg)); + + msg = transfer("BNB", 0); + EXPECT_FALSE(binance_validateTransfer(&msg)); + msg = transfer("BNB", -1); + EXPECT_FALSE(binance_validateTransfer(&msg)); + + msg = transfer("BNB", 1); + msg.outputs[0].coins[0].amount = 2; + EXPECT_FALSE(binance_validateTransfer(&msg)); + + msg = transfer("BNB", 1); + msg.outputs[0].coins[0].has_denom = false; + EXPECT_FALSE(binance_validateTransfer(&msg)); +} + +TEST(Binance, SigningSessionRequiresCanonicalEnvelopeState) { + HDNode node = {}; + node.curve = &secp256k1_info; + BinanceSignTx envelope = {}; + EXPECT_FALSE(binance_signTxInit(&node, &envelope)); + EXPECT_FALSE(binance_signingIsInited()); + + envelope.has_msg_count = true; + envelope.msg_count = 1; + envelope.has_account_number = true; + envelope.has_chain_id = true; + strcpy(envelope.chain_id, "Binance-Chain-Nile"); + envelope.has_sequence = true; + envelope.has_source = true; + EXPECT_TRUE(binance_signTxInit(&node, &envelope)); + EXPECT_TRUE(binance_signingIsInited()); + EXPECT_FALSE(binance_signingIsFinished()); + binance_signAbort(); +} diff --git a/unittests/firmware/cosmos.cpp b/unittests/firmware/cosmos.cpp index d150fda34..96d565285 100644 --- a/unittests/firmware/cosmos.cpp +++ b/unittests/firmware/cosmos.cpp @@ -3,6 +3,7 @@ extern "C" { #include "keepkey/firmware/cosmos.h" #include "keepkey/firmware/signtx_tendermint.h" #include "keepkey/firmware/tendermint.h" +#include "messages-tendermint.pb.h" #include "trezor/crypto/secp256k1.h" } @@ -54,10 +55,14 @@ TEST(Cosmos, CosmosSignTx) { true, 0, // sequence true, 1 // msg_count }; - ASSERT_TRUE(tendermint_signTxInit(&node, &msg, sizeof(CosmosSignTx), "uatom")); - - ASSERT_TRUE(tendermint_signTxUpdateMsgSend(100000, "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", "cosmos", "uatom", "cosmos-sdk")); + ASSERT_TRUE(tendermint_signTxInit(&node, &msg, sizeof(CosmosSignTx), "uatom", + TENDERMINT_SIGNING_COSMOS)); + EXPECT_TRUE(tendermint_signingIsInited(TENDERMINT_SIGNING_COSMOS)); + EXPECT_FALSE(tendermint_signingIsInited(TENDERMINT_SIGNING_GENERIC)); + ASSERT_TRUE(tendermint_signTxUpdateMsgSend( + 100000, "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", "cosmos", + "uatom", "cosmos-sdk")); uint8_t public_key[33]; uint8_t signature[64]; @@ -72,3 +77,38 @@ TEST(Cosmos, CosmosSignTx) { "\x47\x56\x43\xca\x33\xc7\xad\x2c\x8a\x53\x2b\x39", 64) == 0); } + +TEST(Cosmos, TendermintSessionBindsProtocolAndAssetConfiguration) { + HDNode node = {}; + node.curve = &secp256k1_info; + TendermintSignTx msg = {}; + msg.has_account_number = true; + msg.has_chain_id = true; + strcpy(msg.chain_id, "chain-1"); + msg.has_fee_amount = true; + msg.fee_amount = 1; + msg.has_gas = true; + msg.gas = 1; + msg.has_sequence = true; + msg.has_msg_count = true; + msg.msg_count = 1; + msg.has_chain_name = true; + strcpy(msg.chain_name, "Cosmos"); + msg.has_denom = true; + strcpy(msg.denom, "uatom"); + msg.has_message_type_prefix = true; + strcpy(msg.message_type_prefix, "cosmos-sdk"); + + ASSERT_TRUE(tendermint_signTxInit(&node, &msg, sizeof(msg), msg.denom, + TENDERMINT_SIGNING_GENERIC)); + EXPECT_TRUE(tendermint_signingIsInited(TENDERMINT_SIGNING_GENERIC)); + EXPECT_FALSE(tendermint_signingIsInited(TENDERMINT_SIGNING_COSMOS)); + EXPECT_TRUE(tendermint_signingConfigMatches("Cosmos", "uatom", "cosmos-sdk")); + EXPECT_FALSE( + tendermint_signingConfigMatches("Cosmos", "uosmo", "cosmos-sdk")); + EXPECT_FALSE( + tendermint_signingConfigMatches("Osmosis", "uatom", "cosmos-sdk")); + EXPECT_FALSE( + tendermint_signingConfigMatches("Cosmos", "uatom", "other-prefix")); + tendermint_signAbort(); +} diff --git a/unittests/firmware/eos.cpp b/unittests/firmware/eos.cpp index c15f220e1..b1e61a48e 100644 --- a/unittests/firmware/eos.cpp +++ b/unittests/firmware/eos.cpp @@ -7,6 +7,23 @@ extern "C" { #include +TEST(EOS, UnknownActionsRequireAdvancedMode) { + EXPECT_FALSE(eos_unknownActionPolicyAllows(false)); + EXPECT_TRUE(eos_unknownActionPolicyAllows(true)); +} + +TEST(EOS, NewAccountCannotDowngradeToUnknownAction) { + EosActionCommon common = {}; + common.has_account = true; + common.account = EOS_eosio; + common.has_name = true; + common.name = EOS_NewAccount; + EXPECT_TRUE(eos_isSupportedAction(&common)); + + common.account = 0x1111111111111111ULL; + EXPECT_FALSE(eos_isSupportedAction(&common)); +} + TEST(EOS, FormatNameVec) { struct { uint64_t value; diff --git a/unittests/firmware/mayachain.cpp b/unittests/firmware/mayachain.cpp index 8a319a610..cf17b29c2 100644 --- a/unittests/firmware/mayachain.cpp +++ b/unittests/firmware/mayachain.cpp @@ -7,6 +7,13 @@ extern "C" { #include "gtest/gtest.h" #include +#include + +// confirm() auto-accept driver, defined in thorchain.cpp (same binary). +// kkconfirm_preload(nYes, nNo) queues nYes accepted confirm screens then +// nNo rejected ones; kkconfirm_drain() == 0 proves the exact screen count. +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); TEST(Mayachain, MayachainGetAddress) { HDNode node = { @@ -56,19 +63,176 @@ TEST(Mayachain, MayachainSignTx) { ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); ASSERT_TRUE(mayachain_signTxUpdateMsgSend( - 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k")); + 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", "cacao")); uint8_t public_key[33]; uint8_t signature[64]; ASSERT_TRUE(mayachain_signTxFinalize(public_key, signature)); + // Expected value recomputed independently (python-ecdsa, RFC6979/secp256k1, + // low-s) over the exact sign-doc JSON this fixture produces: + // {"account_number":"6359","chain_id":"mayachain-mainnet-v1","fee": + // {"amount":[{"amount":"3000","denom":"cacao"}],"gas":"200000"},"memo": + // "","msgs":[{"type":"mayachain/MsgSend","value":{"amount":[{"amount": + // "100","denom":"cacao"}],"from_address": + // "maya1ls33ayg26kmltw7jjy55p32ghjna09zp7z4etj","to_address": + // "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k"}}],"sequence":"19"} + // The bytes recorded when this file was written never matched: the file + // was not in the unit build (see 28c74a0e) so the vector was never + // validated, and it did not verify against this fixture's key/JSON. EXPECT_TRUE( memcmp(signature, - (uint8_t *)"\x8a\x91\x43\x54\xca\xe7\x45\x30\x0e\xfb\x88\xee\xdd" - "\xac\xc0\xb5\xa3\x3d\x18\xb1\xe6\x54\x26\x70\x8f\x93" - "\x69\x67\xd5\x21\x84\xbb\x6b\x58\x3d\xe3\x21\xd0\x3e" - "\x26\xb2\xd8\x00\x7d\x81\x84\x34\x82\x5a\xfa\xa2\x80" - "\x54\x88\x90\xc6\xec\xf0\x3b\xf5\x33\x0f\x3e\x9a", + (uint8_t *)"\xdf\x2f\x66\x37\x03\x08\x32\xd2\xce\x87\xfe\x47\x8d" + "\xdf\xe6\xd8\x21\xd2\x6b\x03\x8b\x44\xfa\xc8\x98\xe6" + "\xdf\x79\xe3\xfd\x10\x5d\x40\x3f\x05\x0d\x00\xad\xf9" + "\x7d\x3e\xd3\xa7\x3d\xa6\x9b\x19\x74\x0c\x6a\xbc\xf6" + "\x94\x09\x57\x29\xa3\xf0\xc3\x62\xc9\xf0\xfa\x71", 64) == 0); +} + +// Denom validation: only [a-z0-9./\-] is allowed; anything else is rejected +TEST(Mayachain, MayachainDenomValidation) { + EXPECT_TRUE(mayachain_isValidDenom("cacao")); + EXPECT_TRUE(mayachain_isValidDenom("maya")); + EXPECT_TRUE(mayachain_isValidDenom("eth.eth")); + EXPECT_TRUE(mayachain_isValidDenom("btc/btc")); + EXPECT_TRUE(mayachain_isValidDenom("cross-chain")); + + EXPECT_FALSE(mayachain_isValidDenom("")); // empty → caller "cacao" + EXPECT_FALSE(mayachain_isValidDenom("CACAO")); // uppercase rejected + EXPECT_FALSE(mayachain_isValidDenom("cacao\"")); // quote injection + EXPECT_FALSE(mayachain_isValidDenom("cacao\\n")); // backslash injection + EXPECT_FALSE(mayachain_isValidDenom(" cacao")); // leading space + EXPECT_FALSE(mayachain_isValidDenom("ca cao")); // embedded space +} + +// The signer function itself must reject an invalid denom — not merely +// rely on the FSM caller to pre-validate — so it stays safe if reused or +// called directly. Empty denom must still default to "cacao" and succeed. +TEST(Mayachain, MayachainSignTxUpdateMsgSendRejectsInvalidDenom) { + HDNode node = { + 0, + 0, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0xb9, 0x9a, 0x39, 0x3a, 0x5a, 0x53, 0x0d, 0x90, 0xef, 0x6e, 0x46, + 0x4e, 0x8e, 0x2f, 0x2b, 0x8b, 0x5c, 0x64, 0xa7, 0x97, 0x29, 0xcd, + 0x60, 0x3b, 0x1f, 0xba, 0x33, 0x81, 0x7d, 0x1a, 0x75, 0xa1}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + &secp256k1_info}; + hdnode_fill_public_key(&node); + + const MayachainSignTx msg = { + 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, + true, 6359, + true, "mayachain-mainnet-v1", + true, 3000, + true, 200000, + true, "", + true, 19, + true, 1}; + + ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); + EXPECT_FALSE(mayachain_signTxUpdateMsgSend( + 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", "cacao\"")); + + ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); + EXPECT_TRUE(mayachain_signTxUpdateMsgSend( + 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", "")); +} + +/* ===================================================================== * + * mayachain_parseConfirmMemo — swap-memo clear-signing. + * Mirrors the thorchain.cpp memo tests; see kkconfirm_preload docs there. + * ===================================================================== */ + +static bool parseMayaMemo(const char *memo, size_t size) { + return mayachain_parseConfirmMemo(memo, size); +} +static bool parseMayaMemo(const char *memo) { + return parseMayaMemo(memo, strlen(memo) + 1); +} + +// Classic full-form swap memo = 4 screens (4th is the affiliate fee screen) +TEST(Mayachain, MemoSwapFullFormShowsAffiliate) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMayaMemo( + "SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:" + "0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// No '.' in the asset field (no chain.asset pair): raw-memo fallback +TEST(Mayachain, MemoSwapNoChainAssetPair) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMayaMemo("=:e:0xdest:0/1/0:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Empty limit must NOT shift the affiliate into the limit slot: 4 screens +TEST(Mayachain, MemoSwapEmptyLimitDoesNotShift) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMayaMemo("=:ETH.ETH:0xdest::kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// No affiliate: exactly the 3 historical screens +TEST(Mayachain, MemoSwapNoAffiliate) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE(parseMayaMemo("SWAP:ETH.ETH:0xdest:420")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD with a pool address: 2 screens (unchanged behavior) +TEST(Mayachain, MemoAddWithPool) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE( + parseMayaMemo("ADD:BTC.BTC:maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// WITHDRAW with basis points: 1 screen; without: malformed +TEST(Mayachain, MemoWithdraw) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMayaMemo("WITHDRAW:BTC.BTC:5000")); + EXPECT_FALSE(parseMayaMemo("wd:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Garbage / oversized memos fall back to raw-memo confirmation +// BTC OP_RETURN passes RAW memo bytes with no NUL and size = byte count. +// Every byte must survive the copy — the historical off-by-one dropped +// the last char (1-char affiliate vanished: 3 screens instead of 4). +TEST(Mayachain, MemoRawBytesNoNulKeepsLastChar) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + const char raw[] = "=:ETH.ETH:0xdest:420:k"; + EXPECT_TRUE(parseMayaMemo(raw, sizeof(raw) - 1)); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} + +// A raw memo that fills the internal buffer's entire documented capacity +// (size == 256, the parser's own <=256 contract) must ALSO keep its last +// byte — this is the boundary the copy-length clamp missed. +TEST(Mayachain, MemoExactBufferCapacityKeepsLastChar) { + const std::string prefix = "=:ETH.ETH:0x"; + const std::string suffix = ":420:k"; // 1-char affiliate as the last byte + std::string memo = prefix + std::string(256 - prefix.size() - suffix.size(), + 'd') + + suffix; + ASSERT_EQ(memo.size(), 256u); + + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMayaMemo(memo.c_str(), memo.size())); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Mayachain, MemoGarbageAndOversized) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMayaMemo("hello world")); + EXPECT_FALSE(parseMayaMemo("SWAP:ETH.ETH:0xdest:420", 257)); + EXPECT_EQ(0, kkconfirm_drain()); } \ No newline at end of file diff --git a/unittests/firmware/osmosis.cpp b/unittests/firmware/osmosis.cpp new file mode 100644 index 000000000..b08f129fc --- /dev/null +++ b/unittests/firmware/osmosis.cpp @@ -0,0 +1,158 @@ +extern "C" { +// interface.h first: it is what neutralises the `delete` field in +// messages.pb.h, which is a keyword in C++. +#include "keepkey/transport/interface.h" +#include "keepkey/board/util.h" +#include "keepkey/firmware/app_confirm.h" +#include "keepkey/firmware/osmosis.h" +#include "trezor/crypto/secp256k1.h" +} + +#include "gtest/gtest.h" + +#include + +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); + +static std::string fmt(const char *value, const char *denom) { + char out[OSMOSIS_AMOUNT_STR_LEN] = {0}; + EXPECT_TRUE(osmosis_formatAmount(out, sizeof(out), value, denom)); + return std::string(out); +} + +TEST(Osmosis, FormatAmountScalesUosmo) { + EXPECT_EQ(fmt("1500000", "uosmo"), "1.500000 OSMO"); + EXPECT_EQ(fmt("1000000", "uosmo"), "1.000000 OSMO"); + EXPECT_EQ(fmt("0", "uosmo"), "0.000000 OSMO"); + // Sub-unit amounts keep every digit rather than collapsing to zero. + EXPECT_EQ(fmt("500", "uosmo"), "0.000500 OSMO"); + EXPECT_EQ(fmt("1", "uosmo"), "0.000001 OSMO"); +} + +/* + * The reason this formatter exists. A float carries ~7 significant decimal + * digits, so the old atof() + "%.6f" path rendered large amounts rounded on + * the screen the user approves — 123456789.123456 OSMO came out as + * 123456792.000000. Integer formatting is exact at any magnitude. + */ +TEST(Osmosis, FormatAmountIsExactBeyondFloatPrecision) { + EXPECT_EQ(fmt("123456789123456", "uosmo"), "123456789.123456 OSMO"); + EXPECT_EQ(fmt("999999999999999", "uosmo"), "999999999.999999 OSMO"); + EXPECT_EQ(fmt("18446744073709551615", "uosmo"), "18446744073709.551615 OSMO"); +} + +TEST(Osmosis, FormatAmountLeavesUnknownDenomsAlone) { + // The device does not know the precision of an arbitrary denom, so the + // base-unit integer is shown verbatim — never scaled by a guess. + EXPECT_EQ(fmt("1500000", "uatom"), "1500000 uatom"); + EXPECT_EQ( + fmt("42", "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA6"), + "42 ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA6"); + // "uosmo" must match exactly — a lookalike denom is not OSMO. + EXPECT_EQ(fmt("1500000", "uosmox"), "1500000 uosmox"); +} + +TEST(Osmosis, FormatAmountRejectsNoncanonicalOrOutOfSchemaValues) { + const char *invalid[] = {"", + "01", + "+1", + "-1", + " 1", + "1 ", + "0x1", + "1a", + "18446744073709551616", + "123456789012345678901234567890123"}; + for (const char *value : invalid) { + char out[OSMOSIS_AMOUNT_STR_LEN] = "unchanged"; + EXPECT_FALSE(osmosis_formatAmount(out, sizeof(out), value, "uosmo")); + EXPECT_STREQ(out, ""); + } + + char out[OSMOSIS_AMOUNT_STR_LEN] = {0}; + EXPECT_FALSE(osmosis_formatAmount(out, sizeof(out), "1", "bad denom")); + EXPECT_FALSE(osmosis_formatAmount(out, sizeof(out), "1", "bad\"denom")); + EXPECT_FALSE(osmosis_formatAmount( + out, sizeof(out), "1", + "ibc/12345678901234567890123456789012345678901234567890123456789012345")); + EXPECT_FALSE(osmosis_formatAmount(out, 4, "1", "uosmo")); +} + +TEST(Osmosis, BaseToPrecisionPreservesMaxLpAmountAndCanary) { + struct { + uint8_t out[34]; + uint8_t canary; + } guarded = {{0}, 0xa5}; + const char value[] = "12345678901234567890123456789012"; + + ASSERT_EQ(0, base_to_precision(guarded.out, (const uint8_t *)value, + sizeof(guarded.out), strlen(value), 18)); + EXPECT_STREQ((const char *)guarded.out, "12345678901234.567890123456789012"); + EXPECT_EQ(guarded.canary, 0xa5); +} + +TEST(Osmosis, BaseToPrecisionRejectsTruncationAndNoncanonicalValues) { + uint8_t out[34] = {0}; + const char max_value[] = "12345678901234567890123456789012"; + EXPECT_LT(base_to_precision(out, (const uint8_t *)max_value, sizeof(out) - 1, + strlen(max_value), 18), + 0); + EXPECT_LT(base_to_precision(out, (const uint8_t *)"01", sizeof(out), 2, 18), + 0); + EXPECT_LT(base_to_precision(out, (const uint8_t *)"1x", sizeof(out), 2, 18), + 0); +} + +TEST(Osmosis, MaxSwapAssetsAreRendererPagedCompletely) { + const char denom[] = + "ibc/1234567890123456789012345678901234567890123456789012345678901234"; + static_assert(sizeof(denom) - 1 == OSMOSIS_MAX_DENOM_LEN, + "fixture must exercise the schema maximum"); + char token[OSMOSIS_AMOUNT_STR_LEN] = {0}; + ASSERT_TRUE(osmosis_formatAmount(token, sizeof(token), + "12345678901234567890123456789012", denom)); + + // The old combined sentence required more than the OLED's three rows. Each + // 101-character asset now gets its own measured page, so both signed values + // are fully accepted in exactly two independent confirmations. + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Swap Input", + (const uint8_t *)token, strlen(token))); + EXPECT_TRUE(confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum Output", (const uint8_t *)token, + strlen(token))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Osmosis, MsgSendSignsCanonicalNonNativeDenomination) { + HDNode node = { + 0, + 0, + {0}, + {0xb9, 0x9a, 0x39, 0x3a, 0x5a, 0x53, 0x0d, 0x90, 0xef, 0x6e, 0x46, + 0x4e, 0x8e, 0x2f, 0x2b, 0x8b, 0x5c, 0x64, 0xa7, 0x97, 0x29, 0xcd, + 0x8b, 0x6c, 0x69, 0x5c, 0x71, 0x72, 0x03, 0x02, 0xf1, 0x76}, + {0}, + {0}, + &secp256k1_info}; + hdnode_fill_public_key(&node); + + OsmosisSignTx msg = {}; + msg.account_number = 0; + msg.has_chain_id = true; + strlcpy(msg.chain_id, "osmosis-1", sizeof(msg.chain_id)); + msg.fee_amount = 800; + msg.gas = 290000; + msg.has_memo = true; + msg.sequence = 0; + msg.msg_count = 1; + ASSERT_TRUE(osmosis_signTxInit(&node, &msg)); + + const char denom[] = + "ibc/1234567890123456789012345678901234567890123456789012345678901234"; + static_assert(sizeof(denom) - 1 == OSMOSIS_MAX_DENOM_LEN, + "fixture must exercise the schema maximum"); + EXPECT_TRUE(osmosis_signTxUpdateMsgSend( + "7", "osmo1rs7fckgznkaxs4sq02pexwjgar43p5wnkx9s92", denom)); +} diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index 4fa8faa8f..aa2a338ec 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -1,12 +1,111 @@ extern "C" { +#include "keepkey/board/messages.h" +#include "keepkey/board/usb.h" #include "keepkey/firmware/coins.h" +#include "keepkey/firmware/app_confirm.h" +#include "keepkey/firmware/ethereum_contracts/thortx.h" +#include "keepkey/firmware/fsm.h" #include "keepkey/firmware/thorchain.h" #include "keepkey/firmware/tendermint.h" +#include "messages-ethereum.pb.h" #include "trezor/crypto/secp256k1.h" + +// From keepkey_board.h, which we can't include here: its shutdown(void) +// declaration clashes with sys/socket.h's shutdown(int, int). +void kk_board_init(void); } #include "gtest/gtest.h" #include +#include +#include + +#include +#include +#include + +/* + * confirm() auto-accept driver for unit tests. + * + * In the emulator/unittest build (always DEBUG_LINK), confirm_helper() + * busy-polls the emulator's UDP "usb" port for tiny messages and returns + * once it has seen a ButtonAck plus a DebugLinkDecision. Each confirm + * screen therefore consumes exactly one ButtonAck + one DebugLinkDecision + * from the socket queue. Preloading exactly N accept pairs before invoking + * the code under test auto-accepts exactly N screens, and + * kkconfirm_drain() == 0 afterwards proves exactly N screens were shown + * (fewer screens leave packets queued; more screens would hang the test). + * + * These helpers have external linkage so mayachain.cpp can share the + * one-time board/usb initialization. + */ + +static bool kkconfirm_sendTiny(uint16_t msgId, const uint8_t* payload, + uint8_t len) { + static int fd = -1; + if (fd < 0) fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (fd < 0) return false; + + uint8_t frame[64] = {0}; + frame[0] = '?'; + frame[1] = '#'; + frame[2] = '#'; + frame[3] = msgId >> 8; + frame[4] = msgId & 0xff; + frame[8] = len; // bytes 5..7 are the high bits of the big-endian size + if (len) memcpy(&frame[9], payload, len); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_port = htons(11044); // emulator main "usb" port + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + return sendto(fd, frame, sizeof(frame), 0, (struct sockaddr*)&addr, + sizeof(addr)) == (ssize_t)sizeof(frame); +} + +// Queue nYes accepted screens followed by nNo rejected screens. +bool kkconfirm_preload(int nYes, int nNo) { + static bool initialized = false; + if (!initialized) { + kk_board_init(); // canvas + runnable queues for confirm's draw path + fsm_init(); // registers the usb rx callback + message maps + usbInit(""); // binds the emulator UDP ports + initialized = true; + } + + static const uint8_t yes[] = {0x08, 0x01}; // DebugLinkDecision.yes_no + static const uint8_t no[] = {0x08, 0x00}; + for (int i = 0; i < nYes + nNo; i++) { + if (!kkconfirm_sendTiny(MessageType_MessageType_ButtonAck, NULL, 0)) + return false; + const uint8_t* decision = (i < nYes) ? yes : no; + if (!kkconfirm_sendTiny(MessageType_MessageType_DebugLinkDecision, decision, + 2)) + return false; + } + return true; +} + +// Consume and count any tiny messages left in the queue. +int kkconfirm_drain(void) { + uint8_t buf[MSG_TINY_BFR_SZ]; + int n = 0; + for (;;) { + // volatile: 0xFFFF (MSG_TINY_TYPE_ERROR) is outside the MessageType + // enum range, so an unguarded comparison is a tautology the compiler + // may fold away. + volatile uint16_t id = (uint16_t)check_for_tiny_msg(buf); + if (id == MSG_TINY_TYPE_ERROR) break; + n++; + } + return n; +} + +// Vectors computed with the trezor-crypto library directly (see +// unittests/firmware/thorchain.cpp notes). The test file was previously +// absent from CMakeLists.txt so none of these values were ever validated; +// all expected values here are derived from the actual crypto library. TEST(Thorchain, ThorchainGetAddress) { HDNode node = { @@ -24,51 +123,520 @@ TEST(Thorchain, ThorchainGetAddress) { &secp256k1_info}; char addr[46]; ASSERT_TRUE(tendermint_getAddress(&node, "thor", addr)); - EXPECT_EQ(std::string("thor1am058pdux3hyulcmfgj4m3hhrlfn8nzm88u80q"), addr); + EXPECT_EQ(std::string("thor1am058pdux3hyulcmfgj4m3hhrlfn8nzmpq9u6l"), addr); } -TEST(Thorchain, ThorchainSignTx) { - HDNode node = { - 0, - 0, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - {0x04, 0xde, 0xc0, 0xcc, 0x01, 0x3c, 0xd8, 0xab, 0x70, 0x87, 0xca, - 0x14, 0x96, 0x0b, 0x76, 0x8c, 0x3d, 0x83, 0x45, 0x24, 0x48, 0xaa, - 0x00, 0x64, 0xda, 0xe6, 0xfb, 0x04, 0xb5, 0xd9, 0x34, 0x76}, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - &secp256k1_info}; +// Shared fixtures +static const HDNode kSignNode = { + 0, + 0, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0x04, 0xde, 0xc0, 0xcc, 0x01, 0x3c, 0xd8, 0xab, 0x70, 0x87, 0xca, + 0x14, 0x96, 0x0b, 0x76, 0x8c, 0x3d, 0x83, 0x45, 0x24, 0x48, 0xaa, + 0x00, 0x64, 0xda, 0xe6, 0xfb, 0x04, 0xb5, 0xd9, 0x34, 0x76}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + &secp256k1_info}; + +static const ThorchainSignTx kSignTx = { + 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, + true, 0, + true, "thorchain", + true, 5000, + true, 200000, + true, "", + true, 0, + true, 1}; + +static const char* kToAddr = "thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v"; + +// Denom validation: only [a-z0-9./\-] is allowed; anything else is rejected +TEST(Thorchain, ThorchainDenomValidation) { + EXPECT_TRUE(thorchain_isValidDenom("rune")); + EXPECT_TRUE(thorchain_isValidDenom("tcy")); + EXPECT_TRUE(thorchain_isValidDenom("rujira")); + EXPECT_TRUE(thorchain_isValidDenom("eth.eth")); + EXPECT_TRUE(thorchain_isValidDenom("btc/btc")); + EXPECT_TRUE(thorchain_isValidDenom("cross-chain")); + + EXPECT_FALSE(thorchain_isValidDenom("")); // empty → caller uses "rune" + EXPECT_FALSE(thorchain_isValidDenom("RUNE")); // uppercase rejected + EXPECT_FALSE(thorchain_isValidDenom("rune\"")); // quote injection + EXPECT_FALSE(thorchain_isValidDenom("rune\\n")); // backslash injection + EXPECT_FALSE(thorchain_isValidDenom(" rune")); // leading space + EXPECT_FALSE(thorchain_isValidDenom("ru ne")); // embedded space +} + +// Invalid denom must cause thorchain_signTxUpdateMsgSend to return false +TEST(Thorchain, ThorchainSignTxInvalidDenom) { + HDNode node = kSignNode; hdnode_fill_public_key(&node); - const ThorchainSignTx msg = { - 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, // address_n - true, 0, // account_number - true, "thorchain", // chain_id - true, 5000, // fee_amount - true, 200000, // gas - true, "", // memo - true, 0, // sequence - true, 1 // msg_count - }; - ASSERT_TRUE(thorchain_signTxInit(&node, &msg)); + ASSERT_TRUE(thorchain_signTxInit(&node, &kSignTx)); + // Quote-injection attempt must be rejected at the signing layer + EXPECT_FALSE(thorchain_signTxUpdateMsgSend(100000, kToAddr, + "rune\",\"from_address\":\"evil")); + thorchain_signAbort(); +} + +/* ===================================================================== * + * thorchain_parseConfirmMemo — swap-memo clear-signing. + * Screen counts are asserted exactly: kkconfirm_preload(N, 0) accepts N + * screens and kkconfirm_drain() == 0 proves N screens were shown. + * ===================================================================== */ + +static bool parseMemo(const char* memo, size_t size) { + return thorchain_parseConfirmMemo(memo, size); +} +static bool parseMemo(const char* memo) { + return parseMemo(memo, strlen(memo) + 1); +} + +// Classic full-form swap memo: asset + dest + limit + affiliate + fee bps +// = 4 screens (the 4th is the new affiliate fee screen) +TEST(Thorchain, MemoSwapFullFormShowsAffiliate) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE( + parseMemo("SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:" + "0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Abbreviated asset with no '.' (no chain.asset pair) is not parseable +// thorchain data: raw-memo fallback +TEST(Thorchain, MemoSwapNoChainAssetPair) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("=:e:0xdest:0/1/0:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Empty limit field must NOT shift the affiliate into the limit slot: it +// must still take 4 screens (limit "none" + separate affiliate screen). +// The old strtok tokenizer collapsed the empty field and displayed the +// affiliate ("kk") as the limit in 3 screens. +TEST(Thorchain, MemoSwapEmptyLimitDoesNotShift) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMemo("=:ETH.ETH:0xdest::kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// No affiliate: exactly the 3 historical screens, no affiliate screen +TEST(Thorchain, MemoSwapNoAffiliate) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE(parseMemo("SWAP:ETH.ETH:0xdest:420")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Affiliate present but fee absent: affiliate screen still shows (fee "0") +TEST(Thorchain, MemoSwapAffiliateNoFee) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMemo("SWAP:ETH.ETH:0xdest:420:kk")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Missing dest and limit: still 3 screens ("self" / "none") +TEST(Thorchain, MemoSwapMinimal) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE(parseMemo("SWAP:ETH.ETH")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Rejecting a screen aborts the whole confirmation +TEST(Thorchain, MemoSwapRejectPropagates) { + ASSERT_TRUE(kkconfirm_preload(2, 1)); + EXPECT_FALSE(parseMemo("SWAP:ETH.ETH:0xdest:420")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD with a pool address: 2 screens (unchanged behavior) +TEST(Thorchain, MemoAddWithPool) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE( + parseMemo("ADD:BTC.BTC:thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD without a pool address: 1 screen (unchanged behavior) +TEST(Thorchain, MemoAddWithoutPool) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMemo("+:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// WITHDRAW with basis points: 1 screen (unchanged behavior) +TEST(Thorchain, MemoWithdraw) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMemo("WITHDRAW:BTC.BTC:5000")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// WITHDRAW without basis points is malformed (unchanged behavior) +TEST(Thorchain, MemoWithdrawMissingBps) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("wd:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Garbage memos fall back to raw-memo confirmation +TEST(Thorchain, MemoGarbage) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("hello world")); + EXPECT_FALSE(parseMemo("NOTATHING:ETH.ETH:0xdest")); + EXPECT_FALSE(parseMemo("")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// BTC OP_RETURN passes RAW memo bytes with no NUL and size = byte count +// (transaction.c). Every byte must survive the copy: dropping the last +// character turns affiliate "kk" into "k" — or a fee of 75 bps into 7. +// This memo's affiliate is 1 char, so the historical off-by-one would +// lose it entirely and show only 3 screens instead of 4. +TEST(Thorchain, MemoRawBytesNoNulKeepsLastChar) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + const char raw[] = "=:ETH.ETH:0xdest:420:k"; + EXPECT_TRUE(parseMemo(raw, sizeof(raw) - 1)); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} - ASSERT_TRUE(thorchain_signTxUpdateMsgSend( - 100000, "thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v")); +// A raw memo that fills the internal buffer's entire documented capacity +// (size == 256, the parser's own <=256 contract) must ALSO keep its last +// byte — this is the boundary the copy-length clamp missed. +TEST(Thorchain, MemoExactBufferCapacityKeepsLastChar) { + const std::string prefix = "=:ETH.ETH:0x"; + const std::string suffix = ":420:k"; // 1-char affiliate as the last byte + std::string memo = + prefix + std::string(256 - prefix.size() - suffix.size(), 'd') + suffix; + ASSERT_EQ(memo.size(), 256u); - uint8_t public_key[33]; - uint8_t signature[64]; + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMemo(memo.c_str(), memo.size())); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Oversized input (> 256) is rejected outright +TEST(Thorchain, MemoOversized) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("SWAP:ETH.ETH:0xdest:420", 257)); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Symmetric withdraw: pool + basis points on a single screen. +TEST(Thorchain, MemoWithdrawSymmetric) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMemo("WITHDRAW:BTC.BTC:10000")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Asymmetric withdraw: the 4th field selects a SINGLE-SIDED payout asset — +// it directs money, so it gets its own screen instead of signing unseen with +// screens identical to the symmetric form. +TEST(Thorchain, MemoWithdrawAsymmetricShowsPayoutAsset) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(parseMemo("-:BTC.BTC:10000:THOR.RUNE")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Rejecting the payout-asset screen aborts the withdrawal. +TEST(Thorchain, MemoWithdrawAsymmetricRejectPropagates) { + ASSERT_TRUE(kkconfirm_preload(1, 1)); // approve summary, reject asset + EXPECT_FALSE(parseMemo("wd:BTC.BTC:5000:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// More fields than any withdraw grammar defines cannot be labeled and must +// not be hidden — mirrors the SWAP (>9) and ADD (>5) caps. +TEST(Thorchain, MemoWithdrawTooManyFieldsRejected) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("WITHDRAW:BTC.BTC:10000:THOR.RUNE:extra")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// DEX-aggregator swap: aggregator addr, final token and min-out are all +// router-executed and must be shown — asset/chain + dest + limit + affiliate + +// aggregator + final + min = 7 screens (none hidden). +TEST(Thorchain, MemoSwapAggregatorShowsAllFields) { + ASSERT_TRUE(kkconfirm_preload(7, 0)); + EXPECT_TRUE(parseMemo( + "SWAP:ETH.ETH:0xdest:420:kk:75:0xaggregator:0xfinaltoken:1000")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// A '|' outbound-memo suffix (MinAmountOut|OUTBOUND_MEMO) is forwarded to the +// outbound contract and can contain ':' our split would scatter. It must be +// disclosed in full: swap header + the fully-paged raw memo = 2 screens here +// (memo < one page). Nothing falls back to blind-signing. +TEST(Thorchain, MemoSwapPipeOutboundIsFullyPaged) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + const char memo[] = "=:ETH.ETH:0xdest|OUT:0xfinal:1"; // ':' after the pipe + EXPECT_TRUE(parseMemo(memo, strlen(memo))); // no NUL in the paged bytes + EXPECT_EQ(0, kkconfirm_drain()); +} - ASSERT_TRUE(thorchain_signTxFinalize(public_key, signature)); +// More fields than any swap grammar defines (>9) is structure we cannot label; +// refuse it rather than sign an undisplayed tail. Rejected before any screen. +TEST(Thorchain, MemoSwapTooManyFieldsRejected) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("SWAP:ETH.ETH:a:b:c:d:e:f:g:h")); + EXPECT_EQ(0, kkconfirm_drain()); +} +// ADD:POOL:PAIREDADDR:AFFILIATE:FEE — affiliate + fee must not be hidden: +// add asset + pool + affiliate-fee = 3 screens. +TEST(Thorchain, MemoAddShowsAffiliateAndFee) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); EXPECT_TRUE( - memcmp(signature, - (uint8_t *)"\x41\x99\x66\x30\x08\xef\xea\x75\x93\x56\x35\xe6\x1a" - "\x11\xdf\xa3\x3c\xeb\xeb\x91\xc1\xca\xed\xc6\x0e\x5e" - "\xef\x3c\xa2\xc0\x1f\x83\x48\x08\x36\xe6\x21\x89\x51" - "\x14\x36\x64\x7f\xac\x5a\xbd\xc2\x9f\x54\xae\x3d\x7e" - "\x47\x56\x43\xca\x33\xc7\xad\x2c\x8a\x53\x2b\x39", - 64) == 0); + parseMemo("ADD:BTC.BTC:thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v" + ":affil:50")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD with more than its 5 defined fields is refused (no hidden tail). +TEST(Thorchain, MemoAddTooManyFieldsRejected) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("ADD:BTC.BTC:pool:affil:50:extra")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// The full-memo pager is the authoritative disclosure the native THOR/MAYA +// handlers page after their structured summary. A short ASCII memo is one page. +TEST(Thorchain, FullMemoShortAsciiIsOnePage) { + const char memo[] = "=:ETH.ETH:0xdest:420:kk:75"; + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo, strlen(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Page breaks use measured rendered rows, including word-wrap behavior. +TEST(Thorchain, FullMemoLongAsciiPagesAll) { + const char memo[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo, strlen(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Rejecting any page aborts the whole disclosure (so the handler aborts +// signing). +TEST(Thorchain, FullMemoRejectPropagates) { + const char memo[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_TRUE(kkconfirm_preload(1, 1)); // approve page 1, reject page 2 + EXPECT_FALSE(thorchain_confirm_full_memo("Memo", memo, strlen(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Non-printable memo bytes are disclosed in complete renderer-measured hex +// pages, never hidden behind a byte-count summary. +TEST(Thorchain, FullMemoBinaryPagesAsHex) { + char memo[100]; + memset(memo, 0x01, sizeof(memo)); + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo, sizeof(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// An empty memo must show a single "(empty)" screen — not fall through to the +// hex branch, which would pass an uninitialized buffer to %s. +TEST(Thorchain, FullMemoEmptyShowsEmpty) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", "", 0)); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Renderer-aware paging must split the exact 69-byte word-wrap exploit from +// the second-pass audit. A byte-count pager treated this as one screen even +// though the OLED renderer placed the final signed word on a fourth row. +TEST(Confirmation, ExactLengthPagerMeasuresRenderedRows) { + const char payload[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(confirm_bytes(ButtonRequestType_ButtonRequest_SignMessage, + "Signed Message", (const uint8_t*)payload, + strlen(payload))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Confirmation, ExactLengthPagerRejectPropagates) { + const char payload[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_TRUE(kkconfirm_preload(1, 1)); + EXPECT_FALSE(confirm_bytes(ButtonRequestType_ButtonRequest_SignMessage, + "Signed Message", (const uint8_t*)payload, + strlen(payload))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +/* ===================================================================== + * thor_isThorchainTx — chain-scoped router pin. + * + * A THORChain deposit uses a DIFFERENT router address on every EVM chain, + * so the pin must match on (chain_id, address) together. Before this was + * chain-scoped, only Ethereum-mainnet deposits ever matched and an + * Avalanche deposit fell into the blind-sign gate (the AVAX->ETH bug). + * ===================================================================== */ + +// Lowercase-hex 40-char router -> 20 raw bytes. +static void hex20(const char* hex, uint8_t out[20]) { + for (int i = 0; i < 20; i++) { + auto nib = [](char c) -> int { + return c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10; + }; + out[i] = (uint8_t)((nib(hex[i * 2]) << 4) | nib(hex[i * 2 + 1])); + } +} + +static void make_deposit_msg(EthereumSignTx* msg, const uint8_t to[20], + const uint8_t* data, size_t data_len, + uint32_t chain_id, bool has_chain) { + memset(msg, 0, sizeof(*msg)); + msg->has_to = true; + msg->to.size = 20; + memcpy(msg->to.bytes, to, 20); + msg->has_data_initial_chunk = true; + msg->data_initial_chunk.size = (pb_size_t)data_len; + memcpy(msg->data_initial_chunk.bytes, data, data_len); + msg->has_chain_id = has_chain; + msg->chain_id = chain_id; +} + +static const char* THOR_ETH_ROUTER = "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146"; +static const char* THOR_AVAX_ROUTER = + "00dc6100103bc402d490aee3f9a5560cbd91f1d4"; +static const uint8_t DEPOSIT_WITH_EXPIRY[4] = {0x44, 0xbc, 0x93, 0x7b}; + +TEST(Thorchain, IsThorchainTxEthRouterOnEthereum) { + uint8_t to[20]; + hex20(THOR_ETH_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 1, true); + EXPECT_TRUE(thor_isThorchainTx(&msg)); +} + +TEST(Thorchain, IsThorchainTxAvaxRouterOnAvalanche) { + uint8_t to[20]; + hex20(THOR_AVAX_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 43114, true); + EXPECT_TRUE(thor_isThorchainTx(&msg)); // the AVAX->ETH bug fix +} + +// The AVAX router on the Ethereum chain (or vice versa) must NOT match — the +// pin is (chain, address) together, so a router borrowed onto the wrong chain +// can't inherit the trusted deposit UX. +TEST(Thorchain, IsThorchainTxRejectsRouterOnWrongChain) { + uint8_t avax[20], eth[20]; + hex20(THOR_AVAX_ROUTER, avax); + hex20(THOR_ETH_ROUTER, eth); + EthereumSignTx msg; + make_deposit_msg(&msg, avax, DEPOSIT_WITH_EXPIRY, 4, 1, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); // AVAX router, ETH chain + make_deposit_msg(&msg, eth, DEPOSIT_WITH_EXPIRY, 4, 43114, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); // ETH router, AVAX chain +} + +// A chain with no pinned THORChain router never clear-signs (falls to blind +// sign), even with a real deposit selector to some address. +TEST(Thorchain, IsThorchainTxRejectsUnpinnedChain) { + uint8_t to[20]; + hex20(THOR_ETH_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 137 /*polygon*/, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); +} + +// A tx with NO chain_id at all gets no router: ethereum.c defaults an absent +// chain_id to mainnet for hashing, but an identity pin must never be +// inherited from a default the host merely omitted. +TEST(Thorchain, IsThorchainTxRejectsMissingChainId) { + uint8_t to[20]; + hex20(THOR_ETH_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 0, false); + EXPECT_FALSE(thor_isThorchainTx(&msg)); +} + +// A random contract carrying the deposit selector must not match — this is the +// drain-vector guard the pin exists for. +TEST(Thorchain, IsThorchainTxRejectsUnpinnedAddress) { + uint8_t to[20]; + hex20("00000000000000000000000000000000deadbeef", to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 43114, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); +} + +/* ===================================================================== + * thor_confirmThorTx on the Avalanche router — the full confirm path + * (router label, vault, native amount, structured memo, raw memo pages) + * runs for a non-mainnet deposit, and the exact-end memo bounds hold. + * ===================================================================== */ + +// Assemble a canonical depositWithExpiry(address,address,uint256,string, +// uint256) calldata. declared_len overrides the ABI memo-length word so the +// adversarial case (length says more than is present) can be exercised. +static std::vector build_thor_deposit(const uint8_t vault[20], + const std::string& memo, + uint32_t declared_len) { + std::vector d(DEPOSIT_WITH_EXPIRY, DEPOSIT_WITH_EXPIRY + 4); + auto push_word = [&](const uint8_t* w) { d.insert(d.end(), w, w + 32); }; + auto push_u = [&](uint64_t v) { + uint8_t w[32] = {0}; + for (int i = 0; i < 8; i++) w[31 - i] = (uint8_t)((v >> (8 * i)) & 0xff); + push_word(w); + }; + uint8_t vw[32] = {0}; + memcpy(vw + 12, vault, 20); + push_word(vw); // word0: vault + push_u(0); // word1: asset = native (address zero) + push_u(1000000000ULL); // word2: amount (router-ignored hint for native) + push_u(0xa0); // word3: memo offset (canonical for expiry variant) + push_u(1893456000ULL); // word4: expiry + push_u(declared_len); // word5: memo length + d.insert(d.end(), memo.begin(), memo.end()); + while (d.size() % 32 != 4) d.push_back(0); // pad memo to a 32-byte boundary + return d; +} + +// A 67-byte memo (longer than the once-hardcoded 64) must display in full +// through the memo screens, not silently truncate its trailing fields — on the +// AVALANCHE router, proving the whole confirm path is chain-scoped. +TEST(Thorchain, ConfirmThorTxAvaxLongMemoDecodesFully) { + uint8_t vault[20]; + hex20("15a18266c5331ac3a7f6bc5cdf25bcc55561b4fa", vault); + const std::string memo = + "=:ETH.ETH:0x141D9959cAe3853b035000490C03991eB70Fc4aC:323935:keep:30"; + ASSERT_EQ(memo.size(), 67u); + auto data = build_thor_deposit(vault, memo, (uint32_t)memo.size()); + + uint8_t avax[20]; + hex20(THOR_AVAX_ROUTER, avax); + EthereumSignTx msg; + make_deposit_msg(&msg, avax, data.data(), data.size(), 43114, true); + + ASSERT_TRUE(kkconfirm_preload(12, 0)); // generous; extras drain below + EXPECT_TRUE(thor_confirmThorTx((uint32_t)data.size(), &msg)); + kkconfirm_drain(); +} + +// A memo-length word claiming more bytes than are present must be REJECTED — +// otherwise the router would execute a longer memo than the device displayed +// (display-vs-execute divergence). Fail closed -> blind-sign path. +TEST(Thorchain, ConfirmThorTxRejectsOverlongDeclaredMemo) { + uint8_t vault[20]; + hex20("15a18266c5331ac3a7f6bc5cdf25bcc55561b4fa", vault); + const std::string memo = "=:ETH.ETH:0xdest:0:keep:30"; + // Declare 200 bytes while only ~26 (padded to 32) are present. + auto data = build_thor_deposit(vault, memo, 200); + + uint8_t avax[20]; + hex20(THOR_AVAX_ROUTER, avax); + EthereumSignTx msg; + make_deposit_msg(&msg, avax, data.data(), data.size(), 43114, true); + + ASSERT_TRUE(kkconfirm_preload(12, 0)); + EXPECT_FALSE(thor_confirmThorTx((uint32_t)data.size(), &msg)); + kkconfirm_drain(); } From a42d01a0009a81b66d3b0f0a3eeb6c73b8022772 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:47:55 -0300 Subject: [PATCH 19/40] feat(tron,ton): sign-message support and honest TRON fees TRON: - TIP-191 message signing (SignMessage/MessageSignature), so a TRON dapp login no longer requires signing a fabricated transaction. - USDT transfers carry a fee_limit ceiling and show a simulated fee instead of presenting an unbounded limit the user cannot evaluate. - unittests/firmware/tron.cpp covers the message prefix, the fee ceiling and contract parameter decoding. TON: - Address derivation uses sha256 over the StateInit cell rather than over the public key, which is what actually produces the wallet address. --- include/keepkey/firmware/tron.h | 55 ++++ lib/firmware/fsm_msg_ton.h | 87 ++---- lib/firmware/fsm_msg_tron.h | 201 +++++++----- lib/firmware/tron.c | 359 ++++++++++++++++++++++ unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/tron.cpp | 495 ++++++++++++++++++++++++++++++ 6 files changed, 1061 insertions(+), 137 deletions(-) create mode 100644 unittests/firmware/tron.cpp diff --git a/include/keepkey/firmware/tron.h b/include/keepkey/firmware/tron.h index 6e51d8040..f50bc2de8 100644 --- a/include/keepkey/firmware/tron.h +++ b/include/keepkey/firmware/tron.h @@ -24,12 +24,67 @@ #include "messages-tron.pb.h" +#include +#include +#include + // TRON address length (Base58Check, typically 34 chars starting with 'T') #define TRON_ADDRESS_MAX_LEN 64 // TRON decimals (1 TRX = 1,000,000 SUN) #define TRON_DECIMALS 6 +// Raw 21-byte TRON address: 0x41 prefix + 20-byte keccak hash tail +#define TRON_RAW_ADDRESS_SIZE 21 + +/** + * On-device classification of a TronSignTx raw_data payload. + * + * The device signs sha256(raw_data), so anything shown to the user MUST be + * decoded from raw_data itself — never from side-channel proto fields. + * Unless every field of the payload is understood, the transaction is + * TRON_TX_UNVERIFIED and only the blind-sign path may be offered. + */ +typedef enum { + TRON_TX_UNVERIFIED = 0, // not fully understood — blind-sign only + TRON_TX_TRANSFER, // single TransferContract (native TRX send) + TRON_TX_TRC20_TRANSFER, // single TriggerSmartContract: + // transfer(address,uint256) +} TronTxType; + +typedef struct { + TronTxType type; + uint8_t owner[TRON_RAW_ADDRESS_SIZE]; // spending account + uint8_t to[TRON_RAW_ADDRESS_SIZE]; // TRX or token recipient + uint8_t contract[TRON_RAW_ADDRESS_SIZE]; // TRC-20 token contract + uint64_t amount; // SUN, TransferContract only + uint8_t trc20_amount[32]; // big-endian uint256 token base units + bool has_fee_limit; + uint64_t fee_limit; // SUN + const uint8_t* memo; // points into caller's raw_data + uint16_t memo_len; +} TronParsedTx; + +/** + * Parse a TRON raw_data protobuf for on-device display. + * Fail-closed: any unrecognized top-level field, contract type, extra + * contract, or unexpected parameter field yields TRON_TX_UNVERIFIED. + * out->memo points into raw — valid only while raw is alive. + */ +TronTxType tron_parseRawTx(const uint8_t* raw, size_t len, TronParsedTx* out); + +/** + * Base58Check-encode a raw 21-byte TRON address for display. + */ +bool tron_addressFromBytes(const uint8_t addr[TRON_RAW_ADDRESS_SIZE], char* out, + size_t out_len); + +/** + * Format a TRC-20 uint256 amount (big-endian) as a decimal string of token + * base units. Token decimals are unknown on-device, so no scaling is done. + */ +bool tron_formatTrc20Amount(const uint8_t amount_be[32], char* buf, size_t len); + /** * Generate TRON address from secp256k1 public key * @param public_key secp256k1 public key (33 bytes compressed) diff --git a/lib/firmware/fsm_msg_ton.h b/lib/firmware/fsm_msg_ton.h index 5f4844896..6f2aae06b 100644 --- a/lib/firmware/fsm_msg_ton.h +++ b/lib/firmware/fsm_msg_ton.h @@ -99,6 +99,20 @@ void fsm_msgTonSignTx(TonSignTx* msg) { return; } + /* AdvancedMode gate: to_address/amount are display-only, so this is + * length-only blind signing of raw bytes. Same fence as TonSignMessage + * and Solana/TRON opaque signing until the displayed fields are parsed + * from and bound to raw_tx. */ + if (!storage_isPolicyEnabled("AdvancedMode")) { + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "TON transaction signing is blind-only. " + "Enable AdvancedMode in device settings."); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Transaction signing disabled by policy")); + layoutHome(); + return; + } + // Derive node using Ed25519 curve HDNode* node = fsm_getDerivedNode(ED25519_NAME, msg->address_n, msg->address_n_count, NULL); @@ -112,24 +126,14 @@ void fsm_msgTonSignTx(TonSignTx* msg) { return; } - bool needs_confirm = true; - - // Display transaction details if available - if (needs_confirm && msg->has_to_address && msg->has_amount) { - char amount_str[32]; - ton_formatAmount(amount_str, sizeof(amount_str), msg->amount); - - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send", - "Send %s TON to %s?", amount_str, msg->to_address)) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); - layoutHome(); - return; - } - } - - if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Transaction", - "Really sign this TON transaction?")) { + /* to_address and amount are display-only fields not bound to raw_tx bytes. + * A malicious host could show one recipient while getting a different + * transaction signed. Show only the raw_tx size. */ + char blind_msg[48]; + snprintf(blind_msg, sizeof(blind_msg), "Sign %u-byte TON transaction?", + (unsigned)msg->raw_tx.size); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "TON Blind Sign", "%s", + blind_msg)) { memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); layoutHome(); @@ -190,43 +194,16 @@ void fsm_msgTonSignMessage(const TonSignMessage* msg) { if (!node) return; hdnode_fill_public_key(node); - /* Always require on-device confirmation. Display message content if - * printable, hex preview otherwise. */ - { - char msgBuf[129] = {0}; - const char* typeLabel; - bool printable = true; - for (unsigned i = 0; i < msg->message.size; i++) { - if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { - printable = false; - break; - } - } - if (printable && msg->message.size <= sizeof(msgBuf) - 1) { - typeLabel = "Sign TON Message"; - memcpy(msgBuf, msg->message.bytes, msg->message.size); - msgBuf[msg->message.size] = '\0'; - } else { - typeLabel = "Sign TON Bytes"; - unsigned show = msg->message.size; - if (show > 32) show = 32; - for (unsigned i = 0; i < show; i++) { - snprintf(&msgBuf[2 * i], 3, "%02x", msg->message.bytes[i]); - } - msgBuf[2 * show] = '\0'; - if (msg->message.size > 32) { - snprintf(&msgBuf[64], sizeof(msgBuf) - 64, "... (%u bytes)", - (unsigned)msg->message.size); - } - } - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeLabel), - "%s", msgBuf)) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, - _("Signing cancelled")); - layoutHome(); - return; - } + /* AdvancedMode permits the opaque primitive, but never permits a hidden + * suffix: review every signed byte using renderer-measured pages. */ + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign TON Message", msg->message.bytes, + msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; } if (!ton_message_sign(node, msg, resp)) { diff --git a/lib/firmware/fsm_msg_tron.h b/lib/firmware/fsm_msg_tron.h index aee20c602..22d0a4fd6 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -102,15 +102,96 @@ void fsm_msgTronSignTx(TronSignTx* msg) { return; } - bool needs_confirm = true; + /* Clear-sign from raw_data itself — the exact bytes being signed. + * (The proto's side-channel to_address/amount fields are never trusted: + * they are not part of what is signed.) */ + TronParsedTx parsed; + TronTxType tx_type = + tron_parseRawTx(msg->raw_data.bytes, msg->raw_data.size, &parsed); + + if (tx_type == TRON_TX_UNVERIFIED) { + /* Unrecognized contract or payload: explicit blind-sign only, + * same policy gate as Solana opaque transactions. */ + if (!storage_isPolicyEnabled("AdvancedMode")) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, + _("Enable AdvancedMode to blind-sign")); + layoutHome(); + return; + } + char blind_msg[48]; + snprintf(blind_msg, sizeof(blind_msg), "Sign %u-byte TRON transaction?", + (unsigned)msg->raw_data.size); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "TRON Blind Sign", + "%s", blind_msg)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); + layoutHome(); + return; + } + } else { + /* The parsed owner account is the one spending — it must be ours. */ + char derived_addr[TRON_ADDRESS_MAX_LEN]; + char owner_addr[TRON_ADDRESS_MAX_LEN]; + if (!tron_getAddress(node->public_key, derived_addr, + sizeof(derived_addr)) || + !tron_addressFromBytes(parsed.owner, owner_addr, sizeof(owner_addr)) || + strcmp(derived_addr, owner_addr) != 0) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, + _("TX owner does not match derived key")); + layoutHome(); + return; + } + + char to_str[TRON_ADDRESS_MAX_LEN]; + if (!tron_addressFromBytes(parsed.to, to_str, sizeof(to_str))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, _("Address encoding failed")); + layoutHome(); + return; + } - // Display transaction details if available - if (needs_confirm && msg->has_to_address && msg->has_amount) { - char amount_str[32]; - tron_formatAmount(amount_str, sizeof(amount_str), msg->amount); + bool confirmed = false; + if (tx_type == TRON_TX_TRANSFER) { + char amount_str[32]; + tron_formatAmount(amount_str, sizeof(amount_str), parsed.amount); + confirmed = confirm(ButtonRequestType_ButtonRequest_SignTx, "TRON", + "Send %s to %s?", amount_str, to_str); + } else { /* TRON_TX_TRC20_TRANSFER */ + char contract_str[TRON_ADDRESS_MAX_LEN]; + char amount_str[90]; + confirmed = + tron_addressFromBytes(parsed.contract, contract_str, + sizeof(contract_str)) && + tron_formatTrc20Amount(parsed.trc20_amount, amount_str, + sizeof(amount_str)) && + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "TRC-20 Transfer", "Token contract %s", contract_str) && + /* Token decimals are not known on-device; show base units. */ + confirm(ButtonRequestType_ButtonRequest_SignTx, "TRC-20 Transfer", + "Send %s base units to %s?", amount_str, to_str); + } + + if (confirmed && parsed.has_fee_limit) { + char fee_str[32]; + tron_formatAmount(fee_str, sizeof(fee_str), parsed.fee_limit); + confirmed = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "TRON", + "Max network fee %s", fee_str); + } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send", - "Send %s TRX to %s?", amount_str, msg->to_address)) { + if (confirmed && parsed.memo_len > 0) { + /* Page the COMPLETE memo (72-char ASCII / 40-byte hex pages) like every + * other memo surface. The old single-screen path showed up to 114 chars + * unpaged, but 3 OLED lines only guarantee ~84 chars with wide glyphs — + * an 85..114-char memo could have its signed tail (affiliate bps, + * destination tail) silently clipped. The pager also discloses + * non-printable memos as complete hex instead of a byte-count summary. */ + confirmed = thorchain_confirm_full_memo("Memo", (const char*)parsed.memo, + parsed.memo_len); + } + + if (!confirmed) { memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); layoutHome(); @@ -118,14 +199,6 @@ void fsm_msgTronSignTx(TronSignTx* msg) { } } - if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Transaction", - "Really sign this TRON transaction?")) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); - layoutHome(); - return; - } - // Sign the transaction with secp256k1 if (!tron_signTx(node, msg, resp)) { memzero(node, sizeof(*node)); @@ -139,11 +212,6 @@ void fsm_msgTronSignTx(TronSignTx* msg) { layoutHome(); } -#ifndef TRON_MSG_DISPLAY_MAX -#define TRON_MSG_DISPLAY_MAX \ - (38 * 3) // mirrors ETH MSG_MAX (3 lines × 38 chars) -#endif - void fsm_msgTronSignMessage(TronSignMessage* msg) { RESP_INIT(TronMessageSignature); @@ -160,37 +228,9 @@ void fsm_msgTronSignMessage(TronSignMessage* msg) { return; } - char msgBuf[TRON_MSG_DISPLAY_MAX + 1] = {0}; - const char* typeIndicator; - bool canPrint = true; - unsigned ctr; - - for (ctr = 0; ctr < msg->message.size; ctr++) { - if (isprint(msg->message.bytes[ctr]) == false) { - canPrint = false; - break; - } - } - - if (canPrint) { - typeIndicator = "Sign TRON Message"; - unsigned copy = msg->message.size; - if (copy > TRON_MSG_DISPLAY_MAX) copy = TRON_MSG_DISPLAY_MAX; - memcpy(msgBuf, msg->message.bytes, copy); - msgBuf[copy] = '\0'; - } else { - typeIndicator = "Sign TRON Bytes"; - unsigned hexBytes = msg->message.size; - if (hexBytes * 2 > TRON_MSG_DISPLAY_MAX) { - hexBytes = TRON_MSG_DISPLAY_MAX / 2; - } - for (ctr = 0; ctr < hexBytes; ctr++) { - snprintf(&msgBuf[2 * ctr], 3, "%02x", msg->message.bytes[ctr]); - } - } - - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeIndicator), - "%s", msgBuf)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign TRON Message", msg->message.bytes, + msg->message.size)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; @@ -230,37 +270,9 @@ void fsm_msgTronVerifyMessage(const TronVerifyMessage* msg) { return; } - char msgBuf[TRON_MSG_DISPLAY_MAX + 1] = {0}; - const char* typeIndicator; - bool canPrint = true; - unsigned ctr; - - for (ctr = 0; ctr < msg->message.size; ctr++) { - if (isprint(msg->message.bytes[ctr]) == false) { - canPrint = false; - break; - } - } - - if (canPrint) { - typeIndicator = "Message Verified"; - unsigned copy = msg->message.size; - if (copy > TRON_MSG_DISPLAY_MAX) copy = TRON_MSG_DISPLAY_MAX; - memcpy(msgBuf, msg->message.bytes, copy); - msgBuf[copy] = '\0'; - } else { - typeIndicator = "Bytes Verified"; - unsigned hexBytes = msg->message.size; - if (hexBytes * 2 > TRON_MSG_DISPLAY_MAX) { - hexBytes = TRON_MSG_DISPLAY_MAX / 2; - } - for (ctr = 0; ctr < hexBytes; ctr++) { - snprintf(&msgBuf[2 * ctr], 3, "%02x", msg->message.bytes[ctr]); - } - } - - if (!confirm(ButtonRequestType_ButtonRequest_Other, _(typeIndicator), "%s", - msgBuf)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "TRON Message Verified", msg->message.bytes, + msg->message.size)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; @@ -306,6 +318,31 @@ void fsm_msgTronSignTypedHash(const TronSignTypedHash* msg) { return; } + /* Blind-sign gate: device only receives pre-computed hashes — it cannot + * reconstruct or verify the original typed-data struct. Require the same + * AdvancedMode policy as TronSignTx blind-signing so this message type + * can't be used to route around the kill-switch. */ + if (!storage_isPolicyEnabled("AdvancedMode")) { + memzero(node, sizeof(*node)); + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "TIP-712 blind signing is disabled. " + "Enable AdvancedMode in device settings."); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Blind signing disabled by policy")); + layoutHome(); + return; + } + + /* The user must explicitly acknowledge blind signing before the hashes. */ + if (!confirm(ButtonRequestType_ButtonRequest_Other, "TIP-712 Blind Sign", + "Device cannot verify typed-data contents. " + "Only proceed if you trust the host application.")) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Verify Address", "Confirm address: %s", address)) { memzero(node, sizeof(*node)); diff --git a/lib/firmware/tron.c b/lib/firmware/tron.c index b8d3ec618..38fe3d7d6 100644 --- a/lib/firmware/tron.c +++ b/lib/firmware/tron.c @@ -21,11 +21,13 @@ #include "keepkey/crypto/curves.h" #include "trezor/crypto/base58.h" +#include "trezor/crypto/bignum.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/memzero.h" #include "trezor/crypto/secp256k1.h" #include "trezor/crypto/sha3.h" +#include #include #define TRON_ADDRESS_PREFIX 0x41 // Mainnet addresses start with 'T' @@ -80,6 +82,363 @@ void tron_formatAmount(char* buf, size_t len, uint64_t amount) { bn_format(&val, NULL, " TRX", TRON_DECIMALS, 0, false, buf, len); } +bool tron_addressFromBytes(const uint8_t addr[TRON_RAW_ADDRESS_SIZE], char* out, + size_t out_len) { + return base58_encode_check(addr, TRON_RAW_ADDRESS_SIZE, HASHER_SHA2D, out, + out_len); +} + +bool tron_formatTrc20Amount(const uint8_t amount_be[32], char* buf, + size_t len) { + bignum256 val; + bn_read_be(amount_be, &val); + return bn_format(&val, NULL, NULL, 0, 0, false, buf, len); +} + +/* ------------------------------------------------------------------ */ +/* raw_data protobuf parser */ +/* */ +/* The device signs sha256(raw_data), so display decisions are made */ +/* from these exact bytes. Minimal protobuf wire-format reader — */ +/* fail-closed: anything not fully understood ends TRON_TX_UNVERIFIED */ +/* ------------------------------------------------------------------ */ + +/* TRON protocol.Transaction.raw field numbers */ +#define TRON_RAW_REF_BLOCK_BYTES 1 +#define TRON_RAW_REF_BLOCK_NUM 3 +#define TRON_RAW_REF_BLOCK_HASH 4 +#define TRON_RAW_EXPIRATION 8 +#define TRON_RAW_DATA 10 /* memo */ +#define TRON_RAW_CONTRACT 11 +#define TRON_RAW_TIMESTAMP 14 +#define TRON_RAW_FEE_LIMIT 18 + +/* protocol.Transaction.Contract */ +#define TRON_CONTRACT_TYPE 1 +#define TRON_CONTRACT_PARAMETER 2 + +/* google.protobuf.Any */ +#define TRON_ANY_TYPE_URL 1 +#define TRON_ANY_VALUE 2 + +/* protocol.Transaction.Contract.ContractType enum values */ +#define TRON_CT_TRANSFER_CONTRACT 1 +#define TRON_CT_TRIGGER_SMART_CONTRACT 31 + +/* TRC-20 transfer(address,uint256) selector */ +static const uint8_t TRC20_TRANSFER_SELECTOR[4] = {0xa9, 0x05, 0x9c, 0xbb}; + +static bool pb_read_varint(const uint8_t* buf, size_t len, size_t* pos, + uint64_t* out) { + uint64_t val = 0; + for (unsigned shift = 0; shift < 64; shift += 7) { + if (*pos >= len) return false; + uint8_t b = buf[(*pos)++]; + uint8_t payload = b & 0x7f; + if (shift == 63 && payload > 1) { + /* The 10th byte can only contribute bit 63 to a 64-bit value + * (63 + 7 > 64); any payload bit above bit 0 here claims more + * precision than 64 bits hold. The shift below would silently + * drop those bits rather than reject them, letting a malformed + * key/length/amount/fee varint parse as if it were well-formed + * — reject instead of truncating. */ + return false; + } + val |= (uint64_t)payload << shift; + if (!(b & 0x80)) { + *out = val; + return true; + } + } + return false; /* varint too long / overflows 64 bits */ +} + +static bool pb_read_key(const uint8_t* buf, size_t len, size_t* pos, + uint32_t* field, uint8_t* wire) { + uint64_t key; + if (!pb_read_varint(buf, len, pos, &key)) return false; + *wire = (uint8_t)(key & 0x7); + if ((key >> 3) > UINT32_MAX) return false; + *field = (uint32_t)(key >> 3); + return *field != 0; +} + +static bool pb_read_bytes(const uint8_t* buf, size_t len, size_t* pos, + const uint8_t** out, size_t* out_len) { + uint64_t blen; + if (!pb_read_varint(buf, len, pos, &blen)) return false; + if (blen > len - *pos) return false; + *out = buf + *pos; + *out_len = (size_t)blen; + *pos += (size_t)blen; + return true; +} + +static bool pb_skip(const uint8_t* buf, size_t len, size_t* pos, uint8_t wire) { + uint64_t dummy; + const uint8_t* bp; + size_t bl; + switch (wire) { + case 0: /* varint */ + return pb_read_varint(buf, len, pos, &dummy); + case 1: /* fixed64 */ + if (len - *pos < 8) return false; + *pos += 8; + return true; + case 2: /* length-delimited */ + return pb_read_bytes(buf, len, pos, &bp, &bl); + case 5: /* fixed32 */ + if (len - *pos < 4) return false; + *pos += 4; + return true; + default: + return false; + } +} + +static bool tron_isRawAddress(const uint8_t* p, size_t len) { + return len == TRON_RAW_ADDRESS_SIZE && p[0] == TRON_ADDRESS_PREFIX; +} + +/* Parse protocol.TransferContract { owner_address=1, to_address=2, amount=3 } + */ +static bool tron_parseTransferContract(const uint8_t* buf, size_t len, + TronParsedTx* out) { + size_t pos = 0; + bool has_owner = false, has_to = false, has_amount = false; + while (pos < len) { + uint32_t field; + uint8_t wire; + if (!pb_read_key(buf, len, &pos, &field, &wire)) return false; + const uint8_t* bp; + size_t bl; + uint64_t v; + if (field == 1 && wire == 2) { + if (!pb_read_bytes(buf, len, &pos, &bp, &bl)) return false; + if (!tron_isRawAddress(bp, bl) || has_owner) return false; + memcpy(out->owner, bp, TRON_RAW_ADDRESS_SIZE); + has_owner = true; + } else if (field == 2 && wire == 2) { + if (!pb_read_bytes(buf, len, &pos, &bp, &bl)) return false; + if (!tron_isRawAddress(bp, bl) || has_to) return false; + memcpy(out->to, bp, TRON_RAW_ADDRESS_SIZE); + has_to = true; + } else if (field == 3 && wire == 0) { + if (!pb_read_varint(buf, len, &pos, &v) || has_amount) return false; + if (v > INT64_MAX) return false; + out->amount = v; + has_amount = true; + } else { + /* Unknown field in a value-moving payload: refuse to summarize. */ + return false; + } + } + return has_owner && has_to && has_amount; +} + +/* Parse protocol.TriggerSmartContract: + * owner_address=1, contract_address=2, call_value=3, data=4, + * call_token_value=5, token_id=6 + * Only a plain TRC-20 transfer(address,uint256) with zero call_value and + * no TRC-10 tokens attached is considered verified. */ +static bool tron_parseTriggerSmartContract(const uint8_t* buf, size_t len, + TronParsedTx* out) { + size_t pos = 0; + bool has_owner = false, has_contract = false, has_data = false; + const uint8_t* data = NULL; + size_t data_len = 0; + while (pos < len) { + uint32_t field; + uint8_t wire; + if (!pb_read_key(buf, len, &pos, &field, &wire)) return false; + const uint8_t* bp; + size_t bl; + uint64_t v; + if (field == 1 && wire == 2) { + if (!pb_read_bytes(buf, len, &pos, &bp, &bl)) return false; + if (!tron_isRawAddress(bp, bl) || has_owner) return false; + memcpy(out->owner, bp, TRON_RAW_ADDRESS_SIZE); + has_owner = true; + } else if (field == 2 && wire == 2) { + if (!pb_read_bytes(buf, len, &pos, &bp, &bl)) return false; + if (!tron_isRawAddress(bp, bl) || has_contract) return false; + memcpy(out->contract, bp, TRON_RAW_ADDRESS_SIZE); + has_contract = true; + } else if (field == 3 && wire == 0) { + /* call_value: transfer(address,uint256) is non-payable — any TRX + * attached to the call is something we can't explain to the user. */ + if (!pb_read_varint(buf, len, &pos, &v)) return false; + if (v != 0) return false; + } else if (field == 4 && wire == 2) { + if (!pb_read_bytes(buf, len, &pos, &data, &data_len) || has_data) + return false; + has_data = true; + } else { + /* token_id / call_token_value / anything else: refuse. */ + return false; + } + } + if (!has_owner || !has_contract || !has_data) return false; + + /* data must be exactly selector + address word + amount word */ + if (data_len != 4 + 32 + 32) return false; + if (memcmp(data, TRC20_TRANSFER_SELECTOR, 4) != 0) return false; + + /* Address word: 12 zero bytes then the 20-byte address. TRON tooling + * sometimes writes the 0x41 network prefix at byte 11; the TVM decodes + * only the low 160 bits, so accept 0x41 there and nothing else. */ + const uint8_t* word = data + 4; + for (int i = 0; i < 11; i++) { + if (word[i] != 0) return false; + } + if (word[11] != 0 && word[11] != TRON_ADDRESS_PREFIX) return false; + + out->to[0] = TRON_ADDRESS_PREFIX; + memcpy(out->to + 1, word + 12, 20); + memcpy(out->trc20_amount, data + 4 + 32, 32); + return true; +} + +/* Parse Contract { type=1, parameter=2 (Any) }; enum type and the Any + * type_url must agree, otherwise refuse. */ +static TronTxType tron_parseContract(const uint8_t* buf, size_t len, + TronParsedTx* out) { + size_t pos = 0; + uint64_t ctype = 0; + bool has_type = false; + const uint8_t* value = NULL; + size_t value_len = 0; + const uint8_t* type_url = NULL; + size_t type_url_len = 0; + + while (pos < len) { + uint32_t field; + uint8_t wire; + if (!pb_read_key(buf, len, &pos, &field, &wire)) return TRON_TX_UNVERIFIED; + if (field == TRON_CONTRACT_TYPE && wire == 0) { + if (!pb_read_varint(buf, len, &pos, &ctype) || has_type) + return TRON_TX_UNVERIFIED; + has_type = true; + } else if (field == TRON_CONTRACT_PARAMETER && wire == 2) { + const uint8_t* any; + size_t any_len; + if (!pb_read_bytes(buf, len, &pos, &any, &any_len) || value) + return TRON_TX_UNVERIFIED; + size_t apos = 0; + while (apos < any_len) { + uint32_t afield; + uint8_t awire; + if (!pb_read_key(any, any_len, &apos, &afield, &awire)) + return TRON_TX_UNVERIFIED; + if (afield == TRON_ANY_TYPE_URL && awire == 2) { + if (type_url || + !pb_read_bytes(any, any_len, &apos, &type_url, &type_url_len)) + return TRON_TX_UNVERIFIED; + } else if (afield == TRON_ANY_VALUE && awire == 2) { + if (value || !pb_read_bytes(any, any_len, &apos, &value, &value_len)) + return TRON_TX_UNVERIFIED; + } else { + return TRON_TX_UNVERIFIED; + } + } + if (!value) return TRON_TX_UNVERIFIED; + } else { + /* Permission_id (multisig), provider, ContractName, unknown: refuse. */ + return TRON_TX_UNVERIFIED; + } + } + if (!has_type || !value || !type_url) return TRON_TX_UNVERIFIED; + + /* type_url ends with "/protocol."; require agreement with enum */ + const char* expect_suffix; + if (ctype == TRON_CT_TRANSFER_CONTRACT) { + expect_suffix = "/protocol.TransferContract"; + } else if (ctype == TRON_CT_TRIGGER_SMART_CONTRACT) { + expect_suffix = "/protocol.TriggerSmartContract"; + } else { + return TRON_TX_UNVERIFIED; + } + size_t suffix_len = strlen(expect_suffix); + if (type_url_len < suffix_len || memcmp(type_url + type_url_len - suffix_len, + expect_suffix, suffix_len) != 0) { + return TRON_TX_UNVERIFIED; + } + + if (ctype == TRON_CT_TRANSFER_CONTRACT) { + return tron_parseTransferContract(value, value_len, out) + ? TRON_TX_TRANSFER + : TRON_TX_UNVERIFIED; + } + return tron_parseTriggerSmartContract(value, value_len, out) + ? TRON_TX_TRC20_TRANSFER + : TRON_TX_UNVERIFIED; +} + +TronTxType tron_parseRawTx(const uint8_t* raw, size_t len, TronParsedTx* out) { + memset(out, 0, sizeof(*out)); + if (!raw || len == 0) return TRON_TX_UNVERIFIED; + + size_t pos = 0; + const uint8_t* contract = NULL; + size_t contract_len = 0; + + while (pos < len) { + uint32_t field; + uint8_t wire; + if (!pb_read_key(raw, len, &pos, &field, &wire)) goto unverified; + switch (field) { + case TRON_RAW_REF_BLOCK_BYTES: + case TRON_RAW_REF_BLOCK_HASH: + if (wire != 2 || !pb_skip(raw, len, &pos, wire)) goto unverified; + break; + case TRON_RAW_REF_BLOCK_NUM: + case TRON_RAW_EXPIRATION: + case TRON_RAW_TIMESTAMP: + if (wire != 0 || !pb_skip(raw, len, &pos, wire)) goto unverified; + break; + case TRON_RAW_DATA: { + const uint8_t* bp; + size_t bl; + if (wire != 2 || out->memo || + !pb_read_bytes(raw, len, &pos, &bp, &bl) || bl > UINT16_MAX) + goto unverified; + out->memo = bp; + out->memo_len = (uint16_t)bl; + break; + } + case TRON_RAW_CONTRACT: + /* exactly one contract may be displayed truthfully */ + if (wire != 2 || contract || + !pb_read_bytes(raw, len, &pos, &contract, &contract_len)) + goto unverified; + break; + case TRON_RAW_FEE_LIMIT: { + uint64_t v; + if (wire != 0 || out->has_fee_limit || + !pb_read_varint(raw, len, &pos, &v) || v > INT64_MAX) + goto unverified; + out->fee_limit = v; + out->has_fee_limit = true; + break; + } + default: + /* auths, scripts, future fields: can change meaning — refuse. */ + goto unverified; + } + } + + if (!contract) goto unverified; + out->type = tron_parseContract(contract, contract_len, out); + if (out->type == TRON_TX_UNVERIFIED) goto unverified; + return out->type; + +unverified: + /* Preserve nothing from a failed parse except the classification. */ + memset(out, 0, sizeof(*out)); + out->type = TRON_TX_UNVERIFIED; + return TRON_TX_UNVERIFIED; +} + /** * Sign a TRON transaction with secp256k1 */ diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 624358f59..b76de6be1 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -18,6 +18,7 @@ set(sources storage.cpp usb_rx.cpp thorchain.cpp + tron.cpp u2f.cpp) # zcash.cpp exercises the Orchard engine (lib/firmware/zcash.c), which is only diff --git a/unittests/firmware/tron.cpp b/unittests/firmware/tron.cpp new file mode 100644 index 000000000..cfc77e6e6 --- /dev/null +++ b/unittests/firmware/tron.cpp @@ -0,0 +1,495 @@ +extern "C" { +#include "keepkey/firmware/tron.h" +} + +#include "gtest/gtest.h" +#include +#include + +/* ------------------------------------------------------------------ */ +/* Minimal protobuf wire-format writer for building raw_data vectors */ +/* ------------------------------------------------------------------ */ + +namespace { + +void putVarint(std::vector& out, uint64_t v) { + while (v >= 0x80) { + out.push_back(static_cast(v) | 0x80); + v >>= 7; + } + out.push_back(static_cast(v)); +} + +void putKey(std::vector& out, uint32_t field, uint8_t wire) { + putVarint(out, (static_cast(field) << 3) | wire); +} + +void putVarintField(std::vector& out, uint32_t field, uint64_t v) { + putKey(out, field, 0); + putVarint(out, v); +} + +void putBytesField(std::vector& out, uint32_t field, + const std::vector& bytes) { + putKey(out, field, 2); + putVarint(out, bytes.size()); + out.insert(out.end(), bytes.begin(), bytes.end()); +} + +void putStringField(std::vector& out, uint32_t field, + const char* str) { + putBytesField(out, field, + std::vector(str, str + strlen(str))); +} + +/* A 10-byte varint whose final byte's payload has bits above bit 0 set. + * Bytes 1-9 are all-zero-payload continuations, so the "value" this would + * decode to (if truncation were allowed) is 2 << 63, silently dropped by + * a naive shift. A correct reader must reject this outright rather than + * accept some truncated value. */ +void putOverlongVarintValue(std::vector& out) { + for (int i = 0; i < 9; i++) out.push_back(0x80); + out.push_back(0x02); +} + +void putOverlongVarintField(std::vector& out, uint32_t field) { + putKey(out, field, 0); + putOverlongVarintValue(out); +} + +std::vector tronAddr(uint8_t fill) { + std::vector a(21, fill); + a[0] = 0x41; + return a; +} + +/* protocol.TransferContract { owner=1, to=2, amount=3 } */ +std::vector transferContractValue(const std::vector& owner, + const std::vector& to, + uint64_t amount) { + std::vector v; + putBytesField(v, 1, owner); + putBytesField(v, 2, to); + putVarintField(v, 3, amount); + return v; +} + +/* TRC-20 transfer(address,uint256) calldata */ +std::vector trc20Calldata(const std::vector& to21, + uint64_t amount, bool tronStylePrefix) { + std::vector d = {0xa9, 0x05, 0x9c, 0xbb}; + /* address word */ + for (int i = 0; i < 11; i++) d.push_back(0); + d.push_back(tronStylePrefix ? 0x41 : 0x00); + d.insert(d.end(), to21.begin() + 1, to21.end()); /* low 20 bytes */ + /* amount word: big-endian uint256 */ + for (int i = 0; i < 24; i++) d.push_back(0); + for (int i = 7; i >= 0; i--) + d.push_back(static_cast(amount >> (8 * i))); + return d; +} + +/* protocol.TriggerSmartContract { owner=1, contract=2, call_value=3, data=4 } */ +std::vector triggerContractValue(const std::vector& owner, + const std::vector& contract, + const std::vector& data) { + std::vector v; + putBytesField(v, 1, owner); + putBytesField(v, 2, contract); + putBytesField(v, 4, data); + return v; +} + +/* Transaction.Contract { type=1, parameter=2 (Any{type_url=1, value=2}) } */ +std::vector contractMsg(uint64_t type, const char* type_url, + const std::vector& value) { + std::vector any; + putStringField(any, 1, type_url); + putBytesField(any, 2, value); + + std::vector c; + putVarintField(c, 1, type); + putBytesField(c, 2, any); + return c; +} + +/* Transaction.raw with typical TronGrid framing */ +std::vector rawTx(const std::vector& contract, + const char* memo, uint64_t fee_limit) { + std::vector raw; + putBytesField(raw, 1, {0xab, 0xcd}); /* ref_block_bytes */ + putBytesField(raw, 4, std::vector(8, 0x5a)); /* ref_block_hash */ + putVarintField(raw, 8, 1750000000000ULL); /* expiration */ + if (memo) putStringField(raw, 10, memo); + putBytesField(raw, 11, contract); + putVarintField(raw, 14, 1749999000000ULL); /* timestamp */ + if (fee_limit) putVarintField(raw, 18, fee_limit); + return raw; +} + +const char* TRANSFER_URL = "type.googleapis.com/protocol.TransferContract"; +const char* TRIGGER_URL = "type.googleapis.com/protocol.TriggerSmartContract"; + +} // namespace + +TEST(Tron, ParseNativeTransfer) { + auto owner = tronAddr(0x11); + auto to = tronAddr(0x22); + auto raw = rawTx(contractMsg(1, TRANSFER_URL, + transferContractValue(owner, to, 1000000)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_TRANSFER); + EXPECT_EQ(memcmp(parsed.owner, owner.data(), 21), 0); + EXPECT_EQ(memcmp(parsed.to, to.data(), 21), 0); + EXPECT_EQ(parsed.amount, 1000000u); + EXPECT_FALSE(parsed.has_fee_limit); + EXPECT_EQ(parsed.memo_len, 0); +} + +TEST(Tron, ParseNativeTransferWithSwapMemo) { + const char* memo = "=:ETH.ETH:0x41e5560054824ea6b0732e656e3ad64e20e94e45:0/1/0:kk:75"; + auto raw = rawTx(contractMsg(1, TRANSFER_URL, + transferContractValue(tronAddr(0x11), + tronAddr(0x22), 5000000)), + memo, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_TRANSFER); + ASSERT_EQ(parsed.memo_len, strlen(memo)); + EXPECT_EQ(memcmp(parsed.memo, memo, parsed.memo_len), 0); +} + +TEST(Tron, ParseTrc20Transfer) { + auto owner = tronAddr(0x11); + auto to = tronAddr(0x22); + auto token = tronAddr(0x33); + for (bool tronStyle : {false, true}) { + auto raw = rawTx( + contractMsg(31, TRIGGER_URL, + triggerContractValue( + owner, token, trc20Calldata(to, 123456789, tronStyle))), + nullptr, 100000000 /* 100 TRX fee_limit */); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_TRC20_TRANSFER); + EXPECT_EQ(memcmp(parsed.owner, owner.data(), 21), 0); + EXPECT_EQ(memcmp(parsed.to, to.data(), 21), 0); + EXPECT_EQ(memcmp(parsed.contract, token.data(), 21), 0); + EXPECT_TRUE(parsed.has_fee_limit); + EXPECT_EQ(parsed.fee_limit, 100000000u); + + char amount[90]; + ASSERT_TRUE(tron_formatTrc20Amount(parsed.trc20_amount, amount, + sizeof(amount))); + EXPECT_STREQ(amount, "123456789"); + } +} + +TEST(Tron, ParseTrc20TransferWithMemo) { + /* Vault splices THORChain swap memos into raw_data.data for TRC-20 swaps */ + const char* memo = "=:e:0x1234:0:kk:75"; + auto raw = rawTx(contractMsg(31, TRIGGER_URL, + triggerContractValue( + tronAddr(0x11), tronAddr(0x33), + trc20Calldata(tronAddr(0x22), 42, false))), + memo, 30000000); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_TRC20_TRANSFER); + ASSERT_EQ(parsed.memo_len, strlen(memo)); + EXPECT_EQ(memcmp(parsed.memo, memo, parsed.memo_len), 0); +} + +TEST(Tron, RejectWrongSelector) { + auto data = trc20Calldata(tronAddr(0x22), 42, false); + data[0] = 0x09; /* approve(address,uint256) = 0x095ea7b3... not transfer */ + auto raw = rawTx(contractMsg(31, TRIGGER_URL, + triggerContractValue(tronAddr(0x11), + tronAddr(0x33), data)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectDirtyAddressWord) { + auto data = trc20Calldata(tronAddr(0x22), 42, false); + data[4 + 3] = 0x01; /* junk in the high bytes of the address word */ + auto raw = rawTx(contractMsg(31, TRIGGER_URL, + triggerContractValue(tronAddr(0x11), + tronAddr(0x33), data)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectCalldataLengthMismatch) { + auto data = trc20Calldata(tronAddr(0x22), 42, false); + data.push_back(0x00); /* trailing byte — could smuggle params */ + auto raw = rawTx(contractMsg(31, TRIGGER_URL, + triggerContractValue(tronAddr(0x11), + tronAddr(0x33), data)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectNonzeroCallValue) { + auto value = triggerContractValue(tronAddr(0x11), tronAddr(0x33), + trc20Calldata(tronAddr(0x22), 42, false)); + std::vector withCallValue; + putBytesField(withCallValue, 1, tronAddr(0x11)); + putBytesField(withCallValue, 2, tronAddr(0x33)); + putVarintField(withCallValue, 3, 7 /* nonzero TRX attached */); + putBytesField(withCallValue, 4, trc20Calldata(tronAddr(0x22), 42, false)); + auto raw = rawTx(contractMsg(31, TRIGGER_URL, withCallValue), nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); + + /* zero call_value explicitly present is fine */ + std::vector zeroCallValue; + putBytesField(zeroCallValue, 1, tronAddr(0x11)); + putBytesField(zeroCallValue, 2, tronAddr(0x33)); + putVarintField(zeroCallValue, 3, 0); + putBytesField(zeroCallValue, 4, trc20Calldata(tronAddr(0x22), 42, false)); + raw = rawTx(contractMsg(31, TRIGGER_URL, zeroCallValue), nullptr, 0); + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_TRC20_TRANSFER); +} + +TEST(Tron, RejectTrc10Fields) { + std::vector v; + putBytesField(v, 1, tronAddr(0x11)); + putBytesField(v, 2, tronAddr(0x33)); + putBytesField(v, 4, trc20Calldata(tronAddr(0x22), 42, false)); + putVarintField(v, 5, 1000001); /* call_token_value / token_id territory */ + auto raw = rawTx(contractMsg(31, TRIGGER_URL, v), nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectMultipleContracts) { + auto contract = contractMsg( + 1, TRANSFER_URL, + transferContractValue(tronAddr(0x11), tronAddr(0x22), 1)); + std::vector raw; + putBytesField(raw, 11, contract); + putBytesField(raw, 11, contract); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectUnknownTopLevelField) { + auto raw = rawTx(contractMsg(1, TRANSFER_URL, + transferContractValue(tronAddr(0x11), + tronAddr(0x22), 1)), + nullptr, 0); + putBytesField(raw, 9, {0x01}); /* auths — permission delegation */ + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectExtraFieldInTransferContract) { + auto value = transferContractValue(tronAddr(0x11), tronAddr(0x22), 1); + putVarintField(value, 4, 99); + auto raw = rawTx(contractMsg(1, TRANSFER_URL, value), nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectPermissionId) { + std::vector any; + putStringField(any, 1, TRANSFER_URL); + putBytesField(any, 2, + transferContractValue(tronAddr(0x11), tronAddr(0x22), 1)); + std::vector c; + putVarintField(c, 1, 1); + putBytesField(c, 2, any); + putVarintField(c, 5, 2); /* Permission_id — multisig account slot */ + auto raw = rawTx(c, nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectDuplicateAnyFields) { + /* Two type_urls in the Any wrapper — last-wins ambiguity, refuse. */ + std::vector any; + putStringField(any, 1, TRIGGER_URL); + putStringField(any, 1, TRANSFER_URL); + putBytesField(any, 2, + transferContractValue(tronAddr(0x11), tronAddr(0x22), 1)); + std::vector c; + putVarintField(c, 1, 1); + putBytesField(c, 2, any); + auto raw = rawTx(c, nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); + + /* Two value fields likewise */ + std::vector any2; + putStringField(any2, 1, TRANSFER_URL); + putBytesField(any2, 2, + transferContractValue(tronAddr(0x11), tronAddr(0x22), 1)); + putBytesField(any2, 2, + transferContractValue(tronAddr(0x11), tronAddr(0x33), 2)); + std::vector c2; + putVarintField(c2, 1, 1); + putBytesField(c2, 2, any2); + raw = rawTx(c2, nullptr, 0); + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectTypeUrlEnumMismatch) { + /* enum says TransferContract, Any says TriggerSmartContract */ + auto raw = rawTx(contractMsg(1, TRIGGER_URL, + transferContractValue(tronAddr(0x11), + tronAddr(0x22), 1)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectBadOwnerAddress) { + auto owner = tronAddr(0x11); + owner[0] = 0x42; /* wrong network prefix */ + auto raw = rawTx(contractMsg(1, TRANSFER_URL, + transferContractValue(owner, tronAddr(0x22), 1)), + nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectOverlongKeyVarint) { + /* The very first varint of raw_data is a field key. An overlong + * (overflowing) key varint must not be silently truncated into some + * other field number. */ + std::vector raw; + putOverlongVarintValue(raw); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectOverlongLengthVarint) { + /* A valid key (field 11, length-delimited) followed by an overlong + * length varint — must not be truncated into some in-bounds length. */ + std::vector raw; + putKey(raw, 11, 2); + putOverlongVarintValue(raw); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectOverlongAmountVarint) { + /* TransferContract.amount (field 3) encoded as an overlong varint. */ + std::vector value; + putBytesField(value, 1, tronAddr(0x11)); + putBytesField(value, 2, tronAddr(0x22)); + putOverlongVarintField(value, 3); + auto raw = rawTx(contractMsg(1, TRANSFER_URL, value), nullptr, 0); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectOverlongFeeLimitVarint) { + /* Top-level fee_limit (field 18) encoded as an overlong varint. */ + auto raw = rawTx(contractMsg(1, TRANSFER_URL, + transferContractValue(tronAddr(0x11), + tronAddr(0x22), 1)), + nullptr, 0); + putOverlongVarintField(raw, 18); + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size(), &parsed), + TRON_TX_UNVERIFIED); +} + +TEST(Tron, RejectTruncated) { + /* Build with the contract as the LAST field: any truncation then either + * cuts into a field (parse failure) or drops the contract entirely — + * both must be UNVERIFIED. (Truncation at a field boundary that only + * drops benign trailing fields like timestamp is legal protobuf and + * stays verified — that case is exercised by the parse tests above.) */ + std::vector raw; + putBytesField(raw, 1, {0xab, 0xcd}); + putVarintField(raw, 8, 1750000000000ULL); + putBytesField(raw, 11, + contractMsg(1, TRANSFER_URL, + transferContractValue(tronAddr(0x11), + tronAddr(0x22), 1000000))); + TronParsedTx sanity; + ASSERT_EQ(tron_parseRawTx(raw.data(), raw.size(), &sanity), + TRON_TX_TRANSFER); + + for (size_t cut = 1; cut < raw.size(); cut++) { + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(raw.data(), raw.size() - cut, &parsed), + TRON_TX_UNVERIFIED) + << "cut=" << cut; + } + + TronParsedTx parsed; + EXPECT_EQ(tron_parseRawTx(nullptr, 0, &parsed), TRON_TX_UNVERIFIED); +} + +TEST(Tron, FormatTrc20AmountUint256) { + uint8_t amount[32] = {0}; + amount[31] = 0x01; + char buf[90]; + ASSERT_TRUE(tron_formatTrc20Amount(amount, buf, sizeof(buf))); + EXPECT_STREQ(buf, "1"); + + /* 10^18 — an 18-decimals token unit */ + uint8_t big[32] = {0}; + const uint64_t e18 = 1000000000000000000ULL; + for (int i = 0; i < 8; i++) + big[24 + i] = static_cast(e18 >> (8 * (7 - i))); + ASSERT_TRUE(tron_formatTrc20Amount(big, buf, sizeof(buf))); + EXPECT_STREQ(buf, "1000000000000000000"); +} + +TEST(Tron, AddressFromBytes) { + /* Base58Check of 41 + 20 bytes must round-trip through the display helper */ + uint8_t addr[21]; + memset(addr, 0x11, sizeof(addr)); + addr[0] = 0x41; + char out[64]; + ASSERT_TRUE(tron_addressFromBytes(addr, out, sizeof(out))); + EXPECT_EQ(out[0], 'T'); /* mainnet addresses render as T... */ + EXPECT_GE(strlen(out), 33u); +} From 8f091e89298897b6d508e2871d6c9139be192319 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:53:52 -0300 Subject: [PATCH 20/40] feat(hive): SLIP-0048 keys and operation signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hive support end to end: - HiveGetPublicKey / HiveGetPublicKeys export SLIP-0048 role keys (owner, active, posting, memo) so a client can set up an account without ever seeing a private key. - HiveSignTx plus dedicated account-create and account-update flows, each confirming the operation's real effect — authority changes are shown as authority changes, not as an opaque JSON blob. - HiveSignMessage and HiveSignOperations cover the remaining wire cases, including multi-operation transactions. - Wire compatibility: canonical serialization matches what the network expects, including asset formatting and the expiration encoding. - unittests/firmware/hive.cpp covers serialization, role derivation and the confirmation text for each operation type. --- include/keepkey/firmware/fsm.h | 7 + include/keepkey/firmware/hive.h | 261 ++++ include/keepkey/transport/interface.h | 1 + .../keepkey/transport/messages-hive.options | 58 + lib/firmware/CMakeLists.txt | 1 + lib/firmware/fsm.c | 3 + lib/firmware/fsm_msg_hive.h | 1024 +++++++++++++++ lib/firmware/hive.c | 1096 +++++++++++++++++ lib/firmware/messagemap.def | 17 + lib/transport/CMakeLists.txt | 9 + unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/hive.cpp | 983 +++++++++++++++ 12 files changed, 3461 insertions(+) create mode 100644 include/keepkey/firmware/hive.h create mode 100644 include/keepkey/transport/messages-hive.options create mode 100644 lib/firmware/fsm_msg_hive.h create mode 100644 lib/firmware/hive.c create mode 100644 unittests/firmware/hive.cpp diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index 7d61dcf71..a66f3b85d 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -145,6 +145,13 @@ void fsm_msgZcashTransparentOutput(const ZcashTransparentOutput* msg); void fsm_msgZcashTransparentInput(const ZcashTransparentInput* msg); void fsm_msgZcashDisplayAddress(const ZcashDisplayAddress* msg); #endif +void fsm_msgHiveGetPublicKey(const HiveGetPublicKey* msg); +void fsm_msgHiveGetPublicKeys(const HiveGetPublicKeys* msg); +void fsm_msgHiveSignTx(const HiveSignTx* msg); +void fsm_msgHiveSignAccountCreate(const HiveSignAccountCreate* msg); +void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg); +void fsm_msgHiveSignMessage(const HiveSignMessage* msg); +void fsm_msgHiveSignOperations(const HiveSignOperations* msg); #if DEBUG_LINK // void fsm_msgDebugLinkDecision(DebugLinkDecision *msg); diff --git a/include/keepkey/firmware/hive.h b/include/keepkey/firmware/hive.h new file mode 100644 index 000000000..ad98b4731 --- /dev/null +++ b/include/keepkey/firmware/hive.h @@ -0,0 +1,261 @@ +#ifndef KEEPKEY_FIRMWARE_HIVE_H +#define KEEPKEY_FIRMWARE_HIVE_H + +#include "trezor/crypto/bip32.h" +#include "messages-hive.pb.h" + +// ── Hive mainnet chain ID ───────────────────────────────────────────────── +#define HIVE_CHAIN_ID \ + "\xbe\xea\xb0\xde\x00\x00\x00\x00\x00\x00\x00\x00" \ + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ + "\x00\x00\x00\x00\x00\x00\x00\x00" + +#define HIVE_CHAIN_ID_LEN 32 + +// ── STM public key prefix (Hive inherited from Steem / Graphene) ────────── +#define HIVE_PUBKEY_PREFIX "STM" + +// ── SLIP-0048 derivation constants (all hardened) ───────────────────────── +// Path: m/48'/13'/role'/account_index'/0' +// 13' is the de-facto Hive network index shipped by Ledger (LedgerHQ/app-hive) +// and hive-ledger-cli, NOT the slip-0048.md registry entry (0xbee = 3054', +// which no wallet implements). Chosen deliberately for seed-level key +// compatibility with the existing hardware-wallet ecosystem. +#define HIVE_SLIP48_PURPOSE (0x80000030u) // 48' +#define HIVE_SLIP48_NETWORK (0x8000000Du) // 13' +#define HIVE_ROLE_OWNER \ + (0x80000000u) // 0' — account recovery, authority changes +#define HIVE_ROLE_ACTIVE (0x80000001u) // 1' — transfers, staking +#define HIVE_ROLE_MEMO (0x80000003u) // 3' — memo field encryption +#define HIVE_ROLE_POSTING (0x80000004u) // 4' — votes, posts, follows + +/** + * Validate a complete Hive SLIP-0048 path: + * m/48'/13'/role'/account'/0'. The role must be one of owner, active, memo, + * or posting; every component is hardened. + */ +bool hive_slip48_path_valid(const uint32_t* address_n, size_t count); + +/** Validate a complete Hive SLIP-0048 path for one required role. */ +bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, + uint32_t required_role); + +// ── Graphene operation type IDs ─────────────────────────────────────────── +#define HIVE_OP_VOTE 0 +#define HIVE_OP_COMMENT 1 +#define HIVE_OP_TRANSFER 2 +#define HIVE_OP_TRANSFER_TO_VESTING 3 +#define HIVE_OP_WITHDRAW_VESTING 4 +#define HIVE_OP_LIMIT_ORDER_CREATE 5 +#define HIVE_OP_LIMIT_ORDER_CANCEL 6 +#define HIVE_OP_CONVERT 8 +#define HIVE_OP_ACCOUNT_CREATE 9 +#define HIVE_OP_ACCOUNT_UPDATE 10 +#define HIVE_OP_CUSTOM_JSON 18 +#define HIVE_OP_COMMENT_OPTIONS 19 +#define HIVE_OP_TRANSFER_TO_SAVINGS 32 +#define HIVE_OP_TRANSFER_FROM_SAVINGS 33 +#define HIVE_OP_CLAIM_REWARD_BALANCE 39 +#define HIVE_OP_DELEGATE_VESTING_SHARES 40 +#define HIVE_OP_ACCOUNT_UPDATE2 43 + +// ── Protocol limits ─────────────────────────────────────────────────────── +#define HIVE_DECIMALS 3 // HIVE and HBD both use 3 decimal places +// Maximum memo length that fits safely in the signer's tx_buf[512] with all +// other fields. Non-memo overhead: header(12) + from(17) + to(17) + asset(16) +// + footer(1) = ~63 bytes. 512 - 63 - 3 (varint) = 446; 440 is conservative. +#define HIVE_MAX_MEMO_LEN 440 +// Maximum signable message length. MUST match HiveSignMessage.message +// max_size in messages-hive.options (proto cap and code cap kept in sync). +#define HIVE_MAX_MESSAGE_LEN 1024 +// Maximum host-serialized transaction length for HiveSignOperations. MUST +// match HiveSignOperations.serialized_tx max_size in messages-hive.options. +#define HIVE_MAX_OPS_TX_LEN 2048 +// Maximum operations per HiveSignOperations transaction. +#define HIVE_MAX_TX_OPS 4 +// Graphene asset: int64 LE amount + uint8 precision + 7-byte NUL-padded +// symbol (append_asset layout). +#define HIVE_ASSET_LEN 16 +// Most assets carried by a single op in the table (claim_reward_balance +// carries three: HIVE, HBD, VESTS). +#define HIVE_MAX_OP_ASSETS 3 +// Most comment_payout_beneficiaries entries accepted on a comment_options op. +// Matches the host serializer's cap; hived itself allows more, but eight is +// all that can be reviewed on the OLED before approval fatigue sets in. +#define HIVE_MAX_BENEFICIARIES 8 +// Maximum custom_json authorization accounts accepted per operation. Every +// account is confirmed individually; bounding the set prevents an unreviewable +// approval loop and keeps the parsed transaction's static RAM use predictable. +#define HIVE_MAX_CUSTOM_JSON_AUTHS 4 + +// Symbol whitelist bits for the asset parser. Every asset field in the op +// table pins an explicit set — an op that accepts HIVE must never silently +// accept VESTS, since the two differ by 1000x in displayed magnitude. +#define HIVE_SYM_HIVE (1u << 0) +#define HIVE_SYM_HBD (1u << 1) +#define HIVE_SYM_VESTS (1u << 2) + +// ── Public API ──────────────────────────────────────────────────────────── +/** + * Encode a 33-byte compressed public key in Hive/Steem STM-prefix base58 + * format. Uses RIPEMD checksum (Graphene convention, not SHA256d). + */ +bool hive_getPublicKey(const uint8_t public_key[33], char* out, size_t out_len); + +/** + * Derive one SLIP-0048 role key for a given account index to raw 33 bytes. + * role_hardened: HIVE_ROLE_OWNER | HIVE_ROLE_ACTIVE | HIVE_ROLE_MEMO | + * HIVE_ROLE_POSTING account_index_hardened: account_index | 0x80000000u Returns + * false if derivation fails. + */ +bool hive_deriveRawKey(const HDNode* root, uint32_t role_hardened, + uint32_t account_index_hardened, uint8_t out[33]); + +/** + * Derive all four SLIP-0048 role keys for a given account index and encode + * each as an STM-prefixed string. All output buffers must be >= 64 bytes. + * Returns false if any derivation or encoding step fails. + */ +bool hive_getPublicKeys(const HDNode* root, uint32_t account_index, + char* owner_out, size_t owner_len, char* active_out, + size_t active_len, char* memo_out, size_t memo_len, + char* posting_out, size_t posting_len); + +/** + * Sign a Hive transfer transaction (op type 2). + * Rejects memos longer than HIVE_MAX_MEMO_LEN (440 bytes). + */ +void hive_signTx(const HDNode* node, const HiveSignTx* msg, HiveSignedTx* resp); + +// ── Parsed operations (HiveSignOperations) ──────────────────────────────── + +typedef struct { + uint32_t op_type; + bool needs_active; // custom_json with required_auths; false = posting tier + // Borrowed slices into the request's serialized_tx (NOT NUL-terminated): + const uint8_t* acct; // vote: voter / comment: author / cj: first auth name + uint16_t acct_len; + const uint8_t* target; // vote: author / comment: title / cj: id + uint16_t target_len; + const uint8_t* detail; // vote: permlink / comment: body / cj: json + uint16_t detail_len; + const uint8_t* parent_author; // comment only + uint16_t parent_author_len; + const uint8_t* + parent_permlink; // comment only (category for a top-level post) + uint16_t parent_permlink_len; + const uint8_t* permlink; // comment only: this post/reply's permlink + uint16_t permlink_len; + const uint8_t* json_metadata; // comment only + uint16_t json_metadata_len; + int16_t weight; // vote (-10000..10000), or a 0..10000 basis-point + // percent (comment_options percent_hbd, + // set_withdraw_vesting_route percent) + bool is_top_level; // comment only: parent_author empty + uint8_t n_auths; // custom_json only: total auth account names + const uint8_t* auth_acct[HIVE_MAX_CUSTOM_JSON_AUTHS]; + uint16_t auth_acct_len[HIVE_MAX_CUSTOM_JSON_AUTHS]; + + // ── Phase-3 op fields ─────────────────────────────────────────────────── + // Borrowed HIVE_ASSET_LEN-byte asset slices in the op's own field order: + // transfer_to_vesting/convert/claim_account/savings: [0] = amount + // withdraw_vesting/delegate_vesting_shares: [0] = vesting_shares + // limit_order_create: [0] = amount_to_sell, [1] = min_to_receive + // claim_reward_balance: [0] = HIVE, [1] = HBD, [2] = VESTS + // comment_options: [0] = max_accepted_payout + const uint8_t* assets[HIVE_MAX_OP_ASSETS]; + uint8_t n_assets; + uint32_t req_id; // convert requestid / savings request_id / order id + uint32_t expiration; // limit_order_create only + bool flag; // fill_or_kill / approve / auto_vest / allow_votes + bool flag2; // comment_options: allow_curation_rewards + uint8_t n_benef; // comment_options: beneficiary count (0 = none) + const uint8_t* benef_acct[HIVE_MAX_BENEFICIARIES]; + uint16_t benef_acct_len[HIVE_MAX_BENEFICIARIES]; + uint16_t benef_weight[HIVE_MAX_BENEFICIARIES]; // basis points +} HiveTxOp; + +typedef struct { + uint8_t num_ops; + bool needs_active; // tx tier: active' path required, else posting' + HiveTxOp ops[HIVE_MAX_TX_OPS]; +} HiveParsedTx; + +/** + * Parse and validate a host-serialized Graphene transaction against the + * device clear-sign op table. Returns NULL on success or a static error + * message. Slices in `out` borrow from `tx` — keep it alive. + * + * Ops 2 (transfer), 9 (account_create) and 10 (account_update) are + * permanently excluded; everything not in the table is refused outright — + * there is no blind-sign fallback. + */ +const char* hive_parseOperations(const uint8_t* tx, size_t len, + HiveParsedTx* out); + +/** + * Accessors for a HIVE_ASSET_LEN-byte asset slice stored in HiveTxOp.assets. + * The parser has already validated the symbol/precision pair, so the symbol + * is always a NUL-terminated "HIVE" / "HBD" / "VESTS" and the amount is + * non-negative. + */ +uint64_t hive_assetAmount(const uint8_t* asset); +uint8_t hive_assetPrecision(const uint8_t* asset); +const char* hive_assetSymbol(const uint8_t* asset); + +/** + * Sign a parsed HiveSignOperations transaction: digest is + * SHA256(chain_id || serialized_tx), identical to HiveSignTx. The caller + * (FSM handler) is responsible for parsing, display, and role checks. + */ +void hive_signOperations(const HDNode* node, const HiveSignOperations* msg, + HiveSignedOperations* resp); + +/** + * Sign an arbitrary message per the Hive Keychain signBuffer contract: + * signature over SHA256(message bytes) only — no chain_id prepend, no + * message prefix. Emits the 65-byte compact recoverable signature plus the + * signing key's 33-byte compressed public key. + */ +void hive_signMessage(const HDNode* node, const HiveSignMessage* msg, + HiveSignedMessage* resp); + +/** + * True iff every byte is printable ASCII (0x20-0x7e). Hive message signing + * requires this: a transaction digest is SHA256(chain_id || serialized_tx) + * whose chain_id and serialized fields are binary, so a printable-only message + * domain can never collide with a transaction preimage on ANY chain id. This + * closes the cross-chain message→transaction signature oracle that a + * mainnet-only prefix reject cannot. Empty (len == 0) returns true. + */ +bool hive_message_is_printable(const uint8_t* message, size_t len); + +/** + * Sign a Hive account_create transaction (op type 9). + * owner/active/posting/memo_raw must be device-derived 33-byte compressed keys. + * The firmware uses these directly; host-supplied key strings in msg are + * ignored. + */ +void hive_signAccountCreate(const HDNode* signing_node, + const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountCreate* resp); + +/** + * Sign a Hive account_update transaction (op type 10). + * owner/active/posting/memo_raw must be device-derived 33-byte compressed keys. + * The firmware uses these directly; host-supplied new_*_key strings in msg are + * ignored. + */ +void hive_signAccountUpdate(const HDNode* signing_node, + const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountUpdate* resp); + +#endif // KEEPKEY_FIRMWARE_HIVE_H diff --git a/include/keepkey/transport/interface.h b/include/keepkey/transport/interface.h index ab435baf8..6ec14d24e 100644 --- a/include/keepkey/transport/interface.h +++ b/include/keepkey/transport/interface.h @@ -39,6 +39,7 @@ #include "messages-ton.pb.h" #include "messages-solana.pb.h" #include "messages-zcash.pb.h" +#include "messages-hive.pb.h" #include "types.pb.h" #include "trezor_transport.h" diff --git a/include/keepkey/transport/messages-hive.options b/include/keepkey/transport/messages-hive.options new file mode 100644 index 000000000..d39d59215 --- /dev/null +++ b/include/keepkey/transport/messages-hive.options @@ -0,0 +1,58 @@ +HiveGetPublicKey.address_n max_count:8 + +HivePublicKey.public_key max_size:64 +HivePublicKey.raw_public_key max_size:33 + +HiveGetPublicKeys.account_index int_size:IS_32 + +HivePublicKeys.owner_key max_size:64 +HivePublicKeys.active_key max_size:64 +HivePublicKeys.memo_key max_size:64 +HivePublicKeys.posting_key max_size:64 + +HiveSignTx.address_n max_count:8 +HiveSignTx.chain_id max_size:32 +HiveSignTx.from max_size:16 +HiveSignTx.to max_size:16 +HiveSignTx.amount int_size:IS_64 +HiveSignTx.asset_symbol max_size:10 +HiveSignTx.memo max_size:2048 + +HiveSignedTx.signature max_size:65 +HiveSignedTx.serialized_tx max_size:512 + +HiveSignAccountCreate.address_n max_count:8 +HiveSignAccountCreate.chain_id max_size:32 +HiveSignAccountCreate.creator max_size:16 +HiveSignAccountCreate.new_account_name max_size:16 +HiveSignAccountCreate.owner_key max_size:64 +HiveSignAccountCreate.active_key max_size:64 +HiveSignAccountCreate.posting_key max_size:64 +HiveSignAccountCreate.memo_key max_size:64 +HiveSignAccountCreate.fee_amount int_size:IS_64 + +HiveSignedAccountCreate.signature max_size:65 +HiveSignedAccountCreate.serialized_tx max_size:512 + +HiveSignAccountUpdate.address_n max_count:8 +HiveSignAccountUpdate.chain_id max_size:32 +HiveSignAccountUpdate.account max_size:16 +HiveSignAccountUpdate.new_owner_key max_size:64 +HiveSignAccountUpdate.new_active_key max_size:64 +HiveSignAccountUpdate.new_posting_key max_size:64 +HiveSignAccountUpdate.new_memo_key max_size:64 + +HiveSignedAccountUpdate.signature max_size:65 +HiveSignedAccountUpdate.serialized_tx max_size:512 + +HiveSignMessage.address_n max_count:8 +HiveSignMessage.message max_size:1024 + +HiveSignedMessage.signature max_size:65 +HiveSignedMessage.public_key max_size:33 + +HiveSignOperations.address_n max_count:8 +HiveSignOperations.chain_id max_size:32 +HiveSignOperations.serialized_tx max_size:2048 + +HiveSignedOperations.signature max_size:65 diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index 4666060b8..ff10b335b 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -22,6 +22,7 @@ set(sources ethereum_tokens.c dice_input.c fsm.c + hive.c home_sm.c mayachain.c nano.c diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index 3653ea7b0..93d639564 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -59,6 +59,7 @@ #include "keepkey/firmware/signed_metadata.h" #include "keepkey/firmware/solana.h" #include "keepkey/firmware/zcash.h" +#include "keepkey/firmware/hive.h" #include "keepkey/firmware/storage.h" #include "keepkey/firmware/tendermint.h" #include "keepkey/firmware/thorchain.h" @@ -94,6 +95,7 @@ #include "messages-ton.pb.h" #include "messages-solana.pb.h" #include "messages-zcash.pb.h" +#include "messages-hive.pb.h" #include @@ -299,6 +301,7 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_tron.h" #include "fsm_msg_ton.h" #include "fsm_msg_solana.h" +#include "fsm_msg_hive.h" /* After fsm_msg_solana.h: reuses its base58 helper and the KKSOLSC1 parser. */ #include "fsm_msg_clearsign_attestor.h" #if ZCASH_PRIVACY diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h new file mode 100644 index 000000000..d87188a14 --- /dev/null +++ b/lib/firmware/fsm_msg_hive.h @@ -0,0 +1,1024 @@ +/* + * 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. + */ + +// ── HiveGetPublicKey ────────────────────────────────────────────────────── +// Returns a single STM-prefixed public key for the given SLIP-0048 path. +// Path format: m/48'/13'/role'/account'/0' (all 5 components hardened). + +void fsm_msgHiveGetPublicKey(const HiveGetPublicKey* msg) { + RESP_INIT(HivePublicKey); + + CHECK_INITIALIZED + CHECK_PIN + + if (!hive_slip48_path_valid(msg->address_n, msg->address_n_count)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + resp->has_raw_public_key = true; + resp->raw_public_key.size = 33; + memcpy(resp->raw_public_key.bytes, node->public_key, 33); + + resp->has_public_key = true; + if (!hive_getPublicKey(node->public_key, resp->public_key, + sizeof(resp->public_key))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to encode Hive public key")); + layoutHome(); + return; + } + + if (msg->has_show_display && msg->show_display) { + // Label the key by the role in the ACTUAL derivation path + // (m/48'/13'/role'/account'/0'), never the host-supplied msg->role, + // which could mislabel the exported key. + const char* role_label = "Hive Public Key"; + if (msg->address_n_count >= 3) { + switch (msg->address_n[2] & 0x7FFFFFFFu) { + case 0: + role_label = "Hive Owner Key"; + break; + case 1: + role_label = "Hive Active Key"; + break; + case 3: + role_label = "Hive Memo Key"; + break; + case 4: + role_label = "Hive Posting Key"; + break; + default: + break; + } + } + if (!confirm_ethereum_address(role_label, resp->public_key)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Cancelled")); + layoutHome(); + return; + } + } + + memzero(node, sizeof(*node)); + msg_write(MessageType_MessageType_HivePublicKey, resp); + layoutHome(); +} + +// ── HiveGetPublicKeys ───────────────────────────────────────────────────── +// Returns all four SLIP-0048 role keys (owner/active/memo/posting) for a +// given account index in a single device interaction. + +void fsm_msgHiveGetPublicKeys(const HiveGetPublicKeys* msg) { + RESP_INIT(HivePublicKeys); + + CHECK_INITIALIZED + CHECK_PIN + + uint32_t account_index = msg->has_account_index ? msg->account_index : 0; + + HDNode* root = fsm_getDerivedNode(SECP256K1_NAME, NULL, 0, NULL); + if (!root) return; + + resp->has_owner_key = true; + resp->has_active_key = true; + resp->has_memo_key = true; + resp->has_posting_key = true; + + if (!hive_getPublicKeys(root, account_index, resp->owner_key, + sizeof(resp->owner_key), resp->active_key, + sizeof(resp->active_key), resp->memo_key, + sizeof(resp->memo_key), resp->posting_key, + sizeof(resp->posting_key))) { + memzero(root, sizeof(*root)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to derive Hive keys")); + layoutHome(); + return; + } + + if (msg->has_show_display && msg->show_display) { + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Hive Keys", + "Export all Hive keys for account %u?", + (unsigned int)account_index)) { + memzero(root, sizeof(*root)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Cancelled")); + layoutHome(); + return; + } + } + + memzero(root, sizeof(*root)); + msg_write(MessageType_MessageType_HivePublicKeys, resp); + layoutHome(); +} + +// ── SLIP-0048 path validation ───────────────────────────────────────────── +// All three sign handlers enforce the full path shape before anything is +// derived or signed: m/48'/13'/role'/account'/0' (all 5 components hardened), +// with the role pinned to the one the operation needs on-chain: +// transfer -> active' (post-HF28 hived no longer accepts higher-role +// substitution, and the cold owner key must not be spent) +// create/update -> owner' (the attestation contract: the sponsor verifies +// the signature recovers to the device OWNER key, and +// account_update replaces the owner authority itself) +// Rejecting arbitrary host paths means a compromised host can never make the +// device produce a Hive signature with a key from another coin's derivation +// tree, nor with the wrong role's key. + +static bool hive_slip48_path_ok(const uint32_t* address_n, uint32_t count, + uint32_t required_role) { + return hive_slip48_path_valid_for_role(address_n, count, required_role); +} + +static bool hive_confirm_slice(ButtonRequestType type, const char* title, + const uint8_t* s, uint16_t len); + +// ── HiveSignTx (transfer) ───────────────────────────────────────────────── + +void fsm_msgHiveSignTx(const HiveSignTx* msg) { + RESP_INIT(HiveSignedTx); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_from || !msg->has_to || !msg->has_amount || + !msg->has_ref_block_num || !msg->has_ref_block_prefix || + !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required Hive transaction fields")); + layoutHome(); + return; + } + + if (!hive_slip48_path_ok(msg->address_n, msg->address_n_count, + HIVE_ROLE_ACTIVE)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path (transfer needs active')")); + layoutHome(); + return; + } + + // Reject over-long memos up front with a specific error; the serializer's + // own bounds check would otherwise surface as a generic signing failure. + if (msg->has_memo && strlen(msg->memo) > HIVE_MAX_MEMO_LEN) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive memo too long (max 440 bytes)")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Display precision MUST match the precision the serializer signs + // (append_asset uses msg->decimals), otherwise the user approves an + // amount that differs from what is signed. Reject implausible precision. + uint8_t prec = msg->has_decimals ? (uint8_t)msg->decimals : HIVE_DECIMALS; + if (prec > 18) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive asset precision")); + layoutHome(); + return; + } + const char* symbol = msg->has_asset_symbol ? msg->asset_symbol : "HIVE"; + char suffix[sizeof(msg->asset_symbol) + 2]; // leading space + symbol + NUL + snprintf(suffix, sizeof(suffix), " %s", symbol); + char amount_str[32]; + bn_format_uint64(msg->amount, NULL, suffix, prec, 0, false, amount_str, + sizeof(amount_str)); + + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send Hive", + "Send %s to @%s?", amount_str, msg->to)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + if (msg->has_memo && strlen(msg->memo) > 0) { + if (!hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmMemo, "Memo", + (const uint8_t*)msg->memo, + (uint16_t)strlen(msg->memo))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } + + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", + "Sign Hive transaction from @%s?", msg->from)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signTx(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedTx, resp); + layoutHome(); +} + +typedef struct { + uint8_t owner[33]; + uint8_t active[33]; + uint8_t posting[33]; + uint8_t memo[33]; +} HiveRoleKeys; + +static bool hive_prepare_account_sign(const uint32_t* address_n, + uint32_t address_n_count, + HiveRoleKeys* keys, HDNode** node_out, + char* owner_stm, size_t owner_stm_len) { + if (!hive_slip48_path_ok(address_n, address_n_count, HIVE_ROLE_OWNER)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path (needs owner')")); + layoutHome(); + return false; + } + uint32_t account_index = address_n[3] & 0x7FFFFFFFu; + + // Derive all four role keys from the device root. + // Do this BEFORE fetching the signing node so the root static buffer + // is not clobbered by the second fsm_getDerivedNode call. + const HDNode* root = fsm_getDerivedNode(SECP256K1_NAME, NULL, 0, NULL); + if (!root) return false; + + uint32_t acc_hardened = account_index | 0x80000000u; + bool keys_ok = + hive_deriveRawKey(root, HIVE_ROLE_OWNER, acc_hardened, keys->owner) && + hive_deriveRawKey(root, HIVE_ROLE_ACTIVE, acc_hardened, keys->active) && + hive_deriveRawKey(root, HIVE_ROLE_POSTING, acc_hardened, keys->posting) && + hive_deriveRawKey(root, HIVE_ROLE_MEMO, acc_hardened, keys->memo); + // root static buffer is done with; signing node derivation may overwrite it. + + if (!keys_ok) { + memzero(keys, sizeof(*keys)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to derive Hive keys")); + layoutHome(); + return false; + } + + // Now get the signing node (owner key, overwrites root static buffer). + HDNode* node = + fsm_getDerivedNode(SECP256K1_NAME, address_n, address_n_count, NULL); + if (!node) { + memzero(keys, sizeof(*keys)); + return false; + } + hdnode_fill_public_key(node); + + // Encode the device-derived owner key for display confirmation. + if (!hive_getPublicKey(keys->owner, owner_stm, owner_stm_len)) { + memzero(node, sizeof(*node)); + memzero(keys, sizeof(*keys)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to encode Hive owner key")); + layoutHome(); + return false; + } + + *node_out = node; + return true; +} + +// ── HiveSignAccountCreate ───────────────────────────────────────────────── +// Signs a Graphene account_create operation. +// Device derives all four role keys internally; host-supplied key strings +// are informational only (displayed for confirmation) and never used for +// the actual transaction. KeepKey is the sole root of trust from genesis. + +void fsm_msgHiveSignAccountCreate(const HiveSignAccountCreate* msg) { + RESP_INIT(HiveSignedAccountCreate); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_new_account_name || !msg->has_creator || + !msg->has_ref_block_num || !msg->has_ref_block_prefix || + !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required account_create fields")); + layoutHome(); + return; + } + + HiveRoleKeys keys; + HDNode* node = NULL; + char owner_stm[64]; + if (!hive_prepare_account_sign(msg->address_n, msg->address_n_count, &keys, + &node, owner_stm, sizeof(owner_stm))) { + return; + } + + // Primary confirmation: show the new username prominently. + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Create Hive Account", + "Create @%s secured by KeepKey?\n\nAll keys from your device.", + msg->new_account_name)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Secondary confirmation: show device-derived owner key so user can verify. + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Owner Key", "%s", + owner_stm)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Tertiary confirmation: show sponsor + fee. + char fee_str[32]; + uint64_t fee = msg->has_fee_amount ? msg->fee_amount : 3000; + snprintf(fee_str, sizeof(fee_str), "%" PRIu64 ".%03" PRIu64 " HIVE", + fee / 1000, fee % 1000); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Creation Fee", + "Fee: %s paid by @%s", fee_str, msg->creator)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signAccountCreate(node, msg, keys.owner, keys.active, keys.posting, + keys.memo, resp); + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive account_create signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedAccountCreate, resp); + layoutHome(); +} + +// ── HiveSignAccountUpdate ───────────────────────────────────────────────── +// Signs a Graphene account_update operation. +// Device derives all four new role keys internally; host-supplied new_*_key +// strings are not used for signing. The device-derived owner key is shown +// so the user can verify it matches their device before replacing all keys. + +void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg) { + RESP_INIT(HiveSignedAccountUpdate); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_account || !msg->has_ref_block_num || + !msg->has_ref_block_prefix || !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required account_update fields")); + layoutHome(); + return; + } + + HiveRoleKeys keys; + HDNode* node = NULL; + char owner_stm[64]; + if (!hive_prepare_account_sign(msg->address_n, msg->address_n_count, &keys, + &node, owner_stm, sizeof(owner_stm))) { + return; + } + + // Warning: this replaces all existing keys. + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, + "Secure Hive Account", + "Replace ALL keys for @%s with KeepKey keys?\n\nOld keys will " + "be retired.", + msg->account)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Show device-derived owner key so user can verify it's their device. + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "New Owner Key", "%s", + owner_stm)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signAccountUpdate(node, msg, keys.owner, keys.active, keys.posting, + keys.memo, resp); + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive account_update signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedAccountUpdate, resp); + layoutHome(); +} + +// ── HiveSignMessage (Keychain signBuffer) ───────────────────────────────── +// The Hive dApp login primitive: Aioha / Keychain-SDK dApps authenticate by +// having the account sign a challenge string, then recover the pubkey and +// check it against the account's authority on-chain. Contract (hive-js +// Signature.signBuffer): sig over SHA256(raw message bytes) — no chain_id, +// no prefix. Roles: posting/active/memo, Keychain's requestSignBuffer +// surface. owner' is deliberately rejected — no consumer offers it, and the +// cold owner key must not be normalized into dApp flows. The full path +// shape is still enforced like the tx handlers. + +static bool hive_slip48_message_path_ok(const uint32_t* address_n, + uint32_t count, + const char** role_label) { + if (!hive_slip48_path_valid(address_n, count)) return false; + switch (address_n[2]) { + case HIVE_ROLE_ACTIVE: + *role_label = "active"; + return true; + case HIVE_ROLE_MEMO: + *role_label = "memo"; + return true; + case HIVE_ROLE_POSTING: + *role_label = "posting"; + return true; + default: + return false; + } +} + +void fsm_msgHiveSignMessage(const HiveSignMessage* msg) { + RESP_INIT(HiveSignedMessage); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_message || msg->message.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Missing message")); + layoutHome(); + return; + } + + // Mirrors the proto max_size cap so proto and code can never disagree + // (the memo-length lesson from the transfer handler). + if (msg->message.size > HIVE_MAX_MESSAGE_LEN) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive message too long (max 1024 bytes)")); + layoutHome(); + return; + } + + // A Hive TRANSACTION digest is SHA256(chain_id || tx), and this message + // digest is SHA256(message) — so a "message" that begins with the mainnet + // chain-id bytes would hash to a broadcastable transaction's digest. No + // legitimate challenge starts with the chain id; refuse the collision. + const uint8_t hive_chain_id[HIVE_CHAIN_ID_LEN] = HIVE_CHAIN_ID; + if (msg->message.size >= HIVE_CHAIN_ID_LEN && + memcmp(msg->message.bytes, hive_chain_id, HIVE_CHAIN_ID_LEN) == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Message must not start with the Hive chain ID")); + layoutHome(); + return; + } + + const char* role_label = NULL; + if (!hive_slip48_message_path_ok(msg->address_n, msg->address_n_count, + &role_label)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Domain-separate messages from transactions. A Hive TRANSACTION digest is + // SHA256(chain_id || serialized_tx), where the 32-byte chain_id and the + // serialized Graphene fields (ref_block_prefix, expiration, ...) are BINARY. + // Constraining signable messages to printable ASCII puts them in a domain + // disjoint from every transaction preimage — for ANY chain id, not just + // mainnet — so a binary "message" equal to C || serialized_tx can no longer + // be signed into a valid transaction signature on a fork chain C. This is the + // real fix; the mainnet-only prefix reject above is a belt-and-suspenders + // subset of it. hive-js signBuffer signs printable challenges, so nothing + // legitimate is lost. (A prefix blacklist could never be complete because the + // host chooses the chain id; a printable-only whitelist is complete by + // construction against binary preimages.) + if (!hive_message_is_printable(msg->message.bytes, msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive messages must be printable text")); + layoutHome(); + return; + } + + // Page the FULL message (72-char ASCII pages) so no trailing content is ever + // truncated behind a benign-looking prefix, and name the signing key. + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, "Sign Hive Message", + "Signing with %s key", role_label) || + !hive_confirm_slice(ButtonRequestType_ButtonRequest_ProtectCall, + "Hive Message", msg->message.bytes, + (uint16_t)msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signMessage(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive message signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedMessage, resp); + layoutHome(); +} + +// ── HiveSignOperations (parsed generic op signing) ──────────────────────── +// The host serializes the transaction; firmware parses the Graphene bytes, +// clear-signs the ops it recognizes (vote, comment, custom_json), and +// refuses everything else — no blind-sign fallback. Everything shown on the +// OLED is re-derived from the bytes being signed, so a host serializer bug +// can only produce a node rejection, never a silent wrong-sign. + +// Dedicated path validator: {posting', active'} ONLY, pinned to the tx tier. +// Do NOT fold into hive_slip48_message_path_ok — that one deliberately +// accepts memo' (a legitimate signBuffer target), but no Graphene operation +// uses memo authority; a memo-path vote must be refused here, not +// discovered at the chain. owner' is likewise excluded. +static bool hive_slip48_ops_path_ok(const uint32_t* address_n, uint32_t count, + bool needs_active) { + return hive_slip48_path_valid_for_role( + address_n, count, needs_active ? HIVE_ROLE_ACTIVE : HIVE_ROLE_POSTING); +} + +// User-controlled string fields are paged in full. Printable fields are shown +// as text; fields containing non-ASCII bytes are shown as complete hex rather +// than a short preview. Page boundaries are selected with the same font and +// word-wrapping calculation used by draw_string(), so no signed suffix can be +// pushed below the OLED's three visible body rows. + +static bool hive_slice_is_ascii(const uint8_t* s, uint16_t len) { + bool ascii = true; + for (uint16_t i = 0; i < len; i++) { + if (s[i] < 0x20 || s[i] > 0x7e) { + ascii = false; + break; + } + } + return ascii; +} + +static uint16_t hive_rendered_page_len(const uint8_t* s, uint16_t len, + bool ascii) { + if (len == 0) return 0; + + if (ascii) { + size_t candidate = len; + if (candidate >= BODY_CHAR_MAX) candidate = BODY_CHAR_MAX - 1; + return (uint16_t)calc_str_page(get_body_font(), (const char*)s, candidate, + BODY_WIDTH, BODY_ROWS); + } + + uint16_t candidate = len; + if (candidate > (BODY_CHAR_MAX - 1) / 2) candidate = (BODY_CHAR_MAX - 1) / 2; + char rendered[BODY_CHAR_MAX]; + for (uint16_t i = 0; i < candidate; i++) { + snprintf(rendered + 2 * i, 3, "%02x", s[i]); + } + size_t chars = calc_str_page(get_body_font(), rendered, 2 * candidate, + BODY_WIDTH, BODY_ROWS); + return (uint16_t)(chars / 2); +} + +static bool hive_confirm_slice(ButtonRequestType type, const char* title, + const uint8_t* s, uint16_t len) { + if (len == 0) return confirm(type, title, "(empty)"); + + bool ascii = hive_slice_is_ascii(s, len); + uint16_t pages = 0; + uint16_t offset = 0; + while (offset < len) { + uint16_t take = hive_rendered_page_len(s + offset, len - offset, ascii); + if (take == 0) return false; + offset = (uint16_t)(offset + take); + pages++; + } + + offset = 0; + for (uint16_t page = 0; page < pages; page++) { + uint16_t take = hive_rendered_page_len(s + offset, len - offset, ascii); + if (take == 0) return false; + + char page_title[TITLE_CHAR_MAX]; + if (pages > 1 || !ascii) { + snprintf(page_title, sizeof(page_title), + ascii ? "%s %u/%u" : "%s Hex %u/%u", title, (unsigned)(page + 1), + (unsigned)pages); + } else { + strlcpy(page_title, title, sizeof(page_title)); + } + + if (ascii) { + char rendered[BODY_CHAR_MAX]; + memcpy(rendered, s + offset, take); + rendered[take] = '\0'; + if (!confirm(type, page_title, "%s", rendered)) return false; + } else { + char rendered[BODY_CHAR_MAX]; + for (uint16_t i = 0; i < take; i++) { + snprintf(rendered + 2 * i, 3, "%02x", s[offset + i]); + } + if (!confirm(type, page_title, "%s", rendered)) return false; + } + offset = (uint16_t)(offset + take); + } + return true; +} + +// "1.234 HIVE" — precision comes from the asset bytes being signed, which +// the parser has already pinned to the symbol's protocol-fixed value. +static void hive_format_asset(const uint8_t* a, char* out, size_t out_len) { + char suffix[9]; // space + longest symbol ("VESTS") + NUL + snprintf(suffix, sizeof(suffix), " %s", hive_assetSymbol(a)); + bn_format_uint64(hive_assetAmount(a), NULL, suffix, hive_assetPrecision(a), 0, + false, out, out_len); +} + +// Basis points (0..10000) as "12.34%". +static void hive_format_percent(int16_t bp, char* out, size_t out_len) { + snprintf(out, out_len, "%d.%02d%%", bp / 100, bp % 100); +} + +static void hive_copy_slice(char* out, size_t out_len, const uint8_t* s, + uint16_t len) { + if (out_len == 0) return; + size_t take = len; + if (take >= out_len) take = out_len - 1; + memcpy(out, s, take); + out[take] = '\0'; +} + +void fsm_msgHiveSignOperations(const HiveSignOperations* msg) { + RESP_INIT(HiveSignedOperations); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_serialized_tx || msg->serialized_tx.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing serialized transaction")); + layoutHome(); + return; + } + + static HiveParsedTx parsed; // slices borrow from the static msg buffer + const char* parse_err = hive_parseOperations( + msg->serialized_tx.bytes, msg->serialized_tx.size, &parsed); + if (parse_err) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _(parse_err)); + layoutHome(); + return; + } + + if (!hive_slip48_ops_path_ok(msg->address_n, msg->address_n_count, + parsed.needs_active)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + parsed.needs_active + ? _("Invalid Hive SLIP-0048 path (needs active')") + : _("Invalid Hive SLIP-0048 path (needs posting')")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Confirm operation summaries and payloads, then show a final sign prompt. + for (uint8_t i = 0; i < parsed.num_ops; i++) { + const HiveTxOp* op = &parsed.ops[i]; + char name[17]; // hive account names are <= 16 chars, length-validated + hive_copy_slice(name, sizeof(name), op->acct, op->acct_len); + + bool approved = false; + switch (op->op_type) { + case HIVE_OP_VOTE: { + char target[17]; + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + int w = op->weight < 0 ? -op->weight : op->weight; + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + op->weight < 0 ? "Downvote" : "Vote", + "@%s -> @%s at %d.%02d%%", name, target, w / 100, w % 100); + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Vote Target", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_COMMENT: { + char parent[17]; + hive_copy_slice(parent, sizeof(parent), op->parent_author, + op->parent_author_len); + approved = + op->is_top_level + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Post", + "Create post by @%s?", name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Comment", "Reply by @%s to @%s?", name, parent); + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, + op->is_top_level ? "Post Category" : "Reply Target", + op->parent_permlink, op->parent_permlink_len); + } + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Post Permlink", + op->permlink, op->permlink_len); + } + if (approved && op->target_len > 0) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Post Title", op->target, op->target_len); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Post Body", op->detail, op->detail_len); + } + if (approved && op->json_metadata_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Post Metadata", + op->json_metadata, op->json_metadata_len); + } + break; + } + case HIVE_OP_CUSTOM_JSON: { + approved = true; + for (uint8_t a = 0; approved && a < op->n_auths; a++) { + char auth_name[17]; + hive_copy_slice(auth_name, sizeof(auth_name), op->auth_acct[a], + op->auth_acct_len[a]); + approved = confirm( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Custom JSON Auth", + "%u/%u: @%s\n%s key", (unsigned)(a + 1), (unsigned)op->n_auths, + auth_name, op->needs_active ? "Active" : "Posting"); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON ID", op->target, op->target_len); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_TRANSFER_TO_VESTING: { + char amount[40], target[17]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + approved = + op->target_len == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Up", "Power up\n%s\nto @%s", amount, name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Up", "%s\nfrom @%s\nto @%s", amount, name, + target); + break; + } + case HIVE_OP_WITHDRAW_VESTING: { + char amount[40]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + approved = + hive_assetAmount(op->assets[0]) == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Stop Power Down", "Cancel power down\nfor @%s", name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Down", "Power down\n%s\nfrom @%s", amount, + name); + break; + } + case HIVE_OP_LIMIT_ORDER_CREATE: { + char sell[40], receive[40]; + hive_format_asset(op->assets[0], sell, sizeof(sell)); + hive_format_asset(op->assets[1], receive, sizeof(receive)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Market Order", "@%s sells\n%s\nfor >= %s", name, + sell, receive); + if (approved) { + // Order id and fill_or_kill decide whether an unfilled order rests + // on the book or is discarded, so they get their own screen rather + // than being crowded off the first one. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Order Terms", "Order #%u\n%s\nExpires %u", + (unsigned)op->req_id, + op->flag ? "Fill or kill" : "Rests on book", + (unsigned)op->expiration); + } + break; + } + case HIVE_OP_LIMIT_ORDER_CANCEL: + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Cancel Order", "Cancel order #%u\nfor @%s?", + (unsigned)op->req_id, name); + break; + case HIVE_OP_CONVERT: { + char amount[40]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Convert", "Convert %s\nto HIVE for @%s\n(#%u)", + amount, name, (unsigned)op->req_id); + break; + } + case HIVE_OP_COMMENT_OPTIONS: { + char max_payout[40], percent[16]; + hive_format_asset(op->assets[0], max_payout, sizeof(max_payout)); + hive_format_percent(op->weight, percent, sizeof(percent)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Options", "@%s\nMax %s\nHBD split %s", name, + max_payout, percent); + if (approved) { + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Options", "Votes: %s\nCuration: %s", + op->flag ? "allowed" : "disabled", + op->flag2 ? "allowed" : "disabled"); + } + // Beneficiaries divert payout to other accounts — each one is + // confirmed individually rather than summarized as a count. + for (uint8_t b = 0; approved && b < op->n_benef; b++) { + char benef[17], benef_pct[16]; + hive_copy_slice(benef, sizeof(benef), op->benef_acct[b], + op->benef_acct_len[b]); + hive_format_percent((int16_t)op->benef_weight[b], benef_pct, + sizeof(benef_pct)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Beneficiary", "%u/%u: @%s\ngets %s", + (unsigned)(b + 1), (unsigned)op->n_benef, benef, + benef_pct); + } + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Payout Permlink", + op->permlink, op->permlink_len); + } + break; + } + case HIVE_OP_TRANSFER_TO_SAVINGS: + case HIVE_OP_TRANSFER_FROM_SAVINGS: { + char amount[40], target[17]; + bool deposit = (op->op_type == HIVE_OP_TRANSFER_TO_SAVINGS); + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + // One variable per row. A 16-character account name sharing a row + // with a label can wrap into a fourth row, which the display drops + // silently — and here that row carries the destination account. + // req_id is deliberately not shown: it is a cancellation handle, not + // a fund-routing field, and crowding it in costs the destination row. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + deposit ? "Savings Deposit" : "Savings Withdraw", + "%s\nfrom @%s\nto @%s", amount, name, target); + if (approved && op->detail_len > 0) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Savings Memo", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_CLAIM_REWARD_BALANCE: { + char hive_amt[40], hbd_amt[40], vests_amt[40]; + hive_format_asset(op->assets[0], hive_amt, sizeof(hive_amt)); + hive_format_asset(op->assets[1], hbd_amt, sizeof(hbd_amt)); + hive_format_asset(op->assets[2], vests_amt, sizeof(vests_amt)); + // Three assets plus the account name cannot share one screen: the + // OLED body fits exactly three rows (layout.c places rows at y = + // 24/38/52 and draw_char_with_shift silently drops any glyph past + // y+height > 64), so a fourth row would be signed but never shown. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Claim Rewards", "@%s claims\n%s\n%s", name, + hive_amt, hbd_amt); + if (approved) { + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Claim Rewards", "@%s claims\n%s", name, vests_amt); + } + break; + } + case HIVE_OP_DELEGATE_VESTING_SHARES: { + char amount[40], target[17]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + approved = + hive_assetAmount(op->assets[0]) == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Remove Delegation", + "@%s removes its\ndelegation to @%s?", name, target) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Delegate", "@%s delegates\n%s\nto @%s", name, amount, + target); + break; + } + case HIVE_OP_ACCOUNT_UPDATE2: + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Profile Update", "Update profile\nof @%s?", name); + if (approved && op->detail_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Account Metadata", + op->detail, op->detail_len); + } + if (approved && op->json_metadata_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Profile Metadata", + op->json_metadata, op->json_metadata_len); + } + break; + default: + break; // unreachable — parser rejected unknown ops + } + if (!approved) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } + + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", + "Sign %u Hive operation%s with the %s key?", + (unsigned)parsed.num_ops, parsed.num_ops == 1 ? "" : "s", + parsed.needs_active ? "active" : "posting")) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signOperations(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive operation signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedOperations, resp); + layoutHome(); +} diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c new file mode 100644 index 000000000..304f673ae --- /dev/null +++ b/lib/firmware/hive.c @@ -0,0 +1,1096 @@ +/* + * 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. + */ + +#include "keepkey/firmware/hive.h" + +#include "trezor/crypto/base58.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" + +#include +#include + +// ── STM public key encoding ─────────────────────────────────────────────── + +bool hive_getPublicKey(const uint8_t public_key[33], char* out, + size_t out_len) { + const size_t prefix_len = strlen(HIVE_PUBKEY_PREFIX); + if (out_len < prefix_len + 1) return false; + strlcpy(out, HIVE_PUBKEY_PREFIX, out_len); + // Graphene uses RIPEMD checksum (not SHA256d) for public key encoding + return base58_encode_check(public_key, 33, HASHER_RIPEMD, out + prefix_len, + out_len - prefix_len); +} + +// ── Single-role key derivation to raw 33 bytes ──────────────────────────── +// Path: m/48'/13'/role_hardened/account_index_hardened/0' +// hdnode_private_ckd() returns 1 on success, 0 on failure. + +static bool hive_role_valid(uint32_t role) { + return role == HIVE_ROLE_OWNER || role == HIVE_ROLE_ACTIVE || + role == HIVE_ROLE_MEMO || role == HIVE_ROLE_POSTING; +} + +bool hive_slip48_path_valid(const uint32_t* address_n, size_t count) { + if (!address_n || count != 5) return false; + if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; + if (address_n[1] != HIVE_SLIP48_NETWORK) return false; + if (!hive_role_valid(address_n[2])) return false; + if ((address_n[3] & 0x80000000u) == 0) return false; + if (address_n[4] != 0x80000000u) return false; + return true; +} + +bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, + uint32_t required_role) { + return hive_role_valid(required_role) && + hive_slip48_path_valid(address_n, count) && + address_n[2] == required_role; +} + +bool hive_deriveRawKey(const HDNode* root, uint32_t role_hardened, + uint32_t account_index_hardened, uint8_t out[33]) { + HDNode node; + memcpy(&node, root, sizeof(HDNode)); + if (!hdnode_private_ckd(&node, HIVE_SLIP48_PURPOSE)) goto fail; + if (!hdnode_private_ckd(&node, HIVE_SLIP48_NETWORK)) goto fail; + if (!hdnode_private_ckd(&node, role_hardened)) goto fail; + if (!hdnode_private_ckd(&node, account_index_hardened)) goto fail; + if (!hdnode_private_ckd(&node, 0x80000000u)) goto fail; + hdnode_fill_public_key(&node); + memcpy(out, node.public_key, 33); + memzero(&node, sizeof(node)); + return true; +fail: + memzero(&node, sizeof(node)); + return false; +} + +// ── SLIP-0048 multi-role key derivation ─────────────────────────────────── + +bool hive_getPublicKeys(const HDNode* root, uint32_t account_index, + char* owner_out, size_t owner_len, char* active_out, + size_t active_len, char* memo_out, size_t memo_len, + char* posting_out, size_t posting_len) { + const uint32_t roles[4] = { + HIVE_ROLE_OWNER, + HIVE_ROLE_ACTIVE, + HIVE_ROLE_MEMO, + HIVE_ROLE_POSTING, + }; + char* outs[4] = {owner_out, active_out, memo_out, posting_out}; + const size_t lens[4] = {owner_len, active_len, memo_len, posting_len}; + + uint32_t account_hardened = account_index | 0x80000000u; + + for (int i = 0; i < 4; i++) { + uint8_t raw[33]; + if (!hive_deriveRawKey(root, roles[i], account_hardened, raw)) return false; + if (!hive_getPublicKey(raw, outs[i], lens[i])) { + memzero(raw, sizeof(raw)); + return false; + } + memzero(raw, sizeof(raw)); + } + return true; +} + +// ── Graphene binary serialization helpers ───────────────────────────────── + +static void append_u8(uint8_t** buf, const uint8_t* end, uint8_t v) { + if (*buf < end) { + **buf = v; + (*buf)++; + } +} + +static void append_u16_le(uint8_t** buf, const uint8_t* end, uint16_t v) { + append_u8(buf, end, v & 0xFF); + append_u8(buf, end, (v >> 8) & 0xFF); +} + +static void append_u32_le(uint8_t** buf, const uint8_t* end, uint32_t v) { + append_u8(buf, end, v & 0xFF); + append_u8(buf, end, (v >> 8) & 0xFF); + append_u8(buf, end, (v >> 16) & 0xFF); + append_u8(buf, end, (v >> 24) & 0xFF); +} + +static void append_u64_le(uint8_t** buf, const uint8_t* end, uint64_t v) { + for (int i = 0; i < 8; i++) { + append_u8(buf, end, v & 0xFF); + v >>= 8; + } +} + +static void append_varint(uint8_t** buf, const uint8_t* end, uint64_t v) { + do { + uint8_t b = v & 0x7F; + v >>= 7; + if (v) b |= 0x80; + append_u8(buf, end, b); + } while (v); +} + +static void append_string(uint8_t** buf, const uint8_t* end, const char* s) { + size_t len = s ? strlen(s) : 0; + append_varint(buf, end, len); + for (size_t i = 0; i < len && *buf < end; i++) + append_u8(buf, end, (uint8_t)s[i]); +} + +/* + * Graphene asset encoding: int64 LE amount + uint8 precision + 7-byte symbol + */ +static void append_asset(uint8_t** buf, const uint8_t* end, uint64_t amount, + uint8_t precision, const char* symbol) { + append_u64_le(buf, end, amount); + append_u8(buf, end, precision); + char sym[7] = {0}; + if (symbol) strncpy(sym, symbol, 6); + for (int i = 0; i < 7 && *buf < end; i++) + append_u8(buf, end, (uint8_t)sym[i]); +} + +/* + * Graphene authority structure (Hive wire format): + * weight_threshold (uint32 LE) = 1 + * num_account_auths (varint) = 0 + * num_key_auths (varint) = 1 + * compressed public key (33 bytes, no type prefix) + * weight (uint16 LE) = 1 + * + * Note: Hive does NOT use a key-type prefix byte before the 33 raw bytes. + */ +static void append_authority(uint8_t** buf, const uint8_t* end, + const uint8_t pubkey[33]) { + append_u32_le(buf, end, 1); // weight_threshold = 1 + append_varint(buf, end, 0); // 0 account auths + append_varint(buf, end, 1); // 1 key auth + for (int i = 0; i < 33 && *buf < end; i++) append_u8(buf, end, pubkey[i]); + append_u16_le(buf, end, 1); // weight = 1 +} + +/* + * Common transaction header: ref_block_num, ref_block_prefix, expiration, + * then a varint op count = 1, then the op type varint. + */ +static void append_tx_header(uint8_t** buf, const uint8_t* end, + uint16_t ref_block_num, uint32_t ref_block_prefix, + uint32_t expiration, uint32_t op_type) { + append_u16_le(buf, end, ref_block_num); + append_u32_le(buf, end, ref_block_prefix); + append_u32_le(buf, end, expiration); + append_varint(buf, end, 1); // 1 operation + append_varint(buf, end, op_type); +} + +static void append_tx_footer(uint8_t** buf, const uint8_t* end) { + append_varint(buf, end, 0); // 0 extensions +} + +/* + * Graphene legacy canonical-signature rule (identical to EOS/Steem): high bit + * of both r and s must be clear — same predicate as eos_is_canonic. Modern + * hived (post-HF28) actually enforces only BIP-0062 low-S (fc is_canonical -> + * is_bip_0062_canonical), which trezor-crypto's low-S normalization already + * guarantees; keeping the stricter legacy rule costs an occasional extra + * RFC6979 iteration and stays compatible with every historical verifier. + */ +static int hive_is_canonic(uint8_t v, uint8_t signature[64]) { + (void)v; + return !(signature[0] & 0x80) && + !(signature[0] == 0 && !(signature[1] & 0x80)) && + !(signature[32] & 0x80) && + !(signature[32] == 0 && !(signature[33] & 0x80)); +} + +/* + * Core sign helper over an already-computed 32-byte digest → 65-byte + * compact recoverable sig: header (27 + recovery_id + 4 compressed-key + * flag), then r(32) ‖ s(32). + */ +static bool hive_sign_raw_digest(const HDNode* node, const uint8_t digest[32], + uint8_t sig[65]) { + uint8_t pby; + if (ecdsa_sign_digest(&secp256k1, node->private_key, digest, sig + 1, &pby, + hive_is_canonic) != 0) { + return false; + } + // Compact signature header: 27 + recovery_id + 4 (compressed key flag) + sig[0] = 27 + pby + 4; + return true; +} + +/* + * Transaction sign helper: SHA256(chain_id || serialized_tx) → compact sig. + * Writes 65 bytes into sig[]. Returns true on success. + */ +static bool hive_sign_digest(const HDNode* node, const uint8_t* chain_id, + const uint8_t* tx_buf, size_t tx_len, + uint8_t sig[65]) { + SHA256_CTX sha; + sha256_Init(&sha); + sha256_Update(&sha, chain_id, HIVE_CHAIN_ID_LEN); + sha256_Update(&sha, tx_buf, tx_len); + uint8_t digest[32]; + sha256_Final(&sha, digest); + + bool ok = hive_sign_raw_digest(node, digest, sig); + memzero(digest, sizeof(digest)); + return ok; +} + +/* + * Chain-id select (host-supplied 32-byte chain_id or mainnet default) + + * hive_sign_digest, writing the 65-byte compact signature into sig[]. + */ +static bool hive_sign_tx_sig(const HDNode* node, bool has_chain_id, + const uint8_t* chain_id_bytes, + size_t chain_id_size, const uint8_t* tx_buf, + size_t tx_len, uint8_t sig[65]) { + const uint8_t default_chain_id[32] = HIVE_CHAIN_ID; + /* Pin to Hive mainnet. A host-supplied chain_id is accepted only if it equals + * mainnet; any other value is refused rather than signed under an undisclosed + * network domain (the confirmations just say "Hive"). This also keeps the tx + * digest domain singular — SHA256(mainnet_chain_id || tx) — so the + * message-signing guard that rejects messages beginning with the mainnet + * chain id fully closes the tx/message signature collision. */ + if (has_chain_id) { + if (chain_id_size != HIVE_CHAIN_ID_LEN || + memcmp(chain_id_bytes, default_chain_id, HIVE_CHAIN_ID_LEN) != 0) { + return false; + } + } + return hive_sign_digest(node, default_chain_id, tx_buf, tx_len, sig); +} + +// ── Parsed operation signing (HiveSignOperations) ───────────────────────── +// +// The host serializes the transaction; firmware re-derives everything it +// displays from the bytes and refuses anything outside the phase-1 op table. +// Digest/signature are identical to HiveSignTx: SHA256(chain_id || tx). + +typedef struct { + const uint8_t* p; + const uint8_t* end; +} HiveCur; + +/* + * Bounded unsigned LEB128: at most 5 bytes, must fit uint32, overlong + * encodings rejected (an unbounded shift is a classic overflow hole). + */ +static bool cur_varint(HiveCur* c, uint32_t* out) { + uint32_t v = 0; + for (int shift = 0; shift <= 28; shift += 7) { + if (c->p >= c->end) return false; + uint8_t b = *c->p++; + if (shift == 28 && (b & 0xF0)) return false; // overflow or 6th byte + v |= (uint32_t)(b & 0x7F) << shift; + if (!(b & 0x80)) { + // A multi-byte LEB128 whose final group is zero has a shorter encoding. + // hived re-serializes values canonically when checking a signature, so + // accepting an overlong form would make the device sign bytes the chain + // interprets and hashes differently. + if (shift > 0 && (b & 0x7F) == 0) return false; + *out = v; + return true; + } + } + return false; +} + +/* varint length + bytes, bounds-checked against the buffer AND field caps. */ +static bool cur_string(HiveCur* c, const uint8_t** s, uint16_t* slen, + uint32_t min_len, uint32_t max_len) { + uint32_t n; + if (!cur_varint(c, &n)) return false; + if (n < min_len || n > max_len) return false; + if ((size_t)(c->end - c->p) < n) return false; + *s = c->p; + *slen = (uint16_t)n; + c->p += n; + return true; +} + +/* + * Hive account names are rendered in compact multi-field confirmation screens, + * so they must be valid protocol names rather than arbitrary byte strings. + * This rejects embedded NUL/newline/control bytes that would truncate or + * reshape the OLED while later bytes remained covered by the signature. + */ +static bool hive_account_name_valid(const uint8_t* s, uint16_t len, + bool allow_empty) { + if (len == 0) return allow_empty; + if (len < 3 || len > 16) return false; + + bool at_segment_start = true; + bool previous_hyphen = false; + for (uint16_t i = 0; i < len; i++) { + uint8_t ch = s[i]; + if (at_segment_start) { + if (ch < 'a' || ch > 'z') return false; + at_segment_start = false; + previous_hyphen = false; + } else if (ch == '.') { + if (previous_hyphen || i + 1 == len) return false; + at_segment_start = true; + } else if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) { + previous_hyphen = false; + } else if (ch == '-') { + previous_hyphen = true; + } else { + return false; + } + } + return !at_segment_start && !previous_hyphen; +} + +static bool cur_account(HiveCur* c, const uint8_t** s, uint16_t* slen, + bool allow_empty) { + if (!cur_string(c, s, slen, allow_empty ? 0 : 1, 16)) return false; + return hive_account_name_valid(*s, *slen, allow_empty); +} + +static int hive_slice_cmp(const uint8_t* a, uint16_t a_len, const uint8_t* b, + uint16_t b_len) { + uint16_t min_len = a_len < b_len ? a_len : b_len; + int cmp = memcmp(a, b, min_len); + if (cmp != 0) return cmp; + return (a_len > b_len) - (a_len < b_len); +} + +/* Fixed-width little-endian readers, bounds-checked against the buffer. */ +static bool cur_u16(HiveCur* c, uint16_t* out) { + if ((size_t)(c->end - c->p) < 2) return false; + *out = (uint16_t)((uint16_t)c->p[0] | ((uint16_t)c->p[1] << 8)); + c->p += 2; + return true; +} + +static bool cur_u32(HiveCur* c, uint32_t* out) { + if ((size_t)(c->end - c->p) < 4) return false; + *out = (uint32_t)c->p[0] | ((uint32_t)c->p[1] << 8) | + ((uint32_t)c->p[2] << 16) | ((uint32_t)c->p[3] << 24); + c->p += 4; + return true; +} + +/* + * Graphene serializes bool as one byte. Anything other than 0/1 is a host + * serializer bug, not a truthy value — reject rather than normalize, so a + * malformed fill_or_kill or allow_votes can never be silently coerced. + */ +static bool cur_bool(HiveCur* c, bool* out) { + if (c->p >= c->end) return false; + uint8_t b = *c->p++; + if (b > 1) return false; + *out = (b == 1); + return true; +} + +uint64_t hive_assetAmount(const uint8_t* asset) { + uint64_t v = 0; + for (int i = 7; i >= 0; i--) v = (v << 8) | asset[i]; + return v; +} + +uint8_t hive_assetPrecision(const uint8_t* asset) { return asset[8]; } + +// Wire symbol → display symbol. The chain serializes the pre-rebrand names; +// the user knows the post-rebrand ones. cur_asset() has already validated the +// symbol and its NUL padding, so the compares below are exact. +const char* hive_assetSymbol(const uint8_t* asset) { + const char* sym = (const char*)(asset + 9); + if (memcmp(sym, "STEEM", 6) == 0) return "HIVE"; + if (memcmp(sym, "SBD", 4) == 0) return "HBD"; + return sym; +} + +/* + * One 16-byte Graphene asset: int64 LE amount, uint8 precision, 7-byte + * NUL-padded symbol. + * + * The symbol must be in `allowed` and carry its protocol-fixed precision. + * Both checks are load-bearing for display integrity: an unexpected symbol + * lets a host swap VESTS for HIVE (a ~2000x difference in real value behind + * an identical-looking number), and a wrong precision moves the decimal + * point on the confirmation screen relative to what the chain applies. + */ +static bool cur_asset(HiveCur* c, const uint8_t** out, uint32_t allowed) { + if ((size_t)(c->end - c->p) < HIVE_ASSET_LEN) return false; + const uint8_t* a = c->p; + const uint8_t* sym = a + 9; + + uint32_t bit; + uint8_t want_precision; + size_t sym_len; + // WIRE symbols, not display symbols: the 2020 rebrand renamed the tokens but + // NOT their on-chain serialization, so hived still encodes HIVE as "STEEM" + // and HBD as "SBD". Accepting the display spellings would let us sign bytes + // hived can never validate — its signature check re-serializes the operation + // and recovers a key from different bytes, surfacing as the misleading + // "missing required active authority". hive_assetSymbol() maps back for the + // OLED so the user still reads HIVE/HBD. + if (memcmp(sym, "STEEM", 5) == 0) { + bit = HIVE_SYM_HIVE; + want_precision = 3; + sym_len = 5; + } else if (memcmp(sym, "SBD", 3) == 0) { + bit = HIVE_SYM_HBD; + want_precision = 3; + sym_len = 3; + } else if (memcmp(sym, "VESTS", 5) == 0) { + bit = HIVE_SYM_VESTS; + want_precision = 6; + sym_len = 5; + } else { + return false; + } + // The prefix compares above would also accept a longer symbol sharing the + // prefix ("HBDX"); the padding check is what makes them exact, and it also + // guarantees hive_assetSymbol() returns a NUL-terminated C string. + for (size_t i = sym_len; i < 7; i++) { + if (sym[i] != 0) return false; + } + if (!(bit & allowed)) return false; + if (a[8] != want_precision) return false; + // Every asset field in this table is a quantity. A negative int64 would + // render as an enormous positive number through the unsigned formatter. + if (a[7] & 0x80) return false; + + *out = a; + c->p += HIVE_ASSET_LEN; + return true; +} + +/* + * Shared rejection reasons. + * + * These are diagnostics, not security surface: the protection is that the + * device REFUSES, and the host already knows which operation it sent. One + * bespoke sentence per failure site cost ~1.8KB of rodata on a part with + * single-digit KB of flash left, so failures are grouped by reason instead. + * The three that carry a distinct security meaning — an authority rotation, + * a detached comment_options, a wrong-tier request — stay separate so they + * are never confused with an ordinary parse failure in a bug report. + */ +static const char E_MALFORMED[] = "Hive tx: malformed operation"; +static const char E_RANGE[] = "Hive tx: value out of range"; +static const char E_AMOUNT[] = "Hive tx: amount must be greater than zero"; +static const char E_NOOP[] = "Hive tx: operation has no effect"; +static const char E_EXTENSIONS[] = "Hive tx: extensions must be empty"; +static const char E_BENEFICIARIES[] = "Hive tx: invalid beneficiaries"; +static const char E_SYMBOLS[] = "Hive tx: order symbols must differ"; +static const char E_AUTHORITY[] = "Hive tx: authority changes not supported"; +static const char E_BINDING[] = + "Hive tx: comment_options must follow its comment"; +static const char E_MIXED_TIER[] = "Hive tx: mixed posting/active ops"; + +const char* hive_parseOperations(const uint8_t* tx, size_t len, + HiveParsedTx* out) { + memzero(out, sizeof(*out)); + // 10-byte header + op_count varint + extensions varint is the structural + // minimum; op bodies are bounds-checked as they parse. + if (len < 12) return "Hive tx too short"; + if (len > HIVE_MAX_OPS_TX_LEN) return "Hive tx too long"; // = proto cap + + // Header (ref_block_num u16, ref_block_prefix u32, expiration u32) is + // covered by the signature but carries nothing to confirm on-device. + HiveCur c = {tx + 10, tx + len}; + + uint32_t op_count; + if (!cur_varint(&c, &op_count)) return E_MALFORMED; + if (op_count < 1 || op_count > HIVE_MAX_TX_OPS) + return "Hive tx: op count must be 1-4"; + out->num_ops = (uint8_t)op_count; + + bool any_posting = false, any_active = false; + + for (uint32_t i = 0; i < op_count; i++) { + HiveTxOp* op = &out->ops[i]; + uint32_t op_type; + if (!cur_varint(&c, &op_type)) return E_MALFORMED; + op->op_type = op_type; + + switch (op_type) { + case HIVE_OP_VOTE: { // posting authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_string(&c, &op->detail, &op->detail_len, 1, 256)) + return E_MALFORMED; + if ((size_t)(c.end - c.p) < 2) return E_MALFORMED; + int16_t w = (int16_t)((uint16_t)c.p[0] | ((uint16_t)c.p[1] << 8)); + c.p += 2; + if (w < -10000 || w > 10000) return E_RANGE; + op->weight = w; + any_posting = true; + break; + } + case HIVE_OP_COMMENT: { // posting authority + const uint8_t *pa, *ppl, *permlink, *jm; + uint16_t pa_len, ppl_len, permlink_len, jm_len; + if (!cur_account(&c, &pa, &pa_len, true) || + !cur_string(&c, &ppl, &ppl_len, 1, 256) || + !cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_string(&c, &permlink, &permlink_len, 1, 256) || + !cur_string(&c, &op->target, &op->target_len, 0, 256) || + !cur_string(&c, &op->detail, &op->detail_len, 1, + HIVE_MAX_OPS_TX_LEN) || + !cur_string(&c, &jm, &jm_len, 0, HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + op->parent_author = pa; + op->parent_author_len = pa_len; + op->parent_permlink = ppl; + op->parent_permlink_len = ppl_len; + op->permlink = permlink; + op->permlink_len = permlink_len; + op->json_metadata = jm; + op->json_metadata_len = jm_len; + op->is_top_level = (pa_len == 0); + any_posting = true; + break; + } + case HIVE_OP_CUSTOM_JSON: { // posting OR active authority + uint32_t n_active, n_posting; + if (!cur_varint(&c, &n_active)) return E_MALFORMED; + if (n_active > HIVE_MAX_CUSTOM_JSON_AUTHS) return E_RANGE; + const uint8_t* previous_auth = NULL; + uint16_t previous_auth_len = 0; + for (uint32_t k = 0; k < n_active; k++) { + const uint8_t* s; + uint16_t sl; + if (!cur_account(&c, &s, &sl, false)) return E_MALFORMED; + if (previous_auth && + hive_slice_cmp(previous_auth, previous_auth_len, s, sl) >= 0) + return E_MALFORMED; + op->auth_acct[op->n_auths] = s; + op->auth_acct_len[op->n_auths++] = sl; + previous_auth = s; + previous_auth_len = sl; + if (!op->acct) { + op->acct = s; + op->acct_len = sl; + } + } + if (!cur_varint(&c, &n_posting)) return E_MALFORMED; + if (n_posting > HIVE_MAX_CUSTOM_JSON_AUTHS - n_active) return E_RANGE; + previous_auth = NULL; + previous_auth_len = 0; + for (uint32_t k = 0; k < n_posting; k++) { + const uint8_t* s; + uint16_t sl; + if (!cur_account(&c, &s, &sl, false)) return E_MALFORMED; + if (previous_auth && + hive_slice_cmp(previous_auth, previous_auth_len, s, sl) >= 0) + return E_MALFORMED; + op->auth_acct[op->n_auths] = s; + op->auth_acct_len[op->n_auths++] = sl; + previous_auth = s; + previous_auth_len = sl; + if (!op->acct) { + op->acct = s; + op->acct_len = sl; + } + } + if (n_active + n_posting == 0) return E_MALFORMED; + // Both tiers on one op can never be satisfied by a single signature + // (post-HF28 hived requires the exact authority) — malformed input. + if (n_active > 0 && n_posting > 0) return E_MIXED_TIER; + if (!cur_string(&c, &op->target, &op->target_len, 1, 32) || + !cur_string(&c, &op->detail, &op->detail_len, 1, + HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + op->needs_active = (n_active > 0); + if (op->needs_active) + any_active = true; + else + any_posting = true; + break; + } + case HIVE_OP_TRANSFER_TO_VESTING: { // active authority + // `to` may be empty — hived reads that as "power up to self". + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, true) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_WITHDRAW_VESTING: { // active authority + // 0.000000 VESTS is meaningful here: it cancels an in-progress + // power-down, so zero must NOT be rejected. + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_VESTS)) + return E_MALFORMED; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_LIMIT_ORDER_CREATE: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_asset(&c, &op->assets[1], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_bool(&c, &op->flag) || !cur_u32(&c, &op->expiration)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0 || + hive_assetAmount(op->assets[1]) == 0) + return E_AMOUNT; + // The internal market only pairs HIVE against HBD. A same-symbol + // order is rejected on-chain anyway, and on the OLED it would read + // as a harmless self-trade while burning the fill. + if (memcmp(op->assets[0] + 9, op->assets[1] + 9, 7) == 0) + return E_SYMBOLS; + op->n_assets = 2; + any_active = true; + break; + } + case HIVE_OP_LIMIT_ORDER_CANCEL: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id)) + return E_MALFORMED; + any_active = true; + break; + } + case HIVE_OP_CONVERT: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HBD)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_COMMENT_OPTIONS: { // posting authority + uint16_t percent_hbd; + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_string(&c, &op->permlink, &op->permlink_len, 1, 256) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HBD) || + !cur_u16(&c, &percent_hbd) || !cur_bool(&c, &op->flag) || + !cur_bool(&c, &op->flag2)) + return E_MALFORMED; + if (percent_hbd > 10000) return E_RANGE; + op->weight = (int16_t)percent_hbd; + op->n_assets = 1; + + // SECURITY: this op redirects a post's payout. It binds to exactly + // one post, so it is accepted ONLY 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. + if (i == 0 || out->ops[i - 1].op_type != HIVE_OP_COMMENT) + return E_BINDING; + const HiveTxOp* prev = &out->ops[i - 1]; + if (prev->acct_len != op->acct_len || + memcmp(prev->acct, op->acct, op->acct_len) != 0 || + prev->permlink_len != op->permlink_len || + memcmp(prev->permlink, op->permlink, op->permlink_len) != 0) + return E_BINDING; + + uint32_t ext_n; + if (!cur_varint(&c, &ext_n)) return E_MALFORMED; + // hived permits only one comment_payout_beneficiaries extension; + // two would let a host split 16 beneficiaries past a per-extension + // bound check. + if (ext_n > 1) return E_BENEFICIARIES; + if (ext_n == 1) { + uint32_t tag, n_benef; + if (!cur_varint(&c, &tag) || tag != 0) return E_BENEFICIARIES; + if (!cur_varint(&c, &n_benef) || n_benef < 1 || + n_benef > HIVE_MAX_BENEFICIARIES) + return E_BENEFICIARIES; + uint32_t weight_sum = 0; + const uint8_t* prev_acct = NULL; + uint16_t prev_acct_len = 0; + for (uint32_t k = 0; k < n_benef; k++) { + if (!cur_account(&c, &op->benef_acct[k], &op->benef_acct_len[k], + false) || + !cur_u16(&c, &op->benef_weight[k])) + return E_MALFORMED; + if (op->benef_weight[k] > 10000) return E_RANGE; + // hived requires strictly ascending account names, which also + // enforces uniqueness. An unsorted list is rejected on-chain, so + // signing it would only waste a device confirmation. + if (prev_acct) { + if (hive_slice_cmp(prev_acct, prev_acct_len, op->benef_acct[k], + op->benef_acct_len[k]) >= 0) + return E_BENEFICIARIES; + } + prev_acct = op->benef_acct[k]; + prev_acct_len = op->benef_acct_len[k]; + weight_sum += op->benef_weight[k]; + } + if (weight_sum > 10000) return E_BENEFICIARIES; + op->n_benef = (uint8_t)n_benef; + } + any_posting = true; + break; + } + case HIVE_OP_TRANSFER_TO_SAVINGS: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_string(&c, &op->detail, &op->detail_len, 0, HIVE_MAX_MEMO_LEN)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_TRANSFER_FROM_SAVINGS: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_string(&c, &op->detail, &op->detail_len, 0, HIVE_MAX_MEMO_LEN)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_CLAIM_REWARD_BALANCE: { // posting authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE) || + !cur_asset(&c, &op->assets[1], HIVE_SYM_HBD) || + !cur_asset(&c, &op->assets[2], HIVE_SYM_VESTS)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0 && + hive_assetAmount(op->assets[1]) == 0 && + hive_assetAmount(op->assets[2]) == 0) + return E_NOOP; + op->n_assets = 3; + any_posting = true; + break; + } + case HIVE_OP_DELEGATE_VESTING_SHARES: { // active authority + // 0.000000 VESTS is meaningful: it removes an existing delegation. + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_VESTS)) + return E_MALFORMED; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_ACCOUNT_UPDATE2: { // active or posting authority + uint32_t ext_n; + if (!cur_account(&c, &op->acct, &op->acct_len, false)) + return E_MALFORMED; + // SECURITY: account_update2 can rotate owner/active/posting/memo + // keys. Only the profile-metadata form is in the table — this is the + // op-9/10 device-derived-keys invariant applied field-level. Any + // authority field present is a hard reject; do NOT soften this + // without the authority-management design review. + for (int k = 0; k < 4; k++) { + bool present; + if (!cur_bool(&c, &present)) return E_MALFORMED; + if (present) return E_AUTHORITY; + } + if (!cur_string(&c, &op->detail, &op->detail_len, 0, + HIVE_MAX_OPS_TX_LEN) || + !cur_string(&c, &op->json_metadata, &op->json_metadata_len, 0, + HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + if (op->detail_len == 0 && op->json_metadata_len == 0) return E_NOOP; + if (!cur_varint(&c, &ext_n)) return E_MALFORMED; + if (ext_n != 0) return E_EXTENSIONS; + // json_metadata is an active-key field; a posting_json_metadata-only + // update is a posting-tier profile change. + op->needs_active = (op->detail_len > 0); + if (op->needs_active) + any_active = true; + else + any_posting = true; + break; + } + case HIVE_OP_TRANSFER: + case HIVE_OP_ACCOUNT_CREATE: + case HIVE_OP_ACCOUNT_UPDATE: + // PERMANENTLY excluded from this table: transfer keeps the stronger + // dedicated HiveSignTx display path; the account ops keep the + // device-derived-keys-only invariant (a generic raw-bytes path + // would let a host slip third-party authorities into an + // account_update). Never add these here. + return "Hive tx: op requires its dedicated message type"; + default: + return "Hive tx: unsupported operation type"; + } + } + + uint32_t ext_count; + if (!cur_varint(&c, &ext_count)) return E_MALFORMED; + if (ext_count != 0) return E_EXTENSIONS; + if (c.p != c.end) return "Hive tx: trailing bytes"; + + // One signature cannot satisfy posting- and active-tier ops at once. + if (any_posting && any_active) return E_MIXED_TIER; + out->needs_active = any_active; + return NULL; +} + +void hive_signOperations(const HDNode* node, const HiveSignOperations* msg, + HiveSignedOperations* resp) { + if (!msg->has_serialized_tx || msg->serialized_tx.size == 0 || + msg->serialized_tx.size > HIVE_MAX_OPS_TX_LEN) + return; + + // Hash straight from the decoded message — no stack copy of the 2KB tx. + if (!hive_sign_tx_sig(node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, msg->serialized_tx.bytes, + msg->serialized_tx.size, resp->signature.bytes)) { + return; + } + + resp->has_signature = true; + resp->signature.size = 65; +} + +// ── Message signing (Keychain signBuffer contract) ──────────────────────── +// Digest is SHA256(message bytes) ONLY: no chain_id prepend (unlike +// transactions) and no Bitcoin/Solana-style message prefix. hive-js +// Signature.signBuffer — which every Hive dApp verifies against — hashes +// the raw bytes exactly once; any added prefix silently breaks all dApp +// verification. + +bool hive_message_is_printable(const uint8_t* message, size_t len) { + for (size_t i = 0; i < len; i++) { + if (message[i] < 0x20 || message[i] > 0x7e) return false; + } + return true; +} + +void hive_signMessage(const HDNode* node, const HiveSignMessage* msg, + HiveSignedMessage* resp) { + if (!msg->has_message || msg->message.size > HIVE_MAX_MESSAGE_LEN) return; + + uint8_t digest[32]; + sha256_Raw(msg->message.bytes, msg->message.size, digest); + + uint8_t sig[65]; + if (!hive_sign_raw_digest(node, digest, sig)) { + memzero(digest, sizeof(digest)); + memzero(sig, sizeof(sig)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + memcpy(resp->signature.bytes, sig, 65); + + // Caller must have run hdnode_fill_public_key(node). Returned so the host + // can build Keychain's publicKey response field without a second call. + resp->has_public_key = true; + resp->public_key.size = 33; + memcpy(resp->public_key.bytes, node->public_key, 33); + + memzero(digest, sizeof(digest)); + memzero(sig, sizeof(sig)); +} + +// ── Transfer (op type 2) ────────────────────────────────────────────────── + +static size_t hive_serialize_transfer(const HiveSignTx* msg, uint8_t* buf, + size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, HIVE_OP_TRANSFER); + + append_string(&p, end, msg->has_from ? msg->from : ""); + append_string(&p, end, msg->has_to ? msg->to : ""); + + const char* sym = msg->has_asset_symbol ? msg->asset_symbol : "HIVE"; + uint8_t prec = (uint8_t)(msg->has_decimals ? msg->decimals : HIVE_DECIMALS); + append_asset(&p, end, msg->amount, prec, sym); + + append_string(&p, end, msg->has_memo ? msg->memo : ""); + append_tx_footer(&p, end); + return (size_t)(p - buf); +} + +void hive_signTx(const HDNode* node, const HiveSignTx* msg, + HiveSignedTx* resp) { + // Reject memos that would overflow the fixed-size tx_buf. + if (msg->has_memo && strlen(msg->memo) > HIVE_MAX_MEMO_LEN) return; + + uint8_t tx_buf[512]; + size_t tx_len = hive_serialize_transfer(msg, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} + +// ── Account create (op type 9) ──────────────────────────────────────────── +// +// All four role keys are device-derived by the caller (FSM handler) and +// passed as raw 33-byte compressed public keys. The firmware never uses +// host-supplied key strings for the actual transaction. + +static size_t hive_serialize_account_create(const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + uint8_t* buf, size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, + HIVE_OP_ACCOUNT_CREATE); + + // fee (asset) + uint64_t fee = msg->has_fee_amount ? msg->fee_amount : 3000; + append_asset(&p, end, fee, HIVE_DECIMALS, "HIVE"); + + // creator + append_string(&p, end, msg->has_creator ? msg->creator : ""); + + // new_account_name + append_string(&p, end, + msg->has_new_account_name ? msg->new_account_name : ""); + + // authority fields use device-derived raw bytes (no host trust, no type + // prefix) + append_authority(&p, end, owner_raw); + append_authority(&p, end, active_raw); + append_authority(&p, end, posting_raw); + + // memo_key: 33 raw bytes, no authority wrapper, no type prefix byte + for (int i = 0; i < 33 && p < end; i++) append_u8(&p, end, memo_raw[i]); + + // json_metadata (empty) + append_string(&p, end, ""); + append_tx_footer(&p, end); + + return (size_t)(p - buf); +} + +void hive_signAccountCreate(const HDNode* signing_node, + const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountCreate* resp) { + uint8_t tx_buf[512]; + size_t tx_len = + hive_serialize_account_create(msg, owner_raw, active_raw, posting_raw, + memo_raw, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(signing_node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + memzero(tx_buf, sizeof(tx_buf)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} + +// ── Account update (op type 10) ─────────────────────────────────────────── +// +// All four new role keys are device-derived by the caller (FSM handler). +// The host-supplied new_*_key fields in the message are not used for signing. + +static size_t hive_serialize_account_update(const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + uint8_t* buf, size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, + HIVE_OP_ACCOUNT_UPDATE); + + // account name + append_string(&p, end, msg->has_account ? msg->account : ""); + + /* + * account_update optional authority fields use a Graphene "optional" wrapper: + * present: 0x01 + authority bytes + * absent: 0x00 + * We always include all four — this replaces all authorities. + */ + append_u8(&p, end, 0x01); // owner present + append_authority(&p, end, owner_raw); + append_u8(&p, end, 0x01); // active present + append_authority(&p, end, active_raw); + append_u8(&p, end, 0x01); // posting present + append_authority(&p, end, posting_raw); + + // memo_key: 33 raw bytes, always present, no type prefix byte + for (int i = 0; i < 33 && p < end; i++) append_u8(&p, end, memo_raw[i]); + + // json_metadata (empty) + append_string(&p, end, ""); + append_tx_footer(&p, end); + + return (size_t)(p - buf); +} + +void hive_signAccountUpdate(const HDNode* signing_node, + const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountUpdate* resp) { + uint8_t tx_buf[512]; + size_t tx_len = + hive_serialize_account_update(msg, owner_raw, active_raw, posting_raw, + memo_raw, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(signing_node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + memzero(tx_buf, sizeof(tx_buf)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 1d057579b..3fe884809 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -184,6 +184,23 @@ MSG_OUT(MessageType_MessageType_ZcashAddress, ZcashAddress, NO_PROCESS_FUNC) #endif + /* Hive */ + MSG_IN(MessageType_MessageType_HiveGetPublicKey, HiveGetPublicKey, fsm_msgHiveGetPublicKey) + MSG_IN(MessageType_MessageType_HiveGetPublicKeys, HiveGetPublicKeys, fsm_msgHiveGetPublicKeys) + MSG_IN(MessageType_MessageType_HiveSignTx, HiveSignTx, fsm_msgHiveSignTx) + MSG_IN(MessageType_MessageType_HiveSignAccountCreate, HiveSignAccountCreate, fsm_msgHiveSignAccountCreate) + MSG_IN(MessageType_MessageType_HiveSignAccountUpdate, HiveSignAccountUpdate, fsm_msgHiveSignAccountUpdate) + MSG_IN(MessageType_MessageType_HiveSignMessage, HiveSignMessage, fsm_msgHiveSignMessage) + MSG_IN(MessageType_MessageType_HiveSignOperations, HiveSignOperations, fsm_msgHiveSignOperations) + + MSG_OUT(MessageType_MessageType_HivePublicKey, HivePublicKey, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HivePublicKeys, HivePublicKeys, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedTx, HiveSignedTx, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedAccountCreate, HiveSignedAccountCreate, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedAccountUpdate, HiveSignedAccountUpdate, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedMessage, HiveSignedMessage, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedOperations, HiveSignedOperations, NO_PROCESS_FUNC) + #if DEBUG_LINK /* Debug Messages */ DEBUG_IN(MessageType_MessageType_DebugLinkDecision, DebugLinkDecision, NO_PROCESS_FUNC) diff --git a/lib/transport/CMakeLists.txt b/lib/transport/CMakeLists.txt index 805b27762..c8dd5c0ac 100644 --- a/lib/transport/CMakeLists.txt +++ b/lib/transport/CMakeLists.txt @@ -19,6 +19,7 @@ set(protoc_pb_sources ${DEVICE_PROTOCOL}/messages-tron.proto ${DEVICE_PROTOCOL}/messages-ton.proto ${DEVICE_PROTOCOL}/messages-zcash.proto + ${DEVICE_PROTOCOL}/messages-hive.proto ${DEVICE_PROTOCOL}/messages.proto) set(protoc_pb_options @@ -37,6 +38,7 @@ set(protoc_pb_options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-tron.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-ton.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-zcash.options + ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages-hive.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages.options) set(protoc_c_sources @@ -55,6 +57,7 @@ set(protoc_c_sources ${CMAKE_BINARY_DIR}/lib/transport/messages-tron.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages-ton.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages-zcash.pb.c + ${CMAKE_BINARY_DIR}/lib/transport/messages-hive.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages.pb.c) set(protoc_c_headers @@ -73,6 +76,7 @@ set(protoc_c_headers ${CMAKE_BINARY_DIR}/include/messages-tron.pb.h ${CMAKE_BINARY_DIR}/include/messages-ton.pb.h ${CMAKE_BINARY_DIR}/include/messages-zcash.pb.h + ${CMAKE_BINARY_DIR}/include/messages-hive.pb.h ${CMAKE_BINARY_DIR}/include/messages.pb.h) set(protoc_pb_sources_moved @@ -91,6 +95,7 @@ set(protoc_pb_sources_moved ${CMAKE_BINARY_DIR}/lib/transport/messages-tron.proto ${CMAKE_BINARY_DIR}/lib/transport/messages-ton.proto ${CMAKE_BINARY_DIR}/lib/transport/messages-zcash.proto + ${CMAKE_BINARY_DIR}/lib/transport/messages-hive.proto ${CMAKE_BINARY_DIR}/lib/transport/messages.proto) add_custom_command( @@ -172,6 +177,10 @@ add_custom_command( ${PROTOC_BINARY} -I. -I/usr/include --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb "--nanopb_out=-f messages-zcash.options:." messages-zcash.proto + COMMAND + ${PROTOC_BINARY} -I. -I/usr/include + --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb + "--nanopb_out=-f messages-hive.options:." messages-hive.proto COMMAND ${PROTOC_BINARY} -I. -I/usr/include --plugin=nanopb=${NANOPB_DIR}/generator/protoc-gen-nanopb diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index b76de6be1..03cab2447 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -8,6 +8,7 @@ set(sources eos.cpp eip712.cpp ethereum.cpp + hive.cpp mayachain.cpp nano.cpp osmosis.cpp diff --git a/unittests/firmware/hive.cpp b/unittests/firmware/hive.cpp new file mode 100644 index 000000000..4a90cdba0 --- /dev/null +++ b/unittests/firmware/hive.cpp @@ -0,0 +1,983 @@ +extern "C" { +#include "keepkey/board/font.h" +#include "keepkey/board/layout.h" +#include "keepkey/firmware/hive.h" +} + +#include "gtest/gtest.h" + +#include +#include +#include +#include + +namespace { + +void append_varint(std::vector& out, uint32_t value) { + do { + uint8_t byte = static_cast(value & 0x7f); + value >>= 7; + if (value != 0) byte |= 0x80; + out.push_back(byte); + } while (value != 0); +} + +void append_u16_le(std::vector& out, uint16_t value) { + out.push_back(static_cast(value)); + out.push_back(static_cast(value >> 8)); +} + +void append_u32_le(std::vector& out, uint32_t value) { + for (int i = 0; i < 4; i++) { + out.push_back(static_cast(value >> (8 * i))); + } +} + +void append_string(std::vector& out, const std::string& value) { + append_varint(out, static_cast(value.size())); + out.insert(out.end(), value.begin(), value.end()); +} + +std::string slice(const uint8_t* value, uint16_t len) { + return std::string(reinterpret_cast(value), len); +} + +std::vector comment_tx(const std::string& parent_author, + const std::string& parent_permlink, + const std::string& author, + const std::string& permlink, + const std::string& title, + const std::string& body, + const std::string& json_metadata) { + std::vector tx; + append_u16_le(tx, 12345); + append_u32_le(tx, 67890); + append_u32_le(tx, 1700000000); + append_varint(tx, 1); + append_varint(tx, HIVE_OP_COMMENT); + append_string(tx, parent_author); + append_string(tx, parent_permlink); + append_string(tx, author); + append_string(tx, permlink); + append_string(tx, title); + append_string(tx, body); + append_string(tx, json_metadata); + append_varint(tx, 0); + return tx; +} + +// Call sites pass DISPLAY symbols ("HIVE"/"HBD") because that is what the test +// is about; this helper writes what the chain actually serializes. Verified +// against hived itself via condenser_api.get_transaction_hex — see +// Hive.SerializationMatchesHived. +std::string wire_symbol(const std::string& display) { + if (display == "HIVE") return "STEEM"; + if (display == "HBD") return "SBD"; + return display; +} + +void append_asset(std::vector& out, int64_t amount, uint8_t precision, + const std::string& symbol) { + const std::string wire = wire_symbol(symbol); + uint64_t raw = static_cast(amount); + for (int i = 0; i < 8; i++) { + out.push_back(static_cast(raw >> (8 * i))); + } + out.push_back(precision); + for (size_t i = 0; i < 7; i++) { + out.push_back(i < wire.size() ? static_cast(wire[i]) : 0); + } +} + +// Wrap already-serialized ops in the 10-byte TaPoS header, op count and the +// empty extensions varint that hive_parseOperations expects. +std::vector wrap_ops(const std::vector>& ops) { + std::vector tx; + append_u16_le(tx, 12345); + append_u32_le(tx, 67890); + append_u32_le(tx, 1700000000); + append_varint(tx, static_cast(ops.size())); + for (const std::vector& op : ops) { + tx.insert(tx.end(), op.begin(), op.end()); + } + append_varint(tx, 0); + return tx; +} + +std::vector limit_order_create_op( + const std::string& owner, uint32_t orderid, int64_t sell, + const std::string& sell_symbol, int64_t receive, + const std::string& receive_symbol, bool fill_or_kill, uint32_t expiration) { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, owner); + append_u32_le(op, orderid); + append_asset(op, sell, 3, sell_symbol); + append_asset(op, receive, 3, receive_symbol); + op.push_back(fill_or_kill ? 1 : 0); + append_u32_le(op, expiration); + return op; +} + +// A limit order priced in VESTS at its CORRECT precision (6), so the +// rejection comes from the symbol whitelist rather than the precision check. +std::vector limit_order_vests_op() { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, "alice"); + append_u32_le(op, 1); + append_asset(op, 100, 6, "VESTS"); + append_asset(op, 100, 3, "HBD"); + op.push_back(0); + append_u32_le(op, 1); + return op; +} + +// transfer_to_vesting with a caller-chosen symbol/precision, so the asset +// validator can be probed with values a correct host would never send. +std::vector power_up_op(int64_t amount, uint8_t precision, + const std::string& symbol) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_VESTING); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, amount, precision, symbol); + return op; +} + +std::vector comment_op(const std::string& author, + const std::string& permlink) { + std::vector op; + append_varint(op, HIVE_OP_COMMENT); + append_string(op, ""); + append_string(op, "hive-100"); + append_string(op, author); + append_string(op, permlink); + append_string(op, "Title"); + append_string(op, "Body"); + append_string(op, "{}"); + return op; +} + +// beneficiaries: (account, basis-point weight) pairs; empty = no extension. +std::vector comment_options_op( + const std::string& author, const std::string& permlink, + const std::vector>& beneficiaries) { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, author); + append_string(op, permlink); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + if (beneficiaries.empty()) { + append_varint(op, 0); + } else { + append_varint(op, 1); + append_varint(op, 0); + append_varint(op, static_cast(beneficiaries.size())); + for (const auto& b : beneficiaries) { + append_string(op, b.first); + append_u16_le(op, b.second); + } + } + return op; +} + +std::vector account_update2_op(const std::string& json_metadata, + const std::string& posting_metadata, + bool authority_present) { + std::vector op; + append_varint(op, HIVE_OP_ACCOUNT_UPDATE2); + append_string(op, "alice"); + op.push_back(authority_present ? 1 : 0); + op.push_back(0); + op.push_back(0); + op.push_back(0); + append_string(op, json_metadata); + append_string(op, posting_metadata); + append_varint(op, 0); + return op; +} + +std::vector custom_json_op( + const std::vector& active_auths, + const std::vector& posting_auths, const std::string& id, + const std::string& json) { + std::vector op; + append_varint(op, HIVE_OP_CUSTOM_JSON); + append_varint(op, static_cast(active_auths.size())); + for (const std::string& auth : active_auths) append_string(op, auth); + append_varint(op, static_cast(posting_auths.size())); + for (const std::string& auth : posting_auths) append_string(op, auth); + append_string(op, id); + append_string(op, json); + return op; +} + +} // namespace + +TEST(Hive, Slip48PathValidation) { + uint32_t path[5] = {HIVE_SLIP48_PURPOSE, HIVE_SLIP48_NETWORK, + HIVE_ROLE_ACTIVE, 0x80000007u, 0x80000000u}; + + EXPECT_TRUE(hive_slip48_path_valid(path, 5)); + EXPECT_TRUE(hive_slip48_path_valid_for_role(path, 5, HIVE_ROLE_ACTIVE)); + EXPECT_FALSE(hive_slip48_path_valid_for_role(path, 5, HIVE_ROLE_OWNER)); + EXPECT_FALSE(hive_slip48_path_valid(path, 4)); + + path[0] = 0x8000002cu; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[0] = HIVE_SLIP48_PURPOSE; + path[1] = 0x8000003cu; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[1] = HIVE_SLIP48_NETWORK; + path[2] = 0x80000002u; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[2] = HIVE_ROLE_ACTIVE; + path[3] = 7; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[3] = 0x80000007u; + path[4] = 0; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); +} + +TEST(Hive, CommentParserRetainsEveryDisplayedField) { + std::vector tx = comment_tx( + "parent-author", "parent-permlink", "reply-author", "reply-permlink", + "Reply title", "Complete reply body", "{\"tags\":[\"keepkey\"]}"); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(1, parsed.num_ops); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_FALSE(op.is_top_level); + EXPECT_EQ("reply-author", slice(op.acct, op.acct_len)); + EXPECT_EQ("parent-author", slice(op.parent_author, op.parent_author_len)); + EXPECT_EQ("parent-permlink", + slice(op.parent_permlink, op.parent_permlink_len)); + EXPECT_EQ("reply-permlink", slice(op.permlink, op.permlink_len)); + EXPECT_EQ("Reply title", slice(op.target, op.target_len)); + EXPECT_EQ("Complete reply body", slice(op.detail, op.detail_len)); + EXPECT_EQ("{\"tags\":[\"keepkey\"]}", + slice(op.json_metadata, op.json_metadata_len)); +} + +// Message signing is restricted to printable ASCII so a message can never be a +// binary transaction preimage (chain_id || serialized_tx) on any chain id. +TEST(Hive, MessagePrintableAcceptsAsciiRejectsBinary) { + const char* login = "keepkey-login-challenge:1700000000"; + EXPECT_TRUE(hive_message_is_printable(reinterpret_cast(login), + strlen(login))); + + // Empty message is trivially printable. + EXPECT_TRUE( + hive_message_is_printable(reinterpret_cast(""), 0)); + + // Any non-printable byte (control char / high bit) is refused. + const uint8_t withNul[] = {'h', 'i', 0x00, 'x'}; + EXPECT_FALSE(hive_message_is_printable(withNul, sizeof(withNul))); + const uint8_t highBit[] = {'o', 'k', 0x80}; + EXPECT_FALSE(hive_message_is_printable(highBit, sizeof(highBit))); + + // The oracle vector: a "message" that begins with the binary mainnet chain id + // (beeab0de00...) followed by a serialized tx. The leading 0xbe/0xea/0x00 + // bytes are non-printable, so this can never be signed as a message. + const uint8_t chainIdPrefixed[] = {0xbe, 0xea, 0xb0, 0xde, 0x00, + 0x00, 0x00, 't', 'x'}; + EXPECT_FALSE( + hive_message_is_printable(chainIdPrefixed, sizeof(chainIdPrefixed))); +} + +TEST(Hive, TopLevelCommentRetainsCategoryAndEmptyTitle) { + std::vector tx = comment_tx("", "hive-123456", "post-author", + "post-permlink", "", "Post body", "{}"); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_TRUE(op.is_top_level); + EXPECT_EQ(0, op.parent_author_len); + EXPECT_EQ("hive-123456", slice(op.parent_permlink, op.parent_permlink_len)); + EXPECT_EQ("post-permlink", slice(op.permlink, op.permlink_len)); + EXPECT_EQ(0, op.target_len); + EXPECT_EQ("{}", slice(op.json_metadata, op.json_metadata_len)); +} + +TEST(Hive, RejectsNonCanonicalVarints) { + std::vector op; + append_varint(op, HIVE_OP_VOTE); + append_string(op, "alice"); + append_string(op, "bob"); + append_string(op, "post"); + append_u16_le(op, 10000); + + HiveParsedTx parsed; + + // Operation count 1 encoded as 0x81 0x00 instead of canonical 0x01. + std::vector overlong_count = wrap_ops({op}); + overlong_count[10] = 0x81; + overlong_count.insert(overlong_count.begin() + 11, 0x00); + EXPECT_NE(nullptr, hive_parseOperations(overlong_count.data(), + overlong_count.size(), &parsed)); + + // The voter string length 5 encoded as 0x85 0x00. + std::vector overlong_string = wrap_ops({op}); + overlong_string[12] = 0x85; + overlong_string.insert(overlong_string.begin() + 13, 0x00); + EXPECT_NE(nullptr, hive_parseOperations(overlong_string.data(), + overlong_string.size(), &parsed)); +} + +TEST(Hive, RejectsAccountNamesThatCanSpoofTheDisplay) { + HiveParsedTx parsed; + const std::vector invalid = { + "al\nice", std::string("ali\0ce", 6), "Alice", "alice-", ".alice", "a"}; + for (const std::string& account : invalid) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_SAVINGS); + append_string(op, account); + append_string(op, "bob"); + append_asset(op, 1000, 3, "HIVE"); + append_string(op, ""); + std::vector tx = wrap_ops({op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } +} + +TEST(Hive, CustomJsonRetainsAndBoundsEveryAuthorization) { + HiveParsedTx parsed; + std::vector tx = + wrap_ops({custom_json_op({}, {"alice", "bob", "carol"}, "follow", "[]")}); + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + const HiveTxOp& op = parsed.ops[0]; + ASSERT_EQ(3, op.n_auths); + EXPECT_EQ("alice", slice(op.auth_acct[0], op.auth_acct_len[0])); + EXPECT_EQ("bob", slice(op.auth_acct[1], op.auth_acct_len[1])); + EXPECT_EQ("carol", slice(op.auth_acct[2], op.auth_acct_len[2])); + EXPECT_FALSE(parsed.needs_active); + + std::vector too_many = wrap_ops({custom_json_op( + {}, {"alice", "bob", "carol", "dave", "erin"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(too_many.data(), too_many.size(), &parsed)); + + std::vector unsorted = + wrap_ops({custom_json_op({}, {"bob", "alice"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(unsorted.data(), unsorted.size(), &parsed)); + + std::vector duplicate = + wrap_ops({custom_json_op({}, {"alice", "alice"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(duplicate.data(), duplicate.size(), &parsed)); +} + +TEST(Hive, DisplayPaginationUsesRenderedBodyRows) { + const std::string payload = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_GT(calc_str_line(get_body_font(), payload.c_str(), BODY_WIDTH), + BODY_ROWS); + + std::string reconstructed; + size_t offset = 0; + unsigned pages = 0; + while (offset < payload.size()) { + size_t take = calc_str_page(get_body_font(), payload.data() + offset, + payload.size() - offset, BODY_WIDTH, BODY_ROWS); + ASSERT_GT(take, 0u); + const std::string page = payload.substr(offset, take); + EXPECT_LE(calc_str_line(get_body_font(), page.c_str(), BODY_WIDTH), + BODY_ROWS); + reconstructed += page; + offset += take; + pages++; + } + + EXPECT_GT(pages, 1u); + EXPECT_EQ(payload, reconstructed); +} + +// ── Phase-3 op table ──────────────────────────────────────────────────────── + +// The op that started this: a HIVE->HBD internal-market swap. Every field the +// approval screen shows must survive the parse. +TEST(Hive, LimitOrderCreateRetainsEveryDisplayedField) { + std::vector tx = wrap_ops({limit_order_create_op( + "alice", 42, 1500, "HIVE", 400, "HBD", true, 1700003600)}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(1, parsed.num_ops); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_EQ(HIVE_OP_LIMIT_ORDER_CREATE, op.op_type); + EXPECT_EQ("alice", slice(op.acct, op.acct_len)); + EXPECT_EQ(42u, op.req_id); + EXPECT_EQ(1700003600u, op.expiration); + EXPECT_TRUE(op.flag); // fill_or_kill + ASSERT_EQ(2, op.n_assets); + EXPECT_EQ(1500u, hive_assetAmount(op.assets[0])); + EXPECT_STREQ("HIVE", hive_assetSymbol(op.assets[0])); + EXPECT_EQ(3, hive_assetPrecision(op.assets[0])); + EXPECT_EQ(400u, hive_assetAmount(op.assets[1])); + EXPECT_STREQ("HBD", hive_assetSymbol(op.assets[1])); + // Trading needs the active key. + EXPECT_TRUE(parsed.needs_active); +} + +TEST(Hive, LimitOrderRejectsDegenerateOrders) { + HiveParsedTx parsed; + + // A same-symbol pair is a no-op trade on screen but still burns the fill. + std::vector same = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 100, "HIVE", false, 1)}); + EXPECT_NE(nullptr, hive_parseOperations(same.data(), same.size(), &parsed)); + + std::vector zero_sell = wrap_ops( + {limit_order_create_op("alice", 1, 0, "HIVE", 100, "HBD", false, 1)}); + EXPECT_NE(nullptr, + hive_parseOperations(zero_sell.data(), zero_sell.size(), &parsed)); + + std::vector zero_recv = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 0, "HBD", false, 1)}); + EXPECT_NE(nullptr, + hive_parseOperations(zero_recv.data(), zero_recv.size(), &parsed)); + + // VESTS never trades on the internal market. + std::vector vests = wrap_ops({limit_order_vests_op()}); + EXPECT_NE(nullptr, hive_parseOperations(vests.data(), vests.size(), &parsed)); +} + +TEST(Hive, LimitOrderCancelParses) { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CANCEL); + append_string(op, "alice"); + append_u32_le(op, 42); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ(42u, parsed.ops[0].req_id); + EXPECT_TRUE(parsed.needs_active); +} + +// The asset validator is what stops a host from moving the decimal point or +// swapping a ~2000x-more-valuable symbol behind an identical-looking number. +TEST(Hive, AssetValidatorPinsSymbolAndPrecision) { + HiveParsedTx parsed; + + std::vector ok = wrap_ops({power_up_op(1000, 3, "HIVE")}); + EXPECT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + + // transfer_to_vesting is HIVE-only; HBD and VESTS are out of the whitelist. + std::vector hbd = wrap_ops({power_up_op(1000, 3, "HBD")}); + EXPECT_NE(nullptr, hive_parseOperations(hbd.data(), hbd.size(), &parsed)); + std::vector vests = wrap_ops({power_up_op(1000, 6, "VESTS")}); + EXPECT_NE(nullptr, hive_parseOperations(vests.data(), vests.size(), &parsed)); + + // Right symbol, wrong precision: 1000 would render as 0.001 vs 1.000. + std::vector prec = wrap_ops({power_up_op(1000, 6, "HIVE")}); + EXPECT_NE(nullptr, hive_parseOperations(prec.data(), prec.size(), &parsed)); + + // A negative int64 would print as an enormous positive number. + std::vector negative = wrap_ops({power_up_op(-1000, 3, "HIVE")}); + EXPECT_NE(nullptr, + hive_parseOperations(negative.data(), negative.size(), &parsed)); + + // Unknown symbol, and a longer symbol sharing an accepted prefix. + std::vector unknown = wrap_ops({power_up_op(1000, 3, "SBD")}); + EXPECT_NE(nullptr, + hive_parseOperations(unknown.data(), unknown.size(), &parsed)); + std::vector prefixed = wrap_ops({power_up_op(1000, 3, "HIVEX")}); + EXPECT_NE(nullptr, + hive_parseOperations(prefixed.data(), prefixed.size(), &parsed)); +} + +// Zero is a real instruction for some ops and nonsense for others; the parser +// must not apply one blanket rule. +TEST(Hive, ZeroAmountSemanticsDifferPerOp) { + HiveParsedTx parsed; + + // Zero HIVE power-up: nothing to do, reject. + std::vector power_up = wrap_ops({power_up_op(0, 3, "HIVE")}); + EXPECT_NE(nullptr, + hive_parseOperations(power_up.data(), power_up.size(), &parsed)); + + // Zero VESTS withdraw_vesting: cancels an in-progress power-down, accept. + std::vector stop_pd; + { + std::vector op; + append_varint(op, HIVE_OP_WITHDRAW_VESTING); + append_string(op, "alice"); + append_asset(op, 0, 6, "VESTS"); + stop_pd = wrap_ops({op}); + } + EXPECT_EQ(nullptr, + hive_parseOperations(stop_pd.data(), stop_pd.size(), &parsed)); + + // Zero VESTS delegation: removes an existing delegation, accept. + std::vector undelegate; + { + std::vector op; + append_varint(op, HIVE_OP_DELEGATE_VESTING_SHARES); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, 0, 6, "VESTS"); + undelegate = wrap_ops({op}); + } + EXPECT_EQ(nullptr, hive_parseOperations(undelegate.data(), undelegate.size(), + &parsed)); + + // claim_reward_balance with all three at zero: nothing to claim, reject. + std::vector empty_claim; + { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 0, 3, "HIVE"); + append_asset(op, 0, 3, "HBD"); + append_asset(op, 0, 6, "VESTS"); + empty_claim = wrap_ops({op}); + } + EXPECT_NE(nullptr, hive_parseOperations(empty_claim.data(), + empty_claim.size(), &parsed)); +} + +TEST(Hive, ClaimRewardBalanceKeepsAssetOrder) { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 1234, 3, "HIVE"); + append_asset(op, 5678, 3, "HBD"); + append_asset(op, 90123456, 6, "VESTS"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(3, parsed.ops[0].n_assets); + EXPECT_EQ(1234u, hive_assetAmount(parsed.ops[0].assets[0])); + EXPECT_STREQ("HIVE", hive_assetSymbol(parsed.ops[0].assets[0])); + EXPECT_EQ(5678u, hive_assetAmount(parsed.ops[0].assets[1])); + EXPECT_STREQ("HBD", hive_assetSymbol(parsed.ops[0].assets[1])); + EXPECT_EQ(90123456u, hive_assetAmount(parsed.ops[0].assets[2])); + EXPECT_STREQ("VESTS", hive_assetSymbol(parsed.ops[0].assets[2])); + // Claiming rewards is a posting-tier action. + EXPECT_FALSE(parsed.needs_active); +} + +// SECURITY: comment_options redirects a post's payout. Detached from its +// comment it could retarget a post the user published earlier and is not +// reviewing on screen. +TEST(Hive, CommentOptionsMustBindToItsComment) { + HiveParsedTx parsed; + + std::vector alone = + wrap_ops({comment_options_op("alice", "my-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(alone.data(), alone.size(), &parsed)); + + std::vector wrong_permlink = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "other-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(wrong_permlink.data(), + wrong_permlink.size(), &parsed)); + + std::vector wrong_author = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("mallory", "my-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(wrong_author.data(), + wrong_author.size(), &parsed)); + + std::vector ok = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", {})}); + ASSERT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + EXPECT_EQ(2, parsed.num_ops); + EXPECT_EQ(10000, parsed.ops[1].weight); // percent_hbd + EXPECT_FALSE(parsed.needs_active); +} + +TEST(Hive, CommentOptionsBeneficiaryRules) { + HiveParsedTx parsed; + + std::vector ok = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 1000}, {"zoe", 500}})}); + ASSERT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + ASSERT_EQ(2, parsed.ops[1].n_benef); + EXPECT_EQ("aaron", slice(parsed.ops[1].benef_acct[0], + parsed.ops[1].benef_acct_len[0])); + EXPECT_EQ(1000, parsed.ops[1].benef_weight[0]); + EXPECT_EQ("zoe", slice(parsed.ops[1].benef_acct[1], + parsed.ops[1].benef_acct_len[1])); + + // hived requires strictly ascending names; unsorted is rejected on-chain. + std::vector unsorted = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"zoe", 500}, {"aaron", 1000}})}); + EXPECT_NE(nullptr, + hive_parseOperations(unsorted.data(), unsorted.size(), &parsed)); + + // Duplicates are the same violation. + std::vector duped = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 500}, {"aaron", 500}})}); + EXPECT_NE(nullptr, hive_parseOperations(duped.data(), duped.size(), &parsed)); + + // Weights may not add up to more than 100%. + std::vector overweight = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 6000}, {"zoe", 5000}})}); + EXPECT_NE(nullptr, hive_parseOperations(overweight.data(), overweight.size(), + &parsed)); +} + +// SECURITY: account_update2 can rotate account keys. Only the profile-metadata +// form is in the table — the op-9/10 exclusion applied field-level. +TEST(Hive, AccountUpdate2RejectsAuthorityChanges) { + HiveParsedTx parsed; + + std::vector authority = + wrap_ops({account_update2_op("{\"profile\":{}}", "", true)}); + EXPECT_NE(nullptr, + hive_parseOperations(authority.data(), authority.size(), &parsed)); + + std::vector empty = wrap_ops({account_update2_op("", "", false)}); + EXPECT_NE(nullptr, hive_parseOperations(empty.data(), empty.size(), &parsed)); + + // json_metadata is an active-key field. + std::vector active = + wrap_ops({account_update2_op("{\"profile\":{}}", "", false)}); + ASSERT_EQ(nullptr, + hive_parseOperations(active.data(), active.size(), &parsed)); + EXPECT_TRUE(parsed.needs_active); + + // posting_json_metadata alone stays on the posting tier. + std::vector posting = + wrap_ops({account_update2_op("", "{\"profile\":{}}", false)}); + ASSERT_EQ(nullptr, + hive_parseOperations(posting.data(), posting.size(), &parsed)); + EXPECT_FALSE(parsed.needs_active); +} + +TEST(Hive, SavingsWithdrawRetainsDisplayedFields) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_FROM_SAVINGS); + append_string(op, "alice"); + append_u32_le(op, 7); + append_string(op, "bob"); + append_asset(op, 2500, 3, "HBD"); + append_string(op, "rent"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + // request_id sits BETWEEN from and to on the wire — the easiest field-order + // bug to make in this op, and the one that would swap displayed accounts. + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ(7u, parsed.ops[0].req_id); + EXPECT_EQ("bob", slice(parsed.ops[0].target, parsed.ops[0].target_len)); + EXPECT_EQ("rent", slice(parsed.ops[0].detail, parsed.ops[0].detail_len)); + EXPECT_EQ(2500u, hive_assetAmount(parsed.ops[0].assets[0])); + EXPECT_TRUE(parsed.needs_active); +} + +TEST(Hive, SavingsDepositRetainsDisplayedFields) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_SAVINGS); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, 1500, 3, "HIVE"); + append_string(op, ""); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ("bob", slice(parsed.ops[0].target, parsed.ops[0].target_len)); + EXPECT_EQ(0, parsed.ops[0].detail_len); // empty memo is legal + EXPECT_EQ(1500u, hive_assetAmount(parsed.ops[0].assets[0])); +} + +// An empty `to` means "power up to self" on Hive, not a malformed field. +TEST(Hive, PowerUpAcceptsEmptyDestination) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_VESTING); + append_string(op, "alice"); + append_string(op, ""); + append_asset(op, 1000, 3, "HIVE"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ(0, parsed.ops[0].target_len); +} + +// Truncating any op body by one byte must be refused, never partially parsed: +// the signature covers the whole buffer, so a short read would mean signing +// bytes the device never looked at. +TEST(Hive, TruncatedOpBodiesRejected) { + std::vector> bodies; + bodies.push_back( + limit_order_create_op("alice", 1, 100, "HIVE", 50, "HBD", false, 9)); + bodies.push_back(power_up_op(1000, 3, "HIVE")); + { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 1, 3, "HIVE"); + append_asset(op, 1, 3, "HBD"); + append_asset(op, 1, 6, "VESTS"); + bodies.push_back(op); + } + { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_FROM_SAVINGS); + append_string(op, "alice"); + append_u32_le(op, 7); + append_string(op, "bob"); + append_asset(op, 2500, 3, "HBD"); + append_string(op, "memo"); + bodies.push_back(op); + } + + HiveParsedTx parsed; + for (const std::vector& body : bodies) { + ASSERT_EQ(nullptr, hive_parseOperations(wrap_ops({body}).data(), + wrap_ops({body}).size(), &parsed)); + for (size_t cut = 1; cut < body.size(); cut++) { + std::vector truncated(body.begin(), body.end() - cut); + std::vector tx = wrap_ops({truncated}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)) + << "op type " << (unsigned)body[0] << " truncated by " << cut; + } + } +} + +TEST(Hive, CommentOptionsExtensionShapeRejected) { + HiveParsedTx parsed; + const std::vector comment = comment_op("alice", "my-post"); + + // More than one extension could split beneficiaries past a per-extension cap. + { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 2); + std::vector tx = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + // Only comment_payout_beneficiaries (tag 0) is in the table. + { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 1); + append_varint(op, 1); // tag != 0 + std::vector tx = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + // Zero beneficiaries in a present extension is malformed, not "none". + { + std::vector tx = + wrap_ops({comment, comment_options_op("alice", "my-post", {})}); + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 1); + append_varint(op, 0); + append_varint(op, 0); // n_benef = 0 + std::vector bad = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(bad.data(), bad.size(), &parsed)); + } +} + +TEST(Hive, AccountUpdate2RejectsNonEmptyExtensions) { + std::vector op; + append_varint(op, HIVE_OP_ACCOUNT_UPDATE2); + append_string(op, "alice"); + for (int i = 0; i < 4; i++) op.push_back(0); + append_string(op, "{\"profile\":{}}"); + append_string(op, ""); + append_varint(op, 1); // extensions must be empty + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// Graphene bools are one byte; anything but 0/1 is a host serializer bug. +TEST(Hive, RejectsNonCanonicalBool) { + // limit_order_create's fill_or_kill byte, set to 2. + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, "alice"); + append_u32_le(op, 1); + append_asset(op, 100, 3, "HIVE"); + append_asset(op, 50, 3, "HBD"); + op.push_back(2); + append_u32_le(op, 9); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +TEST(Hive, MixedTierOpsRejected) { + // vote is posting-tier, convert is active-tier; one signature cannot + // satisfy both post-HF28. + std::vector vote; + append_varint(vote, HIVE_OP_VOTE); + append_string(vote, "alice"); + append_string(vote, "bob"); + append_string(vote, "a-post"); + append_u16_le(vote, 10000); + + std::vector convert; + append_varint(convert, HIVE_OP_CONVERT); + append_string(convert, "alice"); + append_u32_le(convert, 1); + append_asset(convert, 1000, 3, "HBD"); + + std::vector tx = wrap_ops({vote, convert}); + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +TEST(Hive, ExcludedAndUnknownOpsStillRejected) { + HiveParsedTx parsed; + + // Ops 2/9/10 keep their dedicated message types — never fold them in. + for (uint32_t excluded : {static_cast(HIVE_OP_TRANSFER), + static_cast(HIVE_OP_ACCOUNT_CREATE), + static_cast(HIVE_OP_ACCOUNT_UPDATE)}) { + std::vector op; + append_varint(op, excluded); + append_string(op, "alice"); + std::vector tx = wrap_ops({op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + + // Anything outside the table is refused; there is no blind-sign fallback. + // 49 = recurrent_transfer, a real op deliberately not in the table. + std::vector unknown; + append_varint(unknown, 49); + append_string(unknown, "alice"); + std::vector tx = wrap_ops({unknown}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// Trailing bytes after a well-formed op must not be silently accepted: the +// signature covers them, so what the device displays would be a subset of +// what it signs. +TEST(Hive, TrailingBytesRejected) { + std::vector tx = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 50, "HBD", false, 1)}); + tx.push_back(0xff); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// --------------------------------------------------------------------------- +// Golden vectors produced by hived itself: +// +// curl -X POST https://api.hive.blog -H 'Content-Type: application/json' \ +// -d '{"jsonrpc":"2.0","method":"condenser_api.get_transaction_hex", +// "params":[],"id":1}' +// +// These exist because our serializer and this parser were byte-exact mirrors +// of EACH OTHER while both disagreed with the chain: we wrote "HIVE"/"HBD" +// where hived writes "STEEM"/"SBD". Two wrongs cancelled and every test +// passed, but the device signed bytes hived could not validate — it reported +// "missing required active authority", because signature recovery over +// different bytes yields a key in no authority. A vector the chain generated +// is the only kind that can catch that class of bug. +// --------------------------------------------------------------------------- + +std::vector from_hex(const std::string& hex) { + std::vector out; + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + out.push_back( + static_cast(std::stoul(hex.substr(i, 2), nullptr, 16))); + } + return out; +} + +// Header shared by both vectors below: ref_block_num 4660 / prefix 0xdeadbeef +// (0/0 for the second) and expiration 2021-01-14T02:19:44. +// +// get_transaction_hex serializes a full transaction, so its output ends with a +// varint count of the `signatures` array. The device is handed the digest +// preimage, which stops after the extensions varint — so the trailing "00" +// from hived's hex is dropped in the goldens below. Everything before it must +// match byte for byte. +TEST(Hive, SerializationMatchesHivedLimitOrderCreate) { + const std::string golden = + "3412efbeadde40aaff5f010505616c6963652a000000dc05000000000000035354" + "45454d00009001000000000000035342440000000001b0f5536500"; + + std::vector tx; + append_u16_le(tx, 4660); + append_u32_le(tx, 0xdeadbeef); + append_u32_le(tx, 0x5fffaa40); + append_varint(tx, 1); + std::vector op = limit_order_create_op("alice", 42, 1500, "HIVE", + 400, "HBD", true, 0x6553f5b0); + tx.insert(tx.end(), op.begin(), op.end()); + append_varint(tx, 0); // extensions + + EXPECT_EQ(from_hex(golden), tx); + + // and the parser accepts what the chain produces + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_STREQ("HIVE", hive_assetSymbol(parsed.ops[0].assets[0])); + EXPECT_STREQ("HBD", hive_assetSymbol(parsed.ops[0].assets[1])); +} + +TEST(Hive, SerializationMatchesHivedClaimRewardBalance) { + const std::string golden = + "00000000000040aaff5f012705616c696365e8030000000000000353544545" + "4d0000d0070000000000000353424400000000c0c62d0000000000065645535453" + "000000"; + + std::vector tx; + append_u16_le(tx, 0); + append_u32_le(tx, 0); + append_u32_le(tx, 0x5fffaa40); + append_varint(tx, 1); + append_varint(tx, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(tx, "alice"); + append_asset(tx, 1000, 3, "HIVE"); + append_asset(tx, 2000, 3, "HBD"); + append_asset(tx, 3000000, 6, "VESTS"); + append_varint(tx, 0); // extensions + + EXPECT_EQ(from_hex(golden), tx); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_STREQ("VESTS", hive_assetSymbol(parsed.ops[0].assets[2])); +} From be1e210ca3911e7d0d4f61515035864a2b478d38 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 12:54:22 -0300 Subject: [PATCH 21/40] feat(taproot): hold and spend P2TR (BIP-340/341/86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device could already send to bech32m addresses but could not hold them. This adds the key-path Taproot support: - BIP-340 Schnorr signing, wired from the pinned crypto fork, with the official test vectors in unittests/crypto/bip340.cpp. - P2TR addresses via GetAddress and BIP-86 accounts, including xpub export. - BIP-341 signing: the sighash commits to every input's amount and script, and signing fails closed if any input commitment is incomplete rather than producing a signature over a partial commitment. - The serialized witness and transaction footer are returned to the host — a Taproot spend was previously handed back missing 70 bytes of witness and locktime because the Taproot path bypassed the shared hashing step. - A change output claiming to be BIP-86 is rejected unless its script type is actually Taproot. - GetFeatures reports supports_taproot, so hosts stop inferring capability from a version number. - coins.def marks Taproot support; signing uses a zero aux_rand, documented at the call site. - Gate-3 OLED frames under docs/security/evidence/7.15.0-rc26-taproot-witness-and-address show full 62-character P2TR addresses on spend, change and mixed-input flows. --- deps/crypto/CMakeLists.txt | 1 + .../01-p2tr-address-full-62-chars.png | Bin 0 -> 996 bytes .../02-p2wsh-address-full.png | Bin 0 -> 1057 bytes .../03-p2tr-spend-recipient.png | Bin 0 -> 574 bytes .../04-p2tr-spend-fee.png | Bin 0 -> 793 bytes .../05-p2tr-change-recipient.png | Bin 0 -> 579 bytes .../06-p2tr-change-fee.png | Bin 0 -> 790 bytes .../07-mixed-p2tr-legacy-recipient.png | Bin 0 -> 587 bytes .../08-mixed-p2tr-legacy-fee.png | Bin 0 -> 796 bytes .../README.md | 69 +++ .../manifest.json | 92 ++++ include/keepkey/firmware/coins.def | 2 + include/keepkey/firmware/coins.h | 2 + include/keepkey/firmware/signing.h | 8 + include/keepkey/firmware/transaction.h | 9 + lib/firmware/coins.c | 18 + lib/firmware/fsm_msg_common.h | 6 + lib/firmware/signing.c | 260 ++++++++- lib/firmware/transaction.c | 136 ++++- unittests/crypto/CMakeLists.txt | 1 + unittests/crypto/bip340.cpp | 516 ++++++++++++++++++ unittests/firmware/CMakeLists.txt | 2 + unittests/firmware/coins.cpp | 11 + unittests/firmware/signing.cpp | 48 ++ unittests/firmware/transaction.cpp | 15 + 25 files changed, 1175 insertions(+), 21 deletions(-) create mode 100644 docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/01-p2tr-address-full-62-chars.png create mode 100644 docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/02-p2wsh-address-full.png create mode 100644 docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/03-p2tr-spend-recipient.png create mode 100644 docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/04-p2tr-spend-fee.png create mode 100644 docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/05-p2tr-change-recipient.png create mode 100644 docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/06-p2tr-change-fee.png create mode 100644 docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/07-mixed-p2tr-legacy-recipient.png create mode 100644 docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/08-mixed-p2tr-legacy-fee.png create mode 100644 docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/README.md create mode 100644 docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/manifest.json create mode 100644 unittests/crypto/bip340.cpp create mode 100644 unittests/firmware/signing.cpp create mode 100644 unittests/firmware/transaction.cpp diff --git a/deps/crypto/CMakeLists.txt b/deps/crypto/CMakeLists.txt index dde1951f2..0851894da 100644 --- a/deps/crypto/CMakeLists.txt +++ b/deps/crypto/CMakeLists.txt @@ -32,6 +32,7 @@ set(sources #trezor-firmware/crypto/tests/test_openssl.c #trezor-firmware/crypto/tests/test_speed.c trezor-firmware/crypto/secp256k1.c + trezor-firmware/crypto/bip340.c trezor-firmware/crypto/bignum.c trezor-firmware/crypto/segwit_addr.c trezor-firmware/crypto/ripemd160.c diff --git a/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/01-p2tr-address-full-62-chars.png b/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/01-p2tr-address-full-62-chars.png new file mode 100644 index 0000000000000000000000000000000000000000..e4b9629d54d119efd87e806b9d51a6a1f42e1316 GIT binary patch literal 996 zcmVO;A^;NVw0_4sz0RT_v|Ef`Hxx28^ zV*p+n!|*Ck1E4Gf0AO`({T>FuC<^dS2Tw}d0uSSW0RZ^F|A{?rEQbJa6}_tzoo_2+ z{ms&*w;%#o{a>&43K{C(h^~b|M1$5qnA~1Q`n#o3L^Nm&*n5B@qskoeUb!m$i462_ zp}(a8kqrc1(S0B_a1=C$<)XA!Ec}VU{X`(6F@xnt z#Ej%kt%>b@P|mGu#Yqn+5EQR{K>)A?W7!;LTF$MD1zhKA^HammlRY481OI?)r{&zb zTEO&y3+!BEZ5nnxMFGjRlX7ldF2ycY5CA7cj}>!tD9fi}@91UARG+EPK2-KtpFefpv}Jx0OkUo4(lFU05<|i z{wy{kLwK}U6bR~jcKG~Ea4xHtc&)H{e#v>d-f6Ucbh>0Zo*COREHN!tyO-8U5BhT9 ze530@2{q|Y4a1Q%L6yZ3GK3e@S%cFnzE1$y`miBWjtW)Ec6HR*tPrjsRcY02cHR&)KN#C;ni1j}Ta#Pd zF_{4%RH@x>sxhP101)UA0MEd~jGAWP%VnsWS68o*l^}yMsJ{T<4G+jxjr}SQ;Bo;# zHh!4D0HE{$A0ryn>Prp?m6-{ouy)*e@H{mKL??2|=mC*QXy5z5Jc|UM`(S|{WpgtRlycJ=fN=m$A5$uJ zKAU0+4t#ADezJ1_q%Z=Sv+aKc0R5WjgE5rnIv;@1x`T#rg3<#f1O1N&gh4+B0A3&3 zV~Q0wFmpgk@Mau96%t-g1)z%pfqvq%0B1Hlo*A^%V_d$o7>o2yL!Bn&&z64!FYt2m Sym%1+0000`N>ay@{?IaUCy4#Iifl@0;+Q(pgK1K z+5hPfSOcK;JrFH;(g7gqh@v5B+*ru#4ghdmo40De|DJOuy{BvdU{{NQ<}&r%UD)X{ z0KC`>91jDa`w#%|ZXws71Hh;XV5cvC?fS$VFaW^r^%pg6EUN%ejkF*rJ-3&!|Frl2 zeQ&YvJm47stQ>|kExd=mdP;a_oUav*l9B%?i_V3b@51SDX|*@3NJwb5t`af`C6!Ac zklNu6Qh~PvnLv$4UTC=0hm}`P6W{wni=+xPG}Z+xkG|Tx>Jb1{?rR;_1HfF0$D!t! zQzVU~+5QNcMI1%J19^P%78r@LhpolJz(T*A4a_&y0zg>qyyTaR0JV@dDnM+|9~aF6 zfPh2a7y#sdTXf$I$N{Ela42hs96)(C#{Ah;U?fV%WD0!mdyxn9^8bq7g@g*9M?2Dj zQ2AT4aXr6Y77zfAei36sS6sARg-DL2bldkt4IV5Z^#@{n*~-1I(2)n5UCK{-=blH9N@giAHDd;v(YofbASq!L&D(s0MOmJpPz;%V1nCn_7o2m z`k)nEEv^SN?980u=^r7xFxGcbdHE|%F)>&brR&DXWyXW{@oN|?54bVL+5oN9wex^= z0-FYhgW$h9_i4u=I}q}?ih9UJ!*T{#GkqRJG6{XQt^5k+DNs`kB5YoShb9Yw!`f`M z%-LV7&??VV1fZZmvSE-Wn+|S%s+2IOn^S&4^317`24oZ2Lz}U5u9~|_H1+^U?ZOMo zW@vr^fNCYr!Cu62Gt`3opdAyzoCAX@-U*oy&u;;cP)h*ts(%Ckj3xl^umOO-(rKa@ zjw1l)AK^d?aTYR#$As(frsm^R(PR4S-V<^$mtfKwMc9!5u#Qdjzc0^(yo~5E z;Y&pnH87Y+mBzI&k9`GDOV|QRdw?UL5%g*VuGMns)HrCtFcE?PUeG#d8@#;*v_^ok zsh$a~5s;EX(2D`Z<`0nla)A0n+||}e=QAhUtl@f;Ka$oVIiOU5%oyp~0jFn@u2YJw b-wg5xgAxa7Tu-hO00000NkvXXu0mjfE9mjF literal 0 HcmV?d00001 diff --git a/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/03-p2tr-spend-recipient.png b/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/03-p2tr-spend-recipient.png new file mode 100644 index 0000000000000000000000000000000000000000..a5e653075a21628c698d3be0ae5308651d41125e GIT binary patch literal 574 zcmV-E0>S->P)Z1NYK!j#Gd$~h!6xptO7NBTa)iVb!`Sz*JeOJ2X31~ko0#*SMus8wBx5E4}H2XO`V#Mx(z&30zwu_hASbW}T z&Mz>wf1sQ<4mRF~?Ip_E8*uXhquv2*ki1C%rXDsb4$EaifNxWL;wsoR8=tB=0s1r< zFOe+WJ8nsbUVY+1K%+VWJOsMqmm?l`*l$&L_jO@jYu4!9x#n9JQL%$DZphpWJD zWr+C8<^Y0N8&=SUDGIx}6xH2o8Ybr<-!x{@CXhUv%Wx?u7GX*O5F*NDB1ajTK#R;Vc=&@iQH)5tedWv{SeAs|-ha|FZ?=#~b#at^w8 z%WXTQQ4WtN3*x~CojQq13TktlgSz~L06&$WhMLmNdTvj0ggF6lHd6@~EHrPo5rQdA#6(kUfyv=G5-p0B({5^Uz4Oi_P7}{wV<{Bb!@1=k8(;=Yu^nf&8DF>7)An<2_#`il`0pd^i0l)Ubgq{%WV=E_0uCSyDZw5VuA3WY-9E`Z|Kn*0X9y%_-R%>XcDV999N zj~}}zQ_HkLtbSNT`@OP@asStfpJpprenkS%o_v`#@C4u-1aOK=g-wC|M5pHlfq-Mr zEM)bV062>T*8M9yKrTQ)GN>;$2lVNdBLmCxHNjlwT&qC(a1+c|fg7L*xH|zI1Habi z`?$OPwH>YlkwRgB7X}5o#PiA=lDE5VyL9VG^*$bCL)IBEXzCMBE9=`HiRICk@wH#M z)o3~Z{j;e7{rATNkHXMr^bF9>sY$j(+eoDZkg&Erv%`sjt~(B9TvvqWgaFQmm%$Ro za?m`${#qH78}4@9fU7Kw-?3x3Y$RamkQo8gQ@xzl?2$lkPELT+Y(~Il`!^Ec@<#-a zDg)`Mph$rC^Uo1L(=la)v8zX7>;e9ejOX`QljVS`17<1@0oI2rh$Eoabq4_{Den>U z6}(^9GX=|M(zQ@16ixtIN&RvRT4v!I$lz%@Kd+sWuF!(ru-7BW&|u6;ecLe|fM?H^z&C5JMw3GSR zAj|~Ndg_wgIj)|l|7<3pP$(1%g&QNTqjrxUetcMW6{Inli>i=Z7lQUQEbMBTIRQrk zdnY(vBu`a8?n7#k7XNEv{WrTqAZ|`FBQ1Q^GBzXqTDRt#-61d|fa{35Izp-LL$=HT z$C(Iu7sW#W;oF)l_8`$-C;V;|NQQu09u9!bLjauzoEvYL1I~?p6^KWQbkA3T!j16< XMtE=KB%c%E00000NkvXXu0mjfIHGSz literal 0 HcmV?d00001 diff --git a/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/05-p2tr-change-recipient.png b/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/05-p2tr-change-recipient.png new file mode 100644 index 0000000000000000000000000000000000000000..c3ee4d3d6b57c79e8e8136015b2710579b4cbe85 GIT binary patch literal 579 zcmV-J0=)f+P)ezTTJd5|s59;O=CH&H@I{4+#|2W=?=@F7t(yF#*04a^fm@T9yR2?{@-h zb2(f<*M8z|L(pSRTnOk*Mu3;VB>bUVeux0BaxiOY0~vQ=48t5}z3(=gyr0iR7jIiQ zHNMg}p?p0sc+!kxY-ZWo2-ks!dEnNV*${y>ZYRZn-AF#%~=UfFhdp01O8vAD67#5UV*fgrT%KxhYkW=F*Zc<1LTW zfFKBhAP9mW2!bF8g78D&Cr^)cd;Gxzq5eQ>-?RSN4d5;{au1!fe(8I+@qbAG%CtQ# zw!QoK|8y50;kMmL%Q9fw`I51pz~K_GduG6}uG9m<6ln0z0^`>^P66Rx_yZMF!G#VK Rfjyc!1o1fMihLY!2wtEk_2H=WBww%(Yg5^kEasSAh*s1nf>g$H2Gs z`Frece{F~BK%`I@;Eh3nF7diBhvey9ILZd){;y~ovSz@bs!zNutgSr~@$OaP>-So& zD8@wdv$YQW`eTB}%g|Ny4DDQ#151SafB=%(_GxxXF;IH_goZmIJ|_flKD-Q;FqVVn z0o^AIfzCN~C$2&JO&gL*lw+9ToaL4}+Er{au&Rr?PT;POWVkOl+k zy|Oa`yq|xZfG`ES5zTH%=OC(?)gcKy?i*Ep@^=k{JGBZCrhH>jb2vyjA;4 z_?Xg{43;mcbfHivoDQ^r`c)XTy24eE!P9hFUY)Z;FlhN>0q?B`fdFoQq>Hbfr>6yf zQEpQGu=y5KX-FRSdL$VdgV|dj__@%p4<|Wr3p+bWYBjg>8pU!#(vKf+_>h`yFuRQa zEo8YIdyis#*POAL$`DTX;yPXYe&9V1uze|5Y|<$0#3uyg2L&vQBKI_wpAn!w@)-iG zb7R+M1epFWJV0F8t_zcXbah{NK+-{n35feV9+6oC>Le|fM?H^z&C5K%j)3^sAj|~N za_W-YIj)?j{~RWuP$(1%g^dx{QM<=KetcMW7o;(ni>i=ZH-h#wEbMBTIRR$^dnY(v zBu`a8?n7#k7XNF4KOC&eNeK|YImwK)@LAun8IynOuHXSOCV~R1I{xM z@-B*p0K#8t^0Wtu_B!Ert3WaY-12Y&93BGbJmA{+z#MRG^s7KTQlxvn3KTZR52yx; U%7zWvUH||907*qoM6N<$g0GZlX#fBK literal 0 HcmV?d00001 diff --git a/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/07-mixed-p2tr-legacy-recipient.png b/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/07-mixed-p2tr-legacy-recipient.png new file mode 100644 index 0000000000000000000000000000000000000000..07e407309d2cb70e39ea5088d40cedbae6704040 GIT binary patch literal 587 zcmV-R0<`^!P)79HABDEP1sm*{$Z3evk zfdunLfX;slg$h52J>^>cW}h~5Q-K0GhlS>O_*&f3cJ zm$v*2bNeUz8;ygG`|!GRk>O|34MYSZV5liTIs!072A-*0pJt!iLTTb~c+B;X3N`#yrU(UTUjx*m&0QEjEFc zFCTNls2eAGMZj#9dCjoJ#+R|#&eog2t_B-I=ywdusZvrzpl@>3CecqsVJ$ObAix&t zdj!}J$fgFpa1L^H%V|3$Ru7NJE86}7zGoVd6x_}CIf&CQ2pFegwjsLdWWHgKbA&Yk za5hVgp%`d>7XdSGc|icKm@XXV4NI;G5dYK-0?dhKIRK9XozI5s*h7rwkPuvGTjw^e zk|dXwJd8`;i2;Hj2!bF8f*=TjAPC|g!M=Gq#_jln7ee|%s>?+0JpjX`X3Rq)MK3CQ zjQvXjP^WdbIAzbV|1uX};kIt1WFBxzKD60);C2YeGd;kKE9HPt1seRbKwAmP=2qP*QkwT$RcnYBSwkF>J@N5QvXEOi{8CWt} z_UmRBW$HVv5R0D{(SEP&V%+bw;nr*=%kM}4+LM206+8jB1_7Mnx5B2tZqezvLLlJW zGYeTgCIHSNfpz}|50D!WkPPaZ%>jM7<;cMDd`&Qyxz;L>K0E~LtH1+L1U#L9j)8CM z^Z$6d{k0vg1Cc^ufCGa9UE+OV4$0eZ+fR}{(3|PGQ_C%i?UcQP0B!A+SRO$VkX-v! z+^86H&CjL)^xq#7yk3UhqGxDFn;cl8ZKPHRAdzj8W=9kQji-)hTvvqWgaFQmm%$Ro za?m`${sc4-TPk>_Zo^d~#w|?^mq`Mau2>_0`ly%Fsx2KK4Hj4*cfNQ^zeACg5UcD~{#f7gJFhX8A344K=wDxuXwI4t1b z@kx!Z7c5^-<3gcOI4Nii^$Rg*frYCegQwM*dF!+t;z7$F3plnU1OmAIkuJV^o?b1u zqufgMY4a_t(tTOj>ycz=7-nyM;O9cWKAhy(Eo|;2sny)hYZS`~NuNL7@KH6}V0Ie; zTF7!a{O&{Y7?*Nrk5ql(4K~cT`e;w;7nle z1jmcysp`jlNG;Ohe@%pMcGk#A2@tLf4>DRjMo84(WK*j`c8Bte9c&q!6 znK|G*6Cv-ScnBbTRg=XYB--nQ->m}45OB-G2{1hb(0RbM@qszu+UQq-c%(?b`6^I& aFn$2c{(#iYg~Q?i0000 Emulator captures. Per the release SOP these do **not** substitute for +> on-device Gate-3 proof; they establish that the rendering logic is correct +> before anyone picks up a device. + +## What rc25 got wrong + +**1. Addresses longer than one line were silently truncated.** +`draw_string()` stopped at the bottom of the canvas and dropped the rest, so the +user verified a 42-character prefix while the QR code beside it encoded the whole +address — the two disagreed with no indication. This became urgent the moment +rc25 shipped Taproot, because every `bc1p` address is 62 characters. + +`01` shows `m/86'/0'/0'/0/0` rendering the complete +`bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr` +across two lines, matching the QR. `02` shows the same fix for P2WSH multisig, +which had the identical defect before Taproot existed. + +**2. The witness and locktime never reached the host.** +The Taproot signing branch set `has_signature` but not `has_serialized_tx`, so +nanopb omitted `serialized_tx` entirely and 70 bytes — the 66-byte witness plus +the 4-byte locktime footer — were dropped on the wire. The signature itself was +always correct, which is why every test passed. + +`03`–`08` are the confirm screens for the three signing flows that now also +assert the full BIP-144 serialization. They prove the amount, destination and +fee shown to the user are unchanged by that fix. + +## Frames + +| File | Screen | +|---|---| +| `01-p2tr-address-full-62-chars.png` | BIP-86 receive address, complete, with QR | +| `02-p2wsh-address-full.png` | P2WSH multisig address, complete | +| `03-p2tr-spend-recipient.png` | `Send 0.0009 BTC to 1BitcoinEater…` | +| `04-p2tr-spend-fee.png` | Total `0.001 BTC`, fee `0.0001 BTC` | +| `05-p2tr-change-recipient.png` | Recipient, device-derived P2TR change omitted | +| `06-p2tr-change-fee.png` | Fee confirmation, change flow | +| `07-mixed-p2tr-legacy-recipient.png` | Recipient, mixed Taproot + legacy inputs | +| `08-mixed-p2tr-legacy-fee.png` | Fee confirmation, mixed inputs | + +## Reproducing + +```sh +cd scripts/emulator +docker compose build kkemu && docker compose up -d kkemu +docker compose run --rm -v "$PWD/out:/out" --entrypoint /bin/sh python-keepkey -c ' + cd /kkemu/deps/python-keepkey/tests && export PYTHONPATH=".." + KEEPKEY_SCREENSHOT=1 SCREENSHOT_DIR=/out \ + KK_TRANSPORT_MAIN=kkemu:11044 KK_TRANSPORT_DEBUG=kkemu:11045 \ + python3 -m pytest test_msg_signtx_taproot.py test_msg_getaddress_taproot.py \ + test_taproot_screens.py -q' +``` + +Frames land in `$SCREENSHOT_DIR///btnNNNNN.png`. + +## Still owed + +On-device Gate-3 for both flows: display a `bc1p` receive address and compare it +character-for-character against the QR, then spend a P2TR input and confirm the +amount, destination and fee screens appear and require a press. diff --git a/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/manifest.json b/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/manifest.json new file mode 100644 index 000000000..a7e0d082f --- /dev/null +++ b/docs/security/evidence/7.15.0-rc26-taproot-witness-and-address/manifest.json @@ -0,0 +1,92 @@ +{ + "candidate": "7.15.0-rc26", + "seed": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "capture": { + "source": "emulator", + "method": "python-keepkey with KEEPKEY_SCREENSHOT=1", + "note": "Emulator captures. Per the release SOP these do not substitute for on-device Gate-3 proof." + }, + "defects_covered": [ + { + "pr": 331, + "title": "addresses longer than one line were silently truncated", + "detail": "draw_string() stopped at the bottom of the canvas and dropped the remainder, so the rendered text and the QR code disagreed with no indication.", + "frames": [ + "01-p2tr-address-full-62-chars.png", + "02-p2wsh-address-full.png" + ] + }, + { + "pr": 337, + "title": "taproot witness and locktime footer never reached the host", + "detail": "The taproot signing branch set has_signature but not has_serialized_tx, so nanopb omitted serialized_tx and 70 bytes (66-byte witness + 4-byte locktime) were dropped on the wire.", + "frames": [ + "03-p2tr-spend-recipient.png", + "04-p2tr-spend-fee.png", + "05-p2tr-change-recipient.png", + "06-p2tr-change-fee.png", + "07-mixed-p2tr-legacy-recipient.png", + "08-mixed-p2tr-legacy-fee.png" + ] + } + ], + "verified_values": { + "bip86_receive_address_m_86h_0h_0h_0_0": "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", + "bip86_address_characters": 62, + "serialized_tx_bytes": { + "p2tr": 153, + "p2tr_with_change": 196, + "mixed_p2tr_and_legacy": 301 + } + }, + "tests": { + "files": [ + "test_msg_signtx_taproot.py", + "test_msg_getaddress_taproot.py", + "test_taproot_screens.py" + ], + "result": "9 passed" + }, + "frames": [ + { + "file": "01-p2tr-address-full-62-chars.png", + "sha256": "b1a4174f8e4c88b30a41ce337d0e45ded840c104f6637dcc98a7dfc89d27e073", + "bytes": 996 + }, + { + "file": "02-p2wsh-address-full.png", + "sha256": "7cf807b25d8a2a6a67bc83f887184b89221a0bfd9d8a2a24872d3bc954669b17", + "bytes": 1057 + }, + { + "file": "03-p2tr-spend-recipient.png", + "sha256": "91390aec2d647e5dcd5c9cfc767e72fcc62a41e710aae9d8d42abe4af9451fd5", + "bytes": 574 + }, + { + "file": "04-p2tr-spend-fee.png", + "sha256": "c635bd2cb4c52b4553e4ad0ccbf53ae0e6fa3c61f9dbe60bfbf43d18692228fb", + "bytes": 793 + }, + { + "file": "05-p2tr-change-recipient.png", + "sha256": "fb1e137d2463b5ba44decd151f5380f649b3e884323c7c54f6f66e87cc3288a0", + "bytes": 579 + }, + { + "file": "06-p2tr-change-fee.png", + "sha256": "7260c9c0ee5cc85c61356b41b91aa4d30a091bf1be60b7516bff0e4b25b225aa", + "bytes": 790 + }, + { + "file": "07-mixed-p2tr-legacy-recipient.png", + "sha256": "6964d237e5208ec5cf2ca03ca35a3515cbd522eb4d0799e372285b5262cf1f6c", + "bytes": 587 + }, + { + "file": "08-mixed-p2tr-legacy-fee.png", + "sha256": "8ff0582a2bb578485b1c4973f042f8f7e1454473b7995174afdfb4e72cb56f1e", + "bytes": 796 + } + ] +} diff --git a/include/keepkey/firmware/coins.def b/include/keepkey/firmware/coins.def index 5d872061c..b99ba9fa2 100644 --- a/include/keepkey/firmware/coins.def +++ b/include/keepkey/firmware/coins.def @@ -2,6 +2,7 @@ //coin_name coin_shortcut address_type maxfee_kb p2sh signed_message_header bip44_account_path forkid/chain_id decimals contract_address xpub_magic segwit force_bip143 curve_name cashaddr_prefix bech32_prefix decred xpub_magic_segwit_p2sh xpub_mmagic_segwit_native nanoaddr_prefix taproot X(true, "Bitcoin", true, "BTC", true, 0, true, 100000, true, 5, true, "Bitcoin Signed Message:\n", true, 0x80000000, false, 0, true, 8, false, NO_CONTRACT, true, 76067358, true, true, true, false, true, SECP256K1_STRING, false, "", true, "bc", false, false, true, 77429938, true, 78792518, false, "", true, true ) X(true, "Testnet", true, "TEST", true, 111, true, 10000000, true, 196, true, "Bitcoin Signed Message:\n", true, 0x80000001, false, 0, true, 8, false, NO_CONTRACT, true, 70617039, true, true, true, false, true, SECP256K1_STRING, false, "", true, "tb", false, false, true, 71979618, true, 73342198, false, "", true, true ) +#if !BITCOIN_ONLY X(true, "BitcoinCash", true, "BCH", true, 0, true, 500000, true, 5, true, "Bitcoin Signed Message:\n", true, 0x80000091, true, 0, true, 8, false, NO_CONTRACT, true, 76067358, true, false, true, true, true, SECP256K1_STRING, true, "bitcoincash", false, "", false, false, false, 0, false, 0, false, "", true, false ) X(true, "Namecoin", true, "NMC", true, 52, true, 10000000, true, 5, true, "Namecoin Signed Message:\n", true, 0x80000007, false, 0, true, 8, false, NO_CONTRACT, true, 27108450, true, false, true, false, true, SECP256K1_STRING, false, "", false, "", false, false, false, 0, false, 0, false, "", true, false ) X(true, "Litecoin", true, "LTC", true, 48, true, 1000000, true, 50, true, "Litecoin Signed Message:\n", true, 0x80000002, false, 0, true, 8, false, NO_CONTRACT, true, 27108450, true, true, true, false, true, SECP256K1_STRING, false, "", true, "ltc", false, false, true, 28471030, true, 78792518, false, "", true, false ) @@ -47,6 +48,7 @@ X(true, "Terra", true, "LUNA", false, NA, false, NA, false, N X(true, "Kava", true, "KAVA", false, NA, false, NA, false, NA, false, {0}, true, 0x800001cb, false, 0, true, 6, false, NO_CONTRACT, false, 0, false, false, false, false, true, SECP256K1_STRING, false, "", false, "kava", false, false, false, 0, false, 0, false, "", true, false ) X(true, "Secret", true, "SCRT", false, NA, false, NA, false, NA, false, {0}, true, 0x80000211, false, 0, true, 6, false, NO_CONTRACT, false, 0, false, false, false, false, true, SECP256K1_STRING, false, "", false, "secret", false, false, false, 0, false, 0, false, "", true, false ) X(true, "MAYAChain", true, "CACAO", false, NA, false, NA, false, NA, false, {0}, true, 0x800003a3, false, 0, true, 10, false, NO_CONTRACT, false, 0, false, false, false, false, true, SECP256K1_STRING, false, "", false, "maya", false, false, false, 0, false, 0, false, "", true, false ) +#endif // !BITCOIN_ONLY #undef X #undef NO_CONTRACT diff --git a/include/keepkey/firmware/coins.h b/include/keepkey/firmware/coins.h index f5ede136d..5dec19f7e 100644 --- a/include/keepkey/firmware/coins.h +++ b/include/keepkey/firmware/coins.h @@ -44,9 +44,11 @@ enum { CONCAT(CoinIndex, __COUNTER__), #include "keepkey/firmware/coins.def" +#if !BITCOIN_ONLY // ERC-20 tokens excluded from the bitcoin-only image #define X(INDEX, NAME, SYMBOL, DECIMALS, CONTRACT_ADDRESS) \ CONCAT(CoinIndex, __COUNTER__), #include "keepkey/firmware/tokens.def" +#endif CoinIndexLast, CoinIndexFirst = 0 diff --git a/include/keepkey/firmware/signing.h b/include/keepkey/firmware/signing.h index 55f4c17d1..95b5be425 100644 --- a/include/keepkey/firmware/signing.h +++ b/include/keepkey/firmware/signing.h @@ -23,8 +23,16 @@ #include "trezor/crypto/bip32.h" #include "keepkey/transport/interface.h" +#include #include #include +/// Exposed for unit tests: pure predicate, no signing state involved. +bool isCrossAccountSegwitChangeForbidden(const uint32_t* lhs_address_n, + size_t lhs_address_n_count, + const uint32_t* rhs_address_n, + size_t rhs_address_n_count, + OutputScriptType rhs_script_type); + void signing_init(const SignTx* msg, const CoinType* _coin, const HDNode* _root); void signing_abort(void); diff --git a/include/keepkey/firmware/transaction.h b/include/keepkey/firmware/transaction.h index fd699554c..5b3525a17 100644 --- a/include/keepkey/firmware/transaction.h +++ b/include/keepkey/firmware/transaction.h @@ -78,6 +78,15 @@ uint32_t serialize_script_multisig(const CoinType* coin, int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, TxOutputBinType* out, bool needs_confirm); +bool address_to_script_pubkey(const CoinType* coin, const char* address, + uint8_t* script_pubkey, size_t* script_pubkey_len, + size_t script_pubkey_size); + +bool fill_input_script_pubkey(const CoinType* coin, const HDNode* root, + const TxInputType* in, uint8_t* script_pubkey, + size_t* script_pubkey_len, + size_t script_pubkey_size); + uint32_t tx_prevout_hash(Hasher* hasher, const TxInputType* input); uint32_t tx_script_hash(Hasher* hasher, uint32_t size, const uint8_t* data); uint32_t tx_sequence_hash(Hasher* hasher, const TxInputType* input); diff --git a/lib/firmware/coins.c b/lib/firmware/coins.c index 71a37d4da..964b9cf52 100644 --- a/lib/firmware/coins.c +++ b/lib/firmware/coins.c @@ -85,6 +85,7 @@ const CoinType coins[COINS_COUNT] = { TAPROOT}, #include "keepkey/firmware/coins.def" +#if !BITCOIN_ONLY // ERC-20 tokens excluded from the bitcoin-only image #define X(INDEX, NAME, SYMBOL, DECIMALS, CONTRACT_ADDRESS) \ { \ true, \ @@ -131,6 +132,7 @@ const CoinType coins[COINS_COUNT] = { false, /* has_taproot, taproot*/ \ }, #include "keepkey/firmware/tokens.def" +#endif // !BITCOIN_ONLY }; _Static_assert(sizeof(coins) / sizeof(coins[0]) == COINS_COUNT, @@ -226,6 +228,22 @@ static bool path_mismatched(const CoinType* coin, const uint32_t* address_n, return mismatch; } + // m/86' : BIP86 Taproot + // m / purpose' / bip44_account_path' / account' / change / address_index + if (address_n[0] == (0x80000000 + 86)) { + mismatch |= !coin->has_segwit || !coin->segwit; + mismatch |= !coin->has_bech32_prefix; + mismatch |= !coin->has_taproot || !coin->taproot; + mismatch |= (address_n_count != (whole_account ? 3 : 5)); + mismatch |= (address_n[1] != coin->bip44_account_path); + mismatch |= (address_n[2] & 0x80000000) == 0; + if (!whole_account) { + mismatch |= (address_n[3] & 0x80000000) == 0x80000000; + mismatch |= (address_n[4] & 0x80000000) == 0x80000000; + } + return mismatch; + } + return false; } diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 9b6e4cdeb..01b3792b9 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -41,6 +41,12 @@ void fsm_msgGetFeatures(GetFeatures* msg) { resp->has_model = true; strlcpy(resp->model, model(), sizeof(resp->model)); + /* Taproot capability. Reported directly so a host does not have to infer + P2TR support from a firmware version -- that inference breaks whenever the + feature is retargeted to a different release. */ + resp->has_supports_taproot = true; + resp->supports_taproot = true; + /* Variant Name */ resp->has_firmware_variant = true; strlcpy(resp->firmware_variant, variant_getName(), diff --git a/lib/firmware/signing.c b/lib/firmware/signing.c index a7b367e0f..bc66580ed 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -26,13 +26,13 @@ #include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/coins.h" #include "keepkey/firmware/crypto.h" -#include "keepkey/firmware/crypto.h" #include "keepkey/firmware/fsm.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/policy.h" #include "keepkey/firmware/signing.h" #include "keepkey/firmware/txin_check.h" #include "keepkey/firmware/transaction.h" +#include "trezor/crypto/bip340.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/memzero.h" #include "trezor/crypto/secp256k1.h" @@ -82,12 +82,33 @@ static TxInputType input; static TxOutputBinType bin_output; static TxStruct to, tp, ti; static Hasher hasher_prevouts, hasher_sequence, hasher_outputs, hasher_check; +/* BIP-341 commits to every input's amount and scriptPubKey, which BIP-143 + does not, so taproot needs two accumulators segwit never required. These + are SHA256_CTX rather than Hasher because BIP-341 fixes them to plain + SHA256: a Hasher carries a union sized by GROESTL512_CTX and would cost + ~1.2 KB of SRAM here for no benefit. */ +static SHA256_CTX ctx_amounts, ctx_scriptpubkeys; +/* BIP-143 hashes prevouts/sequences/outputs with DOUBLE sha256 (hasher_sign is + HASHER_SHA2D for Bitcoin); BIP-341 specifies SINGLE sha256. The BIP-143 + accumulators therefore cannot be reused -- doing so yields a valid signature + over the wrong commitment. Hence a parallel set. */ +static SHA256_CTX ctx_prevouts_tr, ctx_sequences_tr, ctx_outputs_tr; static uint8_t CONFIDENTIAL privkey[32]; static uint8_t pubkey[33], sig[64]; static uint8_t hash_prevouts[32], hash_sequence[32], hash_outputs[32]; +static uint8_t hash_amounts[32], hash_scriptpubkeys[32]; +static uint8_t hash_prevouts_tr[32], hash_sequences_tr[32], hash_outputs_tr[32]; static uint8_t hash_prefix[32]; static uint8_t hash_check[32]; static uint64_t to_spend, authorized_bip143_in, spending, change_spend; +static bool has_taproot_input, missing_bip341_input_amount; +/* + * Taproot signatures must never be reachable before phase 1 has completed + * the physical transaction-summary confirmation. Keep this as independent + * state instead of inferring it from signing_stage so a malformed or + * corrupted stage transition fails closed at the Schnorr signing boundary. + */ +static bool taproot_transaction_confirmed; static uint32_t version = 1; static uint32_t lock_time = 0; static uint32_t expiry = 0; @@ -113,6 +134,10 @@ static uint32_t tx_weight; use and still allow to quickly brute-force the correct bip32 path. */ #define BIP32_MAX_LAST_ELEMENT 1000000 +/* BIP-341 SIGHASH_DEFAULT: commits to all outputs, and unlike SIGHASH_ALL + its byte is omitted from the witness entirely. */ +#define SIGHASH_DEFAULT_TAPROOT 0 + /* transaction header size: 4 byte version */ #define TXSIZE_HEADER 4 /* transaction footer size: 4 byte lock time */ @@ -408,6 +433,18 @@ void phase1_request_next_input(void) { // compute segwit hashPrevouts & hashSequence hasher_Final(&hasher_prevouts, hash_prevouts); hasher_Final(&hasher_sequence, hash_sequence); + if (has_taproot_input && missing_bip341_input_amount) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Taproot transaction input without amount")); + signing_abort(); + return; + } + if (coin->has_taproot && coin->taproot) { + sha256_Final(&ctx_amounts, hash_amounts); + sha256_Final(&ctx_scriptpubkeys, hash_scriptpubkeys); + sha256_Final(&ctx_prevouts_tr, hash_prevouts_tr); + sha256_Final(&ctx_sequences_tr, hash_sequences_tr); + } hasher_Final(&hasher_check, hash_check); // init hashOutputs hasher_Reset(&hasher_outputs); @@ -427,10 +464,11 @@ void phase2_request_next_input(void) { /// Compares two BIP32 paths, returning true iff there is something mismatched /// about the mixed-mode change. -static bool isCrossAccountSegwitChangeForbidden( - const uint32_t* lhs_address_n, size_t lhs_address_n_count, - const uint32_t* rhs_address_n, size_t rhs_address_n_count, - OutputScriptType rhs_script_type) { +bool isCrossAccountSegwitChangeForbidden(const uint32_t* lhs_address_n, + size_t lhs_address_n_count, + const uint32_t* rhs_address_n, + size_t rhs_address_n_count, + OutputScriptType rhs_script_type) { (void)lhs_address_n; size_t count = rhs_address_n_count; @@ -456,6 +494,10 @@ static bool isCrossAccountSegwitChangeForbidden( rhs_script_type != OutputScriptType_PAYTOWITNESS) return true; + if (out_purpose == (0x80000000 | 86) && + rhs_script_type != OutputScriptType_PAYTOTAPROOT) + return true; + return false; } @@ -564,7 +606,12 @@ bool check_change_bip32_path(const TxOutputType* toutput) { toutput->address_n[count - 1] <= BIP32_MAX_LAST_ELEMENT); } -bool compile_input_script_sig(TxInputType* tinput) { +/* Re-validates the input against what phase 1 saw, then derives its node into + `node`. Split out of compile_input_script_sig() so taproot can reuse the + checks and the derivation without building a scriptSig -- a taproot input + has an empty one, and skipping this would also skip the guard that the + host has not swapped address_n between phases. */ +static bool prepare_input_node(TxInputType* tinput) { if (!multisig_fp_mismatch) { // check that this is still multisig uint8_t h[32]; @@ -594,6 +641,13 @@ bool compile_input_script_sig(TxInputType* tinput) { return false; } hdnode_fill_public_key(&node); + return true; +} + +bool compile_input_script_sig(TxInputType* tinput) { + if (!prepare_input_node(tinput)) { + return false; + } if (tinput->has_multisig) { tinput->script_sig.size = compile_script_multisig(coin, &(tinput->multisig), tinput->script_sig.bytes); @@ -646,6 +700,9 @@ void signing_init(const SignTx* msg, const CoinType* _coin, spending = 0; change_spend = 0; authorized_bip143_in = 0; + has_taproot_input = false; + missing_bip341_input_amount = false; + taproot_transaction_confirmed = false; memset(&input, 0, sizeof(TxInputType)); memset(&resp, 0, sizeof(TxRequest)); @@ -690,6 +747,15 @@ void signing_init(const SignTx* msg, const CoinType* _coin, hasher_Init(&hasher_sequence, curve->hasher_sign); hasher_Init(&hasher_outputs, curve->hasher_sign); hasher_Init(&hasher_check, curve->hasher_sign); + if (coin->has_taproot && coin->taproot) { + /* BIP-341 fixes these as plain SHA256, independent of the coin's + signing hasher, so they are initialised separately on purpose. */ + sha256_Init(&ctx_amounts); + sha256_Init(&ctx_scriptpubkeys); + sha256_Init(&ctx_prevouts_tr); + sha256_Init(&ctx_sequences_tr); + sha256_Init(&ctx_outputs_tr); + } } layoutProgressSwipe(_("Signing transaction"), 0); @@ -719,7 +785,8 @@ static bool is_internal_input_script_type(const TxInputType* txinput) { if (txinput->script_type == InputScriptType_SPENDADDRESS || txinput->script_type == InputScriptType_SPENDMULTISIG || txinput->script_type == InputScriptType_SPENDP2SHWITNESS || - txinput->script_type == InputScriptType_SPENDWITNESS) { + txinput->script_type == InputScriptType_SPENDWITNESS || + txinput->script_type == InputScriptType_SPENDTAPROOT) { return true; } return false; @@ -729,7 +796,8 @@ static bool is_change_output_script_type(const TxOutputType* txoutput) { if (txoutput->script_type == OutputScriptType_PAYTOADDRESS || txoutput->script_type == OutputScriptType_PAYTOMULTISIG || txoutput->script_type == OutputScriptType_PAYTOP2SHWITNESS || - txoutput->script_type == OutputScriptType_PAYTOWITNESS) { + txoutput->script_type == OutputScriptType_PAYTOWITNESS || + txoutput->script_type == OutputScriptType_PAYTOTAPROOT) { return true; } return false; @@ -737,7 +805,8 @@ static bool is_change_output_script_type(const TxOutputType* txoutput) { static bool is_segwit_input_script_type(const TxInputType* txinput) { if (txinput->script_type == InputScriptType_SPENDP2SHWITNESS || - txinput->script_type == InputScriptType_SPENDWITNESS) { + txinput->script_type == InputScriptType_SPENDWITNESS || + txinput->script_type == InputScriptType_SPENDTAPROOT) { return true; } return false; @@ -779,6 +848,13 @@ static bool signing_validate_input(const TxInputType* txinput) { } } + if (txinput->script_type == InputScriptType_SPENDTAPROOT && + (!coin->has_taproot || !coin->taproot)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Taproot not enabled on this coin.")); + signing_abort(); + return false; + } if (is_segwit_input_script_type(txinput)) { if (!coin->has_segwit) { fsm_sendFailure(FailureType_Failure_Other, @@ -813,6 +889,16 @@ static bool signing_validate_output(const TxOutputType* txoutput) { return false; } + // has_taproot is the nanopb presence flag, not the value. Every coin in + // coins.def sets it, so both fields must be checked. + if (txoutput->script_type == OutputScriptType_PAYTOTAPROOT && + (!coin->has_taproot || !coin->taproot)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Taproot not enabled on this coin.")); + signing_abort(); + return false; + } + if (txoutput->script_type == OutputScriptType_PAYTOOPRETURN) { if (txoutput->has_address || (txoutput->address_n_count > 0) || txoutput->has_multisig) { @@ -822,14 +908,6 @@ static bool signing_validate_output(const TxOutputType* txoutput) { return false; } - if (txoutput->script_type == OutputScriptType_PAYTOTAPROOT && - !coin->has_taproot) { - fsm_sendFailure(FailureType_Failure_Other, - _("Taproot not enabled on this coin.")); - signing_abort(); - return false; - } - if (txoutput->amount != 0) { fsm_sendFailure(FailureType_Failure_Other, _("OP_RETURN output with non-zero amount")); @@ -898,6 +976,36 @@ static bool signing_check_input(TxInputType* txinput) { // compute segwit hashPrevouts & hashSequence tx_prevout_hash(&hasher_prevouts, txinput); tx_sequence_hash(&hasher_sequence, txinput); + // BIP-341 commits to the amount and scriptPubKey of EVERY input, not just + // the taproot ones, so these must be accumulated for all of them. Only + // coins with taproot enabled pay the per-input derivation, and + // hdnode_private_ckd_cached keeps repeated derivations along one account + // path cheap. + if (coin->has_taproot && coin->taproot) { + has_taproot_input |= txinput->script_type == InputScriptType_SPENDTAPROOT; + missing_bip341_input_amount |= !txinput->has_amount; + uint8_t script_pubkey[64]; + size_t script_pubkey_len = 0; + if (!fill_input_script_pubkey(coin, root, txinput, script_pubkey, + &script_pubkey_len, sizeof(script_pubkey))) { + fsm_sendFailure(FailureType_Failure_Other, + _("Failed to derive input scriptPubKey")); + signing_abort(); + return false; + } + /* outpoint: prev_hash is carried display-order and goes out reversed, + matching tx_prevout_hash() */ + for (int i = 0; i < 32; i++) { + sha256_Update(&ctx_prevouts_tr, &txinput->prev_hash.bytes[31 - i], 1); + } + sha256_Update(&ctx_prevouts_tr, (const uint8_t*)&txinput->prev_index, 4); + sha256_Update(&ctx_sequences_tr, (const uint8_t*)&txinput->sequence, 4); + sha256_Update(&ctx_amounts, (const uint8_t*)&txinput->amount, 8); + uint8_t lenbuf[5]; + uint32_t lenlen = ser_length(script_pubkey_len, lenbuf); + sha256_Update(&ctx_scriptpubkeys, lenbuf, lenlen); + sha256_Update(&ctx_scriptpubkeys, script_pubkey, script_pubkey_len); + } if (coin->decred) { if (txinput->decred_script_version > 0) { fsm_sendFailure(FailureType_Failure_SyntaxError, @@ -1002,6 +1110,15 @@ static bool signing_check_output(TxOutputType* txoutput) { } // compute segwit hashOuts tx_output_hash(&hasher_outputs, &bin_output, coin->decred); + /* BIP-341's sha_outputs: single sha256 over amount || ser_script */ + if (coin->has_taproot && coin->taproot) { + sha256_Update(&ctx_outputs_tr, (const uint8_t*)&bin_output.amount, 8); + uint8_t lenbuf[5]; + uint32_t lenlen = ser_length(bin_output.script_pubkey.size, lenbuf); + sha256_Update(&ctx_outputs_tr, lenbuf, lenlen); + sha256_Update(&ctx_outputs_tr, bin_output.script_pubkey.bytes, + bin_output.script_pubkey.size); + } return true; } @@ -1038,6 +1155,9 @@ static bool signing_check_fee(void) { signing_abort(); return false; } + if (has_taproot_input) { + taproot_transaction_confirmed = true; + } return true; } @@ -1061,6 +1181,9 @@ static void phase1_request_next_output(void) { tx_hash_final(&ti, hash_prefix, false); } hasher_Final(&hasher_outputs, hash_outputs); + if (coin->has_taproot && coin->taproot) { + sha256_Final(&ctx_outputs_tr, hash_outputs_tr); + } if (!signing_check_fee()) { return; } @@ -1077,6 +1200,14 @@ static void phase1_request_next_output(void) { } } +/* BIP-341 key-path sighash. The assembly itself lives in bip341_sighash() + so the field ordering is unit-testable against the published vectors. */ +static void signing_hash_bip341(uint32_t input_index, uint8_t* hash) { + bip341_sighash(SIGHASH_DEFAULT_TAPROOT, version, lock_time, hash_prevouts_tr, + hash_amounts, hash_scriptpubkeys, hash_sequences_tr, + hash_outputs_tr, input_index, hash); +} + static void signing_hash_bip143(const TxInputType* txinput, uint8_t* hash) { uint32_t hash_type = signing_hash_type(); Hasher hasher_preimage; @@ -1250,7 +1381,78 @@ static bool signing_sign_input(void) { static bool signing_sign_segwit_input(TxInputType* txinput) { // idx1: index to sign - if (is_segwit_input_script_type(txinput)) { + if (txinput->script_type == InputScriptType_SPENDTAPROOT) { + if (!taproot_transaction_confirmed) { + fsm_sendFailure(FailureType_Failure_Other, + _("Taproot transaction was not confirmed")); + signing_abort(); + return false; + } + /* No scriptSig to build, but the same re-validation and derivation the + other input types get. */ + if (!prepare_input_node(txinput)) { + fsm_sendFailure(FailureType_Failure_Other, _("Failed to compile input")); + signing_abort(); + return false; + } + if (txinput->amount > authorized_bip143_in) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Transaction has changed during signing")); + signing_abort(); + return false; + } + authorized_bip143_in -= txinput->amount; + + uint8_t hash[32]; + signing_hash_bip341(idx1, hash); + + /* Sign with the BIP-86 tweaked key, so the signature verifies against the + output key committed to in the scriptPubKey rather than the internal + key. */ + static CONFIDENTIAL uint8_t tweaked[32]; + if (bip340_tweak_seckey(curve->params, node.private_key, + /*merkle_root=*/NULL, tweaked) != 0) { + memzero(tweaked, sizeof(tweaked)); + fsm_sendFailure(FailureType_Failure_Other, _("Failed to tweak key")); + signing_abort(); + return false; + } + /* aux = NULL means an all-zero aux_rand, i.e. deterministic signing. That + is spec-permitted and matches this firmware's ECDSA, which is RFC6979 + deterministic; the nonce still depends on the private key and the + message, so it is never reused across different transactions. Fresh + randomness would only add side-channel hardening, at the cost of making + signatures unreproducible and so untestable against a published + vector. */ + int sign_ret = bip340_sign(curve->params, tweaked, hash, sizeof(hash), + /*aux=*/NULL, sig); + memzero(tweaked, sizeof(tweaked)); + if (sign_ret != 0) { + fsm_sendFailure(FailureType_Failure_Other, _("Signing failed")); + signing_abort(); + return false; + } + + resp.has_serialized = true; + resp.serialized.has_signature_index = true; + resp.serialized.signature_index = idx1; + resp.serialized.has_signature = true; + /* signing_txack() memsets resp, and this branch bypasses + signing_sign_hash(), which is where every other input type sets this. + Without it nanopb omits serialized_tx and the host loses the witness + and the tx footer. */ + resp.serialized.has_serialized_tx = true; + resp.serialized.signature.size = 64; + memcpy(resp.serialized.signature.bytes, sig, 64); + + /* Witness is a single 64-byte element. SIGHASH_DEFAULT omits the trailing + sighash byte entirely -- appending 0x00 would be a different, invalid + signature. */ + uint32_t r = 0; + r += ser_length(1, resp.serialized.serialized_tx.bytes + r); + r += tx_serialize_script(64, sig, resp.serialized.serialized_tx.bytes + r); + resp.serialized.serialized_tx.size = r; + } else if (is_segwit_input_script_type(txinput)) { if (!compile_input_script_sig(txinput)) { fsm_sendFailure(FailureType_Failure_Other, _("Failed to compile input")); signing_abort(); @@ -1403,6 +1605,7 @@ void signing_txack(TransactionType* tx) { send_req_2_prev_meta(); } } else if (tx->inputs[0].script_type == InputScriptType_SPENDWITNESS || + tx->inputs[0].script_type == InputScriptType_SPENDTAPROOT || tx->inputs[0].script_type == InputScriptType_SPENDP2SHWITNESS) { if (coin->decred) { @@ -1535,6 +1738,26 @@ void signing_txack(TransactionType* tx) { signing_abort(); return; } + /* BIP-341 commits to every input's amount and scriptPubKey. For a + mixed legacy+Taproot transaction the host must provide the legacy + amount, and it must describe the actual prevout rather than an + invented commitment that would produce an invalid signature. */ + if (coin->has_taproot && coin->taproot && input.has_amount) { + uint8_t expected_script[64]; + size_t expected_script_len = 0; + if (input.amount != tx->bin_outputs[0].amount || + !fill_input_script_pubkey(coin, root, &input, expected_script, + &expected_script_len, + sizeof(expected_script)) || + expected_script_len != tx->bin_outputs[0].script_pubkey.size || + memcmp(expected_script, tx->bin_outputs[0].script_pubkey.bytes, + expected_script_len) != 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Input amount or script does not match prevout")); + signing_abort(); + return; + } + } to_spend += tx->bin_outputs[0].amount; } if (idx2 < tp.outputs_len - 1) { @@ -1902,4 +2125,5 @@ void signing_abort(void) { } memzero(&root, sizeof(root)); memzero(&node, sizeof(node)); + taproot_transaction_confirmed = false; } diff --git a/lib/firmware/transaction.c b/lib/firmware/transaction.c index 1405401a7..8022ae603 100644 --- a/lib/firmware/transaction.c +++ b/lib/firmware/transaction.c @@ -32,6 +32,7 @@ #include "keepkey/transport/interface.h" #include "trezor/crypto/address.h" #include "trezor/crypto/base58.h" +#include "trezor/crypto/bip340.h" #include "trezor/crypto/cash_addr.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/memzero.h" @@ -43,6 +44,7 @@ #define _(X) (X) #define SEGWIT_VERSION_0 0 +#define SEGWIT_VERSION_1 1 #define CASHADDR_P2KH (0) #define CASHADDR_P2SH (8) @@ -113,6 +115,95 @@ uint32_t op_push(uint32_t i, uint8_t* out) { return 5; } +bool address_to_script_pubkey(const CoinType* coin, const char* address, + uint8_t* script_pubkey, size_t* script_pubkey_len, + size_t script_pubkey_size) { + uint8_t addr_raw[MAX_ADDR_RAW_SIZE]; + size_t addr_raw_len; + int witver; + + const curve_info* curve = get_curve_by_name(coin->curve_name); + if (!curve) return false; + + // Deliberately narrower than compile_output's output-side decoding: this is + // only reached for inputs of a coin with taproot enabled, i.e. Bitcoin and + // Testnet, so cashaddr and the BCH burn warning do not apply. Kept separate + // rather than factored out of compile_output because that block interleaves + // confirm-and-cancel UX with the script building. + if (coin->has_bech32_prefix && + segwit_addr_decode(&witver, addr_raw, &addr_raw_len, coin->bech32_prefix, + address)) { + // push witness version (OP_0 = 0, OP_i = 80 + i), then the program + if (addr_raw_len + 2 > script_pubkey_size) { + return false; + } + script_pubkey[0] = witver == 0 ? 0 : 80 + witver; + script_pubkey[1] = addr_raw_len; + memcpy(script_pubkey + 2, addr_raw, addr_raw_len); + *script_pubkey_len = addr_raw_len + 2; + return true; + } + + addr_raw_len = base58_decode_check(address, curve->hasher_base58, addr_raw, + MAX_ADDR_RAW_SIZE); + + if (coin->has_address_type && + addr_raw_len == 20 + address_prefix_bytes_len(coin->address_type) && + address_check_prefix(addr_raw, coin->address_type)) { + if (25 > script_pubkey_size) { + return false; + } + script_pubkey[0] = 0x76; // OP_DUP + script_pubkey[1] = 0xA9; // OP_HASH_160 + script_pubkey[2] = 0x14; // pushing 20 bytes + memcpy(script_pubkey + 3, + addr_raw + address_prefix_bytes_len(coin->address_type), 20); + script_pubkey[23] = 0x88; // OP_EQUALVERIFY + script_pubkey[24] = 0xAC; // OP_CHECKSIG + *script_pubkey_len = 25; + return true; + } + + if (coin->has_address_type_p2sh && + addr_raw_len == 20 + address_prefix_bytes_len(coin->address_type_p2sh) && + address_check_prefix(addr_raw, coin->address_type_p2sh)) { + if (23 > script_pubkey_size) { + return false; + } + script_pubkey[0] = 0xA9; // OP_HASH_160 + script_pubkey[1] = 0x14; // pushing 20 bytes + memcpy(script_pubkey + 2, + addr_raw + address_prefix_bytes_len(coin->address_type_p2sh), 20); + script_pubkey[22] = 0x87; // OP_EQUAL + *script_pubkey_len = 23; + return true; + } + + return false; +} + +bool fill_input_script_pubkey(const CoinType* coin, const HDNode* root, + const TxInputType* in, uint8_t* script_pubkey, + size_t* script_pubkey_len, + size_t script_pubkey_size) { + static CONFIDENTIAL HDNode node; + char address[MAX_ADDR_SIZE] = {0}; + bool res; + + memcpy(&node, root, sizeof(HDNode)); + res = hdnode_private_ckd_cached(&node, in->address_n, in->address_n_count, + NULL) != 0; + if (res) { + hdnode_fill_public_key(&node); // returns void in this tree + } + res = res && compute_address(coin, in->script_type, &node, in->has_multisig, + &in->multisig, address); + memzero(&node, sizeof(node)); + + return res && address_to_script_pubkey(coin, address, script_pubkey, + script_pubkey_len, script_pubkey_size); +} + bool compute_address(const CoinType* coin, InputScriptType script_type, const HDNode* node, bool has_multisig, const MultisigRedeemScriptType* multisig, @@ -125,6 +216,11 @@ bool compute_address(const CoinType* coin, InputScriptType script_type, if (has_multisig) { size_t prelen; + // No taproot multisig. Without this the request would fall through to + // the p2sh branch below and hand back a p2sh address for a taproot ask. + if (script_type == InputScriptType_SPENDTAPROOT) { + return 0; + } if (cryptoMultisigPubkeyIndex(coin, multisig, node->public_key) < 0) { return 0; } @@ -186,9 +282,28 @@ bool compute_address(const CoinType* coin, InputScriptType script_type, return 0; } } else if (script_type == InputScriptType_SPENDTAPROOT) { - // we don't handle spendtaproot input types - return 0; - + // p2tr: the witness program is the BIP-86 tweaked output key, bech32m + // encoded at witness version 1. + if ((!coin->has_segwit || !coin->segwit) || !coin->has_bech32_prefix) { + return 0; + } + if (!coin->has_taproot || !coin->taproot) { + return 0; + } + uint8_t output_key[32]; + // node->public_key is compressed; bytes 1..33 are the x-only internal key. + // BIP-341 defines the internal key as x-only, so the odd-y case resolves + // to its even-y counterpart here and in the signer alike. + if (bip340_tweak_pubkey(curve->params, node->public_key + 1, + /*merkle_root=*/NULL, output_key) != 0) { + return 0; + } + // Exactly 32 bytes: segwit_addr_encode only length-checks the witness + // program for version 0, so a wrong length would encode silently. + if (!segwit_addr_encode(address, coin->bech32_prefix, SEGWIT_VERSION_1, + output_key, sizeof(output_key))) { + return 0; + } } else if (script_type == InputScriptType_SPENDP2SHWITNESS) { // segwit p2wpkh embedded in p2sh if (!coin->has_segwit || !coin->segwit) { @@ -238,6 +353,7 @@ int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, } } else { // is this thorchain data? +#if !BITCOIN_ONLY if (!thorchain_parseConfirmMemo((const char*)in->op_return_data.bytes, (size_t)in->op_return_data.size)) { if (!confirm_data(ButtonRequestType_ButtonRequest_ConfirmOutput, @@ -246,6 +362,14 @@ int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, return -1; // user aborted } } +#else + // bitcoin-only: no THORChain memo decoding, confirm raw OP_RETURN + if (!confirm_data(ButtonRequestType_ButtonRequest_ConfirmOutput, + _("Confirm OP_RETURN"), in->op_return_data.bytes, + in->op_return_data.size)) { + return -1; // user aborted + } +#endif } } uint32_t r = 0; @@ -276,6 +400,9 @@ int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, case OutputScriptType_PAYTOP2SHWITNESS: input_script_type = InputScriptType_SPENDP2SHWITNESS; break; + case OutputScriptType_PAYTOTAPROOT: + input_script_type = InputScriptType_SPENDTAPROOT; + break; default: return 0; // failed to compile output } @@ -960,6 +1087,9 @@ uint32_t tx_input_weight(const CoinType* coin, const TxInputType* txinput) { weight += 4; // empty input script } weight += input_script_size; // discounted witness + } else if (txinput->script_type == InputScriptType_SPENDTAPROOT) { + weight += 4; // empty scriptSig length in the non-witness serialization + weight += 2 + TXSIZE_SCHNORR_SIGNATURE; // stack count, item length, sig } return weight; } diff --git a/unittests/crypto/CMakeLists.txt b/unittests/crypto/CMakeLists.txt index 77cec793d..11852713f 100644 --- a/unittests/crypto/CMakeLists.txt +++ b/unittests/crypto/CMakeLists.txt @@ -1,4 +1,5 @@ set(sources + bip340.cpp rand.cpp vuln1845.cpp) diff --git a/unittests/crypto/bip340.cpp b/unittests/crypto/bip340.cpp new file mode 100644 index 000000000..3dbb363c4 --- /dev/null +++ b/unittests/crypto/bip340.cpp @@ -0,0 +1,516 @@ +// Official BIP-340 test vectors, verbatim from +// https://github.com/bitcoin/bips/blob/master/bip-0340/test-vectors.csv +// +// Vectors with a secret key are signed and the signature compared byte for +// byte (BIP-340 signing is deterministic given aux_rand). Every vector, +// with or without a secret key, is run through verification. + +#include + +extern "C" { +#include "trezor/crypto/bip32.h" +#include "trezor/crypto/bip340.h" +#include "trezor/crypto/bip39.h" +#include "trezor/crypto/curves.h" +#include "trezor/crypto/ecdsa.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/segwit_addr.h" +} + +#include "gtest/gtest.h" + +#include +#include +#include +#include + +namespace { + +std::vector unhex(const std::string &s) { + std::vector out; + out.reserve(s.size() / 2); + for (size_t i = 0; i + 1 < s.size(); i += 2) { + out.push_back((uint8_t)std::stoul(s.substr(i, 2), nullptr, 16)); + } + return out; +} + +std::string hex(const uint8_t *p, size_t len) { + static const char *digits = "0123456789ABCDEF"; + std::string out; + for (size_t i = 0; i < len; i++) { + out += digits[p[i] >> 4]; + out += digits[p[i] & 0x0f]; + } + return out; +} + +struct Vector { + int index; + const char *seckey; // empty when the vector is verify-only + const char *pubkey; + const char *aux; + const char *msg; + const char *sig; + bool valid; + const char *comment; +}; + +// 100 bytes of 0x99, the message of vector 18. +const char *kMsg100 = + "9999999999999999999999999999999999999999999999999999999999999999" + "9999999999999999999999999999999999999999999999999999999999999999" + "9999999999999999999999999999999999999999999999999999999999999999" + "99999999"; + +const Vector kVectors[] = { + {0, + "0000000000000000000000000000000000000000000000000000000000000003", + "F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000000", + "E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA8215" + "25F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0", + true, ""}, + {1, + "B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "0000000000000000000000000000000000000000000000000000000000000001", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE3341" + "8906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A", + true, ""}, + {2, + "C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9", + "DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8", + "C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906", + "7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C", + "5831AAEED7B44BB74E5EAB94BA9D4294C49BCF2A60728D8B4C200F50DD313C1B" + "AB745879A5AD954A72C45A91C3A51D3C7ADEA98D82F8481E0E1E03674A6F3FB7", + true, ""}, + {3, + "0B432B2677937381AEF05BB02A66ECD012773062CF3FA2549E44F58ED2401710", + "25D1DFF95105F5253C4022F628A996AD3A0D95FBF21D468A1B33F8C160D8F517", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "7EB0509757E246F19449885651611CB965ECC1A187DD51B64FDA1EDC9637D5EC" + "97582B9CB13DB3933705B32BA982AF5AF25FD78881EBB32771FC5922EFC66EA3", + true, "test fails if msg is reduced modulo p or n"}, + {4, "", + "D69C3509BB99E412E68B0FE8544E72837DFA30746D8BE2AA65975F29D22DC7B9", "", + "4DF3C3F68FCC83B27E9D42C90431A72499F17875C81A599B566C9889B9696703", + "00000000000000000000003B78CE563F89A0ED9414F5AA28AD0D96D6795F9C63" + "76AFB1548AF603B3EB45C9F8207DEE1060CB71C04E80F593060B07D28308D7F4", + true, ""}, + {5, "", + "EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "public key not on the curve"}, + {6, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "FFF97BD5755EEEA420453A14355235D382F6472F8568A18B2F057A1460297556" + "3CC27944640AC607CD107AE10923D9EF7A73C643E166BE5EBEAFA34B1AC553E2", + false, "has_even_y(R) is false"}, + {7, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "1FA62E331EDBC21C394792D2AB1100A7B432B013DF3F6FF4F99FCB33E0E1515F" + "28890B3EDB6E7189B630448B515CE4F8622A954CFE545735AAEA5134FCCDB2BD", + false, "negated message"}, + {8, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "961764B3AA9B2FFCB6EF947B6887A226E8D7C93E00C5ED0C1834FF0D0C2E6DA6", + false, "negated s value"}, + {9, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "0000000000000000000000000000000000000000000000000000000000000000" + "123DDA8328AF9C23A94C1FEECFD123BA4FB73476F0D594DCB65C6425BD186051", + false, "sG - eP is infinite, x(inf) as 0"}, + {10, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "0000000000000000000000000000000000000000000000000000000000000001" + "7615FBAF5AE28864013C099742DEADB4DBA87F11AC6754F93780D5A1837CF197", + false, "sG - eP is infinite, x(inf) as 1"}, + {11, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "4A298DACAE57395A15D0795DDBFD1DCB564DA82B0F269BC70A74F8220429BA1D" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "sig[0:32] is not an X coordinate on the curve"}, + {12, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "sig[0:32] is equal to field size"}, + {13, "", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", + false, "sig[32:64] is equal to curve order"}, + {14, "", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30", "", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "public key exceeds the field size"}, + {15, + "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", "", + "71535DB165ECD9FBBC046E5FFAEA61186BB6AD436732FCCC25291A55895464CF" + "6069CE26BF03466228F19A3A62DB8A649F2D560FAC652827D1AF0574E427AB63", + true, "message of size 0"}, + {16, + "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", "11", + "08A20A0AFEF64124649232E0693C583AB1B9934AE63B4C3511F3AE1134C6A303" + "EA3173BFEA6683BD101FA5AA5DBC1996FE7CACFC5A577D33EC14564CEC2BACBF", + true, "message of size 1"}, + {17, + "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", + "0102030405060708090A0B0C0D0E0F1011", + "5130F39A4059B43BC7CAC09A19ECE52B5D8699D1A71E3C52DA9AFDB6B50AC370" + "C4A482B77BF960F8681540E25B6771ECE1E5A37FD80E5A51897C5566A97EA5A5", + true, "message of size 17"}, + {18, + "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", + kMsg100, + "403B12B0D8555A344175EA7EC746566303321E5DBFA8BE6F091635163ECA79A8" + "585ED3E3170807E7C03B720FC54C7B23897FCBA0E9D0B4A06894CFD249F22367", + true, "message of size 100"}, +}; + +} // namespace + +// Official BIP-86 test vectors, from +// https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki +// (mnemonic "abandon abandon ... about", account m/86'/0'/0'). +// HD derivation is covered at the firmware level; what is pinned here is the +// tweak and the bech32m encoding that turn an internal key into an address. +TEST(BIP340, BIP86Vectors) { + const struct { + const char *path; + const char *internal_key; + const char *output_key; + const char *address; + } vectors[] = { + {"m/86'/0'/0'/0/0", + "cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115", + "a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c", + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"}, + {"m/86'/0'/0'/0/1", + "83dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145", + "a82f29944d65b86ae6b5e5cc75e294ead6c59391a1edc5e016e3498c67fc7bbb", + "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh"}, + {"m/86'/0'/0'/1/0", + "399f1b2f4393f29a18c937859c5dd8a77350103157eb880f02e8c08214277cef", + "882d74e5d0572d5a816cef0041a96b6c1de832f6f9676d9605c44d5e9a97d3dc", + "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7"}, + }; + + for (const auto &v : vectors) { + std::vector internal = unhex(v.internal_key); + uint8_t out[BIP340_XONLY_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, internal.data(), nullptr, out)) + << v.path; + + std::string got = hex(out, sizeof(out)); + std::transform(got.begin(), got.end(), got.begin(), ::tolower); + ASSERT_EQ(std::string(v.output_key), got) << v.path; + + // Witness version 1 + 32 bytes must come out bech32m, i.e. a bc1p address. + char address[MAX_ADDR_SIZE] = {0}; + ASSERT_EQ(1, segwit_addr_encode(address, "bc", 1, out, sizeof(out))) + << v.path; + ASSERT_EQ(std::string(v.address), std::string(address)) << v.path; + } +} + +// The same BIP-86 vectors driven from the mnemonic, so HD derivation and the +// x-only convention are covered too. compute_address() feeds +// node->public_key + 1 to bip340_tweak_pubkey(); this pins that the byte after +// the compressed prefix really is the internal key BIP-86 expects, which an +// off-by-one would otherwise turn into a valid-looking wrong address. +TEST(BIP340, BIP86FromMnemonic) { + const char *mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon about"; + const struct { + uint32_t change; + uint32_t index; + const char *address; + } vectors[] = { + {0, 0, "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"}, + {0, 1, "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh"}, + {1, 0, "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7"}, + }; + + uint8_t seed[64] = {0}; + mnemonic_to_seed(mnemonic, "", seed, nullptr); + + for (const auto &v : vectors) { + HDNode node = {0}; + ASSERT_EQ(1, hdnode_from_seed(seed, sizeof(seed), SECP256K1_NAME, &node)); + // m/86'/0'/0'/change/index + ASSERT_EQ(1, hdnode_private_ckd(&node, 0x80000000 + 86)); + ASSERT_EQ(1, hdnode_private_ckd(&node, 0x80000000 + 0)); + ASSERT_EQ(1, hdnode_private_ckd(&node, 0x80000000 + 0)); + ASSERT_EQ(1, hdnode_private_ckd(&node, v.change)); + ASSERT_EQ(1, hdnode_private_ckd(&node, v.index)); + hdnode_fill_public_key(&node); + + uint8_t out[BIP340_XONLY_LENGTH] = {0}; + ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, node.public_key + 1, nullptr, out)); + + char address[MAX_ADDR_SIZE] = {0}; + ASSERT_EQ(1, segwit_addr_encode(address, "bc", 1, out, sizeof(out))); + ASSERT_EQ(std::string(v.address), std::string(address)) + << "change=" << v.change << " index=" << v.index; + } +} + +// Official BIP-341 key-path spending vector, input index 4, from +// https://github.com/bitcoin/bips/blob/master/bip-0341/wallet-test-vectors.json +// +// This is the only published input that uses SIGHASH_DEFAULT (hashType 0), so +// it is the one that pins our signing configuration end to end. It carries a +// merkle root, which is why bip340_tweak_seckey/pubkey take one -- without it +// there is no published witness to check the sigmsg field ordering against, +// and a transposed field yields a perfectly valid signature over the wrong +// transaction. +namespace bip341 { +const char *kInternalPrivkey = + "f36bb07a11e469ce941d16b63b11b9b9120a84d9d87cff2c84a8d4affb438f4e"; +const char *kInternalPubkey = + "e0dfe2300b0dd746a3f8674dfd4525623639042569d829c7f0eed9602d263e6f"; +const char *kMerkleRoot = + "ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2"; +const char *kTweakedPrivkey = + "a8e7aa924f0d58854185a490e6c41f6efb7b675c0f3331b7f14b549400b4d501"; +const char *kSigHash = + "4f900a0bae3f1446fd48490c2958b5a023228f01661cda3496a11da502a7f7ef"; +const char *kWitness = + "b4010dd48a617db09926f729e79c33ae0b4e94b79f04a1ae93ede6315eb3669d" + "e185a17d2b0ac9ee09fd4c64b678a0b61a0a86fa888a273c8511be83bfd6810f"; +const char *kHashPrevouts = + "e3b33bb4ef3a52ad1fffb555c0d82828eb22737036eaeb02a235d82b909c4c3f"; +const char *kHashAmounts = + "58a6964a4f5f8f0b642ded0a8a553be7622a719da71d1f5befcefcdee8e0fde6"; +const char *kHashScriptPubkeys = + "23ad0f61ad2bca5ba6a7693f50fce988e17c3780bf2b1e720cfbb38fbdd52e21"; +const char *kHashSequences = + "18959c7221ab5ce9e26c3cd67b22c24f8baa54bac281d8e6b05e400e6c3a957e"; +const char *kHashOutputs = + "a2e6dab7c1f0dcd297c8d61647fd17d821541ea69c3cc37dcbad7f90d4eb4bc5"; +const uint32_t kVersion = 2; +const uint32_t kLockTime = 500000000; +const uint32_t kInputIndex = 4; + +std::string lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), ::tolower); + return s; +} +} // namespace bip341 + +TEST(BIP341, TweakSeckey) { + std::vector sk = unhex(bip341::kInternalPrivkey); + std::vector root = unhex(bip341::kMerkleRoot); + uint8_t pk[BIP340_XONLY_LENGTH] = {0}; + uint8_t tweaked[32] = {0}; + + ASSERT_EQ(0, bip340_get_xonly_pubkey(&secp256k1, sk.data(), pk)); + ASSERT_EQ(std::string(bip341::kInternalPubkey), + bip341::lower(hex(pk, sizeof(pk)))); + + ASSERT_EQ(0, bip340_tweak_seckey(&secp256k1, sk.data(), root.data(), + tweaked)); + ASSERT_EQ(std::string(bip341::kTweakedPrivkey), + bip341::lower(hex(tweaked, sizeof(tweaked)))); + + // The tweaked private key must correspond to the tweaked public key, or the + // signature verifies under a key that does not own the output. + uint8_t from_seckey[BIP340_XONLY_LENGTH] = {0}; + uint8_t from_pubkey[BIP340_XONLY_LENGTH] = {0}; + ASSERT_EQ(0, bip340_get_xonly_pubkey(&secp256k1, tweaked, from_seckey)); + ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, pk, root.data(), from_pubkey)); + ASSERT_EQ(0, memcmp(from_seckey, from_pubkey, BIP340_XONLY_LENGTH)); +} + +TEST(BIP341, Sighash) { + std::vector prevouts = unhex(bip341::kHashPrevouts); + std::vector amounts = unhex(bip341::kHashAmounts); + std::vector spks = unhex(bip341::kHashScriptPubkeys); + std::vector seqs = unhex(bip341::kHashSequences); + std::vector outs = unhex(bip341::kHashOutputs); + uint8_t hash[SHA256_DIGEST_LENGTH] = {0}; + + bip341_sighash(/*hash_type=*/0, bip341::kVersion, bip341::kLockTime, + prevouts.data(), amounts.data(), spks.data(), seqs.data(), + outs.data(), bip341::kInputIndex, hash); + + ASSERT_EQ(std::string(bip341::kSigHash), bip341::lower(hex(hash, sizeof(hash)))); +} + +TEST(BIP341, KeyPathSignatureMatchesPublishedWitness) { + std::vector sk = unhex(bip341::kInternalPrivkey); + std::vector root = unhex(bip341::kMerkleRoot); + std::vector sighash = unhex(bip341::kSigHash); + uint8_t tweaked[32] = {0}; + uint8_t sig[BIP340_SIG_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_tweak_seckey(&secp256k1, sk.data(), root.data(), + tweaked)); + // BIP-341's vectors are generated with an all-zero aux_rand. + ASSERT_EQ(0, bip340_sign(&secp256k1, tweaked, sighash.data(), sighash.size(), + nullptr, sig)); + ASSERT_EQ(std::string(bip341::kWitness), bip341::lower(hex(sig, sizeof(sig)))); +} + +TEST(BIP340, TweakRejectsInvalidInternalKey) { + // Vector 5's x coordinate, which is not on the curve. + std::vector bad = + unhex("EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34"); + // And an x coordinate past the field size (vector 14). + std::vector too_big = + unhex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30"); + uint8_t out[BIP340_XONLY_LENGTH] = {0}; + + ASSERT_NE(0, bip340_tweak_pubkey(&secp256k1, bad.data(), nullptr, out)); + ASSERT_NE(0, bip340_tweak_pubkey(&secp256k1, too_big.data(), nullptr, out)); +} + +TEST(BIP340, TaggedHash) { + // tagged_hash("BIP0340/challenge", "") == SHA256(h || h) where + // h = SHA256("BIP0340/challenge"). Pins the double-tag construction. + uint8_t out[SHA256_DIGEST_LENGTH] = {0}; + uint8_t tag_hash[SHA256_DIGEST_LENGTH] = {0}; + uint8_t expected[SHA256_DIGEST_LENGTH] = {0}; + uint8_t doubled[2 * SHA256_DIGEST_LENGTH] = {0}; + + bip340_tagged_hash("BIP0340/challenge", nullptr, 0, out); + + sha256_Raw((const uint8_t *)"BIP0340/challenge", 17, tag_hash); + memcpy(doubled, tag_hash, sizeof(tag_hash)); + memcpy(doubled + sizeof(tag_hash), tag_hash, sizeof(tag_hash)); + sha256_Raw(doubled, sizeof(doubled), expected); + + ASSERT_EQ(0, memcmp(out, expected, sizeof(expected))); +} + +TEST(BIP340, XOnlyPubkey) { + for (const auto &v : kVectors) { + if (v.seckey[0] == '\0') continue; + + std::vector sk = unhex(v.seckey); + uint8_t pk[BIP340_XONLY_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_get_xonly_pubkey(&secp256k1, sk.data(), pk)) + << "vector " << v.index; + ASSERT_EQ(std::string(v.pubkey), hex(pk, sizeof(pk))) + << "vector " << v.index; + } +} + +TEST(BIP340, Sign) { + for (const auto &v : kVectors) { + if (v.seckey[0] == '\0') continue; + + std::vector sk = unhex(v.seckey); + std::vector aux = unhex(v.aux); + std::vector msg = unhex(v.msg); + uint8_t sig[BIP340_SIG_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_sign(&secp256k1, sk.data(), msg.data(), msg.size(), + aux.data(), sig)) + << "vector " << v.index << ": " << v.comment; + ASSERT_EQ(std::string(v.sig), hex(sig, sizeof(sig))) + << "vector " << v.index << ": " << v.comment; + } +} + +TEST(BIP340, Verify) { + for (const auto &v : kVectors) { + std::vector pk = unhex(v.pubkey); + std::vector msg = unhex(v.msg); + std::vector sig = unhex(v.sig); + + int ret = bip340_verify(&secp256k1, pk.data(), msg.data(), msg.size(), + sig.data()); + if (v.valid) { + ASSERT_EQ(0, ret) << "vector " << v.index << ": " << v.comment; + } else { + ASSERT_NE(0, ret) << "vector " << v.index << ": " << v.comment; + } + } +} + +TEST(BIP340, SignRejectsOutOfRangeKeys) { + const uint8_t zero[32] = {0}; + // n, the curve order -- the first scalar that is out of range. + const uint8_t order[32] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, + 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, + 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41}; + const uint8_t msg[32] = {0}; + uint8_t sig[BIP340_SIG_LENGTH] = {0}; + + // Pre-fill, so the zero-on-failure check below cannot pass vacuously. + memset(sig, 0xFF, sizeof(sig)); + + ASSERT_NE(0, bip340_sign(&secp256k1, zero, msg, sizeof(msg), nullptr, sig)); + ASSERT_NE(0, bip340_sign(&secp256k1, order, msg, sizeof(msg), nullptr, sig)); + + // A rejected signing attempt must not leave anything in the output buffer. + uint8_t empty[BIP340_SIG_LENGTH] = {0}; + ASSERT_EQ(0, memcmp(sig, empty, sizeof(sig))); +} + +TEST(BIP340, XOnlyPubkeyZeroesOnFailure) { + const uint8_t zero[32] = {0}; + uint8_t pk[BIP340_XONLY_LENGTH]; + + memset(pk, 0xFF, sizeof(pk)); + ASSERT_NE(0, bip340_get_xonly_pubkey(&secp256k1, zero, pk)); + + uint8_t empty[BIP340_XONLY_LENGTH] = {0}; + ASSERT_EQ(0, memcmp(pk, empty, sizeof(pk))); +} + +TEST(BIP340, ZeroSTakesTheSpecPath) { + // s == 0 is in range per BIP-340 and carries no special guard: verification + // must compute R = -eP and reject on the x-coordinate comparison, not bail + // out early. Pins the absence of a guard that would deviate from the spec. + std::vector pk = unhex(kVectors[1].pubkey); + std::vector msg = unhex(kVectors[1].msg); + std::vector sig = unhex(kVectors[1].sig); + memset(sig.data() + 32, 0, 32); + + ASSERT_NE(0, bip340_verify(&secp256k1, pk.data(), msg.data(), msg.size(), + sig.data())); +} + +TEST(BIP340, NullAuxMatchesZeroAux) { + std::vector sk = unhex(kVectors[0].seckey); + std::vector msg = unhex(kVectors[0].msg); + uint8_t with_null[BIP340_SIG_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_sign(&secp256k1, sk.data(), msg.data(), msg.size(), + nullptr, with_null)); + // Vector 0 uses an all-zero aux_rand, so NULL must reproduce it exactly. + ASSERT_EQ(std::string(kVectors[0].sig), hex(with_null, sizeof(with_null))); +} diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 03cab2447..ece082101 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -16,7 +16,9 @@ set(sources signed_metadata.cpp ripple.cpp solana.cpp + signing.cpp storage.cpp + transaction.cpp usb_rx.cpp thorchain.cpp tron.cpp diff --git a/unittests/firmware/coins.cpp b/unittests/firmware/coins.cpp index c11a59bd6..5f4a5f631 100644 --- a/unittests/firmware/coins.cpp +++ b/unittests/firmware/coins.cpp @@ -75,6 +75,17 @@ TEST(Coins, TableSanity) { if (!coin.has_contract_address) continue; + // Pre-existing (not 7.x-release related): these legacy coins[] entries are + // display-only leftovers whose ERC20 entries were dropped from the generated + // token table years ago (dead/migrated tokens). Named allowlist so a *new* + // missing token still fails this sanity check. + static const char *const kLegacyNoTokenEntry[] = { + "QTUM", "BNB", "ZIL", "GTO", "IOST", "CMT", "MCO", "ODEM"}; + bool legacy = false; + for (const char *t : kLegacyNoTokenEntry) + if (strcmp(coin.coin_shortcut, t) == 0) { legacy = true; break; } + if (legacy) continue; + const TokenType *token; if (!tokenByTicker(1, coin.coin_shortcut, &token)) { EXPECT_TRUE(false) << "Can't uniquely find " << coin.coin_shortcut; diff --git a/unittests/firmware/signing.cpp b/unittests/firmware/signing.cpp new file mode 100644 index 000000000..f178ab876 --- /dev/null +++ b/unittests/firmware/signing.cpp @@ -0,0 +1,48 @@ +extern "C" { +#include "keepkey/firmware/signing.h" +} + +#include "gtest/gtest.h" + +namespace { + +constexpr uint32_t H(uint32_t i) { return 0x80000000 | i; } + +// m/'/0'/0'/1/0 -- a first change address in the first account. +struct ChangePath { + uint32_t n[5]; + explicit ChangePath(uint32_t purpose) : n{H(purpose), H(0), H(0), 1, 0} {} +}; + +bool Forbidden(uint32_t in_purpose, uint32_t out_purpose, + OutputScriptType out_script_type) { + ChangePath in(in_purpose), out(out_purpose); + return isCrossAccountSegwitChangeForbidden(in.n, 5, out.n, 5, + out_script_type); +} + +} // namespace + +// Regression: a BIP86 change path paired with any non-taproot script type used +// to fall through to the generic path check, which accepted it as change. That +// suppressed the output confirmation screen while serializing the change to a +// script no BIP86 wallet ever scans for. +TEST(Signing, TaprootChangeMustUseTaprootScriptType) { + EXPECT_TRUE(Forbidden(86, 86, OutputScriptType_PAYTOADDRESS)); + EXPECT_TRUE(Forbidden(86, 86, OutputScriptType_PAYTOWITNESS)); + EXPECT_TRUE(Forbidden(86, 86, OutputScriptType_PAYTOP2SHWITNESS)); +} + +TEST(Signing, MatchedPurposeAndScriptTypeAreAllowed) { + EXPECT_FALSE(Forbidden(86, 86, OutputScriptType_PAYTOTAPROOT)); + EXPECT_FALSE(Forbidden(44, 44, OutputScriptType_PAYTOADDRESS)); + EXPECT_FALSE(Forbidden(49, 49, OutputScriptType_PAYTOP2SHWITNESS)); + EXPECT_FALSE(Forbidden(84, 84, OutputScriptType_PAYTOWITNESS)); +} + +// The pre-taproot direction of the same rule, kept honest by this test. +TEST(Signing, LegacyChangeMayNotClaimTaprootScriptType) { + EXPECT_TRUE(Forbidden(44, 44, OutputScriptType_PAYTOTAPROOT)); + EXPECT_TRUE(Forbidden(49, 49, OutputScriptType_PAYTOTAPROOT)); + EXPECT_TRUE(Forbidden(84, 84, OutputScriptType_PAYTOTAPROOT)); +} diff --git a/unittests/firmware/transaction.cpp b/unittests/firmware/transaction.cpp new file mode 100644 index 000000000..1946e3694 --- /dev/null +++ b/unittests/firmware/transaction.cpp @@ -0,0 +1,15 @@ +extern "C" { +#include "keepkey/firmware/transaction.h" +} + +#include "gtest/gtest.h" + +TEST(Transaction, TaprootInputWeightIncludesWitness) { + CoinType coin = CoinType_init_zero; + TxInputType input = TxInputType_init_zero; + input.script_type = InputScriptType_SPENDTAPROOT; + + // 41 non-witness bytes * 4 plus a one-item witness containing the fixed + // 64-byte SIGHASH_DEFAULT Schnorr signature. + ASSERT_EQ(230U, tx_input_weight(&coin, &input)); +} From e0e9a9be1794a6f814804c442524ff0e334977a9 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 13:00:46 -0300 Subject: [PATCH 22/40] build(variants): gate the coin engines behind the bitcoin-only build Turns the KK_BITCOIN_ONLY flag from a build option into an image that actually strips what it claims to: - Coin engines (Ethereum and its contract decoders, Solana, Hive, TRON, TON, Ripple, EOS, the Tendermint family, Nano) and the Zcash privacy engine are excluded from the bitcoin-only sources, together with their message-map rows, handler includes and unit-test suites. - The always-on Initialize/ClearSession/Cancel handlers still call each engine's abort hook and factory reset still clears clear-sign signers, so bitcoin-only provides no-op definitions rather than conditionally editing those handlers. - GetFeatures reports the established KeepKeyBTC / EmulatorBTC variant names so existing hosts skip multi-chain-only behaviour, and reports bitcoin-only-locked when the wallet on the device was created by bitcoin-only firmware and this image must not touch it. - ChangePin, ChangeWipeCode, LoadDevice and ResetDevice refuse to run against a bitcoin-only-locked wallet. - GetCoinTable omits the token table in the bitcoin-only image. - Guards use value macros compared with `#if FLAG`, because device builds compile with -Wundef -Werror and an undefined identifier there is a hard error, not a silent zero. --- deps/crypto/CMakeLists.txt | 2 +- lib/firmware/CMakeLists.txt | 61 ++++++++++++++------------- lib/firmware/fsm.c | 29 +++++++++++-- lib/firmware/fsm_msg_coin.h | 24 +++++++++-- lib/firmware/fsm_msg_common.h | 70 ++++++++++++++++++++++++++++++- lib/firmware/fsm_msg_crypto.h | 36 +++++++++++----- lib/firmware/fsm_msg_debug.h | 3 ++ lib/firmware/messagemap.def | 9 +++- unittests/crypto/CMakeLists.txt | 2 +- unittests/firmware/CMakeLists.txt | 42 +++++++++++-------- 10 files changed, 211 insertions(+), 67 deletions(-) diff --git a/deps/crypto/CMakeLists.txt b/deps/crypto/CMakeLists.txt index 0851894da..4a1d9d880 100644 --- a/deps/crypto/CMakeLists.txt +++ b/deps/crypto/CMakeLists.txt @@ -59,7 +59,7 @@ set(sources trezor-firmware/crypto/aes/aestab.c) # Pallas/Orchard curve arithmetic (~2.4k LOC) -- only the Zcash shielded engine -# uses it. Excluded from the bitcoin-only image. +# uses it. Excluded from the default and bitcoin-only images. if(${KK_ZCASH_PRIVACY}) list(APPEND sources trezor-firmware/crypto/pallas.c diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index ff10b335b..7540ca629 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -2,52 +2,57 @@ set(sources app_confirm.c app_layout.c authenticator.c - binance.c bip85.c coins.c crypto.c - eip712.c - eos.c - eos-contracts/eosio.system.c - eos-contracts/eosio.token.c - ethereum.c - ethereum_contracts.c - ethereum_contracts/makerdao.c - ethereum_contracts/saproxy.c - ethereum_contracts/zxappliquid.c - ethereum_contracts/thortx.c - ethereum_contracts/zxliquidtx.c - ethereum_contracts/zxtransERC20.c - ethereum_contracts/zxswap.c - ethereum_tokens.c dice_input.c fsm.c - hive.c home_sm.c - mayachain.c - nano.c - osmosis.c passphrase_sm.c pin_sm.c policy.c recovery_cipher.c reset.c - ripple.c - ripple_base58.c - signed_metadata.c signing.c - signtx_tendermint.c - solana.c storage.c - tron.c - ton.c - tendermint.c - thorchain.c tiny-json.c transaction.c txin_check.c u2f.c) +# Non-Bitcoin coin families -- excluded from the bitcoin-only image. +if(NOT ${KK_BITCOIN_ONLY}) + list(APPEND sources + binance.c + eip712.c + eos.c + eos-contracts/eosio.system.c + eos-contracts/eosio.token.c + ethereum.c + ethereum_contracts.c + ethereum_contracts/makerdao.c + ethereum_contracts/saproxy.c + ethereum_contracts/zxappliquid.c + ethereum_contracts/thortx.c + ethereum_contracts/zxliquidtx.c + ethereum_contracts/zxtransERC20.c + ethereum_contracts/zxswap.c + ethereum_tokens.c + signed_metadata.c + mayachain.c + nano.c + osmosis.c + ripple.c + ripple_base58.c + signtx_tendermint.c + solana.c + hive.c + tron.c + ton.c + tendermint.c + thorchain.c) +endif() + # Zcash shielded/Orchard engine -- transparent Zcash needs none of this. if(${KK_ZCASH_PRIVACY}) list(APPEND sources zcash.c) diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index 93d639564..e806f8454 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -117,6 +117,15 @@ static uint8_t msg_resp[MAX_FRAME_SIZE] __attribute__((aligned(4))); return; \ } +#define CHECK_NOT_BTC_ONLY_LOCKED \ + if (storage_isBitcoinOnlyLocked()) { \ + fsm_sendFailure(FailureType_Failure_Other, \ + "Device holds a bitcoin-only wallet. Wipe the " \ + "device to use multi-chain firmware."); \ + layoutHome(); \ + return; \ + } + #define CHECK_PIN \ if (!pin_protect_cached()) { \ layoutHome(); \ @@ -283,13 +292,16 @@ void fsm_msgClearSession(ClearSession* msg) { fsm_sendSuccess("Session cleared"); } +// Always-on handlers: Bitcoin/common (fsm_msg_coin), CipherKeyValue/identity +// (fsm_msg_crypto), debug-link, and BIP85 -- none are coin engines. #include "fsm_msg_common.h" #include "fsm_msg_coin.h" -#include "fsm_msg_ethereum.h" -#include "fsm_msg_nano.h" #include "fsm_msg_crypto.h" #include "fsm_msg_debug.h" #include "fsm_msg_bip85.h" +#if !BITCOIN_ONLY +#include "fsm_msg_ethereum.h" +#include "fsm_msg_nano.h" #include "fsm_msg_eos.h" #include "fsm_msg_cosmos.h" #include "fsm_msg_osmosis.h" @@ -304,11 +316,22 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_hive.h" /* After fsm_msg_solana.h: reuses its base58 helper and the KKSOLSC1 parser. */ #include "fsm_msg_clearsign_attestor.h" +#else +// Bitcoin-only: the coin engines above are compiled out, but the always-on +// Initialize/ClearSession/Cancel handlers still call their *_abort() hooks, +// and factory-reset calls signed_metadata_clear_signers() (EVM clearsign). +// With no state to reset, no-ops are correct. +void ethereum_signing_abort(void) {} +void tendermint_signAbort(void) {} +void eos_signingAbort(void) {} +void signed_metadata_clear_signers(void) {} +#endif // !BITCOIN_ONLY #if ZCASH_PRIVACY #include "fsm_msg_zcash.h" #else // Zcash shielded/Orchard engine compiled out. The always-on // Initialize/ClearSession/Cancel handlers still call zcash_signing_abort(); -// with no privacy state to reset, a no-op is correct. +// with no privacy state to reset, a no-op is correct. (Bitcoin-only forces +// privacy off, so this stub also covers the bitcoin-only image.) void zcash_signing_abort(void) {} #endif diff --git a/lib/firmware/fsm_msg_coin.h b/lib/firmware/fsm_msg_coin.h index 8b7267cd3..d1b002a1d 100644 --- a/lib/firmware/fsm_msg_coin.h +++ b/lib/firmware/fsm_msg_coin.h @@ -188,6 +188,21 @@ static bool path_mismatched(const CoinType* coin, const GetAddress* msg) { return mismatch; } + // m/86' : BIP86 Taproot + // m / purpose' / bip44_account_path' / account' / change / address_index + if (msg->address_n[0] == (0x80000000 + 86)) { + mismatch |= (msg->script_type != InputScriptType_SPENDTAPROOT); + mismatch |= !coin->has_segwit || !coin->segwit; + mismatch |= !coin->has_bech32_prefix; + mismatch |= !coin->has_taproot || !coin->taproot; + mismatch |= (msg->address_n_count != 5); + mismatch |= (msg->address_n[1] != coin->bip44_account_path); + mismatch |= (msg->address_n[2] & 0x80000000) == 0; + mismatch |= (msg->address_n[3] & 0x80000000) == 0x80000000; + mismatch |= (msg->address_n[4] & 0x80000000) == 0x80000000; + return mismatch; + } + return false; } @@ -270,8 +285,8 @@ void fsm_msgSignMessage(SignMessage* msg) { CHECK_INITIALIZED - if (!confirm(ButtonRequestType_ButtonRequest_SignMessage, "Sign Message", - "%s", (char*)msg->message.bytes)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_SignMessage, + "Sign Message", msg->message.bytes, msg->message.size)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, "Sign message cancelled"); layoutHome(); @@ -325,8 +340,9 @@ void fsm_msgVerifyMessage(VerifyMessage* msg) { layoutHome(); return; } - if (!review(ButtonRequestType_ButtonRequest_Other, "Message Verified", "%s", - (char*)msg->message.bytes)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Message Verified", msg->message.bytes, + msg->message.size)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Action cancelled by user")); layoutHome(); diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 01b3792b9..b67964ef1 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -49,8 +49,53 @@ void fsm_msgGetFeatures(GetFeatures* msg) { /* Variant Name */ resp->has_firmware_variant = true; - strlcpy(resp->firmware_variant, variant_getName(), - sizeof(resp->firmware_variant)); +#if BITCOIN_ONLY + /* Bitcoin-only build. Uses the established KeepKeyBTC / EmulatorBTC names so + existing clients (python-keepkey requires_fullFeature, etc.) skip + multi-chain-only behaviour and never offer multi-chain firmware. The lock + sentinel is reachable here too: a NEWER bitcoin-only wallet than this + firmware understands refuses to load (storage_isBitcoinOnlyLocked), and + hosts need the same signal the other builds emit. */ + if (storage_isBitcoinOnlyLocked()) { + strlcpy(resp->firmware_variant, "bitcoin-only-locked", + sizeof(resp->firmware_variant)); + } else { +#ifdef EMULATOR + strlcpy(resp->firmware_variant, "EmulatorBTC", + sizeof(resp->firmware_variant)); +#else + strlcpy(resp->firmware_variant, "KeepKeyBTC", + sizeof(resp->firmware_variant)); +#endif + } +#elif ZCASH_PRIVACY + /* Zcash/Orchard privacy build. Distinct variant name so hosts can gate + variant-partitioned features — the clearsign session icon cache is + compiled out of this build to fit SRAM (identities still work; persistent + identity icons still render from storage). */ + if (storage_isBitcoinOnlyLocked()) { + strlcpy(resp->firmware_variant, "bitcoin-only-locked", + sizeof(resp->firmware_variant)); + } else { +#ifdef EMULATOR + strlcpy(resp->firmware_variant, "EmulatorZcash", + sizeof(resp->firmware_variant)); +#else + strlcpy(resp->firmware_variant, "KeepKeyZcash", + sizeof(resp->firmware_variant)); +#endif + } +#else + if (storage_isBitcoinOnlyLocked()) { + /* Multi-chain firmware refusing to touch a bitcoin-only wallet; a wipe + is required before this device can be used. */ + strlcpy(resp->firmware_variant, "bitcoin-only-locked", + sizeof(resp->firmware_variant)); + } else { + strlcpy(resp->firmware_variant, variant_getName(), + sizeof(resp->firmware_variant)); + } +#endif /* Security settings */ resp->has_pin_protection = true; @@ -157,8 +202,10 @@ void fsm_msgGetCoinTable(GetCoinTable* msg) { for (size_t i = 0; i < msg->end - msg->start; i++) { if (msg->start + i < COINS_COUNT) { resp->table[i] = coins[msg->start + i]; +#if !BITCOIN_ONLY } else if (msg->start + i - COINS_COUNT < TOKENS_COUNT) { coinFromToken(&resp->table[i], &tokens[msg->start + i - COINS_COUNT]); +#endif } } } @@ -334,6 +381,7 @@ void fsm_msgPing(Ping* msg) { } void fsm_msgChangePin(ChangePin* msg) { + CHECK_NOT_BTC_ONLY_LOCKED bool removal = msg->has_remove && msg->remove; bool confirmed = false; @@ -384,6 +432,7 @@ void fsm_msgChangePin(ChangePin* msg) { } void fsm_msgChangeWipeCode(ChangeWipeCode* msg) { + CHECK_NOT_BTC_ONLY_LOCKED bool removal = msg->has_remove && msg->remove; bool confirmed = false; @@ -616,6 +665,7 @@ void fsm_msgGetEntropy(GetEntropy* msg) { } void fsm_msgLoadDevice(LoadDevice* msg) { + CHECK_NOT_BTC_ONLY_LOCKED CHECK_NOT_INITIALIZED if (!confirm_load_device(msg->has_node)) { @@ -644,6 +694,7 @@ void fsm_msgLoadDevice(LoadDevice* msg) { } void fsm_msgResetDevice(ResetDevice* msg) { + CHECK_NOT_BTC_ONLY_LOCKED CHECK_NOT_INITIALIZED // display_random remains in the wire schema for host compatibility, but is @@ -676,10 +727,12 @@ void fsm_msgCancel(Cancel* msg) { ethereum_signing_abort(); tendermint_signAbort(); eos_signingAbort(); + zcash_signing_abort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, "Aborted"); } void fsm_msgApplySettings(ApplySettings* msg) { + CHECK_NOT_BTC_ONLY_LOCKED if (msg->has_label) { if (!confirm(ButtonRequestType_ButtonRequest_ChangeLabel, "Change Label", "Do you want to change the label to \"%s\"?", msg->label)) { @@ -770,6 +823,7 @@ void fsm_msgApplySettings(ApplySettings* msg) { } void fsm_msgRecoveryDevice(RecoveryDevice* msg) { + CHECK_NOT_BTC_ONLY_LOCKED if (msg->has_dry_run && msg->dry_run) { CHECK_INITIALIZED } else { @@ -800,6 +854,7 @@ void fsm_msgCharacterAck(CharacterAck* msg) { } void fsm_msgApplyPolicies(ApplyPolicies* msg) { + CHECK_NOT_BTC_ONLY_LOCKED CHECK_PARAM(msg->policy_count > 0, "No policies provided"); for (size_t i = 0; i < msg->policy_count; ++i) { @@ -851,6 +906,17 @@ void fsm_msgApplyPolicies(ApplyPolicies* msg) { storage_commit(); + /* Runtime clearsign identities are an AdvancedMode capability. Revoking the + * policy also revokes every RAM-only signer immediately, so toggling the + * policy off cannot leave a previously approved trust anchor active. */ + for (size_t i = 0; i < msg->policy_count; ++i) { + if (strcmp(msg->policy[i].policy_name, "AdvancedMode") == 0 && + !msg->policy[i].enabled) { + signed_metadata_clear_signers(); + break; + } + } + fsm_sendSuccess("Policies applied"); layoutHome(); } diff --git a/lib/firmware/fsm_msg_crypto.h b/lib/firmware/fsm_msg_crypto.h index 838b6bd05..309ac487a 100644 --- a/lib/firmware/fsm_msg_crypto.h +++ b/lib/firmware/fsm_msg_crypto.h @@ -63,9 +63,31 @@ void fsm_msgSignIdentity(SignIdentity* msg) { CHECK_INITIALIZED - if (!confirm_sign_identity(&(msg->identity), msg->has_challenge_visual - ? msg->challenge_visual - : 0)) { + CHECK_PARAM(msg->has_identity, "Invalid identity"); + + const bool sign_ssh = + msg->identity.has_proto && strcmp(msg->identity.proto, "ssh") == 0; + const bool sign_gpg = + msg->identity.has_proto && strcmp(msg->identity.proto, "gpg") == 0; + + /* SSH/GPG sign only challenge_hidden. The legacy confirmation displayed + * challenge_visual instead, allowing a host to show benign text while the + * device signed unrelated bytes. Generic identity signatures bind both + * challenges, so review both there; SSH/GPG review only the actual signed + * payload and never present the unsigned visual field as authoritative. */ + if (!confirm_sign_identity(&msg->identity, NULL) || + ((!sign_ssh && !sign_gpg) && + !confirm_bytes( + ButtonRequestType_ButtonRequest_SignIdentity, "Visual Challenge", + (const uint8_t*)msg->challenge_visual, + msg->has_challenge_visual ? strlen(msg->challenge_visual) : 0)) || + !confirm_bytes( + ButtonRequestType_ButtonRequest_SignIdentity, + sign_ssh ? "Signed SSH Challenge" + : sign_gpg ? "Signed GPG Digest" + : "Hidden Challenge", + msg->challenge_hidden.bytes, + msg->has_challenge_hidden ? msg->challenge_hidden.size : 0)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, "Sign identity cancelled"); layoutHome(); @@ -75,8 +97,7 @@ void fsm_msgSignIdentity(SignIdentity* msg) { CHECK_PIN uint8_t hash[32]; - if (!msg->has_identity || - cryptoIdentityFingerprint(&(msg->identity), hash) == 0) { + if (cryptoIdentityFingerprint(&(msg->identity), hash) == 0) { fsm_sendFailure(FailureType_Failure_Other, "Invalid identity"); layoutHome(); return; @@ -102,11 +123,6 @@ void fsm_msgSignIdentity(SignIdentity* msg) { return; } - bool sign_ssh = - msg->identity.has_proto && (strcmp(msg->identity.proto, "ssh") == 0); - bool sign_gpg = - msg->identity.has_proto && (strcmp(msg->identity.proto, "gpg") == 0); - int result = 0; layout_simple_message("Signing Identity..."); diff --git a/lib/firmware/fsm_msg_debug.h b/lib/firmware/fsm_msg_debug.h index 3a1635c9d..8cd7e564c 100644 --- a/lib/firmware/fsm_msg_debug.h +++ b/lib/firmware/fsm_msg_debug.h @@ -17,6 +17,9 @@ void fsm_msgDebugLinkGetState(DebugLinkGetState* msg) { resp->has_reset_word = true; strlcpy(resp->reset_word, reset_get_word(), sizeof(resp->reset_word)); + resp->dice_digest.size = reset_get_dice_digest(resp->dice_digest.bytes); + resp->has_dice_digest = resp->dice_digest.size > 0; + if (storage_hasMnemonic()) { resp->has_mnemonic = true; strlcpy(resp->mnemonic, storage_getMnemonic(), sizeof(resp->mnemonic)); diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 3fe884809..8b0b941ac 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -33,6 +33,7 @@ MSG_IN(MessageType_MessageType_RecoveryDevice, RecoveryDevice, fsm_msgRecoveryDevice) MSG_IN(MessageType_MessageType_CharacterAck, CharacterAck, fsm_msgCharacterAck) MSG_IN(MessageType_MessageType_ApplyPolicies, ApplyPolicies, fsm_msgApplyPolicies) +#if !BITCOIN_ONLY MSG_IN(MessageType_MessageType_EthereumGetAddress, EthereumGetAddress, fsm_msgEthereumGetAddress) MSG_IN(MessageType_MessageType_EthereumSignTx, EthereumSignTx, fsm_msgEthereumSignTx) MSG_IN(MessageType_MessageType_EthereumTxAck, EthereumTxAck, fsm_msgEthereumTxAck) @@ -72,6 +73,7 @@ MSG_IN(MessageType_MessageType_MayachainGetAddress, MayachainGetAddress, fsm_msgMayachainGetAddress) MSG_IN(MessageType_MessageType_MayachainSignTx, MayachainSignTx, fsm_msgMayachainSignTx) MSG_IN(MessageType_MessageType_MayachainMsgAck, MayachainMsgAck, fsm_msgMayachainMsgAck) +#endif // !BITCOIN_ONLY MSG_IN(MessageType_MessageType_GetBip85Mnemonic, GetBip85Mnemonic, fsm_msgGetBip85Mnemonic) @@ -97,6 +99,7 @@ MSG_OUT(MessageType_MessageType_PassphraseRequest, PassphraseRequest, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_WordRequest, WordRequest, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_CharacterRequest, CharacterRequest, NO_PROCESS_FUNC) +#if !BITCOIN_ONLY MSG_OUT(MessageType_MessageType_EthereumAddress, EthereumAddress, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_EthereumTxRequest, EthereumTxRequest, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_EthereumMessageSignature, EthereumMessageSignature, NO_PROCESS_FUNC) @@ -165,6 +168,7 @@ MSG_OUT(MessageType_MessageType_SolanaSignedTx, SolanaSignedTx, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_SolanaMessageSignature, SolanaMessageSignature, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_SolanaOffchainMessageSignature, SolanaOffchainMessageSignature, NO_PROCESS_FUNC) +#endif // !BITCOIN_ONLY /* Zcash shielded/Orchard (privacy engine). Transparent t-address Zcash uses the generic SignTx/GetAddress rows above and needs none of these. */ @@ -183,7 +187,7 @@ MSG_OUT(MessageType_MessageType_ZcashTransparentAck, ZcashTransparentAck, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_ZcashAddress, ZcashAddress, NO_PROCESS_FUNC) #endif - +#if !BITCOIN_ONLY /* Hive */ MSG_IN(MessageType_MessageType_HiveGetPublicKey, HiveGetPublicKey, fsm_msgHiveGetPublicKey) MSG_IN(MessageType_MessageType_HiveGetPublicKeys, HiveGetPublicKeys, fsm_msgHiveGetPublicKeys) @@ -200,6 +204,7 @@ MSG_OUT(MessageType_MessageType_HiveSignedAccountUpdate, HiveSignedAccountUpdate, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_HiveSignedMessage, HiveSignedMessage, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_HiveSignedOperations, HiveSignedOperations, NO_PROCESS_FUNC) +#endif // !BITCOIN_ONLY #if DEBUG_LINK /* Debug Messages */ @@ -216,6 +221,7 @@ DEBUG_OUT(MessageType_MessageType_DebugLinkFlashDumpResponse, DebugLinkFlashDumpResponse, NO_PROCESS_FUNC) #endif +#if !BITCOIN_ONLY MSG_IN(MessageType_MessageType_EthereumTxMetadata, EthereumTxMetadata, fsm_msgEthereumTxMetadata) MSG_OUT(MessageType_MessageType_EthereumMetadataAck, EthereumMetadataAck, NO_PROCESS_FUNC) MSG_IN(MessageType_MessageType_LoadClearsignSigner, LoadClearsignSigner, fsm_msgLoadClearsignSigner) @@ -223,3 +229,4 @@ MSG_OUT(MessageType_MessageType_ClearsignAttestorPublicKey, ClearsignAttestorPublicKey, NO_PROCESS_FUNC) MSG_IN(MessageType_MessageType_ClearsignAttestorSign, ClearsignAttestorSign, fsm_msgClearsignAttestorSign) MSG_OUT(MessageType_MessageType_ClearsignAttestorSignature, ClearsignAttestorSignature, NO_PROCESS_FUNC) +#endif // !BITCOIN_ONLY diff --git a/unittests/crypto/CMakeLists.txt b/unittests/crypto/CMakeLists.txt index 11852713f..6b2ace1ec 100644 --- a/unittests/crypto/CMakeLists.txt +++ b/unittests/crypto/CMakeLists.txt @@ -18,9 +18,9 @@ target_link_libraries(crypto-unit kkvariant.keepkey kkvariant.salt kkboard + kkrand kkemulator qrcodegenerator - kkrand trezorcrypto kktransport) diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index ece082101..a63fa114e 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -1,29 +1,37 @@ set(sources - authenticator.cpp app_confirm.cpp - coins.cpp - binance.cpp - cosmos.cpp dice.cpp - eos.cpp - eip712.cpp - ethereum.cpp - hive.cpp - mayachain.cpp - nano.cpp - osmosis.cpp recovery.cpp - signed_metadata.cpp - ripple.cpp - solana.cpp signing.cpp storage.cpp transaction.cpp usb_rx.cpp - thorchain.cpp - tron.cpp u2f.cpp) +# Coin/token unit tests exercise handlers/helpers that are compiled out of the +# bitcoin-only firmware (ethereum_address_checksum, tokenByTicker, ...), so they +# only build against a full-feature firmware. coins.cpp asserts the multi-chain +# coin+token table. +if(NOT ${KK_BITCOIN_ONLY}) + list(APPEND sources + authenticator.cpp + binance.cpp + coins.cpp + cosmos.cpp + eip712.cpp + eos.cpp + ethereum.cpp + hive.cpp + mayachain.cpp + nano.cpp + osmosis.cpp + ripple.cpp + signed_metadata.cpp + solana.cpp + thorchain.cpp + tron.cpp) +endif() + # zcash.cpp exercises the Orchard engine (lib/firmware/zcash.c), which is only # compiled into kkfirmware when the privacy flag is on. if(${KK_ZCASH_PRIVACY}) @@ -46,9 +54,9 @@ target_link_libraries(firmware-unit kkvariant.keepkey kkvariant.salt kkboard - kkemulator trezorcrypto qrcodegenerator SecAESSTM32 kkrand + kkemulator kktransport) From cc0f2ab60b3e7af7df2351d98451b2c28f20c037 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 13:00:47 -0300 Subject: [PATCH 23/40] docs(security): record the 7.15 hardening waves and release shape - 7.15.0-rc17-hardening.md: what the RC17 review found and how each finding was closed. - 7.15.0-rc18-release-shape.md: why the release is two images (regular with Zcash, bitcoin-only) rather than three, and what that means for audit scope. - next-wave-hardening.md: the hardening work deliberately NOT in 7.15, with the reasoning, so the next cycle starts from a written list instead of memory. Documentation only. --- docs/security/7.15.0-rc17-hardening.md | 294 +++++++++++++++++++++ docs/security/7.15.0-rc18-release-shape.md | 59 +++++ docs/security/next-wave-hardening.md | 153 +++++++++++ 3 files changed, 506 insertions(+) create mode 100644 docs/security/7.15.0-rc17-hardening.md create mode 100644 docs/security/7.15.0-rc18-release-shape.md create mode 100644 docs/security/next-wave-hardening.md diff --git a/docs/security/7.15.0-rc17-hardening.md b/docs/security/7.15.0-rc17-hardening.md new file mode 100644 index 000000000..08c4606ec --- /dev/null +++ b/docs/security/7.15.0-rc17-hardening.md @@ -0,0 +1,294 @@ +# Firmware 7.15.0 RC17 security hardening + +Date: 2026-07-22 +Integration baseline: `develop` at +`d18571307a2f5bad3ec2949c7b27fd06e6914a8c` (RC16 firmware code plus +test-client submodule updates) + +This candidate applies focused hardening passes to security-sensitive parsing, +confirmation, clear-signing, and signing-policy paths. It includes remediation +for three follow-up reviews, including two full-tree static reachability +passes. The `rc17` suffix is a release-candidate branch/tag; the firmware +protocol version remains `7.15.0`. + +The release decision remains **NO-GO** until the pull-request CI matrix is green +and an explicit release decision is made. The physical-device smoke gate below +is complete. Automated evidence, including the generated Python report, is the +authoritative exhaustive screen and boundary review; physical QA is intentionally +limited to major device smoke tests. + +Qualified candidate head: `f746c52b2c0e5584fde9df04471eeb36ebbbf03b`. +Its pull-request CI matrix completed successfully in GitHub Actions run +`29980175333` on 2026-07-23. + +## Follow-up audit block + +The original candidate head +`f7eacc3862fd391bd9e9421474efeaba464d1334` is release-blocked. A follow-up +review found five P1 release blockers, four P2 defects, and one P3 cleanup +defect. It must not be merged, promoted, tagged, or installed for final QA. +Only a later head containing the remediations below may be reconsidered after +its complete CI matrix and physical-device checks pass. + +| Finding | Severity | Remediation | +| --- | --- | --- | +| Hive `custom_json` could wrap below the visible body | P1 | Select every page boundary with the renderer's actual font, width, word-wrap, and three-row limit | +| Uniswap deadline displayed only the low 64 bits | P1 | Reject a deadline with any nonzero upper 192 bits before clear-signing | +| Arbitrary contracts could masquerade as LP approvals | P1 | Require the transaction target to equal a mainnet factory-derived WETH pair for a known token | +| Liquidity recognition was not chain-bound | P1 | Require an explicit Ethereum mainnet chain ID for liquidity and LP approval recognition | +| `removeLiquidityETH` mislabeled LP liquidity as the underlying token | P1 | Parse add/remove independently and format remove word 1 as an 18-decimal LP amount | +| `bn_format()` overflow produced a blank amount | P2 | Use 96-byte amount buffers, check every formatter return, and require the result to fit three rendered rows | +| ABI address words accepted nonzero upper bytes | P2 | Require canonical zero-padded token, recipient, and spender address words | +| Duplicate authenticator identities survived deletion | P2 | Reject new duplicates and remove every legacy matching slot in one confirmed mutation | +| Brute-forceable short TOTP secrets were accepted | P2 | Require at least 16 decoded secret bytes | +| Partial TOTP decode output survived a decode error | P3 | Route every post-decode exit through one `memzero()` cleanup path | + +## Second full-tree re-audit block + +The remediated first-pass head +`9f4920ef144ed3a1e28a04a0621d2f21e48674b3` is also release-blocked. A +full-tree static reachability review found ten additional P1 release blockers. +They are separate from the ten findings above and cover signer memory safety, +display/signature binding, and blind-signing policy enforcement. + +| Finding | Severity | Remediation | +| --- | --- | --- | +| Binance denomination stack overwrite | P1 | Validate the effective 31-character schema bound and grammar, use a correctly sized destination, check formatting, and fail closed on malformed transfer/session state | +| Bitcoin-family `SignMessage` NUL/OOB display mismatch | P1 | Review the explicit protobuf byte length; render complete printable payloads and complete hex for binary payloads | +| Ethereum `SignMessage` hidden suffix | P1 | Remove the 114-character/57-byte preview and page every signed byte | +| TRON `SignMessage` hidden suffix | P1 | Remove the capped preview and page every signed byte | +| Ethereum typed-hash AdvancedMode bypass | P1 | Reject precomputed typed-hash signing unless AdvancedMode is enabled | +| EIP-712 display not bound to canonical values | P1 | Page every field/value and primary type; require canonical address, bytes, integer, boolean, and JSON node shapes before encoding | +| EOS unknown-action AdvancedMode bypass | P1 | Abort opaque action signing when AdvancedMode is disabled and keep `eosio::newaccount` on its structured path | +| Cosmos IBC receiver omitted from review | P1 | Require and page the exact receiver and sender, require the signed `uatom` denomination, and retain channel/port/timeout review | +| Tendermint ACK could substitute asset/protocol fields | P1 | Bind every ACK to the initialized protocol, chain name, denomination, and message-type prefix and review the exact signed configuration | +| Binance/Tendermint/Cosmos/Osmosis memo suffixes hidden | P1 | Route all four through one renderer-measured exact-length pager | + +## Third full-surface re-audit block + +The second-pass remediated head +`a8ec50a72e12741f0a1b9878b863959385fe02a3` is also release-blocked. A +full-surface re-audit confirmed that the prior 20 exploit paths were closed, +then found three additional P1 blockers in the Osmosis signer plus one related +display/signature hardening defect. + +| Finding | Severity | Remediation | +| --- | --- | --- | +| Osmosis `MsgSend` displayed a host denomination but always signed `uosmo` | P1 | Validate and review the supplied canonical denomination, then serialize that exact denomination; native and non-native fixtures must produce different signatures | +| Maximum Send/Swap fields wrapped below the three-row OLED body | P1 | Review each signed amount, denomination, destination, and minimum independently with renderer-measured exact-length pages; hard-wrap overlong identifiers that the legacy renderer would horizontally clip | +| `base_to_precision()` wrote one byte out of bounds and omitted the final LP digit | P1 | Compute the exact destination length, copy every digit without `strlcpy()`, terminate inside the caller's buffer, and fail closed when the output does not fit | +| Noncanonical or overflowing decimal strings displayed a different value than the signed string | P2 | Accept only the canonical unsigned-decimal spelling, enforce protobuf bounds, and reject native `uosmo` values above `UINT64_MAX` before confirmation or hashing | + +## Remediated findings + +### Hive raw-operation review + +- Reject non-canonical LEB128 varints so the reviewed bytes cannot differ from + the chain's canonical reserialization. +- Validate every account field as a Hive account name before rendering it, + rejecting embedded NULs, newlines, controls, and malformed names. +- Bound `custom_json` authorization sets to four accounts, require canonical + ordering and uniqueness, retain every account in the parsed operation, and + confirm each account individually. +- Page the complete custom JSON ID and JSON payload using measured rendered + lines rather than byte-count chunks. The adversarial 69-byte `%`/space + payload from the follow-up audit is covered by a regression test. + +### Authenticator mutations + +- Make add, remove, and wipe operations fail closed when the user cancels. +- Stop authenticator processing when passphrase acquisition fails. +- Reject overlong or control-character domain/account fields before lookup, + display, or mutation. +- Put the TOTP secret on its own confirmation screen so it cannot overflow the + domain/account screen, and wipe the decoded secret from the stack after use. +- Require at least 128 bits of decoded TOTP secret material, reject duplicate + identities, remove all matching legacy duplicates, and wipe partial decoder + output on failure. + +### Legacy Ethereum Uniswap V2 clear-signing + +- Propagate every confirmation result so cancellation cannot continue to a + signature. +- Display the native-asset value for `addLiquidityETH` and reject a nonzero + native value for `removeLiquidityETH`. +- Reject unknown token contracts rather than formatting a misleading amount. +- Require the exact fixed ABI length and declared protobuf field shape before + any selector or fixed-offset read; trailing calldata is not clear-signed. +- Require Ethereum mainnet, canonical ABI address padding, a deadline that fits + the displayed 64-bit Unix value, and a factory-derived known LP pair for + approval clear-signing. +- Parse add/remove amount semantics separately, display liquidity burned as an + 18-decimal LP amount, check all amount-formatting returns, and keep every + formatted uint256 within the OLED body budget. +- Wipe the derived HD node after the recipient-address comparison. + +### Exact-byte message and memo review + +- Add one shared confirmation primitive that accepts an explicit byte length, + computes page boundaries with the OLED renderer's font, width, word wrapping, + and three-row body limit, and propagates rejection from every page. +- Render printable ASCII completely as text. If any byte is a control or + non-ASCII value, render the complete payload as hexadecimal so embedded NULs + and binary suffixes cannot disappear. +- Use the primitive for Bitcoin-family, Ethereum, and TRON message signing and + verification; Binance, Tendermint, Cosmos, and Osmosis memos; and existing + THORChain/MAYAChain/Ripple/TRON memo wrappers. +- Apply the same rule to Solana/TON blind messages, Solana off-chain messages + and memos, identity signing challenges, and Bitcoin-family `OP_RETURN` + review. These adjacent paths had the same preview or C-string failure mode. + +### Signing policies and structured-data parsing + +- Require AdvancedMode for Ethereum precomputed typed-hash signing and EOS + unknown-action signing. A warning is not treated as policy enforcement. +- Keep `EOS_NewAccount` in the supported-action allowlist so it cannot be + downgraded to an opaque fingerprint flow. +- Require exact EIP-712 primitive type syntax and JSON node shapes; reject + short, overlong, non-hex, odd-nibble, or wrong-sized address/bytes values. +- Require exact `EIP712Domain` primary-type matching, page the primary type, + field names, and every value, and fail closed on missing typed values rather + than dereferencing a null JSON node. +- Bound type-string construction and integer parsing, reject invalid boolean + spellings and integer ranges, and remove partially parsed adjacent-memory + behavior from address/bytes encoding. + +### Binance, Cosmos, and generic Tendermint signing + +- Match Binance's effective denomination schema bound, reject unsafe + characters and non-positive/mismatched transfer amounts, and make signer + initialization/update/finalization state explicit and fail closed. +- Require Cosmos IBC receiver, sender, amount, denomination, channel, port, and + both timeout-height components. Review the exact sender and receiver instead + of labeling the sender as the destination. +- Separate Cosmos and generic Tendermint signing sessions so one protocol's ACK + cannot advance the other. Generic ACK metadata must exactly match the + initialized chain name, denomination, and message-type prefix. +- Display generic Tendermint amounts in their exact signed atomic units and + denomination; do not relabel or rescale them with unrelated coin metadata. +- Review the initialized chain ID, chain name, denomination, and message-type + prefix before final signature approval. + +### Osmosis signer invariants + +- Bind legacy Amino `MsgSend` denominations end-to-end, including direct + `OsmosisMsgAck` traffic that bypasses high-level clients. Validate and review + the supplied denomination, pass it into the serializer, and revalidate it at + the hashing boundary so a non-native denomination cannot be displayed while + a hardcoded `uosmo` value is signed. +- Validate every displayed amount as a canonical, schema-bounded unsigned + decimal. Preserve unknown asset amounts exactly, and require native `uosmo` + amounts to fit the supported 64-bit range. +- Validate denomination length and identifier grammar before using it in a + display string or Amino JSON. +- Put Send amount/destination and Swap input/minimum output on independent + renderer-measured confirmations. Force pixel-width hard breaks for long IBC + identifiers so the display behavior matches the pager's three-row model. +- Repair LP share conversion so a maximum 32-digit input retains its final + digit, fits a 34-byte destination, and cannot write beyond the stack buffer. + Make LP maximum/minimum assets and share amounts separately reviewable. +- Apply the same fail-closed amount review to delegate, undelegate, + redelegate, liquidity, Swap, and IBC paths. Require the receiver and amount + fields for Osmosis IBC and fence redelegation to its serialized `uosmo` + denomination. + +## Automated evidence + +All builds used the pinned `kktech/firmware:v15` toolchain image. + +| Configuration | Firmware tests | ARM build | SRAM reserve | Largest frame | Reserve after frame | +| --- | ---: | --- | ---: | ---: | ---: | +| Full, shipped (`KK_ZCASH_PRIVACY=OFF`) | 303/303 | Pass | 23,364 B | 7,664 B | 15,700 B | +| Bitcoin-only | 28/28 | Pass | 32,908 B | 7,664 B | 25,244 B | +| Zcash privacy | 362/362 | Pass | 19,064 B | 7,664 B | 11,400 B | + +Additional gates: + +- 43 focused Hive, authenticator, and Ethereum regression tests pass, + including 14 directly covering the follow-up remediation cluster. +- 19 new or updated regressions directly exercise the second-pass Binance, + exact-byte pager, EIP-712, EOS, Tendermint, and policy remediations. A broader + 34-test security filter covering the affected suites also passes. +- Four new firmware regressions cover canonical and overflow amount rejection, + exact maximum LP formatting with a stack canary, truncation rejection, and + maximum Swap renderer boundaries. Three Python regressions exercise raw-wire + native/non-native denomination signature separation, raw-wire + noncanonical/overflow rejection, and the full maximum-length Swap + review/signing sequence. +- The full Python emulator suite passes 574 tests with 16 expected skips. Its + settled OLED capture shows both 32-digit Swap values and both complete + 68-character IBC denominations before signing. +- The generated Python report contains 303 scenarios: 297 passed, 6 intentionally + skipped, and 0 failed. It is the release evidence for exhaustive confirmation + screens, maximum-length paging, rejection paths, and signing-flow coverage. +- Cppcheck completes with zero findings. +- Changed production files pass the pinned Clang Format 20 gate. +- `git diff --check` passes. +- Every variant exceeds the 16,384-byte SRAM reserve floor and 4,096-byte + reserve-after-largest-frame margin. + +## Physical-device evidence — 2026-07-23 + +The following checks were repeated against a connected physical KeepKey running +the exact candidate revision +`f746c52b2c0e5584fde9df04471eeb36ebbbf03b`. The device reported firmware +`7.15.0`, variant `KeepKey`, and the repository's public disposable QA address +`osmo1rs7fckgznkaxs4sq02pexwjgar43p5wnkx9s92`. Requests were signed locally +through Vault and were not broadcast. + +- Otherwise-identical `MsgSend` transactions using `uosmo` and `uatom` both + returned 64-byte signatures, and the signatures differed. This proves the + supplied denomination reaches the signed digest instead of a hardcoded + `uosmo` value. +- Amount strings `01`, `-1`, ` 1`, and `18446744073709551616` each failed with + `Invalid Osmosis amount or denomination` and returned no signature. +- A maximum Swap with two 32-digit amounts and two 68-character IBC + denominations completed and returned a signature. +- Maximum 32-digit LP-add and LP-remove share amounts completed and returned + signatures. The device remained connected, initialized, and on the same + candidate revision after the runs; no reset or memory fault was observed. +- With exclusive WebUSB access, a direct LP-remove run approved Minimum Output + A, Minimum Output B, and Pool ID, then sent protocol `Cancel` on button + request four (`LP Shares to Redeem`). Firmware returned cancellation with no + signature, remained initialized, and reported the same candidate revision. + +The operator explicitly confirmed after the run that both maximum Swap +disclosures showed their complete 68-character denominations through the final +`A`, and that the LP share disclosure showed the complete converted value +through its final `...789012`. No OLED truncation or hidden suffix was observed. + +## Physical release smoke gate + +The physical gate verifies that the automated result survives real hardware and +real transport. It does not repeat the Python report screen by screen. The +screen matrix, maximum boundaries, malformed inputs, policy combinations, and +per-page cancellation cases belong in automated tests and the generated report. + +The RC17 physical smoke gate is: + +1. Boot and reconnect the shipped full-firmware candidate; confirm the device + reports firmware `7.15.0`, variant `KeepKey`, and the expected candidate + revision. +2. Complete a representative address and transaction-signing flow through + Vault and receive a valid 64-byte signature. +3. Cancel a representative in-progress signing flow; confirm that no signature + is returned and the device remains initialized and connected. +4. Run the highest-risk changed renderer/memory flow at its accepted maximum; + confirm the final values are visible and the device neither resets nor + reports a memory fault. +5. Perform an operator visual sanity check that the physical screens match the + settled Python/emulator report. + +The evidence recorded above satisfies all five items: the exact candidate +booted and reconnected through Vault; multiple Osmosis transactions signed; the +LP cancellation returned no signature and preserved the session; maximum Swap +and LP values completed without a reset or memory fault; and the operator +confirmed the complete maximum-length disclosures. + +The former exhaustive 15-step physical checklist is retired as a release gate. +Its screen-level and adversarial cases are covered by the automated suite and +Python report. Any uncovered case should be added as a regression test rather +than expanded into recurring manual release ceremony. + +RC17 may be considered for promotion after the current pull-request CI matrix +is green and the release owner makes an explicit GO decision. diff --git a/docs/security/7.15.0-rc18-release-shape.md b/docs/security/7.15.0-rc18-release-shape.md new file mode 100644 index 000000000..9d93caf92 --- /dev/null +++ b/docs/security/7.15.0-rc18-release-shape.md @@ -0,0 +1,59 @@ +# Firmware 7.15.0 RC18 Release Shape + +Date: 2026-07-25 + +RC18 collapses the former three-product build into two release products: + +| Product | Contents | +| --- | --- | +| Regular (`full`) | Every supported chain, including Zcash shielded/Orchard | +| Bitcoin-only | Bitcoin only; all non-Bitcoin coins and Zcash privacy code removed | + +There is no separate `zcash-privacy` artifact. Zcash privacy is part of the +regular firmware and cannot be disabled as a release choice. The internal +`ZCASH_PRIVACY` compile value remains only so bitcoin-only can compile the +privacy sources out. + +The variable-time Pallas secret-scalar finding remains an explicit RC18 +release blocker. It requires a reviewed constant-time implementation; it does +not authorize removing Orchard from the regular product. + +## Release and CI invariants + +- Device, emulator, unit-test, SRAM, and tagged-release matrices contain only + `full` and `bitcoin-only`. +- An unflagged CMake build is the regular product and sets + `BITCOIN_ONLY=0`, `ZCASH_PRIVACY=1`, and `AES_SMALL_TABLES`. +- `-DKK_BITCOIN_ONLY=ON` sets `BITCOIN_ONLY=1` and `ZCASH_PRIVACY=0`. +- The published emulator is the regular/full image. +- Release notes and reproducible-build instructions name only the regular and + bitcoin-only artifacts. + +## Automated evidence + +Both ARM builds use the toolchain image pinned by manifest digest in CI and the +release workflow. Each release artifact set includes its exact builder digest +and source/workflow identity in `PROVENANCE-.txt`. + +| Product | ARM build | Firmware tests | Board tests | Crypto tests | SRAM reserve | Largest frame | Reserve after frame | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Regular (`full`) | Pass | 366/366 | 2/2 | 4/4 | 19,064 B | 7,664 B | 11,400 B | +| Bitcoin-only | Pass | 30/30 | 2/2 | 4/4 | 32,908 B | 7,664 B | 25,244 B | + +The regular release compiles the Orchard privacy engine; bitcoin-only does +not. The regular firmware suite includes 59 Zcash tests. Exact build and SRAM +figures must be refreshed after the constant-time Pallas implementation lands. +Both release gates remain mandatory. + +The canonical full-image integration rehearsal used device-protocol +`e31cddfe7f5c72c983d06a889ac7db649b9811df` and python-keepkey +`9ce1aeb480333176b3ed098066f51b22fd40a9d7`. Its JUnit result was 592 tests: +560 passed, 32 intentionally skipped, 0 failed, and 0 errors. Both the +host-side and raw-device persistence rejection tests passed, and the +report-driven run captured 898 OLED screenshots. This evidence predates the +restoration of Orchard in the regular image and must not be used to approve the +final privacy-capable RC18 candidate. + +Workflow YAML parsing, SRAM-budget JSON parsing, Python syntax compilation, +`git diff --check`, and the release-shape search for stale `zcash-privacy` +matrix entries also pass. diff --git a/docs/security/next-wave-hardening.md b/docs/security/next-wave-hardening.md new file mode 100644 index 000000000..e16eb2ae0 --- /dev/null +++ b/docs/security/next-wave-hardening.md @@ -0,0 +1,153 @@ +# KeepKey next-wave security hardening + +Status: proposed + +Baseline: `BitHighlander/keepkey-firmware:develop` at `21d6a9d100b16566a1e48899abbbb7bab9366187` + +Threat-model snapshot: 2026-08-03 + +## Objective + +Reduce seed-exposure, rollback, entropy, and storage-at-rest risk without +combining unrelated security changes into one release candidate. Each code +change must be independently reviewable, revertible, and tied to a negative +control or a test that would fail if the security rule were removed. + +This plan treats physical possession, a malicious host, fault injection, and a +previously valid signed image as in-scope. It does not claim that firmware can +substitute for a secure element. + +## Gap register and disposition + +| ID | Gap | Current disposition | Delivery vehicle | +|---|---|---|---| +| KK-HARD-001 | No secure element | Hardware revision | Board threat model and SE architecture | +| KK-HARD-002 | Production PIN KDF uses 10 PBKDF2 iterations | Firmware, migration-sensitive | Versioned KDF/storage PR after downgrade gate | +| KK-HARD-003 | Firmware-owned PIN attempt counter has no wipe ceiling | Firmware policy plus hardware limitation | Separate policy RFC and implementation | +| KK-HARD-004 | Single RNG source cannot fail closed | Firmware | Health-test API and seed-generation PR | +| KK-HARD-005 | Host can request display of internal seed entropy | Firmware, immediate | Remove display path while retaining wire compatibility | +| KK-HARD-006 | Valid signed firmware can be rolled back | Bootloader plus irreversible state | OTP security-epoch RFC, prototype, hardware campaign | +| KK-HARD-007 | No per-device supply-chain attestation | Hardware revision | SE-backed manufacturing and server protocol | +| KK-HARD-008 | Firmware measures the bootloader rather than the root measuring firmware | Hardware/boot architecture | Next-board measured-boot design | +| KK-HARD-009 | Large USB/WebUSB parser surface | Product architecture | Reachability inventory, parser fuzzing, pre-PIN minimization | +| KK-HARD-010 | Host receives bulk unconditioned RNG output | Firmware | Conditioned audit stream with compatibility analysis | +| KK-HARD-011 | No anti-klepto/anti-exfil signing | Protocol plus firmware | Research RFC; do not mix with transaction policy patches | + +PR #333's Taproot change-output rule is a release blocker for Taproot-enabled +firmware, but remains outside this series so these branches stay based directly +on `develop`. + +## Delivery waves + +### Wave 0: remove direct secret exposure + +1. Ignore the legacy `ResetDevice.display_random` wire field. +2. Remove the production OLED path that formats and confirms all 32 bytes of + internal entropy. +3. Keep the protobuf field decodable so old hosts do not fail to communicate. +4. Confirm that debug-link-only entropy access remains excluded from production + builds. + +Merge gate: full firmware unit suite, release build, ROM delta, and a source +audit showing no production reference to `display_random` or the "Internal +Entropy" screen. + +### Wave 1: establish downgrade-safe storage hardening + +PIN KDF hardening and anti-rollback are coupled by migration safety. A new KDF +must carry an unambiguous, persisted selector so existing wallets can +be unwrapped with the legacy parameters exactly once and rewrapped after a +correct PIN. An older signed firmware must not silently clear or misinterpret +that selector. + +Delivery order: + +1. Approve the security-epoch format and identify an unused OTP block on every + shipping hardware revision. +2. Ship a bootloader that understands epoch zero without burning an epoch. +3. Verify bootloader update and recovery on real hardware, including power loss + at every flash/OTP boundary. +4. Introduce storage version 19 with an explicit KDF-v2 flag and legacy unwrap + path. +5. Benchmark the production iteration count on the slowest supported device; + record unlock latency and watchdog margin. +6. Only then advance the signed-image security epoch and make older images + ineligible. + +The KDF PR may be reviewed and tested before the bootloader work, but it must +remain draft until the downgrade/recovery gate is satisfied. + +### Wave 2: make entropy fail closed and condition host output + +Split this into two PRs: + +- Change the RNG API to report failure and add repetition-count plus + adaptive-proportion health tests. Seed creation must abort without committing + storage if the source fails. +- Hash-condition the `GetEntropy` stream with domain separation and a counter. + Re-evaluate or remove the 64 KiB confirmation-free budget after conditioning. + +Required tests include constant, alternating, biased-window, reset, and normal +source fixtures. Emulator determinism is not evidence of MCU RNG health; the +release gate requires injected hardware failures or a test build with a +controlled RNG shim. + +### Wave 3: reduce online and parser attack surface + +- Decide an explicit PIN-attempt ceiling and recovery policy. A wipe ceiling is + a product decision with irreversible user impact, not a drive-by constant. +- Inventory every message reachable before initialization and before PIN + unlock. Remove unnecessary handlers from those states. +- Seed the protobuf/USB fuzz harness with every production message type and + require sanitizer-clean parsing before adding new messages. +- Specify an anti-klepto protocol with host capability negotiation and test + vectors before changing nonce generation. + +### Hardware wave + +The current MCU-only design cannot provide an independent PIN oracle, +monotonic attempt counter, device identity secret, or root-held firmware +measurement. The next board threat model must therefore cover: + +- secure-element lifecycle, provisioning, and slot policy; +- seed release requiring both MCU-held and SE-held material; +- SE-backed PIN stretching and monotonic attempts; +- per-device supply-chain challenge/response; +- bootloader-to-firmware measured boot; and +- recovery behavior when either chip is unavailable. + +These are architecture requirements, not open firmware bugs against the +current board. + +## Common acceptance gates + +Every hardening PR must provide: + +- a branch based on the exact current `develop` head, with no release-branch + merge commits; +- a precise security invariant and adversary capability; +- tests in both directions plus a recorded negative control where practical; +- regular and bitcoin-only builds, and privacy builds when touched code is + shared; +- direct test-binary exit status rather than the current + `docker compose run firmware-unit` wrapper exit status; +- format, static-analysis, ROM/RAM, and stack deltas; +- hardware test instructions and expected OLED/USB behavior; +- an explicit statement of downgrade and recovery consequences; and +- two human reviewers, including one reviewer who did not author the finding. + +No release candidate advances to production signing while a release-reachable +critical/high finding lacks either a fix or a signed risk acceptance. + +## Release evidence bundle + +For each candidate, bind the following to the immutable tag and commit: + +- signed tag verification and signer fingerprint; +- exact-head CI and release workflow URLs; +- firmware and payload SHA-256 manifests; +- unit, Python, emulator, and hardware result counts; +- disclosed skips with owners; +- bootloader version and security epoch; +- storage migration source/target versions; and +- reviewer approvals for every security PR in the composition. From 51f045d4c7e0fd09422c97802cf3e9ecc400d789 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 6 Aug 2026 16:22:44 -0300 Subject: [PATCH 24/40] build: pin release client for final integration --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index fabd6c618..b44f1b367 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit fabd6c6189b7f1b3ea7cbd1d372fc13729761178 +Subproject commit b44f1b367e7b8ce98d88a49b3457443335365d8f From 5544bef6d15210239e93a8bce001efbd8c570a97 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 8 Aug 2026 18:26:57 -0300 Subject: [PATCH 25/40] fix(rng): enforce RAND_PLATFORM_INDEPENDENT instead of relying on it trezor-crypto's crypto/rand.c carries a zero-seeded LCG random32() under `#ifndef RAND_PLATFORM_INDEPENDENT`, and rand.c is compiled into the trezorcrypto library. It stays out of the build only because the top-level CMakeLists defines that macro -- and it defined it as `=0`, sitting in a block of USE_* value toggles where `=0` reads as "disabled". Against an #ifndef test `=0` is an enable, so the insecure generator was excluded by double negation. Nothing enforced that. Dropping the define during an ordinary cleanup of an apparently unused `=0` flag would have compiled in the LCG. The one thing that would have caught it is incidental: every target linking trezorcrypto also links kkrand, so both strong random32() definitions collide at link time. That is a property of today's link graph, not a guarantee -- a future target linking trezorcrypto alone builds clean and deterministic. This is the shape of the July 2026 COLDCARD incident, where a board config left the hardware-RNG macro defined-but-zero against a definedness test and seed generation silently used a software PRNG for five years. Not exploitable here: no shipped binary contains the LCG, and reset.c mixes host entropy into every seed regardless. Fix it while it is free. - Define RAND_PLATFORM_INDEPENDENT bare rather than `=0`, matching upstream trezor-core's SConscript.firmware, so the spelling states what it means. - #error in lib/rand/rng.c if the macro is ever absent, next to the existing EMULATOR/__arm__ assertion. Preprocessor-only: zero ROM, zero RAM. Verified with positive controls: preprocessing crypto/rand.c emits the LCG with no define and omits it under both the old and new spellings; rng.c compiles clean with the define and fails with it removed. Reported by an external researcher against 7.14.0 alongside a second finding on the EMULATOR switch, which the 7.15 line already fixes (rc-era #332 added the __arm__ assertion and replaced the libc random() fallback with emulatorRandom()). --- CMakeLists.txt | 9 ++++++++- lib/rand/rng.c | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 542b6027d..a011b1f70 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -131,7 +131,14 @@ add_definitions(-DUSE_CARDANO=0) add_definitions(-DUSE_MONERO=0) add_definitions(-DUSE_NEM=0) -add_definitions(-DRAND_PLATFORM_INDEPENDENT=0) +# NOT a USE_* style on/off toggle despite sitting next to them: trezor-crypto's +# rand.c tests this macro with #ifndef, so only its *definedness* matters. The +# old -D...=0 spelling read as "off" while actually meaning "on", and the +# insecure LCG random32() stayed out of the build purely by that double +# negation. Define it bare, matching upstream trezor-core's SConscript.firmware, +# so a future cleanup of an apparent "=0 means unused" define cannot silently +# compile in the LCG. lib/rand/rng.c #errors if this ever goes missing. +add_definitions(-DRAND_PLATFORM_INDEPENDENT) add_definitions(-DPB_FIELD_16BIT=1) add_definitions(-DPB_NO_ERRMSG=1) diff --git a/lib/rand/rng.c b/lib/rand/rng.c index 71948958c..4ef89565f 100644 --- a/lib/rand/rng.c +++ b/lib/rand/rng.c @@ -57,6 +57,23 @@ "EMULATOR selects the host-CSPRNG random32(); ARM firmware must use the STM32 hardware RNG" #endif +/* The same lesson applied to the other RNG selection switch. trezor-crypto's + * crypto/rand.c carries a zero-seeded LCG random32() under #ifndef + * RAND_PLATFORM_INDEPENDENT, and rand.c is compiled into the trezorcrypto + * library (deps/crypto/CMakeLists.txt). It is excluded today only because the + * top-level CMakeLists defines that macro -- previously as "=0", which reads + * like a disable but, against an #ifndef test, is an enable. + * + * Every target that links trezorcrypto also links kkrand, so a stale build + * would currently hit a duplicate-symbol error on random32() rather than + * silently take the LCG. That is an accident of the link graph, not a + * guarantee: a future target linking trezorcrypto alone would build clean and + * deterministic. Assert the macro instead of relying on either coincidence. */ +#ifndef RAND_PLATFORM_INDEPENDENT +#error \ + "RAND_PLATFORM_INDEPENDENT must be defined; without it trezor-crypto compiles its insecure LCG random32()" +#endif + void reset_rng(void) { #ifndef EMULATOR /* disable RNG */ From 27970b0c67019901228f7918081adce1c2fab94a Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 8 Aug 2026 20:28:17 -0300 Subject: [PATCH 26/40] ci: stop qualifying the regular build as "-full" Every release through v7.14.1 published firmware.keepkey.bin. The two-variant matrix started stamping matrix.variant into the filenames, so the regular build would have shipped as firmware.keepkey.v7.15.0-full.bin -- a rename that gains nothing and breaks the name users, docs and download scripts already know. The job names already had this right: 'label' is empty for the regular build and only bitcoin-only is annotated. Do the same for filenames by adding a 'suffix' key alongside it, empty for the regular build and "-bitcoin-only" for the other. matrix.variant stays what it is -- the build selector, and the provenance value. Only the unusual variant is qualified now: firmware.keepkey.v7.15.0.bin HASHES.txt PROVENANCE.txt firmware.keepkey.v7.15.0-bitcoin-only.bin HASHES-bitcoin-only.txt ... Also fixes a bug this rename would otherwise have introduced: create-release collected hash and provenance files with 'HASHES-*.txt' / 'PROVENANCE-*.txt', which stop matching once the regular variant's files lose the dash -- the regular build's hash manifest would have been dropped from the release without failing the job. Globs are now HASHES*.txt / PROVENANCE*.txt. Internal-only artifact names (emu-image-, unit-test-results-) are untouched; they never leave CI and renaming them would just churn their download-artifact consumers. --- .github/workflows/ci.yml | 11 ++++++++--- .github/workflows/release.yml | 33 +++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 560f78d80..ab0a94409 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -387,11 +387,16 @@ jobs: fail-fast: false matrix: include: + # 'label' names the job, 'suffix' names the built files, 'variant' selects + # the build. The default variant is empty in both so the flashable artifact + # matches the historical name instead of gaining a "-full" token. - variant: full label: "" + suffix: "" cmake_flags: "" - variant: bitcoin-only label: " (bitcoin-only)" + suffix: "-bitcoin-only" cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Checkout @@ -475,11 +480,11 @@ jobs: cd bin for f in *.bin; do [ -f "$f" ] || continue - mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${{ matrix.variant }}-${f}" + mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}${{ matrix.suffix }}-${f}" done for f in *.elf; do [ -f "$f" ] || continue - mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${{ matrix.variant }}-${f}" + mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}${{ matrix.suffix }}-${f}" done ls -lh echo "::notice::Firmware v${{ steps.version.outputs.fw_version }} (${{ matrix.variant }}) built successfully" @@ -487,7 +492,7 @@ jobs: - name: Upload firmware artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: firmware-v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${{ matrix.variant }} + name: firmware-v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}${{ matrix.suffix }} path: | bin/*.bin bin/*.elf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb08d05f9..8fd4a6153 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,9 +87,15 @@ jobs: fail-fast: false matrix: include: + # 'suffix' names the published files; 'variant' stays the internal build + # selector. The default build takes an EMPTY suffix so its assets keep the + # names every previous release used (v7.14.x shipped firmware.keepkey.bin). + # Only the bitcoin-only build is qualified, because it is the unusual one. - variant: full + suffix: "" cmake_flags: "" - variant: bitcoin-only + suffix: "-bitcoin-only" cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -171,17 +177,18 @@ jobs: working-directory: release run: | VER="${{ needs.validate.outputs.fw_version }}" - VARIANT="${{ matrix.variant }}" - [ -f firmware.keepkey.bin ] && mv firmware.keepkey.bin "firmware.keepkey.v${VER}-${VARIANT}.bin" - [ -f firmware.keepkey.elf ] && mv firmware.keepkey.elf "firmware.keepkey.v${VER}-${VARIANT}.elf" - [ -f bootloader.bin ] && mv bootloader.bin "bootloader.v${VER}-${VARIANT}.bin" - mv HASHES.txt "HASHES-${VARIANT}.txt" + SUFFIX="${{ matrix.suffix }}" + [ -f firmware.keepkey.bin ] && mv firmware.keepkey.bin "firmware.keepkey.v${VER}${SUFFIX}.bin" + [ -f firmware.keepkey.elf ] && mv firmware.keepkey.elf "firmware.keepkey.v${VER}${SUFFIX}.elf" + [ -f bootloader.bin ] && mv bootloader.bin "bootloader.v${VER}${SUFFIX}.bin" + mv HASHES.txt "HASHES${SUFFIX}.txt" ls -lh - name: Record build provenance working-directory: release run: | VARIANT="${{ matrix.variant }}" + SUFFIX="${{ matrix.suffix }}" { echo "firmware_commit=${GITHUB_SHA}" echo "source_repository=${GITHUB_REPOSITORY}" @@ -191,8 +198,8 @@ jobs: echo "runner_image=${ImageOS:-unknown}" echo "variant=${VARIANT}" echo "cmake_flags=${{ matrix.cmake_flags }}" - } > "PROVENANCE-${VARIANT}.txt" - cat "PROVENANCE-${VARIANT}.txt" + } > "PROVENANCE${SUFFIX}.txt" + cat "PROVENANCE${SUFFIX}.txt" - name: Upload release artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -267,8 +274,10 @@ jobs: - name: Prepare release assets run: | mkdir -p release-assets - cp artifacts/*.bin artifacts/*.elf artifacts/HASHES-*.txt \ - artifacts/PROVENANCE-*.txt release-assets/ + # No dash in the glob: the default variant's files are HASHES.txt and + # PROVENANCE.txt, and a 'HASHES-*' pattern would silently drop them. + cp artifacts/*.bin artifacts/*.elf artifacts/HASHES*.txt \ + artifacts/PROVENANCE*.txt release-assets/ ls -lh release-assets/ - name: Generate release body @@ -278,7 +287,7 @@ jobs: ## KeepKey Firmware ${{ needs.validate.outputs.tag_name }} ### Variants - - **full (regular)** — \`firmware.keepkey.v${VER}-full.bin\` — all supported chains, including Zcash shielded/Orchard + - **regular** — \`firmware.keepkey.v${VER}.bin\` — all supported chains, including Zcash shielded/Orchard - **bitcoin-only** — \`firmware.keepkey.v${VER}-bitcoin-only.bin\` — BTC-only, non-BTC coins stripped ### Reproducible Build Verification @@ -287,8 +296,8 @@ jobs: tail -c +257 bin/firmware.keepkey.bin | shasum -a 256 # bitcoin-only: # ./scripts/build/docker/device/release.sh -DKK_BITCOIN_ONLY=ON - # (extra args pass through to cmake) and compare against the - # matching HASHES-.txt + # (extra args pass through to cmake) and compare against + # HASHES.txt, or HASHES-bitcoin-only.txt for that variant \`\`\` > **DRAFT TEST CANDIDATE** — RC artifacts are unsigned and intended for From 4dd5ae510edef594d5f2a7556ed805669a558ba9 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 8 Aug 2026 22:21:13 -0300 Subject: [PATCH 27/40] ci(report): validate the catalog against the same evidence it renders The report is rendered from the merged JUnit (Python integration + the four native firmware unit XMLs) but --validate-junit was handed the Python JUnit alone. Any catalog entry naming a native unit test therefore resolved to "missing" and failed the gate, so no native test could ever be catalogued -- the firmware-unit XMLs were merged in and then unusable, which is half the reason 432 native tests were invisible to the release report. Validate against the merged file. The requirement that canonical Python evidence exist and be non-empty is enforced earlier, before the merge, so this weakens nothing. Verified locally against the 7.15.0 RC artifacts from run 31284108490: the trigger renders 325 catalogued tests and exits 0, and injecting a failure into Storage::PinKdfV16RewrapsToV19AfterCorrectPin makes it exit non-zero with "report catalog validation failed". Pairs with python-keepkey "report: catalog the 7.15 seed-generation evidence and state the report's scope"; the pin must move for section K to appear. --- scripts/generate-test-report.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index fe772f7d5..55ed1518c 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -88,10 +88,17 @@ def main(): # Render first so a failed candidate still has a truthful diagnostic PDF, # then fail the job if any catalog entry failed or is missing. Deliberate # feature/policy skips remain valid per the report generator contract. + # + # Validate against the SAME merged evidence the PDF was rendered from. It + # used to validate against the Python JUnit alone, so any catalog entry + # naming a native firmware unit test resolved to "missing" and the gate + # could never accept one -- which is half of why no native test was ever + # catalogued. The canonical-Python-evidence requirement is already + # enforced above, before the merge, so nothing is weakened here. validate_cmd = [ sys.executable, REPORT_GENERATOR, - '--junit=%s' % python_junit, + '--junit=%s' % (merged or python_junit), '--validate-junit', ] if fw_version: From 60e299a95fb2095cfac9babc4bde0f65d546fd28 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 8 Aug 2026 22:28:40 -0300 Subject: [PATCH 28/40] ci(dylib): run the one confirm-flow test that cannot hang test_dylib_confirm_flow.py contains two tests and neither has ever run in CI. The main python job skips the file (it needs KK_TRANSPORT=dylib), and the dylib job invoked test_dylib_screenshot.py only, so both were invisible -- the skip reasons appear in the JUnit but no gate reads them. They are not equivalent, and treating them as one file is what kept both out. test_features_round_trip is the pure Initialize -> Features path with no confirm at all; its own docstring says it should always pass. That one runs now. test_load_device_with_auto_confirm stays skipped, and deliberately: it is blocked on an open firmware defect, not on test plumbing. confirm_helper busy-loops on a ButtonAck the dylib consumed but never delivered, which is the same path keepkey-vault's wipe_device hangs on, and no pytest-timeout method can interrupt a C-level kkemu_poll(). Adding the file wholesale would convert a silent gap into a job that burns its 25-minute timeout. Named explicitly rather than by file so the distinction is visible at the call site instead of living only in a skip decorator. --- .github/workflows/ci.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab0a94409..559ce1914 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -809,7 +809,7 @@ jobs: # skip rationale), it's a transitive dep some tests use. pip install pytest pytest-timeout - - name: Run dylib screenshot tests + - name: Run dylib transport tests working-directory: deps/python-keepkey/tests env: KK_TRANSPORT: dylib @@ -817,8 +817,22 @@ jobs: run: | # `keepkeylib/` on PYTHONPATH so the package's relative-style # imports inside generated *_pb2.py files resolve. + # + # test_dylib_confirm_flow contributes exactly ONE test here: + # test_features_round_trip, the pure Initialize -> Features path + # that never enters confirm_helper. It is named explicitly rather + # than adding the whole file, because the file's other test + # (test_load_device_with_auto_confirm) is @unittest.skip'd on an + # OPEN firmware bug -- confirm_helper busy-loops on a ButtonAck + # the dylib consumed but never delivered, and no pytest-timeout + # method can interrupt a C-level kkemu_poll() loop. Adding the + # file wholesale would trade a silent coverage gap for a runner + # that burns the job's 25-minute timeout. + # + # When that firmware fix lands, run the whole file instead. PYTHONPATH=../keepkeylib:.. python -m pytest \ test_dylib_screenshot.py \ + "test_dylib_confirm_flow.py::TestDylibConfirmFlow::test_features_round_trip" \ -v --tb=short --junit-xml=../../../test-reports/dylib-junit.xml - name: Upload dylib test results From 5f848f9330c29b706626890f020aa3a4e199fb2a Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 01:18:31 -0300 Subject: [PATCH 29/40] fix(release): don't rename HASHES.txt onto itself for the regular variant 27970b0c6 gave the regular variant an empty suffix so its published assets keep their plain names. Three of the four renames in "Rename artifacts" also insert the version, so they stay distinct. The fourth does not: mv HASHES.txt "HASHES${SUFFIX}.txt" With SUFFIX empty that is `mv HASHES.txt HASHES.txt`, and GNU coreutils does not treat a same-file move as a no-op -- it errors "are the same file" and exits 1. The step runs under the default `bash -e`, so the regular firmware's release job dies there, after the build and the hashing have already succeeded. Only the bitcoin-only leg would publish. Nothing caught this because release.yml runs on tag push only: the branch's 28/28 green CI never executes it, and the last green release run (rc26, 2026-08-04) predates the suffix change -- it still renamed to "HASHES-${VARIANT}.txt", which is always distinct. The first thing to exercise this path would have been the v7.15.0 tag itself. Skip the move when there is nothing to rename. The regular variant's intended output name is plain HASHES.txt, which is what it already has. Verified by running the step's script under `bash -e` for both matrix legs: empty suffix exits 0 leaving HASHES.txt, "-bitcoin-only" exits 0 leaving HASHES-bitcoin-only.txt. --- .github/workflows/release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fd4a6153..33b7fe9af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -181,7 +181,12 @@ jobs: [ -f firmware.keepkey.bin ] && mv firmware.keepkey.bin "firmware.keepkey.v${VER}${SUFFIX}.bin" [ -f firmware.keepkey.elf ] && mv firmware.keepkey.elf "firmware.keepkey.v${VER}${SUFFIX}.elf" [ -f bootloader.bin ] && mv bootloader.bin "bootloader.v${VER}${SUFFIX}.bin" - mv HASHES.txt "HASHES${SUFFIX}.txt" + # The regular variant carries an EMPTY suffix, so this rename is a + # no-op for it -- and `mv HASHES.txt HASHES.txt` is not a harmless + # no-op to GNU coreutils, it is "are the same file" and exit 1, which + # the step's default `bash -e` turns into a failed release. The three + # renames above are safe because each one also inserts the version. + [ -z "$SUFFIX" ] || mv HASHES.txt "HASHES${SUFFIX}.txt" ls -lh - name: Record build provenance From 940b70151e53c07ee81ec66deb1e1094ddeb7a61 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 01:28:59 -0300 Subject: [PATCH 30/40] ci(sram): put the budget numbers where a reviewer will see them The SRAM budget gate has always enforced correctly -- verified against the shipped 7.15.0 RC ELF, where the reserve measures 17,940 B against a 16,384 B budget, and all three of its failure modes exit 1: reserve breached, largest frame eating the margin, and an empty .su archive (it refuses to pass the frame-margin check without stack-usage data rather than treating "no records" as "largest frame = 0"). But its output only ever existed in a job log nobody opens. The frame-arena fix -- the RC8 msg_write boot fault, the single largest SRAM change in this release -- therefore had no reviewable evidence anywhere, and an RC audit recorded it as having zero coverage. It did not; nothing surfaced it. Mirror the report into the run summary, per variant. The status handling is deliberate. `tee` would otherwise swallow a non-zero exit, so pipefail is on; and the status is captured rather than allowed to abort the step under `set -e`, because a FAILING gate is exactly when the numbers need to reach the summary. The step re-exits with the captured status, so a breach still fails the build. Verified by extracting the step's script and running it against stubs for both outcomes: pass exits 0, breach exits 1 and the summary still contains the ::error:: line. shellcheck clean. --- .github/workflows/ci.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 559ce1914..0ca2fa27b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -469,11 +469,28 @@ jobs: - name: SRAM budget gate (${{ matrix.variant }}) run: | pip install --quiet pyelftools + # Mirror the report into the run summary. The gate has always + # enforced correctly, but its numbers only ever existed in a job + # log nobody opens, so the frame-arena fix had no reviewable + # evidence anywhere -- an RC audit recorded it as "zero coverage". + # pipefail so tee cannot mask a budget breach, and capture the status + # instead of letting `set -e` abort here -- a FAILING gate is exactly + # when the numbers need to reach the summary. Re-exit with it below. + set -o pipefail + rc=0 python3 tools/check_sram_budget.py \ --elf bin/firmware.keepkey.elf \ --su-tar bin/stack-usage.tgz \ --budgets tools/sram-budgets.json \ - --variant "${{ matrix.variant }}" + --variant "${{ matrix.variant }}" 2>&1 \ + | tee "/tmp/sram-${{ matrix.variant }}.txt" || rc=$? + { + echo "### SRAM budget — ${{ matrix.variant }}" + echo '```' + cat "/tmp/sram-${{ matrix.variant }}.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit $rc - name: Rename firmware artifacts run: | From 56807a702bcbb234f4a03ded36d76d6bd7c7c4f6 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 01:50:37 -0300 Subject: [PATCH 31/40] fix(zcash): show the shielded output amount, which never reached the screen The per-output shielded confirm built "Send shielded ZEC?\n\nAmount: %s" for a three-row body. A unified address is 106 characters -- three full rows on its own, as the captured display-address frame shows (38 + 38 + 30). The standard notification does not scroll or paginate: draw_string stops emitting as soon as a character will not fit, with no indication. So the screen rendered the question plus the first 76 characters of the address, and silently dropped the remaining 30 along with the entire amount line. This is the gate that was supposed to show the value. total_amount on the summary prompt is taken straight from the host message, and the contract comment in the signing path says so outright -- it calls total_amount "a summary prompt" and states that Orchard output recipients and values "have their own verification gates". This confirm is that gate. The device does bind value to cmx, so a host cannot present an inconsistent note; the defect is that the user approved a value they were never shown. Both pools are affected. is_ironwood selects only which compute_cmx runs; the address rendering and the confirm are the shared tail of the same function, so Ironwood and Orchard render the identical screen. Amount first on a body that cannot overflow, then the full address through layout_zcash_address_text_notification -- the layout built for a 106-char UA and already used by the display-address flow. VERIFICATION LIMITS, stated plainly: - The overflow is established from the code and from a measured 106-char UA in a real captured frame, not from a rendering of this screen. - Nothing in CI executes it. ZcashSignPCZT is never sent to a device anywhere in the suite -- every test in test_msg_zcash_sign_pczt drives a ScriptedTransport with canned responses. The device-driven Zcash tests cover transparent signing, display-address, FVK and seed fingerprint. - So this compiles in CI and is not otherwise exercised. It MUST be walked on hardware during the RC round, and both screens photographed. - The flow now emits two ButtonRequests per shielded output instead of one. Hosts that loop on ButtonRequest/ButtonAck are unaffected; anything counting exact requests needs updating. --- lib/firmware/fsm_msg_zcash.h | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/firmware/fsm_msg_zcash.h b/lib/firmware/fsm_msg_zcash.h index 9616354c8..c59dd9335 100644 --- a/lib/firmware/fsm_msg_zcash.h +++ b/lib/firmware/fsm_msg_zcash.h @@ -266,8 +266,38 @@ static bool zcash_verify_and_confirm_orchard_output( char amount_str[32]; zcash_format_amount(msg->value, amount_str, sizeof(amount_str)); + + /* Two screens, deliberately. + * + * A unified address is 106 characters, which is three full body rows on + * its own -- exactly what layout_zcash_address_text_notification is built + * to render, and what the display-address flow already shows. The standard + * notification body is three rows and draw_string simply stops emitting + * once a character will not fit: there is no scroll and no pagination, so + * surplus text is dropped without any indication. + * + * Putting the question, the address and the amount in one body therefore + * rendered the question plus the first 76 characters of the address and + * silently discarded the rest of it along with the entire amount line. + * That is not a cosmetic screen. total_amount on the summary prompt is + * taken from the host message, and the contract documented in + * zcash_pczt_sign() delegates verification of Orchard output values to + * this confirm -- so dropping the amount removed the only place the user + * could see the value being committed to. + * + * Amount first, on a body that cannot overflow, then the full address + * through the layout that fits it. */ if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Zcash Output", - "Send shielded ZEC?\n%s\nAmount: %s", address, amount_str)) { + "Send shielded ZEC?\nAmount: %s", amount_str)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + memzero(address, sizeof(address)); + return false; + } + + if (!confirm_with_custom_layout(&layout_zcash_address_text_notification, + ButtonRequestType_ButtonRequest_ConfirmOutput, + "Shielded recipient", "%s", address)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Signing cancelled")); memzero(address, sizeof(address)); From 9f31519f4821801301f805f2a637c3f1bb28f487 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 02:16:18 -0300 Subject: [PATCH 32/40] chore(deps): pin python-keepkey with the on-device shielded signing test Moves deps/python-keepkey to 3bbf996, which adds the first test that actually sends ZcashSignPCZT to a device. Until now every PCZT test drove a scripted transport with canned responses, so the on-device shielded path -- including the per-output confirm this branch fixes -- was never executed by anything. The new test is a real regression test for that fix, not a companion to it: run against the shipped 7.15.0 RC emulator it fails with "expected 2 ConfirmOutput screens, got 1". This pin is what lets CI run it against the fixed firmware. Also brings the report catalog changes: native firmware unit tests can now be referenced at all, the header states how much of the run the catalog covers, and the shielded confirm screens are finally requested as screenshots. PRACTICE PIN -- 3bbf996 is on an unmerged fork branch (BitHighlander/python-keepkey#30, stacked on keepkey/python-keepkey#211). Re-pin to a merged master SHA before tagging v7.15.0, along with device-protocol and trezor-firmware. --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index b44f1b367..3bbf996f0 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit b44f1b367e7b8ce98d88a49b3457443335365d8f +Subproject commit 3bbf996f09cd20a22f82ce2de6d4fb746489ed9d From f37d24eaf362345a2975f1579cb070e99398197d Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 02:47:49 -0300 Subject: [PATCH 33/40] ci: keep the evidence when the tests fail, and validate against all of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects, both found by the integration job failing on this branch. 1. A failing test run threw away everything it had produced. The step comments say "always extract reports", and the upload steps carry if: always(). Neither helped, because the step runs under the default `bash -e` and captured exit codes with docker compose up --exit-code-from python-keepkey python-keepkey; PY_RC=$? A bare failing command aborts immediately under -e, so the abort happened before the assignment and before the docker cp block. Nothing was ever copied out of the container, and if: always() had nothing to upload. The run that exposed this captured four OLED frames of the Zcash shielded confirm -- the first shielded frames this project has ever produced -- and uploaded none of them. Its JUnit and PDF went the same way. Evidence disappearing exactly when a run fails is the worst possible time for it. `|| RC=$?` keeps the assignment and the extraction on the path while still failing the step afterwards. Verified by running the step's own script with a deliberately failing container: extraction is reached, RCs are captured, and the step still exits 1. With the semicolon form the extraction line never prints. 2. The in-container catalog validation could not see native unit tests. python-keepkey-tests.sh validated against the Python JUnit alone, so every catalog entry naming a native firmware unit test resolved to "missing" -- the same defect already fixed in scripts/generate-test-report.py, in a second call site. The test-reports volume is shared rw with the firmware-unit container, which runs first, so those XMLs are already present; merge them before validating and before rendering. If they are absent this falls back to Python-only and native entries then fail as "missing" — it still fails closed rather than quietly passing. Verified against the real artifacts from the failing run: merging the native XMLs resolves K3-K11, leaving only the Z22-Z25 entries that the older Python JUnit predates. --- .github/workflows/ci.yml | 14 +++++++-- scripts/emulator/python-keepkey-tests.sh | 36 ++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ca2fa27b..adb78bf71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -591,8 +591,18 @@ jobs: working-directory: scripts/emulator run: | # Run each test container — capture exit codes, always extract reports - docker compose up --build --exit-code-from firmware-unit firmware-unit; FW_RC=$? - docker compose up --build --exit-code-from python-keepkey python-keepkey; PY_RC=$? + # + # `|| RC=$?` rather than `; RC=$?`: this step runs under the default + # `bash -e`, where a bare failing command aborts immediately. With the + # semicolon form the abort happened BEFORE the assignment and before + # the extraction below, so a failing test run uploaded nothing at all + # — no JUnit, no report, no OLED frames — precisely when the evidence + # was needed. The `if: always()` on the upload steps could not help, + # because nothing had been copied out of the container yet. + FW_RC=0 + PY_RC=0 + docker compose up --build --exit-code-from firmware-unit firmware-unit || FW_RC=$? + docker compose up --build --exit-code-from python-keepkey python-keepkey || PY_RC=$? mkdir -p ${{ github.workspace }}/test-reports diff --git a/scripts/emulator/python-keepkey-tests.sh b/scripts/emulator/python-keepkey-tests.sh index 489a64674..bdc8aa35e 100755 --- a/scripts/emulator/python-keepkey-tests.sh +++ b/scripts/emulator/python-keepkey-tests.sh @@ -101,16 +101,48 @@ KK_TRANSPORT_DEBUG=kkemu:11045 \ pytest -v --junitxml=/kkemu/test-reports/python-keepkey/junit.xml PYTEST_RC=$? +# Merge in the native firmware unit results before validating or rendering. +# The test-reports volume is shared rw with the firmware-unit container, which +# runs first, so its XMLs are already here. Validating against the Python JUnit +# alone made every catalog entry naming a native unit test resolve to "missing", +# which is why no native test could ever be catalogued and all 432 of them were +# invisible to the report. +# +# If the native XMLs are absent this falls back to Python-only, and any native +# catalog entry then fails as "missing" -- i.e. it still fails closed, it does +# not quietly pass. +echo "=== Phase 2: Merge JUnit evidence ===" +MERGED=/kkemu/test-reports/junit-merged.xml +python3 - <<'PY' +import glob, os, xml.etree.ElementTree as ET +files = sorted(glob.glob('/kkemu/test-reports/python-keepkey/junit*.xml')) +native = sorted(glob.glob('/kkemu/test-reports/firmware-unit/*.xml')) +root = ET.Element('testsuites') +for f in files + native: + try: + for suite in ET.parse(f).iter('testsuite'): + root.append(suite) + except ET.ParseError: + print("WARN: skipping malformed %s" % f) +ET.ElementTree(root).write('/kkemu/test-reports/junit-merged.xml', + xml_declaration=True, encoding='unicode') +print("Merged %d Python + %d native JUnit file(s)" % (len(files), len(native))) +if not native: + print("WARN: no firmware-unit XMLs found; native catalog entries will " + "report as missing") +PY +[ -s "$MERGED" ] || MERGED=/kkemu/test-reports/python-keepkey/junit.xml + echo "=== Phase 2: Validate report catalog ===" python3 ../scripts/generate-test-report.py \ - --junit=/kkemu/test-reports/python-keepkey/junit.xml \ + --junit="$MERGED" \ ${FW_VERSION:+--fw-version=$FW_VERSION} \ --validate-junit CATALOG_RC=$? echo "=== Phase 2: Generate test report ===" python3 ../scripts/generate-test-report.py \ - --junit=/kkemu/test-reports/python-keepkey/junit.xml \ + --junit="$MERGED" \ ${FW_VERSION:+--fw-version=$FW_VERSION} \ --screenshots=/kkemu/test-reports/screenshots \ --output=/kkemu/test-reports/test-report.pdf From b21f52c8010ea191eb0b54e36582018bb95a6fa6 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 03:10:35 -0300 Subject: [PATCH 34/40] fix(zcash): bound the transparent input index before indexing with it fsm_msgZcashTransparentInput checked that msg->index matched current_transparent_input, but never that either was inside transparent_inputs[ZCASH_MAX_TRANSPARENT_INPUTS], an 8-element static array. Nothing stops a host sending more ZcashTransparentInput messages after the declared count is consumed. The ack loop stops asking, but the handler still accepts them, and current_transparent_input keeps incrementing -- so index 8, 9, 10 and beyond each wrote a fully host-controlled ZcashTransparentInputState past the end of the array: amount, a 32-byte prevout txid, the script_pubkey and the whole address_n array, straight into whatever follows it in static memory. Reject an index outside the declared count or the array bound, and refuse once the expected inputs have all arrived. --- lib/firmware/fsm_msg_zcash.h | 52 +++++++++++++++--------------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/lib/firmware/fsm_msg_zcash.h b/lib/firmware/fsm_msg_zcash.h index c59dd9335..380041cad 100644 --- a/lib/firmware/fsm_msg_zcash.h +++ b/lib/firmware/fsm_msg_zcash.h @@ -266,38 +266,8 @@ static bool zcash_verify_and_confirm_orchard_output( char amount_str[32]; zcash_format_amount(msg->value, amount_str, sizeof(amount_str)); - - /* Two screens, deliberately. - * - * A unified address is 106 characters, which is three full body rows on - * its own -- exactly what layout_zcash_address_text_notification is built - * to render, and what the display-address flow already shows. The standard - * notification body is three rows and draw_string simply stops emitting - * once a character will not fit: there is no scroll and no pagination, so - * surplus text is dropped without any indication. - * - * Putting the question, the address and the amount in one body therefore - * rendered the question plus the first 76 characters of the address and - * silently discarded the rest of it along with the entire amount line. - * That is not a cosmetic screen. total_amount on the summary prompt is - * taken from the host message, and the contract documented in - * zcash_pczt_sign() delegates verification of Orchard output values to - * this confirm -- so dropping the amount removed the only place the user - * could see the value being committed to. - * - * Amount first, on a body that cannot overflow, then the full address - * through the layout that fits it. */ if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Zcash Output", - "Send shielded ZEC?\nAmount: %s", amount_str)) { - fsm_sendFailure(FailureType_Failure_ActionCancelled, - _("Signing cancelled")); - memzero(address, sizeof(address)); - return false; - } - - if (!confirm_with_custom_layout(&layout_zcash_address_text_notification, - ButtonRequestType_ButtonRequest_ConfirmOutput, - "Shielded recipient", "%s", address)) { + "Send shielded ZEC?\n%s\nAmount: %s", address, amount_str)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Signing cancelled")); memzero(address, sizeof(address)); @@ -1391,6 +1361,26 @@ void fsm_msgZcashTransparentInput(const ZcashTransparentInput* msg) { return; } + /* Bound the index against the array before it is used to address it. + * + * Matching current_transparent_input is not sufficient on its own. Nothing + * stops a host sending further ZcashTransparentInput messages after the + * declared count has been consumed: the ack loop simply stops asking, while + * current_transparent_input keeps incrementing past n_transparent_inputs on + * every extra message. Each one then wrote a fully host-controlled + * ZcashTransparentInputState -- amount, 32-byte txid, script_pubkey, the + * whole address_n array -- past the end of an 8-element static array. */ + if (msg->index >= zcash_signing.n_transparent_inputs || + msg->index >= ZCASH_MAX_TRANSPARENT_INPUTS || + zcash_signing.current_transparent_input >= + zcash_signing.n_transparent_inputs) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Transparent input index out of range")); + zcash_signing_abort(); + layoutHome(); + return; + } + if (msg->index != zcash_signing.current_transparent_input) { fsm_sendFailure(FailureType_Failure_SyntaxError, _("Unexpected transparent input index")); From 774b5ab71c7ef645a2e8df84fcb46814f67d14be Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 03:11:40 -0300 Subject: [PATCH 35/40] fix(solana): never sign a transaction the parser judged malformed The review chain ran VERIFIED, then `else if (schema_verified)`, then OPAQUE, and only rejected SOL_TX_REVIEW_MALFORMED in the final else. So the schema arm shadowed the rejection: an attested KKSOLSC1 schema matching one partially-parsed instruction was enough to reach the schema review screens and then the signature, on a message the parser had already declared malformed. A schema describes a single instruction; it says nothing about the rest of a transaction that failed to parse. Reject malformed up front, as its own guard rather than by restoring it to the end of the chain -- branch ordering is what hid it the first time. The final else stays as a backstop for any future review state. --- lib/firmware/fsm_msg_solana.h | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 18977f0a3..7ab054cdd 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -860,6 +860,27 @@ void fsm_msgSolanaSignTx(const SolanaSignTx* msg) { schema_verified = true; } + /* A transaction the parser judged malformed is never signable, whatever + * else is attached to it. + * + * This used to be enforced only by the final `else` of the chain below, so + * the `else if (schema_verified)` arm shadowed it: an attested KKSOLSC1 + * schema matching one partially-parsed instruction was enough to reach the + * schema review and then the signature, on a message the parser had already + * rejected as malformed. A schema describes one instruction; it says + * nothing about the rest of a transaction that failed to parse. + * + * Stated as its own guard rather than restored to the end of the chain, + * because branch ordering is what concealed it the first time. */ + if (tx_review == SOL_TX_REVIEW_MALFORMED) { + memzero(node, sizeof(*node)); + memzero(&schema, sizeof(schema)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Malformed Solana transaction")); + layoutHome(); + return; + } + if (tx_review == SOL_TX_REVIEW_VERIFIED) { /* Per-instruction disclosure + priority fee, shared with SignMessage. */ if (!solana_confirm_verified_tx(&parsed, msg)) { From ab2c978c9aab2e3b1e65b2b9554782f0ab829337 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 03:11:40 -0300 Subject: [PATCH 36/40] fix(clearsign): don't let the tx<->metadata binding be disarmed mid-signing fsm_msgEthereumTxMetadata already refused to run during signing, with a comment explaining exactly why: clearing the binding mid-flight lets a host approve a benign decode, suppressing the blind-sign gate, then stream attacker-chosen calldata for the remaining chunks. Two other paths reached the same clear with no such guard. fsm_msgLoadClearsignSigner ends in signed_metadata_store_signer() -> signed_metadata_clear(), which drops stored_metadata along with relied_on_metadata. It now carries the same prologue as its sibling. fsm_msgClearSession was worse: an unauthenticated MSG_IN needing no button press, which called session_clear() -> signed_metadata_clear_signers() -> signed_metadata_clear() while Ethereum signing continued across it. signed_metadata_enforce() then saw relied=false and passed. It aborted only Zcash; fsm_msgInitialize and fsm_msgCancel abort every engine, and so does this now. The bitcoin-only build already provides an ethereum_signing_abort() no-op for exactly these always-on handlers. --- lib/firmware/fsm.c | 9 +++++++++ lib/firmware/fsm_msg_ethereum.h | 16 ++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index e806f8454..3ad635fae 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -287,6 +287,15 @@ void fsm_sendFailure(FailureType code, const char* text) { void fsm_msgClearSession(ClearSession* msg) { (void)msg; + /* Abort every signing engine before clearing, the way fsm_msgInitialize and + * fsm_msgCancel already do. session_clear() reaches + * signed_metadata_clear_signers() -> signed_metadata_clear(), which drops + * the clear-sign tx<->metadata binding and relied_on_metadata. Ethereum + * signing was left running across that, so this unauthenticated, + * no-button-press message could disarm the binding mid-flight and let the + * remaining calldata chunks be signed with the enforcement gate seeing + * nothing to enforce. Only Zcash was being aborted here. */ + ethereum_signing_abort(); zcash_signing_abort(); session_clear(/*clear_pin=*/true); fsm_sendSuccess("Session cleared"); diff --git a/lib/firmware/fsm_msg_ethereum.h b/lib/firmware/fsm_msg_ethereum.h index 46ad0fcf9..364b33005 100644 --- a/lib/firmware/fsm_msg_ethereum.h +++ b/lib/firmware/fsm_msg_ethereum.h @@ -71,6 +71,22 @@ void fsm_msgEthereumTxMetadata(const EthereumTxMetadata* msg) { void fsm_msgLoadClearsignSigner(const LoadClearsignSigner* msg) { CHECK_INITIALIZED CHECK_PIN + + /* Same reasoning as fsm_msgEthereumTxMetadata above, and the same fix. + * Storing a signer ends in signed_metadata_clear(), which drops the + * tx<->metadata binding along with relied_on_metadata -- so loading a + * signer mid-signing let a host approve a benign decode and then stream + * different calldata, with signed_metadata_enforce() seeing relied=false + * and passing. The guard was on the metadata message but not on its + * sibling. */ + if (ethereum_signing_isInProgress()) { + ethereum_signing_abort(); + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Signer load not allowed during signing")); + layoutHome(); + return; + } + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), _("AdvancedMode required for clearsign signers")); From f1e3d7a4c6d477c7e202fb792aed724601a8944b Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 03:11:40 -0300 Subject: [PATCH 37/40] fix(taproot): initialise the BIP-341 hash contexts on the taproot condition The five BIP-341 SHA-256 contexts were initialised inside the non-overwintered `else`, while every update and finalise site for them is gated only on `coin->has_taproot && coin->taproot`. `overwintered` arrives from the host on SignTx, so setting it on a taproot coin skipped the initialisation and left the use sites live: the sighash was then built over whatever those static contexts held -- uninitialised on the first signature after boot, and carried over from the previous transaction afterwards. Initialise them on the taproot condition alone, outside the overwintered branch. Whether a non-Zcash coin should accept overwintered at all is a separate question and deliberately not changed here. --- lib/firmware/signing.c | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/firmware/signing.c b/lib/firmware/signing.c index bc66580ed..e2919c13f 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -747,15 +747,26 @@ void signing_init(const SignTx* msg, const CoinType* _coin, hasher_Init(&hasher_sequence, curve->hasher_sign); hasher_Init(&hasher_outputs, curve->hasher_sign); hasher_Init(&hasher_check, curve->hasher_sign); - if (coin->has_taproot && coin->taproot) { - /* BIP-341 fixes these as plain SHA256, independent of the coin's - signing hasher, so they are initialised separately on purpose. */ - sha256_Init(&ctx_amounts); - sha256_Init(&ctx_scriptpubkeys); - sha256_Init(&ctx_prevouts_tr); - sha256_Init(&ctx_sequences_tr); - sha256_Init(&ctx_outputs_tr); - } + } + + /* BIP-341 fixes these as plain SHA256, independent of the coin's signing + hasher, so they are initialised separately on purpose. + + Initialised on the taproot condition alone, and deliberately outside the + overwintered branch above. Every update and finalise site for these five + contexts is gated only on `coin->has_taproot && coin->taproot`, but the + initialisation used to sit inside the non-overwintered `else`. Since + `overwintered` comes from the host on SignTx, setting it on a taproot + coin skipped the init while leaving the use sites live, so the sighash + was built from whatever these static contexts held -- uninitialised on + the first signature after boot, and carried over from the previous + transaction after that. */ + if (coin->has_taproot && coin->taproot) { + sha256_Init(&ctx_amounts); + sha256_Init(&ctx_scriptpubkeys); + sha256_Init(&ctx_prevouts_tr); + sha256_Init(&ctx_sequences_tr); + sha256_Init(&ctx_outputs_tr); } layoutProgressSwipe(_("Signing transaction"), 0); From 79b7a3c4b946b60f3ad59bb2a59de852aa730847 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 03:41:55 -0300 Subject: [PATCH 38/40] fix: restore the shielded two-screen confirm, bound the outputs array, size the bech32 buffers Three things, one of which is a self-inflicted regression the new device test caught within one CI run. 1. RESTORES the Zcash shielded two-screen output confirm. b21f52c80 was committed from a working tree that had silently rolled back to the pre-fix content, so it reverted the fix from 56807a702 while adding the index bound. Nothing in the diff summary showed it. The device test added for exactly this defect failed on the next run with "expected 2 ConfirmOutput screens, got 1" -- the same message it produces against the unfixed release build. An audit of every fix on this branch found this and the output bound below were the only two casualties; the other eleven survived intact. 2. Bounds the transparent OUTPUT index, the sibling of the input overflow. Fixing fsm_msgZcashTransparentInput did not cover fsm_msgZcashTransparentOutput: a different array with its own free-running counter. After the declared outputs are stored the dispatch moves to transparent inputs without incrementing current_transparent_input, which leaves the output handler re-armed -- so a host that ignores the ack and keeps sending outputs walks current_transparent_output past n_transparent_outputs, writing a host-controlled amount and a 128-byte script_pubkey past transparent_outputs[8], onto transparent_inputs[0] and then outside the struct. 3. Sizes every Tendermint-family bech32_decode buffer to its documented contract. segwit_addr.h requires hrp >= BECH32_MAX_HRP_LEN + 1 (84) and data >= strlen(input) - 8. Every caller in signtx_tendermint.c, osmosis.c, thorchain.c, mayachain.c, tendermint.c and binance.c used char hrp[45] and uint8_t decoded[38] against address fields whose proto max_size is 53 -- so a long address overflowed both. bech32_decode fills these buffers BEFORE validating the checksum, so the usual `if (!bech32_decode(...)) return false;` guard never prevented it: a rejected address had already written past the end. --- include/keepkey/firmware/tendermint.h | 14 +++++++ lib/firmware/binance.c | 4 +- lib/firmware/fsm_msg_zcash.h | 58 ++++++++++++++++++++++++++- lib/firmware/mayachain.c | 4 +- lib/firmware/osmosis.c | 24 +++++------ lib/firmware/signtx_tendermint.c | 24 +++++------ lib/firmware/tendermint.c | 2 +- lib/firmware/thorchain.c | 4 +- 8 files changed, 102 insertions(+), 32 deletions(-) diff --git a/include/keepkey/firmware/tendermint.h b/include/keepkey/firmware/tendermint.h index 67cad3ff2..db7e50984 100644 --- a/include/keepkey/firmware/tendermint.h +++ b/include/keepkey/firmware/tendermint.h @@ -2,6 +2,20 @@ #define KEEPKEY_FIRMWARE_TENDERMINT_H #include "trezor/crypto/bip32.h" +#include "trezor/crypto/segwit_addr.h" + +/* Output size for the data half of a bech32_decode(). + * + * segwit_addr.h documents the contract as: hrp needs BECH32_MAX_HRP_LEN + 1 + * bytes, and data needs strlen(input) - 8. The Tendermint-family callers all + * used char hrp[45] / uint8_t decoded[38] against address fields whose proto + * max_size is 53, so a long address wrote past both -- and bech32_decode + * fills these buffers BEFORE it validates the checksum, so the usual + * `if (!bech32_decode(...)) return false;` guard does not prevent it. + * + * 64 covers any input up to 72 characters, comfortably above every address + * cap on these paths. */ +#define BECH32_DECODED_MAX 64 #include #include diff --git a/lib/firmware/binance.c b/lib/firmware/binance.c index aac7f8aba..05a11546c 100644 --- a/lib/firmware/binance.c +++ b/lib/firmware/binance.c @@ -107,8 +107,8 @@ bool binance_serializeCoin(const BinanceCoin* coin) { bool binance_serializeInputOutput(const BinanceInputOutput* io) { size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; + char hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; if (!bech32_decode(hrp, decoded, &decoded_len, io->address)) { return false; } diff --git a/lib/firmware/fsm_msg_zcash.h b/lib/firmware/fsm_msg_zcash.h index 380041cad..5b4e0f9aa 100644 --- a/lib/firmware/fsm_msg_zcash.h +++ b/lib/firmware/fsm_msg_zcash.h @@ -266,8 +266,42 @@ static bool zcash_verify_and_confirm_orchard_output( char amount_str[32]; zcash_format_amount(msg->value, amount_str, sizeof(amount_str)); + + /* Two screens, deliberately. + * + * A unified address is 106 characters, which is three full body rows on its + * own -- exactly what layout_zcash_address_text_notification is built to + * render, and what the display-address flow already shows. The standard + * notification body is three rows and draw_string simply stops emitting + * once a character will not fit: there is no scroll and no pagination, so + * surplus text is dropped without any indication. + * + * Putting the question, the address and the amount in one body therefore + * rendered the question plus the first 76 characters of the address and + * silently discarded the rest of it along with the entire amount line. + * That is not a cosmetic screen: total_amount on the summary prompt is + * taken from the host message, and the contract documented in + * zcash_pczt_sign() delegates verification of Orchard output values to this + * confirm -- so dropping the amount removed the only place the user could + * see the value being committed to. + * + * Amount first, on a body that cannot overflow, then the full address + * through the layout that fits it. + * + * test_msg_zcash_sign_pczt_device.py asserts both screens are emitted; it + * fails with "expected 2 ConfirmOutput screens, got 1" against the packed + * single-screen version. */ if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Zcash Output", - "Send shielded ZEC?\n%s\nAmount: %s", address, amount_str)) { + "Send shielded ZEC?\nAmount: %s", amount_str)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + memzero(address, sizeof(address)); + return false; + } + + if (!confirm_with_custom_layout(&layout_zcash_address_text_notification, + ButtonRequestType_ButtonRequest_ConfirmOutput, + "Shielded recipient", "%s", address)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Signing cancelled")); memzero(address, sizeof(address)); @@ -1261,6 +1295,28 @@ void fsm_msgZcashTransparentOutput(const ZcashTransparentOutput* msg) { return; } + /* Same invariant as the transparent input handler, and it has to be stated + * separately: this is a different array with its own free-running counter. + * + * After the declared outputs are stored, the dispatch below moves on to + * transparent inputs without incrementing current_transparent_input, which + * leaves this handler re-armed. A host that ignores the ack and keeps + * sending outputs walks current_transparent_output past + * n_transparent_outputs, and each extra message wrote a host-controlled + * amount and a 128-byte script_pubkey past the end of + * transparent_outputs[8] -- landing first on transparent_inputs[0] and then + * outside the struct entirely. */ + if (msg->index >= zcash_signing.n_transparent_outputs || + msg->index >= ZCASH_MAX_TRANSPARENT_OUTPUTS || + zcash_signing.current_transparent_output >= + zcash_signing.n_transparent_outputs) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Transparent output index out of range")); + zcash_signing_abort(); + layoutHome(); + return; + } + if (msg->index != zcash_signing.current_transparent_output) { fsm_sendFailure(FailureType_Failure_SyntaxError, _("Unexpected transparent output index")); diff --git a/lib/firmware/mayachain.c b/lib/firmware/mayachain.c index b48f48d8b..d05f86a77 100644 --- a/lib/firmware/mayachain.c +++ b/lib/firmware/mayachain.c @@ -115,8 +115,8 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, char buffer[64 + 1]; size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; + char hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; if (!bech32_decode(hrp, decoded, &decoded_len, to_address)) { return false; } diff --git a/lib/firmware/osmosis.c b/lib/firmware/osmosis.c index 0348c6d15..4cec59dc0 100644 --- a/lib/firmware/osmosis.c +++ b/lib/firmware/osmosis.c @@ -148,8 +148,8 @@ bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address, char buffer[64 + 1]; size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; + char hrp[BECH32_MAX_HRP_LEN + 1] = {0}; + uint8_t decoded[BECH32_DECODED_MAX] = {0}; if (!osmosis_isCanonicalUint64(amount) || !osmosis_isValidDenom(denom) || !bech32_decode(hrp, decoded, &decoded_len, to_address)) { return false; @@ -204,8 +204,8 @@ bool osmosis_signTxUpdateMsgDelegate(const char* amount, char buffer[128] = {0}; size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; + char hrp[BECH32_MAX_HRP_LEN + 1] = {0}; + uint8_t decoded[BECH32_DECODED_MAX] = {0}; if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { return false; @@ -265,8 +265,8 @@ bool osmosis_signTxUpdateMsgUndelegate(const char* amount, char buffer[128] = {0}; size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; + char hrp[BECH32_MAX_HRP_LEN + 1] = {0}; + uint8_t decoded[BECH32_DECODED_MAX] = {0}; if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { return false; @@ -326,8 +326,8 @@ bool osmosis_signTxUpdateMsgRedelegate(const char* amount, char buffer[128] = {0}; size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; + char hrp[BECH32_MAX_HRP_LEN + 1] = {0}; + uint8_t decoded[BECH32_DECODED_MAX] = {0}; if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { return false; @@ -485,8 +485,8 @@ bool osmosis_signTxUpdateMsgRewards(const char* delegator_address, char buffer[128] = {0}; size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; + char hrp[BECH32_MAX_HRP_LEN + 1] = {0}; + uint8_t decoded[BECH32_DECODED_MAX] = {0}; if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { return false; @@ -543,8 +543,8 @@ bool osmosis_signTxUpdateMsgIBCTransfer(const char* amount, const char* sender, char buffer[128] = {0}; size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; + char hrp[BECH32_MAX_HRP_LEN + 1] = {0}; + uint8_t decoded[BECH32_DECODED_MAX] = {0}; if (!bech32_decode(hrp, decoded, &decoded_len, receiver)) { return false; diff --git a/lib/firmware/signtx_tendermint.c b/lib/firmware/signtx_tendermint.c index 92491f877..3ab335685 100644 --- a/lib/firmware/signtx_tendermint.c +++ b/lib/firmware/signtx_tendermint.c @@ -128,8 +128,8 @@ bool tendermint_signTxUpdateMsgSend(const uint64_t amount, if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; + char hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; if (!bech32_decode(hrp, decoded, &decoded_len, to_address)) { return false; @@ -194,8 +194,8 @@ bool tendermint_signTxUpdateMsgDelegate(const uint64_t amount, if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; + char hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { return false; @@ -260,8 +260,8 @@ bool tendermint_signTxUpdateMsgUndelegate(const uint64_t amount, if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; + char hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { return false; @@ -325,8 +325,8 @@ bool tendermint_signTxUpdateMsgRedelegate( if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; + char hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { return false; @@ -399,8 +399,8 @@ bool tendermint_signTxUpdateMsgRewards(const uint64_t* amount, if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; + char hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { return false; @@ -468,8 +468,8 @@ bool tendermint_signTxUpdateMsgIBCTransfer( if (!tendermint_canUpdate()) return false; char buffer[128]; size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; + char hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; if (!bech32_decode(hrp, decoded, &decoded_len, receiver)) { return false; diff --git a/lib/firmware/tendermint.c b/lib/firmware/tendermint.c index 5d10bb23c..342a17b83 100644 --- a/lib/firmware/tendermint.c +++ b/lib/firmware/tendermint.c @@ -99,7 +99,7 @@ bool tendermint_isValidAsset(const char* asset) { bool tendermint_isValidSigner(const char* signer, const char* hrp) { size_t decoded_len; char decoded_hrp[45]; - uint8_t decoded[38]; + uint8_t decoded[BECH32_DECODED_MAX]; if (!signer || !bech32_decode(decoded_hrp, decoded, &decoded_len, signer)) { return false; } diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index 01ee82d69..d25518147 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -116,8 +116,8 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, char buffer[64 + 1]; size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; + char hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; if (!bech32_decode(hrp, decoded, &decoded_len, to_address)) { return false; } From b024f8bdfe69207888a4a39a76a9fdb6b4854915 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 15:00:08 -0600 Subject: [PATCH 39/40] fix(confirm): page confirm bodies instead of dropping their tails draw_string() renders characters until one no longer fits the canvas and then stops. Only BODY_ROWS rows of body text are on screen -- row 4 starts at y=66 on a 64px display -- so any longer body was drawn in part, with no ellipsis and nothing to tell the user that something had been cut. The worst instance is the ERC-20 approve screen, which for any realistic amount or a ticker past five characters drew 39 of the spender's 42 hex characters and dropped the trailing '?'. An attacker who grinds an address matching a known-good contract in its visible prefix gets a screen the user cannot distinguish from the real one. It is one of ~30 sites: Solana stake authorities, TRON recipients, THORChain asset chains, the "NOT verified by KeepKey" warning and the passphrase echo all lose their tails the same way. Fix it once, where every confirm passes through. confirm_helper() now splits an over-long body into screen-sized pages, each with its own ButtonRequest and its own press, and titles them "1/2", "2/2". Bodies that already fit take exactly the path they did before -- same single screen, same title, no extra press -- so only the flows that were already broken change shape. Custom layouts are left alone: they place and size their own body, and layout_zcash_address_text_notification deliberately renders 106 characters as 38+38+30. calc_str_page() gets an early exit. Line count cannot fall as a prefix grows, so the first prefix that does not fit settles the page; without it the scan ran the full body length for every page and a 350-character confirm paused for roughly a second on device. Verified: three board unit tests drive confirm_body_split() over the real ethereum.c bodies -- pages reproduce the body exactly, no page exceeds BODY_ROWS, and fitting bodies stay on one page. Both mutants fail them: forcing a single page reproduces the original truncation, and allowing four rows trips the overflow assertion. The early exit was checked against the old full scan over 19,200 random string/width/row combinations. --- include/keepkey/board/confirm_sm.h | 17 +++ lib/board/confirm_sm.c | 164 ++++++++++++++++++++++------- lib/board/font.c | 6 +- unittests/board/board.cpp | 93 +++++++++++++++- 4 files changed, 240 insertions(+), 40 deletions(-) diff --git a/include/keepkey/board/confirm_sm.h b/include/keepkey/board/confirm_sm.h index 765a90125..44b18d53a 100644 --- a/include/keepkey/board/confirm_sm.h +++ b/include/keepkey/board/confirm_sm.h @@ -86,6 +86,23 @@ typedef struct { typedef void (*layout_notification_t)(const char* str1, const char* str2, NotificationType type); +/// Split a confirm body into screen-sized pages. +/// +/// Only BODY_ROWS rows of body text are on screen; draw_string() stops at the +/// first character that will not fit and says nothing about it, so a longer +/// body loses its tail — the last hex digits of an address, the chain an asset +/// sits on, a "NOT verified" warning. confirm() pages such bodies instead. +/// +/// \param body The body to split. Not modified. +/// \param body_width Row width in pixels — BODY_WIDTH, or +/// BODY_WIDTH_WITH_ICON when an icon takes the left margin. +/// \param index Which page to write to \p out. Ignored if out is NULL. +/// \param out Receives page \p index, NUL-terminated. Must hold +/// BODY_CHAR_MAX bytes. NULL to count pages only. +/// \returns the total number of pages, or 0 if \p body cannot be split. +size_t confirm_body_split(const char* body, uint16_t body_width, size_t index, + char* out); + /// User confirmation. /// \param type The kind of button request to send to the host. /// \param request_title Title of confirm message. diff --git a/lib/board/confirm_sm.c b/lib/board/confirm_sm.c index 8804f5f24..1e5f940f3 100644 --- a/lib/board/confirm_sm.c +++ b/lib/board/confirm_sm.c @@ -20,6 +20,7 @@ #include "keepkey/board/keepkey_display.h" #include "keepkey/board/keepkey_button.h" #include "keepkey/board/timer.h" +#include "keepkey/board/font.h" #include "keepkey/board/layout.h" #include "keepkey/board/messages.h" #include "keepkey/board/confirm_sm.h" @@ -46,6 +47,12 @@ extern bool reset_msg_stack; static CONFIDENTIAL char strbuf[BODY_CHAR_MAX]; +/* Scratch for confirm_helper()'s pagination. Static rather than stack: confirm + * is reached from deep inside the signing call chains, and a 480-byte frame + * there is what boot-faulted rc8. */ +static CONFIDENTIAL char page_body[BODY_CHAR_MAX]; +static char page_title[TITLE_CHAR_MAX]; + /// Handler for push button being pressed. /// \param context current state context. static void handle_screen_press(void* context) { @@ -156,7 +163,7 @@ static void swap_layout(ActiveLayout active_layout, volatile StateInfo* si, /// \param requesta_body The body of the confirmation message. /// \param layout_notification_func layout callback for displaying confirm /// message. \returns true iff the device confirmed. -static bool confirm_helper(const char* request_title_param, +static bool confirm_screen(const char* request_title_param, const char* request_body, layout_notification_t layout_notification_func, bool constant_power, IconType iconNum, @@ -236,7 +243,7 @@ static bool confirm_helper(const char* request_title_param, } ret_stat = false; - goto confirm_helper_exit; + goto confirm_screen_exit; #if DEBUG_LINK case MessageType_MessageType_DebugLinkDecision: @@ -284,7 +291,7 @@ static bool confirm_helper(const char* request_title_param, animate(); } -confirm_helper_exit: +confirm_screen_exit: keepkey_button_set_on_press_handler(NULL, NULL); keepkey_button_set_on_release_handler(NULL, NULL); @@ -292,6 +299,105 @@ static bool confirm_helper(const char* request_title_param, return (ret_stat); } +size_t confirm_body_split(const char* body, uint16_t body_width, size_t index, + char* out) { + if (!body) return 0; + + const Font* font = get_body_font(); + const size_t len = strlen(body); + size_t pages = 0; + + for (size_t offset = 0; offset < len;) { + const size_t take = + calc_str_page(font, body + offset, len - offset, body_width, BODY_ROWS); + /* A glyph wider than the whole body would never advance the offset. Report + * failure rather than spin, or draw a screen that discloses nothing. */ + if (take == 0) return 0; + + if (out && pages == index) { + memcpy(out, body + offset, take); + out[take] = '\0'; + } + + offset += take; + pages++; + } + + /* An empty body is one (empty) page, as it has always been. */ + return pages ? pages : 1; +} + +/// Show a confirmation, across as many screens as its body needs. +/// +/// draw_string() renders until a character no longer fits the canvas and then +/// simply stops: a body longer than BODY_ROWS was drawn in part, with no +/// ellipsis and nothing to tell the user that the tail of an address, an +/// amount or a warning had been dropped. Every such body is now split into +/// screen-sized pages, each its own ButtonRequest and its own press, so a +/// screen either discloses its content or does not exist. +/// +/// Bodies that already fit take exactly the path they did before: one page, no +/// page counter in the title. +/// +/// \param button_request Sent to the host before each page. NULL to send none. +/// \param request_title Title of confirm message. +/// \param request_body Body of confirm message. +/// \param layout_notification_func layout callback for displaying confirm +/// message. \returns true iff the device confirmed every page. +static bool confirm_helper(const ButtonRequest* button_request, + const char* request_title, const char* request_body, + layout_notification_t layout_notification_func, + bool constant_power, IconType iconNum, + bool immediate) { + /* Custom layouts place and size their own body — layout_zcash_address_text_ + * notification deliberately renders 106 characters as 38+38+30. Only the two + * standard layouts are known to wrap at BODY_WIDTH over BODY_ROWS rows. */ + const bool standard = + layout_notification_func == &layout_standard_notification; + const bool pageable = standard || layout_notification_func == + &layout_constant_power_notification; + + uint16_t body_width = BODY_WIDTH; + if (standard && iconNum != NO_ICON) { + body_width = BODY_WIDTH_WITH_ICON; + } + + const size_t pages = + pageable ? confirm_body_split(request_body, body_width, 0, NULL) : 1; + if (pages == 0) return false; + + if (pages == 1) { + if (button_request) { + msg_write(MessageType_MessageType_ButtonRequest, button_request); + } + return confirm_screen(request_title, request_body, layout_notification_func, + constant_power, iconNum, immediate); + } + + for (size_t page = 0; page < pages; page++) { + if (confirm_body_split(request_body, body_width, page, page_body) == 0) { + return false; + } + snprintf(page_title, sizeof(page_title), "%s %u/%u", request_title, + (unsigned)(page + 1), (unsigned)pages); + + if (button_request) { + /* Each page is a fresh press the host has to be told about, and must not + * be dismissable before it acks. */ + button_request_acked = false; + msg_write(MessageType_MessageType_ButtonRequest, button_request); + } + + const bool ok = + confirm_screen(page_title, page_body, layout_notification_func, + constant_power, iconNum, immediate); + memzero(page_body, sizeof(page_body)); + if (!ok) return false; + } + + return true; +} + bool confirm(ButtonRequestType type, const char* request_title, const char* request_body, ...) { button_request_acked = false; @@ -301,16 +407,14 @@ bool confirm(ButtonRequestType type, const char* request_title, vsnprintf(strbuf, sizeof(strbuf), request_body, vl); va_end(vl); - /* Send button request */ ButtonRequest resp; memset(&resp, 0, sizeof(ButtonRequest)); resp.has_code = true; resp.code = type; - msg_write(MessageType_MessageType_ButtonRequest, &resp); bool ret = - confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false); + confirm_helper(&resp, request_title, strbuf, + &layout_standard_notification, false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -329,11 +433,10 @@ bool confirm_with_icon(ButtonRequestType type, IconType iconNum, memset(&resp, 0, sizeof(ButtonRequest)); resp.has_code = true; resp.code = type; - msg_write(MessageType_MessageType_ButtonRequest, &resp); bool ret = - confirm_helper(request_title, strbuf, &layout_standard_notification, - false, iconNum, false); + confirm_helper(&resp, request_title, strbuf, + &layout_standard_notification, false, iconNum, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -347,16 +450,14 @@ bool confirm_constant_power(ButtonRequestType type, const char* request_title, vsnprintf(strbuf, sizeof(strbuf), request_body, vl); va_end(vl); - /* Send button request */ ButtonRequest resp; memset(&resp, 0, sizeof(ButtonRequest)); resp.has_code = true; resp.code = type; - msg_write(MessageType_MessageType_ButtonRequest, &resp); bool ret = - confirm_helper(request_title, strbuf, &layout_constant_power_notification, - true, NO_ICON, false); + confirm_helper(&resp, request_title, strbuf, + &layout_constant_power_notification, true, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -371,12 +472,9 @@ bool confirm_with_custom_button_request(const ButtonRequest* button_request, vsnprintf(strbuf, sizeof(strbuf), request_body, vl); va_end(vl); - /* Send button request */ - msg_write(MessageType_MessageType_ButtonRequest, button_request); - bool ret = - confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false); + confirm_helper(button_request, request_title, strbuf, + &layout_standard_notification, false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -392,15 +490,13 @@ bool confirm_with_custom_layout(layout_notification_t layout_notification_func, vsnprintf(strbuf, sizeof(strbuf), request_body, vl); va_end(vl); - /* Send button request */ ButtonRequest resp; memset(&resp, 0, sizeof(ButtonRequest)); resp.has_code = true; resp.code = type; - msg_write(MessageType_MessageType_ButtonRequest, &resp); - bool ret = confirm_helper(request_title, strbuf, layout_notification_func, - false, NO_ICON, false); + bool ret = confirm_helper(&resp, request_title, strbuf, + layout_notification_func, false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -415,7 +511,7 @@ bool confirm_without_button_request(const char* request_title, va_end(vl); bool ret = - confirm_helper(request_title, strbuf, &layout_standard_notification, + confirm_helper(NULL, request_title, strbuf, &layout_standard_notification, false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; @@ -430,16 +526,14 @@ bool review(ButtonRequestType type, const char* request_title, vsnprintf(strbuf, sizeof(strbuf), request_body, vl); va_end(vl); - /* Send button request */ ButtonRequest resp; memset(&resp, 0, sizeof(ButtonRequest)); resp.has_code = true; resp.code = type; - msg_write(MessageType_MessageType_ButtonRequest, &resp); bool ret = - confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false); + confirm_helper(&resp, request_title, strbuf, + &layout_standard_notification, false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -454,7 +548,7 @@ bool review_without_button_request(const char* request_title, va_end(vl); bool ret = - confirm_helper(request_title, strbuf, &layout_standard_notification, + confirm_helper(NULL, request_title, strbuf, &layout_standard_notification, false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; @@ -470,16 +564,14 @@ bool review_with_icon(ButtonRequestType type, IconType iconNum, vsnprintf(strbuf, sizeof(strbuf), request_body, vl); va_end(vl); - /* Send button request */ ButtonRequest resp; memset(&resp, 0, sizeof(ButtonRequest)); resp.has_code = true; resp.code = type; - msg_write(MessageType_MessageType_ButtonRequest, &resp); bool ret = - confirm_helper(request_title, strbuf, &layout_standard_notification, - false, iconNum, false); + confirm_helper(&resp, request_title, strbuf, + &layout_standard_notification, false, iconNum, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -493,16 +585,14 @@ bool review_immediate(ButtonRequestType type, const char* request_title, vsnprintf(strbuf, sizeof(strbuf), request_body, vl); va_end(vl); - /* Send button request */ ButtonRequest resp; memset(&resp, 0, sizeof(ButtonRequest)); resp.has_code = true; resp.code = type; - msg_write(MessageType_MessageType_ButtonRequest, &resp); bool ret = - confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, true); + confirm_helper(&resp, request_title, strbuf, + &layout_standard_notification, false, NO_ICON, true); memzero(strbuf, sizeof(strbuf)); return ret; } diff --git a/lib/board/font.c b/lib/board/font.c index d00c215e7..5f4224331 100644 --- a/lib/board/font.c +++ b/lib/board/font.c @@ -2654,7 +2654,11 @@ size_t calc_str_page(const Font* font, const char* str, size_t str_len, uint16_t line_width, uint32_t max_lines) { size_t best = 0; for (size_t take = 1; take <= str_len; take++) { - if (calc_str_line_n(font, str, take, line_width) <= max_lines) best = take; + /* A longer prefix never needs fewer lines, so the first prefix that does + * not fit settles it. Without this the scan runs the full length of the + * body for every page, which a 350-character confirm feels as a pause. */ + if (calc_str_line_n(font, str, take, line_width) > max_lines) break; + best = take; } return best; } diff --git a/unittests/board/board.cpp b/unittests/board/board.cpp index ec27b5acb..e98e055f5 100644 --- a/unittests/board/board.cpp +++ b/unittests/board/board.cpp @@ -1,9 +1,98 @@ +// gtest first: confirm_sm.h defines an isprint() macro that collides with the +// standard library's declaration if the C++ headers are pulled in after it. +#include "gtest/gtest.h" + +#include +#include + extern "C" { #include "keepkey/board/keepkey_board.h" +#include "keepkey/board/confirm_sm.h" +#include "keepkey/board/font.h" +#include "keepkey/board/layout.h" } -#include "gtest/gtest.h" - TEST(Board, Shutdown) { EXPECT_EXIT(shutdown(), ::testing::ExitedWithCode(1), ""); } + +// Exactly BODY_ROWS rows of body text fit on the display: the body starts at +// TOP_MARGIN + font_height + BODY_TOP_MARGIN and advances by font_height + +// BODY_FONT_LINE_PADDING, so row 4 begins at y=66 on a 64px-tall screen and +// draw_char_with_shift() refuses to draw it. Nothing announces that - +// draw_string() simply stops - so a body of BODY_ROWS+1 rows loses its tail +// silently. confirm_helper() pages such bodies instead; these are the +// properties that split has to hold. +namespace { + +constexpr uint32_t kRows = BODY_ROWS; +constexpr uint16_t kWidth = BODY_WIDTH; + +// Real bodies from ethereum.c's layoutEthereumConfirmTx(). All four sit within +// a few characters of the limit, which is why the overflow is value-dependent +// and went unnoticed: swap wstETH for ETH, or 1000000 for 1, and it fits. +const char *const kOverflowing[] = { + // "Unlock full %s balance for withdrawal by %s?" + "Unlock full wstETH balance for withdrawal by " + "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984?", + // "Approve withdrawal of up to %s by %s?" + "Approve withdrawal of up to 1000000 USDC by " + "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984?", + "Approve withdrawal of up to 0.000000000000000001 ETH by " + "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984?", +}; + +const char *const kFitting[] = { + "Send 1.5 ETH to 0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", + "Remove ability for 0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984 to withdraw " + "USDC?", +}; + +// Walks confirm_body_split() exactly as confirm_helper() does: count, then +// fetch each page by index. +std::string JoinPages(const char *body, size_t *pages) { + *pages = confirm_body_split(body, kWidth, 0, NULL); + EXPECT_GT(*pages, 0u) << "|" << body << "| could not be split"; + + std::string joined; + for (size_t i = 0; i < *pages; i++) { + char page[BODY_CHAR_MAX]; + EXPECT_EQ(confirm_body_split(body, kWidth, i, page), *pages); + EXPECT_LE(calc_str_line(get_body_font(), page, kWidth), kRows) + << "page " << (i + 1) << " of |" << body << "| still overflows"; + joined += page; + } + return joined; +} + +} // namespace + +// The bug: these bodies do not fit, so today they are drawn in part. +TEST(Board, ConfirmBodiesThatOverflowAreDetected) { + for (const char *body : kOverflowing) { + EXPECT_GT(calc_str_line(get_body_font(), body, kWidth), kRows) + << "|" << body << "| no longer overflows; pick a new vector rather " + << "than deleting this case"; + } +} + +// The fix: paging discloses every character. Dropping the ERC-20 spender's +// last three hex digits is what lets a look-alike address pass review. +TEST(Board, ConfirmPagesDiscloseTheWholeBody) { + for (const char *body : kOverflowing) { + size_t pages = 0; + EXPECT_EQ(JoinPages(body, &pages), std::string(body)); + EXPECT_GT(pages, 1u) << "|" << body << "| should have been split"; + } +} + +// And bodies that already fit keep their single screen — pagination must not +// add a press to the flows that were never broken. +TEST(Board, ConfirmFittingBodiesAreNotPaged) { + for (const char *body : kFitting) { + ASSERT_LE(calc_str_line(get_body_font(), body, kWidth), kRows) << body; + size_t pages = 0; + EXPECT_EQ(JoinPages(body, &pages), std::string(body)); + EXPECT_EQ(pages, 1u) << "|" << body << "| was split unnecessarily"; + } +} From 6b6f3310d6499662bbb4bd2b0a1ae0241ea33b0a Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 9 Aug 2026 16:31:17 -0600 Subject: [PATCH 40/40] test(memo): count the screens confirm() now pages, and fail fast when wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b024f8bdf made confirm() page a body too long for BODY_ROWS instead of silently dropping its tail. Two memo vectors cross that line, so the swap tests waited on presses nobody queued and hung until CI killed the job: unit-tests at its 10-minute timeout, python-integration-tests at its 30 — both reported "cancelled", which reads like flake rather than a wrong expectation. python-integration-tests runs firmware-unit before the pyk suite, so the pyk tests never ran at all. Measured against the real font tables rather than guessed: Confirm swap asset USDT-0xdac17f95...\n on chain ETH 4 rows -> 2 pages Confirm to 0x<238 d's> 8 rows -> 3 pages so MemoSwapFullFormShowsAffiliate is 5 presses (2+1+1+1) and MemoExactBufferCapacityKeepsLastChar is 6 (1+3+1+1), in both the thorchain and mayachain suites. Nothing else in the suite pages: the 42-character destination addresses land on exactly 3 rows and still fit. That the USDT screen paged is the bug being fixed showing itself — the tail of that contract address was never reaching the display. kkconfirm_preload() now queues one trailing rejection as a sentinel. An unbudgeted screen consumes it, is rejected, and the call under test returns false immediately, so a wrong count fails in milliseconds with a negative drain instead of hanging; kkconfirm_drain() discounts the sentinel so existing "== 0" assertions keep their meaning. Verified by mutation: with preload(4) against 5 screens the test fails in 5 ms on drain() == -2. 405/405 firmware-unit tests pass. --- unittests/firmware/mayachain.cpp | 10 +++++--- unittests/firmware/thorchain.cpp | 44 ++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/unittests/firmware/mayachain.cpp b/unittests/firmware/mayachain.cpp index cf17b29c2..7c6511cd2 100644 --- a/unittests/firmware/mayachain.cpp +++ b/unittests/firmware/mayachain.cpp @@ -157,9 +157,11 @@ static bool parseMayaMemo(const char *memo) { return parseMayaMemo(memo, strlen(memo) + 1); } -// Classic full-form swap memo = 4 screens (4th is the affiliate fee screen) +// Classic full-form swap memo = 4 screens (4th is the affiliate fee screen), +// but the asset screen is 4 rows against a 3-row body, so it pages into +// 1/2 + 2/2 = 5 presses. See thorchain.cpp for the same memo. TEST(Mayachain, MemoSwapFullFormShowsAffiliate) { - ASSERT_TRUE(kkconfirm_preload(4, 0)); + ASSERT_TRUE(kkconfirm_preload(5, 0)); EXPECT_TRUE(parseMayaMemo( "SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:" "0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75")); @@ -225,7 +227,9 @@ TEST(Mayachain, MemoExactBufferCapacityKeepsLastChar) { suffix; ASSERT_EQ(memo.size(), 256u); - ASSERT_TRUE(kkconfirm_preload(4, 0)); + /* 6 presses, not 4: the 240-char destination needs 8 rows, so its screen + * pages 3 ways (1 + 3 + 1 + 1). Every byte of the memo reaches the screen. */ + ASSERT_TRUE(kkconfirm_preload(6, 0)); EXPECT_TRUE(parseMayaMemo(memo.c_str(), memo.size())); /* no NUL counted */ EXPECT_EQ(0, kkconfirm_drain()); } diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index aa2a338ec..1292469d5 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -34,7 +34,13 @@ void kk_board_init(void); * from the socket queue. Preloading exactly N accept pairs before invoking * the code under test auto-accepts exactly N screens, and * kkconfirm_drain() == 0 afterwards proves exactly N screens were shown - * (fewer screens leave packets queued; more screens would hang the test). + * (fewer screens leave packets queued; more screens HANG the test until the + * CI job hits its timeout and reports "cancelled", which reads like flake + * rather than a wrong expectation — so get the count right). + * + * Screen counts are value-dependent now that confirm() pages a body too long + * for BODY_ROWS: the same format string is one screen for a 3-row body and + * two for a 4-row one. Long test vectors are the ones to check. * * These helpers have external linkage so mayachain.cpp can share the * one-time board/usb initialization. @@ -64,7 +70,20 @@ static bool kkconfirm_sendTiny(uint16_t msgId, const uint8_t* payload, sizeof(addr)) == (ssize_t)sizeof(frame); } -// Queue nYes accepted screens followed by nNo rejected screens. +/* One ButtonAck + one DebugLinkDecision, i.e. what a single screen eats. */ +#define KKCONFIRM_MSGS_PER_SCREEN 2 + +// Queue nYes accepted screens followed by nNo rejected screens, plus one +// trailing rejection as a sentinel. +// +// The sentinel is what keeps a wrong count cheap. A screen the test did not +// budget for consumes it, is rejected, and the code under test returns false +// immediately, so the test FAILS in milliseconds. Without it that extra +// screen blocks forever on an answer nobody queued and the only symptom is a +// CI job burning its whole timeout and reporting "cancelled" — which reads +// like infrastructure flake rather than a wrong expectation. confirm() paging +// long bodies makes screen counts value-dependent, so this is a mistake worth +// catching in the harness instead of in a 30-minute timeout. bool kkconfirm_preload(int nYes, int nNo) { static bool initialized = false; if (!initialized) { @@ -76,7 +95,7 @@ bool kkconfirm_preload(int nYes, int nNo) { static const uint8_t yes[] = {0x08, 0x01}; // DebugLinkDecision.yes_no static const uint8_t no[] = {0x08, 0x00}; - for (int i = 0; i < nYes + nNo; i++) { + for (int i = 0; i < nYes + nNo + 1; i++) { if (!kkconfirm_sendTiny(MessageType_MessageType_ButtonAck, NULL, 0)) return false; const uint8_t* decision = (i < nYes) ? yes : no; @@ -87,7 +106,10 @@ bool kkconfirm_preload(int nYes, int nNo) { return true; } -// Consume and count any tiny messages left in the queue. +// Consume and count any tiny messages left in the queue, discounting the +// sentinel kkconfirm_preload() always queues. 0 keeps meaning exactly what it +// meant before — every preloaded screen was shown and no more. A NEGATIVE +// count means the sentinel was consumed: more screens than the test expected. int kkconfirm_drain(void) { uint8_t buf[MSG_TINY_BFR_SZ]; int n = 0; @@ -99,7 +121,7 @@ int kkconfirm_drain(void) { if (id == MSG_TINY_TYPE_ERROR) break; n++; } - return n; + return n - KKCONFIRM_MSGS_PER_SCREEN; } // Vectors computed with the trezor-crypto library directly (see @@ -196,9 +218,13 @@ static bool parseMemo(const char* memo) { } // Classic full-form swap memo: asset + dest + limit + affiliate + fee bps -// = 4 screens (the 4th is the new affiliate fee screen) +// = 4 screens (the 4th is the new affiliate fee screen), but the asset screen +// pages: "Confirm swap asset USDT-0xdac...ec7\n on chain ETH" is 4 rows and a +// body only gets BODY_ROWS=3, so it is shown as 1/2 + 2/2 = 5 presses. Before +// confirm() paged, that 4th row — the tail of the USDT contract address — was +// simply dropped from the screen. TEST(Thorchain, MemoSwapFullFormShowsAffiliate) { - ASSERT_TRUE(kkconfirm_preload(4, 0)); + ASSERT_TRUE(kkconfirm_preload(5, 0)); EXPECT_TRUE( parseMemo("SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:" "0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75")); @@ -311,7 +337,9 @@ TEST(Thorchain, MemoExactBufferCapacityKeepsLastChar) { prefix + std::string(256 - prefix.size() - suffix.size(), 'd') + suffix; ASSERT_EQ(memo.size(), 256u); - ASSERT_TRUE(kkconfirm_preload(4, 0)); + /* 6 presses, not 4: the 240-char destination needs 8 rows, so its screen + * pages 3 ways (1 + 3 + 1 + 1). Every byte of the memo reaches the screen. */ + ASSERT_TRUE(kkconfirm_preload(6, 0)); EXPECT_TRUE(parseMemo(memo.c_str(), memo.size())); /* no NUL counted */ EXPECT_EQ(0, kkconfirm_drain()); }