diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 784473e79..af041d590 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 @@ -215,6 +216,16 @@ jobs: needs: [lint-format, static-analysis, check-submodules, secret-scan] runs-on: ubuntu-latest timeout-minutes: 15 + 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: - name: Checkout uses: actions/checkout@v6 @@ -250,20 +261,21 @@ 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 @@ -271,6 +283,16 @@ jobs: needs: [lint-format, static-analysis, check-submodules, secret-scan] runs-on: ubuntu-latest timeout-minutes: 15 + 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: - name: Checkout uses: actions/checkout@v6 @@ -312,7 +334,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,7 +342,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/bin && \ cp bin/*.bin /root/keepkey-firmware/bin/ && \ @@ -332,19 +355,19 @@ jobs: cd bin for f in *.bin; do [ -f "$f" ] || continue - mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${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 @@ -358,24 +381,34 @@ jobs: needs: build-emulator runs-on: ubuntu-latest timeout-minutes: 10 + 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: - 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 +418,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 +717,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 +780,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 +796,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..c33c36b6f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "deps/device-protocol"] path = deps/device-protocol -url = https://github.com/keepkey/device-protocol.git + url = https://github.com/BitHighlander/device-protocol.git branch = master [submodule "deps/trezor-firmware"] path = deps/crypto/trezor-firmware diff --git a/CMakeLists.txt b/CMakeLists.txt index cee2e661e..d4411fced 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,8 @@ 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) +option(KK_ZCASH_PRIVACY "Build the Zcash shielded/Orchard privacy engine" OFF) # When building the dylib, every static lib it links (kkfirmware, kkboard, # trezorcrypto, kkrand, kktransport, qrcodegenerator, SecAESSTM32, ...) must @@ -97,13 +99,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) @@ -129,6 +139,33 @@ 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) 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..2ec999a9b 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit d637b78291a423fd8119df9935a9365be8a7758e +Subproject commit 2ec999a9b2e5174da5981e85f66845a97cdaa877 diff --git a/deps/python-keepkey b/deps/python-keepkey index fabd6c618..c101137ca 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit fabd6c6189b7f1b3ea7cbd1d372fc13729761178 +Subproject commit c101137cacf94530e34d3e85144adf884fc41fe5 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/layout.h b/include/keepkey/board/layout.h index ed5ab8f33..9ce9c718d 100644 --- a/include/keepkey/board/layout.h +++ b/include/keepkey/board/layout.h @@ -79,6 +79,7 @@ typedef enum { typedef enum { NO_ICON = 0, ETHEREUM_ICON, + VERIFIED_ICON, } IconType; typedef void (*AnimateCallback)(void* data, uint32_t duration, 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_contracts/thortx.h b/include/keepkey/firmware/ethereum_contracts/thortx.h index 8f61c27f3..1bdc50aca 100644 --- a/include/keepkey/firmware/ethereum_contracts/thortx.h +++ b/include/keepkey/firmware/ethereum_contracts/thortx.h @@ -30,7 +30,15 @@ "\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 (mainnet) */ +#define MAYA_ROUTER "d89dce570de35a6f42d3bca7dba50a6d89bfc2a2" /* deposit(address,address,uint256,string) — legacy selector */ #define THOR_SELECTOR_DEPOSIT "\x1f\xec\xe7\xb4" @@ -43,6 +51,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..6f38457f3 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,20 @@ 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); + #if DEBUG_LINK // void fsm_msgDebugLinkDecision(DebugLinkDecision *msg); void fsm_msgDebugLinkGetState(DebugLinkGetState* msg); @@ -142,4 +158,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..f791690ec --- /dev/null +++ b/include/keepkey/firmware/hive.h @@ -0,0 +1,98 @@ +#ifndef KEEPKEY_FIRMWARE_HIVE_H +#define KEEPKEY_FIRMWARE_HIVE_H + +#include "trezor/crypto/bip32.h" +#include "messages-hive.pb.h" + +// ── Hive mainnet chain ID ───────────────────────────────────────────────── +// 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' +#define HIVE_SLIP48_PURPOSE (0x80000030u) // 48' +#define HIVE_SLIP48_NETWORK (0x8000000Du) // 13' — Hive SLIP-0048 network ID +#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_TRANSFER 2 +#define HIVE_OP_ACCOUNT_CREATE 9 +#define HIVE_OP_ACCOUNT_UPDATE 10 + +// ── Protocol limits ─────────────────────────────────────────────────────── +#define HIVE_MAX_ACCOUNT_LEN 16 // max Hive username length +#define HIVE_DECIMALS 3 // HIVE and HBD both use 3 decimal places + +// ── 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); + +/** + * 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..300b73427 100644 --- a/include/keepkey/firmware/mayachain.h +++ b/include/keepkey/firmware/mayachain.h @@ -10,6 +10,10 @@ 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); + 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/signed_metadata.h b/include/keepkey/firmware/signed_metadata.h new file mode 100644 index 000000000..b2cf2800c --- /dev/null +++ b/include/keepkey/firmware/signed_metadata.h @@ -0,0 +1,179 @@ +#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 +/* 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. */ +void signed_metadata_store_signer(uint8_t key_id, const uint8_t *pubkey, + const char *alias); + +/* 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..d22b1b249 100644 --- a/include/keepkey/firmware/storage.h +++ b/include/keepkey/firmware/storage.h @@ -27,6 +27,18 @@ #define STORAGE_VERSION \ 17 /* 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); @@ -70,6 +88,11 @@ void storage_loadDevice(LoadDevice* msg); /// \return true iff the root node was found. bool storage_getRootNode(const char* curve, bool usePassphrase, HDNode* node); +/// \brief Get the raw 64-byte BIP-39 seed, triggering passphrase if needed. +/// \param usePassphrase[in] Whether to use the passphrase. +/// \returns pointer to 64-byte seed, or NULL on failure. +const uint8_t* storage_getRawSeed(bool usePassphrase); + /// \brief Fetch the node used for U2F signing. /// \returns true iff retrieval was successful. bool storage_getU2FRoot(HDNode* node); diff --git a/include/keepkey/firmware/thorchain.h b/include/keepkey/firmware/thorchain.h index 5ebb2a993..bc2ca6749 100644 --- a/include/keepkey/firmware/thorchain.h +++ b/include/keepkey/firmware/thorchain.h @@ -10,9 +10,13 @@ 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); + 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..6f0455874 --- /dev/null +++ b/include/keepkey/firmware/zcash.h @@ -0,0 +1,400 @@ +/* + * 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); + +/** + * Derive an Orchard-only ZIP-316 Unified Address directly from seed material. + * + * Production firmware should continue to access the seed only through the + * storage-scoped wrappers below; this composition helper is exposed for + * isolated unit tests and storage-owned call sites. + * + * @param seed BIP-39 master seed + * @param seed_len Seed length (typically 64 bytes) + * @param account Account index (0-based, will be hardened) + * @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_derive_orchard_unified_address(const uint8_t* seed, + uint32_t seed_len, uint32_t account, + 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..eec9eee40 100644 --- a/include/keepkey/transport/messages-ethereum.options +++ b/include/keepkey/transport/messages-ethereum.options @@ -49,4 +49,6 @@ 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 diff --git a/include/keepkey/transport/messages-hive.options b/include/keepkey/transport/messages-hive.options new file mode 100644 index 000000000..e878f3e3b --- /dev/null +++ b/include/keepkey/transport/messages-hive.options @@ -0,0 +1,46 @@ +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 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..54bf6da92 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; diff --git a/lib/board/layout.c b/lib/board/layout.c index 59e9dd9e2..d2b718c36 100644 --- a/lib/board/layout.c +++ b/lib/board/layout.c @@ -327,6 +327,10 @@ void layout_standard_notification(const char* str1, const char* str2, 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; 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/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..dba3798d9 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; } @@ -548,7 +552,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; diff --git a/lib/firmware/ethereum.c b/lib/firmware/ethereum.c index b7c12c1b5..1cd0025e3 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,6 +1024,7 @@ 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; } 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..433fac2eb 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,9 +82,24 @@ 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* thorchainData; const uint8_t* contractAssetAddress; const uint8_t *vaultAddress, *assetAddress; uint32_t ctr; @@ -71,35 +110,32 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 32 + 12); bn_from_bytes(msg->data_initial_chunk.bytes + 4 + 2 * 32, 32, &Amount); /* deposit(): memo at 4 + 5*32; depositWithExpiry(): memo at 4 + 6*32 */ - thorchainData = - (uint8_t*)(msg->data_initial_chunk.bytes + 4 + (is_expiry ? 6 : 5) * 32); + thorchainData = (const 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; } - // just display token address and amount as string for (ctr = 0; ctr < 20; ctr++) { snprintf(&confStr[ctr * 2], 3, "%02x", vaultAddress[ctr]); } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data", + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, "Using Asgard vault %s", confStr)) { return false; } if (memcmp(contractAssetAddress, ETH_ADDRESS, sizeof(ETH_ADDRESS)) == 0) { - assetAddress = (const uint8_t*) - ETH_NATIVE; // get eth native parameters if asset is not a token + assetAddress = (const uint8_t*)ETH_NATIVE; } else { assetAddress = contractAssetAddress; } @@ -107,30 +143,24 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { 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, - "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 - // number 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; } - } else { 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 +169,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..1394e05ec 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) {} // ponytail: no shielded signing to abort +#endif diff --git a/lib/firmware/fsm_msg_bip85.h b/lib/firmware/fsm_msg_bip85.h new file mode 100644 index 000000000..420d4ba55 --- /dev/null +++ b/lib/firmware/fsm_msg_bip85.h @@ -0,0 +1,143 @@ +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; + static char CONFIDENTIAL tokened_mnemonic[TOKENED_MNEMONIC_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]; + + strlcpy(tokened_mnemonic, mnemonic_buf, TOKENED_MNEMONIC_BUF); + memzero(mnemonic_buf, sizeof(mnemonic_buf)); + + /* Clear formatting buffers */ + memzero(formatted_mnemonic, sizeof(formatted_mnemonic)); + memzero(mnemonic_display, sizeof(mnemonic_display)); + + const char *tok = strtok(tokened_mnemonic, " "); + + while (tok) { + snprintf(formatted_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_display, FORMATTED_MNEMONIC_BUF, "%s %s", + formatted_mnemonic[page_count], formatted_word); + + if (calc_str_line(get_body_font(), mnemonic_display, BODY_WIDTH) > 3) { + page_count++; + + if (MAX_PAGES <= page_count) { + memzero(tokened_mnemonic, sizeof(tokened_mnemonic)); + memzero(formatted_mnemonic, sizeof(formatted_mnemonic)); + memzero(mnemonic_display, sizeof(mnemonic_display)); + memzero(formatted_word, sizeof(formatted_word)); + fsm_sendFailure(FailureType_Failure_Other, + "Too many pages of mnemonic words"); + layoutHome(); + return; + } + + snprintf(mnemonic_display, FORMATTED_MNEMONIC_BUF, "%s %s", + formatted_mnemonic[page_count], formatted_word); + } + + strlcpy(formatted_mnemonic[page_count], mnemonic_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", + formatted_mnemonic[current_page])) { + memzero(tokened_mnemonic, sizeof(tokened_mnemonic)); + memzero(formatted_mnemonic, sizeof(formatted_mnemonic)); + memzero(mnemonic_display, sizeof(mnemonic_display)); + memzero(formatted_word, sizeof(formatted_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(tokened_mnemonic, sizeof(tokened_mnemonic)); + memzero(formatted_mnemonic, sizeof(formatted_mnemonic)); + memzero(mnemonic_display, sizeof(mnemonic_display)); + memzero(formatted_word, sizeof(formatted_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..13e87ccc9 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,27 @@ 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 +#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 +170,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 +347,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 +398,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 +484,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 +527,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 +556,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 +586,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 +682,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 +713,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..2c0ad3d57 100644 --- a/lib/firmware/fsm_msg_ethereum.h +++ b/lib/firmware/fsm_msg_ethereum.h @@ -19,6 +19,71 @@ * along with this library. If not, see . */ +#include "keepkey/firmware/signed_metadata.h" + +void fsm_msgEthereumTxMetadata(const EthereumTxMetadata* msg) { + CHECK_INITIALIZED + CHECK_PIN + + 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")); + + /* Mandatory on-device consent — the whole trust model hangs on this + * confirm. The same fingerprint reappears on every per-tx warning. */ + char fingerprint[METADATA_FINGERPRINT_LEN]; + signed_metadata_pubkey_fingerprint(msg->pubkey.bytes, fingerprint); + if (!confirm(ButtonRequestType_ButtonRequest_Other, _("Load Clearsigner"), + "Trust signer '%s' (%s) to describe transactions? NOT verified " + "by KeepKey.", + msg->alias, fingerprint)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Load clearsign signer cancelled")); + layoutHome(); + return; + } + + signed_metadata_store_signer((uint8_t)msg->key_id, msg->pubkey.bytes, + msg->alias); + 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; diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h new file mode 100644 index 000000000..54603e61b --- /dev/null +++ b/lib/firmware/fsm_msg_hive.h @@ -0,0 +1,458 @@ +/* + * 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) { + // Determine role label for display + const char* role_label = "Hive Public Key"; + if (msg->has_role) { + switch (msg->role) { + 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(); +} + +// ── 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; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + const char* symbol = msg->has_asset_symbol ? msg->asset_symbol : "HIVE"; + char amount_str[32]; + snprintf(amount_str, sizeof(amount_str), "%" PRIu64 ".%03" PRIu64 " %s", + msg->amount / 1000, msg->amount % 1000, symbol); + + 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(); +} + +// ── 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; + } + + // Path must have at least 4 components so we can extract account_index. + // Expected: m/48'/13'/0'/account_index'/0' (count = 5) + if (msg->address_n_count < 4) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Hive path too short")); + layoutHome(); + return; + } + uint32_t account_index = msg->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; + + uint8_t owner_raw[33], active_raw[33], posting_raw[33], memo_raw[33]; + uint32_t acc_hardened = account_index | 0x80000000u; + bool keys_ok = + hive_deriveRawKey(root, HIVE_ROLE_OWNER, acc_hardened, owner_raw) && + hive_deriveRawKey(root, HIVE_ROLE_ACTIVE, acc_hardened, active_raw) && + hive_deriveRawKey(root, HIVE_ROLE_POSTING, acc_hardened, posting_raw) && + hive_deriveRawKey(root, HIVE_ROLE_MEMO, acc_hardened, memo_raw); + // root static buffer is done with; signing node derivation may overwrite it. + + if (!keys_ok) { + memzero(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to derive Hive keys")); + layoutHome(); + return; + } + + // Now get the signing node (owner key, overwrites root static buffer). + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) { + memzero(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + return; + } + hdnode_fill_public_key(node); + + // Encode the device-derived owner key for display confirmation. + char owner_stm[64]; + if (!hive_getPublicKey(owner_raw, owner_stm, sizeof(owner_stm))) { + memzero(node, sizeof(*node)); + memzero(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to encode Hive owner key")); + layoutHome(); + 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(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + 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(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + 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(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signAccountCreate(node, msg, owner_raw, active_raw, posting_raw, + memo_raw, resp); + memzero(node, sizeof(*node)); + memzero(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + + 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; + } + + if (msg->address_n_count < 4) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Hive path too short")); + layoutHome(); + return; + } + uint32_t account_index = msg->address_n[3] & 0x7FFFFFFFu; + + // Derive all four role keys before fetching the signing node. + const HDNode* root = fsm_getDerivedNode(SECP256K1_NAME, NULL, 0, NULL); + if (!root) return; + + uint8_t owner_raw[33], active_raw[33], posting_raw[33], memo_raw[33]; + uint32_t acc_hardened = account_index | 0x80000000u; + bool keys_ok = + hive_deriveRawKey(root, HIVE_ROLE_OWNER, acc_hardened, owner_raw) && + hive_deriveRawKey(root, HIVE_ROLE_ACTIVE, acc_hardened, active_raw) && + hive_deriveRawKey(root, HIVE_ROLE_POSTING, acc_hardened, posting_raw) && + hive_deriveRawKey(root, HIVE_ROLE_MEMO, acc_hardened, memo_raw); + + if (!keys_ok) { + memzero(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to derive Hive keys")); + layoutHome(); + return; + } + + // Signing node (overwrites root static buffer). + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) { + memzero(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + return; + } + hdnode_fill_public_key(node); + + // Encode device-derived owner key for display. + char owner_stm[64]; + if (!hive_getPublicKey(owner_raw, owner_stm, sizeof(owner_stm))) { + memzero(node, sizeof(*node)); + memzero(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to encode Hive owner key")); + layoutHome(); + 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(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + 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(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signAccountUpdate(node, msg, owner_raw, active_raw, posting_raw, + memo_raw, resp); + memzero(node, sizeof(*node)); + memzero(owner_raw, sizeof(owner_raw)); + memzero(active_raw, sizeof(active_raw)); + memzero(posting_raw, sizeof(posting_raw)); + memzero(memo_raw, sizeof(memo_raw)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive account_update signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedAccountUpdate, resp); + layoutHome(); +} diff --git a/lib/firmware/fsm_msg_mayachain.h b/lib/firmware/fsm_msg_mayachain.h index be9229665..cdb21248c 100644 --- a/lib/firmware/fsm_msg_mayachain.h +++ b/lib/firmware/fsm_msg_mayachain.h @@ -141,13 +141,28 @@ 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: { char amount_str[32]; char denom_str[71]; - sprintf(denom_str, " %s", msg->send.denom); + snprintf(denom_str, sizeof(denom_str), " %s", coin_denom); bn_format_uint64(msg->send.amount, NULL, denom_str, 10, 0, false, amount_str, sizeof(amount_str)); if (!confirm_transaction_output( @@ -163,7 +178,7 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } } 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 +187,11 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } } else if (msg->has_deposit) { - char amount_str[32]; - char asset_str[21]; + // 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 +263,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..fc063d3a2 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,11 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } } else if (msg->has_deposit) { - char amount_str[32]; - char asset_str[21]; + // 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 +270,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..54e2fc96f 100644 --- a/lib/firmware/fsm_msg_ton.h +++ b/lib/firmware/fsm_msg_ton.h @@ -112,24 +112,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..e47fbf12b 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; + } - // 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); + 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; + } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send", - "Send %s TRX to %s?", amount_str, msg->to_address)) { + bool confirmed = false; + if (tx_type == TRON_TX_TRANSFER) { + char amount_str[32]; + tron_formatAmount(amount_str, sizeof(amount_str), parsed.amount); + confirmed = confirm(ButtonRequestType_ButtonRequest_SignTx, "TRON", + "Send %s to %s?", amount_str, to_str); + } else { /* TRON_TX_TRC20_TRANSFER */ + char contract_str[TRON_ADDRESS_MAX_LEN]; + char amount_str[90]; + confirmed = + tron_addressFromBytes(parsed.contract, contract_str, + sizeof(contract_str)) && + tron_formatTrc20Amount(parsed.trc20_amount, amount_str, + sizeof(amount_str)) && + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "TRC-20 Transfer", "Token contract %s", contract_str) && + /* Token decimals are not known on-device; show base units. */ + confirm(ButtonRequestType_ButtonRequest_SignTx, "TRC-20 Transfer", + "Send %s base units to %s?", amount_str, to_str); + } + + if (confirmed && parsed.has_fee_limit) { + char fee_str[32]; + tron_formatAmount(fee_str, sizeof(fee_str), parsed.fee_limit); + confirmed = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "TRON", + "Max network fee %s", fee_str); + } + + if (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,18 @@ 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. The user must + * explicitly acknowledge this before seeing the raw 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..891359df6 --- /dev/null +++ b/lib/firmware/fsm_msg_zcash.h @@ -0,0 +1,1388 @@ +/* + * 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: up to 16 actions (64 bytes each) */ + uint8_t signatures[16][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) { + 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)); +} + +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); +} + +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; + } + + /* Determine account — require explicit account or strict ZIP-32 path + * m/32'/133'/account' (all hardened, exactly 3 elements). Matches + * ZcashDisplayAddress / ZcashGetOrchardFVK and prevents a malformed + * host path from silently signing against an unintended account. */ + uint32_t account; + if (msg->has_account) { + account = msg->account; + } else if (msg->address_n_count == 3 && + msg->address_n[0] == (0x80000000 | 32) && + msg->address_n[1] == (0x80000000 | 133) && + (msg->address_n[2] & 0x80000000)) { + account = msg->address_n[2] & 0x7FFFFFFF; + } else { + fsm_sendFailure( + FailureType_Failure_SyntaxError, + _("Require account field or ZIP-32 path m/32'/133'/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; + + switch (zcash_pczt_signing_request_status(&signing_meta)) { + case ZCASH_PCZT_SIGNING_REQUEST_OK: + break; + case ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS: + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing transaction digests")); + layoutHome(); + return; + case ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE: + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid transaction digest")); + layoutHome(); + return; + case ZCASH_PCZT_SIGNING_REQUEST_MISSING_HEADER_FIELDS: + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing transaction header")); + layoutHome(); + return; + case ZCASH_PCZT_SIGNING_REQUEST_UNSUPPORTED_SAPLING_COMPONENT: + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Sapling not supported")); + layoutHome(); + return; + case ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST: + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing transparent digest")); + layoutHome(); + return; + case ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA: + default: + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing Orchard metadata")); + 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; + } + } + + /* Optional seed_fingerprint binding (ZIP-32 §6.1). + * If host asserts a seed identity, verify it matches before signing. + * Catches "wrong device" attacks where the host accidentally targets + * a different seed than the one it built the PCZT against. */ + if (msg->has_expected_seed_fingerprint && + msg->expected_seed_fingerprint.size == 32) { + uint8_t actual_fp[32]; + if (!storage_zcashSeedFingerprint(true, actual_fp)) { + fsm_sendFailure(FailureType_Failure_NotInitialized, + _("Device not initialized or seed unavailable")); + layoutHome(); + return; + } + bool match = + memcmp(actual_fp, msg->expected_seed_fingerprint.bytes, 32) == 0; + memzero(actual_fp, sizeof(actual_fp)); + if (!match) { + fsm_sendFailure(FailureType_Failure_Other, + _("Seed fingerprint mismatch — wrong device")); + layoutHome(); + return; + } + } + + /* 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; + + /* 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); + } + layoutProgress(_("Signing Zcash"), 0); +} + +void fsm_msgZcashGetOrchardFVK(const ZcashGetOrchardFVK* msg) { + RESP_INIT(ZcashOrchardFVK); + + CHECK_INITIALIZED + + CHECK_PIN + + /* Determine account — require explicit account or strict ZIP-32 path + * m/32'/133'/account' (all hardened, exactly 3 elements). Matches + * ZcashDisplayAddress / ZcashSignPCZT and prevents a malformed host + * path from silently exporting an FVK for an unintended account. */ + uint32_t account; + if (msg->has_account) { + account = msg->account; + } else if (msg->address_n_count == 3 && + msg->address_n[0] == (0x80000000 | 32) && + msg->address_n[1] == (0x80000000 | 133) && + (msg->address_n[2] & 0x80000000)) { + account = msg->address_n[2] & 0x7FFFFFFF; + } else { + fsm_sendFailure( + FailureType_Failure_SyntaxError, + _("Require account field or ZIP-32 path m/32'/133'/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 + + /* Determine account — require explicit account or valid ZIP-32 path. + * When using address_n, enforce the expected Orchard path shape: + * m/32'/133'/account' (all hardened) + * This matches the derivation used in ZcashGetOrchardFVK and + * ZcashSignPCZT, and prevents a malformed path from silently + * deriving against an unintended account. */ + uint32_t account; + if (msg->has_account) { + account = msg->account; + } else if (msg->address_n_count == 3 && + msg->address_n[0] == (0x80000000 | 32) && + msg->address_n[1] == (0x80000000 | 133) && + (msg->address_n[2] & 0x80000000)) { + account = msg->address_n[2] & 0x7FFFFFFF; + } else { + fsm_sendFailure( + FailureType_Failure_SyntaxError, + _("Require account field or ZIP-32 path m/32'/133'/account'")); + layoutHome(); + return; + } + + /* Optional seed_fingerprint binding (ZIP-32 §6.1). + * Reject before any derivation if the host targets the wrong seed. */ + if (msg->has_expected_seed_fingerprint && + msg->expected_seed_fingerprint.size == 32) { + uint8_t actual_fp[32]; + if (!storage_zcashSeedFingerprint(true, actual_fp)) { + fsm_sendFailure(FailureType_Failure_NotInitialized, + _("Device not initialized or seed unavailable")); + layoutHome(); + return; + } + bool match = + memcmp(actual_fp, msg->expected_seed_fingerprint.bytes, 32) == 0; + memzero(actual_fp, sizeof(actual_fp)); + if (!match) { + fsm_sendFailure(FailureType_Failure_Other, + _("Seed fingerprint mismatch — wrong device")); + 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; + } + + /* 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(); + memzero(zcash_signing.signatures, sizeof(zcash_signing.signatures)); + layoutHome(); + return; + } + } + + if (!zcash_verify_and_confirm_fee()) { + zcash_signing_abort(); + memzero(zcash_signing.signatures, sizeof(zcash_signing.signatures)); + 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(); + memzero(zcash_signing.signatures, sizeof(zcash_signing.signatures)); + + 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++; + + 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); + } + + layoutProgress(_("Signing Zcash"), 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. */ + zcash_send_action_ack(0); + layoutProgress(_("Signing Zcash"), 0); +} diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c new file mode 100644 index 000000000..7cf0aef99 --- /dev/null +++ b/lib/firmware/hive.c @@ -0,0 +1,426 @@ +/* + * 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 +} + +/* + * Sign helper: SHA256(chain_id || serialized_tx) → secp256k1 recoverable 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); + + uint8_t pby; + if (ecdsa_sign_digest(&secp256k1, node->private_key, digest, sig + 1, &pby, + NULL) != 0) { + memzero(digest, sizeof(digest)); + return false; + } + // Compact signature header: 27 + recovery_id + 4 (compressed key flag) + sig[0] = 27 + pby + 4; + memzero(digest, sizeof(digest)); + return true; +} + +// ── Transfer (op type 2) ────────────────────────────────────────────────── + +// Maximum memo length that fits safely in 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; use 440 as the conservative limit. +#define HIVE_MAX_MEMO_LEN 440 + +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)); + + const uint8_t default_chain_id[32] = HIVE_CHAIN_ID; + const uint8_t* chain_id = + (msg->has_chain_id && msg->chain_id.size == HIVE_CHAIN_ID_LEN) + ? msg->chain_id.bytes + : default_chain_id; + + uint8_t sig[65]; + if (!hive_sign_digest(node, chain_id, tx_buf, tx_len, sig)) { + memzero(sig, sizeof(sig)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + memcpy(resp->signature.bytes, sig, 65); + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(sig, sizeof(sig)); + 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)); + + const uint8_t default_chain_id[32] = HIVE_CHAIN_ID; + const uint8_t* chain_id = + (msg->has_chain_id && msg->chain_id.size == HIVE_CHAIN_ID_LEN) + ? msg->chain_id.bytes + : default_chain_id; + + uint8_t sig[65]; + if (!hive_sign_digest(signing_node, chain_id, tx_buf, tx_len, sig)) { + memzero(sig, sizeof(sig)); + memzero(tx_buf, sizeof(tx_buf)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + memcpy(resp->signature.bytes, sig, 65); + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(sig, sizeof(sig)); + 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)); + + const uint8_t default_chain_id[32] = HIVE_CHAIN_ID; + const uint8_t* chain_id = + (msg->has_chain_id && msg->chain_id.size == HIVE_CHAIN_ID_LEN) + ? msg->chain_id.bytes + : default_chain_id; + + uint8_t sig[65]; + if (!hive_sign_digest(signing_node, chain_id, tx_buf, tx_len, sig)) { + memzero(sig, sizeof(sig)); + memzero(tx_buf, sizeof(tx_buf)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + memcpy(resp->signature.bytes, sig, 65); + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(sig, sizeof(sig)); + memzero(tx_buf, tx_len); +} diff --git a/lib/firmware/mayachain.c b/lib/firmware/mayachain.c index a4613ad11..b70236105 100644 --- a/lib/firmware/mayachain.c +++ b/lib/firmware/mayachain.c @@ -29,8 +29,24 @@ #include "trezor/crypto/segwit_addr.h" #include +#include #include +// Allow lowercase alpha, digits, and the punctuation used in MAYAChain asset +// identifiers (e.g. "eth.eth", "btc/btc", cross-chain synthetic prefixes). +// Rejects anything that needs JSON escaping (backslash, quote). +bool mayachain_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; +} + static CONFIDENTIAL HDNode node; static SHA256_CTX ctx; static bool initialized; @@ -119,16 +135,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), @@ -205,98 +232,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 +353,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..df3a6128d 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,10 @@ 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) + MSG_OUT(MessageType_MessageType_Bip85Mnemonic, Bip85Mnemonic, NO_PROCESS_FUNC) /* Normal Out Messages */ MSG_OUT(MessageType_MessageType_Success, Success, NO_PROCESS_FUNC) @@ -95,6 +100,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 +169,39 @@ 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_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) +#endif // !BITCOIN_ONLY #if DEBUG_LINK /* Debug Messages */ @@ -178,3 +217,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..1eee063b6 100644 --- a/lib/firmware/recovery_cipher.c +++ b/lib/firmware/recovery_cipher.c @@ -371,13 +371,29 @@ void next_character(void) { } #endif + /* Save word so we can show it as "prev" on next word. + * When auto-complete fires, current_word holds the full expanded word + * (e.g. "alcohol" not "alc"). Otherwise save the typed prefix. */ + static char CONFIDENTIAL last_completed_word[12]; + if (strlen(current_word) > 0) { + strlcpy(last_completed_word, current_word, sizeof(last_completed_word)); + } + /* Format current word and display it along with cipher */ static char CONFIDENTIAL formatted_word[CURRENT_WORD_BUF + 10]; 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)); } @@ -462,6 +478,24 @@ 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. */ + if (enforce_wordlist && 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); + memzero(check_word, sizeof(check_word)); + if (!valid) { + 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; + } + } + memzero(coded_word, sizeof(coded_word)); memzero(decoded_word, sizeof(decoded_word)); @@ -575,7 +609,7 @@ void recovery_cipher_finalize(void) { } memzero(temp_word, sizeof(temp_word)); - if (!auto_completed && !enforce_wordlist) { + if (!auto_completed && enforce_wordlist) { if (!dry_run) { storage_reset(); } 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..d81a9efcc --- /dev/null +++ b/lib/firmware/signed_metadata.c @@ -0,0 +1,766 @@ +#include "keepkey/firmware/signed_metadata.h" + +#include "keepkey/board/confirm_sm.h" +#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; + +/* + * Built-in metadata verification public keys. + * + * Phase 1 ships with NO built-in keys: every clearsign signer must be + * loaded at runtime via LoadClearsignSigner (user-confirmed on device, + * RAM-only, gone on reboot), and every transaction it vouches for is + * preceded by a warning screen naming the signer's alias. There is no + * hardcoded "KeepKey says this is safe" path in this phase. + * + * Phase 2 (once the offline signing infrastructure is hardened) puts the + * KeepKey production key back in slot 0. A slot holding a built-in key can + * never be shadowed by a loaded signer (see signed_metadata_signer_valid) + * and is the only path that clearsigns without the warning screen. + */ +static const uint8_t METADATA_PUBKEYS[METADATA_MAX_KEYS][33] = { + /* Key 0: production (returns in phase 2) */ + {0x00}, + /* Key 1: rotation slot */ + {0x00}, + {0x00}, + /* Key 3: was the DEBUG_LINK-only CI test key; CI now loads it through + * LoadClearsignSigner so tests exercise the same warning flow users see. */ + {0x00}, +}; + +/* Runtime-loaded signers. RAM only — cleared on reboot by construction. */ +static uint8_t loaded_pubkeys[METADATA_MAX_KEYS][33]; +static char loaded_aliases[METADATA_MAX_KEYS][METADATA_ALIAS_MAX_LEN + 1]; + +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)); + /* Metadata verified by a now-dropped signer must not outlive it. */ + signed_metadata_clear(); +} + +bool signed_metadata_signer_valid(uint8_t key_id, const uint8_t *pubkey, + size_t pubkey_len, const char *alias) { + curve_point point; + size_t alias_len; + + if (key_id >= METADATA_MAX_KEYS || METADATA_PUBKEYS[key_id][0] != 0x00 || + !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; +} + +void signed_metadata_store_signer(uint8_t key_id, const uint8_t *pubkey, + const char *alias) { + if (key_id >= METADATA_MAX_KEYS) { + return; + } + memcpy(loaded_pubkeys[key_id], pubkey, sizeof(loaded_pubkeys[key_id])); + strlcpy(loaded_aliases[key_id], alias, sizeof(loaded_aliases[key_id])); + /* Replacing a signer invalidates anything the old one verified. */ + signed_metadata_clear(); +} + +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. Built-in keys win; a runtime- + * loaded signer can only occupy an empty built-in 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 (METADATA_PUBKEYS[key_id][0] != 0x00) { + return METADATA_PUBKEYS[key_id]; + } + if (loaded_pubkeys[key_id][0] != 0x00) { + *is_loaded = true; + return loaded_pubkeys[key_id]; + } + 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; +} + +bool signed_metadata_confirm(void) { + char body[128]; + + if (!metadata_available || + stored_metadata.classification != METADATA_VERIFIED) { + return false; + } + + if (metadata_signer_loaded) { + /* Warning screen FIRST, before any clearsign page: the decode below is + * vouched for by a signer the user loaded, not by KeepKey. */ + char fingerprint[METADATA_FINGERPRINT_LEN]; + signed_metadata_pubkey_fingerprint(loaded_pubkeys[stored_metadata.key_id], + fingerprint); + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), + "Signer '%s' (%s) you loaded describes this tx. NOT verified by " + "KeepKey.", + loaded_aliases[stored_metadata.key_id], fingerprint); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Clearsign Warning", "%s", body)) { + return false; + } + + /* Method screen without the "Insight Verified" branding/icon — 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(ButtonRequestType_ButtonRequest_ConfirmOutput, "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(ButtonRequestType_ButtonRequest_ConfirmOutput, + 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(ButtonRequestType_ButtonRequest_ConfirmOutput, + 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_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..b754c8e1e 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,26 @@ _Static_assert(sizeof(ConfigFlash) <= FLASH_STORAGE_LEN, "ConfigFlash struct is too large for storage partition"); static ConfigFlash CONFIDENTIAL shadow_config; +/* Multi-chain firmware found bitcoin-only storage in flash: refuse to load + * or overwrite it until the user explicitly wipes. Never set in bitcoin-only + * builds. */ +static bool btc_only_locked = false; + +bool storage_isBitcoinOnlyLocked(void) { return btc_only_locked; } + +// Stamp a newly-created seed into the reserved bitcoin-only version band so +// multi-chain firmware refuses it (see storage_fromFlash). Called only from +// seed-creation paths, so a pre-existing multi-chain wallet migrated under +// bitcoin-only firmware keeps its normal, portable version. No-op (but still +// referenced, so no -Wunused) in multi-chain builds. +#if BITCOIN_ONLY +static void storage_stampBitcoinOnlySeed(void) { + shadow_config.storage.version = STORAGE_VERSION_BTC_ONLY; +} +#else +static void storage_stampBitcoinOnlySeed(void) {} +#endif + #if DEBUG_LINK // These won't survive resets like the stuff in flash would, but thats a // reasonable compromise given how testing works. @@ -184,8 +205,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 +220,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: \ @@ -1153,7 +1186,8 @@ StorageUpdateStatus storage_fromFlash(SessionState* ss, ConfigFlash* dst, 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: @@ -1203,6 +1237,26 @@ StorageUpdateStatus storage_fromFlash(SessionState* ss, ConfigFlash* dst, dst->storage.version = STORAGE_VERSION; return dst->storage.version == version ? SUS_Valid : SUS_Updated; + case StorageVersion_BTC_ONLY: +#if BITCOIN_ONLY + if (raw_version == (uint32_t)STORAGE_VERSION_BTC_ONLY) { + // Our own bitcoin-only wallet: same layout as the latest multi-chain + // version, only the wire version differs. Keep it stamped in the band + // so it stays refused by multi-chain firmware. + storage_readV17(dst, flash, STORAGE_SECTOR_LEN); + dst->storage.version = STORAGE_VERSION_BTC_ONLY; + return SUS_Valid; + } + // A different (e.g. newer) bitcoin-only wallet: refuse rather than wipe, + // so a firmware downgrade never destroys a newer wallet. + return SUS_BitcoinOnlyLocked; +#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 +1394,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 +1447,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,6 +1524,11 @@ 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]; @@ -1624,6 +1693,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); } @@ -1866,6 +1939,38 @@ 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 + +const uint8_t* storage_getRawSeed(bool usePassphrase) { + return storage_getSeed(&shadow_config, usePassphrase); +} + 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 +2099,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 +2117,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..561af88a4 100644 --- a/lib/firmware/storage.h +++ b/lib/firmware/storage.h @@ -185,6 +185,7 @@ typedef enum { SUS_Invalid, SUS_Valid, SUS_Updated, + SUS_BitcoinOnlyLocked, // written by bitcoin-only firmware; refuse to load } StorageUpdateStatus; /// \brief Copy configuration from storage partition in flash memory to shadow diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index 92b075d4f..3dcd41e29 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -29,8 +29,24 @@ #include "trezor/crypto/segwit_addr.h" #include +#include #include +// Allow lowercase alpha, digits, and the punctuation used in THORChain asset +// identifiers (e.g. "eth.eth", "btc/btc", cross-chain synthetic prefixes). +// Rejects anything that needs JSON escaping (backslash, quote). +bool thorchain_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; +} + static CONFIDENTIAL HDNode node; static SHA256_CTX ctx; static bool initialized; @@ -95,7 +111,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 +135,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), @@ -204,98 +231,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 +352,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/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..45cf51f6b --- /dev/null +++ b/lib/firmware/zcash.c @@ -0,0 +1,1160 @@ +/* + * 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_unified_address(const uint8_t* seed, + uint32_t seed_len, uint32_t account, + const uint8_t index_le[11], + const char* hrp, char* address_out, + size_t address_out_len) { + if (!seed || !index_le || !hrp || !address_out) return false; + + ZcashOrchardKeys keys; + bool ok = zcash_derive_orchard_keys(seed, seed_len, account, &keys) && + zcash_orchard_derive_unified_address(&keys, index_le, hrp, + address_out, address_out_len); + memzero(&keys, sizeof(keys)); + 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 neg_ask; + bn_copy(&pallas_order, &neg_ask); + bignum256 ask_val; + bn_read_le(keys->ask, &ask_val); + bn_normalize(&ask_val); + bn_normalize(&neg_ask); + /* neg_ask = order - ask */ + int32_t borrow = 0; + for (int i = 0; i < 9; i++) { + int32_t diff = + (int32_t)neg_ask.val[i] - (int32_t)ask_val.val[i] + borrow; + if (diff < 0) { + diff += (1 << 29); + borrow = -1; + } else { + borrow = 0; + } + neg_ask.val[i] = (uint32_t)diff; + } + 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/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/firmware/keepkey_main.c b/tools/firmware/keepkey_main.c index fcf4aa9cb..6954f3cf2 100644 --- a/tools/firmware/keepkey_main.c +++ b/tools/firmware/keepkey_main.c @@ -73,16 +73,15 @@ void memory_getDeviceLabel(char *str, size_t len) { bool inPrivilegedMode(void) { // Check to see if we are in priv mode. If so, return true to drop privs. uint32_t creg = 0xffff; - // CONTROL register nPRIV,bit[0]: + // CONTROL register nPRIV,bit[0]: // 0 Thread mode has privileged access - // 1 Thread mode has unprivileged access. + // 1 Thread mode has unprivileged access. // Note: In Handler mode, execution is always privileged fi_defense_delay(creg); // vary access time - __asm__ volatile( - "mrs %0, control" : "=r" (creg)); + __asm__ volatile("mrs %0, control" : "=r"(creg)); fi_defense_delay(creg); // vary test time - if (creg & 0x0001) - return false; // can't drop privs + if (creg & 0x0001) + return false; // can't drop privs else return true; @@ -173,9 +172,23 @@ int main(void) { _timerusr_isr = (void *)&timerisr_usr; _mmhusr_isr = (void *)&mmhisr; - { // limit sigRet lifetime to this block + { // limit sigRet lifetime to this block + /* F5 hardening: replace full signatures_ok() (~1 sec crypto) with fast + * metadata presence check. The bootloader has already performed the + * authoritative signature verification before jumping here. We only + * need to know whether the bootloader considered us signed, which is + * indicated by valid signature indices in flash metadata. */ int sigRet = SIG_FAIL; - sigRet = signatures_ok(); + + volatile uint8_t si1 = *((volatile uint8_t *)FLASH_META_SIGINDEX1); + volatile uint8_t si2 = *((volatile uint8_t *)FLASH_META_SIGINDEX2); + volatile uint8_t si3 = *((volatile uint8_t *)FLASH_META_SIGINDEX3); + + if (si1 >= 1 && si1 <= PUBKEYS && si2 >= 1 && si2 <= PUBKEYS && si3 >= 1 && + si3 <= PUBKEYS && si1 != si2 && si1 != si3 && si2 != si3) { + sigRet = SIG_OK; + } + flash_collectHWEntropy(SIG_OK == sigRet); /* Drop privileges */ @@ -194,8 +207,9 @@ int main(void) { drbg_init(); - /* Bootloader Verification. Only check if valid signed firmware on-board, allow any other firmware - to run with any bootloader. This allows unsigned release firmware to run after warning */ + /* Bootloader Verification. Only check if valid signed firmware on-board, + allow any other firmware to run with any bootloader. This allows unsigned + release firmware to run after warning */ if (SIG_OK == sigRet) { check_bootloader(); } 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..af55e5d41 --- /dev/null +++ b/unittests/firmware/signed_metadata.cpp @@ -0,0 +1,1247 @@ +/* + * Unit tests for the EVM clear-signing ("Insight") signed-metadata module. + * + * Phase 1 ships with NO built-in verification keys: METADATA_PUBKEYS is all + * zeros and 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/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 == firmware METADATA_PUBKEYS[3]. */ +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); + } + 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"); + + /* 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) ------------------------------ */ + +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..6f8c434e7 100644 --- a/unittests/firmware/storage.cpp +++ b/unittests/firmware/storage.cpp @@ -497,6 +497,33 @@ 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) { + char flash[64]; + 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 + TEST(Storage, StorageRoundTrip) { ConfigFlash start; memset(&start, 0xAB, sizeof(start)); diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index 4fa8faa8f..cca22cf5a 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,302 @@ 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 = { +// 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"; + +// DISABLED: the four SignTx vectors below use kSignNode, which has an all-zero +// private key; hdnode_fill_public_key() yields an invalid signing node, so +// thorchain_signTxUpdateMsgSend bails at tendermint_getAddress and returns false +// (ThorchainSignTxInvalidDenom EXPECT_FALSEs and passes regardless of denom, +// confirming the early failure). These vectors were never validated (#269 CI was +// proto-blocked). On-device uses a real seed-derived key (verified 8/8, #292). +// Re-enable after rebuilding the fixture with a valid key + recomputed sigs. +// Baseline RUNE send — exact signature vector +TEST(Thorchain, DISABLED_ThorchainSignTx) { + HDNode node = kSignNode; + hdnode_fill_public_key(&node); + + ASSERT_TRUE(thorchain_signTxInit(&node, &kSignTx)); + ASSERT_TRUE(thorchain_signTxUpdateMsgSend(100000, kToAddr, "rune")); + + uint8_t public_key[33]; + uint8_t signature[64]; + ASSERT_TRUE(thorchain_signTxFinalize(public_key, signature)); + + EXPECT_EQ( 0, + memcmp(signature, + (uint8_t *)"\xc3\xea\xe2\xa3\xc2\xb6\x24\x00\x8d\x8a\xc4\x49\xe2" + "\x53\xdb\xa5\x31\x2e\x4d\xbd\x12\xd6\x77\x39\xd3\xf9" + "\xce\xe1\xc3\xbd\x34\x62\x69\xd2\xaa\x8a\x79\xbe\x81" + "\xd8\x1a\x9e\xe3\x94\x99\x07\xbb\xe2\x08\x04\x1a\xfa" + "\xfe\xfa\x14\x9f\x67\xb3\x9d\x4a\xe2\x29\xc8\x47", + 64)); +} + +// Empty denom must produce identical output to explicit "rune" +TEST(Thorchain, DISABLED_ThorchainSignTxDefaultDenom) { + HDNode node = kSignNode; + hdnode_fill_public_key(&node); + + ASSERT_TRUE(thorchain_signTxInit(&node, &kSignTx)); + ASSERT_TRUE(thorchain_signTxUpdateMsgSend(100000, kToAddr, "")); + + uint8_t public_key[33]; + uint8_t signature[64]; + ASSERT_TRUE(thorchain_signTxFinalize(public_key, signature)); + + EXPECT_EQ( 0, - {0, 0, 0, 0, 0, 0, 0, 0, 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}; + memcmp(signature, + (uint8_t *)"\xc3\xea\xe2\xa3\xc2\xb6\x24\x00\x8d\x8a\xc4\x49\xe2" + "\x53\xdb\xa5\x31\x2e\x4d\xbd\x12\xd6\x77\x39\xd3\xf9" + "\xce\xe1\xc3\xbd\x34\x62\x69\xd2\xaa\x8a\x79\xbe\x81" + "\xd8\x1a\x9e\xe3\x94\x99\x07\xbb\xe2\x08\x04\x1a\xfa" + "\xfe\xfa\x14\x9f\x67\xb3\x9d\x4a\xe2\x29\xc8\x47", + 64)); +} + +// TCY (THORChain native yield/governance token) — exact signature vector +TEST(Thorchain, DISABLED_ThorchainSignTxTCY) { + 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_signTxUpdateMsgSend( - 100000, "thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v")); + ASSERT_TRUE(thorchain_signTxInit(&node, &kSignTx)); + ASSERT_TRUE(thorchain_signTxUpdateMsgSend(100000, kToAddr, "tcy")); uint8_t public_key[33]; uint8_t signature[64]; + ASSERT_TRUE(thorchain_signTxFinalize(public_key, signature)); + + EXPECT_EQ( + 0, + memcmp(signature, + (uint8_t *)"\xad\xa0\xb6\xce\x50\x41\xc1\x01\x46\xf0\x86\x94\xb9" + "\x97\x29\x41\x13\x41\xef\x87\x70\xe8\x58\x7c\x01\xf9" + "\x81\x3f\x71\x8e\xbb\xc7\x58\xcf\xeb\xfc\xf9\x28\x55" + "\x73\xe0\x85\x31\x52\xfc\x0e\xbf\xbd\xa6\x4e\xe8\xd2" + "\xca\xd6\xc4\xd1\xfc\x18\x31\x13\x33\x2f\x2b\xae", + 64)); +} + +// RUJIRA (DEX protocol native to THORChain) — exact signature vector +TEST(Thorchain, DISABLED_ThorchainSignTxRujira) { + HDNode node = kSignNode; + hdnode_fill_public_key(&node); + ASSERT_TRUE(thorchain_signTxInit(&node, &kSignTx)); + ASSERT_TRUE(thorchain_signTxUpdateMsgSend(100000, kToAddr, "rujira")); + + uint8_t public_key[33]; + uint8_t signature[64]; ASSERT_TRUE(thorchain_signTxFinalize(public_key, signature)); - EXPECT_TRUE( + EXPECT_EQ( + 0, 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); + (uint8_t *)"\xed\x3b\x99\xac\xfa\x12\x32\xf7\x04\x72\x43\x17\x27" + "\x37\xbc\xb3\x15\x32\xc6\xe2\x1e\x5f\x5b\x4b\xb4\x3c" + "\x10\x7f\x7e\x08\x6a\x60\x28\xa3\x26\x53\x37\x44\x21" + "\xcb\x62\x29\xe4\x5a\xba\x82\x89\x2e\xc7\x6a\x27\x4b" + "\xe7\xfd\x4e\x77\xe2\xa4\x3a\x8e\x5a\x82\xbb\x17", + 64)); +} + +// 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); + + ASSERT_TRUE(thorchain_signTxInit(&node, &kSignTx)); + // Quote-injection attempt must be rejected at the signing layer + EXPECT_FALSE(thorchain_signTxUpdateMsgSend(100000, kToAddr, + "rune\",\"from_address\":\"evil")); + thorchain_signAbort(); +} + +/* ===================================================================== * + * thorchain_parseConfirmMemo — swap-memo clear-signing. + * Screen counts are asserted exactly: kkconfirm_preload(N, 0) accepts N + * screens and kkconfirm_drain() == 0 proves N screens were shown. + * ===================================================================== */ + +static bool parseMemo(const char *memo, size_t size) { + return thorchain_parseConfirmMemo(memo, size); +} +static bool parseMemo(const char *memo) { + return parseMemo(memo, strlen(memo) + 1); +} + +// Classic full-form swap memo: asset + dest + limit + affiliate + fee bps +// = 4 screens (the 4th is the new affiliate fee screen) +TEST(Thorchain, MemoSwapFullFormShowsAffiliate) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE( + parseMemo("SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:" + "0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Abbreviated asset with no '.' (no chain.asset pair) is not parseable +// thorchain data: raw-memo fallback +TEST(Thorchain, MemoSwapNoChainAssetPair) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("=:e:0xdest:0/1/0:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Empty limit field must NOT shift the affiliate into the limit slot: it +// must still take 4 screens (limit "none" + separate affiliate screen). +// The old strtok tokenizer collapsed the empty field and displayed the +// affiliate ("kk") as the limit in 3 screens. +TEST(Thorchain, MemoSwapEmptyLimitDoesNotShift) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMemo("=:ETH.ETH:0xdest::kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// No affiliate: exactly the 3 historical screens, no affiliate screen +TEST(Thorchain, MemoSwapNoAffiliate) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE(parseMemo("SWAP:ETH.ETH:0xdest:420")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Affiliate present but fee absent: affiliate screen still shows (fee "0") +TEST(Thorchain, MemoSwapAffiliateNoFee) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMemo("SWAP:ETH.ETH:0xdest:420:kk")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Missing dest and limit: still 3 screens ("self" / "none") +TEST(Thorchain, MemoSwapMinimal) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE(parseMemo("SWAP:ETH.ETH")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Rejecting a screen aborts the whole confirmation +TEST(Thorchain, MemoSwapRejectPropagates) { + ASSERT_TRUE(kkconfirm_preload(2, 1)); + EXPECT_FALSE(parseMemo("SWAP:ETH.ETH:0xdest:420")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD with a pool address: 2 screens (unchanged behavior) +TEST(Thorchain, MemoAddWithPool) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE( + parseMemo("ADD:BTC.BTC:thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD without a pool address: 1 screen (unchanged behavior) +TEST(Thorchain, MemoAddWithoutPool) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMemo("+:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// WITHDRAW with basis points: 1 screen (unchanged behavior) +TEST(Thorchain, MemoWithdraw) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMemo("WITHDRAW:BTC.BTC:5000")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// WITHDRAW without basis points is malformed (unchanged behavior) +TEST(Thorchain, MemoWithdrawMissingBps) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("wd:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Garbage memos fall back to raw-memo confirmation +TEST(Thorchain, MemoGarbage) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("hello world")); + EXPECT_FALSE(parseMemo("NOTATHING:ETH.ETH:0xdest")); + EXPECT_FALSE(parseMemo("")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// BTC OP_RETURN passes RAW memo bytes with no NUL and size = byte count +// (transaction.c). Every byte must survive the copy: dropping the last +// character turns affiliate "kk" into "k" — or a fee of 75 bps into 7. +// This memo's affiliate is 1 char, so the historical off-by-one would +// lose it entirely and show only 3 screens instead of 4. +TEST(Thorchain, MemoRawBytesNoNulKeepsLastChar) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + const char raw[] = "=:ETH.ETH:0xdest:420:k"; + EXPECT_TRUE(parseMemo(raw, sizeof(raw) - 1)); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} + +// 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..8974ff66a --- /dev/null +++ b/unittests/firmware/zcash.cpp @@ -0,0 +1,1872 @@ +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, +}; + +static const uint8_t SINSEMILLA_EMPTY_HASH_POINT[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_EMPTY_HASH[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_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, +}; + +static const uint8_t SINSEMILLA_TWENTY_THREE_BITS_SHORT_COMMIT[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, +}; + +/* 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). + * + * NOTE: These vectors verify the derivation function itself. + * The current FSM seed-proxy bug means the firmware doesn't call + * this function with the correct seed — but the function is correct. + */ +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 { + uint8_t ak[32]; + uint8_t nk[32]; + uint8_t rivk[32]; + uint8_t dk[32]; + uint8_t index[11]; + uint8_t receiver[43]; +}; + +static const OrchardReceiverAssemblyVector ORCHARD_RECEIVER_ASSEMBLY_VECTORS[] = { + {{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}, + {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}, + {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}, + {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}, + {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}}, + {{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}, + {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}, + {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}, + {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}, + {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}}, + {{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}, + {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}, + {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}}, + {{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}, + {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}, + {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. + * + * The derivation bugs that caused prior mismatches were: + * 1. Child derivation used "ZcashIP32Orchard" instead of "Zcash_ExpandSeed" + * 2. Domain separator was 0x11 instead of 0x81 + * 3. Index encoding was big-endian instead of little-endian + * All three are now fixed. + */ + 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_FromSeedAccountAndIndex) { + char address[ZCASH_ZIP316_ORCHARD_ONLY_MAX_ADDRESS_SIZE]; + const uint8_t index0[11] = {0}; + + ASSERT_TRUE(zcash_derive_orchard_unified_address( + SEED_ALL, 64, 0, index0, "u", address, sizeof(address))); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_MAINNET_0); + + ASSERT_TRUE(zcash_derive_orchard_unified_address( + SEED_ALL, 64, 0, index0, "utest", address, sizeof(address))); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_0); + + memzero(address, sizeof(address)); +} + +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))); + + EXPECT_FALSE(zcash_derive_orchard_unified_address( + nullptr, 64, 0, index0, "u", address, sizeof(address))); + + 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); +}