From 49587ccf8cb3f34738122342dc4bfdec0da95600 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 00:47:06 -0300 Subject: [PATCH 01/55] release(7.15.0): consolidated release delta over upstream develop Full 7.15.0 feature set as one reviewable unit: - EVM clear-signing: signed-metadata v1 + v2 static schema, runtime LoadClearsignSigner (warning-gated, no built-in keys in phase 1), persistent identities + trust icons (STORAGE_VERSION 17->18) - EVM signing hardening: RLP zero-value canonicalisation (EIP-1559), EIP-712 cancel propagation, token chain_id widening - New chains: Hive SLIP-0048 (SignTx / SignOperations / SignMessage / account ops), Zcash Orchard shielded (PCZT signing, FVK export, unified addresses, signing progress UX), Ripple memos, THORChain any-denom - Per-chain clear/blind-sign: TRON, Solana v0, TON, Maya/THOR affiliate - Robustness: BIP-85, BIP-39 recovery validation, fault-injection hardening, SRAM frame arena + 16KiB reserve gate + CI budget - Build: bitcoin-only / zcash-privacy variants + seed-lock, 3-variant CI matrix, emulator dylib (Windows cross-compile, confirm gating) Pins: device-protocol 4eb7d5e / python-keepkey c475482 (tips of keepkey up/release-protocol and reconcile/upstream-sync; pin-swap to master follows device-protocol#112 / python-keepkey#197). --- .github/workflows/ci.yml | 117 +- .github/workflows/release.yml | 59 +- .gitmodules | 6 +- CMakeLists.txt | 71 +- cmake/toolchains/mingw-w64-x86_64.cmake | 40 + deps/crypto/CMakeLists.txt | 11 + deps/crypto/trezor-firmware | 2 +- deps/device-protocol | 2 +- deps/python-keepkey | 2 +- include/keepkey/board/bsd_compat.h | 28 + include/keepkey/board/confirm_sm.h | 4 + include/keepkey/board/draw.h | 25 + include/keepkey/board/layout.h | 15 + include/keepkey/board/messages.h | 6 + include/keepkey/emulator/libkkemu.h | 78 +- include/keepkey/firmware/app_confirm.h | 3 + include/keepkey/firmware/app_layout.h | 10 +- include/keepkey/firmware/bip85.h | 22 + include/keepkey/firmware/coins.def | 2 + include/keepkey/firmware/coins.h | 2 + include/keepkey/firmware/eip712.h | 3 +- include/keepkey/firmware/ethereum.h | 1 + .../firmware/ethereum_contracts/thortx.h | 14 +- include/keepkey/firmware/ethereum_tokens.h | 10 +- include/keepkey/firmware/fsm.h | 20 + include/keepkey/firmware/hive.h | 163 ++ include/keepkey/firmware/mayachain.h | 9 + include/keepkey/firmware/reset.h | 10 + include/keepkey/firmware/signed_metadata.h | 207 ++ include/keepkey/firmware/solana.h | 4 + include/keepkey/firmware/storage.h | 20 +- include/keepkey/firmware/tendermint.h | 6 + include/keepkey/firmware/thorchain.h | 11 +- include/keepkey/firmware/tron.h | 55 + include/keepkey/firmware/zcash.h | 378 ++++ include/keepkey/transport/interface.h | 2 + .../transport/messages-ethereum.options | 3 + .../keepkey/transport/messages-hive.options | 58 + .../keepkey/transport/messages-ripple.options | 2 + .../transport/messages-thorchain.options | 1 + .../keepkey/transport/messages-zcash.options | 54 + lib/board/confirm_sm.c | 51 +- lib/board/draw.c | 85 +- lib/board/layout.c | 136 +- lib/board/messages.c | 130 +- lib/board/signatures.c | 48 +- lib/board/timer.c | 30 +- lib/board/udp.c | 6 + lib/board/usb.c | 49 +- lib/emulator/CMakeLists.txt | 7 + lib/emulator/libkkemu.c | 337 +++- lib/emulator/setup.c | 33 +- lib/emulator/udp.c | 10 +- lib/firmware/CMakeLists.txt | 65 +- lib/firmware/app_confirm.c | 25 + lib/firmware/app_layout.c | 92 +- lib/firmware/bip85.c | 105 + lib/firmware/coins.c | 2 + lib/firmware/eip712.c | 72 +- lib/firmware/eos.c | 6 + lib/firmware/ethereum.c | 182 +- lib/firmware/ethereum_contracts.c | 24 +- lib/firmware/ethereum_contracts/saproxy.c | 3 +- lib/firmware/ethereum_contracts/thortx.c | 84 +- lib/firmware/ethereum_contracts/zxappliquid.c | 3 +- lib/firmware/ethereum_contracts/zxliquidtx.c | 21 +- lib/firmware/ethereum_contracts/zxswap.c | 20 +- .../ethereum_contracts/zxtransERC20.c | 4 + lib/firmware/ethereum_tokens.c | 5 +- lib/firmware/fsm.c | 44 +- lib/firmware/fsm_msg_bip85.h | 142 ++ lib/firmware/fsm_msg_common.h | 52 +- lib/firmware/fsm_msg_ethereum.h | 160 +- lib/firmware/fsm_msg_hive.h | 750 +++++++ lib/firmware/fsm_msg_mayachain.h | 59 +- lib/firmware/fsm_msg_ripple.h | 10 + lib/firmware/fsm_msg_solana.h | 116 +- lib/firmware/fsm_msg_thorchain.h | 54 +- lib/firmware/fsm_msg_ton.h | 40 +- lib/firmware/fsm_msg_tron.h | 135 +- lib/firmware/fsm_msg_zcash.h | 1385 +++++++++++++ lib/firmware/hive.c | 662 ++++++ lib/firmware/mayachain.c | 192 +- lib/firmware/messagemap.def | 48 + lib/firmware/recovery_cipher.c | 81 +- lib/firmware/reset.c | 34 +- lib/firmware/ripple.c | 19 + lib/firmware/signed_metadata.c | 1008 ++++++++++ lib/firmware/solana.c | 55 +- lib/firmware/storage.c | 253 ++- lib/firmware/storage.h | 43 + lib/firmware/storage_versions.inc | 3 +- lib/firmware/tendermint.c | 42 + lib/firmware/thorchain.c | 188 +- lib/firmware/tiny-json.c | 8 +- lib/firmware/transaction.c | 9 + lib/firmware/tron.c | 359 ++++ lib/firmware/zcash.c | 1129 +++++++++++ lib/rand/rng.c | 50 +- lib/transport/CMakeLists.txt | 18 + scripts/emulator/python-keepkey-tests.sh | 9 +- tools/check_sram_budget.py | 124 ++ tools/emulator/CMakeLists.txt | 44 +- tools/firmware/CMakeLists.txt | 4 +- tools/firmware/keepkey.ld | 10 + tools/sram-budgets.json | 10 + unittests/crypto/CMakeLists.txt | 16 + unittests/firmware/CMakeLists.txt | 31 +- unittests/firmware/coins.cpp | 11 + unittests/firmware/mayachain.cpp | 176 +- unittests/firmware/signed_metadata.cpp | 1415 +++++++++++++ unittests/firmware/solana.cpp | 146 +- unittests/firmware/storage.cpp | 132 +- unittests/firmware/thorchain.cpp | 323 ++- unittests/firmware/tron.cpp | 495 +++++ unittests/firmware/zcash.cpp | 1787 +++++++++++++++++ 116 files changed, 14392 insertions(+), 660 deletions(-) create mode 100644 cmake/toolchains/mingw-w64-x86_64.cmake create mode 100644 include/keepkey/board/bsd_compat.h create mode 100644 include/keepkey/firmware/bip85.h create mode 100644 include/keepkey/firmware/hive.h create mode 100644 include/keepkey/firmware/signed_metadata.h create mode 100644 include/keepkey/firmware/zcash.h create mode 100644 include/keepkey/transport/messages-hive.options create mode 100644 include/keepkey/transport/messages-zcash.options create mode 100644 lib/firmware/bip85.c create mode 100644 lib/firmware/fsm_msg_bip85.h create mode 100644 lib/firmware/fsm_msg_hive.h create mode 100644 lib/firmware/fsm_msg_zcash.h create mode 100644 lib/firmware/hive.c create mode 100644 lib/firmware/signed_metadata.c create mode 100644 lib/firmware/zcash.c create mode 100644 tools/check_sram_budget.py create mode 100644 tools/sram-budgets.json create mode 100644 unittests/firmware/signed_metadata.cpp create mode 100644 unittests/firmware/tron.cpp create mode 100644 unittests/firmware/zcash.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 784473e79..697aa3c74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,15 +9,16 @@ # └─ 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 / zcash-privacy] +# └─ 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 @@ -99,7 +100,7 @@ jobs: static-analysis: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 @@ -212,9 +213,23 @@ jobs: # ═══════════════════════════════════════════════════════════ build-emulator: + name: build-emulator${{ matrix.label }} needs: [lint-format, static-analysis, check-submodules, secret-scan] 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" + - variant: zcash-privacy + label: " (zcash-privacy)" + cmake_flags: "-DKK_ZCASH_PRIVACY=ON" steps: - name: Checkout uses: actions/checkout@v6 @@ -250,27 +265,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 with: - name: emu-image + name: emu-image-${{ matrix.variant }} path: /tmp/emu-image.tar retention-days: 1 build-arm-firmware: + name: build-arm-firmware${{ matrix.label }} needs: [lint-format, static-analysis, check-submodules, secret-scan] 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" + - variant: zcash-privacy + label: " (zcash-privacy)" + cmake_flags: "-DKK_ZCASH_PRIVACY=ON" steps: - name: Checkout uses: actions/checkout@v6 @@ -312,7 +342,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 +350,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 zcash-privacy variant 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 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,27 +406,41 @@ 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" + - variant: zcash-privacy + label: " (zcash-privacy)" + cmake_flags: "-DKK_ZCASH_PRIVACY=ON" steps: - name: Download emulator image uses: actions/download-artifact@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; \ @@ -385,7 +450,7 @@ jobs: uses: actions/upload-artifact@v7 if: always() with: - name: unit-test-results + name: unit-test-results-${{ matrix.variant }} path: test-reports/firmware-unit/ retention-days: 30 @@ -684,7 +749,10 @@ jobs: uses: actions/download-artifact@v4 continue-on-error: true with: - name: unit-test-results + # Report covers the full/default variant only — bitcoin-only and + # zcash-privacy are built and unit-tested in their own matrix legs + # but don't get a PDF (see unit-tests / build-arm-firmware). + name: unit-test-results-full path: test-reports/firmware-unit/ - name: Download python test results @@ -744,7 +812,8 @@ jobs: - name: Download emulator image uses: actions/download-artifact@v8 with: - name: emu-image + # Only the full/default variant is published to DockerHub. + name: emu-image-full path: /tmp - name: Load emulator image @@ -759,8 +828,8 @@ 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee6c178c7..b01ba9a96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,6 +49,16 @@ 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" + - variant: zcash-privacy + cmake_flags: "-DKK_ZCASH_PRIVACY=ON" steps: - uses: actions/checkout@v6 with: @@ -71,7 +81,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,7 +89,8 @@ 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/ && \ @@ -90,7 +101,7 @@ jobs: - 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 +120,17 @@ 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: Upload release artifacts uses: actions/upload-artifact@v7 with: - name: release-firmware + name: release-firmware-${{ matrix.variant }} path: release/* retention-days: 90 @@ -125,6 +138,16 @@ 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" + - variant: zcash-privacy + cmake_flags: "-DKK_ZCASH_PRIVACY=ON" steps: - uses: actions/checkout@v6 with: @@ -147,10 +170,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: @@ -163,13 +188,14 @@ jobs: - name: Download firmware artifacts uses: actions/download-artifact@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 release-assets/ ls -lh release-assets/ - name: Generate release body @@ -178,15 +204,22 @@ jobs: cat > release-body.md <.txt \`\`\` > **DRAFT** — firmware must be signed by 3/5 key holders before publishing. - ### 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 diff --git a/.gitmodules b/.gitmodules index 2d6c4446a..b8d6fe804 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "deps/device-protocol"] path = deps/device-protocol -url = https://github.com/keepkey/device-protocol.git -branch = master + url = https://github.com/keepkey/device-protocol.git +branch = up/release-protocol [submodule "deps/trezor-firmware"] path = deps/crypto/trezor-firmware url = https://github.com/keepkey/trezor-firmware.git @@ -14,7 +14,7 @@ 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 +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..e0f9ff8cb 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,18 @@ 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) +# The emulator runs on a host with no flash/SRAM budget, so it defaults the +# Zcash shielded/Orchard privacy engine ON -- this is what makes the published +# libkkemu dylib and Docker image (what vault and auditors actually run) +# include it. Device firmware keeps the OFF default and only ships the engine +# via an explicit -DKK_ZCASH_PRIVACY=ON. Bitcoin-only can never enable it +# (mutually exclusive, see below), so it forces the OFF default too. +if(KK_EMULATOR AND NOT KK_BITCOIN_ONLY) + option(KK_ZCASH_PRIVACY "Build the Zcash shielded/Orchard privacy engine" ON) +else() + option(KK_ZCASH_PRIVACY "Build the Zcash shielded/Orchard privacy engine" OFF) +endif() # When building the dylib, every static lib it links (kkfirmware, kkboard, # trezorcrypto, kkrand, kktransport, qrcodegenerator, SecAESSTM32, ...) must @@ -84,6 +96,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 +116,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 +150,44 @@ add_definitions(-DBIP39_WORDLIST_PADDED=1) add_definitions(-DAES_128=1) +# NOTE: AES table size is selected per-variant below. This PR splits Zcash +# (and its Pallas curve arithmetic) into the zcash-privacy variant, so the +# default and bitcoin-only images regain flash headroom and keep the fast +# FOUR_TABLES AES; only zcash-privacy opts into AES_SMALL_TABLES. + if(${KK_DEBUG_LINK}) add_definitions(-DDEBUG_LINK=1) else() add_definitions(-DDEBUG_LINK=0) endif() +# Bitcoin-only removes the Zcash coin entirely, which the shielded/Orchard +# privacy engine depends on -- the two flags cannot coexist. +if(${KK_BITCOIN_ONLY} AND ${KK_ZCASH_PRIVACY}) + message( + FATAL_ERROR + "KK_BITCOIN_ONLY and KK_ZCASH_PRIVACY are mutually exclusive: bitcoin-only strips the Zcash coin that the privacy engine requires." + ) +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 this variant tightest on flash; shrink the + # Gladman AES lookup tables from 4KB to 1KB each (-15,360 bytes ROM, + # slightly slower AES). full/bitcoin-only keep 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 +220,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/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..47ca306f2 100644 --- a/deps/crypto/CMakeLists.txt +++ b/deps/crypto/CMakeLists.txt @@ -57,6 +57,17 @@ 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 default and bitcoin-only images. +if(${KK_ZCASH_PRIVACY}) + list(APPEND sources + trezor-firmware/crypto/pallas.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/deps/crypto/trezor-firmware b/deps/crypto/trezor-firmware index 03d8a55a8..56f404e45 160000 --- a/deps/crypto/trezor-firmware +++ b/deps/crypto/trezor-firmware @@ -1 +1 @@ -Subproject commit 03d8a55a832fb61bb89477ef7239a80ecb367080 +Subproject commit 56f404e452bc7738cd3b3e14454dfecb25c083bf diff --git a/deps/device-protocol b/deps/device-protocol index d637b7829..4eb7d5e4f 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit d637b78291a423fd8119df9935a9365be8a7758e +Subproject commit 4eb7d5e4f30ab75930df59cb19e40500217a68e8 diff --git a/deps/python-keepkey b/deps/python-keepkey index fabd6c618..c4754827a 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit fabd6c6189b7f1b3ea7cbd1d372fc13729761178 +Subproject commit c4754827afd6d1e0f2abd6c5d18e7a220d55a27d 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/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/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/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/app_confirm.h b/include/keepkey/firmware/app_confirm.h index 15fcd7c64..da9610ede 100644 --- a/include/keepkey/firmware/app_confirm.h +++ b/include/keepkey/firmware/app_confirm.h @@ -50,6 +50,9 @@ 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/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/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/eip712.h b/include/keepkey/firmware/eip712.h index 686acd6de..9b5ba1a9a 100644 --- a/include/keepkey/firmware/eip712.h +++ b/include/keepkey/firmware/eip712.h @@ -95,8 +95,9 @@ 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); diff --git a/include/keepkey/firmware/ethereum.h b/include/keepkey/firmware/ethereum.h index 77c8f4775..b15cf07f7 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); diff --git a/include/keepkey/firmware/ethereum_contracts/thortx.h b/include/keepkey/firmware/ethereum_contracts/thortx.h index 8f61c27f3..03fcd7b03 100644 --- a/include/keepkey/firmware/ethereum_contracts/thortx.h +++ b/include/keepkey/firmware/ethereum_contracts/thortx.h @@ -30,7 +30,17 @@ "\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" + +/* 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 +53,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_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..81e3e841d 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); @@ -131,6 +133,22 @@ 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 +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); void fsm_msgDebugLinkGetState(DebugLinkGetState* msg); @@ -142,4 +160,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/hive.h b/include/keepkey/firmware/hive.h new file mode 100644 index 000000000..145b55704 --- /dev/null +++ b/include/keepkey/firmware/hive.h @@ -0,0 +1,163 @@ +#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 + +// ── Graphene operation type IDs ─────────────────────────────────────────── +#define HIVE_OP_VOTE 0 +#define HIVE_OP_COMMENT 1 +#define HIVE_OP_TRANSFER 2 +#define HIVE_OP_ACCOUNT_CREATE 9 +#define HIVE_OP_ACCOUNT_UPDATE 10 +#define HIVE_OP_CUSTOM_JSON 18 + +// ── 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 + +// ── 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-or-permlink / cj: id + uint16_t target_len; + const uint8_t* detail; // vote: permlink / comment: body / cj: json + uint16_t detail_len; + int16_t weight; // vote only (-10000..10000) + bool is_top_level; // comment only: parent_author empty + uint8_t n_auths; // custom_json only: total auth account names +} 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 + * phase-1 op table (vote, comment, custom_json). Returns NULL on success or + * a static error message. Slices in `out` borrow from `tx` — keep it alive. + */ +const char* hive_parseOperations(const uint8_t* tx, size_t len, + HiveParsedTx* out); + +/** + * 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); + +/** + * 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/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/reset.h b/include/keepkey/firmware/reset.h index a41cb101d..da5ae10f6 100644 --- a/include/keepkey/firmware/reset.h +++ b/include/keepkey/firmware/reset.h @@ -33,6 +33,16 @@ MAX_WORDS*(MAX_WORD_LEN + ADDITIONAL_WORD_PAD) + 1 #define MNEMONIC_BY_SCREEN_BUF WORDS_PER_SCREEN*(MAX_WORD_LEN + 1) + 1 +/* 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(bool display_random, uint32_t _strength, bool passphrase_protection, bool pin_protection, const char* language, const char* label, bool _no_backup, diff --git a/include/keepkey/firmware/signed_metadata.h b/include/keepkey/firmware/signed_metadata.h new file mode 100644 index 000000000..e84fb7386 --- /dev/null +++ b/include/keepkey/firmware/signed_metadata.h @@ -0,0 +1,207 @@ +#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); + +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. When persist=true the signer + * (incl. icon) is ALSO written to flash so it survives reboot. Returns false + * only when persist was requested but every persistent slot is occupied by a + * different identity — the RAM (session) signer is stored regardless. */ +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, else a persisted + * identity that survived reboot. 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, + bool persist); + +/* 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); +MetadataClassification signed_metadata_process(const uint8_t *payload, + size_t payload_len, + uint8_t key_id); +/* 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/solana.h b/include/keepkey/firmware/solana.h index a56611dad..59d3aa8b5 100644 --- a/include/keepkey/firmware/solana.h +++ b/include/keepkey/firmware/solana.h @@ -156,6 +156,10 @@ 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; } SolanaParsedInstruction; /* Parsed transaction header */ diff --git a/include/keepkey/firmware/storage.h b/include/keepkey/firmware/storage.h index 9fc8c1954..494eedf0b 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*/ + 18 /* 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/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..4c1358a73 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); 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/include/keepkey/firmware/zcash.h b/include/keepkey/firmware/zcash.h new file mode 100644 index 000000000..5fe61ee7e --- /dev/null +++ b/include/keepkey/firmware/zcash.h @@ -0,0 +1,378 @@ +/* + * 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 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 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 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); + +/** + * 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 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]); + +/** + * 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]); + +/* ── 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..6ec14d24e 100644 --- a/include/keepkey/transport/interface.h +++ b/include/keepkey/transport/interface.h @@ -38,6 +38,8 @@ #include "messages-tron.pb.h" #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-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-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/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-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-zcash.options b/include/keepkey/transport/messages-zcash.options new file mode 100644 index 000000000..f7776b869 --- /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.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 + +ZcashTransparentSig.signature max_size:73 +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/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..6b94907fc 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 persisted. + */ +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/layout.c b/lib/board/layout.c index 59e9dd9e2..9acf909ca 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 sweeps a small dim block across the unfilled portion of + * the bar (a perpetual "working" cue that keeps the display visibly moving even + * after the eased fill has pixel-saturated); pass -1 for no marker. */ +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,41 @@ void animating_progress_handler(const char* desc, int permil) { draw_box(canvas, &bp); } + // Sweep marker: a small mid-gray glint cycling across the FULL inner track, + // independent of the fill level, so its travel never shrinks and the bar + // keeps visibly moving for arbitrarily slow host proofs. Mid-gray (0x99) + // reads as a darker notch over the 0xff fill and a brighter block over the + // 0x00 remainder, so it stays visible at any progress value. + if (marker_phase >= 0 && width > 2) { + const uint32_t marker_w = 8; + uint32_t span_w = width - 2; + uint32_t w = marker_w < span_w ? marker_w : span_w; + uint32_t travel = span_w - w; + bp.width = w; + bp.height = height - 2; + bp.base.x = x + 1 + (travel * (uint32_t)marker_phase) / 1000; + bp.base.y = y + 1; + bp.base.color = 0x99; + 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 +877,76 @@ 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 eases a "trickle" that advances quickly at first then slows, + * asymptotically approaching the next real milestone without ever reaching it + * (so it never falsely shows 100%). 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; + /* add = span * elapsed / (elapsed + TAU): fast early, slowing as it nears the + * target, never actually reaching it. */ + const uint32_t TAU = 1200; /* ms; ~half the remaining gap closed by 1.2s */ + int span = trickle.target - trickle.base; + int add = span > 0 ? (int)(((uint64_t)span * elapsed) / (elapsed + TAU)) : 0; + /* Sweep marker 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 SWEEP_PERIOD = 1600; /* ms per sweep across the unfilled bar */ + int phase = (int)(((elapsed % SWEEP_PERIOD) * 1000) / SWEEP_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, marker at phase 0) 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 +956,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/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 */ diff --git a/lib/board/signatures.c b/lib/board/signatures.c index f4be6bf82..9bf2107fb 100644 --- a/lib/board/signatures.c +++ b/lib/board/signatures.c @@ -20,7 +20,9 @@ #include "trezor/crypto/sha2.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/memzero.h" #include "keepkey/board/memory.h" +#include "keepkey/board/memcmp_s.h" #include "keepkey/board/signatures.h" #include "keepkey/board/pubkeys.h" @@ -68,23 +70,51 @@ int signatures_ok(void) { return KEY_EXPIRED; } /* Expired signing key */ + /* F3 hardening: double-compute SHA-256, compare in constant time */ + uint8_t firmware_fingerprint2[32]; sha256_Raw((uint8_t*)FLASH_APP_START, codelen, firmware_fingerprint); + asm volatile("" ::: "memory"); + sha256_Raw((uint8_t*)FLASH_APP_START, codelen, firmware_fingerprint2); - if (ecdsa_verify_digest(&secp256k1, pubkey[sigindex1 - 1], - (uint8_t*)FLASH_META_SIG1, - firmware_fingerprint) != 0) { /* Failure */ + if (memcmp_s(firmware_fingerprint, firmware_fingerprint2, 32) != 0) { + memzero(firmware_fingerprint, sizeof(firmware_fingerprint)); + memzero(firmware_fingerprint2, sizeof(firmware_fingerprint2)); return SIG_FAIL; } + memzero(firmware_fingerprint2, sizeof(firmware_fingerprint2)); - if (ecdsa_verify_digest(&secp256k1, pubkey[sigindex2 - 1], - (uint8_t*)FLASH_META_SIG2, - firmware_fingerprint) != 0) { /* Failure */ + /* F3 hardening: infective aggregation — accumulate all three ECDSA + * results instead of early-returning on each. Forces attacker to + * corrupt all three verify calls, not just skip one branch. */ + volatile int verify_acc = 0; + volatile int verify_sentinel = 0; + + verify_acc |= + ecdsa_verify_digest(&secp256k1, pubkey[sigindex1 - 1], + (uint8_t*)FLASH_META_SIG1, firmware_fingerprint); + verify_sentinel++; + asm volatile("" ::: "memory"); + + verify_acc |= + ecdsa_verify_digest(&secp256k1, pubkey[sigindex2 - 1], + (uint8_t*)FLASH_META_SIG2, firmware_fingerprint); + verify_sentinel++; + asm volatile("" ::: "memory"); + + verify_acc |= + ecdsa_verify_digest(&secp256k1, pubkey[sigindex3 - 1], + (uint8_t*)FLASH_META_SIG3, firmware_fingerprint); + verify_sentinel++; + asm volatile("" ::: "memory"); + + memzero(firmware_fingerprint, sizeof(firmware_fingerprint)); + + /* All three verifies must have executed and all must have passed */ + if (verify_sentinel != 3) { return SIG_FAIL; } - if (ecdsa_verify_digest(&secp256k1, pubkey[sigindex3 - 1], - (uint8_t*)FLASH_META_SIG3, - firmware_fingerprint) != 0) { /* Failure */ + if (verify_acc != 0) { return SIG_FAIL; } 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/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/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/emulator/CMakeLists.txt b/lib/emulator/CMakeLists.txt index fa5909a17..c1fcdcb4d 100644 --- a/lib/emulator/CMakeLists.txt +++ b/lib/emulator/CMakeLists.txt @@ -35,6 +35,13 @@ if(${KK_EMULATOR}) ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-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/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/setup.c b/lib/emulator/setup.c index 1b4d657d4..69b9a65dd 100644 --- a/lib/emulator/setup.c +++ b/lib/emulator/setup.c @@ -22,46 +22,73 @@ #include "keepkey/rand/rng.h" #include -#include #include #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN /* exclude winsock.h — it declares \ + shutdown(SOCKET,int) */ +#include +#include +#else +#include #include #include +#endif #define EMULATOR_FLASH_FILE "emulator.img" -uint32_t __stack_chk_guard; +/* __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 int urandom = -1; static void setup_urandom(void); + +#ifndef _WIN32 static void setup_flash(void); void setup(void) { setup_urandom(); 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) { +#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 ssize_t n = read(urandom, buffer, size); if (n < 0 || ((size_t)n) != size) { perror("Failed to read /dev/urandom"); exit(1); } +#endif } static void setup_urandom(void) { +#ifndef _WIN32 urandom = open("/dev/urandom", O_RDONLY); if (urandom < 0) { perror("Failed to open /dev/urandom"); exit(1); } +#endif } +#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 +119,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 f23ebcfb8..ff28a5e86 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -2,48 +2,61 @@ 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 fsm.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 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) +endif() + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/scm_revision.h.in" "${CMAKE_CURRENT_BINARY_DIR}/scm_revision.h" @ONLY) diff --git a/lib/firmware/app_confirm.c b/lib/firmware/app_confirm.c index d0b17e9bc..56939a449 100644 --- a/lib/firmware/app_confirm.c +++ b/lib/firmware/app_confirm.c @@ -321,6 +321,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 * diff --git a/lib/firmware/app_layout.c b/lib/firmware/app_layout.c index 25b0db18b..2499916be 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 * @@ -693,7 +768,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 +777,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/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/coins.c b/lib/firmware/coins.c index 71a37d4da..a231bc41a 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, diff --git a/lib/firmware/eip712.c b/lib/firmware/eip712.c index 1fec75f4b..1d0c2cda0 100644 --- a/lib/firmware/eip712.c +++ b/lib/firmware/eip712.c @@ -288,16 +288,20 @@ int encodeBytesN(const char* typeT, const char* string, uint8_t* encoded) { 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); + return SUCCESS; + } + if (!review(ButtonRequestType_ButtonRequest_Other, "MESSAGE DATA", + "Press button to continue for\n\"%s\" values", name)) { + return USER_CANCELLED; } return SUCCESS; } int confirmValue(const char* value) { - (void)review(ButtonRequestType_ButtonRequest_Other, "MESSAGE DATA", "%s %s", - nameForValue, value); + if (!review(ButtonRequestType_ButtonRequest_Other, "MESSAGE DATA", "%s %s", + nameForValue, value)) { + return USER_CANCELLED; + } return SUCCESS; } @@ -320,7 +324,7 @@ void marshallDsVals(const char* value) { return; } -void dsConfirm(void) { +int dsConfirm(void) { // First check if we recognize the contract const TokenType* assetToken; uint8_t addrHexStr[20] = {0}; @@ -380,12 +384,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; } /* @@ -448,7 +457,9 @@ 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 (SUCCESS != (errRet = confirmName(typeName, hasValue))) { + return errRet; + } if (walkVals == 0) { return JSON_TYPE_WNOVAL; @@ -463,8 +474,9 @@ int parseVals(const json_t* eip712Types, const json_t* jType, // values are there. if (ds_vals) { marshallDsVals(json_getValue(addrVals)); - } else { - confirmValue(json_getValue(addrVals)); + } else if (SUCCESS != + (errRet = confirmValue(json_getValue(addrVals)))) { + return errRet; } errRet = encAddress(json_getValue(addrVals), encBytes); @@ -478,8 +490,8 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } else { if (ds_vals) { marshallDsVals(valStr); - } else { - confirmValue(valStr); + } else if (SUCCESS != (errRet = confirmValue(valStr))) { + return errRet; } errRet = encAddress(valStr, encBytes); if (SUCCESS != errRet) { @@ -498,8 +510,9 @@ int parseVals(const json_t* eip712Types, const json_t* jType, // values are there. if (ds_vals) { marshallDsVals(json_getValue(stringVals)); - } else { - confirmValue(json_getValue(stringVals)); + } else if (SUCCESS != + (errRet = confirmValue(json_getValue(stringVals)))) { + return errRet; } errRet = encString(json_getValue(stringVals), strEncBytes); if (SUCCESS != errRet) { @@ -512,8 +525,8 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } else { if (ds_vals) { marshallDsVals(valStr); - } else { - confirmValue(valStr); + } else if (SUCCESS != (errRet = confirmValue(valStr))) { + return errRet; } errRet = encString(valStr, encBytes); if (SUCCESS != errRet) { @@ -528,8 +541,8 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } else { if (ds_vals) { marshallDsVals(valStr); - } else { - confirmValue(valStr); + } else if (SUCCESS != (errRet = confirmValue(valStr))) { + return errRet; } uint8_t negInt = 0; // 0 is positive, 1 is negative if (0 == strncmp("int", typeType, strlen("int") - 1)) { @@ -548,7 +561,14 @@ 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); + char* endptr = NULL; + long long intVal = strtoll(valStr, &endptr, 10); + if (endptr == valStr || *endptr != '\0') { + return GENERAL_ERROR; + } + if (0 == strncmp("uint", typeType, 4) && intVal < 0) { + return GENERAL_ERROR; + } // Needs to be big endian, so add to encBytes appropriately encBytes[24] = (intVal >> 56) & 0xff; encBytes[25] = (intVal >> 48) & 0xff; @@ -567,8 +587,8 @@ int parseVals(const json_t* eip712Types, const json_t* jType, // This could be 'bytes', 'bytes1', ..., 'bytes32' if (ds_vals) { marshallDsVals(valStr); - } else { - confirmValue(valStr); + } else if (SUCCESS != (errRet = confirmValue(valStr))) { + return errRet; } if (0 == strcmp(typeType, "bytes")) { errRet = encodeBytes(valStr, encBytes); @@ -590,8 +610,8 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } else { if (ds_vals) { marshallDsVals(valStr); - } else { - confirmValue(valStr); + } else if (SUCCESS != (errRet = confirmValue(valStr))) { + return errRet; } for (ctr = 0; ctr < 32; ctr++) { // leading zeros in bool @@ -699,7 +719,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/eos.c b/lib/firmware/eos.c index 45e7acacc..6dc7b1f8f 100644 --- a/lib/firmware/eos.c +++ b/lib/firmware/eos.c @@ -534,7 +534,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/ethereum.c b/lib/firmware/ethereum.c index b7c12c1b5..249c6a941 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" @@ -204,6 +205,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 +238,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 +295,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"); @@ -585,8 +628,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 +696,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 +779,30 @@ 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()) { + // Decoded who/what/why approved; raw-data confirm is suppressed. The + // signature is bound to this metadata's tx hash in send_signature(). + needs_confirm = false; + 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 +848,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 +893,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 +957,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 +1024,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; @@ -1126,9 +1221,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")); 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..4df12174e 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -41,14 +41,38 @@ 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; + if (!thor_has_deposit_selector(msg)) return false; + char toStr[41]; + thor_format_to_addr(msg, toStr); + return strncmp(toStr, MAYA_ROUTER, 40) == 0; +} + +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. Without this, 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. Mirrors thor_isMayachainTx. */ + char toStr[41]; + thor_format_to_addr(msg, toStr); + return strncmp(toStr, THOR_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) + @@ -58,7 +82,22 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { const size_t min_chunk = is_expiry ? 260 : 228; if (msg->data_initial_chunk.size < min_chunk) return false; - char confStr[41], *conf; + /* 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; + } + } + + char confStr[41]; + const char* conf; const TokenType* assetToken; uint8_t* thorchainData; const uint8_t* contractAssetAddress; @@ -75,15 +114,15 @@ 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); + if (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,7 +131,7 @@ 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; } @@ -111,8 +150,8 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { 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, + "from asset %s", confStr)) { return false; } // We don't know what the exponent should be so just confirm raw unformatted @@ -120,8 +159,8 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { bn_format(&Amount, NULL, " unformatted", 0, 0, false, confStr, sizeof(confStr)); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "amount %s", confStr)) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, + "amount %s", confStr)) { return false; } @@ -129,8 +168,8 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { ethereumFormatAmount(&Amount, assetToken, msg->chain_id, confStr, sizeof(confStr)); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "Confirm sending %s", confStr)) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, + "Confirm sending %s", confStr)) { return false; } } @@ -139,3 +178,12 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { 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..b4e752419 100644 --- a/lib/firmware/ethereum_contracts/zxappliquid.c +++ b/lib/firmware/ethereum_contracts/zxappliquid.c @@ -36,7 +36,8 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, const EthereumSignTx *msg) { - (void)data_total; + /* reads selector + 2 32-byte words (spender, allowance) */ + if (data_total < 4 + 2 * 32) return false; const char *to, *tikstr, *poolstr, *allowance, *amt; unsigned char data[40]; uint8_t digest[SHA3_256_DIGEST_LENGTH] = {0}; diff --git a/lib/firmware/ethereum_contracts/zxliquidtx.c b/lib/firmware/ethereum_contracts/zxliquidtx.c index e70ccd4e3..8f2123830 100644 --- a/lib/firmware/ethereum_contracts/zxliquidtx.c +++ b/lib/firmware/ethereum_contracts/zxliquidtx.c @@ -91,23 +91,29 @@ static bool confirmFromAccountMatch(const EthereumSignTx* msg, if (!hdnode_get_ethereum_pubkeyhash(node, addressBytes)) { memzero(node, sizeof(*node)); + return false; } fromAddress = (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 5 * 32 - 20); - if (memcmp(fromAddress, addressBytes, 20) == 0) { - fromSrc = "self"; - } else { - fromSrc = "NOT this wallet"; - } + bool isSelf = (memcmp(fromAddress, addressBytes, 20) == 0); + fromSrc = isSelf ? "this wallet" : "NOT this wallet"; for (uint32_t ctr = 0; ctr < 20; ctr++) { snprintf(&addressStr[2 + ctr * 2], 3, "%02x", fromAddress[ctr]); } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, addremStr, - "Confirming ETH address is %s: %s", fromSrc, addressStr)) { + "Liquidity recipient is %s: %s", fromSrc, addressStr)) { + return false; + } + + /* Fail closed: the liquidity/LP recipient (the `to` parameter) must be the + * signer's own address. Previously a non-self recipient only produced a soft + * "NOT this wallet" warning that the user could click through, letting LP + * tokens / withdrawn ETH be routed to an attacker. */ + if (!isSelf) { return false; } return true; @@ -125,7 +131,8 @@ bool zx_isZxLiquidTx(const EthereumSignTx* msg) { } bool zx_confirmZxLiquidTx(uint32_t data_total, const EthereumSignTx* msg) { - (void)data_total; + /* reads through the deadline word at offset 4 + 6*32 - 8 .. 4 + 6*32 */ + if (data_total < 4 + 6 * 32) return false; const TokenType* token; char constr1[40], constr2[40], tokbuf[32]; const char* arStr = ""; 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..b62431b53 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" @@ -55,7 +56,10 @@ #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/zcash.h" +#include "keepkey/firmware/hive.h" #include "keepkey/firmware/storage.h" #include "keepkey/firmware/tendermint.h" #include "keepkey/firmware/thorchain.h" @@ -90,6 +94,8 @@ #include "messages-tron.pb.h" #include "messages-ton.pb.h" #include "messages-solana.pb.h" +#include "messages-zcash.pb.h" +#include "messages-hive.pb.h" #include @@ -111,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(); \ @@ -272,16 +287,21 @@ 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"); } +// 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" @@ -293,3 +313,23 @@ 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" +#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. (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_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 44173a24c..98609d521 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); @@ -42,8 +43,44 @@ void fsm_msgGetFeatures(GetFeatures* msg) { /* Variant Name */ resp->has_firmware_variant = true; - strlcpy(resp->firmware_variant, variant_getName(), +#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. */ +#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; @@ -150,8 +187,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 } } } @@ -325,6 +364,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; @@ -375,6 +415,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; @@ -460,6 +501,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(); @@ -500,6 +544,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)) { @@ -528,6 +573,7 @@ void fsm_msgLoadDevice(LoadDevice* msg) { } void fsm_msgResetDevice(ResetDevice* msg) { + CHECK_NOT_BTC_ONLY_LOCKED CHECK_NOT_INITIALIZED reset_init(msg->has_display_random && msg->display_random, @@ -557,10 +603,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)) { @@ -651,6 +699,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 { @@ -681,6 +730,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) { diff --git a/lib/firmware/fsm_msg_ethereum.h b/lib/firmware/fsm_msg_ethereum.h index 5c9827164..ee5027128 100644 --- a/lib/firmware/fsm_msg_ethereum.h +++ b/lib/firmware/fsm_msg_ethereum.h @@ -19,6 +19,134 @@ * 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; + } + + 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(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; icons READ BACK from flash are re-checked independently in + * signed_metadata_signer_icon(), since a record persisted by older firmware + * never passes through here again after a reboot. */ + 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, still return Success, + * and be persisted to flash — the user consents 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; + + /* 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. persist makes the + * trust anchor durable across reboots, so it's called out. */ + 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, persist)) { + 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)) { + /* Session signer stored, but no persistent slot was free. Tell the truth + * rather than silently keeping it RAM-only under a persist request. */ + fsm_sendFailure(FailureType_Failure_Other, + _("No free persistent identity slot — wipe an old one")); + 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; @@ -331,24 +459,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); diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h new file mode 100644 index 000000000..51f9ed4e1 --- /dev/null +++ b/lib/firmware/fsm_msg_hive.h @@ -0,0 +1,750 @@ +/* + * 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 + + 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) { + if (count != 5) return false; + if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; + if (address_n[1] != HIVE_SLIP48_NETWORK) return false; + if (address_n[2] != required_role) return false; + if ((address_n[3] & 0x80000000u) == 0) return false; + if (address_n[4] != 0x80000000u) return false; // key index 0' + return true; +} + +// ── 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 (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, "Memo", "%s", + 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 (count != 5) return false; + if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; + if (address_n[1] != HIVE_SLIP48_NETWORK) return false; + if ((address_n[3] & 0x80000000u) == 0) return false; + if (address_n[4] != 0x80000000u) return false; // key index 0' + 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); + + // Printable ASCII within the display budget is shown verbatim; anything + // longer — or with non-ASCII bytes — gets the hex preview + byte count. + // The budget matters: confirm()'s body buffer silently truncates at + // BODY_CHAR_MAX, so a long "printable" message could show a benign prefix + // while the device signs hidden trailing content. Same 128-byte budget as + // SolanaSignMessage. + bool printable = msg->message.size <= 128; + for (uint32_t i = 0; printable && i < msg->message.size; i++) { + if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { + printable = false; + } + } + + bool approved; + if (printable) { + approved = confirm(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign Hive Message", "(%s key) %.*s", role_label, + (int)msg->message.size, msg->message.bytes); + } else { + char preview[96]; // 64 hex chars + "... (1024 bytes)" + NUL + unsigned show = msg->message.size > 32 ? 32 : msg->message.size; + for (unsigned i = 0; i < show; i++) { + snprintf(&preview[2 * i], 3, "%02x", msg->message.bytes[i]); + } + if (msg->message.size > 32) { + snprintf(&preview[2 * show], sizeof(preview) - 2 * show, "... (%u bytes)", + (unsigned)msg->message.size); + } + approved = confirm(ButtonRequestType_ButtonRequest_ProtectCall, + "Sign Hive Bytes", "(%s key) %s", role_label, preview); + } + if (!approved) { + 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) { + if (count != 5) return false; + if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; + if (address_n[1] != HIVE_SLIP48_NETWORK) return false; + if ((address_n[3] & 0x80000000u) == 0) return false; + if (address_n[4] != 0x80000000u) return false; // key index 0' + return address_n[2] == (needs_active ? HIVE_ROLE_ACTIVE : HIVE_ROLE_POSTING); +} + +// Uniform display rules (PR #306 finding-1): printable-ASCII within the +// budget is copied verbatim; a truncated slice carries an explicit +// "(+N more)" marker; any non-ASCII byte switches the whole slice to a +// byte-count + short hex preview (partial UTF-8 sequences garble the OLED). +static void hive_format_slice(const uint8_t* s, uint16_t len, char* out, + size_t out_len) { + bool ascii = true; + for (uint16_t i = 0; i < len; i++) { + if (s[i] < 0x20 || s[i] > 0x7e) { + ascii = false; + break; + } + } + if (ascii) { + uint16_t show = len > 120 ? 120 : len; + if ((size_t)show + 16 > out_len) show = (uint16_t)(out_len - 16); + memcpy(out, s, show); + if (show < len) { + snprintf(out + show, out_len - show, "(+%u more)", + (unsigned)(len - show)); + } else { + out[show] = '\0'; + } + } else { + unsigned show = len > 8 ? 8 : len; + int off = snprintf(out, out_len, "<%u bytes> ", (unsigned)len); + for (unsigned i = 0; i < show && off + 2 < (int)out_len; i++, off += 2) { + snprintf(out + off, out_len - off, "%02x", s[i]); + } + } +} + +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); + + // One confirm per operation, then a final sign confirm (transfer pattern). + 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 + memcpy(name, op->acct, op->acct_len); + name[op->acct_len] = '\0'; + char target[140], detail[140]; + hive_format_slice(op->target, op->target_len, target, sizeof(target)); + hive_format_slice(op->detail, op->detail_len, detail, sizeof(detail)); + + bool approved = false; + switch (op->op_type) { + case HIVE_OP_VOTE: { + int w = op->weight < 0 ? -op->weight : op->weight; + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + op->weight < 0 ? "Downvote" : "Vote", + "@%s -> @%s/%s at %d.%02d%%", name, target, detail, + w / 100, w % 100); + break; + } + case HIVE_OP_COMMENT: + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + op->is_top_level ? "Post" : "Comment", "@%s: %s\n%s", + name, target, detail); + break; + case HIVE_OP_CUSTOM_JSON: { + char extra[12] = ""; + if (op->n_auths > 1) { + snprintf(extra, sizeof(extra), " +%u", (unsigned)(op->n_auths - 1)); + } + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON", "id: %s\nby @%s%s\n%s", target, name, + extra, detail); + 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/fsm_msg_mayachain.h b/lib/firmware/fsm_msg_mayachain.h index be9229665..f4eedba24 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, @@ -245,7 +288,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_ripple.h b/lib/firmware/fsm_msg_ripple.h index ddd25af35..a678522e3 100644 --- a/lib/firmware/fsm_msg_ripple.h +++ b/lib/firmware/fsm_msg_ripple.h @@ -109,6 +109,16 @@ void fsm_msgRippleSignTx(RippleSignTx* msg) { } } + if (msg->has_memo && msg->memo[0] != '\0') { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Memo", "%s", + 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_solana.h b/lib/firmware/fsm_msg_solana.h index ea3072b07..0cd46b8ba 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -100,14 +100,12 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, "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); } @@ -126,6 +124,33 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } } + case SOL_INSTR_TOKEN_TRANSFER_CHECKED: { + char to_str[45]; + solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); + + /* For TransferChecked, decimals come from the signed instruction + * bytes (pi->extra_u8) — host-supplied ti->decimals is untrusted. */ + const SolanaTokenInfo* ti = NULL; + if (pi->has_mint && msg) { + ti = solana_findTokenInfo(msg, pi->mint); + } + + const char* symbol = (ti && ti->has_symbol) ? ti->symbol : NULL; + if (symbol) { + char amount_str[48]; + solana_formatTokenAmount(amount_str, sizeof(amount_str), pi->amount, + symbol, pi->extra_u8); + 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); + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Send %s to %s?", amount_str, to_str); + } + } + case SOL_INSTR_TOKEN_APPROVE: { char to_str[45]; solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); @@ -153,9 +178,14 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, "Burn %llu tokens?", (unsigned long long)pi->amount); - case SOL_INSTR_TOKEN_CLOSE_ACCOUNT: + case SOL_INSTR_TOKEN_CLOSE_ACCOUNT: { + /* Closing a (wrapped-SOL) token account sweeps its lamports to the + * destination — show it, or an attacker routes the rent elsewhere. */ + char to_str[45]; + solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Close token account?"); + "Close token account, send balance to %s?", to_str); + } case SOL_INSTR_TOKEN_FREEZE_ACCOUNT: return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, @@ -252,9 +282,23 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, "Set loaded account data to %llu bytes?", (unsigned long long)pi->extra_value); - case SOL_INSTR_MEMO: - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Memo attached"); + case SOL_INSTR_MEMO: { + /* Show the memo body — swap intents (e.g. THORChain '=:ETH.ETH:...') + * ride in the memo, so hiding it hides where the funds go next. */ + bool printable = pi->data_len > 0; + for (uint16_t i = 0; i < pi->data_len; i++) { + if (pi->data[i] < 0x20 || pi->data[i] > 0x7e) { + printable = false; + break; + } + } + if (printable && pi->data_len <= 114) { + return confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, title, + "Memo: %.*s", (int)pi->data_len, pi->data); + } + return confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, title, + "Memo attached (%u bytes)", (unsigned)pi->data_len); + } case SOL_INSTR_UNKNOWN: default: { @@ -458,13 +502,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,6 +547,34 @@ void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg) { if (!node) return; hdnode_fill_public_key(node); + 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; + } + for (uint8_t i = 0; i < parsed.num_instructions; i++) { + if (!solana_confirmInstruction(&parsed.instructions[i], NULL, i, + parsed.num_instructions)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Solana", + "Sign this Solana transaction?")) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } else /* blind message path */ /* Always require on-device confirmation (matches Ethereum behavior). * Display message content if printable, hex preview otherwise. */ { diff --git a/lib/firmware/fsm_msg_thorchain.h b/lib/firmware/fsm_msg_thorchain.h index 801af7872..cd905396b 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, @@ -240,7 +280,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/fsm_msg_ton.h b/lib/firmware/fsm_msg_ton.h index 5f4844896..57b8e9710 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(); diff --git a/lib/firmware/fsm_msg_tron.h b/lib/firmware/fsm_msg_tron.h index aee20c602..85d064aac 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -102,15 +102,103 @@ 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; + } + + 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); + } - // 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); + 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) { + bool printable = true; + for (uint16_t i = 0; i < parsed.memo_len; i++) { + if (parsed.memo[i] < 0x20 || parsed.memo[i] > 0x7e) { + printable = false; + break; + } + } + if (printable && parsed.memo_len <= 114) { + confirmed = confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, "Memo", + "%.*s", (int)parsed.memo_len, parsed.memo); + } else { + confirmed = + confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, "Memo", + "Data attached (%u bytes)", (unsigned)parsed.memo_len); + } + } + + if (!confirmed) { memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); layoutHome(); @@ -118,14 +206,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)); @@ -306,6 +386,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/fsm_msg_zcash.h b/lib/firmware/fsm_msg_zcash.h new file mode 100644 index 000000000..43465f90d --- /dev/null +++ b/lib/firmware/fsm_msg_zcash.h @@ -0,0 +1,1385 @@ +/* + * 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]; + /* 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]; + /* Signatures buffer: one 64-byte sig per action */ + uint8_t signatures[ZCASH_MAX_ACTIONS][64]; + /* 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 (!has_expected || expected_size != 32) 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) { + 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]; + if (!zcash_orchard_compute_cmx(msg->recipient.bytes, msg->value, + msg->nullifier.bytes, msg->rseed.bytes, + computed_cmx) || + memcmp(computed_cmx, msg->cmx.bytes, 32) != 0) { + memzero(computed_cmx, sizeof(computed_cmx)); + fsm_sendFailure(FailureType_Failure_Other, + _("Orchard 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; +} + +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_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; + } + + zcash_compute_shielded_sighash( + zcash_signing.header_digest, transparent_digest, EMPTY_SAPLING_DIGEST, + zcash_signing.expected_orchard_digest, zcash_signing.branch_id, + zcash_signing.sighash); + 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 §4.4: signature_digest = ZcashTxHash_( + * header_digest || transparent_sig_digest || sapling_digest || + * orchard_digest) Binding the transparent ECDSA sig to all four components + * ensures it cannot be replayed in a transaction with different + * Orchard/header data. */ + 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) && + zcash_compute_shielded_sighash(zcash_signing.header_digest, + t_sig_digest, EMPTY_SAPLING_DIGEST, + zcash_signing.expected_orchard_digest, + zcash_signing.branch_id, full_sighash) && + 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; + + 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.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 to Orchard?\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; + } + } + + 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; + memcpy(zcash_signing.header_digest, header_digest, 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: + * - Orchard 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 the 4 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], s_digest[32]; + + if (n_tinputs == 0 && n_toutputs == 0) { + memcpy(t_digest, EMPTY_TRANSPARENT_DIGEST, 32); + memcpy(s_digest, EMPTY_SAPLING_DIGEST, 32); + + zcash_compute_shielded_sighash( + header_digest, t_digest, s_digest, msg->orchard_digest.bytes, + zcash_signing.branch_id, 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)); + memzero(s_digest, sizeof(s_digest)); + + /* Phase 2b: Orchard digest verification is mandatory for signing. + * The device incrementally hashes each action's data and verifies the + * computed orchard_digest matches the one used for sighash. */ + memcpy(zcash_signing.expected_orchard_digest, 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, "ZTxIdOrcActCHash", 16); + blake2b_InitPersonal(&zcash_signing.memos_ctx, 32, "ZTxIdOrcActMHash", 16); + blake2b_InitPersonal(&zcash_signing.noncompact_ctx, 32, "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; + } + + /* 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; + } + + /* Compute ak = [ask]G_spendauth on Pallas curve (SpendAuth basepoint) */ + bignum256 ask_scalar; + bn_read_le(keys.ask, &ask_scalar); + curve_point ak_point; + redpallas_scalar_mult_spendauth_G(&ask_scalar, &ak_point); + + /* Serialize ak as Pallas point (LE x-coord, sign bit in high byte) */ + uint8_t ak_bytes[32]; + bignum256 x_copy; + bn_copy(&ak_point.x, &x_copy); + bn_write_le(&x_copy, ak_bytes); + if (bn_is_odd(&ak_point.y)) { + ak_bytes[31] |= 0x80; + } + + /* Build response */ + resp->has_ak = true; + resp->ak.size = 32; + memcpy(resp->ak.bytes, ak_bytes, 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(&ask_scalar, sizeof(ask_scalar)); + 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_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 && + msg->enc_noncompact.size > 0 && 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; + } + + if (!zcash_verify_and_confirm_orchard_output(msg)) { + zcash_signing_abort(); + layoutHome(); + return; + } + + /* 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; + + /* Sign this action with RedPallas: + * sig = RedPallas.sign(ask, alpha, sighash) */ + if (redpallas_sign_digest(zcash_signing.keys.ask, msg->alpha.bytes, sighash, + zcash_signing.signatures[msg->index]) != 0) { + fsm_sendFailure(FailureType_Failure_Other, _("RedPallas signing failed")); + zcash_signing_abort(); + layoutHome(); + return; + } + + 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); + + /* Compute orchard_digest = BLAKE2b("ZTxIdOrchardHash", + * compact_hash || memos_hash || noncompact_hash || + * flags(1) || value_balance(8) || anchor(32)) */ + BLAKE2B_CTX orchard_ctx; + blake2b_InitPersonal(&orchard_ctx, 32, "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); + 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, + _("Orchard 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.n_actions; + for (uint32_t i = 0; i < zcash_signing.n_actions; 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/hive.c b/lib/firmware/hive.c new file mode 100644 index 000000000..bda925dd8 --- /dev/null +++ b/lib/firmware/hive.c @@ -0,0 +1,662 @@ +/* + * 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. + +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; + const uint8_t* chain_id = (has_chain_id && chain_id_size == HIVE_CHAIN_ID_LEN) + ? chain_id_bytes + : default_chain_id; + return hive_sign_digest(node, 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)) { + *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; +} + +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 "Hive tx: malformed op count"; + 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 "Hive tx: malformed op type"; + op->op_type = op_type; + + switch (op_type) { + case HIVE_OP_VOTE: { // posting authority + if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || + !cur_string(&c, &op->target, &op->target_len, 1, 16) || + !cur_string(&c, &op->detail, &op->detail_len, 1, 256)) + return "Hive vote: malformed fields"; + if ((size_t)(c.end - c.p) < 2) return "Hive vote: missing weight"; + 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 "Hive vote: weight out of 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_string(&c, &pa, &pa_len, 0, 16) || + !cur_string(&c, &ppl, &ppl_len, 1, 256) || + !cur_string(&c, &op->acct, &op->acct_len, 1, 16) || + !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 "Hive comment: malformed fields"; + if (op->target_len == 0) { // no title — show the permlink instead + op->target = permlink; + op->target_len = permlink_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 "Hive custom_json: malformed auths"; + for (uint32_t k = 0; k < n_active; k++) { + const uint8_t* s; + uint16_t sl; + if (!cur_string(&c, &s, &sl, 1, 16)) + return "Hive custom_json: malformed auths"; + if (!op->acct) { + op->acct = s; + op->acct_len = sl; + } + } + if (!cur_varint(&c, &n_posting)) + return "Hive custom_json: malformed auths"; + for (uint32_t k = 0; k < n_posting; k++) { + const uint8_t* s; + uint16_t sl; + if (!cur_string(&c, &s, &sl, 1, 16)) + return "Hive custom_json: malformed auths"; + if (!op->acct) { + op->acct = s; + op->acct_len = sl; + } + } + if (n_active + n_posting == 0) + return "Hive custom_json: no auth accounts"; + // 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 "Hive custom_json: mixed active+posting auths"; + 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 "Hive custom_json: malformed id/json"; + op->n_auths = (uint8_t)(n_active + n_posting); + op->needs_active = (n_active > 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 "Hive tx: malformed extensions"; + if (ext_count != 0) return "Hive tx: extensions must be empty"; + 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 "Hive tx: mixed posting/active ops"; + 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. + +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/mayachain.c b/lib/firmware/mayachain.c index a4613ad11..ad1929d80 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,20 +359,17 @@ 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 } - float percent = (float)(atoi(parseTokPtrs[3])) / 100; + float percent = (float)(atoi(fields[2])) / 100; if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Mayachain withdraw liquidity", "Confirm withdraw %3.2f%% of asset %s on chain %s", percent, - parseTokPtrs[2], parseTokPtrs[1])) { + asset, chain)) { return false; } return true; diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index ee82cc2bd..30f8fb1bf 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,9 @@ 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) /* Normal Out Messages */ MSG_OUT(MessageType_MessageType_Success, Success, NO_PROCESS_FUNC) @@ -95,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) @@ -163,6 +168,43 @@ 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. */ +#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 !BITCOIN_ONLY + /* 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) +#endif // !BITCOIN_ONLY #if DEBUG_LINK /* Debug Messages */ @@ -178,3 +220,9 @@ DEBUG_OUT(MessageType_MessageType_DebugLinkLog, DebugLinkLog, NO_PROCESS_FUNC) 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) +#endif // !BITCOIN_ONLY 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/reset.c b/lib/firmware/reset.c index 362b3b49e..574930f6c 100644 --- a/lib/firmware/reset.c +++ b/lib/firmware/reset.c @@ -43,6 +43,13 @@ static bool awaiting_entropy = false; static char CONFIDENTIAL current_words[MNEMONIC_BY_SCREEN_BUF]; static bool no_backup; +/* 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(bool display_random, uint32_t _strength, bool passphrase_protection, bool pin_protection, const char* language, const char* label, bool _no_backup, @@ -167,16 +174,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); @@ -257,11 +271,11 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { exit: 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(); } 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/signed_metadata.c b/lib/firmware/signed_metadata.c new file mode 100644 index 000000000..a3ce91094 --- /dev/null +++ b/lib/firmware/signed_metadata.c @@ -0,0 +1,1008 @@ +#include "keepkey/firmware/signed_metadata.h" + +#include "storage.h" // ClearsignIdentity + persistent-slot accessors +#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 "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. */ +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. + * A signer loaded with persist=true is ALSO written to flash (V18 storage) and + * reconsulted after reboot via storage_getClearsignIdentity(). The RAM slot is + * the session working copy; the flash slot is the durable trust anchor. */ +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. */ +_Static_assert(CLEARSIGN_ICON_MAX == METADATA_ICON_MAX, + "storage icon cap must match the clearsign icon cap"); +#if !ZCASH_PRIVACY +/* Session icon cache for ephemeral (RAM-only) signers. The zcash-privacy + * variant compiles this out to fit SRAM (the Orchard engine leaves it the + * tightest variant): ephemeral signers render text-only there, while + * PERSISTENT identity icons still render straight from the storage shadow. + * Hosts detect this from firmware_variant (KeepKeyZcash/EmulatorZcash). */ +static uint8_t loaded_icons[METADATA_MAX_KEYS][CLEARSIGN_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 + +/* Find the persistent identity that reloads into `key_id`, or NULL. */ +static const ClearsignIdentity *persistent_identity_for(uint8_t key_id) { + int n = storage_clearsignIdentityCount(); + for (int i = 0; i < n; i++) { + const ClearsignIdentity *id = storage_getClearsignIdentity(i); + if (id && id->key_id == key_id) return id; + } + return NULL; +} + +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; +} + +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; + } + + 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; + } + + 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: + 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: + 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; } + +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. NB: this + * clears only the RAM session copies; persisted identities live in flash and + * are cleared by WipeDevice, not here. */ + 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) { + if (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 <= CLEARSIGN_ICON_MAX; + +#if !ZCASH_PRIVACY + /* Session icon into the RAM working slot. The zcash-privacy variant has no + * session icon cache (SRAM); persist below still stores the icon bytes, so + * persistent identities keep their logo across variants. */ + 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; + } +#endif + + bool persisted = true; + if (persist) { + ClearsignIdentity id; + memzero(&id, sizeof(id)); + id.present = true; + id.key_id = key_id; + memcpy(id.pubkey, pubkey, sizeof(id.pubkey)); + strlcpy(id.alias, alias, sizeof(id.alias)); + if (has_icon) { + id.icon_w = icon_w; + id.icon_h = icon_h; + id.icon_len = icon_len; + memcpy(id.icon, icon, icon_len); + } + persisted = storage_upsertClearsignIdentity(&id); + memzero(&id, sizeof(id)); + } + + /* Replacing a signer invalidates anything the old one verified. */ + signed_metadata_clear(); + return persisted; +} + +/* Resolve the alias for a slot (RAM working copy, else a persisted identity). + * Returns NULL if the slot has no loaded/persisted signer. */ +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]; + const ClearsignIdentity *pid = persistent_identity_for(key_id); + return pid ? pid->alias : NULL; +} + +/* Resolve the icon for a slot (RAM working copy, else a persisted identity). + * Returns false when the slot has no icon (text-only identity). */ +/* 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 STORED 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), so + * validating in the staging helper alone would leave that path — and every + * legacy flash record — unchecked. 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. */ +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); +} + +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 + /* No session icon cache in this variant — an ephemeral signer renders + * text-only. It must NOT fall through to the persistent slot below: that + * would put a different identity's logo on this signer's screens. */ + 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 + } + const ClearsignIdentity *pid = persistent_identity_for(key_id); + if (!pid || pid->icon_len == 0) return false; + /* Flash records predate the geometry/encoding rules and are NOT re-validated + * by the load handler after a reboot, so an identity persisted by older + * firmware can carry a 41-64px or malformed icon. Check it here. */ + if (!icon_renderable(pid->icon, pid->icon_len, pid->icon_w, pid->icon_h)) { + return false; + } + if (icon_out) *icon_out = pid->icon; + if (w_out) *w_out = pid->icon_w; + if (h_out) *h_out = pid->icon_h; + if (len_out) *len_out = pid->icon_len; + return true; +} + +/* 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. + * fsm_msgLoadClearsignSigner already rejects width > LEFT_MARGIN_WITH_ICON, + * but an icon persisted to flash by older firmware re-enters here straight + * from storage without passing that check — so 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, + bool persist) { + 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) to describe transactions?%s NOT verified by " + "KeepKey.", + alias, fingerprint, persist ? " Kept until wiped." : ""); + 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]; + } + /* Not in a RAM slot this session — fall back to a persisted identity that + * survived reboot. (A fresh load into the same slot supersedes it above.) */ + const ClearsignIdentity *pid = persistent_identity_for(key_id); + if (pid) { + *is_loaded = true; + return pid->pubkey; + } + return NULL; +} + +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 || !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 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/solana.c b/lib/firmware/solana.c index abadf4cfe..bbfbd63f9 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -122,10 +122,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 +140,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 +151,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 +167,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 +182,19 @@ 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]; + + if (external) { + /* Accounts resolved via lookup tables: unverifiable on-device. */ + pi->type = SOL_INSTR_UNKNOWN; + *force_opaque = true; + continue; + } + memcpy(pi->program_id, tx->accounts[program_idx], SOL_PUBKEY_SIZE); /* Classify and decode */ @@ -438,6 +459,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 +510,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 +529,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,7 +560,8 @@ 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; @@ -563,7 +588,15 @@ static SolanaTxReview solana_parseVersionedTx(const uint8_t* raw, } if (pos != raw_len) return SOL_TX_REVIEW_MALFORMED; - return SOL_TX_REVIEW_OPAQUE; + + /* A v0 message whose instructions only touch static accounts is exactly + * as verifiable as a legacy message. Lookup-table sections are allowed + * to exist; any instruction actually reaching into them forced opaque + * above (external indices). */ + if (tx->num_instructions == 0 || has_unknown || force_opaque) { + return SOL_TX_REVIEW_OPAQUE; + } + return SOL_TX_REVIEW_VERIFIED; } SolanaTxReview solana_inspectTx(const uint8_t* raw, size_t raw_len, diff --git a/lib/firmware/storage.c b/lib/firmware/storage.c index c1cd77d12..889cc3850 100644 --- a/lib/firmware/storage.c +++ b/lib/firmware/storage.c @@ -45,6 +45,7 @@ #include "keepkey/firmware/passphrase_sm.h" #include "keepkey/firmware/policy.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" @@ -90,6 +91,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. @@ -176,6 +199,18 @@ static void write_u32_le(char* ptr, uint32_t val) { ptr[3] = (val >> 24) & 0xff; } +/* Cast each byte through uint8_t first: on the emulator (signed char) a raw + * (uint16_t)ptr[i] would sign-extend a 0x80+ low byte (icon_len reaches 384). + */ +static uint16_t read_u16_le(const char* ptr) { + return (uint16_t)((uint8_t)ptr[0]) | ((uint16_t)((uint8_t)ptr[1]) << 8); +} + +static void write_u16_le(char* ptr, uint16_t val) { + ptr[0] = val & 0xff; + ptr[1] = (val >> 8) & 0xff; +} + static bool read_bool(const char* ptr) { return *ptr; } static void write_bool(char* ptr, bool val) { *ptr = val ? 1 : 0; } @@ -184,8 +219,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 +234,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: \ @@ -1080,6 +1127,55 @@ void storage_readStorageV17(Storage* storage, const char* ptr, size_t len) { memcpy(storage->encrypted_sec, ptr + 1501, sizeof(storage->encrypted_sec)); } +// V18 appends the persistent clear-sign identities block immediately after the +// V17 encrypted_sec (which ends at ptr + 1501 + V17_ENCSEC_SIZE = ptr + 2525). +// Everything before it is the byte-identical V17 layout — never reorder it. +// 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); + + for (int i = 0; i < PERSISTENT_IDENTITY_COUNT; i++) { + const ClearsignIdentity* id = &storage->pub.clearsign_identities[i]; + char* p = ptr + CLEARSIGN_IDENTITY_BLOCK_OFF + + (size_t)i * CLEARSIGN_IDENTITY_SERIALIZED_LEN; + write_u8(p + 0, id->present ? 1 : 0); + write_u8(p + 1, id->key_id); + memcpy(p + 2, id->pubkey, sizeof(id->pubkey)); // 33 + memcpy(p + 35, id->alias, sizeof(id->alias)); // 32 + write_u8(p + 67, id->icon_w); + write_u8(p + 68, id->icon_h); + write_u16_le(p + 69, id->icon_len); + memcpy(p + 71, id->icon, sizeof(id->icon)); // CLEARSIGN_ICON_MAX + } +} + +void storage_readStorageV18(Storage* storage, const char* ptr, size_t len) { + storage_readStorageV17(storage, ptr, len); + + for (int i = 0; i < PERSISTENT_IDENTITY_COUNT; i++) { + ClearsignIdentity* id = &storage->pub.clearsign_identities[i]; + const char* p = ptr + CLEARSIGN_IDENTITY_BLOCK_OFF + + (size_t)i * CLEARSIGN_IDENTITY_SERIALIZED_LEN; + id->present = read_u8(p + 0) != 0; + id->key_id = read_u8(p + 1); + memcpy(id->pubkey, p + 2, sizeof(id->pubkey)); + memcpy(id->alias, p + 35, sizeof(id->alias)); + id->alias[sizeof(id->alias) - 1] = + '\0'; // never trust flash to be NUL-term + id->icon_w = read_u8(p + 67); + id->icon_h = read_u8(p + 68); + id->icon_len = read_u16_le(p + 69); + if (id->icon_len > CLEARSIGN_ICON_MAX) + id->icon_len = 0; // corrupt => no icon + memcpy(id->icon, p + 71, sizeof(id->icon)); + } +} + 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 +1244,25 @@ 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); +} + 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 +1308,54 @@ 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_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 { + storage_readV18(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 +1494,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()) { @@ -1386,6 +1547,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) { @@ -1460,9 +1624,17 @@ 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]; + // V18 storage layout = V17 (2525 bytes) + persistent identities block + // (PERSISTENT_IDENTITY_COUNT * CLEARSIGN_IDENTITY_SERIALIZED_LEN = 2*454 = + // 908) = 3433; + meta (44) = 3477. Rounded up to a multiple of 4 (the CRC + // below iterates uint32_t words) => 3480. + static char flash_temp[3480]; memzero(flash_temp, sizeof(flash_temp)); @@ -1472,7 +1644,7 @@ void storage_commit(void) { // commit what was in storage->encrypted_sec } - storage_writeV17(flash_temp, sizeof(flash_temp), &shadow_config); + storage_writeV18(flash_temp, sizeof(flash_temp), &shadow_config); memcpy(&shadow_config, STORAGE_MAGIC_STR, STORAGE_MAGIC_LEN); @@ -1624,6 +1796,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); } @@ -1657,6 +1833,45 @@ const char* storage_getLabel(void) { return shadow_config.storage.pub.label; } +// ── Persistent clear-sign identities (V18) ─────────────────────────────── + +int storage_clearsignIdentityCount(void) { return PERSISTENT_IDENTITY_COUNT; } + +const ClearsignIdentity* storage_getClearsignIdentity(int slot) { + if (slot < 0 || slot >= PERSISTENT_IDENTITY_COUNT) return NULL; + const ClearsignIdentity* id = + &shadow_config.storage.pub.clearsign_identities[slot]; + return id->present ? id : NULL; +} + +// Write an identity to a persistent slot and commit. Reuses the slot already +// holding this pubkey (re-load with a new icon/alias), else the first free +// slot. Returns false if all slots are taken by other identities (caller keeps +// the RAM-only signer working — persistence just didn't happen). +bool storage_upsertClearsignIdentity(const ClearsignIdentity* id) { + if (!id) return false; + int free_slot = -1; + int target = -1; + for (int i = 0; i < PERSISTENT_IDENTITY_COUNT; i++) { + const ClearsignIdentity* cur = + &shadow_config.storage.pub.clearsign_identities[i]; + if (cur->present && memcmp(cur->pubkey, id->pubkey, 33) == 0) { + target = i; + break; + } + if (!cur->present && free_slot < 0) free_slot = i; + } + if (target < 0) target = free_slot; + if (target < 0) return false; // no room + + ClearsignIdentity* dst = + &shadow_config.storage.pub.clearsign_identities[target]; + memcpy(dst, id, sizeof(*dst)); + dst->present = true; + storage_commit(); + return true; +} + void storage_setLanguage(const char* lang) { if (!lang) { return; @@ -1866,6 +2081,34 @@ 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 +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"), 250); + return zcash_derive_orchard_keys(seed, 64, account, keys_out); +} + +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 +2237,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 +2255,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..220afa493 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 +/* Persistent clear-sign identities (the "KeepKey + identity" trust anchors). + * A loaded signer whose LoadClearsignSigner carried persist=true is written to + * flash here so it survives reboot; RAM-only signers (signed_metadata.c) are + * unaffected. WipeDevice clears these along with the rest of storage. + * 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 @@ -70,6 +95,9 @@ typedef struct _Storage { bool authdata_encrypted; uint8_t random_salt[32]; uint8_t authdata_fingerprint[32]; + /* V18: persistent clear-sign identities. Zero-initialized (present=false) + * for wallets migrated up from V17 — no data loss. */ + ClearsignIdentity clearsign_identities[PERSISTENT_IDENTITY_COUNT]; } pub; bool has_sec; @@ -181,10 +209,23 @@ pintest_t session_clear_impl(SessionState* ss, Storage* storage, /// \returns NULL on error, otherwise \returns the private seed. const uint8_t* storage_getSeed(const ConfigFlash* cfg, bool usePassphrase); +/// \brief Number of persistent clear-sign identity slots. +int storage_clearsignIdentityCount(void); + +/// \brief The persistent clear-sign identity in `slot`, or NULL if empty/out +/// of range. Pointer into shadow storage — valid until the next commit. +const ClearsignIdentity* storage_getClearsignIdentity(int slot); + +/// \brief Persist a clear-sign identity (reuse its pubkey's slot, else the +/// first free one) and commit. Returns false if all slots are occupied +/// by other identities. +bool storage_upsertClearsignIdentity(const ClearsignIdentity* id); + 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 +244,10 @@ 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_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_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..a6c15e4ec 100644 --- a/lib/firmware/storage_versions.inc +++ b/lib/firmware/storage_versions.inc @@ -22,7 +22,8 @@ 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_LAST(18) #undef STORAGE_VERSION_ENTRY 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..255bb2b0e 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -29,8 +29,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 +47,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 +108,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 +132,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 +168,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 +185,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\":\""; @@ -204,98 +237,120 @@ 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; + 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 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] == '=') { + 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, - "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; + } + } 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, "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; } } @@ -303,20 +358,17 @@ 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 } - float percent = (float)(atoi(parseTokPtrs[3])) / 100; + float percent = (float)(atoi(fields[2])) / 100; if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain withdraw liquidity", "Confirm withdraw %3.2f%% of asset %s on chain %s", percent, - parseTokPtrs[2], parseTokPtrs[1])) { + asset, chain)) { return false; } return true; 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/lib/firmware/transaction.c b/lib/firmware/transaction.c index 1405401a7..9d30f1acd 100644 --- a/lib/firmware/transaction.c +++ b/lib/firmware/transaction.c @@ -238,6 +238,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 +247,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; 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/lib/firmware/zcash.c b/lib/firmware/zcash.c new file mode 100644 index 000000000..4ca632845 --- /dev/null +++ b/lib/firmware/zcash.c @@ -0,0 +1,1129 @@ +/* + * 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_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_mod_q(&lo); + + bn_read_le(input + 32, &hi); + pallas_mod_q(&hi); + + bn_read_le(two_256_mod_q, &t256); + + /* result = hi * (2^256 mod q) mod q */ + bn_copy(&hi, &result); + pallas_mul_mod_q(&result, &t256); + + /* result = result + lo mod q */ + pallas_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_mod_p(&lo); + + bn_read_le(input + 32, &hi); + pallas_mod_p(&hi); + + bn_read_le(two_256_mod_p, &t256); + + /* result = hi * (2^256 mod p) mod p */ + bn_copy(&hi, &result); + pallas_mul_mod_p(&result, &t256); + + /* result = result + lo mod p */ + bignum256 sum; + pallas_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; + pallas_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; +} + +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]) { + if (!receiver || !rho || !rseed || !cmx_out) return false; + + uint8_t msg[136]; + uint8_t prf_in[33]; + uint8_t prf_out[64]; + uint8_t rcm[32]; + uint8_t psi[32]; + curve_point q, r; + bool ok = false; + + memcpy(prf_in + 1, rho, 32); + + prf_in[0] = 0x05; + prf_expand(rseed, prf_in, sizeof(prf_in), prf_out); + to_scalar(prf_out, rcm); + + prf_in[0] = 0x09; + prf_expand(rseed, prf_in, sizeof(prf_in), prf_out); + to_base(prf_out, psi); + + 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(&q, &r, msg, 1086, rcm, cmx_out) == 0; + + 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(&q, sizeof(q)); + memzero(&r, sizeof(r)); + return ok; +} + +bool zcash_derive_orchard_keys(const uint8_t* seed, uint32_t seed_len, + uint32_t account, ZcashOrchardKeys* keys) { + 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(&ask_test, &ak_test); + 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)); + } + + /* 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_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]) { + 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); + hasher_Final(&h, sighash_out); + + return true; +} + +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->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_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/rand/rng.c b/lib/rand/rng.c index 57c8c75b9..9e61f3e82 100644 --- a/lib/rand/rng.c +++ b/lib/rand/rng.c @@ -27,6 +27,14 @@ #include #endif +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN /* exclude winsock.h — it declares \ + shutdown(SOCKET,int) */ +#include +#include +#include +#endif + void reset_rng(void) { #ifndef EMULATOR /* disable RNG */ @@ -77,20 +85,46 @@ uint32_t random32(void) { } last = new; return new; +#elif defined(_WIN32) + /* Windows has no POSIX random(); use the system CSPRNG (stronger than the + * macOS/Linux emulator's random() PRNG anyway). Resolves via the bcrypt link + * on kkemulator_dylib (tools/emulator/CMakeLists.txt). */ + uint32_t v = 0; + if (BCryptGenRandom(NULL, (PUCHAR)&v, (ULONG)sizeof(v), + BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + abort(); + } + return v; #else return random(); #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); } diff --git a/lib/transport/CMakeLists.txt b/lib/transport/CMakeLists.txt index d42d3e113..c8dd5c0ac 100644 --- a/lib/transport/CMakeLists.txt +++ b/lib/transport/CMakeLists.txt @@ -18,6 +18,8 @@ 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-hive.proto ${DEVICE_PROTOCOL}/messages.proto) set(protoc_pb_options @@ -35,6 +37,8 @@ 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-hive.options ${CMAKE_SOURCE_DIR}/include/keepkey/transport/messages.options) set(protoc_c_sources @@ -52,6 +56,8 @@ 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-hive.pb.c ${CMAKE_BINARY_DIR}/lib/transport/messages.pb.c) set(protoc_c_headers @@ -69,6 +75,8 @@ 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-hive.pb.h ${CMAKE_BINARY_DIR}/include/messages.pb.h) set(protoc_pb_sources_moved @@ -86,6 +94,8 @@ 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-hive.proto ${CMAKE_BINARY_DIR}/lib/transport/messages.proto) add_custom_command( @@ -163,6 +173,14 @@ 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 + "--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/scripts/emulator/python-keepkey-tests.sh b/scripts/emulator/python-keepkey-tests.sh index 28b122fc9..bbbce7519 100755 --- a/scripts/emulator/python-keepkey-tests.sh +++ b/scripts/emulator/python-keepkey-tests.sh @@ -41,9 +41,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 diff --git a/tools/check_sram_budget.py b/tools/check_sram_budget.py new file mode 100644 index 000000000..bc21774ea --- /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 zcash-privacy 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 zcash-privacy +""" + +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/emulator/CMakeLists.txt b/tools/emulator/CMakeLists.txt index 63b80bd88..9f14af996 100644 --- a/tools/emulator/CMakeLists.txt +++ b/tools/emulator/CMakeLists.txt @@ -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 setup.c / rng.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..3c91156e6 100644 --- a/tools/firmware/CMakeLists.txt +++ b/tools/firmware/CMakeLists.txt @@ -13,8 +13,10 @@ if(NOT ${KK_EMULATOR}) 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/firmware/keepkey.ld b/tools/firmware/keepkey.ld index 964b43676..51a313f00 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 zcash-privacy variant 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/tools/sram-budgets.json b/tools/sram-budgets.json new file mode 100644 index 000000000..ee0ca71a2 --- /dev/null +++ b/tools/sram-budgets.json @@ -0,0 +1,10 @@ +{ + "_comment": "Per-variant 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 zcash-privacy 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": {}, + "zcash-privacy": {} + } +} diff --git a/unittests/crypto/CMakeLists.txt b/unittests/crypto/CMakeLists.txt index 782549f28..7c9898e38 100644 --- a/unittests/crypto/CMakeLists.txt +++ b/unittests/crypto/CMakeLists.txt @@ -22,3 +22,19 @@ 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}) + add_executable(zcash-crypto-unit + ../firmware/zcash.cpp + ${CMAKE_SOURCE_DIR}/lib/firmware/zcash.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) +endif() diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 647d72571..59224841e 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -1,15 +1,34 @@ set(sources - coins.cpp - cosmos.cpp - eos.cpp - ethereum.cpp - nano.cpp recovery.cpp - ripple.cpp storage.cpp usb_rx.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 + coins.cpp + cosmos.cpp + eos.cpp + ethereum.cpp + mayachain.cpp + nano.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}) + list(APPEND sources zcash.cpp) +endif() + include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/lib/firmware 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/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/signed_metadata.cpp b/unittests/firmware/signed_metadata.cpp new file mode 100644 index 000000000..a76d717eb --- /dev/null +++ b/unittests/firmware/signed_metadata.cpp @@ -0,0 +1,1415 @@ +/* + * 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 "trezor/crypto/ecdsa.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" +} + +#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"; + +class SignedMetadataTest : public ::testing::Test { + protected: + void SetUp() override { + 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(); } + + 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, 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)")); +} + +/* ---- 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); +} + +/* 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 (dynamic types out of scope) -> MALFORMED. */ +TEST_F(SignedMetadataTest, V2RejectsUnsupportedFormat) { + V2Spec s = v2_base_spec(); + s.args[1] = V2Arg{"data", ARG_FORMAT_BYTES, 0, ""}; + std::vector blob = sign_body(build_v2_body(s)); + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* 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)); +} + +} // namespace diff --git a/unittests/firmware/solana.cpp b/unittests/firmware/solana.cpp index 36ccef252..debae5425 100644 --- a/unittests/firmware/solana.cpp +++ b/unittests/firmware/solana.cpp @@ -480,15 +480,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 +541,20 @@ 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, VersionedMessageWithUnreferencedLookupTableIsVerified) { uint8_t raw[256]; size_t pos = 0; @@ -582,11 +602,127 @@ TEST(Solana, VersionedMessageWithLookupTableIsOpaque) { raw[pos++] = 1; raw[pos++] = 2; + /* Table attached but no instruction reaches into it: every displayed + * field is decoded from static accounts, so it stays verifiable. */ + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); + EXPECT_TRUE(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; diff --git a/unittests/firmware/storage.cpp b/unittests/firmware/storage.cpp index b04af1ebe..1dc4fdb7e 100644 --- a/unittests/firmware/storage.cpp +++ b/unittests/firmware/storage.cpp @@ -497,6 +497,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)); @@ -556,6 +620,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 +718,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, 0x12, 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 +784,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; + for (int i = 0; i < 33; i++) a->pubkey[i] = (uint8_t)(i + 1); + strcpy(a->alias, "CI Test"); + a->icon_w = 48; + a->icon_h = 48; + a->icon_len = 120; + for (int i = 0; i < a->icon_len; i++) a->icon[i] = (uint8_t)(0xF0 ^ i); + + // Identity 1: text-only (no icon). + ClearsignIdentity *b = &start.storage.pub.clearsign_identities[1]; + memset(b, 0, sizeof(*b)); + b->present = true; + b->key_id = 2; + for (int i = 0; i < 33; i++) b->pubkey[i] = (uint8_t)(0x80 + i); + strcpy(b->alias, "Pioneer Insight"); + + std::vector flash(3480, 0); + storage_writeV18((char *)&flash[0], flash.size(), &start); + + ConfigFlash end; + memset(&end, 0x00, sizeof(end)); + storage_readV18(&end, (const char *)&flash[0], flash.size()); + + for (int k = 0; k < PERSISTENT_IDENTITY_COUNT; k++) { + const ClearsignIdentity *s = &start.storage.pub.clearsign_identities[k]; + const ClearsignIdentity *r = &end.storage.pub.clearsign_identities[k]; + ASSERT_EQ(s->present, r->present) << "present " << k; + ASSERT_EQ(s->key_id, r->key_id) << "key_id " << k; + ASSERT_EQ(0, memcmp(s->pubkey, r->pubkey, 33)) << "pubkey " << k; + ASSERT_STREQ(s->alias, r->alias) << "alias " << k; + ASSERT_EQ(s->icon_w, r->icon_w) << "icon_w " << k; + ASSERT_EQ(s->icon_h, r->icon_h) << "icon_h " << k; + ASSERT_EQ(s->icon_len, r->icon_len) << "icon_len " << k; + ASSERT_EQ(0, memcmp(s->icon, r->icon, CLEARSIGN_ICON_MAX)) << "icon " << k; + } + + // A corrupt on-flash icon_len (> CLEARSIGN_ICON_MAX) must clamp to 0 on read + // so downstream renderers never index past the icon buffer. Identity 0's + // icon_len lives at flash[44 + 2525 + 69] (present, key_id, pubkey[33], + // alias[32], icon_w, icon_h => 69). + const size_t id0_icon_len_off = 44 + 1501 + V17_ENCSEC_SIZE + 69; + flash[id0_icon_len_off] = 0xFF; + flash[id0_icon_len_off + 1] = 0xFF; + ConfigFlash corrupt; + memset(&corrupt, 0x00, sizeof(corrupt)); + storage_readV18(&corrupt, (const char *)&flash[0], flash.size()); + ASSERT_EQ(0, corrupt.storage.pub.clearsign_identities[0].icon_len); +} diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index 4fa8faa8f..c31a780e1 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -1,12 +1,107 @@ extern "C" { +#include "keepkey/board/messages.h" +#include "keepkey/board/usb.h" #include "keepkey/firmware/coins.h" +#include "keepkey/firmware/fsm.h" #include "keepkey/firmware/thorchain.h" #include "keepkey/firmware/tendermint.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 + +/* + * 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 +119,203 @@ 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. + * ===================================================================== */ - ASSERT_TRUE(thorchain_signTxUpdateMsgSend( - 100000, "thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v")); +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()); +} - uint8_t public_key[33]; - uint8_t signature[64]; +// 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()); +} - ASSERT_TRUE(thorchain_signTxFinalize(public_key, signature)); +// 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( - 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")); + 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()); +} + +// 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); + + 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()); } 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); +} diff --git a/unittests/firmware/zcash.cpp b/unittests/firmware/zcash.cpp new file mode 100644 index 000000000..e1a4905d1 --- /dev/null +++ b/unittests/firmware/zcash.cpp @@ -0,0 +1,1787 @@ +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"; + + /* 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.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)); +} + +TEST(Zcash, OrchardNoteCommitment_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}; + 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]; + ASSERT_TRUE(zcash_orchard_compute_cmx(recipient, value, rho, rseed, cmx)); + EXPECT_TRUE(memcmp(cmx, expected_cmx, sizeof(cmx)) == 0); + + 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, 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, + "u1ut4h93zg5670tyqss7tneru3t7h6dk62r9hhyxyrpv3nwwe9dnyj5l0ruwygf74gp5f3zklj5xly4h8h54un3asugt9mn6gwfqsq3wq7"); + + 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_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, 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 ────────────────────────────────── */ + +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, 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); +} From f6cd678914c6e16b99d4ec2de765a564c9bb8e83 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 01:45:00 -0300 Subject: [PATCH 02/55] fix review findings in Hive and Zcash --- include/keepkey/firmware/hive.h | 22 +++- include/keepkey/firmware/zcash.h | 6 + lib/firmware/fsm_msg_hive.h | 186 +++++++++++++++++++++--------- lib/firmware/fsm_msg_zcash.h | 18 ++- lib/firmware/hive.c | 34 +++++- lib/firmware/zcash.c | 4 + unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/hive.cpp | 128 ++++++++++++++++++++ unittests/firmware/zcash.cpp | 8 ++ 9 files changed, 345 insertions(+), 62 deletions(-) create mode 100644 unittests/firmware/hive.cpp diff --git a/include/keepkey/firmware/hive.h b/include/keepkey/firmware/hive.h index 145b55704..216095dfb 100644 --- a/include/keepkey/firmware/hive.h +++ b/include/keepkey/firmware/hive.h @@ -29,6 +29,17 @@ #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 @@ -92,10 +103,19 @@ typedef struct { // 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-or-permlink / cj: id + 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 only (-10000..10000) bool is_top_level; // comment only: parent_author empty uint8_t n_auths; // custom_json only: total auth account names diff --git a/include/keepkey/firmware/zcash.h b/include/keepkey/firmware/zcash.h index 5fe61ee7e..79b27c391 100644 --- a/include/keepkey/firmware/zcash.h +++ b/include/keepkey/firmware/zcash.h @@ -328,6 +328,12 @@ bool zcash_orchard_derive_unified_address(const ZcashOrchardKeys* keys, 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 diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h index 51f9ed4e1..70bd59e3b 100644 --- a/lib/firmware/fsm_msg_hive.h +++ b/lib/firmware/fsm_msg_hive.h @@ -19,6 +19,13 @@ void fsm_msgHiveGetPublicKey(const HiveGetPublicKey* msg) { 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; @@ -137,15 +144,12 @@ void fsm_msgHiveGetPublicKeys(const HiveGetPublicKeys* msg) { static bool hive_slip48_path_ok(const uint32_t* address_n, uint32_t count, uint32_t required_role) { - if (count != 5) return false; - if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; - if (address_n[1] != HIVE_SLIP48_NETWORK) return false; - if (address_n[2] != required_role) return false; - if ((address_n[3] & 0x80000000u) == 0) return false; - if (address_n[4] != 0x80000000u) return false; // key index 0' - return true; + 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) { @@ -212,8 +216,9 @@ void fsm_msgHiveSignTx(const HiveSignTx* msg) { } if (msg->has_memo && strlen(msg->memo) > 0) { - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, "Memo", "%s", - msg->memo)) { + 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(); @@ -468,11 +473,7 @@ void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg) { static bool hive_slip48_message_path_ok(const uint32_t* address_n, uint32_t count, const char** role_label) { - if (count != 5) return false; - if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; - if (address_n[1] != HIVE_SLIP48_NETWORK) return false; - if ((address_n[3] & 0x80000000u) == 0) return false; - if (address_n[4] != 0x80000000u) return false; // key index 0' + if (!hive_slip48_path_valid(address_n, count)) return false; switch (address_n[2]) { case HIVE_ROLE_ACTIVE: *role_label = "active"; @@ -602,20 +603,18 @@ void fsm_msgHiveSignMessage(const HiveSignMessage* msg) { // 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) { - if (count != 5) return false; - if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; - if (address_n[1] != HIVE_SLIP48_NETWORK) return false; - if ((address_n[3] & 0x80000000u) == 0) return false; - if (address_n[4] != 0x80000000u) return false; // key index 0' - return address_n[2] == (needs_active ? HIVE_ROLE_ACTIVE : HIVE_ROLE_POSTING); + return hive_slip48_path_valid_for_role( + address_n, count, needs_active ? HIVE_ROLE_ACTIVE : HIVE_ROLE_POSTING); } -// Uniform display rules (PR #306 finding-1): printable-ASCII within the -// budget is copied verbatim; a truncated slice carries an explicit -// "(+N more)" marker; any non-ASCII byte switches the whole slice to a -// byte-count + short hex preview (partial UTF-8 sequences garble the OLED). -static void hive_format_slice(const uint8_t* s, uint16_t len, char* out, - size_t out_len) { +// 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. At the body font's maximum 8-pixel glyph width, each +// page fits within the three 225-pixel OLED body rows. +#define HIVE_DISPLAY_ASCII_CHUNK 72 +#define HIVE_DISPLAY_HEX_CHUNK 40 + +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) { @@ -623,23 +622,55 @@ static void hive_format_slice(const uint8_t* s, uint16_t len, char* out, break; } } - if (ascii) { - uint16_t show = len > 120 ? 120 : len; - if ((size_t)show + 16 > out_len) show = (uint16_t)(out_len - 16); - memcpy(out, s, show); - if (show < len) { - snprintf(out + show, out_len - show, "(+%u more)", - (unsigned)(len - show)); + return ascii; +} + +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 chunk_size = + ascii ? HIVE_DISPLAY_ASCII_CHUNK : HIVE_DISPLAY_HEX_CHUNK; + uint16_t pages = (uint16_t)((len + chunk_size - 1) / chunk_size); + + for (uint16_t page = 0; page < pages; page++) { + uint16_t offset = (uint16_t)(page * chunk_size); + uint16_t take = (uint16_t)(len - offset); + if (take > chunk_size) take = chunk_size; + + 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 { - out[show] = '\0'; + strlcpy(page_title, title, sizeof(page_title)); } - } else { - unsigned show = len > 8 ? 8 : len; - int off = snprintf(out, out_len, "<%u bytes> ", (unsigned)len); - for (unsigned i = 0; i < show && off + 2 < (int)out_len; i++, off += 2) { - snprintf(out + off, out_len - off, "%02x", s[i]); + + if (ascii) { + char rendered[HIVE_DISPLAY_ASCII_CHUNK + 1]; + memcpy(rendered, s + offset, take); + rendered[take] = '\0'; + if (!confirm(type, page_title, "%s", rendered)) return false; + } else { + char rendered[HIVE_DISPLAY_HEX_CHUNK * 2 + 1]; + 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; } } + return true; +} + +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) { @@ -679,39 +710,82 @@ void fsm_msgHiveSignOperations(const HiveSignOperations* msg) { if (!node) return; hdnode_fill_public_key(node); - // One confirm per operation, then a final sign confirm (transfer pattern). + // 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 - memcpy(name, op->acct, op->acct_len); - name[op->acct_len] = '\0'; - char target[140], detail[140]; - hive_format_slice(op->target, op->target_len, target, sizeof(target)); - hive_format_slice(op->detail, op->detail_len, detail, sizeof(detail)); + 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/%s at %d.%02d%%", name, target, detail, - w / 100, w % 100); + 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: - approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - op->is_top_level ? "Post" : "Comment", "@%s: %s\n%s", - name, target, detail); + 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: { + char target[33]; + hive_copy_slice(target, sizeof(target), op->target, op->target_len); char extra[12] = ""; if (op->n_auths > 1) { snprintf(extra, sizeof(extra), " +%u", (unsigned)(op->n_auths - 1)); } - approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Custom JSON", "id: %s\nby @%s%s\n%s", target, name, - extra, detail); + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON", "id: %s\nby @%s%s", target, name, extra); + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON", op->detail, op->detail_len); + } break; } default: diff --git a/lib/firmware/fsm_msg_zcash.h b/lib/firmware/fsm_msg_zcash.h index 43465f90d..67987738f 100644 --- a/lib/firmware/fsm_msg_zcash.h +++ b/lib/firmware/fsm_msg_zcash.h @@ -198,7 +198,12 @@ static bool zcash_resolve_account(bool has_account, uint32_t account_field, static bool zcash_check_seed_fingerprint(bool has_expected, const uint8_t* expected, size_t expected_size) { - if (!has_expected || expected_size != 32) return true; + 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)) { @@ -789,6 +794,17 @@ void fsm_msgZcashGetOrchardFVK(const ZcashGetOrchardFVK* msg) { 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; diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c index bda925dd8..ba0be97be 100644 --- a/lib/firmware/hive.c +++ b/lib/firmware/hive.c @@ -35,6 +35,28 @@ bool hive_getPublicKey(const uint8_t public_key[33], char* out, // 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; @@ -338,10 +360,14 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, HIVE_MAX_OPS_TX_LEN) || !cur_string(&c, &jm, &jm_len, 0, HIVE_MAX_OPS_TX_LEN)) return "Hive comment: malformed fields"; - if (op->target_len == 0) { // no title — show the permlink instead - op->target = permlink; - op->target_len = permlink_len; - } + 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; diff --git a/lib/firmware/zcash.c b/lib/firmware/zcash.c index 4ca632845..c84ef1e3b 100644 --- a/lib/firmware/zcash.c +++ b/lib/firmware/zcash.c @@ -1098,6 +1098,10 @@ bool zcash_pczt_signing_request_is_clear( * 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; diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 59224841e..1c4210208 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -14,6 +14,7 @@ if(NOT ${KK_BITCOIN_ONLY}) cosmos.cpp eos.cpp ethereum.cpp + hive.cpp mayachain.cpp nano.cpp ripple.cpp diff --git a/unittests/firmware/hive.cpp b/unittests/firmware/hive.cpp new file mode 100644 index 000000000..79caca927 --- /dev/null +++ b/unittests/firmware/hive.cpp @@ -0,0 +1,128 @@ +extern "C" { +#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; +} + +} // 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)); +} + +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)); +} diff --git a/unittests/firmware/zcash.cpp b/unittests/firmware/zcash.cpp index e1a4905d1..4b08fdf43 100644 --- a/unittests/firmware/zcash.cpp +++ b/unittests/firmware/zcash.cpp @@ -1735,6 +1735,14 @@ TEST(Zcash, SeedFingerprint_ReferenceVector) { 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]; From 8d414e40b1416eeb550bf4bed567a8c5d6dc6ddf Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 14:58:59 -0300 Subject: [PATCH 03/55] fix(clearsign): harden EVM/THOR/Hive/Solana signing against hidden-field and cross-domain attacks Ten clear-sign findings from security review, fixed while keeping clear-signing functional (display everything; never degrade to blind-sign for legit memos). - signed_metadata: v2 schema cannot bind msg->value, so refuse to clear-sign a payable tx under v2 (the native-value screen is suppressed on a v2 match). - thortx: bind THOR/MAYA router to chain_id==1; display the real msg->value for native deposits (router ignores the ABI amount) and reject amount/value disagreement; read the ABI memo length instead of a fixed 64 bytes and bound the padded calldata exactly; lower min-chunk to 164/196 so short memos still clear-sign. - thorchain: display DEX-aggregator (agg addr, final token, min out) and ADD affiliate/fee fields; page the complete raw memo when a '|' outbound suffix is present (it can carry ':' the split would scatter). - hive: pin tx chain_id to mainnet; reject non-printable messages so a binary transaction preimage (chain_id||tx) can never be signed as a message on any fork chain id (closes the cross-domain signature oracle). - solana: page the full memo (text/hex) instead of a byte-count summary; show the real mint next to the host-supplied, unauthenticated token symbol. Adds unit tests for the THORChain aggregator/ADD memo display and the Hive printable-message guard. --- include/keepkey/firmware/hive.h | 10 ++ lib/firmware/ethereum_contracts/thortx.c | 72 ++++++++++++-- lib/firmware/fsm_msg_hive.h | 55 +++++------ lib/firmware/fsm_msg_solana.h | 96 ++++++++++++++---- lib/firmware/hive.c | 24 ++++- lib/firmware/signed_metadata.c | 11 +++ lib/firmware/thorchain.c | 120 ++++++++++++++++++++++- unittests/firmware/hive.cpp | 25 +++++ unittests/firmware/thorchain.cpp | 45 +++++++++ 9 files changed, 394 insertions(+), 64 deletions(-) diff --git a/include/keepkey/firmware/hive.h b/include/keepkey/firmware/hive.h index 216095dfb..ede828ecd 100644 --- a/include/keepkey/firmware/hive.h +++ b/include/keepkey/firmware/hive.h @@ -152,6 +152,16 @@ void hive_signOperations(const HDNode* node, const HiveSignOperations* msg, 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. diff --git a/lib/firmware/ethereum_contracts/thortx.c b/lib/firmware/ethereum_contracts/thortx.c index 4df12174e..c0c45216f 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -51,6 +51,10 @@ static void thor_format_to_addr(const EthereumSignTx* msg, char out[41]) { 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); @@ -59,6 +63,9 @@ bool thor_isMayachainTx(const EthereumSignTx* msg) { bool thor_isThorchainTx(const EthereumSignTx* msg) { if (!msg->has_to || msg->to.size != 20) return false; + /* THOR_ROUTER is an Ethereum-mainnet identity; bind to mainnet for the same + * reason as thor_isMayachainTx above. */ + if (!msg->has_chain_id || msg->chain_id != 1) return false; if (!thor_has_deposit_selector(msg)) return false; /* Pin to the THORChain router. Without this, ANY contract carrying the * deposit selector would get the THORChain clear-sign UX and bypass the @@ -75,11 +82,14 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, 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; /* The memo is a dynamic `string`; its ABI head pointer (word 3, offset @@ -96,6 +106,32 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, } } + /* 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; const TokenType* assetToken; @@ -136,12 +172,27 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, 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 + /* For native ETH the router forwards msg.value and ignores the ABI amount + * word, so the ABI amount can read 0.01 while the tx sends 100 ETH. Display + * the value actually sent, and refuse if the ABI amount disagrees (0 is the + * canonical "unset" and is allowed). For token deposits the router pulls via + * transferFrom; native value must not ride along or it is swept unshown. */ + const bool is_native = + memcmp(contractAssetAddress, ETH_ADDRESS, sizeof(ETH_ADDRESS)) == 0; + bignum256 Value; + bn_from_bytes(msg->value.bytes, msg->value.size, &Value); + if (is_native) { + if (!bn_is_zero(&Amount) && !bn_is_equal(&Amount, &Value)) { + return false; + } + assetAddress = (const uint8_t*)ETH_NATIVE; } else { + if (!bn_is_zero(&Value)) { + return false; + } assetAddress = contractAssetAddress; } + const bignum256* displayAmount = is_native ? &Value : &Amount; assetToken = tokenByChainAddress(msg->chain_id, assetAddress); @@ -156,7 +207,7 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, } // We don't know what the exponent should be so just confirm raw unformatted // number - bn_format(&Amount, NULL, " unformatted", 0, 0, false, confStr, + bn_format(displayAmount, NULL, " unformatted", 0, 0, false, confStr, sizeof(confStr)); if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, @@ -165,7 +216,7 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, } } else { - ethereumFormatAmount(&Amount, assetToken, msg->chain_id, confStr, + ethereumFormatAmount(displayAmount, assetToken, msg->chain_id, confStr, sizeof(confStr)); if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, @@ -174,7 +225,8 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, } } - if (!thorchain_parseConfirmMemo((const char*)thorchainData, 64)) return false; + if (!thorchain_parseConfirmMemo((const char*)thorchainData, memo_len)) + return false; return true; } diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h index 70bd59e3b..02750b5a6 100644 --- a/lib/firmware/fsm_msg_hive.h +++ b/lib/firmware/fsm_msg_hive.h @@ -537,38 +537,33 @@ void fsm_msgHiveSignMessage(const HiveSignMessage* msg) { if (!node) return; hdnode_fill_public_key(node); - // Printable ASCII within the display budget is shown verbatim; anything - // longer — or with non-ASCII bytes — gets the hex preview + byte count. - // The budget matters: confirm()'s body buffer silently truncates at - // BODY_CHAR_MAX, so a long "printable" message could show a benign prefix - // while the device signs hidden trailing content. Same 128-byte budget as - // SolanaSignMessage. - bool printable = msg->message.size <= 128; - for (uint32_t i = 0; printable && i < msg->message.size; i++) { - if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { - printable = false; - } + // 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; } - bool approved; - if (printable) { - approved = confirm(ButtonRequestType_ButtonRequest_ProtectCall, - "Sign Hive Message", "(%s key) %.*s", role_label, - (int)msg->message.size, msg->message.bytes); - } else { - char preview[96]; // 64 hex chars + "... (1024 bytes)" + NUL - unsigned show = msg->message.size > 32 ? 32 : msg->message.size; - for (unsigned i = 0; i < show; i++) { - snprintf(&preview[2 * i], 3, "%02x", msg->message.bytes[i]); - } - if (msg->message.size > 32) { - snprintf(&preview[2 * show], sizeof(preview) - 2 * show, "... (%u bytes)", - (unsigned)msg->message.size); - } - approved = confirm(ButtonRequestType_ButtonRequest_ProtectCall, - "Sign Hive Bytes", "(%s key) %s", role_label, preview); - } - if (!approved) { + // 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(); diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 0cd46b8ba..266a9619c 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -40,6 +40,62 @@ static void solana_pubkeyToStr(const uint8_t key[SOL_PUBKEY_SIZE], char* out, key[31]); } +/* Display budget per confirm body: printable memos page as text, binary memos + * page as hex so the FULL content is always shown — a memo can carry swap + * intent (THORChain '=:ETH.ETH:...'), so nothing may be hidden behind a byte + * count. Mirrors hive_confirm_slice. */ +#define SOL_MEMO_ASCII_CHUNK 72 +#define SOL_MEMO_HEX_CHUNK 40 + +static bool solana_confirm_memo(const char* title, const uint8_t* s, + uint16_t len) { + if (len == 0) { + return confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, title, + "Memo (empty)"); + } + bool ascii = true; + for (uint16_t i = 0; i < len; i++) { + if (s[i] < 0x20 || s[i] > 0x7e) { + ascii = false; + break; + } + } + uint16_t chunk = ascii ? SOL_MEMO_ASCII_CHUNK : SOL_MEMO_HEX_CHUNK; + uint16_t pages = (uint16_t)((len + chunk - 1) / chunk); + + for (uint16_t page = 0; page < pages; page++) { + uint16_t offset = (uint16_t)(page * chunk); + uint16_t take = (uint16_t)(len - offset); + if (take > chunk) take = chunk; + + char page_title[32]; + 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[SOL_MEMO_ASCII_CHUNK + 1]; + memcpy(rendered, s + offset, take); + rendered[take] = '\0'; + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, page_title, "%s", + rendered)) + return false; + } else { + char rendered[SOL_MEMO_HEX_CHUNK * 2 + 1]; + for (uint16_t i = 0; i < take; i++) { + snprintf(rendered + 2 * i, 3, "%02x", s[offset + i]); + } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, page_title, "%s", + rendered)) + return false; + } + } + return true; +} + /* Confirm a single parsed instruction */ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, const SolanaSignTx* msg, uint8_t idx, @@ -110,6 +166,15 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } if (ti && ti->has_symbol && ti->has_decimals) { + /* The symbol is host-supplied and unauthenticated: a malicious host can + * label any mint "USDC". Show the real mint next to the claimed symbol + * so a spoof is visible before the amount screen. */ + char mint_str[45]; + solana_pubkeyToStr(pi->mint, mint_str, sizeof(mint_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Token \"%s\"\nmint %s", ti->symbol, mint_str)) { + return false; + } char amount_str[48]; solana_formatTokenAmount(amount_str, sizeof(amount_str), pi->amount, ti->symbol, (uint8_t)ti->decimals); @@ -137,6 +202,14 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, const char* symbol = (ti && ti->has_symbol) ? ti->symbol : NULL; if (symbol) { + /* Host-supplied symbol is unauthenticated; show the real mint too so a + * spoofed label ("USDC" on an arbitrary mint) is visible. */ + char mint_str[45]; + solana_pubkeyToStr(pi->mint, mint_str, sizeof(mint_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Token \"%s\"\nmint %s", symbol, mint_str)) { + return false; + } char amount_str[48]; solana_formatTokenAmount(amount_str, sizeof(amount_str), pi->amount, symbol, pi->extra_u8); @@ -282,23 +355,12 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, "Set loaded account data to %llu bytes?", (unsigned long long)pi->extra_value); - case SOL_INSTR_MEMO: { - /* Show the memo body — swap intents (e.g. THORChain '=:ETH.ETH:...') - * ride in the memo, so hiding it hides where the funds go next. */ - bool printable = pi->data_len > 0; - for (uint16_t i = 0; i < pi->data_len; i++) { - if (pi->data[i] < 0x20 || pi->data[i] > 0x7e) { - printable = false; - break; - } - } - if (printable && pi->data_len <= 114) { - return confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, title, - "Memo: %.*s", (int)pi->data_len, pi->data); - } - return confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, title, - "Memo attached (%u bytes)", (unsigned)pi->data_len); - } + case SOL_INSTR_MEMO: + /* 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: { diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c index ba0be97be..867e56e3c 100644 --- a/lib/firmware/hive.c +++ b/lib/firmware/hive.c @@ -259,10 +259,19 @@ static bool hive_sign_tx_sig(const HDNode* node, bool has_chain_id, 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; - const uint8_t* chain_id = (has_chain_id && chain_id_size == HIVE_CHAIN_ID_LEN) - ? chain_id_bytes - : default_chain_id; - return hive_sign_digest(node, chain_id, tx_buf, tx_len, sig); + /* 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) ───────────────────────── @@ -465,6 +474,13 @@ void hive_signOperations(const HDNode* node, const HiveSignOperations* msg, // 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; diff --git a/lib/firmware/signed_metadata.c b/lib/firmware/signed_metadata.c index a3ce91094..442de85eb 100644 --- a/lib/firmware/signed_metadata.c +++ b/lib/firmware/signed_metadata.c @@ -731,6 +731,17 @@ bool signed_metadata_matches_tx(const EthereumSignTx *msg) { } if (stored_metadata.version == METADATA_VERSION_SCHEMA) { + /* v2 commits to calldata only — never to msg->value — and a v2 match + * suppresses the native-value confirm screen in ethereum.c. A payable + * method could then clear-sign an arbitrary ETH transfer whose value is + * never shown. The schema cannot express a value binding, so refuse to + * clear-sign any tx that moves native value; fall through to the blind-sign + * path (AdvancedMode) instead. (v1 is safe: its tx_hash covers value.) */ + for (uint32_t i = 0; i < msg->value.size; i++) { + if (msg->value.bytes[i] != 0) { + return false; + } + } /* 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 diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index 255bb2b0e..00a6bd58d 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -232,6 +232,52 @@ 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. */ +static bool thorchain_confirm_full_memo(const char* title, const char* memo, + size_t len) { + enum { ASCII_CHUNK = 72, HEX_CHUNK = 40 }; + bool ascii = len > 0; + for (size_t i = 0; i < len; i++) { + if ((uint8_t)memo[i] < 0x20 || (uint8_t)memo[i] > 0x7e) { + ascii = false; + break; + } + } + size_t chunk = ascii ? ASCII_CHUNK : HEX_CHUNK; + size_t pages = (len + chunk - 1) / chunk; + if (pages == 0) pages = 1; + + for (size_t page = 0; page < pages; page++) { + size_t offset = page * chunk; + size_t take = len - offset; + if (take > chunk) take = chunk; + + char page_title[24]; + snprintf(page_title, sizeof(page_title), ascii ? "%s %u/%u" : "%s Hex %u/%u", + title, (unsigned)(page + 1), (unsigned)pages); + + if (ascii) { + char rendered[ASCII_CHUNK + 1]; + memcpy(rendered, memo + offset, take); + rendered[take] = '\0'; + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, page_title, + "%s", rendered)) + return false; + } else { + char rendered[HEX_CHUNK * 2 + 1]; + for (size_t i = 0; i < take; i++) { + snprintf(rendered + 2 * i, 3, "%02x", (uint8_t)memo[offset + i]); + } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, page_title, + "%s", rendered)) + return false; + } + } + return true; +} + bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { /* Input: swapStr is candidate thorchain data @@ -251,7 +297,12 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { field (e.g. the affiliate) into an earlier display slot. */ - char* fields[8] = {NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL}; + /* 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. */ @@ -276,7 +327,7 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { // Split on ':', keeping empty fields nfields = 0; fields[nfields++] = memoBuf; - for (i = 0; memoBuf[i] != '\0' && nfields < 8; i++) { + for (i = 0; memoBuf[i] != '\0' && nfields < 10; i++) { if (memoBuf[i] == ':') { memoBuf[i] = '\0'; fields[nfields++] = &memoBuf[i + 1]; @@ -302,6 +353,20 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { // Check for swap 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 = @@ -312,6 +377,19 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { (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", asset, @@ -334,14 +412,42 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { 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(fields[0], "ADD", 3) == 0 || *fields[0] == 'a' || *fields[0] == '+') { - // add liquidity pool address (optional) + // 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", @@ -354,6 +460,14 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { 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; + } + } return true; } diff --git a/unittests/firmware/hive.cpp b/unittests/firmware/hive.cpp index 79caca927..b99899a2f 100644 --- a/unittests/firmware/hive.cpp +++ b/unittests/firmware/hive.cpp @@ -112,6 +112,31 @@ TEST(Hive, CommentParserRetainsEveryDisplayedField) { 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", "{}"); diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index c31a780e1..6c01ddd78 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -319,3 +319,48 @@ TEST(Thorchain, MemoOversized) { EXPECT_FALSE(parseMemo("SWAP:ETH.ETH:0xdest:420", 257)); 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()); +} + +// 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(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()); +} From 3febf3ca5a31ca45971dd10c3b8740d632b581c9 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 15:08:47 -0300 Subject: [PATCH 04/55] style: clang-format the clearsign hardening changes --- lib/firmware/ethereum_contracts/thortx.c | 8 +++++--- lib/firmware/fsm_msg_solana.h | 13 +++++++------ lib/firmware/thorchain.c | 24 +++++++++++++----------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/lib/firmware/ethereum_contracts/thortx.c b/lib/firmware/ethereum_contracts/thortx.c index c0c45216f..2ad543e33 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -111,7 +111,8 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, * (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. */ + * 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++) { @@ -126,10 +127,11 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, 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 */ + 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 */ + return false; /* trailing bytes after the memo would be executed but hidden + */ } char confStr[41]; diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 266a9619c..6d4cfb3a9 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -70,8 +70,9 @@ static bool solana_confirm_memo(const char* title, const uint8_t* s, char page_title[32]; if (pages > 1 || !ascii) { - snprintf(page_title, sizeof(page_title), ascii ? "%s %u/%u" : "%s Hex %u/%u", - title, (unsigned)(page + 1), (unsigned)pages); + 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)); } @@ -80,16 +81,16 @@ static bool solana_confirm_memo(const char* title, const uint8_t* s, char rendered[SOL_MEMO_ASCII_CHUNK + 1]; memcpy(rendered, s + offset, take); rendered[take] = '\0'; - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, page_title, "%s", - rendered)) + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, page_title, + "%s", rendered)) return false; } else { char rendered[SOL_MEMO_HEX_CHUNK * 2 + 1]; for (uint16_t i = 0; i < take; i++) { snprintf(rendered + 2 * i, 3, "%02x", s[offset + i]); } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, page_title, "%s", - rendered)) + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, page_title, + "%s", rendered)) return false; } } diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index 00a6bd58d..5830cf8cb 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -255,8 +255,9 @@ static bool thorchain_confirm_full_memo(const char* title, const char* memo, if (take > chunk) take = chunk; char page_title[24]; - snprintf(page_title, sizeof(page_title), ascii ? "%s %u/%u" : "%s Hex %u/%u", - title, (unsigned)(page + 1), (unsigned)pages); + snprintf(page_title, sizeof(page_title), + ascii ? "%s %u/%u" : "%s Hex %u/%u", title, (unsigned)(page + 1), + (unsigned)pages); if (ascii) { char rendered[ASCII_CHUNK + 1]; @@ -361,8 +362,8 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { * 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)) { + "Thorchain swap", "Confirm swap asset %s\n on chain %s", + asset, chain)) { return false; } return thorchain_confirm_full_memo("Swap memo", swapStr, size); @@ -377,7 +378,8 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { (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. */ + /* 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 = @@ -415,18 +417,18 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { // 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)) { + !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)) { + !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)) { + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Min output %s", min_out)) { return false; } return true; From bb9b7dabbc9499711b9ad513deffb6959268ab9e Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 15:34:02 -0300 Subject: [PATCH 05/55] fix(clearsign): page full raw memo on native THOR/MAYA, harden Solana token symbol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-round review follow-ups: - Native THOR/MAYA deposit and send handlers signed after a single unpaged confirm("%s", memo) when the structured parser returned false, so parser-level rejects never actually rejected and long memos (THOR up to 250 bytes) truncated. Every native memo site — and the EVM router path — now pages the COMPLETE raw memo (thorchain_confirm_full_memo, now shared/public) after the best-effort structured summary; a reject on any page aborts signing. - This raw-memo disclosure also backstops mayachain.c, whose structured parser had not been hardened (ignored aggregator/final-token/min-output and ADD affiliate/fee) — those fields are now shown in full via the paged raw memo. - Solana token symbols are host-supplied and only length-capped; a symbol with newlines could push the mint/recipient off-screen. Restrict symbols to printable ASCII (solana_symbol_is_safe) and show the authenticated mint on its own screen before the amount; an unsafe symbol falls back to a raw token count. Adds unit tests for the raw-memo pager (short/long/reject/binary). --- include/keepkey/firmware/thorchain.h | 9 ++++ lib/firmware/ethereum_contracts/thortx.c | 7 +++ lib/firmware/fsm_msg_mayachain.h | 42 +++++++-------- lib/firmware/fsm_msg_solana.h | 66 ++++++++++++++++-------- lib/firmware/fsm_msg_thorchain.h | 44 ++++++++-------- lib/firmware/thorchain.c | 9 ++-- unittests/firmware/thorchain.cpp | 36 +++++++++++++ 7 files changed, 144 insertions(+), 69 deletions(-) diff --git a/include/keepkey/firmware/thorchain.h b/include/keepkey/firmware/thorchain.h index 4c1358a73..398495863 100644 --- a/include/keepkey/firmware/thorchain.h +++ b/include/keepkey/firmware/thorchain.h @@ -37,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/ethereum_contracts/thortx.c b/lib/firmware/ethereum_contracts/thortx.c index 2ad543e33..d15ef0752 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -230,6 +230,13 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, 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; } diff --git a/lib/firmware/fsm_msg_mayachain.h b/lib/firmware/fsm_msg_mayachain.h index f4eedba24..432ee3d40 100644 --- a/lib/firmware/fsm_msg_mayachain.h +++ b/lib/firmware/fsm_msg_mayachain.h @@ -231,17 +231,17 @@ 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)); + // Best-effort structured summary, then ALWAYS page the complete raw memo + // (shared THOR/MAYA pager) — that paged disclosure is the guarantee that + // no field the structured view omits or truncates is signed unseen. + mayachain_parseConfirmMemo(msg->deposit.memo, memo_len); + if (!thorchain_confirm_full_memo(_("Memo"), msg->deposit.memo, + memo_len)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } @@ -261,17 +261,15 @@ 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; - } + // This memo is ignored if the deposit msg has a memo. Structured summary + // then ALWAYS page the full raw memo (see the deposit path above). + size_t memo_len = strnlen(sign_tx->memo, sizeof(sign_tx->memo)); + mayachain_parseConfirmMemo(sign_tx->memo, memo_len); + if (!thorchain_confirm_full_memo(_("Memo"), sign_tx->memo, memo_len)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 6d4cfb3a9..43d4c1753 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -40,6 +40,17 @@ static void solana_pubkeyToStr(const uint8_t key[SOL_PUBKEY_SIZE], char* out, key[31]); } +/* 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; +} + /* Display budget per confirm body: printable memos page as text, binary memos * page as hex so the FULL content is always shown — a memo can carry swap * intent (THORChain '=:ETH.ETH:...'), so nothing may be hidden behind a byte @@ -166,28 +177,34 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, ti = solana_findTokenInfo(msg, pi->mint); } - if (ti && ti->has_symbol && ti->has_decimals) { - /* The symbol is host-supplied and unauthenticated: a malicious host can - * label any mint "USDC". Show the real mint next to the claimed symbol - * so a spoof is visible before the amount screen. */ + /* 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 \"%s\"\nmint %s", ti->symbol, mint_str)) { + "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); - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Send %s to %s?", amount_str, to_str); } + 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: { @@ -201,28 +218,33 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, ti = solana_findTokenInfo(msg, pi->mint); } - const char* symbol = (ti && ti->has_symbol) ? ti->symbol : NULL; - if (symbol) { - /* Host-supplied symbol is unauthenticated; show the real mint too so a - * spoofed label ("USDC" on an arbitrary mint) is visible. */ + /* 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)); if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Token \"%s\"\nmint %s", symbol, mint_str)) { + "Token mint\n%s", mint_str)) { return false; } + } + + const char* symbol = + (ti && ti->has_symbol && solana_symbol_is_safe(ti->symbol)) + ? ti->symbol + : NULL; + if (symbol) { char amount_str[48]; solana_formatTokenAmount(amount_str, sizeof(amount_str), pi->amount, symbol, pi->extra_u8); 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); - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Send %s to %s?", amount_str, to_str); } + 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_APPROVE: { diff --git a/lib/firmware/fsm_msg_thorchain.h b/lib/firmware/fsm_msg_thorchain.h index cd905396b..e7f80d574 100644 --- a/lib/firmware/fsm_msg_thorchain.h +++ b/lib/firmware/fsm_msg_thorchain.h @@ -225,17 +225,19 @@ 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)); + // Best-effort structured summary for readability, then ALWAYS page the + // complete raw memo — that paged disclosure is the security guarantee, so + // a field the structured view omits (aggregator/affiliate) or a long + // field that would truncate can never be signed unseen. A reject on the + // raw memo aborts. + thorchain_parseConfirmMemo(msg->deposit.memo, memo_len); + if (!thorchain_confirm_full_memo(_("Memo"), msg->deposit.memo, + memo_len)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } @@ -255,17 +257,15 @@ 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; - } + // This memo is ignored if the deposit msg has a memo. Structured summary + // then ALWAYS page the full raw memo (see the deposit path above). + size_t memo_len = strnlen(sign_tx->memo, sizeof(sign_tx->memo)); + thorchain_parseConfirmMemo(sign_tx->memo, memo_len); + if (!thorchain_confirm_full_memo(_("Memo"), sign_tx->memo, memo_len)) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; } } diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index 5830cf8cb..e1b283828 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -234,9 +234,12 @@ void thorchain_signAbort(void) { /* 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. */ -static bool thorchain_confirm_full_memo(const char* title, const char* memo, - size_t len) { + * 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) { enum { ASCII_CHUNK = 72, HEX_CHUNK = 40 }; bool ascii = len > 0; for (size_t i = 0; i < len; i++) { diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index 6c01ddd78..405a5a3db 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -364,3 +364,39 @@ TEST(Thorchain, MemoAddTooManyFieldsRejected) { 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()); +} + +// A long memo (up to THORChain's 250-byte limit) pages in full instead of +// truncating in one confirm: 150 ASCII bytes / 72 per page = 3 pages. +TEST(Thorchain, FullMemoLongAsciiPagesAll) { + std::string memo(150, 'a'); + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo.c_str(), memo.size())); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Rejecting any page aborts the whole disclosure (so the handler aborts signing). +TEST(Thorchain, FullMemoRejectPropagates) { + std::string memo(150, 'a'); + ASSERT_TRUE(kkconfirm_preload(1, 1)); // approve page 1, reject page 2 + EXPECT_FALSE(thorchain_confirm_full_memo("Memo", memo.c_str(), memo.size())); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Non-printable memo bytes are disclosed as hex pages (40 bytes/page), never +// hidden behind a byte-count summary. +TEST(Thorchain, FullMemoBinaryPagesAsHex) { + char memo[50]; + memset(memo, 0x01, sizeof(memo)); // 50 non-printable bytes -> 2 hex pages + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo, sizeof(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} From 5da843ee5e6337eb77e301dabd024d5fe2df38cc Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 16:07:37 -0300 Subject: [PATCH 06/55] fix(thortx): recognize both native-ETH conventions; don't reject amount!=value CI integration tests surfaced two problems in the earlier native-deposit hardening: - Native ETH is expressed either as address(0) or the 0xEeee..Ee sentinel; the is_native check only matched address(0), so a native deposit whose asset used the sentinel was mis-classified as a token and rejected for carrying value. Match both, comparing exactly 20 bytes. - Requiring the ABI amount to equal msg.value was too strict: the amount word is a router-ignored hint that legitimately differs from the forwarded value. Displaying msg.value (the amount actually sent) already closes the display-0.01-send-100 gap, so the equality check is dropped. Bumps python-keepkey to invert the Hive non-printable-message test to expect the new rejection. Full python-keepkey integration suite passes locally (546/0). --- deps/python-keepkey | 2 +- lib/firmware/ethereum_contracts/thortx.c | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index c4754827a..7231374c3 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit c4754827afd6d1e0f2abd6c5d18e7a220d55a27d +Subproject commit 7231374c3fc79c39af3f5fd1daa379e0779cb4ac diff --git a/lib/firmware/ethereum_contracts/thortx.c b/lib/firmware/ethereum_contracts/thortx.c index d15ef0752..19e456fef 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -179,16 +179,25 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, * the value actually sent, and refuse if the ABI amount disagrees (0 is the * canonical "unset" and is allowed). For token deposits the router pulls via * transferFrom; native value must not ride along or it is swept unshown. */ - const bool is_native = - memcmp(contractAssetAddress, ETH_ADDRESS, sizeof(ETH_ADDRESS)) == 0; + /* Native ETH is expressed either as address(0) (the on-chain router + * convention) or as the 0xEeee..Ee sentinel; recognize both, or a native + * deposit whose asset uses the sentinel would be mis-classified as a token + * and rejected for carrying value. Compare exactly 20 bytes (not + * sizeof, which includes the literal's NUL and would over-read into amount). + */ + const bool is_native = memcmp(contractAssetAddress, ETH_ADDRESS, 20) == 0 || + memcmp(contractAssetAddress, ETH_NATIVE, 20) == 0; bignum256 Value; bn_from_bytes(msg->value.bytes, msg->value.size, &Value); if (is_native) { - if (!bn_is_zero(&Amount) && !bn_is_equal(&Amount, &Value)) { - return false; - } + /* 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. */ assetAddress = (const uint8_t*)ETH_NATIVE; } 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; } From 0a25a1b4e7e230faee254e1eb361486e78e6405d Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 16:52:00 -0300 Subject: [PATCH 07/55] fix(clearsign): Solana withdrawal destinations, unchecked-SPL gating, native-memo abort, empty-memo UB, native-ETH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-round security review follow-ups: - Solana stake/vote withdrawals and stake split displayed only the amount, so a host could redirect the withdrawn SOL unseen. Show the recipient (and the vote account for delegate). - Unchecked SPL Transfer/Approve carry no signed mint — the token being moved or delegated cannot be proven, so they now require AdvancedMode (blind-sign); only the *Checked variants (mint signed + displayed) clear-sign. - Native THOR/MAYA handlers ignored the structured parser's bool return, which conflates "unrecognized" with "user rejected", so a rejected screen could be followed by raw-memo pages and then signing. Drop the structured pass; the raw memo pager is the sole, authoritative gate, so a reject aborts. - The raw-memo pager read an uninitialized buffer for an empty memo (len==0 fell into the hex branch). Return a "(empty)" screen; add a unit test. - Accept only address(0) as native ETH for the pinned THOR/MAYA routers: the 0xEeee sentinel is treated as a token on-chain and would clear-sign a tx that reverts. Bumps python-keepkey (fixture + Solana AdvancedMode tests). Full integration suite passes locally (546/0). --- deps/python-keepkey | 2 +- lib/firmware/ethereum_contracts/thortx.c | 19 ++++++------------- lib/firmware/fsm_msg_mayachain.h | 12 +++++------- lib/firmware/fsm_msg_solana.h | 20 ++++++++++++++++---- lib/firmware/fsm_msg_thorchain.h | 15 ++++++--------- lib/firmware/solana.c | 8 ++++++++ lib/firmware/thorchain.c | 6 ++++++ unittests/firmware/thorchain.cpp | 8 ++++++++ 8 files changed, 56 insertions(+), 34 deletions(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 7231374c3..78bd6ca84 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 7231374c3fc79c39af3f5fd1daa379e0779cb4ac +Subproject commit 78bd6ca84edd8b4531507d8cdbb693d47640c54c diff --git a/lib/firmware/ethereum_contracts/thortx.c b/lib/firmware/ethereum_contracts/thortx.c index 19e456fef..b434ec343 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -174,19 +174,12 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, return false; } - /* For native ETH the router forwards msg.value and ignores the ABI amount - * word, so the ABI amount can read 0.01 while the tx sends 100 ETH. Display - * the value actually sent, and refuse if the ABI amount disagrees (0 is the - * canonical "unset" and is allowed). For token deposits the router pulls via - * transferFrom; native value must not ride along or it is swept unshown. */ - /* Native ETH is expressed either as address(0) (the on-chain router - * convention) or as the 0xEeee..Ee sentinel; recognize both, or a native - * deposit whose asset uses the sentinel would be mis-classified as a token - * and rejected for carrying value. Compare exactly 20 bytes (not - * sizeof, which includes the literal's NUL and would over-read into amount). - */ - const bool is_native = memcmp(contractAssetAddress, ETH_ADDRESS, 20) == 0 || - memcmp(contractAssetAddress, ETH_NATIVE, 20) == 0; + /* Both pinned routers treat ONLY address(0) as native ETH (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) { diff --git a/lib/firmware/fsm_msg_mayachain.h b/lib/firmware/fsm_msg_mayachain.h index 432ee3d40..4b2daaf2c 100644 --- a/lib/firmware/fsm_msg_mayachain.h +++ b/lib/firmware/fsm_msg_mayachain.h @@ -232,10 +232,9 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { if (msg->deposit.has_memo) { size_t memo_len = strnlen(msg->deposit.memo, sizeof(msg->deposit.memo)); - // Best-effort structured summary, then ALWAYS page the complete raw memo - // (shared THOR/MAYA pager) — that paged disclosure is the guarantee that - // no field the structured view omits or truncates is signed unseen. - mayachain_parseConfirmMemo(msg->deposit.memo, memo_len); + // 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(); @@ -261,10 +260,9 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } if (sign_tx->has_memo && !msg->deposit.has_memo) { - // This memo is ignored if the deposit msg has a memo. Structured summary - // then ALWAYS page the full raw memo (see the deposit path above). + // 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)); - mayachain_parseConfirmMemo(sign_tx->memo, memo_len); if (!thorchain_confirm_full_memo(_("Memo"), sign_tx->memo, memo_len)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 43d4c1753..0c3e85c93 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -296,15 +296,21 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, "Sync wrapped SOL?"); case SOL_INSTR_STAKE_DELEGATE: { + char vote_str[45]; + solana_pubkeyToStr(pi->to, vote_str, sizeof(vote_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Delegate stake?"); + "Delegate stake\nto %s?", vote_str); } case SOL_INSTR_STAKE_WITHDRAW: { char amount_str[32]; solana_formatAmount(amount_str, sizeof(amount_str), pi->lamports); + /* Show the recipient — a withdrawal that hides it lets a host redirect + * the withdrawn SOL while the device shows only the amount. */ + 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 from stake\nto %s?", amount_str, to_str); } case SOL_INSTR_STAKE_AUTHORIZE: { @@ -317,8 +323,11 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, case SOL_INSTR_STAKE_SPLIT: { char amount_str[32]; solana_formatAmount(amount_str, sizeof(amount_str), pi->lamports); + /* Show the destination stake account the split lamports land in. */ + 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 stake by %s\nto %s?", amount_str, to_str); } case SOL_INSTR_STAKE_DEACTIVATE: @@ -339,8 +348,11 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, case SOL_INSTR_VOTE_WITHDRAW: { char amount_str[32]; solana_formatAmount(amount_str, sizeof(amount_str), pi->lamports); + /* Show the recipient — same redirect risk as stake withdrawal. */ + 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: { diff --git a/lib/firmware/fsm_msg_thorchain.h b/lib/firmware/fsm_msg_thorchain.h index e7f80d574..25912a383 100644 --- a/lib/firmware/fsm_msg_thorchain.h +++ b/lib/firmware/fsm_msg_thorchain.h @@ -226,12 +226,10 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { if (msg->deposit.has_memo) { size_t memo_len = strnlen(msg->deposit.memo, sizeof(msg->deposit.memo)); - // Best-effort structured summary for readability, then ALWAYS page the - // complete raw memo — that paged disclosure is the security guarantee, so - // a field the structured view omits (aggregator/affiliate) or a long - // field that would truncate can never be signed unseen. A reject on the - // raw memo aborts. - thorchain_parseConfirmMemo(msg->deposit.memo, memo_len); + // 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(); @@ -257,10 +255,9 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } if (sign_tx->has_memo && !msg->deposit.has_memo) { - // This memo is ignored if the deposit msg has a memo. Structured summary - // then ALWAYS page the full raw memo (see the deposit path above). + // 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)); - thorchain_parseConfirmMemo(sign_tx->memo, memo_len); if (!thorchain_confirm_full_memo(_("Memo"), sign_tx->memo, memo_len)) { thorchain_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index bbfbd63f9..c1d269906 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -259,6 +259,11 @@ 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) { pi->type = SOL_INSTR_TOKEN_TRANSFER_CHECKED; @@ -275,6 +280,9 @@ 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 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); diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index e1b283828..735999b93 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -241,6 +241,12 @@ void thorchain_signAbort(void) { bool thorchain_confirm_full_memo(const char* title, const char* memo, size_t len) { enum { ASCII_CHUNK = 72, HEX_CHUNK = 40 }; + if (len == 0) { + /* An empty memo would otherwise fall through to the hex branch below with + * take == 0, leaving `rendered` uninitialized when passed to %s. */ + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "(empty)"); + } bool ascii = len > 0; for (size_t i = 0; i < len; i++) { if ((uint8_t)memo[i] < 0x20 || (uint8_t)memo[i] > 0x7e) { diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index 405a5a3db..ae0725fb8 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -400,3 +400,11 @@ TEST(Thorchain, FullMemoBinaryPagesAsHex) { 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()); +} From e85eae2143b6c60d45014c478ec8c0e89740ee42 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 17:20:06 -0300 Subject: [PATCH 08/55] =?UTF-8?q?fix(solana):=20audit-driven=20completenes?= =?UTF-8?q?s=20=E2=80=94=20sign-slice,=20OOB,=20display=20gaps,=20opaque?= =?UTF-8?q?=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth-round Solana review (audited against Trezor and Ledger, which both use an all-or-nothing model and refuse unchecked SPL transfers): Correctness: - solana_signTx signed msg->raw_tx from offset 0, but solana_inspectTx parsed after stripping a leading 0x00 signature-count byte — so an unsigned-tx-format payload was signed as 0x00||message and would not verify on-chain. Normalize one message slice (solana_message_slice) for both parse and sign, and reject any signature that fails ed25519 self-verification. - StakeAuthorize read a le32 authorize-type at offset 36 but only required 36 bytes — a 4-byte over-read; require >= 40. - thorchain full-memo pager: "ascii = len > 0" is always true after the empty guard (cppcheck); use true. Display completeness (fields were captured but hidden): - MintTo shows mint + recipient; Burn shows mint + source; StakeMerge shows source + destination; Stake/VoteAuthorize show the role (staker/withdrawer, voter/withdrawer) + new authority; InitializeNonce shows the authority; ATA create shows owner + mint; Freeze/Thaw show the account. Opaque gating (cannot be fully/safely disclosed → AdvancedMode blind-sign): - CreateAccount (assigns owner program + space, not parsed) and SetAuthority (account-takeover vector; the clear-to-None case isn't distinguished). Bumps python-keepkey with tests for the new gates. Full integration suite passes locally (549/0); cppcheck clean. --- deps/python-keepkey | 2 +- lib/firmware/fsm_msg_solana.h | 91 +++++++++++++++++++++++++++++------ lib/firmware/solana.c | 70 ++++++++++++++++++++------- lib/firmware/thorchain.c | 2 +- 4 files changed, 130 insertions(+), 35 deletions(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 78bd6ca84..536bfb662 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 78bd6ca84edd8b4531507d8cdbb693d47640c54c +Subproject commit 536bfb662a33093b3599b33df1d5949ab6fd124c diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 0c3e85c93..17d8021bd 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -145,9 +145,13 @@ 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: { + /* 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?"); + "Initialize nonce\nauthority %s?", auth_str); + } case SOL_INSTR_SYSTEM_AUTHORIZE_NONCE: { char auth_str[45]; @@ -266,13 +270,35 @@ 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: { /* Closing a (wrapped-SOL) token account sweeps its lamports to the @@ -283,13 +309,19 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, "Close token account, send balance to %s?", to_str); } - case SOL_INSTR_TOKEN_FREEZE_ACCOUNT: + case SOL_INSTR_TOKEN_FREEZE_ACCOUNT: { + char acct_str[45]; + solana_pubkeyToStr(pi->from, acct_str, sizeof(acct_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Freeze token account?"); + "Freeze token account\n%s?", acct_str); + } - case SOL_INSTR_TOKEN_THAW_ACCOUNT: + case SOL_INSTR_TOKEN_THAW_ACCOUNT: { + char acct_str[45]; + solana_pubkeyToStr(pi->from, acct_str, sizeof(acct_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Thaw token account?"); + "Thaw token account\n%s?", acct_str); + } case SOL_INSTR_TOKEN_SYNC_NATIVE: return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, @@ -314,10 +346,13 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } case SOL_INSTR_STAKE_AUTHORIZE: { + /* Which power is handed over matters: Staker (delegate) vs Withdrawer + * (move the funds). extra_u8 is the StakeAuthorize enum. */ 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: { @@ -334,15 +369,29 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, "Deactivate stake?"); - 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: { + /* Voter vs Withdrawer — the withdrawer can move the vote account's SOL. + */ 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: { @@ -366,9 +415,19 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, 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, diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index c1d269906..8ec62d8cb 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -212,6 +212,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); @@ -295,6 +301,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) { @@ -364,7 +376,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); @@ -607,6 +622,22 @@ static SolanaTxReview solana_parseVersionedTx(const uint8_t* raw, 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, SolanaParsedTx* tx) { if (raw_len == 0) { @@ -614,24 +645,18 @@ 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); } bool solana_parseTx(const uint8_t* raw, size_t raw_len, SolanaParsedTx* tx) { @@ -703,11 +728,22 @@ 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, - node->public_key + 1, sig); + ed25519_sign(message, message_len, node->private_key, node->public_key + 1, + sig); + + /* Never emit a signature that does not verify over those exact bytes. */ + if (ed25519_sign_open(message, message_len, node->public_key + 1, sig) != 0) { + memzero(sig, sizeof(sig)); + return false; + } resp->has_signature = true; resp->signature.size = SOL_SIG_SIZE; diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index 735999b93..3de49f180 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -247,7 +247,7 @@ bool thorchain_confirm_full_memo(const char* title, const char* memo, return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, "(empty)"); } - bool ascii = len > 0; + bool ascii = true; for (size_t i = 0; i < len; i++) { if ((uint8_t)memo[i] < 0x20 || (uint8_t)memo[i] > 0x7e) { ascii = false; From 33184d766abcfbae1aeeffff0a51a73363796ed4 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 17:57:54 -0300 Subject: [PATCH 09/55] feat(solana): verify signed token definitions (firmware side, reuses clear-sign anchor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EVM clear-sign trust model — a host attestation verified against a signer the user loaded via LoadClearsignSigner — is chain-agnostic. Reuse it so a Solana token's symbol can be authenticated instead of trusted from the host. - signed_metadata_verify_attestation(): generic "verify an ECDSA signature over sha256(data) against a loaded/pinned clear-sign signer" primitive. - SolanaTokenInfo gains optional signature + signer_key_id (device-protocol bump). A TransferChecked's host symbol is trusted only when its signature verifies over a domain-separated (mint, decimals, symbol) digest; a present- but-invalid signature rejects the symbol outright, and no signature keeps today's behavior (symbol shown beside the always-authenticated mint). A verified symbol labels its mint screen "Verified token mint". Purely additive and dormant: no current host sets a signature, so behavior is unchanged until the host/SDK signing side (a follow-up) lands — mirroring how EVM clear-sign shipped firmware-first. Also updates Solana.ParseSPLTokenTransfer to expect OPAQUE (unchecked SPL transfers now require AdvancedMode). Adds an attestation unit test. Full gtest suite (277) + integration (549) green locally; cppcheck clean. --- deps/device-protocol | 2 +- include/keepkey/firmware/signed_metadata.h | 39 +++-- .../keepkey/transport/messages-solana.options | 1 + lib/firmware/fsm_msg_solana.h | 65 ++++++++- lib/firmware/signed_metadata.c | 134 ++++++++++-------- unittests/firmware/signed_metadata.cpp | 32 +++++ unittests/firmware/solana.cpp | 6 +- 7 files changed, 197 insertions(+), 82 deletions(-) diff --git a/deps/device-protocol b/deps/device-protocol index 4eb7d5e4f..47e19d8b0 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit 4eb7d5e4f30ab75930df59cb19e40500217a68e8 +Subproject commit 47e19d8b0816db20e15d9b1e27da83c70d5ed88d diff --git a/include/keepkey/firmware/signed_metadata.h b/include/keepkey/firmware/signed_metadata.h index e84fb7386..83ec1e09e 100644 --- a/include/keepkey/firmware/signed_metadata.h +++ b/include/keepkey/firmware/signed_metadata.h @@ -117,8 +117,8 @@ void signed_metadata_clear(void); /* 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); +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 @@ -129,24 +129,24 @@ bool signed_metadata_signer_valid(uint8_t key_id, const uint8_t *pubkey, * (incl. icon) is ALSO written to flash so it survives reboot. Returns false * only when persist was requested but every persistent slot is occupied by a * different identity — the RAM (session) signer is stored regardless. */ -bool signed_metadata_store_signer(uint8_t key_id, const uint8_t *pubkey, - const char *alias, const uint8_t *icon, +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, else a persisted * identity that survived reboot. 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); +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, +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, bool persist); @@ -162,14 +162,23 @@ void signed_metadata_pubkey_fingerprint(const uint8_t pubkey[33], /* 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); -MetadataClassification signed_metadata_process(const uint8_t *payload, +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); /* 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_matches_tx(const EthereumSignTx* msg); bool signed_metadata_confirm(void); /* True once a verified confirm has suppressed the raw-data confirmation, i.e. @@ -188,8 +197,8 @@ bool signed_metadata_enforce(const uint8_t hash[32]); * 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); + 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 @@ -202,6 +211,6 @@ bool signed_metadata_enforce_decision(bool relied, bool available, bool signed_metadata_enforce_schema_decision(bool relied, bool available, bool decoded, int classification); -const SignedMetadata *signed_metadata_get(void); +const SignedMetadata* signed_metadata_get(void); #endif diff --git a/include/keepkey/transport/messages-solana.options b/include/keepkey/transport/messages-solana.options index ded9d68c1..b71dfc113 100644 --- a/include/keepkey/transport/messages-solana.options +++ b/include/keepkey/transport/messages-solana.options @@ -5,6 +5,7 @@ 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 diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 17d8021bd..a58818b76 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -51,6 +51,41 @@ static bool solana_symbol_is_safe(const char* sym) { return true; } +/* True iff the token info carries a valid attestation: an ECDSA signature over + * a domain-separated (mint, decimals, symbol) digest by a clear-sign signer the + * user loaded (LoadClearsignSigner). Reuses the EVM clear-sign trust anchor. + * The firmware only VERIFIES here — producing these signatures (host/SDK + a + * signing authority) is a follow-up; until then no host sets .signature and + * this always returns false, leaving behavior unchanged. */ +static 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 || !solana_symbol_is_safe(ti->symbol)) { + 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 + 13]; + 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); + size_t sym_len = strlen(ti->symbol); + 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); +} + /* Display budget per confirm body: printable memos page as text, binary memos * page as hex so the FULL content is always shown — a memo can carry swap * intent (THORChain '=:ETH.ETH:...'), so nothing may be hidden behind a byte @@ -222,21 +257,39 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, ti = solana_findTokenInfo(msg, pi->mint); } + /* 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 (ti && ti->has_symbol && solana_symbol_is_safe(ti->symbol)) { + if (ti->has_signature) { + if (solana_token_info_trusted(ti)) { + 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)); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Token mint\n%s", mint_str)) { + if (!confirm( + ButtonRequestType_ButtonRequest_ConfirmOutput, title, + symbol_verified ? "Verified token mint\n%s" : "Token mint\n%s", + mint_str)) { return false; } } - const char* symbol = - (ti && ti->has_symbol && solana_symbol_is_safe(ti->symbol)) - ? ti->symbol - : NULL; if (symbol) { char amount_str[48]; solana_formatTokenAmount(amount_str, sizeof(amount_str), pi->amount, diff --git a/lib/firmware/signed_metadata.c b/lib/firmware/signed_metadata.c index 442de85eb..1b3642d5a 100644 --- a/lib/firmware/signed_metadata.c +++ b/lib/firmware/signed_metadata.c @@ -55,16 +55,16 @@ static uint16_t loaded_icon_len[METADATA_MAX_KEYS]; #endif /* Find the persistent identity that reloads into `key_id`, or NULL. */ -static const ClearsignIdentity *persistent_identity_for(uint8_t key_id) { +static const ClearsignIdentity* persistent_identity_for(uint8_t key_id) { int n = storage_clearsignIdentityCount(); for (int i = 0; i < n; i++) { - const ClearsignIdentity *id = storage_getClearsignIdentity(i); + const ClearsignIdentity* id = storage_getClearsignIdentity(i); if (id && id->key_id == key_id) return id; } return NULL; } -static bool read_u8(const uint8_t **cursor, const uint8_t *end, uint8_t *out) { +static bool read_u8(const uint8_t** cursor, const uint8_t* end, uint8_t* out) { if ((size_t)(end - *cursor) < 1) { return false; } @@ -74,8 +74,8 @@ static bool read_u8(const uint8_t **cursor, const uint8_t *end, uint8_t *out) { return true; } -static bool read_be_u16(const uint8_t **cursor, const uint8_t *end, - uint16_t *out) { +static bool read_be_u16(const uint8_t** cursor, const uint8_t* end, + uint16_t* out) { if ((size_t)(end - *cursor) < 2) { return false; } @@ -85,8 +85,8 @@ static bool read_be_u16(const uint8_t **cursor, const uint8_t *end, return true; } -static bool read_be_u32(const uint8_t **cursor, const uint8_t *end, - uint32_t *out) { +static bool read_be_u32(const uint8_t** cursor, const uint8_t* end, + uint32_t* out) { if ((size_t)(end - *cursor) < 4) { return false; } @@ -97,7 +97,7 @@ static bool read_be_u32(const uint8_t **cursor, const uint8_t *end, return true; } -static bool read_bytes(const uint8_t **cursor, const uint8_t *end, uint8_t *out, +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; @@ -108,7 +108,7 @@ static bool read_bytes(const uint8_t **cursor, const uint8_t *end, uint8_t *out, return true; } -static bool read_string(const uint8_t **cursor, const uint8_t *end, char *out, +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 || @@ -122,7 +122,7 @@ static bool read_string(const uint8_t **cursor, const uint8_t *end, char *out, return true; } -static bool read_arg_name(const uint8_t **cursor, const uint8_t *end, char *out, +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 || @@ -140,7 +140,7 @@ static bool read_arg_name(const uint8_t **cursor, const uint8_t *end, char *out, * 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) { +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 @@ -183,8 +183,8 @@ static bool arg_value_ok(uint8_t format, const uint8_t *value, uint16_t len) { } /* 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) { +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)) && @@ -193,8 +193,8 @@ static bool parse_common_head(const uint8_t **cursor, const uint8_t *end, /* 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) { +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) || @@ -208,12 +208,12 @@ static bool parse_trailer(const uint8_t **cursor, const uint8_t *end, } /* v1 args: name + format + explicit (host-decoded) value. */ -static bool parse_v1_args(const uint8_t **cursor, const uint8_t *end, - SignedMetadata *out) { +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]; + 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 || @@ -235,11 +235,11 @@ static bool parse_v1_args(const uint8_t **cursor, const uint8_t *end, * 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) { +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]; + MetadataArg* arg = &out->args[i]; if (!read_arg_name(cursor, end, arg->name, METADATA_MAX_ARG_NAME_LEN) || !read_u8(cursor, end, &format)) { @@ -281,10 +281,10 @@ static bool parse_v2_args(const uint8_t **cursor, const uint8_t *end, 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; +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)) { @@ -329,7 +329,7 @@ static bool parse_metadata_binary(const uint8_t *payload, size_t payload_len, * 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) { +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; @@ -338,8 +338,8 @@ static bool decode_v2_args(SignedMetadata *md, const EthereumSignTx *msg) { } 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]; + const uint8_t* word = msg->data_initial_chunk.bytes + 4 + 32u * i; + MetadataArg* arg = &md->args[i]; switch (arg->format) { case ARG_FORMAT_ADDRESS: @@ -377,8 +377,8 @@ static bool decode_v2_args(SignedMetadata *md, const EthereumSignTx *msg) { return true; } -static void bn_from_metadata_bytes(const uint8_t *value, size_t value_len, - bignum256 *out) { +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); @@ -415,8 +415,8 @@ void signed_metadata_clear_signers(void) { signed_metadata_clear(); } -bool signed_metadata_signer_valid(uint8_t key_id, const uint8_t *pubkey, - size_t pubkey_len, const char *alias) { +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; @@ -452,8 +452,8 @@ bool signed_metadata_signer_valid(uint8_t key_id, const uint8_t *pubkey, 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, +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) { if (key_id >= METADATA_MAX_KEYS) { @@ -508,10 +508,10 @@ bool signed_metadata_store_signer(uint8_t key_id, const uint8_t *pubkey, /* Resolve the alias for a slot (RAM working copy, else a persisted identity). * Returns NULL if the slot has no loaded/persisted signer. */ -const char *signed_metadata_signer_alias(uint8_t key_id) { +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]; - const ClearsignIdentity *pid = persistent_identity_for(key_id); + const ClearsignIdentity* pid = persistent_identity_for(key_id); return pid ? pid->alias : NULL; } @@ -526,7 +526,7 @@ const char *signed_metadata_signer_alias(uint8_t key_id) { * legacy flash record — unchecked. 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. */ -static bool icon_renderable(const uint8_t *icon, uint16_t icon_len, +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; @@ -534,9 +534,9 @@ static bool icon_renderable(const uint8_t *icon, uint16_t icon_len, return draw_bitmap_mono_rle_valid(icon, (uint32_t)icon_len, icon_w, icon_h); } -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) { +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 @@ -557,7 +557,7 @@ bool signed_metadata_signer_icon(uint8_t key_id, const uint8_t **icon_out, return true; #endif } - const ClearsignIdentity *pid = persistent_identity_for(key_id); + const ClearsignIdentity* pid = persistent_identity_for(key_id); if (!pid || pid->icon_len == 0) return false; /* Flash records predate the geometry/encoding rules and are NOT re-validated * by the load handler after a reboot, so an identity persisted by older @@ -576,8 +576,8 @@ bool signed_metadata_signer_icon(uint8_t key_id, const uint8_t **icon_out, * 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, +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 @@ -608,8 +608,8 @@ static IconType stage_runtime_icon(Image *img, AnimationFrame *frame, return RUNTIME_ICON; } -bool signed_metadata_confirm_load(const char *alias, const char *fingerprint, - const uint8_t *icon, uint8_t icon_w, +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, bool persist) { Image icon_img; @@ -644,7 +644,7 @@ bool signed_metadata_from_loaded_signer(void) { } /* Resolve the verification key for a slot. */ -static const uint8_t *metadata_pubkey_for(uint8_t key_id, bool *is_loaded) { +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; @@ -655,7 +655,7 @@ static const uint8_t *metadata_pubkey_for(uint8_t key_id, bool *is_loaded) { } /* Not in a RAM slot this session — fall back to a persisted identity that * survived reboot. (A fresh load into the same slot supersedes it above.) */ - const ClearsignIdentity *pid = persistent_identity_for(key_id); + const ClearsignIdentity* pid = persistent_identity_for(key_id); if (pid) { *is_loaded = true; return pid->pubkey; @@ -663,13 +663,31 @@ static const uint8_t *metadata_pubkey_for(uint8_t key_id, bool *is_loaded) { return NULL; } -MetadataClassification signed_metadata_process(const uint8_t *payload, +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) { + 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; + const uint8_t* pubkey; signed_metadata_clear(); @@ -698,7 +716,7 @@ MetadataClassification signed_metadata_process(const uint8_t *payload, return stored_metadata.classification; } -bool signed_metadata_matches_tx(const EthereumSignTx *msg) { +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 @@ -781,8 +799,8 @@ static bool signed_metadata_confirm_screens(void) { * 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); + 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); @@ -794,7 +812,7 @@ static bool signed_metadata_confirm_screens(void) { /* 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; + 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, @@ -860,7 +878,7 @@ static bool signed_metadata_confirm_screens(void) { /* Screen 3..N: Each decoded argument */ for (uint8_t i = 0; i < stored_metadata.num_args; i++) { - MetadataArg *arg = &stored_metadata.args[i]; + MetadataArg* arg = &stored_metadata.args[i]; memset(body, 0, sizeof(body)); switch (arg->format) { @@ -914,7 +932,7 @@ static bool signed_metadata_confirm_screens(void) { memcpy(suffix + 1, arg->value + 2, symlen); suffix[1 + symlen] = '\0'; - const uint8_t *amt = arg->value + 2 + symlen; + 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++) { @@ -975,8 +993,8 @@ 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) { + const uint8_t* stored_hash, + const uint8_t* hash) { if (!relied) { return true; /* signature was not gated by metadata */ } @@ -1014,6 +1032,6 @@ bool signed_metadata_enforce(const uint8_t hash[32]) { stored_metadata.tx_hash, hash); } -const SignedMetadata *signed_metadata_get(void) { +const SignedMetadata* signed_metadata_get(void) { return metadata_available ? &stored_metadata : NULL; } diff --git a/unittests/firmware/signed_metadata.cpp b/unittests/firmware/signed_metadata.cpp index a76d717eb..419272145 100644 --- a/unittests/firmware/signed_metadata.cpp +++ b/unittests/firmware/signed_metadata.cpp @@ -1412,4 +1412,36 @@ TEST(SignedMetadataEnforceSchema, ReliedButUnavailableOrUnverifiedFails) { 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) { + 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(); +} + } // namespace diff --git a/unittests/firmware/solana.cpp b/unittests/firmware/solana.cpp index debae5425..fce57c2b3 100644 --- a/unittests/firmware/solana.cpp +++ b/unittests/firmware/solana.cpp @@ -225,8 +225,10 @@ 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); From e1ea5af60a3813957f19a4f9b3efc00802b3b0c1 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 18:11:25 -0300 Subject: [PATCH 10/55] fix(solana): gate the ed25519 self-verify out of the zcash-privacy build The Solana signing self-verification pulls in ed25519_sign_open (the ed25519 verification path), the only such use in the firmware. That overflowed the ROM-tight zcash-privacy variant by ~3.3KB. The real correctness guarantee is solana_message_slice() making parse and sign use the identical bytes; the self-verify is defense-in-depth, so compile it out only on zcash-privacy. Verified: zcash-privacy ARM firmware.keepkey.elf now links. --- lib/firmware/solana.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index 8ec62d8cb..caa548629 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -739,11 +739,17 @@ bool solana_signTx(const HDNode* node, const SolanaSignTx* msg, ed25519_sign(message, message_len, node->private_key, node->public_key + 1, sig); - /* Never emit a signature that does not verify over those exact bytes. */ +#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; From aac74e7f60e6062e65147e8b7ee20ffba9ba6e8a Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 18:39:10 -0300 Subject: [PATCH 11/55] fix(solana): harden signed token-definition verification (review follow-ups) - Name the attesting signer instead of a generic "Verified": a verified symbol now shows "Token signed by " (all signers are user-loaded, not built-in KeepKey keys), matching the EVM clear-sign identity model. - Require the attested decimals to equal the signed instruction's decimals (pi->extra_u8) before trusting the symbol, so an attested (mint,decimals, symbol) tuple that disagrees with the transaction cannot earn "verified". - Reject signer_key_id >= METADATA_MAX_KEYS before narrowing the uint32 proto field to the uint8 keyring slot, so 256 can't alias slot 0. - Move solana_token_info_trusted to a public function and add a unit test that builds the real production preimage (tag || mint || decimals || symbol), signs it, and exercises acceptance plus every rejection branch (decimals mismatch, bad signature, out-of-range key id, no signature). gtests 278, integration 549, cppcheck clean; zcash-privacy ARM links. --- include/keepkey/firmware/solana.h | 7 +++ lib/firmware/fsm_msg_solana.h | 59 ++++++++--------------- lib/firmware/solana.c | 38 +++++++++++++++ unittests/firmware/signed_metadata.cpp | 67 ++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 40 deletions(-) diff --git a/include/keepkey/firmware/solana.h b/include/keepkey/firmware/solana.h index 59d3aa8b5..57bff82af 100644 --- a/include/keepkey/firmware/solana.h +++ b/include/keepkey/firmware/solana.h @@ -199,6 +199,13 @@ void solana_formatTokenAmount(char* buf, size_t len, uint64_t amount, 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); + /* 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 a58818b76..ec9ed154a 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -51,41 +51,6 @@ static bool solana_symbol_is_safe(const char* sym) { return true; } -/* True iff the token info carries a valid attestation: an ECDSA signature over - * a domain-separated (mint, decimals, symbol) digest by a clear-sign signer the - * user loaded (LoadClearsignSigner). Reuses the EVM clear-sign trust anchor. - * The firmware only VERIFIES here — producing these signatures (host/SDK + a - * signing authority) is a follow-up; until then no host sets .signature and - * this always returns false, leaving behavior unchanged. */ -static 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 || !solana_symbol_is_safe(ti->symbol)) { - 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 + 13]; - 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); - size_t sym_len = strlen(ti->symbol); - 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); -} - /* Display budget per confirm body: printable memos page as text, binary memos * page as hex so the FULL content is always shown — a memo can carry swap * intent (THORChain '=:ETH.ETH:...'), so nothing may be hidden behind a byte @@ -268,7 +233,11 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, bool symbol_verified = false; if (ti && ti->has_symbol && solana_symbol_is_safe(ti->symbol)) { if (ti->has_signature) { - if (solana_token_info_trusted(ti)) { + /* 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; } @@ -282,10 +251,20 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, if (pi->has_mint) { char mint_str[45]; solana_pubkeyToStr(pi->mint, mint_str, sizeof(mint_str)); - if (!confirm( - ButtonRequestType_ButtonRequest_ConfirmOutput, title, - symbol_verified ? "Verified token mint\n%s" : "Token mint\n%s", - mint_str)) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Token mint\n%s", mint_str)) { + return false; + } + } + + /* Name WHO attested the symbol — a generic "Verified" would hide which + * loaded signer vouched for it (they are all user-loaded, not built-in + * KeepKey keys). */ + if (symbol_verified) { + const char* alias = signed_metadata_signer_alias(ti->signer_key_id); + if (!alias) alias = "unknown"; + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + "Token \"%s\"\nsigned by %s", symbol, alias)) { return false; } } diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index caa548629..488ebddfc 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -19,6 +19,7 @@ #include "keepkey/firmware/solana.h" +#include "keepkey/firmware/signed_metadata.h" #include "trezor/crypto/memzero.h" #include @@ -720,6 +721,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 */ /* ------------------------------------------------------------------ */ diff --git a/unittests/firmware/signed_metadata.cpp b/unittests/firmware/signed_metadata.cpp index 419272145..abfcec4cc 100644 --- a/unittests/firmware/signed_metadata.cpp +++ b/unittests/firmware/signed_metadata.cpp @@ -21,6 +21,7 @@ extern "C" { #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 "trezor/crypto/ecdsa.h" #include "trezor/crypto/secp256k1.h" #include "trezor/crypto/sha2.h" @@ -1444,4 +1445,70 @@ TEST(SignedMetadataAttestation, VerifiesValidRejectsTampered) { signed_metadata_clear_signers(); } +// 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) { + 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(); +} + } // namespace From 8853a6c134bd709581c82cf25a50b950ebbc3ad7 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 19:12:27 -0300 Subject: [PATCH 12/55] fix(solana): disclose acted-on accounts; gate Token-2022; fix vote-validator; signer fingerprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten Solana completeness/correctness review findings: - Show WHICH account is acted on (parsed but previously hidden), via a shared solana_confirm_account helper (keeps the added screens ROM-cheap): System Assign (account + owner), Stake/Vote Authorize (the account being rekeyed), Stake Delegate (stake + vote), Stake Deactivate, Vote UpdateCommission, and Token CloseAccount (source + balance destination — closing sweeps the whole lamport balance the device can't see). - Token-2022 TransferChecked can invoke an undisclosed transfer hook / fee, so force it opaque (AdvancedMode); only legacy SPL TransferChecked clear-signs. - Vote UpdateValidatorIdentity has no data payload — it was displaying 32 attacker-supplied trailing bytes. Require the canonical 4-byte encoding and read the new validator from account index 1. - The verified-token screen now shows alias + signer fingerprint (aliases are not unique) and drops the "unknown" fallback from the verified path (signed_metadata_signer_fingerprint). Adds a Token-2022-opaque gtest. gtests 279, integration 549, cppcheck clean; zcash-privacy ARM links. --- include/keepkey/firmware/signed_metadata.h | 7 ++ lib/firmware/fsm_msg_solana.h | 93 +++++++++++++++------- lib/firmware/signed_metadata.c | 11 +++ lib/firmware/solana.c | 22 ++++- unittests/firmware/solana.cpp | 44 ++++++++++ 5 files changed, 144 insertions(+), 33 deletions(-) diff --git a/include/keepkey/firmware/signed_metadata.h b/include/keepkey/firmware/signed_metadata.h index 83ec1e09e..0f50a32ca 100644 --- a/include/keepkey/firmware/signed_metadata.h +++ b/include/keepkey/firmware/signed_metadata.h @@ -174,6 +174,13 @@ MetadataClassification signed_metadata_process(const uint8_t* payload, 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(). diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index ec9ed154a..568675d91 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -40,6 +40,17 @@ 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. */ @@ -161,10 +172,12 @@ 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: @@ -257,14 +270,18 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } } - /* Name WHO attested the symbol — a generic "Verified" would hide which - * loaded signer vouched for it (they are all user-loaded, not built-in - * KeepKey keys). */ + /* 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); - if (!alias) alias = "unknown"; + 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\"\nsigned by %s", symbol, alias)) { + "Token \"%s\"\nby %s %s", symbol, alias ? alias : "", + fp)) { return false; } } @@ -333,12 +350,13 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } case SOL_INSTR_TOKEN_CLOSE_ACCOUNT: { - /* Closing a (wrapped-SOL) token account sweeps its lamports to the - * destination — show it, or an attacker routes the rent elsewhere. */ - char to_str[45]; - solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Close token account, send balance to %s?", to_str); + /* 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: { @@ -360,10 +378,12 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, "Sync wrapped SOL?"); case SOL_INSTR_STAKE_DELEGATE: { - char vote_str[45]; - solana_pubkeyToStr(pi->to, vote_str, sizeof(vote_str)); - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Delegate stake\nto %s?", vote_str); + /* 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: { @@ -378,8 +398,12 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } case SOL_INSTR_STAKE_AUTHORIZE: { - /* Which power is handed over matters: Staker (delegate) vs Withdrawer - * (move the funds). extra_u8 is the StakeAuthorize enum. */ + /* 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"; @@ -398,8 +422,8 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } 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: { /* Show source and destination — merge moves the source's stake into the @@ -417,8 +441,11 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } case SOL_INSTR_VOTE_AUTHORIZE: { - /* Voter vs Withdrawer — the withdrawer can move the vote account's SOL. - */ + /* 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"; @@ -437,15 +464,21 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } 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: { /* Show the wallet owner and the token mint the new account is for. */ diff --git a/lib/firmware/signed_metadata.c b/lib/firmware/signed_metadata.c index 1b3642d5a..4af2298f5 100644 --- a/lib/firmware/signed_metadata.c +++ b/lib/firmware/signed_metadata.c @@ -663,6 +663,17 @@ static const uint8_t* metadata_pubkey_for(uint8_t key_id, bool* is_loaded) { return NULL; } +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) { + 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) { diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index 488ebddfc..6053eba4a 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -258,6 +258,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) { @@ -281,6 +287,11 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, 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; + /* 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); @@ -425,11 +436,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; diff --git a/unittests/firmware/solana.cpp b/unittests/firmware/solana.cpp index fce57c2b3..2df5d3c43 100644 --- a/unittests/firmware/solana.cpp +++ b/unittests/firmware/solana.cpp @@ -235,6 +235,50 @@ TEST(Solana, ParseSPLTokenTransfer) { 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); +} + TEST(Solana, ParseAssociatedTokenAccountCreate) { uint8_t raw[512]; size_t pos = 0; From 8d536da40c311b87d6dd8537111823bf5ebc1a98 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 19:55:02 -0300 Subject: [PATCH 13/55] fix(solana): disclose priority fee in SOL, remaining acted-on accounts, + tests - Show the priority fee as an actual SOL cost: when a tx sets a compute-unit price, display the fee payer and the MAXIMUM priority fee (ceil(price*limit/1e6) lamports, overflow-safe, using the 1.4M-CU cap when no explicit limit is set so the figure is never understated). Raw CU price/limit with no units could otherwise hide a large SOL loss (fees are charged even on failed execution). - Show the acted-on account (previously parsed but hidden) for nonce withdraw/authorize/advance/initialize, Allocate, stake/vote withdraw, stake split, token Revoke and SyncNative; Freeze/Thaw now also show the mint. - Add parser regression tests for the validator-identity fix: canonical UpdateValidatorIdentity reads the new validator from account index 1, and a trailing-byte (non-canonical) encoding is rejected as opaque. gtests 281, integration 549, cppcheck clean; zcash-privacy ARM links. --- lib/firmware/fsm_msg_solana.h | 129 ++++++++++++++++++++++++++++------ unittests/firmware/solana.cpp | 55 +++++++++++++++ 2 files changed, 162 insertions(+), 22 deletions(-) diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 568675d91..928e5eff7 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -119,6 +119,56 @@ static bool solana_confirm_memo(const char* title, const uint8_t* s, return true; } +/* 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; + uint64_t lamports; + if (limit != 0 && price > UINT64_MAX / limit) { + /* Overflow: the requested fee is absurd — saturate so the SOL figure is + * obviously enormous rather than wrapping to a small number. */ + lamports = UINT64_MAX / 1000000u; + } else { + lamports = (price * limit + 999999u) / 1000000u; /* ceil to lamports */ + } + 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, @@ -144,10 +194,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]; @@ -157,14 +211,22 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } 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\nauthority %s?", auth_str); + "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, @@ -180,10 +242,14 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, 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: { char to_str[45]; @@ -309,8 +375,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]; @@ -360,22 +426,23 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } case SOL_INSTR_TOKEN_FREEZE_ACCOUNT: { - char acct_str[45]; - solana_pubkeyToStr(pi->from, acct_str, sizeof(acct_str)); - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Freeze token account\n%s?", acct_str); + /* 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: { - char acct_str[45]; - solana_pubkeyToStr(pi->from, acct_str, sizeof(acct_str)); - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Thaw token account\n%s?", acct_str); + 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: { /* Show which stake account is delegated, not just the vote account — a @@ -387,14 +454,17 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } 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); - /* Show the recipient — a withdrawal that hides it lets a host redirect - * the withdrawn SOL while the device shows only the amount. */ char to_str[45]; solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Withdraw %s from stake\nto %s?", amount_str, to_str); + "Withdraw %s\nto %s?", amount_str, to_str); } case SOL_INSTR_STAKE_AUTHORIZE: { @@ -412,13 +482,16 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } 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); - /* Show the destination stake account the split lamports land in. */ char to_str[45]; solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "Split stake by %s\nto %s?", amount_str, to_str); + "Split %s\nto %s?", amount_str, to_str); } case SOL_INSTR_STAKE_DEACTIVATE: @@ -454,9 +527,12 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } 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); - /* Show the recipient — same redirect risk as stake withdrawal. */ char to_str[45]; solana_pubkeyToStr(pi->to, to_str, sizeof(to_str)); return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, @@ -662,6 +738,15 @@ void fsm_msgSolanaSignTx(const SolanaSignTx* msg) { return; } } + /* Disclose the priority fee (SOL) if the tx sets a compute-unit price. */ + if (!solana_confirm_priority_fee( + &parsed, parsed.num_accounts > 0 ? parsed.accounts[0] : NULL)) { + memzero(node, sizeof(*node)); + 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. */ if (!storage_isPolicyEnabled("AdvancedMode")) { diff --git a/unittests/firmware/solana.cpp b/unittests/firmware/solana.cpp index 2df5d3c43..11e46cfaa 100644 --- a/unittests/firmware/solana.cpp +++ b/unittests/firmware/solana.cpp @@ -279,6 +279,61 @@ TEST(Solana, Token2022TransferCheckedIsOpaque) { 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, ParseAssociatedTokenAccountCreate) { uint8_t raw[512]; size_t pos = 0; From 3cdc36cba50b11d7fb23ef1a3ff3075a88a84a58 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 20:36:32 -0300 Subject: [PATCH 14/55] fix(solana): overflow-safe priority fee + single verified-tx confirm path Priority-fee ceiling arithmetic ((price*limit + 999999)/1e6) could overflow the +999999 rounding term even when price*limit itself fit, wrapping the displayed fee to zero while the real cost was up to ~1.8e10 SOL. Replace with overflow-safe quotient/remainder ceil in a public, testable solana_priority_fee_lamports(); it returns false (caller refuses to sign) rather than saturating when the true value exceeds UINT64_MAX. Route both fsm_msgSolanaSignTx and fsm_msgSolanaSignMessage through one solana_confirm_verified_tx() (per-instruction disclosure + priority-fee screen) so a transaction-shaped SignMessage payload can no longer dodge the fee screen and the two entry points' security screens cannot drift. gtest PriorityFeeOverflowSafe covers UINT64_MAX/1 -> 18446744073710, the non-overflow cases, and UINT64_MAX/UINT64_MAX -> reject. --- include/keepkey/firmware/solana.h | 6 +++ lib/firmware/fsm_msg_solana.h | 68 +++++++++++++++++-------------- lib/firmware/solana.c | 35 ++++++++++++++++ unittests/firmware/solana.cpp | 20 +++++++++ 4 files changed, 99 insertions(+), 30 deletions(-) diff --git a/include/keepkey/firmware/solana.h b/include/keepkey/firmware/solana.h index 57bff82af..177f345a4 100644 --- a/include/keepkey/firmware/solana.h +++ b/include/keepkey/firmware/solana.h @@ -206,6 +206,12 @@ const SolanaTokenInfo* solana_findTokenInfo( * 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 928e5eff7..35c8d4c58 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -147,13 +147,15 @@ static bool solana_confirm_priority_fee(const SolanaParsedTx* tx, } const uint64_t kMaxCuLimit = 1400000u; /* Solana per-tx CU cap */ uint64_t limit = have_limit ? cu_limit : kMaxCuLimit; - uint64_t lamports; - if (limit != 0 && price > UINT64_MAX / limit) { - /* Overflow: the requested fee is absurd — saturate so the SOL figure is - * obviously enormous rather than wrapping to a small number. */ - lamports = UINT64_MAX / 1000000u; - } else { - lamports = (price * limit + 999999u) / 1000000u; /* ceil to lamports */ + + /* 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); @@ -628,6 +630,24 @@ 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. */ +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); @@ -727,20 +747,8 @@ void fsm_msgSolanaSignTx(const SolanaSignTx* msg) { } 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; - } - } - /* Disclose the priority fee (SOL) if the tx sets a compute-unit price. */ - if (!solana_confirm_priority_fee( - &parsed, parsed.num_accounts > 0 ? parsed.accounts[0] : NULL)) { + /* Per-instruction disclosure + priority fee, shared with SignMessage. */ + if (!solana_confirm_verified_tx(&parsed, msg)) { memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Signing cancelled")); @@ -862,15 +870,15 @@ void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg) { layoutHome(); return; } - for (uint8_t i = 0; i < parsed.num_instructions; i++) { - if (!solana_confirmInstruction(&parsed.instructions[i], NULL, i, - parsed.num_instructions)) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, - _("Signing cancelled")); - layoutHome(); - return; - } + /* 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_SignTx, "Solana", "Sign this Solana transaction?")) { diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index 6053eba4a..588d3c5d4 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -684,6 +684,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; diff --git a/unittests/firmware/solana.cpp b/unittests/firmware/solana.cpp index 11e46cfaa..dc6b7ac7f 100644 --- a/unittests/firmware/solana.cpp +++ b/unittests/firmware/solana.cpp @@ -334,6 +334,26 @@ TEST(Solana, VoteUpdateValidatorRejectsTrailingBytes) { 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; From 27a794d181999e9d32cd191f233789ab1d77e6e9 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 18 Jul 2026 02:07:59 -0300 Subject: [PATCH 15/55] =?UTF-8?q?test(report):=20PDF=20is=20primary=20proo?= =?UTF-8?q?f=20=E2=80=94=20un-skip=20native=20MAYA,=20capture=20hive=20sig?= =?UTF-8?q?n-message/ops=20+=20solana=20mint=20&=20attested-symbol=20+=20E?= =?UTF-8?q?VM=20deposit=20flows,=20fix=20frame=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump deps/python-keepkey to 71d6086: - mayachain native signtx tests un-skipped (stale thorchain-copied vectors replaced with host-side sign-doc digest verification against the known device pubkey) — native MAYA raw-memo paging now runs and captures OLED. - new solana tests: TransferChecked clear-sign (dedicated 'Token mint' screen, AdvancedMode OFF) and signed-token-def attestation ('Token "USDC" by ' via LoadClearsignSigner); messages_solana_pb2 regenerated with the optional SolanaTokenInfo signature/signer_key_id fields (device-protocol 47e19d8). - report SECTIONS: Hive G6-G28 (sign-message printable-whitelist oracle fix + sign-ops + fences), Solana S8/S12 captions corrected to force-opaque + S13-S24, Maya M4-M6, THOR H2 full raw-memo sequence + H4, EVM E20/E21 deposit happy paths captured. - frame picker: blank/lock frames never rendered; cross-test duplicate census ranks test-specific frames above shared chrome (fixes the leaked IMPORT-RECOVERY/blank frames); outcome frames after the lead frame kept for gate tests. Validated locally (scripts/emulator docker compose): 554 passed / 0 failed / 16 skipped; screenshot pass 176 tests, 824 PNGs; report 283 tests, 277 passed, 0 pending. --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 536bfb662..71d60868e 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 536bfb662a33093b3599b33df1d5949ab6fd124c +Subproject commit 71d60868ebb2fcd153af04e17c27fa18181222b0 From 6edc1959db42c51ae72450e46141140845514052 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 18 Jul 2026 13:23:32 -0300 Subject: [PATCH 16/55] fix(thorchain): clear-sign deposits on every pinned EVM chain, not just mainnet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-port of #309 onto the consolidated line. thor_isThorchainTx pinned the router to the Ethereum-mainnet address, so a THORChain depositWithExpiry on Avalanche never matched and fell into the AdvancedMode blind-sign gate (bare ActionCancelled). Scope the pin to (chain_id, address): thor_router_for_chain returns the router for this tx's chain — mainnet d37bbe.., Avalanche C-Chain 00dc61.. (verified live against /inbound_addresses) — or NULL (unpinned chain -> blind-sign gate). Differences from #309, deliberate: - A tx with NO chain_id gets no router at all (fail-closed), rather than inheriting mainnet from ethereum.c's hashing default — an identity pin must never come from a default the host merely omitted. - #309's memo-length fix is already on this line in a stronger form (exact padded-end bound, whole-calldata-in-chunk); its tests are ported against those semantics. - Native deposits now format with the CHAIN's native ticker via ethereumFormatAmount(NULL token) — the 0xEE pseudo-token is pinned to " ETH" and mislabeled every other chain's native amount ('0.5 ETH' for 0.5 AVAX). Adds the missing 43114 -> AVAX ticker case. BSC (56) and Base (8453) routers remain unpinned until verified against a live node (the shipped Pioneer catalog's AVAX entry is already stale, and those pairs route via Relay today). Tests: 8 firmware unit (chain-scoped match/reject both directions, unpinned chain/address, missing chain_id, AVAX full-confirm 67-byte memo, over-declared memo length rejected) — 290/290; pyk +2 (AVAX deposit ECDSA-recovered over chainId 43114 + unpinned-chain gate) — 556 passed/0 failed; OLED shows 'Confirm sending 0.5 AVAX'; report E23/E24. ARM zcash-privacy elf links (ROM budget holds). deps/python-keepkey -> d39ab36. Supersedes and closes #309. --- deps/python-keepkey | 2 +- .../firmware/ethereum_contracts/thortx.h | 14 ++ lib/firmware/ethereum.c | 3 + lib/firmware/ethereum_contracts/thortx.c | 103 +++++++---- unittests/firmware/thorchain.cpp | 175 ++++++++++++++++++ 5 files changed, 258 insertions(+), 39 deletions(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 71d60868e..d39ab3673 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 71d60868ebb2fcd153af04e17c27fa18181222b0 +Subproject commit d39ab36730ecb6db3a158afc85a8e934e03a0731 diff --git a/include/keepkey/firmware/ethereum_contracts/thortx.h b/include/keepkey/firmware/ethereum_contracts/thortx.h index 03fcd7b03..8f71c7fcc 100644 --- a/include/keepkey/firmware/ethereum_contracts/thortx.h +++ b/include/keepkey/firmware/ethereum_contracts/thortx.h @@ -37,6 +37,20 @@ * 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. */ diff --git a/lib/firmware/ethereum.c b/lib/firmware/ethereum.c index 249c6a941..0c49371b1 100644 --- a/lib/firmware/ethereum.c +++ b/lib/firmware/ethereum.c @@ -431,6 +431,9 @@ void ethereumFormatAmount(const bignum256* amnt, const TokenType* token, case 137: suffix = " MATIC"; break; // Polygon Mainnet + case 43114: + suffix = " AVAX"; + break; // Avalanche C-Chain } } } diff --git a/lib/firmware/ethereum_contracts/thortx.c b/lib/firmware/ethereum_contracts/thortx.c index b434ec343..8fdf3648a 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -61,19 +61,38 @@ bool thor_isMayachainTx(const EthereumSignTx* msg) { 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; - /* THOR_ROUTER is an Ethereum-mainnet identity; bind to mainnet for the same - * reason as thor_isMayachainTx above. */ - if (!msg->has_chain_id || msg->chain_id != 1) return false; if (!thor_has_deposit_selector(msg)) return false; - /* Pin to the THORChain router. Without this, 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. Mirrors thor_isMayachainTx. */ + /* 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, THOR_ROUTER, 40) == 0; + return strncmp(toStr, router, 40) == 0; } static bool thor_confirm_deposit_tx(uint32_t data_total, @@ -153,7 +172,8 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, // Start confirmations thor_format_to_addr(msg, confStr); - if (strncmp(confStr, THOR_ROUTER, 40) == 0) { + 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; @@ -174,7 +194,7 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, return false; } - /* Both pinned routers treat ONLY address(0) as native ETH (and require + /* 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 @@ -186,8 +206,16 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, /* 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. */ - assetAddress = (const uint8_t*)ETH_NATIVE; + * 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 (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, + "Confirm sending %s", confStr)) { + return false; + } } 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. */ @@ -195,37 +223,36 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, return false; } assetAddress = contractAssetAddress; - } - const bignum256* displayAmount = is_native ? &Value : &Amount; - assetToken = tokenByChainAddress(msg->chain_id, assetAddress); + assetToken = tokenByChainAddress(msg->chain_id, assetAddress); - 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(displayAmount, NULL, " unformatted", 0, 0, false, 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, + 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; - } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + protocol_label, "amount %s", confStr)) { + return false; + } - } else { - ethereumFormatAmount(displayAmount, assetToken, msg->chain_id, confStr, - sizeof(confStr)); + } else { + ethereumFormatAmount(&Amount, assetToken, msg->chain_id, confStr, + sizeof(confStr)); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, - "Confirm sending %s", confStr)) { - return false; + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + protocol_label, "Confirm sending %s", confStr)) { + return false; + } } } diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index ae0725fb8..15e3c113b 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -2,9 +2,11 @@ extern "C" { #include "keepkey/board/messages.h" #include "keepkey/board/usb.h" #include "keepkey/firmware/coins.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) @@ -15,6 +17,7 @@ void kk_board_init(void); #include "gtest/gtest.h" #include #include +#include #include #include @@ -408,3 +411,175 @@ TEST(Thorchain, FullMemoEmptyShowsEmpty) { EXPECT_TRUE(thorchain_confirm_full_memo("Memo", "", 0)); 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 675bc9badb2ab970b017726ca56a8ef188bbf554 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 18 Jul 2026 13:45:49 -0300 Subject: [PATCH 17/55] fix(review-r12): memo-disclosure gaps, CI shipped-config leg, pre-tag hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-12 review remediation (5 majors + one-liners + regression tests). Memo disclosure (majors 1-3): - ripple: route the memo through thorchain_confirm_full_memo — the single unpaged confirm rendered only 3 OLED lines and honored embedded newlines, so up to ~180 signed bytes of Memos-field routing data could be invisible. - tron: same pager replaces the <=114-char single screen (3 lines only guarantee ~84 wide-glyph chars, clipping a signed affiliate/destination tail); non-printable memos now page as full hex. - thorchain/mayachain WITHDRAW: display the 4th field (asymmetric single- sided payout asset selector — it directs money) and cap nfields>4, matching the SWAP(>9)/ADD(>5) caps. Screens for -:BTC.BTC:BPS:ASSET were otherwise identical to the symmetric form. CI (major 4): the emulator CMake default is KK_ZCASH_PRIVACY=ON, so the empty-flag full leg built identical to the zcash-privacy leg and the SHIPPED device config (multi-coin, privacy OFF) was never unit-tested. Pin the full leg to -DKK_ZCASH_PRIVACY=OFF; publish-emulator now ships the zcash-privacy image (same bytes the old full leg produced). Submodule pins (major 5): deps/python-keepkey -> 8586652 (declares device-protocol from keepkey/up-release-protocol, now fast-forwarded to the 47e19d8 pin). Fork device-protocol and keepkey/up-release-protocol both hold the pin, so 'submodule update --remote' can no longer rewind below the proto fields / hive tests the release depends on. Pre-tag one-liners: storage.c V18 buffer comment arithmetic (3479/3480); BITCOIN_ONLY GetFeatures emits the bitcoin-only-locked sentinel; solana TransferChecked requires the canonical 10-byte data + >=4 accounts else opaque (a short encoding showed a zeroed 1111.. destination); zcash pins enc_noncompact.size==16; signed_metadata applies the printable-ASCII allowlist to method/arg names too; release.yml runs check_sram_budget.py + requires green CI on the tagged SHA before producing artifacts; release.sh passes extra args through to cmake for per-variant reproducible builds. Regression tests: signed_metadata v2 nonzero-native-value reject; solana TransferChecked canonical/short-data/short-accounts + StakeAuthorize 36-vs-40-byte; zcash orchard transparent-sig-digest S.2-vs-T.1 (the shield fix site, previously uncalled); thorchain WITHDRAW asymmetric asset + field-cap. Firmware unit + pyk suites validated locally. --- .github/workflows/ci.yml | 15 +- .github/workflows/release.yml | 36 +- deps/python-keepkey | 2 +- lib/firmware/ethereum_contracts/thortx.c | 8 +- lib/firmware/fsm_msg_common.h | 17 +- lib/firmware/fsm_msg_ripple.h | 9 +- lib/firmware/fsm_msg_tron.h | 23 +- lib/firmware/fsm_msg_zcash.h | 5 +- lib/firmware/mayachain.c | 14 + lib/firmware/signed_metadata.c | 20 + lib/firmware/solana.c | 12 +- lib/firmware/storage.c | 6 +- lib/firmware/thorchain.c | 17 + scripts/build/docker/device/release.sh | 7 +- unittests/firmware/signed_metadata.cpp | 261 ++++--- unittests/firmware/solana.cpp | 110 ++- unittests/firmware/thorchain.cpp | 62 +- unittests/firmware/zcash.cpp | 944 +++++++++++------------ 18 files changed, 905 insertions(+), 663 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 697aa3c74..9ec1a89f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,9 +221,15 @@ jobs: fail-fast: false matrix: include: + # The emulator CMake default is KK_ZCASH_PRIVACY=ON (what the published + # dylib/Docker image include), so an empty-flag full leg builds + # byte-identical to the zcash-privacy leg and the SHIPPED device + # configuration (multi-coin, privacy OFF) is never unit-tested. Pin the + # full leg to the shipped config; publish-emulator ships the + # zcash-privacy image (the same bytes the old full leg produced). - variant: full label: "" - cmake_flags: "" + cmake_flags: "-DKK_ZCASH_PRIVACY=OFF" - variant: bitcoin-only label: " (bitcoin-only)" cmake_flags: "-DKK_BITCOIN_ONLY=ON" @@ -812,8 +818,11 @@ jobs: - name: Download emulator image uses: actions/download-artifact@v8 with: - # Only the full/default variant is published to DockerHub. - name: emu-image-full + # Publish the zcash-privacy variant: it matches the emulator's CMake + # default (privacy engine ON — what vault and auditors run), which + # the "full" leg no longer builds now that it pins the shipped + # device configuration (privacy OFF). + name: emu-image-zcash-privacy path: /tmp - name: Load emulator image diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b01ba9a96..06acecff9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,23 @@ jobs: exit 1 fi + - 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 + build-firmware: needs: validate runs-on: ubuntu-latest @@ -96,8 +113,20 @@ jobs: 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: | @@ -213,8 +242,11 @@ jobs: \`\`\`bash ./scripts/build/docker/device/release.sh tail -c +257 bin/firmware.keepkey.bin | shasum -a 256 - # bitcoin-only / zcash-privacy: pass -DKK_BITCOIN_ONLY=ON / -DKK_ZCASH_PRIVACY=ON - # to the cmake invocation and compare against the matching HASHES-.txt + # bitcoin-only / zcash-privacy variants: + # ./scripts/build/docker/device/release.sh -DKK_BITCOIN_ONLY=ON + # ./scripts/build/docker/device/release.sh -DKK_ZCASH_PRIVACY=ON + # (extra args pass through to cmake) and compare against the + # matching HASHES-.txt \`\`\` > **DRAFT** — firmware must be signed by 3/5 key holders before publishing. diff --git a/deps/python-keepkey b/deps/python-keepkey index d39ab3673..85866526c 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit d39ab36730ecb6db3a158afc85a8e934e03a0731 +Subproject commit 85866526c33a0af7217b6e085c20bf870803c735 diff --git a/lib/firmware/ethereum_contracts/thortx.c b/lib/firmware/ethereum_contracts/thortx.c index 8fdf3648a..d86e98e52 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -155,10 +155,9 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, char confStr[41]; const char* conf; - const TokenType* assetToken; uint8_t* thorchainData; const uint8_t* contractAssetAddress; - const uint8_t *vaultAddress, *assetAddress; + const uint8_t* vaultAddress; uint32_t ctr; bignum256 Amount; @@ -222,9 +221,10 @@ static bool thor_confirm_deposit_tx(uint32_t data_total, if (!bn_is_zero(&Value)) { return false; } - assetAddress = contractAssetAddress; + const uint8_t* assetAddress = contractAssetAddress; - assetToken = tokenByChainAddress(msg->chain_id, assetAddress); + const TokenType* assetToken = + tokenByChainAddress(msg->chain_id, assetAddress); if (strncmp(assetToken->ticker, " UNKN", 5) == 0) { // just display token address and amount as string diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 98609d521..63c9d0044 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -46,13 +46,22 @@ void fsm_msgGetFeatures(GetFeatures* msg) { #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. */ + 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)); + strlcpy(resp->firmware_variant, "EmulatorBTC", + sizeof(resp->firmware_variant)); #else - strlcpy(resp->firmware_variant, "KeepKeyBTC", sizeof(resp->firmware_variant)); + 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 diff --git a/lib/firmware/fsm_msg_ripple.h b/lib/firmware/fsm_msg_ripple.h index a678522e3..bbe7fa6cf 100644 --- a/lib/firmware/fsm_msg_ripple.h +++ b/lib/firmware/fsm_msg_ripple.h @@ -110,8 +110,13 @@ void fsm_msgRippleSignTx(RippleSignTx* msg) { } if (msg->has_memo && msg->memo[0] != '\0') { - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Memo", "%s", - msg->memo)) { + /* 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(); diff --git a/lib/firmware/fsm_msg_tron.h b/lib/firmware/fsm_msg_tron.h index 85d064aac..79b61d1fe 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -181,21 +181,14 @@ void fsm_msgTronSignTx(TronSignTx* msg) { } if (confirmed && parsed.memo_len > 0) { - bool printable = true; - for (uint16_t i = 0; i < parsed.memo_len; i++) { - if (parsed.memo[i] < 0x20 || parsed.memo[i] > 0x7e) { - printable = false; - break; - } - } - if (printable && parsed.memo_len <= 114) { - confirmed = confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, "Memo", - "%.*s", (int)parsed.memo_len, parsed.memo); - } else { - confirmed = - confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, "Memo", - "Data attached (%u bytes)", (unsigned)parsed.memo_len); - } + /* 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) { diff --git a/lib/firmware/fsm_msg_zcash.h b/lib/firmware/fsm_msg_zcash.h index 67987738f..0028bacca 100644 --- a/lib/firmware/fsm_msg_zcash.h +++ b/lib/firmware/fsm_msg_zcash.h @@ -1001,7 +1001,10 @@ void fsm_msgZcashPCZTAction(const ZcashPCZTAction* msg) { 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 && - msg->enc_noncompact.size > 0 && msg->has_cv_net && + /* 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; diff --git a/lib/firmware/mayachain.c b/lib/firmware/mayachain.c index ad1929d80..bfa2ce0fa 100644 --- a/lib/firmware/mayachain.c +++ b/lib/firmware/mayachain.c @@ -364,6 +364,11 @@ bool mayachain_parseConfirmMemo(const char* swapStr, size_t size) { 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(fields[2])) / 100; if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, @@ -372,6 +377,15 @@ bool mayachain_parseConfirmMemo(const char* swapStr, size_t size) { 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/signed_metadata.c b/lib/firmware/signed_metadata.c index 4af2298f5..cbb95a3f9 100644 --- a/lib/firmware/signed_metadata.c +++ b/lib/firmware/signed_metadata.c @@ -108,6 +108,20 @@ static bool read_bytes(const uint8_t** cursor, const uint8_t* end, uint8_t* out, 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; @@ -115,6 +129,9 @@ static bool read_string(const uint8_t** cursor, const uint8_t* end, char* out, 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'; @@ -129,6 +146,9 @@ static bool read_arg_name(const uint8_t** cursor, const uint8_t* end, char* out, 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'; diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index 588d3c5d4..302a1340f 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -278,15 +278,21 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, * 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) { diff --git a/lib/firmware/storage.c b/lib/firmware/storage.c index 889cc3850..975899444 100644 --- a/lib/firmware/storage.c +++ b/lib/firmware/storage.c @@ -1631,9 +1631,9 @@ void storage_commit(void) { // Temporary storage for marshalling secrets in & out of flash. // V18 storage layout = V17 (2525 bytes) + persistent identities block - // (PERSISTENT_IDENTITY_COUNT * CLEARSIGN_IDENTITY_SERIALIZED_LEN = 2*454 = - // 908) = 3433; + meta (44) = 3477. Rounded up to a multiple of 4 (the CRC - // below iterates uint32_t words) => 3480. + // (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)); diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index 3de49f180..330b31c78 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -488,6 +488,11 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { 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(fields[2])) / 100; if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, @@ -496,6 +501,18 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { 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 { diff --git a/scripts/build/docker/device/release.sh b/scripts/build/docker/device/release.sh index 18cfe53dc..acba5a90b 100755 --- a/scripts/build/docker/device/release.sh +++ b/scripts/build/docker/device/release.sh @@ -7,13 +7,18 @@ IMAGETAG=kktech/firmware:v15 docker image inspect $IMAGETAG > /dev/null || docker pull $IMAGETAG +# Extra cmake flags pass straight through (reproducible-build verification of +# the other variants): ./release.sh -DKK_BITCOIN_ONLY=ON / -DKK_ZCASH_PRIVACY=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/unittests/firmware/signed_metadata.cpp b/unittests/firmware/signed_metadata.cpp index abfcec4cc..b580ad0dd 100644 --- a/unittests/firmware/signed_metadata.cpp +++ b/unittests/firmware/signed_metadata.cpp @@ -17,9 +17,9 @@ */ 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 "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 "trezor/crypto/ecdsa.h" @@ -37,11 +37,12 @@ extern "C" { 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}; +/* 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] = { @@ -60,31 +61,31 @@ const uint8_t CONTRACT_B[20] = {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 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, +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) { +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) { +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) { +void put_bytes(std::vector& v, const uint8_t* b, size_t n) { v.insert(v.end(), b, b + n); } @@ -97,7 +98,7 @@ struct Arg { int value_len_override; // -1 => use value.size() }; -Arg mk_arg(const std::string &name, uint8_t format, const uint8_t *value, +Arg mk_arg(const std::string& name, uint8_t format, const uint8_t* value, size_t value_len) { Arg a; a.name = name; @@ -122,7 +123,8 @@ struct Spec { int num_args_override; // -1 => use args.size() }; -/* Canonical VERIFIED metadata: transfer(to:ADDRESS, amount:AMOUNT) on chain 1. */ +/* Canonical VERIFIED metadata: transfer(to:ADDRESS, amount:AMOUNT) on chain 1. + */ Spec base_spec() { Spec s; s.version = 0x01; @@ -143,7 +145,7 @@ Spec base_spec() { /* Serialize the signed region (version .. key_id), exactly matching * parse_metadata_binary() / serialize_metadata(). */ -std::vector build_body(const Spec &s) { +std::vector build_body(const Spec& s) { std::vector b; put_u8(b, s.version); put_be32(b, s.chain_id); @@ -154,14 +156,14 @@ std::vector build_body(const Spec &s) { 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()); + 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) { + 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_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(); @@ -191,8 +193,8 @@ std::vector sign_body(std::vector 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, +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; @@ -206,25 +208,25 @@ void make_msg(EthereumSignTx *msg, const uint8_t contract[20], } /* A standard transfer() calldata chunk that matches base_spec(). */ -void make_matching_msg(EthereumSignTx *msg) { +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"; +const char* TEST_ALIAS = "CI Test"; class SignedMetadataTest : public ::testing::Test { protected: void SetUp() override { signed_metadata_clear_signers(); signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, - NULL, 0, 0, 0, false); + NULL, 0, 0, 0, false); } void TearDown() override { signed_metadata_clear_signers(); } - void ExpectMalformed(const std::vector &blob, uint8_t key_id) { + 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()); @@ -248,7 +250,7 @@ TEST_F(SignedMetadataTest, ValidVerifiedSlot3) { 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(); + const SignedMetadata* m = signed_metadata_get(); ASSERT_NE(m, nullptr); EXPECT_EQ(m->classification, METADATA_VERIFIED); EXPECT_EQ(m->chain_id, 1u); @@ -416,13 +418,13 @@ TEST_F(SignedMetadataTest, ArgFormatOutOfRange) { 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, + 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(); + 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); @@ -447,8 +449,8 @@ TEST_F(SignedMetadataTest, StringArgRejectsUnprintableAndPercent) { /* ---- ARG_FORMAT_TOKEN_AMOUNT (decimals + symbol + amount) --------------- */ std::vector token_amount_value(uint8_t decimals, - const std::string &symbol, - const std::vector &amount) { + const std::string& symbol, + const std::vector& amount) { std::vector v; v.push_back(decimals); v.push_back((uint8_t)symbol.size()); @@ -466,7 +468,7 @@ TEST_F(SignedMetadataTest, TokenAmountAccepted) { 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(); + 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()); @@ -488,22 +490,22 @@ 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()); + 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()); + 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()); + 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 */ @@ -636,7 +638,8 @@ TEST_F(SignedMetadataTest, MatchesTxWrongChainId) { 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 + make_msg(&no_chain, CONTRACT_A, data, sizeof(data), false, + 0); // treated as 0 EXPECT_FALSE(signed_metadata_matches_tx(&no_chain)); } @@ -769,8 +772,8 @@ struct IconCanvas { } }; -bool decode_icon(const std::vector &data, uint16_t w, uint16_t h, - IconCanvas *ic) { +bool decode_icon(const std::vector& data, uint16_t w, uint16_t h, + IconCanvas* ic) { Image img; img.w = w; img.h = h; @@ -780,7 +783,7 @@ bool decode_icon(const std::vector &data, uint16_t w, uint16_t h, frame.x = 0; frame.y = 0; frame.duration = 0; - frame.color = 100; /* value*100/100 => data bytes land verbatim */ + frame.color = 100; /* value*100/100 => data bytes land verbatim */ frame.image = &img; return draw_bitmap_mono_rle(&ic->canvas, &frame, /*erase=*/false); } @@ -834,7 +837,7 @@ TEST(SignedMetadataIcon, MaxRunOf127Decodes) { TEST(SignedMetadataIcon, TruncatedStreamIsRejected) { IconCanvas ic; - EXPECT_FALSE(decode_icon({0x08, 0xFF}, 4, 4, &ic)); /* claims 8, has 2 */ + EXPECT_FALSE(decode_icon({0x08, 0xFF}, 4, 4, &ic)); /* claims 8, has 2 */ } /* ── Exact-validation guards (review round 2) ────────────────────────────── @@ -845,7 +848,7 @@ TEST(SignedMetadataIcon, TruncatedStreamIsRejected) { 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)); + 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)); } @@ -853,48 +856,50 @@ TEST(SignedMetadataIcon, StraddlingRunIsRejected) { 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)); + 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)); + 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)); + 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)); + 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)); + 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)); + 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)); + 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((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 @@ -903,7 +908,6 @@ TEST(SignedMetadataIcon, IconColumnCapIsNarrowerThanTheIconHeight) { EXPECT_LT(LEFT_MARGIN_WITH_ICON, 64); } - TEST(SignedMetadataSignerValid, AcceptsValidCompressedKeyAllSlots) { for (uint8_t slot = 0; slot < METADATA_MAX_KEYS; slot++) { EXPECT_TRUE( @@ -937,16 +941,18 @@ TEST(SignedMetadataSignerValid, RejectsNonCompressedPrefix) { } 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, 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())); + 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, "Pioneer")); EXPECT_TRUE( signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "KeepKey Swap")); EXPECT_TRUE( @@ -956,10 +962,11 @@ TEST(SignedMetadataSignerValid, RejectsBadAlias) { * 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, + "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( @@ -989,19 +996,19 @@ TEST(SignedMetadataFingerprint, IsSha256Prefix) { 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, 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)); + 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)); + EXPECT_TRUE( + signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, h, h)); } TEST(SignedMetadataEnforce, ReliedHashMismatch) { @@ -1024,17 +1031,17 @@ TEST(SignedMetadataEnforce, ReliedHashNull) { TEST(SignedMetadataEnforce, ReliedNotAvailable) { uint8_t h[32]; memcpy(h, TX_HASH, 32); - EXPECT_FALSE(signed_metadata_enforce_decision(true, false, METADATA_VERIFIED, - h, h)); + 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)); + 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) { @@ -1062,11 +1069,11 @@ struct V2Arg { std::string symbol; /* TOKEN_AMOUNT only */ }; -V2Arg v2_addr(const std::string &name) { +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) { +V2Arg v2_token(const std::string& name, uint8_t decimals, + const std::string& symbol) { return V2Arg{name, ARG_FORMAT_TOKEN_AMOUNT, decimals, symbol}; } @@ -1095,24 +1102,24 @@ V2Spec v2_base_spec() { return s; } -std::vector build_v2_body(const V2Spec &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_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) { + 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_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_bytes(b, (const uint8_t*)a.symbol.data(), a.symbol.size()); } } put_u8(b, s.classification); @@ -1126,7 +1133,7 @@ std::vector v2_base_blob() { } /* ABI calldata: selector + one 32-byte head word per arg. */ -void put_addr_word(std::vector &d, const uint8_t addr[20]) { +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); } @@ -1139,8 +1146,8 @@ std::vector v2_transfer_calldata() { return d; } -void make_v2_msg(EthereumSignTx *msg, const uint8_t contract[20], - const std::vector &data, bool has_len, +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; @@ -1163,7 +1170,7 @@ TEST_F(SignedMetadataTest, V2SchemaDecodesTransferArgs) { METADATA_VERIFIED); EXPECT_TRUE(signed_metadata_available()); - const SignedMetadata *md = signed_metadata_get(); + const SignedMetadata* md = signed_metadata_get(); ASSERT_NE(md, nullptr); EXPECT_EQ(md->version, METADATA_VERSION_SCHEMA); EXPECT_EQ(md->num_args, 2); @@ -1186,14 +1193,40 @@ TEST_F(SignedMetadataTest, V2SchemaDecodesTransferArgs) { EXPECT_EQ(memcmp(md->args[1].value + 6, AMOUNT32, 32), 0); } +/* THE v2 drain preventer: a v2 schema commits to calldata only — never to + * msg->value — and a v2 match suppresses ethereum.c's native-value confirm + * screen. A payable method could then clear-sign an arbitrary ETH transfer + * whose value is never shown. Any nonzero native value must therefore refuse + * the v2 match and fall to the blind-sign gate. (v1 is safe: tx_hash covers + * value.) */ +TEST_F(SignedMetadataTest, V2SchemaRejectsNonzeroNativeValue) { + 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 is enough — any nonzero value refuses + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); + + /* Same tx with zero value clear-signs — proving the refusal above is the + * value guard, not some other binding. */ + msg.value.size = 0; + msg.has_value = false; + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); +} + /* 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). */ + * (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, @@ -1216,12 +1249,13 @@ TEST_F(SignedMetadataTest, V2SchemaDecodesRelaySolverArgs) { EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), METADATA_VERIFIED); - const SignedMetadata *md = signed_metadata_get(); + 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. + // 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); @@ -1229,7 +1263,8 @@ TEST_F(SignedMetadataTest, V2SchemaDecodesRelaySolverArgs) { EXPECT_EQ(data.size(), 100u); EthereumSignTx msg; - make_v2_msg(&msg, RELAY_SOLVER, data, /*has_len=*/true, (uint32_t)data.size()); + 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). @@ -1250,7 +1285,8 @@ TEST_F(SignedMetadataTest, V2SchemaDecodesRelaySolverArgs) { EXPECT_EQ(memcmp(md->args[2].value, REQ_ID, 32), 0); } -/* has_data_length omitted but the initial chunk IS the whole calldata: allowed. */ +/* 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), @@ -1349,7 +1385,7 @@ TEST_F(SignedMetadataTest, V2MatchesTxIsIdempotent) { 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(); + 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 @@ -1427,8 +1463,8 @@ TEST(SignedMetadataAttestation, VerifiesValidRejectsTampered) { 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)); + 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))); @@ -1467,7 +1503,7 @@ TEST(SolanaTokenDef, TrustedOnlyWithValidAttestation) { // Canonical preimage: tag || mint(32) || decimals(le32) || symbol. std::vector pre; - const char *tag = "KeepKeySolanaTokenDef/1"; + 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); @@ -1480,15 +1516,16 @@ TEST(SolanaTokenDef, TrustedOnlyWithValidAttestation) { 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)); + 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. + // Attested-tuple disagreement: a different decimals no longer matches the + // sig. ti.decimals = 9; EXPECT_FALSE(solana_token_info_trusted(&ti)); ti.decimals = 6; diff --git a/unittests/firmware/solana.cpp b/unittests/firmware/solana.cpp index dc6b7ac7f..978cf3943 100644 --- a/unittests/firmware/solana.cpp +++ b/unittests/firmware/solana.cpp @@ -227,7 +227,8 @@ TEST(Solana, ParseSPLTokenTransfer) { SolanaParsedTx 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. */ + * 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); @@ -256,9 +257,9 @@ TEST(Solana, Token2022TransferCheckedIsOpaque) { 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++] = 1; /* 1 instruction */ + raw[pos++] = 4; /* program index = token-2022 */ + raw[pos++] = 4; /* 4 accounts */ raw[pos++] = 0; raw[pos++] = 1; raw[pos++] = 2; @@ -319,7 +320,8 @@ TEST(Solana, VoteUpdateValidatorReadsAccountNotData) { 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. */ + /* 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)); @@ -328,7 +330,8 @@ TEST(Solana, VoteUpdateValidatorReadsAccountNotData) { 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). */ + * 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); @@ -873,3 +876,98 @@ 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); +} diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index 15e3c113b..a61ab20ca 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -39,7 +39,7 @@ void kk_board_init(void); * one-time board/usb initialization. */ -static bool kkconfirm_sendTiny(uint16_t msgId, const uint8_t *payload, +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); @@ -59,7 +59,7 @@ static bool kkconfirm_sendTiny(uint16_t msgId, const uint8_t *payload, 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, + return sendto(fd, frame, sizeof(frame), 0, (struct sockaddr*)&addr, sizeof(addr)) == (ssize_t)sizeof(frame); } @@ -78,9 +78,9 @@ bool kkconfirm_preload(int nYes, int nNo) { 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)) + const uint8_t* decision = (i < nYes) ? yes : no; + if (!kkconfirm_sendTiny(MessageType_MessageType_DebugLinkDecision, decision, + 2)) return false; } return true; @@ -150,7 +150,7 @@ static const ThorchainSignTx kSignTx = { true, 0, true, 1}; -static const char *kToAddr = "thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v"; +static const char* kToAddr = "thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v"; // Denom validation: only [a-z0-9./\-] is allowed; anything else is rejected TEST(Thorchain, ThorchainDenomValidation) { @@ -187,10 +187,10 @@ TEST(Thorchain, ThorchainSignTxInvalidDenom) { * screens and kkconfirm_drain() == 0 proves N screens were shown. * ===================================================================== */ -static bool parseMemo(const char *memo, size_t size) { +static bool parseMemo(const char* memo, size_t size) { return thorchain_parseConfirmMemo(memo, size); } -static bool parseMemo(const char *memo) { +static bool parseMemo(const char* memo) { return parseMemo(memo, strlen(memo) + 1); } @@ -305,10 +305,9 @@ TEST(Thorchain, MemoRawBytesNoNulKeepsLastChar) { // 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; + 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)); @@ -323,6 +322,37 @@ TEST(Thorchain, MemoOversized) { 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). @@ -356,8 +386,9 @@ TEST(Thorchain, MemoSwapTooManyFieldsRejected) { // add asset + pool + affiliate-fee = 3 screens. TEST(Thorchain, MemoAddShowsAffiliateAndFee) { ASSERT_TRUE(kkconfirm_preload(3, 0)); - EXPECT_TRUE(parseMemo("ADD:BTC.BTC:thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v" - ":affil:50")); + EXPECT_TRUE( + parseMemo("ADD:BTC.BTC:thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v" + ":affil:50")); EXPECT_EQ(0, kkconfirm_drain()); } @@ -386,7 +417,8 @@ TEST(Thorchain, FullMemoLongAsciiPagesAll) { EXPECT_EQ(0, kkconfirm_drain()); } -// Rejecting any page aborts the whole disclosure (so the handler aborts signing). +// Rejecting any page aborts the whole disclosure (so the handler aborts +// signing). TEST(Thorchain, FullMemoRejectPropagates) { std::string memo(150, 'a'); ASSERT_TRUE(kkconfirm_preload(1, 1)); // approve page 1, reject page 2 diff --git a/unittests/firmware/zcash.cpp b/unittests/firmware/zcash.cpp index 4b08fdf43..2d8f43e7f 100644 --- a/unittests/firmware/zcash.cpp +++ b/unittests/firmware/zcash.cpp @@ -17,47 +17,41 @@ extern "C" { /* 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, + 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, + 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, + 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, + 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, + 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, + 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}; @@ -66,62 +60,54 @@ 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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 \ @@ -129,43 +115,35 @@ static const uint8_t SINSEMILLA_TWENTY_THREE_BITS_COMMIT_POINT[32] = { /* 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, + 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, + 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, + 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, + 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 */ @@ -184,14 +162,12 @@ static int cmp_le256(const uint8_t a[32], const uint8_t b[32]) { * 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, + 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, }; /* @@ -199,38 +175,33 @@ static const uint8_t SEED_ALL[64] = { * 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, + 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, + 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, + 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, + 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, + 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] = { @@ -248,123 +219,97 @@ struct OrchardFf1Vector { }; 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}}, + {{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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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. */ @@ -376,48 +321,36 @@ struct OrchardReceiverVector { }; 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}}, + {{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. */ @@ -429,121 +362,104 @@ struct OrchardIvkVector { }; 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}}, + {{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; + 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, + 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, + 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}}, +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. */ @@ -579,8 +495,8 @@ TEST(Zcash, DeriveOrchardKeys_ReferenceVector_Account0) { << "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)); + 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"; @@ -691,12 +607,12 @@ TEST(Zcash, OrchardDiversifier_DeterministicAndDistinct) { 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)); 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)); + 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); @@ -711,9 +627,8 @@ TEST(Zcash, ExpandMessageXmdBlake2b_ReferenceVector) { 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)), + 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); } @@ -799,10 +714,10 @@ TEST(Zcash, SinsemillaPrimitives_ReferenceVectors) { }; 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); + 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; @@ -810,14 +725,13 @@ TEST(Zcash, SinsemillaPrimitives_ReferenceVectors) { uint8_t hash[32]; uint8_t short_commit[32]; - ASSERT_EQ(pallas_sinsemilla_hash_to_point(&q, vector.msg, - vector.msg_bits, &hash_point), + 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); + 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, @@ -826,12 +740,12 @@ TEST(Zcash, SinsemillaPrimitives_ReferenceVectors) { 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); + 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)); @@ -846,19 +760,19 @@ TEST(Zcash, SinsemillaPrimitives_ReferenceVectors) { 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); + 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), + 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), @@ -896,8 +810,7 @@ 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); + EXPECT_EQ(zcash_zip316_f4jumble(nullptr, ZCASH_ZIP316_F4JUMBLE_MIN_LEN), -1); } TEST(Zcash, Zip316OrchardOnlyUnifiedAddress_ReferenceVectors) { @@ -910,8 +823,8 @@ TEST(Zcash, Zip316OrchardOnlyUnifiedAddress_ReferenceVectors) { 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)), + "utest", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, address, + sizeof(address)), 0); EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_0); @@ -922,8 +835,8 @@ TEST(Zcash, Zip316OrchardOnlyUnifiedAddress_ReferenceVectors) { 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)), + "utest", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[1].receiver, address, + sizeof(address)), 0); EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_1); @@ -938,8 +851,8 @@ TEST(Zcash, Zip316OrchardOnlyUnifiedAddress_RejectsInvalidInputs) { 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)), + "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, @@ -963,20 +876,20 @@ TEST(Zcash, OrchardUnifiedAddress_FromDerivedKeys) { 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))); + 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))); + 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))); + 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))); + 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)); @@ -991,14 +904,14 @@ TEST(Zcash, OrchardUnifiedAddress_RejectsInvalidInputs) { 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(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))); @@ -1014,21 +927,18 @@ TEST(Zcash, OrchardNoteCommitment_KnownVector) { 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 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}; + 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]; ASSERT_TRUE(zcash_orchard_compute_cmx(recipient, value, rho, rseed, cmx)); @@ -1052,14 +962,14 @@ TEST(Zcash, OrchardReceiverToUnifiedAddress_KnownVector) { 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, - "u1ut4h93zg5670tyqss7tneru3t7h6dk62r9hhyxyrpv3nwwe9dnyj5l0ruwygf74gp5f3zklj5xly4h8h54un3asugt9mn6gwfqsq3wq7"); + 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)); + EXPECT_FALSE( + zcash_orchard_receiver_to_unified_address(recipient, "u", address, 16)); memzero(address, sizeof(address)); } @@ -1068,8 +978,8 @@ TEST(Zcash, OrchardDiversifyHash_ReferenceVectors) { 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)); + 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; @@ -1126,8 +1036,8 @@ TEST(Zcash, OrchardIvk_RejectsInvalidAkEncoding) { 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)); + 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)); } @@ -1331,34 +1241,29 @@ TEST(Zcash, PCZTSigningPolicy_RejectsTransparentComponentsWithoutDigest) { } 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}; + 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}; + 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}; + 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}; + 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}; + 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, @@ -1366,9 +1271,8 @@ static const uint8_t ZIP244_P2PKH_SCRIPT_11[25] = { 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}; + 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, @@ -1376,9 +1280,8 @@ static const uint8_t ZIP244_P2PKH_SCRIPT_33[25] = { 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}; + 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++) { @@ -1419,8 +1322,8 @@ static void make_zip244_transparent_fixture( TEST(Zcash, ComputeHeaderDigest_FromPlaintextFields) { uint8_t digest[32] = {0}; - ASSERT_TRUE(zcash_compute_header_digest( - 5, 0x26a7270a, 0xc2d6d0b4, 123456, 987654, digest)); + ASSERT_TRUE(zcash_compute_header_digest(5, 0x26a7270a, 0xc2d6d0b4, 123456, + 987654, digest)); EXPECT_TRUE(memcmp(digest, ZIP244_EXPECTED_HEADER_DIGEST, 32) == 0); } @@ -1435,16 +1338,14 @@ TEST(Zcash, ComputeTransparentDigest_DistinctFromPerInputSighash) { 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)); + 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(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); } @@ -1463,10 +1364,10 @@ TEST(Zcash, ComputeTransparentSighash_RejectsUnsupportedRequest) { 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)); + 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) { @@ -1479,20 +1380,20 @@ TEST(Zcash, ComputeTransparentSighash_CommitsToOutputScriptAndValue) { 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)); + 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)); + 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)); + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 0, + 0x01, changed_value)); EXPECT_TRUE(memcmp(original, changed_value, 32) != 0); } @@ -1505,13 +1406,13 @@ TEST(Zcash, ComputeShieldedSighash_Deterministic) { memset(sapling, 0x03, 32); memset(orchard, 0x04, 32); - uint32_t branch_id = 0x37519621; /* NU5 */ + 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)); + 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"; @@ -1527,13 +1428,13 @@ TEST(Zcash, ComputeShieldedSighash_DifferentInputs) { 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)); + 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)); + 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"; @@ -1548,10 +1449,10 @@ TEST(Zcash, ComputeShieldedSighash_DifferentBranchId) { 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)); + 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"; @@ -1565,8 +1466,8 @@ TEST(Zcash, ComputeShieldedSighash_KnownVector) { * For NU5 (branch_id = 0x37519621): * personalization = "ZcashTxHash_" || 0x21965137 * - * Input: BLAKE2b-256(personalization, header || transparent || sapling || orchard) - * where each digest is 32 bytes of zeros. + * 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}; @@ -1575,8 +1476,8 @@ TEST(Zcash, ComputeShieldedSighash_KnownVector) { uint32_t branch_id = 0x37519621; uint8_t sighash[32]; - ASSERT_TRUE(zcash_compute_shielded_sighash( - header, transparent, sapling, orchard, branch_id, sighash)); + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash)); /* * Independently verified: BLAKE2b-256 with personalization @@ -1623,8 +1524,7 @@ TEST(Zcash, RedPallasSign_ProducesVerifiableSignature) { /* Signature must be nonzero */ uint8_t zero[64] = {0}; - EXPECT_TRUE(memcmp(signature, zero, 64) != 0) - << "Signature must be nonzero"; + EXPECT_TRUE(memcmp(signature, zero, 64) != 0) << "Signature must be nonzero"; /* * Verify the signature against the randomized verification key rk. @@ -1724,10 +1624,9 @@ TEST(Zcash, SeedFingerprint_ReferenceVector) { 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, + 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]; @@ -1793,3 +1692,66 @@ TEST(Zcash, SeedFingerprint_DiffersForDifferentSeeds) { 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 57cbe6e3ebe4f8f5fe2b85144e03b7ebe2f3b9fa Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 18 Jul 2026 15:43:43 -0300 Subject: [PATCH 18/55] fix(zcash): smooth 45s progress ramp + fill-front glint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trickle eased half its span in 1.2s then crawled asymptotically, so during a 40-50s host proof window the bar looked frozen; the full-track sweep marker (33c2f379) compensated by gliding an 8px notch across the whole bar, which appears instantly at the track start and detaches from the fill, leaving a visible gap. Replace both: - Ramp linearly through 90% of the milestone span over an expected 45s window, then hold — the last 10% is only crossed by the next real milestone, so the bar never claims work that hasn't happened. - Replace the detached sweep with a glint on the fill's leading segment that breathes dim<->bright each 1.6s. Activity always shows exactly at the progress front: no gap, and it cannot run out of travel as the unfilled span shrinks near 100%. --- lib/board/layout.c | 72 +++++++++++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/lib/board/layout.c b/lib/board/layout.c index 9acf909ca..f354f4feb 100644 --- a/lib/board/layout.c +++ b/lib/board/layout.c @@ -728,9 +728,9 @@ static const char* _otpStr = ""; * queue, so an animation callback (trickle_progress_callback) can redraw itself * every frame without removing itself from the queue. * - * marker_phase: 0..999 sweeps a small dim block across the unfilled portion of - * the bar (a perpetual "working" cue that keeps the display visibly moving even - * after the eased fill has pixel-saturated); pass -1 for no marker. */ + * 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; @@ -802,21 +802,23 @@ static void progress_render_ex(const char* desc, int permil, int marker_phase) { draw_box(canvas, &bp); } - // Sweep marker: a small mid-gray glint cycling across the FULL inner track, - // independent of the fill level, so its travel never shrinks and the bar - // keeps visibly moving for arbitrarily slow host proofs. Mid-gray (0x99) - // reads as a darker notch over the 0xff fill and a brighter block over the - // 0x00 remainder, so it stays visible at any progress value. - if (marker_phase >= 0 && width > 2) { - const uint32_t marker_w = 8; - uint32_t span_w = width - 2; - uint32_t w = marker_w < span_w ? marker_w : span_w; - uint32_t travel = span_w - w; - bp.width = w; + // 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 + 1 + (travel * (uint32_t)marker_phase) / 1000; + bp.base.x = x + finished_width - glint_w; bp.base.y = y + 1; - bp.base.color = 0x99; + bp.base.color = (uint8_t)(0x44 + (tri * 0x66) / 500); draw_box(canvas, &bp); } @@ -880,9 +882,9 @@ void layout_add_animation(AnimateCallback callback, void* data, /* --- 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 eases a "trickle" that advances quickly at first then slows, - * asymptotically approaching the next real milestone without ever reaching it - * (so it never falsely shows 100%). It is driven off the animation timer, which + * 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; @@ -896,15 +898,25 @@ static void trickle_progress_callback(void* data, uint32_t duration, uint32_t elapsed) { (void)data; (void)duration; - /* add = span * elapsed / (elapsed + TAU): fast early, slowing as it nears the - * target, never actually reaching it. */ - const uint32_t TAU = 1200; /* ms; ~half the remaining gap closed by 1.2s */ + /* 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 add = span > 0 ? (int)(((uint64_t)span * elapsed) / (elapsed + TAU)) : 0; - /* Sweep marker 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 SWEEP_PERIOD = 1600; /* ms per sweep across the unfilled bar */ - int phase = (int)(((elapsed % SWEEP_PERIOD) * 1000) / SWEEP_PERIOD); + 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); @@ -921,9 +933,9 @@ void layoutProgressTrickle(const char* desc, int base_permil, trickle_active = true; layout_add_animation(&trickle_progress_callback, NULL, 0 /* loop forever */); force_animation_start(); - /* Draw the first frame now (at base, marker at phase 0) so the bar appears - * immediately, before the animation timer next fires. progress_render_ex - * keeps the animation queue intact. */ + /* 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); } From e3b261bd4e4a7b86b8e4dc98005851b03982c6c4 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 18 Jul 2026 15:49:55 -0300 Subject: [PATCH 19/55] style: clang-format --- lib/board/layout.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/board/layout.c b/lib/board/layout.c index f354f4feb..7c4d44d66 100644 --- a/lib/board/layout.c +++ b/lib/board/layout.c @@ -910,8 +910,9 @@ static void trickle_progress_callback(void* data, uint32_t duration, int cap = (span * 9) / 10; int add = 0; if (cap > 0) { - add = elapsed >= EXPECTED_MS ? cap - : (int)(((uint64_t)cap * elapsed) / EXPECTED_MS); + 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). */ From 74f13acaff7658b405e36849f0fc0c38e915a43b Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 18 Jul 2026 16:25:41 -0300 Subject: [PATCH 20/55] fix(zcash): draw the signing screen the instant a confirm is approved Two windows left a dead screen (the dismissed dialog) for 10-20s: - after approving an Orchard output, RedPallas signing runs before the first progress draw; - after the initial Shield/Shielded confirm, Orchard ZIP-32 key derivation runs before the first draw. Both are pure device-side Pallas math with no display update. Draw the 'Signing Zcash' bar immediately on approval, before the heavy work. The transparent-output path already drew immediately (no change). --- lib/firmware/fsm_msg_zcash.h | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/firmware/fsm_msg_zcash.h b/lib/firmware/fsm_msg_zcash.h index 0028bacca..cd40a8022 100644 --- a/lib/firmware/fsm_msg_zcash.h +++ b/lib/firmware/fsm_msg_zcash.h @@ -657,6 +657,10 @@ void fsm_msgZcashSignPCZT(const ZcashSignPCZT* msg) { } } + /* 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)) { @@ -1022,6 +1026,12 @@ void fsm_msgZcashPCZTAction(const ZcashPCZTAction* msg) { return; } + /* The user just approved — switch to the signing screen NOW. RedPallas + * signing below is many seconds of pure device-side math, and without + * this draw the dismissed dialog would sit on screen the whole time. */ + layoutProgress(_("Signing Zcash"), (zcash_signing.current_action * 1000) / + zcash_signing.n_actions); + /* 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); From eda7b951b10e4733ece407fd4ba4ffd6f44cd4e1 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 19 Jul 2026 17:10:17 -0300 Subject: [PATCH 21/55] feat(hive): clear-sign 11 more operations (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device clear-sign table held only vote/comment/custom_json, but the client and vault already serialize nine more. Those nine were rejected on-device with "unsupported operation type" — shipped host code with no firmware to sign it. Separately, dApp swaps on the Hive internal market failed at the extension with "Operation not in the KeepKey clear-sign table (got limit_order_create)". Adds to hive_parseOperations + fsm_msgHiveSignOperations: 3 transfer_to_vesting 19 comment_options 4 withdraw_vesting 32 transfer_to_savings 5 limit_order_create 33 transfer_from_savings 6 limit_order_cancel 39 claim_reward_balance 8 convert 40 delegate_vesting_shares 43 account_update2 Ops 2/9/10 remain permanently excluded. Anything outside the table is still refused outright — no blind-sign fallback. New parser primitives: cur_asset pins each asset's symbol to a per-op whitelist AND to that symbol's protocol-fixed precision. Both are load-bearing for display integrity: a swapped symbol hides a ~2000x value difference behind an identical-looking number, and a wrong precision moves the decimal point relative to what the chain applies. Negative int64 amounts are rejected — they would render as enormous positive values. cur_bool rejects any byte but 0/1 rather than coercing it. cur_u16/32 bounds-checked fixed-width readers. Enforced on-device, not merely host-side: - comment_options is accepted ONLY immediately after a comment op with the same author and permlink. Detached, it could redirect payout of a post published earlier that the user is not reviewing on screen. - account_update2 hard-rejects any authority field — the op-9/10 device-derived-keys invariant applied field-level. - beneficiaries must be strictly ascending, unique, and sum <= 100%. - zero amounts are refused where they are meaningless and allowed where they carry meaning (withdraw_vesting 0 stops a power-down, delegate_vesting_shares 0 removes a delegation). Display: every screen is capped at three body 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. claim_reward_balance therefore spans two screens rather than losing its VESTS line, and account/amount fields never share a row with a label where a 16-character name could push the destination off-screen. Flash is the binding constraint on this part (0xA0000 total, ~7.4KB free before this change), so rejection reasons are grouped by cause instead of one bespoke sentence per failure site. They are diagnostics, not security surface — the protection is that the device refuses, and the host knows what it sent. The three carrying distinct security meaning stay separate. Net cost 3,664 bytes; 3,784 bytes remain free. Tests: 23 Hive cases (was 4), including per-byte truncation sweeps, asset symbol/precision/sign rejection, the comment_options binding invariant, authority-change rejection, tier assignment, and mixed-tier refusal. 322/322 firmware-unit pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/keepkey/firmware/hive.h | 68 +++- lib/firmware/fsm_msg_hive.h | 172 +++++++++ lib/firmware/hive.c | 354 ++++++++++++++++-- unittests/firmware/hive.cpp | 630 ++++++++++++++++++++++++++++++++ 4 files changed, 1199 insertions(+), 25 deletions(-) diff --git a/include/keepkey/firmware/hive.h b/include/keepkey/firmware/hive.h index ede828ecd..fdc18c84d 100644 --- a/include/keepkey/firmware/hive.h +++ b/include/keepkey/firmware/hive.h @@ -44,9 +44,20 @@ bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, #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 @@ -62,6 +73,23 @@ bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, #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 + +// 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 ──────────────────────────────────────────────────────────── /** @@ -116,9 +144,29 @@ typedef struct { uint16_t permlink_len; const uint8_t* json_metadata; // comment only uint16_t json_metadata_len; - int16_t weight; // vote only (-10000..10000) + 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 + + // ── 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 { @@ -129,12 +177,26 @@ typedef struct { /** * Parse and validate a host-serialized Graphene transaction against the - * phase-1 op table (vote, comment, custom_json). Returns NULL on success or - * a static error message. Slices in `out` borrow from `tx` — keep it alive. + * 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 diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h index 02750b5a6..0a76eea96 100644 --- a/lib/firmware/fsm_msg_hive.h +++ b/lib/firmware/fsm_msg_hive.h @@ -659,6 +659,20 @@ static bool hive_confirm_slice(ButtonRequestType type, const char* title, 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; @@ -783,6 +797,164 @@ void fsm_msgHiveSignOperations(const HiveSignOperations* msg) { } 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; + } 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 } diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c index 867e56e3c..124f6ca8e 100644 --- a/lib/firmware/hive.c +++ b/lib/firmware/hive.c @@ -317,6 +317,118 @@ static bool cur_string(HiveCur* c, const uint8_t** s, uint16_t* slen, return true; } +/* 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* a) { + uint64_t v = 0; + for (int i = 7; i >= 0; i--) v = (v << 8) | a[i]; + return v; +} + +uint8_t hive_assetPrecision(const uint8_t* a) { return a[8]; } + +const char* hive_assetSymbol(const uint8_t* a) { return (const char*)(a + 9); } + +/* + * 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; + if (memcmp(sym, "HIVE", 4) == 0) { + bit = HIVE_SYM_HIVE; + want_precision = 3; + sym_len = 4; + } else if (memcmp(sym, "HBD", 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)); @@ -330,7 +442,7 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, HiveCur c = {tx + 10, tx + len}; uint32_t op_count; - if (!cur_varint(&c, &op_count)) return "Hive tx: malformed 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; @@ -340,7 +452,7 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, 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 "Hive tx: malformed op type"; + if (!cur_varint(&c, &op_type)) return E_MALFORMED; op->op_type = op_type; switch (op_type) { @@ -348,11 +460,11 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || !cur_string(&c, &op->target, &op->target_len, 1, 16) || !cur_string(&c, &op->detail, &op->detail_len, 1, 256)) - return "Hive vote: malformed fields"; - if ((size_t)(c.end - c.p) < 2) return "Hive vote: missing weight"; + 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 "Hive vote: weight out of range"; + if (w < -10000 || w > 10000) return E_RANGE; op->weight = w; any_posting = true; break; @@ -368,7 +480,7 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, !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 "Hive comment: malformed fields"; + return E_MALFORMED; op->parent_author = pa; op->parent_author_len = pa_len; op->parent_permlink = ppl; @@ -383,40 +495,34 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, } case HIVE_OP_CUSTOM_JSON: { // posting OR active authority uint32_t n_active, n_posting; - if (!cur_varint(&c, &n_active)) - return "Hive custom_json: malformed auths"; + if (!cur_varint(&c, &n_active)) return E_MALFORMED; for (uint32_t k = 0; k < n_active; k++) { const uint8_t* s; uint16_t sl; - if (!cur_string(&c, &s, &sl, 1, 16)) - return "Hive custom_json: malformed auths"; + if (!cur_string(&c, &s, &sl, 1, 16)) return E_MALFORMED; if (!op->acct) { op->acct = s; op->acct_len = sl; } } - if (!cur_varint(&c, &n_posting)) - return "Hive custom_json: malformed auths"; + if (!cur_varint(&c, &n_posting)) return E_MALFORMED; for (uint32_t k = 0; k < n_posting; k++) { const uint8_t* s; uint16_t sl; - if (!cur_string(&c, &s, &sl, 1, 16)) - return "Hive custom_json: malformed auths"; + if (!cur_string(&c, &s, &sl, 1, 16)) return E_MALFORMED; if (!op->acct) { op->acct = s; op->acct_len = sl; } } - if (n_active + n_posting == 0) - return "Hive custom_json: no auth accounts"; + 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 "Hive custom_json: mixed active+posting auths"; + 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 "Hive custom_json: malformed id/json"; + return E_MALFORMED; op->n_auths = (uint8_t)(n_active + n_posting); op->needs_active = (n_active > 0); if (op->needs_active) @@ -425,6 +531,210 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, 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_string(&c, &op->acct, &op->acct_len, 1, 16) || + !cur_string(&c, &op->target, &op->target_len, 0, 16) || + !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_string(&c, &op->acct, &op->acct_len, 1, 16) || + !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_string(&c, &op->acct, &op->acct_len, 1, 16) || + !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_string(&c, &op->acct, &op->acct_len, 1, 16) || + !cur_u32(&c, &op->req_id)) + return E_MALFORMED; + any_active = true; + break; + } + case HIVE_OP_CONVERT: { // active authority + if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || + !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_string(&c, &op->acct, &op->acct_len, 1, 16) || + !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_string(&c, &op->benef_acct[k], &op->benef_acct_len[k], 1, + 16) || + !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) { + uint16_t min_len = prev_acct_len < op->benef_acct_len[k] + ? prev_acct_len + : op->benef_acct_len[k]; + int cmp = memcmp(prev_acct, op->benef_acct[k], min_len); + if (cmp > 0 || + (cmp == 0 && prev_acct_len >= op->benef_acct_len[k])) + 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_string(&c, &op->acct, &op->acct_len, 1, 16) || + !cur_string(&c, &op->target, &op->target_len, 1, 16) || + !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_string(&c, &op->acct, &op->acct_len, 1, 16) || + !cur_u32(&c, &op->req_id) || + !cur_string(&c, &op->target, &op->target_len, 1, 16) || + !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_string(&c, &op->acct, &op->acct_len, 1, 16) || + !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_string(&c, &op->acct, &op->acct_len, 1, 16) || + !cur_string(&c, &op->target, &op->target_len, 1, 16) || + !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_string(&c, &op->acct, &op->acct_len, 1, 16)) + 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: @@ -440,12 +750,12 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, } uint32_t ext_count; - if (!cur_varint(&c, &ext_count)) return "Hive tx: malformed extensions"; - if (ext_count != 0) return "Hive tx: extensions must be empty"; + 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 "Hive tx: mixed posting/active ops"; + if (any_posting && any_active) return E_MIXED_TIER; out->needs_active = any_active; return NULL; } diff --git a/unittests/firmware/hive.cpp b/unittests/firmware/hive.cpp index b99899a2f..013e5da20 100644 --- a/unittests/firmware/hive.cpp +++ b/unittests/firmware/hive.cpp @@ -64,6 +64,133 @@ std::vector comment_tx(const std::string& parent_author, return tx; } +void append_asset(std::vector& out, int64_t amount, uint8_t precision, + const std::string& 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 < symbol.size() ? static_cast(symbol[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; +} + } // namespace TEST(Hive, Slip48PathValidation) { @@ -151,3 +278,506 @@ TEST(Hive, TopLevelCommentRetainsCategoryAndEmptyTitle) { EXPECT_EQ(0, op.target_len); EXPECT_EQ("{}", slice(op.json_metadata, op.json_metadata_len)); } + +// ── 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)); +} From 67b7609687526db38d9c24e1fac273af21042846 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 19 Jul 2026 17:36:48 -0300 Subject: [PATCH 22/55] chore: bump python-keepkey pin (phase-3 hive op device tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up feat/hive-clearsign-ops-phase3 in keepkey/python-keepkey: device tests for the 11 new clear-sign ops, plus two fixes to existing tests that this firmware change invalidated — three assertions matching rejection strings that are now grouped by cause, and an unknown-op case that used op type 3 (now transfer_to_vesting, so it no longer reached that path). 38/38 hive device tests pass against an emulator built from this branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 85866526c..38acf57b9 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 85866526c33a0af7217b6e085c20bf870803c735 +Subproject commit 38acf57b93601455f84261ceb32463bfc45d1deb From e78713a4bbf1cde188713b7257585fbe149f81f6 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 13:13:38 -0300 Subject: [PATCH 23/55] style(hive): match asset accessor param names to their declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cppcheck funcArgNamesDifferent x3 — the only red check on this PR. Zero-warning policy, so CI fails on style findings. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/firmware/hive.c | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c index 124f6ca8e..7b9b18dd9 100644 --- a/lib/firmware/hive.c +++ b/lib/firmware/hive.c @@ -346,15 +346,17 @@ static bool cur_bool(HiveCur* c, bool* out) { return true; } -uint64_t hive_assetAmount(const uint8_t* a) { +uint64_t hive_assetAmount(const uint8_t* asset) { uint64_t v = 0; - for (int i = 7; i >= 0; i--) v = (v << 8) | a[i]; + for (int i = 7; i >= 0; i--) v = (v << 8) | asset[i]; return v; } -uint8_t hive_assetPrecision(const uint8_t* a) { return a[8]; } +uint8_t hive_assetPrecision(const uint8_t* asset) { return asset[8]; } -const char* hive_assetSymbol(const uint8_t* a) { return (const char*)(a + 9); } +const char* hive_assetSymbol(const uint8_t* asset) { + return (const char*)(asset + 9); +} /* * One 16-byte Graphene asset: int64 LE amount, uint8 precision, 7-byte From c4029261f09ba1bc4775d9263714752e376bd537 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 13:43:29 -0300 Subject: [PATCH 24/55] =?UTF-8?q?fix(eip712):=20drop=20sscanf=20=E2=80=94?= =?UTF-8?q?=20it=20cost=206KB=20of=20ROM=20the=20zcash-privacy=20build=20n?= =?UTF-8?q?o=20longer=20has?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the phase-3 Hive op table pushed the zcash-privacy variant 2136 bytes past its ROM budget: ld: section `.data' will not fit in region `rom' ld: region `rom' overflowed by 2136 bytes The three sscanf calls here were the firmware's only callers of newlib's scanf engine, which links in ~6KB to parse two hex digits and a decimal chain id. Both jobs already have cheaper answers in this file: encAddress() two lines up parses hex with strncpy + strtol, and strtoul covers the chain id on device and emulator alike, so the EMULATOR ifdef goes with it. text drops 653884 -> 647884, leaving the zcash-privacy build ~3.9KB of headroom. full and bitcoin-only are unaffected in behaviour. An unparseable chain id now yields 0 instead of leaving chainInt uninitialised, which is the only behavioural change. --- lib/firmware/eip712.c | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/firmware/eip712.c b/lib/firmware/eip712.c index 1d0c2cda0..26ecbe3af 100644 --- a/lib/firmware/eip712.c +++ b/lib/firmware/eip712.c @@ -347,9 +347,13 @@ int dsConfirm(void) { } if (dsverifyingContract != NULL) { + // 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 (ctr = 2; ctr < 42; ctr += 2) { - sscanf((char*)&dsverifyingContract[ctr], "%2hhx", - &addrHexStr[(ctr - 2) / 2]); + strncpy(byteStrBuf, (char*)&dsverifyingContract[ctr], 2); + addrHexStr[(ctr - 2) / 2] = (uint8_t)strtol(byteStrBuf, NULL, 16); } strcat(verifyingContract, "Verifying Contract: "); strncat(verifyingContract, dsverifyingContract, @@ -358,11 +362,7 @@ int 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) { From 8febed13dbd3baed94b6266feabef6adbaab8704 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 13:44:19 -0300 Subject: [PATCH 25/55] style(hive): drop the unreachable break after the convert case --- lib/firmware/fsm_msg_hive.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h index 0a76eea96..9b8efdad2 100644 --- a/lib/firmware/fsm_msg_hive.h +++ b/lib/firmware/fsm_msg_hive.h @@ -853,7 +853,7 @@ void fsm_msgHiveSignOperations(const HiveSignOperations* msg) { "Convert", "Convert %s\nto HIVE for @%s\n(#%u)", amount, name, (unsigned)op->req_id); break; - } break; + } case HIVE_OP_COMMENT_OPTIONS: { char max_payout[40], percent[16]; hive_format_asset(op->assets[0], max_payout, sizeof(max_payout)); From f36f1511bd00327655279aa38e531f7736a638b0 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 15:21:59 -0300 Subject: [PATCH 26/55] =?UTF-8?q?fix(eip712):=20reduce=20ctr/assetToken=20?= =?UTF-8?q?scope=20=E2=80=94=20cppcheck=20flagged=20them=20post-sscanf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the sscanf calls let cppcheck see through to the real scope of these two: `ctr` is only used inside its own for-loop, and `assetToken` is only used and read inside a single `if` block below. CI's static-analysis job runs with a zero-warning policy, so this was blocking the branch. --- lib/firmware/eip712.c | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/firmware/eip712.c b/lib/firmware/eip712.c index 26ecbe3af..aefe88ef4 100644 --- a/lib/firmware/eip712.c +++ b/lib/firmware/eip712.c @@ -326,13 +326,11 @@ void marshallDsVals(const char* 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 = ""; @@ -351,7 +349,7 @@ int dsConfirm(void) { // 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 (ctr = 2; ctr < 42; ctr += 2) { + for (int ctr = 2; ctr < 42; ctr += 2) { strncpy(byteStrBuf, (char*)&dsverifyingContract[ctr], 2); addrHexStr[(ctr - 2) / 2] = (uint8_t)strtol(byteStrBuf, NULL, 16); } @@ -370,7 +368,8 @@ int dsConfirm(void) { // } } if (noChain == false && dsverifyingContract != NULL) { - assetToken = tokenByChainAddress(chainInt, (uint8_t*)addrHexStr); + const TokenType* assetToken = + tokenByChainAddress(chainInt, (uint8_t*)addrHexStr); (void)assetToken; fillerStr = ""; } From 5e634d5ab0d8859c1b07d7232ce15c59f4cc302d Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 15:22:25 -0300 Subject: [PATCH 27/55] =?UTF-8?q?fix(osmosis):=20stop=20rounding=20amounts?= =?UTF-8?q?=20on=20the=20confirm=20screen=20=E2=80=94=20drop=20the=20float?= =?UTF-8?q?=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Osmosis confirm screen (send, delegate, undelegate, LP add/remove, redelegate, swap, IBC transfer) rendered amounts with atof() + "%.6f". A float carries ~7 significant decimal digits, so any amount past that on the screen the user is asked to approve got silently rounded — e.g. 123456789123456 uosmo showed as "123456792.000000 OSMO" instead of "123456789.123456 OSMO". osmosis_formatAmount() does the same base-unit scaling in integer math via bn_format_uint64 (bignum.h), the formatter the Hive and Ethereum confirm screens already use, and is exact at any magnitude. It also drops the last callers of atof/pow/float-printf in this file, which pulled newlib's floating-point engine into a ROM budget the zcash-privacy build no longer has: text drops 647884 -> 639796 (zcash-privacy) and 645576 -> 633956 (full), an ~8KB win on both. Moved into osmosis.c/.h (mirroring hive.c) so it's independently unit tested rather than only reachable through fsm_msgOsmosisMsgAck. --- include/keepkey/firmware/osmosis.h | 16 ++++ lib/firmware/fsm_msg_osmosis.h | 130 +++++++++++------------------ lib/firmware/osmosis.c | 18 ++++ unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/osmosis.cpp | 47 +++++++++++ 5 files changed, 133 insertions(+), 79 deletions(-) create mode 100644 unittests/firmware/osmosis.cpp diff --git a/include/keepkey/firmware/osmosis.h b/include/keepkey/firmware/osmosis.h index 330c4b9a1..36033471a 100644 --- a/include/keepkey/firmware/osmosis.h +++ b/include/keepkey/firmware/osmosis.h @@ -66,6 +66,22 @@ bool osmosis_signTxUpdateMsgSwap(const uint64_t pool_id, const char* token_in_denom, const char* token_out_min_amount); +#define OSMOSIS_PRECISION 6 + +// 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. + */ +void 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/lib/firmware/fsm_msg_osmosis.h b/lib/firmware/fsm_msg_osmosis.h index c654f744d..5d3a6c8d5 100644 --- a/lib/firmware/fsm_msg_osmosis.h +++ b/lib/firmware/fsm_msg_osmosis.h @@ -1,5 +1,3 @@ -#include -#define OSMOSIS_PRECISION 6 #define OSMOSIS_LP_ASSET_PRECISION 18 void fsm_msgOsmosisGetAddress(const OsmosisGetAddress* msg) { @@ -140,15 +138,9 @@ 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[103]; - snprintf(amount_str, sizeof(amount_str) - 1, "%.6f %s", amount, denom); + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + osmosis_formatAmount(amount_str, sizeof(amount_str), msg->send.amount, + msg->send.denom); /** Confirm transaction parameters on screen */ if (!confirm_transaction_output( @@ -179,12 +171,9 @@ 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]; + osmosis_formatAmount(amount_str, sizeof(amount_str), msg->delegate.amount, + msg->delegate.denom); /** Confirm transaction parameters on-screen */ if (!confirm_osmosis_address("Confirm Delegator Address", @@ -203,8 +192,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", - "%.6f %s", amount, denom)) { + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", "%s", + amount_str)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -231,12 +220,9 @@ 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]; + osmosis_formatAmount(amount_str, sizeof(amount_str), msg->undelegate.amount, + msg->undelegate.denom); /** Confirm transaction parameters on-screen */ if (!confirm_osmosis_address("Confirm Delegator Address", @@ -255,8 +241,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", - "%.6f %s", amount, denom)) { + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", "%s", + amount_str)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -298,23 +284,19 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { 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]; + osmosis_formatAmount(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); - 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]; + osmosis_formatAmount(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); /** Confirm transaction parameters on-screen */ if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Liquidity", - "Deposit %.6f %s and...", amount_in_max_b, denom_in_max_b)) { + "Deposit %s and...", amount_in_max_b_str)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -322,7 +304,7 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Liquidity", - "... %.6f %s?", amount_in_max_a, denom_in_max_a)) { + "... %s?", amount_in_max_a_str)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -385,24 +367,19 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { 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]; + osmosis_formatAmount(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); - 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]; + osmosis_formatAmount(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); /** Confirm transaction parameters on-screen */ if (!confirm(ButtonRequestType_ButtonRequest_Other, "Remove Liquidity", - "Withdraw %.6f %s and...", amount_out_min_b, - denom_out_min_b)) { + "Withdraw %s and...", amount_out_min_b_str)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -410,7 +387,7 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Remove Liquidity", - "... %.6f %s ?", amount_out_min_a, denom_out_min_a)) { + "... %s ?", amount_out_min_a_str)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -458,11 +435,15 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - float amount = atof(msg->redelegate.amount) / pow(10, OSMOSIS_PRECISION); + // Redelegation is OSMO-denominated, so the amount is always scaled and + // labelled OSMO regardless of what denom the message carries. + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + osmosis_formatAmount(amount_str, sizeof(amount_str), msg->redelegate.amount, + "uosmo"); /** Confirm transaction parameters on-screen */ if (!confirm(ButtonRequestType_ButtonRequest_Other, "Redelegate", - "Redelegate %.6f OSMO?", amount)) { + "Redelegate %s?", amount_str)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -559,24 +540,18 @@ 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]; + osmosis_formatAmount(token_in_str, sizeof(token_in_str), + msg->swap.token_in_amount, msg->swap.token_in_denom); - 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]; + osmosis_formatAmount(token_out_min_str, sizeof(token_out_min_str), + msg->swap.token_out_min_amount, + msg->swap.token_out_denom); /** 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)) { + "Swap %s for at least %s?", token_in_str, token_out_min_str)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -617,16 +592,13 @@ 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]; + osmosis_formatAmount(amount_str, sizeof(amount_str), + msg->ibc_transfer.amount, msg->ibc_transfer.denom); /** Confirm transaction parameters on-screen */ if (!confirm(ButtonRequestType_ButtonRequest_Other, "IBC Transfer", - "Transfer %.6f %s?", amount, denom)) { + "Transfer %s?", amount_str)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); diff --git a/lib/firmware/osmosis.c b/lib/firmware/osmosis.c index 6d0657257..dc5515ee6 100644 --- a/lib/firmware/osmosis.c +++ b/lib/firmware/osmosis.c @@ -618,6 +618,24 @@ 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. + */ +void osmosis_formatAmount(char* out, size_t out_len, const char* value, + const char* denom) { + if (strcmp(denom, "uosmo") != 0) { + snprintf(out, out_len, "%s %s", value, denom); + return; + } + bn_format_uint64(strtoull(value, NULL, 10), NULL, " OSMO", OSMOSIS_PRECISION, + 0, true, out, out_len); +} + bool osmosis_signingIsInited(void) { return initialized; } bool osmosis_signingIsFinished(void) { return msgs_remaining == 0; } diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 1c4210208..f2c9f9aa1 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -17,6 +17,7 @@ if(NOT ${KK_BITCOIN_ONLY}) hive.cpp mayachain.cpp nano.cpp + osmosis.cpp ripple.cpp signed_metadata.cpp solana.cpp diff --git a/unittests/firmware/osmosis.cpp b/unittests/firmware/osmosis.cpp new file mode 100644 index 000000000..4061de1ce --- /dev/null +++ b/unittests/firmware/osmosis.cpp @@ -0,0 +1,47 @@ +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/firmware/osmosis.h" +} + +#include "gtest/gtest.h" + +#include + +static std::string fmt(const char *value, const char *denom) { + char out[OSMOSIS_AMOUNT_STR_LEN] = {0}; + 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"); +} From caa499330a02e97e203547a1357c411a4b022a14 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 21:58:29 -0300 Subject: [PATCH 28/55] fix(hive): serialize assets with the wire symbols the chain uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hived encodes HIVE as "STEEM" and HBD as "SBD". The 2020 rebrand renamed the tokens but not their on-chain serialization, and this parser required the display spellings — so a host that serialized what we asked for produced bytes hived re-serializes differently. Its signature check recovers a key from those different bytes, finds it in no authority, and reports "missing required active authority", which reads like a key problem and is not one. Every asset-bearing hive op was affected (transfer_to_vesting, convert, comment_options, transfer_to/from_savings, claim_reward_balance, limit_order_create); vote/comment/custom_json and the VESTS-only ops were not. cur_asset now accepts STEEM/SBD/VESTS and hive_assetSymbol maps back, so the OLED still reads HIVE/HBD — wire spelling and display spelling deliberately differ. The unit tests could not have caught this: they were byte-exact mirrors of this parser, so both sides were wrong together. Added two golden vectors generated by hived's own condenser_api.get_transaction_hex, which is the only source that can. Pins the matching python-keepkey test-serializer fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- deps/python-keepkey | 2 +- lib/firmware/hive.c | 21 +++++++-- unittests/firmware/hive.cpp | 94 ++++++++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 38acf57b9..e98228f8b 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 38acf57b93601455f84261ceb32463bfc45d1deb +Subproject commit e98228f8b27a5275ff9f69d1c29352d9dfaf7516 diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c index 7b9b18dd9..4c3c659c6 100644 --- a/lib/firmware/hive.c +++ b/lib/firmware/hive.c @@ -354,8 +354,14 @@ uint64_t hive_assetAmount(const uint8_t* asset) { 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) { - return (const char*)(asset + 9); + 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; } /* @@ -376,11 +382,18 @@ static bool cur_asset(HiveCur* c, const uint8_t** out, uint32_t allowed) { uint32_t bit; uint8_t want_precision; size_t sym_len; - if (memcmp(sym, "HIVE", 4) == 0) { + // 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 = 4; - } else if (memcmp(sym, "HBD", 3) == 0) { + sym_len = 5; + } else if (memcmp(sym, "SBD", 3) == 0) { bit = HIVE_SYM_HBD; want_precision = 3; sym_len = 3; diff --git a/unittests/firmware/hive.cpp b/unittests/firmware/hive.cpp index 013e5da20..5b9578d71 100644 --- a/unittests/firmware/hive.cpp +++ b/unittests/firmware/hive.cpp @@ -64,15 +64,26 @@ std::vector comment_tx(const std::string& parent_author, 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 < symbol.size() ? static_cast(symbol[i]) : 0); + out.push_back(i < wire.size() ? static_cast(wire[i]) : 0); } } @@ -781,3 +792,84 @@ TEST(Hive, TrailingBytesRejected) { 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 38238b5b07abfdd9554467d00063f7fee2293280 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 22:11:01 -0300 Subject: [PATCH 29/55] chore(deps): pin python-keepkey to reconcile/upstream-sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .gitmodules declares reconcile/upstream-sync as the pinned branch, but the pin had drifted onto feat/hive-clearsign-ops-phase3 — a commit that existed on no canonical branch. The hive test work is now merged there (#198), so the pin points back at the declared branch and also picks up the solana version-gate commit the feature branch was missing. Co-Authored-By: Claude Opus 4.8 (1M context) --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index e98228f8b..a37a96a2a 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit e98228f8b27a5275ff9f69d1c29352d9dfaf7516 +Subproject commit a37a96a2a4d13f54f8997793d493753f67c7e4ab From 4e5fc884f7b8fdb8e7f567dfa690822caebb57ef Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 23:34:01 -0300 Subject: [PATCH 30/55] test: repin python-keepkey for phase-2/3 Hive screenshot capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pyk a010285 adds SECTIONS entries G29-G38 so the ops from #315 are selected by screenshot_filter(). They have run green in the full suite since they landed (38 Hive cases, 0 skipped), but SECTIONS stopped at G28 and the filter only emits tests whose screenshot hint list is non-empty — so eleven newly clear-signed ops had behavioural proof and no OLED frame. For a clear-sign table that is the wrong half to be missing: a correct signature over bytes the user was shown something ELSE for is the failure the table exists to prevent. Screenshot hints go to the ops that render a confirm screen (both limit-order ops, the six active-tier value ops, posting-tier claim, the zero-amount stop-power-down / remove-delegation pair, both comment_options cases). The three pure-rejection tests keep empty hints deliberately — a refused op never draws a screen. TEST-ONLY: touches scripts/generate-test-report.py. It does not touch keepkeylib/eth/ethereum_tokens.py, the one pyk file the ARM build consumes, so the firmware binary from this commit must be byte-identical to 1aa44ef5's. --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index a37a96a2a..a010285b2 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit a37a96a2a4d13f54f8997793d493753f67c7e4ab +Subproject commit a010285b236bdfc0cc9dd838012f75007d671d20 From 3f4139d02b04d99cccb4f056679ecfe70809ce15 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 20 Jul 2026 23:55:54 -0300 Subject: [PATCH 31/55] =?UTF-8?q?test:=20repin=20python-keepkey=20?= =?UTF-8?q?=E2=80=94=20G36=20screenshot=20hint=20removed=20(rejection-only?= =?UTF-8?q?=20test)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index a010285b2..59f7c858b 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit a010285b236bdfc0cc9dd838012f75007d671d20 +Subproject commit 59f7c858b3aab4926067201f91638e6e52ff82f3 From 7d4eeb445e413c69bf9698e5e497e83eecad68ad Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 21 Jul 2026 00:46:26 -0300 Subject: [PATCH 32/55] =?UTF-8?q?test:=20repin=20python-keepkey=20?= =?UTF-8?q?=E2=80=94=20Osmosis=20confirm-screen=20device=20tests=20+=20scr?= =?UTF-8?q?eenshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pyk b257df2 adds test_msg_osmosis_signtx.py and SECTIONS group P. Osmosis had no device tests at all, while 7.15.0 changed how every Osmosis amount is rendered (atof + %.6f -> bn_format_uint64), so the screens most affected by that change had no on-device or visual coverage. --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 59f7c858b..b257df27d 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 59f7c858b3aab4926067201f91638e6e52ff82f3 +Subproject commit b257df27d0a6ad3dfa4fa101fafb0e74ae57eda1 From 1715df9c1bc4b17dfe57ea8e1d73499feb1be70b Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 21 Jul 2026 01:17:47 -0300 Subject: [PATCH 33/55] =?UTF-8?q?test:=20repin=20python-keepkey=20?= =?UTF-8?q?=E2=80=94=20Osmosis=20MsgSend=20client=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pyk 3a9e1b7. The first Osmosis device tests exposed that osmosis_sign_tx's MsgSend branch had never run: it whitelisted the Cosmos denom (uatom), dropped the denom field, and assigned an int to a string proto field. --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index b257df27d..3a9e1b717 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit b257df27d0a6ad3dfa4fa101fafb0e74ae57eda1 +Subproject commit 3a9e1b71797149b4e3cf71636456b406322e2126 From e422edc0987bad978bf0f523e5709c0031ecdd4c Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 21 Jul 2026 01:47:28 -0300 Subject: [PATCH 34/55] =?UTF-8?q?test:=20repin=20python-keepkey=20?= =?UTF-8?q?=E2=80=94=20osmo1=20fixture=20derived=20from=20the=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 3a9e1b717..0594d57db 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 3a9e1b71797149b4e3cf71636456b406322e2126 +Subproject commit 0594d57db271c7240f2afc3822d5826dfee50a65 From 81a3b87d7a4680fa16ff1a38b923e019756b7f2f Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 21 Jul 2026 01:58:32 -0300 Subject: [PATCH 35/55] =?UTF-8?q?test:=20repin=20python-keepkey=20?= =?UTF-8?q?=E2=80=94=20osmosis=5Fget=5Faddress=20returns=20a=20str?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 0594d57db..52e1fb59a 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 0594d57db271c7240f2afc3822d5826dfee50a65 +Subproject commit 52e1fb59a2cdc9532940e3459a7842d771b59fbd From d18571307a2f5bad3ec2949c7b27fd06e6914a8c Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 21 Jul 2026 02:58:58 -0300 Subject: [PATCH 36/55] =?UTF-8?q?test:=20repin=20python-keepkey=20?= =?UTF-8?q?=E2=80=94=20MsgSend=20denom=20fence=20+=20review=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 52e1fb59a..1a697419e 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 52e1fb59a2cdc9532940e3459a7842d771b59fbd +Subproject commit 1a697419e3c63e127db935382be7dc26d355f7b4 From f7eacc3862fd391bd9e9421474efeaba464d1334 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 21 Jul 2026 19:42:20 -0300 Subject: [PATCH 37/55] fix(security): harden signing and authenticator confirmations --- docs/security/7.15.0-rc17-hardening.md | 85 +++++++++++ include/keepkey/firmware/authenticator.h | 3 +- include/keepkey/firmware/hive.h | 6 + lib/firmware/authenticator.c | 55 ++++--- lib/firmware/ethereum_contracts/zxappliquid.c | 17 ++- lib/firmware/ethereum_contracts/zxliquidtx.c | 41 +++++- lib/firmware/fsm_msg_common.h | 11 +- lib/firmware/fsm_msg_hive.h | 22 ++- lib/firmware/hive.c | 127 ++++++++++++---- unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/authenticator.cpp | 70 +++++++++ unittests/firmware/ethereum.cpp | 83 ++++++++++- unittests/firmware/hive.cpp | 137 ++++++++++++++---- 13 files changed, 555 insertions(+), 103 deletions(-) create mode 100644 docs/security/7.15.0-rc17-hardening.md create mode 100644 unittests/firmware/authenticator.cpp 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..44fc46959 --- /dev/null +++ b/docs/security/7.15.0-rc17-hardening.md @@ -0,0 +1,85 @@ +# Firmware 7.15.0 RC17 security hardening + +Date: 2026-07-21 +Integration baseline: `develop` at +`d18571307a2f5bad3ec2949c7b27fd06e6914a8c` (RC16 firmware code plus +test-client submodule updates) + +This candidate applies three focused hardening passes to security-sensitive +parsing, confirmation, and clear-signing paths. It does not claim to be a new +whole-firmware audit. The `rc17` suffix is a release-candidate branch/tag; +the firmware protocol version remains `7.15.0`. + +## 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 instead of showing only a + truncated authorization summary. + +### 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. + +### 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. +- Display the deadline as an exact Unix integer and wipe the derived HD node + after the recipient-address comparison. + +## 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`) | 277/277 | Pass | 23,372 B | 7,664 B | 15,708 B | +| Bitcoin-only | 28/28 | Pass | 32,908 B | 7,664 B | 25,244 B | +| Zcash privacy | 336/336 | Pass | 19,072 B | 7,664 B | 11,408 B | + +Additional gates: + +- 35 focused Hive, authenticator, and Ethereum regression tests pass. +- 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. + +## Final hardware QA checklist + +Use a disposable test seed and verify on a physical KeepKey: + +1. Cancel authenticator add, remove, and wipe at every confirmation screen; + reconnect and confirm that storage did not change. +2. Add a valid TOTP account, compare generated codes with an independent + authenticator, remove it, then wipe the authenticator store. +3. Sign representative Hive transfer, comment, and multi-authorization + `custom_json` operations. Confirm every authorization, ID byte, and JSON + byte is reviewable; malformed account names and overlong varints must fail. +4. Exercise supported Uniswap V2 add/remove liquidity fixtures. Confirm token + amount, minimums, native value, recipient, and deadline; cancel each screen + in a separate run and confirm that no signature is returned. +5. Run upgrade, reboot, PIN/passphrase, transaction-signing, and recovery smoke + tests for full, Bitcoin-only, and Zcash-privacy firmware artifacts. + +RC17 should be promoted only after the pull request CI matrix and this physical +device checklist are both green. diff --git a/include/keepkey/firmware/authenticator.h b/include/keepkey/firmware/authenticator.h index bb9981f68..e4cc19b94 100644 --- a/include/keepkey/firmware/authenticator.h +++ b/include/keepkey/firmware/authenticator.h @@ -41,6 +41,7 @@ enum AUTH_ERR_TYPE { LARGESEED, BADPASS, UNKERR, + AUTH_CANCELLED, NUM_AUTHERRS }; @@ -68,7 +69,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/hive.h b/include/keepkey/firmware/hive.h index fdc18c84d..ad98b4731 100644 --- a/include/keepkey/firmware/hive.h +++ b/include/keepkey/firmware/hive.h @@ -83,6 +83,10 @@ bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, // 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 @@ -149,6 +153,8 @@ typedef struct { // 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: diff --git a/lib/firmware/authenticator.c b/lib/firmware/authenticator.c index 851a48ad2..77202691e 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,30 @@ 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 + char authSecret[AUTHSECRET_SIZE_MAX] = { + 0}; // 128-bit key len is the recommended minimum, this is room for + // 160-bit size_t authSecretLen; // 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 +118,7 @@ unsigned addAuthAccount(char* accountWithSeed) { if (NULL == account) { return TOKERR; } - if (0 == strlen(account)) { + if (!authDisplayFieldValid(account, ACCOUNT_SIZE - 1)) { return TOKERR; } @@ -144,12 +155,21 @@ unsigned addAuthAccount(char* accountWithSeed) { return BADSECRET; // bad decode } - 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)) { + memzero(authSecret, sizeof(authSecret)); + return AUTH_CANCELLED; + } authData[slot].secretSize = authSecretLen; memcpy(authData[slot].authSecret, authSecret, authData[slot].secretSize); + memzero(authSecret, sizeof(authSecret)); strlcpy(authData[slot].domain, domain, DOMAIN_SIZE); strlcpy(authData[slot].account, account, ACCOUNT_SIZE); @@ -301,14 +321,14 @@ unsigned removeAuthAccount(char* domAcc) { // 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; } @@ -328,9 +348,10 @@ unsigned removeAuthAccount(char* domAcc) { 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)); setAuthData(); diff --git a/lib/firmware/ethereum_contracts/zxappliquid.c b/lib/firmware/ethereum_contracts/zxappliquid.c index b4e752419..ee4eadad9 100644 --- a/lib/firmware/ethereum_contracts/zxappliquid.c +++ b/lib/firmware/ethereum_contracts/zxappliquid.c @@ -37,7 +37,10 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, const EthereumSignTx *msg) { /* reads selector + 2 32-byte words (spender, allowance) */ - if (data_total < 4 + 2 * 32) return false; + if (data_total != 4 + 2 * 32 || !msg->has_data_initial_chunk || + msg->data_initial_chunk.size != 4 + 2 * 32 || !msg->has_to || + msg->to.size != 20) + return false; const char *to, *tikstr, *poolstr, *allowance, *amt; unsigned char data[40]; uint8_t digest[SHA3_256_DIGEST_LENGTH] = {0}; @@ -99,14 +102,16 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, } 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; + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, + "Amount: %s", amt) && + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, + "approve for pool %s %s", tikstr, poolstr); } bool zx_isZxApproveLiquid(const EthereumSignTx *msg) { + if (!msg->has_to || msg->to.size != 20 || !msg->has_data_initial_chunk || + msg->data_initial_chunk.size != 4 + 2 * 32) + return false; 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) diff --git a/lib/firmware/ethereum_contracts/zxliquidtx.c b/lib/firmware/ethereum_contracts/zxliquidtx.c index 8f2123830..4dfa4f1da 100644 --- a/lib/firmware/ethereum_contracts/zxliquidtx.c +++ b/lib/firmware/ethereum_contracts/zxliquidtx.c @@ -64,6 +64,7 @@ static HDNode* zx_getDerivedNode(const char* curve, const uint32_t* address_n, } static bool isAddLiquidityEthCall(const EthereumSignTx* msg) { + if (msg->data_initial_chunk.size < 4) return false; if (memcmp(msg->data_initial_chunk.bytes, "\xf3\x05\xd7\x19", 4) == 0) return true; @@ -71,6 +72,7 @@ static bool isAddLiquidityEthCall(const EthereumSignTx* msg) { } static bool isRemoveLiquidityEthCall(const EthereumSignTx* msg) { + if (msg->data_initial_chunk.size < 4) return false; if (memcmp(msg->data_initial_chunk.bytes, "\x02\x75\x1c\xec", 4) == 0) return true; @@ -93,6 +95,7 @@ static bool confirmFromAccountMatch(const EthereumSignTx* msg, memzero(node, sizeof(*node)); return false; } + memzero(node, sizeof(*node)); fromAddress = (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 5 * 32 - 20); @@ -120,6 +123,9 @@ static bool confirmFromAccountMatch(const EthereumSignTx* msg, } bool zx_isZxLiquidTx(const EthereumSignTx* msg) { + if (!msg->has_to || msg->to.size != 20 || !msg->has_data_initial_chunk || + msg->data_initial_chunk.size != 4 + 6 * 32) + return false; if (memcmp(msg->to.bytes, UNISWAP_ROUTER_ADDRESS, 20) == 0) { // correct contract address? @@ -132,7 +138,10 @@ bool zx_isZxLiquidTx(const EthereumSignTx* msg) { bool zx_confirmZxLiquidTx(uint32_t data_total, const EthereumSignTx* msg) { /* reads through the deadline word at offset 4 + 6*32 - 8 .. 4 + 6*32 */ - if (data_total < 4 + 6 * 32) return false; + if (data_total != 4 + 6 * 32 || !msg->has_data_initial_chunk || + msg->data_initial_chunk.size != 4 + 6 * 32 || !msg->has_to || + msg->to.size != 20 || msg->value.size > 32) + return false; const TokenType* token; char constr1[40], constr2[40], tokbuf[32]; const char* arStr = ""; @@ -150,6 +159,7 @@ bool zx_confirmZxLiquidTx(uint32_t data_total, const EthereumSignTx* msg) { tokenAddress = (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 32 - 20); token = tokenByChainAddress(msg->chain_id, tokenAddress); + if (token == UnknownToken) return false; deadlineBytes = (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 6 * 32 - 8); deadline = ((uint64_t)deadlineBytes[0] << 8 * 7) | @@ -169,22 +179,39 @@ bool zx_confirmZxLiquidTx(uint32_t data_total, const EthereumSignTx* msg) { &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 (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, + "%s\nMinimum %s", constr1, constr2)) + return false; if (!confirmFromAccountMatch(msg, arStr)) { return false; } + if (isAddLiquidityEthCall(msg)) { + bn_from_bytes(msg->value.bytes, msg->value.size, &Amount); + ethereumFormatAmount(&Amount, NULL, msg->chain_id, tokbuf, sizeof(tokbuf)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, + "Native amount\n%s", tokbuf)) + return false; + } else { + for (size_t i = 0; i < msg->value.size; i++) { + if (msg->value.bytes[i] != 0) 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)); snprintf(constr1, 32, "%s", tokbuf); - confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, "Minimum %s", - constr1); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, + "Minimum %s", constr1)) + return false; - confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, "Deadline %s", - ctime((const time_t*)&deadline)); + char deadline_str[24]; + snprintf(deadline_str, sizeof(deadline_str), "%" PRIu64, deadline); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, + "Deadline (Unix)\n%s", deadline_str)) + return false; return true; } diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 63c9d0044..f8c2a955e 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -214,13 +214,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) { @@ -246,6 +247,7 @@ void fsm_msgPing(Ping* msg) { "Authenticator secret seed too large", "passphrase incorrect for authdata", "Auth secret unknown error", + "Authenticator action cancelled", }; typedef enum _AUTH_MSG_TYPE { @@ -286,7 +288,7 @@ void fsm_msgPing(Ping* msg) { 0}; // allow room for domain + ":" + account CHECK_PIN - checkPassphrase(); + if (!checkPassphrase()) return; switch (authMsg) { case INITAUTH: @@ -325,8 +327,7 @@ void fsm_msgPing(Ping* msg) { break; case WIPEADATA: - wipeAuthData(); - errcode = NOERR; + errcode = wipeAuthData(); resp->has_message = false; break; diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h index 9b8efdad2..5d0bcd14f 100644 --- a/lib/firmware/fsm_msg_hive.h +++ b/lib/firmware/fsm_msg_hive.h @@ -781,15 +781,21 @@ void fsm_msgHiveSignOperations(const HiveSignOperations* msg) { break; } case HIVE_OP_CUSTOM_JSON: { - char target[33]; - hive_copy_slice(target, sizeof(target), op->target, op->target_len); - char extra[12] = ""; - if (op->n_auths > 1) { - snprintf(extra, sizeof(extra), " +%u", (unsigned)(op->n_auths - 1)); + 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); } - approved = - confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Custom JSON", "id: %s\nby @%s%s", target, name, extra); if (approved) { approved = hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c index 4c3c659c6..304f673ae 100644 --- a/lib/firmware/hive.c +++ b/lib/firmware/hive.c @@ -297,6 +297,11 @@ static bool cur_varint(HiveCur* c, uint32_t* out) { 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; } @@ -317,6 +322,53 @@ static bool cur_string(HiveCur* c, const uint8_t** s, uint16_t* slen, 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; @@ -472,8 +524,8 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, switch (op_type) { case HIVE_OP_VOTE: { // posting authority - if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || - !cur_string(&c, &op->target, &op->target_len, 1, 16) || + 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; @@ -487,9 +539,9 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, 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_string(&c, &pa, &pa_len, 0, 16) || + if (!cur_account(&c, &pa, &pa_len, true) || !cur_string(&c, &ppl, &ppl_len, 1, 256) || - !cur_string(&c, &op->acct, &op->acct_len, 1, 16) || + !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, @@ -511,20 +563,40 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, 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_string(&c, &s, &sl, 1, 16)) return E_MALFORMED; + 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_string(&c, &s, &sl, 1, 16)) return E_MALFORMED; + 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; @@ -538,7 +610,6 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, !cur_string(&c, &op->detail, &op->detail_len, 1, HIVE_MAX_OPS_TX_LEN)) return E_MALFORMED; - op->n_auths = (uint8_t)(n_active + n_posting); op->needs_active = (n_active > 0); if (op->needs_active) any_active = true; @@ -548,8 +619,8 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, } case HIVE_OP_TRANSFER_TO_VESTING: { // active authority // `to` may be empty — hived reads that as "power up to self". - if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || - !cur_string(&c, &op->target, &op->target_len, 0, 16) || + 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; @@ -560,7 +631,7 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, 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_string(&c, &op->acct, &op->acct_len, 1, 16) || + 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; @@ -568,7 +639,7 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, break; } case HIVE_OP_LIMIT_ORDER_CREATE: { // active authority - if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || + 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) || @@ -587,14 +658,14 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, break; } case HIVE_OP_LIMIT_ORDER_CANCEL: { // active authority - if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || + 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_string(&c, &op->acct, &op->acct_len, 1, 16) || + 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; @@ -605,7 +676,7 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, } case HIVE_OP_COMMENT_OPTIONS: { // posting authority uint16_t percent_hbd; - if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || + 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) || @@ -645,8 +716,8 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, const uint8_t* prev_acct = NULL; uint16_t prev_acct_len = 0; for (uint32_t k = 0; k < n_benef; k++) { - if (!cur_string(&c, &op->benef_acct[k], &op->benef_acct_len[k], 1, - 16) || + 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; @@ -654,12 +725,8 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, // enforces uniqueness. An unsorted list is rejected on-chain, so // signing it would only waste a device confirmation. if (prev_acct) { - uint16_t min_len = prev_acct_len < op->benef_acct_len[k] - ? prev_acct_len - : op->benef_acct_len[k]; - int cmp = memcmp(prev_acct, op->benef_acct[k], min_len); - if (cmp > 0 || - (cmp == 0 && prev_acct_len >= op->benef_acct_len[k])) + 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]; @@ -673,8 +740,8 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, break; } case HIVE_OP_TRANSFER_TO_SAVINGS: { // active authority - if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || - !cur_string(&c, &op->target, &op->target_len, 1, 16) || + 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; @@ -684,9 +751,9 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, break; } case HIVE_OP_TRANSFER_FROM_SAVINGS: { // active authority - if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || + if (!cur_account(&c, &op->acct, &op->acct_len, false) || !cur_u32(&c, &op->req_id) || - !cur_string(&c, &op->target, &op->target_len, 1, 16) || + !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; @@ -696,7 +763,7 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, break; } case HIVE_OP_CLAIM_REWARD_BALANCE: { // posting authority - if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || + 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)) @@ -711,8 +778,8 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, } case HIVE_OP_DELEGATE_VESTING_SHARES: { // active authority // 0.000000 VESTS is meaningful: it removes an existing delegation. - if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16) || - !cur_string(&c, &op->target, &op->target_len, 1, 16) || + 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; @@ -721,7 +788,7 @@ const char* hive_parseOperations(const uint8_t* tx, size_t len, } case HIVE_OP_ACCOUNT_UPDATE2: { // active or posting authority uint32_t ext_n; - if (!cur_string(&c, &op->acct, &op->acct_len, 1, 16)) + 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 diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index f2c9f9aa1..c9cf3e989 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -10,6 +10,7 @@ set(sources # coin+token table. if(NOT ${KK_BITCOIN_ONLY}) list(APPEND sources + authenticator.cpp coins.cpp cosmos.cpp eos.cpp diff --git a/unittests/firmware/authenticator.cpp b/unittests/firmware/authenticator.cpp new file mode 100644 index 000000000..6ebd71e8d --- /dev/null +++ b/unittests/firmware/authenticator.cpp @@ -0,0 +1,70 @@ +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); + +TEST(Authenticator, WipeCancellationFailsClosed) { + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_EQ(AUTH_CANCELLED, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Authenticator, AddAndRemoveCancellationFailsClosed) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + setup(); + storage_init(); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); + + char cancelled_add[] = "example:alice:JBSWY3DPEHPK3PXP"; + 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:JBSWY3DPEHPK3PXP"; + 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)); +} diff --git a/unittests/firmware/ethereum.cpp b/unittests/firmware/ethereum.cpp index f8329a416..00de2b9ce 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,76 @@ TEST(Ethereum, AddressChecksum) { test_checksum("dbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB"); test_checksum("D1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"); } + +static EthereumSignTx liquidity_tx(bool known_token) { + 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, "\xf3\x05\xd7\x19", 4); + + const TokenType* token = nullptr; + EXPECT_TRUE(tokenByTicker(1, "DAI", &token)); + if (token == nullptr) 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; + msg.value.size = 1; + msg.value.bytes[0] = 1; + 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)); +} diff --git a/unittests/firmware/hive.cpp b/unittests/firmware/hive.cpp index 5b9578d71..127a8844a 100644 --- a/unittests/firmware/hive.cpp +++ b/unittests/firmware/hive.cpp @@ -102,13 +102,10 @@ std::vector wrap_ops(const std::vector>& ops) { 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 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); @@ -202,6 +199,21 @@ std::vector account_update2_op(const std::string& json_metadata, 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) { @@ -254,11 +266,12 @@ TEST(Hive, CommentParserRetainsEveryDisplayedField) { // 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))); + 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)); + 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'}; @@ -290,14 +303,82 @@ TEST(Hive, TopLevelCommentRetainsCategoryAndEmptyTitle) { 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)); +} + // ── 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)}); + 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)); @@ -337,8 +418,7 @@ TEST(Hive, LimitOrderRejectsDegenerateOrders) { 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()}); + std::vector vests = wrap_ops({limit_order_vests_op()}); EXPECT_NE(nullptr, hive_parseOperations(vests.data(), vests.size(), &parsed)); } @@ -470,9 +550,9 @@ TEST(Hive, CommentOptionsMustBindToItsComment) { 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", {})}); + 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)); @@ -482,9 +562,9 @@ TEST(Hive, CommentOptionsMustBindToItsComment) { 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", - {})}); + 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 @@ -546,15 +626,15 @@ TEST(Hive, AccountUpdate2RejectsAuthorityChanges) { // 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)); + 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)); + ASSERT_EQ(nullptr, + hive_parseOperations(posting.data(), posting.size(), &parsed)); EXPECT_FALSE(parsed.needs_active); } @@ -812,7 +892,8 @@ TEST(Hive, TrailingBytesRejected) { 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))); + out.push_back( + static_cast(std::stoul(hex.substr(i, 2), nullptr, 16))); } return out; } @@ -835,8 +916,8 @@ TEST(Hive, SerializationMatchesHivedLimitOrderCreate) { 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); + 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 From e215ecac9fa2f9fe7f485b9110e401bf8fddbfa9 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 21 Jul 2026 21:18:05 -0300 Subject: [PATCH 38/55] fix(security): remediate RC17 audit blockers --- docs/security/7.15.0-rc17-hardening.md | 58 +++- include/keepkey/board/font.h | 5 + include/keepkey/firmware/authenticator.h | 2 + .../firmware/ethereum_contracts/zxliquidtx.h | 3 + lib/board/font.c | 44 ++- lib/firmware/authenticator.c | 56 ++-- lib/firmware/ethereum_contracts/zxappliquid.c | 202 +++++++------ lib/firmware/ethereum_contracts/zxliquidtx.c | 280 +++++++++--------- lib/firmware/fsm_msg_common.h | 1 + lib/firmware/fsm_msg_hive.h | 51 +++- unittests/firmware/authenticator.cpp | 49 ++- unittests/firmware/ethereum.cpp | 122 +++++++- unittests/firmware/hive.cpp | 27 ++ 13 files changed, 606 insertions(+), 294 deletions(-) diff --git a/docs/security/7.15.0-rc17-hardening.md b/docs/security/7.15.0-rc17-hardening.md index 44fc46959..d5b6919d4 100644 --- a/docs/security/7.15.0-rc17-hardening.md +++ b/docs/security/7.15.0-rc17-hardening.md @@ -10,6 +10,28 @@ parsing, confirmation, and clear-signing paths. It does not claim to be a new whole-firmware audit. The `rc17` suffix is a release-candidate branch/tag; the firmware protocol version remains `7.15.0`. +## 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 | + ## Remediated findings ### Hive raw-operation review @@ -21,8 +43,9 @@ the firmware protocol version remains `7.15.0`. - 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 instead of showing only a - truncated authorization summary. +- 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 @@ -32,6 +55,9 @@ the firmware protocol version remains `7.15.0`. 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 @@ -42,8 +68,13 @@ the firmware protocol version remains `7.15.0`. - 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. -- Display the deadline as an exact Unix integer and wipe the derived HD node - after the recipient-address comparison. +- 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. ## Automated evidence @@ -51,13 +82,14 @@ 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`) | 277/277 | Pass | 23,372 B | 7,664 B | 15,708 B | +| Full, shipped (`KK_ZCASH_PRIVACY=OFF`) | 285/285 | Pass | 23,372 B | 7,664 B | 15,708 B | | Bitcoin-only | 28/28 | Pass | 32,908 B | 7,664 B | 25,244 B | -| Zcash privacy | 336/336 | Pass | 19,072 B | 7,664 B | 11,408 B | +| Zcash privacy | 344/344 | Pass | 19,072 B | 7,664 B | 11,408 B | Additional gates: -- 35 focused Hive, authenticator, and Ethereum regression tests pass. +- 43 focused Hive, authenticator, and Ethereum regression tests pass, + including 14 directly covering the follow-up remediation cluster. - Cppcheck completes with zero findings. - Changed production files pass the pinned Clang Format 20 gate. - `git diff --check` passes. @@ -71,13 +103,17 @@ Use a disposable test seed and verify on a physical KeepKey: 1. Cancel authenticator add, remove, and wipe at every confirmation screen; reconnect and confirm that storage did not change. 2. Add a valid TOTP account, compare generated codes with an independent - authenticator, remove it, then wipe the authenticator store. + authenticator, verify duplicate and sub-128-bit secrets are rejected, remove + it, then wipe the authenticator store. 3. Sign representative Hive transfer, comment, and multi-authorization `custom_json` operations. Confirm every authorization, ID byte, and JSON - byte is reviewable; malformed account names and overlong varints must fail. + byte is reviewable, including the adversarial `%`/space payload across page + boundaries; malformed account names and overlong varints must fail. 4. Exercise supported Uniswap V2 add/remove liquidity fixtures. Confirm token - amount, minimums, native value, recipient, and deadline; cancel each screen - in a separate run and confirm that no signature is returned. + amount, LP burn amount, minimums, native value, recipient, and deadline. + Confirm Polygon/router reuse, dirty ABI address words, high deadline bits, + and arbitrary approval targets never receive a Uniswap protocol label. + Cancel each screen in a separate run and confirm no signature is returned. 5. Run upgrade, reboot, PIN/passphrase, transaction-signing, and recovery smoke tests for full, Bitcoin-only, and Zcash-privacy firmware artifacts. 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/firmware/authenticator.h b/include/keepkey/firmware/authenticator.h index e4cc19b94..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,7 @@ enum AUTH_ERR_TYPE { LARGESEED, BADPASS, UNKERR, + DUPLICATE, AUTH_CANCELLED, NUM_AUTHERRS }; 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/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/firmware/authenticator.c b/lib/firmware/authenticator.c index 77202691e..e07b0007c 100644 --- a/lib/firmware/authenticator.c +++ b/lib/firmware/authenticator.c @@ -102,11 +102,12 @@ unsigned wipeAuthData(void) { unsigned addAuthAccount(char* accountWithSeed) { char *domain, *account, *seedStr; - unsigned slot; + 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 @@ -131,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; } @@ -139,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 @@ -152,7 +159,8 @@ 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; } // Keep the secret on its own screen. A 32-character base32 secret appended @@ -163,19 +171,21 @@ unsigned addAuthAccount(char* accountWithSeed) { account) || !confirm(ButtonRequestType_ButtonRequest_Other, "TOTP Secret", "%s", seedStr)) { - memzero(authSecret, sizeof(authSecret)); - return AUTH_CANCELLED; + result = AUTH_CANCELLED; + goto cleanup; } authData[slot].secretSize = authSecretLen; memcpy(authData[slot].authSecret, authSecret, authData[slot].secretSize); - memzero(authSecret, sizeof(authSecret)); strlcpy(authData[slot].domain, domain, DOMAIN_SIZE); 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[]) { @@ -317,7 +327,7 @@ 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 @@ -336,15 +346,16 @@ 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 } @@ -353,7 +364,12 @@ unsigned removeAuthAccount(char* domAcc) { 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/ethereum_contracts/zxappliquid.c b/lib/firmware/ethereum_contracts/zxappliquid.c index ee4eadad9..841fd5fe2 100644 --- a/lib/firmware/ethereum_contracts/zxappliquid.c +++ b/lib/firmware/ethereum_contracts/zxappliquid.c @@ -7,114 +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) { - /* reads selector + 2 32-byte words (spender, allowance) */ - if (data_total != 4 + 2 * 32 || !msg->has_data_initial_chunk || - msg->data_initial_chunk.size != 4 + 2 * 32 || !msg->has_to || - msg->to.size != 20) - return false; - 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; +} - 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]; - } else { - for (ctr = 0; ctr < 20; ctr++) { - snprintf(&digestStr[ctr * 2], 3, "%02x", to[ctr]); - } - tikstr = ""; - poolstr = digestStr; +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; +} - allowance = (char *)(msg->data_initial_chunk.bytes + 4 + 32); - if (memcmp(allowance, (uint8_t *)&MAX_ALLOWANCE, 32) == 0) { - amt = "full balance"; +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 < 32; ctr++) { - snprintf(&amtStr[ctr * 2], 3, "%02x", allowance[ctr]); - } - amt = amtStr; + memcpy(ordered, token_b, 20); + memcpy(ordered + 20, token_a, 20); + } + + 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; +} - const char *appStr = "uniswap approve liquidity"; - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, - "Amount: %s", amt) && - confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, - "approve for pool %s %s", tikstr, poolstr); +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_isZxApproveLiquid(const EthereumSignTx *msg) { - if (!msg->has_to || msg->to.size != 20 || !msg->has_data_initial_chunk || - msg->data_initial_chunk.size != 4 + 2 * 32) +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; - 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; + + 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 { + 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; + } + + 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) { + return approve_shape_is_clear_signable(msg); } diff --git a/lib/firmware/ethereum_contracts/zxliquidtx.c b/lib/firmware/ethereum_contracts/zxliquidtx.c index 4dfa4f1da..568080722 100644 --- a/lib/firmware/ethereum_contracts/zxliquidtx.c +++ b/lib/firmware/ethereum_contracts/zxliquidtx.c @@ -20,198 +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 (msg->data_initial_chunk.size < 4) return false; - 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 (msg->data_initial_chunk.size < 4) return false; - 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); } - memzero(node, sizeof(*node)); + 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; +} - bool isSelf = (memcmp(fromAddress, addressBytes, 20) == 0); - fromSrc = isSelf ? "this wallet" : "NOT this wallet"; +static bool confirmFromAccountMatch(const EthereumSignTx* msg) { + char address_str[43] = {'0', 'x', '\0'}; + uint8_t address_bytes[20]; - for (uint32_t ctr = 0; ctr < 20; ctr++) { - snprintf(&addressStr[2 + ctr * 2], 3, "%02x", fromAddress[ctr]); + 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)); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, addremStr, - "Liquidity recipient is %s: %s", fromSrc, addressStr)) { - return false; + 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]); } - /* Fail closed: the liquidity/LP recipient (the `to` parameter) must be the - * signer's own address. Previously a non-self recipient only produced a soft - * "NOT this wallet" warning that the user could click through, letting LP - * tokens / withdrawn ETH be routed to an attacker. */ - if (!isSelf) { + 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 (!msg->has_to || msg->to.size != 20 || !msg->has_data_initial_chunk || - msg->data_initial_chunk.size != 4 + 6 * 32) - return false; - 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) { - /* reads through the deadline word at offset 4 + 6*32 - 8 .. 4 + 6*32 */ - if (data_total != 4 + 6 * 32 || !msg->has_data_initial_chunk || - msg->data_initial_chunk.size != 4 + 6 * 32 || !msg->has_to || - msg->to.size != 20 || msg->value.size > 32) + if (data_total != UNISWAP_LIQUIDITY_CALL_SIZE || + !liquidity_shape_is_clear_signable(msg)) return false; - const TokenType* token; - char constr1[40], constr2[40], tokbuf[32]; - const char* arStr = ""; - const uint8_t *tokenAddress, *deadlineBytes; - bignum256 Amount; - uint64_t deadline; - if (isAddLiquidityEthCall(msg)) { - arStr = "uniswap add liquidity"; - } else if (isRemoveLiquidityEthCall(msg)) { - arStr = "uniswap remove liquidity"; - } else { - return false; - } + const TokenType* token = liquidity_token(msg); + bignum256 amount; + char amount_text[UNISWAP_AMOUNT_TEXT_SIZE]; - tokenAddress = (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 32 - 20); - token = tokenByChainAddress(msg->chain_id, tokenAddress); - if (token == UnknownToken) return false; - 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); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, - "%s\nMinimum %s", constr1, constr2)) + 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; - 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; - } + + if (!confirmFromAccountMatch(msg)) return false; if (isAddLiquidityEthCall(msg)) { - bn_from_bytes(msg->value.bytes, msg->value.size, &Amount); - ethereumFormatAmount(&Amount, NULL, msg->chain_id, tokbuf, sizeof(tokbuf)); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, - "Native amount\n%s", tokbuf)) + 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; - } else { - for (size_t i = 0; i < msg->value.size; i++) { - if (msg->value.bytes[i] != 0) 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)); - - snprintf(constr1, 32, "%s", tokbuf); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, - "Minimum %s", constr1)) + 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; - char deadline_str[24]; - snprintf(deadline_str, sizeof(deadline_str), "%" PRIu64, deadline); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, - "Deadline (Unix)\n%s", deadline_str)) - 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/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index f8c2a955e..ed0363dc5 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -247,6 +247,7 @@ 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", }; diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h index 5d0bcd14f..d87188a14 100644 --- a/lib/firmware/fsm_msg_hive.h +++ b/lib/firmware/fsm_msg_hive.h @@ -604,10 +604,9 @@ static bool hive_slip48_ops_path_ok(const uint32_t* address_n, uint32_t count, // 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. At the body font's maximum 8-pixel glyph width, each -// page fits within the three 225-pixel OLED body rows. -#define HIVE_DISPLAY_ASCII_CHUNK 72 -#define HIVE_DISPLAY_HEX_CHUNK 40 +// 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; @@ -620,19 +619,46 @@ static bool hive_slice_is_ascii(const uint8_t* s, uint16_t len) { 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 chunk_size = - ascii ? HIVE_DISPLAY_ASCII_CHUNK : HIVE_DISPLAY_HEX_CHUNK; - uint16_t pages = (uint16_t)((len + chunk_size - 1) / chunk_size); + 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 offset = (uint16_t)(page * chunk_size); - uint16_t take = (uint16_t)(len - offset); - if (take > chunk_size) take = chunk_size; + 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) { @@ -644,17 +670,18 @@ static bool hive_confirm_slice(ButtonRequestType type, const char* title, } if (ascii) { - char rendered[HIVE_DISPLAY_ASCII_CHUNK + 1]; + 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[HIVE_DISPLAY_HEX_CHUNK * 2 + 1]; + 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; } diff --git a/unittests/firmware/authenticator.cpp b/unittests/firmware/authenticator.cpp index 6ebd71e8d..e5615cb61 100644 --- a/unittests/firmware/authenticator.cpp +++ b/unittests/firmware/authenticator.cpp @@ -14,20 +14,29 @@ void setup(void); 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)); - setup(); - storage_init(); EXPECT_EQ(NOERR, wipeAuthData()); EXPECT_EQ(0, kkconfirm_drain()); - char cancelled_add[] = "example:alice:JBSWY3DPEHPK3PXP"; + char cancelled_add[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; ASSERT_TRUE(kkconfirm_preload(0, 1)); EXPECT_EQ(AUTH_CANCELLED, addAuthAccount(cancelled_add)); EXPECT_EQ(0, kkconfirm_drain()); @@ -35,7 +44,7 @@ TEST(Authenticator, AddAndRemoveCancellationFailsClosed) { char account[DOMAIN_SIZE + ACCOUNT_SIZE + 2] = {0}; EXPECT_EQ(NOACC, getAuthAccount("0", account)); - char accepted_add[] = "example:alice:JBSWY3DPEHPK3PXP"; + char accepted_add[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; ASSERT_TRUE(kkconfirm_preload(2, 0)); EXPECT_EQ(NOERR, addAuthAccount(accepted_add)); EXPECT_EQ(0, kkconfirm_drain()); @@ -68,3 +77,35 @@ TEST(Authenticator, RejectsAmbiguousDisplayFieldsBeforeMutation) { 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/ethereum.cpp b/unittests/firmware/ethereum.cpp index 00de2b9ce..0599d9930 100644 --- a/unittests/firmware/ethereum.cpp +++ b/unittests/firmware/ethereum.cpp @@ -47,7 +47,8 @@ TEST(Ethereum, AddressChecksum) { test_checksum("D1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"); } -static EthereumSignTx liquidity_tx(bool known_token) { +static EthereumSignTx liquidity_tx(bool known_token, bool add = true, + const char* ticker = "DAI") { EthereumSignTx msg; memset(&msg, 0, sizeof(msg)); msg.has_chain_id = true; @@ -57,10 +58,11 @@ static EthereumSignTx liquidity_tx(bool known_token) { 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, "\xf3\x05\xd7\x19", 4); + memcpy(msg.data_initial_chunk.bytes, + add ? "\xf3\x05\xd7\x19" : "\x02\x75\x1c\xec", 4); const TokenType* token = nullptr; - EXPECT_TRUE(tokenByTicker(1, "DAI", &token)); + EXPECT_TRUE(tokenByTicker(1, ticker, &token)); if (token == nullptr) return msg; uint8_t unknown[20]; memset(unknown, 0xa5, sizeof(unknown)); @@ -77,8 +79,40 @@ static EthereumSignTx liquidity_tx(bool known_token) { 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; - msg.value.size = 1; - msg.value.bytes[0] = 1; + 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; } @@ -119,3 +153,81 @@ 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"); + 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"); + 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)); +} diff --git a/unittests/firmware/hive.cpp b/unittests/firmware/hive.cpp index 127a8844a..4a90cdba0 100644 --- a/unittests/firmware/hive.cpp +++ b/unittests/firmware/hive.cpp @@ -1,4 +1,6 @@ extern "C" { +#include "keepkey/board/font.h" +#include "keepkey/board/layout.h" #include "keepkey/firmware/hive.h" } @@ -372,6 +374,31 @@ TEST(Hive, CustomJsonRetainsAndBoundsEveryAuthorization) { 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 From 9f4920ef144ed3a1e28a04a0621d2f21e48674b3 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 21 Jul 2026 23:47:43 -0300 Subject: [PATCH 39/55] test(ethereum): pin liquidity fixture addresses --- unittests/firmware/ethereum.cpp | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/unittests/firmware/ethereum.cpp b/unittests/firmware/ethereum.cpp index 0599d9930..7cfe23cb7 100644 --- a/unittests/firmware/ethereum.cpp +++ b/unittests/firmware/ethereum.cpp @@ -47,8 +47,16 @@ TEST(Ethereum, AddressChecksum) { test_checksum("D1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"); } -static EthereumSignTx liquidity_tx(bool known_token, bool add = true, - const char* ticker = "DAI") { +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; @@ -61,9 +69,9 @@ static EthereumSignTx liquidity_tx(bool known_token, bool add = true, memcpy(msg.data_initial_chunk.bytes, add ? "\xf3\x05\xd7\x19" : "\x02\x75\x1c\xec", 4); - const TokenType* token = nullptr; - EXPECT_TRUE(tokenByTicker(1, ticker, &token)); - if (token == nullptr) return msg; + 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( @@ -189,14 +197,14 @@ TEST(Ethereum, RemoveLiquidityRejectsNativeValue) { } TEST(Ethereum, RemoveLiquidityFormatsPrimaryAmountAsLpTokens) { - EthereumSignTx add = liquidity_tx(true, true, "USDC"); + 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"); + 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))); From a3d64f92b0ca6db46e3f4ef070222f51450b85dc Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 22 Jul 2026 01:55:48 -0300 Subject: [PATCH 40/55] fix(security): remediate second RC17 audit blockers --- docs/security/7.15.0-rc17-hardening.md | 119 +++- include/keepkey/firmware/app_confirm.h | 9 + include/keepkey/firmware/binance.h | 4 + include/keepkey/firmware/eip712.h | 11 +- include/keepkey/firmware/eos.h | 3 + include/keepkey/firmware/ethereum.h | 2 + include/keepkey/firmware/signtx_tendermint.h | 15 +- include/keepkey/firmware/tiny-json.h | 5 +- lib/firmware/app_confirm.c | 90 ++- lib/firmware/binance.c | 76 ++- lib/firmware/eip712.c | 549 +++++++++++-------- lib/firmware/eos.c | 20 +- lib/firmware/ethereum.c | 25 +- lib/firmware/fsm_msg_binance.h | 45 +- lib/firmware/fsm_msg_coin.h | 9 +- lib/firmware/fsm_msg_cosmos.h | 31 +- lib/firmware/fsm_msg_crypto.h | 36 +- lib/firmware/fsm_msg_ethereum.h | 80 +-- lib/firmware/fsm_msg_osmosis.h | 6 +- lib/firmware/fsm_msg_solana.h | 151 +---- lib/firmware/fsm_msg_tendermint.h | 75 ++- lib/firmware/fsm_msg_ton.h | 47 +- lib/firmware/fsm_msg_tron.h | 73 +-- lib/firmware/signtx_tendermint.c | 94 +++- lib/firmware/thorchain.c | 51 +- unittests/firmware/CMakeLists.txt | 2 + unittests/firmware/binance.cpp | 83 +++ unittests/firmware/cosmos.cpp | 46 +- unittests/firmware/eip712.cpp | 67 +++ unittests/firmware/eos.cpp | 17 + unittests/firmware/ethereum.cpp | 13 + unittests/firmware/thorchain.cpp | 47 +- 32 files changed, 1192 insertions(+), 709 deletions(-) create mode 100644 unittests/firmware/binance.cpp create mode 100644 unittests/firmware/eip712.cpp diff --git a/docs/security/7.15.0-rc17-hardening.md b/docs/security/7.15.0-rc17-hardening.md index d5b6919d4..0d5def4fa 100644 --- a/docs/security/7.15.0-rc17-hardening.md +++ b/docs/security/7.15.0-rc17-hardening.md @@ -1,14 +1,20 @@ # Firmware 7.15.0 RC17 security hardening -Date: 2026-07-21 +Date: 2026-07-22 Integration baseline: `develop` at `d18571307a2f5bad3ec2949c7b27fd06e6914a8c` (RC16 firmware code plus test-client submodule updates) -This candidate applies three focused hardening passes to security-sensitive -parsing, confirmation, and clear-signing paths. It does not claim to be a new -whole-firmware audit. The `rc17` suffix is a release-candidate branch/tag; -the firmware protocol version remains `7.15.0`. +This candidate applies focused hardening passes to security-sensitive parsing, +confirmation, clear-signing, and signing-policy paths. It includes remediation +for two follow-up reviews, including a full-tree static reachability pass. 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 and the +physical-device checklist below are both green. Automated evidence in this +document qualifies the remediated head for hardware QA; it does not authorize +merge, tagging, or release. ## Follow-up audit block @@ -32,6 +38,27 @@ its complete CI matrix and physical-device checks pass. | 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 | + ## Remediated findings ### Hive raw-operation review @@ -76,20 +103,69 @@ its complete CI matrix and physical-device checks pass. 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. + ## 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`) | 285/285 | Pass | 23,372 B | 7,664 B | 15,708 B | +| Full, shipped (`KK_ZCASH_PRIVACY=OFF`) | 299/299 | 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 | 344/344 | Pass | 19,072 B | 7,664 B | 11,408 B | +| Zcash privacy | 358/358 | 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. - Cppcheck completes with zero findings. - Changed production files pass the pinned Clang Format 20 gate. - `git diff --check` passes. @@ -114,8 +190,33 @@ Use a disposable test seed and verify on a physical KeepKey: Confirm Polygon/router reuse, dirty ABI address words, high deadline bits, and arbitrary approval targets never receive a Uniswap protocol label. Cancel each screen in a separate run and confirm no signature is returned. -5. Run upgrade, reboot, PIN/passphrase, transaction-signing, and recovery smoke - tests for full, Bitcoin-only, and Zcash-privacy firmware artifacts. +5. Sign Bitcoin-family, Ethereum, and TRON messages at the schema maximum. Test + embedded NUL, binary, and the exact 69-byte `%`/space word-wrap payload; + every byte or hex octet must be reviewable and cancellation on every page + must return no signature. +6. Repeat exact-byte review checks for Binance, Tendermint, Cosmos, and Osmosis + 256-byte memos. Smoke-test THORChain/MAYAChain/Ripple/TRON/Solana memo paths, + Solana/TON blind messages, identity challenges, and binary `OP_RETURN`. +7. With AdvancedMode disabled, verify Ethereum typed-hash and EOS unknown-action + signing terminate with no signature. With it enabled, verify the expected + opaque flow remains available. Confirm `eosio::newaccount` always uses its + structured handler. +8. Exercise EIP-712 long dynamic values, exact 20-byte addresses, dynamic and + fixed bytes, integer boundaries, booleans, missing fields, malformed JSON + node shapes, and primary-type prefixes such as `EIP`. Malformed values must + fail before a signature and every accepted value must be fully reviewable. +9. Sign Cosmos IBC transfers with distinct sender and receiver values. Confirm + both exact addresses, amount, channel, port, and timeout; missing receiver or + a denomination other than `uatom` must fail. +10. Attempt generic Tendermint ACKs with a changed chain name, denomination, + message-type prefix, and a Cosmos/Tendermint cross-protocol ACK. Each must + abort the session. Confirm accepted amounts and fees use the exact signed + atomic denomination. +11. Exercise Binance denominations at 31 characters and reject 32-character, + lowercase, control-character, mismatched, zero, and negative cases without + a reset or signature. +12. Run upgrade, reboot, PIN/passphrase, transaction-signing, and recovery smoke + tests for full, Bitcoin-only, and Zcash-privacy firmware artifacts. RC17 should be promoted only after the pull request CI matrix and this physical device checklist are both green. diff --git a/include/keepkey/firmware/app_confirm.h b/include/keepkey/firmware/app_confirm.h index da9610ede..b9c62adbe 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,6 +47,14 @@ 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 is paged as + * text; any payload containing a 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(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); 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/eip712.h b/include/keepkey/firmware/eip712.h index 9b5ba1a9a..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 */ @@ -102,4 +102,9 @@ typedef enum { DOMAIN = 1, MESSAGE } dm; 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/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/ethereum.h b/include/keepkey/firmware/ethereum.h index b15cf07f7..e9ccbbb91 100644 --- a/include/keepkey/firmware/ethereum.h +++ b/include/keepkey/firmware/ethereum.h @@ -71,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/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/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/app_confirm.c b/lib/firmware/app_confirm.c index 56939a449..6ce137314 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" @@ -416,6 +417,81 @@ bool confirm_sign_identity(const IdentityType* identity, body); } +static bool confirm_bytes_is_ascii(const uint8_t* data, size_t size) { + for (size_t i = 0; i < size; i++) { + if (data[i] < 0x20 || data[i] > 0x7e) return false; + } + return true; +} + +static size_t confirm_bytes_page_len(const uint8_t* data, size_t size, + bool ascii) { + if (size == 0) return 0; + + if (ascii) { + size_t candidate = size; + if (candidate >= BODY_CHAR_MAX) candidate = BODY_CHAR_MAX - 1; + return calc_str_page(get_body_font(), (const char*)data, candidate, + BODY_WIDTH, BODY_ROWS); + } + + size_t candidate = size; + if (candidate > (BODY_CHAR_MAX - 1) / 2) candidate = (BODY_CHAR_MAX - 1) / 2; + char rendered[BODY_CHAR_MAX]; + for (size_t i = 0; i < candidate; i++) { + snprintf(rendered + 2 * i, 3, "%02x", data[i]); + } + size_t chars = calc_str_page(get_body_font(), rendered, 2 * candidate, + BODY_WIDTH, BODY_ROWS); + return (chars & ~(size_t)1) / 2; +} + +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 ascii = confirm_bytes_is_ascii(data, size); + size_t pages = 0; + size_t offset = 0; + while (offset < size) { + const size_t take = + confirm_bytes_page_len(data + offset, size - offset, ascii); + if (take == 0) return false; + offset += take; + pages++; + } + + offset = 0; + for (size_t page = 0; page < pages; page++) { + const size_t take = + confirm_bytes_page_len(data + offset, size - 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)); + } + + char rendered[BODY_CHAR_MAX]; + if (ascii) { + memcpy(rendered, data + offset, take); + rendered[take] = '\0'; + } else { + for (size_t i = 0; i < take; i++) { + snprintf(rendered + 2 * i, 3, "%02x", data[offset + i]); + } + } + 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; @@ -453,17 +529,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/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/eip712.c b/lib/firmware/eip712.c index aefe88ef4..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,46 +340,37 @@ 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; - return SUCCESS; - } - if (!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) { - if (!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; @@ -324,6 +395,11 @@ void marshallDsVals(const char* value) { return; } +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 uint8_t addrHexStr[20] = {0}; @@ -446,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 @@ -454,31 +529,35 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } } - bool hasValue = (JSON_TEXT == json_getType(walkVals) || - JSON_INTEGER == json_getType(walkVals)); + if (walkVals == 0) { + return JSON_TYPE_WNOVAL; + } + 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 (walkVals == 0) { - return JSON_TYPE_WNOVAL; - } else { - if (0 == strncmp("address", typeType, strlen("address") - 1)) { + { + 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 if (SUCCESS != - (errRet = 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; } @@ -487,9 +566,8 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } keccak_Final(&valCtx, encBytes); } else { - if (ds_vals) { - marshallDsVals(valStr); - } else if (SUCCESS != (errRet = confirmValue(valStr))) { + if (value_type != JSON_TEXT) return GENERAL_ERROR; + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { return errRet; } errRet = encAddress(valStr, encBytes); @@ -498,22 +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 if (SUCCESS != - (errRet = 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; } @@ -522,9 +601,8 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } keccak_Final(&valCtx, encBytes); } else { - if (ds_vals) { - marshallDsVals(valStr); - } else if (SUCCESS != (errRet = confirmValue(valStr))) { + if (value_type != JSON_TEXT) return GENERAL_ERROR; + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { return errRet; } errRet = encString(valStr, encBytes); @@ -533,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 if (SUCCESS != (errRet = 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; } @@ -560,154 +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 + 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 (endptr == valStr || *endptr != '\0') { + if (errno == ERANGE || endptr == valStr || *endptr != '\0') { return GENERAL_ERROR; } - if (0 == strncmp("uint", typeType, 4) && intVal < 0) { + 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 if (SUCCESS != (errRet = confirmValue(valStr))) { - return errRet; - } - if (0 == strcmp(typeType, "bytes")) { - errRet = encodeBytes(valStr, encBytes); - if (SUCCESS != errRet) { + } 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 { + 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 if (type_matches(typeType, "bool")) { + if (']' == typeType[strlen(typeType) - 1]) { + return BOOL_ARRAY_ERROR; } else { - errRet = encodeBytesN(typeType, valStr, encBytes); - if (SUCCESS != errRet) { + 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 if (0 == strncmp("bool", typeType, strlen(typeType))) { - if (']' == typeType[strlen(typeType) - 1]) { - return BOOL_ARRAY_ERROR; } else { - if (ds_vals) { - marshallDsVals(valStr); - } else if (SUCCESS != (errRet = confirmValue(valStr))) { - return errRet; - } - for (ctr = 0; ctr < 32; ctr++) { - // leading zeros in bool - encBytes[ctr] = 0; - } - if (0 == strncmp(valStr, "true", sizeof("true"))) { - encBytes[31] = 0x01; + // 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; } - } - } 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; + 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); - - 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(&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); } } } diff --git a/lib/firmware/eos.c b/lib/firmware/eos.c index 6dc7b1f8f..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) { diff --git a/lib/firmware/ethereum.c b/lib/firmware/ethereum.c index 0c49371b1..f1b2037e7 100644 --- a/lib/firmware/ethereum.c +++ b/lib/firmware/ethereum.c @@ -49,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 @@ -1308,14 +1316,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); 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_coin.h b/lib/firmware/fsm_msg_coin.h index 8b7267cd3..978c80712 100644 --- a/lib/firmware/fsm_msg_coin.h +++ b/lib/firmware/fsm_msg_coin.h @@ -270,8 +270,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 +325,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_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_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_ethereum.h b/lib/firmware/fsm_msg_ethereum.h index ee5027128..643d652db 100644 --- a/lib/firmware/fsm_msg_ethereum.h +++ b/lib/firmware/fsm_msg_ethereum.h @@ -319,44 +319,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; @@ -372,12 +344,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")); @@ -394,29 +360,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; @@ -433,6 +379,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, diff --git a/lib/firmware/fsm_msg_osmosis.h b/lib/firmware/fsm_msg_osmosis.h index 5d3a6c8d5..212f08f05 100644 --- a/lib/firmware/fsm_msg_osmosis.h +++ b/lib/firmware/fsm_msg_osmosis.h @@ -674,8 +674,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(); @@ -720,4 +720,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_solana.h b/lib/firmware/fsm_msg_solana.h index 35c8d4c58..675219449 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -62,61 +62,10 @@ static bool solana_symbol_is_safe(const char* sym) { return true; } -/* Display budget per confirm body: printable memos page as text, binary memos - * page as hex so the FULL content is always shown — a memo can carry swap - * intent (THORChain '=:ETH.ETH:...'), so nothing may be hidden behind a byte - * count. Mirrors hive_confirm_slice. */ -#define SOL_MEMO_ASCII_CHUNK 72 -#define SOL_MEMO_HEX_CHUNK 40 - static bool solana_confirm_memo(const char* title, const uint8_t* s, uint16_t len) { - if (len == 0) { - return confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, title, - "Memo (empty)"); - } - bool ascii = true; - for (uint16_t i = 0; i < len; i++) { - if (s[i] < 0x20 || s[i] > 0x7e) { - ascii = false; - break; - } - } - uint16_t chunk = ascii ? SOL_MEMO_ASCII_CHUNK : SOL_MEMO_HEX_CHUNK; - uint16_t pages = (uint16_t)((len + chunk - 1) / chunk); - - for (uint16_t page = 0; page < pages; page++) { - uint16_t offset = (uint16_t)(page * chunk); - uint16_t take = (uint16_t)(len - offset); - if (take > chunk) take = chunk; - - char page_title[32]; - 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[SOL_MEMO_ASCII_CHUNK + 1]; - memcpy(rendered, s + offset, take); - rendered[take] = '\0'; - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, page_title, - "%s", rendered)) - return false; - } else { - char rendered[SOL_MEMO_HEX_CHUNK * 2 + 1]; - for (uint16_t i = 0; i < take; i++) { - snprintf(rendered + 2 * i, 3, "%02x", s[offset + i]); - } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmMemo, page_title, - "%s", rendered)) - return false; - } - } - return true; + return confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, title, s, + len); } /* Priority fee = ceil(cu_price_micro_lamports * cu_limit / 1e6) lamports, and @@ -888,45 +837,16 @@ void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg) { layoutHome(); return; } - } else /* blind message path */ - /* 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 (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); - } - } - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeLabel), - "%s", msgBuf)) { - 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 */ @@ -1004,45 +924,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/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_ton.h b/lib/firmware/fsm_msg_ton.h index 57b8e9710..6f2aae06b 100644 --- a/lib/firmware/fsm_msg_ton.h +++ b/lib/firmware/fsm_msg_ton.h @@ -194,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 79b61d1fe..22d0a4fd6 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -212,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); @@ -233,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; @@ -303,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; 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/thorchain.c b/lib/firmware/thorchain.c index 330b31c78..48d396c05 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" @@ -240,52 +241,8 @@ void thorchain_signAbort(void) { * best-effort structured summary. */ bool thorchain_confirm_full_memo(const char* title, const char* memo, size_t len) { - enum { ASCII_CHUNK = 72, HEX_CHUNK = 40 }; - if (len == 0) { - /* An empty memo would otherwise fall through to the hex branch below with - * take == 0, leaving `rendered` uninitialized when passed to %s. */ - return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, title, - "(empty)"); - } - bool ascii = true; - for (size_t i = 0; i < len; i++) { - if ((uint8_t)memo[i] < 0x20 || (uint8_t)memo[i] > 0x7e) { - ascii = false; - break; - } - } - size_t chunk = ascii ? ASCII_CHUNK : HEX_CHUNK; - size_t pages = (len + chunk - 1) / chunk; - if (pages == 0) pages = 1; - - for (size_t page = 0; page < pages; page++) { - size_t offset = page * chunk; - size_t take = len - offset; - if (take > chunk) take = chunk; - - char page_title[24]; - snprintf(page_title, sizeof(page_title), - ascii ? "%s %u/%u" : "%s Hex %u/%u", title, (unsigned)(page + 1), - (unsigned)pages); - - if (ascii) { - char rendered[ASCII_CHUNK + 1]; - memcpy(rendered, memo + offset, take); - rendered[take] = '\0'; - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, page_title, - "%s", rendered)) - return false; - } else { - char rendered[HEX_CHUNK * 2 + 1]; - for (size_t i = 0; i < take; i++) { - snprintf(rendered + 2 * i, 3, "%02x", (uint8_t)memo[offset + i]); - } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, page_title, - "%s", rendered)) - return false; - } - } - return true; + return confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + (const uint8_t*)memo, len); } bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { @@ -519,4 +476,4 @@ bool thorchain_parseConfirmMemo(const char* swapStr, size_t size) { // 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 c9cf3e989..b6e221f29 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -11,8 +11,10 @@ set(sources if(NOT ${KK_BITCOIN_ONLY}) list(APPEND sources authenticator.cpp + binance.cpp coins.cpp cosmos.cpp + eip712.cpp eos.cpp ethereum.cpp hive.cpp 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/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/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/ethereum.cpp b/unittests/firmware/ethereum.cpp index 7cfe23cb7..83e67af23 100644 --- a/unittests/firmware/ethereum.cpp +++ b/unittests/firmware/ethereum.cpp @@ -47,6 +47,19 @@ TEST(Ethereum, AddressChecksum) { 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}; diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index a61ab20ca..aa2a338ec 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -2,6 +2,7 @@ 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" @@ -408,29 +409,30 @@ TEST(Thorchain, FullMemoShortAsciiIsOnePage) { EXPECT_EQ(0, kkconfirm_drain()); } -// A long memo (up to THORChain's 250-byte limit) pages in full instead of -// truncating in one confirm: 150 ASCII bytes / 72 per page = 3 pages. +// Page breaks use measured rendered rows, including word-wrap behavior. TEST(Thorchain, FullMemoLongAsciiPagesAll) { - std::string memo(150, 'a'); - ASSERT_TRUE(kkconfirm_preload(3, 0)); - EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo.c_str(), memo.size())); + 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) { - std::string memo(150, 'a'); + const char memo[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; ASSERT_TRUE(kkconfirm_preload(1, 1)); // approve page 1, reject page 2 - EXPECT_FALSE(thorchain_confirm_full_memo("Memo", memo.c_str(), memo.size())); + EXPECT_FALSE(thorchain_confirm_full_memo("Memo", memo, strlen(memo))); EXPECT_EQ(0, kkconfirm_drain()); } -// Non-printable memo bytes are disclosed as hex pages (40 bytes/page), never -// hidden behind a byte-count summary. +// Non-printable memo bytes are disclosed in complete renderer-measured hex +// pages, never hidden behind a byte-count summary. TEST(Thorchain, FullMemoBinaryPagesAsHex) { - char memo[50]; - memset(memo, 0x01, sizeof(memo)); // 50 non-printable bytes -> 2 hex pages + 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()); @@ -444,6 +446,29 @@ TEST(Thorchain, FullMemoEmptyShowsEmpty) { 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. * From a8ec50a72e12741f0a1b9878b863959385fe02a3 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 22 Jul 2026 02:15:08 -0300 Subject: [PATCH 41/55] test(ethereum): repin typed-hash policy coverage --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 1a697419e..bf188c5b8 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 1a697419e3c63e127db935382be7dc26d355f7b4 +Subproject commit bf188c5b8b9f70c6c3185e69bd380ddfb3c09980 From 422780f27534394a5cb1650a934c6a7b0124a911 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 22 Jul 2026 17:56:27 -0300 Subject: [PATCH 42/55] fix(security): close Osmosis RC17 blockers --- deps/python-keepkey | 2 +- docs/security/7.15.0-rc17-hardening.md | 65 ++++++- include/keepkey/board/util.h | 5 +- include/keepkey/firmware/osmosis.h | 13 +- lib/board/util.c | 61 ++++--- lib/firmware/app_confirm.c | 103 ++++++++--- lib/firmware/fsm_msg_osmosis.h | 236 ++++++++++++++++--------- lib/firmware/osmosis.c | 81 ++++++++- unittests/firmware/osmosis.cpp | 84 ++++++++- 9 files changed, 492 insertions(+), 158 deletions(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index bf188c5b8..87f4c1ab4 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit bf188c5b8b9f70c6c3185e69bd380ddfb3c09980 +Subproject commit 87f4c1ab43b8625949809a14f50bcf0757a7b72f diff --git a/docs/security/7.15.0-rc17-hardening.md b/docs/security/7.15.0-rc17-hardening.md index 0d5def4fa..3455982ac 100644 --- a/docs/security/7.15.0-rc17-hardening.md +++ b/docs/security/7.15.0-rc17-hardening.md @@ -7,9 +7,9 @@ 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 two follow-up reviews, including a full-tree static reachability pass. The -`rc17` suffix is a release-candidate branch/tag; the firmware protocol version -remains `7.15.0`. +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 and the physical-device checklist below are both green. Automated evidence in this @@ -59,6 +59,21 @@ display/signature binding, and blind-signing policy enforcement. | 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 | Require an explicit `uosmo` denomination in the wire handler and independently enforce the same invariant in the serializer | +| 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 @@ -149,15 +164,37 @@ display/signature binding, and blind-signing policy enforcement. - Review the initialized chain ID, chain name, denomination, and message-type prefix before final signature approval. +### Osmosis signer invariants + +- Fence legacy Amino `MsgSend` to `uosmo` in firmware, including direct + `OsmosisMsgAck` traffic that bypasses the Python client's early check. Pass + the denomination into the serializer and revalidate it at the hashing + boundary. +- 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`) | 299/299 | Pass | 23,364 B | 7,664 B | 15,700 B | +| 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 | 358/358 | Pass | 19,064 B | 7,664 B | 11,400 B | +| Zcash privacy | 362/362 | Pass | 19,064 B | 7,664 B | 11,400 B | Additional gates: @@ -166,6 +203,14 @@ Additional gates: - 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 + non-`uosmo` rejection, 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. - Cppcheck completes with zero findings. - Changed production files pass the pinned Clang Format 20 gate. - `git diff --check` passes. @@ -217,6 +262,16 @@ Use a disposable test seed and verify on a physical KeepKey: a reset or signature. 12. Run upgrade, reboot, PIN/passphrase, transaction-signing, and recovery smoke tests for full, Bitcoin-only, and Zcash-privacy firmware artifacts. +13. Send a raw Osmosis `MsgSend` ACK with a non-`uosmo` denomination and confirm + rejection occurs before any signature. Repeat with leading zero, sign, + whitespace, and greater-than-`UINT64_MAX` amount strings. +14. Review maximum-length accepted Osmosis Send and Swap fixtures. Confirm the + Send amount and destination and both Swap assets are on distinct screens, + every 68-character Swap denomination is visible through its final + character, and the signed minimum output is never below the OLED boundary. +15. Exercise maximum 32-digit Osmosis LP add/remove share amounts. Confirm the + final decimal digit is present, cancellation returns no signature, and the + device neither resets nor reports a memory fault. RC17 should be promoted only after the pull request CI matrix and this physical device checklist are both green. 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/osmosis.h b/include/keepkey/firmware/osmosis.h index 36033471a..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, @@ -67,6 +69,8 @@ bool osmosis_signTxUpdateMsgSwap(const uint64_t pool_id, 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. @@ -77,9 +81,12 @@ bool osmosis_signTxUpdateMsgSwap(const uint64_t pool_id, * ("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. + * 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. */ -void osmosis_formatAmount(char* out, size_t out_len, const char* value, +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); 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 6ce137314..1bbf086cd 100644 --- a/lib/firmware/app_confirm.c +++ b/lib/firmware/app_confirm.c @@ -424,26 +424,78 @@ static bool confirm_bytes_is_ascii(const uint8_t* data, size_t size) { return true; } -static size_t confirm_bytes_page_len(const uint8_t* data, size_t size, - bool ascii) { +static size_t confirm_bytes_render_page(const uint8_t* data, size_t size, + bool ascii, + char rendered[BODY_CHAR_MAX]) { if (size == 0) return 0; - if (ascii) { - size_t candidate = size; - if (candidate >= BODY_CHAR_MAX) candidate = BODY_CHAR_MAX - 1; - return calc_str_page(get_body_font(), (const char*)data, candidate, - BODY_WIDTH, BODY_ROWS); - } + 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) { + char chars[2]; + size_t char_count; + if (ascii) { + 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; + } - size_t candidate = size; - if (candidate > (BODY_CHAR_MAX - 1) / 2) candidate = (BODY_CHAR_MAX - 1) / 2; - char rendered[BODY_CHAR_MAX]; - for (size_t i = 0; i < candidate; i++) { - snprintf(rendered + 2 * i, 3, "%02x", data[i]); + 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 (ascii && 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++; } - size_t chars = calc_str_page(get_body_font(), rendered, 2 * candidate, - BODY_WIDTH, BODY_ROWS); - return (chars & ~(size_t)1) / 2; + + rendered[written] = '\0'; + return consumed; } bool confirm_bytes(ButtonRequestType button_request, const char* title, @@ -455,8 +507,9 @@ bool confirm_bytes(ButtonRequestType button_request, const char* title, size_t pages = 0; size_t offset = 0; while (offset < size) { - const size_t take = - confirm_bytes_page_len(data + offset, size - offset, ascii); + char rendered[BODY_CHAR_MAX]; + const size_t take = confirm_bytes_render_page(data + offset, size - offset, + ascii, rendered); if (take == 0) return false; offset += take; pages++; @@ -464,8 +517,9 @@ bool confirm_bytes(ButtonRequestType button_request, const char* title, offset = 0; for (size_t page = 0; page < pages; page++) { - const size_t take = - confirm_bytes_page_len(data + offset, size - offset, ascii); + char rendered[BODY_CHAR_MAX]; + const size_t take = confirm_bytes_render_page(data + offset, size - offset, + ascii, rendered); if (take == 0) return false; char page_title[TITLE_CHAR_MAX]; @@ -477,15 +531,6 @@ bool confirm_bytes(ButtonRequestType button_request, const char* title, strlcpy(page_title, title, sizeof(page_title)); } - char rendered[BODY_CHAR_MAX]; - if (ascii) { - memcpy(rendered, data + offset, take); - rendered[take] = '\0'; - } else { - for (size_t i = 0; i < take; i++) { - snprintf(rendered + 2 * i, 3, "%02x", data[offset + i]); - } - } if (!confirm(button_request, page_title, "%s", rendered)) return false; offset += take; } diff --git a/lib/firmware/fsm_msg_osmosis.h b/lib/firmware/fsm_msg_osmosis.h index 212f08f05..7110996b6 100644 --- a/lib/firmware/fsm_msg_osmosis.h +++ b/lib/firmware/fsm_msg_osmosis.h @@ -1,5 +1,16 @@ #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); @@ -130,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")); @@ -138,21 +150,39 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } + // MsgSend's legacy Amino serializer is intentionally uosmo-only. Enforce + // that invariant in firmware because callers can bypass host libraries and + // send OsmosisMsgAck directly over the wire. + if (strcmp(msg->send.denom, "uosmo") != 0) { + osmosis_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Only uosmo is supported for Osmosis MsgSend"); + layoutHome(); + return; + } + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; - osmosis_formatAmount(amount_str, sizeof(amount_str), msg->send.amount, - msg->send.denom); + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->send.amount, msg->send.denom)) { + return; + } - /** 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"); @@ -163,7 +193,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")); @@ -172,8 +203,11 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } char amount_str[OSMOSIS_AMOUNT_STR_LEN]; - osmosis_formatAmount(amount_str, sizeof(amount_str), msg->delegate.amount, - msg->delegate.denom); + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->delegate.amount, + msg->delegate.denom)) { + return; + } /** Confirm transaction parameters on-screen */ if (!confirm_osmosis_address("Confirm Delegator Address", @@ -192,8 +226,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", "%s", - amount_str)) { + 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(); @@ -212,7 +246,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")); @@ -221,8 +256,11 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } char amount_str[OSMOSIS_AMOUNT_STR_LEN]; - osmosis_formatAmount(amount_str, sizeof(amount_str), msg->undelegate.amount, - msg->undelegate.denom); + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->undelegate.amount, + msg->undelegate.denom)) { + return; + } /** Confirm transaction parameters on-screen */ if (!confirm_osmosis_address("Confirm Delegator Address", @@ -241,8 +279,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", "%s", - amount_str)) { + 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(); @@ -271,40 +309,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; } char amount_in_max_b_str[OSMOSIS_AMOUNT_STR_LEN]; - osmosis_formatAmount(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); + 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; + } char amount_in_max_a_str[OSMOSIS_AMOUNT_STR_LEN]; - osmosis_formatAmount(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); + 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 %s and...", amount_in_max_b_str)) { + 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", - "... %s?", amount_in_max_a_str)) { + 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(); @@ -312,16 +355,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(); @@ -354,40 +397,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; } char amount_out_min_b_str[OSMOSIS_AMOUNT_STR_LEN]; - osmosis_formatAmount(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); + 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; + } char amount_out_min_a_str[OSMOSIS_AMOUNT_STR_LEN]; - osmosis_formatAmount(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); + 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 %s and...", amount_out_min_b_str)) { + 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", - "... %s ?", amount_out_min_a_str)) { + 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(); @@ -395,16 +443,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(); @@ -427,7 +475,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")); @@ -435,15 +483,24 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - // Redelegation is OSMO-denominated, so the amount is always scaled and - // labelled OSMO regardless of what denom the message carries. + 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]; - osmosis_formatAmount(amount_str, sizeof(amount_str), msg->redelegate.amount, - "uosmo"); + 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 %s?", amount_str)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Redelegate", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -541,17 +598,26 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } char token_in_str[OSMOSIS_AMOUNT_STR_LEN]; - osmosis_formatAmount(token_in_str, sizeof(token_in_str), - msg->swap.token_in_amount, msg->swap.token_in_denom); + if (!osmosis_formatAmountOrFail(token_in_str, sizeof(token_in_str), + msg->swap.token_in_amount, + msg->swap.token_in_denom)) { + return; + } char token_out_min_str[OSMOSIS_AMOUNT_STR_LEN]; - osmosis_formatAmount(token_out_min_str, sizeof(token_out_min_str), - msg->swap.token_out_min_amount, - msg->swap.token_out_denom); + 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 %s for at least %s?", token_in_str, token_out_min_str)) { + // 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(); @@ -559,7 +625,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(); @@ -584,7 +650,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")); @@ -593,12 +660,15 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } char amount_str[OSMOSIS_AMOUNT_STR_LEN]; - osmosis_formatAmount(amount_str, sizeof(amount_str), - msg->ibc_transfer.amount, msg->ibc_transfer.denom); + 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 %s?", amount_str)) { + 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(); diff --git a/lib/firmware/osmosis.c b/lib/firmware/osmosis.c index dc5515ee6..57af54a82 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,9 @@ 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) || !denom || + strcmp(denom, "uosmo") != 0 || + !bech32_decode(hrp, decoded, &decoded_len, to_address)) { return false; } @@ -626,14 +673,32 @@ bool osmosis_signTxFinalize(uint8_t* public_key, uint8_t* signature) { * the decimal point in integer math, the same way the Hive and Ethereum * confirm screens do. */ -void osmosis_formatAmount(char* out, size_t out_len, const char* value, +bool osmosis_formatAmount(char* out, size_t out_len, const char* value, const char* denom) { - if (strcmp(denom, "uosmo") != 0) { - snprintf(out, out_len, "%s %s", value, denom); - return; + 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; } - bn_format_uint64(strtoull(value, NULL, 10), NULL, " OSMO", OSMOSIS_PRECISION, - 0, true, out, out_len); + return true; } bool osmosis_signingIsInited(void) { return initialized; } diff --git a/unittests/firmware/osmosis.cpp b/unittests/firmware/osmosis.cpp index 4061de1ce..76aee5b48 100644 --- a/unittests/firmware/osmosis.cpp +++ b/unittests/firmware/osmosis.cpp @@ -2,6 +2,8 @@ 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" } @@ -9,9 +11,12 @@ extern "C" { #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}; - osmosis_formatAmount(out, sizeof(out), value, denom); + EXPECT_TRUE(osmosis_formatAmount(out, sizeof(out), value, denom)); return std::string(out); } @@ -40,8 +45,81 @@ 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"); + 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()); +} From f0b9efd487e8d4e76fa286aa8c7446ce039884c7 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 00:37:28 -0300 Subject: [PATCH 43/55] fix(osmosis): sign canonical non-native denominations --- lib/firmware/fsm_msg_osmosis.h | 11 ----------- lib/firmware/osmosis.c | 18 ++++++++++++------ unittests/firmware/osmosis.cpp | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/lib/firmware/fsm_msg_osmosis.h b/lib/firmware/fsm_msg_osmosis.h index 7110996b6..b64daf9a8 100644 --- a/lib/firmware/fsm_msg_osmosis.h +++ b/lib/firmware/fsm_msg_osmosis.h @@ -150,17 +150,6 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - // MsgSend's legacy Amino serializer is intentionally uosmo-only. Enforce - // that invariant in firmware because callers can bypass host libraries and - // send OsmosisMsgAck directly over the wire. - if (strcmp(msg->send.denom, "uosmo") != 0) { - osmosis_signAbort(); - fsm_sendFailure(FailureType_Failure_SyntaxError, - "Only uosmo is supported for Osmosis MsgSend"); - layoutHome(); - return; - } - char amount_str[OSMOSIS_AMOUNT_STR_LEN]; if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), msg->send.amount, msg->send.denom)) { diff --git a/lib/firmware/osmosis.c b/lib/firmware/osmosis.c index 57af54a82..703401f35 100644 --- a/lib/firmware/osmosis.c +++ b/lib/firmware/osmosis.c @@ -150,8 +150,7 @@ bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address, size_t decoded_len; char hrp[45] = {0}; uint8_t decoded[38] = {0}; - if (!osmosis_isCanonicalUint64(amount) || !denom || - strcmp(denom, "uosmo") != 0 || + if (!osmosis_isCanonicalUint64(amount) || !osmosis_isValidDenom(denom) || !bech32_decode(hrp, decoded, &decoded_len, to_address)) { return false; } @@ -172,10 +171,17 @@ 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), diff --git a/unittests/firmware/osmosis.cpp b/unittests/firmware/osmosis.cpp index 76aee5b48..507233f8a 100644 --- a/unittests/firmware/osmosis.cpp +++ b/unittests/firmware/osmosis.cpp @@ -123,3 +123,35 @@ TEST(Osmosis, MaxSwapAssetsAreRendererPagedCompletely) { 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)); +} From ac8364927f03d19434f07f755d59afdc2fcaf5c8 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 00:39:15 -0300 Subject: [PATCH 44/55] style(osmosis): apply clang-format --- lib/firmware/osmosis.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/firmware/osmosis.c b/lib/firmware/osmosis.c index 703401f35..0348c6d15 100644 --- a/lib/firmware/osmosis.c +++ b/lib/firmware/osmosis.c @@ -176,8 +176,7 @@ bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address, 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_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)); From c5466589cb5d545d4b3a1cfc1638d969b006fd92 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 00:55:08 -0300 Subject: [PATCH 45/55] test(osmosis): include secp256k1 curve fixture --- unittests/firmware/osmosis.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/unittests/firmware/osmosis.cpp b/unittests/firmware/osmosis.cpp index 507233f8a..18ca86a62 100644 --- a/unittests/firmware/osmosis.cpp +++ b/unittests/firmware/osmosis.cpp @@ -5,6 +5,7 @@ extern "C" { #include "keepkey/board/util.h" #include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/osmosis.h" +#include "trezor/crypto/curves.h" } #include "gtest/gtest.h" From 12b463f314539ae50278931b23c856e774165bd3 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 01:02:15 -0300 Subject: [PATCH 46/55] test(osmosis): include secp256k1 declaration --- unittests/firmware/osmosis.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unittests/firmware/osmosis.cpp b/unittests/firmware/osmosis.cpp index 18ca86a62..b08f129fc 100644 --- a/unittests/firmware/osmosis.cpp +++ b/unittests/firmware/osmosis.cpp @@ -5,7 +5,7 @@ extern "C" { #include "keepkey/board/util.h" #include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/osmosis.h" -#include "trezor/crypto/curves.h" +#include "trezor/crypto/secp256k1.h" } #include "gtest/gtest.h" From 277968ea7db07c57a7ea783b1bf98736d1a990f3 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 01:30:28 -0300 Subject: [PATCH 47/55] test(osmosis): repin denomination binding coverage --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 87f4c1ab4..887fb15f9 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 87f4c1ab43b8625949809a14f50bcf0757a7b72f +Subproject commit 887fb15f9ca4ddc27579e3c11e3e6328d19c849c From f746c52b2c0e5584fde9df04471eeb36ebbbf03b Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 01:41:08 -0300 Subject: [PATCH 48/55] fix(ci): pin exact python-keepkey revision --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index 887fb15f9..887fb1511 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 887fb15f9ca4ddc27579e3c11e3e6328d19c849c +Subproject commit 887fb151104c0f346f21808ef78502f91269107e From 532d16fc666704a4ce876591fa4aca2bd5dcf2f3 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 13:52:32 -0300 Subject: [PATCH 49/55] docs(rc17): record physical Osmosis QA evidence --- docs/security/7.15.0-rc17-hardening.md | 60 +++++++++++++++++++++----- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/security/7.15.0-rc17-hardening.md b/docs/security/7.15.0-rc17-hardening.md index 3455982ac..f54937f31 100644 --- a/docs/security/7.15.0-rc17-hardening.md +++ b/docs/security/7.15.0-rc17-hardening.md @@ -16,6 +16,11 @@ physical-device checklist below are both green. Automated evidence in this document qualifies the remediated head for hardware QA; it does not authorize merge, tagging, or release. +Qualified candidate head: `f746c52b2c0e5584fde9df04471eeb36ebbbf03b`. +Its pull-request CI matrix completed successfully in GitHub Actions run +`29980175333` on 2026-07-23. The physical-device checklist remains the release +gate. + ## Follow-up audit block The original candidate head @@ -69,7 +74,7 @@ display/signature hardening defect. | Finding | Severity | Remediation | | --- | --- | --- | -| Osmosis `MsgSend` displayed a host denomination but always signed `uosmo` | P1 | Require an explicit `uosmo` denomination in the wire handler and independently enforce the same invariant in the serializer | +| 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 | @@ -166,10 +171,11 @@ display/signature hardening defect. ### Osmosis signer invariants -- Fence legacy Amino `MsgSend` to `uosmo` in firmware, including direct - `OsmosisMsgAck` traffic that bypasses the Python client's early check. Pass - the denomination into the serializer and revalidate it at the hashing - boundary. +- 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. @@ -206,8 +212,9 @@ Additional gates: - 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 - non-`uosmo` rejection, raw-wire noncanonical/overflow rejection, and the full - maximum-length Swap review/signing sequence. + 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. @@ -217,6 +224,36 @@ Additional gates: - 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. + +The transport results above do not, by themselves, prove the final OLED +character was visually inspected. Record explicit operator confirmation for +the two maximum Swap disclosures and the LP share disclosure before closing +items 14–15. LP-remove cancellation also remains open: the attempted manual +runs were approved and returned signatures, while the production-style image's +enumerated debug interface did not answer `DebugLinkGetState`; no negative +decision was injected and no conclusion about the cancellation path should be +drawn from those attempts. + ## Final hardware QA checklist Use a disposable test seed and verify on a physical KeepKey: @@ -262,9 +299,12 @@ Use a disposable test seed and verify on a physical KeepKey: a reset or signature. 12. Run upgrade, reboot, PIN/passphrase, transaction-signing, and recovery smoke tests for full, Bitcoin-only, and Zcash-privacy firmware artifacts. -13. Send a raw Osmosis `MsgSend` ACK with a non-`uosmo` denomination and confirm - rejection occurs before any signature. Repeat with leading zero, sign, - whitespace, and greater-than-`UINT64_MAX` amount strings. +13. Send otherwise-identical raw Osmosis `MsgSend` ACKs using `uosmo` and a + canonical non-native denomination such as `uatom`. Confirm the exact + denomination is reviewable, both transactions sign, and their signatures + differ. Repeat with leading-zero, signed, whitespace-prefixed, and + greater-than-`UINT64_MAX` amount strings and confirm each fails before a + signature. 14. Review maximum-length accepted Osmosis Send and Swap fixtures. Confirm the Send amount and destination and both Swap assets are on distinct screens, every 68-character Swap denomination is visible through its final From 411b00828e1eba0de1994358a85d2e439bcb6a5e Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 13:58:38 -0300 Subject: [PATCH 50/55] docs(rc17): record LP cancellation proof --- docs/security/7.15.0-rc17-hardening.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/security/7.15.0-rc17-hardening.md b/docs/security/7.15.0-rc17-hardening.md index f54937f31..ae92ca32b 100644 --- a/docs/security/7.15.0-rc17-hardening.md +++ b/docs/security/7.15.0-rc17-hardening.md @@ -244,15 +244,15 @@ through Vault and were not broadcast. - 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 transport results above do not, by themselves, prove the final OLED character was visually inspected. Record explicit operator confirmation for the two maximum Swap disclosures and the LP share disclosure before closing -items 14–15. LP-remove cancellation also remains open: the attempted manual -runs were approved and returned signatures, while the production-style image's -enumerated debug interface did not answer `DebugLinkGetState`; no negative -decision was injected and no conclusion about the cancellation path should be -drawn from those attempts. +items 14–15. ## Final hardware QA checklist From 686a6e1a2df624154ec429d05da752b92731a2ed Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 14:03:48 -0300 Subject: [PATCH 51/55] docs(rc17): confirm physical OLED boundaries --- docs/security/7.15.0-rc17-hardening.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/security/7.15.0-rc17-hardening.md b/docs/security/7.15.0-rc17-hardening.md index ae92ca32b..537c1e4d8 100644 --- a/docs/security/7.15.0-rc17-hardening.md +++ b/docs/security/7.15.0-rc17-hardening.md @@ -249,10 +249,10 @@ through Vault and were not broadcast. request four (`LP Shares to Redeem`). Firmware returned cancellation with no signature, remained initialized, and reported the same candidate revision. -The transport results above do not, by themselves, prove the final OLED -character was visually inspected. Record explicit operator confirmation for -the two maximum Swap disclosures and the LP share disclosure before closing -items 14–15. +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. ## Final hardware QA checklist From f3bfca387e7073442552a4ee915e4cb50def26f3 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 14:45:30 -0300 Subject: [PATCH 52/55] docs(tendermint): plan generalized direct signing --- docs/Generalized-Tendermint-Signing.md | 537 +++++++++++++++++++++++++ docs/README.md | 1 + 2 files changed, 538 insertions(+) create mode 100644 docs/Generalized-Tendermint-Signing.md diff --git a/docs/Generalized-Tendermint-Signing.md b/docs/Generalized-Tendermint-Signing.md new file mode 100644 index 000000000..69ee80588 --- /dev/null +++ b/docs/Generalized-Tendermint-Signing.md @@ -0,0 +1,537 @@ +# Generalized Cosmos and Tendermint signing + +Status: future design proposal + +This document describes a path to full Cosmos SDK `SIGN_MODE_DIRECT` support, +generalized Tendermint/CometBFT-family signing, and IBC transfers across supported +chains. It is not part of the 7.15.0-rc17 release scope and does not change that +release's acceptance criteria. + +The name "Tendermint" is retained where it refers to the existing KeepKey +protocol. New code should use the more precise terms "Cosmos SDK" and +"CometBFT-family." + +## Product position + +Blind signing with an explicit warning is a valid first support tier for a new +chain or message type. KeepKey already uses this phased model for Ethereum +contract data and opaque Solana, TRON, TON, and EOS operations. Generalized +Cosmos support should follow the same model: + +1. Sign the exact transaction bytes in Advanced Mode after an unmistakable blind + signing warning. +2. Parse and display transaction envelope fields that can be bound to those + exact bytes. +3. Add clear-signing adapters for common messages over time. +4. Automatically replace blind review with clear review as message adapters + become available. + +Unknown does not mean forbidden. It means the device must never present an +unknown message as understood. + +This distinction makes broad compatibility practical: + +- A Cosmos SDK transaction using a supported key algorithm and + `SIGN_MODE_DIRECT` can be signed even when one or more `Any` message values are + not understood by the firmware. +- Recognized messages receive semantic clear-signing screens. +- Unrecognized messages receive blind-signing screens and an exact transaction + commitment. +- A non-Cosmos application built on Tendermint or CometBFT can reuse the + generalized byte-signing engine, but still needs an adapter defining its + signing domain, preimage, and key algorithm. A Cosmos `SignDoc` is not a + universal transaction format for every CometBFT application. + +The target is therefore universal signing compatibility for supported +algorithms and defined signing domains, not a claim that the firmware +semantically understands every application. + +## Goals + +- Implement Cosmos SDK protobuf `SIGN_MODE_DIRECT`. +- Preserve legacy Amino support for chains that still require it. +- Blind-sign otherwise valid, unsupported Cosmos messages behind Advanced Mode. +- Progressively clear-sign known bank, staking, distribution, governance, IBC, + authz, feegrant, CosmWasm, and chain-specific messages. +- Derive and display addresses for arbitrary compatible chains without + conflating display names, registry identities, and Bech32 prefixes. +- Support arbitrary Cosmos coin denoms, canonical integer amounts, multiple + coins, fee grants, and fee payers within explicit resource limits. +- Clear-sign ICS-20 transfers for all valid routes and assets, including + voucher, factory, and long trace-derived denoms. +- Reuse a bounded, session-oriented byte-signing core for non-Cosmos + CometBFT-family adapters. +- Keep every reviewed value cryptographically bound to the exact signed + preimage. + +## Non-goals and boundaries + +- The firmware does not discover IBC paths, channels, counterparty chains, or + relayers. The host builds the route; the device reviews the source-chain + transaction. +- Blind signing does not provide semantic verification. A digest and byte count + prove which bytes were approved, not what an unknown message will do. +- Host-provided labels, symbols, decimals, recipients, or amounts must never + substitute for values parsed from the signed bytes. +- `SIGN_MODE_DIRECT` does not cover every non-Cosmos CometBFT application. + Applications with different signing preimages or algorithms require explicit + adapters. +- Multisig, `SIGN_MODE_DIRECT_AUX`, `SIGN_MODE_TEXTUAL`, and non-secp256k1 keys + are not implied by the first implementation. They require explicit protocol + and UX decisions. + +## Current implementation and gaps + +The existing generic Tendermint protocol is a legacy Amino implementation. It is +useful compatibility code, but it is not a base for universal support. + +| Area | Current behavior | Required v2 behavior | +| --- | --- | --- | +| Sign mode | Legacy Amino JSON only | Exact protobuf `SignDoc` bytes for `SIGN_MODE_DIRECT`, plus legacy Amino | +| Generic messages | Wire schema advertises several message types; firmware handler accepts only `send` | Known messages clear-sign; every other valid `Any` can blind-sign | +| IBC | Generic IBC schema has no amount field | Bind token, sender, receiver, port, channel, timeouts, and memo | +| Amounts | Message amount is `uint64`; fee is `uint32` | Canonical unsigned decimal strings with an explicit maximum | +| Denoms | Generic denom is limited to 9 characters | Long exact denoms, including `ibc/<64-hex>` and factory denoms | +| Fees | One session-wide denom is reused | Repeated fee coins, independent message coins, gas, payer, and granter | +| Chain identity | `chain_name` is both coin lookup name and address HRP; `address_prefix` is ignored | Immutable, separate chain ID, registry ID, display name, and HRPs | +| Address support | Limited by the compiled coin registry | Validated chain descriptors and explicit path policy | +| Tests | Generic coverage is primarily session/config binding | Cross-SDK vectors, parser adversarial tests, emulator UX, and hardware QA | +| Host integration | Generic address support is partial; generic signing is not end-to-end | Protocol, hdwallet, Vault, registry, builder, and broadcast support | + +The existing `Tendermint*`, `Cosmos*`, and `Osmosis*` message IDs must remain +compatible. The new design should use new v2 message IDs and implementation +state rather than changing the meaning or size limits of old fields. + +## Signing and review tiers + +The transaction is assigned the least-trusted tier required by any component. +One unknown message downgrades the transaction to blind or mixed review; it must +not inherit a clear-sign label from neighboring known messages. + +### Tier A: clear signing + +All security-relevant fields are parsed from the exact signed bytes and displayed: + +- chain ID and signing account; +- message actions, senders, recipients, validators, contracts, and parameters; +- exact coin amounts and denoms; +- memo and timeout values; +- fees, gas, payer, granter, and sequence as applicable. + +Friendly symbols or decimal conversion may supplement raw values only when the +metadata is trusted and the exact denom remains reviewable. + +### Tier B: mixed review + +The firmware understands the transaction envelope and some messages, but at +least one message or extension is opaque. It: + +- clearly labels the whole transaction as containing unverified actions; +- displays all safely parsed envelope and known-message fields; +- displays each unknown `type_url`, value byte count, and value digest; +- displays an overall signing-preimage digest and byte count; +- requires Advanced Mode and explicit blind-sign confirmation. + +### Tier C: blind signing + +The adapter can validate the signing domain and sign the exact preimage, but +cannot semantically parse the application payload. It: + +- requires Advanced Mode; +- displays a prominent "Blind Sign" warning; +- displays the chain/signing domain, derivation path or derived address, exact + byte count, and full transaction digest; +- never displays host side-channel values as if they came from the transaction; +- signs only after an explicit final confirmation. + +Malformed framing, an unsupported key algorithm, a path that cannot be bound to +the signer, an oversized payload, or an ambiguous/non-canonical clear-sign parse +is rejected. Unsupported semantics alone may fall back to Tier B or C. + +Advanced Mode is a hard gate, not the only warning. Enabling it once does not +silently approve future blind signatures. + +## Exact-byte invariant + +The principal invariant is: + +> The device hashes and signs exactly the bytes whose properties and digest it +> reviewed. + +For `SIGN_MODE_DIRECT`, the secp256k1 signature is over the SHA-256 digest of the +serialized `cosmos.tx.v1beta1.SignDoc`: + +- `body_bytes` +- `auth_info_bytes` +- `chain_id` +- `account_number` + +The preferred protocol streams the serialized `SignDoc` bytes to the device. +The device simultaneously: + +1. validates ordered chunk framing and the declared total length; +2. hashes the exact incoming bytes; +3. parses display information from those same immutable bytes; +4. binds the selected key and signer information; +5. performs the required review flow; +6. signs the accumulated digest without reconstructing a different `SignDoc`. + +Hashing host-provided structured fields and later reserializing them creates an +avoidable display/signature mismatch risk. If structured transport is retained +for memory reasons, the framing itself must define one canonical serialized +preimage and the firmware must both parse and hash that exact stream. + +For Tier A clear signing, protobuf handling must reject encodings whose meaning +could differ between the firmware and a node, including ambiguous duplicate +singular fields, malformed or overlong varints, invalid lengths, truncated +submessages, conflicting signer modes, and unsupported critical extension +options. A structurally valid unknown `Any` is not malformed and may use the +blind tier. + +For Tier B or C, the transaction digest should be shown in full using the +existing exact-byte pager. The digest is an exact commitment, not a semantic +summary. + +## Proposed v2 protocol + +Exact names and field numbers require a device-protocol review. The logical flow +is: + +```text +CosmosSignDirectInit + -> CosmosSignDirectRequest(offset, max_chunk) + -> CosmosSignDirectChunk(offset, bytes) + -> ... repeat ... + -> device review + -> CosmosSignedDirect(public_key, signature, sign_doc_hash) +``` + +`CosmosSignDirectInit` should bind at least: + +- derivation path; +- expected serialized `SignDoc` length; +- expected SHA-256 digest, used only as a transport cross-check; +- requested signing mode and key algorithm; +- optional registry identity and descriptor version; +- host-declared review expectation, which the device may only downgrade. + +The device owns the running offset, remaining length, parser state, and hash. +Chunks must be contiguous and accepted exactly once. Any unexpected message, +offset, length, initialization attempt, cancellation, USB reset, or parser error +aborts and wipes the session. + +The response returns the compressed public key, compact 64-byte signature, and +the device-computed `SignDoc` digest so the host can cross-check the exact +preimage. + +The implementation should not accept a host-computed digest as the only thing +to sign. A future prehashed expert primitive, if ever required by a distinct +application, must be a separately named domain, use a stronger warning, and +display the complete digest. + +## Direct-mode parser + +The parser operates incrementally with bounded memory. It recognizes: + +- `TxBody.messages` as repeated `Any { type_url, value }`; +- memo and timeout height; +- extension and non-critical extension options; +- `AuthInfo.signer_infos`, public keys, `ModeInfo`, and sequence; +- `Fee.amount`, gas limit, payer, and granter; +- tip fields where the target SDK supports them. + +Initial scope should require one signer and `SIGN_MODE_DIRECT`. The firmware +derives the public key, verifies that the signer info and account address belong +to that key where those values are available, and rejects a mode substitution. +Multisig and multi-signer transactions should remain unsupported until the +review flow and signature assembly contract are specified. + +Message adapters consume the exact `Any.value` bytes. They may promote a message +to clear signing only after: + +- the `type_url` is an exact supported value; +- required fields and canonical encodings validate; +- signer/owner/sender fields bind to the derived key where applicable; +- all security-relevant fields fit and are reviewable; +- unknown critical fields cannot change the reviewed meaning. + +An adapter failure caused by unknown semantics downgrades to blind review. A +failure caused by malformed or contradictory encoding rejects the transaction. + +## Chain descriptors and addresses + +A v2 chain descriptor separates values that the legacy protocol conflates: + +- immutable registry ID and descriptor version; +- network chain ID; +- display name; +- account, validator, and consensus Bech32 HRPs; +- derivation path policy and SLIP-0044 coin type; +- public-key/signature algorithm; +- allowed sign modes; +- trusted native asset metadata, if any. + +The transaction-bound chain ID is always displayed. A friendly chain name cannot +replace it. + +Descriptors may be firmware-curated or supplied by a host registry with an +authenticated provenance model. Until that trust model is implemented, +host-supplied metadata is untrusted: display raw chain ID, HRP, path, exact denom, +and atomic amount. An unknown descriptor may still use blind signing when its +algorithm and address construction are supported. + +Address requests must use the explicit account HRP, not a coin lookup name. +Non-standard paths require the existing path warning policy. The displayed +address and the signing key must be derived from the same descriptor and path. + +The first algorithm target is Cosmos-style secp256k1. Ethermint +`ethsecp256k1`, ed25519, and other application key types require explicit +derivation, address, signature, and test-vector support; they must not be +silently treated as standard Cosmos secp256k1. + +## Coins, amounts, and fees + +The v2 schema should model Cosmos `Coin` directly: + +```text +Coin { + string denom + string amount +} +``` + +- `amount` is a canonical unsigned base-10 integer string: digits only, no sign, + whitespace, decimal point, exponent, or leading zeroes except `"0"`. +- Firmware defines and tests an explicit maximum digit count. A 256-bit ceiling + is a reasonable initial engineering target, subject to SDK compatibility and + OLED/resource tests. +- `denom` has a documented byte maximum large enough for IBC and factory denoms + and validates the relevant Cosmos SDK grammar. +- Every message owns its own coin list; fee coins are independent and repeated. +- Unknown metadata displays atomic amount plus exact denom. Trusted metadata may + add a decimal rendering but cannot hide the atomic pair. + +## IBC support + +The first clear-sign IBC adapter should support the protobuf +`ibc.applications.transfer.v1.MsgTransfer` forms used by target SDK versions. +Review must bind and show: + +- source port and channel; +- token amount and exact denom; +- sender and receiver; +- timeout height revision number and revision height; +- timeout timestamp; +- memo, including packet-forwarding JSON as exact text unless a separately + validated adapter interprets it. + +This works for native denoms, `ibc/` voucher denoms, factory denoms, and +other valid source-chain denominations. The firmware does not need a compiled +pairwise matrix of every source and destination chain. + +"IBC between all supported chains" means the host may construct any valid +source-chain ICS-20 route and the device can sign it. It does not mean the device +asserts that a channel reaches the host-claimed destination. Without trusted, +fresh channel proofs, the device should display the exact port/channel and +receiver rather than invent a destination-chain guarantee. + +IBC messages from unknown modules remain viable through the blind tier. + +## Legacy Amino + +Legacy Amino remains available for older chains but should also receive a v2 +transport rather than extending the current fixed-width schema. + +Two safe approaches are possible: + +1. stream the exact canonical Amino JSON sign bytes and blind-sign them; or +2. stream typed fields while the firmware constructs one canonical JSON + preimage and clear-signs supported message adapters. + +The first approach provides broad compatibility sooner. The second provides +stronger semantic review. They can coexist, with exact-byte blind support first +and structured adapters added incrementally. + +The v2 Amino model must separate message and fee denoms, use string amounts, +support long denoms and multiple coins, and bind all configuration to one +session. The old protocol continues unchanged for compatibility. + +## Non-Cosmos CometBFT applications + +The generalized core should expose an internal adapter contract: + +```text +domain identifier +preimage framing rules +hash function +key/signature algorithm +optional bounded parser +review classification +``` + +A new application can begin at Tier C by defining and testing its exact signing +preimage. Later parser adapters can promote its operations to Tier B or A. This +is the path to genuinely broad Tendermint/CometBFT support without pretending +all applications share Cosmos protobuf transaction semantics. + +Every external protocol gets a distinct domain identifier. Cross-domain ACKs, +chunks, finalization calls, and session reuse are rejected. + +## Rollout + +### Phase 0: specification and vectors + +- Freeze v2 threat model, wire flow, limits, canonical protobuf policy, and abort + behavior. +- Collect `SIGN_MODE_DIRECT` golden vectors from Cosmos SDK and at least one + independent host implementation. +- Choose an initial descriptor trust model and supported key algorithms. + +### Phase 1: universal Cosmos direct blind signing + +- Stream and hash exact `SignDoc` bytes on device. +- Validate the envelope enough to establish domain, chain ID, signer mode, + signer key, and resource safety. +- Permit unknown `Any` messages through Tier B/C in Advanced Mode. +- Show the blind warning, path/address, byte count, and full digest. +- Return signature, public key, and device-computed digest. + +This phase deliberately provides useful new-chain support before every module +has a clear-signing adapter. + +### Phase 2: envelope clear signing + +- Clear-sign chain ID, account number, sequence, memo, timeout, fee coins, gas, + payer, and granter when parsed unambiguously. +- Add trusted/untrusted chain descriptor UX. +- Downgrade extension options that are valid but not understood. + +### Phase 3: common message clear signing + +- Bank send and multi-send. +- Staking delegate, undelegate, redelegate, and cancel-unbonding. +- Distribution rewards and validator commission. +- Governance vote, deposit, and proposal variants. +- ICS-20 transfer. + +### Phase 4: application adapters + +- CosmWasm execute/instantiate/migrate, with contract payload remaining blind + until schema-aware review exists. +- Authz and feegrant, including nested-message downgrade rules. +- Chain-specific modules chosen by use and risk. +- Additional CometBFT signing domains and key algorithms. + +### Phase 5: default clear signing + +- Promote well-tested chains and message families to Tier A by default. +- Keep Advanced Mode fallback for valid unknown additions so protocol upgrades + do not make the chain unusable. + +## Workstreams + +| Repository/layer | Work | +| --- | --- | +| device-protocol | Add v2 init/chunk/request/response messages, limits, generated bindings, and message IDs | +| firmware transport | Session isolation, ordered streaming, abort/wipe behavior, and response framing | +| firmware crypto | Exact-byte hashing, secp256k1 signing, key/signer binding, and domain separation | +| firmware parser | Bounded `SignDoc`, `TxBody`, `AuthInfo`, `Any`, `Coin`, and message adapters | +| firmware UX | Tier labels, blind warning, digest/byte count, exact-value paging, and cancellation | +| hdwallet | Generic address and sign APIs, chunk driver, registry plumbing, and signature cross-checks | +| Vault | Generic address/sign endpoints, policy errors, transport recovery, and no metadata substitution | +| chain registry | Versioned descriptors, HRPs, paths, algorithms, assets, and provenance | +| host integrations | Transaction building, IBC route discovery, broadcast, and chain test vectors | +| QA/release | Unit, emulator, hardware OLED, fuzzing, interoperability, and downgrade tests | + +## Test and release gates + +### Cryptographic and interoperability + +- Device signatures match Cosmos SDK and independent host vectors. +- The returned digest equals SHA-256 of the exact serialized `SignDoc`. +- Signatures verify and broadcast on representative SDK versions and chains. +- Legacy Amino behavior remains byte-for-byte compatible. + +### Parser and transport adversarial tests + +- truncated fields and submessages; +- oversized lengths, integer overflow, and resource-limit boundaries; +- non-minimal/overlong varints and duplicate singular fields; +- unknown fields, unknown `Any` values, and extension options; +- chunk replay, gaps, overlap, reordering, early finalization, and excess bytes; +- session replacement, cross-domain ACKs, stale chunks, and USB interruption; +- signer-mode, public-key, path, chain-ID, fee, and sequence substitution; +- cancellation from every screen, proving that no signature is returned. + +### Review classification + +- fully known transactions are Tier A; +- any opaque nested or top-level action forces Tier B/C; +- unknown semantics can blind-sign in Advanced Mode; +- malformed transactions never downgrade to blind signing; +- no host-only label is presented as signed data; +- blind review always shows warning, byte count, and full digest; +- disabling Advanced Mode blocks every blind path. + +### Asset and IBC matrix + +- native, IBC voucher, factory, slash-containing, and maximum-length denoms; +- maximum amount values and multiple message/fee coins; +- native and non-native fee denoms; +- IBC timeout height, timeout timestamp, memo, and no-timeout edge cases; +- receivers with different valid HRPs and maximum supported length; +- packet-forwarding memos and unknown IBC module messages; +- representative Cosmos Hub, Osmosis, THORChain/MAYAChain-style legacy, and + additional SDK chains. + +### Device evidence + +- full firmware and constrained configurations fit ROM/SRAM budgets; +- parser stack frames retain the required reserve; +- fuzz targets and sanitizer builds pass on the host; +- emulator and physical OLED captures prove every maximum-length field can be + reviewed; +- cancellation and blind-sign policy behavior pass on physical hardware. + +## Open decisions + +- Stream a complete serialized `SignDoc`, or stream its exact field framing? + Complete exact bytes are preferred if the incremental parser can meet memory + limits. +- Require canonical protobuf for all tiers, or only for clear signing? The + safest initial policy is canonical envelope encoding for every tier. +- Use only global Advanced Mode, or add a per-session blind-sign capability? + Initial recommendation: Advanced Mode plus confirmation on every transaction. +- What descriptor provenance is trusted enough for symbols and decimals? +- What exact maxima apply to transaction bytes, nesting, messages, denoms, + amounts, HRPs, and type URLs? +- Which Ethermint key/address variants are in the first supported algorithm set? +- How should multisig, multiple signer infos, tips, fee payers, authz nesting, + and fee grants be reviewed? +- When should `SIGN_MODE_DIRECT_AUX` and `SIGN_MODE_TEXTUAL` be added? +- Which legacy Amino chains require exact JSON streaming before direct-mode + support? + +## Definition of done + +The generalized Cosmos milestone is complete when: + +- any structurally valid, resource-bounded Cosmos SDK `SIGN_MODE_DIRECT` + transaction using a supported key algorithm can be signed through an explicit + Advanced Mode blind path, even when its messages are unknown; +- known message adapters clear-sign only values parsed from the exact signed + bytes; +- arbitrary valid coin denoms and integer amounts work within documented limits; +- any valid host-constructed ICS-20 source-chain transfer can be signed, without + a compiled pairwise chain matrix; +- the address, signer key, signing mode, chain ID, and signed preimage are bound + to one isolated session; +- malformed data, ambiguous clear-sign encodings, and unsupported algorithms + fail closed; +- cancellation never produces a signature; +- protocol, firmware, hdwallet, Vault, registry, and hardware QA are delivered + together. + +The broader CometBFT-family milestone is complete only when each additional +application has an explicit, tested signing-domain adapter. Its initial adapter +may be blind-only; semantic clear signing can follow. diff --git a/docs/README.md b/docs/README.md index e456f6ad5..f64d8f0d0 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) +* [Generalized Cosmos and Tendermint Signing](Generalized-Tendermint-Signing.md) From a86903bbce1e6dc6a6c22d98236a73b1362e990b Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 14:50:59 -0300 Subject: [PATCH 53/55] docs(rc17): narrow physical QA to smoke gate --- docs/security/7.15.0-rc17-hardening.md | 111 ++++++++++--------------- 1 file changed, 44 insertions(+), 67 deletions(-) diff --git a/docs/security/7.15.0-rc17-hardening.md b/docs/security/7.15.0-rc17-hardening.md index 537c1e4d8..08c4606ec 100644 --- a/docs/security/7.15.0-rc17-hardening.md +++ b/docs/security/7.15.0-rc17-hardening.md @@ -11,15 +11,15 @@ 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 and the -physical-device checklist below are both green. Automated evidence in this -document qualifies the remediated head for hardware QA; it does not authorize -merge, tagging, or release. +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. The physical-device checklist remains the release -gate. +`29980175333` on 2026-07-23. ## Follow-up audit block @@ -218,6 +218,9 @@ Additional gates: - 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. @@ -254,64 +257,38 @@ 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. -## Final hardware QA checklist - -Use a disposable test seed and verify on a physical KeepKey: - -1. Cancel authenticator add, remove, and wipe at every confirmation screen; - reconnect and confirm that storage did not change. -2. Add a valid TOTP account, compare generated codes with an independent - authenticator, verify duplicate and sub-128-bit secrets are rejected, remove - it, then wipe the authenticator store. -3. Sign representative Hive transfer, comment, and multi-authorization - `custom_json` operations. Confirm every authorization, ID byte, and JSON - byte is reviewable, including the adversarial `%`/space payload across page - boundaries; malformed account names and overlong varints must fail. -4. Exercise supported Uniswap V2 add/remove liquidity fixtures. Confirm token - amount, LP burn amount, minimums, native value, recipient, and deadline. - Confirm Polygon/router reuse, dirty ABI address words, high deadline bits, - and arbitrary approval targets never receive a Uniswap protocol label. - Cancel each screen in a separate run and confirm no signature is returned. -5. Sign Bitcoin-family, Ethereum, and TRON messages at the schema maximum. Test - embedded NUL, binary, and the exact 69-byte `%`/space word-wrap payload; - every byte or hex octet must be reviewable and cancellation on every page - must return no signature. -6. Repeat exact-byte review checks for Binance, Tendermint, Cosmos, and Osmosis - 256-byte memos. Smoke-test THORChain/MAYAChain/Ripple/TRON/Solana memo paths, - Solana/TON blind messages, identity challenges, and binary `OP_RETURN`. -7. With AdvancedMode disabled, verify Ethereum typed-hash and EOS unknown-action - signing terminate with no signature. With it enabled, verify the expected - opaque flow remains available. Confirm `eosio::newaccount` always uses its - structured handler. -8. Exercise EIP-712 long dynamic values, exact 20-byte addresses, dynamic and - fixed bytes, integer boundaries, booleans, missing fields, malformed JSON - node shapes, and primary-type prefixes such as `EIP`. Malformed values must - fail before a signature and every accepted value must be fully reviewable. -9. Sign Cosmos IBC transfers with distinct sender and receiver values. Confirm - both exact addresses, amount, channel, port, and timeout; missing receiver or - a denomination other than `uatom` must fail. -10. Attempt generic Tendermint ACKs with a changed chain name, denomination, - message-type prefix, and a Cosmos/Tendermint cross-protocol ACK. Each must - abort the session. Confirm accepted amounts and fees use the exact signed - atomic denomination. -11. Exercise Binance denominations at 31 characters and reject 32-character, - lowercase, control-character, mismatched, zero, and negative cases without - a reset or signature. -12. Run upgrade, reboot, PIN/passphrase, transaction-signing, and recovery smoke - tests for full, Bitcoin-only, and Zcash-privacy firmware artifacts. -13. Send otherwise-identical raw Osmosis `MsgSend` ACKs using `uosmo` and a - canonical non-native denomination such as `uatom`. Confirm the exact - denomination is reviewable, both transactions sign, and their signatures - differ. Repeat with leading-zero, signed, whitespace-prefixed, and - greater-than-`UINT64_MAX` amount strings and confirm each fails before a - signature. -14. Review maximum-length accepted Osmosis Send and Swap fixtures. Confirm the - Send amount and destination and both Swap assets are on distinct screens, - every 68-character Swap denomination is visible through its final - character, and the signed minimum output is never below the OLED boundary. -15. Exercise maximum 32-digit Osmosis LP add/remove share amounts. Confirm the - final decimal digit is present, cancellation returns no signature, and the - device neither resets nor reports a memory fault. - -RC17 should be promoted only after the pull request CI matrix and this physical -device checklist are both green. +## 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. From d10d09ddee0d225343a2052fd58f0c2d6d018851 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 15:47:30 -0300 Subject: [PATCH 54/55] fix(release): support RC tags and selective submodules --- .github/workflows/release.yml | 66 ++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06acecff9..f5ecbf661 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 @@ -29,22 +29,39 @@ 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 - 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})" - exit 1 + 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 }} @@ -78,8 +95,15 @@ jobs: cmake_flags: "-DKK_ZCASH_PRIVACY=ON" steps: - uses: actions/checkout@v6 - with: - submodules: recursive + + - 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 @@ -179,8 +203,15 @@ jobs: cmake_flags: "-DKK_ZCASH_PRIVACY=ON" steps: - uses: actions/checkout@v6 - with: - submodules: recursive + + - 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 @@ -231,7 +262,7 @@ jobs: 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 (per variant) - [ ] Built on multiple machines, hashes match @@ -263,7 +296,8 @@ jobs: uses: softprops/action-gh-release@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 From ac4e637c17c86703ecc1c62ffecd9d7330c2f285 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 23 Jul 2026 17:34:24 -0300 Subject: [PATCH 55/55] fix: render multiline signed messages as text --- include/keepkey/firmware/app_confirm.h | 10 +++--- lib/firmware/app_confirm.c | 40 ++++++++++++++++-------- unittests/firmware/CMakeLists.txt | 1 + unittests/firmware/app_confirm.cpp | 42 ++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 unittests/firmware/app_confirm.cpp diff --git a/include/keepkey/firmware/app_confirm.h b/include/keepkey/firmware/app_confirm.h index b9c62adbe..cf1630288 100644 --- a/include/keepkey/firmware/app_confirm.h +++ b/include/keepkey/firmware/app_confirm.h @@ -48,11 +48,13 @@ 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 is paged as - * text; any payload containing a 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. + * 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); diff --git a/lib/firmware/app_confirm.c b/lib/firmware/app_confirm.c index 1bbf086cd..fdd9378a8 100644 --- a/lib/firmware/app_confirm.c +++ b/lib/firmware/app_confirm.c @@ -417,15 +417,19 @@ bool confirm_sign_identity(const IdentityType* identity, body); } -static bool confirm_bytes_is_ascii(const uint8_t* data, size_t size) { +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 true; + return has_visible_character; } static size_t confirm_bytes_render_page(const uint8_t* data, size_t size, - bool ascii, + bool text, char rendered[BODY_CHAR_MAX]) { if (size == 0) return 0; @@ -436,9 +440,21 @@ static size_t confirm_bytes_render_page(const uint8_t* data, size_t size, 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 (ascii) { + if (text) { chars[0] = (char)data[consumed]; char_count = 1; } else { @@ -456,7 +472,7 @@ static size_t confirm_bytes_render_page(const uint8_t* data, size_t size, // 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 (ascii && chars[0] == ' ') { + if (text && chars[0] == ' ') { uint32_t word_width = width; for (size_t i = consumed + 1; i < size && data[i] != ' ' && data[i] != '\n'; i++) { @@ -503,13 +519,13 @@ bool confirm_bytes(ButtonRequestType button_request, const char* title, if (!title || (!data && size != 0)) return false; if (size == 0) return confirm(button_request, title, "(empty)"); - const bool ascii = confirm_bytes_is_ascii(data, size); + 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, - ascii, rendered); + const size_t take = + confirm_bytes_render_page(data + offset, size - offset, text, rendered); if (take == 0) return false; offset += take; pages++; @@ -518,14 +534,14 @@ bool confirm_bytes(ButtonRequestType button_request, const char* title, 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, - ascii, rendered); + 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 || !ascii) { + if (pages > 1 || !text) { snprintf(page_title, sizeof(page_title), - ascii ? "%s %u/%u" : "%s Hex %u/%u", title, (unsigned)(page + 1), + text ? "%s %u/%u" : "%s Hex %u/%u", title, (unsigned)(page + 1), (unsigned)pages); } else { strlcpy(page_title, title, sizeof(page_title)); diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index b6e221f29..f2c47a261 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -1,4 +1,5 @@ set(sources + app_confirm.cpp recovery.cpp storage.cpp usb_rx.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)); +}