diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44c30c189..bcda1052a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,27 +5,37 @@ # Stage 1: GATE (seconds, no Docker) # ├─ lint-format clang-format diff check # ├─ static-analysis cppcheck static analysis +# ├─ crypto-tests pinned fork crypto vectors + ASan/UBSan # ├─ secret-scan gitleaks credential detection # └─ check-submodules verify all deps present # # Stage 2: BUILD (parallel, gated by Stage 1) -# ├─ build-emulator Docker image → artifact -# └─ build-arm-firmware cross-compile → .bin/.elf (downloadable) +# ├─ build-emulator Docker image → artifact [matrix: full / bitcoin-only] +# └─ build-arm-firmware cross-compile → .bin/.elf (downloadable) [same matrix] # # Stage 3: TEST (parallel, gated by Stage 2) -# ├─ unit-tests GoogleTest (make xunit) -# └─ python-integration full test suite +# ├─ unit-tests GoogleTest (make xunit) [same matrix — proves each +# │ variant's coin/token gating actually compiles+passes] +# └─ python-integration full test suite (full/default variant only) # -# Stage 4: PUBLISH (manual trigger, all tests must pass) -# └─ publish-emulator DockerHub push (workflow_dispatch only) +# Stage 4: PUBLISH (all tests must pass) +# ├─ publish-emulator DockerHub push, full/default variant only (workflow_dispatch only) +# └─ publish-emulator-libs macOS dylib + Windows DLL → rolling prerelease +# 'emulator-dylib-latest' (push to develop or alpha). +# Both platforms ship together or the job fails. +# Tagged releases get the same pair via release.yml. name: CI on: push: - branches: [master, develop, 'feature/**', 'fix/**', 'release/**', 'hotfix/**'] + branches: [master, develop, alpha, 'feature/**', 'fix/**', 'release/**', 'hotfix/**'] + # 'release/**' is here so a stacked release train is actually gated. A PR whose + # base is another release branch does not match master/develop/alpha, so it + # reported "no checks" in the UI while the push-triggered run on the same + # branch was red -- a reviewer saw a clean PR over a failing build. pull_request: - branches: [master, develop] + branches: [master, develop, alpha, 'release/**'] workflow_dispatch: inputs: publish_emulator: @@ -54,7 +64,7 @@ jobs: timeout-minutes: 3 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -87,7 +97,7 @@ jobs: timeout-minutes: 2 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 @@ -96,17 +106,34 @@ jobs: # Pin and verify the only scanner binary that is installed/executed. # Bumps require an independently recorded digest and ruleset review. run: | - GITLEAKS_VERSION=8.30.1 - GITLEAKS_SHA256=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb - GITLEAKS_ARCHIVE=/tmp/gitleaks.tar.gz - curl --fail --show-error --location \ + # PINNED to 8.30.0, deliberately, and VERIFIED. + # + # Two things this block has to get right at once, and a clean merge of + # two parents previously got exactly one of them: + # + # - the VERSION is pinned because tracking releases/latest lets a new + # upstream ruleset turn this gate red with no change to this + # repository. That happened: a newer generic-api-key rule began + # flagging published BIP32 test vectors in 2014/2018 history, and + # because every build job declares `needs: [.., secret-scan]`, the + # whole build and test graph was SKIPPED rather than failed (#424). + # - the BINARY is checksummed, because a scan is only worth what the + # executable running it is. + # + # The merged version downloaded 8.30.1 with a verified checksum and then + # overwrote it with an unverified 8.30.0 through `curl | tar`, so the + # checksum proved nothing about the binary that actually ran. One + # version, one archive, one verified digest. Bump deliberately, with the + # scan re-verified. + GITLEAKS_VERSION=8.30.0 + GITLEAKS_SHA256=79a3ab579b53f71efd634f3aaf7e04a0fa0cf206b7ed434638d1547a2470a66e + GITLEAKS_ARCHIVE="${RUNNER_TEMP}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -sSfL --retry 3 --retry-all-errors \ "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ -o "${GITLEAKS_ARCHIVE}" echo "${GITLEAKS_SHA256} ${GITLEAKS_ARCHIVE}" | sha256sum --check --strict tar -xzf "${GITLEAKS_ARCHIVE}" -C /usr/local/bin gitleaks - INSTALLED_VERSION=$(gitleaks version) - echo "gitleaks ${INSTALLED_VERSION}" - test "${INSTALLED_VERSION}" = "${GITLEAKS_VERSION}" + gitleaks version - name: Run gitleaks env: @@ -138,14 +165,20 @@ jobs: static-analysis: runs-on: ubuntu-latest - timeout-minutes: 5 + # Exact-wire parsers deliberately have many branch predicates. Cppcheck's + # all-configurations pass remains mandatory, but the 15-minute ceiling can + # terminate a clean scan while it is still progressing through Solana. + timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: false + - name: Init crypto submodule + run: git submodule update --init deps/crypto/trezor-firmware + - name: Install cppcheck run: sudo apt-get update && sudo apt-get install -y cppcheck @@ -182,15 +215,17 @@ jobs: : "${ERRORS:=0}" "${WARNINGS:=0}" "${STYLE:=0}" "${PERF:=0}" "${PORT:=0}" TOTAL=$((ERRORS + WARNINGS + STYLE + PERF + PORT)) - echo "## cppcheck summary" >> "$GITHUB_STEP_SUMMARY" - echo "| Severity | Count |" >> "$GITHUB_STEP_SUMMARY" - echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY" - echo "| error | $ERRORS |" >> "$GITHUB_STEP_SUMMARY" - echo "| warning | $WARNINGS |" >> "$GITHUB_STEP_SUMMARY" - echo "| style | $STYLE |" >> "$GITHUB_STEP_SUMMARY" - echo "| performance | $PERF |" >> "$GITHUB_STEP_SUMMARY" - echo "| portability | $PORT |" >> "$GITHUB_STEP_SUMMARY" - echo "| **total** | **$TOTAL** |" >> "$GITHUB_STEP_SUMMARY" + { + echo "## cppcheck summary" + echo "| Severity | Count |" + echo "|----------|-------|" + echo "| error | $ERRORS |" + echo "| warning | $WARNINGS |" + echo "| style | $STYLE |" + echo "| performance | $PERF |" + echo "| portability | $PORT |" + echo "| **total** | **$TOTAL** |" + } >> "$GITHUB_STEP_SUMMARY" # Print findings as GitHub annotations cat cppcheck_report.txt @@ -208,7 +243,7 @@ jobs: echo "cppcheck: clean — zero findings" - name: Upload cppcheck report - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: cppcheck-report @@ -220,10 +255,13 @@ jobs: timeout-minutes: 2 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Init crypto submodule + run: git submodule update --init deps/crypto/trezor-firmware + - name: Verify submodules are declared run: | echo "Checking required submodule declarations..." @@ -246,6 +284,84 @@ jobs: done [ "$FAILED" = "0" ] || exit 1 + - name: Enforce RC18 security invariants + run: | + python3 tools/check_pallas_api_boundary.py + if git grep -n -E \ + 'uses:[[:space:]]+[^#[:space:]]+@(v[0-9]+|main|master)([[:space:]#]|$)' \ + -- .github/workflows; then + echo "::error::Every GitHub Action must be pinned to a full commit SHA" + exit 1 + fi + if git grep -n -E 'kktech/firmware:v[0-9]+' -- \ + .github/workflows scripts/build/docker scripts/emulator; then + echo "::error::The firmware builder must be pinned by manifest digest" + exit 1 + fi + if git grep -n -E \ + 'storage_(get|upsert)ClearsignIdentity|persistent_identity_for' \ + -- lib include; then + echo "::error::Unauthenticated persistent clearsign trust is retired" + exit 1 + fi + if git grep -n -E 'return[[:space:]]+random\(\)' -- \ + lib/rand/rng.c; then + echo "::error::Emulator cryptography must not use libc random()" + exit 1 + fi + if git grep -n -F 'option(KK_ZCASH_PRIVACY' -- CMakeLists.txt; then + echo "::error::Zcash privacy must not become a third release choice" + exit 1 + fi + grep -q 'set(KK_ZCASH_PRIVACY ON)' CMakeLists.txt + grep -q 'set(KK_ZCASH_PRIVACY OFF)' CMakeLists.txt + + crypto-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Init crypto submodule + run: git submodule update --init deps/crypto/trezor-firmware + + - name: Install crypto test dependencies + run: sudo apt-get update && sudo apt-get install -y check libssl-dev pkg-config valgrind + + - name: Run optimized crypto suite + working-directory: deps/crypto/trezor-firmware/crypto + run: | + make clean + make VALGRIND=0 tests/test_check tests/test_pallas_ct + ./tests/test_check + ./tests/test_pallas_ct + + - name: Verify Pallas secret flow with Valgrind + working-directory: deps/crypto/trezor-firmware/crypto + run: | + make clean + make VALGRIND=1 OPTFLAGS='-O2 -g' tests/test_pallas_ct + valgrind --quiet --error-exitcode=1 --track-origins=yes \ + ./tests/test_pallas_ct + + - name: Run crypto suite with ASan and UBSan + working-directory: deps/crypto/trezor-firmware/crypto + run: | + make clean + make CC='gcc -fsanitize=address,undefined' VALGRIND=0 \ + OPTFLAGS='-O1 -g -fno-omit-frame-pointer' \ + tests/test_check tests/test_pallas_ct + # Check's default per-test timeout is too short for the two exhaustive + # codepoint tests under sanitizer instrumentation on shared runners. + # Keep the job-level timeout as the hard upper bound. + CK_DEFAULT_TIMEOUT=30 \ + ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 \ + UBSAN_OPTIONS=halt_on_error=1 ./tests/test_check + ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 \ + UBSAN_OPTIONS=halt_on_error=1 ./tests/test_pallas_ct - name: Enforce RNG source invariants run: | if grep -q 'RAND_PLATFORM_INDEPENDENT=0' CMakeLists.txt; then @@ -265,12 +381,25 @@ jobs: # ═══════════════════════════════════════════════════════════ build-emulator: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + name: build-emulator${{ matrix.label }} + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + # Regular/full includes every supported chain, including Zcash + # shielded/Orchard. Bitcoin-only is the sole reduced build. + - variant: full + label: "" + cmake_flags: "" + - variant: bitcoin-only + label: " (bitcoin-only)" + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -284,11 +413,11 @@ jobs: git submodule update --init deps/sca-hardening/SecAESSTM32 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Cache base image id: cache-base - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -303,41 +432,47 @@ 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: emu-image + name: emu-image-${{ matrix.variant }} path: /tmp/emu-image.tar retention-days: 1 build-arm-firmware: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + name: build-arm-firmware${{ matrix.label }} + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false - # Both release variants must compile on every PR. Without the - # bitcoin-only leg, a change that only breaks the KK_BITCOIN_ONLY image - # goes green here and fails for the first time in the release build. matrix: include: + # 'label' names the job, 'suffix' names the built files, 'variant' selects + # the build. The default variant is empty in both so the flashable artifact + # matches the historical name instead of gaining a "-full" token. - variant: full + label: "" + suffix: "" cmake_flags: "" - variant: bitcoin-only + label: " (bitcoin-only)" + suffix: "-bitcoin-only" cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -352,7 +487,7 @@ jobs: - name: Cache base image id: cache-base - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -376,37 +511,74 @@ 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 \ ${{ env.BASE_IMAGE }} /bin/sh -c "\ mkdir /root/build && cd /root/build && \ cmake -C /root/keepkey-firmware/cmake/caches/device.cmake /root/keepkey-firmware \ - ${{ matrix.cmake_flags }} \ -DCMAKE_BUILD_TYPE=MinSizeRel \ - -DCMAKE_COLOR_MAKEFILE=ON && \ + -DCMAKE_COLOR_MAKEFILE=ON \ + ${{ matrix.cmake_flags }} && \ make && \ + python3 /root/keepkey-firmware/tools/check_pallas_ct_disassembly.py \ + --elf bin/firmware.keepkey.elf \ + --variant '${{ matrix.variant }}' && \ mkdir -p /root/keepkey-firmware/bin && \ cp bin/*.bin /root/keepkey-firmware/bin/ && \ cp bin/*.elf /root/keepkey-firmware/bin/ && \ + cp bin/*.map /root/keepkey-firmware/bin/ 2>/dev/null || true && \ + arm-none-eabi-size -A bin/firmware.keepkey.elf > /root/keepkey-firmware/bin/firmware.keepkey.size.txt 2>/dev/null || true && \ + find . -name '*.su' -print0 | tar czf /root/keepkey-firmware/bin/stack-usage.tgz --null -T - && \ chmod -R a+rw /root/keepkey-firmware/bin" + # SRAM budget gate — RC7's privacy-enabled build hard-faulted on boot + # because static SRAM left an 11.2 KB gap while msg_write() carried a + # 12.4 KB stack frame. keepkey.ld now ASSERTs a 16 KiB reserve at link + # time; this step reports the numbers and enforces the frame margin + # (tools/sram-budgets.json). + - name: SRAM budget gate (${{ matrix.variant }}) + run: | + pip install --quiet pyelftools + # Mirror the report into the run summary. The gate has always + # enforced correctly, but its numbers only ever existed in a job + # log nobody opens, so the frame-arena fix had no reviewable + # evidence anywhere -- an RC audit recorded it as "zero coverage". + # pipefail so tee cannot mask a budget breach, and capture the status + # instead of letting `set -e` abort here -- a FAILING gate is exactly + # when the numbers need to reach the summary. Re-exit with it below. + set -o pipefail + rc=0 + python3 tools/check_sram_budget.py \ + --elf bin/firmware.keepkey.elf \ + --su-tar bin/stack-usage.tgz \ + --budgets tools/sram-budgets.json \ + --variant "${{ matrix.variant }}" 2>&1 \ + | tee "/tmp/sram-${{ matrix.variant }}.txt" || rc=$? + { + echo "### SRAM budget — ${{ matrix.variant }}" + echo '```' + cat "/tmp/sram-${{ matrix.variant }}.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit $rc + - name: Rename firmware artifacts run: | 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.suffix }}-${f}" done for f in *.elf; do [ -f "$f" ] || continue - mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${f}" + mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}${{ matrix.suffix }}-${f}" done ls -lh - echo "::notice::Firmware v${{ steps.version.outputs.fw_version }} built successfully" + echo "::notice::Firmware v${{ steps.version.outputs.fw_version }} (${{ matrix.variant }}) built successfully" - - name: Bind ARM outputs to source commits + - name: Bind ARM outputs to source commits and product env: ARM_VARIANT: ${{ matrix.variant }} run: | @@ -452,14 +624,15 @@ jobs: PY - name: Upload firmware artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - # matrix.variant in the name: two legs uploading the same artifact - # name is a hard failure on upload-artifact@v4+ (see release.yml). name: firmware-v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${{ matrix.variant }} path: | bin/*.bin bin/*.elf + bin/*.map + bin/*.size.txt + bin/stack-usage.tgz bin/arm-build-manifest.json retention-days: 90 @@ -468,44 +641,55 @@ jobs: # ═══════════════════════════════════════════════════════════ unit-tests: + name: unit-tests${{ matrix.label }} needs: build-emulator runs-on: ubuntu-latest timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - variant: full + label: "" + cmake_flags: "" + - variant: bitcoin-only + label: " (bitcoin-only)" + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Download emulator image - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: emu-image + name: emu-image-${{ matrix.variant }} path: /tmp - name: Load emulator image run: docker load -i /tmp/emu-image.tar - - name: Run unit tests + - name: Run unit tests (${{ matrix.variant }}) run: | # make xunit returns non-zero if any test fails — capture # exit code so JUnit XML still gets copied for reporting docker run --rm \ -v ${{ github.workspace }}/test-reports:/kkemu/test-reports \ --entrypoint /bin/sh \ - ${{ env.EMU_IMAGE }} \ + ${{ env.EMU_IMAGE }}-${{ matrix.variant }} \ -c "mkdir -p /kkemu/test-reports/firmware-unit && \ make xunit; RC=\$?; \ cp -r unittests/*.xml /kkemu/test-reports/firmware-unit/ 2>/dev/null; \ exit \$RC" - name: Upload unit test results - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: - name: unit-test-results + name: unit-test-results-${{ matrix.variant }} path: test-reports/firmware-unit/ retention-days: 30 python-integration-tests: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -520,7 +704,7 @@ jobs: oled_artifact: oled-screenshots-bitcoin-only steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -536,18 +720,25 @@ jobs: - name: Build and run tests (docker compose) working-directory: scripts/emulator run: | + # Run each test container — capture exit codes, always extract reports + # + # `|| RC=$?` rather than `; RC=$?`: this step runs under the default + # `bash -e`, where a bare failing command aborts immediately. With the + # semicolon form the abort happened BEFORE the assignment and before + # the extraction below, so a failing test run uploaded nothing at all + # — no JUnit, no report, no OLED frames — precisely when the evidence + # was needed. The `if: always()` on the upload steps could not help, + # because nothing had been copied out of the container yet. + FW_RC=0 + PY_RC=0 COMPOSE_ARGS=(-f docker-compose.yml) if [ "${{ matrix.variant }}" = "bitcoin-only" ]; then COMPOSE_ARGS+=(-f docker-compose.bitcoin-only.yml) fi - - # Run each test container — capture exit codes, always extract reports - set +e docker compose "${COMPOSE_ARGS[@]}" -p ${{ matrix.project }} \ - up --build --exit-code-from firmware-unit firmware-unit; FW_RC=$? + up --build --exit-code-from firmware-unit firmware-unit || FW_RC=$? docker compose "${COMPOSE_ARGS[@]}" -p ${{ matrix.project }} \ - up --build --exit-code-from python-keepkey python-keepkey; PY_RC=$? - set -e + up --build --exit-code-from python-keepkey python-keepkey || PY_RC=$? REPORT_ROOT=${{ github.workspace }}/test-reports/${{ matrix.variant }} mkdir -p "$REPORT_ROOT" @@ -577,7 +768,7 @@ jobs: [ "$FW_RC" -eq 0 ] && [ "$PY_RC" -eq 0 ] || exit 1 - name: Upload Python test results - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: ${{ matrix.python_artifact }} @@ -585,7 +776,7 @@ jobs: retention-days: 30 - name: Upload native test results - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: firmware-unit-results-${{ matrix.variant }} @@ -593,13 +784,13 @@ jobs: retention-days: 30 - name: Upload OLED screenshots - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: ${{ matrix.oled_artifact }} path: test-reports/${{ matrix.variant }}/screenshots/ retention-days: 90 - if-no-files-found: error + if-no-files-found: warn - name: Tear down if: always() @@ -648,12 +839,12 @@ jobs: # fsm_msgDebugLinkGetState is excluded from the build and any # read_layout() call hangs the test). python-dylib-tests: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: macos-latest timeout-minutes: 25 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -670,7 +861,7 @@ jobs: git submodule update --init deps/googletest - name: Setup Python 3.10 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.10' @@ -749,7 +940,8 @@ jobs: # it fsm_msgDebugLinkGetState is excluded from the build. # CMAKE_POLICY_VERSION_MINIMUM works around vendored # googletest's pre-3.5 policy declaration. - export PATH="$PATH:$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" + NANOPB_GENERATOR_DIR="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" + export PATH="$PATH:$NANOPB_GENERATOR_DIR" which protoc-gen-nanopb which nanopb_generator.py cmake \ @@ -761,26 +953,76 @@ jobs: - name: Build kkemulator_dylib run: | - export PATH="$PATH:$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" - cmake --build build-emu --target kkemulator_dylib -j$(sysctl -n hw.ncpu) + NANOPB_GENERATOR_DIR="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" + export PATH="$PATH:$NANOPB_GENERATOR_DIR" + CPU_COUNT="$(sysctl -n hw.ncpu)" + cmake --build build-emu --target kkemulator_dylib -j"$CPU_COUNT" ls -la build-emu/lib/libkkemu* || ls -la build-emu/lib/emulator/libkkemu* || true # Surface the resolved binary path for the run step. macOS # produces .dylib; .so is preserved as a fallback for when this # job goes cross-platform. - DYLIB=$(find build-emu -name 'libkkemu.dylib' -o -name 'libkkemu.so' | head -1) + DYLIB="$(find build-emu \( -name 'libkkemu.dylib' -o -name 'libkkemu.so' \) -print -quit)" test -f "$DYLIB" || (echo "::error::libkkemu artifact not found" && exit 1) - echo "DYLIB_PATH=$(pwd)/$DYLIB" >> $GITHUB_ENV + echo "DYLIB_PATH=$(pwd)/$DYLIB" >> "$GITHUB_ENV" + + # ── Windows emulator DLL (cross-compiled from this macOS runner) ── + # Uses cmake/toolchains/mingw-w64-x86_64.cmake, so no Windows runner + # is needed. BLOCKING on purpose: the DLL and the dylib are the same + # deliverable on two platforms, and this job is the only place either + # is built. When the cross-build was non-blocking, a regression here + # produced a macOS-only set and stayed green. + - name: Install MinGW-w64 + run: brew install mingw-w64 + + - name: Cross-build Windows emulator DLL + run: | + # Same nanopb PATH handling as the macOS configure step above; the + # toolchain file cannot see the pyenv wrappers on its own. + NANOPB_DIR="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')" + export PATH="$PATH:$NANOPB_DIR/generator" + CPU_COUNT="$(sysctl -n hw.ncpu)" + NANOPB_PLUGIN="$(command -v protoc-gen-nanopb)" + cmake \ + -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64-x86_64.cmake \ + -DKK_EMULATOR=1 \ + -DKK_BUILD_DYLIB=1 \ + -DKK_DEBUG_LINK=ON \ + -DNANOPB_DIR="$NANOPB_DIR" \ + -DNANOPB_PLUGIN="$NANOPB_PLUGIN" \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + -B build-emu-win . + cmake --build build-emu-win --target kkemulator_dylib -j"$CPU_COUNT" + DLL="$(find build-emu-win -name 'libkkemu.dll' -print -quit)" + test -f "$DLL" || (echo "::error::libkkemu.dll not produced by cross-build" && exit 1) + file "$DLL" + echo "WIN_DLL_PATH=$(pwd)/$DLL" >> "$GITHUB_ENV" + + - name: Stage emulator libraries + # One directory, published names applied here, so every consumer + # (this artifact, the rolling prerelease, a tagged firmware + # release) ships the identical pair of files. + if: always() && env.DYLIB_PATH != '' && env.WIN_DLL_PATH != '' + run: | + mkdir -p emulator-libs + cp "$DYLIB_PATH" emulator-libs/libkkemu-macos-arm64.dylib + cp "$WIN_DLL_PATH" emulator-libs/libkkemu-win-x64.dll + ls -lh emulator-libs/ - - name: Upload libkkemu.dylib - # Always upload, even on later test failure — the binary is + - name: Upload emulator libraries + # Always upload, even on later test failure — the binaries are # what vault and external auditors actually consume from this # PR. Tagged with the short commit SHA so multiple PR pushes # don't overwrite each other when a reviewer downloads them. - if: always() && env.DYLIB_PATH != '' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + # + # Gated on BOTH paths: a partial artifact carrying only the dylib + # is exactly the silent drop this coupling exists to prevent, and + # release.yml treats this artifact as the source of truth for a + # tagged release's emulator assets. + if: always() && env.DYLIB_PATH != '' && env.WIN_DLL_PATH != '' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: libkkemu-${{ github.event.pull_request.head.sha || github.sha }} - path: ${{ env.DYLIB_PATH }} + path: emulator-libs/ retention-days: 30 if-no-files-found: error @@ -797,7 +1039,7 @@ jobs: # skip rationale), it's a transitive dep some tests use. pip install pytest pytest-timeout - - name: Run dylib screenshot tests + - name: Run dylib transport tests working-directory: deps/python-keepkey/tests env: KK_TRANSPORT: dylib @@ -805,12 +1047,26 @@ jobs: run: | # `keepkeylib/` on PYTHONPATH so the package's relative-style # imports inside generated *_pb2.py files resolve. + # + # test_dylib_confirm_flow contributes exactly ONE test here: + # test_features_round_trip, the pure Initialize -> Features path + # that never enters confirm_helper. It is named explicitly rather + # than adding the whole file, because the file's other test + # (test_load_device_with_auto_confirm) is @unittest.skip'd on an + # OPEN firmware bug -- confirm_helper busy-loops on a ButtonAck + # the dylib consumed but never delivered, and no pytest-timeout + # method can interrupt a C-level kkemu_poll() loop. Adding the + # file wholesale would trade a silent coverage gap for a runner + # that burns the job's 25-minute timeout. + # + # When that firmware fix lands, run the whole file instead. PYTHONPATH=../keepkeylib:.. python -m pytest \ test_dylib_screenshot.py \ + "test_dylib_confirm_flow.py::TestDylibConfirmFlow::test_features_round_trip" \ -v --tb=short --junit-xml=../../../test-reports/dylib-junit.xml - name: Upload dylib test results - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: python-dylib-test-results @@ -823,47 +1079,44 @@ jobs: # ═══════════════════════════════════════════════════════════ generate-test-report: - needs: [unit-tests, python-integration-tests, python-dylib-tests, build-arm-firmware] + needs: [unit-tests, python-integration-tests, build-arm-firmware] if: always() runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Download unit test results - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + continue-on-error: true with: - name: unit-test-results + # Report covers the regular/full build. Bitcoin-only is built and + # unit-tested in its own matrix leg but does not get a PDF. + name: unit-test-results-full path: test-reports/firmware-unit/ - name: Download python test results - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + continue-on-error: true with: name: python-test-results path: test-reports/python-keepkey/ - name: Download OLED screenshots - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + continue-on-error: true with: name: oled-screenshots path: test-reports/screenshots/ - - name: Download dylib test results - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c - with: - name: python-dylib-test-results - path: test-reports/ - - - name: Download ARM firmware - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + - name: Download both ARM products + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: firmware-v* path: test-reports/arm/ - # Preserve one directory per product. Full and bitcoin-only contain - # identically named outputs whose bytes intentionally differ. merge-multiple: false - name: Extract firmware version @@ -873,47 +1126,21 @@ jobs: echo "fw_version=${FW_VERSION}" >> "$GITHUB_OUTPUT" - name: Init python-keepkey submodule - id: python - run: | - git submodule update --init deps/python-keepkey - echo "sha=$(git rev-parse HEAD:deps/python-keepkey)" >> "$GITHUB_OUTPUT" + run: git submodule update --init deps/python-keepkey - name: Generate test report PDF env: FW_VERSION: ${{ steps.version.outputs.fw_version }} - KK_FIRMWARE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - KK_PYTHON_SHA: ${{ steps.python.outputs.sha }} - KK_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - KK_WORKFLOW_EVENT: ${{ github.event_name }} - KK_FIRMWARE_PR: ${{ github.event.pull_request.html_url }} - KK_PYTHON_PR: https://github.com/keepkey/python-keepkey/pull/197 + KK_BUILD_LABEL: ${{ github.head_ref || github.ref_name }}@${{ github.event.pull_request.head.sha || github.sha }} run: python3 scripts/generate-test-report.py - name: Upload test report - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: test-report - path: test-report/ + path: test-report.pdf retention-days: 90 - if-no-files-found: error - - release-evidence-gate: - needs: [unit-tests, python-integration-tests, python-dylib-tests, build-arm-firmware, generate-test-report] - if: always() - runs-on: ubuntu-latest - steps: - - name: Require every release job - env: - UNIT_RESULT: ${{ needs.unit-tests.result }} - PYTHON_RESULT: ${{ needs.python-integration-tests.result }} - DYLIB_RESULT: ${{ needs.python-dylib-tests.result }} - ARM_RESULT: ${{ needs.build-arm-firmware.result }} - REPORT_RESULT: ${{ needs.generate-test-report.result }} - run: | - for result in "$UNIT_RESULT" "$PYTHON_RESULT" "$DYLIB_RESULT" "$ARM_RESULT" "$REPORT_RESULT"; do - test "$result" = success || exit 1 - done # ═══════════════════════════════════════════════════════════ # STAGE 4: PUBLISH — manual trigger only, all tests must pass @@ -971,8 +1198,7 @@ jobs: NOT_SUCCESS=$(echo "$NEEDS_JSON" \ | jq -r 'to_entries[] | select(.value.result != "success") | .key') if [ -n "$NOT_SUCCESS" ]; then - NOT_SUCCESS_INLINE=$(printf '%s\n' "$NOT_SUCCESS" | tr '\n' ' ') - echo "::error::Required jobs did not succeed: ${NOT_SUCCESS_INLINE}" + echo "::error::Required jobs did not succeed: $(echo $NOT_SUCCESS | tr '\n' ' ')" echo "A skipped or cancelled required job is NOT a pass. If a gate-stage" echo "job failed, everything downstream was skipped and produced no signal." exit 1 @@ -982,7 +1208,7 @@ jobs: # ═══════════════════════════════════════════════════════════ publish-emulator: - needs: [release-evidence-gate, build-emulator] + needs: [unit-tests, python-integration-tests, python-dylib-tests, build-arm-firmware] if: >- github.event_name == 'workflow_dispatch' && github.event.inputs.publish_emulator == 'true' @@ -990,14 +1216,15 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Download emulator image - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: emu-image + # Publish the regular/full image, including Zcash privacy support. + name: emu-image-full path: /tmp - name: Load emulator image @@ -1012,11 +1239,11 @@ jobs: - name: Tag images for publish run: | - docker tag ${{ env.EMU_IMAGE }} kktech/kkemu:latest - docker tag ${{ env.EMU_IMAGE }} kktech/kkemu:v${{ steps.version.outputs.fw_version }} + docker tag ${{ env.EMU_IMAGE }}-full kktech/kkemu:latest + docker tag ${{ env.EMU_IMAGE }}-full kktech/kkemu:v${{ steps.version.outputs.fw_version }} - name: Login to DockerHub - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 with: username: ${{ secrets.KK_DOCKERHUB_USER }} password: ${{ secrets.KK_DOCKERHUB_PASS }} @@ -1025,3 +1252,82 @@ jobs: run: | docker push kktech/kkemu:latest docker push kktech/kkemu:v${{ steps.version.outputs.fw_version }} + + # ═══════════════════════════════════════════════════════════ + # STAGE 4b: PUBLISH EMULATOR LIBS — rolling release of the + # emulator native libraries so downstream consumers (vault + # native-emulator / OLED preview) have a stable download URL + # instead of scraping per-run, 30-day-retention CI artifacts. + # + # Runs for every integration branch that ships emulator work — + # develop is the release line, alpha is the fork-pinned + # integration branch, and both are consumed. Tagged releases + # get the same two files from release.yml, which reuses the + # artifact this job's dependency produced. + # + # macOS dylib and Windows DLL ship together, always. They are + # one deliverable on two platforms; a release carrying only + # one is a broken release, not a partial success, so every + # check here is fatal. + # ═══════════════════════════════════════════════════════════ + publish-emulator-libs: + needs: [python-dylib-tests] + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/alpha') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Download emulator libraries + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: libkkemu-${{ github.sha }} + path: release-dylib + + - name: Stage release assets + run: | + FW_VERSION=$(sed -n '/^project/,/)/p' CMakeLists.txt | grep -oP '\d+\.\d+\.\d+') + # Traceability — which firmware/commit these binaries correspond to. + { + echo "firmware_version=${FW_VERSION}" + echo "commit=${GITHUB_SHA}" + echo "branch=${GITHUB_REF_NAME}" + echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > release-dylib/VERSION.txt + echo "=== Assets ===" && ls -lh release-dylib/ + # Both binaries or no publish. The upload side already enforces + # this; asserting again here means a hand-crafted or partially + # expired artifact cannot slip a one-platform release through. + for asset in libkkemu-macos-arm64.dylib libkkemu-win-x64.dll; do + test -f "release-dylib/$asset" || \ + (echo "::error::$asset missing — refusing to publish a partial emulator release" && exit 1) + done + + - name: Publish to rolling prerelease + env: + GH_TOKEN: ${{ github.token }} + run: | + # Idempotent: create the rolling tag on first use, then replace + # its assets in place. `gh release upload --clobber` is used + # rather than an action that rewrites the release body, so the + # description stays stable across every update. + if ! gh release view emulator-dylib-latest --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create emulator-dylib-latest --repo "$GITHUB_REPOSITORY" \ + --prerelease --title "Emulator libraries (rolling)" \ + --notes "Rolling build of the KeepKey emulator native libraries. + + - \`libkkemu-macos-arm64.dylib\` — macOS Apple Silicon + - \`libkkemu-win-x64.dll\` — Windows x86_64 (MinGW cross-build) + - \`VERSION.txt\` — firmware version + commit these correspond to + + Both platforms are published together; CI fails rather than + shipping one without the other. Updated on every push to + \`develop\` and \`alpha\`. Not a signed firmware release." + fi + gh release upload emulator-dylib-latest release-dylib/* \ + --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9319365f..78570b00b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,14 @@ # KeepKey Firmware Release Pipeline # -# Triggered by version tags (v*) on master. -# Verifies and packages the exact binaries from successful presign CI, -# then creates a draft GitHub Release with their evidence. +# Triggered by version tags (v*). +# Builds firmware, computes reproducible hashes, +# and creates a draft GitHub Release with all artifacts. # -# Git-flow: tag master after merging a release/* or hotfix/* branch. +# Tag a commit only after its CI workflow has completed successfully. # # Usage: -# git tag v7.11.0 -# git push origin v7.11.0 +# git tag -a v7.15.0-rc17 -m "KeepKey firmware 7.15.0 RC17 test candidate" +# git push origin v7.15.0-rc17 name: Release @@ -22,7 +22,6 @@ env: permissions: contents: write - actions: read jobs: validate: @@ -30,147 +29,55 @@ jobs: timeout-minutes: 3 outputs: fw_version: ${{ steps.version.outputs.fw_version }} - ci_run_id: ${{ steps.evidence.outputs.ci_run_id }} - arm_artifact: ${{ steps.evidence.outputs.arm_artifact }} + tag_name: ${{ steps.version.outputs.tag_name }} + is_prerelease: ${{ steps.version.outputs.is_prerelease }} steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 - with: - submodules: recursive + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Extract and verify version id: version run: | TAG_VERSION="${GITHUB_REF_NAME#v}" FW_VERSION=$(sed -n '/^project/,/)/p' CMakeLists.txt | grep -oP '\d+\.\d+\.\d+') - echo "fw_version=${FW_VERSION}" >> "$GITHUB_OUTPUT" + IS_PRERELEASE=false + RC_PREFIX="${FW_VERSION}-rc" + if [ "$TAG_VERSION" != "$FW_VERSION" ]; then - echo "::error::Tag (${TAG_VERSION}) != CMakeLists.txt (${FW_VERSION})" - exit 1 + if [[ "$TAG_VERSION" != "${RC_PREFIX}"* ]]; then + echo "::error::Tag (${TAG_VERSION}) must be ${FW_VERSION} or ${RC_PREFIX}" + exit 1 + fi + + RC_NUMBER="${TAG_VERSION#"${RC_PREFIX}"}" + if ! [[ "$RC_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid release-candidate tag (${TAG_VERSION}); expected ${RC_PREFIX}" + exit 1 + fi + IS_PRERELEASE=true fi - - name: Resolve exact-commit presign evidence - id: evidence - env: - GH_TOKEN: ${{ github.token }} - FW_VERSION: ${{ steps.version.outputs.fw_version }} - run: | - gh api --method GET \ - "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs" \ - -f head_sha="$GITHUB_SHA" -f status=success -f per_page=100 \ - > /tmp/ci-runs.json - python3 - <<'PY' - import json - import os - - with open('/tmp/ci-runs.json', encoding='utf-8') as handle: - runs = json.load(handle).get('workflow_runs', []) - runs = [run for run in runs - if run.get('head_sha') == os.environ['GITHUB_SHA'] - and run.get('conclusion') == 'success'] - if not runs: - raise SystemExit('no successful CI run exists for the tagged commit') - run = max(runs, key=lambda item: item.get('created_at', '')) - run_id = str(run['id']) - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output: - output.write('ci_run_id=%s\n' % run_id) - with open('/tmp/ci-run-id', 'w', encoding='ascii') as output: - output.write(run_id) - PY - RUN_ID=$(cat /tmp/ci-run-id) - gh api \ - "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?per_page=100" \ - > /tmp/ci-artifacts.json - python3 - <<'PY' - import json - import os - - with open('/tmp/ci-artifacts.json', encoding='utf-8') as handle: - artifacts = json.load(handle).get('artifacts', []) - names = [item['name'] for item in artifacts - if not item.get('expired')] - if 'test-report' not in names: - raise SystemExit('exact-commit CI has no test-report artifact') - prefix = 'firmware-v%s-' % os.environ['FW_VERSION'] - arm = [name for name in names if name.startswith(prefix)] - variants = { - variant: [name for name in arm if name.endswith('-' + variant)] - for variant in ('full', 'bitcoin-only') - } - if any(len(found) != 1 for found in variants.values()): - raise SystemExit('expected exact full and bitcoin-only ARM artifacts, found %r' % arm) - with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output: - output.write('arm_full_artifact=%s\n' % variants['full'][0]) - output.write('arm_bitcoin_artifact=%s\n' % variants['bitcoin-only'][0]) - PY - - - name: Verify audited source and binary hashes + { + echo "fw_version=${FW_VERSION}" + echo "tag_name=${GITHUB_REF_NAME}" + echo "is_prerelease=${IS_PRERELEASE}" + } >> "$GITHUB_OUTPUT" + + - name: Require green CI on the tagged commit env: GH_TOKEN: ${{ github.token }} - CI_RUN_ID: ${{ steps.evidence.outputs.ci_run_id }} - ARM_FULL_ARTIFACT: ${{ steps.evidence.outputs.arm_full_artifact }} - ARM_BITCOIN_ARTIFACT: ${{ steps.evidence.outputs.arm_bitcoin_artifact }} run: | - gh run download "$CI_RUN_ID" --repo "$GITHUB_REPOSITORY" \ - --name test-report --dir audited-report - gh run download "$CI_RUN_ID" --repo "$GITHUB_REPOSITORY" \ - --name "$ARM_FULL_ARTIFACT" --dir audited-arm/full - gh run download "$CI_RUN_ID" --repo "$GITHUB_REPOSITORY" \ - --name "$ARM_BITCOIN_ARTIFACT" --dir audited-arm/bitcoin-only - python3 - <<'PY' - import hashlib - import json - import os - import subprocess - from pathlib import Path - - def digest(path): - value = hashlib.sha256() - with path.open('rb') as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b''): - value.update(chunk) - return value.hexdigest() - - report_dir = Path('audited-report') - arm_dir = Path('audited-arm') - with (report_dir / 'test-report-manifest.json').open( - encoding='utf-8') as handle: - manifest = json.load(handle) - if manifest.get('firmware_sha') != os.environ['GITHUB_SHA']: - raise SystemExit('presign manifest is for a different commit') - python_sha = subprocess.check_output( - ['git', 'rev-parse', 'HEAD:deps/python-keepkey'], - text=True).strip() - if manifest.get('python_sha') != python_sha: - raise SystemExit('presign manifest has a different Python pin') - run_id = os.environ['CI_RUN_ID'] - if not manifest.get('run_url', '').endswith('/' + run_id): - raise SystemExit('presign manifest names a different CI run') - pdf = report_dir / manifest['pdf']['path'] - if digest(pdf) != manifest['pdf']['sha256']: - raise SystemExit('presign PDF hash mismatch') - variants = manifest.get('arm', {}).get('variants', {}) - if set(variants) != {'full', 'bitcoin-only'}: - raise SystemExit('presign manifest does not bind both ARM products') - manifest_hashes = {} - for variant, evidence in variants.items(): - variant_dir = arm_dir / variant - arm_manifest = variant_dir / 'arm-build-manifest.json' - manifest_hashes[variant] = digest(arm_manifest) - if manifest_hashes[variant] != evidence['manifest_sha256']: - raise SystemExit('%s ARM manifest hash mismatch' % variant) - expected = {item['name']: item['sha256'] - for item in evidence['files']} - actual = {path.name: digest(path) - for path in variant_dir.iterdir() - if path.suffix in ('.bin', '.elf')} - if not expected or actual != expected: - raise SystemExit('%s audited ARM artifact set or hash mismatch' % variant) - combined = hashlib.sha256(json.dumps( - manifest_hashes, sort_keys=True).encode('ascii')).hexdigest() - if combined != manifest['arm']['manifest_set_sha256']: - raise SystemExit('ARM manifest-set hash mismatch') - print('exact presign evidence and both ARM products verified') - PY + # A tag on a red (or untested) commit must not produce release + # artifacts. The tagged SHA already ran the CI workflow on its + # branch push; require that run to exist and have succeeded. + CONCLUSION=$(gh run list --repo "$GITHUB_REPOSITORY" \ + --workflow CI --commit "$GITHUB_SHA" \ + --json status,conclusion \ + --jq '[.[] | select(.status == "completed")] | map(.conclusion) | first') + echo "CI conclusion for $GITHUB_SHA: ${CONCLUSION:-none}" + if [ "$CONCLUSION" != "success" ]; then + echo "::error::No successful CI run found for ${GITHUB_SHA} — refusing to release." + exit 1 + fi build-firmware: needs: validate @@ -192,80 +99,74 @@ jobs: cmake_flags: "-DKK_BITCOIN_ONLY=ON" timeout-minutes: 20 steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - name: Download audited release inputs - env: - GH_TOKEN: ${{ github.token }} - CI_RUN_ID: ${{ needs.validate.outputs.ci_run_id }} - ARM_ARTIFACT: ${{ needs.validate.outputs.arm_artifact }} + - name: Init required submodules run: | - gh run download "$CI_RUN_ID" --repo "$GITHUB_REPOSITORY" \ - --name test-report --dir audited-report - gh run download "$CI_RUN_ID" --repo "$GITHUB_REPOSITORY" \ - --name "$ARM_ARTIFACT" --dir audited-arm + git submodule update --init deps/crypto/trezor-firmware + git submodule update --init deps/device-protocol + git submodule update --init --recursive deps/python-keepkey + git submodule update --init deps/googletest + git submodule update --init deps/qrenc/QR-Code-generator + git submodule update --init deps/sca-hardening/SecAESSTM32 - - name: Prepare the audited binaries - run: | - python3 - <<'PY' - import shutil - from pathlib import Path - - report_dir = Path('audited-report') - arm_dir = Path('audited-arm') - release = Path('release') - release.mkdir() - mappings = { - '-firmware.keepkey.bin': 'firmware.keepkey.bin', - '-firmware.keepkey.elf': 'firmware.keepkey.elf', - '-bootloader.bin': 'bootloader.bin', - } - copied = set() - for source in arm_dir.iterdir(): - for suffix, target in mappings.items(): - if source.name.endswith(suffix): - if target in copied: - raise SystemExit('duplicate audited artifact: ' + target) - shutil.copyfile(str(source), str(release / target)) - copied.add(target) - required = {'firmware.keepkey.bin', 'firmware.keepkey.elf'} - if not required.issubset(copied): - raise SystemExit('audited firmware bin/elf are missing') - shutil.copyfile(str(report_dir / 'test-report.pdf'), - str(release / 'presign-test-report.pdf')) - shutil.copyfile(str(report_dir / 'test-report-manifest.json'), - str(release / 'presign-evidence-manifest.json')) - PY + - name: Cache base image + id: cache-base + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: /tmp/base-image.tar + key: base-image-${{ env.BASE_IMAGE }} - - name: Compute hashes - working-directory: release + - name: Pull and cache base image + if: steps.cache-base.outputs.cache-hit != 'true' run: | - SUFFIX="${{ matrix.suffix }}" - { - echo "# KeepKey Firmware v${{ needs.validate.outputs.fw_version }} — Hash Manifest" - echo "" - # Provenance: name the exact toolchain these bytes came out of. BASE_IMAGE - # is a sha256 manifest digest, not a tag, so this identifies one immutable - # image rather than whatever the tag pointed at on the day. Without it a - # green CI build and a locally reproduced binary cannot be shown to be the - # same toolchain product. See GH #425. - echo "builder image ${BASE_IMAGE}" - echo "source commit ${GITHUB_SHA}" - echo "" - for f in *.bin; do - [ -f "$f" ] || continue - FULL_HASH=$(sha256sum "$f" | awk '{print $1}') - echo "sha256 (full) $f $FULL_HASH" - FILE_SIZE=$(stat -c%s "$f") - if [ "$FILE_SIZE" -gt 256 ]; then - PAYLOAD_HASH=$(tail -c +257 "$f" | sha256sum | awk '{print $1}') - echo "sha256 (payload) $f $PAYLOAD_HASH" - fi - echo "" - done - } > "HASHES${SUFFIX}.txt" - cat "HASHES${SUFFIX}.txt" + docker pull ${{ env.BASE_IMAGE }} + docker save ${{ env.BASE_IMAGE }} -o /tmp/base-image.tar + + - name: Load base image from cache + if: steps.cache-base.outputs.cache-hit == 'true' + run: docker load -i /tmp/base-image.tar + - name: Cross-compile firmware (${{ matrix.variant }}) + run: | + docker run --rm \ + -v ${{ github.workspace }}:/root/keepkey-firmware:z \ + ${{ env.BASE_IMAGE }} /bin/sh -c "\ + mkdir /root/build && cd /root/build && \ + cmake -C /root/keepkey-firmware/cmake/caches/device.cmake /root/keepkey-firmware \ + -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DCMAKE_COLOR_MAKEFILE=ON \ + ${{ matrix.cmake_flags }} && \ + make && \ + mkdir -p /root/keepkey-firmware/release && \ + cp bin/firmware.keepkey.bin /root/keepkey-firmware/release/ && \ + cp bin/firmware.keepkey.elf /root/keepkey-firmware/release/ && \ + find . -name '*.su' -print0 | tar czf /root/keepkey-firmware/release/stack-usage.tgz --null -T - && \ + chmod -R a+rw /root/keepkey-firmware/release" + + # Same SRAM budget gate CI enforces (rc8 boot-fault class): release + # artifacts must clear it too, not just the 16 KiB linker ASSERT. + - name: SRAM budget gate (${{ matrix.variant }}) + run: | + pip install --quiet pyelftools + python3 tools/check_sram_budget.py \ + --elf release/firmware.keepkey.elf \ + --su-tar release/stack-usage.tgz \ + --budgets tools/sram-budgets.json \ + --variant "${{ matrix.variant }}" + + # A RELEASE PUBLISHES FIRMWARE ONLY -- never a bootloader. + # + # The build produces bin/bootloader.bin, and this step used to copy it + # into release/, where the rename turned it into bootloader.v.bin and + # release-assets/* published it. A bootloader is a separately signed + # artifact with its own rollout, and shipping one as a side effect of + # tagging firmware is how a device gets bricked by an image nobody + # reviewed as a bootloader release. It is no longer copied, so it cannot + # be renamed, hashed or attached. + # + # Renaming has to happen BEFORE hashing, or the manifest names files that + # are never published. - name: Rename artifacts working-directory: release run: | @@ -273,11 +174,40 @@ jobs: SUFFIX="${{ matrix.suffix }}" [ -f firmware.keepkey.bin ] && mv firmware.keepkey.bin "firmware.keepkey.v${VER}${SUFFIX}.bin" [ -f firmware.keepkey.elf ] && mv firmware.keepkey.elf "firmware.keepkey.v${VER}${SUFFIX}.elf" - [ -f bootloader.bin ] && mv bootloader.bin "bootloader.v${VER}.bin" ls -lh + # The manifest CI can produce describes the UNSIGNED build, because the + # signatures do not exist yet: hash-manifest.sh reads that state off the + # artifacts and says so in the file. Key holders re-run the same script + # over the signed binaries before publishing -- see the release body's + # signing checklist -- so the published hashes describe the published + # bytes rather than a draft nobody installs. + - name: Compute hashes + run: | + scripts/release/hash-manifest.sh release \ + "${{ needs.validate.outputs.fw_version }}" \ + "${{ matrix.variant }}" \ + "${{ matrix.suffix }}" + + - name: Record build provenance + working-directory: release + run: | + VARIANT="${{ matrix.variant }}" + SUFFIX="${{ matrix.suffix }}" + { + echo "firmware_commit=${GITHUB_SHA}" + echo "source_repository=${GITHUB_REPOSITORY}" + echo "source_ref=${GITHUB_REF}" + echo "workflow_ref=${GITHUB_WORKFLOW_REF}" + echo "builder_image=${BASE_IMAGE}" + echo "runner_image=${ImageOS:-unknown}" + echo "variant=${VARIANT}" + echo "cmake_flags=${{ matrix.cmake_flags }}" + } > "PROVENANCE${SUFFIX}.txt" + cat "PROVENANCE${SUFFIX}.txt" + - name: Upload release artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: # Per-variant, because upload-artifact v4+ makes names immutable: two # matrix legs writing one name is a hard failure, not a merge. @@ -289,14 +219,29 @@ jobs: needs: validate runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - variant: full + cmake_flags: "" + - variant: bitcoin-only + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 - with: - submodules: recursive + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Init required submodules + run: | + git submodule update --init deps/crypto/trezor-firmware + git submodule update --init deps/device-protocol + git submodule update --init --recursive deps/python-keepkey + git submodule update --init deps/googletest + git submodule update --init deps/qrenc/QR-Code-generator + git submodule update --init deps/sca-hardening/SecAESSTM32 - name: Cache base image id: cache-base - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -311,21 +256,29 @@ 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: needs: [validate, build-firmware, test] runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: write + # Reading the CI run's artifacts (the emulator libraries) needs + # actions:read. The top-level block grants contents only, and a + # declared block zeroes every scope it omits. + actions: read steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download firmware artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: pattern: release-firmware-* path: artifacts @@ -334,40 +287,118 @@ jobs: - name: Prepare release assets run: | mkdir -p release-assets - cp artifacts/*.bin artifacts/*.elf artifacts/*.pdf artifacts/*.json \ - artifacts/HASHES*.txt release-assets/ + # No dash in the glob: the default variant's files are HASHES.txt and + # PROVENANCE.txt, and a 'HASHES-*' pattern would silently drop them. + cp artifacts/*.bin artifacts/*.elf artifacts/HASHES*.txt \ + artifacts/PROVENANCE*.txt release-assets/ + ls -lh release-assets/ + + # Firmware only. If a bootloader ever reaches this directory again, + # stop rather than publish it. + if ls release-assets/ | grep -i bootloader; then + echo "::error::A bootloader artifact reached the release assets." + exit 1 + fi + + - name: Attach emulator libraries + env: + GH_TOKEN: ${{ github.token }} + run: | + # Every release ships the emulator native libs alongside the + # device firmware. They are NOT rebuilt here: CI already built + # and tested them on the branch push this tag points at, and + # validate/ already required that run to be green. Reusing that + # artifact means the published binaries are the ones that were + # tested, not a lookalike from a second build. + RUN_ID=$(gh run list --repo "$GITHUB_REPOSITORY" \ + --workflow CI --commit "$GITHUB_SHA" \ + --json status,conclusion,databaseId \ + --jq '[.[] | select(.status == "completed" and .conclusion == "success")] | map(.databaseId) | first') + if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then + echo "::error::No successful CI run for ${GITHUB_SHA} — cannot source emulator libraries." + exit 1 + fi + echo "Sourcing emulator libraries from CI run ${RUN_ID}" + # Artifacts expire after 30 days. Tagging a commit older than + # that means re-running CI on it to regenerate them. + gh run download "$RUN_ID" --repo "$GITHUB_REPOSITORY" \ + -n "libkkemu-${GITHUB_SHA}" -D emulator-libs + + # Both platforms or no release. A firmware release that ships a + # macOS emulator lib and silently omits the Windows one is the + # exact failure this coupling exists to prevent. + for asset in libkkemu-macos-arm64.dylib libkkemu-win-x64.dll; do + test -f "emulator-libs/$asset" || \ + (echo "::error::$asset missing from CI artifact — refusing to publish a partial emulator set." && exit 1) + done + cp emulator-libs/libkkemu-macos-arm64.dylib \ + emulator-libs/libkkemu-win-x64.dll release-assets/ ls -lh release-assets/ - name: Generate release body run: | VER="${{ needs.validate.outputs.fw_version }}" cat > release-body.md < **DRAFT** — firmware must be signed by 3/5 key holders before publishing. - - ### Signing Checklist - - [ ] Built on multiple machines, hashes match + > **DRAFT TEST CANDIDATE** — RC artifacts are unsigned and intended for + > release-candidate testing. Firmware must be signed by 3/5 key holders + > before publishing a production release. + > + > The attached \`HASHES*.txt\` describes the **unsigned** build. Signing + > rewrites the 256-byte metadata descriptor, so the device-image hash + > changes; only the payload hash survives it. Do not pin a hash from a + > draft manifest. + + ### Signing Checklist (per variant) + - [ ] Built on multiple machines, payload hashes match - [ ] Signed on air-gapped machine (3/5 signers) - [ ] Storage upgrade tested on production device + - [ ] **Signatures VERIFIED on every signed variant, BEFORE upload:** + \`scripts/release/verify-signatures.py \` + The real 3-of-5 ECDSA check -- the same one the device does -- + parsing the keys from include/keepkey/board/pubkeys.h so a + rotation cannot leave it checking a stale set. + The \`--require-signed\` manifest step below is STRUCTURAL ONLY: + a signature region holding a single non-zero byte passes it, so + it cannot tell a signed image from an unsigned one. Run both, + and run this one first. - [ ] Signed .bin uploaded, replacing unsigned + - [ ] \`HASHES*.txt\` regenerated from the signed binaries and re-uploaded: + \`scripts/release/hash-manifest.sh --require-signed ${VER} \` + (fails if any image is missing its quorum; the device-image hash it + prints is what Vault should pin) - [ ] Release notes finalized EOF - name: Create draft release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: draft: true - name: "Firmware v${{ needs.validate.outputs.fw_version }}" + prerelease: ${{ needs.validate.outputs.is_prerelease }} + name: "Firmware ${{ needs.validate.outputs.tag_name }}" body_path: release-body.md files: release-assets/* fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index 6a4df67a2..ec14e62d6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ build .DS_Store .vscode/ +build-btconly-check/ .claude/ # cppcheck output (static-analysis writes this at repo root in CI) diff --git a/CMakeLists.txt b/CMakeLists.txt index 413dae82e..07f4c61ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ endif() project( KeepKeyFirmware - VERSION 7.14.3 + VERSION 7.15.0 LANGUAGES C CXX ASM) set(BOOTLOADER_MAJOR_VERSION 2) @@ -22,6 +22,16 @@ option(KK_DEBUG_LINK "Build with debug-link enabled" OFF) option(KK_BUILD_FUZZERS "Build the fuzzers?" OFF) option(KK_BITCOIN_ONLY "Build Bitcoin-only firmware (strip all non-BTC coins)" OFF) +# Zcash shielded/Orchard support is part of the regular firmware. It is an +# internal compile selection, not a third release variant: bitcoin-only strips +# the Zcash coin and privacy engine; every regular device/emulator build ships +# both. The open constant-time Pallas audit finding remains a release gate for +# RC18, but it must not silently change the product being audited. +if(KK_BITCOIN_ONLY) + set(KK_ZCASH_PRIVACY OFF) +else() + set(KK_ZCASH_PRIVACY ON) +endif() # When building the dylib, every static lib it links (kkfirmware, kkboard, # trezorcrypto, kkrand, kktransport, qrcodegenerator, SecAESSTM32, ...) must @@ -64,7 +74,7 @@ endif() if(NOT EXISTS ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto/Makefile) message( FATAL_ERROR - " trezor-crypto missing. Need to 'git submodule update --init --recursive" + "trezor-crypto fork missing. Run 'git submodule update --init deps/crypto/trezor-firmware'" ) endif() @@ -79,13 +89,20 @@ find_program(NANOPB_GENERATOR nanopb_generator.py) if(${KK_EMULATOR} AND NOT NANOPB_GENERATOR) message( FATAL_ERROR - "Must install nanopb 0.3.9.4, and put nanopb-nanopb-0.3.9.4/generator on your PATH" + "Must install nanopb 0.3.9.8, and put nanopb-nanopb-0.3.9.8/generator on your PATH" ) endif() if(${KK_EMULATOR}) add_definitions(-DEMULATOR) add_definitions(-DCONFIDENTIAL=) + # macOS/BSD declare strlcpy/strlcat in ; glibc (Linux) and MinGW + # (Windows) do not. Force-include the prototypes so the ~20 call sites build + # without -Werror=implicit-function-declaration (definitions come from + # lib/board/strlcpy.c + strlcat.c). Apple already has them in . + if(NOT APPLE) + add_compile_options(-include ${CMAKE_SOURCE_DIR}/include/keepkey/board/bsd_compat.h) + endif() else() add_definitions(-DCONFIDENTIAL=__attribute__\(\(section\("confidential"\)\)\)) endif() @@ -115,6 +132,13 @@ add_definitions(-DUSE_CARDANO=0) add_definitions(-DUSE_MONERO=0) add_definitions(-DUSE_NEM=0) +# NOT a USE_* style on/off toggle despite sitting next to them: trezor-crypto's +# rand.c tests this macro with #ifndef, so only its *definedness* matters. The +# old -D...=0 spelling read as "off" while actually meaning "on", and the +# insecure LCG random32() stayed out of the build purely by that double +# negation. Define it bare, matching upstream trezor-core's SConscript.firmware, +# so a future cleanup of an apparent "=0 means unused" define cannot silently +# compile in the LCG. lib/rand/rng.c #errors if this ever goes missing. # trezor-crypto's rand.c tests only whether this macro is defined. A value of # zero therefore did not disable anything; it excluded the library's insecure # test LCG by definedness. Use the upstream spelling so that intent is clear, @@ -137,22 +161,37 @@ add_definitions(-DBIP39_WORDLIST_PADDED=1) add_definitions(-DAES_128=1) +# NOTE: AES table size is selected per release product below. The regular +# image includes Zcash and its Pallas curve arithmetic, so it uses the smaller +# AES tables to preserve flash headroom. Bitcoin-only keeps FOUR_TABLES AES. + if(${KK_DEBUG_LINK}) add_definitions(-DDEBUG_LINK=1) else() add_definitions(-DDEBUG_LINK=0) endif() -# Always defined, 0 or 1, and always tested with `#if BITCOIN_ONLY`. Device -# builds compile with -Wundef -Werror, so an undefined identifier inside `#if` -# is a hard error rather than a silent zero -- which is what we want, because -# a silently-zero guard would ship the coin engines into the stripped image. +# Value macros: always defined, 0 or 1, and always tested with `#if FLAG`. +# Device builds compile with -Wundef -Werror, so an undefined identifier inside +# `#if` is a hard error rather than a silent zero -- which is what we want, +# because a silently-zero guard would ship the coin engines into the stripped +# image. if(${KK_BITCOIN_ONLY}) add_definitions(-DBITCOIN_ONLY=1) else() add_definitions(-DBITCOIN_ONLY=0) endif() +if(${KK_ZCASH_PRIVACY}) + add_definitions(-DZCASH_PRIVACY=1) + # The Orchard engine leaves the regular image tightest on flash; shrink the + # Gladman AES lookup tables from 4KB to 1KB each (-15,360 bytes ROM, + # slightly slower AES). Bitcoin-only keeps the fast FOUR_TABLES. + add_definitions(-DAES_SMALL_TABLES) +else() + add_definitions(-DZCASH_PRIVACY=0) +endif() + if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") add_definitions(-DDEBUG_ON) add_definitions(-DMEMORY_PROTECT=0) @@ -185,6 +224,12 @@ if(NOT ${KK_EMULATOR}) link_directories(${LIBOPENCM3_PATH}/lib) include_directories(${LIBOPENCM3_PATH}/include) + # Emit per-function stack-frame sizes (.su files) on device builds. CI's + # SRAM budget gate (tools/check_sram_budget.py) reports the largest frames + # and fails when the linker-asserted stack reserve minus the largest frame + # leaves less than the configured margin. See tools/firmware/keepkey.ld. + add_compile_options(-fstack-usage) + # Dummy empty libraries for stack smashing protection support, since we # implement __stack_chk_guard and __stack_chk_fail ourselves. file(WRITE ${CMAKE_BINARY_DIR}/ssp.c "") @@ -213,6 +258,10 @@ if(${KK_EMULATOR}) add_test(test-firmware ${CMAKE_BINARY_DIR}/bin/firmware-unit) add_test(test-board ${CMAKE_BINARY_DIR}/bin/board-unit) add_test(test-crypto ${CMAKE_BINARY_DIR}/bin/crypto-unit) + if(${KK_ZCASH_PRIVACY}) + add_test(test-pallas-ct ${CMAKE_BINARY_DIR}/bin/pallas-ct-unit) + add_test(test-zcash-crypto ${CMAKE_BINARY_DIR}/bin/zcash-crypto-unit) + endif() add_custom_target( xunit @@ -223,4 +272,10 @@ if(${KK_EMULATOR}) COMMAND ${CMAKE_BINARY_DIR}/bin/crypto-unit --gtest_output=xml:${CMAKE_BINARY_DIR}/unittests/crypto.xml) + if(${KK_ZCASH_PRIVACY}) + add_custom_command(TARGET xunit POST_BUILD + COMMAND ${CMAKE_BINARY_DIR}/bin/pallas-ct-unit + --gtest_output=xml:${CMAKE_BINARY_DIR}/unittests/pallas-ct.xml) + endif() + endif() diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..a5a41ad99 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# Convenience targets — mirrors CI jobs so failures are caught locally. +# +# CI pins clang-format-20. Use that version if available, otherwise fall back. +# To install: brew install llvm@20 or apt-get install clang-format-20 +CLANG_FORMAT ?= $(shell command -v clang-format-20 2>/dev/null || echo clang-format) + +# Directories and exclusions must match .github/workflows/ci.yml lint-format job. +LINT_DIRS := include/keepkey lib/firmware lib/board lib/transport/src +LINT_SOURCES := $(shell find $(LINT_DIRS) -name '*.c' -o -name '*.h' 2>/dev/null \ + | grep -v generated | grep -v '\.pb\.') + +.PHONY: lint format help + +## lint: Check formatting (same rules as CI). Exits non-zero on any violation. +lint: + @echo "clang-format version: $$($(CLANG_FORMAT) --version)" + @FAILED=0; \ + for f in $(LINT_SOURCES); do \ + if ! $(CLANG_FORMAT) --style=file --dry-run --Werror "$$f" 2>/dev/null; then \ + echo " NEEDS FORMAT: $$f"; \ + FAILED=1; \ + fi; \ + done; \ + if [ "$$FAILED" = "1" ]; then \ + echo ""; \ + echo "Run 'make format' to fix all files."; \ + exit 1; \ + else \ + echo "All files pass clang-format check."; \ + fi + +## format: Auto-fix formatting in-place for all source files. +format: + @echo "Formatting $(LINT_DIRS)..." + @for f in $(LINT_SOURCES); do \ + $(CLANG_FORMAT) --style=file -i "$$f"; \ + done + @echo "Done. Review changes with: git diff" + +## help: List available targets. +help: + @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## / make /' diff --git a/cmake/caches/device.cmake b/cmake/caches/device.cmake index 436a23bce..8265191dc 100644 --- a/cmake/caches/device.cmake +++ b/cmake/caches/device.cmake @@ -45,7 +45,14 @@ set(WARN_FLAGS -Werror") -set(KK_C_FLAGS "${ARCH_FLAGS} -std=gnu99 ${WARN_FLAGS}" CACHE STRING "") +# Newlib's snprintf unconditionally links the float engine (_svfprintf_r, +# _dtoa_r, soft-double libgcc, malloc) — ~22 KB of ROM with zero %f users in +# the firmware. Route all callers to the integer-only siprintf family instead. +# %lld/%llu still work (this toolchain's libc.a compiles the integer engine +# with long-long support). Device builds only; host/emulator keep libc printf. +set(PRINTF_FLAGS "-Dsnprintf=sniprintf -Dvsnprintf=vsniprintf") + +set(KK_C_FLAGS "${ARCH_FLAGS} -std=gnu99 ${WARN_FLAGS} ${PRINTF_FLAGS}" CACHE STRING "") set(KK_CXX_FLAGS "${ARCH_FLAGS} -std=gnu++11 ${WARN_FLAGS} \ -fno-exceptions \ -fno-rtti \ diff --git a/cmake/toolchains/mingw-w64-x86_64.cmake b/cmake/toolchains/mingw-w64-x86_64.cmake new file mode 100644 index 000000000..974f1a49c --- /dev/null +++ b/cmake/toolchains/mingw-w64-x86_64.cmake @@ -0,0 +1,40 @@ +# MinGW-w64 cross-compile toolchain for the Windows emulator DLL (libkkemu.dll, +# x86_64). Lets us cross-build the Windows DLL from the existing macOS/Linux +# emulator build host — no Windows runner required. +# +# Usage: +# cmake -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64-x86_64.cmake \ +# -DKK_EMULATOR=ON -DKK_BUILD_DYLIB=ON -DKK_DEBUG_LINK=ON ... +# cmake --build --target kkemulator_dylib +# +# Install MinGW: `brew install mingw-w64` (macOS) / `apt-get install mingw-w64`. +# +# Only the kkemulator_dylib target is meant to cross-compile. The standalone +# UDP `kkemu` binary is gated out on Windows (tools/emulator/CMakeLists.txt). + +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_PROCESSOR x86_64) + +set(TOOLCHAIN_PREFIX x86_64-w64-mingw32) +find_program(CMAKE_C_COMPILER NAMES ${TOOLCHAIN_PREFIX}-gcc) +find_program(CMAKE_CXX_COMPILER NAMES ${TOOLCHAIN_PREFIX}-g++) +find_program(CMAKE_RC_COMPILER NAMES ${TOOLCHAIN_PREFIX}-windres) + +if(NOT CMAKE_C_COMPILER) + message(FATAL_ERROR + "${TOOLCHAIN_PREFIX}-gcc not found. Install MinGW-w64 " + "(brew install mingw-w64 / apt-get install mingw-w64).") +endif() + +# Derive the target sysroot from the compiler location so this works across +# Homebrew versions and Linux package layouts. +get_filename_component(_kk_cc "${CMAKE_C_COMPILER}" REALPATH) +get_filename_component(_kk_bin "${_kk_cc}" DIRECTORY) +get_filename_component(_kk_root "${_kk_bin}/.." ABSOLUTE) +set(CMAKE_FIND_ROOT_PATH "${_kk_root}/${TOOLCHAIN_PREFIX}") + +# Find host programs on the host; libraries/headers in the target sysroot. +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) diff --git a/deps/crypto/CMakeLists.txt b/deps/crypto/CMakeLists.txt index 2a9fbff97..4a1d9d880 100644 --- a/deps/crypto/CMakeLists.txt +++ b/deps/crypto/CMakeLists.txt @@ -58,6 +58,18 @@ set(sources #trezor-firmware/crypto/aes/aestst.c trezor-firmware/crypto/aes/aestab.c) +# Pallas/Orchard curve arithmetic (~2.4k LOC) -- only the Zcash shielded engine +# uses it. Excluded from the default and bitcoin-only images. +if(${KK_ZCASH_PRIVACY}) + list(APPEND sources + trezor-firmware/crypto/pallas.c + trezor-firmware/crypto/pallas_ct.c + trezor-firmware/crypto/pallas_sinsemilla.c + trezor-firmware/crypto/pallas_swu.c + trezor-firmware/crypto/redpallas.c + trezor-firmware/crypto/zcash_zip316.c) +endif() + # Clang 5.0 in the docker image (kktech/firmware:v7) is missing # , which breaks these. Until they're needed, we'll just elide # them. @@ -69,7 +81,6 @@ set(sources include_directories( ${CMAKE_CURRENT_SOURCE_DIR}/trezor-firmware/crypto - ${CMAKE_CURRENT_SOURCE_DIR}/trezor-firmware/ ${CMAKE_CURRENT_SOURCE_DIR}/trezor-firmware/crypto/ed25519-donna ${OPENSSL_INCLUDE_DIR}) diff --git a/deps/crypto/trezor-firmware b/deps/crypto/trezor-firmware index cdc05bebe..8a392f70a 160000 --- a/deps/crypto/trezor-firmware +++ b/deps/crypto/trezor-firmware @@ -1 +1 @@ -Subproject commit cdc05bebe9e6989cf711e1b5bea6324fd09f848e +Subproject commit 8a392f70a5d5575ece3dfb35f115d4a4b27f497c diff --git a/docs/Build.md b/docs/Build.md index 73b6ebffe..c5f120283 100644 --- a/docs/Build.md +++ b/docs/Build.md @@ -1,9 +1,14 @@ Prerequisites ------------- -Install nanopb-0.3.9.4 from: +Install nanopb-0.3.9.8 from: -`https://github.com/nanopb/nanopb/releases/tag/nanopb-0.3.9.4` +`https://github.com/nanopb/nanopb/releases/tag/nanopb-0.3.9.8` + +This must match the version baked into the pinned builder image +(`Dockerfile`, `git clone --branch nanopb-0.3.9.8`). Generated headers +differ between nanopb majors, so a mismatch means a local build and a CI +build are not the same product even from the same source. See GH #425. Install the python-protobuf dependency: @@ -31,3 +36,20 @@ Running the tests $ cd build $ make all test ``` + +Release products +----------------- + +Two release products, no separate `zcash-privacy` artifact: + +| Product | Contents | +| --- | --- | +| Regular (`full`) | Every supported chain, including Zcash shielded/Orchard | +| Bitcoin-only | Bitcoin only; all non-Bitcoin coins and Zcash privacy code removed | + +An unflagged CMake build is the regular product (`BITCOIN_ONLY=0`, +`ZCASH_PRIVACY=1`). `-DKK_BITCOIN_ONLY=ON` sets `BITCOIN_ONLY=1` and +`ZCASH_PRIVACY=0`. Zcash privacy is part of the regular firmware and cannot be +disabled as a release choice; the internal `ZCASH_PRIVACY` value exists only so +bitcoin-only can compile the privacy sources out. Device, emulator, unit-test, +SRAM, and tagged-release CI matrices cover only these two products. diff --git a/docs/DiceEntropy.md b/docs/DiceEntropy.md index c3394591b..4c52b337c 100644 --- a/docs/DiceEntropy.md +++ b/docs/DiceEntropy.md @@ -1,14 +1,7 @@ # Dice Entropy On-device dice rolls, folded into the seed at creation time. Available from -firmware v7.14.3 (bitcoin-only line) and v7.15.0 (`ResetDevice.dice_entropy`). - -One difference from 7.15 in this line: the legacy `display_random` entropy -screen still exists here, because already-shipped 7.14 hosts request it. The -two are mutually exclusive — `ResetDevice` with both `display_random` and -`dice_entropy` set is refused with a SyntaxError, since the screen shows the -POST-mix internal entropy and honoring both would hand a host the seed -pre-image and make the dice fold-in worthless. +firmware v7.15.0 (`ResetDevice.dice_entropy`). ## What happens @@ -53,8 +46,7 @@ described it as a verifiable commitment; that was strictly worse. A host that supplies `ext_entropy` and reads that screen once computes `SHA256(shown || ext_entropy)` — the seed pre-image. Dice change nothing about that attack, because the displayed value is already post-mix. Unverifiable -mixing beats a verifiable seed pre-image. See the comment above the -`dice_entropy` block in `reset.c:reset_init()`. +mixing beats a verifiable seed pre-image. See the comment at `reset.c:136`. The roll digest is safe by contrast because it hashes the user's own input, not seed material. diff --git a/docs/README.md b/docs/README.md index e456f6ad5..ccdbe8ac7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,8 @@ * [How to Build](Build.md) * [Storage Layout](Storage.md) +* [Storage Version Gate — audit SOP](StorageVersionGate.md) * [Supported Coins](Coins.md) * [Host Communications](Host.md) * [Release Process](Release.md) +* [Dice Entropy](DiceEntropy.md) diff --git a/docs/Release.md b/docs/Release.md index dc753f30b..9eb498ab3 100644 --- a/docs/Release.md +++ b/docs/Release.md @@ -11,6 +11,12 @@ Release Process 1. We stay in compliance with the GPL license. 1. Build a release build of the firmware on multiple different machines, and compare firmware hashes. 1. Sign it on the airgapped machine with 3/5 signers. +1. Verify the signatures before publishing: `scripts/release/verify-signatures.py `. + * `hash-manifest.sh --require-signed` is structural only — a signature region holding one non-zero byte passes it. This does the real 3-of-5 ECDSA check the device does. + * It parses the keys from `include/keepkey/board/pubkeys.h`, so a rotation cannot leave it checking a stale set. 1. Double check that storage upgrade preserves keys on a production device. + * A signed upgrade must NEVER wipe; a downgrade wiping is correct and expected. + * If `STORAGE_VERSION` was bumped, set `STORAGE_VERSION_LAST_SHIPPED` to match in this release commit. See [Storage Version Gate](StorageVersionGate.md). + * Unsigned RC/dev builds cannot prove this: the bootloader wipes storage for unsigned images by design. Use a signed build. 1. Upload the signed firmware to github. 1. Publish release notes on github. diff --git a/docs/StorageVersionGate.md b/docs/StorageVersionGate.md new file mode 100644 index 000000000..ffa3dc750 --- /dev/null +++ b/docs/StorageVersionGate.md @@ -0,0 +1,107 @@ +# Storage version gate — audit SOP + +**A signed upgrade must never wipe. A downgrade wipes, and that is correct.** + +Those two sentences are the whole policy. Everything below exists to make the +first one impossible to break by accident. + +## Why an upgrade can wipe + +`storage_init()` calls `storage_fromFlash()` on whatever blob is in flash. If +`version_from_int()` does not recognise the version it returns +`StorageVersion_NONE`, `storage_fromFlash()` returns `SUS_Invalid`, and +`storage_init()` runs `storage_reset()` + `storage_commit()`. No prompt, no +warning — the wallet is gone at boot. + +An upgrading device always arrives carrying a blob written by the release it is +leaving. So incoming firmware must recognise every version any shipped firmware +ever wrote. There are exactly two ways to break that: + +1. **Lower `STORAGE_VERSION`** below a version that has shipped. +2. **Remove, reorder, or skip an entry** in `storage_versions.inc`, so a version + that used to be recognised no longer is. + +Both compile cleanly without the gate. Both silently wipe every field device on +upgrade. Neither shows up in any functional test, because tests create storage +with the firmware under test and never cross a release boundary. + +The reverse direction is not a defect: older firmware cannot read a newer blob, +so a **downgrade** legitimately lands on `SUS_Invalid` and resets. Do not +"fix" that. Do not add a compatibility shim for it. Downgrades wipe. + +Signing is a separate wipe path with its own rule — see below. + +## The hard checks + +Two `_Static_assert`s in `lib/firmware/storage.c`, both compile-time: + +| Check | Fires when | +|---|---| +| `STORAGE_VERSION >= STORAGE_VERSION_LAST_SHIPPED` | Someone lowers the version below a shipped release | +| `StorageVersion_##N == N`, on every entry | `storage_versions.inc` stops being contiguous from 1 | + +The second works because the enum is emitted in `.inc` order after +`StorageVersion_NONE = 0`, so a contiguous `1..N` list makes +`StorageVersion_N == N`. Delete entry 5 and `StorageVersion_17` becomes 16, and +the build stops. + +It is asserted on **every** entry, not just the last. Checking only the last +entry pins the entry *count*, which is weaker than it looks: renumbering +`ENTRY(16)` to `ENTRY(99)` leaves `StorageVersion_17` at 17 and compiles clean, +while `version_from_int` quietly loses `case 16` and every device carrying +version 16 is wiped on upgrade. That gap was found by mutation-testing the +assert rather than by reading it. + +`STORAGE_VERSION_LAST_SHIPPED` lives in `include/keepkey/firmware/storage.h`. + +## Auditing a change that touches storage + +1. **Did `STORAGE_VERSION` change?** If it went up, the release checklist below + applies. If it went **down**, stop — this wipes every upgrading device. There + is no valid reason to lower it on a branch; a revert of unshipped work should + restore the *reader* while leaving the version number alone. +2. **Did `storage_versions.inc` change?** Only ever by appending. Any deletion or + renumbering is a wipe, and the build will say so. +3. **Did `STORAGE_VERSION_LAST_SHIPPED` change?** Only legitimate in a release + commit, and only upward. A bump appearing in a feature branch, or any + decrease, is the single highest-severity review item in this file — it is + precisely the edit that disarms the gate to make a build compile. +4. **Is there a new `case` in `storage_fromFlash` for the new version, with the + fallthrough chain intact from the oldest version forward?** The chain is how + an old blob is migrated step by step; a missing link loads garbage rather + than wiping, which is worse. +5. **Run the reboot regression.** Create/reset, set PIN, serialize, reload as + after reboot, unlock, compare the recovered key. The ordinary storage tests + never cross the serialize/reboot boundary, and that boundary is where this + class of defect lives. + +## Release checklist addition + +When bumping `STORAGE_VERSION` for a release: + +- Add the new entry to `storage_versions.inc` — append only. +- Add the `case` and fallthrough in `storage_fromFlash`. +- Set `STORAGE_VERSION_LAST_SHIPPED` to the new value **in the release commit**, + not before. Until the build actually ships, the previous value is the truth. +- Verify on a production device that upgrade preserves keys. `docs/Release.md` + has always said this; the static asserts do not replace it, they only stop the + two failure modes that reach a device unnoticed. + +## The other wipe path: signatures + +Independent of versions, the bootloader erases storage unless +`should_restore()` (`tools/bootloader/usb_flash.c`) is satisfied. It requires +all three: + +- `SIG_FLAG != 0` — the incoming image's metadata does not request a wipe +- `!old_firmware_was_unsigned` — the firmware being replaced was officially signed +- `signatures_ok() == SIG_OK` on the new image + +Signed → signed preserves storage. Anything involving an unsigned image on +either side wipes, deliberately: it stops custom firmware from dumping storage +sectors written by official firmware. + +Consequence for testing: an unsigned development or RC build **cannot** validate +the preserve path, because it fails the second and third conditions by +construction. "Upgrade did not wipe" is only ever proven with a signed build on +a production device. diff --git a/docs/coin-integration/README.md b/docs/coin-integration/README.md new file mode 100644 index 000000000..5110255dc --- /dev/null +++ b/docs/coin-integration/README.md @@ -0,0 +1,305 @@ +# Coin Integration Guide for KeepKey Firmware + +This guide explains the submodule-first workflow required when adding new coin +support to the firmware. If you skip or reorder these steps, CI will fail +because the firmware's submodule commits won't resolve. + +## Repository Map + +The firmware repo has 7 submodules. Three matter for coin integration: + +| Submodule | Path | Upstream URL | Fork URL | Purpose | +|-----------|------|-------------|----------|---------| +| device-protocol | `deps/device-protocol` | `keepkey/device-protocol` | `BitHighlander/device-protocol` | Protobuf message definitions | +| python-keepkey | `deps/python-keepkey` | `keepkey/python-keepkey` | `BitHighlander/python-keepkey` | Python client + integration tests | +| trezor-firmware | `deps/crypto/trezor-firmware` | `keepkey/trezor-firmware` | (keepkey org) | Crypto primitives (curves, hashing) | + +**Critical**: `.gitmodules` points to upstream `keepkey/*` URLs. For development +branches with commits that don't exist upstream yet, you must temporarily change +the URLs to point to your fork. + +## The Submodule-First Workflow + +### Why Order Matters + +When CI clones the firmware, it runs `git submodule update --init`. The submodule +commits referenced in the firmware tree must exist in the repos that `.gitmodules` +URLs point to. If you add a new proto file to device-protocol and update the +firmware's submodule pointer, but that commit only exists on your fork and +`.gitmodules` still points to upstream — CI fails. + +### Dependency Order + +``` +1. device-protocol fork → proto messages +2. trezor-firmware fork → crypto primitives (if new curve/algo needed) +3. python-keepkey fork → client methods + wire ID registration + tests +4. firmware → FSM handlers, derivation, signing + unit tests +``` + +Each step must be pushed to the correct fork and the commit hash noted before +the next step can reference it. + +### Step-by-Step + +#### Phase 0: Verify Fork Remotes + +For each submodule that needs changes, ensure the fork remote exists: + +```bash +cd modules/keepkey-firmware + +# device-protocol: check if your fork remote exists +cd deps/device-protocol +git remote -v +# If only 'origin' pointing to keepkey/, add your fork: +git remote add fork https://github.com/BitHighlander/device-protocol.git +git fetch fork +cd ../.. + +# python-keepkey: same pattern +cd deps/python-keepkey +git remote add fork https://github.com/BitHighlander/python-keepkey.git +git fetch fork +cd ../.. + +# trezor-firmware (crypto): usually keepkey org has push access +cd deps/crypto/trezor-firmware +git remote -v +cd ../../.. +``` + +#### Phase 1: Device Protocol (Proto Messages) + +```bash +cd deps/device-protocol + +# Create branch from upstream master +git fetch origin +git checkout -b feature/-proto origin/master + +# Add your proto file +# Example: messages-zcash.proto, messages-solana.proto, etc. +# Also update messages.proto with new MessageType enum values + +git add messages-.proto +git commit -m "feat: add protocol messages (IDs XXXX-XXXX)" + +# Push to YOUR FORK (not upstream!) +git push fork feature/-proto + +# Note the commit hash — you'll need it for firmware +git rev-parse HEAD +# → abc1234... +``` + +**Wire ID conventions**: Check existing ranges in `messages.proto` to avoid collisions. +Current allocations: +- Zcash: 1300-1307 +- Tron: 1400-1403 +- TON: 1500-1503 +- Solana: 1200-1205 + +#### Phase 2: Crypto Primitives (If Needed) + +Only needed if the coin requires a new elliptic curve or hash function not +already in trezor-firmware/crypto. + +```bash +cd deps/crypto/trezor-firmware + +# Check current state +git status + +# Create branch from current HEAD +git checkout -b feature/-crypto + +# Add new crypto files +git add crypto/.c crypto/.h +git commit -m "feat: add primitives for " + +# Push (usually to keepkey/trezor-firmware directly) +git push origin feature/-crypto +``` + +**Examples of coin-specific crypto**: +- Zcash: `pallas.c/h`, `redpallas.c/h` (Pallas curve + RedPallas signatures) +- Solana: `ed25519` (already in repo) + +#### Phase 3: Python-KeepKey (Client + Tests) + +```bash +cd deps/python-keepkey + +git fetch origin +git checkout -b feature/-tests origin/master + +# Add/update: +# 1. keepkeylib/messages__pb2.py — protobuf bindings (or generate from proto) +# 2. keepkeylib/client.py — add client method(s) +# 3. keepkeylib/mapping.py — register wire IDs +# 4. tests/test_msg_.py — integration tests + +git add -A +git commit -m "feat: add client methods and tests" + +# Push to YOUR FORK +git push fork feature/-tests +``` + +**Important**: python-keepkey uses hand-written `_pb2.py` files for protobuf 3.x +compatibility. Do NOT regenerate all proto bindings — only add the new coin's +`_pb2.py` and register wire IDs in `mapping.py`. + +#### Phase 4: Firmware (Core Implementation) + +Now create the firmware branch with updated submodule pointers: + +```bash +cd modules/keepkey-firmware + +# Branch from develop +git checkout -b feature/ origin/develop + +# UPDATE .gitmodules TO POINT TO YOUR FORKS +# This is the critical step most people miss! +git config -f .gitmodules submodule.deps/device-protocol.url \ + https://github.com/BitHighlander/device-protocol.git +git config -f .gitmodules submodule.deps/python-keepkey.url \ + https://github.com/BitHighlander/python-keepkey.git + +# Update submodule pointers to your fork branch commits +cd deps/device-protocol +git fetch fork +git checkout +cd ../.. + +cd deps/python-keepkey +git fetch fork +git checkout +cd ../.. + +# If crypto was changed: +cd deps/crypto/trezor-firmware +git checkout +cd ../../.. + +# Stage submodule pointer updates + .gitmodules +git add .gitmodules deps/device-protocol deps/python-keepkey deps/crypto/trezor-firmware + +# Now add firmware code: +# - include/keepkey/firmware/.h +# - lib/firmware/.c +# - lib/firmware/fsm_msg_.h +# - include/keepkey/transport/messages-.options +# - lib/firmware/messagemap.def (add message registrations) +# - lib/firmware/fsm.c (add #include and declarations) +# - lib/firmware/CMakeLists.txt (add source files) +# - lib/transport/CMakeLists.txt (add proto build steps) +# - unittests/firmware/.cpp (add unit tests) +# - unittests/firmware/CMakeLists.txt (register test file) + +git add -A +git commit -m "feat: add support" +git push origin feature/ +``` + +#### Phase 5: Before Merging Upstream + +When your firmware PR is ready to merge into the main `keepkey/keepkey-firmware`: + +1. First merge device-protocol changes into upstream `keepkey/device-protocol` +2. First merge python-keepkey changes into upstream `keepkey/python-keepkey` +3. First merge crypto changes into upstream `keepkey/trezor-firmware` +4. **Then** update `.gitmodules` URLs back to `keepkey/*` upstream +5. Update submodule pointers to the upstream merge commits +6. Push the firmware PR + +## CI Pipeline + +The firmware uses CircleCI with docker-compose: + +- **emulator-build-test**: Builds emulator, runs `firmware-unit` and `python-keepkey` tests +- Tests must produce a status file with "0" to pass +- Both unit tests (GoogleTest C++) and integration tests (Python) run + +### Making CI Pass + +For each PR, ensure: +1. All submodule URLs in `.gitmodules` resolve (fork URLs during development) +2. All submodule commits exist in the repos the URLs point to +3. Firmware builds clean with `cmake` + `make` +4. `firmware-unit` tests pass (GoogleTest, `unittests/firmware/`) +5. `python-keepkey` tests pass (pytest, `deps/python-keepkey/tests/`) + +## Common Mistakes + +### 1. Forgetting to update .gitmodules URLs +**Symptom**: CI fails with "Could not find remote branch" or "reference is not a tree" +**Fix**: Change `.gitmodules` URLs to point to your fork before pushing + +### 2. Pushing submodule changes to upstream instead of fork +**Symptom**: Unauthorized push failure, or accidentally landing unreviewed proto changes +**Fix**: Always add your fork as a separate remote named `fork`, push there + +### 3. Not initializing the device-protocol submodule +**Symptom**: `deps/device-protocol` shows as `-` prefix in `git submodule status` +**Fix**: `git submodule init deps/device-protocol && git submodule update deps/device-protocol` + +### 4. Regenerating all python-keepkey proto bindings +**Symptom**: Massive diff touching files you didn't mean to change +**Fix**: Only add the new coin's `_pb2.py` file and register wire IDs in `mapping.py` + +### 5. Working in detached HEAD without realizing it +**Symptom**: Commits exist locally but can't be pushed, "branch not found" +**Fix**: Always create a named branch before committing in submodules + +## File Layout for New Coin + +``` +keepkey-firmware/ +├── deps/ +│ ├── device-protocol/ +│ │ └── messages-.proto ← Proto messages +│ ├── crypto/trezor-firmware/ +│ │ └── crypto/.{c,h} ← Crypto primitives (if needed) +│ └── python-keepkey/ +│ ├── keepkeylib/messages__pb2.py +│ ├── keepkeylib/client.py ← Add client method(s) +│ ├── keepkeylib/mapping.py ← Register wire IDs +│ └── tests/test_msg_.py ← Integration tests +├── include/keepkey/ +│ ├── firmware/.h ← Public API +│ └── transport/messages-.options ← Nanopb options +├── lib/firmware/ +│ ├── .c ← Core implementation +│ ├── fsm_msg_.h ← FSM message handlers +│ ├── fsm.c ← #include + declarations +│ ├── messagemap.def ← Message registrations +│ └── CMakeLists.txt ← Add source file +├── lib/transport/ +│ └── CMakeLists.txt ← Add proto build steps +└── unittests/firmware/ + ├── .cpp ← Unit tests (GoogleTest) + └── CMakeLists.txt ← Register test file +``` + +## Existing Coin References + +When implementing a new coin, study these existing implementations: + +| Coin | Proto | Firmware | Tests | Complexity | +|------|-------|----------|-------|------------| +| Mayachain | messages-mayachain.proto | mayachain.c | mayachain.cpp | Simple (address + sign) | +| Cosmos | messages-cosmos.proto | cosmos.c | cosmos.cpp | Moderate (amino encoding) | +| Ethereum | messages-ethereum.proto | ethereum.c | ethereum.cpp | Complex (EIP-155, tokens) | +| Zcash | messages-zcash.proto | zcash.c | (in progress) | Complex (ZIP-32, Orchard, RedPallas) | + +## Branch Naming Conventions + +| Repo | Branch Pattern | Example | +|------|---------------|---------| +| device-protocol | `feature/-proto` | `feature/zcash-proto` | +| trezor-firmware | `feature/-crypto` | `feature/zcash-crypto` | +| python-keepkey | `feature/-tests` | `feature/zcash-orchard-tests` | +| keepkey-firmware | `feature/` | `feature/zcash` | diff --git a/docs/coin-integration/zcash-on-device-ua.md b/docs/coin-integration/zcash-on-device-ua.md new file mode 100644 index 000000000..12cf02c46 --- /dev/null +++ b/docs/coin-integration/zcash-on-device-ua.md @@ -0,0 +1,425 @@ +# Zcash on-device unified address derivation (Phase 2) + +**Status:** design — not yet implemented. +**Owner:** firmware (this repo) +**Companions:** `hdwallet`, `keepkey-vault`, `device-protocol`. + +## 1. Why this exists + +The flow shipped in PR #142 / `feature-zcash` (PR #220) verifies that a host-supplied +`(ak, nk, rivk)` matches the device's seed-derived FVK, then displays the +**host-supplied** `u1...` string on the OLED. That is **not** an attestation +that the displayed unified address is spendable by the device. A malicious +host that knows the correct FVK can submit any UA string and the device will +faithfully render it. + +The only way the device can promise "this address is spendable by my seed at +this account" is to **derive the unified address itself** from material it +controls (the seed-rooted FVK + a diversifier index it accepts as input) and +display the device-derived bytes. The user then compares the device-shown +`u1...` to the wallet's claim before publishing. + +Half measures (FVK match only, fingerprint binding, "trust the wallet to +display the same UA we sent") are not interchangeable with on-device +derivation. They catch a different, smaller set of attacks. This document +specifies what's actually required to make the strong claim. + +## 2. Threat model + +The device is asked to attest: + +> The unified address rendered on the OLED is a valid encoding of an Orchard +> receiver `(d_j, pk_d_j)` where `pk_d_j = [ivk] · g_d_j`, `g_d_j` is the +> Pallas group element derived from a diversifier `d_j = FF1.Decrypt(dk, j)`, +> and `(ak, nk, rivk, dk)` is the FVK derived from the device's seed at the +> requested ZIP-32 Orchard account. + +Adversaries: + +1. **Compromised host process** — bun, hdwallet, sidecar, browser, USB driver. May fabricate or substitute UAs, FVKs, indexes, addresses. Cannot tamper with the device's OLED. +2. **Compromised wallet UI** — same observation surface as the host. User's only honest read is the OLED + the wallet's claim, side by side. +3. **Bit-flip / glitch** — out of scope for this document; addressed by the existing fault-injection hardening. + +Properties we want against (1) and (2): + +- The bytes rendered on the OLED must be a function of `(seed, account, j)` and nothing else. No host input flows into the displayed string. +- A user who reads the OLED and sees the same string in their wallet has cryptographic assurance that the UA's Orchard receiver is spendable by the device. + +Properties we explicitly do **not** claim: + +- Anything about non-Orchard receivers (transparent, Sapling) bundled into a multi-receiver UA. A UA can carry arbitrary other receivers; only the Orchard one is bound to this device. Display copy must scope the claim accordingly. +- Privacy / linkability of the displayed UA. ZIP-32 §6.1 fingerprinting is a separate (already-shipped) concern. + +## 3. Why this is large + +KeepKey firmware is C on Cortex-M3. Embedded Rust is not in this repo's +toolchain (Trezor Model T and Keystone3 both run Rust embedded; we don't). +Every primitive needed for Orchard UA derivation has to be implemented or +ported in C. The crates `orchard`, `pasta_curves`, `sinsemilla`, `f4jumble`, +`fpe`, and `zcash_keys` are the reference implementations; they don't run +here. + +What we already have, verified against the active Trezor firmware crypto +submodule at `deps/crypto/trezor-firmware` commit `376c64bcf`: + +- BLAKE2b (`deps/crypto/trezor-firmware/crypto/blake2b.{c,h}`) +- AES block cipher (`deps/crypto/trezor-firmware/crypto/aes/`) +- Pallas curve arithmetic (`pallas.{c,h}`) — point ops, scalar mult, modular ops +- RedPallas signatures (`redpallas.{c,h}`) +- A hard-coded RedPallas SpendAuth basepoint, useful for `ak = [ask]G_spendauth` + but not a general `GroupHash^Pallas` implementation +- Generic Bech32/Bech32m checksum support in + `deps/crypto/trezor-firmware/crypto/segwit_addr.{c,h}`. This still needs a + ZIP-316-specific wrapper and may need its BIP-173 90-character output guard + relaxed for UA strings. + +What we **don't** have and must build or adapt: + +| Primitive | Source / spec | Approx C LOC | Risk | +|---|---|---|---| +| FF1-AES256 (NIST SP 800-38G) for diversifier derivation | NIST SP 800-38G; `fpe` Rust crate | ~300 | Medium — AES exists, but FF1/FPE does not | +| `expand_message_xmd_blake2b` | RFC 9380 §5.3.1 | ~80 | Low | +| Pallas Simplified SWU map (`map_to_curve_simple_swu`) + isogeny | Pasta paper / Halo 2 reference | ~500 | High — algebraic, easy to get wrong, needs cross-vectors | +| `GroupHash^Pallas` + `DiversifyHash^Orchard` | ZIP-32 §5.4.2.1 / Orchard book §5.4 | ~80 | Low (composition of above) | +| Sinsemilla hash + commitment | Halo 2 spec §5.4.1.9; Orchard book §5.4 | ~500 | High — chunked commitment, easy off-by-one | +| `Commit^ivk` (specifically `ivk = SinsemillaShortCommit("z.cash:Orchard-CommitIvk", ak ‖ nk; rivk)`) | Orchard book §5.4 | ~100 | Med | +| F4Jumble (4-round Feistel-like permutation) | ZIP-316 §4.2 | ~150 | Low — well-specified | +| ZIP-316 Bech32m adapter | BIP-350 / ZIP-316 | ~50 | Low — checksum exists, but UA sizing/padding glue does not | +| ZIP-316 UA encoding (single-receiver, Orchard) | ZIP-316 §4 | ~150 | Low | +| Cross-language test harness | — | ~500 | — | +| **Subtotal — production primitives** | | **~2,500 LOC** | | + +This is conservative and excludes the FSM handler, layout code, proto changes, +and unit tests for everything (probably another 1,500 LOC). + +First-pass feasibility conclusion: the Trezor firmware dependency gives us the +generic base layer (BLAKE2b, AES, Pallas point/scalar arithmetic, RedPallas, and +Bech32m), but it does not give us the Orchard unified-address derivation stack. +There is no checked-in Trezor C implementation of FF1, Sinsemilla, +`expand_message_xmd_blake2b`, Pallas SWU/isogeny, Orchard `GroupHash`, F4Jumble, +or ZIP-316 UA assembly. Phase 2 therefore remains a real crypto port, not just +wiring existing Trezor libraries together. + +### 3.1. Online library survey + +Surveyed 2026-04-30. The pattern is clear: only FF1 has a plausible C source +to adapt. The rest should be treated as spec-driven C implementations with +Rust upstreams used for goldens. + +| Needed primitive | Best upstream candidate | Language / license | Fit for KeepKey firmware | Estimated firmware LOC | Effect on plan | +|---|---|---|---|---:|---| +| FF1-AES256 for `d_j = FF1.Decrypt(dk, j)` | [`0NG/Format-Preserving-Encryption`](https://github.com/0NG/Format-Preserving-Encryption) | C / MIT | Useful algorithm reference, not drop-in. It depends on OpenSSL BIGNUM/AES; firmware should replace this with existing Trezor AES and a fixed-size radix-256 path for the 11-byte diversifier. | 300-500 | Reduces spec ambiguity, but does not avoid a port. Still needs NIST vectors and Orchard vectors. | +| FF1 specification and test source | [NIST SP 800-38G Rev. 1 draft](https://csrc.nist.gov/pubs/sp/800/38/g/r1/2pd) | Spec | Normative source. NIST's 2025 draft keeps FF1, removes FF3, and disallows floating point. | 0 production / 50-100 tests | Hard requirement for validation. Also tells us not to port code that uses floating-point `log2` or general decimal FPE assumptions blindly. | +| `expand_message_xmd_blake2b` | [RFC 9380](https://www.rfc-editor.org/rfc/rfc9380.html) | Spec | Directly implement against existing BLAKE2b. No C library needed. | 80-120 | Low-risk self-contained helper. | +| Pallas Simplified SWU + isogeny | [`zcash/pasta_curves`](https://github.com/zcash/pasta_curves) / [docs.rs source](https://docs.rs/pasta_curves/latest/src/pasta_curves/pallas.rs.html) | Rust / MIT or Apache-2.0 | Authoritative reference and test-vector source only. No usable C port found. Existing KeepKey `pallas.{c,h}` gives field and point ops, but not SWU/isogeny. | 500-800 | Highest algebraic risk. Rust vectors are mandatory before using in address derivation. | +| Orchard `GroupHash^Pallas` / `DiversifyHash` | [`zcash/orchard`](https://github.com/zcash/orchard) plus `pasta_curves` | Rust / MIT or Apache-2.0 | Composition layer over `expand_message_xmd_blake2b` and SWU/isogeny. No standalone C implementation found. | 80-150 | Small LOC, but correctness depends entirely on SWU/isogeny. | +| Sinsemilla hash and commitment | [`zcash/sinsemilla`](https://github.com/zcash/sinsemilla) and Halo 2 Sinsemilla docs | Rust / MIT or Apache-2.0 | Reference only. No C implementation found. Needs fixed generators/constants and chunking rules ported carefully. | 700-1,200 | Main risk after SWU. Larger than the first estimate if constants/tables are checked in rather than generated. | +| `Commit^ivk` / Orchard IVK | [`zcash/orchard`](https://github.com/zcash/orchard) | Rust / MIT or Apache-2.0 | Thin wrapper over Sinsemilla plus scalar handling. Not useful until Sinsemilla exists. | 100-180 | Medium risk; mostly test-vector coverage. | +| F4Jumble | [`f4jumble` crate](https://docs.rs/f4jumble) / [ZIP-316](https://zips.z.cash/zip-0316) | Rust / MIT or Apache-2.0, spec MIT | Implement directly from ZIP-316 using existing BLAKE2b. No C library found; Rust crate is good for goldens. | 150-250 | Low-risk. Does not block crypto receiver derivation; needed for final `u1...` string. | +| Bech32m for ZIP-316 strings | Existing Trezor `segwit_addr.{c,h}`; optional reference [`whitslack/libbech32`](https://github.com/whitslack/libbech32) | C / existing vendored license; libbech32 MIT-style | We already have Bech32m checksum support. Need ZIP-316 use that ignores BIP-173's 90-character cap, per ZIP-316. `libbech32` is useful only if we want a comparison implementation. | 50-120 | Reduces previous estimate: no new checksum implementation, only wrapper/length-policy work. | +| ZIP-316 UA item assembly | [`zcash_address::unified`](https://docs.rs/zcash_address/latest/zcash_address/unified/index.html) / [ZIP-316](https://zips.z.cash/zip-0316) | Rust / MIT or Apache-2.0, spec MIT | Reference only. Implement compactSize item encoding for a single Orchard receiver, padding, F4Jumble, Bech32m. | 150-250 | Low-to-medium risk; mostly parser/encoding edge cases and exact HRP behavior. | + +Net effect: expected new production C stays roughly **2,100-3,500 LOC** before +FSM/UI/proto/test glue. The best-case reduction from online libraries is mostly +around Bech32m and FF1; there is no library discovery that changes the hard +parts: SWU/isogeny and Sinsemilla still need first-party C ports verified +byte-for-byte against upstream Rust. + +## 4. Cryptographic recipe (the actual algorithm) + +References: + +- Zcash Protocol Spec (NU6) §4.2.3, §5.4.1.6, §5.4.8.5 +- ZIP-32 §5.4 (Orchard key derivation) +- ZIP-316 (Unified Addresses) +- Orchard book — `https://zcash.github.io/orchard/` +- Pasta paper — `https://github.com/zcash/pasta_curves` + +Given device seed `S`, account index `a`: + +``` +# Already implemented in zcash.c +sk = ZIP-32 Orchard derivation (S, a) +ask = ToScalar(PRF^expand(sk, [0x06])) +nk = ToBase (PRF^expand(sk, [0x07])) +rivk = ToScalar(PRF^expand(sk, [0x08])) +ak = [ask]·G_spendauth # already serialised by current code + +# NEW — must be implemented +dk = PRF^expand(sk, [0x09])[0..32] # diversifier key (uses existing PRF^expand) +ivk = SinsemillaShortCommit( + "z.cash:Orchard-CommitIvk", + I2LEBSP_l_ivk(ak) ‖ I2LEBSP_l_ivk(nk), + rivk + ) + +# Per-address (single diversifier index j; default j = 0) +d_j = FF1-AES256.Decrypt(dk, /* tweak = */ "", I2LEBSP_88(j)) # 11 bytes +g_d_j = DiversifyHash^Orchard(d_j) = GroupHash^Pallas("z.cash:Orchard-gd", d_j) +pk_d_j = [ivk] · g_d_j # uses existing Pallas scalar mult + +raw_addr = bytes(d_j) ‖ bytes(pk_d_j) # 11 + 32 = 43 bytes + +# UA encoding (single receiver, Orchard) +receiver = uint8(0x03) ‖ uint8(43) ‖ raw_addr # ZIP-316 typecode 0x03 = Orchard +hrp = "u" +encoded = bech32m(hrp, F4Jumble(receiver ‖ padding_for_hrp("u"))) +``` + +The displayed string is `encoded`. Every byte that goes into it is a +deterministic function of `(S, a, j)` — the host contributes only the choice +of `j`. + +## 5. API design + +### 5.1 New proto messages (`device-protocol`) + +```proto +// Request: device derives the canonical Orchard UA for (account, j) from its +// own seed and renders it on the OLED for user confirmation. The host does +// NOT supply any address bytes. +// +// @next ZcashUnifiedAddress +// @next Failure +message ZcashGetUnifiedAddress { + repeated uint32 address_n = 1; // m/32'/133'/account' + optional uint32 account = 2; // alternative to address_n + optional uint64 diversifier_index = 3; // 88-bit; default 0 + optional bool show_display = 4; // when true, render OLED + require confirm + optional bytes expected_seed_fingerprint = 5; // optional ZIP-32 §6.1 binding +} + +// Response. +// +// @prev ZcashGetUnifiedAddress +message ZcashUnifiedAddress { + optional string address = 1; // device-derived "u1..." (max_size: 256) + optional bytes raw_receiver = 2; // d || pk_d, 43 bytes — for cross-check + optional bytes seed_fingerprint = 3; // ZIP-32 §6.1 fingerprint of attesting device + optional uint64 diversifier_index = 4; // echoed for clarity +} +``` + +The existing `ZcashDisplayAddress` (host supplies the string) **stays** but +is renamed in copy to "Display address (FVK match)" to reflect what it +actually proves. The new message is the strong-attestation path. + +### 5.2 hdwallet wrapper + +```ts +wallet.zcashGetUnifiedAddress({ + addressNList, + account?: number, + diversifierIndex?: bigint, // default 0n + showDisplay?: boolean, // default true + expectedSeedFingerprint?: Uint8Array, +}): Promise<{ + address: string, // device-derived UA — trustable + rawReceiver: Uint8Array, // 43 bytes + seedFingerprint?: Uint8Array, + diversifierIndex: bigint, +}> +``` + +### 5.3 Vault privacy tab + +Replace the current "Verify on device" button (which calls +`ZcashDisplayAddress`) with a **two-step** flow: + +1. "Show device address" → calls `ZcashGetUnifiedAddress`. UI displays the + returned UA prominently next to the device-rendered version. User reads + both side by side. +2. Optional "Verify host address (FVK match only)" button retained for the + weaker check, with copy that names the limitation. This is the existing + PR #141 button with the wording fixed. + +## 6. Implementation phases + +Each phase is mergeable on its own. Each closes with a cross-language test +harness verifying outputs against the upstream `orchard` Rust crate via +test vectors checked into the firmware repo. + +### Phase 2.1 — Foundation primitives + +**Goal:** ship FF1-AES256 + Pallas SWU + GroupHash + their tests. No new +firmware behavior — these compile into the binary and have unit tests. + +**Files (estimate):** +- `deps/crypto/trezor-firmware/crypto/ff1.{c,h}` — FF1-AES256 +- `deps/crypto/trezor-firmware/crypto/pallas_swu.{c,h}` — SWU map + isogeny +- Extend `pallas.{c,h}` with `expand_message_xmd_blake2b` +- `unittests/firmware/zcash_phase2.cpp` + +**Test vectors needed:** +- FF1-AES256: NIST CAVP vectors + `fpe` Rust crate vectors +- SWU: `pasta_curves::pallas::map_to_curve_simple_swu` golden outputs +- GroupHash: `orchard::keys` test fixtures + +**Effort:** 3–5 days dev, 2 days vectors + tests. **High-risk** on SWU isogeny. + +### Phase 2.2 — Sinsemilla + Orchard receiver + +**Goal:** derive `(d_j, pk_d_j)` on device. New FSM internal helper, no proto +changes yet. + +**Files:** +- `deps/crypto/trezor-firmware/crypto/sinsemilla.{c,h}` +- Extend `lib/firmware/zcash.{c,h}`: + - Add `dk` to `ZcashOrchardKeys` + - `zcash_orchard_ivk(rivk, ak, nk, ivk_out)` — uses Sinsemilla + - `zcash_orchard_diversifier(dk, j, d_out)` — uses FF1 + - `zcash_orchard_receiver(account, j, raw_out_43)` — composes everything + +**Test vectors:** `orchard::keys::FullViewingKey::default_address` for many +`(seed, account, j)` tuples. We must agree byte-for-byte. + +**Effort:** 5–7 days. **Highest-risk** phase — Sinsemilla is involved and +under-documented relative to its complexity. + +### Phase 2.3 — F4Jumble + bech32m + UA encoding + +**Goal:** turn 43 raw receiver bytes into a `u1...` string. + +**Files:** +- `lib/firmware/zip316.{c,h}` — F4Jumble + UA assembly +- Extend bech32 helper to support the `m` constant (BIP-350) + +**Test vectors:** ZIP-316 has explicit test vectors. Plus +`zcash_address::unified::Address::encode` outputs. + +**Effort:** 2–3 days. Low risk. + +### Phase 2.4 — Proto + FSM handler + UI + +**Goal:** wire the new message end-to-end through firmware → hdwallet → vault. + +**Files:** +- `device-protocol/messages-zcash.proto` — `ZcashGetUnifiedAddress` / + `ZcashUnifiedAddress` +- `lib/firmware/fsm_msg_zcash.h` — `fsm_msgZcashGetUnifiedAddress` +- `lib/firmware/messagemap.def` + `fsm.h` — message routing +- `hdwallet-keepkey` — wrapper +- `keepkey-vault` — UI flow update + bun handler + +**Effort:** 2–3 days. Mostly mechanical given primitives are tested. + +### Phase 2.5 — Hardening, fault injection, hardware test + +**Goal:** confirm on real hardware, scrub side channels, add fault-injection +mitigations consistent with the rest of the firmware. + +**Effort:** 2–3 days + hardware time. + +**Total: 14–21 working days** for a single contributor focused on this. Add +~50% for unknowns and review feedback → call it 4–5 weeks of calendar time +including review, hardware verification, and the cross-language test +infrastructure. + +## 7. Testing strategy + +### 7.1 Cross-language goldens + +Generate goldens from the `orchard` Rust crate, check them into +`unittests/firmware/zcash_orchard_vectors.h` as C arrays: + +```rust +// Generator (host-side, Rust) — run once, commit output +let seed = hex!("000102...1f"); +for account in [0, 1, 7, 100, 0x7fffffff] { + for j in [0u128, 1, 256, 0xdeadbeef, (1u128 << 88) - 1] { + let sk = ExtendedSpendingKey::master(&seed); + let osk = sk.derive_internal(account); + let fvk = FullViewingKey::from(&osk); + let addr = fvk.address_at(j, Scope::External); + let ua = unified::Address::try_from_items(vec![Receiver::Orchard(addr.to_raw_address_bytes())]); + emit_c_vector(seed, account, j, addr, ua); + } +} +``` + +The firmware unit test imports the header and asserts byte equality at every +intermediate stage (ivk, d, g_d, pk_d, raw_receiver, encoded UA). + +### 7.2 Phase boundaries + +Each phase ships with its own `unittests/firmware/zcash_phaseN.cpp` that +runs in CI via the existing `firmware-unit` target. A phase can't merge +without its goldens. + +### 7.3 On-device sanity + +Once Phase 2.4 lands, manual hardware test: +- Initialize a known seed (the all-allallall mnemonic) +- Call `ZcashGetUnifiedAddress(account=0, j=0)` +- Confirm OLED renders the same `u1...` string the upstream Rust crate + computes for that mnemonic + j=0 +- Repeat for j={1, 100, 2^32, 2^88-1} +- User-cancel test: long-press Cancel → device returns `Failure_ActionCancelled` +- Wrong-FVK injection: skip — there is no host-supplied FVK in this flow + +## 8. Open design questions + +1. **Diversifier index bounds.** Specced as 88-bit. Practical wallets use + small indexes (≤ 2^31). Do we accept full 88-bit on the wire and reject + ranges with no real users, or cap at 2^32 to fit a `uint32`? **Tentative:** + accept full `uint64` on the wire (caller passes 0..2^64 range; spec + technically allows up to 2^88 but no wallet uses that), reject any value + > 2^64-1 with `Failure_SyntaxError`. +2. **Multi-receiver UAs.** This design only ever displays a single-receiver + Orchard UA. Wallets that present users with `u1...` containing additional + transparent or Sapling receivers will have a UA the device won't match. + **Position:** the device-attested string is the *Orchard-only* UA derived + from this device. Wallets that bundle more receivers should present + *both* — "your full UA" and "the device-attested Orchard receiver" — and + the user verifies the latter against the OLED. +3. **Diversifier index display.** Show `j` on the OLED alongside the UA, or + omit? **Tentative:** show; at minimum show `j == 0` vs `j > 0` and the + account number, so users can spot if they ask for default but the host + slipped in a non-default index. +4. **bech32m vs raw-receiver display.** OLED is 256x64. A `u1...` Orchard + UA fits across multiple lines (already works in PR #142). Raw receiver + would be 86 hex chars — also displayable, less natural. **Tentative:** + show `u1...` (consistent with what wallets show); QR encodes the full UA. +5. **Should `dk` ship in `ZcashOrchardFVK`?** Currently FVK is `(ak, nk, rivk)`. + `dk` is part of the FVK in ZIP-32 (FVK = `(ak, nk, rivk, dk)`). Wallets + that want to derive their own diversifiers without round-tripping the + device need it. **Tentative:** add `dk` to `ZcashOrchardFVK` as + `optional bytes dk = 5;`. It's not secret beyond what FVK already exposes. + +## 9. Migration path + +The existing `ZcashDisplayAddress` flow stays; its UI copy is downgraded to +"Display address (FVK match) — proves the FVK belongs to this device, not +that the displayed UA is spendable." The new +`ZcashGetUnifiedAddress` flow is the recommended path going forward. Wallets +that want strong attestation use the new message; older code paths continue +to work. + +Once Phase 2 ships: + +- Vault: replace the "Verify on device" button's primary action to call + `ZcashGetUnifiedAddress`, drop the host-supplied address from the FSM call. +- Documentation: record the strong-attestation flow as the canonical "verify + address" procedure. + +## 10. References + +- Zcash Protocol Specification, NU6 — https://zips.z.cash/protocol/protocol.pdf +- ZIP-32 — https://zips.z.cash/zip-0032 +- ZIP-316 — https://zips.z.cash/zip-0316 +- Orchard book — https://zcash.github.io/orchard/ +- Pasta paper / curves — https://github.com/zcash/pasta_curves +- Halo 2 spec (Sinsemilla) — https://zcash.github.io/halo2/design/gadgets/sinsemilla.html +- BIP-350 (bech32m) — https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki +- NIST SP 800-38G (FF1) — https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38G.pdf +- RFC 9380 (hash-to-curve) — https://datatracker.ietf.org/doc/rfc9380/ + +## 11. What this document is not + +- A schedule. Effort estimates are working-days for a focused contributor; + calendar time depends on review velocity and hardware availability. +- Final spec for diversifier-index UX. Section 8 calls out tentative + positions; final UI copy and OLED layout are part of Phase 2.4 review. +- A claim that anything in Phase 2.1–2.3 is small. Sinsemilla and the SWU + isogeny are the high-risk pieces and may take longer than the estimates. + The estimates assume one contributor familiar with finite-field crypto. diff --git a/docs/coin-integration/zcash-pczt-clearsign.md b/docs/coin-integration/zcash-pczt-clearsign.md new file mode 100644 index 000000000..e03b3b612 --- /dev/null +++ b/docs/coin-integration/zcash-pczt-clearsign.md @@ -0,0 +1,322 @@ +# Zcash PCZT Clear Signing + +**Status:** implemented on `feature/clearsign-txs`. +**Base:** `release/7.15.0`. +**Scope:** Orchard PCZT signing. + +## Threat Model + +The firmware must not sign a host-provided Orchard sighash by itself. If the +companion app can choose the sighash directly, a compromised companion can turn +the hardware wallet into a blank-check signer: the user confirms one summary, +but the signature authorizes a different transaction digest. + +The firmware therefore has to build the signing digest from transaction data it +can validate. The current PCZT protocol does this in layers: + +1. The host sends ZIP-244 component digests and Orchard bundle metadata. +2. The device assembles the final ZIP-244 sighash from those component digests. +3. The device recomputes the Orchard digest from streamed action fields. +4. Signatures are returned only after the recomputed Orchard digest matches the + Orchard digest used in the device-computed sighash. + +## Firmware Policy + +`ZcashSignPCZT` is rejected before user confirmation unless it includes: + +- `header_digest`, exactly 32 bytes. +- Plaintext header fields: `tx_version`, `version_group_id`, `branch_id`, + `lock_time`, and `expiry_height`. Firmware recomputes ZIP-244 + `header_digest` from these fields and rejects on mismatch. +- `orchard_digest`, exactly 32 bytes. +- Orchard flags, value balance, and 32-byte anchor. +- A 32-byte transparent digest when transparent inputs or outputs are present. +- Optional transparent digest, if present, must be exactly 32 bytes. + +Sapling is out of scope for this signing path. Any host-provided +`sapling_digest` is rejected; firmware uses the ZIP-244 empty Sapling digest +internally. + +`ZcashPCZTAction` is rejected unless each action includes the fields needed to +recompute the Orchard digest: + +- `nullifier`, `cmx`, `epk`, `cv_net`, and `rk`, each 32 bytes. +- `enc_compact`, 52 bytes. +- `enc_memo`, 512 bytes. +- non-empty `enc_noncompact`. +- `out_ciphertext`, 80 bytes. +- `value`, 43-byte `recipient` (`d || pk_d`), and 32-byte `rseed` for trusted + Orchard output display. + +The legacy path where `ZcashPCZTAction.sighash` was accepted as the signing +digest is intentionally rejected. + +## What Is Verified + +The device now verifies `header_digest` from plaintext header fields and +recomputes `transparent_digest` from streamed transparent inputs/outputs before +emitting any transparent or Orchard signature. Sapling is not accepted in this +path. For shielded-only Orchard transactions, the transparent digest defaults to +the ZIP-244 empty transparent digest, so there is no host-provided transparent +component. + +The device verifies the Orchard action digest from the action plaintext fields +available in PCZT. Each action must also carry the plaintext Orchard output +metadata needed for trusted display: raw receiver `recipient = d || pk_d`, +`value`, and `rseed`. Firmware recomputes the action `cmx` from that metadata +and the action nullifier before displaying the ZIP-316 Orchard-only Unified +Address and amount. + +The device also computes the fee as: + +```text +fee = transparent_input_total - transparent_output_total + orchard_value_balance +``` + +The computed fee must match the requested fee and must be confirmed on-device +before any final signatures are returned. + +## UI Behavior + +Current signing screens show: + +- Shielded-only: total amount, fee, and Orchard action count. +- Transparent shielding: total amount, fee, transparent input count, and + transparent output count, and Orchard action count. +- Transparent input signing: per-input amount and BIP-44 path validation. +- Transparent outputs: each standard P2PKH/P2SH t-address and amount. +- Orchard outputs: each ZIP-316 Orchard-only Unified Address and amount after + note commitment binding. +- Final fee confirmation: computed fee after digest/output verification. + +## Outputs + +If we can derive a digest from plaintext transaction fields, we should do so for +outputs too. + +Transparent outputs are streamed as recipient scripts and values. Firmware +computes the transparent digest, displays transparent destination/address and +amount, and rejects non-standard scripts until an explicit raw-script review +policy exists. + +Orchard outputs are displayed from the supplied raw receiver/value/rseed +metadata only after firmware recomputes `cmx = NoteCommit(...)` and verifies it +matches the action `cmx`. This binds the displayed privacy recipient and amount +to the signed Orchard action. + +## libzcash-orchard-c Review + +Reviewed: https://github.com/wh00hw/libzcash-orchard-c + +This repo is applicable as implementation guidance, not as a wholesale firmware +dependency. It is a pure C11 static library under MIT, but it overlaps heavily +with primitives already present in this firmware tree: BLAKE2b, Pallas, +Sinsemilla, RedPallas, secp256k1, BIP32/BIP39, Base58, and ZIP-316 helpers. The +useful part for KeepKey is its transaction-signing shape: + +- Separate ZIP-244 `T.2 transparent_digest` from ZIP-244 `S.2` per-input + transparent signature digest. These are different commitments and must not be + collapsed into a single helper. +- Stream transparent inputs and outputs into independent BLAKE2b component + hashers: prevouts, sequences, outputs, amounts, scripts, and per-input + `txin_sig_digest`. +- Track transparent input/output value totals while hashing so the device can + compute `fee = transparent_in - transparent_out + orchard_value_balance` + locally and show that fee on-device. +- Treat Sapling as unsupported. A Sapling component would be a hidden value sink + until the firmware has Sapling parsing and display, so this firmware path + rejects host-provided Sapling data and uses the ZIP-244 empty Sapling digest. +- Capture every transparent output `(value, script_pubkey)` and render standard + P2PKH/P2SH scripts as Zcash t-addresses for user review. +- For Orchard outputs, `cmx` binding is the first target: require plaintext + `(d, pk_d, value, rseed)` metadata and recompute the note commitment against + the action `cmx`. The stronger follow-up is memo binding: recompute + `enc_ciphertext` and `epk` from `(recipient, value, rseed, memo)` using + Orchard KDF + ChaCha20-Poly1305. + +Reference role: KeepKey uses the transaction-signing structure and test-vector +shape while keeping the existing firmware primitives and protocol surfaces. + +Key files reviewed: + +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/include/zip244.h +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/src/zip244.c +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/include/orchard_signer.h +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/src/orchard_signer.c +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/include/base58.h +- https://github.com/wh00hw/libzcash-orchard-c/blob/main/SECURITY.md + +## Updated Implementation Plan + +### Phase 1: digest helpers and policy + +Status: implemented for header/transparent helpers and clear-signing policy. + +- Require component digests and Orchard metadata before signing. +- Reject the legacy host-only action `sighash` path. +- Compute the ZIP-244 root sighash on-device from component digests. +- Verify the Orchard digest from streamed action fields. +- Add pure helpers for: + - `header_digest` from plaintext header fields. + - `transparent_digest` from plaintext transparent inputs/outputs. + - per-input transparent `SIGHASH_ALL` digest. + +### Phase 2: plaintext header and transparent streaming + +Status: implemented. + +- Extend the protocol with plaintext header fields and verify + `header_digest` locally. Implemented. +- Extend transparent input messages with raw ZIP-244 digest fields: + `prevout_txid`, `prevout_index`, `sequence`, `amount`, and `script_pubkey`. +- Add transparent output streaming: + `index`, `amount`, and `script_pubkey`. +- Compute `transparent_digest` locally and compare it to the companion-provided + digest during the migration period. +- Compute per-input transparent sighashes locally before ECDSA signing, instead + of signing `ZcashTransparentInput.sighash`. +- Track transparent input and output totals, compute the fee, compare it to the + requested fee, and display the computed fee. +- Reject transactions with transparent outputs that cannot be rendered on-device + until the UI has an explicit "unknown script" review policy. + +### Phase 3: transparent output UI + +Status: implemented for standard P2PKH/P2SH scripts. + +- Add a Zcash transparent script renderer for standard P2PKH and P2SH: + - mainnet P2PKH: `t1` + - mainnet P2SH: `t3` + - testnet P2PKH: `tm` + - testnet P2SH: `t2` +- Display each transparent output address and amount on the trusted screen. +- Require every displayed transparent output and the computed fee to be + confirmed before any signature is produced. + +### Phase 4: stronger Orchard output metadata binding + +Status: implemented for recipient/value display via `cmx` binding. + +- Extend `ZcashPCZTAction` with raw Orchard output metadata: + `recipient` (`d || pk_d`), `rseed`, and explicit output value. +- Recompute Orchard note commitment `cmx` from `(d, pk_d, value, rho, rseed)`, + where `rho` is the action nullifier, and reject on mismatch. +- Display the recipient as a ZIP-316 Orchard-only UA and display the output + value. Require per-output confirmation. +- Memo display remains a separate hardening step: add ChaCha20-Poly1305 support + if it is not linked into the firmware image, then recompute `enc_ciphertext` + and `epk` for memo binding. +- Render empty/text/opaque memos on-device once memo binding is available. + +## Crypto Library Inventory + +Already available in this firmware tree: + +- BLAKE2b with personalization. +- AES-256. +- Pallas field and point arithmetic. +- Pallas SWU / group hash support. +- Sinsemilla / Orchard IVK support. +- RedPallas signing. +- ZIP-316 Orchard-only unified-address helpers. +- Zcash transparent Base58Check plus standard P2PKH/P2SH script-to-address + rendering. +- ChaCha20-Poly1305 source exists under trezor-crypto, but it is currently not + linked into the firmware crypto target. Memo binding will need that target + wiring plus Orchard note-encryption KDF glue. + +No new primitive is required for the implemented PCZT clear-signing policy. The +remaining optional hardening work is parsing and digest construction for more +transaction components: + +- Sapling parsing only if Sapling is ever intentionally added to this firmware + path; current policy is to reject it. +- Orchard memo binding/display by recomputing note encryption from + recipient/value/rseed/memo. + +## Keystone Comparison + +Keystone is a useful architecture comparison, but the audit alone is not enough +evidence. I inspected the local Keystone firmware repo: + +- Path: `/Users/highlander/keepkey/keystone3-firmware` +- Branch: `master` +- Commit: `2a48ba022ac24d3b343fa4b9e59251a5de3e1160` + +The actual Keystone signing path does derive the signing digest from PCZT data: + +- `rust/rust_c/src/zcash/mod.rs::sign_zcash_tx` extracts a `ZcashPczt` UR and + calls `app_zcash::sign_pczt`. +- `rust/apps/zcash/src/pczt/sign.rs::sign_pczt` builds a low-level PCZT signer, + then calls `pczt_ext::sign_transparent` and/or `pczt_ext::sign_orchard`. +- `rust/zcash_vendor/src/pczt_ext.rs::shielded_sig_commitment` constructs the + ZIP-244-style commitment from locally computed component digests: + `digest_header`, `transparent_sig_digest`, `digest_sapling`, and + `digest_orchard`. +- `digest_orchard` recomputes the Orchard digest from action fields: + nullifier, cmx, ephemeral key, encrypted memo/ciphertext slices, cv_net, rk, + out ciphertext, bundle flags, value balance, and anchor. +- `transparent_sig_digest` computes transparent prevouts, amounts, scripts, + sequence, outputs, and per-input data for `SIGHASH_ALL`. + +Keystone still has broader in-firmware PCZT parsing and Orchard output +decryption support. KeepKey now covers the same no-bare-sighash signing rule for +the implemented scope: header digest, transparent digest, Orchard digest, +displayed transparent outputs, displayed Orchard receiver/value metadata, and +the miner fee are all verified before signatures are released. + +Keystone also does more UI-side output parsing than our current firmware: + +- `rust/apps/zcash/src/pczt/parse.rs::parse_orchard_output` tries to decrypt + Orchard outputs with external/internal OVKs, validates decoded recipient data, + verifies a supplied `user_address` matches the decoded Orchard receiver, and + treats undecryptable non-zero Orchard outputs as invalid. +- `src/ui/gui_chain/multi/gui_zcash.c::GuiZcashOverviewTo` displays parsed + output value, address, change tag, and memo. +- The checker validates Orchard `cv_net`, value balance, nullifier/rk for owned + spends, and note commitment consistency before the UI/sign flow. + +This gives us a concrete target, not just an FYI: + +1. KeepKey branch: reject bare host sighashes, compute the final sighash from + required component digests, verify the Orchard digest from streamed action + fields, and compute header/transparent digests from streamed plaintext. +2. Output UI: transparent outputs are shown directly from verified script/value. + Orchard outputs are shown from directly supplied PCZT receiver/value/rseed + metadata that is cryptographically checked against the action `cmx`. +3. Remaining parity step: memo binding/display by recomputing Orchard note + encryption from recipient/value/rseed/memo. + +Sources: + +- Local Keystone firmware at the commit above. +- Public audit context: + https://leastauthority.com/wp-content/uploads/2025/03/Least-Authority-ZCG-Keystone-Hardware-Wallet-Final-Audit-Report.pdf + +## Tests + +The TDD coverage for this policy lives in: + +- `unittests/firmware/zcash.cpp` +- `deps/python-keepkey/tests/test_msg_zcash_sign_pczt.py` + +The firmware unit tests cover the pure policy helper, ZIP-244 digest helpers, +transparent digest/sighash helpers, Orchard receiver encoding, and Orchard note +commitment recomputation. The Python protocol tests cover the emulator-facing +behavior: legacy host-sighash requests are rejected, verified PCZT requests +sign, missing Orchard action digest fields abort signing, transparent digests +must match streamed plaintext, host transparent sighashes are rejected, and +Orchard recipient/value tampering is rejected. + +Current local verification: + +- `build/bin/zcash-crypto-unit` passes 56 tests, including: + - `ComputeHeaderDigest_FromPlaintextFields` + - `ComputeTransparentDigest_DistinctFromPerInputSighash` + - `ComputeTransparentDigest_EmptyBundle` + - `ComputeTransparentSighash_RejectsUnsupportedRequest` + - `OrchardNoteCommitment_KnownVector` + - `OrchardReceiverToUnifiedAddress_KnownVector` +- `cmake --build build --target kkfirmware` passes. +- `cmake --build build --target kkfirmware.keepkey` passes. +- `git diff --check` is clean. diff --git a/docs/release/SRS-7.15.md b/docs/release/SRS-7.15.md new file mode 100644 index 000000000..ffe5fbcb2 --- /dev/null +++ b/docs/release/SRS-7.15.md @@ -0,0 +1,298 @@ +# SRS — KeepKey Firmware 7.15.0 + +Software Requirements Specification, IEEE 830 (concise form). +Status: **draft against `alpha`**. Baseline `dda531024`. + +--- + +## 1. Introduction + +### 1.1 Purpose +Defines what 7.15.0 must do, what it must NOT do, and how each requirement is +verified. Audience: firmware, host (Vault/SDK), and release review. + +### 1.2 Scope +7.15.0 is the first release carrying the clear-signing *provider* tier. It adds +context to what the device already shows. **It never removes a screen.** + +Two products ship: + +| Product | Contents | +|---|---| +| Regular (`full`) | Every supported chain, including Zcash shielded/Orchard | +| Bitcoin-only | Bitcoin only; non-Bitcoin coins and Zcash privacy compiled out | + +There is no separate `zcash-privacy` artifact. + +### 1.3 Definitions +- **Clear-signing** — rendering a transaction's meaning (protocol, amounts, + recipient) instead of raw calldata. +- **Provider** — a third-party identity supplying decode context. **Not** + KeepKey attestation. +- **Runtime signer** — a provider identity loaded this session, RAM-only. +- **Pinned signer** — a verification key compiled into firmware. **None exists + in 7.15** and none may. +- **Blind sign** — signing bytes the device cannot describe. +- **AdvancedMode** — session-scoped opt-in policy; never a flash bit. + +### 1.4 References +- `docs/security/clearsign-provider-tier.md` — the tier's own scope rules, + including the two-product decision and the Solana attestor's human-attestation gate +- `docs/security/clearsign-key-delegation-roadmap.md` — phases 0–3 +- `deps/python-keepkey/scripts/generate-test-report.py` — the atlas (`SECTIONS`) + +--- + +## 2. Overall Description + +### 2.1 Product perspective +A signing device whose only real output is **what the user sees before they +press the button**. Every requirement below is ultimately about that screen. + +### 2.2 User characteristics +Assume a user who reads the screen and does not read the host. The device may +never rely on the host to tell the truth, and may never rely on the user knowing +what a selector or an ABI offset is. + +### 2.3 Constraints +- **C-1** STM32F205: ≥16 KiB SRAM reserve between `_ebss` and `_stack`, enforced + by a linker `ASSERT` and `tools/check_sram_budget.py`. +- **C-2** No bootloader changes in this release. +- **C-3** The device's `snprintf` is integer-only; no float conversions. +- **C-4** `confirm()` paginates a body over `BODY_ROWS = 3`; bytes outside + `0x21..0x7e` render as 4-glyph `\xNN` escapes. +- **C-5** Firmware SKIPS unknown protobuf fields — it does not reject them. A + host on an older protocol degrades silently, so gating must be host-side and + fail closed. + +### 2.4 Assumptions +Hosts are untrusted. Providers are untrusted-but-named. The user is the only +authority. + +--- + +## 3. Specific Requirements + +### 3.1 Clear-signing is additive — THE release invariant + +**R-1.1** After a successful clear-sign decode from a runtime-loaded provider, +the baseline raw/unverified review SHALL still run. +*Verify:* atlas F1/F2. Measured — Aave `supply()` baseline is 3 screens; a +VERIFIED v1 decode is 10 screens with those 3 **byte-identical at the tail**; +v2 static schema is 13 with the same tail. + +**R-1.2** A payload whose signature fails verification SHALL fall back to the +ordinary unverified review — neither refusing nor showing partial decoded info. +*Verify:* F3. Measured: 3 frames byte-identical to baseline. + +**R-1.3** No runtime signer SHALL reach the suppression branch. +*Verify:* F4/F5. All four slots produce VERIFIED decodes still followed by the +full baseline; no slot verifies without a runtime load. + +**R-1.4** The firmware SHALL contain no pinned provider key. +*Verify:* zero key bytes in `signed_metadata.c`. **This single property is what +separates 7.15 from 7.16.** + +> **What the user sees.** With a provider loaded and a matching signed payload: +> the provider's alias and fingerprint, then decoded screens naming the +> protocol, amounts and recipient — and then *the same raw-data review they +> would have seen with no provider at all*. Nothing is taken away. + +### 3.2 Provider trust is opt-in and dies on its own + +**R-2.1** AdvancedMode SHALL be session state, never a flash bit. +*Verify:* atlas I1; `storage.c` ignores legacy bit 12 at four sites. + +**R-2.2** Loaded identities SHALL be RAM-only, cleared by reboot, +`ClearSession`, session teardown, and **disabling AdvancedMode**. +*Verify:* I3–I6. + +**R-2.3** Loading a provider SHALL require an on-device confirm that cannot be +suppressed. *Verify:* V14; `signed_metadata_confirm_load`. + +**R-2.4** The device SHALL never represent a provider as KeepKey-endorsed. +It renders the provider's own alias and fingerprint plus "NOT verified by +KeepKey". + +**Deviation closed** (was: disable makes a signer inert but not erased). +`fsm_msgApplyPolicies` now calls `signed_metadata_clear_signers()` when +AdvancedMode is turned off. With the policy off the two behaviours were +indistinguishable — every consumer in `signed_metadata.c` already refuses a +runtime slot — so the bug was only visible on the way back: re-enabling +restored the provider to VERIFIED with no second trust screen, on a +confirmation that named the policy and never the signer. A user who disabled +AdvancedMode to drop a provider had not dropped it. I6 now asserts the signer +is gone, and its expected-response list (one ButtonRequest, one Success) proves +no trust screen appears on the way back. + +### 3.3 Disclosure completeness + +**R-3.1** Every byte covered by the signature SHALL be reachable on screen. +**R-3.2** A memo length that does not describe its own content SHALL be refused +(any NUL inside the declared length). *Verify:* Thorchain/Mayachain suites. +**R-3.3** An affiliate fee SHALL be displayed even when its affiliate slot is +empty. *Verify:* `MemoSwapFeeWithEmptyAffiliateIsStillShown` — 4 screens vs 3 +without the fee. +**R-3.4** An amount SHALL never render as zero when non-zero, and never at the +wrong scale. *Verify:* `Solana.FormatTokenAmountNeverShowsZeroForNonzero`. +**R-3.5** A refused screen SHALL abort signing, never be re-asked differently. +*Verify:* `ThorchainMemoResult` CANCELLED vs UNPARSED. + +### 3.4 Solana per-transaction context — IMPLEMENTED + +**R-4.1** The device SHALL display provider-attested, transaction-bound context +for instructions whose accounts are not in the signed message (Address Lookup +Tables), behind AdvancedMode, additive. + +**Status: implemented** (`KKSOLSW1`, firmware PR #500 — 146 added lines, no new +crypto primitive). Before it, `solana.c` skipped such instructions and rendered +**nothing**: the accounts an instruction would actually touch were invisible +while still being signed. That is the gap 7.15 closes, and closing it adds +screens. + +The attestation binds to the transaction, not to the account list alone: + +``` +preimage = "KeepKeySolanaTxAccounts/1" + || sha256(raw_tx) + || count (le32) + || key[0..count-1] (32 bytes each) +``` + +Verified through the existing chain-agnostic +`signed_metadata_verify_attestation()`. Three properties follow, each with a +test in section S of the atlas: + +- `sha256(raw_tx)` in the preimage means an attestation harvested from one + transaction cannot be replayed onto another — the same accounts under a + different transaction do not verify; +- a bad signature degrades to today's flow rather than refusing, so a broken + provider costs a user nothing but the extra screen; +- with no signer loaded the screens do not appear at all, which is the additive + invariant (R-1.1) restated for this path. + +The domain tag is versioned in the preimage itself, so a future account-context +format cannot be verified by a device that predates it. + +### 3.5 Products + +**R-5.1** Bitcoin-only SHALL compile out non-Bitcoin chains and Zcash privacy. +**R-5.2** The device SHALL report its product honestly in `firmware_variant` +(`KeepKeyBTC`/`EmulatorBTC`). *Verify:* atlas L; D-07. +**R-5.3** Non-Bitcoin paths SHALL refuse cleanly on bitcoin-only, not +half-render. *Verify:* atlas L. + +### 3.6 Storage + +**R-6.1** A signed UPGRADE SHALL never wipe. A DOWNGRADE wiping is expected. +**R-6.2** The committed record SHALL be recognisable on the next boot. +*Verify:* atlas U; D-02. +**R-6.3** Active flash format is V17. *Verify:* `test_active_flash_format_is_v17`. + +### 3.7 Non-functional + +**R-7.1** SRAM reserve ≥16,384 B, both products. *Currently:* full 18,172 B, +bitcoin-only 32,092 B. +**R-7.2** Both products build for ARM with `-Werror`. +**R-7.3** No CI job may silently skip. *Verify:* the aggregate `CI gate`. + +--- + +## 4. Verification status + +| | | +|---|---| +| `firmware-unit` (full) | 439/439 | +| `board-unit` | 12/12 | +| `firmware-unit` (bitcoin-only) | 63/63 | +| pyk suite (full emulator) | 620 passed, 33 skipped, 0 failed | +| pyk suite (bitcoin-only emulator) | 11/11 | +| ARM SRAM reserve | full **17,716 B** · btc-only **31,648 B** (budget ≥ 16,384 B, both PASS) | +| Token table applied | `ethereum_tokens: 350 of 1378 kept` · `uniswap_tokens: 150 of 568 kept` | +| Hardware (gate 3) | **NOT PERFORMED** | + +Measured on the ARM cross-build of the KKSOLSW1 candidate, both variants. The +reserve is `_stack - _ebss` and the gate is enforced in CI, not read off a +build log. + +The full-variant reserve fell 456 B from the previous line (18,172 B) and that +is KKSOLSW1: `fsm_msg_solana.h` flattens the nanopb array into a +`uint8_t lut_keys[SOL_MAX_LUT_ACCOUNTS][SOL_PUBKEY_SIZE]` so the attested keys +are contiguous for hashing. It is the honest cost of the feature and it is +recorded here rather than absorbed silently, because SRAM on this part is spent +once and an unexplained 456 B is the kind of thing that only becomes visible +when the next feature does not fit. + +The token budget is what pays for it: 500 of 1,946 candidate entries, −23,104 B +of flash. The pinned data source has been stale since 2023-04-06, so the long +tail is not coverage of anything current. + +--- + +## 5. Exit criteria + +1. ~~R-4.1 implemented, or explicitly deferred~~ — **met.** KKSOLSW1 landed + (firmware #500); §3.4. +2. Gate 3 OLED evidence per the human-attestation gate in + `docs/security/clearsign-provider-tier.md`: a 44-character base58 program + ID, an 8-byte discriminator on its own screen, all four argument types, + 16-character labels. **CI success alone does not prove this display + boundary.** +3. The CI test report green with **nothing withheld**. +4. `solana-schemas-local.json` CI test key replaced or removed. **Host-side + deliverable** — the file is not in this repository; it ships with the + provider/Vault tooling. Listed here because a device cannot verify a + schema signed with a test key, so it gates the release even though the + fix lands elsewhere. +5. R-1.4 re-verified on the exact release candidate. +6. The D-01 sub-item (duplicate detector never observed firing correctly) + resolved or accepted in writing. + +--- + +## 6. Landing plan — alpha → fork `develop` + +7.15 is cut from `alpha` into the fork's `develop`. `alpha` is *ahead* of 7.15: +it carries 7.16+ work, so the cut is a selection, not a fast-forward. What +follows is the selection. + +### 6.1 What goes + +| # | Change | Lines | Why it is in 7.15 | +|---|---|---|---| +| L1 | 7.14.2 security merge + the 10 defects it exposed | — | Three are shipping bugs. They go first because everything else rebases on them. | +| L2 | Clear-sign provider context, additive (§3.1–3.3) | — | The release's reason to exist. | +| L3 | KKSOLSW1 Solana account context (§3.4) | +146 | Last firmware build item. | +| L4 | Bitcoin-only variant (§3.5) | — | Second product, its own emulator leg. | +| L5 | Storage upgrade preservation (§3.6) | — | Proves a signed upgrade does not wipe. | +| L6 | Token table budget — 1,945 → 500 entries | −23,104 B flash | Pays for the above. | +| L7 | Test atlas sections F, I, L, U, J, Q, K, P + the report gates | — | The evidence. Without it none of the above is auditable. | + +### 6.2 What does NOT go + +Everything gated on a firmware-pinned provider key: the reductive branch, the +delegate certificate chain, expiry — that is 7.16+ scope. The release gate is +mechanical and checkable — **no pinned provider key bytes in the artifact** — +which is why 7.15 needs no custody programme. + +### 6.3 Order + +L1 → L6 → L2 → L3 → L4 → L5 → L7. + +L6 goes early, directly after the defect fixes: it frees the flash the rest +spends, and a ROM overflow discovered after L2–L5 have landed is a bisect +through five features instead of one. + +### 6.4 The gate on each PR + +`ci-gate` green — not a green run summary. A failed Stage-1 gate marks the +whole downstream graph *skipped*, and a skipped required job is not a pass; +this has silently produced an all-green-looking run three times on this line. +`ci-gate` is the only check whose success means the entire graph ran. + +### 6.5 What CI cannot close + +Exit criterion 2. Every display bound in §3.1–3.4 is a claim about pixels, and +the emulator's framebuffer is not the OLED. Gate 3 is hardware, and it is +**still NOT PERFORMED** — it is the one item between a green `develop` and a +signable release candidate. diff --git a/docs/security/anti-rollback-security-epoch-rfc.md b/docs/security/anti-rollback-security-epoch-rfc.md new file mode 100644 index 000000000..fe8a7cd18 --- /dev/null +++ b/docs/security/anti-rollback-security-epoch-rfc.md @@ -0,0 +1,152 @@ +# RFC: OTP-backed firmware security epochs + +Status: design required; no production implementation is authorized by this +document. + +## Security invariant + +After a device accepts an official firmware image in security epoch `N`, no +officially signed image with an epoch lower than `N` may be installed or booted. +The floor must never advance before the new image has passed all integrity and +signature checks. + +A power loss during an update leaves the device with **no bootable +application** — see "Interruption behaviour" below. This RFC previously +required that a power loss leave the device able to boot either the previous or +the new image. That is not achievable on this hardware and the requirement has +been withdrawn. + +Semantic versions are not the monotonic value. Patch and release-candidate +numbers are allowed to move independently; the security epoch advances only +when an older signed image must be permanently revoked. + +## Interruption behaviour + +The device has a single application slot: sectors 7-11, 128 KiB each, 640 KiB +total (`include/keepkey/board/memory.h`). A 7.15 image is roughly 568 KiB, so +two resident copies would need about 1.14 MB. **A/B slots do not fit, and no +amount of firmware work makes them fit** — this is a hardware-revision +requirement, not a backlog item, and it should not be carried in one. + +For the same reason the candidate cannot be verified before erase. There is +nowhere to stage it: no spare flash, and roughly 192 KiB of RAM against a +568 KiB image, so it cannot be buffered either. The signature covers the whole +image and cannot be checked until the final byte arrives, by which point the +installed application is already gone. `handler_erase` +(`tools/bootloader/usb_flash.c`) erases sectors 7-11 on a button press, before +any image bytes exist. + +What is therefore true, and what integrators may rely on: + +- Between erase and the installation of the application magic there is **no + bootable application**. An update interrupted in that window leaves the + device in a **recovery-only** state. +- The bootloader (sectors 5-6) is never erased by an application update, so the + device is always able to accept another image. Recovery-only is not a brick; + re-running the update restores the device. +- The application magic is installed only after the flashed image and its epoch + verify, so an interrupted update cannot leave a partially written image + bootable. +- Storage is preserved across the interruption under the usual signature + conditions (`should_restore()`): the outgoing firmware must have been + officially signed and the incoming image must verify. An unsigned image on + either side wipes storage by design. + +Recovery-only is the honest name for this state. Documenting it is not an +endorsement: an update that cannot be made atomic is a real limitation, and the +mitigation is procedural — do not interrupt an update — until hardware with a +second slot exists. + +## Why ordinary flash is insufficient + +The bootloader can erase and rewrite application flash, and the attacker in +this threat model is deliberately installing an older valid image. A floor +stored beside mutable firmware or normal storage can be restored with the old +image and does not establish monotonicity. + +The STM32F2 OTP region exposes sixteen 32-byte blocks. Current source assigns +manufacturing data to block 0, model data to block 1, and hardware entropy to +block 3. Before choosing any remaining block, manufacturing images and all +shipping board revisions must be audited; absence of a source reference is not +proof that a factory process never programmed it. + +## Proposed representation + +Reserve one audited OTP block as a 256-step unary counter. Epoch `N` is encoded +by programming the first `N` bits from 1 to 0. The decoded epoch is the length +of the contiguous programmed prefix. + +Reject the OTP state if a programmed bit appears after an unprogrammed bit. +This catches torn or non-canonical values instead of interpreting them as a +lower floor. Do not lock the block after each update; the OTP 1-to-0 property is +the monotonic mechanism. + +The signed application metadata needs a dedicated epoch field covered by the +existing firmware signatures. Reusing undocumented `meta_flags` bits is only +acceptable after confirming every bootloader generation parses and signs the +same bytes. A new metadata format with an explicit compatibility version is +preferred. + +## Update state machine + +1. Parse the candidate metadata without trusting it. +2. Decode the current OTP floor and reject malformed OTP. +3. Reject `candidate_epoch < floor` before erasing the installed image. This is + the only candidate check that can precede the erase: the epoch is declared in + metadata, whereas hash and signature cover an image that has not arrived yet. +4. Erase the application sectors. **From here until step 7 the device has no + bootable application** (see "Interruption behaviour"). +5. Write the candidate while preserving the existing storage-protection + contract. +6. Re-read from flash and verify image bounds, hash, and the complete 3-of-N + signature policy. A failure here leaves the device recovery-only, which is + correct: a candidate that fails verification must not be bootable. +7. If `candidate_epoch > floor`, program and verify each required OTP bit. +8. Install the application magic only after image and epoch verification. +9. At every boot, reject an installed image whose epoch is below the OTP floor. + +Unsigned/user-approved firmware must never advance the official floor. The RFC +must decide whether such firmware may boot at all once a floor is active; either +choice needs an explicit user-facing recovery story. + +## Fault-injection requirements + +- Accumulate signature results and validate sentinels as the current verifier + does; do not add a single skippable epoch branch after signature validation. +- Read the OTP floor more than once with independent control-flow checks before + an irreversible write. +- Verify every programmed bit and halt on disagreement. +- Ensure a glitch cannot turn malformed OTP into epoch zero. +- Include the epoch in the host-visible bootloader features and release + evidence so operators can diagnose state without trusting firmware. + +## Compatibility and rollout + +This requires a bootloader campaign. Application-only deployment cannot protect +devices whose installed bootloader ignores epochs. + +1. Inventory bootloader versions in the field and their update paths. +2. Prototype with a non-production test block on sacrificial devices. +3. Ship epoch-aware bootloader code with floor zero and no OTP advancement. +4. Confirm update, downgrade, unsigned-firmware, storage-preservation, and + recovery behavior on each hardware revision. +5. Audit factory OTP contents and permanently reserve the selected block. +6. Only a later release may advance epoch one. + +## Required tests + +- candidate epoch below/equal/above floor; +- malformed non-contiguous OTP patterns; +- exhausted 256-step counter; +- signature failure with a higher claimed epoch; +- unsigned firmware with a higher claimed epoch; +- hash mismatch after flash write; +- power loss before erase, during image write, after image verification, during + OTP programming, and before application magic installation — each must leave + the device either bootable on the previous image (power loss strictly before + erase) or recovery-only, and never bootable on an unverified image; +- boot of an installed image below the floor; and +- recovery-mode behavior when no eligible application remains. + +The implementation PR must include a negative control showing that removing the +floor comparison permits a signed lower-epoch image. diff --git a/docs/security/clearsign-key-delegation-roadmap.md b/docs/security/clearsign-key-delegation-roadmap.md new file mode 100644 index 000000000..7b4b1e59d --- /dev/null +++ b/docs/security/clearsign-key-delegation-roadmap.md @@ -0,0 +1,1602 @@ +# Clear-sign signing authority: why delegation, and the road to it + +Status: roadmap for audit. Written to be read by an auditor deciding what to +attack, and by an implementer deciding what to build next. + +Companion docs: `7.15.0-rc21-clearsign-release-control.md` (what ships today), +`anti-rollback-security-epoch-rfc.md` (the epoch mechanism this depends on). + +The governing sentence for this work: + +> **Bitcoin-derived evidence may only reduce clear-sign authority; metadata +> failure may never silently reduce the level of review required to produce a +> signature.** + +--- + +## 0. Blocking decisions — read this before costing anything + +**All ten are now decided (2026-08-13).** The decisions and their reasoning are +recorded in *Decisions locked* immediately below; the analysis that produced +them is left in place in the sections that follow, because the reasoning is +what makes them re-checkable later. + +The history is worth keeping: an earlier reading of this document concluded that +the architecture was settled and only ROM measurement remained open. That was +false, and it was the most expensive misreading available here. The ratchet +substrate did not exist in the form this document assumed, the authority model +named a class rather than a key, the updater invariant was contradicted by the +shipping bootloader, and the certificate schema described one signed object +three different ways. Measuring a validator against an undecided substrate +produces a number with no referent. + +ROM measurement comes **last**, and is now unblocked — every row below is +settled, so a measured validator finally has a referent. + +| # | Sev | Blocker | Where resolved | Status | +|---|---|---|---|---| +| 2 | High | Ratchet substrate. The four-field `SecurityRatchets` facility does not exist; the anti-rollback RFC defines a single 256-step unary OTP counter that cannot represent a block height. | §5b *Substrate* | **DECIDED** — descoped to epochs; use the anti-rollback OTP counter | +| 3 | High | Authority model. "ROOT SIGNATURE" is an authority class, not a key. | §5b *Authority* | **DECIDED** — distinct keys, domain-tagged transcripts, negative tests | +| 4 | High | Updater invariant. The bootloader erases the whole application partition before it has seen the candidate. | §8 *Updater invariant* | **DECIDED** — recovery-only state documented; RFC amended | +| 8 | Med | Certificate schema conflicts across the document; the acceptance rule tests an epoch range that is never defined. | §6 *Canonical certificate* | **DECIDED** — ratified at 169 bytes (freshness fields removed) | +| 1 | High | Cross-variant preservation. "absent or inert" in bitcoin-only resurrects expired delegates on the round trip back to full. | §5b *Variant scope* | Resolved here | +| 6 | High | Proof-session inputs are host-supplied. | §5b *What the device validates* | **OUT OF SCOPE** — no freshness proofs | +| 7 | High | Blind-sign policy is security-critical persistent state with no integrity protection. | §5b *Policy integrity* | **DECIDED** — session-scoped, nothing persisted | +| 9 | High | "Already-verified certificate" is circular: the proof session needs a verified certificate, and §6 acceptance needs freshness, which only a proof session establishes. A new device bootstraps nothing. | §5b *Blocker 9* | Resolved here — authenticated vs authorized | +| 10 | High | A single global freshness height is committed, but each proof is checked against its own certificate's `expiry_min_work`. A weak certificate's proof expires certificates whose higher threshold was never met. | §5b *Blocker 10* | **OUT OF SCOPE** — no freshness accounting | +| 5 | High | Single-root custody: one compromise yields globally warning-free false interpretations until firmware replacement. Applies to **both** roots — the schema root has no expiry at all. | §4 *Custody* | **DECIDED** — 1-of-1 on a stock signed KeepKey; risk accepted | + +**Custody (5) no longer blocks the build, but it does gate the first ceremony.** +The custody device must run stock, signed 7.15 — so the release ships first, the +device is provisioned second, and only a later release may pin an anchor. That +ordering works because 7.15 pins no anchor: `signed_metadata.c` deliberately +rejects persistent trust anchors and uses RAM-only session signers, so the root +pubkey does not need to exist at build time. The circular dependency people +expect here appears only at the release that pins an anchor, and that is not +this one. + +Blind-sign policy stickiness, previously flagged as undecided, is settled by +decision 7: nothing is persisted, so there is no stickiness to define. + +--- + +## 0a. Decisions locked — 2026-08-13 + +Decided by the owner in one pass, biggest risk first. Each records what was +chosen, and what was knowingly accepted by choosing it. Where a decision closes +a blocker by removing scope rather than solving it, that is said outright. + +### D1 — Custody: 1-of-1 on a stock, signed KeepKey (blocker 5) + +The root is a single production KeepKey running stock signed firmware. Not a +threshold, not an HSM. + +**Accepted risk, unchanged from the finding:** one compromise yields globally +warning-free false interpretations, remediable only by a firmware release. + +**Why it is defensible.** DR is solved by construction — the root is a BIP-39 +seed, so device loss is a restore, and a threshold scheme would have *added* a +DR problem rather than removed one. Rotation is cheap today and stays cheap +until an anchor is pinned; pinning is what converts a bundle push into a +firmware release, and it is now a dated decision rather than a side effect. +And with a single root the compensating control is not quorum but **visibility**: +every schema and certificate this key signs is published to a signing log, so a +signature nobody authorised is noticeable. Detection, not prevention. + +**Operational requirements:** dedicated device that never holds funds; PIN set; +offline except during a ceremony; seed backed up to the existing safe; signing +log published; ceremony rehearsed on a throwaway device before the real key +exists. + +**Sequencing:** the device must run stock signed 7.15 — the attestor messages +(`ClearsignAttestorGetPublicKey`) do not exist on 7.14.1 — so the release ships +first and the key is generated second. + +### D2 — Freshness is out of scope; the ratchet is epochs only (blockers 2, 6, 10) + +`clearsign_freshness` is dropped. `SecurityRatchets` reduces to epoch counters, +which the anti-rollback OTP mechanism already provides. Do not build a second +mechanism. + +**What this removes:** the authenticated journal, the OTP-generation binding, +the wear budget, the power-loss state machine, and the four parameters this +document said needed an owner. Bitcoin headers as a freshness oracle are not +being implemented. Blockers 6 and 10 close with it — there are no proof-session +inputs to constrain and no work accounting to reconcile. + +**What replaces expiry:** ordering, not time. Each delegation carries an epoch; +the device refuses anything below its stored minimum and never lowers it. +Rotation is **monthly**, and revocation is rotating early. + +**Why monthly matters:** the OTP block is a 256-step unary counter, so monthly +rotation is roughly 21 years of device lifetime. The counter fits without a +journal. Weekly rotation would be about 5 years and would eventually force the +journal back into scope — so the cadence is not a scheduling preference, it is +the parameter that keeps the substrate simple. + +**Residual risk, stated for the audit:** a device that never sees a newer epoch +keeps accepting an old delegation indefinitely. The one-month bound is real for +a device that connects; it is unbounded for one that does not. + +**Why that is tolerable here:** the per-transaction signing flow is server +hosted, so a device using the delegated path is by definition connected, and the +epoch bump travels the same channel. A device that never connects cannot reach +the hot key and is therefore not exposed to a compromised delegate. + +### D3 — Authority separation is cryptographic (blocker 3) + +1. **Distinct keys.** Schema root and delegate are separate keys. The schema + root uses a dedicated derivation path on the custody device, used for nothing + else. +2. **Domain-tagged transcripts.** Every signed message begins with a purpose + string (`KeepKey/ClearSign/Schema/v1`, `KeepKey/ClearSign/Delegate/v1`). + Verifiers reject an untagged transcript rather than treating it as legacy. +3. **Context binding.** Network, model, variant, format version, purpose and + epoch are inside the signed bytes. +4. **Negative tests are a merge gate.** Cross-protocol replay and type confusion + must fail to verify, as tests. Without them the tags are decoration nobody + checks — the same failure mode as a quorum gate that passes on one non-zero + byte. + +The delegate certificate and the per-transaction blob carry **different** tags. +They are signed by the same key but authorise different things, so a stolen +per-tx blob must never be presentable as a delegation. + +Firmware signing format is unchanged and cannot be tagged retroactively — +signed images exist in the field. Tagging only the new authorities is sufficient: +clear-sign transcripts hash `tag || payload`, firmware hashes the image, and +crossing them requires a preimage collision. + +### D4 — Updater invariant: recovery-only, and the spec says so (blocker 4) + +Amended in `anti-rollback-security-epoch-rfc.md` (commit `a3bd19fe7`). The +old-or-new bootability requirement is withdrawn as unachievable on this +hardware: one 640 KiB application slot against a ~568 KiB image leaves no room +to stage a candidate, and ~192 KiB of RAM cannot buffer one either. + +An interrupted update leaves the device **recovery-only** — no bootable +application, bootloader intact and able to accept another image, application +magic installed only after verification, storage preserved under the usual +`should_restore()` conditions. + +Dual-slot is recorded as a **hardware-revision requirement**, not a firmware +backlog item. New hardware is not planned, so it is not something anyone can +pick up. + +### D5 — Blind-sign policy is session-scoped (blocker 7) + +`AdvancedMode` stops being persistent. Enabling it requires physical +confirmation once per power cycle; nothing is written to flash. + +**Why this over authenticated storage.** It matches the precedent already set: +`signed_metadata.c` deliberately rejects persistent trust anchors *because the +public storage section has no authenticated integrity against physical flash +modification*, and uses RAM-only session signers instead. Blind-sign policy is +the same class of state, in the same section, under the same threat. Answering +it differently would be the inconsistency. It also avoids a storage version bump, +which is now a deliberate gated act (see `docs/StorageVersionGate.md`). + +**It also removes three sub-questions.** Behaviour after reset, after variant +change, and on corrupted policy are all trivially "off" when nothing is +persisted. + +**Cost:** anyone who legitimately blind-signs re-enables after each power cycle. +That belongs in release notes. For a deliberate security downgrade the friction +is arguably correct — during 7.15 testing a persistent `AdvancedMode` was left +on and silently converted four test suites' "rejected correctly" results into +false greens, because the rejection had been a human pressing cancel rather than +the gate firing. + +### D6 — Certificate layout ratified at 169 bytes (blocker 8) + +The layout in §6 is ratified with the four freshness fields removed +(`btc_anchor_hash`, `btc_anchor_height`, `expiry_height_delta`, +`expiry_min_work`). Signed transcript is bytes 0..104; signature at 105; total +169. + +They are **removed, not reserved.** `format_version` already refuses anything +not exactly known, so freshness — if it ever ships — is format_version 2 with a +longer layout, and old verifiers reject it cleanly. Dead must-be-zero fields on +a trust boundary are how an issuer and a verifier drift apart. + +`cert_hash` keeps its definition (sha256 over all 169 bytes, signature included, +distinct from the transcript hash). It loses its old consumer with +`BitcoinFreshnessBegin` and gains a better one: it is the identifier used by the +signing log from D1. + +--- + +## 1. What clear-signing is defending against + +A hardware wallet's only real guarantee is its own screen. Everything else — +the host, the browser, the dapp, the RPC — is assumed hostile. Blind signing +breaks that guarantee: the user approves a digest, and the screen cannot say +what the digest means. Every drainer attack lives in that gap. + +Clear-signing closes it by rendering intent on the trusted display: *who* the +counterparty is, *what* the call does, and with *what* amounts. + +The problem is that the device cannot compute that rendering by itself. +Decoding an arbitrary contract call means knowing the ABI, the token decimals, +the protocol's semantics. A 168 KB-of-RAM device cannot hold the world's +contract metadata, and it must not learn it from the same host it distrusts. + +**So something has to tell the device how to render a call, and the device has +to be able to verify that instruction came from someone it trusts.** That +"someone" is the signing authority. Every design decision below follows from +having to run one. + +--- + +## 2. Two blob formats, and only one needs a hot key + +`signed_metadata.h` already draws the line: + +| | v1 `METADATA_VERSION_LEGACY` | v2 `METADATA_VERSION_SCHEMA` | +|---|---|---| +| Scope | one specific transaction | a contract + selector, statically | +| Carries | committed `tx_hash` + pre-decoded values | decode instructions only, no values, no hash | +| Who decodes | the host; device binds to the digest | **the device**, from the calldata it is about to sign | +| Signing | **online, per transaction** | **once, offline**; servable from a CDN | +| Key exposure | hot key, reachable at tx time | none | + +v2 is the better design wherever it reaches, precisely because it has no hot +key: the display is bound to the signature by construction, since the device +derives the values from the exact bytes it signs. + +**v2 does not reach everywhere.** It cannot cover: + +- contracts absent from the catalog — the long tail, and where drainers live; +- values not derivable from calldata alone (token symbol/decimals for an + arbitrary address, ENS/name resolution, off-chain quote or route data); +- aggregator and bridge flows whose meaning depends on off-chain state at + quote time; +- anything needing *fresh* reputational context ("this contract was flagged + yesterday"), which a statically signed catalog cannot express. + +Those cases need a signature over *this* transaction, produced *now*. That is +v1, and v1 needs a key that is online when the user is transacting. + +**This is the entire reason delegation exists.** Not a preference — a direct +consequence of needing per-transaction attestation for the long tail. + +--- + +## 3. Why the online key cannot be the root + +An online signing service is, by construction, exposed: internet-facing, +operationally reachable, subject to host compromise, supply-chain compromise +and insider access. Assume it will eventually be compromised and design for +the day it is. + +If that key is the anchor compiled into firmware, compromise is maximal and +near-unfixable: + +- an attacker can forge a clear-sign descriptor for **any** transaction, which + means presenting an arbitrary drainer as a benign transfer on the trusted + display — reintroducing exactly the attack clear-signing exists to prevent, + while *increasing* user confidence; +- it affects **every KeepKey ever shipped**, retroactively; +- the only remedy is a firmware release to every device, gated on users + choosing to update. + +So the root must never be the key that signs per-transaction payloads. It signs +one thing: **delegations**. + +A delegated signer bounds the damage to something survivable: + +- a compromised delegate is valid for a bounded window (target: **1 month**); +- it can be **revoked** without a firmware release, once the revocation + mechanism in §5 exists; +- it is **scoped** — v1 blobs only, no authority to issue further delegates, + optionally chain-limited; +- the root stays offline, on hardware, and is used a handful of times a year. + +### What Ledger's public implementation does and does not substantiate + +Ledger runs the same cryptographic shape for its live path, and the parts we can +verify from public code are worth copying rather than reinventing: + +- an OS-level PKI with a root CA (`LEDGER_ROOT_V3`), certificates loaded at + runtime through a generic `LOAD_CERTIFICATE` APDU, verified and retained as + the current PKI key, with chaining via previously validated keys; +- **capability-scoped** certificates. The key usage is part of the certificate + -- `TX_SIMU_SIGNER`, `CALLDATA`, `TRUSTED_NAME`, `NFT_METADATA`, `COIN_META`, + `PLUGIN_METADATA`, `SWAP_TEMPLATE`, `EXCHANGE_PAYLOAD`, and more. The + Ethereum app does not trust a bare "Ledger-approved key"; it asks the PKI + subsystem for a certificate whose usage is + `CERTIFICATE_PUBLIC_KEY_USAGE_TX_SIMU_SIGNER` and verifies the report with + that key; +- **transaction binding on the device**, not merely a signature over a report. + +Do NOT write that Ledger "reached the same conclusion" about short-lived +revocable delegates. That is not substantiable from public code. In the open +Speculos implementation the certificate's `TIME_VALIDITY` field is only checked +structurally for length -- it is not compared against a trusted clock -- and +`VALIDITY_INDEX` is not visibly ratcheted against tamper-resistant monotonic +state. Speculos emulates BOLOS behaviour and is not the complete proprietary +Ledger OS, so the correct statement is not "Ledger has no revocation" but: + +> **the public device and app code does not demonstrate an offline +> expiry/revocation solution we can copy.** + +The precise wording to use, because it is the hardest to shoot down: + +> Ledger's public implementation demonstrates the same underlying PKI pattern: +> dynamic transaction assessments are signed by runtime-loaded, +> capability-scoped keys whose certificates are validated through Ledger's PKI, +> rather than treating a generic online signer as unrestricted trust. The +> public Speculos implementation exposes certificate validity fields but does +> not demonstrate an offline revocation/freshness mechanism we can rely on as +> precedent; Speculos models BOLOS behaviour but is not the complete +> proprietary BOLOS implementation. + +So Ledger validates the *certificate -> scoped online signer -> tx-bound +payload* portion of this design. It does not hand us an answer to the offline +freshness problem, and nothing can -- see section 5b. + +--- + +## 4. The root lives on a KeepKey + +`fsm_msg_clearsign_attestor.h` already implements a KeepKey acting as a signing +authority — `ClearsignAttestorPublicKey` and `ClearsignAttestorSignature`, with +the human-attestation gate documented in the RC21 release-control doc: the +device shows program/instruction labels, the full base58 program ID and the +discriminator on their own confirmations, and every argument's ordinal, ABI +type and label before it will sign. + +That gate is what makes a KeepKey a *better* root than a conventional HSM for +this job. A YubiHSM signs whatever the calling process hands it; the operator +sees nothing. A KeepKey attestor makes a human read the declaration on a +trusted screen before the signature exists. For an authority whose entire +purpose is to certify "this is what the transaction means," the signing device +displaying the claim is the point, not a formality. + +Consistent with the no-AWS/self-hosted-first stance: the root is hardware we +control, in a location we control, with no cloud KMS anywhere in the trust path. + +### Custody — BLOCKER 5. Owner decision, and it is not decidable here + +"Air-gapped, multi-person, recorded; the root key generated on-device with dice +entropy" describes a *ceremony*. It does not describe **custody**, and the +distinction is the blocker: as written, this is one dice-generated key on one +KeepKey. A single root means a single compromise, and the consequence is worse +than for any other key in the system, because §6 grants exactly one privilege to +a root-verified chain — **warning-free rendering**. One stolen root produces +globally warning-free false interpretations on every device that trusts it, and +the only remedy is a firmware release that every user must choose to install. +That is the failure mode §3 opens this document by rejecting for the online key, +reappearing one level up. + +The requirement is **N-of-M across independent devices and locations**, so that +no single device seizure, no single premises compromise and no single insider +produces a signature. Four things have to be specified alongside it, and each +one is a way for the scheme to fail quietly if left implicit: + +- **Rotation.** How a root is retired on schedule rather than only in crisis. +- **Backup.** How M is reconstituted after a lost device, without the backup + itself becoming a 1-of-1 path around the quorum. +- **Disaster recovery.** What happens when quorum becomes unreachable — + bearing in mind the fail-closed story in *Why Bitcoin rather than a root-signed + epoch broadcast*: delegates age out, static and device-native signing continue. + Losing the root must degrade to that, not to a rushed single-key restore. +- **Overlapping-anchor transition.** Firmware pins the root, so rotating it + means shipping firmware that trusts old and new simultaneously for a window + long enough that devices which update late are never stranded. Without this, + rotation is indistinguishable from a compromise-forced emergency. + +**These parameters commit the organisation to an operational programme, so they +are not decided in this document.** What is decided here is that no root may be +pinned before they are, because pinning is the irreversible step: a root in +shipped firmware cannot be un-shipped. + +### Both roots are irreversible, and the schema root is not the lighter case + +An earlier revision claimed the **schema** root could ship on "a lighter custody +model" because it grants no per-transaction authority, letting Phase 1 proceed +while this decision was open. **That was wrong, and the error is worth keeping +visible because it is the seductive one:** it reasons about the key's *scope* +and forgets its *remedy*. + +A compromised schema root signs a malicious v2 catalog entry for any contract +the attacker chooses, and §6 grants a root-verified chain exactly one privilege +— warning-free rendering. So the outcome is the same as for the delegation root: +a false interpretation on the trusted display, with the warning suppressed, on +every device that trusts the key. + +The schema root is in one respect **worse**. A delegate certificate carries +`cert_epoch`, `btc_anchor_height` and `expiry_height_delta`, so a stolen +delegate ages out on its own; that is the entire point of §5b. The v2 catalog +has no such fields, so a compromised schema root has no expiry and no ratchet. +Its only remedy is a firmware release every user must choose to install — the +unbounded case, not the bounded one. + +**Both roots need threshold custody. Their parameters may differ; the +requirement does not.** What legitimately differs is operational: catalog +reissuance is infrequent and batched, delegate issuance is monthly, so N, M and +ceremony cadence can be tuned per root. What does not differ is that a single +device or a single person must not be able to produce either signature. + +Concretely: **Phase 1 is gated on the schema root's custody decision, and +Phase 2 on the delegation root's.** Splitting the roots buys cryptographic +separation and independent compromise stories. It does not buy a phase that +skips custody. + +**Ceremony (still required, and downstream of the above):** air-gapped, +multi-person, recorded; keys generated on-device with dice entropy; public keys +published and pinned in firmware; delegation issuance a scheduled ceremony, not +an on-call operation; the Bitcoin anchor chosen at issuance must be recent, or +the validity window opens in the past. + +--- + +## 5. The hard problem: the device has no clock and no network + +Everything above assumes the device can enforce "valid for one month" and +"revoked." **It cannot, and this is the part an auditor should press hardest +on.** + +A KeepKey has no real-time clock and no independent network. It knows only +what the host tells it, and the host is the adversary. + +- **Host-supplied time is worthless.** An attacker holding a delegate + certificate compromised 8 months ago simply tells the device it is still + within the window. +- **Certificate `not-after` fields are advisory only.** They constrain the + *issuer's* discipline, not the device's acceptance. + +What a clockless device *can* enforce is **ordering**, via a monotonic epoch — +the same primitive the anti-rollback RFC needs for storage: + +1. Each delegation carries an integer `epoch`. +2. The device refuses any delegation whose epoch is below its stored minimum. +3. Rotating monthly bumps the epoch. Revoking is bumping early. +4. The device raises its minimum when it sees a validly signed higher epoch, + and never lowers it. + +Delivery of the minimum epoch, worst to best: + +| Mechanism | Revocation latency | Cost | +|---|---|---| +| Firmware release pins a new minimum | weeks, gated on user updating | none — no new state | +| Signed epoch-bump message the device ratchets on | minutes, on next connect | needs integrity-protected storage | + +**The residual risk that must be stated plainly in any audit:** a device that +never sees a newer epoch keeps accepting an old delegation indefinitely. The +one-month bound is real for a device that connects; it is unbounded for one +that does not. No amount of certificate metadata changes this — only contact +with a newer epoch does. + +This also means the epoch counter needs integrity. It cannot live in the public +storage section: RC18 rejected persistent trust anchors there precisely because +that section has **no authenticated integrity against physical flash +modification**, and an attacker who can lower the stored minimum re-enables +every revoked delegate. Options — OTP, an authenticated storage section, or +carrying the minimum in the firmware image itself — are exactly the +anti-rollback epoch design, and this work should not fork from it. + +--- + +## 5b. Revocation cannot be forced onto an offline device + +Revocation needs the device to either **learn** it is revoked (a channel the +adversary controls) or **expire on its own** (a clock it does not have). A +withheld message is indistinguishable from no network, so any design resting on +delivering a negative statement -- a revocation list, an on-chain revocation +record, a "this delegate is dead" broadcast -- is unenforceable against the +party we already assume is hostile. + +The precise claim, which is weaker than "expiry is enforceable" and is the one +that survives audit: + +> **Revocation cannot be forced onto an offline device. Freshness-gated expiry +> becomes enforceable once the device receives sufficient authenticated +> evidence of progress.** + +So: credentials are short-lived, revoking means *stop reissuing*, and the +device demands proof of freshness -- a positive statement it can check -- +rather than proof of non-revocation, which it cannot. Bitcoin improves **who +can supply** that evidence, not **whether an adversarial host can suppress it**. + +The question becomes: where does a clockless device get freshness it cannot be +lied to about? + +### Bitcoin headers as the freshness oracle + +> **OUT OF SCOPE (D2).** Bitcoin-derived freshness is not being implemented. +> Expiry is replaced by epoch ordering with monthly rotation; revocation is +> rotating early. Everything from here to the end of the freshness material — +> the proof session, the header validation rules, `BitcoinFreshnessBegin`, the +> anchor and work fields, and blocker 10's accounting — describes a design that +> was evaluated and deliberately not built. Retained as the rationale, and as +> the starting point if freshness is ever reinstated. **Do not implement from +> it.** + + +Validating a header is one `sha256d` and a target comparison, and forging one +at mainnet difficulty means outspending the network. That makes a header +genuine evidence that work and time have passed, and it beats a KeepKey-signed +epoch bump on the axis that matters: + +**the ratchet advances without KeepKey's participation.** Any host, explorer or +full node can push the tip forward. A KeepKey-signed epoch only advances if the +device reaches KeepKey infrastructure -- precisely the channel an attacker +suppresses. Decentralised liveness is the entire benefit. + +It also yields real elapsed-time semantics rather than bare ordering: one month +is about 4320 blocks, so `cert.height + 4320 >= accepted_tip` is an expiry +check. + +What it does **not** fix: + +- **Freezing still works.** Withhold new headers and the device is pinned in the + past. Bitcoin does not remove this residual; it makes the honest path work + without us. +- **Integrity-protected monotonic storage is still required** for the accepted + tip. If flash modification can lower it, every expired delegate returns. This + does not escape the anti-rollback requirement of section 5 -- it rides it. +- **Track cumulative work, not height.** Height alone is forgeable via a + low-difficulty fork, and difficulty can legitimately fall 4x per retarget, so + a hardcoded target is soft. Monotonic cumulative work plus firmware-carried + checkpoints is the defensible form. +- Require the referenced block to be buried by N blocks so reorgs are moot. + +### The invariant + +> **Bitcoin does not provide revocation to an offline device. It provides +> vendor-independent positive evidence of elapsed work. The device uses that +> evidence only to REDUCE clear-signing authority -- never to grant authority, +> and never to trigger a destructive state transition.** + +That asymmetry is the point. It turns any parser or work-accounting mistake +from a potential key-management failure into, at worst, a clear-sign +availability failure. + +### Substrate — BLOCKER 2. DESCOPED by D2; analysis retained + +**Decided (D2): `clearsign_freshness` is not being built.** `SecurityRatchets` +reduces to epoch counters, which the anti-rollback OTP mechanism already +provides — no journal, no generation binding, no wear budget, no power-loss +state machine, and none of the four parameters below need an owner. Monthly +rotation over a 256-step unary counter is ~21 years, so the counter suffices +unaltered. + +The analysis below is retained because it is the reason freshness was dropped, +and because it is what must be re-read if anyone proposes reinstating it. **Do +not implement from this section.** + +Do NOT collapse everything into one global integer. The intent is **one** +integrity-protected monotonic state facility -- one implementation, one atomic +update path, one set of anti-rollback guarantees, one audit surface -- holding +domain-separated counters: + + SecurityRatchets { + firmware_epoch; + storage_epoch; + clearsign_epoch; + clearsign_freshness; /* Bitcoin-derived */ + } + +**This is a requirement, not a component that exists, and the mechanism the +anti-rollback RFC actually specifies cannot provide it.** Earlier text here +said the options "are exactly the anti-rollback epoch design, and this work +should not fork from it". The first half is wrong. `anti-rollback-security- +epoch-rfc.md` reserves *one* audited OTP block as a **256-step unary counter**: +epoch `N` is the length of the programmed prefix, and there are 256 advances in +the device's lifetime, total. Four consequences, each of which has to be +answered before any of this is buildable: + +- **A unary OTP counter cannot hold a block height.** `clearsign_freshness` + tracks a Bitcoin tip in the hundreds of thousands. It does not fit in 256 + unary steps and never will. `firmware_epoch` and `clearsign_freshness` are + not the same kind of value and cannot share a representation. +- **Authenticated flash prevents forgery, not restoration.** An attacker who + can rewrite flash and replay an *authenticated older snapshot* of the ratchet + block has rolled the ratchet back without forging anything. Monotonicity needs + an anti-replay binding to something the attacker cannot restore -- OTP state, + or a counter in a region the attacker cannot rewrite -- not merely a MAC. +- **Committing on every `Finish` wears flash.** The protocol below advances + freshness once per accepted proof. At monthly reissuance that is modest; under + a host that submits proofs continuously it is not, and the wear budget has to + be a stated number rather than an assumption. +- **A hostile host can force advances with honest chains.** It does not need to + forge anything: it replays genuine, ever-longer header chains from the real + network to drive one persistent advance per proof. Rate limiting is therefore + not an optimisation (see *What the device validates*, below). + +**Required shape:** OTP-backed coarse generation or checkpoint -- cheap, +irreversible, few lifetime advances -- carrying an authenticated journal in +rewritable storage for the fine-grained values, where the journal is bound to +the current OTP generation so that restoring a journal from a previous +generation is detectable and rejected. + +Four parameters must be decided with that shape, and none is a detail: + +1. **Rollback tolerance.** How far may fine-grained state legitimately regress + within one generation (power loss, torn write) before the device treats it as + an attack? +2. **Checkpoint granularity.** How many block heights per OTP generation? This + sets both the wear budget and the worst-case rollback window. +3. **Wear budget.** Erase cycles per year at the assumed proof rate, against the + part's endurance, with the rate limit that keeps it there. +4. **Power-loss state machine.** Exactly which intermediate states are + reachable, and what each one means at the next boot. + +Until these are answered, "measure the validator's ROM" has no referent: the +journal, the atomic update path and the generation binding are all unmeasured +and all mandatory. + +### Authority — BLOCKER 3. "ROOT SIGNATURE" is a class, not a key + +The separation this design depends on is: + + -> firmware_epoch + -> storage_epoch + -> clearsign_epoch + BITCOIN WORK -> clearsign_freshness (and nothing else, ever) + +Written as "ROOT SIGNATURE -> firmware_epoch, storage_epoch, clearsign_epoch" +this reads as one key with authority over all three, which is precisely the +concentration §3 argues against -- and it would let a compromised clear-sign +root revoke firmware or migrate storage. **Name which root governs which +ratchet, and make the separation cryptographic rather than notational:** + +- **distinct keys or quorums per authority.** Not one key signing + differently-typed messages; different keys. Sharing a key makes the + domain tag the only thing standing between a clear-sign compromise and the + firmware floor. +- **domain-tagged signed transcripts.** Every signed object commits to its + purpose, so a message minted for one authority cannot be reinterpreted as + another. The tag is inside the signed bytes, at a fixed offset, before any + variable-length field. +- **binding to network, model, variant, format version and purpose**, so a + certificate for one deployment cannot be replayed into another. +- **negative tests, as release gates:** cross-protocol replay (a clear-sign + certificate offered as a firmware epoch bump, and the reverse), type confusion + between certificate and proof-session messages, and a delegate certificate + presented where a root object is expected. + +The clear-sign root must never gain authority over firmware or storage epochs. +That is the property the whole domain separation exists to deliver, and it is +structural only once the keys differ. + +With that in place, Bitcoin-derived state can never cause a KDF migration, a +storage rewrite, a seed wipe, a firmware trust change, or a PIN behaviour +change, no matter how far it is pushed or how wrong the validator is -- which +is stronger than "unify the epoch and separate the actions", a rule that relies +on discipline at every call site. + +### What the device actually validates + +The delegate certificate carries its own Bitcoin anchor, so the trusted point +is re-established at every issuance and never goes stale between firmware +releases. **The certificate layout is defined once, in §6 *Canonical +certificate*** -- the fields used here are `btc_anchor_hash`, +`btc_anchor_height`, `expiry_height_delta` and `expiry_min_work`, all covered by +the delegation root's signature. + +The host then supplies 80-byte headers extending the certificate's anchor, and +the device checks only: + +- `prev_hash` links each header to the last; +- `sha256d(header) <= target` encoded by that header's `nBits`; +- the target is within sane bounds; +- header count; +- accumulated work, derived from each header's claimed target. + +**Why this is sound without consensus rules:** work is computed from the +*claimed* target and the hash is verified to *meet* that target, so claimed +work is always backed by demonstrated work. Cheap `nBits` yields trivial +accumulated work and fails `W`; hard `nBits` requires genuinely finding those +hashes. `prev_hash` linkage from a root-signed anchor prevents splicing real +headers from elsewhere in the chain. No retarget validation, no median-time- +past, no version bits, no chain selection: the question is not "is this +Bitcoin's canonical chain" but "does a chain descending from my trusted anchor +contain enough genuine SHA-256 work". + +Expiry requires `height_delta >= 4320` **and** `work >= W`. The AND matters: +height alone is forgeable cheaply, so an OR would let an attacker expire +legitimate delegates for free. + +Bitcoin timestamps are stochastic and consensus-latitudinal. Treat the chain as +a decentralised monotonic freshness source, never as a wall clock. + +### Forged forward progress: bounded to denial of service + +An attacker cannot preserve a stolen delegate by forging progress -- advancing +the tip expires their own credential. But the earlier claim that "forged +headers only help us" was wrong: + +> Forged forward progress cannot extend the attacker's authorization. Its +> security consequence is bounded to **denial of service**, provided +> Bitcoin-derived state has no authority over destructive or key-management +> operations. + +The DoS is real: fabricated far-future freshness is monotonic, so legitimate +certificates anchored near the true network height would read as ancient for +years -- a permanent clear-sign outage. Requiring genuine accumulated work +makes that expensive in proportion to how far the attacker pushes, and a cap on +advance-per-session plus sane target bounds keeps it bounded. + +### Metadata absence is metadata failure + +Downgrade-by-expiry has a companion that is *easier* to exploit, and it is the +one that blocks Phase 3 until closed. If a failed validation lands the user on +the ordinary raw-review path, an attacker does not need to submit an expired +certificate at all -- **they simply omit the metadata**. No certificate, no +descriptor, no validation failure, and the device takes the normal blind path. + +So this cannot be expressed as "if metadata is present, validate it". It +requires a device-enforced signing policy: + + typedef enum { + SIGN_POLICY_DEVICE_PARSED, /* device renders it fully itself */ + SIGN_POLICY_VERIFIED_INTERPRETATION_REQUIRED, /* external attestation mandatory */ + SIGN_POLICY_EXPLICIT_BLIND_SIGNING, /* separately enabled on device */ + } signing_policy_t; + +Under `VERIFIED_INTERPRETATION_REQUIRED`, every one of these is the **same +terminal condition** -- explicit error, no signing confirmation, no signature: + + missing certificate wrong capability + expired certificate tx-binding mismatch + invalid certificate malformed interpretation + +**The invariant:** + +> Raw review is additive, not a fallback. When the active device policy +> requires verified interpretation, missing, malformed, invalid, expired, +> wrongly scoped or transaction-mismatched metadata terminates the signing +> session without producing a signature. A host cannot enter blind signing by +> omitting metadata or by causing validation to fail. Blind signing is +> reachable only through a separately enabled on-device policy and begins a +> new, explicitly unverified signing flow. + +Two distinct protections, and Phase 0 establishes only the first: + +1. successful clear-signing cannot **suppress** the underlying raw review; +2. failed clear-signing cannot **fall through** to an otherwise normal flow. + +The second must be its own testable state-machine invariant. + +No inline "Continue anyway" on the expiry screen. The device returns a failure. +A user determined to blind-sign must leave the flow, enable the policy, and +start again -- otherwise the error becomes one more click-through warning. + +### Four things the policy model must get right + +**1. The policy is device state; the host must never select it.** If the +transaction request can name its own policy, the host simply always names +`EXPLICIT_BLIND_SIGNING` and the entire mechanism evaporates. Policy is +persisted device configuration, changed only through an on-device flow, and +never a field in a signing request. The selection channel is itself a trust +boundary. + +**1b. BLOCKER 7 — and "persisted device configuration" is not yet a safe +place.** The policy is security-critical persistent state, and no integrity +protection has been specified for it. Today's public storage section has **no +authenticated integrity against physical flash modification** -- that is exactly +why RC18 retired the V18 clear-sign identity records. A policy byte stored there +means an attacker with physical access flips one bit and enables the downgrade +policy directly, without touching a certificate, a delegate or a proof. Every +protection in this section then evaporates, and it evaporates *silently*, +because the device believes the user chose it. + +Two acceptable models, and the choice follows the substrate decision (#2): + +- **Authenticated storage.** The policy lives in the integrity-protected + facility, so modification is detected. This is the natural home if that + facility exists; it is another reason #2 comes first. +- **Session-scoped with physical confirmation.** Blind signing is never + persisted at all: it is enabled per session by an on-device confirmation and + cleared at teardown. This needs no authenticated storage, at the cost of + nagging -- and it interacts directly with the stickiness question in item 4 + below, which it would answer by construction. + +Three behaviours must be specified either way, because each is a way for the +protection to end up off without anyone deciding: + +- **after a device reset** the policy returns to the secure default, never to + whatever was there before; +- **after a variant change** (full -> bitcoin-only -> full) the policy is either + preserved authentically or reset to the secure default; it must not be + inherited from uninterpreted bytes; +- **on corrupted or unreadable policy state** the device selects the strict + policy, not the permissive one. Fail closed here for the same reason the + signing path fails closed: an attacker who can corrupt the field must not gain + anything by doing so. + +**2. The policy is per transaction class, not global.** A plain ETH transfer +with empty calldata, or an ERC-20 `transfer` the device decodes with its own +built-in token table, needs no external attestation -- there is nothing to +attest. If `VERIFIED_INTERPRETATION_REQUIRED` blocks those, it blocks ordinary +sends and users will disable it immediately. The device must first classify +what it can render **itself**, and the policy governs only the residue it +cannot. That classification must be derived from the transaction the device is +signing, never from a host-supplied hint. + +**3. Rollout ordering: a policy users are forced to disable is worse than no +policy.** Defaulting to `VERIFIED_INTERPRETATION_REQUIRED` before catalog +coverage is high produces a wave of legitimate transactions that simply fail, +and the support answer becomes "turn on blind signing" -- which users then +leave on forever. Coverage first, default-on second. Shipping the default early +converts a security feature into a permanent opt-out. + +**4. Open question: should the blind-sign policy be sticky?** Permanent +enablement means the first support incident disables the protection for that +user for good, which is how security settings decay. Session-scoped or +time-limited enablement resists decay but nags. Not decided here; decide it +before Phase 3 ships, because retrofitting stickiness changes the threat model. + +### Downgrade-by-expiry must fail closed + +A consequence of "Bitcoin may only reduce authority" that needs stating, +because reducing authority is not automatically safe: if expiring the +clear-sign path causes the device to fall back to a flow that *looks* normal -- +raw hex the user approves out of habit -- then an attacker who can force +expiry has downgraded the user's protection rather than denied service. + +Expiry must fail **closed** and **visibly**: no clear-sign rendering, and a +screen that says the interpretation is unavailable and unverified. It must +never resemble a successful signing flow. Phase 0's annotation-only shape, with +the raw review always retained, is already the correct behaviour here and must +survive into later phases. + +### Two complementary revocation paths + +| | Mechanism | Latency | Requires | +|---|---|---|---| +| Emergency invalidation | root signs `clearsign_epoch >= 48` | immediate **on receipt** | KeepKey alive to issue it | +| Natural | Bitcoin freshness crosses the certificate's expiry threshold | up to the window | nothing but Bitcoin | + +Together they cover both failure modes: a compromise discovered while KeepKey +operates is killed immediately, and a credential outlives KeepKey's existence +only until it ages out. A hostile host can suppress both -- the unavoidable +offline-device boundary from section 5b. + +### Why Bitcoin rather than a root-signed epoch broadcast + +A root-signed "epoch 48" distributed over many channels (API, GitHub, IPFS, +CDN, npm, community mirrors, third-party wallets) is far cheaper than header +validation, and a malicious host suppresses it exactly as easily. So +suppression-resistance is NOT the argument, and ceremony cost barely is -- +monthly delegate reissuance implies a monthly ceremony anyway. + +The argument that survives is **vendor independence**, and the resulting +failure mode is fail-closed: + + KeepKey disappears + v + no new delegate certificates + v + dynamic clear-sign service eventually stops + v + existing delegates nevertheless age out + v + static/device-native signing still works + +Under a broadcast model, freshness is a liveness dependency on KeepKey +continuing to publish: if the company is acquired or goes dark, every +outstanding delegate stays valid forever with no path to expiry. That is the +reason to pay the ROM. + +### Variant scope: none of this ships in bitcoin-only + +Bitcoin-only firmware does not need clear-signing and must not carry any of it. +A Bitcoin transaction is inputs, outputs, addresses and amounts, all of which +the device already renders natively and completely. There is no opaque calldata +to interpret, so there is nothing for a signing authority to attest to. + +This is already the case in the build and is not a change: `signed_metadata.c` +sits inside `if(NOT ${KK_BITCOIN_ONLY})` in `lib/firmware/CMakeLists.txt`, and +the attestor message handlers are behind `#if !BITCOIN_ONLY` in `fsm.c`. + +**The domain-separated ratchet design is what makes this exclusion possible.** +The substrate -- the integrity-protected monotonic store -- ships in both +variants, because `firmware_epoch` and `storage_epoch` apply to bitcoin-only +just as much. Only `clearsign_freshness` and the header validator are +full-variant. Under a single global epoch that Bitcoin work could advance, +bitcoin-only would have to carry header validation just to stay coherent with +an epoch it shares with storage anti-rollback. Domain separation means the +variant simply has no field that Bitcoin has authority over, and therefore no +reason to validate a header. + +Two things follow that are easy to get backwards: + +- **The Bitcoin header validator ships in the multi-chain firmware and NOT in + the bitcoin-only firmware.** Correct, and it reads as backwards; expect to + explain it more than once. +- **The ROM cost concentrates on the tighter variant.** Excluding bitcoin-only + does not split the budget, it loads all of it onto full -- which is the + build that was down to ~572 bytes free before the 34 KB reclaim in fw #339 + and #340. Bitcoin-only has headroom precisely because the coins are stripped, + and none of that headroom helps here. + +**Memory availability is not an inclusion criterion on a trust boundary.** +Bitcoin-only has hundreds of kilobytes free, and that is not a reason to put a +remotely reachable header parser, a proof-session state machine, compact-target +arithmetic, persistent ratchet update paths and extra protocol surface on a +device that has no delegate whose authority needs expiring. It would also +create a second variant to fuzz and audit for no benefit. + + shared across variants: + integrity-protected ratchet substrate + domain-separated ratchet definitions + generic atomic monotonic update machinery + + full / multi-chain only: + delegation certificates + dynamic clear-sign payloads + Bitcoin freshness validator + clearsign_freshness updates + + bitcoin-only retail: + no delegation validator + clearsign_freshness PRESERVED OPAQUELY -- carried, never interpreted, + never advanced, never lowered + + root-attestor special firmware: + may optionally include validator tooling + is NOT the retail bitcoin-only image + +**BLOCKER 1 — "absent or inert" was wrong, and it contradicted the shared +substrate above.** If bitcoin-only firmware drops or zeroes +`clearsign_freshness`, the round trip + + full -> bitcoin-only -> full + +resets freshness to nothing, and **every delegate the device had already aged +out becomes valid again.** Reflashing is a host-initiated operation, so this is +a downgrade an attacker performs, not an accident. It is also internally +inconsistent: the substrate is declared shared across variants, and a shared +substrate whose fields one variant discards is not shared. + +The transition rule has to be explicit, and there are exactly two defensible +forms: + +1. **Opaque preservation (preferred).** Bitcoin-only carries the field as + authenticated bytes inside the same ratchet facility, with no code that can + advance or lower it. It has no validator, so it has no way to interpret the + value -- which is the point: it cannot be tricked into moving something it + cannot read. Costs the field's storage and its integrity binding, nothing + more; specifically it does *not* pull the header validator into the variant. +2. **Full firmware refuses clear-signing when the preserved state is missing.** + If preservation is not implemented, a device returning from bitcoin-only has + no freshness, and full firmware must treat "no freshness state" as + *unexpired-cannot-be-established* -- clear-signing off until a fresh proof + arrives -- rather than as freshness zero, which would accept everything. + +What is NOT acceptable is leaving it unstated, because the default behaviour of +a missing field is option 2's failure mode with option 1's assumption: the +field reads as zero and every certificate looks fresh. + +The root-signing KeepKey does not itself need to validate a header chain: its +ceremony establishes the recent anchor through independent tooling and human +verification. A special attestor image may carry the validator later; that is a +different threat model from putting it on every bitcoin-only customer device. + +Revisit only if bitcoin-only ever wants a capability that needs an external +attestation -- `TRUSTED_NAME` for Bitcoin addresses is the plausible one. That +is not a requirement today and should not be built speculatively; this +paragraph exists so the exclusion stays a decision rather than becoming an +oversight. + +### Validator engineering: budget, protocol, and what gets persisted + +Engineering estimates, not measurements. SHA-256 and the multiprecision +machinery are already linked, so this is not a new crypto library. + +| Component | Likely incremental ROM | +|---|---| +| Header parsing, linkage, bounds | 1-2 KB | +| Compact-target decode + PoW compare | 1-2 KB | +| Exact 256-bit block work + accumulation | 2-5 KB | +| Proof-session FSM, protocol, failures | 1.5-3 KB | +| **Total** | **5-10 KB** | + +Against roughly 35 KB of headroom in the current full build this is plausible, +but it needs an exact map-diff spike against the eventual Phase 3 base before +anyone commits. + +**Hard acceptance criteria, not optimisations** -- this repo has a 16 KiB +reserve gate and a history of boot faults from large static and automatic +buffers: + +- persistent/static validator state: **<= 512 B** +- additional maximum stack frame: **<= 256 B** +- whole-proof buffering: **0 B** + +**BLOCKER 6 — every proof constraint comes from the certificate, never from the +host.** An earlier draft of this protocol had `BitcoinFreshnessBegin` carry the +anchor hash, anchor height and thresholds. Those are exactly the values that +decide whether a proof succeeds; supplying them from the host means the host +picks an anchor it can cheaply extend and thresholds it can trivially meet, and +the validator then correctly verifies a proof that means nothing. The host may +**reference** a certificate the device has already verified, and stream headers. +Nothing else: + + BitcoinFreshnessBegin cert_hash (selects an AUTHENTICATED cert) + BitcoinFreshnessChunk sequence, concatenated 80-byte headers + BitcoinFreshnessFinish expected total header count + + anchor hash, anchor height, expiry_height_delta and expiry_min_work are + read from the referenced certificate's ROOT-SIGNED bytes. If no + authenticated certificate matches cert_hash, the session does not start. + +### BLOCKER 9 — "verified" has to mean two different things + +Saying the host may reference "a certificate the device has already verified" +is circular against §6's acceptance rule, which requires freshness to be +satisfied. A device fresh from the factory, or one returning from bitcoin-only +with no established freshness, has no certificate that passes acceptance -- so +it can never start the proof session that would establish the freshness that +acceptance requires. Nothing bootstraps. + +The deadlock dissolves once **verified** is split, because the two checks +answer different questions: + +| State | Established by | Means | +|---|---|---| +| **authenticated** | exact encoding accepted, `root_signature` verifies against the built-in delegation root, and `format_version` / `domain_tag` / `network_id` / `model_binding` / `variant_binding` all match this device | *this object is genuinely ours and has not been tampered with* | +| **authorized** | authenticated, **plus** `cert_epoch >=` stored minimum, freshness window satisfied, `usage` and `chain_scope` permit this assertion, `can_delegate == 0`, and every transaction binding matches | *this object may be acted on now* | + +- **A freshness proof may reference an AUTHENTICATED certificate.** That is + sound: the values the proof depends on -- anchor, height, delta, work -- are + covered by the root signature, so authentication alone already fixes every + constraint the host might otherwise choose. Requiring authorization here would + be requiring the answer as an input to the question. +- **Warning-free rendering requires an AUTHORIZED certificate.** Nothing may be + signed, and no warning may be suppressed, on the weaker state. + +Note what this does *not* loosen: an authenticated-but-unauthorized certificate +can drive the ratchet forward, and forward is the direction that only ever +*reduces* authority (§5b *The invariant*). It can never grant any. + +**Rate-limit persistent advances.** A hostile host does not have to forge +anything to hammer the ratchet: it replays genuine header chains from the real +network, each longer than the last, and drives one persistent advance per proof. +Either cap advances per session and per unit of device uptime, or require a +minimum checkpoint delta -- the advance must be large enough to be worth a write +-- so that the number of flash commits per year is bounded by design rather than +by host politeness. This is the wear budget from *Substrate* above, enforced. + + for each 80-byte header: + require prev_hash == running_tip + decode and validate nBits + require sha256d(header) <= target + accumulate block work + running_tip = header_hash; count++ + +A chunk of 4-16 headers avoids 4320 USB round trips without materialising the +proof; the chunk buffer is transient and belongs to the existing transport +machinery, not to validator state. On any failure: wipe pending context, return +Failure, **do not alter persistent freshness**. On disconnect, cancel, reboot or +power loss: discard pending, committed freshness unchanged. Only `Finish`, +after both thresholds pass, atomically advances the ratchet -- which also keeps +flash wear and power-loss ambiguity out of the design. + +**Persist height only; work is a witness.** After a proof passes both +thresholds, commit: + + clearsign_freshness_height = + max(clearsign_freshness_height, cert.anchor_height + accepted_headers) + +and persist neither the running tip, nor per-anchor accumulated work, nor +historical anchors. + +### BLOCKER 10 — a global height silently discards the work witness + +> **OUT OF SCOPE (D2).** Bitcoin-derived freshness is not being implemented. +> Expiry is replaced by epoch ordering with monthly rotation; revocation is +> rotating early. Everything from here to the end of the freshness material — +> the proof session, the header validation rules, `BitcoinFreshnessBegin`, the +> anchor and work fields, and blocker 10's accounting — describes a design that +> was evaluated and deliberately not built. Retained as the rationale, and as +> the starting point if freshness is ever reinstated. **Do not implement from +> it.** + + +The paragraph below was written as if height alone carried the proof's cost, +and it does not. Each proof is checked against **its own certificate's** +`expiry_min_work`, but what gets committed is a single global height that then +evaluates *every* certificate's expiry. So: + +> A proof presented under certificate A, whose `expiry_min_work` is low, +> advances the global height far enough to expire certificate B -- whose higher +> work threshold was never demonstrated by anything. + +Old or deliberately weak certificates therefore become downgrade inputs to the +shared freshness oracle: whoever holds the weakest policy ever issued sets the +real cost of advancing everyone's clock. The authorization effect stays +fail-closed -- freshness only ever *reduces* authority -- so this is not a +signing bypass. What it destroys is the cost bound that was supposed to make +the permanent clear-sign DoS in *Forged forward progress* expensive. + +**Not resolved here. Pick one before implementing:** + +1. **A global work policy.** Every height checkpoint must meet one + floor defined by firmware, not by whichever certificate is presented. + Simplest, and it makes per-certificate `expiry_min_work` advisory. +2. **Persist enough work evidence** that a later certificate's threshold can be + evaluated against what was actually demonstrated -- which reintroduces the + per-anchor state this section removed, and needs an answer to the + "work since A versus work since B" incoherence that removal was avoiding. +3. **Constrain who may advance.** Only certificates at or above the current + policy version may move the ratchet, so a superseded weak policy stops being + an input. + +Whichever is chosen, **cap the committed height at the work-qualified +checkpoint** rather than crediting every accepted header: headers beyond the +point where the work threshold was met are free to produce, and crediting them +hands back the cheap advance the threshold exists to prevent. + +The reasoning the original paragraph rested on, kept because it is right as far +as it goes: every proof starts from a +**root-signed** anchor, so work proves the claimed height advance was expensive, +and the resulting monotonic height alone then evaluates *any* certificate's +expiry (`freshness_height >= cert.anchor_height + cert.expiry_height_delta`). +It also sidesteps the incoherent comparison of "work since anchor A" against +"work since anchor B". The integrity-protected field stays a single monotonic +height. + +**Compact-target arithmetic** is the ROM and runtime wildcard: +`floor(2^256 / (target + 1))`. Cache `last_nBits -> last_target -> +last_block_work`; an honest window repeats nBits for long stretches, so the +expensive path runs a handful of times. A malicious host can vary nBits every +header and force it 4320 times -- bounded session-level compute DoS, not an +authorization bypass. + +The decoder must explicitly reject: zero target, negative compact target, +overflowed target, target above `powLimit`, non-canonical compact encoding, +hash/target endian confusion, and work-accumulator overflow. + +**The endian case needs a dedicated golden test** with a real mainnet header: +Bitcoin's serialised hash conventions make it easy to compare byte-reversed +values and build a validator that accepts nearly everything or nearly nothing, +and both failure modes look "working" in a happy-path test. (We already carry +the genesis hash in our own chain identifiers, `bip122:000000000019d6689c...`, +so at least one vector is independently checkable.) + +**ROM fallback if exact work is too expensive:** have the certificate sign +`maximum_permitted_target` and `minimum_header_count`, and require per header +`hash <= header.target <= cert.maximum_permitted_target`. N headers then prove a +conservative minimum of work with no division and no 256-bit accumulator. +Smaller, but it handles hashrate collapse worse: if real difficulty falls below +the permitted floor, expiry stops entirely, whereas exact work merely slows. +Prototype exact work first; keep the target-floor construction as the fallback. + +### Hashrate drift is correctly asymmetric + +| Condition | Effect | +|---|---| +| Hashrate rises | W may arrive early; H prevents premature expiry | +| Hashrate falls | H may arrive before W; expiry delayed | +| Fake low-difficulty chain | H may advance cheaply; W does not | +| Short high-difficulty chain | W may advance; H does not | + +Both unusual network conditions and implementation conservatism can only +*extend* credential life; neither buys early expiry. State the target lifetime +as issuance policy, never as a guarantee: *"approximately one month under the +work and block-production conditions assumed at issuance; severe hashrate loss +extends the validity interval."* + +### Implementation constraints worth pricing early + +- **Streaming, O(1) state.** 4320 headers is ~346 KB. It cannot be buffered on + this device: headers must be validated and folded incrementally, keeping only + the running tip and accumulated work. +- **Anchor recency at issuance.** The root ceremony must anchor to a recent + block, or the validity window opens in the past. +- **Hashrate drift.** With AND-semantics, a sustained hashrate collapse makes + work accumulate slowly and extends credential life. Bitcoin has never seen a + sustained collapse of the magnitude that would matter, but the direction of + the error should be recorded rather than discovered. + +### Freshness must be signed by the root + +If the delegate signs its own freshness proof, a stolen delegate signs one too +and the mechanism is theatre. The proof must come from the offline root, which +collapses the design to its simplest statement: + +> The root reissues the delegate monthly, each certificate naming a Bitcoin +> block. The device accepts a certificate only if that block lies within about +> 4320 blocks of the best tip the device has accepted. Revoking is not +> reissuing. + +No revocation channel is built, because none would work. + +### Threat model, stated for audit + +| Case | Outcome | +|---|---| +| Signing server compromised, victim's host honest | Vault advances the tip; the stolen delegate expires within a month. **Bounded and defended.** | +| Server compromised **and** host hostile | Attacker freezes the tip and keeps using the stale delegate indefinitely. **Not defended.** Blast radius is still one delegate's scope -- forging a *new* delegate needs the root. | +| Device that never connects | Frozen, indefinitely trusting. Unavoidable for any offline device. | + +An on-chain revocation record (OP_RETURN plus an SPV merkle proof) is tempting +and should be skipped: it is a negative statement again, so an attacker simply +does not supply the proof. Positive freshness dominates it at lower ROM cost. + +--- + +## 6. What the device must verify (the new path) + +Today a signer is a bare public key in one of `METADATA_MAX_KEYS` (4) RAM +slots, loaded by `LoadClearsignSigner` under a mandatory on-device confirm, and +**anything it signs shows a warning screen naming the alias**. Only a built-in +key can sign warning-free. + +A delegate is runtime-loaded by nature — it changes monthly. So under today's +rules every delegated clear-sign would warn, which defeats the purpose. The +missing capability is not another key slot; it is **chain validation**: + +``` +built-in root anchor (compiled into firmware, Phase 2) + │ verifies + ▼ +delegation certificate { delegate pubkey, epoch, scope, not-after (advisory) } + │ verifies + ▼ +per-transaction v1 blob { tx_hash, decoded values } +``` + +### Capability scoping, taken from Ledger + +A certificate must not say "this key is trusted". It must say what the key is +allowed to assert. Copy the shape of Ledger's key-usage enum: + + CLEARSIGN_SCHEMA static v2 catalog entries + CLEARSIGN_DYNAMIC per-tx v1 interpretation + TX_RISK risk / simulation verdict only + TOKEN_METADATA symbol + decimals for an address + TRUSTED_NAME address -> name resolution + SWAP_QUOTE / BRIDGE_QUOTE + +The point is containment: **a compromised TOKEN_METADATA signer must never be +able to author a dynamic transaction interpretation.** Generic "metadata +authority" gives an attacker the whole surface from any one key. + +### Canonical certificate — BLOCKER 8. RATIFIED at 169 bytes (D6) + +**Decided (D6): this layout is ratified with the four freshness fields removed.** +They are removed rather than reserved — `format_version` refuses anything not +exactly known, so freshness would be format_version 2 with a longer layout. +Build from this table. + +This document previously described the certificate twice, with different +fields: §5b named `btc_anchor_hash` / `btc_anchor_height` / +`expiry_height_delta` / `expiry_min_work`, while this section named +`bitcoin_not_before` / `bitcoin_not_after`. The acceptance rule then tested +"`epoch` within `[epoch_min, epoch_max]`" against a scalar `epoch` field that +appears in neither list. Three descriptions of one signed object is three +implementations, and on a trust boundary that is a vulnerability rather than an +inconsistency: the verifier and the issuer disagree about which bytes are +covered. + +**A compact fixed encoding, not X.509** (ROM is scarce and an ASN.1 parser on a +trust boundary is a liability). Widths below follow what `signed_metadata.h` +already commits the device to — 33-byte compressed secp256k1 public keys, +64-byte compact ECDSA signatures over `sha256(data)` — so this introduces no +new primitive: + + off len field notes + --- --- ------------------- -------------------------------------------- + 0 2 format_version u16 LE. Refuse anything not exactly known. + 2 24 domain_tag "KKCLEARSIGN-DELEGATE-V1", NUL-padded. Fixed + offset, ahead of every variable field. + 26 4 network_id u32 LE. Deployment binding (mainnet/testnet). + 30 2 model_binding u16 LE. Device model class. + 32 1 variant_binding 1 = full. bitcoin-only holds no delegate. + 33 1 usage CLEARSIGN_DYNAMIC, TOKEN_METADATA, ... + 34 1 can_delegate MUST be 0 for every delegate issued. + 35 1 chain_count 1..CHAIN_SCOPE_MAX (8). 0 is invalid. + 36 32 chain_scope 8 x u32 LE, ASCENDING, no duplicates; entries + past chain_count MUST be zero. Fixed width so + the signed length never varies. + 68 4 cert_epoch u32 LE. Accepted only if >= the device's + stored clearsign_epoch. + 72 33 delegate_pubkey compressed secp256k1. + --------- signed transcript ends at 105 ----------------------------------- + 105 64 root_signature compact ECDSA (r||s, 32+32 BE) over + sha256(bytes 0..104) by the DELEGATION root. + MUST be low-S canonical: s <= n/2, reject + otherwise. Both s and n-s verify, so without + this one certificate has two valid encodings + and therefore two different canonical + hashes -- and cert_hash covers the + signature. + --------- total 169 bytes ---------------------------------------------- + +Three rules the table alone does not carry, and each is a place two +implementations would otherwise diverge: + +- **The signed transcript is exactly bytes 0..104** — every field above the + signature, nothing else, no length prefix, no trailing padding. State it in + bytes rather than as "the certificate", or the issuer and the verifier will + eventually disagree about whether the signature covers itself. +- **The canonical certificate hash is `sha256` over all 169 bytes**, signature + included, and it must differ from the signed transcript hash so the two can + never be confused for one another. Its consumer is the signing log (D1), not + `BitcoinFreshnessBegin` — that message is out of scope with freshness. +- **Reject rather than ignore.** Unknown `format_version`, unknown `usage`, + `chain_count` of 0 or above the maximum, non-ascending or duplicate chain ids, + non-zero padding past `chain_count`, `can_delegate != 0`, a non-canonical + high-S signature, or a length that is not exactly 241 — each is a refusal, + not a field to skip. + +**Status: proposed, not ratified.** This closes the ambiguity that made the +earlier revision unimplementable — the three descriptions of one signed object, +and the acceptance rule testing a field that appeared in none of them — but the +offsets above are a proposal awaiting sign-off, not a decided format. Do not +implement an issuer against it until it is. + +`bitcoin_not_before` / `bitcoin_not_after` are **removed**: they expressed the +same constraint as anchor + delta, in a form that invites treating them as wall +clock. `epoch_min` / `epoch_max` are **removed**: a certificate has one epoch. +A range described nothing the device could check, which is why the acceptance +rule tested a field that did not exist. + +### Two roots, not one — and this is what Phase 1 vs Phase 2 was confusing + +Phase 1 ships "the built-in anchor that verifies the catalog" and Phase 2 says +"pin the root public key". Both are true because they are **different keys**, +and saying "the anchor" for both is what made the phases look contradictory: + +| Root | Signs | Pinned in | May advance | +|---|---|---|---| +| **Schema root** | v2 static catalog entries. Offline, no hot key downstream. | Phase 1 firmware | nothing | +| **Delegation root** | delegate certificates for the per-tx v1 service. | Phase 2 firmware | `clearsign_epoch` | + +They are separate keys with separate ceremonies and separate compromise stories. +A compromised schema root can mis-describe catalogued contracts; a compromised +delegation root can mint delegates for anything. Neither has any authority over +`firmware_epoch` or `storage_epoch` (§5b *Authority*). Phase 1 can therefore ship +its anchor without waiting on the custody decision for the delegation root, +which is the sequencing benefit of splitting them. + +### Transaction binding, taken from Ledger verbatim + +A signature over a report is not enough; the device must independently bind the +report to the operation actually in progress. Ledger's Ethereum app refuses a +Transaction Check report unless it matches, and these checks are **mandatory, +not advisory**: + +- `report.tx_hash == hash of the transaction being signed` +- `report.from == the sender the DEVICE derived` -- device-derived, never + host-claimed, or a report issued for another address can be replayed +- `report.chain_id == the actual chain id` +- for EIP-712, additionally bind the domain hash + +Without these, malware obtains a benign report for transaction A and attaches +it to malicious transaction B. This is the whole reason v1 commits a `tx_hash`. + +### Full acceptance rule + +Device-side acceptance requires all of: + +1. certificate signature verifies against the built-in **delegation root**, and + `format_version`, `domain_tag`, `network_id`, `model_binding` and + `variant_binding` all match this device and this object type; +2. `cert_epoch >=` the device's stored `clearsign_epoch` minimum; +3. Bitcoin window satisfied against the accepted tip — + `clearsign_freshness_height < cert.btc_anchor_height + + cert.expiry_height_delta` — with freshness read as *established*, not + defaulted (§5b *Variant scope*, option 2); +4. `usage` permits this assertion, and `chain_scope` covers this chain; +5. the delegate is not the anchor, and `can_delegate == false` is honoured; +6. payload signature verifies against the delegate key; +7. every binding check above matches the in-progress operation. + +Only a chain terminating at the built-in anchor may render warning-free. A bare +`LoadClearsignSigner` key keeps its warning **forever** — that path is for +developers and self-service, and it should never become the production path. + +--- + +## 7. Phases, with what an auditor should attack + +### Phase 0 — today, and what the next release ships + +Advanced-flag-only, exactly as RC21 is coded: no production signer pinned; +attestor and runtime signer loading usable only under `AdvancedMode`; loaded +identities RAM-only, cleared by session teardown, `ClearSession`, reboot, and +disabling `AdvancedMode`; metadata is **annotation-only**, with the baseline +raw/unverified review retained after the decoded screens. + +Audit targets: that the flag genuinely gates every path; that identities cannot +survive a reboot; that a loaded signer can never suppress the raw review — the +failure that closed fw #322 was a rogue signer **suppressing** the raw-data +screen, and it is the highest-value attack in this phase; OLED truncation on +every attestor confirmation (fw #331 class). + +### Phase 1 — v2 static catalog, no hot key + +Ship the offline-signed schema catalog and the built-in **schema root** that +verifies it (§6 *Two roots, not one*). Covers the head of the distribution with +**zero online key exposure** — no key is reachable at transaction time. + +**Gated on BLOCKER 5 for the schema root.** Zero *online* exposure is not zero +consequence: pinning is irreversible, and a compromised schema root renders +warning-free with no expiry to age it out (§4 *Both roots are irreversible*). +It does not wait on the DELEGATION root's parameters, which is the whole +sequencing benefit of splitting them — but it does wait on its own. + +Audit targets: the schema root's custody and ceremony; device-side decode +correctness against adversarial calldata (the display is only as good as the +decoder); catalog distribution integrity; +that a v2 blob cannot assert values it did not derive; confirm-screen overflow +per value, which is value-dependent — measure, do not read. + +### Phase 2 — built-in production delegation root + +Pin the **delegation** root public key, distinct from Phase 1's schema root. +Warning-free rendering becomes possible for chains terminating at it. + +**Gated on BLOCKER 5.** Pinning is irreversible — a root in shipped firmware +cannot be un-shipped — so the custody programme in §4 must be decided first. + +Audit targets: key ceremony and custody; that phase-1 runtime signers still +warn; that nothing can promote a runtime signer to anchor status; that the +schema root cannot verify a delegate certificate, or the delegation root a +catalog entry (§5b *Authority*, cross-protocol replay). + +### Phase 3 — delegation and the per-tx service + +Certificate chain validation on device, epoch enforcement, the delegation +ceremony, and the online v1 signer holding only a delegate key. + +Audit targets: everything in sections 5, 5b and 6. **Release gates, not +suggestions** — and the first is the single most important test in the +programme: + + verified interpretation required + metadata OMITTED -> no signature + + expired cert -> no signature + invalid cert signature -> no signature + wrong certificate capability -> no signature + delegate attempting re-delegation -> no signature + tx hash mismatch -> no signature + device-derived sender mismatch -> no signature + chain id mismatch -> no signature + EIP-712 domain mismatch -> no signature + + freshness proof on an AUTHENTICATED + but unauthorized cert -> allowed; ratchet may advance (#9) + warning-free render on an + authenticated-only cert -> refused (#9) + weak cert's proof advancing height + past a stronger cert's threshold -> per the #10 decision, not by default + + proof height met, work short -> no ratchet advance + proof work met, height short -> no ratchet advance + bad prev_hash midway -> no ratchet advance + power loss before Finish -> old ratchet retained + replay below committed freshness -> rejected + Bitcoin proof touching storage_epoch-> structurally impossible + +One per §0 blocker, because each closes a path that produces no error today: + + Begin naming its own anchor/thresholds -> rejected; constraints come only + from the verified certificate (#6) + honest chains replayed to force writes -> advances rate-limited (#2, #6) + clearsign cert offered as a firmware + epoch bump, and the reverse -> rejected on domain tag AND key (#3) + schema root verifying a delegate cert -> rejected (#3, #8) + full -> bitcoin-only -> full round trip -> expired delegates STAY expired; + freshness preserved opaquely (#1) + policy byte corrupted in flash -> strict policy selected, not + permissive (#7) + policy after device reset -> secure default, never the previous + value (#7) + ratchet journal from a previous OTP + generation replayed -> rejected (#2) + candidate epoch below floor -> rejected BEFORE the erase, or the + documented recovery-only state (#4) + +Plus explicit mode separation: **metadata validation failure != blind-sign +entry.** That test must assert not only a returned error but that signing state +was cleared and no subsequent Ack can resurrect the original session. + +Plus policy integrity: the host cannot select the policy in a signing request; +the policy survives reboot; the device's own transaction classification cannot +be steered by host-supplied hints. + +Plus epoch rollback via flash modification, scope escape, re-delegation, +delegate reuse across chains, and the behaviour of a device that has not +connected in a year. + +--- + +## 8. Sequencing constraint + +**Phase 3 cannot ship before the anti-rollback security epoch exists.** They +are the same mechanism: a monotonic, integrity-protected minimum that a +clockless device enforces by ordering. Building a second one for clear-sign +would mean two ratchets with two failure modes, and the weaker one sets the +security level. + +That is also why the storage-V19 revert matters here. The epoch is now on the +critical path for **both** PIN-KDF hardening and delegated clear-signing, which +should raise its priority above what a storage-only view would suggest. + +### Updater invariant — BLOCKER 4. The shipping bootloader contradicts it + +The anti-rollback RFC's update state machine requires, in order: verify the +candidate's bounds, hash and full signature policy; decode the OTP floor; +**reject `candidate_epoch < floor` before erasing the installed image**. Its +security invariant is stronger still: + +> A power loss must leave the device able to boot either the previous accepted +> image or the new accepted image. + +**Neither is achievable with the current bootloader.** `handler_erase()` in +`tools/bootloader/usb_flash.c:425-493` erases sectors 7-11 — the entire +application partition — on receipt of `FirmwareErase`, which arrives *before* +any candidate bytes or metadata. At that moment the bootloader has seen no +epoch, no hash and no signature; there is nothing to compare against the floor. +And with one application slot, the window between erase and a completed upload +is a state in which **neither** image is bootable. That is not a regression this +work introduces — it is today's behaviour — but the epoch design cannot be built +on top of it, because "reject before erase" has no erase left to precede. + +Three ways out, and one must be chosen before the epoch ships: + +1. **Staging or dual-slot.** Receive into a second region, verify fully, then + activate. Restores both properties directly. Costs an application-sized + region the part may not have spare, so this needs a flash-map answer before + it is costed. +2. **Signed-digest preflight with streamed verification.** The candidate's + signed metadata — including its epoch and image digest — is transferred and + verified *first*, the floor comparison happens there, and only then does the + erase proceed; the body is streamed and verified against the committed digest + as it lands. Preserves "reject before erase" without a second slot, but does + **not** restore old-or-new bootability across power loss. +3. **Rewrite the invariant** to admit recovery-only interruption states: state + explicitly that a power loss mid-upload leaves the device in a bootloader + recovery mode with no bootable application, and that this is accepted. This + is the honest description of today's device, and it is a legitimate choice — + but it must be written down, because the RFC currently promises otherwise and + an auditor will read the promise. + +Whichever is chosen, the OTP floor must still only advance after the image has +been verified *from flash* (RFC step 6→7), so a torn write can never ratchet the +device past an image it cannot boot. + +--- + +## 9. Open questions — parameters, downstream of §0 + +**These are not the blockers.** The blockers are in §0 and they are structural; +what follows are parameters that only become answerable once §0 is settled. +Reading this list as "what remains" is the misreading §0 exists to prevent. + +- **ROM cost of the streaming header validator**, measured against the + post-#339/#340 headroom in the FULL variant. Bitcoin-only does not carry the + validator (see *Variant scope*), so the whole cost lands on the tighter build. + **Measure last.** The substrate (#2) determines whether there is a journal and + an atomic update path to measure at all; the authority model (#3) determines + how many verification contexts exist; the updater decision (#4) may add a + staging path; the certificate layout (#8) sets the parser. A number produced + before those exist describes a design nobody has chosen. +- Delegate count: one, or several with disjoint scopes (per chain, per + partner)? More delegates means smaller blast radius but more chain + validation and more ROM. Follows the custody decision (#5), which sets how + expensive an issuance ceremony is. +- Does a delegated v1 render truly warning-free, or keep a subtler marker + ("described by KeepKey, 12 Aug") — recording that a third party asserted the + meaning, without the alarm of the self-service path? +- Does v2's reach make v1 rare enough that the per-tx service is + opt-in rather than default? +- Blind-sign policy stickiness (§5b, *Four things the policy model must get + right*, item 4). Note that the session-scoped option under BLOCKER 7 answers + this by construction, so the two should be decided together rather than + separately. + +Resolved out of this list: epoch/tip storage is now BLOCKER 2, not a parameter — +it is not a choice between three media but a facility that does not exist. + +Certificate encoding is **not** settled, and an earlier revision of this line +said it was, contradicting §6's own "proposed, not ratified". What is settled is +the *shape*: a compact fixed layout, never X.509. The byte offsets in §6 await +sign-off. diff --git a/docs/security/clearsign-provider-tier.md b/docs/security/clearsign-provider-tier.md new file mode 100644 index 000000000..e391e300e --- /dev/null +++ b/docs/security/clearsign-provider-tier.md @@ -0,0 +1,189 @@ +# Clear-sign providers: Phase 0 as a shippable tier + +Status: **goals**, for agreement before build. Companion to +`clearsign-key-delegation-roadmap.md`, which defines Phases 0–3. + +The roadmap treats Phase 0 as a developer affordance — "that path is for +developers and self-service, and it should never become the production path". +That sentence is about *warning-free* rendering, which Phase 0 can never deliver. +It is not an argument against shipping Phase 0 as a **product tier that never +claims to be warning-free**. This document states that tier so it can be +accepted or rejected deliberately rather than by omission. + +--- + +## Scope invariant — this release + +Stated as rules rather than a phase number, because the number is an index into a +document: it renumbers, it collides between documents, and a reader cannot check +it. These can be checked against code in under a minute. + +1. **Additive only.** A provider adds screens; it never removes one. + Enforced in firmware — EVM: `signed_metadata_from_loaded_signer()` forces + `needs_confirm` and `data_needs_confirm` back to true, so the raw-calldata + review still runs (`ethereum.c`). Solana: `signed_metadata_signer_is_runtime()` + (`fsm_msg_solana.h`). Grep the names; the line numbers rot. +2. **Never claims KeepKey approval.** A runtime signer renders the provider's own + alias and fingerprint plus "NOT verified by KeepKey". +3. **Opt-in, per session.** `AdvancedMode` is session state and never written to + flash, and `signed_metadata_confirm_load` is a device confirm that cannot be + suppressed. Identities are RAM-only. + +**Anything that suppresses a screen or renders a KeepKey endorsement is a +different release.** Root custody, delegate certificates, quorum and freshness all +sit on that side of the line — see `clearsign-key-delegation-roadmap.md` §0a, +where every one of those decisions is already locked. + +Phase numbers in this repo label history, not scope. If a phase number and this +section ever disagree, this section wins. + +--- + +## The model + +A **clear-sign provider** is a third-party identity that supplies decode context. +It is **not** KeepKey attestation, and KeepKey never tells the user otherwise. + +- **Pioneer is the first provider**, and the reference implementation. +- The Pioneer identity is **unsigned by KeepKey**. The device shows the + provider's own alias and fingerprint; nothing represents it as endorsed. +- Until Phase 2 we **accept** that: no warning-free rendering, no KeepKey claim. +- Context is **purely additive**. The baseline raw/unverified review is retained + after the decoded screens, exactly as firmware already enforces for runtime + signers. + +**Omission of review before the advanced gate is reserved for Phase 2 and +nothing else.** A provider adds screens; it never removes any. + +The Phase 1 / Phase 2 boundary is therefore best named as the **"signed by +KeepKey" gate**. Crossing it is what buys suppression — and it is also the path +by which a provider is eventually promoted: once the KeepKey root signs a +delegate certificate for Pioneer, Pioneer becomes KeepKey-approved and its +context may render without the alarm. Until then it is a named third party and +is displayed as one. + +## What already works, unchanged, on rc29 + +None of the following needs firmware work: + +| capability | mechanism | +|---|---| +| load a provider identity at runtime | `LoadClearsignSigner` (alias, 33-byte pubkey, optional icon) | +| user sees WHO they are trusting | `signed_metadata_confirm_load(alias, fingerprint, icon)` — an on-device confirm | +| provider context is additive only | EVM: `signed_metadata_from_loaded_signer()` forces `needs_confirm` and `data_needs_confirm` back to true, so the raw-calldata review still runs (`ethereum.c`). Solana: `signed_metadata_signer_is_runtime()` (`fsm_msg_solana.h`) | +| a rogue provider cannot hide bytes | runtime signers may never suppress the raw-data review (the failure that closed fw #322) | +| trust dies on its own | identities are RAM-only: cleared by reboot, `ClearSession`, session teardown, or disabling `AdvancedMode` | +| pre-signed additive payloads | EVM v2 blobs; Solana KKSOLSC1 instruction schemas | +| live per-transaction context | `EthereumTxMetadata`, bound to the tx via `signed_metadata_matches_tx` | + +## Two constraints that shape the build + +### 1. Loading is confirmed on device, every session + +`signed_metadata_confirm_load` is not optional and cannot be suppressed — the +firmware comment is explicit that *the whole trust model hangs on this confirm*. +Vault therefore **cannot silently auto-load a provider**, and should not try. + +This is a feature for this tier, not friction: the confirm screen showing +`Pioneer` + fingerprint **is** the moment the user learns the context is +third-party. Because identities are RAM-only, it recurs every reboot, so the +disclosure cannot be shown once and forgotten. + +The honest UX is therefore *"enable AdvancedMode → Vault offers to load Pioneer → +device shows the identity → user confirms"*, once per session — not a silent +background load. + +### 2. Live per-tx context is EVM-only, and Solana does not need it + +`EthereumTxMetadata` is transaction-bound, so a provider can sign *this* +transaction *now*. `SolanaSignTx` accepts only +`schema_payload` / `schema_signature` / `schema_signer_key_id` — instruction +scoped and reusable, with no per-tx field. + +**This is not a Solana gap to close with firmware.** A Solana schema describes +how to *read* an instruction; the device decodes the actual values out of the +bytes it is about to sign, so the display is bound to the signature by +construction. Per-transaction signing would add nothing to decode correctness. + +The real Solana limitation is **address lookup tables**: `solana.c:204` — +"Accounts resolved via lookup tables: unverifiable on-device" — and +`solana_schemaApplies` skips instructions whose accounts are absent from the +signed message (`if (ix->external) continue`). A live signature cannot repair +this, because the device would have to take the host's word for accounts it +cannot see, which is precisely what it refuses to do. + +**The fix is host-side: the provider must inline ALT accounts into the message +before signing.** No firmware change. + +Per-tx Solana attestation is only interesting later, for context that is *not* +derivable from the bytes at all (reputation, recipient labels, fiat values) — +a Phase 3 want, not a blocker here. + +## What actually has to be built + +Nothing in firmware. The work is provider-side and host-side: + +1. **Pioneer signing service** — holds the provider key; pre-signs the schema + catalog, and signs per-transaction EVM metadata live. +2. **Pioneer ALT inlining** — so Solana schemas can apply at all. +3. **Vault provider flow** — offer to load the provider after AdvancedMode is + enabled, surface the device confirm, remember the *user's choice* as a Vault + setting while the *device trust* stays session-scoped, and attach provider + payloads only when a provider is actually loaded. +4. **Replace the CI test key.** `solana-schemas-local.json` currently ships two + schemas signed with the CI test key in slot 3 and says so in its own notes. + Those must be re-signed by the provider key, or removed — shipping + test-signed material to customer devices is how a swap reaches a device that + cannot verify it. + +## What this tier explicitly does not claim + +- No warning-free rendering. The blind-sign review always follows. +- No KeepKey endorsement of the provider. +- No suppression of any screen before the advanced gate. +- No persistence of device-side trust across a reboot. + +A compromised provider key can therefore **mislabel** a transaction, but cannot +**conceal** it: the user still sees the raw review and an explicit +"cannot fully verify" prompt, and the trust expires on its own. That bounded +blast radius is the reason this tier needs no custody programme — and the reason +it must never be quietly upgraded into one. + +## Human-attestation gate (Solana attestor) + +The constrained `KKSOLSC1` attestor is usable only while `AdvancedMode` is +enabled. Loaded signer identities are RAM-only; metadata from a runtime signer +is annotation-only and never suppresses the baseline raw/unverified review. + +Before it signs, the attestor must show every security-relevant declaration: + +1. program and instruction labels; +2. the complete base58 program ID on its own confirmation; +3. the complete discriminator on its own confirmation; +4. every argument's ordinal, ABI type, and label; and +5. every displayed account index and label. + +Program ID and discriminator may not share one notification screen — a +44-character base58 program ID consumes two body rows, and an 8-byte +discriminator cannot reliably fit in the remaining row. Argument types may not +be omitted: two different ordered type declarations can have the same total +width while assigning the same labels to different byte offsets. + +`SolanaSignTx` tags 9, 10, and 11 carry `schema_payload`, `schema_signature`, +and `schema_signer_key_id`. Tags 5 through 8 are reserved for the +transaction-bound `KKSOLSW1` descriptor and one-request opaque-signing +consent — removing that reservation or assigning those tags is a +protocol-review event. Hosts built against the older experimental schema +contract (tags 5, 6, 7) fall back silently to the ordinary unverified review, +since protobuf treats those fields as unknown; that fallback is safe but +operationally silent, so host release notes must state which contract a +reusable schema requires. + +## Open question for the roadmap + +The roadmap already asks (§ *Open parameters*) whether a delegated v1 should +render truly warning-free or keep a subtler marker such as +*"described by KeepKey, 12 Aug"*. The same question applies one phase earlier and +is not asked there: **what marker does a Phase 0 provider carry?** Today it is +the generic blind-sign warning, which does not name the provider on the +signing screen even though the load screen did. diff --git a/docs/security/pin-kdf-v19-migration.md b/docs/security/pin-kdf-v19-migration.md new file mode 100644 index 000000000..245bbb37b --- /dev/null +++ b/docs/security/pin-kdf-v19-migration.md @@ -0,0 +1,78 @@ +# PIN KDF v19 migration + +Status: draft implementation for review and hardware benchmarking + +Baseline: `BitHighlander/keepkey-firmware` `develop` at +`21d6a9d100b16566a1e48899abbbb7bab9366187` + +## Security goal + +Storage v16 reduced the production PBKDF2 work factor used to wrap the storage +key from 100,000 iterations to 10. A flash image therefore leaves a short PIN +with almost no cryptographic work factor if readout protection is bypassed. + +Storage v19 restores the production PIN work factor to 100,000 iterations. The +emulator and debug configurations use 1,000 iterations so the unit suite stays +practical. The change only covers the user PIN wrapping key; wipe-code and +authdata derivation remain on their existing parameters and need separate, +versioned migrations. + +## Compatibility invariant + +Existing wallets must always be unwrapped with the parameters that originally +wrapped them. The firmware must not rewrite a wallet until a correct PIN has +successfully authenticated the decrypted storage key. + +V19 therefore adds an explicit `pin_kdf_v2` storage flag instead of changing +the meaning of the existing v15/v16 flag: + +| Persistent state | KDF used to verify PIN | Action after correct PIN | +| --- | --- | --- | +| `pin_kdf_v2` | v19 | none | +| v16 transition flag only | v16 | rewrap with v19 and set `pin_kdf_v2` | +| neither flag | v15 | rewrap with v19 and set both transition flags | + +An incorrect PIN never changes the wrapped key or migration flags. New PINs +are wrapped directly with the v19 parameters. + +The v19 flag occupies bit 20 of the existing public-storage flags word. The +serialized byte length is unchanged. A v18 reader deliberately ignores this +bit; a v19 reader restores it. + +## Release ordering + +Do not ship this migration in a production release until the downgrade policy +is enforced. Older firmware does not understand storage version 19 or its KDF +flag. Allowing a device to boot an older signed image after migration risks a +wallet lockout, destructive recovery behavior, or accidental reinterpretation +of the storage record. + +The intended order is: + +1. Agree on and implement the anti-rollback security-epoch design in the + bootloader. +2. Prove the bootloader update and interruption behavior on real devices. +3. Benchmark the 100,000-iteration PIN path on supported KeepKey hardware. +4. Exercise v15, v16, and v18 migrations through wrong PIN, correct PIN, + interrupted commit, reboot, and recovery flows. +5. Enable v19 only in a release whose minimum security epoch rejects firmware + that cannot read it. + +## Required evidence + +- Unit tests prove the production v16-to-v19 rewrap path and the v19 selector. +- A negative control that disables rewrapping makes the regression test fail. +- A wrong PIN leaves the wrapped key and all migration flags unchanged. +- V19 round-trips the new flag; the V18 reader ignores it. +- Full emulator unit suites pass from a clean build. +- Hardware timing includes minimum, median, and maximum unlock latency across + supported board revisions and temperature/power conditions. +- Power-loss testing covers every write boundary during the rewrap commit. +- Downgrade attempts after migration fail closed without modifying storage. + +## Non-goals + +This change does not make short PINs equivalent to high-entropy secrets, add a +secure element, or prevent offline guessing after arbitrary flash extraction. +It restores a material software work factor while the hardware architecture +continues to rely on STM32 readout protection and write protection. diff --git a/docs/security/storage-version-downgrade-policy.md b/docs/security/storage-version-downgrade-policy.md new file mode 100644 index 000000000..719593e72 --- /dev/null +++ b/docs/security/storage-version-downgrade-policy.md @@ -0,0 +1,155 @@ +# Storage version and downgrade policy + +Status: current as of the rc28 revert. Read this before touching +`STORAGE_VERSION`, `storage_versions.inc`, or `STORAGE_PIN_KDF_V19`. + +--- + +## 0. The rule that nearly got "fixed" into a vulnerability + +**Wiping storage on downgrade is intentional. It is a security control, not a +bug.** + +`storage_fromFlash` maps any version it does not recognise to +`StorageVersion_NONE`, which `storage_init` answers with `storage_reset()` + +`storage_commit()`. That looks like data loss, and during the RC27 audit it was +proposed to "refuse rather than reset" so the wallet would survive. + +Do not do this. If storage survived a downgrade, an attacker with physical +access could flash an older *validly signed* image carrying a known extraction +bug and keep the seed. The wallet would then only ever be as strong as the +weakest firmware KeepKey has ever signed. Wiping means a downgrade yields an +empty device, and the attack buys nothing. + +The correct way to spare an honest user is to stop the downgrade from +happening, not to make it non-destructive. That is what the security epoch in +`anti-rollback-security-epoch-rfc.md` is for. + +### The one exception, and why it is not a contradiction + +The bitcoin-only band (`StorageVersion_BTC_ONLY`) *does* refuse rather than +wipe when it sees a wallet newer than the running firmware understands. That is +a different axis: it separates bitcoin-only from multi-chain firmware, not new +from old. Rollback protection still holds, because genuinely older firmware +does not know the band exists — it sees an unrecognised version and wipes, as +above. + +--- + +## 1. Where things stand + +| Version | State | +|---|---| +| 17 | **What this firmware reads and writes.** Same as shipped v7.14.1. | +| 18 | Never shipped. Added and reverted inside the 7.15 line. Reader/writer functions still exist but are unreachable — no enum entry. | +| 19 | Never shipped. Same as 18. The PIN-KDF implementation is retained and unit-tested behind `STORAGE_PIN_KDF_V19 == 0`. | + +RC27 wrote version 19. **Installing rc28 on a device that ran RC27 wipes it**, +because rc28 does not recognise version 19 — exactly per §0. That is correct +behaviour and must appear in the rc28 release notes. Internal testers need +their recovery phrase before updating. + +### Why the revert was needed + +Booting RC27 once on an existing wallet silently migrated it 17 → 19 and +re-committed. No prompt, no user action. From that moment the device could not +be downgraded without being wiped, and nothing in the release enforced a +minimum epoch, so the wipe would fire on ordinary users — including anyone who +drops an older signed `.bin` on Vault's firmware drop zone. Shipping a one-way +migration ahead of the mechanism designed to make it safe is the ordering +`pin-kdf-v19-migration.md` explicitly warns against. + +--- + +## 2. The trap that shapes the gate + +The v19 marker is a single flag bit (bit 20 of the public flags word) that only +round-trips in storage version 19. So a v19 *rewrap* and a v19 *write* must be +enabled together, or not at all: + +- Rewrap without the version → the key is wrapped with v19 parameters and read + back as v15/v16 on the next boot. The wallet is not wiped, it is **silently + and permanently unlockable-by-nobody**, with flash otherwise intact. That is + worse than a wipe. +- Version without the rewrap → harmless but pointless. + +This is why `STORAGE_PIN_KDF_V19` gates the rewrap in +`storage_isPinCorrect_impl` and not just the serializer. The invariant to hold +in review: + +> The KDF version selected at unlock must be the one the persisted flag will +> still describe after `storage_commit()`. + +The same trap makes a "read V19 but write V17" bridge impossible: reading a +v19 record and committing it as v17 drops the flag while the wrapped key stays +v19-wrapped. Re-wrapping downward needs the PIN, which is not available at +boot. There is no safe path back down; migrated devices wipe and restore. + +--- + +## 3. Re-enabling version 19 + +All of the following, in order. Flipping `STORAGE_PIN_KDF_V19` to 1 is the +*last* step, not the first. + +1. Implement the anti-rollback security epoch in the bootloader + (`anti-rollback-security-epoch-rfc.md` is a design note; nothing implements + it today). +2. Prove bootloader update and interruption behaviour on real devices. +3. Benchmark the 100,000-iteration PIN path on every supported board revision — + minimum, median, maximum unlock latency, across temperature and power. +4. Exercise v15, v16 and v18 migrations through wrong PIN, correct PIN, + interrupted commit, reboot, and recovery. +5. Power-loss testing at every write boundary during the rewrap commit. +6. Ship in a release whose minimum epoch **refuses** firmware that cannot read + version 19, so a downgrade is rejected up front instead of wiping. + +Only then: add `STORAGE_VERSION_ENTRY(18)` / `STORAGE_VERSION_LAST(19)` to +`storage_versions.inc`, set `STORAGE_VERSION` to 19, restore the version cases +in `storage_fromFlash`, point `storage_commit` at `storage_writeV19`, restore +`flash_temp` to 3480, and set `STORAGE_PIN_KDF_V19` to 1. + +The `_Static_assert(VAL == STORAGE_VERSION)` in `version_from_int` and the +absent `default:` case in the `storage_fromFlash` switch mean the compiler +enumerates every site for you. Trust it over grep — that is how the revert was +done. + +--- + +## 4. Clear-sign identity block + +`ClearsignIdentity` and the 910-byte `clearsign_identities` array are what made +version 18. They are **dead**: nothing reads or writes them, and the header +says so. + +They exist because persisting clear-sign signer identities to public flash was +**rejected** — a rogue persisted signer suppresses the raw-data screen, and +public storage has no authenticated integrity against physical flash +modification. The block was reserved and scrubbed so nothing could outlive a +factory reset. + +**Clear-sign does not need it, and the KeepKey-controlled-key direction needs it +least of all**: a KeepKey-issued schema signature verifies against a built-in +anchor compiled into the firmware, which costs zero device storage. If that is +the chosen endgame, the identity block should be deleted outright rather than +carried to version 18. + +Follow-up not done in the revert: the V18/V19 reader/writer functions and the +identity array are still compiled, just unreachable. Removing them reclaims +~910 bytes of the `ConfigFlash` shadow copy, which matters against the SRAM +budget gates. Kept out of the revert to keep a wallet-critical diff small and +reviewable. + +--- + +## 5. Related audit findings + +- The `storage_write*` family takes a `len` it does not honour — + `storage_writeV17` guards `len < 1024` then writes to offset 2569, and the + V18 variant guarded the same 1024 while writing to 3479. The revert removes + the V18/V19 case; the V17 contract is still wrong and should be fixed + separately. `.cppcheck-suppressions` currently blanket-suppresses + `bufferAccessOutOfBounds` for `storage.c`, so CI cannot see either. +- Vault should warn before flashing firmware older than the connected device's + storage version, whichever way this policy lands. The wipe is correct; a + user meeting it with no warning is not. diff --git a/docs/testing/ATLAS-GUIDE.md b/docs/testing/ATLAS-GUIDE.md new file mode 100644 index 000000000..f2f27391a --- /dev/null +++ b/docs/testing/ATLAS-GUIDE.md @@ -0,0 +1,157 @@ +# The Test Atlas — what it is, how to read it, what it cannot tell you + +The atlas is `SECTIONS` in +`deps/python-keepkey/scripts/generate-test-report.py`. It produces the PDF test +report, and it drives the screenshot filter. Those two facts together are the +single most important thing to understand about it: + +> **A test that is not in SECTIONS is captured by nothing and appears nowhere.** +> Adding a test does not put it in the report. Cataloguing it does. + +--- + +## 1. How to read a report + +The header line is the verdict: + +``` +Firmware 7.15.0 | 2026-08-21 20:12 | 376 tests: 372 passed, 4 skipped, 0 pending +Candidate: alpha@d3bb0055f... +``` + +**The four counts add up to the total, and the report asserts it** before +printing. They once did not: the scope paragraph rebound `skipped` to the +run-wide census, so the catalog's breakdown quoted a skip count from a +different population and nothing reconciled. The total counts DISTINCT tests, +not catalog rows — a few tests are deliberately catalogued twice because they +carry two different arguments (J9 and VG2 are the same refusal), and summing +rows made the header claim more tests than the run contains. + +Read the **candidate** first. A report is evidence about one commit. A green +report for a tree that is not the one you are shipping proves nothing about the +one you are. + +Then read the three counts, which mean different things: + +| word | meaning | is it evidence? | +|---|---|---| +| **passed** | ran, asserted, succeeded | yes | +| **skipped** | did not execute | **no** | +| **pending** | catalogued, no result at all | **no** | +| **withheld** | every test in the section skipped | **no** | + +A skip is never evidence a feature works. The report says so on page 1, and it +says so because an RC audit once grepped the PDF for feature keywords, found +none, and reported four features as untested when their tests had run green in +the same CI run. + +## 2. Section states + +- **Tested** — at least one test produced a real result. +- **Withheld on this build** — every test skipped *by design*, e.g. the + bitcoin-only section on a full-feature emulator. Legitimate, but it means this + report carries no evidence for that section. Get it from the other product's + report. +- **Pending (no firmware support yet)** — nothing ran. Usually the feature is + absent. **Sometimes it is a wiring bug**: the Storage Upgrade Preservation + section rendered "pending" while all eight of its tests were passing, because + `parse_junit()` only recognised three module-name families. A pending section + whose tests you believe exist is a bug in the report, not in the firmware. + +## 3. Version gating + +Each section carries a `min_fw`. A section is active only when +`ver_ge(fw_version, min_fw)`. This is what makes a release cut mechanical: + +| FW_VERSION | active sections | +|---|---| +| 7.14.2 | 18 | +| 7.15.0 | 26 | + +`FW_VERSION` comes from `CMakeLists.txt`, and getting it wrong silently narrows +what is checked. It has happened: CI read `7.14.0` for an entire release, which +excluded every 7.14.1+ section from screenshot capture — the release's own +screens were never looked at by anything. The runner now FAILS rather than +defaulting. + +## 4. What a section must contain + +```python +('F', 'Clear-Sign Provider Context - Additive Invariant', '7.15.0', + 'Background: what this proves and why it matters.', + [ 'the rules, as bullet lines' ], + [ ('F1', 'test_module', 'test_method', + 'short title', + 'what the device must do, and why', + ['screen 1', 'screen 2']) ]) +``` + +The screenshot list drives capture. **An empty list is legitimate and +deliberate** for a refusal path that draws nothing — its evidence is the Failure +on the wire plus the *absence* of a ButtonRequest. Because empty means +something here, an entry that is empty for a *different* reason must say so on +the line: seven entries once declared screens their test cannot draw at all +(the `getaddress` tests answer on the wire; the drawing is the `show_address` +sibling), and the audit that catches this failed on every run until someone +read it. + +Every entry needs a **context** — the sentence saying what it proves. An entry +without one renders as a bare test name, which is exactly the row an auditor +cannot evaluate. `_audit_catalog()` asserts it on every render, along with +unique section letters and unique test ids. + +A module listed in `MUST_RUN_MODULES` turns a skip into a failure from the +named firmware version onward. Use it for a capability the build CLAIMS to +have: taproot tests open with `requires_taproot()`, so a regressed capability +would skip all six and the report would still read green — certifying coverage +it never obtained. + +For a claim about ORDER, add the test to `FULL_SEQUENCE_TESTS`, or the report +shows a best-of-3 frame sample and hides the very thing being proved. + +## 5. Measuring screens without being lied to + +The confirm driver preloads accept/reject pairs; each screen consumes two +packets. + +- `drain() == 0` → exactly N screens shown +- `drain() > 0` → **fewer** screens than budgeted +- `drain() < 0` → **more** than expected (the sentinel was eaten) +- preload one too few → the test **hangs** rather than failing + +Screen counts are value-dependent: `confirm()` paginates a body over +`BODY_ROWS = 3`, and bytes outside `0x21..0x7e` render as 4-glyph `\xNN` +escapes — **space included**. Measure with an over-large preload +(`screens = N - drain/2`); never model it. + +## 6. The traps that have actually cost releases + +1. **A skip that hides a defect.** Three Uniswap liquidity tests were skipped + whenever the variant started with `"Emulator"`. The emulator is the only + thing CI runs, so they had never executed on any branch — and they were + hiding a defect that made Uniswap liquidity unsignable to a third party. +2. **A capability that is implemented but not advertised.** Taproot signing + worked; the firmware never set `supports_taproot`, so six catalogued tests + skipped and the report showed a shipped feature as untested. +3. **A stage-1 CI gate failing and skipping the whole build graph.** Six jobs + reported "skipped" and the run looked like one red job instead of a release + with no evidence behind it. +4. **Lifting a skip incorrectly.** Replacing `self.skipTest(...)` with `pass` + leaves the following `return`, so the body never runs and the test "passes" + vacuously in ~0.1 s. Delete the whole guard. A suspiciously fast pass is the + tell. +5. **Testing the wrong product.** `requires_fullFeature()` skips on + `KeepKeyBTC`/`EmulatorBTC`. Until the firmware reported the variant honestly + it never skipped anything, and multi-chain tests ran against a device with + those chains compiled out. + +## 7. What the atlas still cannot tell you + +- **Whether the screen says the right words.** Frames are compared as bytes. + Tests prove a screen is the SAME screen as a baseline; they do not read it. + Text-level judgement is human review of the captured PNGs — that is what + gate 3 is for. +- **Anything about real hardware.** Every number here is the emulator. +- **Whether a section is complete.** The atlas is a curated catalog: 374 of 879 + collected tests. Absence from the report is not evidence of absence of + coverage — check the JUnit artifacts. diff --git a/fuzzer/firmware/CMakeLists.txt b/fuzzer/firmware/CMakeLists.txt index e38b4f8f5..99591be6a 100644 --- a/fuzzer/firmware/CMakeLists.txt +++ b/fuzzer/firmware/CMakeLists.txt @@ -2,7 +2,7 @@ include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/lib/firmware ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(libraries kkfirmware diff --git a/include/keepkey/board/bsd_compat.h b/include/keepkey/board/bsd_compat.h new file mode 100644 index 000000000..0e2b46495 --- /dev/null +++ b/include/keepkey/board/bsd_compat.h @@ -0,0 +1,28 @@ +#ifndef KEEPKEY_BOARD_BSD_COMPAT_H +#define KEEPKEY_BOARD_BSD_COMPAT_H + +/* + * Declarations for BSD libc extensions that macOS/BSD expose via + * but glibc (Linux) and MinGW (Windows) do not. The emulator build compiles + * lib/board/strlcpy.c + strlcat.c when the libc lacks the definitions + * (KK_HAVE_STRLCPY / KK_HAVE_STRLCAT), so only the prototypes are missing. + * + * Force-included for non-Apple emulator builds (see CMakeLists.txt) so every + * translation unit sees the prototypes without us having to chase down ~20 + * call sites — and without touching the real hardware (ARM) build at all. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +size_t strlcpy(char *dst, const char *src, size_t siz); +size_t strlcat(char *dst, const char *src, size_t siz); + +#ifdef __cplusplus +} +#endif + +#endif /* KEEPKEY_BOARD_BSD_COMPAT_H */ diff --git a/include/keepkey/board/confirm_sm.h b/include/keepkey/board/confirm_sm.h index 63de8312b..a2868f78d 100644 --- a/include/keepkey/board/confirm_sm.h +++ b/include/keepkey/board/confirm_sm.h @@ -25,7 +25,6 @@ #include "keepkey/board/layout.h" #include -#include /* implement a means to display debug information */ #ifdef DEBUG_ON @@ -98,7 +97,13 @@ typedef void (*layout_notification_t)(const char* str1, const char* str2, /// \returns true iff the whole body will be on screen. bool confirm_body_fits(const char* body, uint16_t body_width); -/// Same renderer-backed fit probe at the constant-power draw origin. +/// Same, for constant-power screens, which draw from x = 128 + LEFT_MARGIN. +/// +/// Only KEEPKEY_DISPLAY_WIDTH - (128 + LEFT_MARGIN) px exists past that origin, +/// so a body that fits when measured from the left margin can still be clipped +/// here. Exposed for tests: the seed-backup pages are drawn by this layout, and +/// a page that does not fit loses every character after the first rejected +/// glyph -- including whole later lines. bool confirm_body_fits_constant_power(const char* body, uint16_t body_width); /// Split a constant-power body at the last complete row that fits. @@ -202,17 +207,9 @@ bool review_with_icon(ButtonRequestType type, IconType iconNum, const char* request_title, const char* request_body, ...) __attribute__((format(printf, 4, 5))); -/// Like confirm, but the hold is immediate: a short click confirms. -/// -/// The screen is otherwise a confirmation, and the verdict is real -- a host -/// Cancel (or Initialize) still \returns false, so callers that page a body -/// across several screens can bail out of an intermediate page. Use it for -/// screens that are shown rather than consented to; reserve confirm()'s full -/// hold for the screen that actually approves something. -/// \param type The kind of button request to send to the host. +/// Like confirm, but always \returns true and immediately. /// \param request_title Title of confirm message. /// \param request_body Body of confirm message. -/// \returns true iff the device confirmed. bool review_immediate(ButtonRequestType type, const char* request_title, const char* request_body, ...) __attribute__((format(printf, 3, 4))); diff --git a/include/keepkey/board/draw.h b/include/keepkey/board/draw.h index 516cb3c99..5d1262fa7 100644 --- a/include/keepkey/board/draw.h +++ b/include/keepkey/board/draw.h @@ -61,6 +61,31 @@ void draw_char_simple(Canvas* canvas, const Font* font, char c, uint8_t color, void draw_box(Canvas* canvas, BoxDrawableParams* p); void draw_box_simple(Canvas* canvas, uint8_t color, uint16_t x, uint16_t y, uint16_t width, uint16_t height); +/* + * draw_bitmap_mono_rle_valid() - Validate an RLE stream against a geometry. + * + * Pure and side-effect-free: decodes nothing, writes nothing, touches no + * canvas. Returns true iff the stream is EXACTLY well-formed for a w*h image: + * - every packet count is valid (never 0, never 0x80/-128 — the decoder's + * counter is int8_t and cannot represent a 128 literal), + * - no run straddles the end of the image, + * - exactly w*h pixels are produced, and + * - the whole input is consumed (no trailing packets). + * + * The drawing path is lenient by construction (it stops once the canvas is + * full), so callers that accept host-supplied streams MUST validate here at + * the trust boundary rather than infer validity from a successful draw. + * + * INPUT + * - data: RLE stream + * - length: stream length in bytes + * - w, h: target image geometry + * OUTPUT + * true iff the stream decodes exactly to w*h pixels + */ +bool draw_bitmap_mono_rle_valid(const uint8_t* data, uint32_t length, + uint16_t w, uint16_t h); + bool draw_bitmap_mono_rle(Canvas* canvas, const AnimationFrame* frame, bool erase); diff --git a/include/keepkey/board/font.h b/include/keepkey/board/font.h index 95933f2a5..1fe6eedf4 100644 --- a/include/keepkey/board/font.h +++ b/include/keepkey/board/font.h @@ -20,6 +20,7 @@ #ifndef FONT_H #define FONT_H +#include #include /* Data pertaining to the image of a character */ @@ -53,5 +54,9 @@ uint32_t font_width(const Font* font); uint32_t calc_str_width(const Font* font, const char* str); uint32_t calc_str_line(const Font* font, const char* str, uint16_t line_width); +uint32_t calc_str_line_n(const Font* font, const char* str, size_t str_len, + uint16_t line_width); +size_t calc_str_page(const Font* font, const char* str, size_t str_len, + uint16_t line_width, uint32_t max_lines); #endif diff --git a/include/keepkey/board/keepkey_board.h b/include/keepkey/board/keepkey_board.h index a2e520a48..69ad68802 100644 --- a/include/keepkey/board/keepkey_board.h +++ b/include/keepkey/board/keepkey_board.h @@ -89,6 +89,8 @@ void board_init(void); void kk_board_init(void); void __stack_chk_fail(void) __attribute__((noreturn)); +/// CRC-32/MPEG-2 over \p word_len 32-bit WORDS (not bytes). \p data must be +/// 4-byte aligned: the hardware path casts it to uint32_t*. uint32_t calc_crc32(const void* data, int word_len); void __attribute__((noreturn)) shutdown(void); diff --git a/include/keepkey/board/layout.h b/include/keepkey/board/layout.h index 7f67bd7b8..d65b96cd7 100644 --- a/include/keepkey/board/layout.h +++ b/include/keepkey/board/layout.h @@ -85,6 +85,11 @@ typedef enum { typedef enum { NO_ICON = 0, ETHEREUM_ICON, + VERIFIED_ICON, + /* A runtime-supplied 1bpp mono RLE bitmap (e.g. a loaded clear-sign identity + * logo). The frame is set via layout_set_runtime_icon() before the confirm; + * drawn by layout_add_icon(). */ + RUNTIME_ICON, } IconType; typedef void (*AnimateCallback)(void* data, uint32_t duration, @@ -117,6 +122,12 @@ void layout_constant_power_notification(const char* str1, const char* str2, NotificationType type); void layout_notification_icon(NotificationType type, DrawableParams* sp); void layout_add_icon(IconType type); + +/// \brief Set the frame drawn for RUNTIME_ICON on the next confirm. Pass NULL +/// to clear. The AnimationFrame + its Image must outlive the confirm +/// (typically file-static in the caller). +struct AnimationFrame_; +void layout_set_runtime_icon(const struct AnimationFrame_* frame); void layout_warning(const char* str); void layout_warning_static(const char* str); void layout_simple_message(const char* str); @@ -130,10 +141,17 @@ void animating_progress_handler(const char* desc, int permil); void layoutProgress(const char* desc, int permil); void layoutProgressForAuth(const char* otp, const char* desc, int permil); void layoutProgressSwipe(const char* desc, int permil); +void layoutProgressTrickle(const char* desc, int base_permil, + int target_permil); +void layoutProgressTrickleStop(void); +void layout_animate_poll(void); void layout_add_animation(AnimateCallback callback, void* data, uint32_t duration); void layout_animate_images(void* data, uint32_t duration, uint32_t elapsed); void layout_clear(void); +#if DEBUG_LINK +void layout_debuglink_watermark(void); +#endif void layout_clear_animations(void); void layout_clear_static(void); diff --git a/include/keepkey/board/messages.h b/include/keepkey/board/messages.h index fd0c6e4c3..9b6dbbf97 100644 --- a/include/keepkey/board/messages.h +++ b/include/keepkey/board/messages.h @@ -102,6 +102,12 @@ typedef void (*raw_msg_handler_t)(RawMessage* msg, uint32_t frame_length); const pb_field_t* message_fields(MessageMapType type, MessageType msg_id, MessageMapDirection dir); +/* Shared frame arena (defined in messages.c). Acquiring the arena for TX or + * scratch drops any partially reassembled inbound frame — see the FrameArena + * contract in messages.c. Single-threaded transport only. */ +TrezorFrameBuffer* frame_arena_tx(void); +uint16_t* frame_arena_scratch2049(void); + bool msg_write(MessageType msg_id, const void* msg); #if DEBUG_LINK diff --git a/include/keepkey/board/util.h b/include/keepkey/board/util.h index 5272a41ea..f6fab624a 100644 --- a/include/keepkey/board/util.h +++ b/include/keepkey/board/util.h @@ -54,8 +54,7 @@ void dec64_to_str(uint64_t dec64_val, char* str); bool is_valid_ascii(const uint8_t* data, uint32_t size); -int base_to_precision(uint8_t* dest, const uint8_t* value, - const uint8_t dest_len, const uint8_t value_len, - const uint8_t precision); +int base_to_precision(uint8_t* dest, const uint8_t* value, size_t dest_len, + size_t value_len, uint8_t precision); #endif diff --git a/include/keepkey/emulator/libkkemu.h b/include/keepkey/emulator/libkkemu.h index ec75ff957..6e5b8f336 100644 --- a/include/keepkey/emulator/libkkemu.h +++ b/include/keepkey/emulator/libkkemu.h @@ -3,7 +3,16 @@ * * The host process provides a pre-allocated 1MB flash buffer. * All I/O goes through ring buffers (no UDP sockets). - * Single-threaded: call kkemu_poll() from your event loop. + * + * Two drive modes: + * - Host-driven (default): call kkemu_poll() from your event loop. Purely + * single-threaded — used by the FFI/python test harnesses. + * - Thread-driven: call kkemu_start() once after kkemu_init() and let a + * dedicated dylib thread own the event loop. Required for screen-first + * confirm gating (confirm_helper can block in C without freezing the host + * event loop). The host then never calls kkemu_poll(); it interacts only + * through the lock-free rings (kkemu_write/read/pop_frame) and brackets + * flash snapshots with kkemu_lock()/kkemu_unlock(). */ #ifndef LIBKKEMU_H #define LIBKKEMU_H @@ -77,20 +86,16 @@ int kkemu_read(uint8_t* buf, size_t len, int iface); int kkemu_poll(void); /** - * Get the OLED framebuffer (256x64, 1-bit per pixel = 2048 bytes). + * Snapshot the current OLED framebuffer (256x64, 1-bit, 2048 bytes) into + * internal scratch and return a pointer to it (valid until the next call). * - * This returns a pointer to internal scratch storage containing a snapshot - * of the current display in packed SSD1306 page format. + * WARNING: host-driven mode ONLY. Reads the live canvas with no synchronization + * against the poll thread — do NOT call it once kkemu_start() is running. In + * thread-driven mode use kkemu_pop_frame() (the lock-free SPSC ring) instead. + * Returns NULL if the emulator is not initialized. * * @param width Receives 256. * @param height Receives 64. - * @return Pointer to framebuffer data. The pointer remains valid only until - * the next call to kkemu_get_display(), which overwrites the same - * scratch buffer. Calling kkemu_poll() may update the emulator's - * display state, but it does not refresh previously returned data - * in place; call kkemu_get_display() again after kkemu_poll() to - * obtain an updated framebuffer snapshot. Returns NULL if emulator - * is not initialized. */ const uint8_t* kkemu_get_display(int* width, int* height); @@ -98,14 +103,15 @@ const uint8_t* kkemu_get_display(int* width, int* height); * Pop the next captured framebuffer from the display capture ring. * * Every display_refresh() inside the firmware (including those that fire - * inside confirm_helper's busy loop within a single kkemu_poll() call) - * snapshots the canvas into a ring buffer. Adjacent identical frames - * are deduplicated. This lets the host see intermediate screen states - * (confirm dialogs, cipher prompts, recovery screens) that would - * otherwise be invisible — they exist only inside synchronous C calls. + * inside confirm_helper's busy loop) snapshots the canvas into a lock-free + * SPSC ring. Adjacent identical frames are deduplicated. This is the canonical + * way to observe intermediate screen states (confirm dialogs, cipher prompts, + * recovery screens) — and the only display path that is safe to call while the + * poll thread runs. * * @param out_packed Buffer of at least 2048 bytes (256x64, 1-bit packed - * SSD1306 page format — same as kkemu_get_display). + * SSD1306 page format: byte index = x + (y/8)*256, + * bit within byte = y%8). * @return 1 if a frame was popped, 0 if the ring is empty. */ int kkemu_pop_frame(uint8_t* out_packed); @@ -115,6 +121,44 @@ int kkemu_pop_frame(uint8_t* out_packed); */ int kkemu_is_running(void); +/** + * Start the dedicated poll thread (thread-driven mode). + * + * After this returns 0, a dylib-internal thread owns the firmware event loop + * and the host MUST NOT call kkemu_poll() anymore. Idempotent. Requires + * kkemu_init() to have succeeded. + * + * @return 0 on success (or already started), -1 on error. + */ +int kkemu_start(void); + +/** + * Stop + join the poll thread. Injects a Cancel first so a confirm_helper + * parked waiting for a button decision unblocks and the thread can exit. + * Idempotent; a no-op if the thread was never started. kkemu_shutdown() + * calls this automatically. + */ +void kkemu_stop(void); + +/** + * Bracket a host-side read of the flash buffer (e.g. before encrypting and + * persisting it) so it can't tear a concurrent storage_commit() on the poll + * thread. No-op in host-driven mode. Must be balanced with kkemu_unlock(). + * + * kkemu_lock() BLOCKS and must not be used from a host loop that also has to + * stay alive to deliver a confirm decision — use kkemu_trylock() there. + */ +void kkemu_lock(void); +void kkemu_unlock(void); + +/** + * Non-blocking acquire of the firmware lock. Returns 1 if acquired (balance + * with kkemu_unlock()), 0 if currently held by the poll thread (e.g. during a + * pending confirm) — yield the host event loop and retry. Returns 1 as a no-op + * when the poll thread isn't running. + */ +int kkemu_trylock(void); + #ifdef __cplusplus } #endif diff --git a/include/keepkey/firmware/app_confirm.h b/include/keepkey/firmware/app_confirm.h index 452eb5809..b817a4cc6 100644 --- a/include/keepkey/firmware/app_confirm.h +++ b/include/keepkey/firmware/app_confirm.h @@ -89,6 +89,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/authenticator.h b/include/keepkey/firmware/authenticator.h index ccd285f58..5b7d34200 100644 --- a/include/keepkey/firmware/authenticator.h +++ b/include/keepkey/firmware/authenticator.h @@ -28,6 +28,7 @@ #define ACCOUNT_SIZE 12 // allow 11 chars for account string #define AUTHSECRET_SIZE_MAX \ 20 // 128-bit key len is the recommended minimum, this is room for 160-bit +#define AUTHSECRET_SIZE_MIN 16 // reject brute-forceable TOTP secrets #define AUTHDATA_SIZE \ 10 // WARNING: This value must be coordinated with the size of uint8_t // encrypted_sec[] in in lib/firmware/storage.h and the storage version @@ -43,7 +44,8 @@ enum AUTH_ERR_TYPE { LARGESEED, BADPASS, UNKERR, - CANCELED, + DUPLICATE, + AUTH_CANCELLED, NUM_AUTHERRS }; 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 4d3102c08..b99ba9fa2 100644 --- a/include/keepkey/firmware/coins.def +++ b/include/keepkey/firmware/coins.def @@ -48,7 +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 +#endif // !BITCOIN_ONLY #undef X #undef NO_CONTRACT diff --git a/include/keepkey/firmware/coins.h b/include/keepkey/firmware/coins.h index e7ef132f2..8a3fa2d3f 100644 --- a/include/keepkey/firmware/coins.h +++ b/include/keepkey/firmware/coins.h @@ -44,7 +44,7 @@ enum { CONCAT(CoinIndex, __COUNTER__), #include "keepkey/firmware/coins.def" -#if !BITCOIN_ONLY +#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" diff --git a/include/keepkey/firmware/eip712.h b/include/keepkey/firmware/eip712.h index d7cd993f1..5f3e8c42f 100644 --- a/include/keepkey/firmware/eip712.h +++ b/include/keepkey/firmware/eip712.h @@ -25,9 +25,9 @@ Parser wants to see C strings, not javascript strings: requires all complete json message strings to be enclosed by braces, i.e., { ... } Cannot have entire json string quoted, i.e., "{ ... }" will not - work. Remove all quote escape chars, e.g., {"types": not {\"types\": int - values must be hex. Negative sign indicates negative value, e.g., -5, -8a67 - Note: Do not prefix ints or uints with 0x + work. Remove all quote escape chars, e.g., {"types": not {\"types\": + Integer values must use canonical base-10 digits. Negative values use a + leading minus sign. Do not prefix ints or uints with 0x. All hex and byte strings must be big-endian Byte strings and address should be prefixed by 0x */ @@ -111,4 +111,10 @@ int encode(const json_t* jsonTypes, const json_t* jsonVals, const char* typeS, uint8_t* hashRet); bool eip712_parse_canonical_u32(const char* text, uint32_t* value); +/* Exposed for strict-value regression tests. encAddress is declared above with + the rest of the encoder API; re-declaring it here trips + -Werror=redundant-decls on the ARM build. */ +int encodeBytes(const char* string, uint8_t* encoded); +int encodeBytesN(const char* typeT, const char* string, uint8_t* encoded); + #endif diff --git a/include/keepkey/firmware/ethereum.h b/include/keepkey/firmware/ethereum.h index 013cb99c4..f8a969a3a 100644 --- a/include/keepkey/firmware/ethereum.h +++ b/include/keepkey/firmware/ethereum.h @@ -35,6 +35,7 @@ typedef struct _CoinType CoinType; void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, bool needs_confirm); +bool ethereum_signing_isInProgress(void); void ethereum_signing_abort(void); void ethereum_signing_txack(EthereumTxAck* tx); void format_ethereum_address(const uint8_t* to, char* destination_str, @@ -80,6 +81,9 @@ void ethereum_typed_hash_sign(const EthereumSignTypedHash* msg, EthereumTypedDataSignature* resp); bool ethereum_typed_hash_policy_allows(bool advanced_mode); bool ethereum_structured_eip712_enabled(void); +/* True only for the exact string "EIP712Domain" -- the primaryType whose + signature legitimately carries no message hash. Never a prefix match. */ +bool ethereum_eip712_is_domain_primary_type(const char* primary_type); bool ethereum_path_check(uint32_t address_n_count, const uint32_t* address_n, bool pubkey_export, uint64_t chain); void e712_types_values(Ethereum712TypesValues* msg, diff --git a/include/keepkey/firmware/ethereum_contracts/thortx.h b/include/keepkey/firmware/ethereum_contracts/thortx.h index 2de81e7e3..8f71c7fcc 100644 --- a/include/keepkey/firmware/ethereum_contracts/thortx.h +++ b/include/keepkey/firmware/ethereum_contracts/thortx.h @@ -22,13 +22,39 @@ #include #include -#include #define ETH_ADDRESS \ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ "\x00\x00" +#define ETH_NATIVE \ + "\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee\xee" \ + "\xee\xee" -#define THOR_ROUTER "42a5ed456650a09dc10ebc6361a7480fdd61f27b" +/* THORChain ETH router (mainnet), current v4.1.1. + * NOTE: THORChain migrates this router periodically (v1 42a5ed.. -> v3 + * 3624525.. -> v4 d37bbe..). A hardcoded pin must be updated on each migration; + * the durable path is the signed-metadata clear-sign protocol (host-signed, + * key-pinned) which needs no firmware update per router change. */ +#define THOR_ROUTER "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146" + +/* THORChain deploys its Router at a DIFFERENT address on every EVM chain, so + * the pin must be chain-scoped (see thor_router_for_chain): a deposit on any + * chain but mainnet can never match THOR_ROUTER and would fall to the + * blind-sign gate. Avalanche C-Chain router, verified live against THORChain + * /inbound_addresses via a Pioneer quote (2026-07). Lowercase, no 0x, to match + * thor_format_to_addr's output. Same migration caveat as THOR_ROUTER. + * ponytail: BSC (chainId 56) and Base (8453) routers also exist on-chain but + * are omitted until verified against a live node — the shipped Pioneer catalog + * lists STALE addresses (its AVAX entry 8f66c4ae.. is already wrong vs the live + * 00dc6100..), and Pioneer currently routes BSC/Base swaps via Relay, not a + * THORChain deposit, so no such tx reaches the device today. Add each here once + * verified live. */ +#define THOR_ROUTER_AVAX "00dc6100103bc402d490aee3f9a5560cbd91f1d4" + +/* Maya Protocol ETH router v4 (mainnet), verified on Etherscan + * (0xe3985e6b61b814f7cdb188766562ba71b446b46d). The prior pin + * d89dce57.. has never held contract code on mainnet. */ +#define MAYA_ROUTER "e3985e6b61b814f7cdb188766562ba71b446b46d" /* deposit(address,address,uint256,string) — legacy selector */ #define THOR_SELECTOR_DEPOSIT "\x1f\xec\xe7\xb4" @@ -41,9 +67,8 @@ typedef struct _EthereumSignTx EthereumSignTx; bool thor_has_deposit_selector(const EthereumSignTx* msg); bool thor_is_expiry_variant(const EthereumSignTx* msg); bool thor_isThorchainTx(const EthereumSignTx* msg); -bool thor_assetIsNative(const uint8_t asset_address[20]); +bool thor_isMayachainTx(const EthereumSignTx* msg); bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg); -bool thor_formatUnknownAssetAmount(const uint8_t word[32], char* out, - size_t out_len); +bool thor_confirmMayaTx(uint32_t data_total, const EthereumSignTx* msg); #endif diff --git a/include/keepkey/firmware/ethereum_contracts/zxliquidtx.h b/include/keepkey/firmware/ethereum_contracts/zxliquidtx.h index dc1b91783..1a4c6046d 100644 --- a/include/keepkey/firmware/ethereum_contracts/zxliquidtx.h +++ b/include/keepkey/firmware/ethereum_contracts/zxliquidtx.h @@ -22,8 +22,7 @@ #include #include - -#include "trezor/crypto/bip32.h" +#include #define UNISWAP_ROUTER_ADDRESS \ "\x7a\x25\x0d\x56\x30\xB4\xcF\x53\x97\x39\xdF\x2C\x5d\xAc\xb4\xc6\x59\xF2" \ @@ -32,7 +31,8 @@ typedef struct _EthereumSignTx EthereumSignTx; bool zx_isZxLiquidTx(const EthereumSignTx* msg); -bool zx_confirmZxLiquidTx(uint32_t data_total, const EthereumSignTx* msg, - const HDNode* node); +bool zx_confirmZxLiquidTx(uint32_t data_total, const EthereumSignTx* msg); +bool zx_formatZxLiquidityPrimaryAmount(const EthereumSignTx* msg, char* out, + size_t out_len); #endif diff --git a/include/keepkey/firmware/ethereum_tokens.h b/include/keepkey/firmware/ethereum_tokens.h index 8b8519ae7..fcf7abf65 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,6 +38,7 @@ enum { }; #define TOKENS_COUNT ((int)TokenIndexLast - (int)TokenIndexFirst) +#endif typedef struct _TokenType { const char* const address; diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index 6b3f65c7c..a9b650abd 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -96,6 +96,12 @@ 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_msgClearsignAttestorGetPublicKey( + const ClearsignAttestorGetPublicKey* msg); +void fsm_msgClearsignAttestorSign(const ClearsignAttestorSign* msg); void fsm_msgNanoGetAddress(NanoGetAddress* msg); void fsm_msgNanoSignTx(NanoSignTx* msg); @@ -142,6 +148,22 @@ void fsm_msgSolanaSignTx(const SolanaSignTx* msg); void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg); void fsm_msgSolanaSignOffchainMessage(const SolanaSignOffchainMessage* msg); +#if ZCASH_PRIVACY +void fsm_msgZcashSignPCZT(const ZcashSignPCZT* msg); +void fsm_msgZcashPCZTAction(const ZcashPCZTAction* msg); +void fsm_msgZcashGetOrchardFVK(const ZcashGetOrchardFVK* msg); +void fsm_msgZcashTransparentOutput(const ZcashTransparentOutput* msg); +void fsm_msgZcashTransparentInput(const ZcashTransparentInput* msg); +void fsm_msgZcashDisplayAddress(const ZcashDisplayAddress* msg); +#endif +void fsm_msgHiveGetPublicKey(const HiveGetPublicKey* msg); +void fsm_msgHiveGetPublicKeys(const HiveGetPublicKeys* msg); +void fsm_msgHiveSignTx(const HiveSignTx* msg); +void fsm_msgHiveSignAccountCreate(const HiveSignAccountCreate* msg); +void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg); +void fsm_msgHiveSignMessage(const HiveSignMessage* msg); +void fsm_msgHiveSignOperations(const HiveSignOperations* msg); + #if DEBUG_LINK // void fsm_msgDebugLinkDecision(DebugLinkDecision *msg); void fsm_msgDebugLinkGetState(DebugLinkGetState* msg); @@ -153,4 +175,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..ad98b4731 --- /dev/null +++ b/include/keepkey/firmware/hive.h @@ -0,0 +1,261 @@ +#ifndef KEEPKEY_FIRMWARE_HIVE_H +#define KEEPKEY_FIRMWARE_HIVE_H + +#include "trezor/crypto/bip32.h" +#include "messages-hive.pb.h" + +// ── Hive mainnet chain ID ───────────────────────────────────────────────── +#define HIVE_CHAIN_ID \ + "\xbe\xea\xb0\xde\x00\x00\x00\x00\x00\x00\x00\x00" \ + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" \ + "\x00\x00\x00\x00\x00\x00\x00\x00" + +#define HIVE_CHAIN_ID_LEN 32 + +// ── STM public key prefix (Hive inherited from Steem / Graphene) ────────── +#define HIVE_PUBKEY_PREFIX "STM" + +// ── SLIP-0048 derivation constants (all hardened) ───────────────────────── +// Path: m/48'/13'/role'/account_index'/0' +// 13' is the de-facto Hive network index shipped by Ledger (LedgerHQ/app-hive) +// and hive-ledger-cli, NOT the slip-0048.md registry entry (0xbee = 3054', +// which no wallet implements). Chosen deliberately for seed-level key +// compatibility with the existing hardware-wallet ecosystem. +#define HIVE_SLIP48_PURPOSE (0x80000030u) // 48' +#define HIVE_SLIP48_NETWORK (0x8000000Du) // 13' +#define HIVE_ROLE_OWNER \ + (0x80000000u) // 0' — account recovery, authority changes +#define HIVE_ROLE_ACTIVE (0x80000001u) // 1' — transfers, staking +#define HIVE_ROLE_MEMO (0x80000003u) // 3' — memo field encryption +#define HIVE_ROLE_POSTING (0x80000004u) // 4' — votes, posts, follows + +/** + * Validate a complete Hive SLIP-0048 path: + * m/48'/13'/role'/account'/0'. The role must be one of owner, active, memo, + * or posting; every component is hardened. + */ +bool hive_slip48_path_valid(const uint32_t* address_n, size_t count); + +/** Validate a complete Hive SLIP-0048 path for one required role. */ +bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, + uint32_t required_role); + +// ── Graphene operation type IDs ─────────────────────────────────────────── +#define HIVE_OP_VOTE 0 +#define HIVE_OP_COMMENT 1 +#define HIVE_OP_TRANSFER 2 +#define HIVE_OP_TRANSFER_TO_VESTING 3 +#define HIVE_OP_WITHDRAW_VESTING 4 +#define HIVE_OP_LIMIT_ORDER_CREATE 5 +#define HIVE_OP_LIMIT_ORDER_CANCEL 6 +#define HIVE_OP_CONVERT 8 +#define HIVE_OP_ACCOUNT_CREATE 9 +#define HIVE_OP_ACCOUNT_UPDATE 10 +#define HIVE_OP_CUSTOM_JSON 18 +#define HIVE_OP_COMMENT_OPTIONS 19 +#define HIVE_OP_TRANSFER_TO_SAVINGS 32 +#define HIVE_OP_TRANSFER_FROM_SAVINGS 33 +#define HIVE_OP_CLAIM_REWARD_BALANCE 39 +#define HIVE_OP_DELEGATE_VESTING_SHARES 40 +#define HIVE_OP_ACCOUNT_UPDATE2 43 + +// ── Protocol limits ─────────────────────────────────────────────────────── +#define HIVE_DECIMALS 3 // HIVE and HBD both use 3 decimal places +// Maximum memo length that fits safely in the signer's tx_buf[512] with all +// other fields. Non-memo overhead: header(12) + from(17) + to(17) + asset(16) +// + footer(1) = ~63 bytes. 512 - 63 - 3 (varint) = 446; 440 is conservative. +#define HIVE_MAX_MEMO_LEN 440 +// Maximum signable message length. MUST match HiveSignMessage.message +// max_size in messages-hive.options (proto cap and code cap kept in sync). +#define HIVE_MAX_MESSAGE_LEN 1024 +// Maximum host-serialized transaction length for HiveSignOperations. MUST +// match HiveSignOperations.serialized_tx max_size in messages-hive.options. +#define HIVE_MAX_OPS_TX_LEN 2048 +// Maximum operations per HiveSignOperations transaction. +#define HIVE_MAX_TX_OPS 4 +// Graphene asset: int64 LE amount + uint8 precision + 7-byte NUL-padded +// symbol (append_asset layout). +#define HIVE_ASSET_LEN 16 +// Most assets carried by a single op in the table (claim_reward_balance +// carries three: HIVE, HBD, VESTS). +#define HIVE_MAX_OP_ASSETS 3 +// Most comment_payout_beneficiaries entries accepted on a comment_options op. +// Matches the host serializer's cap; hived itself allows more, but eight is +// all that can be reviewed on the OLED before approval fatigue sets in. +#define HIVE_MAX_BENEFICIARIES 8 +// Maximum custom_json authorization accounts accepted per operation. Every +// account is confirmed individually; bounding the set prevents an unreviewable +// approval loop and keeps the parsed transaction's static RAM use predictable. +#define HIVE_MAX_CUSTOM_JSON_AUTHS 4 + +// Symbol whitelist bits for the asset parser. Every asset field in the op +// table pins an explicit set — an op that accepts HIVE must never silently +// accept VESTS, since the two differ by 1000x in displayed magnitude. +#define HIVE_SYM_HIVE (1u << 0) +#define HIVE_SYM_HBD (1u << 1) +#define HIVE_SYM_VESTS (1u << 2) + +// ── Public API ──────────────────────────────────────────────────────────── +/** + * Encode a 33-byte compressed public key in Hive/Steem STM-prefix base58 + * format. Uses RIPEMD checksum (Graphene convention, not SHA256d). + */ +bool hive_getPublicKey(const uint8_t public_key[33], char* out, size_t out_len); + +/** + * Derive one SLIP-0048 role key for a given account index to raw 33 bytes. + * role_hardened: HIVE_ROLE_OWNER | HIVE_ROLE_ACTIVE | HIVE_ROLE_MEMO | + * HIVE_ROLE_POSTING account_index_hardened: account_index | 0x80000000u Returns + * false if derivation fails. + */ +bool hive_deriveRawKey(const HDNode* root, uint32_t role_hardened, + uint32_t account_index_hardened, uint8_t out[33]); + +/** + * Derive all four SLIP-0048 role keys for a given account index and encode + * each as an STM-prefixed string. All output buffers must be >= 64 bytes. + * Returns false if any derivation or encoding step fails. + */ +bool hive_getPublicKeys(const HDNode* root, uint32_t account_index, + char* owner_out, size_t owner_len, char* active_out, + size_t active_len, char* memo_out, size_t memo_len, + char* posting_out, size_t posting_len); + +/** + * Sign a Hive transfer transaction (op type 2). + * Rejects memos longer than HIVE_MAX_MEMO_LEN (440 bytes). + */ +void hive_signTx(const HDNode* node, const HiveSignTx* msg, HiveSignedTx* resp); + +// ── Parsed operations (HiveSignOperations) ──────────────────────────────── + +typedef struct { + uint32_t op_type; + bool needs_active; // custom_json with required_auths; false = posting tier + // Borrowed slices into the request's serialized_tx (NOT NUL-terminated): + const uint8_t* acct; // vote: voter / comment: author / cj: first auth name + uint16_t acct_len; + const uint8_t* target; // vote: author / comment: title / cj: id + uint16_t target_len; + const uint8_t* detail; // vote: permlink / comment: body / cj: json + uint16_t detail_len; + const uint8_t* parent_author; // comment only + uint16_t parent_author_len; + const uint8_t* + parent_permlink; // comment only (category for a top-level post) + uint16_t parent_permlink_len; + const uint8_t* permlink; // comment only: this post/reply's permlink + uint16_t permlink_len; + const uint8_t* json_metadata; // comment only + uint16_t json_metadata_len; + int16_t weight; // vote (-10000..10000), or a 0..10000 basis-point + // percent (comment_options percent_hbd, + // set_withdraw_vesting_route percent) + bool is_top_level; // comment only: parent_author empty + uint8_t n_auths; // custom_json only: total auth account names + const uint8_t* auth_acct[HIVE_MAX_CUSTOM_JSON_AUTHS]; + uint16_t auth_acct_len[HIVE_MAX_CUSTOM_JSON_AUTHS]; + + // ── Phase-3 op fields ─────────────────────────────────────────────────── + // Borrowed HIVE_ASSET_LEN-byte asset slices in the op's own field order: + // transfer_to_vesting/convert/claim_account/savings: [0] = amount + // withdraw_vesting/delegate_vesting_shares: [0] = vesting_shares + // limit_order_create: [0] = amount_to_sell, [1] = min_to_receive + // claim_reward_balance: [0] = HIVE, [1] = HBD, [2] = VESTS + // comment_options: [0] = max_accepted_payout + const uint8_t* assets[HIVE_MAX_OP_ASSETS]; + uint8_t n_assets; + uint32_t req_id; // convert requestid / savings request_id / order id + uint32_t expiration; // limit_order_create only + bool flag; // fill_or_kill / approve / auto_vest / allow_votes + bool flag2; // comment_options: allow_curation_rewards + uint8_t n_benef; // comment_options: beneficiary count (0 = none) + const uint8_t* benef_acct[HIVE_MAX_BENEFICIARIES]; + uint16_t benef_acct_len[HIVE_MAX_BENEFICIARIES]; + uint16_t benef_weight[HIVE_MAX_BENEFICIARIES]; // basis points +} HiveTxOp; + +typedef struct { + uint8_t num_ops; + bool needs_active; // tx tier: active' path required, else posting' + HiveTxOp ops[HIVE_MAX_TX_OPS]; +} HiveParsedTx; + +/** + * Parse and validate a host-serialized Graphene transaction against the + * device clear-sign op table. Returns NULL on success or a static error + * message. Slices in `out` borrow from `tx` — keep it alive. + * + * Ops 2 (transfer), 9 (account_create) and 10 (account_update) are + * permanently excluded; everything not in the table is refused outright — + * there is no blind-sign fallback. + */ +const char* hive_parseOperations(const uint8_t* tx, size_t len, + HiveParsedTx* out); + +/** + * Accessors for a HIVE_ASSET_LEN-byte asset slice stored in HiveTxOp.assets. + * The parser has already validated the symbol/precision pair, so the symbol + * is always a NUL-terminated "HIVE" / "HBD" / "VESTS" and the amount is + * non-negative. + */ +uint64_t hive_assetAmount(const uint8_t* asset); +uint8_t hive_assetPrecision(const uint8_t* asset); +const char* hive_assetSymbol(const uint8_t* asset); + +/** + * Sign a parsed HiveSignOperations transaction: digest is + * SHA256(chain_id || serialized_tx), identical to HiveSignTx. The caller + * (FSM handler) is responsible for parsing, display, and role checks. + */ +void hive_signOperations(const HDNode* node, const HiveSignOperations* msg, + HiveSignedOperations* resp); + +/** + * Sign an arbitrary message per the Hive Keychain signBuffer contract: + * signature over SHA256(message bytes) only — no chain_id prepend, no + * message prefix. Emits the 65-byte compact recoverable signature plus the + * signing key's 33-byte compressed public key. + */ +void hive_signMessage(const HDNode* node, const HiveSignMessage* msg, + HiveSignedMessage* resp); + +/** + * True iff every byte is printable ASCII (0x20-0x7e). Hive message signing + * requires this: a transaction digest is SHA256(chain_id || serialized_tx) + * whose chain_id and serialized fields are binary, so a printable-only message + * domain can never collide with a transaction preimage on ANY chain id. This + * closes the cross-chain message→transaction signature oracle that a + * mainnet-only prefix reject cannot. Empty (len == 0) returns true. + */ +bool hive_message_is_printable(const uint8_t* message, size_t len); + +/** + * Sign a Hive account_create transaction (op type 9). + * owner/active/posting/memo_raw must be device-derived 33-byte compressed keys. + * The firmware uses these directly; host-supplied key strings in msg are + * ignored. + */ +void hive_signAccountCreate(const HDNode* signing_node, + const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountCreate* resp); + +/** + * Sign a Hive account_update transaction (op type 10). + * owner/active/posting/memo_raw must be device-derived 33-byte compressed keys. + * The firmware uses these directly; host-supplied new_*_key strings in msg are + * ignored. + */ +void hive_signAccountUpdate(const HDNode* signing_node, + const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountUpdate* resp); + +#endif // KEEPKEY_FIRMWARE_HIVE_H diff --git a/include/keepkey/firmware/mayachain.h b/include/keepkey/firmware/mayachain.h index 0d54890da..947979b2b 100644 --- a/include/keepkey/firmware/mayachain.h +++ b/include/keepkey/firmware/mayachain.h @@ -21,6 +21,15 @@ typedef struct _MayachainSignTx MayachainSignTx; typedef struct _MayachainMsgDeposit MayachainMsgDeposit; +// Returns true iff `denom` is a plausible MAYAChain denom: non-empty, +// and contains only lowercase alpha, digits, '.', '/', or '-'. +bool mayachain_isValidDenom(const char* denom); + +// Deposit asset grammar: as above but uppercase alpha also allowed. +bool mayachain_isValidAsset(const char* asset); +// Deposit signer must be bech32 with the active network's HRP. +bool mayachain_isValidSigner(const char* signer); + bool mayachain_signTxInit(const HDNode* _node, const MayachainSignTx* _msg); bool mayachain_signTxUpdateMsgSend(const uint64_t amount, const char* to_address, const char* denom); diff --git a/include/keepkey/firmware/osmosis.h b/include/keepkey/firmware/osmosis.h index 28fa0597a..3600510d2 100644 --- a/include/keepkey/firmware/osmosis.h +++ b/include/keepkey/firmware/osmosis.h @@ -68,6 +68,27 @@ bool osmosis_signTxUpdateMsgSwap(const uint64_t pool_id, const char* token_in_denom, const char* token_out_min_amount); +#define OSMOSIS_PRECISION 6 +#define OSMOSIS_MAX_AMOUNT_DIGITS 32 +#define OSMOSIS_MAX_DENOM_LEN 68 + +// Longest amount a confirm screen renders: the digits, a point, a space and +// the longest denom a message can carry. +#define OSMOSIS_AMOUNT_STR_LEN 103 + +/** + * Render an integer base-unit amount for a confirm screen: + * ("1500000", "uosmo") -> "1.500000 OSMO". + * + * Only uosmo is scaled — any other denom is shown exactly as the chain states + * it, because the device does not know its precision. Returns false unless the + * amount is a canonical, schema-bounded unsigned decimal and the denomination + * is a schema-bounded Cosmos asset identifier. Native uosmo additionally must + * fit uint64, which is the range accepted by the native-asset display policy. + */ +bool osmosis_formatAmount(char* out, size_t out_len, const char* value, + const char* denom); + bool osmosis_signTxFinalize(uint8_t* public_key, uint8_t* signature); bool osmosis_signingIsInited(void); bool osmosis_signingIsFinished(void); diff --git a/include/keepkey/firmware/policy.h b/include/keepkey/firmware/policy.h index 4a7882e70..48e4a8136 100644 --- a/include/keepkey/firmware/policy.h +++ b/include/keepkey/firmware/policy.h @@ -27,6 +27,12 @@ // NOTE: when adding policies, *ONLY* add to the end. Otherwise this breaks // storage_upgradePolicies(); +// +// NOTE: storage flags bit 12 is BURNED. It used to persist AdvancedMode, which +// is now session-scoped (never written, never restored -- see storage.c). Do +// not reuse the bit for a new policy or field: firmware at 7.15 and earlier +// reads it as AdvancedMode, so a device downgraded to one of those builds would +// read the new field as "blind signing enabled". static const PolicyType policies[] = { {true, "ShapeShift", true, false}, {true, "Pin Caching", true, true}, diff --git a/include/keepkey/firmware/reset.h b/include/keepkey/firmware/reset.h index e950d1076..4c713a3f2 100644 --- a/include/keepkey/firmware/reset.h +++ b/include/keepkey/firmware/reset.h @@ -33,6 +33,16 @@ MAX_WORDS*(MAX_WORD_LEN + ADDITIONAL_WORD_PAD) + 1 #define MNEMONIC_BY_SCREEN_BUF WORDS_PER_SCREEN*(MAX_WORD_LEN + 1) + 1 +/* Paginated-mnemonic display scratch, shared between the backup flow here and + * the BIP-85 display flow (fsm_msg_bip85.h) — one ~2.8 KB set instead of two. + * Both flows are modal and single-threaded: each formats and displays inside + * its own handler call. Every user MUST memzero the set at entry AND on every + * exit path. Defined in reset.c (.confidential). */ +extern char mnemonic_scratch_tokened[TOKENED_MNEMONIC_BUF]; +extern char mnemonic_scratch_formatted[MAX_PAGES][FORMATTED_MNEMONIC_BUF]; +extern char mnemonic_scratch_display[FORMATTED_MNEMONIC_BUF]; +extern char mnemonic_scratch_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; + /* ---- setup ceremony ------------------------------------------------- * * ResetDevice and RecoveryDevice are transactions. The settings the host @@ -83,16 +93,15 @@ void setup_arm(SetupKind kind); /// mnemonic, disarms, then commits to flash. void setup_commit(const char* mnemonic, bool imported); -/* \a dice_entropy runs the on-device dice collection, which folds into the - * device half BEFORE the EntropyRequest and entirely before setup_arm(). - * Mutually exclusive with \a display_random: the entropy screen shows the - * post-mix value, which would be the seed pre-image once ext_entropy is - * known, so requesting both is refused. */ -void reset_init(bool display_random, uint32_t _strength, - bool passphrase_protection, bool pin_protection, - const char* language, const char* label, bool _no_backup, - uint32_t _auto_lock_delay_ms, uint32_t _u2f_counter, - bool dice_entropy); +/* No display_random parameter: ResetDevice.display_random remains on the wire + * for host compatibility but is ignored, because internal entropy is seed + * pre-image material and must never be rendered. \a dice_entropy runs the + * on-device dice collection, which folds into the device half BEFORE the + * EntropyRequest and entirely before setup_arm(). */ +void reset_init(uint32_t _strength, bool passphrase_protection, + bool pin_protection, const char* language, const char* label, + bool _no_backup, uint32_t _auto_lock_delay_ms, + uint32_t _u2f_counter, bool dice_entropy); void reset_entropy(const uint8_t* ext_entropy, uint32_t len); uint32_t reset_get_int_entropy(uint8_t* entropy); const char* reset_get_word(void); diff --git a/include/keepkey/firmware/signed_metadata.h b/include/keepkey/firmware/signed_metadata.h new file mode 100644 index 000000000..9c62a8e17 --- /dev/null +++ b/include/keepkey/firmware/signed_metadata.h @@ -0,0 +1,239 @@ +#ifndef KEEPKEY_FIRMWARE_SIGNED_METADATA_H +#define KEEPKEY_FIRMWARE_SIGNED_METADATA_H + +#include +#include +#include + +typedef struct _EthereumSignTx EthereumSignTx; +struct SHA3_CTX; + +#define METADATA_MAX_ARGS 8 +#define METADATA_MAX_METHOD_LEN 64 +#define METADATA_MAX_ARG_NAME_LEN 32 +/* Sized for TOKEN_AMOUNT: decimals(1) + symbol_len(1) + symbol(<=10) + + * amount(<=32). Other formats remain capped at 32 by their own guards. */ +#define METADATA_MAX_ARG_VALUE_LEN 44 +#define METADATA_MAX_TOKEN_SYMBOL_LEN 10 +#define METADATA_MAX_KEYS 4 +#define METADATA_ALIAS_MAX_LEN 31 +/* Identity icon cap (1bpp mono RLE). Must equal the device-protocol + * LoadClearsignSigner.icon max_size and storage.h CLEARSIGN_ICON_MAX + * (static-asserted in signed_metadata.c). */ +#define METADATA_ICON_MAX 384 +/* hex(first 4 bytes of sha256(pubkey)) + NUL */ +#define METADATA_FINGERPRINT_LEN 9 + +typedef enum { + METADATA_OPAQUE = 0, + METADATA_VERIFIED = 1, + METADATA_MALFORMED = 2, +} MetadataClassification; + +/* + * Blob format versions (the first payload byte). + * + * LEGACY (v1): per-transaction. The blob carries a committed tx_hash and the + * pre-decoded argument VALUES; the host is trusted for the decode and the + * device only binds it to the signed digest (signed_metadata_enforce). This is + * the format that requires an online, per-tx signer holding the attestation + * key. + * + * SCHEMA (v2): static. The blob carries NO tx_hash and NO values — only how to + * decode the call: (chainId, contract, selector, method, per-arg name + display + * format [+ static decimals/symbol]). The DEVICE decodes the argument values + * from the exact calldata it is about to sign, so the display is bound to the + * signature by construction. No tx_hash, no per-tx signing: the catalog is + * signed ONCE, offline, and can be served from a host CDN (no hot key). + */ +#define METADATA_VERSION_LEGACY 0x01 +#define METADATA_VERSION_SCHEMA 0x02 + +/* + * Argument display formats. The goal of clear-signing is that the device + * answers WHO the user is dealing with (validated contract address, protocol + * name), WHAT the transaction does (method + human-readable typed args: + * recipient, "Amount: 1,000 USDC"), and WHY the decode can be trusted + * (signer attestation bound to the exact tx hash). RAW/BYTES hex dumps are + * the fallback, not the product. + */ +typedef enum { + ARG_FORMAT_RAW = 0, /* hex dump (first 16 bytes) */ + ARG_FORMAT_ADDRESS = 1, /* 20 bytes -> full EIP-55 address, never truncated */ + ARG_FORMAT_AMOUNT = 2, /* big-endian uint256 -> raw integer, "wei" */ + ARG_FORMAT_BYTES = 3, /* hex dump (first 16 bytes) */ + /* Attested printable label, e.g. protocol: "Uniswap V2". Same character + * rules as the signer alias minus length (printable subset, no '%'). */ + ARG_FORMAT_STRING = 4, + /* decimals(1) + symbol_len(1) + symbol(<=10, [A-Za-z0-9]) + amount(1..32 + * big-endian). Rendered as a decimal-scaled amount with the symbol, e.g. + * "1000 USDC"; all-0xFF 32-byte amounts render "UNLIMITED ". */ + ARG_FORMAT_TOKEN_AMOUNT = 5, +} ArgFormat; + +typedef struct { + char name[METADATA_MAX_ARG_NAME_LEN + 1]; + ArgFormat format; + uint8_t value[METADATA_MAX_ARG_VALUE_LEN]; + uint16_t value_len; +} MetadataArg; + +typedef struct { + uint8_t version; + uint32_t chain_id; + uint8_t contract_address[20]; + uint8_t selector[4]; + uint8_t tx_hash[32]; + char method_name[METADATA_MAX_METHOD_LEN + 1]; + uint8_t num_args; + MetadataArg args[METADATA_MAX_ARGS]; + MetadataClassification classification; + uint32_t timestamp; + uint8_t key_id; + uint8_t signature[64]; + uint8_t recovery; +} SignedMetadata; + +bool signed_metadata_available(void); + +/* True when the stored v2 (schema) metadata was decoded from the current tx's + * calldata by the most recent signed_metadata_matches_tx() call. Reset at the + * top of every matches_tx() so it reflects only that call (never a stale prior + * match). The v2 enforce path requires it; exported for unit testing. */ +bool signed_metadata_schema_decoded(void); + +/* True when the matched schema is v2 AND the transaction moves native value. + * A v2 schema cannot express a value binding, so the caller MUST still show + * the amount/recipient screen; only the raw-calldata screen may be replaced + * by the decoded display. */ +bool signed_metadata_schema_moves_value(void); + +void signed_metadata_clear(void); + +/* Borrow the per-transaction metadata arena as a Keccak context. If a loaded + * signer supplied annotation-only metadata, preserve its final signature + * binding in a compact sidecar and release the rendered metadata first. + * Firmware-pinned metadata that suppresses raw review is never released. + * Ethereum signing blocks new metadata messages until abort/completion. */ +struct SHA3_CTX* signed_metadata_keccak_scratch(void); + +/* + * Runtime-loaded clearsign signers (phase 1: the ONLY verification path). + * + * A signer is a compressed secp256k1 pubkey + display alias loaded into a + * key slot at the host's request, gated by a mandatory on-device confirm + * (see fsm_msgLoadClearsignSigner). Loaded signers live in RAM only and are + * gone on reboot. Metadata verified by a loaded signer always shows a + * warning screen naming the alias before any clearsign page — only the + * built-in (phase 2) keys sign warning-free. + */ + +/* Pure validation: slot in range and not occupied by a built-in key, pubkey a + * valid compressed secp256k1 point, alias non-empty printable ASCII within + * METADATA_ALIAS_MAX_LEN. No state, no I/O. */ +bool signed_metadata_signer_valid(uint8_t key_id, const uint8_t* pubkey, + size_t pubkey_len, const char* alias); + +/* Store a signer into a slot. Caller (the FSM handler) MUST have passed + * signed_metadata_signer_valid() and obtained on-device user confirmation + * first — this function is the post-consent write, nothing more. + * + * icon (optional, icon_len<=384, 1bpp mono RLE) is kept as the session icon for + * the slot; icon_len==0 => text-only identity. RC18 rejects persist=true before + * changing the session slot because public storage lacks authenticated + * integrity. */ +bool signed_metadata_store_signer(uint8_t key_id, const uint8_t* pubkey, + const char* alias, const uint8_t* icon, + uint8_t icon_w, uint8_t icon_h, + uint16_t icon_len, bool persist); + +/* Resolve a slot's alias / icon from the RAM session copy. alias returns NULL + * and icon returns false when the slot has no signer / no icon (text-only). + * Used by the per-tx confirm. */ +const char* signed_metadata_signer_alias(uint8_t key_id); +bool signed_metadata_signer_icon(uint8_t key_id, const uint8_t** icon_out, + uint8_t* w_out, uint8_t* h_out, + uint16_t* len_out); + +/* The LoadClearsignSigner consent screen: leads with the identity's logo (if + * any) + alias + fingerprint. Returns true iff the user confirmed. The FSM + * handler calls this before storing the signer. */ +bool signed_metadata_confirm_load(const char* alias, const char* fingerprint, + const uint8_t* icon, uint8_t icon_w, + uint8_t icon_h, uint16_t icon_len); + +/* Drop all runtime-loaded signers (and any metadata they verified). */ +void signed_metadata_clear_signers(void); + +/* out = hex of the first 4 bytes of sha256(pubkey[33]), NUL-terminated. + * Shown at load-confirm and on the per-tx warning screen so the user can + * correlate the two. */ +void signed_metadata_pubkey_fingerprint(const uint8_t pubkey[33], + char out[METADATA_FINGERPRINT_LEN]); + +/* True when the currently stored metadata was verified by a runtime-loaded + * signer (=> its confirm flow is warning-first, never "Insight Verified"). */ +bool signed_metadata_from_loaded_signer(void); +/* True when key_id currently resolves to a runtime-loaded signer. This lets + * non-EVM callers preserve their normal Advanced-mode review after showing an + * additive schema decode. */ +bool signed_metadata_signer_is_runtime(uint8_t key_id); +MetadataClassification signed_metadata_process(const uint8_t* payload, + size_t payload_len, + uint8_t key_id); + +/* Generic attestation check reusing the (chain-agnostic) clear-sign signer + * keyring: returns true iff a signer is loaded/pinned for `key_id` AND the + * 64-byte compact ECDSA signature `sig` verifies over sha256(data). Used by + * non-EVM paths (e.g. Solana signed token definitions) that want to trust + * host-supplied data only when a loaded signer attests to it. */ +bool signed_metadata_verify_attestation(uint8_t key_id, const uint8_t* data, + size_t data_len, const uint8_t* sig, + size_t sig_len); + +/* Fingerprint (hex of sha256(pubkey)[0:4]) of the signer loaded/pinned in + * `key_id`, written NUL-terminated to `out`. Returns false if no signer is + * present. Lets non-EVM callers disambiguate signers (aliases are not unique) + * the same way the EVM per-tx warning does. */ +bool signed_metadata_signer_fingerprint(uint8_t key_id, + char out[METADATA_FINGERPRINT_LEN]); +/* Display gate: does this metadata plausibly describe `msg`? Binds contract + * address, selector and chain id so the wrong method is never shown. The + * authoritative full-tx binding is enforced later by signed_metadata_enforce(). + */ +bool signed_metadata_matches_tx(const EthereumSignTx* msg); +bool signed_metadata_confirm(void); + +/* True once a verified confirm has suppressed the raw-data confirmation, i.e. + * the signature is now gated on the metadata matching the final tx hash. */ +bool signed_metadata_relied(void); + +/* Authoritative binding, called after the real Ethereum sighash is finalized + * (in send_signature, the only point it exists). Returns true if signing may + * proceed: either no metadata was relied upon, or the relied-upon metadata's + * committed tx_hash equals `hash`. Fail-closed on any mismatch. */ +bool signed_metadata_enforce(const uint8_t hash[32]); + +/* Pure enforcement decision, exported for unit testing. Given the module flags + * and the metadata's committed tx hash, decides whether signing may proceed for + * the just-finalized `hash`. signed_metadata_enforce() is a thin wrapper that + * feeds the module state into this function. No state, no I/O. */ +bool signed_metadata_enforce_decision(bool relied, bool available, + int classification, + const uint8_t* stored_hash, + const uint8_t* hash); + +/* Pure enforcement decision for v2 (static schema) blobs, exported for unit + * testing. v2 has no committed tx_hash; the binding is structural (args decoded + * from the signed calldata), so signing proceeds when the relied-upon metadata + * is available, VERIFIED, and was actually decoded (`decoded`) — no digest + * comparison. `decoded` must be the recorded result of decode_v2_args() for + * this signing operation, not inferred from call order. + * signed_metadata_enforce() dispatches here when the stored blob's version is + * METADATA_VERSION_SCHEMA. */ +bool signed_metadata_enforce_schema_decision(bool relied, bool available, + bool decoded, int classification); + +const SignedMetadata* signed_metadata_get(void); + +#endif diff --git a/include/keepkey/firmware/signing.h b/include/keepkey/firmware/signing.h index 2a4433887..d132cf250 100644 --- a/include/keepkey/firmware/signing.h +++ b/include/keepkey/firmware/signing.h @@ -34,15 +34,9 @@ bool isCrossAccountSegwitChangeForbidden(const uint32_t* lhs_address_n, size_t rhs_address_n_count, OutputScriptType rhs_script_type); -/// Pure helpers exposed so native tests bind ABI-sensitive/security checks. -bool signing_output_multisig_quorum_is_valid(const TxOutputType* txoutput); -void signing_checksum_script_type_bytes(InputScriptType script_type, - uint8_t out[4]); - -#if DEBUG_LINK -void signing_test_seed_state(void); -bool signing_test_state_is_cleared(void); -#endif +/// Encode the protobuf enum in the fixed four-byte little-endian form used by +/// the Bitcoin transaction-consistency checksum on every target ABI. +void signing_encode_script_type(InputScriptType script_type, uint8_t out[4]); void signing_init(const SignTx* msg, const CoinType* _coin, const HDNode* _root); diff --git a/include/keepkey/firmware/solana.h b/include/keepkey/firmware/solana.h index e7d8d6628..d31e27e9f 100644 --- a/include/keepkey/firmware/solana.h +++ b/include/keepkey/firmware/solana.h @@ -31,6 +31,11 @@ #define SOL_PUBKEY_SIZE 32 #define SOL_SIG_SIZE 64 #define SOL_MAX_ACCOUNTS 32 +/* KKSOLSW1: how many lookup-table-resolved accounts a provider may attest for + one transaction. Bounded because the preimage and the screens are both + linear in it, and because a provider that needs to name more than eight + accounts is describing something the user cannot meaningfully review. */ +#define SOL_MAX_LUT_ACCOUNTS 8 #define SOL_MAX_INSTRUCTIONS 8 #define SOL_LAMPORTS_DIVISOR 1000000000ULL #define SOL_MAX_TOKEN_DECIMALS 18 @@ -156,10 +161,18 @@ typedef struct { uint8_t mint[SOL_PUBKEY_SIZE]; bool has_mint; uint8_t extra_u8; - /* Exact instruction bytes retained for variable-length verified fields - * such as Memo. The parser bounds this slice inside the signed message. */ + /* 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; - size_t data_len; + uint16_t data_len; + /* Account index list, same lifetime as `data`. Needed to resolve a + * KKSOLSC1 schema's labelled accounts back to real pubkeys. */ + const uint8_t* acct_indices; + uint8_t num_acct_indices; + /* True when this instruction reaches into an address-lookup table, so its + * accounts are NOT present in the signed message. A schema must never be + * applied to one: the pubkeys it would display are unknowable on-device. */ + bool external; } SolanaParsedInstruction; /* Parsed transaction header */ @@ -181,6 +194,98 @@ typedef enum { SOL_TX_REVIEW_VERIFIED, } SolanaTxReview; +/* Firmware-owned token definitions. These are intentionally tiny and only + * cover identities whose mint and decimals are stable enough to be part of + * the device's trusted display policy. */ +typedef struct { + uint8_t mint[SOL_PUBKEY_SIZE]; + const char* symbol; + uint8_t decimals; +} SolanaKnownToken; + +/* ── KKSOLSC1: reusable instruction schemas ─────────────────────────── + * + * A schema says how to READ one program instruction — it carries no amounts + * and no transaction hash. A trusted clearsign signer attests it ONCE per + * (program, discriminator); every later transaction reuses the same blob and + * the device decodes the values straight out of the bytes it is signing. + * + * Safety rests on structural completeness, not on binding to a transaction: + * - discriminator + the declared arg widths must equal the instruction + * data length EXACTLY, so no unaccounted byte can carry a second effect; + * - every account index the schema displays must exist in the instruction; + * - the instruction must not reach into a lookup table (see `external`); + * - and every OTHER instruction in the transaction must be one firmware + * already recognises, so a schema can never green-light a message whose + * real effect sits in an instruction nobody described. + * + * Canonical payload (all integers big-endian, text printable ASCII, no '%'): + * magic 8 "KKSOLSC1" + * version 1 = 1 + * program_id 32 + * disc_len 1 1..8 + * discriminator disc_len + * program name 1 + 1..SOL_SCHEMA_NAME_MAX + * instr name 1 + 1..SOL_SCHEMA_NAME_MAX + * n_args 1 0..SOL_SCHEMA_MAX_ARGS + * per arg: type(1) label_len(1) label + * n_accounts 1 0..SOL_SCHEMA_MAX_ACCOUNTS + * per account: index(1) label_len(1) label + * No bytes may follow. Args are laid out sequentially from the end of the + * discriminator, in declaration order. + */ +#define SOL_SCHEMA_NAME_MAX 20 +#define SOL_SCHEMA_LABEL_MAX 16 +#define SOL_SCHEMA_MAX_ARGS 4 +#define SOL_SCHEMA_MAX_ACCOUNTS 4 +#define SOL_SCHEMA_DISC_MAX 8 + +typedef enum { + SOL_SCHEMA_ARG_U64 = 1, /* 8 bytes, shown as a decimal integer */ + SOL_SCHEMA_ARG_U8 = 2, /* 1 byte */ + SOL_SCHEMA_ARG_PUBKEY = 3, /* 32 bytes, shown base58 */ + SOL_SCHEMA_ARG_OPAQUE32 = 4, /* 32 bytes, shown in full over pages */ +} SolanaSchemaArgType; + +typedef struct { + SolanaSchemaArgType type; + char label[SOL_SCHEMA_LABEL_MAX + 1]; +} SolanaSchemaArg; + +typedef struct { + uint8_t index; + char label[SOL_SCHEMA_LABEL_MAX + 1]; +} SolanaSchemaAccount; + +typedef struct { + uint8_t program_id[SOL_PUBKEY_SIZE]; + uint8_t disc[SOL_SCHEMA_DISC_MAX]; + uint8_t disc_len; + char program_name[SOL_SCHEMA_NAME_MAX + 1]; + char instruction_name[SOL_SCHEMA_NAME_MAX + 1]; + SolanaSchemaArg args[SOL_SCHEMA_MAX_ARGS]; + uint8_t num_args; + SolanaSchemaAccount accounts[SOL_SCHEMA_MAX_ACCOUNTS]; + uint8_t num_accounts; +} SolanaInstrSchema; + +/* Parse a KKSOLSC1 payload. Validates every length and text field and + * requires the payload to be consumed exactly. */ +/* Byte width one schema arg consumes in the instruction data. 0 = unknown + * type, which the parser rejects. */ +uint16_t solana_schemaArgWidth(SolanaSchemaArgType t); + +bool solana_parseInstrSchema(const uint8_t* payload, size_t payload_len, + SolanaInstrSchema* out); + +/* Find the instruction this schema describes and prove it may be trusted: + * program id + discriminator match, the schema accounts for the instruction + * data exactly, its account indices are in range, the instruction is not + * lookup-table backed, and every other instruction in `tx` is a program + * firmware already decodes. Returns the matching index via `out_index`. */ +bool solana_schemaApplies(const SolanaInstrSchema* schema, + const SolanaParsedTx* tx, uint8_t* out_index); + /* Inspect a raw Solana transaction and classify it for signing UX */ SolanaTxReview solana_inspectTx(const uint8_t* raw, size_t raw_len, SolanaParsedTx* tx); @@ -191,15 +296,73 @@ bool solana_parseTx(const uint8_t* raw, size_t raw_len, SolanaParsedTx* tx); /* Format SOL amount */ void solana_formatAmount(char* buf, size_t len, uint64_t lamports); -/* Maximum priority fee in lamports. Uses the 1.4M-CU protocol cap when no - * explicit limit is present. Returns false for duplicates or overflow. */ -bool solana_calculatePriorityFee(const SolanaParsedTx* tx, uint64_t* fee_out, - bool* has_fee); - /* Format token amount with decimals */ void solana_formatTokenAmount(char* buf, size_t len, uint64_t amount, const char* symbol, uint8_t decimals); +/* Extract and safely calculate the transaction's compute-budget priority fee. + */ +bool solana_calculatePriorityFee(const SolanaParsedTx* tx, uint64_t* fee_out, + bool* has_fee); + +/* Look up a firmware-owned token identity by its signed mint account. */ +const SolanaKnownToken* solana_findKnownToken( + const uint8_t mint[SOL_PUBKEY_SIZE]); + +/* Derive the canonical SPL associated token account for + * (owner, token_program, mint), using Solana's find_program_address rules. */ +bool solana_deriveAssociatedTokenAddress( + const uint8_t owner[SOL_PUBKEY_SIZE], + const uint8_t token_program[SOL_PUBKEY_SIZE], + const uint8_t mint[SOL_PUBKEY_SIZE], uint8_t out[SOL_PUBKEY_SIZE]); + +/* Match a host-provided candidate owner only after deriving its ATA and + * comparing it to the destination that is present in the signed instruction. + * Returns the verified owner through out, or false without modifying out. */ +bool solana_findTokenRecipientOwner( + const SolanaSignTx* msg, const uint8_t token_program[SOL_PUBKEY_SIZE], + const uint8_t mint[SOL_PUBKEY_SIZE], + const uint8_t destination[SOL_PUBKEY_SIZE], uint8_t out[SOL_PUBKEY_SIZE]); + +/* Look up token info from the host-provided list */ +const SolanaTokenInfo* solana_findTokenInfo( + const SolanaSignTx* msg, const uint8_t mint[SOL_PUBKEY_SIZE]); + +/* True iff `ti` carries a valid attestation: an ECDSA signature (by a clearsign + * signer the user loaded) over a domain-separated (mint, decimals, symbol) + * digest. Range-checks signer_key_id before narrowing it. Verifies only the + * attested tuple — the caller must additionally confirm the attested decimals + * match the signed instruction before trusting the amount. */ +bool solana_token_info_trusted(const SolanaTokenInfo* ti); + +/* KKSOLSW1: is the host-supplied lookup-table account list attested by a + * clear-sign signer FOR THIS EXACT TRANSACTION? + * + * A v0 message may source instruction accounts from an Address Lookup Table. + * Those bytes are not in the message being signed, so the device cannot derive + * them and forces the whole transaction opaque -- refused without AdvancedMode, + * an explicit blind sign with it. A provider may instead attest the resolved + * list, turning that blind sign into a clear sign. + * + * Preimage, domain-tagged so a signature made for any other purpose cannot be + * replayed as one, and bound to the message so it cannot be replayed onto a + * different transaction: + * + * "KeepKeySolanaTxAccounts/1" || sha256(raw_tx) || count(le32) || key[i](32) + * + * Returns false unless a signer is loaded for `key_id` and the signature + * verifies. Annotation only: the caller still runs the unverified review. */ +bool solana_lut_accounts_trusted(const uint8_t* raw_tx, size_t raw_len, + const uint8_t (*accounts)[32], + size_t num_accounts, uint32_t signer_key_id, + const uint8_t* sig, size_t sig_len); + +/* ceil(price * limit / 1,000,000) priority-fee lamports, overflow-safe. Returns + * false (and leaves *out untouched) if the true value exceeds UINT64_MAX — the + * caller must then refuse to sign rather than display a wrapped figure. */ +bool solana_priority_fee_lamports(uint64_t price, uint64_t limit, + uint64_t* out); + /* Sign transaction */ bool solana_signTx(const HDNode* node, const SolanaSignTx* msg, SolanaSignedTx* resp); diff --git a/include/keepkey/firmware/storage.h b/include/keepkey/firmware/storage.h index cc3e0f096..c5fc45e06 100644 --- a/include/keepkey/firmware/storage.h +++ b/include/keepkey/firmware/storage.h @@ -28,13 +28,22 @@ #define STORAGE_VERSION \ 17 /* Must add case fallthrough in storage_fromFlash after increment*/ -/* The highest storage version that has actually SHIPPED to users. A signed - * upgrade must never wipe, and the way that breaks is a release whose - * STORAGE_VERSION sits BELOW a version already in the field: every such device - * then reads its blob as an unknown future format and resets. Lowering this - * number is the exact edit that turns every upgrade in the field into a silent - * wipe, so it must be an explicit, reviewed act rather than a side effect. - * v7.14.1 shipped storage V17. */ +/* The highest storage version written by any firmware that has SHIPPED in a + * signed release. v7.14.1 shipped storage V17. + * + * A signed UPGRADE MUST NEVER WIPE. An upgrading device arrives carrying a blob + * written by the release it is leaving; if the incoming firmware does not + * recognise that version, version_from_int() returns StorageVersion_NONE, + * storage_fromFlash() returns SUS_Invalid, and storage_init() calls + * storage_reset() + storage_commit() -- the wallet is gone with no prompt. A + * DOWNGRADE hitting that path is intended and normal: older firmware cannot be + * expected to read a newer blob. + * + * So STORAGE_VERSION may only ever go UP. Bump this baseline when a release + * ships, in the release commit, never to make a build compile: lowering it is + * the exact edit that turns every upgrade in the field into a silent wipe, and + * it must be an explicit, reviewed act rather than a side effect. See + * docs/Release.md "Storage version gate". */ #define STORAGE_VERSION_LAST_SHIPPED 17 /* A seed CREATED under bitcoin-only firmware is stamped with a version in a @@ -79,6 +88,10 @@ void storage_wipe(void); /// write, so a ceremony allowed to run would report success while persisting /// nothing -- and a seed the user funded would vanish on the next boot. /// +/// The seed itself stays intact in flash -- nothing is committed while locked +/// -- so reflashing bitcoin-only firmware recovers the wallet. Using the device +/// under multi-chain firmware requires an explicit wipe first. +/// /// Cleared only by storage_wipe(). bool storage_isBitcoinOnlyLocked(void); diff --git a/include/keepkey/firmware/tendermint.h b/include/keepkey/firmware/tendermint.h index 2bb39b4b3..e2d17ee2e 100644 --- a/include/keepkey/firmware/tendermint.h +++ b/include/keepkey/firmware/tendermint.h @@ -2,6 +2,20 @@ #define KEEPKEY_FIRMWARE_TENDERMINT_H #include "trezor/crypto/bip32.h" +#include "trezor/crypto/segwit_addr.h" + +/* Output size for the data half of a bech32_decode(). + * + * segwit_addr.h documents the contract as: hrp needs BECH32_MAX_HRP_LEN + 1 + * bytes, and data needs strlen(input) - 8. The Tendermint-family callers all + * used char hrp[45] / uint8_t decoded[38] against address fields whose proto + * max_size is 53, so a long address wrote past both -- and bech32_decode + * fills these buffers BEFORE it validates the checksum, so the usual + * `if (!bech32_decode(...)) return false;` guard does not prevent it. + * + * 64 covers any input up to 72 characters, comfortably above every address + * cap on these paths. */ +#define BECH32_DECODED_MAX 64 #include #include @@ -28,12 +42,7 @@ bool tendermint_pathMismatched(const CoinType* coin, const uint32_t* address_n, bool tendermint_getAddress(const HDNode* node, const char* prefix, char* address); -/** - * Validate non-empty host text before it is reused in both Amino JSON and a - * printf-based confirmation. This deliberately accepts visible ASCII except - * JSON string delimiters; spaces and controls are refused so the display has - * no hidden layout semantics. - */ +/** Reject empty or display-ambiguous host-provided JSON text. */ bool tendermint_validateSafeText(const char* value); /** Validate a Bech32 address and bind it to the expected human-readable part. @@ -54,6 +63,12 @@ bool tendermint_validateValidatorAddress(const char* address, bool tendermint_validateBech32Address(const char* address, const char* expected_prefix); +bool tendermint_isValidDenom(const char* denom); + +bool tendermint_isValidAsset(const char* asset); + +bool tendermint_isValidSigner(const char* signer, const char* hrp); + void tendermint_sha256UpdateEscaped(SHA256_CTX* ctx, const char* s, size_t len); bool tendermint_snprintf(SHA256_CTX* ctx, char* temp, size_t len, diff --git a/include/keepkey/firmware/thorchain.h b/include/keepkey/firmware/thorchain.h index bf3a1f95a..c07206b57 100644 --- a/include/keepkey/firmware/thorchain.h +++ b/include/keepkey/firmware/thorchain.h @@ -18,9 +18,18 @@ typedef struct _ThorchainSignTx ThorchainSignTx; typedef struct _ThorchainMsgDeposit ThorchainMsgDeposit; +// Returns true iff denom contains only chars safe in JSON without escaping. +// Valid: [a-z0-9./\-]. Rejects empty string, quotes, backslashes, whitespace. +bool thorchain_isValidDenom(const char* denom); + +// Deposit asset grammar: as above but uppercase alpha also allowed. +bool thorchain_isValidAsset(const char* asset); +// Deposit signer must be bech32 with the active network's HRP. +bool thorchain_isValidSigner(const char* signer); + bool thorchain_signTxInit(const HDNode* _node, const ThorchainSignTx* _msg); bool thorchain_signTxUpdateMsgSend(const uint64_t amount, - const char* to_address); + const char* to_address, const char* denom); bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg); bool thorchain_signTxFinalize(uint8_t* public_key, uint8_t* signature); bool thorchain_signingIsInited(void); @@ -64,4 +73,13 @@ typedef enum { ThorchainMemoResult thorchain_parseConfirmMemo(const char* swapStr, size_t size); +// Pages the COMPLETE raw memo (ASCII as text pages, binary as hex pages) so no +// byte is ever truncated behind confirm()'s body budget. Native THOR/MAYA +// deposit/send handlers call this as the authoritative disclosure after their +// best-effort structured summary, so a field the structured view omits (or a +// long field that would truncate) can never be signed unseen. Returns false if +// the user rejects any page. Shared by the MAYA path (same memo grammar). +bool thorchain_confirm_full_memo(const char* title, const char* memo, + size_t len); + #endif diff --git a/include/keepkey/firmware/tiny-json.h b/include/keepkey/firmware/tiny-json.h index 15183d8b3..bcaf48df8 100644 --- a/include/keepkey/firmware/tiny-json.h +++ b/include/keepkey/firmware/tiny-json.h @@ -75,7 +75,6 @@ typedef struct json_s { jsonType_t type; } json_t; -extern int errno; /** Parse a string to get a json. * @param str String pointer with a JSON object. It will be modified. * @param mem Array of json properties to allocate. diff --git a/include/keepkey/firmware/transaction.h b/include/keepkey/firmware/transaction.h index 9de826349..fee3d3dd1 100644 --- a/include/keepkey/firmware/transaction.h +++ b/include/keepkey/firmware/transaction.h @@ -28,6 +28,10 @@ #include #include +/// Shared input/output/compiler invariant for Bitcoin multisig scripts. +bool transaction_multisig_quorum_is_valid( + const MultisigRedeemScriptType* multisig); + #define TX_OVERWINTERED 0x80000000 /* Transaction output compilation errors */ diff --git a/include/keepkey/firmware/tron.h b/include/keepkey/firmware/tron.h index f9abdb4b1..82b81b152 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/txin_check.h b/include/keepkey/firmware/txin_check.h index 72df652b2..6478c28c6 100644 --- a/include/keepkey/firmware/txin_check.h +++ b/include/keepkey/firmware/txin_check.h @@ -34,5 +34,11 @@ bool txin_dgst_compare(const char* amt_str, const char* addr_str); void txin_dgst_final(void); void txin_dgst_getstrs(char* prev, char* cur, size_t len); void txin_dgst_save_and_reset(const char* amt_str, const char* addr_str); +/* Re-arm the rolling hash WITHOUT recording a comparison key. For an output + that carries no amount/address to compare -- OP_RETURN -- saving would + pollute last_amount_str/last_addr_str and could manufacture a false + duplicate later, but skipping the reset leaves the context finalised for + the next transaction. This does the reset half only. */ +void txin_dgst_reset_only(void); #endif diff --git a/include/keepkey/firmware/zcash.h b/include/keepkey/firmware/zcash.h new file mode 100644 index 000000000..b331f52b1 --- /dev/null +++ b/include/keepkey/firmware/zcash.h @@ -0,0 +1,430 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2025 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library. If not, see . + */ + +#ifndef KEEPKEY_FIRMWARE_ZCASH_H +#define KEEPKEY_FIRMWARE_ZCASH_H + +#include +#include +#include + +/* Orchard spending keys derived via ZIP-32. + * cppcheck doesn't see these used because the consumers live in + * fsm_msg_zcash.h which is #include'd into fsm.c rather than compiled + * separately, so the struct members appear "unused" in this TU. */ +typedef struct { + // cppcheck-suppress unusedStructMember + uint8_t sk[32]; /* Spending key (master secret at this level) */ + // cppcheck-suppress unusedStructMember + uint8_t ask[32]; /* Spend authorizing key (scalar) */ + // cppcheck-suppress unusedStructMember + uint8_t ak[32]; /* Public spend validating key (compressed, even y) */ + // cppcheck-suppress unusedStructMember + uint8_t nk[32]; /* Nullifier deriving key */ + // cppcheck-suppress unusedStructMember + uint8_t rivk[32]; /* Commitment randomness key */ + // cppcheck-suppress unusedStructMember + uint8_t dk[32]; /* Diversifier key */ +} ZcashOrchardKeys; + +typedef void (*ZcashOrchardProgressCallback)(uint32_t completed, uint32_t total, + void* context); + +typedef struct { + bool has_header_digest; + size_t header_digest_size; + bool has_transparent_digest; + size_t transparent_digest_size; + bool has_sapling_digest; + size_t sapling_digest_size; + bool has_orchard_digest; + size_t orchard_digest_size; + bool is_ironwood; + bool has_ironwood_digest; + size_t ironwood_digest_size; + bool has_orchard_flags; + uint32_t orchard_flags; + bool has_orchard_value_balance; + bool has_orchard_anchor; + size_t orchard_anchor_size; + bool has_header_fields; + uint32_t n_transparent_inputs; + uint32_t n_transparent_outputs; +} ZcashPCZTSigningRequestMeta; + +typedef enum { + ZCASH_PCZT_SIGNING_REQUEST_OK = 0, + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS, + ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE, + ZCASH_PCZT_SIGNING_REQUEST_MISSING_HEADER_FIELDS, + ZCASH_PCZT_SIGNING_REQUEST_UNSUPPORTED_SAPLING_COMPONENT, + ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA, + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST, +} ZcashPCZTSigningRequestStatus; + +typedef struct { + const uint8_t* prevout_txid; + uint32_t prevout_index; + uint32_t sequence; + uint64_t value; + const uint8_t* script_pubkey; + size_t script_pubkey_size; +} ZcashTransparentInputDigestInfo; + +typedef struct { + uint64_t value; + const uint8_t* script_pubkey; + size_t script_pubkey_size; +} ZcashTransparentOutputDigestInfo; + +#define ZCASH_ORCHARD_RAW_RECEIVER_SIZE 43 +#define ZCASH_ORCHARD_UNIFIED_ADDRESS_SIZE 128 + +/** + * Validate the clear-signing metadata required before Orchard signatures. + * + * This rejects the legacy flow where the host supplied only a per-action + * sighash. The firmware must assemble the ZIP-244 sighash from transaction + * component digests and verify the Orchard digest against streamed action data + * before returning signatures. + */ +ZcashPCZTSigningRequestStatus zcash_pczt_signing_request_status( + const ZcashPCZTSigningRequestMeta* meta); + +bool zcash_pczt_signing_request_is_clear( + const ZcashPCZTSigningRequestMeta* meta); + +/** + * Derive Orchard spending keys from the device seed via ZIP-32. + * Path: m_orchard / 32' / 133' / account' + * + * Uses BLAKE2b with personalization "ZcashIP32Orchard" for key derivation. + * + * @param seed BIP-39 master seed + * @param seed_len Seed length (typically 64 bytes) + * @param account Account index (0-based, will be hardened) + * @param keys Output: derived Orchard keys + * @return true on success + */ +bool zcash_derive_orchard_keys(const uint8_t* seed, uint32_t seed_len, + uint32_t account, ZcashOrchardKeys* keys); + +/** + * Progress-reporting Orchard key derivation for interactive device flows. + * Progress is driven by the fixed public scalar-multiplication schedule and + * does not depend on the derived secret key. + */ +bool zcash_derive_orchard_keys_with_progress( + const uint8_t* seed, uint32_t seed_len, uint32_t account, + ZcashOrchardKeys* keys, ZcashOrchardProgressCallback progress, + void* progress_context); + +/** + * Compute the ZIP 244 shielded sighash for Orchard spend authorization. + * + * For shielded-only transactions, transparent_sig_digest uses the "no inputs" + * form. For mixed transactions, transparent data must be provided separately. + * + * @param header_digest 32-byte pre-computed header digest + * @param transparent_digest 32-byte transparent sig digest (or empty hash) + * @param sapling_digest 32-byte sapling digest (or empty hash) + * @param orchard_digest 32-byte orchard digest + * @param branch_id Consensus branch ID (LE) + * @param sighash_out 32-byte output sighash + * @return true on success + */ +bool zcash_compute_shielded_sighash(const uint8_t header_digest[32], + const uint8_t transparent_digest[32], + const uint8_t sapling_digest[32], + const uint8_t orchard_digest[32], + uint32_t branch_id, + uint8_t sighash_out[32]); + +/** Compute the five-component ZIP-229 transaction-v6 sighash. */ +bool zcash_compute_v6_shielded_sighash(const uint8_t header_digest[32], + const uint8_t transparent_digest[32], + const uint8_t sapling_digest[32], + const uint8_t orchard_digest[32], + const uint8_t ironwood_digest[32], + uint32_t branch_id, + uint8_t sighash_out[32]); + +/** + * Compute ZIP-244 T.1 header_digest from plaintext transaction header fields. + */ +bool zcash_compute_header_digest(uint32_t version, uint32_t version_group_id, + uint32_t branch_id, uint32_t lock_time, + uint32_t expiry_height, + uint8_t digest_out[32]); + +/** + * Compute ZIP-244 T.2 transparent_digest from plaintext transparent data. + * + * This is the digest mixed into the Orchard/Sapling signing commitment. It is + * not the same as the per-input transparent signature digest. + */ +bool zcash_compute_transparent_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint8_t digest_out[32]); + +/** + * Compute ZIP-244 §4.9 transparent_sig_digest for Orchard spend authorization. + * + * Uses the S.2 form with EMPTY txin_sig_digest when n_inputs > 0 (shield txs), + * or falls back to T.1 when n_inputs == 0 (deshield / private-send). This is + * what the Zcash consensus node uses to verify Orchard spend auth sigs and the + * binding signature in a hybrid (transparent + Orchard) transaction. + */ +bool zcash_compute_orchard_transparent_sig_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint8_t digest_out[32]); + +/** + * Compute ZIP-244 S.2 per-input transparent signature digest. + * + * This currently accepts SIGHASH_ALL only, matching the existing transparent + * signing flow. + */ +bool zcash_compute_transparent_sighash_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint32_t signable_input_index, uint8_t sighash_type, + uint8_t digest_out[32]); + +/** + * Encode a raw Orchard receiver (d || pk_d) as an Orchard-only ZIP-316 Unified + * Address for display. This is for recipient review; it does not derive or + * prove ownership of the receiver. + */ +bool zcash_orchard_receiver_to_unified_address( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], const char* hrp, + char* address_out, size_t address_out_len); + +/** + * Recompute an Orchard output note commitment x-coordinate (cmx). + * + * cmx = Extract_P(NoteCommit_rcm^Orchard(g_d, pk_d, v, rho, psi)) + * where receiver = d || pk_d, rho is the action nullifier, and rseed is the + * output note seed. This binds the user-displayed receiver/value to the action + * commitment before any authorization signature is emitted. + */ +bool zcash_orchard_compute_cmx( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32]); + +/** ZIP-2005 V3 note commitment used by the Ironwood pool. */ +bool zcash_ironwood_compute_cmx( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32]); + +bool zcash_ironwood_compute_cmx_with_progress( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32], + ZcashOrchardProgressCallback progress, void* progress_context); + +/** + * Progress-reporting note-commitment verification for interactive PCZT flows. + * The callback exposes only the public Sinsemilla word index and count. + */ +bool zcash_orchard_compute_cmx_with_progress( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32], + ZcashOrchardProgressCallback progress, void* progress_context); + +/** + * Derive an Orchard diversifier from a diversifier key and 88-bit index. + * + * ZIP-32 defines Orchard diversifiers as: + * d_j = FF1-AES256.Encrypt(dk, "", I2LEBSP_88(j)) + * + * Both index_le and diversifier_out are 11-byte LEBS2OSP encodings of the + * 88-bit bitstrings. + * + * @param dk 32-byte Orchard diversifier key + * @param index_le 11-byte little-endian diversifier index bitstring + * @param diversifier_out 11-byte output diversifier + * @return true on success + */ +bool zcash_orchard_derive_diversifier(const uint8_t dk[32], + const uint8_t index_le[11], + uint8_t diversifier_out[11]); + +/** + * Compute DiversifyHash^Orchard(d) as a serialized Pallas point. + * + * g_d = GroupHash^Pallas("z.cash:Orchard-gd", d) + * + * If the group hash ever returns the identity, Orchard falls back to hashing + * the empty message under the same domain. + * + * @param diversifier 11-byte Orchard diversifier + * @param gd_out 32-byte compressed Pallas point + * @return true on success + */ +bool zcash_orchard_diversify_hash(const uint8_t diversifier[11], + uint8_t gd_out[32]); + +/** + * Derive an Orchard diversified transmission key. + * + * g_d = DiversifyHash^Orchard(d) + * pk_d = KA^Orchard.DerivePublic(ivk, g_d) = [ivk] g_d + * + * @param ivk 32-byte nonzero Orchard incoming viewing key encoding + * @param diversifier 11-byte Orchard diversifier + * @param gd_out optional 32-byte compressed g_d output, may be NULL + * @param pkd_out 32-byte compressed diversified transmission key + * @return true on success + */ +bool zcash_orchard_derive_transmission_key(const uint8_t ivk[32], + const uint8_t diversifier[11], + uint8_t gd_out[32], + uint8_t pkd_out[32]); + +/** + * Derive the external Orchard incoming viewing key from FVK components. + * + * ivk = Commit^ivk.Output(ExtractP(ak), nk, rivk) + * + * @param ak 32-byte Orchard spend validating key encoding, sign bit clear + * @param nk 32-byte Orchard nullifier deriving key + * @param rivk 32-byte Orchard IVK commitment randomness + * @param ivk_out 32-byte nonzero Orchard incoming viewing key + * @return true on success + */ +bool zcash_orchard_derive_ivk(const uint8_t ak[32], const uint8_t nk[32], + const uint8_t rivk[32], uint8_t ivk_out[32]); + +/** + * Derive a raw Orchard receiver from external FVK components and index. + * + * d_j = DiversifierKey(dk).get(j) + * ivk = Commit^ivk.Output(ExtractP(ak), nk, rivk) + * pk_dj = KA^Orchard.DerivePublic(ivk, DiversifyHash(d_j)) + * + * @param ak 32-byte Orchard spend validating key encoding + * @param nk 32-byte Orchard nullifier deriving key + * @param rivk 32-byte Orchard IVK commitment randomness + * @param dk 32-byte Orchard diversifier key + * @param index_le 11-byte little-endian diversifier index bitstring + * @param receiver_out 43-byte raw receiver: d_j || pk_dj + * @return true on success + */ +bool zcash_orchard_derive_receiver(const uint8_t ak[32], const uint8_t nk[32], + const uint8_t rivk[32], const uint8_t dk[32], + const uint8_t index_le[11], + uint8_t receiver_out[43]); + +/** + * Derive an Orchard-only ZIP-316 Unified Address from derived Orchard keys. + * + * ak = [ask] G_spendauth + * receiver = d_j || pk_dj + * address = Bech32m(HRP, F4Jumble(Orchard receiver payload)) + * + * @param keys ZIP-32-derived Orchard key material + * @param index_le 11-byte little-endian diversifier index bitstring + * @param hrp ZIP-316 HRP ("u" for mainnet, "utest" for testnet) + * @param address_out NUL-terminated output address + * @param address_out_len Size of address_out + * @return true on success + */ +bool zcash_orchard_derive_unified_address(const ZcashOrchardKeys* keys, + const uint8_t index_le[11], + const char* hrp, char* address_out, + size_t address_out_len); + +/** + * Compute the ZIP-32 §6.1 seed fingerprint. + * + * SeedFingerprint := BLAKE2b-256( + * "Zcash_HD_Seed_FP", I2LEBSP_8(len(seed)) || seed) + * + * The 1-byte length prefix domain-separates seeds of different lengths that + * happen to share a prefix. + * + * 32-byte stable identifier of a seed. Used by host wallets and PCZTs + * (zip32_derivation.seed_fingerprint) to confirm which device seed produced + * a given key, address, or signature. Trivial seeds (all-zero, all-0xFF) + * and seeds outside [32, 252] bytes are rejected per ZIP-32 §6.1. + * + * @param seed Seed bytes (BIP-39 seed or BIP-32 master seed) + * @param seed_len Seed length, must be in [32, 252] + * @param fingerprint_out 32-byte output fingerprint + * @return true on success, false if seed is invalid + */ +bool zcash_calculate_seed_fingerprint(const uint8_t* seed, uint32_t seed_len, + uint8_t fingerprint_out[32]); + +/** + * Validate the wire shape of an optional asserted seed fingerprint. Omission is + * valid; a present assertion must be exactly 32 bytes. + */ +bool zcash_seed_fingerprint_request_valid(bool present, size_t size); + +/* ── Storage-scoped wrappers ─────────────────────────────────────────── + * + * The two functions below own the seed access. Implementations live in + * lib/firmware/storage.c so the raw 64-byte BIP-39 seed never escapes + * that translation unit. Callers (FSM handlers) get only the derived + * material — Orchard keys or the 32-byte fingerprint — never a pointer + * to the seed itself. This is the only sanctioned way for production + * firmware code to consume seed-derived Zcash material. + * + * The bare zcash_derive_orchard_keys() / zcash_calculate_seed_fingerprint() + * functions above remain in the header for unit tests, which feed them + * known test vectors directly. + */ + +/** + * Derive Orchard keys for an account using the device's session seed. + * + * @param account Account index (0-based, will be hardened) + * @param usePassphrase Whether to apply the passphrase (prompts if needed) + * @param keys_out Output: derived Orchard keys + * @return true on success, false if seed unavailable or derivation fails + */ +bool storage_zcashOrchardKeys(uint32_t account, bool usePassphrase, + ZcashOrchardKeys* keys_out); + +/** + * Compute the ZIP-32 §6.1 seed fingerprint for the device's session seed. + * + * @param usePassphrase Whether to apply the passphrase (prompts if needed) + * @param fingerprint_out 32-byte output fingerprint + * @return true on success, false if seed unavailable + */ +bool storage_zcashSeedFingerprint(bool usePassphrase, + uint8_t fingerprint_out[32]); + +/** + * Tear down any in-progress Zcash signing session. + * + * Wipes the static signing state (active flag, derived Orchard keys, + * accumulated signatures, sub-digest contexts, transparent-input + * counters) so a host cannot resume streaming PCZTAction or + * TransparentInput messages against a previously-approved session + * after Initialize, Cancel, or ClearSession. Safe to call when no + * session is active. + */ +void zcash_signing_abort(void); + +#endif diff --git a/include/keepkey/rand/rng.h b/include/keepkey/rand/rng.h index 0297aaf7c..34b7a6536 100644 --- a/include/keepkey/rand/rng.h +++ b/include/keepkey/rand/rng.h @@ -37,6 +37,12 @@ void reset_rng(void); /// cleared and is never cleared itself: recovery is a power cycle. bool rng_seed_error_latched(void); +/// Account for one poll where the RNG's seed/clock error is still active. +/// Returns true after the bounded retry budget is exhausted, resets \p samples, +/// and latches the fault before the caller clears hardware evidence. +/// Exposed so the register-independent recovery policy is unit-testable. +bool rng_persistent_error_step(uint32_t* samples); + #ifdef EMULATOR /// Test seam for the STM32 seed/clock-error state machine. These helpers are /// absent from ARM firmware; reset models a fresh power-on between cases. 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-hive.options b/include/keepkey/transport/messages-hive.options new file mode 100644 index 000000000..d39d59215 --- /dev/null +++ b/include/keepkey/transport/messages-hive.options @@ -0,0 +1,58 @@ +HiveGetPublicKey.address_n max_count:8 + +HivePublicKey.public_key max_size:64 +HivePublicKey.raw_public_key max_size:33 + +HiveGetPublicKeys.account_index int_size:IS_32 + +HivePublicKeys.owner_key max_size:64 +HivePublicKeys.active_key max_size:64 +HivePublicKeys.memo_key max_size:64 +HivePublicKeys.posting_key max_size:64 + +HiveSignTx.address_n max_count:8 +HiveSignTx.chain_id max_size:32 +HiveSignTx.from max_size:16 +HiveSignTx.to max_size:16 +HiveSignTx.amount int_size:IS_64 +HiveSignTx.asset_symbol max_size:10 +HiveSignTx.memo max_size:2048 + +HiveSignedTx.signature max_size:65 +HiveSignedTx.serialized_tx max_size:512 + +HiveSignAccountCreate.address_n max_count:8 +HiveSignAccountCreate.chain_id max_size:32 +HiveSignAccountCreate.creator max_size:16 +HiveSignAccountCreate.new_account_name max_size:16 +HiveSignAccountCreate.owner_key max_size:64 +HiveSignAccountCreate.active_key max_size:64 +HiveSignAccountCreate.posting_key max_size:64 +HiveSignAccountCreate.memo_key max_size:64 +HiveSignAccountCreate.fee_amount int_size:IS_64 + +HiveSignedAccountCreate.signature max_size:65 +HiveSignedAccountCreate.serialized_tx max_size:512 + +HiveSignAccountUpdate.address_n max_count:8 +HiveSignAccountUpdate.chain_id max_size:32 +HiveSignAccountUpdate.account max_size:16 +HiveSignAccountUpdate.new_owner_key max_size:64 +HiveSignAccountUpdate.new_active_key max_size:64 +HiveSignAccountUpdate.new_posting_key max_size:64 +HiveSignAccountUpdate.new_memo_key max_size:64 + +HiveSignedAccountUpdate.signature max_size:65 +HiveSignedAccountUpdate.serialized_tx max_size:512 + +HiveSignMessage.address_n max_count:8 +HiveSignMessage.message max_size:1024 + +HiveSignedMessage.signature max_size:65 +HiveSignedMessage.public_key max_size:33 + +HiveSignOperations.address_n max_count:8 +HiveSignOperations.chain_id max_size:32 +HiveSignOperations.serialized_tx max_size:2048 + +HiveSignedOperations.signature max_size:65 diff --git a/include/keepkey/transport/messages-zcash.options b/include/keepkey/transport/messages-zcash.options new file mode 100644 index 000000000..63d1035b4 --- /dev/null +++ b/include/keepkey/transport/messages-zcash.options @@ -0,0 +1,54 @@ +ZcashSignPCZT.address_n max_count:10 +ZcashSignPCZT.pczt_data max_size:0 +ZcashSignPCZT.total_amount int_size:IS_64 +ZcashSignPCZT.fee int_size:IS_64 +ZcashSignPCZT.header_digest max_size:32 +ZcashSignPCZT.transparent_digest max_size:32 +ZcashSignPCZT.sapling_digest max_size:32 +ZcashSignPCZT.orchard_digest max_size:32 +ZcashSignPCZT.ironwood_digest max_size:32 +ZcashSignPCZT.orchard_value_balance int_size:IS_64 +ZcashSignPCZT.orchard_anchor max_size:32 +ZcashSignPCZT.expected_seed_fingerprint max_size:32 + +ZcashPCZTAction.alpha max_size:32 +ZcashPCZTAction.sighash max_size:32 +ZcashPCZTAction.cv_net max_size:32 +ZcashPCZTAction.value int_size:IS_64 +ZcashPCZTAction.nullifier max_size:32 +ZcashPCZTAction.cmx max_size:32 +ZcashPCZTAction.epk max_size:32 +ZcashPCZTAction.enc_compact max_size:52 +ZcashPCZTAction.enc_memo max_size:512 +ZcashPCZTAction.enc_noncompact max_size:564 +ZcashPCZTAction.rk max_size:32 +ZcashPCZTAction.out_ciphertext max_size:80 +ZcashPCZTAction.recipient max_size:43 +ZcashPCZTAction.rseed max_size:32 + +ZcashSignedPCZT.signatures max_count:16, max_size:64 +ZcashSignedPCZT.txid max_size:32 + +ZcashGetOrchardFVK.address_n max_count:10 + +ZcashOrchardFVK.ak max_size:32 +ZcashOrchardFVK.nk max_size:32 +ZcashOrchardFVK.rivk max_size:32 +ZcashOrchardFVK.seed_fingerprint max_size:32 + +ZcashTransparentOutput.amount int_size:IS_64 +ZcashTransparentOutput.script_pubkey max_size:128 + +ZcashTransparentInput.sighash max_size:32 +ZcashTransparentInput.address_n max_count:8 +ZcashTransparentInput.amount int_size:IS_64 +ZcashTransparentInput.prevout_txid max_size:32 +ZcashTransparentInput.script_pubkey max_size:128 + +ZcashTransparentSigned.signatures max_count:8, max_size:73 + +ZcashDisplayAddress.address_n max_count:8 +ZcashDisplayAddress.expected_seed_fingerprint max_size:32 + +ZcashAddress.address max_size:128 +ZcashAddress.seed_fingerprint max_size:32 diff --git a/include/keepkey/transport/messages.options b/include/keepkey/transport/messages.options index ad21feade..f6b820e78 100644 --- a/include/keepkey/transport/messages.options +++ b/include/keepkey/transport/messages.options @@ -27,7 +27,7 @@ PinMatrixAck.pin max_size:10 PassphraseAck.passphrase max_size:51 -Entropy.entropy max_size:1024 +Entropy.entropy max_size:8192 GetPublicKey.address_n max_count:8 GetPublicKey.ecdsa_curve_name max_size:32 @@ -139,10 +139,13 @@ FlashHashResponse.data max_size:32 Bip85Mnemonic.mnemonic max_size:241 -# ClearSign attestor messages exist in the pinned protocol but are not -# implemented by this firmware. nanopb still generates their structs, and a +# ClearSign attestor. The payload cap is the largest +# KKSOLSC1 schema: magic(8)+version(1)+program(32)+disc(1+8)+2 names(2*21)+ +# args(1+4*18)+accounts(1+4*18) = 238 bytes. +# These stay sized even in the KK_BITCOIN_ONLY build, where the attestor +# handlers are compiled out but nanopb still generates the structs: a # bytes/string field with no size here becomes a pb_callback_t, which this -# build forbids -- so they are sized rather than left to become callbacks. +# build forbids. ClearsignAttestorPublicKey.public_key max_size:33 ClearsignAttestorSign.payload max_size:256 ClearsignAttestorSignature.signature max_size:64 diff --git a/lib/board/CMakeLists.txt b/lib/board/CMakeLists.txt index 10c30b424..bb2c376dc 100644 --- a/lib/board/CMakeLists.txt +++ b/lib/board/CMakeLists.txt @@ -51,7 +51,7 @@ endif() include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto ${CMAKE_CURRENT_SOURCE_DIR}) add_library(kkboard ${sources}) diff --git a/lib/board/confirm_sm.c b/lib/board/confirm_sm.c index d54e6e92a..6646263a2 100644 --- a/lib/board/confirm_sm.c +++ b/lib/board/confirm_sm.c @@ -52,18 +52,19 @@ extern bool reset_msg_stack; static CONFIDENTIAL char strbuf[BODY_CHAR_MAX]; -/* vsnprintf() returns the length it WOULD have written. Treat anything that - * did not fit as a refusal: once characters are lost, no renderer or pager can - * recover them and there is no complete body the user can approve. */ -static bool format_body_into(char* out, size_t out_len, - const char* request_body, va_list vl) { - if (!out || out_len == 0 || !request_body) return false; - const int needed = vsnprintf(out, out_len, request_body, vl); - return needed >= 0 && (size_t)needed < out_len; -} - -static bool format_body(const char* request_body, va_list vl) { - return format_body_into(strbuf, sizeof(strbuf), request_body, vl); +/* Set by format_body() when the formatted body did not fit strbuf, i.e. when + * characters were lost before any screen existed to show them. Read and + * cleared by confirm_helper(). Truncation here is invisible to every later + * check: what reaches the renderer is a complete, well-formed, shorter string, + * so the screen looks correct and is not. */ +static bool body_truncated = false; + +/* The single place a host-supplied body is formatted. vsnprintf() returns the + * length it WOULD have written, which is the only chance to notice that + * strbuf was too small -- after this, the evidence is gone. */ +static void format_body(const char* request_body, va_list vl) { + const int needed = vsnprintf(strbuf, sizeof(strbuf), request_body, vl); + body_truncated = (needed < 0) || ((size_t)needed >= sizeof(strbuf)); } /// Handler for push button being pressed. @@ -362,6 +363,24 @@ bool confirm_body_fits(const char* body, uint16_t body_width) { font_height(body_font) + BODY_FONT_LINE_PADDING); } +/* The same probe, for constant-power screens. + * + * layout_constant_power_notification() draws from x = 128 + LEFT_MARGIN, + * because the display driver mirrors the right half of the canvas onto the + * panel. Only KEEPKEY_DISPLAY_WIDTH - (128 + LEFT_MARGIN) = 124 px exists past + * that origin, while BODY_WIDTH (225) is what gets passed as the wrap width. + * The wrap therefore never fires before the canvas edge does, draw_char_impl + * rejects the first glyph that crosses 256, and draw_string_walk stops -- + * dropping the rest of the body, including whole later lines, with no ellipsis + * and no indicator. + * + * Measuring with BODY_WIDTH from the LEFT margin (confirm_body_fits) would say + * such a body fits, because from x = 4 it does. The origin is the whole point, + * so this probe starts where the real draw starts. Same loop, same per-glyph + * fit test, so measuring and drawing cannot disagree. + * + * body_width is accepted and forwarded unchanged so this can stand in for + * confirm_body_fits() wherever a fit probe is selected by layout. */ bool confirm_body_fits_constant_power(const char* body, uint16_t body_width) { Canvas* canvas = layout_get_canvas(); const Font* body_font = get_body_font(); @@ -375,6 +394,8 @@ bool confirm_body_fits_constant_power(const char* body, uint16_t body_width) { } else if (body_line_count == TWO_LINES) { sp.y = TOP_MARGIN_FOR_TWO_LINES; } + + /* Mirrors layout_constant_power_notification() exactly. */ sp.y += font_height(body_font) + BODY_TOP_MARGIN; sp.x = 128 + LEFT_MARGIN; sp.color = BODY_COLOR; @@ -383,13 +404,23 @@ bool confirm_body_fits_constant_power(const char* body, uint16_t body_width) { font_height(body_font) + BODY_FONT_LINE_PADDING); } +/// Fit probe selected by layout: measuring must start where drawing starts. +typedef bool (*body_fits_fn)(const char*, uint16_t); + +static body_fits_fn fits_probe_for(layout_notification_t fn) { + if (fn == &layout_constant_power_notification) { + return &confirm_body_fits_constant_power; + } + return &confirm_body_fits; +} + /// How many characters of `body` fit one screen, starting from `body[0]`? /// /// Binary search over confirm_body_fits(), which replays the real placement. /// Returns at least 1 so a body of unrenderable glyphs still advances rather /// than looping forever. static size_t page_take(const char* body, uint16_t body_width, char* buf, - size_t buf_size) { + size_t buf_size, body_fits_fn fits) { const size_t len = strlen(body); if (len == 0) return 0; @@ -401,7 +432,7 @@ static size_t page_take(const char* body, uint16_t body_width, char* buf, const size_t mid = lo + (hi - lo) / 2; memcpy(buf, body, mid); buf[mid] = '\0'; - if (confirm_body_fits(buf, body_width)) { + if (fits(buf, body_width)) { best = mid; lo = mid + 1; } else { @@ -423,46 +454,26 @@ static size_t page_take(const char* body, uint16_t body_width, char* buf, /// after the first writes its own request and clears button_request_acked, so /// a host that answers every request it is told about never waits on a press /// it never heard of. -/// -/// `notify_host` is false for the *_without_button_request() entry points, -/// which deliberately never message the host; emitting per-page requests for -/// those would tell a host about presses it never asked to arbitrate. static bool page_body_confirm(const char* request_title, const char* body, layout_notification_t layout_notification_func, bool constant_power, IconType iconNum, - bool immediate, uint16_t body_width, - bool notify_host) { + bool immediate, uint16_t body_width) { + const body_fits_fn fits = fits_probe_for(layout_notification_func); static CONFIDENTIAL char page_buf[BODY_CHAR_MAX]; static char page_title[TITLE_CHAR_MAX]; - /* Pass 1: count. - * - * The cap REFUSES; it must never truncate. Breaking out with input still - * unread left `pages` at 100 while the body ran on, and the render loop then - * treats page 100 as the last one -- so the hold that means "I approve this" - * lands on a prefix, with the tail neither shown nor accounted for. A body of - * 351 newlines reaches that: confirm_body_fits() accepts three newlines and - * rejects four, so page_take() returns 3 and the body needs 117 pages. - * - * Returning false instead is not a lost capability. BODY_CHAR_MAX is 352, and - * a body needing more than 99 pages is one averaging under four characters a - * screen -- unreachable for real text, and not something a user could review - * in any meaningful sense if it were. The caller reports it exactly as it - * reports a refused screen. */ + /* Pass 1: count. */ size_t pages = 0; { const char* p = body; while (*p) { - const size_t take = page_take(p, body_width, page_buf, sizeof(page_buf)); + const size_t take = + page_take(p, body_width, page_buf, sizeof(page_buf), fits); if (take == 0) break; p += take; while (*p == ' ') p++; /* a leading space is dropped at a line start */ pages++; - if (pages > 99) { - /* title formats n/m, and a prefix must never become the approval */ - memzero(page_buf, sizeof(page_buf)); - return false; - } + if (pages > 99) break; /* title formats n/m; refuse to run away */ } } if (pages <= 1) { @@ -474,7 +485,8 @@ static bool page_body_confirm(const char* request_title, const char* body, bool ok = false; const char* p = body; for (size_t page = 0; page < pages && *p; page++) { - const size_t take = page_take(p, body_width, page_buf, sizeof(page_buf)); + const size_t take = + page_take(p, body_width, page_buf, sizeof(page_buf), fits); if (take == 0) break; memcpy(page_buf, p, take); page_buf[take] = '\0'; @@ -485,7 +497,7 @@ static bool page_body_confirm(const char* request_title, const char* body, if (title_len < 0 || (size_t)title_len >= sizeof(page_title)) break; const bool last = (page + 1 == pages); - if (page > 0 && notify_host) { + if (page > 0) { ButtonRequest page_ack; memset(&page_ack, 0, sizeof(page_ack)); page_ack.has_code = true; @@ -510,40 +522,111 @@ static bool page_body_confirm(const char* request_title, const char* body, return ok; } -/// Show a confirmation, paging when its complete body will not fit the screen. +/// Show a confirmation, warning first when its body will not fit the screen. /// /// draw_string() draws until a glyph no longer fits the canvas and then simply /// stops: a body taller than BODY_ROWS is drawn in part, with no ellipsis and /// nothing to tell the user that the tail of an address, an amount or a -/// warning was dropped. Complete formatted bodies are therefore paged here. -/// Source formatting overflow is refused by every public entry point before a -/// ButtonRequest is emitted, because lost source cannot be paged. +/// warning was dropped. The vsnprintf() into strbuf[BODY_CHAR_MAX] below cuts +/// long host strings a second time, just as quietly. +/// +/// So when the body will not fit, put an explicit screen in front of it. That +/// screen costs its own hold, and the hold is a real consent signal: a host +/// Cancel breaks it and the caller reports ActionCancelled, exactly as it +/// would for the body screen. A body that is only partly shown is now never +/// shown without saying so. /// /// Bodies that fit take exactly the path they took before: one screen, one /// ButtonRequest, one hold. static bool confirm_helper(const char* request_title, const char* request_body, layout_notification_t layout_notification_func, bool constant_power, IconType iconNum, - bool immediate, bool notify_host) { + bool immediate) { const uint16_t body_width = (uint16_t)((iconNum == NO_ICON) ? BODY_WIDTH : BODY_WIDTH_WITH_ICON); - /* Only layout_standard_notification is known to wrap the body at BODY_WIDTH - * over BODY_ROWS rows. Custom layouts place and size their own body, and - * layout_constant_power_notification draws from x = 128 + LEFT_MARGIN where - * the canvas edge, not BODY_WIDTH, is the limit. Measuring either of those - * against BODY_WIDTH would be wrong, so leave them exactly as they were. */ + /* Consume the source-completeness latch exactly once, whatever happens + * below: leaving it set would make the NEXT confirmation warn for this + * one's reason. */ + const bool truncated = body_truncated; + body_truncated = false; + + /* Two independent ways the user can be shown less than what is being + * approved, and they need separate measurements because they happen at + * different times: + * + * SOURCE the formatted body did not fit strbuf. Characters were lost + * before the renderer ever saw them, so no amount of looking + * at the screen can detect it -- only vsnprintf()'s return + * value could, and format_body() kept it. + * RENDER the body reached the renderer intact but did not fit the + * canvas. draw_string_fits() replays the real placement and + * reports whether the last character landed. + * + * The probe must start where the real draw starts, so it is selected by + * layout. layout_standard_notification wraps at BODY_WIDTH from LEFT_MARGIN; + * layout_constant_power_notification draws from x = 128 + LEFT_MARGIN, where + * the canvas edge and not BODY_WIDTH is the limit. + * + * Constant-power screens used to be excluded here on the grounds that + * measuring them against BODY_WIDTH would be wrong. It would have been -- but + * excluding them meant the seed-backup pages, which are drawn by exactly that + * layout, had NO completeness check at all. Measured over 200k random 24-word + * mnemonics with the real font tables: 1.7% produce a backup page the + * renderer silently clips, and 0.65% never show one of the words at all, + * because the walk stops at the first rejected glyph and drops every + * character after it. A user writes down 23 words and cannot restore. + * + * The answer is to measure at the right origin, not to skip the measurement. + * Custom layouts that place their own body still opt out. + * + * A SOURCE truncation is layout-independent and must warn regardless. */ + /* NOT wired to constant-power screens, deliberately, and this is a + * behavioural constraint rather than an oversight. + * + * page_body_confirm() emits one ButtonRequest PER PAGE (see #482: "Every page + * after the first writes its own request"). The seed-backup flow is driven by + * a host that reads one word group per ButtonRequest, so paging a backup + * screen makes the host read that group TWICE and reconstruct a mnemonic with + * duplicated words. That is a protocol change for every host, not just a test + * artifact, and it silently corrupts the thing the user is writing down. + * + * So the measurement stays available and honest -- see + * confirm_body_fits_constant_power(), and the test that pins a real clipped + * backup page -- but it does not silently change the flow. Fixing the + * clipping properly means packing reset.c's pages against the width they are + * actually drawn at, which needs MAX_PAGES raised (~3.7 KB more static SRAM) + * and on-device OLED verification. Tracked in #519. */ const bool render_incomplete = (layout_notification_func == &layout_standard_notification) && !confirm_body_fits(request_body, body_width); + if (truncated) { + /* SOURCE truncation: characters were lost in vsnprintf() before the + * renderer ever saw them. They cannot be paged, because they do not + * exist any more. Say exactly that -- the old copy promised to show the + * rest on the next hold and then redrew the same clipped body, which is + * worse than not warning at all: a user who read it carefully was + * misled about what they had seen. */ + if (!confirm_screen("Cut Off", + "This text is too long to show in full. The rest " + "cannot be displayed. Hold to continue anyway.", + &layout_standard_notification, constant_power, NO_ICON, + immediate)) { + return false; + } + return page_body_confirm(request_title, request_body, + layout_notification_func, constant_power, iconNum, + immediate, body_width); + } + if (render_incomplete) { /* RENDER overflow: the body reached the renderer intact, so every * character is still in hand and can be shown -- on more than one screen. * Page it. */ return page_body_confirm(request_title, request_body, layout_notification_func, constant_power, iconNum, - immediate, body_width, notify_host); + immediate, body_width); } return confirm_screen(request_title, request_body, layout_notification_func, @@ -556,12 +639,8 @@ bool confirm(ButtonRequestType type, const char* request_title, va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } /* Send button request */ ButtonRequest resp; @@ -572,7 +651,7 @@ bool confirm(ButtonRequestType type, const char* request_title, bool ret = confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false, true); + false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -629,10 +708,6 @@ bool confirm_constant_power_paged(ButtonRequestType type, #if DEBUG_LINK if (decided_via_debug) { - /* Production keeps the legacy one-ButtonRequest-per-word-group - * protocol. The debug build emits a request for each renderer subpage - * so the evidence harness can capture every physical OLED page instead - * of silently retaining only the first one. */ memset(&resp, 0, sizeof(resp)); resp.has_code = true; resp.code = type; @@ -642,8 +717,7 @@ bool confirm_constant_power_paged(ButtonRequestType type, #endif ok = confirm_screen(request_title, sub, &layout_constant_power_notification, - true, NO_ICON, - /*immediate=*/!last); + true, NO_ICON, /*immediate=*/!last); #if DEBUG_LINK if (ok && last_exit_was_debug_decision) decided_via_debug = true; #endif @@ -659,12 +733,8 @@ bool confirm_constant_power(ButtonRequestType type, const char* request_title, va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } /* Send button request */ ButtonRequest resp; @@ -675,7 +745,7 @@ bool confirm_constant_power(ButtonRequestType type, const char* request_title, bool ret = confirm_helper(request_title, strbuf, &layout_constant_power_notification, - true, NO_ICON, false, true); + true, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -687,19 +757,15 @@ bool confirm_with_custom_button_request(const ButtonRequest* button_request, va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } /* Send button request */ msg_write(MessageType_MessageType_ButtonRequest, button_request); bool ret = confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false, true); + false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -720,12 +786,8 @@ bool confirm_with_custom_layout(layout_notification_t layout_notification_func, va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } /* Send button request */ ButtonRequest resp; @@ -734,9 +796,8 @@ bool confirm_with_custom_layout(layout_notification_t layout_notification_func, resp.code = type; msg_write(MessageType_MessageType_ButtonRequest, &resp); - bool ret = - confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false, true); + bool ret = confirm_helper(request_title, strbuf, layout_notification_func, + false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -768,12 +829,8 @@ bool confirm_address_with_custom_layout( va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } /* Send button request */ ButtonRequest resp; @@ -783,7 +840,7 @@ bool confirm_address_with_custom_layout( msg_write(MessageType_MessageType_ButtonRequest, &resp); bool ret = confirm_helper(request_title, strbuf, layout_notification_func, - false, NO_ICON, false, true); + false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -794,16 +851,12 @@ bool confirm_without_button_request(const char* request_title, va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } bool ret = confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false, false); + false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -815,12 +868,8 @@ bool confirm_with_icon(ButtonRequestType type, IconType iconNum, va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } /* Send button request */ ButtonRequest resp; @@ -831,7 +880,7 @@ bool confirm_with_icon(ButtonRequestType type, IconType iconNum, bool ret = confirm_helper(request_title, strbuf, &layout_standard_notification, - false, iconNum, false, true); + false, iconNum, false); memzero(strbuf, sizeof(strbuf)); return ret; } @@ -842,12 +891,8 @@ bool review(ButtonRequestType type, const char* request_title, va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } /* Send button request */ ButtonRequest resp; @@ -858,7 +903,7 @@ bool review(ButtonRequestType type, const char* request_title, const bool shown = confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false, true); + false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return shown; } @@ -869,16 +914,12 @@ bool review_without_button_request(const char* request_title, va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } const bool shown = confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, false, false); + false, NO_ICON, false); memzero(strbuf, sizeof(strbuf)); return shown; } @@ -890,12 +931,8 @@ bool review_with_icon(ButtonRequestType type, IconType iconNum, va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } /* Send button request */ ButtonRequest resp; @@ -906,7 +943,7 @@ bool review_with_icon(ButtonRequestType type, IconType iconNum, const bool shown = confirm_helper(request_title, strbuf, &layout_standard_notification, - false, iconNum, false, true); + false, iconNum, false); memzero(strbuf, sizeof(strbuf)); return shown; } @@ -917,12 +954,8 @@ bool review_immediate(ButtonRequestType type, const char* request_title, va_list vl; va_start(vl, request_body); - const bool formatted = format_body(request_body, vl); + format_body(request_body, vl); va_end(vl); - if (!formatted) { - memzero(strbuf, sizeof(strbuf)); - return false; - } /* Send button request */ ButtonRequest resp; @@ -933,7 +966,7 @@ bool review_immediate(ButtonRequestType type, const char* request_title, const bool shown = confirm_helper(request_title, strbuf, &layout_standard_notification, - false, NO_ICON, true, true); + false, NO_ICON, true); memzero(strbuf, sizeof(strbuf)); return shown; } diff --git a/lib/board/draw.c b/lib/board/draw.c index cb45fb39b..b34fe483b 100644 --- a/lib/board/draw.c +++ b/lib/board/draw.c @@ -355,6 +355,58 @@ void draw_box_simple(Canvas* canvas, uint8_t color, uint16_t x, uint16_t y, * OUTPUT * true/false whether image was drawn */ +/* + * draw_bitmap_mono_rle_valid() - see draw.h. Pure walk of the RLE grammar; + * writes nothing. The drawing path below stops as soon as the canvas is full, + * so it cannot tell a well-formed stream from one whose last run straddles the + * image or that carries trailing packets. Host-supplied icons must be checked + * here, at the trust boundary, before they are shown or cached for a session. + */ +bool draw_bitmap_mono_rle_valid(const uint8_t* data, uint32_t length, + uint16_t w, uint16_t h) { + if (!data || w == 0 || h == 0) { + return false; + } + + const uint32_t pixels = (uint32_t)w * (uint32_t)h; + uint32_t emitted = 0; + uint32_t i = 0; + + while (emitted < pixels) { + if (i >= length) { + return false; /* ran out of input mid-image */ + } + const uint8_t raw = data[i]; + if (raw == 0x80u || raw == 0u) { + return false; /* undecodable (int8_t counter) / not a packet */ + } + i++; + + uint32_t run; + if (raw > 127u) { + run = (uint32_t)(256u - raw); /* LITERAL: 1..127 distinct values */ + if (i + run > length) { + return false; /* literal body truncated */ + } + i += run; + } else { + run = raw; /* RUN: 1..127 copies of one value */ + if (i >= length) { + return false; /* missing the run's value byte */ + } + i++; + } + + if (emitted + run > pixels) { + return false; /* run straddles the end of the image */ + } + emitted += run; + } + + /* Exactly filled, and nothing left over. */ + return emitted == pixels && i == length; +} + bool draw_bitmap_mono_rle(Canvas* canvas, const AnimationFrame* frame, bool erase) { if (!frame || !canvas) { @@ -370,6 +422,16 @@ bool draw_bitmap_mono_rle(Canvas* canvas, const AnimationFrame* frame, return false; } + /* Validate the whole stream up front. The loop below fills the canvas and + * stops, so on its own it cannot reject a final run that straddles the image + * or trailing packets past the last pixel — it would draw and report success. + * Checking first makes the return value mean "this stream is well-formed AND + * was drawn", which is what callers gating on host-supplied icons need. + * (Verified: every bundled image stream terminates exactly.) */ + if (!draw_bitmap_mono_rle_valid(img->data, img->length, img->w, img->h)) { + return false; + } + int8_t sequence = 0; int8_t nonsequence = 0; uint32_t pixel_index = 0; @@ -383,12 +445,29 @@ bool draw_bitmap_mono_rle(Canvas* canvas, const AnimationFrame* frame, // sequence > 0 implies the next x pixels are the same // sequence < 0 implies the next -x pixels are all different if ((sequence == 0) && (nonsequence == 0)) { - sequence = img->data[pixel_index]; + /* Read the packet count. 0x80 (-128) is rejected: `nonsequence` below + * is int8_t, so -(-128) = 128 does not fit and wraps back to -128, + * breaking the `nonsequence > 0` invariant. Under NDEBUG the assert is + * compiled out and we would decode with a negative counter + * (signed-overflow UB). 0 is likewise not a valid packet: it leaves + * both counters at zero and breaks the same invariant. A host-supplied + * icon reaches here, so fail closed rather than trust the encoder. */ + const uint8_t raw = img->data[pixel_index]; + if (raw == 0x80u || raw == 0u) { + return false; + } pixel_index++; - if (sequence < 0) { - nonsequence = -sequence; + /* Explicit two's-complement read. Narrowing a uint8_t > 127 straight + * into an int8_t is implementation-defined, so spell the conversion + * out: 1..127 stay positive (RUN), 129..255 become -127..-1 (LITERAL). + */ + if (raw > 127u) { + nonsequence = (int8_t)((int)raw - 256); /* -127..-1 */ + nonsequence = (int8_t)(-nonsequence); /* 1..127, fits int8_t */ sequence = 0; + } else { + sequence = (int8_t)raw; /* 1..127 */ } } diff --git a/lib/board/font.c b/lib/board/font.c index b562acbd6..979722449 100644 --- a/lib/board/font.c +++ b/lib/board/font.c @@ -21,6 +21,7 @@ #include "keepkey/board/font.h" #include +#include /* strlen, for calc_str_line over calc_str_line_n */ /* --- Image Font ------------------------------------------------------------ */ @@ -2598,32 +2599,40 @@ uint32_t calc_str_width(const Font* font, const char* str) { * OUTPUT * line count */ -uint32_t calc_str_line(const Font* font, const char* str, uint16_t line_width) { - /* Must not be uint8_t: confirm_body_fits() treats this count as a security - * boundary, and a body carrying 255 newlines would wrap an 8-bit counter - * back to 0 and be reported as fitting on screen. */ +/* Length-bounded form. Hive needs to measure a prefix of a buffer that is not + * NUL-terminated at the point of interest, so the walk takes an explicit + * length; calc_str_line() is this function over strlen(). + * + * line_count must not be uint8_t: confirm_body_fits() treats this count as a + * security boundary, and a body carrying 255 newlines would wrap an 8-bit + * counter back to 0 and be reported as fitting on screen. The alpha-side + * version of this function used uint8_t; that is the bug 0b2f08185 fixed and it + * is not reintroduced here. */ +uint32_t calc_str_line_n(const Font* font, const char* str, size_t str_len, + uint16_t line_width) { uint32_t line_count = 1; uint16_t x_offset = 0; + size_t offset = 0; - while (*str) { - uint8_t character_width = font_get_char(font, str[0])->width; + while (offset < str_len && str[offset]) { + uint8_t character_width = font_get_char(font, str[offset])->width; uint16_t word_width = character_width; - const char* next_character = str + 1; + size_t next_offset = offset + 1; /* Allow line breaks */ - if (*str == '\n') { + if (str[offset] == '\n') { line_count++; x_offset = 0; - str++; + offset++; continue; } /* Calculate next work width */ - if (*str == ' ') { - while (*next_character && *next_character != ' ' && - *next_character != '\n') { - word_width += font_get_char(font, *next_character)->width; - next_character++; + if (str[offset] == ' ') { + while (next_offset < str_len && str[next_offset] && + str[next_offset] != ' ' && str[next_offset] != '\n') { + word_width += font_get_char(font, str[next_offset])->width; + next_offset++; } } @@ -2634,14 +2643,33 @@ uint32_t calc_str_line(const Font* font, const char* str, uint16_t line_width) { } /* Remove leading spaces */ - if (x_offset == 0 && *str == ' ') { - str++; + if (x_offset == 0 && str[offset] == ' ') { + offset++; continue; } x_offset += character_width; - str++; + offset++; } return line_count; } + +uint32_t calc_str_line(const Font* font, const char* str, uint16_t line_width) { + return calc_str_line_n(font, str, strlen(str), line_width); +} + +/* How many characters of `str` fit within max_lines. Used by Hive to page a + * long body; the confirm layer measures with the renderer instead (see + * confirm_body_fits), so this is not on a consent path. */ +size_t calc_str_page(const Font* font, const char* str, size_t str_len, + uint16_t line_width, uint32_t max_lines) { + size_t best = 0; + for (size_t take = 1; take <= str_len; take++) { + /* A longer prefix never needs fewer lines, so the first prefix that does + * not fit settles it. */ + if (calc_str_line_n(font, str, take, line_width) > max_lines) break; + best = take; + } + return best; +} diff --git a/lib/board/keepkey_board.c b/lib/board/keepkey_board.c index eeae2b65d..dc57d8c66 100644 --- a/lib/board/keepkey_board.c +++ b/lib/board/keepkey_board.c @@ -111,22 +111,11 @@ void kk_board_init(void) { layout_init(display_canvas_init()); } -#ifdef EMULATOR -/// Reverses (reflects) bits in a 32-bit word. -/// http://www.hackersdelight.org/hdcodetxt/crc.c.txt -static uint32_t reverse(unsigned x) { - x = ((x & 0x55555555) << 1) | ((x >> 1) & 0x55555555); - x = ((x & 0x33333333) << 2) | ((x >> 2) & 0x33333333); - x = ((x & 0x0F0F0F0F) << 4) | ((x >> 4) & 0x0F0F0F0F); - x = (x << 24) | ((x & 0xFF00) << 8) | ((x >> 8) & 0xFF00) | (x >> 24); - return x; -} -#endif - /* calc_crc32() - Calculate crc32 for block of memory * * INPUT - * none + * data - word-aligned block of memory + * word_len - length in 32-bit WORDS, not bytes * OUTPUT * crc32 of data */ @@ -137,20 +126,23 @@ uint32_t calc_crc32(const void* data, int word_len) { crc_reset(); crc32 = crc_calculate_block((uint32_t*)data, word_len); #else - /// http://www.hackersdelight.org/hdcodetxt/crc.c.txt + /* Model the STM32 CRC peripheral the hardware path above uses: CRC-32/MPEG-2 + * -- poly 0x04C11DB7, init 0xFFFFFFFF, no input or output reflection, no + * final XOR -- consuming one 32-bit word per iteration, MSB first. + * + * The previous implementation disagreed with hardware on both counts. It + * consumed word_len *bytes* of a reflected zlib CRC-32, so a 643-word buffer + * was covered as 643 bytes: no emulator test could demonstrate that the tail + * of storage_commit()'s 2572-byte record is protected, which is exactly the + * property the V17 CRC fix needed to prove. */ + const uint32_t* words = (const uint32_t*)data; crc32 = 0xFFFFFFFF; for (int i = 0; i < word_len; i++) { - uint32_t byte = ((const char*)data)[i]; // Get next byte. - byte = reverse(byte); // 32-bit reversal. - for (int j = 0; j <= 7; j++) { // Do eight times. - if ((int)(crc32 ^ byte) < 0) - crc32 = (crc32 << 1) ^ 0x04C11DB7; - else - crc32 = crc32 << 1; - byte = byte << 1; // Ready next msg bit. + crc32 ^= words[i]; + for (int j = 0; j < 32; j++) { + crc32 = (crc32 & 0x80000000u) ? (crc32 << 1) ^ 0x04C11DB7 : (crc32 << 1); } } - crc32 = reverse(~crc32); #endif return crc32; diff --git a/lib/board/layout.c b/lib/board/layout.c index c89aed04e..0a81c1c36 100644 --- a/lib/board/layout.c +++ b/lib/board/layout.c @@ -324,12 +324,30 @@ void layout_standard_notification(const char* str1, const char* str2, * OUTPUT * none */ +/* Frame drawn for RUNTIME_ICON — a loaded clear-sign identity logo. Set by + * layout_set_runtime_icon() before the confirm; the caller owns the storage. */ +static const AnimationFrame* runtime_icon_frame = NULL; + +void layout_set_runtime_icon(const struct AnimationFrame_* frame) { + runtime_icon_frame = frame; +} + void layout_add_icon(IconType type) { switch (type) { case ETHEREUM_ICON: + /* ponytail: reuse the ETH glyph as the "verified" mark — it's an ETH tx. + * Swap in a dedicated checkmark bitmap if the trust mark needs to differ. + */ + case VERIFIED_ICON: draw_bitmap_mono_rle(canvas, get_ethereum_icon_frame(), false); break; + case RUNTIME_ICON: + if (runtime_icon_frame) { + draw_bitmap_mono_rle(canvas, runtime_icon_frame, false); + } + break; + default: /* no action requires */ break; @@ -637,6 +655,20 @@ void layout_animate_images(void* data, uint32_t duration, uint32_t elapsed) { } } +#if DEBUG_LINK +void layout_debuglink_watermark(void) { + const Font* font = get_body_font(); + const char* watermark = "DEBUG_LINK"; + DrawableParams sp; + sp.x = KEEPKEY_DISPLAY_WIDTH - calc_str_width(font, watermark) - + BODY_FONT_LINE_PADDING; + sp.y = KEEPKEY_DISPLAY_HEIGHT - font_height(font); + sp.color = 0x22; + draw_string(canvas, font, watermark, &sp, KEEPKEY_DISPLAY_WIDTH, + font_height(font)); +} +#endif + /* * layout_clear() - Clear animation queue and clear display * @@ -649,6 +681,9 @@ void layout_clear(void) { layout_clear_animations(); layout_clear_static(); +#if DEBUG_LINK + layout_debuglink_watermark(); +#endif } /* @@ -689,11 +724,20 @@ static const char* _otpStr = ""; * OTP in large font desc - text to display permil - progress in units of 1 to * 1000 OUTPUT none */ -void animating_progress_handler(const char* desc, int permil) { +/* Render the progress bar into the framebuffer WITHOUT clearing the animation + * queue, so an animation callback (trickle_progress_callback) can redraw itself + * every frame without removing itself from the queue. + * + * marker_phase: 0..999 breathes a glint on the fill's leading segment (a + * perpetual "working" cue that keeps the display visibly moving even after + * the eased fill has pixel-saturated); pass -1 for no glint. */ +static void progress_render_ex(const char* desc, int permil, int marker_phase) { if (!canvas) return; - call_leaving_handler(); - layout_clear(); + layout_clear_static(); +#if DEBUG_LINK + layout_debuglink_watermark(); +#endif permil = permil >= 1000 ? 1000 : permil; permil = permil <= 0 ? 0 : permil; @@ -758,9 +802,43 @@ void animating_progress_handler(const char* desc, int permil) { draw_box(canvas, &bp); } + // Front glint: the fill's leading segment breathes (dim <-> bright) while + // the trickle is active. Activity always shows exactly at the progress + // front — unlike a marker sweeping the track, it cannot detach from the + // fill and open a gap, and it cannot run out of travel as the unfilled + // span shrinks near 100% (long final-action proofs). + if (marker_phase >= 0 && finished_width > 4) { + const uint32_t glint_max = 10; + uint32_t glint_w = + finished_width - 2 < glint_max ? finished_width - 2 : glint_max; + /* Triangle wave 0..500..0 over one breath period. */ + uint32_t tri = (uint32_t)marker_phase < 500 ? (uint32_t)marker_phase + : 1000 - (uint32_t)marker_phase; + bp.width = glint_w; + bp.height = height - 2; + bp.base.x = x + finished_width - glint_w; + bp.base.y = y + 1; + bp.base.color = (uint8_t)(0x44 + (tri * 0x66) / 500); + draw_box(canvas, &bp); + } + display_refresh(); } +static void progress_render(const char* desc, int permil) { + progress_render_ex(desc, permil, -1); +} + +/* One-shot progress draw: clears any queued animation (historical behaviour, so + * a stray animation cannot redraw over a static progress screen) then renders. + */ +void animating_progress_handler(const char* desc, int permil) { + if (!canvas) return; + call_leaving_handler(); + layout_clear_animations(); + progress_render(desc, permil); +} + void layoutProgress(const char* desc, int permil) { animating_progress_handler(desc, permil); } @@ -801,6 +879,87 @@ void layout_add_animation(AnimateCallback callback, void* data, animation_queue_push(&active_queue, animation); } +/* --- Trickle progress for long host-driven operations ----------------------- + * Shielded Zcash signing blocks on the host generating zk-proofs, so the device + * would otherwise sit on a frozen progress bar and look like it has failed. + * This ramps a "trickle" smoothly through most of the gap to the next real + * milestone over the expected host-proof duration, holding short of it (so it + * never falsely shows work done). It is driven off the animation timer, which + * layout_animate_poll() pumps from usbPoll() while the device blocks on host + * I/O. A dedicated flag gates that pump so no other flow is affected. */ +static volatile bool trickle_active = false; +static struct { + const char* desc; + int base; /* permil committed by the last real milestone */ + int target; /* permil to ease toward (the next milestone) */ +} trickle; + +static void trickle_progress_callback(void* data, uint32_t duration, + uint32_t elapsed) { + (void)data; + (void)duration; + /* Host proof windows between milestones run ~40-50s. Ramp linearly through + * 90% of the milestone span over that guessed duration, then hold — the + * last 10% is only crossed by the next REAL milestone, so the bar never + * claims work that hasn't happened. The breathing glint keeps signalling + * activity while the ramp holds. + * ponytail: EXPECTED_MS is a guess, not a measurement — retune if host + * proof times change materially. */ + const uint32_t EXPECTED_MS = 45000; + int span = trickle.target - trickle.base; + int cap = (span * 9) / 10; + int add = 0; + if (cap > 0) { + add = elapsed >= EXPECTED_MS + ? cap + : (int)(((uint64_t)cap * elapsed) / EXPECTED_MS); + } + /* Glint phase loops forever, so the display keeps changing even after the + * eased fill has stopped producing new pixels (long zk-proof waits). */ + const uint32_t BREATH_PERIOD = 1600; /* ms per dim<->bright breath cycle */ + int phase = (int)(((elapsed % BREATH_PERIOD) * 1000) / BREATH_PERIOD); + /* Draw via progress_render_ex (not animating_progress_handler) so redrawing + * the frame does not clear the animation queue and remove this callback. */ + progress_render_ex(trickle.desc, trickle.base + add, phase); +} + +/* (Re-)arm the trickle to ease from base_permil toward target_permil. Re-adding + * the callback resets its elapsed to 0 so the ease restarts from base_permil. + */ +void layoutProgressTrickle(const char* desc, int base_permil, + int target_permil) { + trickle.desc = desc; + trickle.base = base_permil; + trickle.target = target_permil; + trickle_active = true; + layout_add_animation(&trickle_progress_callback, NULL, 0 /* loop forever */); + force_animation_start(); + /* Draw the first frame now (at base, glint at its dimmest) so the bar + * appears immediately, before the animation timer next fires. + * progress_render_ex keeps the animation queue intact. */ + progress_render_ex(desc, base_permil, 0); +} + +void layoutProgressTrickleStop(void) { + trickle_active = false; + Animation* animation = + animation_queue_get(&active_queue, &trickle_progress_callback); + if (animation != NULL) { + animation_queue_push(&free_queue, animation); + } +} + +/* Advance a queued progress animation one step if the timer has ticked. Called + * from usbPoll() so the trickle keeps moving while we block on host I/O. Gated + * on trickle_active so it is a no-op for every other flow (confirm dialogs, + * PIN entry, etc. are untouched). */ +void layout_animate_poll(void) { + if (trickle_active && is_animating()) { + animate(); + display_refresh(); + } +} + /* * layout_clear_animations() - Clear all animation from queue * @@ -810,6 +969,7 @@ void layout_add_animation(AnimateCallback callback, void* data, * none */ void layout_clear_animations(void) { + trickle_active = false; Animation* animation = animation_queue_pop(&active_queue); while (animation != NULL) { diff --git a/lib/board/messages.c b/lib/board/messages.c index cc2ad25f1..f2a6c7699 100644 --- a/lib/board/messages.c +++ b/lib/board/messages.c @@ -44,6 +44,66 @@ static msg_debug_link_get_state_t msg_debug_link_get_state; */ bool reset_msg_stack = false; +/* ── Shared frame arena ────────────────────────────────────────────────── + * One MAX_FRAME_SIZE-class buffer shared by three mutually-exclusive users: + * + * 1. Inbound frame reassembly (usb_rx_helper writes frame_arena.rx). + * 2. Outbound wire encode (msg_write / msg_debug_write encode into + * frame_arena.tx via frame_arena_tx()) — previously a 12 KB automatic + * TrezorFrameBuffer on the msg_write stack, which is what overflowed + * the zcash-privacy variant's 11 KB stack gap on the STM32F205. + * 3. Large transient in-handler scratch (frame_arena_scratch2049 for the + * recovery-cipher wordlist permutation). + * + * Why this is safe: the transport is strictly cooperative/single-threaded. + * usbd_poll() runs only from explicit usbPoll() call sites (main loop, the + * tiny-message pump, u2f) — there is no USB ISR — so RX can never preempt + * a TX encode or an executing handler. Tiny-mode RX (button/pin/cancel + * during a handler wait) goes through msg_read_tiny's own 64-byte buffer + * and never touches this arena. RAW dispatch hands handlers the 64-byte + * packet buffer, not the arena. + * + * Contract: acquiring the arena for TX or scratch DROPS any partially + * reassembled inbound frame. Only a host that pipelines a second request + * before reading the first response can hit this; it gets a Failure on its + * next continuation frame instead of silent corruption (the protocol is + * strict request-response). + */ +typedef union { + uint8_t rx[MAX_FRAME_SIZE]; + TrezorFrameBuffer tx; + uint16_t scratch_u16[2049]; +} FrameArena; + +static FrameArena frame_arena; + +/* Inbound reassembly state — file scope so arena acquisition can reset it. */ +static bool rxFirstFrame = true; +static uint16_t rxMsgId = 0xffff; +static uint32_t rxMsgSize = 0; +static size_t + rxCursor; //< Index into frame_arena.rx where the next frame lands. +static const MessagesMap_t* rxEntry = NULL; + +static void frame_arena_rx_reset(void) { + rxMsgId = 0xffff; + rxMsgSize = 0; + memset(frame_arena.rx, 0, sizeof(frame_arena.rx)); + rxCursor = 0; + rxFirstFrame = true; + rxEntry = NULL; +} + +TrezorFrameBuffer* frame_arena_tx(void) { + frame_arena_rx_reset(); + return &frame_arena.tx; +} + +uint16_t* frame_arena_scratch2049(void) { + frame_arena_rx_reset(); + return frame_arena.scratch_u16; +} + /* * message_map_entry() - Finds a requested message map entry * @@ -200,21 +260,15 @@ static void raw_dispatch(const MessagesMap_t* entry, const uint8_t* msg, /// Common helper that handles USB messages from host void usb_rx_helper(const uint8_t* buf, size_t length, MessageMapType type) { - static bool firstFrame = true; - - static uint16_t msgId; - static uint32_t msgSize; - static uint8_t msg[MAX_FRAME_SIZE]; - static size_t - cursor; //< Index into msg where the current frame is to be written. - static const MessagesMap_t* entry; - - if (firstFrame) { - msgId = 0xffff; - msgSize = 0; - memset(msg, 0, sizeof(msg)); - cursor = 0; - entry = NULL; + /* Reassembly state + buffer live at file scope (frame_arena.rx) so that + * frame_arena_tx()/frame_arena_scratch2049() can invalidate a partial + * inbound frame — see the FrameArena contract above. */ + if (rxFirstFrame) { + rxMsgId = 0xffff; + rxMsgSize = 0; + memset(frame_arena.rx, 0, sizeof(frame_arena.rx)); + rxCursor = 0; + rxEntry = NULL; } assert(buf != NULL); @@ -229,7 +283,7 @@ void usb_rx_helper(const uint8_t* buf, size_t length, MessageMapType type) { goto reset; } - if (firstFrame && (buf[1] != '#' || buf[2] != '#')) { + if (rxFirstFrame && (buf[1] != '#' || buf[2] != '#')) { (*msg_failure)(FailureType_Failure_UnexpectedMessage, "Malformed packet"); goto reset; } @@ -238,25 +292,25 @@ void usb_rx_helper(const uint8_t* buf, size_t length, MessageMapType type) { const uint8_t* frame; size_t frameSize; - if (firstFrame) { + if (rxFirstFrame) { // Reset the buffer that we're writing fragments into. - memset(msg, 0, sizeof(msg)); + memset(frame_arena.rx, 0, sizeof(frame_arena.rx)); // Then fish out the id / size, which are big-endian uint16 / // uint32's respectively. - msgId = buf[4] | ((uint16_t)buf[3]) << 8; - msgSize = buf[8] | ((uint32_t)buf[7]) << 8 | ((uint32_t)buf[6]) << 16 | - ((uint32_t)buf[5]) << 24; + rxMsgId = buf[4] | ((uint16_t)buf[3]) << 8; + rxMsgSize = buf[8] | ((uint32_t)buf[7]) << 8 | ((uint32_t)buf[6]) << 16 | + ((uint32_t)buf[5]) << 24; // Determine callback handler and message map type. - entry = message_map_entry(type, msgId, IN_MSG); + rxEntry = message_map_entry(type, rxMsgId, IN_MSG); // And reset the cursor. - cursor = 0; + rxCursor = 0; // Then take note of the fragment boundaries. frame = &buf[9]; - frameSize = MIN(length - 9, msgSize); + frameSize = MIN(length - 9, rxMsgSize); } else { // Otherwise it's a continuation/fragment. frame = &buf[1]; @@ -264,49 +318,45 @@ void usb_rx_helper(const uint8_t* buf, size_t length, MessageMapType type) { } // If the msgId wasn't in our map, bail. - if (!entry) { + if (!rxEntry) { (*msg_failure)(FailureType_Failure_UnexpectedMessage, "Unknown message"); goto reset; } - if (entry->dispatch == RAW) { + if (rxEntry->dispatch == RAW) { /* Call dispatch for every segment since we are not buffering and parsing, * and assume the raw dispatched callbacks will handle their own state and * buffering internally */ - raw_dispatch(entry, frame, frameSize, msgSize); - firstFrame = false; + raw_dispatch(rxEntry, frame, frameSize, rxMsgSize); + rxFirstFrame = false; return; } size_t end; - if (check_uadd_overflow(cursor, frameSize, &end) || sizeof(msg) < end) { + if (check_uadd_overflow(rxCursor, frameSize, &end) || + sizeof(frame_arena.rx) < end) { (*msg_failure)(FailureType_Failure_UnexpectedMessage, "Malformed message"); goto reset; } // Copy content to frame buffer. - memcpy(&msg[cursor], frame, frameSize); + memcpy(&frame_arena.rx[rxCursor], frame, frameSize); // Advance the cursor. - cursor = end; + rxCursor = end; // Only parse and message map if all segments have been buffered. - bool last_segment = cursor >= msgSize; + bool last_segment = rxCursor >= rxMsgSize; if (!last_segment) { - firstFrame = false; + rxFirstFrame = false; return; } - dispatch(entry, msg, msgSize); + dispatch(rxEntry, frame_arena.rx, rxMsgSize); reset: - msgId = 0xffff; - msgSize = 0; - memset(msg, 0, sizeof(msg)); - cursor = 0; - firstFrame = true; - entry = NULL; + frame_arena_rx_reset(); } /* Tiny messages */ diff --git a/lib/board/signatures.c b/lib/board/signatures.c index f4be6bf82..9bf2107fb 100644 --- a/lib/board/signatures.c +++ b/lib/board/signatures.c @@ -20,7 +20,9 @@ #include "trezor/crypto/sha2.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/memzero.h" #include "keepkey/board/memory.h" +#include "keepkey/board/memcmp_s.h" #include "keepkey/board/signatures.h" #include "keepkey/board/pubkeys.h" @@ -68,23 +70,51 @@ int signatures_ok(void) { return KEY_EXPIRED; } /* Expired signing key */ + /* F3 hardening: double-compute SHA-256, compare in constant time */ + uint8_t firmware_fingerprint2[32]; sha256_Raw((uint8_t*)FLASH_APP_START, codelen, firmware_fingerprint); + asm volatile("" ::: "memory"); + sha256_Raw((uint8_t*)FLASH_APP_START, codelen, firmware_fingerprint2); - if (ecdsa_verify_digest(&secp256k1, pubkey[sigindex1 - 1], - (uint8_t*)FLASH_META_SIG1, - firmware_fingerprint) != 0) { /* Failure */ + if (memcmp_s(firmware_fingerprint, firmware_fingerprint2, 32) != 0) { + memzero(firmware_fingerprint, sizeof(firmware_fingerprint)); + memzero(firmware_fingerprint2, sizeof(firmware_fingerprint2)); return SIG_FAIL; } + memzero(firmware_fingerprint2, sizeof(firmware_fingerprint2)); - if (ecdsa_verify_digest(&secp256k1, pubkey[sigindex2 - 1], - (uint8_t*)FLASH_META_SIG2, - firmware_fingerprint) != 0) { /* Failure */ + /* F3 hardening: infective aggregation — accumulate all three ECDSA + * results instead of early-returning on each. Forces attacker to + * corrupt all three verify calls, not just skip one branch. */ + volatile int verify_acc = 0; + volatile int verify_sentinel = 0; + + verify_acc |= + ecdsa_verify_digest(&secp256k1, pubkey[sigindex1 - 1], + (uint8_t*)FLASH_META_SIG1, firmware_fingerprint); + verify_sentinel++; + asm volatile("" ::: "memory"); + + verify_acc |= + ecdsa_verify_digest(&secp256k1, pubkey[sigindex2 - 1], + (uint8_t*)FLASH_META_SIG2, firmware_fingerprint); + verify_sentinel++; + asm volatile("" ::: "memory"); + + verify_acc |= + ecdsa_verify_digest(&secp256k1, pubkey[sigindex3 - 1], + (uint8_t*)FLASH_META_SIG3, firmware_fingerprint); + verify_sentinel++; + asm volatile("" ::: "memory"); + + memzero(firmware_fingerprint, sizeof(firmware_fingerprint)); + + /* All three verifies must have executed and all must have passed */ + if (verify_sentinel != 3) { return SIG_FAIL; } - if (ecdsa_verify_digest(&secp256k1, pubkey[sigindex3 - 1], - (uint8_t*)FLASH_META_SIG3, - firmware_fingerprint) != 0) { /* Failure */ + if (verify_acc != 0) { return SIG_FAIL; } diff --git a/lib/board/timer.c b/lib/board/timer.c index 3868b6d16..c66c62e4d 100644 --- a/lib/board/timer.c +++ b/lib/board/timer.c @@ -25,6 +25,11 @@ #else #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN /* exclude winsock.h — it declares \ + shutdown(SOCKET,int) */ +#include /* Sleep() */ +#endif #endif #include "keepkey/board/keepkey_board.h" @@ -229,11 +234,13 @@ void timer_init(void) { nvic_set_priority(NVIC_TIM4_IRQ, 16 * 2); timer_enable_counter(TIM4); -#else +#elif !defined(_WIN32) void tim4_sighandler(int sig); signal(SIGALRM, tim4_sighandler); ualarm(1000, 1000); #endif + /* _WIN32: no SIGALRM/ualarm — libkkemu's kkemu_poll() drives timerisr_usr(). + */ } uint32_t fi_defense_delay(volatile uint32_t value) { @@ -287,8 +294,21 @@ void delay_us(uint32_t us) { void delay_ms(uint32_t ms) { remaining_delay = ms; +#ifdef _WIN32 + /* No async SIGALRM timer on Windows, and kkemu_poll() drives timerisr_usr() + * only once per poll — so a plain spin here would never make progress when + * delay_ms() is reached from inside usbPoll() (e.g. PIN/U2F/authenticator + * flows). Advance the tick ourselves from wall-clock Sleep instead. Keeps + * timeSinceWakeup + the runnable queue moving exactly like the SIGALRM path, + * and stays single-threaded (no data races). */ while (remaining_delay > 0) { + Sleep(1); + timerisr_usr(); } +#else + while (remaining_delay > 0) { + } +#endif } /* @@ -310,6 +330,12 @@ void delay_ms_with_callback(uint32_t ms, callback_func_t callback_func, if (remaining_delay % frequency_ms == 0) { (*callback_func)(); } +#ifdef _WIN32 + /* See delay_ms(): drive the tick from wall-clock Sleep on Windows so this + * loop terminates when reached from inside usbPoll(). */ + Sleep(1); + timerisr_usr(); +#endif } } @@ -348,7 +374,7 @@ void timerisr_usr(void) { #endif } -#ifdef EMULATOR +#if defined(EMULATOR) && !defined(_WIN32) void tim4_sighandler(int sig) { timerisr_usr(); } #endif diff --git a/lib/board/udp.c b/lib/board/udp.c index ba35d15ec..1240c2155 100644 --- a/lib/board/udp.c +++ b/lib/board/udp.c @@ -21,6 +21,7 @@ #include "keepkey/board/usb.h" #include "keepkey/board/timer.h" +#include "keepkey/board/layout.h" #include "keepkey/emulator/emulator.h" #include @@ -62,6 +63,11 @@ void usbPoll(void) { // msg_read_tiny(msg.message, sizeof(msg.message)); } } + + // Keep a queued progress animation moving while we block on host I/O (e.g. + // Zcash proof generation on the host), matching device usbPoll(). No-op + // unless a trickle animation is active. + layout_animate_poll(); } bool usb_tx(const uint8_t* msg, uint32_t len) { diff --git a/lib/board/usb.c b/lib/board/usb.c index 0db5e32c6..53fb07a89 100644 --- a/lib/board/usb.c +++ b/lib/board/usb.c @@ -414,6 +414,11 @@ void usbInit(const char* origin_url) { void usbPoll(void) { // poll read buffer usbd_poll(usbd_dev); + // Keep a queued progress animation moving while we block on host I/O (e.g. + // Zcash proof generation on the host), so the screen never looks frozen. + // No-op unless a trickle animation is active, so all other flows are + // unaffected. + layout_animate_poll(); } void usbReconnect(void) { @@ -435,28 +440,31 @@ bool msg_write(MessageType msg_id, const void* msg) { if (!fields) return false; - TrezorFrameBuffer framebuf; - memset(&framebuf, 0, sizeof(framebuf)); - framebuf.frame.usb_header.hid_type = '?'; - framebuf.frame.header.pre1 = '#'; - framebuf.frame.header.pre2 = '#'; - framebuf.frame.header.id = __builtin_bswap16(msg_id); + /* Encode into the shared frame arena instead of a 12 KB automatic — that + * stack frame overflowed the zcash-privacy variant's SRAM gap. Safe on the + * single-threaded transport; see the FrameArena contract in messages.c. */ + TrezorFrameBuffer* framebuf = frame_arena_tx(); + memset(framebuf, 0, sizeof(*framebuf)); + framebuf->frame.usb_header.hid_type = '?'; + framebuf->frame.header.pre1 = '#'; + framebuf->frame.header.pre2 = '#'; + framebuf->frame.header.id = __builtin_bswap16(msg_id); pb_ostream_t os = - pb_ostream_from_buffer(framebuf.buffer, sizeof(framebuf.buffer)); + pb_ostream_from_buffer(framebuf->buffer, sizeof(framebuf->buffer)); if (!pb_encode(&os, fields, msg)) return false; - framebuf.frame.header.len = __builtin_bswap32(os.bytes_written); + framebuf->frame.header.len = __builtin_bswap32(os.bytes_written); // Chunk out data - for (uint32_t pos = 1; pos < sizeof(framebuf.frame) + os.bytes_written; + for (uint32_t pos = 1; pos < sizeof(framebuf->frame) + os.bytes_written; pos += 64 - 1) { uint8_t tmp_buffer[64] = {0}; tmp_buffer[0] = '?'; - memcpy(tmp_buffer + 1, ((const uint8_t*)&framebuf) + pos, 64 - 1); + memcpy(tmp_buffer + 1, ((const uint8_t*)framebuf) + pos, 64 - 1); #ifndef EMULATOR while (usbd_ep_write_packet(usbd_dev, ENDPOINT_ADDRESS_IN, tmp_buffer, @@ -476,28 +484,29 @@ bool msg_debug_write(MessageType msg_id, const void* msg) { if (!fields) return false; - TrezorFrameBuffer framebuf; - memset(&framebuf, 0, sizeof(framebuf)); - framebuf.frame.usb_header.hid_type = '?'; - framebuf.frame.header.pre1 = '#'; - framebuf.frame.header.pre2 = '#'; - framebuf.frame.header.id = __builtin_bswap16(msg_id); + /* Same shared-arena encode as msg_write — see the FrameArena contract. */ + TrezorFrameBuffer* framebuf = frame_arena_tx(); + memset(framebuf, 0, sizeof(*framebuf)); + framebuf->frame.usb_header.hid_type = '?'; + framebuf->frame.header.pre1 = '#'; + framebuf->frame.header.pre2 = '#'; + framebuf->frame.header.id = __builtin_bswap16(msg_id); pb_ostream_t os = - pb_ostream_from_buffer(framebuf.buffer, sizeof(framebuf.buffer)); + pb_ostream_from_buffer(framebuf->buffer, sizeof(framebuf->buffer)); if (!pb_encode(&os, fields, msg)) return false; - framebuf.frame.header.len = __builtin_bswap32(os.bytes_written); + framebuf->frame.header.len = __builtin_bswap32(os.bytes_written); // Chunk out data - for (uint32_t pos = 1; pos < sizeof(framebuf.frame) + os.bytes_written; + for (uint32_t pos = 1; pos < sizeof(framebuf->frame) + os.bytes_written; pos += 64 - 1) { uint8_t tmp_buffer[64] = {0}; tmp_buffer[0] = '?'; - memcpy(tmp_buffer + 1, ((const uint8_t*)&framebuf) + pos, 64 - 1); + memcpy(tmp_buffer + 1, ((const uint8_t*)framebuf) + pos, 64 - 1); #ifndef EMULATOR while (usbd_ep_write_packet(usbd_dev, ENDPOINT_ADDRESS_DEBUG_IN, tmp_buffer, diff --git a/lib/board/util.c b/lib/board/util.c index 0d4e1a23c..c3bca1f23 100644 --- a/lib/board/util.c +++ b/lib/board/util.c @@ -104,16 +104,31 @@ bool is_valid_ascii(const uint8_t* data, uint32_t size) { /* convert number in base units to specified decimal precision */ int base_to_precision(uint8_t* dest, const uint8_t* value, - const uint8_t dest_len, const uint8_t value_len, + const size_t dest_len, const size_t value_len, const uint8_t precision) { if (!(dest && value)) { // invalid pointer return -1; } - if (dest_len == 0) { + if (dest_len == 0 || value_len == 0) { return -1; } + /* Decimal inputs are signed as strings. Accept only their unique canonical + representation so the value shown on the OLED is byte-for-byte bound to + the value placed in the transaction: no leading zeros ("01" and "1" would + otherwise render identically) and no non-digit bytes (a "1x" would render + as the digits around whatever the host smuggled in). Rejecting here is + what makes the callers' negative-return refusal paths reachable. */ + if (value_len > 1 && value[0] == '0') { + return -1; + } + for (size_t i = 0; i < value_len; i++) { + if (value[i] < '0' || value[i] > '9') { + return -1; + } + } + /* Rewritten with explicit index arithmetic. The previous implementation had two defects, both reachable from the Osmosis formatters: diff --git a/lib/board/variant.c b/lib/board/variant.c index 40ee2b9e6..af0838cfd 100644 --- a/lib/board/variant.c +++ b/lib/board/variant.c @@ -152,7 +152,16 @@ const VariantAnimation* variant_getLogo(bool reversed) { const char* variant_getName(void) { #ifdef EMULATOR +#if BITCOIN_ONLY + /* The bitcoin-only emulator must NOT answer "Emulator": the pyk suite's + common.requires_fullFeature() skips a test when the variant is + "KeepKeyBTC" or "EmulatorBTC", so reporting the full-feature name here + meant it never skipped anything and every multi-chain test ran against a + bitcoin-only device. */ + return "EmulatorBTC"; +#else return "Emulator"; +#endif #else if (name) { return name; diff --git a/lib/emulator/CMakeLists.txt b/lib/emulator/CMakeLists.txt index fa5909a17..7d7eef5f8 100644 --- a/lib/emulator/CMakeLists.txt +++ b/lib/emulator/CMakeLists.txt @@ -3,7 +3,8 @@ if(${KK_EMULATOR}) set(sources oled.c udp.c - setup.c) + setup.c + random.c) @@ -19,6 +20,7 @@ if(${KK_EMULATOR}) oled.c udp.c setup.c + random.c ringbuf.c libkkemu.c) @@ -34,7 +36,14 @@ if(${KK_EMULATOR}) target_include_directories(kkemulator_dylib PRIVATE ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) + # libkkemu.c runs the firmware event loop on a dedicated thread (POSIX + # pthreads; Win32 CreateThread under MinGW). Threads::Threads is a no-op on + # the Win32 path and pulls in -lpthread on POSIX. Use the PLAIN signature — + # tools/emulator/CMakeLists.txt links this same target plainly, and CMake + # forbids mixing keyword (PRIVATE) and plain target_link_libraries calls. + find_package(Threads REQUIRED) + target_link_libraries(kkemulator_dylib Threads::Threads) set_target_properties(kkemulator_dylib PROPERTIES OUTPUT_NAME "kkemu" POSITION_INDEPENDENT_CODE ON) diff --git a/lib/emulator/libkkemu.c b/lib/emulator/libkkemu.c index 4730f9a05..beb1c32de 100644 --- a/lib/emulator/libkkemu.c +++ b/lib/emulator/libkkemu.c @@ -25,11 +25,58 @@ #include #include #include +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN /* exclude winsock.h — it declares \ + shutdown(SOCKET,int) */ +#include +#else #include +#include +#include +#endif /* Defined in firmware — we just need the declaration */ extern void fsm_init(void); +/* ── Poll thread (Approach B: reactive confirm) ────────────────────────── + * + * Optional: the host calls kkemu_start() to run the firmware event loop on a + * dedicated thread inside the dylib. This lets confirm_helper's blocking C + * busy-loop wait for a button decision IN C without freezing the host's event + * loop — so the vault can render the real OLED confirm frame, HOLD it, and + * deliver the DebugLinkDecision only when the user clicks (screen-first gating, + * like a physical device). + * + * Only the poll thread ever drives firmware execution (kkemu_poll_body). The + * host interacts solely through the lock-free SPSC rings (kkemu_write/read, + * kkemu_pop_frame). g_fw_lock serializes the poll body against host-side flash + * snapshots (kkemu_lock/unlock) so storage_commit can't tear a saveFlash read. + * + * When the thread is NOT started (g_poll_running == 0) the dylib stays purely + * single-threaded and host-driven via kkemu_poll() — exactly as the FFI test + * suite and python-keepkey tests use it. The lock helpers no-op in that mode. + */ +#ifdef _WIN32 +static CRITICAL_SECTION g_fw_lock; +static HANDLE g_poll_thread = NULL; +#define FW_LOCK() EnterCriticalSection(&g_fw_lock) +#define FW_UNLOCK() LeaveCriticalSection(&g_fw_lock) +#else +static pthread_mutex_t g_fw_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_t g_poll_thread; +#define FW_LOCK() pthread_mutex_lock(&g_fw_lock) +#define FW_UNLOCK() pthread_mutex_unlock(&g_fw_lock) +#endif + +/* Cross-thread poll-running flag. _Atomic (not volatile — volatile is not a + * synchronization primitive in C): the poll thread reads it each loop while + * start/stop write it from the host thread. acquire/release publishes the + * surrounding firmware/ring state alongside the flag. */ +#include +static _Atomic int g_poll_running = 0; +#define POLL_RUNNING() atomic_load_explicit(&g_poll_running, memory_order_acquire) +#define POLL_SET(v) atomic_store_explicit(&g_poll_running, (v), memory_order_release) + /* ── Ring buffers (replace UDP sockets) ─────────────────────────────── */ static RingBuf rb_main_in; /* host → firmware (main interface) */ @@ -46,21 +93,27 @@ static int libkkemu_initialized = 0; * The host drains via kkemu_pop_frame(). Adjacent identical frames are * skipped so an idle firmware doesn't spam the ring. * - * Sized for ~4 seconds at 16ms refresh; if the host falls behind the - * oldest frames are dropped (write advances past read). + * Sized for ~4 seconds at 16ms refresh. Cross-thread in thread-driven mode: + * the poll thread is the sole producer, the host (kkemu_pop_frame) the sole + * consumer — a lock-free SPSC ring with the same atomic discipline as the HID + * rings (ringbuf.c). When the ring is full the producer drops the NEW frame + * (it must NOT overwrite a slot the consumer may be mid-copy on, and it must + * NOT write the consumer-owned read index). */ #define FRAME_PACKED_SIZE 2048 #define FRAME_RING_SIZE 64 +/* Host poll cadence (the vault's setInterval is ~16ms). kkemu_poll() ticks the + * firmware ms-timer this many times per call so animations advance at ~real + * speed without relying on the (host-runtime-unreliable) SIGALRM timer. */ +#define KKEMU_POLL_INTERVAL_MS 16 + static uint8_t frame_ring[FRAME_RING_SIZE][FRAME_PACKED_SIZE]; -static uint8_t last_packed[FRAME_PACKED_SIZE]; -/* Pack target for libkkemu_capture_frame(), so a frame that turns out to be a - duplicate never touches the ring. See the comment there. */ -static uint8_t capture_scratch[FRAME_PACKED_SIZE]; -static int last_packed_valid = 0; -static uint32_t frame_write_idx = - 0; /* monotonic, mod FRAME_RING_SIZE for slot */ -static uint32_t frame_read_idx = 0; /* monotonic */ +static uint8_t last_packed[FRAME_PACKED_SIZE]; /* producer-only (poll thread) */ +static int last_packed_valid = 0; /* producer-only */ +static uint8_t capture_scratch[FRAME_PACKED_SIZE]; /* producer-only pack buffer */ +static _Atomic uint32_t frame_write_idx = 0; /* written by producer ONLY */ +static _Atomic uint32_t frame_read_idx = 0; /* written by consumer ONLY */ /* * Scratch returned by kkemu_get_display(). File-scope (not function-static) @@ -111,15 +164,9 @@ size_t libkkemu_socketWrite(int iface, const void* buffer, size_t size) { static void libkkemu_capture_frame(const uint8_t* canvas_buf) { if (!canvas_buf) return; - /* Pack into scratch, NOT straight into the ring slot. - * - * Packing in place and only then testing for a duplicate destroyed data: - * once the ring is full, frame_ring[frame_write_idx % FRAME_RING_SIZE] is - * the OLDEST UNREAD frame, and the early return on a duplicate left it - * overwritten while frame_read_idx still pointed at it. The host's next - * kkemu_pop_frame() then returned a frame it had never been shown, and the - * one it was owed was gone. Deduplicate first; touch the ring only for a - * frame that is actually going to be published. */ + /* Pack into a producer-private scratch — NOT a ring slot. When the ring is + * full the next write slot still holds an unread frame the consumer may be + * copying, so we must decide to publish/drop before touching it. */ memset(capture_scratch, 0, FRAME_PACKED_SIZE); for (int x = 0; x < 256; x++) { for (int y = 0; y < 64; y++) { @@ -129,21 +176,25 @@ static void libkkemu_capture_frame(const uint8_t* canvas_buf) { } } - /* Dedup: skip if identical to last captured */ + /* Dedup against the last captured frame (producer-only state). */ if (last_packed_valid && memcmp(capture_scratch, last_packed, FRAME_PACKED_SIZE) == 0) { return; } + /* SPSC publish, drop-on-full (same discipline as ringbuf.c). The producer + * writes only frame_write_idx; the consumer writes only frame_read_idx. When + * not full, write%SIZE != read%SIZE (their distance is in [1, SIZE-1]), so + * producer and consumer never touch the same slot. last_packed is updated + * ONLY on a real publish, so a frame dropped while full can still be captured + * on a later tick. */ + uint32_t w = atomic_load_explicit(&frame_write_idx, memory_order_relaxed); + uint32_t r = atomic_load_explicit(&frame_read_idx, memory_order_acquire); + if (w - r >= FRAME_RING_SIZE) return; /* full → drop the new frame */ + + memcpy(frame_ring[w % FRAME_RING_SIZE], capture_scratch, FRAME_PACKED_SIZE); memcpy(last_packed, capture_scratch, FRAME_PACKED_SIZE); last_packed_valid = 1; - - memcpy(frame_ring[frame_write_idx % FRAME_RING_SIZE], capture_scratch, - FRAME_PACKED_SIZE); - frame_write_idx++; - /* Drop oldest if host fell behind */ - if (frame_write_idx - frame_read_idx > FRAME_RING_SIZE) { - frame_read_idx = frame_write_idx - FRAME_RING_SIZE; - } + atomic_store_explicit(&frame_write_idx, w + 1, memory_order_release); } /* ── Public API ─────────────────────────────────────────────────────── */ @@ -166,12 +217,21 @@ int kkemu_init(uint8_t* flash_buf, size_t flash_len) { * Production hosts of libkkemu should treat a logged failure as a * security warning and refuse to load secrets. */ +#ifdef _WIN32 + if (!VirtualLock(flash_buf, flash_len)) { + fprintf(stderr, + "[libkkemu] VirtualLock(%zu bytes) failed (err %lu) — flash buffer " + "may be paged to disk; do not load production secrets\n", + flash_len, (unsigned long)GetLastError()); + } +#else if (mlock(flash_buf, flash_len) != 0) { fprintf(stderr, "[libkkemu] mlock(%zu bytes) failed: %s — flash buffer may be " "swapped to disk; do not load production secrets\n", flash_len, strerror(errno)); } +#endif /* Initialize ring buffers (replaces UDP socket init) */ libkkemu_socketInit(); @@ -208,6 +268,11 @@ int kkemu_init(uint8_t* flash_buf, size_t flash_len) { void kkemu_shutdown(void) { if (!libkkemu_initialized) return; + /* Stop + join the poll thread FIRST so nothing drives firmware execution + * while we commit storage and zero the rings below (idempotent if the host + * never started the thread). */ + kkemu_stop(); + /* * End any workflow still in flight BEFORE anything else. * @@ -263,7 +328,11 @@ void kkemu_shutdown(void) { * want to inspect / persist post-mortem state. Documented contract. */ if (emulator_flash_base) { +#ifdef _WIN32 + VirtualUnlock(emulator_flash_base, KKEMU_FLASH_SIZE); +#else munlock(emulator_flash_base, KKEMU_FLASH_SIZE); +#endif emulator_flash_base = NULL; } @@ -286,35 +355,198 @@ int kkemu_read(uint8_t* buf, size_t len, int iface) { return ringbuf_pop(rb, buf, KKEMU_PACKET_SIZE) ? KKEMU_PACKET_SIZE : 0; } -int kkemu_poll(void) { - if (!libkkemu_initialized) return -1; +/* + * One iteration of the firmware event loop. Same as exec() in main.cpp: + * usbPoll() — reads input, dispatches through FSM + * animate() — updates screen animations + * display_refresh() — renders framebuffer + * + * usbPoll() internally calls emulatorSocketRead() which we've replaced with + * libkkemu_socketRead() via the ring buffers. + * + * Drive the firmware millisecond timer from the poll on EVERY platform. The + * dylib is caller-driven; relying on the SIGALRM/ualarm timer (which the + * standalone kkemu binary uses) is unreliable inside the host runtime — Bun + * does not deliver the firmware's SIGALRM, so animate_flag never flips and + * every animation (boot logo, screensaver) stays frozen → a blank OLED at + * rest. Tick ~one poll-interval of milliseconds so the periodic animation + * runnable fires and animations + delay_ms() advance at roughly real speed. + */ +static void kkemu_poll_body(void) { + for (int t = 0; t < KKEMU_POLL_INTERVAL_MS; t++) timerisr_usr(); - /* - * This is the same as exec() in main.cpp: - * usbPoll() — reads input, dispatches through FSM - * animate() — updates screen animations - * display_refresh() — renders framebuffer - * - * usbPoll() internally calls emulatorSocketRead() which we've - * replaced with libkkemu_socketRead() via the ring buffers. - */ usbPoll(); animate(); display_refresh(); +} +int kkemu_poll(void) { + if (!libkkemu_initialized) return -1; + /* When the poll thread owns execution, the host must not also poll — + * that would be two threads driving the single-threaded firmware core. + * Treat a stray host poll as a no-op rather than a data race. */ + if (POLL_RUNNING()) return 0; + kkemu_poll_body(); return 0; } +static void kkemu_sleep_ms(int ms) { +#ifdef _WIN32 + Sleep((DWORD)ms); +#else + struct timespec ts = {ms / 1000, (long)(ms % 1000) * 1000000L}; + nanosleep(&ts, NULL); +#endif +} + +/* The poll thread holds g_fw_lock across each body call, releasing it during + * the inter-poll sleep. While confirm_helper busy-waits for a decision the body + * does not return, so the lock stays held for the whole confirm — but that must + * NOT block the host: the decision is delivered through the lock-free rings, and + * the host acquires the lock for flash snapshots via kkemu_trylock() (which + * never blocks the host event loop). The host must never take g_fw_lock with a + * blocking call while a confirm may be pending, or it would deadlock against the + * very loop that needs the host alive to deliver the decision. */ +static void kkemu_poll_loop(void) { + while (POLL_RUNNING()) { + FW_LOCK(); + if (POLL_RUNNING()) kkemu_poll_body(); + FW_UNLOCK(); + kkemu_sleep_ms(KKEMU_POLL_INTERVAL_MS); + } +} + +#ifdef _WIN32 +static DWORD WINAPI kkemu_poll_thread_fn(LPVOID arg) { + (void)arg; + kkemu_poll_loop(); + return 0; +} +#else +static void* kkemu_poll_thread_fn(void* arg) { + (void)arg; + kkemu_poll_loop(); + return NULL; +} +#endif + +/* Push a Cancel (MessageType 20) into the main input ring so a confirm_helper + * blocked on the poll thread reads it, returns false, and lets the thread exit + * its loop — otherwise kkemu_stop() would join a thread parked forever waiting + * for a button decision that will never arrive. + * + * This injected Cancel is the ONLY firmware-side wakeup for a parked confirm + * (confirm_helper has no idle timeout in EMULATOR builds), and kkemu_stop() + * then joins the thread with no deadline — so a SILENTLY dropped Cancel would + * freeze the (single-threaded) host forever, beyond any watchdog's reach. The + * push can only fail if rb_main_in is full; the parked confirm drains one input + * frame per spin, so a slot frees within ~a poll tick. Retry briefly, and shout + * loudly if it somehow never takes rather than dropping it. */ +static void kkemu_inject_cancel(void) { + uint8_t frame[KKEMU_PACKET_SIZE]; + memset(frame, 0, sizeof(frame)); + frame[0] = 0x3F; /* '?' HID report marker */ + frame[1] = 0x23; /* '#' */ + frame[2] = 0x23; /* '#' */ + frame[3] = 0x00; /* MessageType_Cancel high */ + frame[4] = 0x14; /* MessageType_Cancel low (20) */ + /* payload length 0 (bytes 5-8 already zero) */ + for (int i = 0; i < 200; i++) { + if (ringbuf_push(&rb_main_in, frame, sizeof(frame))) return; + kkemu_sleep_ms(1); + } + fprintf(stderr, + "[libkkemu] FATAL: could not inject Cancel to wake a parked confirm " + "before join — rb_main_in stayed full for ~200ms; the poll thread may " + "not exit\n"); +} + +int kkemu_start(void) { + if (!libkkemu_initialized) return -1; + if (POLL_RUNNING()) return 0; /* idempotent */ + +#ifdef _WIN32 + InitializeCriticalSection(&g_fw_lock); + POLL_SET(1); + g_poll_thread = CreateThread(NULL, 0, kkemu_poll_thread_fn, NULL, 0, NULL); + if (!g_poll_thread) { + POLL_SET(0); + DeleteCriticalSection(&g_fw_lock); + return -1; + } +#else + POLL_SET(1); + if (pthread_create(&g_poll_thread, NULL, kkemu_poll_thread_fn, NULL) != 0) { + POLL_SET(0); + return -1; + } +#endif + return 0; +} + +void kkemu_stop(void) { + if (!POLL_RUNNING()) return; + + POLL_SET(0); + /* Unblock any confirm_helper currently parked on the thread, then join. */ + kkemu_inject_cancel(); +#ifdef _WIN32 + if (g_poll_thread) { + WaitForSingleObject(g_poll_thread, INFINITE); + CloseHandle(g_poll_thread); + g_poll_thread = NULL; + } + DeleteCriticalSection(&g_fw_lock); +#else + pthread_join(g_poll_thread, NULL); +#endif +} + +/* Host-side guard for reading the shared flash buffer (saveFlash) without + * tearing a concurrent storage_commit on the poll thread. No-op when the + * thread isn't running (single-threaded test path needs no lock, and on + * Windows the CRITICAL_SECTION only exists between start and stop). + * + * WARNING: kkemu_lock() BLOCKS, and the poll thread can hold g_fw_lock for the + * whole duration of a pending confirm. The host must therefore NOT call + * kkemu_lock() from a thread/loop that also has to stay alive to deliver the + * confirm decision (it would deadlock). Use kkemu_trylock() + an event-loop + * yield there instead. kkemu_lock() is retained for paths with no pending + * confirm. */ +void kkemu_lock(void) { + if (POLL_RUNNING()) FW_LOCK(); +} + +void kkemu_unlock(void) { + if (POLL_RUNNING()) FW_UNLOCK(); +} + +/* Non-blocking acquire. Returns 1 if the firmware lock is now held by the + * caller (balance with kkemu_unlock()), 0 if it is currently held by the poll + * thread (e.g. mid-confirm) — the caller should yield its event loop and retry, + * which keeps the loop alive to deliver the decision that releases the lock. + * No-op success (returns 1, nothing to unlock) when the thread isn't running. */ +int kkemu_trylock(void) { + if (!POLL_RUNNING()) return 1; +#ifdef _WIN32 + return TryEnterCriticalSection(&g_fw_lock) ? 1 : 0; +#else + return pthread_mutex_trylock(&g_fw_lock) == 0 ? 1 : 0; +#endif +} + +/* + * Snapshot the current OLED canvas into packed SSD1306 format (byte index = + * x + (y/8)*256, bit = y%8). Host-driven convenience used by the python + * screenshot harness, which drives the firmware single-threaded via kkemu_poll. + * + * WARNING: NOT thread-safe. It reads the live firmware canvas directly with no + * synchronization against the poll thread, so it is only safe in HOST-DRIVEN + * mode (no kkemu_start). In thread-driven mode the canonical, race-free way to + * observe the display is the SPSC capture ring via kkemu_pop_frame(); do not + * wire kkemu_get_display into a threaded host. + */ const uint8_t* kkemu_get_display(int* width, int* height) { - /* - * Pack the firmware's 8-bpp grayscale canvas (256×64 = 16384 bytes) into - * the 1-bit packed layout vault expects (2048 bytes). Same format - * DebugLinkGetState.layout uses: byte index = x + (y/8)*256, - * bit within byte = y%8 (LSB = top row of the 8-pixel column). - * - * Output goes into the file-scope `display_packed_scratch` so - * kkemu_shutdown() can zero it on teardown alongside the frame ring. - */ if (!libkkemu_initialized) { if (width) *width = 0; if (height) *height = 0; @@ -331,7 +563,7 @@ const uint8_t* kkemu_get_display(int* width, int* height) { memset(display_packed_scratch, 0, sizeof(display_packed_scratch)); for (int x = 0; x < 256; x++) { for (int y = 0; y < 64; y++) { - if (display_mono_pixel_is_lit(c->buffer[y * 256 + x], x, y)) { + if (c->buffer[y * 256 + x] > 0) { display_packed_scratch[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); } } @@ -344,10 +576,13 @@ const uint8_t* kkemu_get_display(int* width, int* height) { int kkemu_pop_frame(uint8_t* out_packed) { if (!libkkemu_initialized || !out_packed) return 0; - if (frame_read_idx == frame_write_idx) return 0; - const uint8_t* slot = frame_ring[frame_read_idx % FRAME_RING_SIZE]; - memcpy(out_packed, slot, FRAME_PACKED_SIZE); - frame_read_idx++; + /* SPSC consume: read frame_read_idx (we own it) and frame_write_idx (acquire, + * to see the producer's slot write). Empty when the indices are equal. */ + uint32_t r = atomic_load_explicit(&frame_read_idx, memory_order_relaxed); + uint32_t w = atomic_load_explicit(&frame_write_idx, memory_order_acquire); + if (r == w) return 0; + memcpy(out_packed, frame_ring[r % FRAME_RING_SIZE], FRAME_PACKED_SIZE); + atomic_store_explicit(&frame_read_idx, r + 1, memory_order_release); return 1; } diff --git a/lib/emulator/random.c b/lib/emulator/random.c new file mode 100644 index 000000000..f05fdb1fd --- /dev/null +++ b/lib/emulator/random.c @@ -0,0 +1,69 @@ +/* + * This file is part of the TREZOR project, https://trezor.io/ + * + * Copyright (C) 2017 Saleem Rashid + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#include "keepkey/emulator/emulator.h" +#include "keepkey/emulator/setup.h" + +#include +#include +#include + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#else +#include +#include + +static int urandom = -1; + +static void setup_urandom(void) { + if (urandom >= 0) return; + + urandom = open("/dev/urandom", O_RDONLY); + if (urandom < 0) { + perror("Failed to open /dev/urandom"); + exit(1); + } +} +#endif + +void setup_urandom_only(void) { +#ifndef _WIN32 + setup_urandom(); +#endif +} + +void emulatorRandom(void* buffer, size_t size) { +#ifdef _WIN32 + /* Windows has no /dev/urandom — use the system CSPRNG. */ + if (BCryptGenRandom(NULL, (PUCHAR)buffer, (ULONG)size, + BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + fprintf(stderr, "BCryptGenRandom failed\n"); + exit(1); + } +#else + setup_urandom(); + unsigned char* out = (unsigned char*)buffer; + size_t remaining = size; + while (remaining > 0) { + ssize_t n = read(urandom, out, remaining); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { + perror("Failed to read /dev/urandom"); + exit(1); + } + out += (size_t)n; + remaining -= (size_t)n; + } +#endif +} diff --git a/lib/emulator/setup.c b/lib/emulator/setup.c index bed296eae..58ad9933a 100644 --- a/lib/emulator/setup.c +++ b/lib/emulator/setup.c @@ -19,60 +19,36 @@ #include "keepkey/board/memory.h" #include "keepkey/board/timer.h" -#include "keepkey/rand/rng.h" +#include "keepkey/emulator/setup.h" -#include -#include #include #include #include +#ifndef _WIN32 +#include #include #include +#endif #define EMULATOR_FLASH_FILE "emulator.img" -uint32_t __stack_chk_guard; +/* __stack_chk_guard is defined once in lib/board/keepkey_board.c (as + * uintptr_t). It used to be redefined here as uint32_t, which is (a) the wrong + * size on 64-bit hosts and (b) a duplicate strong symbol. Apple's ld silently + * merged the two; GNU/MinGW ld rejects it ("multiple definition"), which + * blocked the Linux .so and Windows .dll builds. Removed — the board copy is + * canonical. */ -static int urandom = -1; - -static void setup_urandom(void); +#ifndef _WIN32 static void setup_flash(void); void setup(void) { - setup_urandom(); + setup_urandom_only(); setup_flash(); } +#endif -/* For libkkemu: init RNG only (flash buffer provided by host) */ -void setup_urandom_only(void) { setup_urandom(); } - -void emulatorRandom(void* buffer, size_t size) { - setup_urandom(); - - uint8_t* out = (uint8_t*)buffer; - size_t remaining = size; - while (remaining > 0) { - ssize_t n = read(urandom, out, remaining); - if (n < 0 && errno == EINTR) continue; - if (n <= 0) { - perror("Failed to read /dev/urandom"); - exit(1); - } - out += (size_t)n; - remaining -= (size_t)n; - } -} - -static void setup_urandom(void) { - if (urandom >= 0) return; - - urandom = open("/dev/urandom", O_RDONLY); - if (urandom < 0) { - perror("Failed to open /dev/urandom"); - exit(1); - } -} - +#ifndef _WIN32 static void setup_flash(void) { int fd = open(EMULATOR_FLASH_FILE, O_RDWR | O_SYNC | O_CREAT, 0644); if (fd < 0) { @@ -103,3 +79,5 @@ static void setup_flash(void) { memset(emulator_flash_base, 0xff, FLASH_TOTAL_SIZE); } } +#endif /* !_WIN32 — setup_flash is standalone-UDP only; the dylib/DLL host \ + owns flash */ diff --git a/lib/emulator/udp.c b/lib/emulator/udp.c index 671c6437b..265886c63 100644 --- a/lib/emulator/udp.c +++ b/lib/emulator/udp.c @@ -17,17 +17,22 @@ * along with this library. If not, see . */ -#include #include #include #include #include -#include #ifndef KEEPKEY_UDP_PORT #define KEEPKEY_UDP_PORT 11044 #endif +#ifndef KKEMU_DYLIB +/* Sockets are only used by the standalone UDP binary. In dylib/DLL mode all + * I/O goes through ring buffers (below), so skip the BSD socket headers and + * helpers entirely — they don't exist on MinGW/Windows. */ +#include +#include + struct usb_socket { int fd; struct sockaddr_in from; @@ -95,6 +100,7 @@ static size_t socket_read(struct usb_socket* sock, void* buffer, size_t size) { return n; } +#endif /* !KKEMU_DYLIB — socket helpers are standalone-UDP only */ #ifdef KKEMU_DYLIB /* diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index 816412844..624fe0b36 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -22,6 +22,7 @@ set(sources # Non-Bitcoin coin families -- excluded from the bitcoin-only image. if(NOT ${KK_BITCOIN_ONLY}) list(APPEND sources + bip85.c binance.c eip712.c eos.c @@ -37,6 +38,7 @@ if(NOT ${KK_BITCOIN_ONLY}) ethereum_contracts/zxtransERC20.c ethereum_contracts/zxswap.c ethereum_tokens.c + signed_metadata.c mayachain.c nano.c osmosis.c @@ -44,18 +46,24 @@ if(NOT ${KK_BITCOIN_ONLY}) 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) include_directories( ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto ${CMAKE_SOURCE_DIR}/lib/firmware ${CMAKE_CURRENT_BINARY_DIR}) diff --git a/lib/firmware/app_confirm.c b/lib/firmware/app_confirm.c index 496b5d326..f2156eb2b 100644 --- a/lib/firmware/app_confirm.c +++ b/lib/firmware/app_confirm.c @@ -323,6 +323,31 @@ bool confirm_nano_address(const char* desc, const char* address) { ButtonRequestType_ButtonRequest_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 ee211cc19..82bff62b3 100644 --- a/lib/firmware/app_layout.c +++ b/lib/firmware/app_layout.c @@ -597,6 +597,81 @@ void layout_nano_address_notification(const char* desc, const char* address, layout_notification_icon(type, &sp); } +#if ZCASH_PRIVACY +/* + * layout_zcash_address_notification() - Display zcash unified address QR + * with title; the second confirm step in the view-on-device flow. + * + * INPUT + * - desc: title text (e.g. "Zcash #0 Orchard") + * - address: zcash unified address (rendered as QR only — full text is + * shown on the preceding confirm step) + * - type: notification type + * OUTPUT + * none + */ +void layout_zcash_address_notification(const char* desc, const char* address, + NotificationType type) { + DrawableParams sp; + Canvas* canvas = layout_get_canvas(); + + call_leaving_handler(); + layout_clear(); + + if (strcmp(desc, "") != 0) { + const Font* title_font = get_title_font(); + sp.y = TOP_MARGIN_FOR_TWO_LINES; + sp.x = LEFT_MARGIN + 65; + sp.color = BODY_COLOR; + draw_string(canvas, title_font, desc, &sp, TRANSACTION_WIDTH - 2, + font_height(title_font) + BODY_FONT_LINE_PADDING); + } + + layout_address(address, QR_LARGE); + layout_notification_icon(type, &sp); +} + +/* + * layout_zcash_address_text_notification() - Display full zcash unified + * address text with title; the first confirm step in the view-on-device flow. + * + * INPUT + * - desc: title text (e.g. "Zcash #0 Orchard") + * - address: zcash unified address to display as text (3 lines) + * - type: notification type + * OUTPUT + * none + */ +void layout_zcash_address_text_notification(const char* desc, + const char* address, + NotificationType type) { + DrawableParams sp; + Canvas* canvas = layout_get_canvas(); + const Font* address_font = get_body_font(); + + call_leaving_handler(); + layout_clear(); + + if (strcmp(desc, "") != 0) { + const Font* title_font = get_title_font(); + sp.y = TOP_MARGIN_FOR_THREE_LINES; + sp.x = LEFT_MARGIN; + sp.color = BODY_COLOR; + draw_string(canvas, title_font, desc, &sp, TRANSACTION_WIDTH - 2, + font_height(title_font) + BODY_FONT_LINE_PADDING); + } + + /* Full UA below the title; -25 leaves the right column for confirm icons. */ + sp.y = TOP_MARGIN_FOR_THREE_LINES + ADDRESS_XPUB_TOP_MARGIN; + sp.x = LEFT_MARGIN; + sp.color = BODY_COLOR; + draw_string(canvas, address_font, address, &sp, TRANSACTION_WIDTH - 25, + font_height(address_font) + BODY_FONT_LINE_PADDING); + + layout_notification_icon(type, &sp); +} +#endif // ZCASH_PRIVACY + /* * layout_address_notification() - Display address notification * @@ -720,7 +795,8 @@ void layout_pin(const char* str, char pin[]) { * OUTPUT * none */ -void layout_cipher(const char* current_word, const char* cipher) { +void layout_cipher(const char* current_word, const char* cipher, + const char* prev_word_info) { DrawableParams sp; const Font* title_font = get_body_font(); Canvas* canvas = layout_get_canvas(); @@ -728,8 +804,18 @@ void layout_cipher(const char* current_word, const char* cipher) { call_leaving_handler(); layout_clear(); - /* Draw prompt */ - sp.y = 11; + /* Draw previous word info at top-left -- must be x < 76 to avoid + * being wiped by cipher animation which clears x >= CIPHER_START_X */ + if (prev_word_info && prev_word_info[0]) { + sp.y = 2; + sp.x = 4; + sp.color = CIPHER_FONT_COLOR; /* gray -- less prominent than current word */ + draw_string(canvas, title_font, prev_word_info, &sp, 68, + font_height(title_font)); + } + + /* Draw prompt -- push down when prev word is shown */ + sp.y = (prev_word_info && prev_word_info[0]) ? 14 : 11; sp.x = 4; sp.color = BODY_COLOR; draw_string(canvas, title_font, "Recovery Cipher:", &sp, 58, diff --git a/lib/firmware/authenticator.c b/lib/firmware/authenticator.c index fc9be84b9..503b308fe 100644 --- a/lib/firmware/authenticator.c +++ b/lib/firmware/authenticator.c @@ -77,11 +77,14 @@ static bool getAuthData(void) { static void setAuthData(void) { storage_setAuthData(authData); } -static unsigned authenticator_cancel(void) { - /* A nested confirmation refusal does not pass through fsm_msgCancel(), so it - * must revoke the decrypted authenticator cache itself. */ - authenticator_clear_cache(); - return CANCELED; +static bool authDisplayFieldValid(const char* value, size_t max_len) { + size_t len = strnlen(value, max_len + 1); + if (len == 0 || len > max_len) return false; + for (size_t i = 0; i < len; i++) { + uint8_t ch = (uint8_t)value[i]; + if (ch < 0x20 || ch > 0x7e) return false; + } + return true; } #if DEBUG_LINK @@ -106,10 +109,8 @@ void getAuthSlot(char* authSlotData) { unsigned wipeAuthData(void) { if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Wipe Authdata", - "Do you want to PERMANENTLY delete all authenticator " - "accounts?")) { - return authenticator_cancel(); - } + "Do you want to PERMANENTLY delete all authenticator accounts?")) + return AUTH_CANCELLED; // wipe storage and reset authdata encryption flag storage_wipeAuthData(); @@ -119,22 +120,17 @@ unsigned wipeAuthData(void) { } unsigned addAuthAccount(char* accountWithSeed) { - if (accountWithSeed == NULL) return TOKERR; - - /* strtok() inserts NULs into the caller's protobuf string, so retain the - * original extent before parsing. Every exit wipes that whole credential - * suffix, including the Base32 source, rather than leaving it in the static - * message decode buffer until another USB message arrives. */ - const size_t sourceLen = strlen(accountWithSeed); char *domain, *account, *seedStr; unsigned slot = AUTHDATA_SIZE; - char authSecret[AUTHSECRET_SIZE_MAX] = {0}; + char authSecret[AUTHSECRET_SIZE_MAX] = { + 0}; // 128-bit key len is the recommended minimum, this is room for + // 160-bit size_t authSecretLen = 0; unsigned result = UNKERR; // accountWithSeed should be of the form "domain:account:seedStr" domain = strtok(accountWithSeed, ":"); // get the domain string token - if (NULL == domain) { + if (NULL == domain || !authDisplayFieldValid(domain, DOMAIN_SIZE - 1)) { result = TOKERR; goto cleanup; } @@ -144,7 +140,7 @@ unsigned addAuthAccount(char* accountWithSeed) { result = TOKERR; goto cleanup; } - if (0 == strlen(account)) { + if (!authDisplayFieldValid(account, ACCOUNT_SIZE - 1)) { result = TOKERR; goto cleanup; } @@ -160,24 +156,33 @@ unsigned addAuthAccount(char* accountWithSeed) { } authSecretLen = base32_decoded_length(strlen(seedStr)); + if (authSecretLen < AUTHSECRET_SIZE_MIN) { + result = BADSECRET; + goto cleanup; + } if (AUTHSECRET_SIZE_MAX < authSecretLen) { result = LARGESEED; goto cleanup; } if (!getAuthData()) { - result = BADPASS; // fingerprint did not match, passphrase incorrect + result = BADPASS; goto cleanup; } - // look for first empty slot - for (slot = 0; slot < AUTHDATA_SIZE; slot++) { - if (authData[slot].secretSize == 0) { - break; + // Reject duplicate identities and remember the first empty slot. Legacy + // duplicates are removed together by removeAuthAccount(). + for (unsigned i = 0; i < AUTHDATA_SIZE; i++) { + if (authData[i].secretSize != 0 && + strncmp(authData[i].domain, domain, DOMAIN_SIZE) == 0 && + strncmp(authData[i].account, account, ACCOUNT_SIZE) == 0) { + result = DUPLICATE; + goto cleanup; } + if (slot == AUTHDATA_SIZE && authData[i].secretSize == 0) slot = i; } if (slot == AUTHDATA_SIZE) { - result = NOSLOT; // no empty slots + result = NOSLOT; goto cleanup; } @@ -188,10 +193,15 @@ unsigned addAuthAccount(char* accountWithSeed) { goto cleanup; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm add account", - "Domain: %.*s\nAccount: %.*s\nSecret: %s", DOMAIN_SIZE, domain, - ACCOUNT_SIZE, account, seedStr)) { - result = CANCELED; + // Keep the secret on its own screen. A 32-character base32 secret appended + // after domain/account can wrap past the OLED's three body rows, leaving the + // tail signed into storage but invisible to the user. + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Auth Account", + "Domain: %.*s\nAccount: %.*s", DOMAIN_SIZE, domain, ACCOUNT_SIZE, + account) || + !confirm(ButtonRequestType_ButtonRequest_Other, "TOTP Secret", "%s", + seedStr)) { + result = AUTH_CANCELLED; goto cleanup; } @@ -205,8 +215,6 @@ unsigned addAuthAccount(char* accountWithSeed) { cleanup: memzero(authSecret, sizeof(authSecret)); - memzero(accountWithSeed, sourceLen); - if (result == CANCELED) authenticator_clear_cache(); return result; } @@ -308,7 +316,7 @@ unsigned generateOTP(char* accountWithMsg, char otpStr[]) { snprintf(otp_display, sizeof(otp_display), "%06u", otp); if (!review_immediate(ButtonRequestType_ButtonRequest_Other, "display OTP", "Press button to display OTP")) { - result = CANCELED; + result = AUTH_CANCELLED; goto cleanup; } @@ -318,7 +326,7 @@ unsigned generateOTP(char* accountWithMsg, char otpStr[]) { if (tRemainVal < 4) { if (!review_immediate(ButtonRequestType_ButtonRequest_Other, "OTP Timeout", "OTP time slice timed out, regenerate OTP")) { - result = CANCELED; + result = AUTH_CANCELLED; goto cleanup; } } else { @@ -343,7 +351,6 @@ unsigned generateOTP(char* accountWithMsg, char otpStr[]) { memzero(otp_candidate, sizeof(otp_candidate)); memzero(otp_display, sizeof(otp_display)); memzero(account_display, sizeof(account_display)); - if (result == CANCELED) authenticator_clear_cache(); return result; } @@ -370,18 +377,18 @@ unsigned getAuthAccount(const char* slotStr, char acc[]) { unsigned removeAuthAccount(char* domAcc) { char *domain, *account; - unsigned slot; + bool found = false; // accountWithSeed should be of the form "domain:account" domain = strtok(domAcc, ":"); // get the domain string token - if (NULL == domain) { + if (NULL == domain || !authDisplayFieldValid(domain, DOMAIN_SIZE - 1)) { return TOKERR; } account = strtok(NULL, ""); // get the account string token if (NULL == account) { return TOKERR; } - if (0 == strlen(account)) { + if (!authDisplayFieldValid(account, ACCOUNT_SIZE - 1)) { return TOKERR; } @@ -389,25 +396,30 @@ unsigned removeAuthAccount(char* domAcc) { return BADPASS; // fingerprint did not match, passphrase incorrect } - // find slot for account - for (slot = 0; slot < AUTHDATA_SIZE; slot++) { - if ((0 == strncmp(authData[slot].domain, domain, DOMAIN_SIZE - 1)) && - (0 == strncmp(authData[slot].account, account, ACCOUNT_SIZE - 1))) { - break; - } + // Find every matching slot. Older firmware allowed duplicate identities, so + // a confirmed deletion must remove all copies atomically. + for (unsigned slot = 0; slot < AUTHDATA_SIZE; slot++) { + if (authData[slot].secretSize != 0 && + strncmp(authData[slot].domain, domain, DOMAIN_SIZE) == 0 && + strncmp(authData[slot].account, account, ACCOUNT_SIZE) == 0) + found = true; } - if (slot == AUTHDATA_SIZE) { + if (!found) { return NOACC; // account not found } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Delete Account", "Do you want to PERMANENTLY delete account %.*s:%.*s?", - DOMAIN_SIZE - 1, domain, ACCOUNT_SIZE - 1, account)) { - return authenticator_cancel(); - } + DOMAIN_SIZE - 1, domain, ACCOUNT_SIZE - 1, account)) + return AUTH_CANCELLED; - memzero((void*)&authData[slot], sizeof(authType)); + for (unsigned slot = 0; slot < AUTHDATA_SIZE; slot++) { + if (authData[slot].secretSize != 0 && + strncmp(authData[slot].domain, domain, DOMAIN_SIZE) == 0 && + strncmp(authData[slot].account, account, ACCOUNT_SIZE) == 0) + memzero((void*)&authData[slot], sizeof(authType)); + } setAuthData(); return NOERR; // success } diff --git a/lib/firmware/bip85.c b/lib/firmware/bip85.c new file mode 100644 index 000000000..7d2ef6c81 --- /dev/null +++ b/lib/firmware/bip85.c @@ -0,0 +1,105 @@ +#include "keepkey/firmware/bip85.h" +#include "keepkey/firmware/storage.h" +#include "trezor/crypto/bip32.h" +#include "trezor/crypto/bip39.h" +#include "trezor/crypto/curves.h" +#include "trezor/crypto/hmac.h" +#include "trezor/crypto/memzero.h" + +#include + +/* + * BIP-85: Deterministic Entropy From BIP32 Keychains + * + * For BIP-39 mnemonic derivation: + * path = m / 83696968' / 39' / 0' / ' / ' + * k = derived_node.private_key (32 bytes) + * hmac = HMAC-SHA512(key="bip-entropy-from-k", msg=k) + * entropy = hmac[0 : entropy_bytes] + * 12 words -> 16 bytes, 18 words -> 24 bytes, 24 words -> 32 bytes + * mnemonic = bip39_from_entropy(entropy) + */ + +/* BIP-85 application number for deriving entropy from a key */ +static const uint8_t BIP85_HMAC_KEY[] = "bip-entropy-from-k"; +#define BIP85_HMAC_KEY_LEN 18 + +bool bip85_derive_mnemonic(uint32_t word_count, uint32_t index, char *mnemonic, + size_t mnemonic_len) { + /* Reject index >= 0x80000000 to avoid hardened-bit collision */ + if (index & 0x80000000) { + return false; + } + + /* Validate word count and compute entropy length */ + int entropy_bytes; + switch (word_count) { + case 12: + entropy_bytes = 16; + break; + case 18: + entropy_bytes = 24; + break; + case 24: + entropy_bytes = 32; + break; + default: + return false; + } + + /* BIP-85 derivation path: m/83696968'/39'/0'/'/' */ + uint32_t address_n[5]; + address_n[0] = 0x80000000 | 83696968; /* purpose (hardened) */ + address_n[1] = 0x80000000 | 39; /* BIP-39 app (hardened) */ + address_n[2] = 0x80000000; /* English language 0 (hardened) */ + address_n[3] = 0x80000000 | word_count; /* word count (hardened) */ + address_n[4] = 0x80000000 | index; /* child index (hardened) */ + + /* Get the master node from storage (respects passphrase) */ + static CONFIDENTIAL HDNode node; + if (!storage_getRootNode(SECP256K1_NAME, true, &node)) { + memzero(&node, sizeof(node)); + return false; + } + + /* Derive to the BIP-85 path */ + for (int i = 0; i < 5; i++) { + if (hdnode_private_ckd(&node, address_n[i]) == 0) { + memzero(&node, sizeof(node)); + return false; + } + } + + /* HMAC-SHA512(key="bip-entropy-from-k", msg=private_key) */ + static CONFIDENTIAL uint8_t hmac_out[64]; + hmac_sha512(BIP85_HMAC_KEY, BIP85_HMAC_KEY_LEN, node.private_key, 32, + hmac_out); + + /* We no longer need the derived node */ + memzero(&node, sizeof(node)); + + /* Truncate HMAC output to the required entropy length */ + static CONFIDENTIAL uint8_t entropy[32]; + memcpy(entropy, hmac_out, entropy_bytes); + memzero(hmac_out, sizeof(hmac_out)); + + /* Convert entropy to BIP-39 mnemonic */ + const char *words = mnemonic_from_data(entropy, entropy_bytes); + memzero(entropy, sizeof(entropy)); + + if (!words) { + return false; + } + + /* Copy to output buffer */ + size_t words_len = strlen(words); + if (words_len >= mnemonic_len) { + mnemonic_clear(); + return false; + } + + memcpy(mnemonic, words, words_len + 1); + mnemonic_clear(); + + return true; +} diff --git a/lib/firmware/coins.c b/lib/firmware/coins.c index e51b852b2..e5b61cdb2 100644 --- a/lib/firmware/coins.c +++ b/lib/firmware/coins.c @@ -85,7 +85,7 @@ const CoinType coins[COINS_COUNT] = { TAPROOT}, #include "keepkey/firmware/coins.def" -#if !BITCOIN_ONLY +#if !BITCOIN_ONLY // ERC-20 tokens excluded from the bitcoin-only image #define X(INDEX, NAME, SYMBOL, DECIMALS, CONTRACT_ADDRESS) \ { \ true, \ @@ -132,7 +132,7 @@ const CoinType coins[COINS_COUNT] = { false, /* has_taproot, taproot*/ \ }, #include "keepkey/firmware/tokens.def" -#endif +#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 8dc81024a..415839033 100644 --- a/lib/firmware/eip712.c +++ b/lib/firmware/eip712.c @@ -31,6 +31,7 @@ strings and address should be prefixed by 0x */ +#include #include #include #include @@ -83,38 +84,172 @@ static bool append_type_string(char* dest, const char* value) { return true; } +/* Read a run of decimal digits at *cursor into *value, refusing anything that + would exceed limit. strtol()/strtoul() cannot be used for type-string widths: + they saturate silently, so "bytes4294967297" and "uint4294967552" become + small in-range numbers after the caller's cast and a type the host invented + gets encoded as a type the user was shown. Advances *cursor past the digits + only on success. */ +static bool parse_bounded_decimal(const char** cursor, size_t limit, + size_t* value) { + const char* p = *cursor; + if (*p < '0' || *p > '9') return false; + + size_t parsed = 0; + while (*p >= '0' && *p <= '9') { + const size_t digit = (size_t)(*p - '0'); + if (parsed > (limit - digit) / 10) return false; + parsed = parsed * 10 + digit; + p++; + } + *cursor = p; + *value = parsed; + return true; +} + +/* Parse the array part of a type name: "" (not an array), "[]" (dynamic) or + "[N]" (fixed, N > 0). Anything else is rejected outright. */ +static bool parse_array_suffix(const char* suffix, bool* fixed, + size_t* expected) { + *fixed = false; + *expected = 0; + if (*suffix == '\0') return true; + if (*suffix++ != '[') return false; + if (*suffix == ']') return suffix[1] == '\0'; + + size_t count = 0; + if (!parse_bounded_decimal(&suffix, (size_t)-1, &count) || count == 0 || + suffix[0] != ']' || suffix[1] != '\0') { + return false; + } + *fixed = true; + *expected = count; + return true; +} + +static bool type_array_suffix_is_valid(const char* suffix) { + bool fixed = false; + size_t expected = 0; + return parse_array_suffix(suffix, &fixed, &expected); +} + +/* A declared Type[N] must be supplied with exactly N elements. Without this + the device hashes whatever cardinality the host sent while displaying it as + the declared type, so a compliant verifier reconstructing Type[N] computes a + different hash than the one the user approved. */ +static bool fixed_array_cardinality_matches(const char* type, + const json_t* value) { + const char* suffix = strchr(type, '['); + if (!suffix) return true; + + bool fixed = false; + size_t expected = 0; + if (!parse_array_suffix(suffix, &fixed, &expected)) return false; + if (!fixed) return true; + if (json_getType(value) != JSON_ARRAY) return false; + + size_t actual = 0; + for (const json_t* element = json_getChild(value); element; + element = json_getSibling(element)) { + if (++actual > expected) return false; + } + return actual == expected; +} + +/* Match the WHOLE type name, not a prefix. The dispatch this replaces used + strncmp() with a truncated length, so a user-defined struct named + "addressBook" was classified ADDRESS, "interval" was INT and "stringUtils" + was STRING -- the struct the user is shown as a struct is encoded as a + primitive and its own definition never enters the encodeType string. */ +static bool type_matches(const char* type, const char* base) { + const size_t len = strlen(base); + return strncmp(type, base, len) == 0 && + type_array_suffix_is_valid(type + len); +} + +static bool type_is_integer(const char* type, const char* prefix) { + const size_t prefix_len = strlen(prefix); + if (strncmp(type, prefix, prefix_len) != 0) return false; + const char* p = type + prefix_len; + size_t bits = 0; + const bool has_bits = *p >= '0' && *p <= '9'; + if (has_bits && !parse_bounded_decimal(&p, 256, &bits)) return false; + if (has_bits && (bits < 8 || bits > 256 || (bits % 8) != 0)) return false; + return type_array_suffix_is_valid(p); +} + +static unsigned integer_type_width(const char* type, const char* prefix) { + const char* p = type + strlen(prefix); + if (*p < '0' || *p > '9') return 256; + size_t bits = 0; + if (!parse_bounded_decimal(&p, 256, &bits)) return 256; + return (unsigned)bits; +} + +/* Classify "bytes" / "bytesN" and recover N without the 8-bit truncation the + old (uint8_t)strtol() cast performed. */ +static bool type_is_bytes(const char* type, unsigned* byte_size, + bool* dynamic) { + if (strncmp(type, "bytes", 5) != 0) return false; + const char* p = type + 5; + if (*p == '\0' || *p == '[') { + if (!type_array_suffix_is_valid(p)) return false; + *byte_size = 0; + *dynamic = true; + return true; + } + size_t size = 0; + if (!parse_bounded_decimal(&p, 32, &size) || size == 0 || + !type_array_suffix_is_valid(p)) + return false; + *byte_size = (unsigned)size; + *dynamic = false; + return true; +} + +/* A 0x-prefixed, even-length, all-hex string, optionally of an exact byte + count. The encoders walked the value two characters at a time with no + validation at all: an odd-length value stepped OVER the terminating NUL and + fed adjacent RAM into the keccak state while the OLED showed only the short + value the host sent. */ +static bool hex_string_is_valid(const char* string, size_t expected_bytes, + bool exact_size) { + if (!string || string[0] != '0' || string[1] != 'x') return false; + const size_t chars = strlen(string + 2); + if ((chars & 1) != 0 || (exact_size && chars != 2 * expected_bytes)) + return false; + for (size_t i = 0; i < chars; i++) { + if (hex_nibble(string[i + 2]) < 0) return false; + } + return true; +} + int encodableType(const char* typeStr) { int ctr; - if (0 == strncmp(typeStr, "address", sizeof("address") - 1)) { + if (!typeStr || typeStr[0] == '\0') return NOT_ENCODABLE; + + if (type_matches(typeStr, "address")) { return ADDRESS; } - if (0 == strncmp(typeStr, "string", sizeof("string") - 1)) { + if (type_matches(typeStr, "string")) { return STRING; } - if (0 == strncmp(typeStr, "int", sizeof("int") - 1)) { + if (type_is_integer(typeStr, "int")) { // This could be 'int8', 'int16', ..., 'int256' return INT; } - if (0 == strncmp(typeStr, "uint", sizeof("uint") - 1)) { + if (type_is_integer(typeStr, "uint")) { // This could be 'uint8', 'uint16', ..., 'uint256' return UINT; } - if (0 == strncmp(typeStr, "bytes", sizeof("bytes") - 1)) { + unsigned byte_size = 0; + bool dynamic = false; + if (type_is_bytes(typeStr, &byte_size, &dynamic)) { // This could be 'bytes', 'bytes1', ..., 'bytes32' - if (0 == strcmp(typeStr, "bytes")) { - return BYTES; - } else { - // parse out the length val - uint8_t byteTypeSize = (uint8_t)(strtol((typeStr + 5), NULL, 10)); - if (byteTypeSize > 32) { - return NOT_ENCODABLE; - } else { - return BYTES_N; - } - } + return dynamic ? BYTES : BYTES_N; } - if (0 == strcmp(typeStr, "bool")) { + if (type_matches(typeStr, "bool")) { return BOOL; } @@ -126,8 +261,18 @@ int encodableType(const char* typeStr) { strtok(typeNoArrTok, "["); // eliminate the array tokens if there if (udefList[ctr] != 0) { - if (0 == strncmp(udefList[ctr], typeNoArrTok, - strlen(udefList[ctr]) - strlen(typeNoArrTok))) { + /* Compare the stored name (minus any array tokens) against the candidate + by equal length plus a real prefix match. The previous form passed + strlen(stored) - strlen(candidate) as the length: for two same-length + names that is 0, so strncmp() returned 0 and ANY same-length struct was + reported as already-defined -- parseType() then never appended that + struct's definition and the typehash was computed over an incomplete + type set. When the candidate was longer the subtraction underflowed to + a huge size_t. */ + const size_t previous_len = strcspn(udefList[ctr], "["); + const size_t candidate_len = strlen(typeNoArrTok); + if (previous_len == candidate_len && + strncmp(udefList[ctr], typeNoArrTok, candidate_len) == 0) { return PREV_USERDEF; } else { } @@ -279,17 +424,18 @@ int encString(const char* string, uint8_t* encoded) { } int encodeBytes(const char* string, uint8_t* encoded) { + /* Refuse before hashing: the walk below steps two characters at a time, so + an odd-length or non-hex value would read past the end of the host's JSON + buffer and hash bytes the user was never shown. */ + if (!hex_string_is_valid(string, 0, false)) return GENERAL_ERROR; struct SHA3_CTX byteCtx; const char* valStrPtr = string + 2; - uint8_t valByte[1]; - char byteStrBuf[3] = {0}; sha3_256_Init(&byteCtx); while (*valStrPtr != '\0') { - strncpy(byteStrBuf, valStrPtr, 2); - valByte[0] = (uint8_t)(strtol(byteStrBuf, NULL, 16)); - sha3_Update(&byteCtx, (const unsigned char*)valByte, - (size_t)sizeof(uint8_t)); + const uint8_t valByte = + (uint8_t)((hex_nibble(valStrPtr[0]) << 4) | hex_nibble(valStrPtr[1])); + sha3_Update(&byteCtx, &valByte, sizeof(valByte)); valStrPtr += 2; } keccak_Final(&byteCtx, encoded); @@ -297,27 +443,24 @@ int encodeBytes(const char* string, uint8_t* encoded) { } int encodeBytesN(const char* typeT, const char* string, uint8_t* encoded) { - char byteStrBuf[3] = {0}; - unsigned ctr; - - if (MAX_ENCBYTEN_SIZE < strlen(string)) { - return BYTESN_STRING_ERROR; - } - - // parse out the length val - uint8_t byteTypeSize = (uint8_t)(strtol((typeT + 5), NULL, 10)); - if (32 < byteTypeSize) { + /* N comes from type_is_bytes(), which parses it with a bound instead of + (uint8_t)strtol(): "bytes4294967297" used to wrap to 1 and sail past the + "32 < byteTypeSize" guard. The value must then be exactly N bytes -- the + old code right-padded a short value and accepted an over-long one, in both + cases producing a struct hash for a type the host invented. */ + unsigned byteTypeSize = 0; + bool dynamic = false; + if (!type_is_bytes(typeT, &byteTypeSize, &dynamic) || dynamic) { return BYTESN_SIZE_ERROR; } - for (ctr = 0; ctr < 32; ctr++) { - // zero padding - encoded[ctr] = 0; + if (!hex_string_is_valid(string, byteTypeSize, true)) { + return BYTESN_STRING_ERROR; } - unsigned zeroFillLen = 32 - ((strlen(string) - 2 /* skip '0x' */) / 2); + memset(encoded, 0, 32); // bytesN are zero padded on the right - for (ctr = zeroFillLen; ctr < 32; ctr++) { - strncpy(byteStrBuf, &string[2 + 2 * (ctr - zeroFillLen)], 2); - encoded[ctr - zeroFillLen] = (uint8_t)(strtol(byteStrBuf, NULL, 16)); + for (size_t i = 0; i < byteTypeSize; i++) { + encoded[i] = (uint8_t)((hex_nibble(string[2 + 2 * i]) << 4) | + hex_nibble(string[3 + 2 * i])); } return SUCCESS; } @@ -329,9 +472,13 @@ int encodeBytesN(const char* typeT, const char* string, uint8_t* encoded) { confirm() now and refusal is reported to parseVals() as USER_CANCELLED, so no hash is produced at all. */ int confirmName(const char* name, bool valAvailable) { - if (valAvailable) { - nameForValue = name; - } else { + if (!name) return GENERAL_ERROR; + /* Record the name unconditionally. confirmValue() labels the value screen + with it, including every element of an array, and an aggregate field left + it holding the PREVIOUS field's name -- so the elements of an address[] + were each shown captioned with an unrelated field. */ + nameForValue = name; + if (!valAvailable) { if (!confirm(ButtonRequestType_ButtonRequest_Other, "MESSAGE DATA", "Press button to continue for\n\"%s\" values", name)) { return USER_CANCELLED; @@ -341,6 +488,10 @@ int confirmName(const char* name, bool valAvailable) { } int confirmValue(const char* value) { + /* A NULL value is a parse failure, not a refusal: reporting it as + USER_CANCELLED would send FailureType_Failure_ActionCancelled for a + malformed message. It must never reach confirm("%s"). */ + if (!value) return GENERAL_ERROR; if (!confirm(ButtonRequestType_ButtonRequest_Other, "MESSAGE DATA", "%s %s", nameForValue, value)) { return USER_CANCELLED; @@ -391,6 +542,18 @@ void marshallDsVals(const char* value) { return; } +/* Domain-separator values are marshalled and shown together on dsConfirm()'s + single screen; every other value gets its own screen here. Refusal of either + is reported to parseVals() so no hash is produced. */ +static int confirmTypedValue(bool ds_vals, const char* value) { + if (!value) return GENERAL_ERROR; + if (ds_vals) { + marshallDsVals(value); + return SUCCESS; + } + return confirmValue(value); +} + int dsConfirm(void) { char name[41] = {0}; char version[11] = {0}; @@ -517,7 +680,6 @@ int parseVals(const json_t* eip712Types, const json_t* jType, walkVals = nextVal; while (0 != walkVals) { if (0 == strcmp(json_getName(walkVals), typeName)) { - valStr = json_getValue(walkVals); break; } else { // keep looking for val @@ -525,34 +687,38 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } } - bool hasValue = (JSON_TEXT == json_getType(walkVals) || - JSON_INTEGER == json_getType(walkVals)); - errRet = confirmName(typeName, hasValue); - if (SUCCESS != errRet) { + if (walkVals == 0) { + return JSON_TYPE_WNOVAL; + } + const jsonType_t value_type = json_getType(walkVals); + if (!fixed_array_cardinality_matches(typeType, walkVals)) { + return GENERAL_ERROR; + } + const bool hasValue = value_type == JSON_TEXT || + value_type == JSON_INTEGER || + value_type == JSON_BOOLEAN; + valStr = hasValue ? json_getValue(walkVals) : NULL; + if (SUCCESS != (errRet = confirmName(typeName, hasValue))) { return errRet; } - if (walkVals == 0) { - return JSON_TYPE_WNOVAL; - } else { - if (0 == strncmp("address", typeType, strlen("address") - 1)) { + { + if (type_matches(typeType, "address")) { if (']' == typeType[strlen(typeType) - 1]) { // array of addresses + if (value_type != JSON_ARRAY) return GENERAL_ERROR; json_t const* addrVals = json_getChild(walkVals); sha3_256_Init(&valCtx); // hash of concatenated encoded strings while (0 != addrVals) { + if (json_getType(addrVals) != JSON_TEXT) return GENERAL_ERROR; + const char* address = json_getValue(addrVals); // just walk the string values assuming, for fixed sizes, all // values are there. - if (ds_vals) { - marshallDsVals(json_getValue(addrVals)); - } else { - errRet = confirmValue(json_getValue(addrVals)); - if (SUCCESS != errRet) { - return errRet; - } + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, address))) { + return errRet; } - errRet = encAddress(json_getValue(addrVals), encBytes); + errRet = encAddress(address, encBytes); if (SUCCESS != errRet) { return errRet; } @@ -561,13 +727,9 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } keccak_Final(&valCtx, encBytes); } else { - if (ds_vals) { - marshallDsVals(valStr); - } else { - errRet = confirmValue(valStr); - if (SUCCESS != errRet) { - return errRet; - } + if (value_type != JSON_TEXT) return GENERAL_ERROR; + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { + return errRet; } errRet = encAddress(valStr, encBytes); if (SUCCESS != errRet) { @@ -575,24 +737,23 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } } - } else if (0 == strncmp("string", typeType, strlen("string") - 1)) { + } else if (type_matches(typeType, "string")) { if (']' == typeType[strlen(typeType) - 1]) { // array of strings + if (value_type != JSON_ARRAY) return GENERAL_ERROR; json_t const* stringVals = json_getChild(walkVals); uint8_t strEncBytes[32]; sha3_256_Init(&valCtx); // hash of concatenated encoded strings while (0 != stringVals) { + if (json_getType(stringVals) != JSON_TEXT) return GENERAL_ERROR; + const char* string_value = json_getValue(stringVals); // just walk the string values assuming, for fixed sizes, all // values are there. - if (ds_vals) { - marshallDsVals(json_getValue(stringVals)); - } else { - errRet = confirmValue(json_getValue(stringVals)); - if (SUCCESS != errRet) { - return errRet; - } + if (SUCCESS != + (errRet = confirmTypedValue(ds_vals, string_value))) { + return errRet; } - errRet = encString(json_getValue(stringVals), strEncBytes); + errRet = encString(string_value, strEncBytes); if (SUCCESS != errRet) { return errRet; } @@ -601,13 +762,9 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } keccak_Final(&valCtx, encBytes); } else { - if (ds_vals) { - marshallDsVals(valStr); - } else { - errRet = confirmValue(valStr); - if (SUCCESS != errRet) { - return errRet; - } + if (value_type != JSON_TEXT) return GENERAL_ERROR; + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { + return errRet; } errRet = encString(valStr, encBytes); if (SUCCESS != errRet) { @@ -615,21 +772,19 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } } - } else if ((0 == strncmp("uint", typeType, strlen("uint") - 1)) || - (0 == strncmp("int", typeType, strlen("int") - 1))) { + } else if (type_is_integer(typeType, "uint") || + type_is_integer(typeType, "int")) { if (']' == typeType[strlen(typeType) - 1]) { return INT_ARRAY_ERROR; } else { - if (ds_vals) { - marshallDsVals(valStr); - } else { - errRet = confirmValue(valStr); - if (SUCCESS != errRet) { - return errRet; - } + if (value_type != JSON_TEXT && value_type != JSON_INTEGER) + return GENERAL_ERROR; + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { + return errRet; } + const bool is_uint = type_is_integer(typeType, "uint"); uint8_t negInt = 0; // 0 is positive, 1 is negative - if (0 == strncmp("int", typeType, strlen("int") - 1)) { + if (!is_uint) { if (*valStr == '-') { negInt = 1; } @@ -645,153 +800,184 @@ int parseVals(const json_t* eip712Types, const json_t* jType, } } // all int strings are assumed to be base 10 and fit into 64 bits - long long intVal = strtoll(valStr, NULL, 10); + const char* digits = valStr + (negInt ? 1 : 0); + if (*digits == '\0') return GENERAL_ERROR; + for (const char* p = digits; *p; p++) { + if (*p < '0' || *p > '9') return GENERAL_ERROR; + } + errno = 0; + char* endptr = NULL; + long long intVal = strtoll(valStr, &endptr, 10); + if (errno == ERANGE || endptr == valStr || *endptr != '\0') { + return GENERAL_ERROR; + } + if (is_uint && intVal < 0) { + return GENERAL_ERROR; + } + const unsigned declared_bits = + integer_type_width(typeType, is_uint ? "uint" : "int"); + if (declared_bits < 64) { + if (is_uint) { + const uint64_t max_value = (UINT64_C(1) << declared_bits) - 1; + if ((uint64_t)intVal > max_value) return GENERAL_ERROR; + } else { + const int64_t min_value = -(INT64_C(1) << (declared_bits - 1)); + const int64_t max_value = + (INT64_C(1) << (declared_bits - 1)) - 1; + if (intVal < min_value || intVal > max_value) + return GENERAL_ERROR; + } + } // Needs to be big endian, so add to encBytes appropriately - encBytes[24] = (intVal >> 56) & 0xff; - encBytes[25] = (intVal >> 48) & 0xff; - encBytes[26] = (intVal >> 40) & 0xff; - encBytes[27] = (intVal >> 32) & 0xff; - encBytes[28] = (intVal >> 24) & 0xff; - encBytes[29] = (intVal >> 16) & 0xff; - encBytes[30] = (intVal >> 8) & 0xff; - encBytes[31] = (intVal) & 0xff; + const uint64_t intBits = (uint64_t)intVal; + encBytes[24] = (intBits >> 56) & 0xff; + encBytes[25] = (intBits >> 48) & 0xff; + encBytes[26] = (intBits >> 40) & 0xff; + encBytes[27] = (intBits >> 32) & 0xff; + encBytes[28] = (intBits >> 24) & 0xff; + encBytes[29] = (intBits >> 16) & 0xff; + encBytes[30] = (intBits >> 8) & 0xff; + encBytes[31] = intBits & 0xff; } - } else if (0 == strncmp("bytes", typeType, strlen("bytes"))) { - if (']' == typeType[strlen(typeType) - 1]) { - return BYTESN_ARRAY_ERROR; - } else { - // This could be 'bytes', 'bytes1', ..., 'bytes32' - if (ds_vals) { - marshallDsVals(valStr); + } else { + unsigned byte_size = 0; + bool dynamic_bytes = false; + if (type_is_bytes(typeType, &byte_size, &dynamic_bytes)) { + if (']' == typeType[strlen(typeType) - 1]) { + return BYTESN_ARRAY_ERROR; } else { - errRet = confirmValue(valStr); - if (SUCCESS != errRet) { + if (value_type != JSON_TEXT) return GENERAL_ERROR; + // This could be 'bytes', 'bytes1', ..., 'bytes32' + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { return errRet; } - } - if (0 == strcmp(typeType, "bytes")) { - errRet = encodeBytes(valStr, encBytes); - if (SUCCESS != errRet) { - return errRet; + if (dynamic_bytes) { + errRet = encodeBytes(valStr, encBytes); + if (SUCCESS != errRet) { + return errRet; + } + + } else { + errRet = encodeBytesN(typeType, valStr, encBytes); + if (SUCCESS != errRet) { + return errRet; + } } + } + } else if (type_matches(typeType, "bool")) { + if (']' == typeType[strlen(typeType) - 1]) { + return BOOL_ARRAY_ERROR; } else { - errRet = encodeBytesN(typeType, valStr, encBytes); - if (SUCCESS != errRet) { + if (value_type != JSON_BOOLEAN && value_type != JSON_TEXT) + return GENERAL_ERROR; + if (SUCCESS != (errRet = confirmTypedValue(ds_vals, valStr))) { return errRet; } + if (strcmp(valStr, "true") != 0 && strcmp(valStr, "false") != 0) + return GENERAL_ERROR; + for (ctr = 0; ctr < 32; ctr++) { + // leading zeros in bool + encBytes[ctr] = 0; + } + if (strcmp(valStr, "true") == 0) { + encBytes[31] = 0x01; + } } - } - } else if (0 == strncmp("bool", typeType, strlen(typeType))) { - if (']' == typeType[strlen(typeType) - 1]) { - return BOOL_ARRAY_ERROR; } else { - if (ds_vals) { - marshallDsVals(valStr); + // encode user defined type + char encSubTypeStr[STRBUFSIZE + 1] = {0}; + // clear out the user-defined types list + for (ctr = 0; ctr < MAX_USERDEF_TYPES; ctr++) { + udefList[ctr] = NULL; + } + + char typeNoArrTok[MAX_TYPESTRING] = {0}; + // need to get typehash of type first + if (']' == typeType[strlen(typeType) - 1]) { + // array of structs. To parse name, remove array tokens. + if (value_type != JSON_ARRAY) return GENERAL_ERROR; + strncpy(typeNoArrTok, typeType, sizeof(typeNoArrTok) - 1); + if (strlen(typeNoArrTok) < strlen(typeType)) { + return UDEF_ARRAY_NAME_ERR; + } + strtok(typeNoArrTok, "["); + if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { + return errRet; + } + if (SUCCESS != (errRet = parseType(eip712Types, typeNoArrTok, + encSubTypeStr))) { + return errRet; + } } else { - errRet = confirmValue(valStr); - if (SUCCESS != errRet) { + if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { + return errRet; + } + if (SUCCESS != + (errRet = parseType(eip712Types, typeType, encSubTypeStr))) { return errRet; } } - for (ctr = 0; ctr < 32; ctr++) { - // leading zeros in bool - encBytes[ctr] = 0; - } - if (0 == strncmp(valStr, "true", sizeof("true"))) { - encBytes[31] = 0x01; - } - } + sha3_256_Init(&valCtx); + sha3_Update(&valCtx, (const unsigned char*)encSubTypeStr, + (size_t)strlen(encSubTypeStr)); + keccak_Final(&valCtx, encBytes); - } else { - // encode user defined type - char encSubTypeStr[STRBUFSIZE + 1] = {0}; - // clear out the user-defined types list - for (ctr = 0; ctr < MAX_USERDEF_TYPES; ctr++) { - udefList[ctr] = NULL; - } + if (']' == typeType[strlen(typeType) - 1]) { + // array of udefs + struct SHA3_CTX eleCtx = {0}; // local hash context + struct SHA3_CTX arrCtx = {0}; // array elements hash context + uint8_t eleHashBytes[32]; - char typeNoArrTok[MAX_TYPESTRING] = {0}; - // need to get typehash of type first - if (']' == typeType[strlen(typeType) - 1]) { - // array of structs. To parse name, remove array tokens. - strncpy(typeNoArrTok, typeType, sizeof(typeNoArrTok) - 1); - if (strlen(typeNoArrTok) < strlen(typeType)) { - return UDEF_ARRAY_NAME_ERR; - } - strtok(typeNoArrTok, "["); - if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { - return errRet; - } - if (SUCCESS != (errRet = parseType(eip712Types, typeNoArrTok, - encSubTypeStr))) { - return errRet; - } - } else { - if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { - return errRet; - } - if (SUCCESS != - (errRet = parseType(eip712Types, typeType, encSubTypeStr))) { - return errRet; - } - } - sha3_256_Init(&valCtx); - sha3_Update(&valCtx, (const unsigned char*)encSubTypeStr, - (size_t)strlen(encSubTypeStr)); - keccak_Final(&valCtx, encBytes); - - if (']' == typeType[strlen(typeType) - 1]) { - // array of udefs - struct SHA3_CTX eleCtx = {0}; // local hash context - struct SHA3_CTX arrCtx = {0}; // array elements hash context - uint8_t eleHashBytes[32]; + sha3_256_Init(&arrCtx); - sha3_256_Init(&arrCtx); + json_t const* udefVals = json_getChild(walkVals); + while (0 != udefVals) { + if (json_getType(udefVals) != JSON_OBJ) return GENERAL_ERROR; + sha3_256_Init(&eleCtx); + sha3_Update(&eleCtx, (const unsigned char*)encBytes, 32); + if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { + return errRet; + } + if (SUCCESS != + (errRet = parseVals( + eip712Types, + json_getProperty(eip712Types, + strtok(typeNoArrTok, "]")), + json_getChild(udefVals), // where to get the values + &eleCtx // encode hash happens in parse, this is the + // return + ))) { + return errRet; + } + keccak_Final(&eleCtx, eleHashBytes); + sha3_Update(&arrCtx, (const unsigned char*)eleHashBytes, 32); + // just walk the udef values assuming, for fixed sizes, all + // values are there. + udefVals = json_getSibling(udefVals); + } + keccak_Final(&arrCtx, encBytes); - json_t const* udefVals = json_getChild(walkVals); - while (0 != udefVals) { - sha3_256_Init(&eleCtx); - sha3_Update(&eleCtx, (const unsigned char*)encBytes, 32); + } else { + if (value_type != JSON_OBJ) return GENERAL_ERROR; + sha3_256_Init(&valCtx); + sha3_Update(&valCtx, (const unsigned char*)encBytes, + (size_t)sizeof(encBytes)); if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { return errRet; } if (SUCCESS != (errRet = parseVals( - eip712Types, - json_getProperty(eip712Types, strtok(typeNoArrTok, "]")), - json_getChild(udefVals), // where to get the values - &eleCtx // encode hash happens in parse, this is the - // return + eip712Types, json_getProperty(eip712Types, typeType), + json_getChild(walkVals), // where to get the values + &valCtx // val hash happens in parse, this is the return ))) { return errRet; } - keccak_Final(&eleCtx, eleHashBytes); - sha3_Update(&arrCtx, (const unsigned char*)eleHashBytes, 32); - // just walk the udef values assuming, for fixed sizes, all values - // are there. - udefVals = json_getSibling(udefVals); + keccak_Final(&valCtx, encBytes); } - keccak_Final(&arrCtx, encBytes); - - } else { - sha3_256_Init(&valCtx); - sha3_Update(&valCtx, (const unsigned char*)encBytes, - (size_t)sizeof(encBytes)); - if (STACK_GOOD != (errRet = memcheck(STACK_SIZE_GUARD))) { - return errRet; - } - if (SUCCESS != - (errRet = parseVals( - eip712Types, json_getProperty(eip712Types, typeType), - json_getChild(walkVals), // where to get the values - &valCtx // val hash happens in parse, this is the return - ))) { - return errRet; - } - keccak_Final(&valCtx, encBytes); } } } @@ -802,8 +988,7 @@ int parseVals(const json_t* eip712Types, const json_t* jType, tarray = json_getSibling(tarray); } if (ds_vals) { - errRet = dsConfirm(); - if (SUCCESS != errRet) { + if (SUCCESS != (errRet = dsConfirm())) { return errRet; } } diff --git a/lib/firmware/eos.c b/lib/firmware/eos.c index 31ec40060..c45d85d10 100644 --- a/lib/firmware/eos.c +++ b/lib/firmware/eos.c @@ -477,8 +477,15 @@ bool eos_compileActionUnknown(const EosActionCommon* common, } if (!eos_unknownActionPolicyAllows(storage_isPolicyEnabled("AdvancedMode"))) { - fsm_sendFailure(FailureType_Failure_Other, - "Enable AdvancedMode to sign arbitrary EOS actions"); + // The refusal below is unconditional: review()'s return value is + // discarded on purpose, so the gate never depends on it. The screen is + // the on-device disclosure of *why* signing stopped, mirroring + // ethereum.c / fsm_msgSolanaSignMessage / fsm_msgTonSignTx. + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "Arbitrary EOS actions require AdvancedMode. " + "Enable in device settings."); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "Arbitrary EOS action signing disabled by policy"); eos_signingAbort(); layoutHome(); return false; @@ -632,7 +639,13 @@ bool eos_signTx(EosSignedTx* tx) { time_t expiry = header.expiration; char expiry_str[26]; +#ifdef _WIN32 + // asctime_s is the bounds-checked Windows variant; output truncated below. + // cppcheck-suppress asctime_sCalled + asctime_s(expiry_str, sizeof(expiry_str), gmtime(&expiry)); +#else asctime_r(gmtime(&expiry), expiry_str); +#endif expiry_str[24] = 0; // cut off the '\n' uint32_t delay = header.delay_sec; if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", diff --git a/lib/firmware/ethereum.c b/lib/firmware/ethereum.c index 16d24b0e8..f7a68de72 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" @@ -58,6 +59,15 @@ bool ethereum_typed_hash_policy_allows(bool advanced_mode) { */ bool ethereum_structured_eip712_enabled(void) { return false; } +/* Exact match, never a prefix. The legacy test was + * strncmp(primeType, "EIP712Domain", strlen(primeType)) + * whose length came from the HOST-supplied string, so every prefix -- "" and + * "EIP" included -- compared equal and took the domain-only branch, emitting a + * signature with no message hash for typed data the user was shown. */ +bool ethereum_eip712_is_domain_primary_type(const char* primary_type) { + return primary_type && strcmp(primary_type, "EIP712Domain") == 0; +} + /* The EIP-155 legacy recovery id is v + 2 * chain_id + 35, computed below in * a uint32_t, where v is 0 or 1. The bound is the largest chain id whose * WORST case still fits: @@ -77,11 +87,11 @@ bool ethereum_structured_eip712_enabled(void) { return false; } static bool ethereum_signing = false; static uint32_t data_total, data_left; -/* Arbitrary calldata may continue across EthereumTxAck messages. Track a - * second Keccak state whose sole input is calldata so the final approval can - * bind to every byte, not merely the first chunk or the whole RLP preimage. */ +/* Arbitrary calldata can continue across EthereumTxAck messages. This second + * Keccak state commits the approval screen to every calldata byte, independent + * of the transaction-signing preimage. It borrows the cleared per-transaction + * metadata arena so the hardware SRAM reserve does not shrink. */ static bool data_hash_pending = false; -static struct SHA3_CTX data_keccak_ctx; static EthereumTxRequest msg_tx_request; static CONFIDENTIAL uint8_t privkey[32]; static uint32_t chain_id; @@ -141,6 +151,14 @@ bool ethereum_getStandardERC20Coin(const EthereumSignTx* msg, CoinType* coin) { return true; } +void bn_from_bytes(const uint8_t* value, size_t value_len, bignum256* val) { + uint8_t pad_val[32]; + memset(pad_val, 0, sizeof(pad_val)); + memcpy(pad_val + (32 - value_len), value, value_len); + bn_read_be(pad_val, val); + memzero(pad_val, sizeof(pad_val)); +} + bool ethereumFormatTransferAmount(const EthereumSignTx* msg, char* buf, int buflen) { if (!msg || !buf || buflen <= 0 || !ethereum_chainIdIsValid(msg)) { @@ -166,14 +184,6 @@ bool ethereumFormatTransferAmount(const EthereumSignTx* msg, char* buf, return ethereumFormatAmount(&value, token, msg->chain_id, buf, buflen); } -void bn_from_bytes(const uint8_t* value, size_t value_len, bignum256* val) { - uint8_t pad_val[32]; - memset(pad_val, 0, sizeof(pad_val)); - memcpy(pad_val + (32 - value_len), value, value_len); - bn_read_be(pad_val, val); - memzero(pad_val, sizeof(pad_val)); -} - static inline void hash_data(const uint8_t* buf, size_t size) { sha3_Update(&keccak_ctx, buf, size); } @@ -260,6 +270,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...) @@ -279,6 +303,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; @@ -321,6 +360,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"); @@ -382,7 +434,7 @@ static void finalize_eip1559_and_send_signature(void) { /* Format a 256 bit number (amount in wei) into a human readable format * using standard ethereum units. - * The buffer must be at least 28 bytes so the overflow sentinel always fits. + * The buffer must be at least 25 bytes. */ bool ethereumFormatAmount(const bignum256* amnt, const TokenType* token, uint32_t cid, char* buf, int buflen) { @@ -452,7 +504,7 @@ bool ethereumFormatAmount(const bignum256* amnt, const TokenType* token, break; // Arbitrum One case 43114: suffix = " AVAX"; - break; // Avalanche C-Chain + break; // Avalanche C-Chain } /* No case matched: this chain's native asset has no name here. @@ -479,8 +531,18 @@ bool ethereumFormatAmount(const bignum256* amnt, const TokenType* token, } } } - if (!bn_format(amnt, NULL, suffix, decimals, 0, false, buf, buflen)) { - strlcpy(buf, "AMOUNT TOO LARGE TO DISPLAY", buflen); + /* bn_format() BLANKS the buffer and returns 0 when the value does not fit: + * BN_FORMAT_ADD_OUTPUT_CHAR does memset(output, 0, output_length) on + * overflow. Ignoring the return therefore renders an EMPTY amount on the + * confirmation screen, and an empty string is the one rendering a user + * cannot read as wrong -- they approve a transfer whose value was never + * shown. A 256-bit value at 18 decimals needs ~80 characters, so this is + * reachable with an ordinary large-amount transfer, not a corner case. + * + * Never leave the caller a blank amount. Say the value could not be shown, + * so the screen is refusable rather than silently empty. */ + if (bn_format(amnt, NULL, suffix, decimals, 0, false, buf, buflen) == 0) { + strlcpy(buf, _("AMOUNT TOO LARGE TO DISPLAY"), buflen); return false; } return true; @@ -496,7 +558,11 @@ static bool layoutEthereumConfirmTx(const uint8_t* to, uint32_t to_len, memcpy(pad_val + (32 - value_len), value, value_len); bn_read_be(pad_val, &val); - char amount[32]; + /* 256-bit at 18 decimals is 60 integer digits + '.' + 18 fractional + a + * suffix, so 32 bytes silently blanked the amount for ordinary large + * transfers. Size it so the formatter cannot overflow at all; the guard in + * ethereumFormatAmount() remains as the backstop. */ + char amount[96]; if (token == NULL) { if (bn_is_zero(&val)) { strcpy(amount, _("message")); @@ -551,16 +617,17 @@ static bool layoutEthereumConfirmTx(const uint8_t* to, uint32_t to_len, } static bool confirm_ethereum_data_hash(void) { + struct SHA3_CTX* data_keccak_ctx = signed_metadata_keccak_scratch(); + if (data_keccak_ctx == NULL) return false; + uint8_t digest[32]; char hex_digest[65]; - keccak_Final(&data_keccak_ctx, digest); + keccak_Final(data_keccak_ctx, digest); + memzero(data_keccak_ctx, sizeof(*data_keccak_ctx)); data2hex(digest, sizeof(digest), hex_digest); data_hash_pending = false; - /* ASCII hex lets an AdvancedMode user compare the exact Keccak-256 with a - * host-side value. confirm_bytes() guarantees all 64 characters are shown - * if a future layout/font makes them span more than one screen. */ const bool approved = confirm_bytes( ButtonRequestType_ButtonRequest_ConfirmOutput, "Ethereum Data Hash", (const uint8_t*)hex_digest, sizeof(hex_digest) - 1); @@ -671,8 +738,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; } @@ -700,7 +772,6 @@ void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, ethereum_signing = true; data_hash_pending = false; - memzero(&data_keccak_ctx, sizeof(data_keccak_ctx)); sha3_256_Init(&keccak_ctx); memset(&msg_tx_request, 0, sizeof(EthereumTxRequest)); @@ -735,7 +806,7 @@ void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, * id >= 1; a host omitting the field is malformed, not legacy. */ chain_id = msg->has_chain_id ? msg->chain_id : 0; - if (!ethereum_chainIdIsValid(msg)) { + if (chain_id < 1) { fsm_sendFailure(FailureType_Failure_SyntaxError, _("Chain Id out of bounds")); ethereum_signing_abort(); @@ -770,17 +841,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; } @@ -840,6 +924,40 @@ void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, data_needs_confirm = false; } + // Signed metadata clear signing (backwards compatible). + // Only fires if host sent EthereumTxMetadata before this EthereumSignTx. + if (data_needs_confirm && data_total > 0 && signed_metadata_available()) { + if (signed_metadata_matches_tx(msg)) { + if (signed_metadata_confirm()) { + if (signed_metadata_from_loaded_signer()) { + /* A self-service signer is annotation-only. Its decoded screens are + * followed by the same amount and raw-calldata review an Advanced + * transaction would have received without metadata. A lying runtime + * schema therefore cannot conceal transaction bytes. */ + needs_confirm = true; + data_needs_confirm = true; + } else { + /* A future firmware-pinned signer may replace the raw-data screen. + * Payable calls still show amount/recipient because a v2 schema + * describes calldata only and cannot bind msg->value. */ + needs_confirm = signed_metadata_schema_moves_value(); + data_needs_confirm = false; + } + } else { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "Signing cancelled by user"); + ethereum_signing_abort(); // clears metadata + return; + } + } + } + // Drop metadata now UNLESS we relied on it to suppress the raw-data confirm + // (then it must survive to bind the signature). Prevents stale reuse when the + // contractHandled / ERC-20 paths bypass the metadata check above. + if (!signed_metadata_relied()) { + signed_metadata_clear(); + } + // detect ERC-20 token if (data_total == 68 && ethereum_isStandardERC20Transfer(msg)) { token = tokenByChainAddress(chain_id, msg->to.bytes); @@ -849,6 +967,19 @@ void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, if (data_total == 68 && ethereum_isStandardERC20Approve(msg)) { token = tokenByChainAddress(chain_id, msg->to.bytes); is_approve = true; + + /* An unlimited allowance transfers open-ended authority to the spender. + * This release line deliberately refuses it instead of presenting it as a + * bounded token withdrawal. Zero and finite approvals remain supported. */ + const uint8_t* allowance = msg->data_initial_chunk.bytes + 36; + bool unlimited = true; + for (size_t i = 0; i < 32; i++) unlimited &= allowance[i] == 0xff; + if (unlimited) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Unlimited ERC20 approval is disabled")); + ethereum_signing_abort(); + return; + } } if (needs_confirm) { @@ -921,11 +1052,17 @@ void ethereum_signing_init(EthereumSignTx* msg, const HDNode* node, return; } - /* A prefix preview cannot commit to executable bytes in later chunks. - * Collect the complete calldata and approve its Keccak-256 only after the - * last byte has arrived. */ - sha3_256_Init(&data_keccak_ctx); - sha3_Update(&data_keccak_ctx, msg->data_initial_chunk.bytes, + /* A prefix preview cannot bind executable bytes delivered in later + * chunks. Defer consent until the complete calldata hash is available. */ + struct SHA3_CTX* data_keccak_ctx = signed_metadata_keccak_scratch(); + if (data_keccak_ctx == NULL) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Ethereum data review unavailable")); + ethereum_signing_abort(); + return; + } + sha3_256_Init(data_keccak_ctx); + sha3_Update(data_keccak_ctx, msg->data_initial_chunk.bytes, msg->data_initial_chunk.size); data_hash_pending = true; } @@ -958,24 +1095,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]); @@ -1022,19 +1159,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; @@ -1076,7 +1220,14 @@ void ethereum_signing_txack(EthereumTxAck* tx) { } if (data_hash_pending) { - sha3_Update(&data_keccak_ctx, tx->data_chunk.bytes, tx->data_chunk.size); + struct SHA3_CTX* data_keccak_ctx = signed_metadata_keccak_scratch(); + if (data_keccak_ctx == NULL) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Ethereum data review unavailable")); + ethereum_signing_abort(); + return; + } + sha3_Update(data_keccak_ctx, tx->data_chunk.bytes, tx->data_chunk.size); } hash_data(tx->data_chunk.bytes, tx->data_chunk.size); @@ -1100,12 +1251,16 @@ void ethereum_signing_abort(void) { if (ethereum_signing) { memzero(privkey, sizeof(privkey)); data_hash_pending = false; - memzero(&data_keccak_ctx, sizeof(data_keccak_ctx)); + signed_metadata_clear(); layoutHome(); ethereum_signing = false; } } +/* Whether a signing flow is mid-flight. The clearsign metadata handlers + * refuse to accept metadata once signing has started. */ +bool ethereum_signing_isInProgress(void) { return ethereum_signing; } + static void ethereum_message_hash(const uint8_t* message, size_t message_len, uint8_t hash[32]) { struct SHA3_CTX ctx; @@ -1258,6 +1413,19 @@ void ethereum_typed_hash_sign(const EthereumSignTypedHash* msg, resp->signature.bytes[64] = 27 + v; resp->signature.size = 65; + /* Populate response-only fields after every confirmation. Emulator debug + * requests (including screenshot capture) share msg_resp and can clear data + * prepared before the confirmation callbacks complete. */ + uint8_t pubkeyhash[20] = {0}; + if (!hdnode_get_ethereum_pubkeyhash(node, pubkeyhash)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Ethereum address derivation failed")); + return; + } + resp->address[0] = '0'; + resp->address[1] = 'x'; + ethereum_address_checksum(pubkeyhash, resp->address + 2, false, 0); + msg_write(MessageType_MessageType_EthereumTypedDataSignature, resp); } @@ -1294,7 +1462,7 @@ const char* failMsgReturn[LAST_ERROR - 2] = { "EIP-712 pair name is NULL", "EIP-712 typeType has no name in parseVals", "EIP-712 address string is NULL", - "EIP-712 no value for type during walkVals", // 33 + "EIP-712 no value for type during walkVals", // 33 (LAST_ERROR) }; void failMessage(int err) { @@ -1302,9 +1470,13 @@ void failMessage(int err) { /* Not a parse failure: a typed-data review screen ended without a completed button hold, which is what confirm_helper() reports when the host sends Cancel or Initialize. Report it as a cancellation so the host - does not read a refusal as a malformed message. USER_CANCELLED is above - LAST_ERROR and has no failMsgReturn[] slot, so this branch must come - first. */ + does not read a refusal as a malformed message. + + USER_CANCELLED sits deliberately ABOVE LAST_ERROR and has no + failMsgReturn[] slot: the table is sized LAST_ERROR - 2 and indexed + err - 3, so giving a cancellation a row would shift every message + already in it. This branch is therefore the only thing that names the + code, and it also picks the FailureType. It must stay first. */ fsm_sendFailure(FailureType_Failure_ActionCancelled, _("EIP-712 cancelled")); return; @@ -1385,14 +1557,23 @@ void e712_types_values(Ethereum712TypesValues* msg, failMessage(JSON_PTYPENAMEERR); return; } + if (json_getType(obTest) != JSON_TEXT) { + failMessage(JSON_PTYPEVALERR); + return; + } const char* primeType; - if (0 == (primeType = json_getValue(obTest))) { + if (0 == (primeType = json_getValue(obTest)) || primeType[0] == '\0') { failMessage(JSON_PTYPEVALERR); return; } - if (0 != strncmp(primeType, "EIP712Domain", - strlen(primeType))) { // if primaryType is "EIP712Domain", - // message hash is NULL + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "EIP-712 Primary Type", (const uint8_t*)primeType, + strlen(primeType))) { + failMessage(USER_CANCELLED); + return; + } + if (!ethereum_eip712_is_domain_primary_type( + primeType)) { // domain-only signatures have no message hash errRet = encode(jsonT, jsonV, primeType, resp->message_hash.bytes); if (!(SUCCESS == errRet || NULL_MSG_HASH == errRet)) { failMessage(errRet); @@ -1415,11 +1596,26 @@ void e712_types_values(Ethereum712TypesValues* msg, resp->has_domain_separator_hash = true; resp->domain_separator_hash.size = 32; + /* Derive the signer into a local for the confirmation text. resp->address + * is deliberately not written until after the last confirmation (debug-link + * reads reuse msg_resp and clear anything staged before the confirm + * callbacks finish), and RESP_INIT memset it to "" -- so naming + * resp->address here would render "Sign with address ?" and disclose + * nothing at the one screen that has to carry the disclosure. */ + char signer_address[43] = "0x"; + uint8_t signer_pubkeyhash[20] = {0}; + if (!hdnode_get_ethereum_pubkeyhash(node, signer_pubkeyhash)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Ethereum address derivation failed")); + return; + } + ethereum_address_checksum(signer_pubkeyhash, signer_address + 2, false, 0); + // Every screen shown while parsing the typed data is a review(), which // cannot express refusal. Take one real confirmation before producing a // signature so a host cannot obtain one without a button press. if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Typed Data", - "Sign with address %s?", resp->address)) { + "Sign with address %s?", signer_address)) { fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled by user"); memzero(domainSeparatorHash, 32); @@ -1444,5 +1640,17 @@ void e712_types_values(Ethereum712TypesValues* msg, have_ds = false; } + /* Debug-link reads during confirmation reuse msg_resp, so populate the + * returned address only after the final confirmation has completed. */ + uint8_t pubkeyhash[20] = {0}; + if (!hdnode_get_ethereum_pubkeyhash(node, pubkeyhash)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Ethereum address derivation failed")); + return; + } + resp->address[0] = '0'; + resp->address[1] = 'x'; + ethereum_address_checksum(pubkeyhash, resp->address + 2, false, 0); + msg_write(MessageType_MessageType_EthereumTypedDataSignature, resp); } diff --git a/lib/firmware/ethereum_contracts.c b/lib/firmware/ethereum_contracts.c index 49d4ada9a..f2f25b5c7 100644 --- a/lib/firmware/ethereum_contracts.c +++ b/lib/firmware/ethereum_contracts.c @@ -21,10 +21,12 @@ #include "keepkey/firmware/ethereum_contracts.h" #include "keepkey/firmware/ethereum.h" +#include "keepkey/firmware/ethereum_contracts/makerdao.h" #include "keepkey/firmware/ethereum_contracts/saproxy.h" #include "keepkey/firmware/ethereum_contracts/thortx.h" #include "keepkey/firmware/ethereum_contracts/zxappliquid.h" #include "keepkey/firmware/ethereum_contracts/zxliquidtx.h" +#include "keepkey/firmware/ethereum_contracts/zxtransERC20.h" #include "keepkey/firmware/ethereum_contracts/zxswap.h" bool zx_isExchangeProxyChain(uint32_t chain_id) { @@ -56,6 +58,19 @@ bool ethereum_contractHandled(uint32_t data_total, const EthereumSignTx* msg, const HDNode* node) { (void)node; + /* Only a CALL to a contract may be clear-signed, never a CREATE. + * ethereum_signing_check() deliberately permits to.size == 0 when there is + * calldata, and a true return here sets needs_confirm = false, which + * suppresses BOTH layoutEthereumConfirmTx's "new contract?" screen and the + * ETH value screen. Most decoders pin msg->to against a known address and so + * cannot match a CREATE, but makerdao_isMakerDAO never inspects msg->to at + * all -- attacker-chosen init code carrying the `open(address)` selector and + * the Tub constant would otherwise be narrated as "MakerDAO / Open CDP?" + * while a contract deployment and its attached value were signed unseen. + * Refuse before any decoder runs, so a deployment always falls through to + * the generic disclosure path. */ + if (msg->to.size != 20) return false; + /* Every handler parses and displays fixed offsets inside the initial chunk * only. If the calldata does not fit in that chunk, the remainder streams * in via EthereumTxAck and is hashed into the signature without ever being @@ -73,35 +88,51 @@ bool ethereum_contractHandled(uint32_t data_total, const EthereumSignTx* msg, * guarantees the minimum, so establish it once here. */ if (msg->data_initial_chunk.size < 4) return false; + /* transformERC20 is bounded by the two resolved token amounts shown by its + * decoder; unresolved assets fall through to raw calldata review. */ + if (zx_isZxTransformERC20(msg)) return true; + 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; + return false; } bool ethereum_contractConfirmed(uint32_t data_total, const EthereumSignTx* msg, const HDNode* node) { + (void)node; + /* Same selector bound as ethereum_contractHandled(). This function is only * ever reached after that one returned true, so this is belt and braces -- * but the two dispatch on the same predicates and must not be able to * disagree about which of them are safe to evaluate. */ if (msg->data_initial_chunk.size < 4) return false; + if (zx_isZxTransformERC20(msg)) + return zx_confirmZxTransERC20(data_total, msg); + if (sa_isWithdrawFromSalary(msg)) return sa_confirmWithdrawFromSalary(data_total, msg); if (zx_isZxSwap(msg)) return zx_confirmZxSwap(data_total, msg); - if (zx_isZxLiquidTx(msg)) return zx_confirmZxLiquidTx(data_total, msg, node); + if (zx_isZxLiquidTx(msg)) return zx_confirmZxLiquidTx(data_total, 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)) + return makerdao_confirmMakerDAO(data_total, msg); + return false; } diff --git a/lib/firmware/ethereum_contracts/thortx.c b/lib/firmware/ethereum_contracts/thortx.c index 25fc2cdd5..a908c0bb2 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -41,19 +41,12 @@ 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; - } - return false; -} - -bool thor_assetIsNative(const uint8_t asset_address[20]) { +static bool thor_assetIsNative(const uint8_t asset_address[20]) { return asset_address != NULL && memcmp(asset_address, ETH_ADDRESS, 20) == 0; } -bool thor_formatUnknownAssetAmount(const uint8_t word[32], char* out, - size_t out_len) { +static bool thor_formatUnknownAssetAmount(const uint8_t word[32], char* out, + size_t out_len) { if (!word || !out || out_len == 0) return false; bignum256 amount; bn_from_bytes(word, 32, &amount); @@ -61,41 +54,98 @@ bool thor_formatUnknownAssetAmount(const uint8_t word[32], char* out, 0; } -bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { - /* Minimum calldata: selector(4) + vault(32) + asset(32) + amount(32) + - * memo_offset(32) + memo_length(32) = 164 bytes for deposit(), - * + expiry(32) = 196 bytes for depositWithExpiry(). */ +/* 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]); + } + out[40] = '\0'; +} + +bool thor_isMayachainTx(const EthereumSignTx* msg) { + if (!msg->has_to || msg->to.size != 20) return false; + /* MAYA_ROUTER is an Ethereum-mainnet identity; the same address on another + * EVM chain may hold unrelated attacker code. Bind to mainnet so a + * host-selected chain_id cannot borrow the trusted router UX. */ + if (!msg->has_chain_id || msg->chain_id != 1) return false; + if (!thor_has_deposit_selector(msg)) return false; + char toStr[41]; + thor_format_to_addr(msg, toStr); + return strncmp(toStr, MAYA_ROUTER, 40) == 0; +} + +/* The THORChain router address for this tx's chain, or NULL if the chain has + * no pinned router (then the deposit is not clear-signed and falls to the + * blind-sign gate). Each router address is a per-chain identity — the same + * address on another chain may hold unrelated attacker code — so the pin is + * (chain_id, address) together. A tx with NO chain_id gets no router at all: + * ethereum.c would default it to mainnet for hashing, but an identity pin + * must never be inherited from a default the host simply omitted. */ +static const char* thor_router_for_chain(const EthereumSignTx* msg) { + if (!msg->has_chain_id) return NULL; + switch (msg->chain_id) { + case 1: + return THOR_ROUTER; /* Ethereum */ + case 43114: + return THOR_ROUTER_AVAX; /* Avalanche C-Chain */ + default: + return NULL; + } +} + +bool thor_isThorchainTx(const EthereumSignTx* msg) { + if (!msg->has_to || msg->to.size != 20) return false; + if (!thor_has_deposit_selector(msg)) return false; + /* Pin to the THORChain router FOR THIS CHAIN. Without the pin, ANY contract + * carrying the deposit selector would get the THORChain clear-sign UX and + * bypass the AdvancedMode blind-sign gate, letting an attacker contract + * drain while the device shows a benign deposit. Without the chain scope, + * only mainnet deposits ever match (the AVAX->ETH blind-sign bug). */ + const char* router = thor_router_for_chain(msg); + if (!router) return false; + char toStr[41]; + thor_format_to_addr(msg, toStr); + return strncmp(toStr, router, 40) == 0; +} + +static bool thor_confirm_deposit_tx(uint32_t data_total, + const EthereumSignTx* msg, + const char* protocol_label, + const char* router_label) { + /* Minimum calldata to read the fixed head through the memo_length word: + * selector(4) + vault(32) + asset(32) + amount(32) + memo_offset(32) + + * memo_length(32) = 164 bytes for deposit(), + expiry(32) = 196 for + * depositWithExpiry(). The exact memo bounds are enforced below from the ABI + * memo length, so a short memo (e.g. "ADD:ETH.ETH") still clear-signs rather + * than being rejected by an over-tight fixed floor. */ const bool is_expiry = thor_is_expiry_variant(msg); - /* Exactly the bound needed to read the memo's ABI length word below, which - * sits at 4 + 4*32 for deposit() and 4 + 5*32 for depositWithExpiry(). The - * previous 228/260 floor assumed a fixed 64-byte memo and rejected valid - * short ones: `+:BTC/BTC::t:10` pads to 32 bytes, giving 196 bytes of - * calldata for deposit(). The exact-length equality check further down is - * what actually bounds the memo. */ const size_t min_chunk = is_expiry ? 196 : 164; if (msg->data_initial_chunk.size < min_chunk) return false; - /* The memo is a dynamic `string`. Its ABI head pointer (word 3) must be the - * canonical one - 0x80 for deposit()'s 4 head words, 0xa0 for - * depositWithExpiry()'s 5 - because the memo is read below at that FIXED - * offset, which is only where the router's abi.decode will look when the - * pointer matches. A host that points the memo elsewhere would have the - * device display a benign memo while the router executes a different swap - * destination. Refuse to clear-sign a non-canonical encoding. */ - const uint8_t* memo_off_word = msg->data_initial_chunk.bytes + 4 + 3 * 32; - for (size_t i = 0; i < 31; i++) { - if (memo_off_word[i] != 0) return false; + /* 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; + } } - if (memo_off_word[31] != (is_expiry ? 0xa0 : 0x80)) return false; - /* Read the memo's ABI length word instead of assuming a fixed 64 bytes: a - * longer memo places router-executed fields (destination, affiliate fee, - * aggregator routing) past byte 64, which the fixed-length parse never - * displayed but the router still executes. Reject dirty high bytes and cap - * at THORChain's 256-byte memo maximum. */ + /* The memo is a dynamic `string`: read its ABI length word instead of + * assuming a fixed 64 bytes. A longer memo places router-executed fields + * (destination, affiliate, aggregator, min-out) past byte 64 that a fixed + * parse never displays. Reject dirty high bytes, cap at THORChain's 256-byte + * memo max, require the whole calldata to be in this chunk, and require the + * padded memo to end exactly at the calldata end so no trailing bytes hide. + */ const uint8_t* memo_len_word = msg->data_initial_chunk.bytes + 4 + (is_expiry ? 5 : 4) * 32; - for (size_t i = 0; i < 28; i++) { + for (int i = 0; i < 28; i++) { if (memo_len_word[i] != 0) return false; } const uint32_t memo_len = ((uint32_t)memo_len_word[28] << 24) | @@ -103,41 +153,32 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { ((uint32_t)memo_len_word[30] << 8) | (uint32_t)memo_len_word[31]; if (memo_len > 256) return false; - - /* The whole calldata must be in this chunk, and must end exactly where the - * 32-byte-padded memo ends. A second chunk, or trailing words after the - * memo, would be signed but never displayed. */ const size_t memo_off = (size_t)(4 + (is_expiry ? 6 : 5) * 32); - const size_t memo_padded = (((size_t)memo_len + 31u) / 32u) * 32u; - if (data_total != msg->data_initial_chunk.size) return false; - if (memo_off + memo_padded != msg->data_initial_chunk.size) return false; - - /* The equality above bounds the calldata but says nothing about what is IN - * the ABI tail padding. Only memo_len bytes are handed to the parser and - * drawn, while all memo_padded bytes are signed, so a host can carry up to - * 31 arbitrary bytes per transaction in a region no screen ever shows. The - * router ignores them - abi.decode reads memo_len - which is exactly why - * they are attractive: they cost the sender nothing and the device vouches - * for them. Canonical ABI pads with zeroes; anything else is a non-canonical - * encoding this path already refuses elsewhere (dirty high bytes in the - * length word, a non-canonical offset pointer). Refuse it here too rather - * than sign bytes that were never displayed. */ - for (size_t i = memo_off + memo_len; i < memo_off + memo_padded; i++) { - if (msg->data_initial_chunk.bytes[i] != 0) return false; + const size_t memo_padded = ((memo_len + 31u) / 32u) * 32u; + if (data_total != msg->data_initial_chunk.size) { + return false; /* whole calldata must be in the initial chunk to bound it; + unconditional, so a message that simply omits data_length + cannot skip the bound */ + } + if (memo_off + memo_padded != msg->data_initial_chunk.size) { + return false; /* trailing bytes after the memo would be executed but hidden + */ } - char confStr[41], *conf; + char confStr[41]; + const char* conf; const TokenType* assetToken; uint8_t* thorchainData; const uint8_t* contractAssetAddress; const uint8_t *vaultAddress, *assetAddress; uint32_t ctr; - bignum256 Amount; + bignum256 Amount, Value; vaultAddress = (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 12); contractAssetAddress = (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 32 + 12); bn_from_bytes(msg->data_initial_chunk.bytes + 4 + 2 * 32, 32, &Amount); + bn_from_bytes(msg->value.bytes, msg->value.size, &Value); /* 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); @@ -159,9 +200,13 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { * routing it through the Ethereum-only 0xeeee..eeee token sentinel. A NULL * token makes ethereumFormatAmount() select the native ticker from chain_id. */ - if (thor_assetIsNative(contractAssetAddress)) { + const bool is_native = thor_assetIsNative(contractAssetAddress); + if (is_native) { assetToken = NULL; } else { + /* Token deposits pull through transferFrom; any native value would be + * swept without being represented by the ABI amount screen. */ + if (!bn_is_zero(&Value)) return false; assetToken = tokenByChainAddress(msg->chain_id, assetAddress); } @@ -174,8 +219,11 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { sizeof(amountStr))) return false; } else { - if (!ethereumFormatAmount(&Amount, assetToken, msg->chain_id, amountStr, - sizeof(amountStr))) + /* Native deposits forward msg.value; the ABI amount word is only a router + * hint and may legitimately differ. Token deposits use the ABI amount. */ + const bignum256* displayed_amount = is_native ? &Value : &Amount; + if (!ethereumFormatAmount(displayed_amount, assetToken, msg->chain_id, + amountStr, sizeof(amountStr))) return false; } @@ -219,19 +267,16 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { } // Start confirmations - for (ctr = 0; ctr < 20; ctr++) { - snprintf(&confStr[ctr * 2], 3, "%02x", msg->to.bytes[ctr]); - } - /* THOR_ROUTER is an Ethereum-mainnet identity. The same 20 bytes on another - * EVM chain are an unrelated contract, so the trusted label has to be bound - * to the chain; otherwise a host-chosen chain_id borrows it. */ - if (msg->has_chain_id && msg->chain_id == 1 && - strncmp(confStr, THOR_ROUTER, sizeof(THOR_ROUTER)) == 0) { + thor_format_to_addr(msg, confStr); + const char* thor_router = thor_router_for_chain(msg); + if (thor_router && strncmp(confStr, thor_router, 40) == 0) { conf = "Thorchain router"; + } else if (strncmp(confStr, MAYA_ROUTER, 40) == 0) { + conf = router_label; } else { conf = confStr; } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data", + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, "Routing through %s", conf)) { return false; } @@ -240,7 +285,7 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { for (ctr = 0; ctr < 20; ctr++) { snprintf(&confStr[ctr * 2], 3, "%02x", vaultAddress[ctr]); } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data", + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, "Using Asgard vault %s", confStr)) { return false; } @@ -250,36 +295,52 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { for (ctr = 0; ctr < 20; ctr++) { snprintf(&confStr[ctr * 2], 3, "%02x", assetAddress[ctr]); } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "from asset %s", confStr)) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, + "from asset %s", confStr)) { return false; } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "amount %s", amountStr)) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, + "amount %s", amountStr)) { return false; } } else { - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "Confirm sending %s", amountStr)) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, protocol_label, + "Confirm sending %s", amountStr)) { return false; } } if (is_expiry && !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "Expiry epoch %s", expiry_str)) { + protocol_label, "Expiry epoch %s", expiry_str)) { return false; } /* Pass the memo's true ABI length, not a fixed 64. There is no raw-memo - * fallback screen on this path - ethereum.c turns a false return into - * ActionCancelled - so an unparsed memo must refuse rather than sign bytes - * that were never displayed. */ + * fallback screen on this path -- ethereum.c turns a false return into + * ActionCancelled -- so anything short of a confirmed parse must refuse + * rather than sign bytes that were never displayed. */ if (thorchain_parseConfirmMemo((const char*)thorchainData, memo_len) != THORCHAIN_MEMO_CONFIRMED) { return false; } + /* Page the complete raw memo as the authoritative disclosure: a long + * structured field (dest/affiliate/aggregator) would otherwise truncate in + * its single confirm and hide the tail that the router still executes. */ + if (!thorchain_confirm_full_memo("Memo", (const char*)thorchainData, + memo_len)) + return false; + return true; } + +bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { + return thor_confirm_deposit_tx(data_total, msg, "Thorchain data", + "Thorchain router"); +} + +bool thor_confirmMayaTx(uint32_t data_total, const EthereumSignTx* msg) { + return thor_confirm_deposit_tx(data_total, msg, "Maya data", "Maya router"); +} diff --git a/lib/firmware/ethereum_contracts/zxappliquid.c b/lib/firmware/ethereum_contracts/zxappliquid.c index b8d2e89ea..dd3639641 100644 --- a/lib/firmware/ethereum_contracts/zxappliquid.c +++ b/lib/firmware/ethereum_contracts/zxappliquid.c @@ -7,14 +7,6 @@ * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this library. If not, see . */ #include "keepkey/firmware/ethereum_contracts/zxappliquid.h" @@ -22,108 +14,146 @@ #include "keepkey/board/confirm_sm.h" #include "keepkey/board/util.h" -#include "keepkey/firmware/app_confirm.h" -#include "keepkey/firmware/coins.h" #include "keepkey/firmware/ethereum.h" #include "keepkey/firmware/ethereum_tokens.h" -#include "keepkey/firmware/fsm.h" -#include "keepkey/firmware/storage.h" -#include "trezor/crypto/address.h" -#include "trezor/crypto/bip32.h" -#include "trezor/crypto/curves.h" -#include "trezor/crypto/memzero.h" +#include "trezor/crypto/bignum.h" #include "trezor/crypto/sha3.h" -bool zx_confirmApproveLiquidity(uint32_t data_total, - const EthereumSignTx *msg) { - (void)data_total; - const char *to, *tikstr, *poolstr, *allowance, *amt; - unsigned char data[40]; - uint8_t digest[SHA3_256_DIGEST_LENGTH] = {0}; - uint8_t tokdigest[SHA3_256_DIGEST_LENGTH] = {0}; - char digestStr[2 * SHA3_256_DIGEST_LENGTH + 1], amtStr[2 * 32 + 1] = {0}; - int32_t ctr, tokctr; - uint32_t wethord; - const TokenType *WETH, *ttoken; - - if (!tokenByTicker(msg->chain_id, "WETH", &WETH)) return false; - wethord = read_be((const uint8_t *)WETH->address); - to = (const char *)msg->to.bytes; - tokctr = 0; - while (tokctr != -1) { - ttoken = tokenIter(&tokctr); - - // https://uniswap.org/docs/v2/smart-contract-integration/getting-pair-addresses/ - uint32_t ttokenord = read_be((const uint8_t *)ttoken->address); - if (ttokenord < wethord) { - memcpy(data, ttoken->address, 20); - memcpy(&data[20], WETH->address, 20); - } else { - memcpy(data, WETH->address, 20); - memcpy(&data[20], ttoken->address, 20); - } - keccak_256(data, sizeof(data), tokdigest); - SHA3_CTX ctx = {0}; - keccak_256_Init(&ctx); - keccak_Update(&ctx, (unsigned char *)"\xff", 1); - keccak_Update(&ctx, (unsigned char *)"\x5C\x69\xbE\xe7\x01\xef\x81\x4a\x2B\x6a\x3E\xDD\x4B\x16\x52\xCB\x9c\xc5\xaA\x6f", 20); - keccak_Update(&ctx, tokdigest, sizeof(tokdigest)); - keccak_Update(&ctx, (unsigned char *)"\x96\xe8\xac\x42\x77\x19\x8f\xf8\xb6\xf7\x85\x47\x8a\xa9\xa3\x9f\x40\x3c\xb7\x68\xdd\x02\xcb\xee\x32\x6c\x3e\x7d\xa3\x48\x84\x5f", 32); - keccak_Final(&ctx, digest); - if (memcmp(to, &digest[12], 20) == 0) break; +#include +#include + +#define UNISWAP_APPROVE_CALL_SIZE (4 + 2 * 32) +#define UNISWAP_AMOUNT_TEXT_SIZE 96 + +static const uint8_t UNISWAP_FACTORY_ADDRESS[20] = { + 0x5c, 0x69, 0xbe, 0xe7, 0x01, 0xef, 0x81, 0x4a, 0x2b, 0x6a, + 0x3e, 0xdd, 0x4b, 0x16, 0x52, 0xcb, 0x9c, 0xc5, 0xaa, 0x6f}; +static const uint8_t UNISWAP_PAIR_INIT_CODE_HASH[32] = { + 0x96, 0xe8, 0xac, 0x42, 0x77, 0x19, 0x8f, 0xf8, 0xb6, 0xf7, 0x85, + 0x47, 0x8a, 0xa9, 0xa3, 0x9f, 0x40, 0x3c, 0xb7, 0x68, 0xdd, 0x02, + 0xcb, 0xee, 0x32, 0x6c, 0x3e, 0x7d, 0xa3, 0x48, 0x84, 0x5f}; +static const uint8_t WETH_MAINNET_ADDRESS[20] = { + 0xc0, 0x2a, 0xaa, 0x39, 0xb2, 0x23, 0xfe, 0x8d, 0x0a, 0x0e, + 0x5c, 0x4f, 0x27, 0xea, 0xd9, 0x08, 0x3c, 0x75, 0x6c, 0xc2}; + +static bool tx_value_is_zero(const EthereumSignTx* msg) { + if (!msg->has_value && msg->value.size != 0) return false; + for (size_t i = 0; i < msg->value.size; i++) { + if (msg->value.bytes[i] != 0) return false; } + return true; +} - if (tokctr != -1) { - for (ctr = 0; ctr < SHA3_256_DIGEST_LENGTH; ctr++) { - snprintf(&digestStr[ctr * 2], 3, "%02x", digest[ctr]); - } - tikstr = ttoken->ticker; - poolstr = &digestStr[12 * 2]; - } else { - for (ctr = 0; ctr < 20; ctr++) { - snprintf(&digestStr[ctr * 2], 3, "%02x", to[ctr]); - } - tikstr = ""; - poolstr = digestStr; +static bool spender_word_is_router(const EthereumSignTx* msg) { + const uint8_t* word = msg->data_initial_chunk.bytes + 4; + for (size_t i = 0; i < 12; i++) { + if (word[i] != 0) return false; } + return memcmp(word + 12, UNISWAP_ROUTER_ADDRESS, 20) == 0; +} - allowance = (char *)(msg->data_initial_chunk.bytes + 4 + 32); - if (memcmp(allowance, (uint8_t *)&MAX_ALLOWANCE, 32) == 0) { - amt = "full balance"; +static void derive_pair_address(const uint8_t* token_a, const uint8_t* token_b, + uint8_t pair[20]) { + uint8_t ordered[40]; + if (memcmp(token_a, token_b, 20) < 0) { + memcpy(ordered, token_a, 20); + memcpy(ordered + 20, token_b, 20); } else { - for (ctr = 0; ctr < 32; ctr++) { - snprintf(&amtStr[ctr * 2], 3, "%02x", allowance[ctr]); - } - amt = amtStr; + memcpy(ordered, token_b, 20); + memcpy(ordered + 20, token_a, 20); } - const char *appStr = "uniswap approve liquidity"; - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, - "Amount: %s", amt)) { + uint8_t salt[SHA3_256_DIGEST_LENGTH]; + uint8_t digest[SHA3_256_DIGEST_LENGTH]; + keccak_256(ordered, sizeof(ordered), salt); + SHA3_CTX ctx = {0}; + keccak_256_Init(&ctx); + const uint8_t prefix = 0xff; + keccak_Update(&ctx, &prefix, 1); + keccak_Update(&ctx, UNISWAP_FACTORY_ADDRESS, sizeof(UNISWAP_FACTORY_ADDRESS)); + keccak_Update(&ctx, salt, sizeof(salt)); + keccak_Update(&ctx, UNISWAP_PAIR_INIT_CODE_HASH, + sizeof(UNISWAP_PAIR_INIT_CODE_HASH)); + keccak_Final(&ctx, digest); + memcpy(pair, digest + 12, 20); +} + +static const TokenType* pool_underlying_token(const EthereumSignTx* msg) { + int32_t token_index = 0; + while (token_index >= 0) { + const TokenType* token = tokenIter(&token_index); + /* tokenIter() returns UnknownToken *and* sets the counter to -1 when the + * list is exhausted, so the body still runs once more unless we stop here. + * UnknownToken.address is a 3-byte string literal; reading 20 bytes of it + * is an over-read past the literal. */ + if (token == UnknownToken) break; + if (token->chain_id != 1 || + memcmp(token->address, WETH_MAINNET_ADDRESS, 20) == 0) + continue; + uint8_t pair[20]; + derive_pair_address((const uint8_t*)token->address, WETH_MAINNET_ADDRESS, + pair); + if (memcmp(msg->to.bytes, pair, 20) == 0) return token; + } + return NULL; +} + +/// \param[out] token_out When non-NULL, receives the pool's non-WETH token on +/// success. Lets the confirm path reuse the one table scan the shape check +/// already paid for, instead of walking all of tokens[] a second time. +static bool approve_shape_is_clear_signable(const EthereumSignTx* msg, + const TokenType** token_out) { + /* UNISWAP_ROUTER_ADDRESS (as ERC20 approve spender) is an Ethereum-mainnet + * identity. See GH #431. */ + if (!msg->has_chain_id || msg->chain_id != 1 || !msg->has_to || + msg->to.size != 20 || !msg->has_data_initial_chunk || + msg->data_initial_chunk.size != UNISWAP_APPROVE_CALL_SIZE || + memcmp(msg->data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4) != 0 || + msg->value.size > 32 || !tx_value_is_zero(msg) || + !spender_word_is_router(msg)) + return false; + + const TokenType* token = pool_underlying_token(msg); + if (token == NULL) return false; + if (token_out) *token_out = token; + return true; +} + +bool zx_confirmApproveLiquidity(uint32_t data_total, + const EthereumSignTx* msg) { + const TokenType* token = NULL; + if (data_total != UNISWAP_APPROVE_CALL_SIZE || + !approve_shape_is_clear_signable(msg, &token)) return false; + + const uint8_t* allowance = msg->data_initial_chunk.bytes + 4 + 32; + char amount_text[UNISWAP_AMOUNT_TEXT_SIZE]; + if (memcmp(allowance, (const uint8_t*)MAX_ALLOWANCE, 32) == 0) { + strlcpy(amount_text, "full LP balance", sizeof(amount_text)); + } else { + bignum256 amount; + bn_from_bytes(allowance, 32, &amount); + /* No calc_str_line()/BODY_ROWS guard here on purpose: confirm() now runs + * every body through confirm_body_fits()/page_body_confirm(), so an + * over-long amount is paginated behind its own hold rather than silently + * clipped. See the comment at lib/board/confirm_sm.c:313. */ + if (bn_format(&amount, NULL, " LP", 18, 0, false, amount_text, + sizeof(amount_text)) == 0) + return false; } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, - "approve for pool %s %s", tikstr, poolstr)) { + + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Uniswap LP Approval", "%s", amount_text)) return false; + + char pair_text[43] = {'0', 'x', '\0'}; + for (size_t i = 0; i < 20; i++) { + snprintf(pair_text + 2 + i * 2, 3, "%02x", msg->to.bytes[i]); } - return true; + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Uniswap LP Pool", "%s\n%s", token->ticker, pair_text); } -bool zx_isZxApproveLiquid(const EthereumSignTx *msg) { - /* UNISWAP_ROUTER_ADDRESS (as ERC20 approve spender) is an Ethereum-mainnet - * identity. See GH #431. */ - if (!msg->has_chain_id || msg->chain_id != 1) return false; - /* approve(address,uint256) is exactly 68 bytes and has no dynamic argument. - * Check the extent BEFORE reading the spender word at offset 16: the chunk - * buffer keeps bytes from an earlier message past .size, so on a short - * calldata the comparison below would be made against stale data. And a - * longer calldata is hashed in full while only the allowance is drawn, so - * the tail would be signed unseen -- refusing sends it to the raw-calldata - * path instead. */ - if (msg->data_initial_chunk.size != 4 + 2 * 32) return false; - if (memcmp(msg->data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4) == 0) - if (memcmp((uint8_t *)(msg->data_initial_chunk.bytes + 4 + 32 - 20), - UNISWAP_ROUTER_ADDRESS, 20) == 0) - return true; - return false; +bool zx_isZxApproveLiquid(const EthereumSignTx* msg) { + return approve_shape_is_clear_signable(msg, NULL); } diff --git a/lib/firmware/ethereum_contracts/zxliquidtx.c b/lib/firmware/ethereum_contracts/zxliquidtx.c index 67bc6757b..df49c5f1a 100644 --- a/lib/firmware/ethereum_contracts/zxliquidtx.c +++ b/lib/firmware/ethereum_contracts/zxliquidtx.c @@ -20,232 +20,212 @@ #include "keepkey/firmware/ethereum_contracts/zxliquidtx.h" #include "keepkey/board/confirm_sm.h" -#include "keepkey/board/util.h" -#include "keepkey/firmware/app_confirm.h" +#include "keepkey/board/font.h" +#include "keepkey/board/layout.h" #include "keepkey/firmware/ethereum.h" #include "keepkey/firmware/ethereum_tokens.h" +#include "keepkey/firmware/storage.h" +#include "trezor/crypto/address.h" +#include "trezor/crypto/bignum.h" #include "trezor/crypto/bip32.h" +#include "trezor/crypto/curves.h" +#include "trezor/crypto/memzero.h" + +#include +#include + +#define UNISWAP_LIQUIDITY_CALL_SIZE (4 + 6 * 32) +#define UNISWAP_TOKEN_WORD 0 +#define UNISWAP_PRIMARY_AMOUNT_WORD 1 +#define UNISWAP_TOKEN_MIN_WORD 2 +#define UNISWAP_NATIVE_MIN_WORD 3 +#define UNISWAP_RECIPIENT_WORD 4 +#define UNISWAP_DEADLINE_WORD 5 +#define UNISWAP_AMOUNT_TEXT_SIZE 96 + +static const uint8_t* abi_word(const EthereumSignTx* msg, size_t word) { + return msg->data_initial_chunk.bytes + 4 + word * 32; +} -#include +static bool abi_address_is_canonical(const uint8_t* word) { + for (size_t i = 0; i < 12; i++) { + if (word[i] != 0) return false; + } + return true; +} -static bool isAddLiquidityEthCall(const EthereumSignTx* msg) { - if (memcmp(msg->data_initial_chunk.bytes, "\xf3\x05\xd7\x19", 4) == 0) - return true; +static bool uint256_fits_u64(const uint8_t* word) { + for (size_t i = 0; i < 24; i++) { + if (word[i] != 0) return false; + } + return true; +} + +static bool tx_value_is_zero(const EthereumSignTx* msg) { + if (!msg->has_value && msg->value.size != 0) return false; + for (size_t i = 0; i < msg->value.size; i++) { + if (msg->value.bytes[i] != 0) return false; + } + return true; +} - return false; +static bool isAddLiquidityEthCall(const EthereumSignTx* msg) { + return memcmp(msg->data_initial_chunk.bytes, "\xf3\x05\xd7\x19", 4) == 0; } static bool isRemoveLiquidityEthCall(const EthereumSignTx* msg) { - if (memcmp(msg->data_initial_chunk.bytes, "\x02\x75\x1c\xec", 4) == 0) - return true; + return memcmp(msg->data_initial_chunk.bytes, "\x02\x75\x1c\xec", 4) == 0; +} - return false; +static const TokenType* liquidity_token(const EthereumSignTx* msg) { + const uint8_t* token_address = abi_word(msg, UNISWAP_TOKEN_WORD) + 12; + const TokenType* token = tokenByChainAddress(1, token_address); + return token == UnknownToken ? NULL : token; } -static bool confirmFromAccountMatch(const EthereumSignTx* msg, - const char* addremStr, const HDNode* node) { - // Determine withdrawal address - char addressStr[43] = {'0', 'x', '\0'}; - const char* fromSrc; - const uint8_t* fromAddress; - uint8_t addressBytes[20]; +static bool liquidity_shape_is_clear_signable(const EthereumSignTx* msg) { + if (!msg->has_chain_id || msg->chain_id != 1 || !msg->has_to || + msg->to.size != 20 || + memcmp(msg->to.bytes, UNISWAP_ROUTER_ADDRESS, 20) != 0 || + !msg->has_data_initial_chunk || + msg->data_initial_chunk.size != UNISWAP_LIQUIDITY_CALL_SIZE || + msg->value.size > 32 || (!msg->has_value && msg->value.size != 0)) + return false; - if (!node) return false; + if (!isAddLiquidityEthCall(msg) && !isRemoveLiquidityEthCall(msg)) + return false; + if (!abi_address_is_canonical(abi_word(msg, UNISWAP_TOKEN_WORD)) || + !abi_address_is_canonical(abi_word(msg, UNISWAP_RECIPIENT_WORD)) || + !uint256_fits_u64(abi_word(msg, UNISWAP_DEADLINE_WORD))) + return false; + if (liquidity_token(msg) == NULL) return false; + if (isRemoveLiquidityEthCall(msg) && !tx_value_is_zero(msg)) return false; + return true; +} - if (!hdnode_get_ethereum_pubkeyhash(node, addressBytes)) return false; +static bool format_amount(const bignum256* amount, const char* suffix, + unsigned int decimals, char* out, size_t out_len) { + if (bn_format(amount, NULL, suffix, decimals, 0, false, out, out_len) == 0) + return false; + return calc_str_line(get_body_font(), out, BODY_WIDTH) <= BODY_ROWS; +} - fromAddress = - (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 5 * 32 - 20); +bool zx_formatZxLiquidityPrimaryAmount(const EthereumSignTx* msg, char* out, + size_t out_len) { + if (!out || out_len == 0 || !liquidity_shape_is_clear_signable(msg)) + return false; - if (memcmp(fromAddress, addressBytes, 20) == 0) { - fromSrc = "self"; - } else { - fromSrc = "NOT this wallet"; + bignum256 amount; + bn_from_bytes(abi_word(msg, UNISWAP_PRIMARY_AMOUNT_WORD), 32, &amount); + if (isAddLiquidityEthCall(msg)) { + const TokenType* token = liquidity_token(msg); + return format_amount(&amount, token->ticker, token->decimals, out, out_len); } + return format_amount(&amount, " LP", 18, out, out_len); +} - for (uint32_t ctr = 0; ctr < 20; ctr++) { - snprintf(&addressStr[2 + ctr * 2], 3, "%02x", fromAddress[ctr]); - } +static HDNode* zx_getDerivedNode(const char* curve, const uint32_t* address_n, + size_t address_n_count, + uint32_t* fingerprint) { + static HDNode CONFIDENTIAL node; + if (fingerprint) *fingerprint = 0; + if (!get_curve_by_name(curve)) return NULL; + if (!storage_getRootNode(curve, true, &node)) return NULL; + if (!address_n || address_n_count == 0) return &node; + if (hdnode_private_ckd_cached(&node, address_n, address_n_count, + fingerprint) == 0) + return NULL; + return &node; +} + +static bool confirmFromAccountMatch(const EthereumSignTx* msg) { + char address_str[43] = {'0', 'x', '\0'}; + uint8_t address_bytes[20]; - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, addremStr, - "Confirming ETH address is %s: %s", fromSrc, addressStr)) { + HDNode* node = zx_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return false; + if (!hdnode_get_ethereum_pubkeyhash(node, address_bytes)) { + memzero(node, sizeof(*node)); return false; } - return true; -} + memzero(node, sizeof(*node)); -/* Decode the head far enough to name the pool token, or refuse. - * - * Both screens this handler draws state a token amount and a token minimum, - * and both come from ethereumFormatAmount(), which renders the literal - * "Unknown token value" when tokenByChainAddress() misses. An unlisted pool - * token therefore produces a screen that asserts no amount at all while the - * calldata executes: a blind signature wearing a decoder's title. Worse than - * the generic case, because claiming the transaction here is exactly what - * skips the AdvancedMode raw-calldata fallback that would have shown the - * bytes. - * - * Refuse in the PREDICATE, not the confirm: ethereum.c reads a false return - * from ethereum_contractConfirmed() as a user cancel, while a false predicate - * falls through to raw disclosure. Same split as zxswap.c and zxtransERC20.c. - * - * Shared by the predicate and the confirm so the two cannot disagree about - * what is displayable. - */ -static bool zxliquid_resolveToken(const EthereumSignTx* msg, - const TokenType** token) { - /* addLiquidityETH / removeLiquidityETH are both - * (address, uint256, uint256, uint256, address, uint256) - * with no dynamic argument, so the calldata is exactly 4 + 6 * 32 = 196 - * bytes. Everything this handler reads -- the token at offset 16, three - * amounts, the `to` address at 144, and the deadline at 188 -- lies inside - * it. - * - * Exactly 196, in both directions. Short, and the confirm would read bytes - * an earlier message left in the chunk buffer past .size and show them as - * this transaction's amounts and deadline. Long, and the extra words are - * hashed into the signature with no screen showing them. */ - if (msg->data_initial_chunk.size != 4 + 6 * 32) return false; - - /* The deadline is a full uint256, but the screen renders only its low 64 - * bits (see the epoch formatter in zx_confirmZxLiquidTx). Anything set above - * bit 63 is therefore invisible: a deadline of 2^64 + 1 is effectively - * "never expires", and the device would state "Deadline epoch 1" -- a - * long-past time, the opposite of what the router will enforce. - * - * Require the upper 24 bytes to be zero rather than widen the formatter. A - * real Uniswap deadline is a Unix timestamp and fits comfortably; a word that - * does not is not something this screen can describe, so it belongs on the - * raw-calldata path. Checked in the resolver, which the PREDICATE calls, so - * the refusal falls through to disclosure instead of being reported as a - * user cancel. */ - const uint8_t* deadline_word = - (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 5 * 32); - for (size_t i = 0; i < 24; i++) { - if (deadline_word[i] != 0) return false; + const uint8_t* recipient = abi_word(msg, UNISWAP_RECIPIENT_WORD) + 12; + bool is_self = memcmp(recipient, address_bytes, 20) == 0; + for (uint32_t i = 0; i < 20; i++) { + snprintf(&address_str[2 + i * 2], 3, "%02x", recipient[i]); } - const TokenType* t = tokenByChainAddress( - msg->chain_id, - (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 32 - 20)); - if (t == NULL || t == UnknownToken) return false; - - if (token) *token = t; + /* The screen states plainly which of the two cases this is and shows the + * recipient's full address, so a press here is informed consent to exactly + * that recipient. Return whether the USER approved -- not whether the + * recipient happened to be us. + * + * `return is_self` refused the transaction AFTER the user approved it, and + * ethereum.c turns that false into ActionCancelled, so the device reported + * "Signing cancelled by user" for a transaction the user had just confirmed. + * That made every removeLiquidityETH to a third party unsignable, which is + * the normal way to withdraw a pool position to another address. Withholding + * the disclosure is not what makes this safe; showing "NOT this wallet" and + * the address is. */ + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Uniswap Recipient", "%s\n%s", + is_self ? "this wallet" : "NOT this wallet", address_str)) + return false; return true; } bool zx_isZxLiquidTx(const EthereumSignTx* msg) { - /* UNISWAP_ROUTER_ADDRESS is an Ethereum-mainnet identity. The same 20 bytes - * on another EVM chain are an unrelated contract; clear-sign only on mainnet. - * See GH #431. */ - if (!msg->has_chain_id || msg->chain_id != 1) return false; - if (memcmp(msg->to.bytes, UNISWAP_ROUTER_ADDRESS, 20) != - 0) // correct contract address? - return false; + return liquidity_shape_is_clear_signable(msg); +} - if (!isAddLiquidityEthCall(msg) && !isRemoveLiquidityEthCall(msg)) +bool zx_confirmZxLiquidTx(uint32_t data_total, const EthereumSignTx* msg) { + if (data_total != UNISWAP_LIQUIDITY_CALL_SIZE || + !liquidity_shape_is_clear_signable(msg)) return false; - /* Claim the transaction only if the screen can name the pool token. */ - return zxliquid_resolveToken(msg, NULL); -} + const TokenType* token = liquidity_token(msg); + bignum256 amount; + char amount_text[UNISWAP_AMOUNT_TEXT_SIZE]; -bool zx_confirmZxLiquidTx(uint32_t data_total, const EthereumSignTx* msg, - const HDNode* node) { - (void)data_total; - const TokenType* token; - char constr1[40], constr2[40], constr3[40], tokbuf[32]; - const char* arStr = ""; - const uint8_t* deadlineBytes; - bignum256 Amount; - uint64_t deadline; - - if (isAddLiquidityEthCall(msg)) { - arStr = "uniswap add liquidity"; - } else if (isRemoveLiquidityEthCall(msg)) { - arStr = "uniswap remove liquidity"; - } else { + if (!zx_formatZxLiquidityPrimaryAmount(msg, amount_text, + sizeof(amount_text)) || + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + isAddLiquidityEthCall(msg) ? "Uniswap Token" : "Uniswap LP Burn", + "%s", amount_text)) return false; - } - /* Re-resolve rather than trust the predicate's verdict from a distance: the - length bound and the token lookup are the preconditions for every read - below, so they belong on the same code path that performs them. */ - if (!zxliquid_resolveToken(msg, &token)) return false; - deadlineBytes = - (const uint8_t*)(msg->data_initial_chunk.bytes + 4 + 6 * 32 - 8); - deadline = ((uint64_t)deadlineBytes[0] << 8 * 7) | - ((uint64_t)deadlineBytes[1] << 8 * 6) | - ((uint64_t)deadlineBytes[2] << 8 * 5) | - ((uint64_t)deadlineBytes[3] << 8 * 4) | - ((uint64_t)deadlineBytes[4] << 8 * 3) | - ((uint64_t)deadlineBytes[5] << 8 * 2) | - ((uint64_t)deadlineBytes[6] << 8 * 1) | - ((uint64_t)deadlineBytes[7]); - - bn_from_bytes(msg->data_initial_chunk.bytes + 4 + 32, 32, - &Amount); // token amount - if (!ethereumFormatAmount(&Amount, token, msg->chain_id, tokbuf, - sizeof(tokbuf))) - return false; - snprintf(constr1, 32, "%s", tokbuf); - bn_from_bytes(msg->data_initial_chunk.bytes + 4 + 2 * 32, 32, - &Amount); // token min amount - if (!ethereumFormatAmount(&Amount, token, msg->chain_id, tokbuf, - sizeof(tokbuf))) + bn_from_bytes(abi_word(msg, UNISWAP_TOKEN_MIN_WORD), 32, &amount); + if (!format_amount(&amount, token->ticker, token->decimals, amount_text, + sizeof(amount_text)) || + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Uniswap Token Min", "%s", amount_text)) return false; - snprintf(constr2, 32, "%s", tokbuf); - - /* Validate every amount before drawing the first approval screen. A later - * formatting failure must not leave the user with a partial review flow. */ - bn_from_bytes(msg->data_initial_chunk.bytes + 4 + 3 * 32, 32, - &Amount); // eth min amount - if (!ethereumFormatAmount(&Amount, NULL, msg->chain_id, tokbuf, - sizeof(tokbuf))) - return false; - snprintf(constr3, 32, "%s", tokbuf); - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, - "%s\nMinimum %s", constr1, constr2)) { - return false; - } - if (!confirmFromAccountMatch(msg, arStr, node)) { - return false; + if (!confirmFromAccountMatch(msg)) return false; + + if (isAddLiquidityEthCall(msg)) { + bn_from_bytes(msg->value.bytes, msg->value.size, &amount); + if (!format_amount(&amount, " ETH", 18, amount_text, sizeof(amount_text)) || + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Uniswap ETH", + "%s", amount_text)) + return false; } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, - "Minimum %s", constr3)) { + bn_from_bytes(abi_word(msg, UNISWAP_NATIVE_MIN_WORD), 32, &amount); + if (!format_amount(&amount, " ETH", 18, amount_text, sizeof(amount_text)) || + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Uniswap ETH Min", + "%s", amount_text)) return false; - } - /* Render the 64-bit deadline as a decimal epoch string. The prior code - * used ctime((const time_t*)&deadline), which on the STM32F2 target reads - * only the low 4 bytes of the 64-bit deadline (time_t is 32-bit long), so - * post-2038 deadlines render as the wrong date. The decimal epoch is - * unambiguous and avoids the 32-bit time_t truncation and ctime()'s stray - * newline. See GH #435. */ - { - char deadline_str[21] = {0}; - /* uint64 -> decimal string (manual, no printf %llu portability concerns) */ - uint64_t d = deadline; - char tmp[21]; - int len = 0; - if (d == 0) { - tmp[len++] = '0'; - } else { - while (d > 0 && len < (int)sizeof(tmp)) { - tmp[len++] = '0' + (int)(d % 10); - d /= 10; - } - } - for (int i = 0; i < len; i++) { - deadline_str[i] = tmp[len - 1 - i]; - } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, arStr, - "Deadline epoch %s", deadline_str)) { - return false; - } + const uint8_t* deadline_word = abi_word(msg, UNISWAP_DEADLINE_WORD); + uint64_t deadline = 0; + for (size_t i = 24; i < 32; i++) { + deadline = (deadline << 8) | deadline_word[i]; } - - return true; + char deadline_text[21]; + snprintf(deadline_text, sizeof(deadline_text), "%" PRIu64, deadline); + return confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Uniswap Deadline", "%s", deadline_text); } diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index e2ad77edc..65000bcd2 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -35,6 +35,8 @@ #include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/app_layout.h" #include "keepkey/firmware/authenticator.h" +#include "keepkey/firmware/bip85.h" +#include "keepkey/rand/rng_health.h" #include "keepkey/firmware/coins.h" #include "keepkey/firmware/cosmos.h" #include "keepkey/firmware/binance.h" @@ -44,6 +46,7 @@ #include "keepkey/firmware/ethereum.h" #include "keepkey/firmware/ethereum_tokens.h" #include "keepkey/firmware/fsm.h" +#include "keepkey/firmware/hive.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/mayachain.h" #include "keepkey/firmware/nano.h" @@ -54,6 +57,7 @@ #include "keepkey/firmware/recovery_cipher.h" #include "keepkey/firmware/reset.h" #include "keepkey/firmware/ripple.h" +#include "keepkey/firmware/signed_metadata.h" #include "keepkey/firmware/signing.h" #include "keepkey/firmware/signtx_tendermint.h" #include "keepkey/firmware/solana.h" @@ -63,6 +67,7 @@ #include "keepkey/firmware/tron.h" #include "keepkey/firmware/ton.h" #include "keepkey/firmware/transaction.h" +#include "keepkey/firmware/zcash.h" #include "keepkey/firmware/txin_check.h" #include "keepkey/firmware/u2f.h" #include "keepkey/rand/rng.h" @@ -80,6 +85,8 @@ #include "messages.pb.h" #include "messages-ethereum.pb.h" +#include "messages-hive.pb.h" +#include "messages-zcash.pb.h" #include "messages-binance.pb.h" #include "messages-cosmos.pb.h" #include "messages-osmosis.pb.h" @@ -130,6 +137,25 @@ bool fsm_test_derivedNodeIsZero(void) { return; \ } +/* A locked bitcoin-only wallet leaves the RAM shadow reset, so handlers that + * merely PERSIST settings look perfectly ordinary: storage_setPin(), + * storage_setLabel() and friends update the shadow, storage_commit() then + * returns without writing (the btc_only_locked backstop in storage.c), and the + * handler answers Success. The change appears to take effect for the rest of + * the session and is gone at the next boot. + * + * CHECK_NOT_INITIALIZED already refuses this for the ceremonies that CREATE a + * seed. The same reasoning applies to every handler that expects its write to + * survive a reboot, and those were missed. Refuse before doing the work rather + * than reporting a success that did not happen. */ +#define CHECK_NOT_BITCOIN_ONLY_LOCKED \ + if (storage_isBitcoinOnlyLocked()) { \ + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, \ + "Bitcoin-only wallet present. Use Wipe first."); \ + layoutHome(); \ + return; \ + } + #define CHECK_NOT_INITIALIZED \ if (storage_isInitialized()) { \ fsm_sendFailure(FailureType_Failure_UnexpectedMessage, \ @@ -382,6 +408,11 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_crypto.h" #include "fsm_msg_debug.h" #if !BITCOIN_ONLY +// BIP-85 derives child mnemonics for OTHER wallets -- a multi-chain feature. +// It must be gated in step with messagemap.def: a handler compiled with no +// entry referencing it is an unused function, which -Werror turns into a +// build failure. +#include "fsm_msg_bip85.h" #include "fsm_msg_ethereum.h" #include "fsm_msg_nano.h" #include "fsm_msg_eos.h" @@ -395,12 +426,26 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_tron.h" #include "fsm_msg_ton.h" #include "fsm_msg_solana.h" +#include "fsm_msg_hive.h" +/* After fsm_msg_solana.h: reuses its base58 helper and the KKSOLSC1 parser. */ +#include "fsm_msg_clearsign_attestor.h" #else -// The coin engines above are compiled out, but the always-on -// Initialize/Cancel handlers still call each engine's abort hook. With no -// engine state to roll back, no-ops are the correct definitions -- and -// defining them here keeps those handlers free of build-variant branches. +// 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 the correct definitions -- and defining +// them here keeps those handlers free of build-variant branches. void ethereum_signing_abort(void) {} void tendermint_signAbort(void) {} void eos_signingAbort(void) {} +void signed_metadata_clear_signers(void) {} #endif // !BITCOIN_ONLY +#if ZCASH_PRIVACY +#include "fsm_msg_zcash.h" +#else +// Zcash shielded/Orchard engine compiled out. The always-on +// Initialize/ClearSession/Cancel handlers still call zcash_signing_abort(); +// with no privacy state to reset, a no-op is correct. (Bitcoin-only forces +// privacy off, so this stub also covers the bitcoin-only image.) +void zcash_signing_abort(void) {} +#endif diff --git a/lib/firmware/fsm_msg_bip85.h b/lib/firmware/fsm_msg_bip85.h new file mode 100644 index 000000000..48b343303 --- /dev/null +++ b/lib/firmware/fsm_msg_bip85.h @@ -0,0 +1,142 @@ +void fsm_msgGetBip85Mnemonic(const GetBip85Mnemonic *msg) { + CHECK_INITIALIZED + + /* Validate word count (required field, always present in nanopb) */ + if (msg->word_count != 12 && msg->word_count != 18 && msg->word_count != 24) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + "word_count must be 12, 18, or 24"); + layoutHome(); + return; + } + + /* Reject index >= 0x80000000 (hardened-bit collision) */ + if (msg->index & 0x80000000) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + "index must be less than 2147483648"); + layoutHome(); + return; + } + + CHECK_PIN + + /* User confirmation */ + char desc[80]; + snprintf(desc, sizeof(desc), "Derive %lu-word child seed at index %lu?", + (unsigned long)msg->word_count, (unsigned long)msg->index); + + if (!confirm(ButtonRequestType_ButtonRequest_Other, "BIP-85 Derive Seed", + "%s", desc)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "BIP-85 derivation cancelled"); + layoutHome(); + return; + } + + layout_simple_message("Deriving child seed..."); + + /* Derive the mnemonic */ + static CONFIDENTIAL char mnemonic_buf[241]; + if (!bip85_derive_mnemonic(msg->word_count, msg->index, mnemonic_buf, + sizeof(mnemonic_buf))) { + memzero(mnemonic_buf, sizeof(mnemonic_buf)); + fsm_sendFailure(FailureType_Failure_Other, "BIP-85 derivation failed"); + layoutHome(); + return; + } + + /* + * Display mnemonic on device screen only — never send over USB. + * Uses the same paginated display as the backup flow in reset.c. + */ + uint32_t word_count = 0, page_count = 0; + + /* Display scratch shared with the backup flow — see reset.h. Zero the whole + * set at entry per the sharing contract (a prior user may have aborted). */ + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + + strlcpy(mnemonic_scratch_tokened, mnemonic_buf, TOKENED_MNEMONIC_BUF); + memzero(mnemonic_buf, sizeof(mnemonic_buf)); + + const char *tok = strtok(mnemonic_scratch_tokened, " "); + + while (tok) { + snprintf(mnemonic_scratch_word, MAX_WORD_LEN + ADDITIONAL_WORD_PAD, + (word_count & 1) ? "%lu.%s\n" : "%lu.%s", + (unsigned long)(word_count + 1), tok); + + /* Check that we have enough room on display to show word */ + snprintf(mnemonic_scratch_display, FORMATTED_MNEMONIC_BUF, "%s %s", + mnemonic_scratch_formatted[page_count], mnemonic_scratch_word); + + if (calc_str_line(get_body_font(), mnemonic_scratch_display, BODY_WIDTH) > + 3) { + page_count++; + + if (MAX_PAGES <= page_count) { + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + fsm_sendFailure(FailureType_Failure_Other, + "Too many pages of mnemonic words"); + layoutHome(); + return; + } + + snprintf(mnemonic_scratch_display, FORMATTED_MNEMONIC_BUF, "%s %s", + mnemonic_scratch_formatted[page_count], mnemonic_scratch_word); + } + + strlcpy(mnemonic_scratch_formatted[page_count], mnemonic_scratch_display, + FORMATTED_MNEMONIC_BUF); + + tok = strtok(NULL, " "); + word_count++; + } + + /* Switch from 0-indexing to 1-indexing */ + page_count++; + + display_constant_power(true); + + /* Show each page of the mnemonic on screen */ + for (uint32_t current_page = 0; current_page < page_count; current_page++) { + char title[MEDIUM_STR_BUF]; + + if (page_count > 1) { + snprintf(title, MEDIUM_STR_BUF, "BIP-85 Seed %" PRIu32 "/%" PRIu32, + current_page + 1, page_count); + } else { + snprintf(title, MEDIUM_STR_BUF, "BIP-85 Seed"); + } + + if (!confirm_constant_power(ButtonRequestType_ButtonRequest_ConfirmWord, + title, "%s", + mnemonic_scratch_formatted[current_page])) { + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + display_constant_power(false); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "BIP-85 display cancelled"); + layoutHome(); + return; + } + } + + display_constant_power(false); + + /* Wipe all sensitive buffers */ + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + + /* Send success — mnemonic is NOT sent over the wire */ + fsm_sendSuccess("BIP-85 seed displayed on device"); + layoutHome(); +} diff --git a/lib/firmware/fsm_msg_clearsign_attestor.h b/lib/firmware/fsm_msg_clearsign_attestor.h new file mode 100644 index 000000000..806d8da51 --- /dev/null +++ b/lib/firmware/fsm_msg_clearsign_attestor.h @@ -0,0 +1,206 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library. If not, see . + */ + +/* Clearsign attestor: let a KeepKey issue clear-sign schema attestations from + * its seed. It ships in the regular firmware, but every operation is gated by + * AdvancedMode. This lets builders prove the self-service workflow before a + * future release pins a KeepKey production identity. + * + * The attestor NEVER signs arbitrary bytes. It parses the submitted payload + * with the same validator verifying devices run (solana_parseInstrSchema for + * KKSOLSC1) and refuses anything malformed. A fully compromised host can + * therefore only obtain attestations over well-formed, user-confirmed + * descriptors — never a general secp256k1 signing oracle. That is the single + * most important property of this design; do not add a "raw" mode. + * + * Key custody: the attestation key is derived from the device seed at + * ATTESTOR_PATH (a dedicated hardened path outside every coin space), so PIN + * unlock gates its availability, seed backup is key backup, and wipe destroys + * it. + * + * ponytail: KKSOLSC1 only. EVM v2 metadata blobs are attestable in principle + * but sign a different range (payload minus the 65-byte signature trailer, see + * signed_metadata_process) and their parser is static in signed_metadata.c; + * add a second branch here plus an exported pure parser when EVM schemas need + * device-issued signatures. + */ + +/* The attestation key path: purpose 0x4B4B ("KK"), then 0x4353 ("CS") for + * clearsign, then account 0. All hardened, and far outside any SLIP-44 coin + * range, so an attestation key can never collide with a funds key. */ +#define ATTESTOR_PATH_LEN 3 +static const uint32_t ATTESTOR_PATH[ATTESTOR_PATH_LEN] = { + 0x80000000 | 0x4B4B, + 0x80000000 | 0x4353, + 0x80000000u, +}; + +/* Derive the attestation node. Returns NULL and sends the failure itself. */ +static HDNode* attestor_getNode(void) { + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, ATTESTOR_PATH, + ATTESTOR_PATH_LEN, NULL); + if (!node) return NULL; + hdnode_fill_public_key(node); + return node; +} + +/* Human-readable ABI type names for the attestation review. The type is part + * of the security boundary, not decoration: U64+PUBKEY and OPAQUE32+U64 have + * the same total width but assign labels to different byte offsets. Never ask + * an operator to attest an argument label without also showing its type. */ +static const char* attestor_schemaArgTypeName(SolanaSchemaArgType type) { + switch (type) { + case SOL_SCHEMA_ARG_U64: + return "u64 LE"; + case SOL_SCHEMA_ARG_U8: + return "u8"; + case SOL_SCHEMA_ARG_PUBKEY: + return "public key"; + case SOL_SCHEMA_ARG_OPAQUE32: + return "bytes32 hex"; + } + return "invalid"; /* Parser rejects unknown values; defense in depth. */ +} + +void fsm_msgClearsignAttestorGetPublicKey( + const ClearsignAttestorGetPublicKey* msg) { + (void)msg; + RESP_INIT(ClearsignAttestorPublicKey); + + CHECK_INITIALIZED + CHECK_PIN + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), + _("AdvancedMode required for clearsign attestation")); + + HDNode* node = attestor_getNode(); + if (!node) return; + + resp->has_public_key = true; + resp->public_key.size = 33; + memcpy(resp->public_key.bytes, node->public_key, 33); + memzero(node, sizeof(*node)); + + msg_write(MessageType_MessageType_ClearsignAttestorPublicKey, resp); + layoutHome(); +} + +void fsm_msgClearsignAttestorSign(const ClearsignAttestorSign* msg) { + RESP_INIT(ClearsignAttestorSignature); + + CHECK_INITIALIZED + CHECK_PIN + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), + _("AdvancedMode required for clearsign attestation")); + + CHECK_PARAM(msg->has_payload && msg->payload.size > 0, "Missing payload"); + + /* Validate before attesting. The payload must be a descriptor this firmware + * can itself parse — the same code path fsm_msgSolanaSignTx runs — so a + * compromised host cannot use the attestor as a raw signing oracle. */ + SolanaInstrSchema schema; + if (msg->payload.size < 8 || memcmp(msg->payload.bytes, "KKSOLSC1", 8) != 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, "Unsupported descriptor"); + layoutHome(); + return; + } + if (!solana_parseInstrSchema(msg->payload.bytes, msg->payload.size, + &schema)) { + memzero(&schema, sizeof(schema)); + fsm_sendFailure(FailureType_Failure_SyntaxError, "Invalid schema"); + layoutHome(); + return; + } + + char program_id[45]; + char disc_hex[2 * SOL_SCHEMA_DISC_MAX + 1]; + solana_pubkeyToStr(schema.program_id, program_id, sizeof(program_id)); + for (uint8_t i = 0; i < schema.disc_len; i++) { + snprintf(disc_hex + 2 * i, sizeof(disc_hex) - 2 * i, "%02x", + schema.disc[i]); + } + + /* Program IDs may consume two body rows, while an 8-byte discriminator plus + * its label consumes another two. They therefore get separate confirmations: + * combining them can silently clip the discriminator, which is precisely the + * field the operator must compare against the contract ABI. */ + bool confirmed = + confirm(ButtonRequestType_ButtonRequest_SignTx, "Attest Schema", "%s\n%s", + schema.program_name, schema.instruction_name) && + confirm(ButtonRequestType_ButtonRequest_SignTx, "Program ID", "%s", + program_id) && + confirm(ButtonRequestType_ButtonRequest_SignTx, "Discriminator", "%s", + disc_hex); + + /* One label per screen. A structurally valid schema can still lie by + * labelling the wrong offset ("Amount" over the order id), so the operator + * has to read every label — and confirm()'s body is three rendered rows with + * no pagination, so a batched list of max-length labels scrolls off. A label + * nobody saw is a label nobody checked. */ + for (uint8_t i = 0; confirmed && i < schema.num_args; i++) { + confirmed = confirm(ButtonRequestType_ButtonRequest_SignTx, "Attest Schema", + "Arg %u: %s\n%s", (unsigned)(i + 1), + attestor_schemaArgTypeName(schema.args[i].type), + schema.args[i].label); + } + for (uint8_t i = 0; confirmed && i < schema.num_accounts; i++) { + confirmed = + confirm(ButtonRequestType_ButtonRequest_SignTx, "Attest Schema", + "Account #%u shows\n%s", (unsigned)schema.accounts[i].index, + schema.accounts[i].label); + } + memzero(&schema, sizeof(schema)); + if (!confirmed) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + HDNode* node = attestor_getNode(); + if (!node) return; + + /* Plain ECDSA over SHA256(payload): exactly what + * signed_metadata_verify_attestation() checks on the verifying device. */ + uint8_t digest[32]; + sha256_Raw(msg->payload.bytes, msg->payload.size, digest); + + uint8_t sig[64]; + int ret = + ecdsa_sign_digest(&secp256k1, node->private_key, digest, sig, NULL, NULL); + memzero(digest, sizeof(digest)); + if (ret != 0) { + memzero(node, sizeof(*node)); + memzero(sig, sizeof(sig)); + fsm_sendFailure(FailureType_Failure_Other, "Attestation failed"); + layoutHome(); + return; + } + + resp->has_signature = true; + resp->signature.size = sizeof(sig); + memcpy(resp->signature.bytes, sig, sizeof(sig)); + resp->has_public_key = true; + resp->public_key.size = 33; + memcpy(resp->public_key.bytes, node->public_key, 33); + + memzero(sig, sizeof(sig)); + memzero(node, sizeof(*node)); + + msg_write(MessageType_MessageType_ClearsignAttestorSignature, resp); + layoutHome(); +} diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 9644f7459..f27c6891e 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -1,7 +1,12 @@ void fsm_msgInitialize(Initialize* msg) { (void)msg; - fsm_abort_workflows(); - session_clear(false); // do not clear PIN + /* Ends a setup ceremony of either kind, staged settings and all. */ + setup_abort(); + signing_abort(); + ethereum_signing_abort(); + tendermint_signAbort(); + eos_signingAbort(); + session_clear(false); // do not clear PIN, and clears the Zcash session layoutHome(); fsm_msgGetFeatures(0); } @@ -36,9 +41,14 @@ void fsm_msgGetFeatures(GetFeatures* msg) { resp->has_model = true; strlcpy(resp->model, model(), sizeof(resp->model)); - /* Taproot capability. Reported directly so a host does not have to infer - P2TR support from a firmware version -- that inference breaks whenever the - feature is retargeted to a different release. */ + /* Taproot capability. signing.c handles SPENDTAPROOT inputs and PAYTOTAPROOT + outputs, and coins.def carries the BIP-86 entries, but the bit that tells a + host so was never set -- so hosts could not detect support and six + catalogued Bitcoin tests skipped with "Firmware does not report + supports_taproot", making a shipped feature invisible in the report. + Reported directly so a host does not have to infer P2TR support from a + firmware version -- that inference breaks whenever the feature is + retargeted to a different release. */ resp->has_supports_taproot = true; resp->supports_taproot = true; @@ -170,11 +180,16 @@ 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) { + /* Guarded, not just skipped at runtime: the bitcoin-only image defines + TOKENS_COUNT as 0 and links neither `tokens` nor coinFromToken(), so + this branch is both an unsigned `< 0` comparison that + -Werror=type-limits rejects and an undefined reference at link. */ + else if (msg->start + i - COINS_COUNT < TOKENS_COUNT) { coinFromToken(&resp->table[i], &tokens[msg->start + i - COINS_COUNT]); -#endif } +#endif } } @@ -349,6 +364,8 @@ void fsm_msgPing(Ping* msg) { } void fsm_msgChangePin(ChangePin* msg) { + CHECK_NOT_BITCOIN_ONLY_LOCKED + bool removal = msg->has_remove && msg->remove; bool confirmed = false; @@ -399,6 +416,8 @@ void fsm_msgChangePin(ChangePin* msg) { } void fsm_msgChangeWipeCode(ChangeWipeCode* msg) { + CHECK_NOT_BITCOIN_ONLY_LOCKED + bool removal = msg->has_remove && msg->remove; bool confirmed = false; @@ -469,6 +488,28 @@ void fsm_msgChangeWipeCode(ChangeWipeCode* msg) { #endif } +/* The RNG audit budget. + * + * Telling a working hardware RNG from a stuck or grossly biased one needs a + * bulk sample, and a button press per 8 KiB turns the pre-PIN health check into + * an eight-press ceremony that users will click through without reading. So an + * UNINITIALIZED device serves this many bytes press-free, and then stops. + * + * The budget is denominated in BYTES, not requests, so asking for a larger + * chunk cannot buy more of it. + * + * It is safe only because of what an uninitialized device is: it holds no seed + * and no secret, so raw RNG output discloses nothing. The moment it holds one + * -- storage_isInitialized() -- every request confirms again, and so does every + * request after the budget is spent. Both halves are asserted by atlas C27. + */ +#define ENTROPY_AUDIT_BUDGET (64 * 1024) +static uint32_t entropy_audit_remaining = ENTROPY_AUDIT_BUDGET; + +static void fsm_entropyAuditBudgetReset(void) { + entropy_audit_remaining = ENTROPY_AUDIT_BUDGET; +} + void fsm_msgWipeDevice(WipeDevice* msg) { (void)msg; @@ -492,13 +533,17 @@ void fsm_msgWipeDevice(WipeDevice* msg) { } /* Wipe device */ - fsm_abort_workflows(); session_clear(/*clear_pin=*/true); storage_wipe(); storage_reset(); storage_resetUuid(); storage_commit(); + /* A wipe returns the device to the state the audit budget exists for, so it + * returns the budget. Without this a device that had been initialized once + * could never be RNG-audited again without a press per chunk. */ + fsm_entropyAuditBudgetReset(); + fsm_sendSuccess("Device wiped"); layoutHome(); } @@ -516,20 +561,28 @@ void fsm_msgFirmwareUpload(FirmwareUpload* msg) { } void fsm_msgGetEntropy(GetEntropy* msg) { - if (!confirm(ButtonRequestType_ButtonRequest_GetEntropy, "Generate Entropy", - "Do you want to generate and return entropy using the hardware " - "RNG?")) { + uint32_t len = msg->size; + + if (len > ENTROPY_BUF) { + len = ENTROPY_BUF; + } + + /* Spend the budget only on a device with nothing to disclose, and only for + * what this request actually returns. */ + bool press_free = !storage_isInitialized() && len <= entropy_audit_remaining; + + if (press_free) { + entropy_audit_remaining -= len; + } else if (!confirm(ButtonRequestType_ButtonRequest_GetEntropy, + "Generate Entropy", + "Do you want to generate and return entropy using the " + "hardware RNG?")) { fsm_sendFailure(FailureType_Failure_ActionCancelled, "Entropy cancelled"); layoutHome(); return; } RESP_INIT(Entropy); - uint32_t len = msg->size; - - if (len > ENTROPY_BUF) { - len = ENTROPY_BUF; - } resp->entropy.size = len; random_buffer(resp->entropy.bytes, len); @@ -569,8 +622,10 @@ void fsm_msgResetDevice(ResetDevice* msg) { CHECK_NOT_INITIALIZED CHECK_NO_CEREMONY - reset_init(msg->has_display_random && msg->display_random, - msg->has_strength ? msg->strength : 128, + // display_random remains in the wire schema for host compatibility, but is + // intentionally ignored: internal entropy is seed pre-image material and + // must never be rendered or returned by production firmware. + reset_init(msg->has_strength ? msg->strength : 128, msg->has_passphrase_protection && msg->passphrase_protection, msg->has_pin_protection && msg->pin_protection, msg->has_language ? msg->language : 0, @@ -601,6 +656,8 @@ void fsm_msgCancel(Cancel* msg) { } void fsm_msgApplySettings(ApplySettings* msg) { + CHECK_NOT_BITCOIN_ONLY_LOCKED + if (msg->has_label) { if (!confirm(ButtonRequestType_ButtonRequest_ChangeLabel, "Change Label", "Do you want to change the label to \"%s\"?", msg->label)) { @@ -733,6 +790,8 @@ void fsm_msgCharacterAck(CharacterAck* msg) { } void fsm_msgApplyPolicies(ApplyPolicies* msg) { + CHECK_NOT_BITCOIN_ONLY_LOCKED + CHECK_PARAM(msg->policy_count > 0, "No policies provided"); for (size_t i = 0; i < msg->policy_count; ++i) { @@ -780,6 +839,24 @@ void fsm_msgApplyPolicies(ApplyPolicies* msg) { layoutHome(); return; } + + /* Disabling AdvancedMode REVOKES the runtime clear-sign signers it + * authorized, rather than suspending them. + * + * Every consumer in signed_metadata.c already refuses a runtime slot while + * the policy is off, so the difference is only visible on the way back: + * without this, re-enabling AdvancedMode silently re-arms a provider the + * user never re-loaded, on a confirmation screen that names the policy and + * not the signer. A user who turned the policy off to drop a provider had + * not dropped it. + * + * Re-loading costs one LoadClearsignSigner consent screen, which names the + * alias and fingerprint -- the screen that should be shown whenever trust + * begins. */ + if (!msg->policy[i].enabled && + strcmp(msg->policy[i].policy_name, "AdvancedMode") == 0) { + signed_metadata_clear_signers(); + } } storage_commit(); diff --git a/lib/firmware/fsm_msg_crypto.h b/lib/firmware/fsm_msg_crypto.h index 2f74f59fa..de6ccea03 100644 --- a/lib/firmware/fsm_msg_crypto.h +++ b/lib/firmware/fsm_msg_crypto.h @@ -63,9 +63,14 @@ void fsm_msgSignIdentity(SignIdentity* msg) { CHECK_INITIALIZED + CHECK_PARAM(msg->has_identity, "Invalid identity"); + + const bool sign_ssh = + msg->identity.has_proto && strcmp(msg->identity.proto, "ssh") == 0; + const bool sign_gpg = + msg->identity.has_proto && strcmp(msg->identity.proto, "gpg") == 0; const char* curve = msg->has_ecdsa_curve_name ? msg->ecdsa_curve_name : SECP256K1_NAME; - /* Establish that there is something signable BEFORE asking anyone to approve it. The identity check used to sit after the confirmation and the curve was not checked until fsm_getDerivedNode() below, so a request with no identity @@ -73,8 +78,7 @@ void fsm_msgSignIdentity(SignIdentity* msg) { PIN entry -- before failing. The curve also selects the key, so it belongs on the screen's side of the line, not after it. */ uint8_t hash[32]; - if (!msg->has_identity || - cryptoIdentityFingerprint(&(msg->identity), hash) == 0) { + if (cryptoIdentityFingerprint(&(msg->identity), hash) == 0) { fsm_sendFailure(FailureType_Failure_Other, "Invalid identity"); layoutHome(); return; @@ -87,9 +91,22 @@ void fsm_msgSignIdentity(SignIdentity* msg) { return; } - if (!confirm_sign_identity( - &(msg->identity), - msg->has_challenge_visual ? msg->challenge_visual : 0, curve)) { + /* SSH/GPG sign only challenge_hidden. Generic identity signatures bind both + * challenges, so review both there; SSH/GPG review only the actual signed + * payload and never present the unsigned visual field as authoritative. */ + if (!confirm_sign_identity(&msg->identity, NULL, curve) || + ((!sign_ssh && !sign_gpg) && + !confirm_bytes( + ButtonRequestType_ButtonRequest_SignIdentity, "Visual Challenge", + (const uint8_t*)msg->challenge_visual, + msg->has_challenge_visual ? strlen(msg->challenge_visual) : 0)) || + !confirm_bytes( + ButtonRequestType_ButtonRequest_SignIdentity, + sign_ssh ? "Signed SSH Challenge" + : sign_gpg ? "Signed GPG Digest" + : "Hidden Challenge", + msg->challenge_hidden.bytes, + msg->has_challenge_hidden ? msg->challenge_hidden.size : 0)) { memzero(hash, sizeof(hash)); fsm_sendFailure(FailureType_Failure_ActionCancelled, "Sign identity cancelled"); @@ -115,11 +132,6 @@ void fsm_msgSignIdentity(SignIdentity* msg) { return; } - bool sign_ssh = - msg->identity.has_proto && (strcmp(msg->identity.proto, "ssh") == 0); - bool sign_gpg = - msg->identity.has_proto && (strcmp(msg->identity.proto, "gpg") == 0); - int result = 0; layout_simple_message("Signing Identity..."); diff --git a/lib/firmware/fsm_msg_ethereum.h b/lib/firmware/fsm_msg_ethereum.h index 5b9ad01e1..65cade1ec 100644 --- a/lib/firmware/fsm_msg_ethereum.h +++ b/lib/firmware/fsm_msg_ethereum.h @@ -114,6 +114,150 @@ void fsm_msgEthereumSignTx(EthereumSignTx* msg) { void fsm_msgEthereumTxAck(EthereumTxAck* msg) { ethereum_signing_txack(msg); } +void fsm_msgEthereumTxMetadata(const EthereumTxMetadata* msg) { + CHECK_INITIALIZED + CHECK_PIN + + /* Metadata must arrive before signing starts. signed_metadata_process() + * clears the binding on entry, so accepting metadata mid-signing would + * drop the tx<->metadata binding without aborting: a host could approve a + * benign decode (suppressing the blind-sign gate), then inject metadata to + * clear the binding and stream attacker-chosen calldata for the rest. + * Refuse and abort any in-progress signing session. */ + if (ethereum_signing_isInProgress()) { + ethereum_signing_abort(); + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Metadata not allowed during signing")); + layoutHome(); + return; + } + + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), + _("AdvancedMode required for clearsign metadata")); + + RESP_INIT(EthereumMetadataAck); + + MetadataClassification result = signed_metadata_process( + msg->signed_payload.bytes, msg->signed_payload.size, + msg->has_key_id ? msg->key_id : 0); + + resp->classification = (uint32_t)result; + resp->has_display_summary = true; + + switch (result) { + case METADATA_VERIFIED: + strlcpy(resp->display_summary, "Verified", sizeof(resp->display_summary)); + break; + case METADATA_OPAQUE: + strlcpy(resp->display_summary, "Unverified", + sizeof(resp->display_summary)); + break; + case METADATA_MALFORMED: + default: + strlcpy(resp->display_summary, "Invalid", sizeof(resp->display_summary)); + break; + } + + msg_write(MessageType_MessageType_EthereumMetadataAck, resp); +} + +void fsm_msgLoadClearsignSigner(const LoadClearsignSigner* msg) { + CHECK_INITIALIZED + CHECK_PIN + + /* Same reasoning as fsm_msgEthereumTxMetadata above, and the same fix. + * Storing a signer ends in signed_metadata_clear(), which drops the + * tx<->metadata binding along with relied_on_metadata -- so loading a + * signer mid-signing let a host approve a benign decode and then stream + * different calldata, with signed_metadata_enforce() seeing relied=false + * and passing. The guard was on the metadata message but not on its + * sibling. */ + if (ethereum_signing_isInProgress()) { + ethereum_signing_abort(); + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Signer load not allowed during signing")); + layoutHome(); + return; + } + + CHECK_PARAM(storage_isPolicyEnabled("AdvancedMode"), + _("AdvancedMode required for clearsign signers")); + + CHECK_PARAM(msg->has_key_id && msg->has_pubkey && msg->has_alias, + _("key_id, pubkey and alias required")); + /* Range-check as uint32 BEFORE narrowing: (uint8_t)256 would alias slot 0 */ + CHECK_PARAM(msg->key_id < METADATA_MAX_KEYS, _("key_id out of range")); + CHECK_PARAM( + signed_metadata_signer_valid((uint8_t)msg->key_id, msg->pubkey.bytes, + msg->pubkey.size, msg->alias), + _("Invalid clearsign signer")); + + /* Optional identity icon (1bpp mono RLE). The proto caps icon at 384 bytes; + * bound the dims too so the render path never scans a bogus geometry. An icon + * with zero/oversized dims is rejected rather than silently dropped so a + * malformed upload is visible, not a mystery text-only identity. */ + const uint8_t* icon = NULL; + uint16_t icon_len = 0; + uint8_t icon_w = 0, icon_h = 0; + if (msg->has_icon && msg->icon.size > 0) { + CHECK_PARAM(msg->icon.size <= METADATA_ICON_MAX, _("icon too large")); + /* Width is capped at the confirm screen's icon column + * (LEFT_MARGIN_WITH_ICON = 40), NOT at the 64px height. Title/body text + * begins at x=40 and the icon is drawn AFTER the text, so a wider + * host-supplied icon would paint over the alias, fingerprint and the + * "NOT verified by KeepKey" warning — on the very screen that exists to + * carry that warning. This is the trust boundary for icons arriving on the + * wire; signed_metadata_signer_icon() rechecks the session copy at use. */ + CHECK_PARAM(msg->has_icon_width && msg->has_icon_height && + msg->icon_width > 0 && + msg->icon_width <= LEFT_MARGIN_WITH_ICON && + msg->icon_height > 0 && msg->icon_height <= 64, + _("icon dimensions out of range")); + /* Reject a malformed RLE stream HERE rather than discovering it at draw + * time. The render path returns a bool that layout_add_icon() discards, so + * an undecodable icon would otherwise show no logo while still returning + * Success — the user would consent to an identity + * whose logo silently does not exist. Validation is exact (every packet + * well-formed, no run straddling the image, whole input consumed) and + * side-effect-free. + */ + CHECK_PARAM(draw_bitmap_mono_rle_valid( + msg->icon.bytes, (uint32_t)msg->icon.size, + (uint16_t)msg->icon_width, (uint16_t)msg->icon_height), + _("invalid icon encoding")); + icon = msg->icon.bytes; + icon_len = (uint16_t)msg->icon.size; + icon_w = (uint8_t)msg->icon_width; + icon_h = (uint8_t)msg->icon_height; + } + bool persist = msg->has_persist && msg->persist; + CHECK_PARAM(!persist, _("Persistent clearsign signers are disabled")); + + /* Mandatory on-device consent — leads with the identity's logo (if any) + + * alias + fingerprint. The whole trust model hangs on this confirm; the same + * fingerprint reappears on every per-tx identity screen. */ + char fingerprint[METADATA_FINGERPRINT_LEN]; + signed_metadata_pubkey_fingerprint(msg->pubkey.bytes, fingerprint); + if (!signed_metadata_confirm_load(msg->alias, fingerprint, icon, icon_w, + icon_h, icon_len)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Load clearsign signer cancelled")); + layoutHome(); + return; + } + + if (!signed_metadata_store_signer((uint8_t)msg->key_id, msg->pubkey.bytes, + msg->alias, icon, icon_w, icon_h, icon_len, + persist)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Clearsign signer could not be loaded")); + layoutHome(); + return; + } + fsm_sendSuccess(_("Clearsign signer loaded")); + layoutHome(); +} + void fsm_msgEthereumGetAddress(EthereumGetAddress* msg) { RESP_INIT(EthereumAddress); diff --git a/lib/firmware/fsm_msg_hive.h b/lib/firmware/fsm_msg_hive.h new file mode 100644 index 000000000..d87188a14 --- /dev/null +++ b/lib/firmware/fsm_msg_hive.h @@ -0,0 +1,1024 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +// ── HiveGetPublicKey ────────────────────────────────────────────────────── +// Returns a single STM-prefixed public key for the given SLIP-0048 path. +// Path format: m/48'/13'/role'/account'/0' (all 5 components hardened). + +void fsm_msgHiveGetPublicKey(const HiveGetPublicKey* msg) { + RESP_INIT(HivePublicKey); + + CHECK_INITIALIZED + CHECK_PIN + + if (!hive_slip48_path_valid(msg->address_n, msg->address_n_count)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + resp->has_raw_public_key = true; + resp->raw_public_key.size = 33; + memcpy(resp->raw_public_key.bytes, node->public_key, 33); + + resp->has_public_key = true; + if (!hive_getPublicKey(node->public_key, resp->public_key, + sizeof(resp->public_key))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to encode Hive public key")); + layoutHome(); + return; + } + + if (msg->has_show_display && msg->show_display) { + // Label the key by the role in the ACTUAL derivation path + // (m/48'/13'/role'/account'/0'), never the host-supplied msg->role, + // which could mislabel the exported key. + const char* role_label = "Hive Public Key"; + if (msg->address_n_count >= 3) { + switch (msg->address_n[2] & 0x7FFFFFFFu) { + case 0: + role_label = "Hive Owner Key"; + break; + case 1: + role_label = "Hive Active Key"; + break; + case 3: + role_label = "Hive Memo Key"; + break; + case 4: + role_label = "Hive Posting Key"; + break; + default: + break; + } + } + if (!confirm_ethereum_address(role_label, resp->public_key)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Cancelled")); + layoutHome(); + return; + } + } + + memzero(node, sizeof(*node)); + msg_write(MessageType_MessageType_HivePublicKey, resp); + layoutHome(); +} + +// ── HiveGetPublicKeys ───────────────────────────────────────────────────── +// Returns all four SLIP-0048 role keys (owner/active/memo/posting) for a +// given account index in a single device interaction. + +void fsm_msgHiveGetPublicKeys(const HiveGetPublicKeys* msg) { + RESP_INIT(HivePublicKeys); + + CHECK_INITIALIZED + CHECK_PIN + + uint32_t account_index = msg->has_account_index ? msg->account_index : 0; + + HDNode* root = fsm_getDerivedNode(SECP256K1_NAME, NULL, 0, NULL); + if (!root) return; + + resp->has_owner_key = true; + resp->has_active_key = true; + resp->has_memo_key = true; + resp->has_posting_key = true; + + if (!hive_getPublicKeys(root, account_index, resp->owner_key, + sizeof(resp->owner_key), resp->active_key, + sizeof(resp->active_key), resp->memo_key, + sizeof(resp->memo_key), resp->posting_key, + sizeof(resp->posting_key))) { + memzero(root, sizeof(*root)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to derive Hive keys")); + layoutHome(); + return; + } + + if (msg->has_show_display && msg->show_display) { + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Hive Keys", + "Export all Hive keys for account %u?", + (unsigned int)account_index)) { + memzero(root, sizeof(*root)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Cancelled")); + layoutHome(); + return; + } + } + + memzero(root, sizeof(*root)); + msg_write(MessageType_MessageType_HivePublicKeys, resp); + layoutHome(); +} + +// ── SLIP-0048 path validation ───────────────────────────────────────────── +// All three sign handlers enforce the full path shape before anything is +// derived or signed: m/48'/13'/role'/account'/0' (all 5 components hardened), +// with the role pinned to the one the operation needs on-chain: +// transfer -> active' (post-HF28 hived no longer accepts higher-role +// substitution, and the cold owner key must not be spent) +// create/update -> owner' (the attestation contract: the sponsor verifies +// the signature recovers to the device OWNER key, and +// account_update replaces the owner authority itself) +// Rejecting arbitrary host paths means a compromised host can never make the +// device produce a Hive signature with a key from another coin's derivation +// tree, nor with the wrong role's key. + +static bool hive_slip48_path_ok(const uint32_t* address_n, uint32_t count, + uint32_t required_role) { + return hive_slip48_path_valid_for_role(address_n, count, required_role); +} + +static bool hive_confirm_slice(ButtonRequestType type, const char* title, + const uint8_t* s, uint16_t len); + +// ── HiveSignTx (transfer) ───────────────────────────────────────────────── + +void fsm_msgHiveSignTx(const HiveSignTx* msg) { + RESP_INIT(HiveSignedTx); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_from || !msg->has_to || !msg->has_amount || + !msg->has_ref_block_num || !msg->has_ref_block_prefix || + !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required Hive transaction fields")); + layoutHome(); + return; + } + + if (!hive_slip48_path_ok(msg->address_n, msg->address_n_count, + HIVE_ROLE_ACTIVE)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path (transfer needs active')")); + layoutHome(); + return; + } + + // Reject over-long memos up front with a specific error; the serializer's + // own bounds check would otherwise surface as a generic signing failure. + if (msg->has_memo && strlen(msg->memo) > HIVE_MAX_MEMO_LEN) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive memo too long (max 440 bytes)")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Display precision MUST match the precision the serializer signs + // (append_asset uses msg->decimals), otherwise the user approves an + // amount that differs from what is signed. Reject implausible precision. + uint8_t prec = msg->has_decimals ? (uint8_t)msg->decimals : HIVE_DECIMALS; + if (prec > 18) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive asset precision")); + layoutHome(); + return; + } + const char* symbol = msg->has_asset_symbol ? msg->asset_symbol : "HIVE"; + char suffix[sizeof(msg->asset_symbol) + 2]; // leading space + symbol + NUL + snprintf(suffix, sizeof(suffix), " %s", symbol); + char amount_str[32]; + bn_format_uint64(msg->amount, NULL, suffix, prec, 0, false, amount_str, + sizeof(amount_str)); + + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send Hive", + "Send %s to @%s?", amount_str, msg->to)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + if (msg->has_memo && strlen(msg->memo) > 0) { + if (!hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmMemo, "Memo", + (const uint8_t*)msg->memo, + (uint16_t)strlen(msg->memo))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } + + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", + "Sign Hive transaction from @%s?", msg->from)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signTx(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedTx, resp); + layoutHome(); +} + +typedef struct { + uint8_t owner[33]; + uint8_t active[33]; + uint8_t posting[33]; + uint8_t memo[33]; +} HiveRoleKeys; + +static bool hive_prepare_account_sign(const uint32_t* address_n, + uint32_t address_n_count, + HiveRoleKeys* keys, HDNode** node_out, + char* owner_stm, size_t owner_stm_len) { + if (!hive_slip48_path_ok(address_n, address_n_count, HIVE_ROLE_OWNER)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path (needs owner')")); + layoutHome(); + return false; + } + uint32_t account_index = address_n[3] & 0x7FFFFFFFu; + + // Derive all four role keys from the device root. + // Do this BEFORE fetching the signing node so the root static buffer + // is not clobbered by the second fsm_getDerivedNode call. + const HDNode* root = fsm_getDerivedNode(SECP256K1_NAME, NULL, 0, NULL); + if (!root) return false; + + uint32_t acc_hardened = account_index | 0x80000000u; + bool keys_ok = + hive_deriveRawKey(root, HIVE_ROLE_OWNER, acc_hardened, keys->owner) && + hive_deriveRawKey(root, HIVE_ROLE_ACTIVE, acc_hardened, keys->active) && + hive_deriveRawKey(root, HIVE_ROLE_POSTING, acc_hardened, keys->posting) && + hive_deriveRawKey(root, HIVE_ROLE_MEMO, acc_hardened, keys->memo); + // root static buffer is done with; signing node derivation may overwrite it. + + if (!keys_ok) { + memzero(keys, sizeof(*keys)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to derive Hive keys")); + layoutHome(); + return false; + } + + // Now get the signing node (owner key, overwrites root static buffer). + HDNode* node = + fsm_getDerivedNode(SECP256K1_NAME, address_n, address_n_count, NULL); + if (!node) { + memzero(keys, sizeof(*keys)); + return false; + } + hdnode_fill_public_key(node); + + // Encode the device-derived owner key for display confirmation. + if (!hive_getPublicKey(keys->owner, owner_stm, owner_stm_len)) { + memzero(node, sizeof(*node)); + memzero(keys, sizeof(*keys)); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to encode Hive owner key")); + layoutHome(); + return false; + } + + *node_out = node; + return true; +} + +// ── HiveSignAccountCreate ───────────────────────────────────────────────── +// Signs a Graphene account_create operation. +// Device derives all four role keys internally; host-supplied key strings +// are informational only (displayed for confirmation) and never used for +// the actual transaction. KeepKey is the sole root of trust from genesis. + +void fsm_msgHiveSignAccountCreate(const HiveSignAccountCreate* msg) { + RESP_INIT(HiveSignedAccountCreate); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_new_account_name || !msg->has_creator || + !msg->has_ref_block_num || !msg->has_ref_block_prefix || + !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required account_create fields")); + layoutHome(); + return; + } + + HiveRoleKeys keys; + HDNode* node = NULL; + char owner_stm[64]; + if (!hive_prepare_account_sign(msg->address_n, msg->address_n_count, &keys, + &node, owner_stm, sizeof(owner_stm))) { + return; + } + + // Primary confirmation: show the new username prominently. + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Create Hive Account", + "Create @%s secured by KeepKey?\n\nAll keys from your device.", + msg->new_account_name)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Secondary confirmation: show device-derived owner key so user can verify. + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Owner Key", "%s", + owner_stm)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Tertiary confirmation: show sponsor + fee. + char fee_str[32]; + uint64_t fee = msg->has_fee_amount ? msg->fee_amount : 3000; + snprintf(fee_str, sizeof(fee_str), "%" PRIu64 ".%03" PRIu64 " HIVE", + fee / 1000, fee % 1000); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Creation Fee", + "Fee: %s paid by @%s", fee_str, msg->creator)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signAccountCreate(node, msg, keys.owner, keys.active, keys.posting, + keys.memo, resp); + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive account_create signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedAccountCreate, resp); + layoutHome(); +} + +// ── HiveSignAccountUpdate ───────────────────────────────────────────────── +// Signs a Graphene account_update operation. +// Device derives all four new role keys internally; host-supplied new_*_key +// strings are not used for signing. The device-derived owner key is shown +// so the user can verify it matches their device before replacing all keys. + +void fsm_msgHiveSignAccountUpdate(const HiveSignAccountUpdate* msg) { + RESP_INIT(HiveSignedAccountUpdate); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_account || !msg->has_ref_block_num || + !msg->has_ref_block_prefix || !msg->has_expiration) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing required account_update fields")); + layoutHome(); + return; + } + + HiveRoleKeys keys; + HDNode* node = NULL; + char owner_stm[64]; + if (!hive_prepare_account_sign(msg->address_n, msg->address_n_count, &keys, + &node, owner_stm, sizeof(owner_stm))) { + return; + } + + // Warning: this replaces all existing keys. + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, + "Secure Hive Account", + "Replace ALL keys for @%s with KeepKey keys?\n\nOld keys will " + "be retired.", + msg->account)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Show device-derived owner key so user can verify it's their device. + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "New Owner Key", "%s", + owner_stm)) { + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signAccountUpdate(node, msg, keys.owner, keys.active, keys.posting, + keys.memo, resp); + memzero(node, sizeof(*node)); + memzero(&keys, sizeof(keys)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive account_update signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedAccountUpdate, resp); + layoutHome(); +} + +// ── HiveSignMessage (Keychain signBuffer) ───────────────────────────────── +// The Hive dApp login primitive: Aioha / Keychain-SDK dApps authenticate by +// having the account sign a challenge string, then recover the pubkey and +// check it against the account's authority on-chain. Contract (hive-js +// Signature.signBuffer): sig over SHA256(raw message bytes) — no chain_id, +// no prefix. Roles: posting/active/memo, Keychain's requestSignBuffer +// surface. owner' is deliberately rejected — no consumer offers it, and the +// cold owner key must not be normalized into dApp flows. The full path +// shape is still enforced like the tx handlers. + +static bool hive_slip48_message_path_ok(const uint32_t* address_n, + uint32_t count, + const char** role_label) { + if (!hive_slip48_path_valid(address_n, count)) return false; + switch (address_n[2]) { + case HIVE_ROLE_ACTIVE: + *role_label = "active"; + return true; + case HIVE_ROLE_MEMO: + *role_label = "memo"; + return true; + case HIVE_ROLE_POSTING: + *role_label = "posting"; + return true; + default: + return false; + } +} + +void fsm_msgHiveSignMessage(const HiveSignMessage* msg) { + RESP_INIT(HiveSignedMessage); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_message || msg->message.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Missing message")); + layoutHome(); + return; + } + + // Mirrors the proto max_size cap so proto and code can never disagree + // (the memo-length lesson from the transfer handler). + if (msg->message.size > HIVE_MAX_MESSAGE_LEN) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive message too long (max 1024 bytes)")); + layoutHome(); + return; + } + + // A Hive TRANSACTION digest is SHA256(chain_id || tx), and this message + // digest is SHA256(message) — so a "message" that begins with the mainnet + // chain-id bytes would hash to a broadcastable transaction's digest. No + // legitimate challenge starts with the chain id; refuse the collision. + const uint8_t hive_chain_id[HIVE_CHAIN_ID_LEN] = HIVE_CHAIN_ID; + if (msg->message.size >= HIVE_CHAIN_ID_LEN && + memcmp(msg->message.bytes, hive_chain_id, HIVE_CHAIN_ID_LEN) == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Message must not start with the Hive chain ID")); + layoutHome(); + return; + } + + const char* role_label = NULL; + if (!hive_slip48_message_path_ok(msg->address_n, msg->address_n_count, + &role_label)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Hive SLIP-0048 path")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Domain-separate messages from transactions. A Hive TRANSACTION digest is + // SHA256(chain_id || serialized_tx), where the 32-byte chain_id and the + // serialized Graphene fields (ref_block_prefix, expiration, ...) are BINARY. + // Constraining signable messages to printable ASCII puts them in a domain + // disjoint from every transaction preimage — for ANY chain id, not just + // mainnet — so a binary "message" equal to C || serialized_tx can no longer + // be signed into a valid transaction signature on a fork chain C. This is the + // real fix; the mainnet-only prefix reject above is a belt-and-suspenders + // subset of it. hive-js signBuffer signs printable challenges, so nothing + // legitimate is lost. (A prefix blacklist could never be complete because the + // host chooses the chain id; a printable-only whitelist is complete by + // construction against binary preimages.) + if (!hive_message_is_printable(msg->message.bytes, msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Hive messages must be printable text")); + layoutHome(); + return; + } + + // Page the FULL message (72-char ASCII pages) so no trailing content is ever + // truncated behind a benign-looking prefix, and name the signing key. + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, "Sign Hive Message", + "Signing with %s key", role_label) || + !hive_confirm_slice(ButtonRequestType_ButtonRequest_ProtectCall, + "Hive Message", msg->message.bytes, + (uint16_t)msg->message.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signMessage(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive message signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedMessage, resp); + layoutHome(); +} + +// ── HiveSignOperations (parsed generic op signing) ──────────────────────── +// The host serializes the transaction; firmware parses the Graphene bytes, +// clear-signs the ops it recognizes (vote, comment, custom_json), and +// refuses everything else — no blind-sign fallback. Everything shown on the +// OLED is re-derived from the bytes being signed, so a host serializer bug +// can only produce a node rejection, never a silent wrong-sign. + +// Dedicated path validator: {posting', active'} ONLY, pinned to the tx tier. +// Do NOT fold into hive_slip48_message_path_ok — that one deliberately +// accepts memo' (a legitimate signBuffer target), but no Graphene operation +// uses memo authority; a memo-path vote must be refused here, not +// discovered at the chain. owner' is likewise excluded. +static bool hive_slip48_ops_path_ok(const uint32_t* address_n, uint32_t count, + bool needs_active) { + return hive_slip48_path_valid_for_role( + address_n, count, needs_active ? HIVE_ROLE_ACTIVE : HIVE_ROLE_POSTING); +} + +// User-controlled string fields are paged in full. Printable fields are shown +// as text; fields containing non-ASCII bytes are shown as complete hex rather +// than a short preview. Page boundaries are selected with the same font and +// word-wrapping calculation used by draw_string(), so no signed suffix can be +// pushed below the OLED's three visible body rows. + +static bool hive_slice_is_ascii(const uint8_t* s, uint16_t len) { + bool ascii = true; + for (uint16_t i = 0; i < len; i++) { + if (s[i] < 0x20 || s[i] > 0x7e) { + ascii = false; + break; + } + } + return ascii; +} + +static uint16_t hive_rendered_page_len(const uint8_t* s, uint16_t len, + bool ascii) { + if (len == 0) return 0; + + if (ascii) { + size_t candidate = len; + if (candidate >= BODY_CHAR_MAX) candidate = BODY_CHAR_MAX - 1; + return (uint16_t)calc_str_page(get_body_font(), (const char*)s, candidate, + BODY_WIDTH, BODY_ROWS); + } + + uint16_t candidate = len; + if (candidate > (BODY_CHAR_MAX - 1) / 2) candidate = (BODY_CHAR_MAX - 1) / 2; + char rendered[BODY_CHAR_MAX]; + for (uint16_t i = 0; i < candidate; i++) { + snprintf(rendered + 2 * i, 3, "%02x", s[i]); + } + size_t chars = calc_str_page(get_body_font(), rendered, 2 * candidate, + BODY_WIDTH, BODY_ROWS); + return (uint16_t)(chars / 2); +} + +static bool hive_confirm_slice(ButtonRequestType type, const char* title, + const uint8_t* s, uint16_t len) { + if (len == 0) return confirm(type, title, "(empty)"); + + bool ascii = hive_slice_is_ascii(s, len); + uint16_t pages = 0; + uint16_t offset = 0; + while (offset < len) { + uint16_t take = hive_rendered_page_len(s + offset, len - offset, ascii); + if (take == 0) return false; + offset = (uint16_t)(offset + take); + pages++; + } + + offset = 0; + for (uint16_t page = 0; page < pages; page++) { + uint16_t take = hive_rendered_page_len(s + offset, len - offset, ascii); + if (take == 0) return false; + + char page_title[TITLE_CHAR_MAX]; + if (pages > 1 || !ascii) { + snprintf(page_title, sizeof(page_title), + ascii ? "%s %u/%u" : "%s Hex %u/%u", title, (unsigned)(page + 1), + (unsigned)pages); + } else { + strlcpy(page_title, title, sizeof(page_title)); + } + + if (ascii) { + char rendered[BODY_CHAR_MAX]; + memcpy(rendered, s + offset, take); + rendered[take] = '\0'; + if (!confirm(type, page_title, "%s", rendered)) return false; + } else { + char rendered[BODY_CHAR_MAX]; + for (uint16_t i = 0; i < take; i++) { + snprintf(rendered + 2 * i, 3, "%02x", s[offset + i]); + } + if (!confirm(type, page_title, "%s", rendered)) return false; + } + offset = (uint16_t)(offset + take); + } + return true; +} + +// "1.234 HIVE" — precision comes from the asset bytes being signed, which +// the parser has already pinned to the symbol's protocol-fixed value. +static void hive_format_asset(const uint8_t* a, char* out, size_t out_len) { + char suffix[9]; // space + longest symbol ("VESTS") + NUL + snprintf(suffix, sizeof(suffix), " %s", hive_assetSymbol(a)); + bn_format_uint64(hive_assetAmount(a), NULL, suffix, hive_assetPrecision(a), 0, + false, out, out_len); +} + +// Basis points (0..10000) as "12.34%". +static void hive_format_percent(int16_t bp, char* out, size_t out_len) { + snprintf(out, out_len, "%d.%02d%%", bp / 100, bp % 100); +} + +static void hive_copy_slice(char* out, size_t out_len, const uint8_t* s, + uint16_t len) { + if (out_len == 0) return; + size_t take = len; + if (take >= out_len) take = out_len - 1; + memcpy(out, s, take); + out[take] = '\0'; +} + +void fsm_msgHiveSignOperations(const HiveSignOperations* msg) { + RESP_INIT(HiveSignedOperations); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_serialized_tx || msg->serialized_tx.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing serialized transaction")); + layoutHome(); + return; + } + + static HiveParsedTx parsed; // slices borrow from the static msg buffer + const char* parse_err = hive_parseOperations( + msg->serialized_tx.bytes, msg->serialized_tx.size, &parsed); + if (parse_err) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _(parse_err)); + layoutHome(); + return; + } + + if (!hive_slip48_ops_path_ok(msg->address_n, msg->address_n_count, + parsed.needs_active)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + parsed.needs_active + ? _("Invalid Hive SLIP-0048 path (needs active')") + : _("Invalid Hive SLIP-0048 path (needs posting')")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Confirm operation summaries and payloads, then show a final sign prompt. + for (uint8_t i = 0; i < parsed.num_ops; i++) { + const HiveTxOp* op = &parsed.ops[i]; + char name[17]; // hive account names are <= 16 chars, length-validated + hive_copy_slice(name, sizeof(name), op->acct, op->acct_len); + + bool approved = false; + switch (op->op_type) { + case HIVE_OP_VOTE: { + char target[17]; + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + int w = op->weight < 0 ? -op->weight : op->weight; + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + op->weight < 0 ? "Downvote" : "Vote", + "@%s -> @%s at %d.%02d%%", name, target, w / 100, w % 100); + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Vote Target", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_COMMENT: { + char parent[17]; + hive_copy_slice(parent, sizeof(parent), op->parent_author, + op->parent_author_len); + approved = + op->is_top_level + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Post", + "Create post by @%s?", name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Comment", "Reply by @%s to @%s?", name, parent); + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, + op->is_top_level ? "Post Category" : "Reply Target", + op->parent_permlink, op->parent_permlink_len); + } + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Post Permlink", + op->permlink, op->permlink_len); + } + if (approved && op->target_len > 0) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Post Title", op->target, op->target_len); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Post Body", op->detail, op->detail_len); + } + if (approved && op->json_metadata_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Post Metadata", + op->json_metadata, op->json_metadata_len); + } + break; + } + case HIVE_OP_CUSTOM_JSON: { + approved = true; + for (uint8_t a = 0; approved && a < op->n_auths; a++) { + char auth_name[17]; + hive_copy_slice(auth_name, sizeof(auth_name), op->auth_acct[a], + op->auth_acct_len[a]); + approved = confirm( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Custom JSON Auth", + "%u/%u: @%s\n%s key", (unsigned)(a + 1), (unsigned)op->n_auths, + auth_name, op->needs_active ? "Active" : "Posting"); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON ID", op->target, op->target_len); + } + if (approved) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Custom JSON", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_TRANSFER_TO_VESTING: { + char amount[40], target[17]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + approved = + op->target_len == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Up", "Power up\n%s\nto @%s", amount, name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Up", "%s\nfrom @%s\nto @%s", amount, name, + target); + break; + } + case HIVE_OP_WITHDRAW_VESTING: { + char amount[40]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + approved = + hive_assetAmount(op->assets[0]) == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Stop Power Down", "Cancel power down\nfor @%s", name) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Power Down", "Power down\n%s\nfrom @%s", amount, + name); + break; + } + case HIVE_OP_LIMIT_ORDER_CREATE: { + char sell[40], receive[40]; + hive_format_asset(op->assets[0], sell, sizeof(sell)); + hive_format_asset(op->assets[1], receive, sizeof(receive)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Market Order", "@%s sells\n%s\nfor >= %s", name, + sell, receive); + if (approved) { + // Order id and fill_or_kill decide whether an unfilled order rests + // on the book or is discarded, so they get their own screen rather + // than being crowded off the first one. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Order Terms", "Order #%u\n%s\nExpires %u", + (unsigned)op->req_id, + op->flag ? "Fill or kill" : "Rests on book", + (unsigned)op->expiration); + } + break; + } + case HIVE_OP_LIMIT_ORDER_CANCEL: + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Cancel Order", "Cancel order #%u\nfor @%s?", + (unsigned)op->req_id, name); + break; + case HIVE_OP_CONVERT: { + char amount[40]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Convert", "Convert %s\nto HIVE for @%s\n(#%u)", + amount, name, (unsigned)op->req_id); + break; + } + case HIVE_OP_COMMENT_OPTIONS: { + char max_payout[40], percent[16]; + hive_format_asset(op->assets[0], max_payout, sizeof(max_payout)); + hive_format_percent(op->weight, percent, sizeof(percent)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Options", "@%s\nMax %s\nHBD split %s", name, + max_payout, percent); + if (approved) { + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Options", "Votes: %s\nCuration: %s", + op->flag ? "allowed" : "disabled", + op->flag2 ? "allowed" : "disabled"); + } + // Beneficiaries divert payout to other accounts — each one is + // confirmed individually rather than summarized as a count. + for (uint8_t b = 0; approved && b < op->n_benef; b++) { + char benef[17], benef_pct[16]; + hive_copy_slice(benef, sizeof(benef), op->benef_acct[b], + op->benef_acct_len[b]); + hive_format_percent((int16_t)op->benef_weight[b], benef_pct, + sizeof(benef_pct)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Payout Beneficiary", "%u/%u: @%s\ngets %s", + (unsigned)(b + 1), (unsigned)op->n_benef, benef, + benef_pct); + } + if (approved) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Payout Permlink", + op->permlink, op->permlink_len); + } + break; + } + case HIVE_OP_TRANSFER_TO_SAVINGS: + case HIVE_OP_TRANSFER_FROM_SAVINGS: { + char amount[40], target[17]; + bool deposit = (op->op_type == HIVE_OP_TRANSFER_TO_SAVINGS); + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + // One variable per row. A 16-character account name sharing a row + // with a label can wrap into a fourth row, which the display drops + // silently — and here that row carries the destination account. + // req_id is deliberately not shown: it is a cancellation handle, not + // a fund-routing field, and crowding it in costs the destination row. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + deposit ? "Savings Deposit" : "Savings Withdraw", + "%s\nfrom @%s\nto @%s", amount, name, target); + if (approved && op->detail_len > 0) { + approved = + hive_confirm_slice(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Savings Memo", op->detail, op->detail_len); + } + break; + } + case HIVE_OP_CLAIM_REWARD_BALANCE: { + char hive_amt[40], hbd_amt[40], vests_amt[40]; + hive_format_asset(op->assets[0], hive_amt, sizeof(hive_amt)); + hive_format_asset(op->assets[1], hbd_amt, sizeof(hbd_amt)); + hive_format_asset(op->assets[2], vests_amt, sizeof(vests_amt)); + // Three assets plus the account name cannot share one screen: the + // OLED body fits exactly three rows (layout.c places rows at y = + // 24/38/52 and draw_char_with_shift silently drops any glyph past + // y+height > 64), so a fourth row would be signed but never shown. + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Claim Rewards", "@%s claims\n%s\n%s", name, + hive_amt, hbd_amt); + if (approved) { + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Claim Rewards", "@%s claims\n%s", name, vests_amt); + } + break; + } + case HIVE_OP_DELEGATE_VESTING_SHARES: { + char amount[40], target[17]; + hive_format_asset(op->assets[0], amount, sizeof(amount)); + hive_copy_slice(target, sizeof(target), op->target, op->target_len); + approved = + hive_assetAmount(op->assets[0]) == 0 + ? confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Remove Delegation", + "@%s removes its\ndelegation to @%s?", name, target) + : confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Delegate", "@%s delegates\n%s\nto @%s", name, amount, + target); + break; + } + case HIVE_OP_ACCOUNT_UPDATE2: + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Profile Update", "Update profile\nof @%s?", name); + if (approved && op->detail_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Account Metadata", + op->detail, op->detail_len); + } + if (approved && op->json_metadata_len > 0) { + approved = hive_confirm_slice( + ButtonRequestType_ButtonRequest_ConfirmOutput, "Profile Metadata", + op->json_metadata, op->json_metadata_len); + } + break; + default: + break; // unreachable — parser rejected unknown ops + } + if (!approved) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } + + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Transaction", + "Sign %u Hive operation%s with the %s key?", + (unsigned)parsed.num_ops, parsed.num_ops == 1 ? "" : "s", + parsed.needs_active ? "active" : "posting")) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + hive_signOperations(node, msg, resp); + memzero(node, sizeof(*node)); + + if (!resp->has_signature) { + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Hive operation signing failed")); + layoutHome(); + return; + } + + msg_write(MessageType_MessageType_HiveSignedOperations, resp); + layoutHome(); +} diff --git a/lib/firmware/fsm_msg_mayachain.h b/lib/firmware/fsm_msg_mayachain.h index 68d996804..27f47f213 100644 --- a/lib/firmware/fsm_msg_mayachain.h +++ b/lib/firmware/fsm_msg_mayachain.h @@ -80,20 +80,17 @@ void fsm_msgMayachainGetAddress(const MayachainGetAddress* msg) { void fsm_msgMayachainSignTx(const MayachainSignTx* msg) { CHECK_INITIALIZED + CHECK_PIN if (!msg->has_account_number || !msg->has_chain_id || !msg->has_fee_amount || - !msg->has_gas || !msg->has_sequence || !msg->has_msg_count || - msg->msg_count == 0 || !tendermint_validateSafeText(msg->chain_id)) { + !msg->has_gas || !msg->has_sequence) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, - "Missing or Invalid Fields On Message"); + "Missing Fields On Message"); layoutHome(); return; } - /* Reject malformed envelopes before authentication or key derivation. */ - CHECK_PIN - HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, msg->address_n_count, NULL); if (!node) { @@ -122,6 +119,13 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { // Confirm transaction basics // supports only 1 message ack CHECK_PARAM(mayachain_signingIsInited(), "Signing not in progress"); + if (msg->has_send == msg->has_deposit) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Expected exactly one MAYAChain message")); + layoutHome(); + return; + } if (msg->has_send && msg->send.has_to_address && msg->send.has_amount && msg->send.has_denom) { // pass @@ -144,31 +148,42 @@ 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: { - /* The denomination buffer being big enough does not make the DISPLAYED - * amount safe: bn_format() writes into amount_str, and on overflow it - * zeroes the whole buffer and returns 0. Ignoring that return put an - * EMPTY amount on the confirmation screen and signed anyway -- an - * amount of 1 with a 19-character denomination already needs 33 bytes, - * and the signer's 65-byte segment accepts far longer ones. - * - * Size for the protocol maximum instead of hoping: a uint64 rendered - * at 10 decimals is at most 20 digits plus a point (21), the suffix is - * ' ' + 68 visible chars of denom (69), plus NUL. Then CHECK the - * result and fail closed, as fsm_msg_binance.h does. See GH #437. */ - char amount_str[21 + MAYACHAIN_DENOM_SUFFIX_LEN + 1]; - /* MayachainMsgSend.denom max_size:69 (messages-mayachain.options) -> - * 68 visible chars + NUL. ' ' + 68 + NUL = 70 bytes; 71 keeps a 1-byte - * margin. The prior code used unbounded sprintf(); switch to a bounded - * snprintf so a future max_size bump can't silently overflow. */ - if (!mayachain_formatAmount(msg->send.amount, msg->send.denom, - amount_str, sizeof(amount_str))) { + // Amount (no denom suffix) must fit amount_str[32]; a long denom + // appended here would overflow bn_format and blank the amount while + // the real value is still signed. Confirm the denom on its own + // screen instead (matches the THORChain send path). + // + // This also retires the denom_str[71] scratch buffer that GH #437 + // bounded with snprintf(): the denom is no longer copied into a + // fixed-size suffix at all, so a future bump of + // MayachainMsgSend.denom's max_size (69 today) cannot overflow + // anything here. #437's class is closed by construction, not by a + // size that has to be kept in step with the .options file. + char amount_str[32]; + if (!bn_format_uint64(msg->send.amount, NULL, NULL, 10, 0, false, + amount_str, sizeof(amount_str))) { mayachain_signAbort(); - fsm_sendFailure(FailureType_Failure_SyntaxError, - "Invalid MAYAChain send amount"); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to format amount")); layoutHome(); return; } @@ -196,12 +211,19 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { layoutHome(); return; } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Asset", + "%s", coin_denom)) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } break; } } if (!mayachain_signTxUpdateMsgSend(msg->send.amount, msg->send.to_address, - msg->send.denom)) { + coin_denom)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, "Failed to include send message in transaction"); @@ -212,28 +234,25 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } else if (msg->has_deposit) { const char* const signer_prefix = sign_tx->has_testnet && sign_tx->testnet ? "smaya" : "maya"; - /* The signer must be THIS session's account, not merely a well-formed - address on the right network. MsgDeposit serializes `signer` verbatim as - the message authority, so a valid-but-foreign address produced a signed - document the device's key cannot authorize -- and the confirmation below - labels that address as though it were a destination, so the screen would - not have given it away. */ - if (!tendermint_validateSafeText(msg->deposit.asset) || + /* The signer must be this session's account, not merely a well-formed + * address on the right network. */ + // Validate before any display so untrusted strings never reach the UI + // or the sign bytes. + if (!mayachain_isValidAsset(msg->deposit.asset) || + !mayachain_isValidSigner(msg->deposit.signer) || + !tendermint_validateSafeText(msg->deposit.asset) || !tendermint_validateBech32Address(msg->deposit.signer, signer_prefix) || !mayachain_addressIsSigner(msg->deposit.signer)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, - "Invalid MAYAChain deposit fields"); + "Invalid deposit asset or signer"); layoutHome(); return; } - - /* Same defect as the send path above, one field narrower: - * MayachainMsgDeposit.asset is max_size:20, so the suffix reaches 20 - * characters and 21 + 20 + 1 = 42 does not fit a 32-byte amount_str. - * bn_format() then zeroed it and returned 0, and the ignored return let an - * empty amount reach the screen. */ - char amount_str[21 + MAYACHAIN_ASSET_SUFFIX_LEN + 1]; + // 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[21 + MAYACHAIN_DENOM_SUFFIX_LEN + 1]; if (!mayachain_formatAmount(msg->deposit.amount, msg->deposit.asset, amount_str, sizeof(amount_str))) { mayachain_signAbort(); @@ -252,36 +271,26 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } if (msg->deposit.has_memo) { - // See if we can parse the memo /* strnlen, not sizeof: the capacity of a fixed array is not the length of the memo in it. Mirrors the THORChain path. */ - MayachainMemoResult memo_result = mayachain_parseConfirmMemo( - msg->deposit.memo, - strnlen(msg->deposit.memo, sizeof(msg->deposit.memo))); - if (memo_result == MAYACHAIN_MEMO_CANCELLED) { - // A memo screen was refused: a refusal to sign, not a parse failure. - // Re-asking with the raw-bytes screen would launder that "no" into a - // second chance to say yes. + size_t memo_len = strnlen(msg->deposit.memo, sizeof(msg->deposit.memo)); + /* Page the COMPLETE raw memo as the sole, authoritative disclosure. + No structured pre-parse: this path deliberately makes the complete + raw memo the authoritative disclosure, including fields beyond the + structured parser's current vocabulary. + thorchain_confirm_full_memo() is confirm_bytes() over an explicit + length (lib/firmware/thorchain.c), so an embedded NUL cannot hide the + memo tail and every non-printable byte is escaped -- and that now + holds for EVERY memo, not only unparsed ones. It also discloses the + fields the structured parser never displays (aggregator, final token, + min-out). */ + if (!thorchain_confirm_full_memo(_("Memo"), msg->deposit.memo, + memo_len)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (memo_result == MAYACHAIN_MEMO_UNPARSED) { - // Memo not recognizable, ask to confirm it - /* confirm_bytes, not confirm("%s"): "%s" stops at the first NUL, so - an unparsed memo with an embedded zero would be signed with its tail - hidden. Takes an explicit length and escapes non-printables. */ - if (!confirm_bytes( - ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), - (const uint8_t*)msg->deposit.memo, - strnlen(msg->deposit.memo, sizeof(msg->deposit.memo)))) { - mayachain_signAbort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); - layoutHome(); - return; - } - } } if (!mayachain_signTxUpdateMsgDeposit(&(msg->deposit))) { @@ -299,38 +308,17 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { return; } - /* Review the OUTER transaction memo whenever it is present -- including when - * the deposit carries one of its own. - * - * These are two different strings in the signed document, not one superseding - * the other: mayachain_signTxInit() hashes sign_tx->memo into the StdSignDoc - * "memo" field, and the MsgDeposit value below hashes deposit.memo - * separately. Skipping this review when deposit.has_memo let a host show a - * benign deposit memo while a different outer memo was signed unseen -- the - * exact thing this release line exists to prevent. Both are signed, so both - * are shown. */ if (sign_tx->has_memo) { - // See if we can parse the tx memo. - MayachainMemoResult memo_result = mayachain_parseConfirmMemo( - sign_tx->memo, strnlen(sign_tx->memo, sizeof(sign_tx->memo))); - if (memo_result == MAYACHAIN_MEMO_CANCELLED) { - // A memo screen was refused: a refusal to sign, not a parse failure. + // The transaction-level memo and a deposit memo are distinct signed + // fields, so page both when both are present. strnlen, not sizeof; see the + // deposit path above for why there is no structured pass. + size_t memo_len = strnlen(sign_tx->memo, sizeof(sign_tx->memo)); + if (!thorchain_confirm_full_memo(_("Memo"), sign_tx->memo, memo_len)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (memo_result == MAYACHAIN_MEMO_UNPARSED) { - // Memo not recognizable, ask to confirm it -- length-aware, see above. - if (!confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmMemo, _("Memo"), - (const uint8_t*)sign_tx->memo, - strnlen(sign_tx->memo, sizeof(sign_tx->memo)))) { - mayachain_signAbort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); - layoutHome(); - return; - } - } } char node_str[NODE_STRING_LENGTH]; @@ -344,9 +332,9 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_SignTx, node_str, - "Sign %s on %s? Fee: %" PRIu32 " cacao. Gas: %" PRIu32 ".", - msg->has_send ? msg->send.denom : "CACAO", sign_tx->chain_id, - sign_tx->fee_amount, sign_tx->gas)) { + "Sign this %s transaction on %s? " + "Additional network fees apply.", + msg->has_send ? coin_denom : "CACAO", sign_tx->chain_id)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); diff --git a/lib/firmware/fsm_msg_osmosis.h b/lib/firmware/fsm_msg_osmosis.h index baaf4011d..37e1da81c 100644 --- a/lib/firmware/fsm_msg_osmosis.h +++ b/lib/firmware/fsm_msg_osmosis.h @@ -1,47 +1,15 @@ -#include -#include "keepkey/board/util.h" /* base_to_precision for osmosis_format_amount */ -#define OSMOSIS_PRECISION 6 +#include "keepkey/board/util.h" /* base_to_precision for the LP share amounts */ #define OSMOSIS_LP_ASSET_PRECISION 18 -/* Render an Osmosis amount (host-supplied base-10 integer string) as a - * fixed-precision decimal for the confirmation screen, without going through - * float. The prior code did: float amount = atof(str); amount /= pow(10, PREC); - * ... "%.6f", which loses precision past ~7 significant digits while the - * signed JSON keeps the exact integer string (display-vs-signed divergence, - * GH #438). This helper uses base_to_precision (integer-string decimal - * insertion) so the rendered value is always faithful to the signed string. */ -static void osmosis_format_amount(char* out, size_t out_len, - const char* amount_str, const char* denom) { - if (!out || out_len == 0) return; - out[0] = '\0'; - const char* d = denom ? denom : ""; - - if (!amount_str || amount_str[0] == '\0') { - snprintf(out, out_len, "0 %s", d); - return; - } +static bool osmosis_formatAmountOrFail(char* out, size_t out_len, + const char* value, const char* denom) { + if (osmosis_formatAmount(out, out_len, value, denom)) return true; - /* Only the native denom has an exponent this firmware knows. An IBC hash or - a factory denom carries an exponent we cannot determine, so scaling it by - 10^6 would put a number on screen that is not the number being signed. - Those are shown as the exact integer with the exact denom. */ - if (strcmp(d, "uosmo") != 0) { - snprintf(out, out_len, "%s %s", amount_str, d); - return; - } - - const size_t amt_len = strlen(amount_str); - char decimal_buf[80]; - if (amt_len > 64 || - base_to_precision((uint8_t*)decimal_buf, (const uint8_t*)amount_str, - (uint8_t)sizeof(decimal_buf), (uint8_t)amt_len, - OSMOSIS_PRECISION) != 0) { - /* Cannot render faithfully: show the exact signed integer rather than a - rounded or truncated decimal. */ - snprintf(out, out_len, "%s uosmo", amount_str); - return; - } - snprintf(out, out_len, "%s OSMO", decimal_buf); + osmosis_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid Osmosis amount or denomination"); + layoutHome(); + return false; } void fsm_msgOsmosisGetAddress(const OsmosisGetAddress* msg) { @@ -125,20 +93,17 @@ void fsm_msgOsmosisGetAddress(const OsmosisGetAddress* msg) { void fsm_msgOsmosisSignTx(const OsmosisSignTx* msg) { CHECK_INITIALIZED + CHECK_PIN if (!msg->has_account_number || !msg->has_chain_id || !msg->has_fee_amount || - !msg->has_gas || !msg->has_sequence || !msg->has_msg_count || - msg->msg_count == 0 || !tendermint_validateSafeText(msg->chain_id)) { + !msg->has_gas || !msg->has_sequence) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, - "Missing or Invalid Fields On Message"); + "Missing Fields On Message"); layoutHome(); return; } - /* Reject malformed envelopes before authentication or key derivation. */ - CHECK_PIN - HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, msg->address_n_count, NULL); if (!node) { @@ -190,7 +155,7 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { if (msg->has_send) { if (!osmosis_validate_account_address(msg->send.has_to_address, msg->send.to_address) || - !osmosis_validate_amount(msg->send.has_amount, msg->send.amount) || + !msg->send.has_amount || !osmosis_validate_required_text(msg->send.has_denom, msg->send.denom)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, @@ -199,15 +164,20 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - const char* denom = msg->send.denom; - char amount_str[128]; - osmosis_format_amount(amount_str, sizeof(amount_str), msg->send.amount, - denom); + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->send.amount, msg->send.denom)) { + return; + } - /** Confirm transaction parameters on screen */ - if (!confirm_transaction_output( - ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, - msg->send.to_address)) { + // Amount and destination are independent renderer-measured disclosures. + // A single wrapped "Send ... to ..." body could hide the destination. + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Send Amount", (const uint8_t*)amount_str, + strlen(amount_str)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send To", + (const uint8_t*)msg->send.to_address, + strlen(msg->send.to_address))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -240,10 +210,12 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - const char* denom = msg->delegate.denom; - char amount_str[128]; - osmosis_format_amount(amount_str, sizeof(amount_str), msg->delegate.amount, - denom); + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->delegate.amount, + msg->delegate.denom)) { + return; + } /** Confirm transaction parameters on-screen */ if (!confirm_osmosis_address("Confirm Delegator Address", @@ -262,8 +234,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", "%s", - amount_str)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -297,10 +269,12 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - const char* denom = msg->undelegate.denom; - char amount_str[128]; - osmosis_format_amount(amount_str, sizeof(amount_str), - msg->undelegate.amount, denom); + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->undelegate.amount, + msg->undelegate.denom)) { + return; + } /** Confirm transaction parameters on-screen */ if (!confirm_osmosis_address("Confirm Delegator Address", @@ -319,8 +293,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", "%s", - amount_str)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Confirm Amount", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -357,39 +331,45 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - char insoamt[33] = {0}; - uint8_t outsoamt[34] = {0}; - strlcpy(insoamt, msg->lp_add.share_out_amount, - sizeof(msg->lp_add.share_out_amount)); - - if (base_to_precision(outsoamt, (uint8_t*)insoamt, sizeof(outsoamt), - strlen(insoamt), OSMOSIS_LP_ASSET_PRECISION) < 0) { + char outsoamt[34] = {0}; + if (base_to_precision( + (uint8_t*)outsoamt, (const uint8_t*)msg->lp_add.share_out_amount, + sizeof(outsoamt), strlen(msg->lp_add.share_out_amount), + OSMOSIS_LP_ASSET_PRECISION) < 0) { osmosis_signAbort(); - fsm_sendFailure(FailureType_Failure_Other, NULL); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid LP share amount"); layoutHome(); return; } - const char* denom_in_max_b = msg->lp_add.denom_in_max_b; - const char* denom_in_max_a = msg->lp_add.denom_in_max_a; - char amt_b_str[128]; - osmosis_format_amount(amt_b_str, sizeof(amt_b_str), - msg->lp_add.amount_in_max_b, denom_in_max_b); - char amt_a_str[128]; - osmosis_format_amount(amt_a_str, sizeof(amt_a_str), - msg->lp_add.amount_in_max_a, denom_in_max_a); + char amount_in_max_b_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_in_max_b_str, sizeof(amount_in_max_b_str), + msg->lp_add.amount_in_max_b, msg->lp_add.denom_in_max_b)) { + return; + } + + char amount_in_max_a_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_in_max_a_str, sizeof(amount_in_max_a_str), + msg->lp_add.amount_in_max_a, msg->lp_add.denom_in_max_a)) { + return; + } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Liquidity", - "Deposit %s and...", amt_b_str)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Max Deposit A", + (const uint8_t*)amount_in_max_a_str, + strlen(amount_in_max_a_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Add Liquidity", - "... %s?", amt_a_str)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Max Deposit B", + (const uint8_t*)amount_in_max_b_str, + strlen(amount_in_max_b_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -404,10 +384,11 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, - "Confirm Share Out Amount", - "Receive %s GAMM-%" PRIu64 " shares?", outsoamt, - msg->lp_add.pool_id)) { + // shareOutAmount is the exact share count MsgJoinPool mints, not a floor, + // so this screen must not label it as a minimum. + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Confirm Share Out Amount", (const uint8_t*)outsoamt, + strlen(outsoamt))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -447,39 +428,45 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - char insoamt[33] = {0}; - uint8_t outsoamt[34] = {0}; - strlcpy(insoamt, msg->lp_remove.share_in_amount, - sizeof(msg->lp_remove.share_in_amount)); - - if (base_to_precision(outsoamt, (uint8_t*)insoamt, sizeof(outsoamt), - strlen(insoamt), OSMOSIS_LP_ASSET_PRECISION) < 0) { + char outsoamt[34] = {0}; + if (base_to_precision( + (uint8_t*)outsoamt, (const uint8_t*)msg->lp_remove.share_in_amount, + sizeof(outsoamt), strlen(msg->lp_remove.share_in_amount), + OSMOSIS_LP_ASSET_PRECISION) < 0) { osmosis_signAbort(); - fsm_sendFailure(FailureType_Failure_Other, NULL); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid LP share amount"); layoutHome(); return; } - const char* denom_out_min_b = msg->lp_remove.denom_out_min_b; - const char* denom_out_min_a = msg->lp_remove.denom_out_min_a; - char out_b_str[128]; - osmosis_format_amount(out_b_str, sizeof(out_b_str), - msg->lp_remove.amount_out_min_b, denom_out_min_b); - char out_a_str[128]; - osmosis_format_amount(out_a_str, sizeof(out_a_str), - msg->lp_remove.amount_out_min_a, denom_out_min_a); + char amount_out_min_b_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_out_min_b_str, sizeof(amount_out_min_b_str), + msg->lp_remove.amount_out_min_b, msg->lp_remove.denom_out_min_b)) { + return; + } + + char amount_out_min_a_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + amount_out_min_a_str, sizeof(amount_out_min_a_str), + msg->lp_remove.amount_out_min_a, msg->lp_remove.denom_out_min_a)) { + return; + } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Remove Liquidity", - "Withdraw %s and...", out_b_str)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum Output A", (const uint8_t*)amount_out_min_a_str, + strlen(amount_out_min_a_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Remove Liquidity", - "... %s ?", out_a_str)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum Output B", (const uint8_t*)amount_out_min_b_str, + strlen(amount_out_min_b_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -494,9 +481,9 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Pool share amount", - "Redeem %s GAMM-%" PRIu64 " shares?", outsoamt, - msg->lp_remove.pool_id)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "LP Shares to Redeem", (const uint8_t*)outsoamt, + strlen(outsoamt))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -535,13 +522,24 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - char redelegate_str[128]; - osmosis_format_amount(redelegate_str, sizeof(redelegate_str), - msg->redelegate.amount, msg->redelegate.denom); + if (strcmp(msg->redelegate.denom, "uosmo") != 0) { + osmosis_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Only uosmo is supported for Osmosis redelegation"); + layoutHome(); + return; + } + + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->redelegate.amount, + msg->redelegate.denom)) { + return; + } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Redelegate", - "Redelegate %s?", redelegate_str)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Redelegate", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -647,18 +645,27 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - const char* token_in_denom = msg->swap.token_in_denom; - const char* token_out_denom = msg->swap.token_out_denom; - char swap_in_str[128]; - osmosis_format_amount(swap_in_str, sizeof(swap_in_str), - msg->swap.token_in_amount, token_in_denom); - char swap_out_str[128]; - osmosis_format_amount(swap_out_str, sizeof(swap_out_str), - msg->swap.token_out_min_amount, token_out_denom); + char token_in_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(token_in_str, sizeof(token_in_str), + msg->swap.token_in_amount, + msg->swap.token_in_denom)) { + return; + } + + char token_out_min_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail( + token_out_min_str, sizeof(token_out_min_str), + msg->swap.token_out_min_amount, msg->swap.token_out_denom)) { + return; + } - /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "Swap", - "Swap %s for at least %s?", swap_in_str, swap_out_str)) { + // Each signed asset is paged independently so neither the input denom nor + // the minimum output can fall below the OLED's three visible body rows. + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Swap Input", + (const uint8_t*)token_in_str, strlen(token_in_str)) || + !confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Minimum Output", + (const uint8_t*)token_out_min_str, + strlen(token_out_min_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); @@ -716,14 +723,16 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { return; } - const char* denom = msg->ibc_transfer.denom; - char ibc_amount_str[128]; - osmosis_format_amount(ibc_amount_str, sizeof(ibc_amount_str), - msg->ibc_transfer.amount, denom); + char amount_str[OSMOSIS_AMOUNT_STR_LEN]; + if (!osmosis_formatAmountOrFail(amount_str, sizeof(amount_str), + msg->ibc_transfer.amount, + msg->ibc_transfer.denom)) { + return; + } /** Confirm transaction parameters on-screen */ - if (!confirm(ButtonRequestType_ButtonRequest_Other, "IBC Transfer", - "Transfer %s?", ibc_amount_str)) { + if (!confirm_bytes(ButtonRequestType_ButtonRequest_Other, "IBC Transfer", + (const uint8_t*)amount_str, strlen(amount_str))) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); diff --git a/lib/firmware/fsm_msg_ripple.h b/lib/firmware/fsm_msg_ripple.h index 3c8b59055..eada1a82d 100644 --- a/lib/firmware/fsm_msg_ripple.h +++ b/lib/firmware/fsm_msg_ripple.h @@ -31,13 +31,23 @@ void fsm_msgRippleGetAddress(const RippleGetAddress* msg) { const CoinType* coin = fsm_getCoin(true, "Ripple"); - if (!ripple_getAddress(node->public_key, resp->address)) { + /* ripple_getAddress() hands ripple_encode_check() a MAX_ADDR_SIZE (130 byte) + destination, but RippleAddress.address is capped at 36 by the proto + options. Encode into a correctly sized local and copy the result, so the + encoder's bound matches the buffer it is actually writing. Today a Ripple + address encodes to ~35 characters and happens to fit, but that is a + property of the input, not of the contract -- and it is a one byte margin. + gcc 14 rejects the direct call outright (-Werror=stringop-overflow); + gcc 10, which CI uses, does not. */ + char ripple_addr[MAX_ADDR_SIZE]; + if (!ripple_getAddress(node->public_key, ripple_addr)) { memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_Other, _("Address derivation failed")); layoutHome(); return; } + strlcpy(resp->address, ripple_addr, sizeof(resp->address)); resp->has_address = true; if (msg->has_show_display && msg->show_display) { @@ -148,6 +158,21 @@ void fsm_msgRippleSignTx(RippleSignTx* msg) { } } + if (msg->has_memo && msg->memo[0] != '\0') { + /* Page the COMPLETE memo (72-char ASCII / 40-byte hex pages) like every + * other memo surface. A single unpaged confirm renders only 3 OLED lines, + * silently drops the overflow, and honors embedded newlines — so a memo + * whose visible first line looks benign could carry ~180 signed-but-unseen + * bytes into the Memos field that exchanges and bridges use for deposit + * routing. */ + if (!thorchain_confirm_full_memo("Memo", msg->memo, strlen(msg->memo))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); + layoutHome(); + return; + } + } + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Transaction", "Really send %s, with a transaction fee of %s?", amount_string, fee_string)) { diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 83b80a034..b35d85e9b 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -459,6 +459,131 @@ static bool solana_signerInTx(const uint8_t* pubkey, const SolanaParsedTx* tx) { return false; } +typedef enum { + SOL_SCHEMA_REVIEW_NONE = 0, + SOL_SCHEMA_REVIEW_APPROVED, + SOL_SCHEMA_REVIEW_CANCELLED, +} SolanaSchemaReviewResult; + +/* Review an opaque instruction through a signed KKSOLSC1 descriptor. This is + * annotation only: invalid or inapplicable metadata produces no screens, and + * the caller still presents the ordinary blind-sign warning after an approved + * schema review. */ +static SolanaSchemaReviewResult solana_confirmAttestedSchema( + const SolanaSignTx* msg, const SolanaParsedTx* tx) { + if (!msg->has_schema_payload || msg->schema_payload.size == 0 || + !msg->has_schema_signature || msg->schema_signature.size != 64 || + !msg->has_schema_signer_key_id || + msg->schema_signer_key_id >= METADATA_MAX_KEYS) { + return SOL_SCHEMA_REVIEW_NONE; + } + + const uint8_t key_id = (uint8_t)msg->schema_signer_key_id; + if (!signed_metadata_verify_attestation( + key_id, msg->schema_payload.bytes, msg->schema_payload.size, + msg->schema_signature.bytes, msg->schema_signature.size)) { + return SOL_SCHEMA_REVIEW_NONE; + } + + SolanaInstrSchema schema; + uint8_t instruction_index = 0; + if (!solana_parseInstrSchema(msg->schema_payload.bytes, + msg->schema_payload.size, &schema) || + !solana_schemaApplies(&schema, tx, &instruction_index)) { + memzero(&schema, sizeof(schema)); + return SOL_SCHEMA_REVIEW_NONE; + } + + const SolanaParsedInstruction* ix = &tx->instructions[instruction_index]; + for (uint8_t i = 0; i < schema.num_accounts; i++) { + const uint8_t instruction_account = schema.accounts[i].index; + if (!ix->acct_indices || instruction_account >= ix->num_acct_indices || + ix->acct_indices[instruction_account] >= tx->num_accounts) { + memzero(&schema, sizeof(schema)); + return SOL_SCHEMA_REVIEW_NONE; + } + } + + char fingerprint[METADATA_FINGERPRINT_LEN]; + if (!signed_metadata_signer_fingerprint(key_id, fingerprint)) { + fingerprint[0] = '\0'; + } + const char* alias = signed_metadata_signer_alias(key_id); + bool approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Schema Source", + "%s (%s) describes this instruction.\nNOT verified by KeepKey.", + alias ? alias : "Unknown signer", fingerprint); + + if (approved) { + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Instruction", + "%s\n%s", schema.program_name, schema.instruction_name); + } + + char program_id[45]; + solana_pubkeyToStr(schema.program_id, program_id, sizeof(program_id)); + if (approved) { + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Program ID", "%s", program_id); + } + + char discriminator[2 * SOL_SCHEMA_DISC_MAX + 1] = {0}; + for (uint8_t i = 0; i < schema.disc_len; i++) { + snprintf(discriminator + 2 * i, sizeof(discriminator) - 2 * i, "%02x", + schema.disc[i]); + } + if (approved) { + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Discriminator", "%s", discriminator); + } + + const uint8_t* arg = ix->data + schema.disc_len; + for (uint8_t i = 0; approved && i < schema.num_args; i++) { + switch (schema.args[i].type) { + case SOL_SCHEMA_ARG_U64: { + uint64_t value = 0; + for (uint8_t j = 0; j < 8; j++) { + value |= ((uint64_t)arg[j]) << (8 * j); + } + approved = + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + schema.args[i].label, "%llu", (unsigned long long)value); + arg += 8; + break; + } + case SOL_SCHEMA_ARG_U8: + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + schema.args[i].label, "%u", (unsigned)*arg); + arg++; + break; + case SOL_SCHEMA_ARG_PUBKEY: { + char pubkey[45]; + solana_pubkeyToStr(arg, pubkey, sizeof(pubkey)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + schema.args[i].label, "%s", pubkey); + arg += SOL_PUBKEY_SIZE; + break; + } + case SOL_SCHEMA_ARG_OPAQUE32: + approved = confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, + schema.args[i].label, arg, 32); + arg += 32; + break; + } + } + + for (uint8_t i = 0; approved && i < schema.num_accounts; i++) { + const uint8_t account_index = ix->acct_indices[schema.accounts[i].index]; + char account[45]; + solana_pubkeyToStr(tx->accounts[account_index], account, sizeof(account)); + approved = confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + schema.accounts[i].label, "%s", account); + } + + memzero(&schema, sizeof(schema)); + return approved ? SOL_SCHEMA_REVIEW_APPROVED : SOL_SCHEMA_REVIEW_CANCELLED; +} + void fsm_msgSolanaGetAddress(const SolanaGetAddress* msg) { RESP_INIT(SolanaAddress); @@ -600,6 +725,91 @@ void fsm_msgSolanaSignTx(const SolanaSignTx* msg) { return; } + const SolanaSchemaReviewResult schema_review = + solana_confirmAttestedSchema(msg, &parsed); + if (schema_review == SOL_SCHEMA_REVIEW_CANCELLED) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + + /* KKSOLSW1: a provider may attest the accounts this message resolves + through a lookup table -- the ones the device cannot derive, and the + reason it went opaque at all. Showing them turns a blind sign into a + described one. + + Strictly additive, and deliberately BEFORE the blind-sign warning rather + than instead of it: a runtime signer is annotation, never authority, so + the user still sees "the device cannot fully verify the contents" and + still has to approve it. If the attestation is absent, malformed, or + fails to verify, nothing extra is drawn and the flow is byte-for-byte + what it was. */ + /* nanopb gives each repeated `bytes` element as a {size, bytes[32]} + struct, NOT a bare 32-byte array -- casting the array to + (uint8_t(*)[32]) would hash the size word plus 28 bytes of the first + key. Flatten explicitly, and require every element to be a full + SOL_PUBKEY_SIZE key so a short one cannot silently hash as zero-padded. + */ + uint8_t lut_keys[SOL_MAX_LUT_ACCOUNTS][SOL_PUBKEY_SIZE]; + size_t lut_n = 0; + bool lut_well_formed = msg->lut_account_count > 0 && + msg->lut_account_count <= SOL_MAX_LUT_ACCOUNTS; + for (size_t li = 0; lut_well_formed && li < msg->lut_account_count; li++) { + if (msg->lut_account[li].size != SOL_PUBKEY_SIZE) { + lut_well_formed = false; + break; + } + memcpy(lut_keys[lut_n++], msg->lut_account[li].bytes, SOL_PUBKEY_SIZE); + } + + if (lut_well_formed && msg->has_lut_signature && + msg->has_lut_signer_key_id && + solana_lut_accounts_trusted( + msg->raw_tx.bytes, msg->raw_tx.size, + (const uint8_t (*)[32])lut_keys, lut_n, msg->lut_signer_key_id, + msg->lut_signature.bytes, msg->lut_signature.size)) { + char fp[METADATA_FINGERPRINT_LEN]; + const char* alias = + signed_metadata_signer_alias((uint8_t)msg->lut_signer_key_id); + if (!signed_metadata_signer_fingerprint((uint8_t)msg->lut_signer_key_id, + fp)) { + fp[0] = '\0'; + } + /* Name WHO is describing these accounts before showing what they say. + The user is being asked to trust a third party, and the tier never + claims KeepKey verified it. */ + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Lookup Accounts", + "%s (%s) describes %u account(s).\nNOT verified by KeepKey.", + alias ? alias : "Unknown signer", fp, + (unsigned)msg->lut_account_count)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + for (size_t li = 0; li < lut_n; li++) { + char b58[64]; + size_t b58_len = sizeof(b58); + if (!solana_base58_encode(lut_keys[li], SOL_PUBKEY_SIZE, b58, + &b58_len)) { + continue; + } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Lookup Account", "%u/%u\n%s", (unsigned)(li + 1), + (unsigned)lut_n, b58)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } + } + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Blind Sign", "Sign unverified Solana transaction? " "The device cannot fully verify the contents.")) { diff --git a/lib/firmware/fsm_msg_thorchain.h b/lib/firmware/fsm_msg_thorchain.h index ee4667a25..7e45c3063 100644 --- a/lib/firmware/fsm_msg_thorchain.h +++ b/lib/firmware/fsm_msg_thorchain.h @@ -1,3 +1,4 @@ + void fsm_msgThorchainGetAddress(const ThorchainGetAddress* msg) { RESP_INIT(ThorchainAddress); @@ -79,20 +80,17 @@ void fsm_msgThorchainGetAddress(const ThorchainGetAddress* msg) { void fsm_msgThorchainSignTx(const ThorchainSignTx* msg) { CHECK_INITIALIZED + CHECK_PIN if (!msg->has_account_number || !msg->has_chain_id || !msg->has_fee_amount || - !msg->has_gas || !msg->has_sequence || !msg->has_msg_count || - msg->msg_count == 0 || !tendermint_validateSafeText(msg->chain_id)) { + !msg->has_gas || !msg->has_sequence) { thorchain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, - "Missing or Invalid Fields On Message"); + "Missing Fields On Message"); layoutHome(); return; } - /* Reject malformed envelopes before authentication or key derivation. */ - CHECK_PIN - HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, msg->address_n_count, NULL); if (!node) { @@ -121,6 +119,13 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { // Confirm transaction basics // supports only 1 message ack CHECK_PARAM(thorchain_signingIsInited(), "Signing not in progress"); + if (msg->has_send == msg->has_deposit) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Expected exactly one THORChain message")); + layoutHome(); + return; + } if (msg->has_send && msg->send.has_to_address && msg->send.has_amount) { // pass } else if (msg->has_deposit && msg->deposit.has_asset && @@ -143,15 +148,28 @@ 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]; - if (!thorchain_formatAmount(msg->send.amount, "RUNE", 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_SyntaxError, - "Invalid THORChain send amount"); + fsm_sendFailure(FailureType_Failure_FirmwareError, + _("Failed to format amount")); layoutHome(); return; } @@ -179,12 +197,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"); @@ -222,7 +248,7 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { amount_str, sizeof(amount_str))) { thorchain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, - "Invalid THORChain deposit amount"); + "Invalid or undisplayable deposit amount"); layoutHome(); return; } @@ -285,18 +311,9 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { return; } - /* Review the OUTER transaction memo whenever it is present -- including when - * the deposit carries one of its own. - * - * These are two different strings in the signed document, not one superseding - * the other: thorchain_signTxInit() hashes sign_tx->memo into the StdSignDoc - * "memo" field, and the MsgDeposit value below hashes deposit.memo - * separately. Skipping this review when deposit.has_memo let a host show a - * benign deposit memo while a different outer memo was signed unseen -- the - * exact thing this release line exists to prevent. Both are signed, so both - * are shown. */ if (sign_tx->has_memo) { - // See if we can parse the tx memo. + // See if we can parse the tx memo. The transaction and deposit memos are + // distinct signed fields, so both are reviewed when both are present. /* strnlen, not sizeof -- see the deposit path above. */ ThorchainMemoResult memo_result = thorchain_parseConfirmMemo( sign_tx->memo, strnlen(sign_tx->memo, sizeof(sign_tx->memo))); @@ -331,8 +348,9 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_SignTx, node_str, - "Sign RUNE on %s? Fee: %" PRIu32 " rune. Gas: %" PRIu32 ".", - sign_tx->chain_id, sign_tx->fee_amount, sign_tx->gas)) { + "Sign this RUNE transaction on %s? " + "Additional network fees apply.", + sign_tx->chain_id)) { thorchain_signAbort(); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); diff --git a/lib/firmware/fsm_msg_ton.h b/lib/firmware/fsm_msg_ton.h index 9f9a727df..387943930 100644 --- a/lib/firmware/fsm_msg_ton.h +++ b/lib/firmware/fsm_msg_ton.h @@ -101,9 +101,13 @@ void fsm_msgTonSignTx(TonSignTx* msg) { /* AdvancedMode gate: to_address, amount and memo are display-only fields * that are NOT derived from or checked against raw_tx, so this handler can - * only ever blind-sign opaque bytes. Same fence as fsm_msgTonSignMessage - * below, until the displayed fields are parsed out of raw_tx and verified - * against the bytes that actually get signed. */ + * only ever blind-sign opaque bytes. Same fence as Solana/TRON opaque + * transaction signing, until the displayed fields are parsed out of raw_tx + * and verified against the bytes that actually get signed. + * + * Note this gate is NOT mirrored on fsm_msgTonSignMessage below: that path + * discloses every signed byte via confirm_bytes(), which is the stronger + * guarantee this gate is only standing in for. */ if (!storage_isPolicyEnabled("AdvancedMode")) { (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", "TON transaction signing is blind-only. " @@ -127,7 +131,7 @@ void fsm_msgTonSignTx(TonSignTx* msg) { return; } - /* Never render to_address/amount here: they are unbound to the signed + /* Never render to_address/amount/memo here: they are unbound to the signed * bytes, so a hostile host can show one recipient on the OLED and get a * completely different transaction signed. Name only what the device can * actually verify -- how many bytes it is about to sign. */ @@ -193,13 +197,11 @@ void fsm_msgTonSignMessage(const TonSignMessage* msg) { if (!node) return; hdnode_fill_public_key(node); - /* Bind consent to the raw signing scheme, then page every signed byte. - * A prefix-plus-length preview lets equal-length payloads with the same first - * 32 bytes produce identical approval screens and different signatures. */ - if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, "TON Message", - "Format: raw Ed25519. Version: none. Domain: none.") || - !confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, "Raw Message", - msg->message.bytes, msg->message.size)) { + /* AdvancedMode permits the opaque primitive, but never permits a hidden + * suffix: review every signed byte using renderer-measured pages. */ + if (!confirm_bytes(ButtonRequestType_ButtonRequest_ProtectCall, + _("Sign TON Message"), msg->message.bytes, + msg->message.size)) { memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Signing cancelled")); diff --git a/lib/firmware/fsm_msg_tron.h b/lib/firmware/fsm_msg_tron.h index 142ebf6e2..532002fd9 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -102,32 +102,124 @@ void fsm_msgTronSignTx(TronSignTx* msg) { return; } - /* The signature covers raw_data and nothing else (tron.c: sha256_Raw over + /* Clear-sign from raw_data itself — the exact bytes being signed. + * + * The signature covers raw_data and nothing else (tron.c: sha256_Raw over * msg->raw_data, then ecdsa_sign_digest). The proto's to_address/amount * fields are a host-supplied side channel that is never hashed, so the old * "Send %s TRX to %s?" screen asserted a destination and an amount the * device had no way to vouch for: a host could display one payee and get a * signature over a transfer to another, and a host that simply omitted both - * optional fields suppressed the screen altogether. This firmware has no - * TRON protobuf parser, so every TronSignTx is a blind signature. Disclose - * that instead of displaying unbound data, behind the same AdvancedMode - * policy used for opaque Solana transactions and unknown-data ETH calls. */ - if (!storage_isPolicyEnabled("AdvancedMode")) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_Other, - _("Enable AdvancedMode to blind-sign")); - layoutHome(); - return; - } + * optional fields suppressed the screen altogether. + * + * Everything shown below is therefore decoded from raw_data by + * tron_parseRawTx(), which is fail-closed: any payload it does not fully + * understand classifies as TRON_TX_UNVERIFIED and can only be signed blind, + * behind the same AdvancedMode policy used for opaque Solana transactions + * and unknown-data ETH calls. + * + * The gate covers exactly that branch. A parsed transfer discloses strictly + * more than the blind screen ever could — owner-bound, with the real payee + * and amount — so gating it would buy no safety, and AdvancedMode is session + * state that resets on every power cycle (see include/keepkey/firmware/ + * policy.h), which would leave a plain TRX send broken on a default device + * after every replug. Same reasoning as the ETH message-signing fence. */ + 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; + } - if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Blind Sign", - "Sign unverified %u-byte TRON transaction? Amount and " - "destination unknown.", - (unsigned)msg->raw_data.size)) { - memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); - layoutHome(); - return; + /* Name what is unknown, rather than just the byte count. Formatted by + * confirm() directly: the full sentence does not fit the fixed-size + * intermediate buffer this used to build, and a truncated disclosure is + * worse than none. */ + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Blind Sign", + "Sign unverified %u-byte TRON transaction? Amount and " + "destination unknown.", + (unsigned)msg->raw_data.size)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); + layoutHome(); + return; + } + } else { + /* The parsed owner account is the one spending — it must be ours. */ + char derived_addr[TRON_ADDRESS_MAX_LEN]; + char owner_addr[TRON_ADDRESS_MAX_LEN]; + if (!tron_getAddress(node->public_key, derived_addr, + sizeof(derived_addr)) || + !tron_addressFromBytes(parsed.owner, owner_addr, sizeof(owner_addr)) || + strcmp(derived_addr, owner_addr) != 0) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, + _("TX owner does not match derived key")); + layoutHome(); + return; + } + + char to_str[TRON_ADDRESS_MAX_LEN]; + if (!tron_addressFromBytes(parsed.to, to_str, sizeof(to_str))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, _("Address encoding failed")); + layoutHome(); + return; + } + + bool confirmed = false; + if (tx_type == TRON_TX_TRANSFER) { + char amount_str[32]; + tron_formatAmount(amount_str, sizeof(amount_str), parsed.amount); + confirmed = confirm(ButtonRequestType_ButtonRequest_SignTx, "TRON", + "Send %s to %s?", amount_str, to_str); + } else { /* TRON_TX_TRC20_TRANSFER */ + char contract_str[TRON_ADDRESS_MAX_LEN]; + char amount_str[90]; + confirmed = + tron_addressFromBytes(parsed.contract, contract_str, + sizeof(contract_str)) && + tron_formatTrc20Amount(parsed.trc20_amount, amount_str, + sizeof(amount_str)) && + confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "TRC-20 Transfer", "Token contract %s", contract_str) && + /* Token decimals are not known on-device; show base units. */ + confirm(ButtonRequestType_ButtonRequest_SignTx, "TRC-20 Transfer", + "Send %s base units to %s?", amount_str, to_str); + } + + 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) { + /* Page the COMPLETE memo (72-char ASCII / 40-byte hex pages) like every + * other memo surface. The old single-screen path showed up to 114 chars + * unpaged, but 3 OLED lines only guarantee ~84 chars with wide glyphs — + * an 85..114-char memo could have its signed tail (affiliate bps, + * destination tail) silently clipped. The pager also discloses + * non-printable memos as complete hex instead of a byte-count summary. */ + confirmed = thorchain_confirm_full_memo("Memo", (const char*)parsed.memo, + parsed.memo_len); + } + + if (!confirmed) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, "Signing cancelled"); + layoutHome(); + return; + } } if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Transaction", @@ -182,8 +274,10 @@ void fsm_msgTronSignMessage(TronSignMessage* msg) { * byte. * * Note this is NOT the same call as the TRON SignTx fence (#405), which - * stays: a TRON *transaction* still cannot be parsed or bound on this line, - * so it remains genuinely blind and keeps its AdvancedMode gate. */ + * stays: a TronSignTx payload that tron_parseRawTx() cannot fully decode is + * still genuinely blind, and that TRON_TX_UNVERIFIED branch keeps its + * AdvancedMode gate. Payloads the parser does decode are bound to raw_data + * and disclosed, so only the undecodable ones are fenced. */ // Validate path: m/44'/195'/... if (msg->address_n_count < 3 || msg->address_n[0] != (0x80000000 | 44) || @@ -270,15 +364,25 @@ void fsm_msgTronSignTypedHash(const TronSignTypedHash* msg) { return; } + /* Blind-sign gate: the device only receives pre-computed hashes — it cannot + * reconstruct or verify the original typed-data struct. Require the same + * AdvancedMode policy as TronSignTx blind-signing so this message type + * can't be used to route around the kill-switch. Checked here, before any + * key derivation, and explained on screen rather than failing silently. */ if (!tron_typed_hash_policy_allows(storage_isPolicyEnabled("AdvancedMode"))) { + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "TIP-712 blind signing is disabled. " + "Enable AdvancedMode in device settings."); fsm_sendFailure(FailureType_Failure_Other, _("Enable AdvancedMode to blind-sign typed hashes")); layoutHome(); return; } + /* The user must explicitly acknowledge blind signing before the hashes. */ if (!confirm(ButtonRequestType_ButtonRequest_Other, "TIP-712 Blind Sign", - "Cannot verify these hashes. Trust the host?")) { + "Device cannot verify typed-data contents. " + "Only proceed if you trust the host application.")) { fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; diff --git a/lib/firmware/fsm_msg_zcash.h b/lib/firmware/fsm_msg_zcash.h new file mode 100644 index 000000000..75598c412 --- /dev/null +++ b/lib/firmware/fsm_msg_zcash.h @@ -0,0 +1,1671 @@ +/* + * 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}; + +/* ZIP-229 v6 adds/changes the Orchard-protocol component + * personalizations. Sapling's top-level personalization is unchanged from + * v5, so the empty Sapling component continues to use the value above. */ +static const uint8_t EMPTY_ORCHARD_DIGEST_V6[32] = { + 0xa3, 0x36, 0x7d, 0x2f, 0xde, 0xa2, 0x91, 0x01, 0x59, 0xfc, 0x50, + 0x26, 0xe9, 0xbf, 0x1f, 0xcc, 0xd3, 0xe2, 0x8c, 0xe5, 0xe6, 0xde, + 0x46, 0xbf, 0xb7, 0x15, 0x87, 0x23, 0x0e, 0xea, 0x95, 0x15}; + +static const uint8_t EMPTY_IRONWOOD_DIGEST_V6[32] = { + 0xb9, 0xcf, 0xe6, 0x43, 0xce, 0x45, 0xb2, 0x8c, 0x33, 0x19, 0x0f, + 0x0d, 0x52, 0x23, 0xe4, 0x75, 0x97, 0x2f, 0x2a, 0x14, 0x9d, 0xc5, + 0x44, 0x04, 0xfd, 0x83, 0x65, 0x52, 0x1f, 0x84, 0x16, 0xc5}; + +#define ZCASH_MAX_ACTIONS 16 +#define ZCASH_MAX_TRANSPARENT_INPUTS 8 +#define ZCASH_MAX_TRANSPARENT_OUTPUTS 8 +#define ZCASH_MAX_TRANSPARENT_SCRIPT_PUBKEY 128 + +typedef struct { + bool received; + uint8_t prevout_txid[32]; + uint32_t prevout_index; + uint32_t sequence; + uint64_t amount; + uint8_t script_pubkey[ZCASH_MAX_TRANSPARENT_SCRIPT_PUBKEY]; + size_t script_pubkey_size; + uint32_t address_n[8]; + uint32_t address_n_count; +} ZcashTransparentInputState; + +typedef struct { + bool received; + uint64_t amount; + uint8_t script_pubkey[ZCASH_MAX_TRANSPARENT_SCRIPT_PUBKEY]; + size_t script_pubkey_size; +} ZcashTransparentOutputState; + +/* Zcash shielded signing state */ +static struct { + bool active; + uint32_t account; + uint32_t n_actions; + uint32_t current_action; + uint64_t total_amount; + uint64_t fee; + uint32_t branch_id; + ZcashOrchardKeys keys; + uint8_t header_digest[32]; + uint8_t sighash[32]; + bool transaction_v6; + bool is_ironwood; + uint8_t orchard_component_digest[32]; + uint8_t ironwood_component_digest[32]; + /* Phase 2a: on-device sighash computation */ + bool has_device_sighash; + /* Phase 2b: incremental orchard digest verification */ + bool verify_orchard_digest; + uint8_t expected_orchard_digest[32]; + BLAKE2B_CTX compact_ctx; + BLAKE2B_CTX memos_ctx; + BLAKE2B_CTX noncompact_ctx; + uint8_t orchard_flags; + int64_t orchard_value_balance; + uint8_t orchard_anchor[32]; + /* Compact signatures buffer: one 64-byte sig per real Orchard spend. + * Dummy spends are signed by the PCZT finalizer and must not be signed with + * the device's Orchard key. */ + uint8_t signatures[ZCASH_MAX_ACTIONS][64]; + uint32_t signature_count; + /* Phase 3: transparent shielding state */ + bool has_expected_transparent_digest; + uint8_t expected_transparent_digest[32]; + bool transparent_digest_verified; + uint32_t n_transparent_outputs; + uint32_t current_transparent_output; + uint32_t n_transparent_inputs; + uint32_t current_transparent_input; + ZcashTransparentOutputState + transparent_outputs[ZCASH_MAX_TRANSPARENT_OUTPUTS]; + ZcashTransparentInputState transparent_inputs[ZCASH_MAX_TRANSPARENT_INPUTS]; + /* Deferred transparent ECDSA sigs — buffered until Orchard/fee final gate */ + bool has_pending_transparent; + ZcashTransparentSigned pending_transparent; +} zcash_signing; + +/* Public API; declared in keepkey/firmware/zcash.h. */ +void zcash_signing_abort(void) { + /* Centralized cleanup: stop the trickle progress animation here so every + * abort path (Cancel, ClearSession, failures) kills it even when the caller + * does not go through layoutHome()/layout_clear_animations(). */ + layoutProgressTrickleStop(); + memzero(&zcash_signing, sizeof(zcash_signing)); +} + +static bool zcash_script_is_p2pkh(const uint8_t* script, size_t script_size) { + return script && script_size == 25 && script[0] == 0x76 && + script[1] == 0xa9 && script[2] == 0x14 && script[23] == 0x88 && + script[24] == 0xac; +} + +static bool zcash_script_is_p2sh(const uint8_t* script, size_t script_size) { + return script && script_size == 23 && script[0] == 0xa9 && + script[1] == 0x14 && script[22] == 0x87; +} + +static bool zcash_script_is_standard_transparent(const uint8_t* script, + size_t script_size) { + return zcash_script_is_p2pkh(script, script_size) || + zcash_script_is_p2sh(script, script_size); +} + +static bool zcash_transparent_script_to_address(const uint8_t* script, + size_t script_size, char* out, + size_t out_size) { + if (!script || !out || out_size == 0) return false; + + const CoinType* coin = fsm_getCoin(true, "Zcash"); + if (!coin) return false; + + uint32_t address_type; + const uint8_t* hash160; + if (zcash_script_is_p2pkh(script, script_size)) { + if (!coin->has_address_type) return false; + address_type = coin->address_type; + hash160 = script + 3; + } else if (zcash_script_is_p2sh(script, script_size)) { + if (!coin->has_address_type_p2sh) return false; + address_type = coin->address_type_p2sh; + hash160 = script + 2; + } else { + return false; + } + + uint8_t raw[4 + 20] = {0}; + size_t prefix_len = address_prefix_bytes_len(address_type); + if (prefix_len == 0 || prefix_len + 20 > sizeof(raw)) return false; + address_write_prefix_bytes(address_type, raw); + memcpy(raw + prefix_len, hash160, 20); + return base58_encode_check(raw, (int)(prefix_len + 20), HASHER_SHA2D, out, + (int)out_size) != 0; +} + +static void zcash_format_amount(uint64_t amount, char* out, size_t out_size) { + snprintf(out, out_size, "%llu.%08llu ZEC", + (unsigned long long)(amount / 100000000ULL), + (unsigned long long)(amount % 100000000ULL)); +} + +/* Determine account — require explicit account or strict ZIP-32 path + * m/32'/133'/account' (all hardened, exactly 3 elements). Shared by + * ZcashSignPCZT / ZcashGetOrchardFVK / ZcashDisplayAddress so a malformed + * host path cannot silently resolve to an unintended account. */ +static bool zcash_resolve_account(bool has_account, uint32_t account_field, + const uint32_t* address_n, + uint32_t address_n_count, + uint32_t* account_out) { + if (has_account) { + *account_out = account_field; + return true; + } + if (address_n_count == 3 && address_n[0] == (0x80000000 | 32) && + address_n[1] == (0x80000000 | 133) && (address_n[2] & 0x80000000)) { + *account_out = address_n[2] & 0x7FFFFFFF; + return true; + } + fsm_sendFailure( + FailureType_Failure_SyntaxError, + _("Require account field or ZIP-32 path m/32'/133'/account'")); + return false; +} + +/* Optional seed_fingerprint binding (ZIP-32 §6.1). If the host asserts a + * seed identity, verify it matches this device's seed before proceeding. + * Catches "wrong device" attacks where the host accidentally targets a + * different seed than the one it built the request against. */ +static bool zcash_check_seed_fingerprint(bool has_expected, + const uint8_t* expected, + size_t expected_size) { + if (!zcash_seed_fingerprint_request_valid(has_expected, expected_size)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Seed fingerprint must be 32 bytes")); + return false; + } + if (!has_expected) return true; + + uint8_t actual_fp[32]; + if (!storage_zcashSeedFingerprint(true, actual_fp)) { + fsm_sendFailure(FailureType_Failure_NotInitialized, + _("Device not initialized or seed unavailable")); + return false; + } + bool match = memcmp(actual_fp, expected, 32) == 0; + memzero(actual_fp, sizeof(actual_fp)); + if (!match) { + fsm_sendFailure(FailureType_Failure_Other, + _("Seed fingerprint mismatch — wrong device")); + return false; + } + return true; +} + +static bool zcash_verify_and_confirm_orchard_output( + const ZcashPCZTAction* msg, ZcashOrchardProgressCallback progress, + void* progress_context) { + if (!msg->has_value || !msg->has_recipient || + msg->recipient.size != ZCASH_ORCHARD_RAW_RECEIVER_SIZE || + !msg->has_rseed || msg->rseed.size != 32) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing Orchard output metadata")); + return false; + } + + uint8_t computed_cmx[32]; + bool cmx_ok = + zcash_signing.is_ironwood + ? zcash_ironwood_compute_cmx_with_progress( + msg->recipient.bytes, msg->value, msg->nullifier.bytes, + msg->rseed.bytes, computed_cmx, progress, progress_context) + : zcash_orchard_compute_cmx_with_progress( + msg->recipient.bytes, msg->value, msg->nullifier.bytes, + msg->rseed.bytes, computed_cmx, progress, progress_context); + if (!cmx_ok || memcmp(computed_cmx, msg->cmx.bytes, 32) != 0) { + memzero(computed_cmx, sizeof(computed_cmx)); + fsm_sendFailure(FailureType_Failure_Other, + _("Shielded note commitment mismatch")); + return false; + } + memzero(computed_cmx, sizeof(computed_cmx)); + + char address[ZCASH_ORCHARD_UNIFIED_ADDRESS_SIZE]; + if (!zcash_orchard_receiver_to_unified_address(msg->recipient.bytes, "u", + address, sizeof(address))) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Orchard recipient")); + return false; + } + + char amount_str[32]; + zcash_format_amount(msg->value, amount_str, sizeof(amount_str)); + + /* Two screens, deliberately. + * + * A unified address is 106 characters, which is three full body rows on its + * own -- exactly what layout_zcash_address_text_notification is built to + * render, and what the display-address flow already shows. The standard + * notification body is three rows and draw_string simply stops emitting + * once a character will not fit: there is no scroll and no pagination, so + * surplus text is dropped without any indication. + * + * Putting the question, the address and the amount in one body therefore + * rendered the question plus the first 76 characters of the address and + * silently discarded the rest of it along with the entire amount line. + * That is not a cosmetic screen: total_amount on the summary prompt is + * taken from the host message, and the contract documented in + * zcash_pczt_sign() delegates verification of Orchard output values to this + * confirm -- so dropping the amount removed the only place the user could + * see the value being committed to. + * + * Amount first, on a body that cannot overflow, then the full address + * through the layout that fits it. + * + * test_msg_zcash_sign_pczt_device.py asserts both screens are emitted; it + * fails with "expected 2 ConfirmOutput screens, got 1" against the packed + * single-screen version. */ + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Zcash Output", + "Send shielded ZEC?\nAmount: %s", amount_str)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + memzero(address, sizeof(address)); + return false; + } + + if (!confirm_with_custom_layout(&layout_zcash_address_text_notification, + ButtonRequestType_ButtonRequest_ConfirmOutput, + "Shielded recipient", "%s", address)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + memzero(address, sizeof(address)); + return false; + } + + memzero(address, sizeof(address)); + return true; +} + +static bool zcash_compute_verified_fee(uint64_t* fee_out) { + if (!fee_out) return false; + + int64_t net_transparent = 0; + for (uint32_t i = 0; i < zcash_signing.n_transparent_inputs; i++) { + const uint64_t amount = zcash_signing.transparent_inputs[i].amount; + if (amount > (uint64_t)INT64_MAX || + net_transparent > INT64_MAX - (int64_t)amount) { + return false; + } + net_transparent += (int64_t)amount; + } + + for (uint32_t i = 0; i < zcash_signing.n_transparent_outputs; i++) { + const uint64_t amount = zcash_signing.transparent_outputs[i].amount; + if (amount > (uint64_t)INT64_MAX || + net_transparent < INT64_MIN + (int64_t)amount) { + return false; + } + net_transparent -= (int64_t)amount; + } + + const int64_t value_balance = zcash_signing.orchard_value_balance; + if ((value_balance > 0 && net_transparent > INT64_MAX - value_balance) || + (value_balance < 0 && net_transparent < INT64_MIN - value_balance)) { + return false; + } + + const int64_t signed_fee = net_transparent + value_balance; + if (signed_fee < 0) return false; + + *fee_out = (uint64_t)signed_fee; + return true; +} + +static bool zcash_verify_and_confirm_fee(void) { + uint64_t verified_fee = 0; + if (!zcash_compute_verified_fee(&verified_fee)) { + fsm_sendFailure(FailureType_Failure_Other, _("Invalid transaction fee")); + return false; + } + + if (verified_fee != zcash_signing.fee) { + fsm_sendFailure(FailureType_Failure_Other, _("Fee mismatch")); + return false; + } + + char fee_str[32]; + zcash_format_amount(verified_fee, fee_str, sizeof(fee_str)); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Zcash Fee", + "Confirm transaction fee?\n%s", fee_str)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + return false; + } + + return true; +} + +typedef struct { + uint32_t base; + uint32_t span; + uint32_t last; +} ZcashActionProgress; + +static void zcash_action_progress(uint32_t completed, uint32_t total, + void* context) { + ZcashActionProgress* progress = (ZcashActionProgress*)context; + if (!progress || total == 0) return; + + /* completed/total is a public loop schedule: either the fixed scalar round + * count or the fixed-size PCZT note-commitment word count. It never depends + * on ask, the nonce, or a secret message bit. Update only when the visible + * permil changes to avoid redundant OLED transfers while preserving a smooth + * bar. */ + uint32_t permil = progress->base + (progress->span * completed) / total; + if (permil != progress->last) { + progress->last = permil; + layoutProgress(_("Signing Zcash"), (int)permil); + } +} + +static void zcash_send_action_ack(uint32_t next_index) { + ZcashPCZTActionAck* resp_ack = (ZcashPCZTActionAck*)msg_resp; + memset(resp_ack, 0, sizeof(ZcashPCZTActionAck)); + resp_ack->has_next_index = true; + resp_ack->next_index = next_index; + msg_write(MessageType_MessageType_ZcashPCZTActionAck, resp_ack); + + /* The device now blocks until the host generates the (slow) Orchard proof for + * this action. Ease the progress bar from the milestone already reached + * toward the one this action will complete, so the screen keeps moving + * instead of looking stuck at a frozen value. Stopped again when the action + * arrives. */ + uint32_t n = zcash_signing.n_actions; + if (n > 0) { + int base = (int)((next_index * 1000) / n); + int target = (int)(((next_index + 1) * 1000) / n); + layoutProgressTrickle(_("Signing Zcash"), base, target); + } +} + +static void zcash_send_transparent_output_ack(uint32_t next_index) { + ZcashTransparentAck* resp = (ZcashTransparentAck*)msg_resp; + memset(resp, 0, sizeof(ZcashTransparentAck)); + resp->has_next_output_index = true; + resp->next_output_index = next_index; + msg_write(MessageType_MessageType_ZcashTransparentAck, resp); +} + +static void zcash_send_transparent_input_ack(uint32_t next_index) { + ZcashTransparentAck* resp = (ZcashTransparentAck*)msg_resp; + memset(resp, 0, sizeof(ZcashTransparentAck)); + resp->has_next_input_index = true; + resp->next_input_index = next_index; + msg_write(MessageType_MessageType_ZcashTransparentAck, resp); +} + +static bool zcash_build_transparent_digest_info( + ZcashTransparentInputDigestInfo inputs[ZCASH_MAX_TRANSPARENT_INPUTS], + ZcashTransparentOutputDigestInfo outputs[ZCASH_MAX_TRANSPARENT_OUTPUTS]) { + for (uint32_t i = 0; i < zcash_signing.n_transparent_inputs; i++) { + const ZcashTransparentInputState* stored = + &zcash_signing.transparent_inputs[i]; + if (!stored->received) return false; + inputs[i].prevout_txid = stored->prevout_txid; + inputs[i].prevout_index = stored->prevout_index; + inputs[i].sequence = stored->sequence; + inputs[i].value = stored->amount; + inputs[i].script_pubkey = stored->script_pubkey; + inputs[i].script_pubkey_size = stored->script_pubkey_size; + } + + for (uint32_t i = 0; i < zcash_signing.n_transparent_outputs; i++) { + const ZcashTransparentOutputState* stored = + &zcash_signing.transparent_outputs[i]; + if (!stored->received) return false; + outputs[i].value = stored->amount; + outputs[i].script_pubkey = stored->script_pubkey; + outputs[i].script_pubkey_size = stored->script_pubkey_size; + } + + return true; +} + +static bool zcash_compute_active_sighash(const uint8_t transparent_digest[32], + uint8_t sighash[32]) { + if (zcash_signing.transaction_v6) { + return zcash_compute_v6_shielded_sighash( + zcash_signing.header_digest, transparent_digest, EMPTY_SAPLING_DIGEST, + zcash_signing.orchard_component_digest, + zcash_signing.ironwood_component_digest, zcash_signing.branch_id, + sighash); + } + return zcash_compute_shielded_sighash( + zcash_signing.header_digest, transparent_digest, EMPTY_SAPLING_DIGEST, + zcash_signing.orchard_component_digest, zcash_signing.branch_id, sighash); +} + +static bool zcash_finalize_transparent_digest(void) { + if (!zcash_signing.has_expected_transparent_digest) return false; + + ZcashTransparentInputDigestInfo inputs[ZCASH_MAX_TRANSPARENT_INPUTS] = {0}; + ZcashTransparentOutputDigestInfo outputs[ZCASH_MAX_TRANSPARENT_OUTPUTS] = {0}; + uint8_t transparent_digest[32] = {0}; + + if (!zcash_build_transparent_digest_info(inputs, outputs) || + !zcash_compute_orchard_transparent_sig_digest( + inputs, zcash_signing.n_transparent_inputs, outputs, + zcash_signing.n_transparent_outputs, transparent_digest)) { + memzero(transparent_digest, sizeof(transparent_digest)); + memzero(inputs, sizeof(inputs)); + memzero(outputs, sizeof(outputs)); + return false; + } + + if (memcmp(transparent_digest, zcash_signing.expected_transparent_digest, + 32) != 0) { + memzero(transparent_digest, sizeof(transparent_digest)); + memzero(inputs, sizeof(inputs)); + memzero(outputs, sizeof(outputs)); + return false; + } + + if (!zcash_compute_active_sighash(transparent_digest, + zcash_signing.sighash)) { + memzero(transparent_digest, sizeof(transparent_digest)); + memzero(inputs, sizeof(inputs)); + memzero(outputs, sizeof(outputs)); + return false; + } + zcash_signing.has_device_sighash = true; + zcash_signing.transparent_digest_verified = true; + + memzero(transparent_digest, sizeof(transparent_digest)); + memzero(inputs, sizeof(inputs)); + memzero(outputs, sizeof(outputs)); + return true; +} + +static bool zcash_sign_transparent_inputs(bool* cancelled) { + if (!zcash_signing.transparent_digest_verified) return false; + if (cancelled) *cancelled = false; + + bool ok = false; + ZcashTransparentInputDigestInfo inputs[ZCASH_MAX_TRANSPARENT_INPUTS] = {0}; + ZcashTransparentOutputDigestInfo outputs[ZCASH_MAX_TRANSPARENT_OUTPUTS] = {0}; + if (!zcash_build_transparent_digest_info(inputs, outputs)) goto cleanup; + + const CoinType* coin = fsm_getCoin(true, "Zcash"); + if (!coin) goto cleanup; + + memset(&zcash_signing.pending_transparent, 0, sizeof(ZcashTransparentSigned)); + zcash_signing.pending_transparent.signatures_count = + zcash_signing.n_transparent_inputs; + + for (uint32_t i = 0; i < zcash_signing.n_transparent_inputs; i++) { + const ZcashTransparentInputState* stored = + &zcash_signing.transparent_inputs[i]; + + char input_str[64]; + char amount_str[32]; + zcash_format_amount(stored->amount, amount_str, sizeof(amount_str)); + snprintf(input_str, sizeof(input_str), "Input %lu: %s", + (unsigned long)(i + 1), amount_str); + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Sign Input", + "Sign transparent input?\n%s", input_str)) { + if (cancelled) *cancelled = true; + goto cleanup; + } + + HDNode* node = fsm_getDerivedNode(coin->curve_name, stored->address_n, + stored->address_n_count, NULL); + if (!node) goto cleanup; + + /* ZIP-244/229: bind the transparent ECDSA signature to every transaction + * component, including Ironwood for transaction v6. */ + uint8_t t_sig_digest[32] = {0}; + uint8_t full_sighash[32] = {0}; + uint8_t sig[64] = {0}; + uint8_t der_sig[73] = {0}; + + bool sign_ok = zcash_compute_transparent_sighash_digest( + inputs, zcash_signing.n_transparent_inputs, outputs, + zcash_signing.n_transparent_outputs, i, 0x01, t_sig_digest); + if (sign_ok) { + sign_ok = zcash_compute_active_sighash(t_sig_digest, full_sighash); + } + sign_ok = + sign_ok && hdnode_sign_digest(node, full_sighash, sig, NULL, NULL) == 0; + + memzero(node, sizeof(*node)); + memzero(t_sig_digest, sizeof(t_sig_digest)); + memzero(full_sighash, sizeof(full_sighash)); + + if (!sign_ok) { + memzero(sig, sizeof(sig)); + goto cleanup; + } + + int der_len = ecdsa_sig_to_der(sig, der_sig); + zcash_signing.pending_transparent.signatures[i].size = der_len; + memcpy(zcash_signing.pending_transparent.signatures[i].bytes, der_sig, + der_len); + + memzero(sig, sizeof(sig)); + memzero(der_sig, sizeof(der_sig)); + } + + zcash_signing.has_pending_transparent = true; + ok = true; + +cleanup: + memzero(inputs, sizeof(inputs)); + memzero(outputs, sizeof(outputs)); + return ok; +} + +void fsm_msgZcashSignPCZT(const ZcashSignPCZT* msg) { + RESP_INIT(ZcashPCZTActionAck); + + CHECK_INITIALIZED + + CHECK_PIN + + /* Validate parameters */ + if (!msg->has_n_actions || msg->n_actions == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("No actions specified")); + layoutHome(); + return; + } + + if (msg->n_actions > ZCASH_MAX_ACTIONS) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Too many Orchard actions")); + layoutHome(); + return; + } + + uint32_t account; + if (!zcash_resolve_account(msg->has_account, msg->account, msg->address_n, + msg->address_n_count, &account)) { + layoutHome(); + return; + } + + uint32_t n_tinputs = + msg->has_n_transparent_inputs ? msg->n_transparent_inputs : 0; + if (n_tinputs > ZCASH_MAX_TRANSPARENT_INPUTS) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Too many transparent inputs")); + layoutHome(); + return; + } + + uint32_t n_toutputs = + msg->has_n_transparent_outputs ? msg->n_transparent_outputs : 0; + if (n_toutputs > ZCASH_MAX_TRANSPARENT_OUTPUTS) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Too many transparent outputs")); + layoutHome(); + return; + } + + uint32_t branch_id = msg->has_branch_id ? msg->branch_id : 0; + bool is_ironwood = + msg->has_shielded_pool && + msg->shielded_pool == ZcashShieldedPool_ZCASH_SHIELDED_POOL_IRONWOOD; + if (msg->has_shielded_pool && + msg->shielded_pool != ZcashShieldedPool_ZCASH_SHIELDED_POOL_ORCHARD && + msg->shielded_pool != ZcashShieldedPool_ZCASH_SHIELDED_POOL_IRONWOOD) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unknown shielded pool")); + layoutHome(); + return; + } + if (is_ironwood && + (!msg->has_tx_version || msg->tx_version != 6 || + !msg->has_version_group_id || msg->version_group_id != 0xD884B698 || + branch_id != 0x37A5165B)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid Ironwood transaction")); + layoutHome(); + return; + } + + /* The device verifies only the ACTIVE pool's actions, so the inactive pool + * must be provably empty rather than host-attested. Without this, a v6 + * request could carry any orchard_digest and the device would fold it into + * the sighash it signs having never seen the bundle it commits to. Refusing + * also fails closed on an Orchard->Ironwood migration transaction, which this + * signer cannot verify anyway: is_ironwood selects one set of personalization + * strings for every streamed action, so a transaction spanning both pools + * cannot be expressed here. */ + if (is_ironwood && + memcmp(msg->orchard_digest.bytes, EMPTY_ORCHARD_DIGEST_V6, 32) != 0) { + fsm_sendFailure( + FailureType_Failure_SyntaxError, + _("Ironwood transaction must have an empty Orchard bundle")); + layoutHome(); + return; + } + + ZcashPCZTSigningRequestMeta signing_meta = {0}; + signing_meta.has_header_digest = msg->has_header_digest; + signing_meta.header_digest_size = msg->header_digest.size; + signing_meta.has_transparent_digest = msg->has_transparent_digest; + signing_meta.transparent_digest_size = msg->transparent_digest.size; + signing_meta.has_sapling_digest = msg->has_sapling_digest; + signing_meta.sapling_digest_size = msg->sapling_digest.size; + signing_meta.has_orchard_digest = msg->has_orchard_digest; + signing_meta.orchard_digest_size = msg->orchard_digest.size; + signing_meta.is_ironwood = is_ironwood; + signing_meta.has_ironwood_digest = msg->has_ironwood_digest; + signing_meta.ironwood_digest_size = msg->ironwood_digest.size; + signing_meta.has_orchard_flags = msg->has_orchard_flags; + signing_meta.orchard_flags = msg->orchard_flags; + signing_meta.has_orchard_value_balance = msg->has_orchard_value_balance; + signing_meta.has_orchard_anchor = msg->has_orchard_anchor; + signing_meta.orchard_anchor_size = msg->orchard_anchor.size; + signing_meta.has_header_fields = + msg->has_tx_version && msg->has_version_group_id && msg->has_branch_id && + msg->has_lock_time && msg->has_expiry_height; + signing_meta.n_transparent_inputs = n_tinputs; + signing_meta.n_transparent_outputs = n_toutputs; + + const ZcashPCZTSigningRequestStatus status = + zcash_pczt_signing_request_status(&signing_meta); + if (status != ZCASH_PCZT_SIGNING_REQUEST_OK) { + static const char* const status_msgs[] = { + [ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS] = + "Missing transaction digests", + [ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE] = + "Invalid transaction digest", + [ZCASH_PCZT_SIGNING_REQUEST_MISSING_HEADER_FIELDS] = + "Missing transaction header", + [ZCASH_PCZT_SIGNING_REQUEST_UNSUPPORTED_SAPLING_COMPONENT] = + "Sapling not supported", + [ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST] = + "Missing transparent digest", + [ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA] = + "Missing Orchard metadata", + }; + const char* status_msg = + ((size_t)status < sizeof(status_msgs) / sizeof(status_msgs[0]) && + status_msgs[status]) + ? status_msgs[status] + : "Missing Orchard metadata"; + fsm_sendFailure(FailureType_Failure_SyntaxError, _(status_msg)); + layoutHome(); + return; + } + + uint8_t header_digest[32]; + if (!zcash_compute_header_digest(msg->tx_version, msg->version_group_id, + branch_id, msg->lock_time, + msg->expiry_height, header_digest) || + memcmp(header_digest, msg->header_digest.bytes, 32) != 0) { + fsm_sendFailure(FailureType_Failure_Other, _("Header digest mismatch")); + layoutHome(); + return; + } + + /* Confirm with user */ + char amount_str[32]; + char fee_str[32]; + uint64_t total = msg->has_total_amount ? msg->total_amount : 0; + uint64_t fee = msg->has_fee ? msg->fee : 0; + + /* Format amounts (1 ZEC = 100,000,000 zatoshis) */ + zcash_format_amount(total, amount_str, sizeof(amount_str)); + zcash_format_amount(fee, fee_str, sizeof(fee_str)); + + /* Display confirmation — different text for shielded-only vs hybrid */ + if (n_tinputs > 0) { + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Zcash Shield", + "Shield transparent ZEC?\n" + "Amount: %s\nFee: %s\nInputs: %lu\nOutputs: %lu\nActions: %lu", + amount_str, fee_str, (unsigned long)n_tinputs, + (unsigned long)n_toutputs, (unsigned long)msg->n_actions)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } else if (n_toutputs > 0) { + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Zcash Shielded", + "Sign transaction with transparent outputs?\n" + "Amount: %s\nFee: %s\nOutputs: %lu\nActions: %lu", + amount_str, fee_str, (unsigned long)n_toutputs, + (unsigned long)msg->n_actions)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } else { + if (!confirm(ButtonRequestType_ButtonRequest_SignTx, "Zcash Shielded", + "Sign shielded transaction?\n" + "Amount: %s\nFee: %s\nActions: %lu", + amount_str, fee_str, (unsigned long)msg->n_actions)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } + + /* Approved — show the signing screen before Orchard key derivation, which + * is seconds of Pallas math with nothing else drawing. */ + layoutProgress(_("Signing Zcash"), 0); + + if (!zcash_check_seed_fingerprint(msg->has_expected_seed_fingerprint, + msg->expected_seed_fingerprint.bytes, + msg->expected_seed_fingerprint.size)) { + layoutHome(); + return; + } + + /* Clear any stale state from a prior (possibly abandoned) session before + * starting a new one, so buffered transparent signatures or Orchard state + * from an earlier PCZT can never leak into this transaction. */ + zcash_signing_abort(); + + /* Derive Orchard keys via storage; the seed never leaves storage.c. */ + if (!storage_zcashOrchardKeys(account, true, &zcash_signing.keys)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Orchard key derivation failed")); + layoutHome(); + return; + } + + /* Initialize signing state */ + zcash_signing.active = true; + zcash_signing.account = account; + zcash_signing.n_actions = msg->n_actions; + zcash_signing.current_action = 0; + zcash_signing.total_amount = total; + zcash_signing.fee = fee; + zcash_signing.branch_id = branch_id; + zcash_signing.transaction_v6 = msg->tx_version == 6; + zcash_signing.is_ironwood = is_ironwood; + memcpy(zcash_signing.header_digest, header_digest, 32); + memcpy(zcash_signing.orchard_component_digest, msg->orchard_digest.bytes, 32); + if (is_ironwood) { + memcpy(zcash_signing.ironwood_component_digest, msg->ironwood_digest.bytes, + 32); + } else if (zcash_signing.transaction_v6) { + /* An Orchard-v6 request does not stream an Ironwood bundle. Bind the + * canonical empty component rather than zero-filled state or host data. */ + memcpy(zcash_signing.ironwood_component_digest, EMPTY_IRONWOOD_DIGEST_V6, + 32); + } + zcash_signing.has_device_sighash = false; + zcash_signing.verify_orchard_digest = false; + zcash_signing.n_transparent_outputs = + msg->has_n_transparent_outputs ? msg->n_transparent_outputs : 0; + zcash_signing.current_transparent_output = 0; + zcash_signing.n_transparent_inputs = + msg->has_n_transparent_inputs ? msg->n_transparent_inputs : 0; + zcash_signing.current_transparent_input = 0; + zcash_signing.has_expected_transparent_digest = false; + zcash_signing.transparent_digest_verified = false; + + /* Phase 2a: Compute sighash on-device from validated sub-digests. + * + * TRUST MODEL: + * + * What the device verifies: + * - Active shielded-pool digest: recomputed from streamed action data + * (Phase 2b) + * covering nullifiers, commitments, ephemeral keys, ciphertexts, + * value commitments, randomized keys, flags, value balance, anchor. + * - Orchard outputs: each displayed receiver/value is bound to cmx by + * recomputing the note commitment from recipient/value/rseed/rho before + * any authorization signature is emitted. + * - Transaction fee: computed from streamed transparent totals plus + * orchard_value_balance and compared to the requested fee before final + * user confirmation. + * - Sighash: assembled on-device from all v5 or v6 sub-digests. + * - transparent_digest: recomputed from streamed transparent outputs and + * inputs before any transparent or Orchard signature is emitted. + * - header_digest: recomputed from plaintext transaction header fields + * and compared to the supplied component digest. + * - Sapling: explicitly unsupported in this signing path. The device + * always uses the ZIP-244 empty Sapling digest and rejects any + * host-provided Sapling component. + * + * total_amount is a summary prompt. Transparent recipients, Orchard output + * recipients, Orchard output values, and the transaction fee all have their + * own verification gates before signatures are released. + * + * For shielded-only transactions (no transparent inputs): + * transparent_digest defaults to the well-known empty hash, + * so no trust assumption is needed for that component. + * + * For mixed transactions: + * transparent_digest is mandatory and verified against plaintext + * transparent metadata before local sighash derivation. */ + uint8_t t_digest[32]; + + if (n_tinputs == 0 && n_toutputs == 0) { + memcpy(t_digest, EMPTY_TRANSPARENT_DIGEST, 32); + zcash_compute_active_sighash(t_digest, zcash_signing.sighash); + zcash_signing.has_device_sighash = true; + zcash_signing.transparent_digest_verified = true; + } else { + memcpy(zcash_signing.expected_transparent_digest, + msg->transparent_digest.bytes, 32); + zcash_signing.has_expected_transparent_digest = true; + } + memzero(t_digest, sizeof(t_digest)); + + /* Phase 2b: the active shielded-pool digest is mandatory for signing. + * The device incrementally hashes each action's data and verifies the + * computed digest matches the one used for sighash. */ + memcpy(zcash_signing.expected_orchard_digest, + is_ironwood ? msg->ironwood_digest.bytes : msg->orchard_digest.bytes, + 32); + zcash_signing.orchard_flags = (uint8_t)msg->orchard_flags; + zcash_signing.orchard_value_balance = msg->orchard_value_balance; + memcpy(zcash_signing.orchard_anchor, msg->orchard_anchor.bytes, 32); + + blake2b_InitPersonal(&zcash_signing.compact_ctx, 32, + is_ironwood ? "ZTxIdIrnActCH_v6" : "ZTxIdOrcActCHash", + 16); + blake2b_InitPersonal(&zcash_signing.memos_ctx, 32, + is_ironwood ? "ZTxIdIrnActMH_v6" : "ZTxIdOrcActMHash", + 16); + blake2b_InitPersonal(&zcash_signing.noncompact_ctx, 32, + is_ironwood ? "ZTxIdIrnActNH_v6" : "ZTxIdOrcActNHash", + 16); + zcash_signing.verify_orchard_digest = true; + + /* Draw the initial static progress BEFORE requesting the first component: + * for the actions-only path zcash_send_action_ack() arms the trickle, and a + * layoutProgress() after it would clear the animation queue and freeze it. */ + layoutProgress(_("Signing Zcash"), 0); + + /* Request the first plaintext component. Transparent outputs are reviewed + * before any transparent input or Orchard signature can be emitted. */ + if (zcash_signing.n_transparent_outputs > 0) { + zcash_send_transparent_output_ack(0); + } else if (zcash_signing.n_transparent_inputs > 0) { + zcash_send_transparent_input_ack(0); + } else { + zcash_send_action_ack(0); + } +} + +void fsm_msgZcashGetOrchardFVK(const ZcashGetOrchardFVK* msg) { + RESP_INIT(ZcashOrchardFVK); + + CHECK_INITIALIZED + + CHECK_PIN + + uint32_t account; + if (!zcash_resolve_account(msg->has_account, msg->account, msg->address_n, + msg->address_n_count, &account)) { + layoutHome(); + return; + } + + if (msg->has_show_display && msg->show_display && + !confirm(ButtonRequestType_ButtonRequest_ProtectCall, + "Export Zcash View Key", + "Export Orchard viewing key for account %u?\nReveals Zcash " + "activity.", + (unsigned)account)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, _("Cancelled")); + layoutHome(); + return; + } + + /* Derive Orchard keys via storage; the seed never leaves storage.c. */ + layoutProgress(_("Deriving Zcash"), 0); + ZcashOrchardKeys keys; + if (!storage_zcashOrchardKeys(account, true, &keys)) { + fsm_sendFailure(FailureType_Failure_NotInitialized, + _("Orchard key derivation failed (seed unavailable?)")); + layoutHome(); + return; + } + + /* Build response */ + resp->has_ak = true; + resp->ak.size = 32; + memcpy(resp->ak.bytes, keys.ak, 32); + + resp->has_nk = true; + resp->nk.size = 32; + memcpy(resp->nk.bytes, keys.nk, 32); + + resp->has_rivk = true; + resp->rivk.size = 32; + memcpy(resp->rivk.bytes, keys.rivk, 32); + + /* Seed identity (ZIP-32 §6.1). Lets the host pin this FVK to a + * specific device-seed identity for later signing/display flows. */ + uint8_t fp[32]; + if (storage_zcashSeedFingerprint(true, fp)) { + resp->has_seed_fingerprint = true; + resp->seed_fingerprint.size = 32; + memcpy(resp->seed_fingerprint.bytes, fp, 32); + memzero(fp, sizeof(fp)); + } + + /* Clean up sensitive data */ + memzero(&keys, sizeof(keys)); + + msg_write(MessageType_MessageType_ZcashOrchardFVK, resp); + layoutHome(); +} + +void fsm_msgZcashDisplayAddress(const ZcashDisplayAddress* msg) { + RESP_INIT(ZcashAddress); + + CHECK_INITIALIZED + + CHECK_PIN + + uint32_t account; + if (!zcash_resolve_account(msg->has_account, msg->account, msg->address_n, + msg->address_n_count, &account)) { + layoutHome(); + return; + } + + if (!zcash_check_seed_fingerprint(msg->has_expected_seed_fingerprint, + msg->expected_seed_fingerprint.bytes, + msg->expected_seed_fingerprint.size)) { + layoutHome(); + return; + } + + /* Derive Orchard keys via storage; the seed never leaves storage.c. */ + layoutProgress(_("Deriving Zcash"), 0); + ZcashOrchardKeys keys; + if (!storage_zcashOrchardKeys(account, true, &keys)) { + fsm_sendFailure(FailureType_Failure_NotInitialized, + _("Orchard key derivation failed (seed unavailable?)")); + layoutHome(); + return; + } + + layoutProgress(_("Deriving address"), 650); + char derived_address[sizeof(resp->address)]; + const uint8_t default_receiver_index[11] = {0}; + if (!zcash_orchard_derive_unified_address(&keys, default_receiver_index, "u", + derived_address, + sizeof(derived_address))) { + memzero(&keys, sizeof(keys)); + fsm_sendFailure(FailureType_Failure_Other, + _("Orchard address derivation failed")); + layoutHome(); + return; + } + + /* Clean up sensitive key material BEFORE display prompt. */ + memzero(&keys, sizeof(keys)); + + layoutProgress(_("Loading address"), 1000); + + char desc[48]; + snprintf(desc, sizeof(desc), "Zcash #%lu Orchard", (unsigned long)account); + if (!confirm_zcash_address(desc, derived_address)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Address display cancelled")); + layoutHome(); + return; + } + + /* User confirmed — return the address bound to this device's seed. */ + resp->has_address = true; + strlcpy(resp->address, derived_address, sizeof(resp->address)); + + /* Seed identity (ZIP-32 §6.1) — pin the attestation to this device. */ + uint8_t fp[32]; + if (storage_zcashSeedFingerprint(true, fp)) { + resp->has_seed_fingerprint = true; + resp->seed_fingerprint.size = 32; + memcpy(resp->seed_fingerprint.bytes, fp, 32); + memzero(fp, sizeof(fp)); + } + + msg_write(MessageType_MessageType_ZcashAddress, resp); + layoutHome(); +} + +void fsm_msgZcashPCZTAction(const ZcashPCZTAction* msg) { + if (!zcash_signing.active) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Not in Zcash signing mode")); + layoutHome(); + return; + } + + /* An action arrived: stop the trickle so the exact per-action milestone (and + * the fee confirm reached at completion) draws cleanly. Re-armed by the next + * zcash_send_action_ack() if more actions remain. */ + layoutProgressTrickleStop(); + + /* Enforce transparent phase completion: if the session declared any + * transparent data, all plaintext must be streamed and verified before + * Orchard actions. + * This prevents a malicious host from skipping transparent-input + * confirmations and jumping straight to Orchard signing. */ + if (zcash_signing.current_transparent_output < + zcash_signing.n_transparent_outputs || + zcash_signing.current_transparent_input < + zcash_signing.n_transparent_inputs || + ((zcash_signing.n_transparent_outputs > 0 || + zcash_signing.n_transparent_inputs > 0) && + !zcash_signing.transparent_digest_verified)) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Transparent data not yet complete")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Validate action index */ + if (!msg->has_index || msg->index != zcash_signing.current_action) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unexpected action index")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Validate required fields */ + if (!msg->has_alpha || msg->alpha.size != 32) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing or invalid alpha randomizer")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Phase 2a: a device-computed sighash is mandatory. */ + if (!zcash_signing.has_device_sighash) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing transaction digests")); + zcash_signing_abort(); + layoutHome(); + return; + } + + const bool has_orchard_action_data = + zcash_signing.verify_orchard_digest && msg->has_is_spend && + msg->has_nullifier && msg->nullifier.size == 32 && msg->has_cmx && + msg->cmx.size == 32 && msg->has_epk && msg->epk.size == 32 && + msg->has_enc_compact && msg->enc_compact.size == 52 && + msg->has_enc_memo && msg->enc_memo.size == 512 && + msg->has_enc_noncompact && + /* 580-byte enc_ciphertext = compact(52) + memo(512) + noncompact(16); + * pin the exact size like every sibling field so a host serializer bug + * fails fast per-action instead of as an end-of-flow digest mismatch. */ + msg->enc_noncompact.size == 16 && msg->has_cv_net && + msg->cv_net.size == 32 && msg->has_rk && msg->rk.size == 32 && + msg->has_out_ciphertext && msg->out_ciphertext.size == 80; + + if (!has_orchard_action_data) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Missing Orchard action data")); + zcash_signing_abort(); + layoutHome(); + return; + } + + const uint32_t action_base = + (zcash_signing.current_action * 1000) / zcash_signing.n_actions; + const uint32_t action_target = + ((zcash_signing.current_action + 1) * 1000) / zcash_signing.n_actions; + const uint32_t action_span = action_target - action_base; + const uint32_t verification_target = + msg->is_spend ? action_base + action_span / 3 : action_target; + ZcashActionProgress verification_progress = { + action_base, verification_target - action_base, action_base}; + + /* The 1086-bit Orchard note commitment takes 109 public Sinsemilla rounds. + * Draw their real progress instead of freezing the prior trickle at 0%. */ + layoutProgress(_("Signing Zcash"), action_base); + if (!zcash_verify_and_confirm_orchard_output(msg, zcash_action_progress, + &verification_progress)) { + zcash_signing_abort(); + layoutHome(); + return; + } + + /* The user just approved — restore the progress screen at the verified + * milestone. Real spends continue through RedPallas below; dummy spends + * complete without an authorization signature. */ + layoutProgress(_("Signing Zcash"), verification_target); + + /* Phase 2b: feed action data into incremental BLAKE2b contexts */ + blake2b_Update(&zcash_signing.compact_ctx, msg->nullifier.bytes, 32); + blake2b_Update(&zcash_signing.compact_ctx, msg->cmx.bytes, 32); + blake2b_Update(&zcash_signing.compact_ctx, msg->epk.bytes, 32); + blake2b_Update(&zcash_signing.compact_ctx, msg->enc_compact.bytes, 52); + + blake2b_Update(&zcash_signing.memos_ctx, msg->enc_memo.bytes, 512); + + blake2b_Update(&zcash_signing.noncompact_ctx, msg->cv_net.bytes, 32); + blake2b_Update(&zcash_signing.noncompact_ctx, msg->rk.bytes, 32); + blake2b_Update(&zcash_signing.noncompact_ctx, msg->enc_noncompact.bytes, + msg->enc_noncompact.size); + blake2b_Update(&zcash_signing.noncompact_ctx, msg->out_ciphertext.bytes, 80); + + const uint8_t* sighash = zcash_signing.sighash; + + /* Orchard actions always contain a spend and an output, but the spend can be + * a dummy. finalize_io() has already signed dummy spends with their ephemeral + * key; replacing that signature with one from the device key is invalid and + * can never satisfy the action's rk. Stream and verify every action above, + * but return compact signatures only for real spends, in action order. */ + if (msg->is_spend) { + /* Draw the spend-authorization randomness T here, from the CHECKED source, + * and hand it to the signer. + * + * The signer takes T from the caller precisely so this decision is + * visible. The signer derives the nonce as r = H*(T || rk || M) + * rather than reducing T directly, so a repeat of T alone is survivable -- + * but a REPEATED NONCE discloses the spend authorization key from any two + * signatures, so the entropy must still come from a source that has been + * health-checked, and a degraded source must yield NO signature rather than + * a predictable one. + * + * random_buffer_checked() folds the drawn bytes into the continuous + * SP 800-90B state and returns false if the RCT or APT trips; + * rng_health_check() is the latched boot verdict. Both must hold, and the + * signer independently refuses an all-zero T. + * + * 80 bytes, not 32: T is the randomness input to the RedDSA nonce + * derivation r = H*(T || rk || M), and the Zcash protocol specification + * sizes it at 80 so that H*'s output is statistically uniform over the + * scalar field. The signer hashes T with the verification key and the + * message rather than reducing it directly, so a repeated T across two + * DIFFERENT messages still yields different nonces. + * + * Refusing to sign is always safe. Signing with a repeated nonce is not. + */ + uint8_t zcash_T[80]; + if (!rng_health_check() || + !random_buffer_checked(zcash_T, sizeof(zcash_T))) { + memzero(zcash_T, sizeof(zcash_T)); + fsm_sendFailure(FailureType_Failure_Other, + _("RNG health check failed; refusing to sign")); + zcash_signing_abort(); + layoutHome(); + return; + } + + ZcashActionProgress signing_progress = {verification_target, + action_target - verification_target, + verification_target}; + /* _with_ak, not _for_rk: it derives rk from the device's OWN ak and alpha + * and refuses when the host's rk does not match, then signs with the + * derived value. _for_rk feeds the host's rk straight into the nonce and + * challenge hashes without ever checking it describes this device's key, + * so the device would happily authorize under a verification key that is + * not its own. The validating variant already existed and production was + * calling the other one. */ + int sign_rc = redpallas_sign_digest_with_ak( + zcash_signing.keys.ask, zcash_signing.keys.ak, msg->alpha.bytes, + msg->rk.bytes, sighash, zcash_T, + zcash_signing.signatures[zcash_signing.signature_count], + zcash_action_progress, &signing_progress); + memzero(zcash_T, sizeof(zcash_T)); + if (sign_rc != 0) { + fsm_sendFailure(FailureType_Failure_Other, + _("Orchard spend authorization failed")); + zcash_signing_abort(); + layoutHome(); + return; + } + zcash_signing.signature_count++; + } + + zcash_signing.current_action++; + + /* Update progress */ + uint32_t progress = + (zcash_signing.current_action * 1000) / zcash_signing.n_actions; + layoutProgress(_("Signing Zcash"), progress); + + /* Check if all actions are signed */ + if (zcash_signing.current_action >= zcash_signing.n_actions) { + /* Phase 2b: verify orchard digest before returning signatures */ + if (zcash_signing.verify_orchard_digest) { + uint8_t compact_hash[32], memos_hash[32], noncompact_hash[32]; + + blake2b_Final(&zcash_signing.compact_ctx, compact_hash, 32); + blake2b_Final(&zcash_signing.memos_ctx, memos_hash, 32); + blake2b_Final(&zcash_signing.noncompact_ctx, noncompact_hash, 32); + + /* V5 Orchard commits the anchor in the txid component. Transaction-v6 + * Ironwood moves the anchor to the authorizing-data digest (ZIP-229), + * so the device deliberately omits it here. */ + BLAKE2B_CTX orchard_ctx; + blake2b_InitPersonal( + &orchard_ctx, 32, + zcash_signing.is_ironwood + ? "ZTxIdIronwd_H_v6" + : (zcash_signing.transaction_v6 ? "ZTxIdOrchardH_v6" + : "ZTxIdOrchardHash"), + 16); + blake2b_Update(&orchard_ctx, compact_hash, 32); + blake2b_Update(&orchard_ctx, memos_hash, 32); + blake2b_Update(&orchard_ctx, noncompact_hash, 32); + blake2b_Update(&orchard_ctx, &zcash_signing.orchard_flags, 1); + blake2b_Update(&orchard_ctx, + (const uint8_t*)&zcash_signing.orchard_value_balance, 8); + if (!zcash_signing.transaction_v6) { + blake2b_Update(&orchard_ctx, zcash_signing.orchard_anchor, 32); + } + + uint8_t computed_orchard_digest[32]; + blake2b_Final(&orchard_ctx, computed_orchard_digest, 32); + + /* Verify computed matches expected */ + if (memcmp(computed_orchard_digest, zcash_signing.expected_orchard_digest, + 32) != 0) { + fsm_sendFailure(FailureType_Failure_Other, + _("Shielded digest mismatch: transaction data " + "does not match sighash")); + zcash_signing_abort(); + layoutHome(); + return; + } + } + + if (!zcash_verify_and_confirm_fee()) { + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Release deferred transparent ECDSA sigs at the same gate as Orchard sigs + * — both are sent only after Orchard digest verification and fee + * confirmation. */ + if (zcash_signing.has_pending_transparent) { + ZcashTransparentSigned* t_resp = (ZcashTransparentSigned*)msg_resp; + memcpy(t_resp, &zcash_signing.pending_transparent, + sizeof(ZcashTransparentSigned)); + msg_write(MessageType_MessageType_ZcashTransparentSigned, t_resp); + } + + /* All done - send the collected Orchard signatures */ + ZcashSignedPCZT* resp_signed = (ZcashSignedPCZT*)msg_resp; + memset(resp_signed, 0, sizeof(ZcashSignedPCZT)); + + resp_signed->signatures_count = zcash_signing.signature_count; + for (uint32_t i = 0; i < zcash_signing.signature_count; i++) { + resp_signed->signatures[i].size = 64; + memcpy(resp_signed->signatures[i].bytes, zcash_signing.signatures[i], 64); + } + + /* Clean up */ + zcash_signing_abort(); + + msg_write(MessageType_MessageType_ZcashSignedPCZT, resp_signed); + layoutHome(); + } else { + /* Request next action */ + zcash_send_action_ack(zcash_signing.current_action); + } +} + +void fsm_msgZcashTransparentOutput(const ZcashTransparentOutput* msg) { + if (!zcash_signing.active) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Not in Zcash signing mode")); + layoutHome(); + return; + } + + if (zcash_signing.n_transparent_outputs == 0) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("No transparent outputs expected")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (zcash_signing.current_transparent_input != 0) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Transparent outputs must come first")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Same invariant as the transparent input handler, and it has to be stated + * separately: this is a different array with its own free-running counter. + * + * After the declared outputs are stored, the dispatch below moves on to + * transparent inputs without incrementing current_transparent_input, which + * leaves this handler re-armed. A host that ignores the ack and keeps + * sending outputs walks current_transparent_output past + * n_transparent_outputs, and each extra message wrote a host-controlled + * amount and a 128-byte script_pubkey past the end of + * transparent_outputs[8] -- landing first on transparent_inputs[0] and then + * outside the struct entirely. */ + if (msg->index >= zcash_signing.n_transparent_outputs || + msg->index >= ZCASH_MAX_TRANSPARENT_OUTPUTS || + zcash_signing.current_transparent_output >= + zcash_signing.n_transparent_outputs) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Transparent output index out of range")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (msg->index != zcash_signing.current_transparent_output) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unexpected transparent output index")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (!msg->has_amount || !msg->has_script_pubkey || + msg->script_pubkey.size == 0 || + msg->script_pubkey.size > ZCASH_MAX_TRANSPARENT_SCRIPT_PUBKEY) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid transparent output script")); + zcash_signing_abort(); + layoutHome(); + return; + } + + char address[64]; + if (!zcash_transparent_script_to_address(msg->script_pubkey.bytes, + msg->script_pubkey.size, address, + sizeof(address))) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unsupported transparent output script")); + zcash_signing_abort(); + layoutHome(); + return; + } + + char amount_str[32]; + zcash_format_amount(msg->amount, amount_str, sizeof(amount_str)); + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Zcash Output", + "Send transparent ZEC?\n%s\nAmount: %s", address, amount_str)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + zcash_signing_abort(); + layoutHome(); + return; + } + + ZcashTransparentOutputState* stored = + &zcash_signing.transparent_outputs[msg->index]; + stored->received = true; + stored->amount = msg->amount; + stored->script_pubkey_size = msg->script_pubkey.size; + memcpy(stored->script_pubkey, msg->script_pubkey.bytes, + msg->script_pubkey.size); + + zcash_signing.current_transparent_output++; + + /* Static draw before the dispatch: the actions transition below arms the + * trickle, and a layoutProgress() after it would clear and freeze it. */ + layoutProgress(_("Signing Zcash"), 0); + + if (zcash_signing.current_transparent_output < + zcash_signing.n_transparent_outputs) { + zcash_send_transparent_output_ack(zcash_signing.current_transparent_output); + } else if (zcash_signing.n_transparent_inputs > 0) { + zcash_send_transparent_input_ack(0); + } else { + if (!zcash_finalize_transparent_digest()) { + fsm_sendFailure(FailureType_Failure_Other, + _("Transparent digest mismatch")); + zcash_signing_abort(); + layoutHome(); + return; + } + zcash_send_action_ack(0); + } +} + +/* Phase 3: Transparent plaintext streaming for hybrid shielding + * transactions. The host streams all outputs first, then all inputs. Only after + * the firmware verifies transparent_digest from the streamed plaintext does it + * derive per-input ZIP-244 sighashes and emit ECDSA signatures. */ +void fsm_msgZcashTransparentInput(const ZcashTransparentInput* msg) { + if (!zcash_signing.active) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Not in Zcash signing mode")); + layoutHome(); + return; + } + + if (zcash_signing.n_transparent_inputs == 0) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("No transparent inputs expected")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (zcash_signing.current_transparent_output < + zcash_signing.n_transparent_outputs) { + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, + _("Transparent outputs not yet complete")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Bound the index against the array before it is used to address it. + * + * Matching current_transparent_input is not sufficient on its own. Nothing + * stops a host sending further ZcashTransparentInput messages after the + * declared count has been consumed: the ack loop simply stops asking, while + * current_transparent_input keeps incrementing past n_transparent_inputs on + * every extra message. Each one then wrote a fully host-controlled + * ZcashTransparentInputState -- amount, 32-byte txid, script_pubkey, the + * whole address_n array -- past the end of an 8-element static array. */ + if (msg->index >= zcash_signing.n_transparent_inputs || + msg->index >= ZCASH_MAX_TRANSPARENT_INPUTS || + zcash_signing.current_transparent_input >= + zcash_signing.n_transparent_inputs) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Transparent input index out of range")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (msg->index != zcash_signing.current_transparent_input) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unexpected transparent input index")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (msg->has_sighash) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Host transparent sighash rejected")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (!msg->has_amount || !msg->has_prevout_txid || + msg->prevout_txid.size != 32 || !msg->has_prevout_index || + !msg->has_sequence || !msg->has_script_pubkey || + msg->script_pubkey.size == 0 || + msg->script_pubkey.size > ZCASH_MAX_TRANSPARENT_SCRIPT_PUBKEY) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid transparent input data")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (!zcash_script_is_standard_transparent(msg->script_pubkey.bytes, + msg->script_pubkey.size)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Unsupported transparent input script")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* PATH ENFORCEMENT: transparent inputs must use exactly + * m/44'/133'/account'/change/index where: + * - account' is hardened and matches the session account + * - change is 0 (external) or 1 (internal) + * - index is unhardened + * + * This prevents a compromised host from pivoting a shielding approval + * into signing with arbitrary secp256k1 keys on the device. */ + if (msg->address_n_count != 5) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Path must be m/44'/133'/account'/change/index")); + zcash_signing_abort(); + layoutHome(); + return; + } + + if (msg->address_n[0] != (0x80000000 | 44) || + msg->address_n[1] != (0x80000000 | 133)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Path must start with m/44'/133'")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Account must be hardened and match the approved session */ + if (!(msg->address_n[2] & 0x80000000)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Account must be hardened")); + zcash_signing_abort(); + layoutHome(); + return; + } + + uint32_t path_account = msg->address_n[2] & 0x7FFFFFFF; + if (path_account != zcash_signing.account) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Account does not match approved session")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Change must be 0 (external) or 1 (internal), unhardened */ + if (msg->address_n[3] > 1) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Change must be 0 or 1")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Index must be unhardened */ + if (msg->address_n[4] & 0x80000000) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Index must not be hardened")); + zcash_signing_abort(); + layoutHome(); + return; + } + + ZcashTransparentInputState* stored = + &zcash_signing.transparent_inputs[msg->index]; + stored->received = true; + stored->amount = msg->amount; + memcpy(stored->prevout_txid, msg->prevout_txid.bytes, 32); + stored->prevout_index = msg->prevout_index; + stored->sequence = msg->sequence; + stored->script_pubkey_size = msg->script_pubkey.size; + memcpy(stored->script_pubkey, msg->script_pubkey.bytes, + msg->script_pubkey.size); + stored->address_n_count = msg->address_n_count; + memcpy(stored->address_n, msg->address_n, + msg->address_n_count * sizeof(msg->address_n[0])); + + zcash_signing.current_transparent_input++; + + if (zcash_signing.current_transparent_input < + zcash_signing.n_transparent_inputs) { + zcash_send_transparent_input_ack(zcash_signing.current_transparent_input); + layoutProgress(_("Signing Zcash"), 0); + return; + } + + if (!zcash_finalize_transparent_digest()) { + fsm_sendFailure(FailureType_Failure_Other, + _("Transparent digest mismatch")); + zcash_signing_abort(); + layoutHome(); + return; + } + + bool cancelled = false; + if (!zcash_sign_transparent_inputs(&cancelled)) { + fsm_sendFailure(cancelled ? FailureType_Failure_ActionCancelled + : FailureType_Failure_Other, + cancelled ? _("Signing cancelled") + : _("Transparent input signing failed")); + zcash_signing_abort(); + layoutHome(); + return; + } + + /* Transparent ECDSA sigs are buffered in zcash_signing.pending_transparent. + * They are released at the same final gate as Orchard sigs, after Orchard + * digest verification and fee confirmation. */ + /* Static draw before arming: zcash_send_action_ack() arms the trickle, so a + * layoutProgress() after it would clear the animation queue and freeze it. */ + layoutProgress(_("Signing Zcash"), 0); + zcash_send_action_ack(0); +} diff --git a/lib/firmware/hive.c b/lib/firmware/hive.c new file mode 100644 index 000000000..304f673ae --- /dev/null +++ b/lib/firmware/hive.c @@ -0,0 +1,1096 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +#include "keepkey/firmware/hive.h" + +#include "trezor/crypto/base58.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" + +#include +#include + +// ── STM public key encoding ─────────────────────────────────────────────── + +bool hive_getPublicKey(const uint8_t public_key[33], char* out, + size_t out_len) { + const size_t prefix_len = strlen(HIVE_PUBKEY_PREFIX); + if (out_len < prefix_len + 1) return false; + strlcpy(out, HIVE_PUBKEY_PREFIX, out_len); + // Graphene uses RIPEMD checksum (not SHA256d) for public key encoding + return base58_encode_check(public_key, 33, HASHER_RIPEMD, out + prefix_len, + out_len - prefix_len); +} + +// ── Single-role key derivation to raw 33 bytes ──────────────────────────── +// Path: m/48'/13'/role_hardened/account_index_hardened/0' +// hdnode_private_ckd() returns 1 on success, 0 on failure. + +static bool hive_role_valid(uint32_t role) { + return role == HIVE_ROLE_OWNER || role == HIVE_ROLE_ACTIVE || + role == HIVE_ROLE_MEMO || role == HIVE_ROLE_POSTING; +} + +bool hive_slip48_path_valid(const uint32_t* address_n, size_t count) { + if (!address_n || count != 5) return false; + if (address_n[0] != HIVE_SLIP48_PURPOSE) return false; + if (address_n[1] != HIVE_SLIP48_NETWORK) return false; + if (!hive_role_valid(address_n[2])) return false; + if ((address_n[3] & 0x80000000u) == 0) return false; + if (address_n[4] != 0x80000000u) return false; + return true; +} + +bool hive_slip48_path_valid_for_role(const uint32_t* address_n, size_t count, + uint32_t required_role) { + return hive_role_valid(required_role) && + hive_slip48_path_valid(address_n, count) && + address_n[2] == required_role; +} + +bool hive_deriveRawKey(const HDNode* root, uint32_t role_hardened, + uint32_t account_index_hardened, uint8_t out[33]) { + HDNode node; + memcpy(&node, root, sizeof(HDNode)); + if (!hdnode_private_ckd(&node, HIVE_SLIP48_PURPOSE)) goto fail; + if (!hdnode_private_ckd(&node, HIVE_SLIP48_NETWORK)) goto fail; + if (!hdnode_private_ckd(&node, role_hardened)) goto fail; + if (!hdnode_private_ckd(&node, account_index_hardened)) goto fail; + if (!hdnode_private_ckd(&node, 0x80000000u)) goto fail; + hdnode_fill_public_key(&node); + memcpy(out, node.public_key, 33); + memzero(&node, sizeof(node)); + return true; +fail: + memzero(&node, sizeof(node)); + return false; +} + +// ── SLIP-0048 multi-role key derivation ─────────────────────────────────── + +bool hive_getPublicKeys(const HDNode* root, uint32_t account_index, + char* owner_out, size_t owner_len, char* active_out, + size_t active_len, char* memo_out, size_t memo_len, + char* posting_out, size_t posting_len) { + const uint32_t roles[4] = { + HIVE_ROLE_OWNER, + HIVE_ROLE_ACTIVE, + HIVE_ROLE_MEMO, + HIVE_ROLE_POSTING, + }; + char* outs[4] = {owner_out, active_out, memo_out, posting_out}; + const size_t lens[4] = {owner_len, active_len, memo_len, posting_len}; + + uint32_t account_hardened = account_index | 0x80000000u; + + for (int i = 0; i < 4; i++) { + uint8_t raw[33]; + if (!hive_deriveRawKey(root, roles[i], account_hardened, raw)) return false; + if (!hive_getPublicKey(raw, outs[i], lens[i])) { + memzero(raw, sizeof(raw)); + return false; + } + memzero(raw, sizeof(raw)); + } + return true; +} + +// ── Graphene binary serialization helpers ───────────────────────────────── + +static void append_u8(uint8_t** buf, const uint8_t* end, uint8_t v) { + if (*buf < end) { + **buf = v; + (*buf)++; + } +} + +static void append_u16_le(uint8_t** buf, const uint8_t* end, uint16_t v) { + append_u8(buf, end, v & 0xFF); + append_u8(buf, end, (v >> 8) & 0xFF); +} + +static void append_u32_le(uint8_t** buf, const uint8_t* end, uint32_t v) { + append_u8(buf, end, v & 0xFF); + append_u8(buf, end, (v >> 8) & 0xFF); + append_u8(buf, end, (v >> 16) & 0xFF); + append_u8(buf, end, (v >> 24) & 0xFF); +} + +static void append_u64_le(uint8_t** buf, const uint8_t* end, uint64_t v) { + for (int i = 0; i < 8; i++) { + append_u8(buf, end, v & 0xFF); + v >>= 8; + } +} + +static void append_varint(uint8_t** buf, const uint8_t* end, uint64_t v) { + do { + uint8_t b = v & 0x7F; + v >>= 7; + if (v) b |= 0x80; + append_u8(buf, end, b); + } while (v); +} + +static void append_string(uint8_t** buf, const uint8_t* end, const char* s) { + size_t len = s ? strlen(s) : 0; + append_varint(buf, end, len); + for (size_t i = 0; i < len && *buf < end; i++) + append_u8(buf, end, (uint8_t)s[i]); +} + +/* + * Graphene asset encoding: int64 LE amount + uint8 precision + 7-byte symbol + */ +static void append_asset(uint8_t** buf, const uint8_t* end, uint64_t amount, + uint8_t precision, const char* symbol) { + append_u64_le(buf, end, amount); + append_u8(buf, end, precision); + char sym[7] = {0}; + if (symbol) strncpy(sym, symbol, 6); + for (int i = 0; i < 7 && *buf < end; i++) + append_u8(buf, end, (uint8_t)sym[i]); +} + +/* + * Graphene authority structure (Hive wire format): + * weight_threshold (uint32 LE) = 1 + * num_account_auths (varint) = 0 + * num_key_auths (varint) = 1 + * compressed public key (33 bytes, no type prefix) + * weight (uint16 LE) = 1 + * + * Note: Hive does NOT use a key-type prefix byte before the 33 raw bytes. + */ +static void append_authority(uint8_t** buf, const uint8_t* end, + const uint8_t pubkey[33]) { + append_u32_le(buf, end, 1); // weight_threshold = 1 + append_varint(buf, end, 0); // 0 account auths + append_varint(buf, end, 1); // 1 key auth + for (int i = 0; i < 33 && *buf < end; i++) append_u8(buf, end, pubkey[i]); + append_u16_le(buf, end, 1); // weight = 1 +} + +/* + * Common transaction header: ref_block_num, ref_block_prefix, expiration, + * then a varint op count = 1, then the op type varint. + */ +static void append_tx_header(uint8_t** buf, const uint8_t* end, + uint16_t ref_block_num, uint32_t ref_block_prefix, + uint32_t expiration, uint32_t op_type) { + append_u16_le(buf, end, ref_block_num); + append_u32_le(buf, end, ref_block_prefix); + append_u32_le(buf, end, expiration); + append_varint(buf, end, 1); // 1 operation + append_varint(buf, end, op_type); +} + +static void append_tx_footer(uint8_t** buf, const uint8_t* end) { + append_varint(buf, end, 0); // 0 extensions +} + +/* + * Graphene legacy canonical-signature rule (identical to EOS/Steem): high bit + * of both r and s must be clear — same predicate as eos_is_canonic. Modern + * hived (post-HF28) actually enforces only BIP-0062 low-S (fc is_canonical -> + * is_bip_0062_canonical), which trezor-crypto's low-S normalization already + * guarantees; keeping the stricter legacy rule costs an occasional extra + * RFC6979 iteration and stays compatible with every historical verifier. + */ +static int hive_is_canonic(uint8_t v, uint8_t signature[64]) { + (void)v; + return !(signature[0] & 0x80) && + !(signature[0] == 0 && !(signature[1] & 0x80)) && + !(signature[32] & 0x80) && + !(signature[32] == 0 && !(signature[33] & 0x80)); +} + +/* + * Core sign helper over an already-computed 32-byte digest → 65-byte + * compact recoverable sig: header (27 + recovery_id + 4 compressed-key + * flag), then r(32) ‖ s(32). + */ +static bool hive_sign_raw_digest(const HDNode* node, const uint8_t digest[32], + uint8_t sig[65]) { + uint8_t pby; + if (ecdsa_sign_digest(&secp256k1, node->private_key, digest, sig + 1, &pby, + hive_is_canonic) != 0) { + return false; + } + // Compact signature header: 27 + recovery_id + 4 (compressed key flag) + sig[0] = 27 + pby + 4; + return true; +} + +/* + * Transaction sign helper: SHA256(chain_id || serialized_tx) → compact sig. + * Writes 65 bytes into sig[]. Returns true on success. + */ +static bool hive_sign_digest(const HDNode* node, const uint8_t* chain_id, + const uint8_t* tx_buf, size_t tx_len, + uint8_t sig[65]) { + SHA256_CTX sha; + sha256_Init(&sha); + sha256_Update(&sha, chain_id, HIVE_CHAIN_ID_LEN); + sha256_Update(&sha, tx_buf, tx_len); + uint8_t digest[32]; + sha256_Final(&sha, digest); + + bool ok = hive_sign_raw_digest(node, digest, sig); + memzero(digest, sizeof(digest)); + return ok; +} + +/* + * Chain-id select (host-supplied 32-byte chain_id or mainnet default) + + * hive_sign_digest, writing the 65-byte compact signature into sig[]. + */ +static bool hive_sign_tx_sig(const HDNode* node, bool has_chain_id, + const uint8_t* chain_id_bytes, + size_t chain_id_size, const uint8_t* tx_buf, + size_t tx_len, uint8_t sig[65]) { + const uint8_t default_chain_id[32] = HIVE_CHAIN_ID; + /* Pin to Hive mainnet. A host-supplied chain_id is accepted only if it equals + * mainnet; any other value is refused rather than signed under an undisclosed + * network domain (the confirmations just say "Hive"). This also keeps the tx + * digest domain singular — SHA256(mainnet_chain_id || tx) — so the + * message-signing guard that rejects messages beginning with the mainnet + * chain id fully closes the tx/message signature collision. */ + if (has_chain_id) { + if (chain_id_size != HIVE_CHAIN_ID_LEN || + memcmp(chain_id_bytes, default_chain_id, HIVE_CHAIN_ID_LEN) != 0) { + return false; + } + } + return hive_sign_digest(node, default_chain_id, tx_buf, tx_len, sig); +} + +// ── Parsed operation signing (HiveSignOperations) ───────────────────────── +// +// The host serializes the transaction; firmware re-derives everything it +// displays from the bytes and refuses anything outside the phase-1 op table. +// Digest/signature are identical to HiveSignTx: SHA256(chain_id || tx). + +typedef struct { + const uint8_t* p; + const uint8_t* end; +} HiveCur; + +/* + * Bounded unsigned LEB128: at most 5 bytes, must fit uint32, overlong + * encodings rejected (an unbounded shift is a classic overflow hole). + */ +static bool cur_varint(HiveCur* c, uint32_t* out) { + uint32_t v = 0; + for (int shift = 0; shift <= 28; shift += 7) { + if (c->p >= c->end) return false; + uint8_t b = *c->p++; + if (shift == 28 && (b & 0xF0)) return false; // overflow or 6th byte + v |= (uint32_t)(b & 0x7F) << shift; + if (!(b & 0x80)) { + // A multi-byte LEB128 whose final group is zero has a shorter encoding. + // hived re-serializes values canonically when checking a signature, so + // accepting an overlong form would make the device sign bytes the chain + // interprets and hashes differently. + if (shift > 0 && (b & 0x7F) == 0) return false; + *out = v; + return true; + } + } + return false; +} + +/* varint length + bytes, bounds-checked against the buffer AND field caps. */ +static bool cur_string(HiveCur* c, const uint8_t** s, uint16_t* slen, + uint32_t min_len, uint32_t max_len) { + uint32_t n; + if (!cur_varint(c, &n)) return false; + if (n < min_len || n > max_len) return false; + if ((size_t)(c->end - c->p) < n) return false; + *s = c->p; + *slen = (uint16_t)n; + c->p += n; + return true; +} + +/* + * Hive account names are rendered in compact multi-field confirmation screens, + * so they must be valid protocol names rather than arbitrary byte strings. + * This rejects embedded NUL/newline/control bytes that would truncate or + * reshape the OLED while later bytes remained covered by the signature. + */ +static bool hive_account_name_valid(const uint8_t* s, uint16_t len, + bool allow_empty) { + if (len == 0) return allow_empty; + if (len < 3 || len > 16) return false; + + bool at_segment_start = true; + bool previous_hyphen = false; + for (uint16_t i = 0; i < len; i++) { + uint8_t ch = s[i]; + if (at_segment_start) { + if (ch < 'a' || ch > 'z') return false; + at_segment_start = false; + previous_hyphen = false; + } else if (ch == '.') { + if (previous_hyphen || i + 1 == len) return false; + at_segment_start = true; + } else if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) { + previous_hyphen = false; + } else if (ch == '-') { + previous_hyphen = true; + } else { + return false; + } + } + return !at_segment_start && !previous_hyphen; +} + +static bool cur_account(HiveCur* c, const uint8_t** s, uint16_t* slen, + bool allow_empty) { + if (!cur_string(c, s, slen, allow_empty ? 0 : 1, 16)) return false; + return hive_account_name_valid(*s, *slen, allow_empty); +} + +static int hive_slice_cmp(const uint8_t* a, uint16_t a_len, const uint8_t* b, + uint16_t b_len) { + uint16_t min_len = a_len < b_len ? a_len : b_len; + int cmp = memcmp(a, b, min_len); + if (cmp != 0) return cmp; + return (a_len > b_len) - (a_len < b_len); +} + +/* Fixed-width little-endian readers, bounds-checked against the buffer. */ +static bool cur_u16(HiveCur* c, uint16_t* out) { + if ((size_t)(c->end - c->p) < 2) return false; + *out = (uint16_t)((uint16_t)c->p[0] | ((uint16_t)c->p[1] << 8)); + c->p += 2; + return true; +} + +static bool cur_u32(HiveCur* c, uint32_t* out) { + if ((size_t)(c->end - c->p) < 4) return false; + *out = (uint32_t)c->p[0] | ((uint32_t)c->p[1] << 8) | + ((uint32_t)c->p[2] << 16) | ((uint32_t)c->p[3] << 24); + c->p += 4; + return true; +} + +/* + * Graphene serializes bool as one byte. Anything other than 0/1 is a host + * serializer bug, not a truthy value — reject rather than normalize, so a + * malformed fill_or_kill or allow_votes can never be silently coerced. + */ +static bool cur_bool(HiveCur* c, bool* out) { + if (c->p >= c->end) return false; + uint8_t b = *c->p++; + if (b > 1) return false; + *out = (b == 1); + return true; +} + +uint64_t hive_assetAmount(const uint8_t* asset) { + uint64_t v = 0; + for (int i = 7; i >= 0; i--) v = (v << 8) | asset[i]; + return v; +} + +uint8_t hive_assetPrecision(const uint8_t* asset) { return asset[8]; } + +// Wire symbol → display symbol. The chain serializes the pre-rebrand names; +// the user knows the post-rebrand ones. cur_asset() has already validated the +// symbol and its NUL padding, so the compares below are exact. +const char* hive_assetSymbol(const uint8_t* asset) { + const char* sym = (const char*)(asset + 9); + if (memcmp(sym, "STEEM", 6) == 0) return "HIVE"; + if (memcmp(sym, "SBD", 4) == 0) return "HBD"; + return sym; +} + +/* + * One 16-byte Graphene asset: int64 LE amount, uint8 precision, 7-byte + * NUL-padded symbol. + * + * The symbol must be in `allowed` and carry its protocol-fixed precision. + * Both checks are load-bearing for display integrity: an unexpected symbol + * lets a host swap VESTS for HIVE (a ~2000x difference in real value behind + * an identical-looking number), and a wrong precision moves the decimal + * point on the confirmation screen relative to what the chain applies. + */ +static bool cur_asset(HiveCur* c, const uint8_t** out, uint32_t allowed) { + if ((size_t)(c->end - c->p) < HIVE_ASSET_LEN) return false; + const uint8_t* a = c->p; + const uint8_t* sym = a + 9; + + uint32_t bit; + uint8_t want_precision; + size_t sym_len; + // WIRE symbols, not display symbols: the 2020 rebrand renamed the tokens but + // NOT their on-chain serialization, so hived still encodes HIVE as "STEEM" + // and HBD as "SBD". Accepting the display spellings would let us sign bytes + // hived can never validate — its signature check re-serializes the operation + // and recovers a key from different bytes, surfacing as the misleading + // "missing required active authority". hive_assetSymbol() maps back for the + // OLED so the user still reads HIVE/HBD. + if (memcmp(sym, "STEEM", 5) == 0) { + bit = HIVE_SYM_HIVE; + want_precision = 3; + sym_len = 5; + } else if (memcmp(sym, "SBD", 3) == 0) { + bit = HIVE_SYM_HBD; + want_precision = 3; + sym_len = 3; + } else if (memcmp(sym, "VESTS", 5) == 0) { + bit = HIVE_SYM_VESTS; + want_precision = 6; + sym_len = 5; + } else { + return false; + } + // The prefix compares above would also accept a longer symbol sharing the + // prefix ("HBDX"); the padding check is what makes them exact, and it also + // guarantees hive_assetSymbol() returns a NUL-terminated C string. + for (size_t i = sym_len; i < 7; i++) { + if (sym[i] != 0) return false; + } + if (!(bit & allowed)) return false; + if (a[8] != want_precision) return false; + // Every asset field in this table is a quantity. A negative int64 would + // render as an enormous positive number through the unsigned formatter. + if (a[7] & 0x80) return false; + + *out = a; + c->p += HIVE_ASSET_LEN; + return true; +} + +/* + * Shared rejection reasons. + * + * These are diagnostics, not security surface: the protection is that the + * device REFUSES, and the host already knows which operation it sent. One + * bespoke sentence per failure site cost ~1.8KB of rodata on a part with + * single-digit KB of flash left, so failures are grouped by reason instead. + * The three that carry a distinct security meaning — an authority rotation, + * a detached comment_options, a wrong-tier request — stay separate so they + * are never confused with an ordinary parse failure in a bug report. + */ +static const char E_MALFORMED[] = "Hive tx: malformed operation"; +static const char E_RANGE[] = "Hive tx: value out of range"; +static const char E_AMOUNT[] = "Hive tx: amount must be greater than zero"; +static const char E_NOOP[] = "Hive tx: operation has no effect"; +static const char E_EXTENSIONS[] = "Hive tx: extensions must be empty"; +static const char E_BENEFICIARIES[] = "Hive tx: invalid beneficiaries"; +static const char E_SYMBOLS[] = "Hive tx: order symbols must differ"; +static const char E_AUTHORITY[] = "Hive tx: authority changes not supported"; +static const char E_BINDING[] = + "Hive tx: comment_options must follow its comment"; +static const char E_MIXED_TIER[] = "Hive tx: mixed posting/active ops"; + +const char* hive_parseOperations(const uint8_t* tx, size_t len, + HiveParsedTx* out) { + memzero(out, sizeof(*out)); + // 10-byte header + op_count varint + extensions varint is the structural + // minimum; op bodies are bounds-checked as they parse. + if (len < 12) return "Hive tx too short"; + if (len > HIVE_MAX_OPS_TX_LEN) return "Hive tx too long"; // = proto cap + + // Header (ref_block_num u16, ref_block_prefix u32, expiration u32) is + // covered by the signature but carries nothing to confirm on-device. + HiveCur c = {tx + 10, tx + len}; + + uint32_t op_count; + if (!cur_varint(&c, &op_count)) return E_MALFORMED; + if (op_count < 1 || op_count > HIVE_MAX_TX_OPS) + return "Hive tx: op count must be 1-4"; + out->num_ops = (uint8_t)op_count; + + bool any_posting = false, any_active = false; + + for (uint32_t i = 0; i < op_count; i++) { + HiveTxOp* op = &out->ops[i]; + uint32_t op_type; + if (!cur_varint(&c, &op_type)) return E_MALFORMED; + op->op_type = op_type; + + switch (op_type) { + case HIVE_OP_VOTE: { // posting authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_string(&c, &op->detail, &op->detail_len, 1, 256)) + return E_MALFORMED; + if ((size_t)(c.end - c.p) < 2) return E_MALFORMED; + int16_t w = (int16_t)((uint16_t)c.p[0] | ((uint16_t)c.p[1] << 8)); + c.p += 2; + if (w < -10000 || w > 10000) return E_RANGE; + op->weight = w; + any_posting = true; + break; + } + case HIVE_OP_COMMENT: { // posting authority + const uint8_t *pa, *ppl, *permlink, *jm; + uint16_t pa_len, ppl_len, permlink_len, jm_len; + if (!cur_account(&c, &pa, &pa_len, true) || + !cur_string(&c, &ppl, &ppl_len, 1, 256) || + !cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_string(&c, &permlink, &permlink_len, 1, 256) || + !cur_string(&c, &op->target, &op->target_len, 0, 256) || + !cur_string(&c, &op->detail, &op->detail_len, 1, + HIVE_MAX_OPS_TX_LEN) || + !cur_string(&c, &jm, &jm_len, 0, HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + op->parent_author = pa; + op->parent_author_len = pa_len; + op->parent_permlink = ppl; + op->parent_permlink_len = ppl_len; + op->permlink = permlink; + op->permlink_len = permlink_len; + op->json_metadata = jm; + op->json_metadata_len = jm_len; + op->is_top_level = (pa_len == 0); + any_posting = true; + break; + } + case HIVE_OP_CUSTOM_JSON: { // posting OR active authority + uint32_t n_active, n_posting; + if (!cur_varint(&c, &n_active)) return E_MALFORMED; + if (n_active > HIVE_MAX_CUSTOM_JSON_AUTHS) return E_RANGE; + const uint8_t* previous_auth = NULL; + uint16_t previous_auth_len = 0; + for (uint32_t k = 0; k < n_active; k++) { + const uint8_t* s; + uint16_t sl; + if (!cur_account(&c, &s, &sl, false)) return E_MALFORMED; + if (previous_auth && + hive_slice_cmp(previous_auth, previous_auth_len, s, sl) >= 0) + return E_MALFORMED; + op->auth_acct[op->n_auths] = s; + op->auth_acct_len[op->n_auths++] = sl; + previous_auth = s; + previous_auth_len = sl; + if (!op->acct) { + op->acct = s; + op->acct_len = sl; + } + } + if (!cur_varint(&c, &n_posting)) return E_MALFORMED; + if (n_posting > HIVE_MAX_CUSTOM_JSON_AUTHS - n_active) return E_RANGE; + previous_auth = NULL; + previous_auth_len = 0; + for (uint32_t k = 0; k < n_posting; k++) { + const uint8_t* s; + uint16_t sl; + if (!cur_account(&c, &s, &sl, false)) return E_MALFORMED; + if (previous_auth && + hive_slice_cmp(previous_auth, previous_auth_len, s, sl) >= 0) + return E_MALFORMED; + op->auth_acct[op->n_auths] = s; + op->auth_acct_len[op->n_auths++] = sl; + previous_auth = s; + previous_auth_len = sl; + if (!op->acct) { + op->acct = s; + op->acct_len = sl; + } + } + if (n_active + n_posting == 0) return E_MALFORMED; + // Both tiers on one op can never be satisfied by a single signature + // (post-HF28 hived requires the exact authority) — malformed input. + if (n_active > 0 && n_posting > 0) return E_MIXED_TIER; + if (!cur_string(&c, &op->target, &op->target_len, 1, 32) || + !cur_string(&c, &op->detail, &op->detail_len, 1, + HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + op->needs_active = (n_active > 0); + if (op->needs_active) + any_active = true; + else + any_posting = true; + break; + } + case HIVE_OP_TRANSFER_TO_VESTING: { // active authority + // `to` may be empty — hived reads that as "power up to self". + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, true) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_WITHDRAW_VESTING: { // active authority + // 0.000000 VESTS is meaningful here: it cancels an in-progress + // power-down, so zero must NOT be rejected. + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_VESTS)) + return E_MALFORMED; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_LIMIT_ORDER_CREATE: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_asset(&c, &op->assets[1], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_bool(&c, &op->flag) || !cur_u32(&c, &op->expiration)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0 || + hive_assetAmount(op->assets[1]) == 0) + return E_AMOUNT; + // The internal market only pairs HIVE against HBD. A same-symbol + // order is rejected on-chain anyway, and on the OLED it would read + // as a harmless self-trade while burning the fill. + if (memcmp(op->assets[0] + 9, op->assets[1] + 9, 7) == 0) + return E_SYMBOLS; + op->n_assets = 2; + any_active = true; + break; + } + case HIVE_OP_LIMIT_ORDER_CANCEL: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id)) + return E_MALFORMED; + any_active = true; + break; + } + case HIVE_OP_CONVERT: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HBD)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_COMMENT_OPTIONS: { // posting authority + uint16_t percent_hbd; + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_string(&c, &op->permlink, &op->permlink_len, 1, 256) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HBD) || + !cur_u16(&c, &percent_hbd) || !cur_bool(&c, &op->flag) || + !cur_bool(&c, &op->flag2)) + return E_MALFORMED; + if (percent_hbd > 10000) return E_RANGE; + op->weight = (int16_t)percent_hbd; + op->n_assets = 1; + + // SECURITY: this op redirects a post's payout. It binds to exactly + // one post, so it is accepted ONLY immediately after a comment op + // with the same author and permlink. Standing alone it could attach + // beneficiaries to a post the user published earlier and is not + // reviewing on this screen. + if (i == 0 || out->ops[i - 1].op_type != HIVE_OP_COMMENT) + return E_BINDING; + const HiveTxOp* prev = &out->ops[i - 1]; + if (prev->acct_len != op->acct_len || + memcmp(prev->acct, op->acct, op->acct_len) != 0 || + prev->permlink_len != op->permlink_len || + memcmp(prev->permlink, op->permlink, op->permlink_len) != 0) + return E_BINDING; + + uint32_t ext_n; + if (!cur_varint(&c, &ext_n)) return E_MALFORMED; + // hived permits only one comment_payout_beneficiaries extension; + // two would let a host split 16 beneficiaries past a per-extension + // bound check. + if (ext_n > 1) return E_BENEFICIARIES; + if (ext_n == 1) { + uint32_t tag, n_benef; + if (!cur_varint(&c, &tag) || tag != 0) return E_BENEFICIARIES; + if (!cur_varint(&c, &n_benef) || n_benef < 1 || + n_benef > HIVE_MAX_BENEFICIARIES) + return E_BENEFICIARIES; + uint32_t weight_sum = 0; + const uint8_t* prev_acct = NULL; + uint16_t prev_acct_len = 0; + for (uint32_t k = 0; k < n_benef; k++) { + if (!cur_account(&c, &op->benef_acct[k], &op->benef_acct_len[k], + false) || + !cur_u16(&c, &op->benef_weight[k])) + return E_MALFORMED; + if (op->benef_weight[k] > 10000) return E_RANGE; + // hived requires strictly ascending account names, which also + // enforces uniqueness. An unsorted list is rejected on-chain, so + // signing it would only waste a device confirmation. + if (prev_acct) { + if (hive_slice_cmp(prev_acct, prev_acct_len, op->benef_acct[k], + op->benef_acct_len[k]) >= 0) + return E_BENEFICIARIES; + } + prev_acct = op->benef_acct[k]; + prev_acct_len = op->benef_acct_len[k]; + weight_sum += op->benef_weight[k]; + } + if (weight_sum > 10000) return E_BENEFICIARIES; + op->n_benef = (uint8_t)n_benef; + } + any_posting = true; + break; + } + case HIVE_OP_TRANSFER_TO_SAVINGS: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_string(&c, &op->detail, &op->detail_len, 0, HIVE_MAX_MEMO_LEN)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_TRANSFER_FROM_SAVINGS: { // active authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_u32(&c, &op->req_id) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE | HIVE_SYM_HBD) || + !cur_string(&c, &op->detail, &op->detail_len, 0, HIVE_MAX_MEMO_LEN)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0) return E_AMOUNT; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_CLAIM_REWARD_BALANCE: { // posting authority + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_HIVE) || + !cur_asset(&c, &op->assets[1], HIVE_SYM_HBD) || + !cur_asset(&c, &op->assets[2], HIVE_SYM_VESTS)) + return E_MALFORMED; + if (hive_assetAmount(op->assets[0]) == 0 && + hive_assetAmount(op->assets[1]) == 0 && + hive_assetAmount(op->assets[2]) == 0) + return E_NOOP; + op->n_assets = 3; + any_posting = true; + break; + } + case HIVE_OP_DELEGATE_VESTING_SHARES: { // active authority + // 0.000000 VESTS is meaningful: it removes an existing delegation. + if (!cur_account(&c, &op->acct, &op->acct_len, false) || + !cur_account(&c, &op->target, &op->target_len, false) || + !cur_asset(&c, &op->assets[0], HIVE_SYM_VESTS)) + return E_MALFORMED; + op->n_assets = 1; + any_active = true; + break; + } + case HIVE_OP_ACCOUNT_UPDATE2: { // active or posting authority + uint32_t ext_n; + if (!cur_account(&c, &op->acct, &op->acct_len, false)) + return E_MALFORMED; + // SECURITY: account_update2 can rotate owner/active/posting/memo + // keys. Only the profile-metadata form is in the table — this is the + // op-9/10 device-derived-keys invariant applied field-level. Any + // authority field present is a hard reject; do NOT soften this + // without the authority-management design review. + for (int k = 0; k < 4; k++) { + bool present; + if (!cur_bool(&c, &present)) return E_MALFORMED; + if (present) return E_AUTHORITY; + } + if (!cur_string(&c, &op->detail, &op->detail_len, 0, + HIVE_MAX_OPS_TX_LEN) || + !cur_string(&c, &op->json_metadata, &op->json_metadata_len, 0, + HIVE_MAX_OPS_TX_LEN)) + return E_MALFORMED; + if (op->detail_len == 0 && op->json_metadata_len == 0) return E_NOOP; + if (!cur_varint(&c, &ext_n)) return E_MALFORMED; + if (ext_n != 0) return E_EXTENSIONS; + // json_metadata is an active-key field; a posting_json_metadata-only + // update is a posting-tier profile change. + op->needs_active = (op->detail_len > 0); + if (op->needs_active) + any_active = true; + else + any_posting = true; + break; + } + case HIVE_OP_TRANSFER: + case HIVE_OP_ACCOUNT_CREATE: + case HIVE_OP_ACCOUNT_UPDATE: + // PERMANENTLY excluded from this table: transfer keeps the stronger + // dedicated HiveSignTx display path; the account ops keep the + // device-derived-keys-only invariant (a generic raw-bytes path + // would let a host slip third-party authorities into an + // account_update). Never add these here. + return "Hive tx: op requires its dedicated message type"; + default: + return "Hive tx: unsupported operation type"; + } + } + + uint32_t ext_count; + if (!cur_varint(&c, &ext_count)) return E_MALFORMED; + if (ext_count != 0) return E_EXTENSIONS; + if (c.p != c.end) return "Hive tx: trailing bytes"; + + // One signature cannot satisfy posting- and active-tier ops at once. + if (any_posting && any_active) return E_MIXED_TIER; + out->needs_active = any_active; + return NULL; +} + +void hive_signOperations(const HDNode* node, const HiveSignOperations* msg, + HiveSignedOperations* resp) { + if (!msg->has_serialized_tx || msg->serialized_tx.size == 0 || + msg->serialized_tx.size > HIVE_MAX_OPS_TX_LEN) + return; + + // Hash straight from the decoded message — no stack copy of the 2KB tx. + if (!hive_sign_tx_sig(node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, msg->serialized_tx.bytes, + msg->serialized_tx.size, resp->signature.bytes)) { + return; + } + + resp->has_signature = true; + resp->signature.size = 65; +} + +// ── Message signing (Keychain signBuffer contract) ──────────────────────── +// Digest is SHA256(message bytes) ONLY: no chain_id prepend (unlike +// transactions) and no Bitcoin/Solana-style message prefix. hive-js +// Signature.signBuffer — which every Hive dApp verifies against — hashes +// the raw bytes exactly once; any added prefix silently breaks all dApp +// verification. + +bool hive_message_is_printable(const uint8_t* message, size_t len) { + for (size_t i = 0; i < len; i++) { + if (message[i] < 0x20 || message[i] > 0x7e) return false; + } + return true; +} + +void hive_signMessage(const HDNode* node, const HiveSignMessage* msg, + HiveSignedMessage* resp) { + if (!msg->has_message || msg->message.size > HIVE_MAX_MESSAGE_LEN) return; + + uint8_t digest[32]; + sha256_Raw(msg->message.bytes, msg->message.size, digest); + + uint8_t sig[65]; + if (!hive_sign_raw_digest(node, digest, sig)) { + memzero(digest, sizeof(digest)); + memzero(sig, sizeof(sig)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + memcpy(resp->signature.bytes, sig, 65); + + // Caller must have run hdnode_fill_public_key(node). Returned so the host + // can build Keychain's publicKey response field without a second call. + resp->has_public_key = true; + resp->public_key.size = 33; + memcpy(resp->public_key.bytes, node->public_key, 33); + + memzero(digest, sizeof(digest)); + memzero(sig, sizeof(sig)); +} + +// ── Transfer (op type 2) ────────────────────────────────────────────────── + +static size_t hive_serialize_transfer(const HiveSignTx* msg, uint8_t* buf, + size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, HIVE_OP_TRANSFER); + + append_string(&p, end, msg->has_from ? msg->from : ""); + append_string(&p, end, msg->has_to ? msg->to : ""); + + const char* sym = msg->has_asset_symbol ? msg->asset_symbol : "HIVE"; + uint8_t prec = (uint8_t)(msg->has_decimals ? msg->decimals : HIVE_DECIMALS); + append_asset(&p, end, msg->amount, prec, sym); + + append_string(&p, end, msg->has_memo ? msg->memo : ""); + append_tx_footer(&p, end); + return (size_t)(p - buf); +} + +void hive_signTx(const HDNode* node, const HiveSignTx* msg, + HiveSignedTx* resp) { + // Reject memos that would overflow the fixed-size tx_buf. + if (msg->has_memo && strlen(msg->memo) > HIVE_MAX_MEMO_LEN) return; + + uint8_t tx_buf[512]; + size_t tx_len = hive_serialize_transfer(msg, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} + +// ── Account create (op type 9) ──────────────────────────────────────────── +// +// All four role keys are device-derived by the caller (FSM handler) and +// passed as raw 33-byte compressed public keys. The firmware never uses +// host-supplied key strings for the actual transaction. + +static size_t hive_serialize_account_create(const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + uint8_t* buf, size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, + HIVE_OP_ACCOUNT_CREATE); + + // fee (asset) + uint64_t fee = msg->has_fee_amount ? msg->fee_amount : 3000; + append_asset(&p, end, fee, HIVE_DECIMALS, "HIVE"); + + // creator + append_string(&p, end, msg->has_creator ? msg->creator : ""); + + // new_account_name + append_string(&p, end, + msg->has_new_account_name ? msg->new_account_name : ""); + + // authority fields use device-derived raw bytes (no host trust, no type + // prefix) + append_authority(&p, end, owner_raw); + append_authority(&p, end, active_raw); + append_authority(&p, end, posting_raw); + + // memo_key: 33 raw bytes, no authority wrapper, no type prefix byte + for (int i = 0; i < 33 && p < end; i++) append_u8(&p, end, memo_raw[i]); + + // json_metadata (empty) + append_string(&p, end, ""); + append_tx_footer(&p, end); + + return (size_t)(p - buf); +} + +void hive_signAccountCreate(const HDNode* signing_node, + const HiveSignAccountCreate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountCreate* resp) { + uint8_t tx_buf[512]; + size_t tx_len = + hive_serialize_account_create(msg, owner_raw, active_raw, posting_raw, + memo_raw, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(signing_node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + memzero(tx_buf, sizeof(tx_buf)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} + +// ── Account update (op type 10) ─────────────────────────────────────────── +// +// All four new role keys are device-derived by the caller (FSM handler). +// The host-supplied new_*_key fields in the message are not used for signing. + +static size_t hive_serialize_account_update(const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + uint8_t* buf, size_t buf_len) { + uint8_t* p = buf; + const uint8_t* end = buf + buf_len; + + append_tx_header(&p, end, (uint16_t)(msg->ref_block_num & 0xFFFF), + msg->ref_block_prefix, msg->expiration, + HIVE_OP_ACCOUNT_UPDATE); + + // account name + append_string(&p, end, msg->has_account ? msg->account : ""); + + /* + * account_update optional authority fields use a Graphene "optional" wrapper: + * present: 0x01 + authority bytes + * absent: 0x00 + * We always include all four — this replaces all authorities. + */ + append_u8(&p, end, 0x01); // owner present + append_authority(&p, end, owner_raw); + append_u8(&p, end, 0x01); // active present + append_authority(&p, end, active_raw); + append_u8(&p, end, 0x01); // posting present + append_authority(&p, end, posting_raw); + + // memo_key: 33 raw bytes, always present, no type prefix byte + for (int i = 0; i < 33 && p < end; i++) append_u8(&p, end, memo_raw[i]); + + // json_metadata (empty) + append_string(&p, end, ""); + append_tx_footer(&p, end); + + return (size_t)(p - buf); +} + +void hive_signAccountUpdate(const HDNode* signing_node, + const HiveSignAccountUpdate* msg, + const uint8_t owner_raw[33], + const uint8_t active_raw[33], + const uint8_t posting_raw[33], + const uint8_t memo_raw[33], + HiveSignedAccountUpdate* resp) { + uint8_t tx_buf[512]; + size_t tx_len = + hive_serialize_account_update(msg, owner_raw, active_raw, posting_raw, + memo_raw, tx_buf, sizeof(tx_buf)); + + if (!hive_sign_tx_sig(signing_node, msg->has_chain_id, msg->chain_id.bytes, + msg->chain_id.size, tx_buf, tx_len, + resp->signature.bytes)) { + memzero(tx_buf, sizeof(tx_buf)); + return; + } + + resp->has_signature = true; + resp->signature.size = 65; + + resp->has_serialized_tx = true; + resp->serialized_tx.size = tx_len; + memcpy(resp->serialized_tx.bytes, tx_buf, tx_len); + + memzero(tx_buf, tx_len); +} diff --git a/lib/firmware/mayachain.c b/lib/firmware/mayachain.c index 88c1ff3b5..7b18b99de 100644 --- a/lib/firmware/mayachain.c +++ b/lib/firmware/mayachain.c @@ -29,18 +29,28 @@ #include "trezor/crypto/segwit_addr.h" #include -#include #include #include +bool mayachain_isValidDenom(const char* denom) { + return tendermint_isValidDenom(denom); +} + +bool mayachain_isValidAsset(const char* asset) { + return tendermint_isValidAsset(asset); +} + static CONFIDENTIAL HDNode node; static SHA256_CTX ctx; static bool initialized; -static bool has_message; static uint32_t msgs_remaining; static MayachainSignTx msg; static bool testnet; +bool mayachain_isValidSigner(const char* signer) { + return tendermint_isValidSigner(signer, testnet ? "smaya" : "maya"); +} + const MayachainSignTx* mayachain_getMayachainSignTx(void) { return &msg; } bool mayachain_formatAmount(uint64_t amount, const char* denom, char* out, @@ -57,12 +67,7 @@ bool mayachain_formatAmount(uint64_t amount, const char* denom, char* out, } bool mayachain_signTxInit(const HDNode* _node, const MayachainSignTx* _msg) { - mayachain_signAbort(); - if (!_node || !_msg || !_msg->has_msg_count || _msg->msg_count == 0 || - !_msg->has_chain_id || !tendermint_validateSafeText(_msg->chain_id)) { - return false; - } - + initialized = true; msgs_remaining = _msg->msg_count; testnet = false; @@ -112,19 +117,11 @@ bool mayachain_signTxInit(const HDNode* _node, const MayachainSignTx* _msg) { // 10 sha256_Update(&ctx, (uint8_t*)"\",\"msgs\":[", 10); - if (!success) { - mayachain_signAbort(); - return false; - } - initialized = true; - return true; + return success; } bool mayachain_signTxUpdateMsgSend(const uint64_t amount, const char* to_address, const char* denom) { - if (!initialized || msgs_remaining == 0) return false; - if (!tendermint_validateSafeText(denom)) return false; - const char mainnetp[] = "maya"; const char testnetp[] = "smaya"; const char* pfix; @@ -170,8 +167,12 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, return false; } - if (has_message) { - sha256_Update(&ctx, (uint8_t*)",", 1); + // 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; @@ -179,11 +180,14 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, 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), @@ -193,27 +197,18 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), ",\"to_address\":\"%s\"}}", to_address); - if (success) { - has_message = true; - } msgs_remaining--; return success; } bool mayachain_signTxUpdateMsgDeposit(const MayachainMsgDeposit* depmsg) { - if (!initialized || msgs_remaining == 0) return false; - - const char* const signer_prefix = testnet ? "smaya" : "maya"; - if (!depmsg || !depmsg->has_asset || - !tendermint_validateSafeText(depmsg->asset) || !depmsg->has_signer || - !tendermint_validateBech32Address(depmsg->signer, signer_prefix)) { - return false; - } - char buffer[64 + 1]; - if (has_message) { - sha256_Update(&ctx, (uint8_t*)",", 1); + // Defended here too (not just by the FSM caller) so this signing path is + // safe even if called directly or reused elsewhere later. + if (!mayachain_isValidAsset(depmsg->asset) || + !mayachain_isValidSigner(depmsg->signer)) { + return false; } bool success = true; @@ -226,9 +221,11 @@ bool mayachain_signTxUpdateMsgDeposit(const MayachainMsgDeposit* depmsg) { "\"coins\":[{\"amount\":\"%" PRIu64 "\"", depmsg->amount); - // 10 + ^20 + 3 = ^33 - success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), - ",\"asset\":\"%s\"}]", depmsg->asset); + // Use escaping as defense-in-depth; valid assets have no escapable chars + const char* const asset_prefix = ",\"asset\":\""; + sha256_Update(&ctx, (uint8_t*)asset_prefix, strlen(asset_prefix)); + tendermint_sha256UpdateEscaped(&ctx, depmsg->asset, strlen(depmsg->asset)); + sha256_Update(&ctx, (uint8_t*)"\"}]", 3); // const char* const memo_prefix = ",\"memo\":\""; @@ -239,9 +236,6 @@ bool mayachain_signTxUpdateMsgDeposit(const MayachainMsgDeposit* depmsg) { success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "\",\"signer\":\"%s\"}}", depmsg->signer); - if (success) { - has_message = true; - } msgs_remaining--; return success; } @@ -281,79 +275,44 @@ bool mayachain_addressIsSigner(const char* address) { bool mayachain_signingIsInited(void) { return initialized; } -bool mayachain_signingIsFinished(void) { - return msgs_remaining == 0 && has_message; -} +bool mayachain_signingIsFinished(void) { return msgs_remaining == 0; } void mayachain_signAbort(void) { initialized = false; - has_message = false; msgs_remaining = 0; memzero(&msg, sizeof(msg)); memzero(&node, sizeof(node)); } -/* Maya inherited THORChain's strtok-based parser. strtok() collapses empty - * components even though memo fields are positional, so a valid `::` can - * shift an affiliate into the limit slot while producing the same structured - * screens as different signed bytes. Until this parser understands empty - * positions explicitly, route such memos to the caller's raw-byte review. */ -static bool mayachain_memo_has_empty_component(const char* memo, size_t size) { - if (!memo || size == 0) return true; - - for (size_t i = 0; i < size; i++) { - if (memo[i] != ':' && memo[i] != '.') continue; - - if (i == 0 || i + 1 == size || memo[i - 1] == ':' || memo[i - 1] == '.' || - memo[i + 1] == ':' || memo[i + 1] == '.') { - return true; - } - } - - return false; -} - +/* Validate the chain/asset separator before the positional parser labels + * fields. Empty colon-delimited fields remain meaningful and supported. */ static bool mayachain_memo_has_canonical_separators(const char* memo, size_t size) { - /* The grammar is OP:CHAIN.ASSET:DEST:LIMIT[:AFFILIATE:BPS] -- ':' between - fields, '.' only inside the chain/asset pair. - - The tokenizer below cannot tell the two apart. After splitting the - operation on ':' it calls strtok(NULL, ":.") three times, so ':' and '.' - are interchangeable for everything it reads. A memo that puts a colon - where the dot belongs, - - SWAP:ETH:USDT:dest:limit - - therefore produces exactly the same three tokens as SWAP:ETH.USDT:... and - is reviewed as "asset USDT on chain ETH", while THORChain/MAYAChain read - that same memo with USDT as the DESTINATION -- every field after the - operation shifts by one, including the address the funds go to. The screen - and the protocol disagree about a memo the signature covers. - - Require the dot exactly once and only inside the second colon-delimited - field. Anything else is not this grammar, so it goes to the raw-byte path - rather than through a parser that would mislabel it. A destination that - legitimately contains a dot is refused here too; disclosure of the exact - bytes is the safe direction, and this parser is fail-closed by design. */ + /* The grammar requires OP:CHAIN.ASSET. Dots in later positional fields are + * data, so they must not be confused with the one separator required in + * field 1. */ if (!memo || size == 0) return false; size_t field = 0; - size_t dots_total = 0; size_t dots_in_asset_field = 0; + bool has_chain = false; + bool has_asset = false; for (size_t i = 0; i < size; i++) { if (memo[i] == ':') { field++; continue; } - if (memo[i] == '.') { - dots_total++; - if (field == 1) dots_in_asset_field++; - } + if (field != 1) continue; + if (memo[i] == '.') + dots_in_asset_field++; + else if (dots_in_asset_field == 0) + has_chain = true; + else + has_asset = true; } - return dots_total == 1 && dots_in_asset_field == 1; + return dots_in_asset_field == 1 && has_chain && has_asset; } static bool mayachain_memo_is_structured_text(const char* memo, size_t size) { @@ -386,131 +345,122 @@ MayachainMemoResult mayachain_parseConfirmMemo(const char* swapStr, size_t size) { /* Input: swapStr is candidate mayachain data - size is the size of swapStr (<= 255) + size is the size of swapStr (<= 256) Memos should be of the form: - transaction:chain.ticker-id:destination:limit[:affiliate:fee_bps...] + 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 past the ones labelled below (affiliate, affiliate fee, aggregator - routing) are executed by MAYAChain, so each branch pages whatever is left - rather than signing it unseen. Mirrors thorchain.c -- Maya is a fork of - that path and kept the original code. + 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 /* One byte short of the buffer, so a full-length memo is still terminated by the memzero below. */ if (size >= sizeof(memoBuf) || - mayachain_memo_has_empty_component(swapStr, size) || !mayachain_memo_is_structured_text(swapStr, size) || !mayachain_memo_has_canonical_separators(swapStr, size)) { return MAYACHAIN_MEMO_UNPARSED; } memzero(memoBuf, sizeof(memoBuf)); - - /* `size` is a byte count and swapStr is NOT guaranteed to be NUL terminated. - strlcpy copied only size-1 of them, silently dropping the memo's last - character -- an affiliate fee of "75" bps rendered as "7" -- and then - walked past the end of the source looking for a terminator. Copy exactly - `size` bytes; the memzero'd tail terminates them. - Same defect as the THORChain path; Maya is a fork of it and kept the - original code. */ + /* 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); - /* Refuse a declared length that does not describe its own content. - - Be exact about what this does and does not buy on THIS chain, because the - wording copied from thorchain.c overstated it. On THORChain the same check - closes a live disclosure gap: two of its callers pass an EXTERNALLY - declared length -- a BTC OP_RETURN script length (transaction.c) and an - ABI length word (thortx.c) -- and the signature covers every byte of it, - so a memo carrying an embedded zero parsed as if it ended there while the - suffix stayed signed. - - Maya has no such caller. Both call sites pass strnlen() - (fsm_msg_mayachain.h), and the signer hashes strlen(memo) (lines 88 and 165 - above), so parsing and signing already stop at the same byte: nothing after - an embedded NUL is signed, and there is no gap here to close. - - The check stays anyway, for two reasons that are worth stating rather than - dressing up as a fix. A length that misdescribes its content is a - non-canonical encoding and the device should not clear-sign one. And it - keeps this parser safe by construction if Maya ever gains a length-passing - caller of its own, which is exactly how THORChain acquired the real bug. */ - for (uint16_t i = 0; i < size; i++) { + /* The field split below treats memoBuf as a C string and stops at the first + NUL, but all `size` bytes are covered by the signature. A memo carrying an + embedded zero would parse and confirm as if it ended there while the + suffix stayed signed. A length word that does not describe its own content + is a non-canonical encoding, so refuse it and let the caller disclose the + raw bytes. Mirrors thorchain.c. */ + for (i = 0; i < size; i++) { if (memoBuf[i] == '\0') return MAYACHAIN_MEMO_UNPARSED; } - tok = strtok(memoBuf, ":"); - - // get transaction and asset - for (ctr = 0; ctr < 3; ctr++) { - if (tok != NULL) { - parseTokPtrs[ctr] = tok; - tok = strtok(NULL, ":."); - } else { - break; + // 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 MAYACHAIN_MEMO_UNPARSED; } + // 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 MAYACHAIN_MEMO_UNPARSED; + } + *asset = '\0'; + asset++; + // Check for swap - if (strcmp(parseTokPtrs[0], "SWAP") == 0 || - strcmp(parseTokPtrs[0], "s") == 0 || strcmp(parseTokPtrs[0], "=") == 0) { + if (strcmp(fields[0], "SWAP") == 0 || strcmp(fields[0], "s") == 0 || + strcmp(fields[0], "=") == 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 bool has_fee = nfields > 5 && fields[5][0] != '\0'; + const char* fee_bps = has_fee ? fields[5] : "unspecified"; + uint16_t parsed_fee_bps = 0; + if (has_fee && !mayachain_parse_bps(fee_bps, &parsed_fee_bps)) { + return MAYACHAIN_MEMO_UNPARSED; } 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 MAYACHAIN_MEMO_CANCELLED; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain swap", "Confirm to %s", parseTokPtrs[3])) { + "Mayachain swap", "Confirm to %s", dest)) { return MAYACHAIN_MEMO_CANCELLED; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain swap", "Confirm limit %s", parseTokPtrs[4])) { + "Mayachain swap", "Confirm limit %s", limit)) { return MAYACHAIN_MEMO_CANCELLED; } - /* Everything after the limit - affiliate, affiliate fee in basis points, - DEX-aggregator routing - is executed by MAYAChain but was never shown. - The whole memo is hashed by strlen() in signTxUpdateMsgDeposit(), so a - suffix such as ":affiliate:75" was signed unseen. Page each remaining - field rather than sign it unseen. */ - while ((tok = strtok(NULL, ":")) != NULL) { + // Never hide the affiliate fee skim from the user + if (affiliate != NULL || has_fee) { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain swap", "Additional memo field\n%s", tok)) { + "Mayachain swap", "Affiliate fee %s bps to %s", fee_bps, + affiliate ? affiliate : "(none given)")) { return MAYACHAIN_MEMO_CANCELLED; } } @@ -518,33 +468,19 @@ MayachainMemoResult mayachain_parseConfirmMemo(const char* swapStr, } // Check for add liquidity - else if (strcmp(parseTokPtrs[0], "ADD") == 0 || - strcmp(parseTokPtrs[0], "a") == 0 || - strcmp(parseTokPtrs[0], "+") == 0) { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; - } + else if (strcmp(fields[0], "ADD") == 0 || strcmp(fields[0], "a") == 0 || + strcmp(fields[0], "+") == 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 MAYACHAIN_MEMO_CANCELLED; } - if (tok != NULL) { + if (pool != NULL) { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain add liquidity", "Confirm to %s", - parseTokPtrs[3])) { - return MAYACHAIN_MEMO_CANCELLED; - } - } - /* ADD:POOL:PAIREDADDR:AFFILIATE:FEE - the affiliate and its fee are - optional but router-executed, so neither may be hidden. */ - while ((tok = strtok(NULL, ":")) != NULL) { - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain add liquidity", "Additional memo field\n%s", - tok)) { + "Mayachain add liquidity", "Confirm to %s", pool)) { return MAYACHAIN_MEMO_CANCELLED; } } @@ -552,34 +488,35 @@ MayachainMemoResult mayachain_parseConfirmMemo(const char* swapStr, } // Check for withdraw liquidity - else if (strcmp(parseTokPtrs[0], "WITHDRAW") == 0 || - strcmp(parseTokPtrs[0], "wd") == 0 || - strcmp(parseTokPtrs[0], "-") == 0) { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; - } else { + else if (strcmp(fields[0], "WITHDRAW") == 0 || strcmp(fields[0], "wd") == 0 || + strcmp(fields[0], "-") == 0) { + if (nfields < 3 || fields[2][0] == '\0') { return MAYACHAIN_MEMO_UNPARSED; // malformed memo } + /* WD:POOL:BPS[:ASSET] — refuse only genuinely-unknown structure (>4 + * fields), mirroring thorchain.c. */ + if (nfields > 4) { + return MAYACHAIN_MEMO_UNPARSED; + } + /* BPS rendered with integer math: snprintf is the integer-only sniprintf + * on the device, so no float formats. Negative BPS is a malformed memo. */ uint16_t bps = 0; - if (!mayachain_parse_bps(parseTokPtrs[3], &bps)) { + if (!mayachain_parse_bps(fields[2], &bps)) { return MAYACHAIN_MEMO_UNPARSED; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Mayachain withdraw liquidity", - "Confirm withdraw %u.%02u%% of asset %s on chain %s", - (unsigned)(bps / 100u), (unsigned)(bps % 100u), - parseTokPtrs[2], parseTokPtrs[1])) { + "Confirm withdraw %d.%02d%% of asset %s on chain %s", + bps / 100, bps % 100, asset, chain)) { return MAYACHAIN_MEMO_CANCELLED; } - /* WD:POOL:BPS:ASSET - the optional 4th field pays the whole withdrawal - out single-sided in ASSET instead of the symmetric split. It directs - money and the screens are otherwise identical, so it must be shown. */ - while ((tok = strtok(NULL, ":")) != NULL) { + /* Field 4 selects an ASYMMETRIC (single-sided) withdrawal payout asset — + * it directs money and must never sign unseen (see thorchain.c). */ + if (nfields > 3 && fields[3][0] != '\0') { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Mayachain withdraw liquidity", "Additional memo field\n%s", - tok)) { + "Mayachain withdraw liquidity", + "Withdraw single-sided as %s", fields[3])) { return MAYACHAIN_MEMO_CANCELLED; } } diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index d8b8f9134..23f2267fb 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -73,8 +73,13 @@ MSG_IN(MessageType_MessageType_MayachainGetAddress, MayachainGetAddress, fsm_msgMayachainGetAddress) MSG_IN(MessageType_MessageType_MayachainSignTx, MayachainSignTx, fsm_msgMayachainSignTx) MSG_IN(MessageType_MessageType_MayachainMsgAck, MayachainMsgAck, fsm_msgMayachainMsgAck) + + /* BIP-85 derives child mnemonics for OTHER wallets, which is a + * multi-chain feature; the bitcoin-only image does not carry it. */ + MSG_IN(MessageType_MessageType_GetBip85Mnemonic, GetBip85Mnemonic, fsm_msgGetBip85Mnemonic) #endif // !BITCOIN_ONLY + /* Normal Out Messages */ MSG_OUT(MessageType_MessageType_Success, Success, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_Failure, Failure, NO_PROCESS_FUNC) @@ -168,6 +173,42 @@ MSG_OUT(MessageType_MessageType_SolanaOffchainMessageSignature, SolanaOffchainMessageSignature, NO_PROCESS_FUNC) #endif // !BITCOIN_ONLY + /* Zcash shielded/Orchard (privacy engine). Transparent t-address Zcash uses + the generic SignTx/GetAddress rows above and needs none of these. */ +#if ZCASH_PRIVACY + MSG_IN(MessageType_MessageType_ZcashSignPCZT, ZcashSignPCZT, fsm_msgZcashSignPCZT) + MSG_IN(MessageType_MessageType_ZcashPCZTAction, ZcashPCZTAction, fsm_msgZcashPCZTAction) + MSG_IN(MessageType_MessageType_ZcashGetOrchardFVK, ZcashGetOrchardFVK, fsm_msgZcashGetOrchardFVK) + MSG_IN(MessageType_MessageType_ZcashTransparentOutput, ZcashTransparentOutput, fsm_msgZcashTransparentOutput) + MSG_IN(MessageType_MessageType_ZcashTransparentInput, ZcashTransparentInput, fsm_msgZcashTransparentInput) + MSG_IN(MessageType_MessageType_ZcashDisplayAddress, ZcashDisplayAddress, fsm_msgZcashDisplayAddress) + + MSG_OUT(MessageType_MessageType_ZcashPCZTActionAck, ZcashPCZTActionAck, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_ZcashSignedPCZT, ZcashSignedPCZT, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_ZcashOrchardFVK, ZcashOrchardFVK, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_ZcashTransparentSigned, ZcashTransparentSigned, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_ZcashTransparentAck, ZcashTransparentAck, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_ZcashAddress, ZcashAddress, NO_PROCESS_FUNC) +#endif +#if !BITCOIN_ONLY + /* Hive */ + MSG_IN(MessageType_MessageType_HiveGetPublicKey, HiveGetPublicKey, fsm_msgHiveGetPublicKey) + MSG_IN(MessageType_MessageType_HiveGetPublicKeys, HiveGetPublicKeys, fsm_msgHiveGetPublicKeys) + MSG_IN(MessageType_MessageType_HiveSignTx, HiveSignTx, fsm_msgHiveSignTx) + MSG_IN(MessageType_MessageType_HiveSignAccountCreate, HiveSignAccountCreate, fsm_msgHiveSignAccountCreate) + MSG_IN(MessageType_MessageType_HiveSignAccountUpdate, HiveSignAccountUpdate, fsm_msgHiveSignAccountUpdate) + MSG_IN(MessageType_MessageType_HiveSignMessage, HiveSignMessage, fsm_msgHiveSignMessage) + MSG_IN(MessageType_MessageType_HiveSignOperations, HiveSignOperations, fsm_msgHiveSignOperations) + + MSG_OUT(MessageType_MessageType_HivePublicKey, HivePublicKey, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HivePublicKeys, HivePublicKeys, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedTx, HiveSignedTx, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedAccountCreate, HiveSignedAccountCreate, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedAccountUpdate, HiveSignedAccountUpdate, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedMessage, HiveSignedMessage, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_HiveSignedOperations, HiveSignedOperations, NO_PROCESS_FUNC) +#endif // !BITCOIN_ONLY + #if DEBUG_LINK /* Debug Messages */ DEBUG_IN(MessageType_MessageType_DebugLinkDecision, DebugLinkDecision, NO_PROCESS_FUNC) @@ -182,3 +223,13 @@ 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) + MSG_IN(MessageType_MessageType_ClearsignAttestorGetPublicKey, ClearsignAttestorGetPublicKey, fsm_msgClearsignAttestorGetPublicKey) + MSG_OUT(MessageType_MessageType_ClearsignAttestorPublicKey, ClearsignAttestorPublicKey, NO_PROCESS_FUNC) + MSG_IN(MessageType_MessageType_ClearsignAttestorSign, ClearsignAttestorSign, fsm_msgClearsignAttestorSign) + MSG_OUT(MessageType_MessageType_ClearsignAttestorSignature, ClearsignAttestorSignature, NO_PROCESS_FUNC) +#endif // !BITCOIN_ONLY diff --git a/lib/firmware/osmosis.c b/lib/firmware/osmosis.c index 135ec22cd..30400a17f 100644 --- a/lib/firmware/osmosis.c +++ b/lib/firmware/osmosis.c @@ -167,6 +167,50 @@ bool osmosis_signTxInit(const HDNode* _node, const OsmosisSignTx* _msg) { return true; } +static bool osmosis_isCanonicalAmount(const char* value) { + if (!value) return false; + const size_t len = strlen(value); + if (len == 0 || len > OSMOSIS_MAX_AMOUNT_DIGITS || + (len > 1 && value[0] == '0')) { + return false; + } + for (size_t i = 0; i < len; i++) { + if (value[i] < '0' || value[i] > '9') return false; + } + return true; +} + +static bool osmosis_isCanonicalUint64(const char* value) { + if (!osmosis_isCanonicalAmount(value)) return false; + + uint64_t parsed = 0; + for (size_t i = 0; value[i]; i++) { + const uint8_t digit = (uint8_t)(value[i] - '0'); + if (parsed > (UINT64_MAX - digit) / 10) return false; + parsed = parsed * 10 + digit; + } + return true; +} + +static bool osmosis_isValidDenom(const char* denom) { + if (!denom) return false; + const size_t len = strlen(denom); + if (len == 0 || len > OSMOSIS_MAX_DENOM_LEN) return false; + + // Cosmos/Osmosis denominations are printable identifiers, not arbitrary + // JSON. This includes native, IBC and factory-style paths while excluding + // whitespace, quotes, backslashes and control bytes. + for (size_t i = 0; i < len; i++) { + const char c = denom[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '/' || c == ':' || c == '.' || + c == '_' || c == '-')) { + return false; + } + } + return true; +} + bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address, const char* denom) { if (!initialized || msgs_remaining == 0) return false; @@ -183,7 +227,8 @@ bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address, network nor the payload length was checked, so a wrong-chain address, a module or operator address, or a punctuation-bearing HRP passed through into the signed document. */ - if (!tendermint_validateBech32Address(to_address, + if (!osmosis_isCanonicalUint64(amount) || !osmosis_isValidDenom(denom) || + !tendermint_validateBech32Address(to_address, testnet ? testnetp : mainnetp)) { return false; } @@ -830,6 +875,42 @@ bool osmosis_signTxFinalize(uint8_t* public_key, uint8_t* signature) { NULL) == 0; } +/* + * Cosmos amounts arrive as integer base-unit strings. These screens used to + * render them with atof() + "%.6f", which rounds anything past ~7 significant + * digits — on the very screen the user approves — and linked newlib's floating + * point engine into a ROM budget with no room for it. bn_format_uint64 places + * the decimal point in integer math, the same way the Hive and Ethereum + * confirm screens do. + */ +bool osmosis_formatAmount(char* out, size_t out_len, const char* value, + const char* denom) { + if (!out || out_len == 0) return false; + out[0] = '\0'; + if (!osmosis_isCanonicalAmount(value) || !osmosis_isValidDenom(denom) || + (strcmp(denom, "uosmo") == 0 && !osmosis_isCanonicalUint64(value))) { + return false; + } + + int written; + if (strcmp(denom, "uosmo") == 0) { + char scaled[OSMOSIS_MAX_AMOUNT_DIGITS + 2]; + if (base_to_precision((uint8_t*)scaled, (const uint8_t*)value, + sizeof(scaled), strlen(value), + OSMOSIS_PRECISION) < 0) { + return false; + } + written = snprintf(out, out_len, "%s OSMO", scaled); + } else { + written = snprintf(out, out_len, "%s %s", value, denom); + } + if (written < 0 || (size_t)written >= out_len) { + out[0] = '\0'; + return false; + } + return true; +} + bool osmosis_signingIsInited(void) { return initialized; } bool osmosis_signingIsFinished(void) { diff --git a/lib/firmware/recovery_cipher.c b/lib/firmware/recovery_cipher.c index 291b4dd71..641e84876 100644 --- a/lib/firmware/recovery_cipher.c +++ b/lib/firmware/recovery_cipher.c @@ -55,8 +55,13 @@ static char english_alphabet[ENGLISH_ALPHABET_BUF] = static CONFIDENTIAL char cipher[ENGLISH_ALPHABET_BUF]; static int uncyphered_word_count = 0; static bool definitely_using_cipher = false; +/* Accumulators for the word currently being entered. File-scope so + * recovery_delete_character() can keep them in sync with backspaces — + * otherwise stale bytes make a re-entered word fail validation and wipe a + * real recovery. last_completed_word backs the "previous word" indicator. */ static CONFIDENTIAL char coded_word[12]; static CONFIDENTIAL char decoded_word[12]; +static CONFIDENTIAL char last_completed_word[12]; static CONFIDENTIAL char current_word_scratch[CURRENT_WORD_BUF]; static CONFIDENTIAL char formatted_word_scratch[CURRENT_WORD_BUF + 10]; static CONFIDENTIAL char final_mnemonic_scratch[MNEMONIC_BUF]; @@ -81,6 +86,7 @@ void recovery_cipher_reset(void) { definitely_using_cipher = false; memzero(coded_word, sizeof(coded_word)); memzero(decoded_word, sizeof(decoded_word)); + memzero(last_completed_word, sizeof(last_completed_word)); memzero(current_word_scratch, sizeof(current_word_scratch)); memzero(formatted_word_scratch, sizeof(formatted_word_scratch)); memzero(final_mnemonic_scratch, sizeof(final_mnemonic_scratch)); @@ -202,7 +208,11 @@ bool attempt_auto_complete(char* partial_word) { return false; } - static uint16_t CONFIDENTIAL permute[2049]; + /* 4 KB permutation table lives in the shared frame arena: too big for the + * stack, wasteful as its own static. Transient within this call (memzero'd + * on every exit), and this function never encodes a USB response while the + * table is live — see the FrameArena contract in messages.c. */ + uint16_t* permute = frame_arena_scratch2049(); for (int i = 0; i < 2049; i++) { permute[i] = i; } @@ -236,18 +246,18 @@ bool attempt_auto_complete(char* partial_word) { } if (precise_match) { - memzero(permute, sizeof(permute)); + memzero(permute, 2049 * sizeof(*permute)); return true; } /* Autocomplete if we can */ if (match == 1) { strlcpy(partial_word, words[permute[found]], CURRENT_WORD_BUF); - memzero(permute, sizeof(permute)); + memzero(permute, 2049 * sizeof(*permute)); return true; } - memzero(permute, sizeof(permute)); + memzero(permute, 2049 * sizeof(*permute)); return false; } @@ -397,8 +407,16 @@ void next_character(void) { &formatted_word_scratch); memzero(current_word_scratch, sizeof(current_word_scratch)); + /* 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_scratch, cipher); + layout_cipher(formatted_word_scratch, cipher, prev_info); memzero(formatted_word_scratch, sizeof(formatted_word_scratch)); } @@ -438,12 +456,12 @@ void recovery_character(const char* character) { return; } - // Count of words we think the user has entered without using the cipher: if (!mnemonic[0]) { uncyphered_word_count = 0; definitely_using_cipher = false; memzero(coded_word, sizeof(coded_word)); memzero(decoded_word, sizeof(decoded_word)); + memzero(last_completed_word, sizeof(last_completed_word)); } char decoded_character[2] = " "; @@ -478,6 +496,30 @@ void recovery_character(const char* character) { } } } else { + /* Per-word BIP39 validation: reject immediately if the decoded word + * doesn't match any entry in the wordlist. decoded_word is kept in sync + * with backspaces by recovery_delete_character(), so a corrected word is + * validated on its real (post-edit) value. */ + if (strlen(decoded_word) > 0) { + static CONFIDENTIAL char check_word[CURRENT_WORD_BUF]; + strlcpy(check_word, decoded_word, sizeof(check_word)); + bool valid = attempt_auto_complete(check_word); + if (enforce_wordlist && !valid) { + memzero(check_word, sizeof(check_word)); + memzero(coded_word, sizeof(coded_word)); + memzero(decoded_word, sizeof(decoded_word)); + recovery_cipher_abort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Word not found in BIP39 wordlist"); + layout_warning_static("Word not in wordlist"); + return; + } + /* Record the just-completed (auto-expanded) word for the "previous + * word" indicator — only at a real word boundary, never mid-word. */ + strlcpy(last_completed_word, check_word, sizeof(last_completed_word)); + memzero(check_word, sizeof(check_word)); + } + memzero(coded_word, sizeof(coded_word)); memzero(decoded_word, sizeof(decoded_word)); @@ -528,6 +570,22 @@ void recovery_delete_character(void) { mnemonic[len - 1] = '\0'; } + /* Resync the current-word accumulators with the edited mnemonic so a + * corrected word is validated on its real value (stale bytes here would + * fail validation and trigger a storage_reset on a real recovery). + * decoded_word is the typed prefix of the current word; coded_word is its + * reverse-cipher form (session cipher is fixed, so it is reconstructable). */ + char cur[CURRENT_WORD_BUF]; + get_current_word(cur); + strlcpy(decoded_word, cur, sizeof(decoded_word)); + memzero(cur, sizeof(cur)); + size_t wlen = strlen(decoded_word); + for (size_t i = 0; i < wlen && i + 1 < sizeof(coded_word); i++) { + char d = decoded_word[i]; + coded_word[i] = (d >= 'a' && d <= 'z') ? cipher[d - 'a'] : d; + } + coded_word[wlen < sizeof(coded_word) ? wlen : sizeof(coded_word) - 1] = '\0'; + next_character(); } @@ -590,7 +648,15 @@ void recovery_cipher_finalize(void) { } memzero(temp_word_scratch, sizeof(temp_word_scratch)); - if (!auto_completed && !enforce_wordlist) { + /* Cipher recovery decodes to BIP-39 words, so every word must + * auto-complete regardless of enforce_wordlist. Failing only when + * enforce_wordlist was set left the default (host-omitted) path storing a + * mistyped/garbage phrase as the seed and reporting success. + * + * alpha's storage_reset() on this path is deliberately NOT restored: #429 + * removed the cancelled-recovery path that armed a host-only storage_reset() + * with no button press, and setup_abort() below is its replacement. */ + if (!auto_completed) { fsm_sendFailure(FailureType_Failure_SyntaxError, "Words were not entered correctly. Make sure you are using " "the substition cipher."); diff --git a/lib/firmware/reset.c b/lib/firmware/reset.c index 170d68351..0af6b312f 100644 --- a/lib/firmware/reset.c +++ b/lib/firmware/reset.c @@ -193,11 +193,17 @@ void setup_commit(const char* mnemonic, bool imported) { storage_commit(); } -void reset_init(bool display_random, uint32_t _strength, - bool passphrase_protection, bool pin_protection, - const char* language, const char* label, bool _no_backup, - uint32_t _auto_lock_delay_ms, uint32_t _u2f_counter, - bool dice_entropy) { +/* Shared paginated-mnemonic display scratch — see reset.h for the contract + * (also used by the BIP-85 flow; each user zeroes at entry and exit). */ +char CONFIDENTIAL mnemonic_scratch_tokened[TOKENED_MNEMONIC_BUF]; +char CONFIDENTIAL mnemonic_scratch_formatted[MAX_PAGES][FORMATTED_MNEMONIC_BUF]; +char CONFIDENTIAL mnemonic_scratch_display[FORMATTED_MNEMONIC_BUF]; +char CONFIDENTIAL mnemonic_scratch_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; + +void reset_init(uint32_t _strength, bool passphrase_protection, + bool pin_protection, const char* language, const char* label, + bool _no_backup, uint32_t _auto_lock_delay_ms, + uint32_t _u2f_counter, bool dice_entropy) { if (_strength != 128 && _strength != 192 && _strength != 256) { fsm_sendFailure( FailureType_Failure_SyntaxError, @@ -206,26 +212,6 @@ void reset_init(bool display_random, uint32_t _strength, return; } - if (display_random && _no_backup) { - fsm_sendFailure(FailureType_Failure_SyntaxError, - _("Can't show internal entropy when backup is skipped")); - layoutHome(); - return; - } - - /* Refused, not silently ignored: the entropy screen renders the POST-mix - * internal entropy, so honoring both would hand a host that reads that - * screen the seed pre-image and make the dice fold-in worthless. 7.15 - * removes the entropy screen outright; this release keeps it because - * already-shipped hosts of the 7.14 line legitimately request it, but it - * must never coexist with dice. */ - if (display_random && dice_entropy) { - fsm_sendFailure(FailureType_Failure_SyntaxError, - _("Can't show internal entropy when dice entropy is used")); - layoutHome(); - return; - } - /* Nothing below this line writes storage. Everything the host asked for is * staged, and stays staged until reset_entropy() reaches setup_commit(). * Returning early from any of the screens below therefore rolls the whole @@ -300,12 +286,19 @@ void reset_init(bool display_random, uint32_t _strength, /* Dice fold in before EntropyRequest, so the host contribution arrives * strictly after the device has committed to its own. * - * The mixed value is deliberately NOT displayable: display_random is - * refused above whenever dice are in use, because the entropy screen shows - * the POST-mix value, and a host that supplies ext_entropy and reads that - * screen once computes SHA256(shown || ext_entropy) -- the seed pre-image - * -- making the dice fold-in worthless. The roll digest below is safe by - * contrast: it is a hash of the user's own input, not of seed material. + * They are deliberately NOT displayed. An earlier version of this code + * showed the mixed internal entropy on the OLED and called it a + * verifiable commitment; that was wrong. A host that supplies + * ext_entropy and reads that screen once computes + * SHA256(shown || ext_entropy) -- the seed pre-image -- and dice change + * nothing about it, because the displayed value is already post-mix. The + * roll digest below is safe by contrast: it is a hash of the user's own + * input, not of seed material. + * + * ResetDevice.display_random stays in the wire schema and is ignored by + * fsm_msgResetDevice(), which is why the old "Can't show internal entropy + * when backup is skipped" syntax check is gone: there is no longer an + * entropy screen for it to be inconsistent with. * * The digest needs no clear here -- setup_stage() above ran setup_abort(), * which zeroes it. */ @@ -349,26 +342,6 @@ void reset_init(bool display_random, uint32_t _strength, memzero(dice_rolls, sizeof(dice_rolls)); } - if (display_random) { - static char CONFIDENTIAL ent_str[4][17]; - data2hex(int_entropy, 8, ent_str[0]); - data2hex(int_entropy + 8, 8, ent_str[1]); - data2hex(int_entropy + 16, 8, ent_str[2]); - data2hex(int_entropy + 24, 8, ent_str[3]); - - if (!confirm(ButtonRequestType_ButtonRequest_ResetDevice, - _("Internal Entropy"), "%s %s %s %s", ent_str[0], ent_str[1], - ent_str[2], ent_str[3])) { - memzero(ent_str, sizeof(ent_str)); - setup_abort(); - fsm_sendFailure(FailureType_Failure_ActionCancelled, - _("Reset cancelled")); - layoutHome(); - return; - } - memzero(ent_str, sizeof(ent_str)); - } - if (!setup_stagePin(pin_protection)) { /* Clears the roll digest along with the staged settings and entropy. */ setup_abort(); @@ -424,16 +397,23 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { } /* - * Format mnemonic for user review + * Format mnemonic for user review. Display scratch is the set shared with + * the BIP-85 flow (see reset.h) — zero it at entry: the format loop below + * depends on empty page strings, and a prior user may have aborted. */ uint32_t word_count = 0, page_count = 0; - static char CONFIDENTIAL tokened_mnemonic[TOKENED_MNEMONIC_BUF]; static char CONFIDENTIAL mnemonic_by_screen[MAX_PAGES][MNEMONIC_BY_SCREEN_BUF]; - static char CONFIDENTIAL - formatted_mnemonic[MAX_PAGES][FORMATTED_MNEMONIC_BUF]; - static char CONFIDENTIAL mnemonic_display[FORMATTED_MNEMONIC_BUF]; - static char CONFIDENTIAL formatted_word[MAX_WORD_LEN + ADDITIONAL_WORD_PAD]; + char* tokened_mnemonic = mnemonic_scratch_tokened; + char (*formatted_mnemonic)[FORMATTED_MNEMONIC_BUF] = + mnemonic_scratch_formatted; + char* mnemonic_display = mnemonic_scratch_display; + char* formatted_word = mnemonic_scratch_word; + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); + memzero(mnemonic_by_screen, sizeof(mnemonic_by_screen)); strlcpy(tokened_mnemonic, temp_mnemonic, TOKENED_MNEMONIC_BUF); @@ -518,12 +498,11 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { /* The roll digest is cleared by setup_abort(); every path that reaches * here has already run it, directly or through setup_commit(). */ memzero(&ctx, sizeof(ctx)); - memzero(tokened_mnemonic, sizeof(tokened_mnemonic)); + memzero(mnemonic_scratch_tokened, sizeof(mnemonic_scratch_tokened)); memzero(mnemonic_by_screen, sizeof(mnemonic_by_screen)); - memzero(formatted_mnemonic, sizeof(formatted_mnemonic)); - memzero(mnemonic_display, sizeof(mnemonic_display)); - memzero(formatted_word, sizeof(formatted_word)); - mnemonic_clear(); + memzero(mnemonic_scratch_formatted, sizeof(mnemonic_scratch_formatted)); + memzero(mnemonic_scratch_display, sizeof(mnemonic_scratch_display)); + memzero(mnemonic_scratch_word, sizeof(mnemonic_scratch_word)); layoutHome(); } diff --git a/lib/firmware/ripple.c b/lib/firmware/ripple.c index c7e9c5c65..fd2199ee6 100644 --- a/lib/firmware/ripple.c +++ b/lib/firmware/ripple.c @@ -258,6 +258,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..846b5b11c --- /dev/null +++ b/lib/firmware/signed_metadata.c @@ -0,0 +1,1112 @@ +#include "keepkey/firmware/signed_metadata.h" + +#include "keepkey/board/confirm_sm.h" +#include "keepkey/board/draw.h" // draw_bitmap_mono_rle_valid +#include "keepkey/board/layout.h" // RUNTIME_ICON + layout_set_runtime_icon +#include "keepkey/board/variant.h" // Image / AnimationFrame +#include "keepkey/board/util.h" +#include "keepkey/firmware/ethereum.h" +#include "keepkey/firmware/storage.h" +#include "trezor/crypto/address.h" +#include "trezor/crypto/bignum.h" +#include "trezor/crypto/ecdsa.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" +#include "trezor/crypto/sha3.h" + +#include +#include + +#define _(X) (X) + +static bool metadata_available = false; +static bool relied_on_metadata = false; +static bool metadata_signer_loaded = false; +/* v2 only: set true once decode_v2_args() has decoded this metadata's args from + * the tx calldata. The v2 enforce path REQUIRES it — v2 has no committed + * tx_hash, so this is the explicit proof (not an implicit call-order + * assumption) that the displayed values came from the calldata being signed. */ +/* Set during matching: this tx carries native value, so the amount screen + * must NOT be suppressed even though the schema matched. */ +static bool metadata_schema_moves_value = false; +static bool metadata_schema_decoded = false; +typedef union { + SignedMetadata metadata; + struct SHA3_CTX keccak_scratch; +} SignedMetadataStorage; +static SignedMetadataStorage metadata_storage; +#define stored_metadata metadata_storage.metadata +_Static_assert(sizeof(SignedMetadata) >= sizeof(struct SHA3_CTX), + "metadata arena must hold a Keccak context without more SRAM"); + +/* Annotation-only metadata from a runtime-loaded signer still binds the final + * signature to the attested transaction/schema, but it must also be followed + * by the authoritative raw-calldata review. Preserve only the fields needed by + * signed_metadata_enforce() so the much larger rendered metadata arena can be + * reused for that review's streaming Keccak state. */ +typedef struct { + uint8_t tx_hash[32]; + uint8_t version; + uint8_t classification; + bool available; +} SignedMetadataBinding; +static SignedMetadataBinding metadata_binding; + +/* Phase 1 ships with NO built-in verification keys: every clearsign signer is + * loaded at runtime via LoadClearsignSigner. Phase 2 restores the production + * key. */ + +/* Runtime-loaded signers. RAM only — cleared on reboot by construction. RC18 + * deliberately rejects persistent trust anchors: the public storage section + * has no authenticated integrity against physical flash modification. */ +static uint8_t loaded_pubkeys[METADATA_MAX_KEYS][33]; +static char loaded_aliases[METADATA_MAX_KEYS][METADATA_ALIAS_MAX_LEN + 1]; +/* Per-slot session icon (1bpp mono RLE). icon_len==0 => text-only identity. */ +#if !ZCASH_PRIVACY +static uint8_t loaded_icons[METADATA_MAX_KEYS][METADATA_ICON_MAX]; +static uint8_t loaded_icon_w[METADATA_MAX_KEYS]; +static uint8_t loaded_icon_h[METADATA_MAX_KEYS]; +static uint16_t loaded_icon_len[METADATA_MAX_KEYS]; +#endif + +static bool read_u8(const uint8_t** cursor, const uint8_t* end, uint8_t* out) { + if ((size_t)(end - *cursor) < 1) { + return false; + } + + *out = **cursor; + *cursor += 1; + return true; +} + +static bool read_be_u16(const uint8_t** cursor, const uint8_t* end, + uint16_t* out) { + if ((size_t)(end - *cursor) < 2) { + return false; + } + + *out = ((uint16_t)(*cursor)[0] << 8) | (*cursor)[1]; + *cursor += 2; + return true; +} + +static bool read_be_u32(const uint8_t** cursor, const uint8_t* end, + uint32_t* out) { + if ((size_t)(end - *cursor) < 4) { + return false; + } + + *out = ((uint32_t)(*cursor)[0] << 24) | ((uint32_t)(*cursor)[1] << 16) | + ((uint32_t)(*cursor)[2] << 8) | (*cursor)[3]; + *cursor += 4; + return true; +} + +static bool read_bytes(const uint8_t** cursor, const uint8_t* end, uint8_t* out, + size_t size) { + if ((size_t)(end - *cursor) < size) { + return false; + } + + memcpy(out, *cursor, size); + *cursor += size; + return true; +} + +/* method_name and arg names render through confirm() bodies exactly like + * STRING values and signer aliases do — hold them to the same allowlist + * (printable ASCII, '%' excluded) so no metadata-carried text can embed + * control bytes or format specifiers. Only a trusted signer could author + * such a blob, but the charset rule should not depend on who signs. */ +static bool display_text_ok(const uint8_t* text, size_t len) { + for (size_t i = 0; i < len; i++) { + if (text[i] < 0x20 || text[i] > 0x7e || text[i] == '%') { + return false; + } + } + return true; +} + +static bool read_string(const uint8_t** cursor, const uint8_t* end, char* out, + size_t max_len) { + uint16_t value_len = 0; + if (!read_be_u16(cursor, end, &value_len) || value_len == 0 || + value_len > max_len || (size_t)(end - *cursor) < value_len) { + return false; + } + if (!display_text_ok(*cursor, value_len)) { + return false; + } + + memcpy(out, *cursor, value_len); + out[value_len] = '\0'; + *cursor += value_len; + return true; +} + +static bool read_arg_name(const uint8_t** cursor, const uint8_t* end, char* out, + size_t max_len) { + uint8_t value_len = 0; + if (!read_u8(cursor, end, &value_len) || value_len == 0 || + value_len > max_len || (size_t)(end - *cursor) < value_len) { + return false; + } + if (!display_text_ok(*cursor, value_len)) { + return false; + } + + memcpy(out, *cursor, value_len); + out[value_len] = '\0'; + *cursor += value_len; + return true; +} + +/* Per-format value validation, fail-closed at parse time. STRING and + * TOKEN_AMOUNT carry display semantics, so their byte layout is enforced + * before anything is stored; legacy formats keep their original 32-byte cap + * (METADATA_MAX_ARG_VALUE_LEN grew only to fit TOKEN_AMOUNT). */ +static bool arg_value_ok(uint8_t format, const uint8_t* value, uint16_t len) { + switch (format) { + case ARG_FORMAT_STRING: { + /* Attested printable label ("protocol: Uniswap V2"). Rendered through + * confirm() bodies: printable ASCII only, '%' excluded. */ + if (len == 0 || len > 32) { + return false; + } + for (uint16_t i = 0; i < len; i++) { + if (value[i] < 0x20 || value[i] > 0x7e || value[i] == '%') { + return false; + } + } + return true; + } + case ARG_FORMAT_TOKEN_AMOUNT: { + /* decimals(1) + symbol_len(1) + symbol + amount(1..32 BE) */ + if (len < 4) { + return false; + } + uint8_t decimals = value[0]; + uint8_t symlen = value[1]; + if (decimals > 36 || symlen == 0 || + symlen > METADATA_MAX_TOKEN_SYMBOL_LEN || + (uint16_t)(2 + symlen) >= len || len - 2 - symlen > 32) { + return false; + } + for (uint8_t i = 0; i < symlen; i++) { + char c = (char)value[2 + i]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9'); + if (!ok) { + return false; + } + } + return true; + } + default: + return len <= 32; + } +} + +/* chain_id(4) + contract(20) + selector(4) — shared by both blob versions. */ +static bool parse_common_head(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + return read_be_u32(cursor, end, &out->chain_id) && + read_bytes(cursor, end, out->contract_address, + sizeof(out->contract_address)) && + read_bytes(cursor, end, out->selector, sizeof(out->selector)); +} + +/* classification(1) + timestamp(4) + key_id(1) + sig(64) + recovery(1), then + * the cursor must land exactly on `end` — identical for v1 and v2. */ +static bool parse_trailer(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + uint8_t classification = 0; + if (!read_u8(cursor, end, &classification) || classification > 2 || + !read_be_u32(cursor, end, &out->timestamp) || + !read_u8(cursor, end, &out->key_id) || + !read_bytes(cursor, end, out->signature, sizeof(out->signature)) || + !read_u8(cursor, end, &out->recovery) || *cursor != end) { + return false; + } + out->classification = (MetadataClassification)classification; + return true; +} + +/* v1 args: name + format + explicit (host-decoded) value. */ +static bool parse_v1_args(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + for (uint8_t i = 0; i < out->num_args; i++) { + uint8_t format = 0; + uint16_t value_len = 0; + MetadataArg* arg = &out->args[i]; + + if (!read_arg_name(cursor, end, arg->name, METADATA_MAX_ARG_NAME_LEN) || + !read_u8(cursor, end, &format) || format > ARG_FORMAT_TOKEN_AMOUNT || + !read_be_u16(cursor, end, &value_len) || + value_len > METADATA_MAX_ARG_VALUE_LEN || + !read_bytes(cursor, end, arg->value, value_len) || + !arg_value_ok(format, arg->value, value_len)) { + return false; + } + arg->format = (ArgFormat)format; + arg->value_len = value_len; + } + return true; +} + +/* v2 args: name + display format only (NO value — decoded from calldata later). + * TOKEN_AMOUNT additionally carries its static decimals + symbol, pre-stored as + * the value prefix [decimals, symlen, symbol...] so decode_v2_args() only has + * to append the 32-byte amount word. v2 supports the fixed single-word ABI + * types ADDRESS / AMOUNT / TOKEN_AMOUNT; anything else is out of scope -> blind + * sign. */ +static bool parse_v2_args(const uint8_t** cursor, const uint8_t* end, + SignedMetadata* out) { + for (uint8_t i = 0; i < out->num_args; i++) { + uint8_t format = 0; + MetadataArg* arg = &out->args[i]; + + if (!read_arg_name(cursor, end, arg->name, METADATA_MAX_ARG_NAME_LEN) || + !read_u8(cursor, end, &format)) { + return false; + } + switch (format) { + case ARG_FORMAT_ADDRESS: + case ARG_FORMAT_AMOUNT: + /* BYTES covers an opaque fixed word — an order/request id, say — which + * a router genuinely cannot render as an address or an amount. It still + * consumes exactly one 32-byte ABI word, so structural completeness is + * unaffected; only the rendering differs (hex, first 16 bytes). */ + case ARG_FORMAT_BYTES: + arg->value_len = 0; /* filled from the tx calldata at decode time */ + break; + case ARG_FORMAT_TOKEN_AMOUNT: { + uint8_t decimals = 0, symlen = 0; + if (!read_u8(cursor, end, &decimals) || + !read_u8(cursor, end, &symlen) || decimals > 36 || symlen == 0 || + symlen > METADATA_MAX_TOKEN_SYMBOL_LEN || + (size_t)(end - *cursor) < symlen) { + return false; + } + arg->value[0] = decimals; + arg->value[1] = symlen; + for (uint8_t j = 0; j < symlen; j++) { + char c = (char)(*cursor)[j]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9'); + if (!ok) { + return false; + } + arg->value[2 + j] = (uint8_t)c; + } + *cursor += symlen; + arg->value_len = (uint16_t)(2 + symlen); + break; + } + default: + return false; + } + arg->format = (ArgFormat)format; + } + return true; +} + +static bool parse_metadata_binary(const uint8_t* payload, size_t payload_len, + SignedMetadata* out) { + const uint8_t* cursor = payload; + const uint8_t* end = payload + payload_len; + memset(out, 0, sizeof(*out)); + + if (!read_u8(&cursor, end, &out->version)) { + return false; + } + + if (out->version == METADATA_VERSION_LEGACY) { + /* Min: version(1)+chain_id(4)+contract(20)+selector(4)+tx_hash(32)+ + * method_len(2)+method(1)+num_args(1)+trailer(71) = 136 */ + if (payload_len < 136 || !parse_common_head(&cursor, end, out) || + !read_bytes(&cursor, end, out->tx_hash, sizeof(out->tx_hash)) || + !read_string(&cursor, end, out->method_name, METADATA_MAX_METHOD_LEN) || + !read_u8(&cursor, end, &out->num_args) || + out->num_args > METADATA_MAX_ARGS || + !parse_v1_args(&cursor, end, out)) { + return false; + } + } else if (out->version == METADATA_VERSION_SCHEMA) { + /* Min (0 args): version(1)+chain_id(4)+contract(20)+selector(4)+ + * method_len(2)+method(1)+num_args(1)+trailer(71) = 104 (no tx_hash) */ + if (payload_len < 104 || !parse_common_head(&cursor, end, out) || + !read_string(&cursor, end, out->method_name, METADATA_MAX_METHOD_LEN) || + !read_u8(&cursor, end, &out->num_args) || + out->num_args > METADATA_MAX_ARGS || + !parse_v2_args(&cursor, end, out)) { + return false; + } + } else { + return false; + } + + return parse_trailer(&cursor, end, out); +} + +/* + * v2 decode: fill each schema arg's value from the transaction calldata. + * + * All v2 args are fixed single 32-byte ABI head words, laid out sequentially + * from offset 4 (right after the selector). We require the ENTIRE calldata to + * be exactly selector + num_args words, wholly present in the initial chunk — + * so the device decodes, displays, AND signs the same bytes with nothing hidden + * in a later chunk or trailing the words. That structural completeness is what + * binds the displayed decode to the signature; v2 has no tx_hash. + */ +static bool decode_v2_args(SignedMetadata* md, const EthereumSignTx* msg) { + uint32_t expected = 4u + 32u * (uint32_t)md->num_args; + uint32_t initsz = msg->data_initial_chunk.size; + uint32_t total = msg->has_data_length ? msg->data_length : initsz; + if (total != expected || initsz != expected) { + return false; + } + + for (uint8_t i = 0; i < md->num_args; i++) { + const uint8_t* word = msg->data_initial_chunk.bytes + 4 + 32u * i; + MetadataArg* arg = &md->args[i]; + + switch (arg->format) { + case ARG_FORMAT_ADDRESS: + /* ABI address is a left-zero-padded 20-byte value; reject dirty high + * bytes rather than silently truncate (they could hide meaning). */ + for (int j = 0; j < 12; j++) { + if (word[j] != 0) { + return false; + } + } + memcpy(arg->value, word + 12, 20); + arg->value_len = 20; + break; + case ARG_FORMAT_AMOUNT: + case ARG_FORMAT_BYTES: + memcpy(arg->value, word, 32); + arg->value_len = 32; + break; + case ARG_FORMAT_TOKEN_AMOUNT: { + /* value holds [decimals, symlen, symbol] from parse; append the amount. + * Derive the prefix from symlen (value[1]), NOT the current value_len, + * so a repeated decode of the same arg is idempotent (value_len already + * includes a previously-appended amount; value[1] does not change). */ + uint16_t prefix = (uint16_t)(2 + arg->value[1]); + if ((size_t)prefix + 32 > METADATA_MAX_ARG_VALUE_LEN) { + return false; + } + memcpy(arg->value + prefix, word, 32); + arg->value_len = (uint16_t)(prefix + 32); + break; + } + default: + return false; + } + } + return true; +} + +static void bn_from_metadata_bytes(const uint8_t* value, size_t value_len, + bignum256* out) { + uint8_t padded[32] = {0}; + if (value_len > sizeof(padded)) { + value_len = sizeof(padded); + } + memcpy(padded + (sizeof(padded) - value_len), value, value_len); + bn_read_be(padded, out); + memzero(padded, sizeof(padded)); +} + +bool signed_metadata_available(void) { return metadata_available; } + +bool signed_metadata_schema_decoded(void) { return metadata_schema_decoded; } + +bool signed_metadata_schema_moves_value(void) { + return metadata_schema_moves_value; +} + +void signed_metadata_clear(void) { + memzero(&metadata_storage, sizeof(metadata_storage)); + memzero(&metadata_binding, sizeof(metadata_binding)); + metadata_available = false; + relied_on_metadata = false; + metadata_signer_loaded = false; + metadata_schema_decoded = false; + metadata_schema_moves_value = false; +} + +struct SHA3_CTX* signed_metadata_keccak_scratch(void) { + if (metadata_available) { + /* Runtime-loaded identities are annotation-only: their screens have + * already rendered when Ethereum asks for this scratch space, and raw + * review remains authoritative. Keep the final binding fail-closed while + * releasing the display payload. A future firmware-pinned identity may + * suppress raw review and must never take this transition. */ + if (!metadata_signer_loaded || !relied_on_metadata || + stored_metadata.classification != METADATA_VERIFIED) { + return NULL; + } + memcpy(metadata_binding.tx_hash, stored_metadata.tx_hash, + sizeof(metadata_binding.tx_hash)); + metadata_binding.version = stored_metadata.version; + metadata_binding.classification = (uint8_t)stored_metadata.classification; + metadata_binding.available = true; + + memzero(&metadata_storage, sizeof(metadata_storage)); + metadata_available = false; + metadata_signer_loaded = false; + } + return &metadata_storage.keccak_scratch; +} + +void signed_metadata_clear_signers(void) { + memzero(loaded_pubkeys, sizeof(loaded_pubkeys)); + memzero(loaded_aliases, sizeof(loaded_aliases)); +#if !ZCASH_PRIVACY + memzero(loaded_icons, sizeof(loaded_icons)); + memzero(loaded_icon_w, sizeof(loaded_icon_w)); + memzero(loaded_icon_h, sizeof(loaded_icon_h)); + memzero(loaded_icon_len, sizeof(loaded_icon_len)); +#endif + /* Metadata verified by a now-dropped signer must not outlive it. */ + signed_metadata_clear(); +} + +bool signed_metadata_signer_valid(uint8_t key_id, const uint8_t* pubkey, + size_t pubkey_len, const char* alias) { + curve_point point; + size_t alias_len; + + if (key_id >= METADATA_MAX_KEYS || !pubkey || pubkey_len != 33 || !alias) { + return false; + } + + /* Alias is rendered INSIDE quotes on the load screen and the per-tx warning + * ("Trust signer '%s' ..."). Restrict to a strict allowlist — letters, + * digits, space, '-' and '_' — so a host-chosen alias cannot break out of + * its quoted region or inject a semantic trust claim (e.g. a quote to close + * the quotes, or "." / "(" to append "verified by KeepKey."). '%' is also + * excluded so it can never reach the format string as a specifier. */ + alias_len = strlen(alias); + if (alias_len == 0 || alias_len > METADATA_ALIAS_MAX_LEN) { + return false; + } + for (size_t i = 0; i < alias_len; i++) { + char c = alias[i]; + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == ' ' || c == '-' || c == '_'; + if (!ok) { + return false; + } + } + + /* Compressed form only — ecdsa_read_pubkey would read 65 bytes for an + * uncompressed 0x04 prefix, past our 33-byte buffer. Requiring 0x02/0x03 + * also excludes the all-zero "empty slot" sentinel. */ + if (pubkey[0] != 0x02 && pubkey[0] != 0x03) { + return false; + } + return ecdsa_read_pubkey(&secp256k1, pubkey, &point) == 1; +} + +bool signed_metadata_store_signer(uint8_t key_id, const uint8_t* pubkey, + const char* alias, const uint8_t* icon, + uint8_t icon_w, uint8_t icon_h, + uint16_t icon_len, bool persist) { + /* Fail before changing the RAM slot. A caller asking for persistence must + * never receive a session-only downgrade it could mistake for durable trust. + * Persistence can return only after authenticated storage binding exists. */ + if (persist || key_id >= METADATA_MAX_KEYS) { + return false; + } + memcpy(loaded_pubkeys[key_id], pubkey, sizeof(loaded_pubkeys[key_id])); + strlcpy(loaded_aliases[key_id], alias, sizeof(loaded_aliases[key_id])); + + /* A load without an icon clears any prior one for the slot (icon_len + * already validated <= max by the caller — belt-and-braces here). */ + bool has_icon = icon && icon_len > 0 && icon_len <= METADATA_ICON_MAX; + + /* Session icon into the RAM working slot. The Orchard build omits this + * cosmetic cache to preserve its tight SRAM margin; signers remain usable + * and render text-only after the mandatory load confirmation. */ +#if !ZCASH_PRIVACY + memzero(loaded_icons[key_id], sizeof(loaded_icons[key_id])); + if (has_icon) { + memcpy(loaded_icons[key_id], icon, icon_len); + loaded_icon_w[key_id] = icon_w; + loaded_icon_h[key_id] = icon_h; + loaded_icon_len[key_id] = icon_len; + } else { + loaded_icon_w[key_id] = 0; + loaded_icon_h[key_id] = 0; + loaded_icon_len[key_id] = 0; + } +#else + (void)has_icon; + (void)icon_w; + (void)icon_h; +#endif + + /* Replacing a signer invalidates anything the old one verified. */ + signed_metadata_clear(); + return true; +} + +/* Resolve the alias for a session slot. */ +const char* signed_metadata_signer_alias(uint8_t key_id) { + if (key_id >= METADATA_MAX_KEYS) return NULL; + if (loaded_pubkeys[key_id][0] != 0x00) return loaded_aliases[key_id]; + return NULL; +} + +/* Resolve the icon for a session slot. Returns false for a text-only slot. */ +/* An icon is renderable only if its geometry fits the confirm's icon column + * AND its RLE stream decodes exactly to that geometry. This is the single + * choke point for session icons: signed_metadata_signer_icon() is what both the + * load-confirm and the per-tx identity screen call, and the per-tx screen + * stages the frame itself (it never goes through stage_runtime_icon). Fail + * closed to a text-only identity: a missing logo is cosmetic, an over-wide one + * erases the alias, fingerprint and the "NOT verified by KeepKey" warning. */ +#if !ZCASH_PRIVACY +static bool icon_renderable(const uint8_t* icon, uint16_t icon_len, + uint8_t icon_w, uint8_t icon_h) { + if (!icon || icon_len == 0) return false; + if (icon_w == 0 || icon_w > LEFT_MARGIN_WITH_ICON) return false; + if (icon_h == 0 || icon_h > 64) return false; + return draw_bitmap_mono_rle_valid(icon, (uint32_t)icon_len, icon_w, icon_h); +} +#endif + +bool signed_metadata_signer_icon(uint8_t key_id, const uint8_t** icon_out, + uint8_t* w_out, uint8_t* h_out, + uint16_t* len_out) { + if (key_id >= METADATA_MAX_KEYS) return false; + if (loaded_pubkeys[key_id][0] != 0x00) { +#if ZCASH_PRIVACY + (void)icon_out; + (void)w_out; + (void)h_out; + (void)len_out; + return false; +#else + if (loaded_icon_len[key_id] == 0) return false; + if (!icon_renderable(loaded_icons[key_id], loaded_icon_len[key_id], + loaded_icon_w[key_id], loaded_icon_h[key_id])) { + return false; + } + if (icon_out) *icon_out = loaded_icons[key_id]; + if (w_out) *w_out = loaded_icon_w[key_id]; + if (h_out) *h_out = loaded_icon_h[key_id]; + if (len_out) *len_out = loaded_icon_len[key_id]; + return true; +#endif + } + return false; +} + +/* Render an AnimationFrame from a stored icon into the confirm's left column. + * Image + frame are the CALLER's (must outlive the synchronous confirm); this + * only wires them up. Returns RUNTIME_ICON when an icon was set, else NO_ICON. + * Positioning tuned on device — icon column is ~40px, height 64px. */ +static IconType stage_runtime_icon(Image* img, AnimationFrame* frame, + const uint8_t* icon, uint8_t icon_w, + uint8_t icon_h, uint16_t icon_len) { + if (!icon || icon_len == 0) return NO_ICON; + /* Fail closed on an over-wide icon rather than drawing it at x=0: text begins + * at x=40 and the icon is drawn AFTER the text, so a wider icon would paint + * over the alias, fingerprint and the "NOT verified by KeepKey" warning. + * The load handler already checks this, but enforce it again at the point of + * use. Dropping the logo degrades to a text-only identity; letting it erase + * the warning does not. */ + if (icon_w == 0 || icon_w > LEFT_MARGIN_WITH_ICON || icon_h == 0 || + icon_h > 64) { + return NO_ICON; + } + img->w = icon_w; + img->h = icon_h; + img->length = icon_len; + img->data = icon; + /* Center inside the confirm's left icon column (LEFT_MARGIN_WITH_ICON=40px). + * Vertically center in the 64px height. */ + frame->x = (uint16_t)((LEFT_MARGIN_WITH_ICON - icon_w) / 2); + frame->y = (icon_h < 64) ? (uint16_t)((64 - icon_h) / 2) : 0; + frame->duration = 0; + /* Decoder does value*color/100; color=100 => data bytes are direct 0-255. */ + frame->color = 100; + frame->image = img; + layout_set_runtime_icon(frame); + return RUNTIME_ICON; +} + +bool signed_metadata_confirm_load(const char* alias, const char* fingerprint, + const uint8_t* icon, uint8_t icon_w, + uint8_t icon_h, uint16_t icon_len) { + Image icon_img; + AnimationFrame icon_frame; + IconType id_icon = stage_runtime_icon(&icon_img, &icon_frame, icon, icon_w, + icon_h, icon_len); + + char body[160]; + memset(body, 0, sizeof(body)); + /* Lead with the identity (its logo + alias + fingerprint). The trust model + * hangs on this consent; the fingerprint reappears on every per-tx screen. */ + snprintf(body, sizeof(body), + "Trust '%s' (%s) for this session to describe transactions? NOT " + "verified by KeepKey.", + alias, fingerprint); + bool ok = confirm_with_icon(ButtonRequestType_ButtonRequest_Other, id_icon, + _("Load Clearsigner"), "%s", body); + layout_set_runtime_icon(NULL); + return ok; +} + +void signed_metadata_pubkey_fingerprint(const uint8_t pubkey[33], + char out[METADATA_FINGERPRINT_LEN]) { + uint8_t digest[32]; + sha256_Raw(pubkey, 33, digest); + data2hex(digest, 4, out); + memzero(digest, sizeof(digest)); +} + +bool signed_metadata_from_loaded_signer(void) { + return metadata_available && metadata_signer_loaded; +} + +/* Resolve the verification key for a slot. */ +static const uint8_t* metadata_pubkey_for(uint8_t key_id, bool* is_loaded) { + *is_loaded = false; + if (key_id >= METADATA_MAX_KEYS) { + return NULL; + } + if (loaded_pubkeys[key_id][0] != 0x00) { + *is_loaded = true; + return loaded_pubkeys[key_id]; + } + return NULL; +} + +bool signed_metadata_signer_is_runtime(uint8_t key_id) { + bool is_loaded = false; + return metadata_pubkey_for(key_id, &is_loaded) != NULL && is_loaded; +} + +bool signed_metadata_signer_fingerprint(uint8_t key_id, + char out[METADATA_FINGERPRINT_LEN]) { + bool is_loaded = false; + const uint8_t* pubkey = metadata_pubkey_for(key_id, &is_loaded); + if (!pubkey || (is_loaded && !storage_isPolicyEnabled("AdvancedMode"))) { + return false; + } + signed_metadata_pubkey_fingerprint(pubkey, out); + return true; +} + +bool signed_metadata_verify_attestation(uint8_t key_id, const uint8_t* data, + size_t data_len, const uint8_t* sig, + size_t sig_len) { + if (!data || data_len == 0 || !sig || sig_len != 64) { + return false; + } + bool is_loaded = false; + const uint8_t* pubkey = metadata_pubkey_for(key_id, &is_loaded); + if (!pubkey || (is_loaded && !storage_isPolicyEnabled("AdvancedMode"))) { + return false; + } + uint8_t digest[32]; + sha256_Raw(data, data_len, digest); + bool ok = ecdsa_verify_digest(&secp256k1, pubkey, sig, digest) == 0; + memzero(digest, sizeof(digest)); + return ok; +} + +MetadataClassification signed_metadata_process(const uint8_t* payload, + size_t payload_len, + uint8_t key_id) { + uint8_t digest[32]; + size_t signed_len; + bool is_loaded = false; + const uint8_t* pubkey; + + signed_metadata_clear(); + + pubkey = metadata_pubkey_for(key_id, &is_loaded); + if (!pubkey || (is_loaded && !storage_isPolicyEnabled("AdvancedMode")) || + !payload || payload_len < 65) { + return METADATA_MALFORMED; + } + + if (!parse_metadata_binary(payload, payload_len, &stored_metadata) || + stored_metadata.key_id != key_id) { + signed_metadata_clear(); + return METADATA_MALFORMED; + } + + signed_len = payload_len - sizeof(stored_metadata.signature) - 1; + sha256_Raw(payload, signed_len, digest); + + if (ecdsa_verify_digest(&secp256k1, pubkey, stored_metadata.signature, + digest) != 0) { + signed_metadata_clear(); + return METADATA_MALFORMED; + } + + metadata_available = true; + metadata_signer_loaded = is_loaded; + return stored_metadata.classification; +} + +bool signed_metadata_matches_tx(const EthereumSignTx* msg) { + /* Reset the v2 decode proof up front: it must reflect ONLY the current call. + * Any early return below (unavailable, wrong contract/selector/chain) leaves + * it false, so a stale `true` from a prior successful match can never let + * signed_metadata_enforce() pass for a v2 blob that did not decode this tx. + */ + metadata_schema_decoded = false; + + if (!metadata_available || !msg || + stored_metadata.classification != METADATA_VERIFIED || + msg->to.size != sizeof(stored_metadata.contract_address) || + msg->data_initial_chunk.size < sizeof(stored_metadata.selector)) { + return false; + } + + /* Contract address binding */ + if (memcmp(stored_metadata.contract_address, msg->to.bytes, + sizeof(stored_metadata.contract_address)) != 0) { + return false; + } + + /* Function selector binding */ + if (memcmp(stored_metadata.selector, msg->data_initial_chunk.bytes, + sizeof(stored_metadata.selector)) != 0) { + return false; + } + + /* Chain ID binding */ + if ((msg->has_chain_id ? msg->chain_id : 0) != stored_metadata.chain_id) { + return false; + } + + if (stored_metadata.version == METADATA_VERSION_SCHEMA) { + /* v2 commits to calldata only — never to msg->value. A v2 match otherwise + * suppresses the native-value confirm screen in ethereum.c, which would + * let a payable method clear-sign an ETH transfer whose amount is never + * shown. Rather than refuse every payable call (which forced blind-signing + * on exactly the routes that most need review), record that this tx moves + * value; ethereum.c keeps the amount/recipient screen when it does. The + * device reads that amount from the transaction it is signing, so nothing + * unattested is displayed and the schema stays transaction-independent. */ + metadata_schema_moves_value = false; + for (uint32_t i = 0; i < msg->value.size; i++) { + if (msg->value.bytes[i] != 0) { + metadata_schema_moves_value = true; + break; + } + } + /* v2 has no committed values or tx_hash: decode the args straight from the + * calldata this tx will sign. Success here means the schema fully accounts + * for the calldata (decode_v2_args enforces exact length + presence), so + * the display is bound to the signature structurally — nothing is enforced + * later against a digest (there is no tx_hash). A decode failure falls + * through to the normal blind-sign path. Record the decode explicitly: + * signed_metadata_enforce() requires it for v2, so a signature can never be + * emitted for a v2 blob whose args were not decoded from this tx. */ + metadata_schema_decoded = decode_v2_args(&stored_metadata, msg); + return metadata_schema_decoded; + } + + /* v1 only gates what we DISPLAY (so a benign-looking method screen can't be + * shown for the wrong call). The metadata commits to the full tx hash; that + * is enforced against the real signed digest in signed_metadata_enforce() + * because the digest does not exist until send_signature() finalizes it. */ + return true; +} + +/* Renders the clearsign screens in sequence. When a signer with an icon is + * loaded, its logo (the compass) is set as RUNTIME_ICON and STAYS set for the + * whole flow, so every screen — identity, method, contract, each arg — carries + * it. The caller (signed_metadata_confirm) clears the runtime icon once on + * return, covering every early-exit path. */ +static bool signed_metadata_confirm_screens(void) { + char body[128]; + /* Compass shown on every screen once a signer with an icon is loaded. */ + IconType screen_icon = NO_ICON; + Image icon_img; + AnimationFrame icon_frame; + + if (metadata_signer_loaded) { + /* Lead with the loaded IDENTITY (logo, if any, + alias + fingerprint) + * BEFORE any clearsign page. The user approved this identity as their + * trust anchor, so showing it — not a scary "NOT verified by KeepKey" + * banner — is the honest framing. The fingerprint stays reachable so a + * swapped provider is still detectable. */ + uint8_t key_id = stored_metadata.key_id; + bool is_loaded = false; + const uint8_t* pk = metadata_pubkey_for(key_id, &is_loaded); + const char* alias = signed_metadata_signer_alias(key_id); + char fingerprint[METADATA_FINGERPRINT_LEN]; + if (pk) { + signed_metadata_pubkey_fingerprint(pk, fingerprint); + } else { + strlcpy(fingerprint, "????????", sizeof(fingerprint)); + } + if (!alias) alias = "unknown"; + + /* Draw the identity logo in the confirm's left icon column if one was + * loaded. Image + frame are local — valid for the synchronous confirm + * call, then the runtime icon is cleared. (Positioning tuned on device.) */ + const uint8_t* icon_data; + uint8_t icon_w, icon_h; + uint16_t icon_len; + if (signed_metadata_signer_icon(key_id, &icon_data, &icon_w, &icon_h, + &icon_len)) { + icon_img.w = icon_w; + icon_img.h = icon_h; + icon_img.length = icon_len; + icon_img.data = icon_data; + icon_frame.x = 0; + icon_frame.y = (icon_h < 52) ? (uint16_t)((52 - icon_h) / 2 + 6) : 6; + icon_frame.duration = 0; + /* Decoder computes pixel = data * color / 100, so color=100 makes the + * icon's data bytes direct 0-255 intensities (matches the built-in + * icons). color=0xff would overflow uint8 and corrupt every pixel. */ + icon_frame.color = 100; + icon_frame.image = &icon_img; + layout_set_runtime_icon(&icon_frame); + screen_icon = RUNTIME_ICON; + } + + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "%s (%s)\ndescribes this tx.", alias, + fingerprint); + /* Runtime icon stays set from here on — every subsequent screen shows the + * compass. Cleared once by the caller. */ + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, "Identity", "%s", body)) { + return false; + } + + /* Method screen — same identity compass, no "Insight Verified" branding + * (that presentation is reserved for the built-in phase-2 keys). */ + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "Call:\n%s", stored_metadata.method_name); + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, "Clearsign", "%s", body)) { + return false; + } + } else { + /* Screen 1: Verified method — use review_with_icon for trust indicator */ + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "Verified call:\n%s", + stored_metadata.method_name); + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + VERIFIED_ICON, "Insight Verified", "%s", body)) { + return false; + } + } + + /* Screen 2: Contract address — ALWAYS show full address, never truncate. + * Truncation is a spoofing vector (attacker crafts matching prefix+suffix). + */ + char contract_addr[43] = "0x"; + ethereum_address_checksum(stored_metadata.contract_address, contract_addr + 2, + false, stored_metadata.chain_id); + memset(body, 0, sizeof(body)); + snprintf(body, sizeof(body), "Contract:\n%s", contract_addr); + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, stored_metadata.method_name, "%s", + body)) { + return false; + } + + /* Screen 3..N: Each decoded argument */ + for (uint8_t i = 0; i < stored_metadata.num_args; i++) { + MetadataArg* arg = &stored_metadata.args[i]; + memset(body, 0, sizeof(body)); + + switch (arg->format) { + case ARG_FORMAT_ADDRESS: { + char addr_full[43] = "0x"; + if (arg->value_len != 20) { + return false; + } + ethereum_address_checksum(arg->value, addr_full + 2, false, + stored_metadata.chain_id); + snprintf(body, sizeof(body), "%s:\n%s", arg->name, addr_full); + break; + } + case ARG_FORMAT_AMOUNT: { + bignum256 amount; + bn_from_metadata_bytes(arg->value, arg->value_len, &amount); + /* Check for MAX_UINT256 (unlimited approval) */ + bool is_max = true; + for (uint16_t j = 0; j < arg->value_len; j++) { + if (arg->value[j] != 0xFF) { + is_max = false; + break; + } + } + if (is_max && arg->value_len == 32) { + snprintf(body, sizeof(body), "%s:\nUNLIMITED", arg->name); + } else { + /* bn_format() BLANKS its output buffer and returns 0 when the value + * does not fit, so 48 bytes rendered a 256-bit amount as an EMPTY + * string: the clear-sign screen showed the argument name and no + * value, which is the one rendering a user cannot read as wrong. + * Size it beyond the 78-digit worst case and refuse to render a + * blank if it ever overflows anyway. */ + char formatted[96]; + if (bn_format(&amount, NULL, " wei", 0, 0, false, formatted, + sizeof(formatted)) == 0) { + strlcpy(formatted, "AMOUNT TOO LARGE TO DISPLAY", + 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); + /* bn_format() BLANKS its output buffer and returns 0 when the value + * does not fit, so 48 bytes rendered a 256-bit amount as an EMPTY + * string: the clear-sign screen showed the argument name and no + * value, which is the one rendering a user cannot read as wrong. + * Size it beyond the 78-digit worst case and refuse to render a + * blank if it ever overflows anyway. */ + char formatted[96]; + if (bn_format(&amount, NULL, suffix, decimals, 0, false, formatted, + sizeof(formatted)) == 0) { + strlcpy(formatted, "AMOUNT TOO LARGE TO DISPLAY", + sizeof(formatted)); + } + snprintf(body, sizeof(body), "%s:\n%s", arg->name, formatted); + } + break; + } + case ARG_FORMAT_BYTES: + case ARG_FORMAT_RAW: + default: { + char hex[(METADATA_MAX_ARG_VALUE_LEN * 2) + 1]; + size_t display_len = arg->value_len > 16 ? 16 : (size_t)arg->value_len; + data2hex(arg->value, display_len, hex); + snprintf(body, sizeof(body), "%s:\n%s%s", arg->name, hex, + arg->value_len > 16 ? "..." : ""); + break; + } + } + + if (!confirm_with_icon(ButtonRequestType_ButtonRequest_ConfirmOutput, + screen_icon, stored_metadata.method_name, "%s", + body)) { + return false; + } + } + + /* User approved the decoded who/what/why. From here the raw-data confirm is + * suppressed, so the signature MUST be bound to this metadata's tx hash. */ + relied_on_metadata = true; + return true; +} + +bool signed_metadata_confirm(void) { + if (!metadata_available || + stored_metadata.classification != METADATA_VERIFIED) { + return false; + } + bool ok = signed_metadata_confirm_screens(); + /* Single cleanup for every screen-flow exit — the runtime icon frame lives on + * the helper's stack, so it must not outlive this call. */ + layout_set_runtime_icon(NULL); + return ok; +} + +bool signed_metadata_relied(void) { return relied_on_metadata; } + +bool signed_metadata_enforce_decision(bool relied, bool available, + int classification, + const uint8_t* stored_hash, + const uint8_t* hash) { + if (!relied) { + return true; /* signature was not gated by metadata */ + } + /* Fail closed: relied on metadata but it's gone, not verified, or the signed + * digest differs from what was displayed → refuse to emit a signature. + * tx_hash is 32 bytes (see SignedMetadata). */ + return hash != NULL && stored_hash != NULL && available && + classification == METADATA_VERIFIED && + memcmp(stored_hash, hash, 32) == 0; +} + +bool signed_metadata_enforce_schema_decision(bool relied, bool available, + bool decoded, int classification) { + /* v2 (static schema) has no committed tx_hash. Its binding is structural: the + * args were decoded from the exact calldata being signed, and that calldata + * cannot change between decode and sign within one signing operation. So if + * we relied on a verified v2 decode, signing may proceed; there is no digest + * to compare. `decoded` is the explicit proof that decode_v2_args() ran and + * succeeded for this signing operation — required rather than inferred from + * call order, since v2 has no digest fallback. If we did not rely on the + * metadata, signing was never gated by it. */ + return !relied || + (available && decoded && classification == METADATA_VERIFIED); +} + +bool signed_metadata_enforce(const uint8_t hash[32]) { + if (metadata_binding.available) { + if (metadata_binding.version == METADATA_VERSION_SCHEMA) { + return signed_metadata_enforce_schema_decision( + relied_on_metadata, true, metadata_schema_decoded, + (MetadataClassification)metadata_binding.classification); + } + return signed_metadata_enforce_decision( + relied_on_metadata, true, + (MetadataClassification)metadata_binding.classification, + metadata_binding.tx_hash, hash); + } + 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/signing.c b/lib/firmware/signing.c index 41c6bdf85..9ae1e46e3 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -854,6 +854,14 @@ static bool is_segwit_input_script_type(const TxInputType* txinput) { return false; } +void signing_encode_script_type(InputScriptType script_type, uint8_t out[4]) { + const uint32_t value = (uint32_t)script_type; + out[0] = (uint8_t)value; + out[1] = (uint8_t)(value >> 8); + out[2] = (uint8_t)(value >> 16); + out[3] = (uint8_t)(value >> 24); +} + static bool signing_validate_input(const TxInputType* txinput) { if (txinput->prev_hash.size != 32) { fsm_sendFailure(FailureType_Failure_Other, @@ -868,21 +876,16 @@ static bool signing_validate_input(const TxInputType* txinput) { return false; } if (txinput->has_multisig) { - /* Validate before tx_input_script_size() uses m for fee accounting. The - * mixed single-sig/multisig path can stop comparing a common fingerprint, - * so the later fingerprint validation is not a sufficient boundary. */ - if (!multisig_quorum_is_valid(&txinput->multisig)) { - fsm_sendFailure(FailureType_Failure_SyntaxError, - _("Invalid multisig quorum")); - signing_abort(); - return false; - } - - /* DER-encoded secp256k1 signatures are at most 72 bytes. The generated - * field is bytes[73], but the legacy nanopb decoder can accept size 74 - * because its static repeated-element stride includes padding. Bound the - * host-controlled length before any copy, append, hash, or serialization. - */ + /* A DER-encoded ECDSA signature is at most 72 bytes: 0x30 len, then two + * 0x02-tagged integers of at most 33 bytes each. The wire field is sized + * max_size:73, so the decoder accepts 73 -- and the witness path writes + * the sighash byte AT signatures[i].size, which at 73 is one past the end + * of bytes[73]. For i < 14 that lands on signatures[i+1].size and can + * revive a slot the host left empty, changing the witness stack after the + * user has reviewed it; at i == 14 it lands on has_m. + * + * The declared max_size is a DECODER bound, never a runtime one. Bound it + * here, once, before anything indexes with it. */ for (uint32_t i = 0; i < txinput->multisig.signatures_count; i++) { if (txinput->multisig.signatures[i].size > 72) { fsm_sendFailure(FailureType_Failure_SyntaxError, @@ -953,6 +956,13 @@ static bool signing_validate_output(const TxOutputType* txoutput) { signing_abort(); return false; } + if (txoutput->has_multisig && + !transaction_multisig_quorum_is_valid(&txoutput->multisig)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid multisig quorum")); + signing_abort(); + return false; + } if (txoutput->address_n_count > 0 && !is_change_output_script_type(txoutput)) { @@ -1100,9 +1110,8 @@ static bool signing_check_input(TxInputType* txinput) { // computation) tx_prevout_hash(&hasher_check, txinput); uint8_t script_type_bytes[4]; - signing_checksum_script_type_bytes(txinput->script_type, script_type_bytes); + signing_encode_script_type(txinput->script_type, script_type_bytes); hasher_Update(&hasher_check, script_type_bytes, sizeof(script_type_bytes)); - memzero(script_type_bytes, sizeof(script_type_bytes)); return true; } @@ -1560,8 +1569,14 @@ static bool signing_sign_segwit_input(TxInputType* txinput) { continue; } nwitnesses++; - /* Never append the sighash inside the decoded protobuf field. Even a - * nominal 73-byte value has no spare byte there. */ + /* Build the witness element in a local rather than appending the + * sighash byte in place. The wire field is bytes[73] and the write + * went to bytes[size], so a host-supplied size of 73 wrote one past + * the end -- landing on signatures[i+1].size for i < 14, which can + * revive a slot the host deliberately left empty and change the + * witness stack after the user reviewed it, or on has_m at i == 14. + * signing_validate_input() now caps size at 72; this removes the + * out-of-bounds write itself rather than relying on that cap. */ uint8_t sig_with_hashtype[73]; const size_t sig_len = txinput->multisig.signatures[i].size; memcpy(sig_with_hashtype, txinput->multisig.signatures[i].bytes, @@ -1569,7 +1584,6 @@ static bool signing_sign_segwit_input(TxInputType* txinput) { sig_with_hashtype[sig_len] = sighash; r += tx_serialize_script(sig_len + 1, sig_with_hashtype, resp.serialized.serialized_tx.bytes + r); - memzero(sig_with_hashtype, sizeof(sig_with_hashtype)); } uint32_t script_len = compile_script_multisig(coin, &txinput->multisig, 0); @@ -1581,22 +1595,10 @@ static bool signing_sign_segwit_input(TxInputType* txinput) { } else { // single signature uint32_t r = 0; r += ser_length(2, resp.serialized.serialized_tx.bytes + r); - /* The protobuf signature field has no guaranteed spare byte. Serialize - * the wire-only sighash suffix from bounded scratch instead of writing - * one byte past bytes[size]. */ - uint8_t sig_with_hashtype[73]; - const size_t sig_len = resp.serialized.signature.size; - if (sig_len > 72) { - fsm_sendFailure(FailureType_Failure_Other, - _("Invalid signature length")); - signing_abort(); - return false; - } - memcpy(sig_with_hashtype, resp.serialized.signature.bytes, sig_len); - sig_with_hashtype[sig_len] = sighash; - r += tx_serialize_script(sig_len + 1, sig_with_hashtype, + resp.serialized.signature.bytes[resp.serialized.signature.size] = sighash; + r += tx_serialize_script(resp.serialized.signature.size + 1, + resp.serialized.signature.bytes, resp.serialized.serialized_tx.bytes + r); - memzero(sig_with_hashtype, sizeof(sig_with_hashtype)); r += tx_serialize_script(33, node.public_key, resp.serialized.serialized_tx.bytes + r); resp.serialized.serialized_tx.size = r; @@ -1906,11 +1908,9 @@ void signing_txack(TransactionType* tx) { // check prevouts and script type tx_prevout_hash(&hasher_check, tx->inputs); uint8_t script_type_bytes[4]; - signing_checksum_script_type_bytes(tx->inputs[0].script_type, - script_type_bytes); + signing_encode_script_type(tx->inputs[0].script_type, script_type_bytes); hasher_Update(&hasher_check, script_type_bytes, sizeof(script_type_bytes)); - memzero(script_type_bytes, sizeof(script_type_bytes)); if (idx2 == idx1) { if (!compile_input_script_sig(&tx->inputs[0])) { fsm_sendFailure(FailureType_Failure_Other, @@ -2287,8 +2287,6 @@ void signing_abort(void) { memzero(&signing_update_ctr, sizeof(signing_update_ctr)); } -bool signing_is_active(void) { return signing; } - #if DEBUG_LINK static CoinType signing_test_coin; static curve_info signing_test_curve; diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index d67254390..b1806b03d 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -19,7 +19,10 @@ #include "keepkey/firmware/solana.h" +#include "keepkey/firmware/signed_metadata.h" +#include "trezor/crypto/ed25519-donna/ed25519-donna.h" #include "trezor/crypto/memzero.h" +#include "trezor/crypto/sha2.h" #include #include @@ -73,6 +76,18 @@ const uint8_t SOL_MEMO_PROGRAM[SOL_PUBKEY_SIZE] = { 0x71, 0x60, 0xda, 0x38, 0x7c, 0x7c, 0x35, 0xb5, 0xdd, 0xbc, 0x92, 0xbb, 0x81, 0xe4, 0x1f, 0xa8, 0x40, 0x41, 0x05, 0x44, 0x8d}; +/* Circle's mainnet SPL USDC mint: + * EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v. */ +static const SolanaKnownToken SOL_KNOWN_TOKENS[] = {{ + {0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, 0x3d, 0x65, 0xf3, + 0x6a, 0xab, 0xc9, 0x74, 0x31, 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, + 0xe0, 0xe4, 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61}, + "USDC", + 6, +}}; + +static const char SOL_PDA_MARKER[] = "ProgramDerivedAddress"; + /* ------------------------------------------------------------------ */ /* Compact-u16 decoder (Solana transaction format) */ /* ------------------------------------------------------------------ */ @@ -122,10 +137,17 @@ static void copy_account(uint8_t out[SOL_PUBKEY_SIZE], const SolanaParsedTx* tx, } } +/* allow_external_indices: versioned (v0) messages may reference accounts + * loaded from address lookup tables — indices at or beyond the static + * account list. Those accounts are not present in the message, so an + * instruction touching them cannot be verified on-device: it is left + * SOL_INSTR_UNKNOWN and the whole tx is forced opaque instead of being + * rejected as malformed. Legacy messages must never contain such indices. */ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, size_t* pos_io, SolanaParsedTx* tx, uint16_t num_accounts, bool* has_unknown, - bool* force_opaque) { + bool* force_opaque, + bool allow_external_indices) { size_t pos = *pos_io; uint16_t num_instructions; int n = read_compact_u16(raw + pos, raw_len - pos, &num_instructions); @@ -133,11 +155,10 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, pos += n; if (num_instructions > SOL_MAX_INSTRUCTIONS) { + /* Too many to display — opaque. Keep walking the section so the + * structural checks (and any trailing sections) stay meaningful. */ *force_opaque = true; tx->num_instructions = 0; - /* Don't attempt to parse instruction data — treat as opaque. */ - *pos_io = raw_len; - return 0; } else { tx->num_instructions = (uint8_t)num_instructions; } @@ -145,7 +166,11 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, for (uint16_t i = 0; i < num_instructions; i++) { if (pos >= raw_len) return -1; uint8_t program_idx = raw[pos++]; - if (program_idx >= num_accounts) return -1; + bool external = false; + if (program_idx >= num_accounts) { + if (!allow_external_indices) return -1; + external = true; + } uint16_t num_acct_indices; n = read_compact_u16(raw + pos, raw_len - pos, &num_acct_indices); @@ -157,7 +182,10 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, pos += num_acct_indices; for (uint16_t j = 0; j < num_acct_indices; j++) { - if (acct_indices[j] >= num_accounts) return -1; + if (acct_indices[j] >= num_accounts) { + if (!allow_external_indices) return -1; + external = true; + } } uint16_t data_len; @@ -169,49 +197,36 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, const uint8_t* instr_data = raw + pos; pos += data_len; - if (i >= SOL_MAX_INSTRUCTIONS) { + if (i >= SOL_MAX_INSTRUCTIONS || tx->num_instructions == 0) { continue; } SolanaParsedInstruction* pi = &tx->instructions[i]; + + /* Retain the raw payload and account index list for EVERY instruction: a + * KKSOLSC1 schema reads its args out of `data` and resolves its labelled + * accounts through `acct_indices`. Both point into the caller's raw + * message buffer and share its lifetime. (The memo path below also sets + * data/data_len; assigning here first is harmless and covers the rest.) */ + pi->data = instr_data; + pi->data_len = data_len; + pi->acct_indices = acct_indices; + pi->num_acct_indices = + num_acct_indices > 255 ? 255 : (uint8_t)num_acct_indices; + + if (external) { + /* Accounts resolved via lookup tables: unverifiable on-device. */ + pi->type = SOL_INSTR_UNKNOWN; + pi->external = true; + *force_opaque = true; + continue; + } + memcpy(pi->program_id, tx->accounts[program_idx], SOL_PUBKEY_SIZE); - /* Classify and decode */ - /* Every fixed-layout decoder below matches its data length EXACTLY, never - * `>=`. - * - * A `>=` gate decodes the prefix it understands and lets the rest through: - * solana_signTx() signs the whole raw_tx, so trailing bytes on a recognised - * instruction were covered by the signature, shown on no screen, and -- the - * part that matters -- did NOT set *has_unknown, so the transaction was - * never classified opaque and never met the blind-sign gate. The runtime - * ignoring those bytes (SPL's unpack reads its fields and drops the tail) - * is what makes them attractive rather than harmless: free to append, and - * the device vouches for them. - * - * So the rule is: decode only an encoding this device can account for - * byte-for-byte. Anything else is UNKNOWN, which is not a refusal -- it - * routes to the opaque path, where the user is told the contents cannot be - * verified. An encoder that pads therefore loses clear-signing, not the - * ability to sign. - * - * Each gate is the exact number of bytes that decoder reads and can account - * for. Three of them are not merely the old bound tightened, so they are - * worth naming: - * - * System CreateAccount is 52 (u32 tag + u64 lamports + u64 space + - * Pubkey owner). This code accepted 12 while reading only lamports, so - * the space and owner it never looked at were signed unseen. - * - * SPL SetAuthority is 3 or 35, and which one is fixed by its COption - * discriminant: a `Some` with no key, or a `None` carrying 32 bytes, is - * not an encoding this device can claim to have read. - * - * Stake Authorize is 40. The old `>= 36` let read_le32(instr_data + 36) - * run off the end of a 36..39-byte field and report whatever followed it - * in the buffer as the authorization type. - * - * The ATA branch below already worked this way. */ + /* Classify and decode. A verified instruction must match the exact wire + * shape whose fields the confirmation path displays. Prefix matches are + * opaque: trailing bytes are signed semantics, not ignorable padding. */ if (memcmp(pi->program_id, SOL_SYSTEM_PROGRAM, SOL_PUBKEY_SIZE) == 0) { /* System program */ if (data_len >= 4) { @@ -275,13 +290,13 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, 0 || memcmp(pi->program_id, SOL_TOKEN_2022_PROGRAM, SOL_PUBKEY_SIZE) == 0) { - bool is_token_2022 = + /* Token-2022 transfers can invoke a configured transfer-hook program with + * extra accounts and arbitrary logic (and levy transfer fees) that we can + * neither authenticate nor display. Treat them as opaque (AdvancedMode) + * rather than clear-sign only source/mint/dest/amount. */ + const bool is_token2022 = memcmp(pi->program_id, SOL_TOKEN_2022_PROGRAM, SOL_PUBKEY_SIZE) == 0; - /* Token-2022 extensions (fees, hooks and their extra accounts) are not - * authenticated or displayed by this decoder. Never present any - * Token-2022 operation as verified; AdvancedMode remains available for - * an explicit opaque signature. */ - if (is_token_2022) *force_opaque = true; + if (is_token2022) *force_opaque = true; if (data_len >= 1) { uint8_t token_instr = instr_data[0]; if (token_instr == SOL_TOKEN_TRANSFER_IX && data_len == 9 && @@ -291,11 +306,26 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); copy_account(pi->to, tx, acct_indices, num_acct_indices, 1); copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); - /* Unchecked Transfer carries no signed mint or decimals. The device - * cannot identify what asset the amount moves. */ + /* Unchecked Transfer carries no signed mint, so the device cannot + * prove which token is moving — a host can pick any signer-controlled + * account. Force the AdvancedMode blind-sign gate; only the *Checked + * variant (mint signed + displayed) clear-signs. */ *force_opaque = true; } else if (token_instr == SOL_TOKEN_TRANSFER_CHECKED_IX && data_len == 10 && num_acct_indices >= 4) { + /* Canonical TransferChecked ONLY: opcode + amount(8) + decimals(1) + * and all four accounts [source, mint, dest, authority]. A 9-byte + * encoding (no decimals) or a short account list would otherwise + * classify VERIFIED while skipping the mint screen and showing a + * zeroed destination — such non-canonical shapes fall through to + * UNKNOWN and force the whole tx opaque. + * + * This is the strict form of the 7.14.2 rule that a TransferChecked + * shorter than 10 bytes must not classify as checked: the decimals + * byte is the only authoritative scale for the transfer, so a + * missing one may never be fabricated as 0. It additionally rejects + * data_len > 10 and short account lists, which 7.14.2 still let + * through. */ pi->type = SOL_INSTR_TOKEN_TRANSFER_CHECKED; pi->amount = read_le64(instr_data + 1); copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); @@ -305,11 +335,15 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, copy_account(pi->authority, tx, acct_indices, num_acct_indices, 3); /* Decimals live in the signed instruction bytes and are the only * authoritative scale for this transfer, so they must never be - * fabricated. A real TransferChecked data field is exactly 10 bytes - * (tag + u64 amount + decimals); anything else -- short OR long -- - * falls through to SOL_INSTR_UNKNOWN and the transaction is treated - * as opaque. */ + * fabricated. The data_len == 10 guard above is what makes this read + * unconditional and in-bounds; a short encoding falls through to + * SOL_INSTR_UNKNOWN and the transaction is treated as opaque. */ pi->extra_u8 = instr_data[9]; + /* Token-2022 checked transfers may carry an undisclosed transfer hook + * / fee — do not clear-sign them. */ + if (is_token2022) { + *force_opaque = true; + } } else if (token_instr == SOL_TOKEN_APPROVE_IX && data_len == 9 && num_acct_indices >= 3) { pi->type = SOL_INSTR_TOKEN_APPROVE; @@ -317,7 +351,8 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); copy_account(pi->to, tx, acct_indices, num_acct_indices, 1); copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); - /* Unchecked Approve likewise carries no mint. */ + /* Unchecked Approve hides the mint (which token is being delegated), + * same as unchecked Transfer — require AdvancedMode. */ *force_opaque = true; } else if (token_instr == SOL_TOKEN_REVOKE_IX && data_len == 1 && num_acct_indices >= 2) { @@ -328,10 +363,6 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, num_acct_indices >= 2 && ((data_len == 3 && instr_data[2] == 0) || (data_len == 35 && instr_data[2] == 1))) { - /* tag + authority_type + COption: 3 bytes for None, 35 for - Some. The discriminant and the length must agree -- a `Some` with - no key, or a `None` carrying 32 bytes, is not an encoding this - device can claim to have read. */ pi->type = SOL_INSTR_TOKEN_SET_AUTHORITY; pi->extra_u8 = instr_data[1]; copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); @@ -339,8 +370,11 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, if (instr_data[2] == 1) { memcpy(pi->extra, instr_data + 3, SOL_PUBKEY_SIZE); } - /* The authority role, target and permanent None revocation require a - * dedicated complete UX. Until then this is opaque-only. */ + /* Authority handover (owner/close/mint/freeze) is an account-takeover + * vector, and the "set to None" (clear) case is not distinguished + * from an all-zero authority in the parsed struct. Require + * AdvancedMode until a full screen (authority type + target + + * new/None) exists. */ *force_opaque = true; } else if (((token_instr == SOL_TOKEN_MINT_TO_IX && data_len == 9) || (token_instr == SOL_TOKEN_MINT_TO_CHECKED_IX && @@ -353,10 +387,9 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, copy_account(pi->to, tx, acct_indices, num_acct_indices, 1); copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); pi->extra_u8 = - (token_instr == SOL_TOKEN_MINT_TO_CHECKED_IX) ? instr_data[9] : 0; - /* The shared confirmation does not distinguish checked from - * unchecked minting. Until it does, raw review is the only honest - * representation of the signed opcode and scale. */ + token_instr == SOL_TOKEN_MINT_TO_CHECKED_IX ? instr_data[9] : 0; + /* Checked and unchecked minting share one confirmation today, so + * the signed opcode/scale is not fully represented. */ *force_opaque = true; } else if (((token_instr == SOL_TOKEN_BURN_IX && data_len == 9) || (token_instr == SOL_TOKEN_BURN_CHECKED_IX && @@ -369,9 +402,7 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, pi->has_mint = true; copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); pi->extra_u8 = - (token_instr == SOL_TOKEN_BURN_CHECKED_IX) ? instr_data[9] : 0; - /* As with minting, do not clear-sign an opcode whose signed decimals - * are not represented by the confirmation path. */ + token_instr == SOL_TOKEN_BURN_CHECKED_IX ? instr_data[9] : 0; *force_opaque = true; } else if (token_instr == SOL_TOKEN_CLOSE_ACCOUNT_IX && data_len == 1 && num_acct_indices >= 3) { @@ -429,7 +460,6 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, pi->type = SOL_INSTR_STAKE_AUTHORIZE; memcpy(pi->extra, instr_data + 4, SOL_PUBKEY_SIZE); copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); - /* Canonical accounts: stake, clock sysvar, current authority. */ copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); pi->extra_u8 = (uint8_t)role; } else { @@ -472,7 +502,6 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, pi->type = SOL_INSTR_VOTE_AUTHORIZE; memcpy(pi->extra, instr_data + 4, SOL_PUBKEY_SIZE); copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); - /* Canonical accounts: vote, clock sysvar, current authority. */ copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); pi->extra_u8 = (uint8_t)role; } else { @@ -488,6 +517,11 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); } else if (vote_instr == SOL_VOTE_UPDATE_VALIDATOR_IX && data_len == 4 && num_acct_indices >= 3) { + /* UpdateValidatorIdentity has NO data payload: the new validator is + * account index 1. Reading 32 bytes from the data would display + * attacker-supplied trailing bytes instead of the account actually + * used, so require the canonical 4-byte encoding and read account 1. + */ pi->type = SOL_INSTR_VOTE_UPDATE_VALIDATOR; copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); copy_account(pi->extra, tx, acct_indices, num_acct_indices, 1); @@ -507,7 +541,15 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, *has_unknown = true; } } else if (memcmp(pi->program_id, SOL_ATA_PROGRAM, SOL_PUBKEY_SIZE) == 0) { - if ((data_len == 0 || (data_len == 1 && instr_data[0] == 0)) && + /* 0 = Create, 1 = CreateIdempotent, and empty data is the legacy + * encoding of Create. Idempotent takes the SAME accounts in the same + * order and creates the same account — it merely succeeds instead of + * failing when one already exists — so it displays identically. Wallets + * emit it by default (a token transfer whose recipient may lack an ATA), + * and rejecting it forced the whole transaction opaque: an SPL transfer + * that is otherwise fully decodable would blind-sign. */ + if ((data_len == 0 || + (data_len == 1 && (instr_data[0] == 0 || instr_data[0] == 1))) && num_acct_indices >= 6) { pi->type = SOL_INSTR_ATA_CREATE; copy_account(pi->from, tx, acct_indices, num_acct_indices, 0); @@ -515,11 +557,6 @@ static int parse_instruction_section(const uint8_t* raw, size_t raw_len, copy_account(pi->authority, tx, acct_indices, num_acct_indices, 2); copy_account(pi->mint, tx, acct_indices, num_acct_indices, 3); pi->has_mint = true; - /* Canonical ATA Create then names the System and Token programs. A - * Token-2022 program here creates a materially different account even - * though the instruction itself targets the ATA program. Until the - * Token-2022 semantics can be disclosed, only the exact legacy pair is - * clear-signed. */ if (memcmp(tx->accounts[acct_indices[4]], SOL_SYSTEM_PROGRAM, SOL_PUBKEY_SIZE) != 0 || memcmp(tx->accounts[acct_indices[5]], SOL_TOKEN_PROGRAM, @@ -608,7 +645,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 */ @@ -626,7 +664,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++]; @@ -657,13 +695,20 @@ static SolanaTxReview solana_parseVersionedTx(const uint8_t* raw, pos += SOL_PUBKEY_SIZE; n = parse_instruction_section(raw, raw_len, &pos, tx, num_accounts, - &has_unknown, &force_opaque); + &has_unknown, &force_opaque, + /*allow_external_indices=*/true); if (n < 0) return SOL_TX_REVIEW_MALFORMED; uint16_t lookup_table_count; n = read_compact_u16(raw + pos, raw_len - pos, &lookup_table_count); if (n < 0) return SOL_TX_REVIEW_MALFORMED; pos += n; + if (lookup_table_count != 0) { + /* Clear-signing is intentionally limited to self-contained v0 messages. + * Even if current instructions appear to use only static accounts, an ALT + * section requires chain state that this firmware does not resolve. */ + force_opaque = true; + } for (uint16_t i = 0; i < lookup_table_count; i++) { uint16_t writable_count, readonly_count; @@ -684,46 +729,212 @@ static SolanaTxReview solana_parseVersionedTx(const uint8_t* raw, } if (pos != raw_len) return SOL_TX_REVIEW_MALFORMED; - return SOL_TX_REVIEW_OPAQUE; + + /* A zero-LUT v0 message is self-contained and can be verified like legacy. + * Any lookup-table section remains available only through the AdvancedMode + * opaque path until firmware can resolve and authenticate chain state. */ + if (tx->num_instructions == 0 || has_unknown || force_opaque) { + return SOL_TX_REVIEW_OPAQUE; + } + return SOL_TX_REVIEW_VERIFIED; } -static bool solana_messageBytes(const uint8_t* raw, size_t raw_len, - const uint8_t** message, size_t* message_len) { - if (!raw || raw_len == 0 || !message || !message_len) return false; - if (raw[0] == 0) { - if (raw_len == 1) return false; +/* Normalize the bytes that are actually signed. Solana signs the serialized + * MESSAGE. Clients may send either the bare message (byte 0 = num_required_sigs + * >= 1) or a full unsigned transaction whose byte 0 is a compact-u16 signature + * count of 0. Strip that single prefix byte so parsing (solana_inspectTx) and + * signing (solana_signTx) operate on the IDENTICAL slice — otherwise the device + * would display one message but sign 0x00||message, which never verifies. */ +static void solana_message_slice(const uint8_t* raw, size_t raw_len, + const uint8_t** msg_out, size_t* len_out) { + if (raw_len > 1 && raw[0] == 0) { raw++; raw_len--; } - *message = raw; - *message_len = raw_len; - return true; + *msg_out = raw; + *len_out = raw_len; } SolanaTxReview solana_inspectTx(const uint8_t* raw, size_t raw_len, SolanaParsedTx* tx) { - const uint8_t* message; - size_t message_len; - if (!solana_messageBytes(raw, raw_len, &message, &message_len)) { + if (raw_len == 0) { memset(tx, 0, sizeof(*tx)); return SOL_TX_REVIEW_MALFORMED; } - /* Clients may send either a serialized message or a full unsigned - * transaction whose compact-u16 signature count is zero. Solana signatures - * cover the message, not that transaction prefix. solana_signTx() performs - * the identical normalization before signing. */ - raw = message; - raw_len = message_len; + const uint8_t* msg; + size_t msg_len; + solana_message_slice(raw, raw_len, &msg, &msg_len); /* Versioned Solana messages set the top bit in byte 0. * Parse them structurally so malformed v0/ALT payloads fail closed, * but keep the result opaque until the firmware can verify semantics. */ - if (raw[0] & SOL_VERSION_FLAG) { - return solana_parseVersionedTx(raw, raw_len, tx); + if (msg[0] & SOL_VERSION_FLAG) { + return solana_parseVersionedTx(msg, msg_len, tx); } - return solana_parseLegacyTx(raw, raw_len, tx); + return solana_parseLegacyTx(msg, msg_len, tx); +} + +/* ------------------------------------------------------------------ */ +/* KKSOLSC1 reusable instruction schemas */ +/* ------------------------------------------------------------------ */ + +/* Display-safe: printable ASCII, and no '%' so a label can never smuggle a + * conversion specifier into a format string. */ +static bool schema_text_ok(const uint8_t* v, size_t len) { + if (len == 0) return false; + for (size_t i = 0; i < len; i++) { + if (v[i] < 0x20 || v[i] > 0x7e || v[i] == '%') return false; + } + return true; +} + +static bool schema_read_text(const uint8_t** cur, const uint8_t* end, char* out, + size_t max_len) { + if (*cur >= end) return false; + uint8_t len = *(*cur)++; + if (len == 0 || len > max_len || (size_t)(end - *cur) < len || + !schema_text_ok(*cur, len)) { + return false; + } + memcpy(out, *cur, len); + out[len] = '\0'; + *cur += len; + return true; +} + +/* Byte width an arg consumes in the instruction data. */ +uint16_t solana_schemaArgWidth(SolanaSchemaArgType t) { + switch (t) { + case SOL_SCHEMA_ARG_U64: + return 8; + case SOL_SCHEMA_ARG_U8: + return 1; + case SOL_SCHEMA_ARG_PUBKEY: + case SOL_SCHEMA_ARG_OPAQUE32: + return 32; + } + return 0; /* unknown type — caller rejects */ +} + +bool solana_parseInstrSchema(const uint8_t* payload, size_t payload_len, + SolanaInstrSchema* out) { + static const uint8_t magic[8] = {'K', 'K', 'S', 'O', 'L', 'S', 'C', '1'}; + if (!payload || !out || + payload_len < sizeof(magic) + 1 + SOL_PUBKEY_SIZE + 1) { + return false; + } + memset(out, 0, sizeof(*out)); + const uint8_t* cur = payload; + const uint8_t* end = payload + payload_len; + + if (memcmp(cur, magic, sizeof(magic)) != 0) return false; + cur += sizeof(magic); + if (*cur++ != 1) return false; /* version */ + + if ((size_t)(end - cur) < SOL_PUBKEY_SIZE + 1) return false; + memcpy(out->program_id, cur, SOL_PUBKEY_SIZE); + cur += SOL_PUBKEY_SIZE; + + out->disc_len = *cur++; + if (out->disc_len == 0 || out->disc_len > SOL_SCHEMA_DISC_MAX || + (size_t)(end - cur) < out->disc_len) { + return false; + } + memcpy(out->disc, cur, out->disc_len); + cur += out->disc_len; + + if (!schema_read_text(&cur, end, out->program_name, SOL_SCHEMA_NAME_MAX) || + !schema_read_text(&cur, end, out->instruction_name, + SOL_SCHEMA_NAME_MAX) || + cur >= end) { + return false; + } + + out->num_args = *cur++; + if (out->num_args > SOL_SCHEMA_MAX_ARGS) return false; + for (uint8_t i = 0; i < out->num_args; i++) { + if (cur >= end) return false; + uint8_t type = *cur++; + if (solana_schemaArgWidth((SolanaSchemaArgType)type) == 0) return false; + out->args[i].type = (SolanaSchemaArgType)type; + if (!schema_read_text(&cur, end, out->args[i].label, + SOL_SCHEMA_LABEL_MAX)) { + return false; + } + } + + if (cur >= end) return false; + out->num_accounts = *cur++; + if (out->num_accounts > SOL_SCHEMA_MAX_ACCOUNTS) return false; + for (uint8_t i = 0; i < out->num_accounts; i++) { + if (cur >= end) return false; + out->accounts[i].index = *cur++; + if (!schema_read_text(&cur, end, out->accounts[i].label, + SOL_SCHEMA_LABEL_MAX)) { + return false; + } + } + + return cur == end; /* no trailing bytes */ +} + +bool solana_schemaApplies(const SolanaInstrSchema* schema, + const SolanaParsedTx* tx, uint8_t* out_index) { + if (!schema || !tx || !out_index) return false; + + bool found = false; + uint8_t match = 0; + for (uint8_t i = 0; i < tx->num_instructions; i++) { + const SolanaParsedInstruction* ix = &tx->instructions[i]; + if (ix->external) continue; /* accounts not in the signed message */ + if (memcmp(ix->program_id, schema->program_id, SOL_PUBKEY_SIZE) != 0) { + continue; + } + if (!ix->data || ix->data_len < schema->disc_len || + memcmp(ix->data, schema->disc, schema->disc_len) != 0) { + continue; + } + + /* Structural completeness: the discriminator plus every declared arg must + * account for the instruction data EXACTLY. Leftover bytes could carry an + * effect the screens never mention. */ + uint32_t consumed = schema->disc_len; + for (uint8_t a = 0; a < schema->num_args; a++) { + consumed += solana_schemaArgWidth(schema->args[a].type); + } + if (consumed != ix->data_len) continue; + + /* Every displayed account must actually exist in this instruction. */ + bool accounts_ok = true; + for (uint8_t a = 0; a < schema->num_accounts; a++) { + if (schema->accounts[a].index >= ix->num_acct_indices) { + accounts_ok = false; + break; + } + } + if (!accounts_ok) continue; + + if (found) return false; /* ambiguous: two instructions match */ + found = true; + match = i; + } + if (!found) return false; + + /* A schema explains ONE instruction. Every other instruction must be one + * firmware already decodes, or the message could move funds through a path + * no screen described. */ + for (uint8_t i = 0; i < tx->num_instructions; i++) { + if (i == match) continue; + if (tx->instructions[i].external || + tx->instructions[i].type == SOL_INSTR_UNKNOWN) { + return false; + } + } + + *out_index = match; + return true; } bool solana_parseTx(const uint8_t* raw, size_t raw_len, SolanaParsedTx* tx) { @@ -734,6 +945,86 @@ bool solana_parseTx(const uint8_t* raw, size_t raw_len, SolanaParsedTx* tx) { /* Formatting */ /* ------------------------------------------------------------------ */ +bool solana_priority_fee_lamports(uint64_t price, uint64_t limit, + uint64_t* out) { + /* ceil(price * limit / 1e6) with no overflow and no silent wrap. price/limit + * are u64; the product can exceed u64, and even ceil(product/1e6) can exceed + * u64. Split price = q*D + r and accumulate so every step is checked; return + * false (do NOT saturate) if the true lamport value exceeds UINT64_MAX. */ + const uint64_t D = 1000000u; + uint64_t q = price / D; + uint64_t r = price % D; + if (limit != 0 && r > UINT64_MAX / limit) { + return false; /* r*limit overflows (only for absurd limits) */ + } + uint64_t rl = r * limit; + uint64_t lamports = rl / D; + bool ceil_up = (rl % D) != 0; + if (q != 0 && limit != 0) { + if (q > UINT64_MAX / limit) { + return false; + } + uint64_t ql = q * limit; + if (ql > UINT64_MAX - lamports) { + return false; + } + lamports += ql; + } + if (ceil_up) { + if (lamports == UINT64_MAX) { + return false; + } + lamports++; + } + *out = lamports; + return true; +} + +bool solana_calculatePriorityFee(const SolanaParsedTx* tx, uint64_t* fee_out, + bool* has_fee) { + if (!tx || !fee_out || !has_fee) return false; + + uint64_t price = 0; + uint64_t limit = 0; + uint64_t non_budget_instructions = 0; + bool seen_price = false; + bool seen_limit = false; + *fee_out = 0; + *has_fee = false; + + for (uint8_t i = 0; i < tx->num_instructions; i++) { + const SolanaParsedInstruction* instruction = &tx->instructions[i]; + if (instruction->type != SOL_INSTR_COMPUTE_BUDGET_HEAP_FRAME && + instruction->type != SOL_INSTR_COMPUTE_BUDGET_UNIT_LIMIT && + instruction->type != SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE && + instruction->type != SOL_INSTR_COMPUTE_BUDGET_LOADED_ACCOUNTS_SIZE) { + non_budget_instructions++; + } + if (instruction->type == SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE) { + if (seen_price) return false; + seen_price = true; + price = instruction->extra_value; + } else if (instruction->type == SOL_INSTR_COMPUTE_BUDGET_UNIT_LIMIT) { + if (seen_limit) return false; + seen_limit = true; + limit = instruction->extra_value; + } + } + + if (!seen_limit) { + /* Solana's runtime default is 200,000 compute units per non-budget + * instruction, capped at 1,400,000. Derive the actual implicit limit + * instead of overstating every transaction as though it used the cap. */ + limit = non_budget_instructions * 200000u; + if (limit > 1400000u) limit = 1400000u; + } + + if (!seen_price || price == 0) return true; + if (!solana_priority_fee_lamports(price, limit, fee_out)) return false; + *has_fee = true; + return true; +} + void solana_formatAmount(char* buf, size_t len, uint64_t lamports) { uint64_t whole = lamports / SOL_LAMPORTS_DIVISOR; uint64_t frac = lamports % SOL_LAMPORTS_DIVISOR; @@ -743,20 +1034,11 @@ void solana_formatAmount(char* buf, size_t len, uint64_t lamports) { void solana_formatTokenAmount(char* buf, size_t len, uint64_t amount, const char* symbol, uint8_t decimals) { - if (decimals == 0) { + if (decimals == 0 || decimals > SOL_MAX_TOKEN_DECIMALS) { snprintf(buf, len, "%llu %s", (unsigned long long)amount, symbol); return; } - /* A mint's decimals field is an unrestricted uint8_t. Preserve both signed - * values exactly when the scale exceeds this formatter's arithmetic range - * instead of dropping the scale. */ - if (decimals > SOL_MAX_TOKEN_DECIMALS) { - snprintf(buf, len, "%llu base units (%u decimals) %s", - (unsigned long long)amount, (unsigned)decimals, symbol); - return; - } - uint64_t divisor = 1; for (uint8_t i = 0; i < decimals; i++) divisor *= 10; @@ -796,76 +1078,162 @@ void solana_formatTokenAmount(char* buf, size_t len, uint64_t amount, show_frac /= 10; } frac_str[show_dec] = '\0'; + /* Every fractional place is shown, trailing zeros included. Trimming them + * ("1.000000000" -> "1") hides the scale the signed base-unit count was + * divided by, which is the one thing this screen exists to disclose. */ snprintf(buf, len, "%llu.%s %s", (unsigned long long)whole, frac_str, symbol); } -/* Solana's own default when a transaction carries no SetComputeUnitLimit: - 200,000 compute units per non-ComputeBudget instruction, capped at - 1,400,000. See the runtime's compute_budget_processor. */ -#define SOL_DEFAULT_CU_PER_INSTRUCTION 200000u -#define SOL_MAX_CU_LIMIT 1400000u - -static bool solana_isComputeBudgetInstruction(uint8_t type) { - return type == SOL_INSTR_COMPUTE_BUDGET_HEAP_FRAME || - type == SOL_INSTR_COMPUTE_BUDGET_UNIT_LIMIT || - type == SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE || - type == SOL_INSTR_COMPUTE_BUDGET_LOADED_ACCOUNTS_SIZE; +const SolanaKnownToken* solana_findKnownToken( + const uint8_t mint[SOL_PUBKEY_SIZE]) { + for (size_t i = 0; i < sizeof(SOL_KNOWN_TOKENS) / sizeof(SOL_KNOWN_TOKENS[0]); + i++) { + if (memcmp(SOL_KNOWN_TOKENS[i].mint, mint, SOL_PUBKEY_SIZE) == 0) { + return &SOL_KNOWN_TOKENS[i]; + } + } + return NULL; } -bool solana_calculatePriorityFee(const SolanaParsedTx* tx, uint64_t* fee_out, - bool* has_fee) { - const uint64_t divisor = 1000000u; - uint64_t price = 0; - uint64_t limit = 0; - bool seen_price = false; - bool seen_limit = false; - uint64_t non_budget_instructions = 0; - *has_fee = false; - - for (uint8_t i = 0; i < tx->num_instructions; i++) { - const SolanaParsedInstruction* pi = &tx->instructions[i]; - if (!solana_isComputeBudgetInstruction((uint8_t)pi->type)) { - non_budget_instructions++; +bool solana_deriveAssociatedTokenAddress( + const uint8_t owner[SOL_PUBKEY_SIZE], + const uint8_t token_program[SOL_PUBKEY_SIZE], + const uint8_t mint[SOL_PUBKEY_SIZE], uint8_t out[SOL_PUBKEY_SIZE]) { + /* Solana find_program_address searches bump seeds from 255 down. A valid PDA + * is SHA256(seeds..., bump, program_id, "ProgramDerivedAddress") that does + * NOT decompress to an Ed25519 curve point. */ + for (int bump = 255; bump >= 0; bump--) { + SHA256_CTX ctx = {0}; + uint8_t candidate[SHA256_DIGEST_LENGTH]; + uint8_t bump_seed = (uint8_t)bump; + sha256_Init(&ctx); + sha256_Update(&ctx, owner, SOL_PUBKEY_SIZE); + sha256_Update(&ctx, token_program, SOL_PUBKEY_SIZE); + sha256_Update(&ctx, mint, SOL_PUBKEY_SIZE); + sha256_Update(&ctx, &bump_seed, 1); + sha256_Update(&ctx, SOL_ATA_PROGRAM, SOL_PUBKEY_SIZE); + sha256_Update(&ctx, (const uint8_t*)SOL_PDA_MARKER, + sizeof(SOL_PDA_MARKER) - 1); + sha256_Final(&ctx, candidate); + + ge25519 point; + if (ge25519_unpack_vartime(&point, candidate) == 0) { + memcpy(out, candidate, SOL_PUBKEY_SIZE); + return true; } - if (pi->type == SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE) { - if (seen_price) return false; - seen_price = true; - price = pi->extra_value; - } else if (pi->type == SOL_INSTR_COMPUTE_BUDGET_UNIT_LIMIT) { - if (seen_limit) return false; - seen_limit = true; - limit = pi->extra_value; + } + return false; +} + +bool solana_findTokenRecipientOwner( + const SolanaSignTx* msg, const uint8_t token_program[SOL_PUBKEY_SIZE], + const uint8_t mint[SOL_PUBKEY_SIZE], + const uint8_t destination[SOL_PUBKEY_SIZE], uint8_t out[SOL_PUBKEY_SIZE]) { + if (!msg) return false; + for (size_t i = 0; i < msg->token_recipient_owner_count; i++) { + if (msg->token_recipient_owner[i].size != SOL_PUBKEY_SIZE) continue; + uint8_t derived[SOL_PUBKEY_SIZE]; + if (solana_deriveAssociatedTokenAddress(msg->token_recipient_owner[i].bytes, + token_program, mint, derived) && + memcmp(derived, destination, SOL_PUBKEY_SIZE) == 0) { + memcpy(out, msg->token_recipient_owner[i].bytes, SOL_PUBKEY_SIZE); + return true; } } + return false; +} - if (!seen_limit) { - /* Not the 1,400,000 cap. - * - * Assuming the cap whenever SetComputeUnitLimit was absent overstated the - * screen badly: a transfer plus a unit-price instruction is charged on - * 200,000 CUs, and the device showed seven times that as the "Maximum - * priority fee". It is an upper bound, so nothing was ever understated -- - * but a maximum the runtime will never reach is not the transaction's - * maximum, and this release line is about screens that describe the thing - * being signed. num_instructions is a uint8_t, so this cannot overflow. */ - limit = non_budget_instructions * SOL_DEFAULT_CU_PER_INSTRUCTION; - if (limit > SOL_MAX_CU_LIMIT) limit = SOL_MAX_CU_LIMIT; +const SolanaTokenInfo* solana_findTokenInfo( + const SolanaSignTx* msg, const uint8_t mint[SOL_PUBKEY_SIZE]) { + for (size_t i = 0; i < msg->token_info_count; i++) { + if (msg->token_info[i].has_mint && + msg->token_info[i].mint.size == SOL_PUBKEY_SIZE && + memcmp(msg->token_info[i].mint.bytes, mint, SOL_PUBKEY_SIZE) == 0) { + return &msg->token_info[i]; + } } + return NULL; +} - if (!seen_price || price == 0) return true; +bool solana_token_info_trusted(const SolanaTokenInfo* ti) { + if (!ti || !ti->has_signature || !ti->has_signer_key_id || !ti->has_mint || + ti->mint.size != SOL_PUBKEY_SIZE || !ti->has_symbol || + !ti->has_decimals) { + return false; + } + /* uint32 field: reject out-of-range slots BEFORE narrowing to the uint8 the + * keyring uses, so key_id 256 can't alias slot 0. */ + if (ti->signer_key_id >= METADATA_MAX_KEYS) { + return false; + } + size_t sym_len = strnlen(ti->symbol, sizeof(ti->symbol)); + if (sym_len == 0) { + return false; + } + /* Domain tag prevents a signature made for any other purpose (e.g. an EVM + * metadata blob signed by the same key) from being replayed as a token def. + * Preimage: tag || mint(32) || decimals(le32) || symbol. */ + static const char kTag[] = "KeepKeySolanaTokenDef/1"; + uint8_t blob[sizeof(kTag) - 1 + SOL_PUBKEY_SIZE + 4 + sizeof(ti->symbol)]; + size_t n = 0; + memcpy(blob + n, kTag, sizeof(kTag) - 1); + n += sizeof(kTag) - 1; + memcpy(blob + n, ti->mint.bytes, SOL_PUBKEY_SIZE); + n += SOL_PUBKEY_SIZE; + uint32_t dec = ti->decimals; + blob[n++] = (uint8_t)dec; + blob[n++] = (uint8_t)(dec >> 8); + blob[n++] = (uint8_t)(dec >> 16); + blob[n++] = (uint8_t)(dec >> 24); + memcpy(blob + n, ti->symbol, sym_len); + n += sym_len; + return signed_metadata_verify_attestation((uint8_t)ti->signer_key_id, blob, n, + ti->signature.bytes, + ti->signature.size); +} - uint64_t whole = price / divisor; - uint64_t remainder = price % divisor; - if (limit != 0 && whole > UINT64_MAX / limit) return false; - uint64_t base = whole * limit; - uint64_t remainder_product = remainder * limit; - uint64_t rounded = remainder_product / divisor; - if (remainder_product % divisor != 0) rounded++; - if (base > UINT64_MAX - rounded) return false; +bool solana_lut_accounts_trusted(const uint8_t* raw_tx, size_t raw_len, + const uint8_t (*accounts)[32], + size_t num_accounts, uint32_t signer_key_id, + const uint8_t* sig, size_t sig_len) { + if (!raw_tx || !accounts || !sig || num_accounts == 0) return false; + if (num_accounts > SOL_MAX_LUT_ACCOUNTS) return false; + /* uint32 field: reject out-of-range slots BEFORE narrowing to the uint8 the + * keyring uses, so key_id 256 cannot alias slot 0. Same reasoning as + * solana_token_info_trusted(). */ + if (signer_key_id >= METADATA_MAX_KEYS) return false; + + /* Bind to the transaction by hashing the exact bytes being signed. Solana + signs the message directly, so a sha256 over it is ours alone and never + collides with the ed25519 signature the device is about to produce. */ + uint8_t msg_hash[SHA256_DIGEST_LENGTH]; + sha256_Raw(raw_tx, raw_len, msg_hash); + + /* Build the preimage in full and hand it over RAW: verify_attestation() + hashes what it is given, so passing a digest here would verify over + sha256(sha256(preimage)) and no honest signer could ever match it. Same + shape as solana_token_info_trusted(). Bounded by SOL_MAX_LUT_ACCOUNTS, so + the worst case is 25 + 32 + 4 + 8*32 = 317 bytes. */ + static const char kTag[] = "KeepKeySolanaTxAccounts/1"; + uint8_t blob[sizeof(kTag) - 1 + SHA256_DIGEST_LENGTH + 4 + + SOL_MAX_LUT_ACCOUNTS * SOL_PUBKEY_SIZE]; + size_t n = 0; + memcpy(blob + n, kTag, sizeof(kTag) - 1); + n += sizeof(kTag) - 1; + memcpy(blob + n, msg_hash, sizeof(msg_hash)); + n += sizeof(msg_hash); + uint32_t count = (uint32_t)num_accounts; + blob[n++] = (uint8_t)count; + blob[n++] = (uint8_t)(count >> 8); + blob[n++] = (uint8_t)(count >> 16); + blob[n++] = (uint8_t)(count >> 24); + for (size_t i = 0; i < num_accounts; i++) { + memcpy(blob + n, accounts[i], SOL_PUBKEY_SIZE); + n += SOL_PUBKEY_SIZE; + } - *fee_out = base + rounded; - *has_fee = true; - return true; + return signed_metadata_verify_attestation((uint8_t)signer_key_id, blob, n, + sig, sig_len); } /* ------------------------------------------------------------------ */ @@ -876,18 +1244,28 @@ bool solana_signTx(const HDNode* node, const SolanaSignTx* msg, SolanaSignedTx* resp) { if (!msg->has_raw_tx || msg->raw_tx.size == 0) return false; + /* Sign the exact same message slice that solana_inspectTx parsed and the user + * approved (Solana signs the serialized message, not a hash of it). */ const uint8_t* message; size_t message_len; - if (!solana_messageBytes(msg->raw_tx.bytes, msg->raw_tx.size, &message, - &message_len)) { - return false; - } + solana_message_slice(msg->raw_tx.bytes, msg->raw_tx.size, &message, + &message_len); - /* Ed25519 signs the serialized message directly, never the full - * transaction's compact-u16 signature-count prefix. */ uint8_t sig[SOL_SIG_SIZE]; ed25519_sign(message, message_len, node->private_key, sig); +#if !ZCASH_PRIVACY + /* Defense-in-depth: refuse to emit a signature that does not verify over + * those exact bytes. solana_message_slice() already guarantees parsing and + * signing operate on the identical message, so this is a redundant check; + * it is compiled out on the ROM-tight zcash-privacy variant, where pulling in + * the ed25519 verification path would overflow flash. */ + if (ed25519_sign_open(message, message_len, node->public_key + 1, sig) != 0) { + memzero(sig, sizeof(sig)); + return false; + } +#endif + resp->has_signature = true; resp->signature.size = SOL_SIG_SIZE; memcpy(resp->signature.bytes, sig, SOL_SIG_SIZE); diff --git a/lib/firmware/storage.c b/lib/firmware/storage.c index abeb2b3f1..59b29a7db 100644 --- a/lib/firmware/storage.c +++ b/lib/firmware/storage.c @@ -46,8 +46,9 @@ #include "keepkey/firmware/passphrase_sm.h" #include "keepkey/firmware/policy.h" #include "keepkey/firmware/reset.h" -#include "keepkey/firmware/signing.h" +#include "keepkey/firmware/signed_metadata.h" #include "keepkey/firmware/u2f.h" +#include "keepkey/firmware/zcash.h" #include "keepkey/rand/rng.h" #include "keepkey/rand/rng_health.h" #include "keepkey/transport/interface.h" @@ -64,22 +65,26 @@ #include /* -The PIN_ITER defines below changed between storage version 15 and 16 to -eliminate the unacceptable multi-second wait while the pin was being stretched -for a dubious claim to better security. The defines help during upgrades from -v15 to v16 -*/ + * PIN wrapping-key parameters are part of the persistent storage format. + * Never change an existing set in place: old wallets must first unwrap with + * their original parameters, then rewrap after a correct PIN. V19 restores a + * meaningful offline-work factor after V16 reduced it to ten iterations. + */ #if defined(EMULATOR) || defined(DEBUG_ON) #define PIN_ITER_COUNT_v15 1000 #define PIN_ITER_CHUNK_v15 10 #define PIN_ITER_COUNT_v16 10 #define PIN_ITER_CHUNK_v16 1 +#define PIN_ITER_COUNT_v19 1000 +#define PIN_ITER_CHUNK_v19 10 #else #define PIN_ITER_COUNT_v15 100000 #define PIN_ITER_CHUNK_v15 1000 #define PIN_ITER_COUNT_v16 10 #define PIN_ITER_CHUNK_v16 1 +#define PIN_ITER_COUNT_v19 100000 +#define PIN_ITER_CHUNK_v19 1000 #endif #define U2F_KEY_PATH 0x80553246 @@ -201,8 +206,17 @@ static uint8_t read_u8(const char* ptr) { return *ptr; } static void write_u8(char* ptr, uint8_t val) { *ptr = val; } static uint32_t read_u32_le(const char* ptr) { - return ((uint32_t)ptr[0]) | ((uint32_t)ptr[1]) << 8 | - ((uint32_t)ptr[2]) << 16 | ((uint32_t)ptr[3]) << 24; + /* Read through unsigned char. `char` is signed on x86 (the emulator and the + * unit tests), so indexing it directly sign-extends any byte >= 0x80 and + * floods the upper bits: the flags word always has bit 7 set ("Pin Caching, + * enabled always"), so byte 0 is >= 0xC0 and read_u32_le returned + * 0xffffffc0. storage_writeStorageV17 read-modify-writes that same word, + * which wrote the corrupted value back. ARM defaults to unsigned char, so + * shipping firmware was unaffected -- but the signedness of plain `char` is + * implementation-defined and must not be relied on either way. */ + const uint8_t* p = (const uint8_t*)ptr; + return ((uint32_t)p[0]) | ((uint32_t)p[1]) << 8 | ((uint32_t)p[2]) << 16 | + ((uint32_t)p[3]) << 24; } static void write_u32_le(char* ptr, uint32_t val) { @@ -228,6 +242,33 @@ enum StorageVersion { _Static_assert(STORAGE_VERSION < STORAGE_VERSION_BTC_ONLY_BASE, "storage version must stay below the bitcoin-only band"); +/* HARD CHECK 1 -- a signed upgrade must never wipe. + * Lowering STORAGE_VERSION makes every device upgrading FROM a shipped release + * unreadable (StorageVersion_NONE -> SUS_Invalid -> storage_reset). */ +_Static_assert(STORAGE_VERSION >= STORAGE_VERSION_LAST_SHIPPED, + "STORAGE_VERSION is below a version that has shipped: every " + "upgrading device would be wiped. Storage versions only go up."); + +/* HARD CHECK 2 -- no shipped version may stop being recognised. + * The enum is emitted in .inc order after StorageVersion_NONE = 0, so a + * contiguous 1..N list makes StorageVersion_N == N. Deleting, renumbering or + * skipping an entry breaks that equality, and each one silently converts some + * field device's upgrade into a wipe. + * + * Asserted on EVERY entry, not just the last. The last entry alone only pins + * the entry COUNT: renumbering ENTRY(16) to ENTRY(99) leaves StorageVersion_17 + * at 17 and compiles clean, while version_from_int loses `case 16` and every + * device carrying version 16 is wiped on upgrade. + * + * STORAGE_VERSION_LAST falls back to STORAGE_VERSION_ENTRY inside the .inc, so + * defining ENTRY here covers the last entry too. */ +#define STORAGE_VERSION_ENTRY(VAL) \ + _Static_assert(StorageVersion_##VAL == (VAL), \ + "storage_versions.inc is not contiguous from 1: a " \ + "shipped version was removed, renumbered, or skipped, " \ + "and devices carrying it would be wiped on upgrade."); +#include "storage_versions.inc" + static enum StorageVersion version_from_int(int version) { #define STORAGE_VERSION_LAST(VAL) \ _Static_assert(VAL == STORAGE_VERSION, \ @@ -328,20 +369,27 @@ void storage_writeHDNode(char* ptr, size_t len, const HDNodeType* node) { } void storage_deriveWrappingKey(const char* pin, uint8_t wrapping_key[64], - bool sca_hardened, bool v15_16_trans, + bool sca_hardened, + pin_kdf_version_t pin_kdf_version, const uint8_t random_salt[RANDOM_SALT_LEN], const char* message) { size_t pin_len = strlen(pin); if (sca_hardened && pin_len > 0) { uint8_t salt[HW_ENTROPY_LEN + RANDOM_SALT_LEN]; - int iterCount, iterChunk; - - if (v15_16_trans) { // can use new counts - iterCount = PIN_ITER_COUNT_v16; - iterChunk = PIN_ITER_CHUNK_v16; - } else { // need to use storage version 15 counts to derive wrap key - iterCount = PIN_ITER_COUNT_v15; - iterChunk = PIN_ITER_CHUNK_v15; + int iterCount = PIN_ITER_COUNT_v19; + int iterChunk = PIN_ITER_CHUNK_v19; + + switch (pin_kdf_version) { + case PIN_KDF_V15: + iterCount = PIN_ITER_COUNT_v15; + iterChunk = PIN_ITER_CHUNK_v15; + break; + case PIN_KDF_V16: + iterCount = PIN_ITER_COUNT_v16; + iterChunk = PIN_ITER_CHUNK_v16; + break; + case PIN_KDF_V19: + break; } memset(salt, 0, sizeof(salt)); @@ -407,10 +455,29 @@ void storage_keyFingerprint(const uint8_t key[64], uint8_t fingerprint[32]) { sha256_Raw(key, 64, fingerprint); } +pin_kdf_version_t storage_activePinKdfVersion(bool v15_16_trans, + bool pin_kdf_v2) { +#if STORAGE_PIN_KDF_V19 + if (pin_kdf_v2) return PIN_KDF_V19; +#else + /* the flag cannot round-trip in V17; see STORAGE_PIN_KDF_V19 */ + (void)pin_kdf_v2; +#endif + return v15_16_trans ? PIN_KDF_V16 : PIN_KDF_V15; +} + +pin_kdf_version_t storage_rewrapPinKdfVersion(void) { +#if STORAGE_PIN_KDF_V19 + return PIN_KDF_V19; +#else + return PIN_KDF_V16; +#endif +} + pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], const uint8_t fingerprint[32], bool* sca_hardened, bool* v15_16_trans, - uint8_t key[64], + bool* pin_kdf_v2, uint8_t key[64], uint8_t random_salt[RANDOM_SALT_LEN]) { /* This function tests whether the PIN is correct. It will return @@ -425,7 +492,9 @@ pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], required to update the flash with a storage_commit(). */ uint8_t wrapping_key[64]; - storage_deriveWrappingKey(pin, wrapping_key, *sca_hardened, *v15_16_trans, + const pin_kdf_version_t pin_kdf_version = + storage_activePinKdfVersion(*v15_16_trans, *pin_kdf_v2); + storage_deriveWrappingKey(pin, wrapping_key, *sca_hardened, pin_kdf_version, random_salt, _("Verifying PIN")); // unwrap the storage key for fingerprint test @@ -444,16 +513,34 @@ pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], if (memcmp_s(fp, fingerprint, 32) == 0) ret = PIN_GOOD; if (ret == PIN_GOOD) { - if (!*sca_hardened || !*v15_16_trans) { + /* The v19 rewrap is gated because the flag that records it only survives a + * round trip in storage version 19, and this firmware writes version 17. + * Rewrapping without persisting the flag would wrap the key with v19 + * parameters and then read it back as v15/v16 on the next boot -- a silent, + * permanent lockout of a wallet whose flash is otherwise intact. Whichever + * way STORAGE_PIN_KDF_V19 goes, the KDF version selected here must be the + * one the flag will still describe after storage_commit(). */ +#if STORAGE_PIN_KDF_V19 + const bool needs_rewrap = !*sca_hardened || !*v15_16_trans || !*pin_kdf_v2; +#else + const bool needs_rewrap = !*sca_hardened || !*v15_16_trans; + /* No v19 wrap was produced, so the in-RAM flag must not claim one. Also + * keeps this an out-parameter in both configurations. */ + *pin_kdf_v2 = false; +#endif + const pin_kdf_version_t rewrap_to = storage_rewrapPinKdfVersion(); + if (needs_rewrap) { // PIN is correct but: // 1. wrapping key needs to be regenerated using stretched key // 2. storage key needs a rewrap with new wrapping key and algorithm storage_deriveWrappingKey(pin, wrapping_key, true /* sca_hardened */, - true /* v15_16_trans */, random_salt, - _("Verifying PIN")); + rewrap_to, random_salt, _("Verifying PIN")); storage_wrapStorageKey(wrapping_key, key, wrapped_key); *sca_hardened = true; *v15_16_trans = true; +#if STORAGE_PIN_KDF_V19 + *pin_kdf_v2 = true; +#endif ret = PIN_REWRAP; } } @@ -470,8 +557,8 @@ pintest_t storage_isWipeCodeCorrect_impl(const char* wipe_code, uint8_t key[64], uint8_t random_salt[RANDOM_SALT_LEN]) { uint8_t wrapping_key[64]; - storage_deriveWrappingKey(wipe_code, wrapping_key, true, true, random_salt, - _("Verifying PIN")); + storage_deriveWrappingKey(wipe_code, wrapping_key, true, PIN_KDF_V16, + random_salt, _("Verifying PIN")); // unwrap the storage key for fingerprint test storage_unwrapStorageKey(wrapping_key, wrapped_key, key); @@ -494,10 +581,12 @@ pintest_t storage_isWipeCodeCorrect_impl(const char* wipe_code, /* The seed-time RNG gate, at the paths that CREATE or REWRAP key material. * - * SCOPE, stated because an earlier revision of this work overclaimed it: + * SCOPE, stated because the previous revision of this branch overclaimed it: * this covers the draws below and nothing else. It is NOT wallet-wide * enforcement -- ordinary random_buffer() callers still draw unchecked exactly - * as they do on develop. Making the default checked was tried and descoped + * as they do on develop. (The Orchard RedPallas spend-auth randomness used to + * be the headline example here; it is drawn through random_buffer_checked() + * now, in fsm_msg_zcash.h.) Making the default checked was tried and descoped * from 7.15: it can hang or brick the bootloader when the generator has failed * and there is no defined degraded-RNG recovery mode yet. * @@ -628,8 +717,7 @@ void storage_secMigrate(SessionState* ss, Storage* storage, bool encrypt) { void storage_deriveAuthdataKey(const char* passphrase, uint8_t authdataKey[64]) { storage_deriveWrappingKey(passphrase, authdataKey, - /*sca_hardened*/ true, - /*v15_16_trans*/ true, + /*sca_hardened*/ true, PIN_KDF_V16, shadow_config.storage.pub.random_salt, "deriving authdata key"); return; @@ -804,12 +892,18 @@ void storage_readStorageV1(SessionState* ss, Storage* storage, const char* ptr, memcpy(storage->pub.label, ptr + 422, 33); storage->pub.no_backup = false; storage->pub.imported = read_bool(ptr + 456); - if (storage->version == 1) { - storage->pub.policies_count = 0; - } else { - storage->pub.policies_count = 1; - storage_readPolicyV1(&storage->pub.policies[0], ptr + 464, 17); - } + /* Policy state is NEVER trusted from flash, at any version. Reading the + * legacy record put a FLASH-CONTROLLED NAME into policies[0]: because + * storage_upgradePolicies only fills indices from policies_count upward, and + * storage_isPolicyEnabled_impl returns on the FIRST name match scanning from + * index 0, a crafted record naming itself "AdvancedMode" with enabled=1 was + * answered before the real entry at index 3 was ever reached -- re-enabling + * blind signing from unauthenticated storage. + * + * Nothing is lost by discarding it: the only policy this record could name + * legitimately is ShapeShift, which every V11+ reader already forces to + * false, and which has no storage_isPolicyEnabled consumer anywhere. */ + storage_resetPolicies(storage); storage->pub.has_auto_lock_delay_ms = true; storage->pub.auto_lock_delay_ms = STORAGE_DEFAULT_SCREENSAVER_TIMEOUT; @@ -819,7 +913,7 @@ void storage_readStorageV1(SessionState* ss, Storage* storage, const char* ptr, storage->pub.u2f_counter = 0; if (storage->version == 1) { - storage_resetPolicies(storage); + /* policies were reset unconditionally above */ storage_resetCache(&storage->sec.cache); } else { storage_readCacheV1(&storage->sec.cache, ptr + 484, 75); @@ -831,6 +925,8 @@ void storage_readStorageV1(SessionState* ss, Storage* storage, const char* ptr, _Static_assert(sizeof(storage->pub.storage_key_fingerprint) == 32, "key fingerprint must be 32 bytes"); + /* PIN-KDF salt: unpredictability here is what stops a precomputed + * wrapping-key table, so it is key material. */ storage_drawKeyMaterial(storage->pub.random_salt, 32); storage->has_sec = true; @@ -856,24 +952,30 @@ void storage_writeStorageV11(char* ptr, size_t len, const Storage* storage) { if (len < 852) return; write_u32_le(ptr, storage->version); - uint32_t flags = (storage->pub.has_pin ? (1u << 0) : 0) | - (storage->pub.has_language ? (1u << 1) : 0) | - (storage->pub.has_label ? (1u << 2) : 0) | - (storage->pub.has_auto_lock_delay_ms ? (1u << 3) : 0) | - (storage->pub.imported ? (1u << 4) : 0) | - (storage->pub.passphrase_protection ? (1u << 5) : 0) | - (/* ShapeShift policy, enabled always */ (1u << 6)) | - (/* Pin Caching policy, enabled always */ (1u << 7)) | - (storage->pub.has_node ? (1u << 8) : 0) | - (storage->pub.has_mnemonic ? (1u << 9) : 0) | - (storage->pub.has_u2froot ? (1u << 10) : 0) | - (storage_isPolicyEnabled("Experimental") ? (1u << 11) : 0) | - (storage_isPolicyEnabled("AdvancedMode") ? (1u << 12) : 0) | - (storage->pub.no_backup ? (1u << 13) : 0) | - (storage->has_sec_fingerprint ? (1u << 14) : 0) | - // cppcheck-suppress badBitmaskCheck - (storage->pub.sca_hardened ? (1u << 15) : 0) | - /* reserved 31:16 */ 0; + uint32_t flags = + (storage->pub.has_pin ? (1u << 0) : 0) | + (storage->pub.has_language ? (1u << 1) : 0) | + (storage->pub.has_label ? (1u << 2) : 0) | + (storage->pub.has_auto_lock_delay_ms ? (1u << 3) : 0) | + (storage->pub.imported ? (1u << 4) : 0) | + (storage->pub.passphrase_protection ? (1u << 5) : 0) | + (/* ShapeShift policy, enabled always */ (1u << 6)) | + (/* Pin Caching policy, enabled always */ (1u << 7)) | + (storage->pub.has_node ? (1u << 8) : 0) | + (storage->pub.has_mnemonic ? (1u << 9) : 0) | + (storage->pub.has_u2froot ? (1u << 10) : 0) | + (storage_isPolicyEnabled("Experimental") ? (1u << 11) : 0) | + /* bit 12 was AdvancedMode. It is session-scoped now and + * MUST NOT be persisted: this section has no authenticated + * integrity, so a physical attacker who sets the bit in + * flash would silently re-enable blind signing. Written as + * zero, ignored on read. Do not reuse the bit -- a device + * downgraded to older firmware would read it as the policy. */ + (storage->pub.no_backup ? (1u << 13) : 0) | + (storage->has_sec_fingerprint ? (1u << 14) : 0) | + // cppcheck-suppress badBitmaskCheck + (storage->pub.sca_hardened ? (1u << 15) : 0) | + /* reserved 31:16 */ 0; write_u32_le(ptr + 4, flags); write_u32_le(ptr + 8, storage->pub.pin_failed_attempts); @@ -927,8 +1029,11 @@ void storage_readStorageV11(Storage* storage, const char* ptr, size_t len) { storage->pub.has_u2froot = flags & (1u << 10); storage_readPolicyV2(&storage->pub.policies[2], "Experimental", flags & (1u << 11)); - storage_readPolicyV2(&storage->pub.policies[3], "AdvancedMode", - flags & (1u << 12)); + /* Ignore whatever bit 12 holds: AdvancedMode is session-scoped and every + * boot starts with it OFF. A device upgrading with the bit already set in + * flash must not inherit the policy, and neither must one where an attacker + * set it. See storage_writeStorageV16Plaintext. */ + storage_readPolicyV2(&storage->pub.policies[3], "AdvancedMode", false); storage->pub.no_backup = flags & (1u << 13); storage->has_sec_fingerprint = flags & (1u << 14); storage->pub.sca_hardened = flags & (1u << 15); @@ -973,26 +1078,32 @@ void storage_writeStorageV16Plaintext(char* ptr, size_t len, if (len < 852) return; write_u32_le(ptr, storage->version); - uint32_t flags = (storage->pub.has_pin ? (1u << 0) : 0) | - (storage->pub.has_language ? (1u << 1) : 0) | - (storage->pub.has_label ? (1u << 2) : 0) | - (storage->pub.has_auto_lock_delay_ms ? (1u << 3) : 0) | - (storage->pub.imported ? (1u << 4) : 0) | - (storage->pub.passphrase_protection ? (1u << 5) : 0) | - (/* ShapeShift policy, enabled always */ (1u << 6)) | - (/* Pin Caching policy, enabled always */ (1u << 7)) | - (storage->pub.has_node ? (1u << 8) : 0) | - (storage->pub.has_mnemonic ? (1u << 9) : 0) | - (storage->pub.has_u2froot ? (1u << 10) : 0) | - (storage_isPolicyEnabled("Experimental") ? (1u << 11) : 0) | - (storage_isPolicyEnabled("AdvancedMode") ? (1u << 12) : 0) | - (storage->pub.no_backup ? (1u << 13) : 0) | - (storage->has_sec_fingerprint ? (1u << 14) : 0) | - (storage->pub.sca_hardened ? (1u << 15) : 0) | - (storage->pub.has_wipe_code ? (1u << 16) : 0) | - // cppcheck-suppress badBitmaskCheck - (storage->pub.v15_16_trans ? (1u << 17) : 0) | - /* reserved 31:18 */ 0; + uint32_t flags = + (storage->pub.has_pin ? (1u << 0) : 0) | + (storage->pub.has_language ? (1u << 1) : 0) | + (storage->pub.has_label ? (1u << 2) : 0) | + (storage->pub.has_auto_lock_delay_ms ? (1u << 3) : 0) | + (storage->pub.imported ? (1u << 4) : 0) | + (storage->pub.passphrase_protection ? (1u << 5) : 0) | + (/* ShapeShift policy, enabled always */ (1u << 6)) | + (/* Pin Caching policy, enabled always */ (1u << 7)) | + (storage->pub.has_node ? (1u << 8) : 0) | + (storage->pub.has_mnemonic ? (1u << 9) : 0) | + (storage->pub.has_u2froot ? (1u << 10) : 0) | + (storage_isPolicyEnabled("Experimental") ? (1u << 11) : 0) | + /* bit 12 was AdvancedMode. It is session-scoped now and + * MUST NOT be persisted: this section has no authenticated + * integrity, so a physical attacker who sets the bit in + * flash would silently re-enable blind signing. Written as + * zero, ignored on read. Do not reuse the bit -- a device + * downgraded to older firmware would read it as the policy. */ + (storage->pub.no_backup ? (1u << 13) : 0) | + (storage->has_sec_fingerprint ? (1u << 14) : 0) | + (storage->pub.sca_hardened ? (1u << 15) : 0) | + (storage->pub.has_wipe_code ? (1u << 16) : 0) | + // cppcheck-suppress badBitmaskCheck + (storage->pub.v15_16_trans ? (1u << 17) : 0) | + /* reserved 31:18 */ 0; write_u32_le(ptr + 4, flags); write_u32_le(ptr + 8, storage->pub.pin_failed_attempts); @@ -1055,13 +1166,17 @@ void storage_readStorageV16Plaintext(Storage* storage, const char* ptr, storage->pub.has_u2froot = flags & (1u << 10); storage_readPolicyV2(&storage->pub.policies[2], "Experimental", flags & (1u << 11)); - storage_readPolicyV2(&storage->pub.policies[3], "AdvancedMode", - flags & (1u << 12)); + /* Ignore whatever bit 12 holds: AdvancedMode is session-scoped and every + * boot starts with it OFF. A device upgrading with the bit already set in + * flash must not inherit the policy, and neither must one where an attacker + * set it. See storage_writeStorageV16Plaintext. */ + storage_readPolicyV2(&storage->pub.policies[3], "AdvancedMode", false); storage->pub.no_backup = flags & (1u << 13); storage->has_sec_fingerprint = flags & (1u << 14); storage->pub.sca_hardened = flags & (1u << 15); storage->pub.has_wipe_code = flags & (1u << 16); storage->pub.v15_16_trans = flags & (1u << 17); + storage->pub.pin_kdf_v2 = false; storage->pub.policies_count = POLICY_COUNT; @@ -1149,6 +1264,41 @@ void storage_readStorageV17(Storage* storage, const char* ptr, size_t len) { memcpy(storage->encrypted_sec, ptr + 1501, sizeof(storage->encrypted_sec)); } +// V18 appended a clear-sign identity block immediately after encrypted_sec. +// RC18 retains the byte layout for compatibility but retires those records: +// public storage has no authenticated integrity, so they are zeroed on both +// read and write and are never consulted as trust anchors. +// One identity serializes to CLEARSIGN_IDENTITY_SERIALIZED_LEN bytes: +// +0 present(u8) +1 key_id(u8) +2 pubkey[33] +35 alias[32] +67 icon_w(u8) +// +68 icon_h(u8) +69 icon_len(u16 le) +71 icon[CLEARSIGN_ICON_MAX] = 71+384 +#define CLEARSIGN_IDENTITY_BLOCK_OFF (1501 + V17_ENCSEC_SIZE) // 2525 +#define CLEARSIGN_IDENTITY_SERIALIZED_LEN (71 + CLEARSIGN_ICON_MAX) // 455 + +void storage_writeStorageV18(char* ptr, size_t len, const Storage* storage) { + storage_writeStorageV17(ptr, len, storage); + memzero(ptr + CLEARSIGN_IDENTITY_BLOCK_OFF, + PERSISTENT_IDENTITY_COUNT * CLEARSIGN_IDENTITY_SERIALIZED_LEN); +} + +void storage_readStorageV18(Storage* storage, const char* ptr, size_t len) { + storage_readStorageV17(storage, ptr, len); + memzero(storage->pub.clearsign_identities, + sizeof(storage->pub.clearsign_identities)); +} + +void storage_writeStorageV19(char* ptr, size_t len, const Storage* storage) { + storage_writeStorageV18(ptr, len, storage); + uint32_t flags = read_u32_le(ptr + 4); + flags |= storage->pub.pin_kdf_v2 ? (1u << 20) : 0; + write_u32_le(ptr + 4, flags); +} + +void storage_readStorageV19(Storage* storage, const char* ptr, size_t len) { + storage_readStorageV18(storage, ptr, len); + uint32_t flags = read_u32_le(ptr + 4); + storage->pub.pin_kdf_v2 = flags & (1u << 20); +} + void storage_readCacheV1(Cache* cache, const char* ptr, size_t len) { if (len < 65 + 10) return; cache->root_seed_cache_status = read_u8(ptr); @@ -1217,6 +1367,30 @@ void storage_writeV17(char* flash, size_t len, const ConfigFlash* src) { storage_writeStorageV17(flash + 44, 852, &src->storage); } +void storage_readV18(ConfigFlash* dst, const char* flash, size_t len) { + if (len < 1024) return; + storage_readMeta(&dst->meta, flash, 44); + storage_readStorageV18(&dst->storage, flash + 44, 852); +} + +void storage_writeV18(char* flash, size_t len, const ConfigFlash* src) { + if (len < 1024) return; + storage_writeMeta(flash, 44, &src->meta); + storage_writeStorageV18(flash + 44, 852, &src->storage); +} + +void storage_readV19(ConfigFlash* dst, const char* flash, size_t len) { + if (len < 1024) return; + storage_readMeta(&dst->meta, flash, 44); + storage_readStorageV19(&dst->storage, flash + 44, 852); +} + +void storage_writeV19(char* flash, size_t len, const ConfigFlash* src) { + if (len < 1024) return; + storage_writeMeta(flash, 44, &src->meta); + storage_writeStorageV19(flash + 44, 852, &src->storage); +} + StorageUpdateStatus storage_fromFlash(SessionState* ss, ConfigFlash* dst, const char* flash) { memzero(dst, sizeof(*dst)); @@ -1270,8 +1444,22 @@ StorageUpdateStatus storage_fromFlash(SessionState* ss, ConfigFlash* dst, dst->storage.version = STORAGE_VERSION; return dst->storage.version == version ? SUS_Valid : SUS_Updated; case StorageVersion_17: + // V17 is the current version, so this is the steady state: read, stamp + // the same version, report SUS_Valid, and nothing is rewritten to flash. storage_readV17(dst, flash, STORAGE_SECTOR_LEN); dst->storage.version = STORAGE_VERSION; + // ...except when the retired AdvancedMode bit is still set in flash from + // a build that persisted it. This firmware ignores the bit on read, but + // firmware <= 7.15 does not, so leaving it there means a downgrade boots + // with blind signing already on and no confirmation. Report SUS_Updated + // so storage_init commits once and the writer scrubs it to zero. + // + // It cannot be left to the next incidental commit: a device with no PIN + // may never make one. storage_init calls storage_isPinCorrect(""), which + // returns PIN_GOOD without a rewrap once sca_hardened and v15_16_trans + // are set -- which 7.14 and 7.15 both guarantee -- so no commit path runs + // at boot at all. Costs exactly one flash write, once. + if (read_u32_le(flash + 44 + 4) & (1u << 12)) return SUS_Updated; return dst->storage.version == version ? SUS_Valid : SUS_Updated; case StorageVersion_BTC_ONLY: @@ -1488,6 +1676,9 @@ void storage_resetUuid_impl(ConfigFlash* cfg) { void storage_reset(void) { storage_reset_impl(&session, &shadow_config); } void storage_reset_impl(SessionState* ss, ConfigFlash* cfg) { + bip32_cache_clear(); + bip39_cache_clear(); + memset(&cfg->storage, 0, sizeof(cfg->storage)); storage_resetPolicies(&cfg->storage); @@ -1524,13 +1715,16 @@ void storage_clearKeys(void) { } void session_clear(bool clear_pin) { - /* Every session loss is an authorization boundary even when Initialize asks - * to preserve the cached PIN. Abort signing and discard all plaintext - * setup/authenticator state before the caller can report success. */ - signing_abort(); - setup_abort(); - authenticator_clear_cache(); - fsm_clearDerivedNode(); + /* Runtime ClearSign trust belongs to the unlocked device session. Any path + * that tears that session down must also revoke its RAM-only signer slots. */ + signed_metadata_clear_signers(); + /* The Orchard spend AUTHORIZING key lives in the Zcash signing session, so it + * belongs to the unlocked session for exactly the same reason. Clearing it + * here rather than at each caller is what makes the guarantee hold on paths + * nobody enumerated: the screensaver auto-lock (home_sm.c toggle_screensaver) + * and the PIN-failure path (pin_sm.c) both tear the session down without + * going through Initialize or ClearSession, and both left the key live. */ + zcash_signing_abort(); if (PIN_REWRAP == session_clear_impl(&session, &shadow_config.storage, clear_pin)) { storage_commit(); @@ -1553,6 +1747,30 @@ pintest_t session_clear_impl(SessionState* ss, Storage* storage, */ pintest_t ret = PIN_WRONG; + /* AdvancedMode belongs to the unlocked session, like the runtime ClearSign + * signers session_clear() revokes -- fsm_msgApplyPolicies calls those signers + * "an AdvancedMode capability", so revoking them while leaving the policy + * that authorized them armed is the inconsistent half. Re-arming costs + * another on-device confirmation. + * + * Gated on clear_pin, and that gate is load-bearing. clear_pin distinguishes + * a LOCK (screensaver home_sm.c, ClearSession fsm.c, recovery) from a soft + * re-init: fsm_msgInitialize calls session_clear(false), and hosts send + * Initialize routinely -- often before every operation. Disarming there would + * demand a fresh button press after each one, which does not make blind + * signing session-scoped, it makes it unusable. Signers survive that only + * because the host can silently reload them; a physical confirmation cannot + * be reloaded. + * + * Shadow only -- writes no flash, so the "does not modify flash storage + * config state" contract above holds. AdvancedMode is never persisted. */ + if (clear_pin) { + storage_setPolicy_impl(storage->pub.policies, "AdvancedMode", false); + } + + bip32_cache_clear(); + bip39_cache_clear(); + ss->seedCached = false; memset(&ss->seed, 0, sizeof(ss->seed)); @@ -1560,11 +1778,11 @@ pintest_t session_clear_impl(SessionState* ss, Storage* storage, memset(&ss->passphrase, 0, sizeof(ss->passphrase)); if (!storage_hasPin_impl(storage)) { - ret = storage_isPinCorrect_impl("", storage->pub.wrapped_storage_key, - storage->pub.storage_key_fingerprint, - &storage->pub.sca_hardened, - &storage->pub.v15_16_trans, ss->storageKey, - shadow_config.storage.pub.random_salt); + ret = storage_isPinCorrect_impl( + "", storage->pub.wrapped_storage_key, + storage->pub.storage_key_fingerprint, &storage->pub.sca_hardened, + &storage->pub.v15_16_trans, &storage->pub.pin_kdf_v2, ss->storageKey, + shadow_config.storage.pub.random_salt); if (ret == PIN_WRONG) { ss->pinCached = false; @@ -1595,16 +1813,38 @@ void storage_commit(void) { * calls us, so anything still armed here is a DIFFERENT operation * persisting: end the ceremony rather than let its staged settings, or its * arming, outlive a write it did not make. setup_abort() touches no - * storage, so this cannot recurse. */ + * storage, so this cannot recurse. + * + * Ordered BEFORE the bitcoin-only backstop deliberately: a refused commit + * must still not leave a foreign ceremony armed and consumable. */ if (setup_isArmed()) setup_abort(); // Never overwrite a bitcoin-only wallet from multi-chain firmware; the - // only way out is storage_wipe() (which clears the lock). + // 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]; + // + // V17 = meta (44) + storage layout (2525) = 2569 bytes, so the last + // meaningful byte is index 2568. The size MUST be a multiple of 4: the CRC + // below is computed as sizeof(flash_temp) / sizeof(uint32_t) WORDS, and + // integer division silently drops the tail. At 2570 the CRC covered + // 642 words = 2568 bytes and left byte 2568 -- the final byte of the + // encrypted secret section -- unprotected, so a corrupted last byte could + // pass commit verification and only surface later as a secret fingerprint + // failure, which reaches storage_wipe(). 2572 = 643 words covers all 2569. + // + // Aligned because calc_crc32() casts to uint32_t*: the size assertion below + // says the buffer is a whole number of words, not that it starts on one. + // __attribute__((aligned)) rather than C11 _Alignas -- the ARM toolchain + // rejects _Alignas here, and this is the form the rest of the tree already + // uses (fsm.c msg_resp, usb.c buffers). + static char flash_temp[2572] __attribute__((aligned(4))); + _Static_assert(sizeof(flash_temp) % sizeof(uint32_t) == 0, + "flash_temp must be word-sized or the CRC drops its tail"); + _Static_assert(sizeof(flash_temp) >= 2569, + "flash_temp must cover the whole V17 record"); memzero(flash_temp, sizeof(flash_temp)); @@ -1614,10 +1854,21 @@ void storage_commit(void) { // commit what was in storage->encrypted_sec } - storage_writeV17(flash_temp, sizeof(flash_temp), &shadow_config); - + /* Stamp the magic BEFORE serialising, not after. storage_writeV17() copies + shadow_config.meta -- magic included -- into flash_temp, so setting it + afterwards left the RECORD WE ARE ABOUT TO WRITE carrying whatever the + magic held, which on a device whose storage has never been written is + zeroes. find_active_storage()/storage_isActiveSector() then did not + recognise the sector we had just committed, so the next boot took the + not-an-active-sector path again: storage_resetUuid() + storage_commit(), + every boot, until the first storage-changing operation happened to write + it correctly. That is a redundant flash erase+write on every boot and a + window in which no sector is valid. The CRC below is computed over + flash_temp after this call, so the magic is now covered by it too. */ memcpy(&shadow_config, STORAGE_MAGIC_STR, STORAGE_MAGIC_LEN); + storage_writeV17(flash_temp, sizeof(flash_temp), &shadow_config); + uint32_t retries = 0; for (retries = 0; retries < STORAGE_RETRIES; retries++) { /* Capture CRC for verification at restore */ @@ -1831,7 +2082,8 @@ bool storage_isPinCorrect(const char* pin) { pin, shadow_config.storage.pub.wrapped_storage_key, shadow_config.storage.pub.storage_key_fingerprint, &shadow_config.storage.pub.sca_hardened, - &shadow_config.storage.pub.v15_16_trans, session.storageKey, + &shadow_config.storage.pub.v15_16_trans, + &shadow_config.storage.pub.pin_kdf_v2, session.storageKey, shadow_config.storage.pub.random_salt); switch (ret) { @@ -1875,10 +2127,23 @@ void storage_setPin(const char* pin) { void storage_setPin_impl(SessionState* ss, Storage* storage, const char* pin) { // Derive the wrapping key for the new pin + /* THIS is the function that creates the wrap, so it -- not just the rewrap + * path in storage_isPinCorrect_impl -- must honour STORAGE_PIN_KDF_V19. + * Hardcoding v19 here while the record is written as V17 wraps the storage + * key with parameters the persisted flag cannot describe, and the next boot + * derives v15/v16 and fails every PIN: an intact but permanently unopenable + * wallet. Every path that CREATES or REWRAPS a wrap must agree with + * storage_activePinKdfVersion(). */ + /* One value, captured once, used for both the derivation and the flag that + * describes it. Calling storage_rewrapPinKdfVersion() twice would couple the + * persisted description to a second invocation rather than to the wrap + * actually produced -- fine today because the helper is pure, and exactly + * the kind of gap that reopens this lockout the day it is not. */ + const pin_kdf_version_t rewrap_to = storage_rewrapPinKdfVersion(); + uint8_t wrapping_key[64]; - storage_deriveWrappingKey(pin, wrapping_key, /*sca_hardened=*/true, - /*v15_16_trans=*/true, storage->pub.random_salt, - _("Encrypting Secrets")); + storage_deriveWrappingKey(pin, wrapping_key, /*sca_hardened=*/true, rewrap_to, + storage->pub.random_salt, _("Encrypting Secrets")); // Derive a new storageKey. storage_drawKeyMaterial(ss->storageKey, 64); @@ -1888,6 +2153,16 @@ void storage_setPin_impl(SessionState* ss, Storage* storage, const char* pin) { storage->pub.wrapped_storage_key); storage->pub.sca_hardened = true; storage->pub.v15_16_trans = true; + /* Describes the wrap actually produced above, because it tests the same + * captured value that produced it. + * + * Always false while STORAGE_PIN_KDF_V19 is 0, and cppcheck is right to say + * so — but the comparison is the point. Writing `false` here would leave the + * flag agreeing with the KDF only by coincidence, and the next person to flip + * the gate would ship a v19 wrap described as v16: the exact lockout this + * branch exists to fix. Keep it derived. */ + // cppcheck-suppress knownConditionTrueFalse + storage->pub.pin_kdf_v2 = (rewrap_to == PIN_KDF_V19); // Fingerprint the storageKey. storage_keyFingerprint(ss->storageKey, storage->pub.storage_key_fingerprint); @@ -1937,7 +2212,7 @@ void storage_setWipeCode_impl(SessionState* ss, Storage* storage, // Derive the wrapping key for the new wipe code uint8_t wrapping_key[64]; storage_deriveWrappingKey(wipe_code, wrapping_key, /*sca_hardened=*/true, - /*v15_16_trans=*/true, storage->pub.random_salt, + PIN_KDF_V16, storage->pub.random_salt, _("Updating Wipe Code")); // Derive a new wipe code key . @@ -2012,6 +2287,43 @@ const uint8_t* storage_getSeed(const ConfigFlash* cfg, bool usePassphrase) { return NULL; } +/* ── Zcash storage-scoped wrappers ─────────────────────────────────── + * + * ZIP-32 Orchard derives keys directly from the raw 64-byte BIP-39 seed + * (not the BIP-32 master node). Rather than expose a generic + * "give me the seed" function, storage owns the seed access and only + * returns derived material — Orchard keys or the 32-byte fingerprint. + * The seed pointer never leaves this translation unit. + */ + +#if ZCASH_PRIVACY +static void storage_zcash_orchard_progress(uint32_t completed, uint32_t total, + void* context) { + (void)context; + if (total == 0) return; + animating_progress_handler(_("Deriving Zcash"), + (int)((completed * 1000u) / total)); +} + +bool storage_zcashOrchardKeys(uint32_t account, bool usePassphrase, + ZcashOrchardKeys* keys_out) { + if (!keys_out) return false; + const uint8_t* seed = storage_getSeed(&shadow_config, usePassphrase); + if (!seed) return false; + animating_progress_handler(_("Deriving Zcash"), 0); + return zcash_derive_orchard_keys_with_progress( + seed, 64, account, keys_out, storage_zcash_orchard_progress, NULL); +} + +bool storage_zcashSeedFingerprint(bool usePassphrase, + uint8_t fingerprint_out[32]) { + if (!fingerprint_out) return false; + const uint8_t* seed = storage_getSeed(&shadow_config, usePassphrase); + if (!seed) return false; + return zcash_calculate_seed_fingerprint(seed, 64, fingerprint_out); +} +#endif + bool storage_getRootNode(const char* curve, bool usePassphrase, HDNode* node) { // if storage has node, decrypt and use it if (shadow_config.storage.pub.has_node && @@ -2055,6 +2367,10 @@ bool storage_getRootNode(const char* curve, bool usePassphrase, HDNode* node) { &ctx); memzero(&ctx, sizeof(ctx)); memzero(secret, sizeof(secret)); + /* pctx is keyed by the passphrase and its state derives `secret`, the + * AES key that just decrypted the private key and chain code. Both of + * those are wiped above; the context that produced them was not. */ + memzero(&pctx, sizeof(pctx)); } return true; diff --git a/lib/firmware/storage.h b/lib/firmware/storage.h index fba52b872..08c935289 100644 --- a/lib/firmware/storage.h +++ b/lib/firmware/storage.h @@ -32,6 +32,31 @@ #define V16_ENCSEC_SIZE 512 // for reading old encrypted sec size #define V17_ENCSEC_SIZE 1024 +/* Retired V18 clear-sign identity record. The fixed-size fields remain in the + * in-memory/storage layout for backward compatibility, but RC18 never trusts, + * returns, or writes their contents: this public section lacks authenticated + * integrity against physical flash modification. + * pubkey : 33-byte compressed secp256k1 (matches signed_metadata slots) + * alias : METADATA_ALIAS_MAX_LEN(31)+1, printable [A-Za-z0-9 _-] + * icon : 1bpp mono row-major bitmap, <= CLEARSIGN_ICON_MAX bytes, + * icon_len==0 => text-only identity (no logo) + * Serialized size is fixed (CLEARSIGN_IDENTITY_SERIALIZED_LEN) — appended after + * encrypted_sec in the V18 storage layout; never reorder existing fields. */ +#define CLEARSIGN_ICON_MAX 384 +#define CLEARSIGN_IDENTITY_ALIAS_SIZE 32 /* METADATA_ALIAS_MAX_LEN(31) + 1 */ +#define PERSISTENT_IDENTITY_COUNT 2 +typedef struct _ClearsignIdentity { + bool present; + uint8_t key_id; // the signer slot (1..METADATA_MAX_KEYS-1) this identity + // reloads into; the per-tx blob's key_id selects it + uint8_t pubkey[33]; + char alias[CLEARSIGN_IDENTITY_ALIAS_SIZE]; + uint8_t icon_w; + uint8_t icon_h; + uint16_t icon_len; + uint8_t icon[CLEARSIGN_ICON_MAX]; +} ClearsignIdentity; + typedef struct _authBlockType { authType authData[AUTHDATA_SIZE]; // 450 uint8_t reserved[512 - sizeof(authType) * AUTHDATA_SIZE]; // 62 @@ -66,10 +91,13 @@ typedef struct _Storage { bool no_backup; bool sca_hardened; bool v15_16_trans; + bool pin_kdf_v2; bool authdata_initialized; bool authdata_encrypted; uint8_t random_salt[32]; uint8_t authdata_fingerprint[32]; + /* V18 legacy clear-sign records. Always scrubbed on read and write. */ + ClearsignIdentity clearsign_identities[PERSISTENT_IDENTITY_COUNT]; } pub; bool has_sec; @@ -112,13 +140,43 @@ typedef enum { PIN_REWRAP // PIN correct but storage key rewrapped, requires storage update } pintest_t; +typedef enum { + PIN_KDF_V15, + PIN_KDF_V16, + PIN_KDF_V19, +} pin_kdf_version_t; + +/* Gate for the storage-version-19 PIN KDF. + * + * 0 = implemented, tested, and NOT reachable from flash. This firmware writes + * storage version 17, and the flag that records a v19 wrap only round-trips in + * version 19, so persisting a v19 wrap here would lock the wallet out on the + * next boot. + * + * Do not flip this to 1 on its own. Version 19 is a one-way migration: any + * device that boots firmware writing it can no longer be downgraded without + * being wiped, and the wipe is the correct anti-rollback behaviour, not a bug + * to work around. Enable it only in a release whose bootloader enforces a + * minimum security epoch that refuses firmware unable to read version 19 -- + * so the downgrade is rejected up front rather than costing a user their + * wallet. The full gate list is in docs/security/pin-kdf-v19-migration.md. */ +#define STORAGE_PIN_KDF_V19 0 + +/* Single source of truth for KDF selection, so tests cannot drift from + * production by reimplementing the choice. Both the unlock path and the unit + * tests must call these rather than naming a PIN_KDF_* constant directly. */ +pin_kdf_version_t storage_activePinKdfVersion(bool v15_16_trans, + bool pin_kdf_v2); +pin_kdf_version_t storage_rewrapPinKdfVersion(void); + #define MAX_MNEMONIC_LEN 240 void storage_loadNode(HDNode* dst, const HDNodeType* src); /// Derive the wrapping key from the user's pin. void storage_deriveWrappingKey(const char* pin, uint8_t wrapping_key[64], - bool sca_hardened, bool v15_16_trans, + bool sca_hardened, + pin_kdf_version_t pin_kdf_version, const uint8_t random_salt[RANDOM_SALT_LEN], const char* message); @@ -145,7 +203,7 @@ void storage_keyFingerprint(const uint8_t key[64], uint8_t fingerprint[32]); pintest_t storage_isPinCorrect_impl(const char* pin, uint8_t wrapped_key[64], const uint8_t fingerprint[32], bool* sca_hardened, bool* v15_16_trans, - uint8_t key[64], + bool* pin_kdf_v2, uint8_t key[64], uint8_t random_salt[RANDOM_SALT_LEN]); pintest_t storage_isWipeCodeCorrect_impl(const char* wipe_code, @@ -206,8 +264,14 @@ void storage_readV2(SessionState* ss, ConfigFlash* dst, const char* flash, size_t len); void storage_readV11(ConfigFlash* dst, const char* flash, size_t len); void storage_readV16(ConfigFlash* dst, const char* flash, size_t len); +void storage_readV17(ConfigFlash* dst, const char* flash, size_t len); +void storage_readV18(ConfigFlash* dst, const char* flash, size_t len); +void storage_readV19(ConfigFlash* dst, const char* flash, size_t len); void storage_writeV11(char* flash, size_t len, const ConfigFlash* src); void storage_writeV16(char* flash, size_t len, const ConfigFlash* src); +void storage_writeV17(char* flash, size_t len, const ConfigFlash* src); +void storage_writeV18(char* flash, size_t len, const ConfigFlash* src); +void storage_writeV19(char* flash, size_t len, const ConfigFlash* src); void storage_readMeta(Metadata* meta, const char* ptr, size_t len); void storage_readPolicyV1(PolicyType* policy, const char* ptr, size_t len); diff --git a/lib/firmware/tendermint.c b/lib/firmware/tendermint.c index f975571f7..609fd0313 100644 --- a/lib/firmware/tendermint.c +++ b/lib/firmware/tendermint.c @@ -168,6 +168,47 @@ bool tendermint_validateValidatorAddress(const char* address, return tendermint_validateBech32Address(address, expected); } +// Allow lowercase alpha, digits, and the punctuation used in Cosmos-style +// asset identifiers (e.g. "eth.eth", "btc/btc", cross-chain synthetic +// prefixes). Rejects anything that needs JSON escaping (backslash, quote). +bool tendermint_isValidDenom(const char* denom) { + if (!denom || !denom[0]) return false; + for (size_t i = 0; denom[i]; i++) { + char c = denom[i]; + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || + c == '/' || c == '-')) { + return false; + } + } + return true; +} + +// Deposit assets share the denom grammar but are conventionally uppercase +// (e.g. ETH.USDT-0XDAC1...); allow both cases, digits, and . / - only. +bool tendermint_isValidAsset(const char* asset) { + if (!asset || !asset[0]) return false; + for (size_t i = 0; asset[i]; i++) { + char c = asset[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.' || c == '/' || c == '-')) { + return false; + } + } + return true; +} + +// Deposit signer is host-supplied; require a valid bech32 address with the +// expected HRP before it is displayed or signed. +bool tendermint_isValidSigner(const char* signer, const char* hrp) { + size_t decoded_len; + char decoded_hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; + if (!signer || !bech32_decode(decoded_hrp, decoded, &decoded_len, signer)) { + return false; + } + return 0 == strcmp(decoded_hrp, hrp); +} + void tendermint_sha256UpdateEscaped(SHA256_CTX* ctx, const char* s, size_t len) { static const char kHexDigits[] = "0123456789abcdef"; diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index 1c1fe7b95..5bb630981 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -20,6 +20,7 @@ #include "keepkey/firmware/thorchain.h" #include "keepkey/board/confirm_sm.h" #include "keepkey/board/util.h" +#include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/storage.h" #include "keepkey/firmware/tendermint.h" @@ -29,16 +30,28 @@ #include "trezor/crypto/segwit_addr.h" #include +#include #include +bool thorchain_isValidDenom(const char* denom) { + return tendermint_isValidDenom(denom); +} + +bool thorchain_isValidAsset(const char* asset) { + return tendermint_isValidAsset(asset); +} + static CONFIDENTIAL HDNode node; static SHA256_CTX ctx; static bool initialized; -static bool has_message; static uint32_t msgs_remaining; static ThorchainSignTx msg; static bool testnet; +bool thorchain_isValidSigner(const char* signer) { + return tendermint_isValidSigner(signer, testnet ? "tthor" : "thor"); +} + const ThorchainSignTx* thorchain_getThorchainSignTx(void) { return &msg; } bool thorchain_formatAmount(uint64_t amount, const char* asset, char* out, @@ -53,12 +66,7 @@ bool thorchain_formatAmount(uint64_t amount, const char* asset, char* out, } bool thorchain_signTxInit(const HDNode* _node, const ThorchainSignTx* _msg) { - thorchain_signAbort(); - if (!_node || !_msg || !_msg->has_msg_count || _msg->msg_count == 0 || - !_msg->has_chain_id || !tendermint_validateSafeText(_msg->chain_id)) { - return false; - } - + initialized = true; msgs_remaining = _msg->msg_count; testnet = false; @@ -108,23 +116,23 @@ bool thorchain_signTxInit(const HDNode* _node, const ThorchainSignTx* _msg) { // 10 sha256_Update(&ctx, (uint8_t*)"\",\"msgs\":[", 10); - if (!success) { - thorchain_signAbort(); - return false; - } - initialized = true; - return true; + return success; } bool thorchain_signTxUpdateMsgSend(const uint64_t amount, - const char* to_address) { - if (!initialized || msgs_remaining == 0) return false; - + const char* to_address, const char* denom) { const char mainnetp[] = "thor"; const char testnetp[] = "tthor"; const char* pfix; char buffer[64 + 1]; + size_t decoded_len; + char hrp[BECH32_MAX_HRP_LEN + 1]; + uint8_t decoded[BECH32_DECODED_MAX]; + if (!bech32_decode(hrp, decoded, &decoded_len, to_address)) { + return false; + } + char from_address[46]; pfix = mainnetp; @@ -148,8 +156,11 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, return false; } - if (has_message) { - sha256_Update(&ctx, (uint8_t*)",", 1); + // 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; @@ -157,10 +168,14 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, 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), @@ -170,25 +185,18 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), ",\"to_address\":\"%s\"}}", to_address); - if (success) has_message = true; msgs_remaining--; return success; } bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg) { - if (!initialized || msgs_remaining == 0) return false; - - const char* const signer_prefix = testnet ? "tthor" : "thor"; - if (!depmsg || !depmsg->has_asset || - !tendermint_validateSafeText(depmsg->asset) || !depmsg->has_signer || - !tendermint_validateBech32Address(depmsg->signer, signer_prefix)) { - return false; - } - char buffer[64 + 1]; - if (has_message) { - sha256_Update(&ctx, (uint8_t*)",", 1); + // Defended here too (not just by the FSM caller) so this signing path is + // safe even if called directly or reused elsewhere later. + if (!thorchain_isValidAsset(depmsg->asset) || + !thorchain_isValidSigner(depmsg->signer)) { + return false; } bool success = true; @@ -201,9 +209,11 @@ bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg) { "\"coins\":[{\"amount\":\"%" PRIu64 "\"", depmsg->amount); - // 10 + ^20 + 3 = ^33 - success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), - ",\"asset\":\"%s\"}]", depmsg->asset); + // Use escaping as defense-in-depth; valid assets have no escapable chars + const char* const asset_prefix = ",\"asset\":\""; + sha256_Update(&ctx, (uint8_t*)asset_prefix, strlen(asset_prefix)); + tendermint_sha256UpdateEscaped(&ctx, depmsg->asset, strlen(depmsg->asset)); + sha256_Update(&ctx, (uint8_t*)"\"}]", 3); // const char* const memo_prefix = ",\"memo\":\""; @@ -214,7 +224,6 @@ bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg) { success &= tendermint_snprintf(&ctx, buffer, sizeof(buffer), "\",\"signer\":\"%s\"}}", depmsg->signer); - if (success) has_message = true; msgs_remaining--; return success; } @@ -254,85 +263,56 @@ bool thorchain_addressIsSigner(const char* address) { bool thorchain_signingIsInited(void) { return initialized; } -bool thorchain_signingIsFinished(void) { - return msgs_remaining == 0 && has_message; -} +bool thorchain_signingIsFinished(void) { return msgs_remaining == 0; } void thorchain_signAbort(void) { initialized = false; - has_message = false; msgs_remaining = 0; memzero(&msg, sizeof(msg)); memzero(&node, sizeof(node)); } -/* strtok() discards empty delimiter-separated components. Empty memo fields - * are positional and meaningful -- for example, an omitted swap limit before - * an affiliate is encoded as `::`. Structured review cannot use a tokenizer - * that turns that memo into the same token sequence as one with no empty - * position, because it can label the affiliate as the limit or otherwise - * shift every field that follows. - * - * Treat any empty `:` or `.` component as non-canonical for this legacy - * parser. Callers either disclose the raw memo (UTXO) or refuse the structured - * EVM path. This is deliberately fail-closed until the parser is replaced by - * one that preserves and understands every position in the current grammar. */ -static bool thorchain_memo_has_empty_component(const char* memo, size_t size) { - if (!memo || size == 0) return true; - - for (size_t i = 0; i < size; i++) { - if (memo[i] != ':' && memo[i] != '.') continue; - - if (i == 0 || i + 1 == size || memo[i - 1] == ':' || memo[i - 1] == '.' || - memo[i + 1] == ':' || memo[i + 1] == '.') { - return true; - } - } - - return false; +/* Page the COMPLETE raw memo so nothing is truncated behind confirm()'s body + * budget. THORChain memos are ASCII; a non-printable byte gets a hex page so + * even a malformed memo is fully disclosed rather than hidden. Shared with the + * MAYA path (mayachain memos use the same grammar) and the native signing + * handlers, which page this as the authoritative disclosure after any + * best-effort structured summary. */ +bool thorchain_confirm_full_memo(const char* title, const char* memo, + size_t len) { + return confirm_bytes(ButtonRequestType_ButtonRequest_ConfirmOutput, title, + (const uint8_t*)memo, len); } +/* Validate the chain/asset separator before the positional parser labels + * fields. Empty colon-delimited fields remain meaningful and supported. */ static bool thorchain_memo_has_canonical_separators(const char* memo, size_t size) { - /* The grammar is OP:CHAIN.ASSET:DEST:LIMIT[:AFFILIATE:BPS] -- ':' between - fields, '.' only inside the chain/asset pair. - - The tokenizer below cannot tell the two apart. After splitting the - operation on ':' it calls strtok(NULL, ":.") three times, so ':' and '.' - are interchangeable for everything it reads. A memo that puts a colon - where the dot belongs, - - SWAP:ETH:USDT:dest:limit - - therefore produces exactly the same three tokens as SWAP:ETH.USDT:... and - is reviewed as "asset USDT on chain ETH", while THORChain/MAYAChain read - that same memo with USDT as the DESTINATION -- every field after the - operation shifts by one, including the address the funds go to. The screen - and the protocol disagree about a memo the signature covers. - - Require the dot exactly once and only inside the second colon-delimited - field. Anything else is not this grammar, so it goes to the raw-byte path - rather than through a parser that would mislabel it. A destination that - legitimately contains a dot is refused here too; disclosure of the exact - bytes is the safe direction, and this parser is fail-closed by design. */ + /* The grammar requires OP:CHAIN.ASSET. Dots in later positional fields are + * data (for example the THOR.RUNE asymmetric-withdrawal selector), so they + * must not be confused with the one separator required in field 1. */ if (!memo || size == 0) return false; size_t field = 0; - size_t dots_total = 0; size_t dots_in_asset_field = 0; + bool has_chain = false; + bool has_asset = false; for (size_t i = 0; i < size; i++) { if (memo[i] == ':') { field++; continue; } - if (memo[i] == '.') { - dots_total++; - if (field == 1) dots_in_asset_field++; - } + if (field != 1) continue; + if (memo[i] == '.') + dots_in_asset_field++; + else if (dots_in_asset_field == 0) + has_chain = true; + else + has_asset = true; } - return dots_total == 1 && dots_in_asset_field == 1; + return dots_in_asset_field == 1 && has_chain && has_asset; } static bool thorchain_memo_is_structured_text(const char* memo, size_t size) { @@ -367,162 +347,224 @@ ThorchainMemoResult thorchain_parseConfirmMemo(const char* swapStr, 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[:affiliate:fee_bps...] + 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 past the ones labelled below (affiliate, affiliate fee, aggregator - routing) are executed by THORChain, so each branch pages whatever is left - rather than signing it unseen. + 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. */ - // THORChain's memo maximum, and the largest `size` any caller can pass. - enum { THORCHAIN_MEMO_MAX = 256 }; - - char* parseTokPtrs[5] = {NULL, NULL, NULL, NULL, - NULL}; // we can parse up to 5 labelled tokens - char* tok; - // One byte past the maximum, so a full-length memo is still NUL terminated - // by the memzero below. - char memoBuf[THORCHAIN_MEMO_MAX + 1]; - uint16_t ctr; + /* Up to 9 fields for a DEX-aggregator swap + * (SWAP:ASSET:DEST:LIM:AFFILIATE:FEE:AGGREGATOR:FINALTOKEN:MINOUT); the 10th + * slot lets us detect (and reject) a memo with more fields than any known + * grammar rather than silently merging the tail into a displayed field. */ + char* fields[10] = {NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL}; + /* Memos are documented/accepted up to 256 bytes; memoBuf reserves one + * extra byte so a full 256-byte memo still leaves a guaranteed NUL + * terminator, instead of the copy silently dropping its last byte. */ + enum { MEMO_MAX = 256 }; + char memoBuf[MEMO_MAX + 1]; + size_t nfields, i; + char *chain, *asset; // check if memo data is recognized - if (size > THORCHAIN_MEMO_MAX || - thorchain_memo_has_empty_component(swapStr, size) || - !thorchain_memo_is_structured_text(swapStr, size) || + if (size > MEMO_MAX || !thorchain_memo_is_structured_text(swapStr, size) || !thorchain_memo_has_canonical_separators(swapStr, size)) { return THORCHAIN_MEMO_UNPARSED; } memzero(memoBuf, sizeof(memoBuf)); - /* `size` is a byte count and swapStr is NOT guaranteed to be NUL - terminated - the BTC OP_RETURN caller hands us raw script bytes. strlcpy - copies only size-1 of them, silently dropping the memo's last character - (an affiliate fee of "75" bps renders as "7"), and then walks past the end - of the source looking for a terminator. Copy exactly `size` bytes; the - memzero'd tail terminates them. */ + /* 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); - /* strtok below treats memoBuf as a C string, so it stops at the first NUL -- - but `size` bytes were copied and ALL of them are covered by the signature. - A memo such as "=:ETH.ETH::0\0:affiliate:75" would parse and confirm - as if it ended at the zero byte while the suffix stayed in the signed - calldata. The EVM caller passes the true ABI length, so those bytes are - real. - - Reject ANY NUL inside the declared length, including a trailing one. - - An earlier version of this check exempted trailing NULs on the grounds - that nothing is hidden behind them. That was wrong twice over. It was - adopted to make two test fixtures pass -- fixtures that declare 59 bytes - for a 58-byte memo -- which is the one thing the release invariant forbids: - tests adapt to disclosure, disclosure never weakens for a test. And it - accepts a length word that does not describe its own content, which is the - same non-canonical ABI encoding that the offset-word validation already - refuses. A declaration the device cannot trust is not made trustworthy by - the bytes it misdescribes happening to be zero. - - The caller's UNPARSED path discloses the raw bytes with a length-aware - writer, so nothing is lost by refusing to parse. */ - for (uint16_t i = 0; i < size; i++) { + /* The field split below treats memoBuf as a C string, so it stops at the + first NUL -- but `size` bytes were copied and ALL of them are covered by + the signature. A memo such as "=:ETH.ETH::0\0:affiliate:75" would + parse and confirm as if it ended at the zero byte while the suffix stayed + in the signed calldata. The EVM caller passes the true ABI length, so + those bytes are real. + + Reject ANY NUL inside the declared length, including a trailing one. A + length word that does not describe its own content is a non-canonical + encoding, and is not made trustworthy by the bytes it misdescribes + happening to be zero. The caller's UNPARSED path discloses the raw bytes + with a length-aware writer, so nothing is lost by refusing to parse. */ + for (i = 0; i < size; i++) { if (memoBuf[i] == '\0') return THORCHAIN_MEMO_UNPARSED; } - tok = strtok(memoBuf, ":"); - - // get transaction and asset - for (ctr = 0; ctr < 3; ctr++) { - if (tok != NULL) { - parseTokPtrs[ctr] = tok; - tok = strtok(NULL, ":."); - } else { - break; + // Split on ':', keeping empty fields + nfields = 0; + fields[nfields++] = memoBuf; + for (i = 0; memoBuf[i] != '\0' && nfields < 10; i++) { + if (memoBuf[i] == ':') { + memoBuf[i] = '\0'; + fields[nfields++] = &memoBuf[i + 1]; } } - if (ctr != 3) { - // Must have three tokens at this point: transaction, chain, asset. If - // not, just confirm data + if (nfields < 2) { + // Must have at least transaction and chain.asset. If not, just confirm + // data return THORCHAIN_MEMO_UNPARSED; } + // 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 THORCHAIN_MEMO_UNPARSED; + } + *asset = '\0'; + asset++; + // Check for swap - if (strcmp(parseTokPtrs[0], "SWAP") == 0 || - strcmp(parseTokPtrs[0], "s") == 0 || strcmp(parseTokPtrs[0], "=") == 0) { - // This is a swap, set up destination and limit - // This is the dest, may be blank which means swap to self - parseTokPtrs[3] = "self"; - parseTokPtrs[4] = "none"; - if (tok != NULL) { - if ((uint32_t)(tok - (parseTokPtrs[2] + strlen(parseTokPtrs[2]))) == 1) { - // has dest address - parseTokPtrs[3] = tok; - tok = strtok(NULL, ":"); - } - if (tok != NULL) { - // has limit - parseTokPtrs[4] = tok; + if (strcmp(fields[0], "SWAP") == 0 || strcmp(fields[0], "s") == 0 || + strcmp(fields[0], "=") == 0) { + /* Aggregator outbound memo: field 8 is MinAmountOut|OUTBOUND_MEMO, and + * everything after '|' is forwarded to the outbound contract. That suffix + * can itself contain ':' which our ':'-split would scatter (or overflow + * past field 9), so a single confirm could truncate it. When a '|' is + * present, skip structured field display and page the COMPLETE raw memo so + * every signed byte is shown. */ + if (memchr(swapStr, '|', size) != NULL) { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Confirm swap asset %s\n on chain %s", + asset, chain)) { + return THORCHAIN_MEMO_CANCELLED; } + return thorchain_confirm_full_memo("Swap memo", swapStr, size) + ? THORCHAIN_MEMO_CONFIRMED + : THORCHAIN_MEMO_CANCELLED; + } + // This is a swap, set up destination and limit + // The dest may be blank which means swap to self + const char* dest = + (nfields > 2 && fields[2][0] != '\0') ? fields[2] : "self"; + const char* limit = + (nfields > 3 && fields[3][0] != '\0') ? fields[3] : "none"; + const char* affiliate = + (nfields > 4 && fields[4][0] != '\0') ? fields[4] : NULL; + const bool has_fee = (nfields > 5 && fields[5][0] != '\0'); + const char* fee_bps = has_fee ? fields[5] : "unspecified"; + uint16_t parsed_fee_bps = 0; + if (has_fee && !thorchain_parse_bps(fee_bps, &parsed_fee_bps)) { + return THORCHAIN_MEMO_UNPARSED; + } + /* DEX-aggregator swap-out fields — all router-executed, so all displayed. + */ + const char* agg_addr = + (nfields > 6 && fields[6][0] != '\0') ? fields[6] : NULL; + const char* final_token = + (nfields > 7 && fields[7][0] != '\0') ? fields[7] : NULL; + const char* min_out = + (nfields > 8 && fields[8][0] != '\0') ? fields[8] : NULL; + + /* Refuse only genuinely-unknown structure — more fields than any THORChain + * swap grammar defines (>9), which we cannot label and must not hide. */ + if (nfields > 9) { + return THORCHAIN_MEMO_UNPARSED; } 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 THORCHAIN_MEMO_CANCELLED; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain swap", "Confirm to %s", parseTokPtrs[3])) { + "Thorchain swap", "Confirm to %s", dest)) { return THORCHAIN_MEMO_CANCELLED; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain swap", "Confirm limit %s", parseTokPtrs[4])) { + "Thorchain swap", "Confirm limit %s", limit)) { return THORCHAIN_MEMO_CANCELLED; } - /* Everything after the limit - affiliate, affiliate fee in basis points, - DEX-aggregator routing - is executed by THORChain but was never shown. - Page each remaining field rather than sign it unseen. */ - while ((tok = strtok(NULL, ":")) != NULL) { + /* Never hide the affiliate fee skim. Gated on EITHER field being present, + * not on the affiliate alone: a memo may carry a fee with an empty + * affiliate slot ("=:ETH.ETH:0xdest:0::75"), and those bytes are inside + * the signed length whether or not the slot naming their recipient is + * filled in. Showing the fee against "(none given)" discloses what is + * actually signed; skipping the screen discloses nothing. */ + if (affiliate != NULL || has_fee) { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain swap", "Additional memo field\n%s", tok)) { + "Thorchain swap", "Affiliate fee %s bps to %s", fee_bps, + affiliate ? affiliate : "(none given)")) { return THORCHAIN_MEMO_CANCELLED; } } + // DEX-aggregator routing: the router forwards the output through this + // aggregator to a final token, so both must be visible. + if (agg_addr != NULL && + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "DEX aggregator %s", agg_addr)) { + return THORCHAIN_MEMO_CANCELLED; + } + if (final_token != NULL && + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Final token %s", final_token)) { + return THORCHAIN_MEMO_CANCELLED; + } + if (min_out != NULL && + !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain swap", "Min output %s", min_out)) { + return THORCHAIN_MEMO_CANCELLED; + } return THORCHAIN_MEMO_CONFIRMED; } // Check for add liquidity - else if (strcmp(parseTokPtrs[0], "ADD") == 0 || - strcmp(parseTokPtrs[0], "a") == 0 || - strcmp(parseTokPtrs[0], "+") == 0) { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; + else if (strcmp(fields[0], "ADD") == 0 || strcmp(fields[0], "a") == 0 || + strcmp(fields[0], "+") == 0) { + // ADD:POOL:PAIREDADDR:AFFILIATE:FEE — paired address, affiliate and fee are + // all optional but router-executed, so none may be hidden. + const char* pool = (nfields > 2 && fields[2][0] != '\0') ? fields[2] : NULL; + const char* affiliate = + (nfields > 3 && fields[3][0] != '\0') ? fields[3] : NULL; + const bool has_fee = (nfields > 4 && fields[4][0] != '\0'); + const char* fee_bps = has_fee ? fields[4] : "unspecified"; + uint16_t parsed_fee_bps = 0; + if (has_fee && !thorchain_parse_bps(fee_bps, &parsed_fee_bps)) { + return THORCHAIN_MEMO_UNPARSED; + } + + /* ADD grammar defines at most 5 fields; more than that is structure we + * cannot label and must not sign hidden, so refuse it. */ + if (nfields > 5) { + return THORCHAIN_MEMO_UNPARSED; } 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 THORCHAIN_MEMO_CANCELLED; } - 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 THORCHAIN_MEMO_CANCELLED; } } - /* ADD:POOL:PAIREDADDR:AFFILIATE:FEE - the affiliate and its fee are - optional but router-executed, so neither may be hidden. */ - while ((tok = strtok(NULL, ":")) != NULL) { + /* Same as the SWAP branch: a fee in an otherwise-unnamed affiliate slot + * is still signed, so it is still shown. */ + if (affiliate != NULL || has_fee) { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain add liquidity", "Additional memo field\n%s", - tok)) { + "Thorchain add liquidity", "Affiliate fee %s bps to %s", + fee_bps, affiliate ? affiliate : "(none given)")) { return THORCHAIN_MEMO_CANCELLED; } } @@ -530,34 +572,38 @@ ThorchainMemoResult thorchain_parseConfirmMemo(const char* swapStr, } // Check for withdraw liquidity - else if (strcmp(parseTokPtrs[0], "WITHDRAW") == 0 || - strcmp(parseTokPtrs[0], "wd") == 0 || - strcmp(parseTokPtrs[0], "-") == 0) { - if (tok != NULL) { - // add liquidity pool address - parseTokPtrs[3] = tok; - } else { + else if (strcmp(fields[0], "WITHDRAW") == 0 || strcmp(fields[0], "wd") == 0 || + strcmp(fields[0], "-") == 0) { + if (nfields < 3 || fields[2][0] == '\0') { return THORCHAIN_MEMO_UNPARSED; // malformed memo } + /* WD:POOL:BPS[:ASSET] — refuse only genuinely-unknown structure (>4 + * fields), mirroring the SWAP (>9) and ADD (>5) caps. */ + if (nfields > 4) { + return THORCHAIN_MEMO_UNPARSED; + } + /* BPS rendered with integer math: snprintf is the integer-only sniprintf + * on the device, so no float formats. Negative BPS is a malformed memo. */ uint16_t bps = 0; - if (!thorchain_parse_bps(parseTokPtrs[3], &bps)) { + if (!thorchain_parse_bps(fields[2], &bps)) { return THORCHAIN_MEMO_UNPARSED; } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain withdraw liquidity", - "Confirm withdraw %u.%02u%% of asset %s on chain %s", - (unsigned)(bps / 100u), (unsigned)(bps % 100u), - parseTokPtrs[2], parseTokPtrs[1])) { + "Confirm withdraw %d.%02d%% of asset %s on chain %s", + bps / 100, bps % 100, asset, chain)) { return THORCHAIN_MEMO_CANCELLED; } - /* WD:POOL:BPS:ASSET - the optional 4th field pays the whole withdrawal - out single-sided in ASSET instead of the symmetric split. It directs - money and the screens are otherwise identical, so it must be shown. */ - while ((tok = strtok(NULL, ":")) != NULL) { + /* Field 4 is the ASYMMETRIC-withdrawal asset selector: WD:POOL:BPS:ASSET + * pays the whole withdrawal out single-sided in ASSET instead of the + * symmetric split. It directs money, so it must never sign unseen — + * otherwise the screens for the asymmetric form are identical to the + * symmetric one. */ + if (nfields > 3 && fields[3][0] != '\0') { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain withdraw liquidity", "Additional memo field\n%s", - tok)) { + "Thorchain withdraw liquidity", + "Withdraw single-sided as %s", fields[3])) { return THORCHAIN_MEMO_CANCELLED; } } diff --git a/lib/firmware/tiny-json.c b/lib/firmware/tiny-json.c index c915eff58..80f0e9e21 100644 --- a/lib/firmware/tiny-json.c +++ b/lib/firmware/tiny-json.c @@ -33,7 +33,9 @@ // #include -int errno = 0; +/* Renamed from `errno` to avoid colliding with the libc macro on + * glibc/MinGW (where errno expands to (*_errno())). Write-only, never read. */ +int json_errno = 0; /** Structure to handle a heap of JSON properties. */ typedef struct jsonStaticPool_s { @@ -73,7 +75,7 @@ static bool isEndOfPrimitive(char ch); json_t const* json_createWithPool(char* str, jsonPool_t* pool) { char* ptr = goBlank(str); if (!ptr || (*ptr != '{' && *ptr != '[')) { - errno = -1; + json_errno = -1; return 0; } json_t* obj = pool->init(pool); @@ -82,7 +84,7 @@ json_t const* json_createWithPool(char* str, jsonPool_t* pool) { obj->u.c.child = 0; ptr = objValue(ptr, obj, pool); if (!ptr) { - errno = -2; + json_errno = -2; return 0; } return obj; diff --git a/lib/firmware/transaction.c b/lib/firmware/transaction.c index 33ca0662d..8944b3140 100644 --- a/lib/firmware/transaction.c +++ b/lib/firmware/transaction.c @@ -389,6 +389,18 @@ int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, in->op_return_data.size); r += in->op_return_data.size; out->script_pubkey.size = r; + /* signing.c calls txin_dgst_final() once per output, and the pay-to-address + path below re-arms the context via txin_dgst_save_and_reset(). This path + returns before that, so a transaction whose LAST output is OP_RETURN used + to leave the hash finalised and never re-initialised -- the NEXT + transaction's inputs were then hashed into a finalised context, its + digest no longer matched while the amount and address still did, and the + device falsely reported "WARNING: Duplicate Transaction!" and aborted + until the user replugged. Every THORChain/Maya swap from Bitcoin is an + OP_RETURN memo, so an ordinary send right after a swap hit this. + Reset only: an OP_RETURN has no amount/address worth saving as a + comparison key. */ + txin_dgst_reset_only(); return r; } @@ -616,6 +628,14 @@ uint32_t compile_script_sig(uint32_t address_type, const uint8_t* pubkeyhash, } } +bool transaction_multisig_quorum_is_valid( + const MultisigRedeemScriptType* multisig) { + if (multisig == NULL || !multisig->has_m) return false; + const uint32_t m = multisig->m; + const uint32_t n = multisig->pubkeys_count; + return m >= 1 && m <= 15 && n >= 1 && n <= 15 && m <= n; +} + // if out == NULL just compute the length bool multisig_quorum_is_valid(const MultisigRedeemScriptType* multisig) { if (multisig == NULL || !multisig->has_m) return false; @@ -627,7 +647,7 @@ bool multisig_quorum_is_valid(const MultisigRedeemScriptType* multisig) { uint32_t compile_script_multisig(const CoinType* coin, const MultisigRedeemScriptType* multisig, uint8_t* out) { - if (!multisig_quorum_is_valid(multisig)) return 0; + if (!transaction_multisig_quorum_is_valid(multisig)) return 0; const uint32_t m = multisig->m; const uint32_t n = multisig->pubkeys_count; uint32_t r = 0; @@ -656,7 +676,7 @@ uint32_t compile_script_multisig(const CoinType* coin, uint32_t compile_script_multisig_hash(const CoinType* coin, const MultisigRedeemScriptType* multisig, uint8_t* hash) { - if (!multisig_quorum_is_valid(multisig)) return 0; + if (!transaction_multisig_quorum_is_valid(multisig)) return 0; const uint32_t m = multisig->m; const uint32_t n = multisig->pubkeys_count; diff --git a/lib/firmware/tron.c b/lib/firmware/tron.c index 21f74e121..8ae4cda99 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' @@ -82,6 +84,363 @@ void tron_formatAmount(char* buf, size_t len, uint64_t amount) { } } +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/txin_check.c b/lib/firmware/txin_check.c index fe01d8077..5c37160c0 100644 --- a/lib/firmware/txin_check.c +++ b/lib/firmware/txin_check.c @@ -91,6 +91,11 @@ void txin_dgst_getstrs(char* prev, char* cur, size_t len) { } // save last state and reset for next tx request +void txin_dgst_reset_only(void) { + memzero(txin_current_digest, SHA256_DIGEST_LENGTH); + sha256_Init(&txin_hash_ctx); + return; +} void txin_dgst_save_and_reset(const char* amt_str, const char* addr_str) { memcpy(txin_last_digest, txin_current_digest, SHA256_DIGEST_LENGTH); memcpy(last_amount_str, amt_str, AMT_STR_LEN); diff --git a/lib/firmware/zcash.c b/lib/firmware/zcash.c new file mode 100644 index 000000000..72da93a4b --- /dev/null +++ b/lib/firmware/zcash.c @@ -0,0 +1,1236 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2025 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library. If not, see . + */ + +#include "keepkey/firmware/zcash.h" + +#include +#include + +#include "trezor/crypto/aes/aes.h" +#include "trezor/crypto/bignum.h" +#include "trezor/crypto/blake2b.h" +#include "trezor/crypto/hasher.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/pallas.h" +#include "trezor/crypto/pallas_ct.h" +#include "trezor/crypto/pallas_sinsemilla.h" +#include "trezor/crypto/pallas_swu.h" +#include "trezor/crypto/redpallas.h" +#include "trezor/crypto/zcash_zip316.h" + +/* + * ZIP-32 Orchard key derivation. + * + * Master key: + * I = BLAKE2b-512("ZcashIP32Orchard", seed) + * sk = I[0..32], chain_code = I[32..64] + * + * Child derivation (hardened only): + * I = BLAKE2b-512("ZcashIP32Orchard", chain_code, + * 0x11 || sk || i_be) + * where 0x11 indicates hardened derivation with Orchard, + * and i_be is the 4-byte big-endian child index with the hardened bit set. + * + * From the spending key sk, subkeys are derived using PRF^expand: + * PRF^expand(sk, t) = BLAKE2b-512("Zcash_ExpandSeed", sk || t) + * + * ask = ToScalar(PRF^expand(sk, [0x06])) + * nk = ToBase(PRF^expand(sk, [0x07])) + * rivk = ToScalar(PRF^expand(sk, [0x08])) + * + * ToScalar: interpret 64 bytes as LE integer, reduce mod order + * ToBase: interpret 64 bytes as LE integer, reduce mod prime + */ + +/* + * BLAKE2b-512 with personalization "ZcashIP32Orchard" — master key only. + * Used for: I = BLAKE2b-512("ZcashIP32Orchard", seed) + * NOT used for child derivation (which uses PRF^expand). + */ +static void zip32_orchard_master(const uint8_t* seed, size_t seed_len, + uint8_t out[64]) { + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 64, "ZcashIP32Orchard", 16); + blake2b_Update(&ctx, seed, seed_len); + blake2b_Final(&ctx, out, 64); + /* blake2b_Final() clears its own scratch and leaves the finished state in + * ctx: h[0..7] IS the 64-byte master key just produced, and buf still holds + * the last block of the BIP-39 SEED. Both are the highest-value secrets on + * the device. */ + memzero(&ctx, sizeof(ctx)); +} + +/* 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); + /* ctx.buf still holds sk (the Orchard spending key) and ctx.h is the + * expanded output. Same leak as zip32_orchard_master() above. */ + memzero(&ctx, sizeof(ctx)); +} + +/* + * 2^256 mod q (Pallas scalar field order), little-endian. + * q = 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000001 + * R = 0x3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF992C350BE34205675B2B3E9CFFFFFFFD + * Verified: R + 3*q == 2^256. + */ +static const uint8_t two_256_mod_q[32] = { + 0xfd, 0xff, 0xff, 0xff, 0x9c, 0x3e, 0x2b, 0x5b, 0x67, 0x05, 0x42, + 0xe3, 0x0b, 0x35, 0x2c, 0x99, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, +}; + +/* + * 2^256 mod p (Pallas base field prime), little-endian. + * p = 0x40000000000000000000000000000000224698fc094cf91b992d30ed00000001 + * R = 0x3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF992C350BE41914AD34786D38FFFFFFFD + * Verified: R + 3*p == 2^256. + */ +static const uint8_t two_256_mod_p[32] = { + 0xfd, 0xff, 0xff, 0xff, 0x38, 0x6d, 0x78, 0x34, 0xad, 0x14, 0x19, + 0xe4, 0x0b, 0x35, 0x2c, 0x99, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f, +}; + +/* + * ToScalar: reduce a 512-bit LE integer mod Pallas scalar order. + * + * Uses wide reduction matching the orchard crate's from_uniform_bytes: + * result = (lo + hi * 2^256) mod q + * where lo = input[0..31], hi = input[32..63] (little-endian). + */ +static void to_scalar(const uint8_t input[64], uint8_t output[32]) { + bignum256 lo, hi, t256, result; + + bn_read_le(input, &lo); + pallas_ct_mod_q(&lo); + + bn_read_le(input + 32, &hi); + pallas_ct_mod_q(&hi); + + bn_read_le(two_256_mod_q, &t256); + + /* result = hi * (2^256 mod q) mod q */ + bn_copy(&hi, &result); + pallas_ct_mul_mod_q(&result, &t256); + + /* result = result + lo mod q */ + pallas_ct_add_mod_q(&result, &lo); + + bn_write_le(&result, output); + + memzero(&lo, sizeof(lo)); + memzero(&hi, sizeof(hi)); + memzero(&result, sizeof(result)); +} + +/* + * ToBase: reduce a 512-bit LE integer mod Pallas base field prime. + * + * Uses wide reduction: + * result = (lo + hi * 2^256) mod p + * where lo = input[0..31], hi = input[32..63] (little-endian). + */ +static void to_base(const uint8_t input[64], uint8_t output[32]) { + bignum256 lo, hi, t256, result; + + bn_read_le(input, &lo); + pallas_ct_mod_p(&lo); + + bn_read_le(input + 32, &hi); + pallas_ct_mod_p(&hi); + + bn_read_le(two_256_mod_p, &t256); + + /* result = hi * (2^256 mod p) mod p */ + bn_copy(&hi, &result); + pallas_ct_mul_mod_p(&result, &t256); + + /* result = result + lo mod p */ + bignum256 sum; + pallas_ct_add_mod_p(&result, &lo, &sum); + bn_copy(&sum, &result); + memzero(&sum, sizeof(sum)); + + bn_write_le(&result, output); + + memzero(&lo, sizeof(lo)); + memzero(&hi, sizeof(hi)); + memzero(&result, sizeof(result)); +} + +/* Hardened child index */ +#define ZIP32_HARDENED 0x80000000 + +/* + * ZIP-32 Orchard diversifiers use FF1-AES256 over an 88-bit binary numeral + * string. Parameters are fixed by the Zcash protocol: + * + * radix = 2, minlen = maxlen = n = 88, tweak = "", rounds = 10 + * + * The input and output byte arrays are LEBS2OSP encodings of the 88-bit + * strings, but FF1's NUM/STR operations interpret each half in numeral-string + * order. Keep the bit-order conversion explicit to avoid silently turning this + * into a radix-256 construction, which would be a different permutation. + */ +#define ZCASH_FF1_BITS 88 +#define ZCASH_FF1_HALF_BITS 44 +#define ZCASH_FF1_MASK44 ((UINT64_C(1) << ZCASH_FF1_HALF_BITS) - 1) + +static uint8_t bit_get_le(const uint8_t* bytes, uint32_t bit) { + return (bytes[bit >> 3] >> (bit & 7)) & 1; +} + +static void bit_set_le(uint8_t* bytes, uint32_t bit, uint8_t value) { + if (value) { + bytes[bit >> 3] |= (uint8_t)(1u << (bit & 7)); + } +} + +static uint64_t ff1_bits_to_num(const uint8_t bits[11], uint32_t offset, + uint32_t len) { + uint64_t n = 0; + for (uint32_t i = 0; i < len; i++) { + n = (n << 1) | bit_get_le(bits, offset + i); + } + return n; +} + +static void ff1_num_to_bits(uint64_t n, uint8_t bits[11], uint32_t offset, + uint32_t len) { + for (uint32_t i = 0; i < len; i++) { + uint32_t shift = len - 1 - i; + bit_set_le(bits, offset + i, (uint8_t)((n >> shift) & 1)); + } +} + +static void ff1_store_be48(uint64_t n, uint8_t out[6]) { + for (int i = 5; i >= 0; i--) { + out[i] = (uint8_t)(n & 0xff); + n >>= 8; + } +} + +static bool aes256_encrypt_block(const aes_encrypt_ctx* ctx, + const uint8_t in[16], uint8_t out[16]) { + return aes_encrypt(in, out, ctx) == EXIT_SUCCESS; +} + +static bool ff1_round_y_mod_2_44(const aes_encrypt_ctx* ctx, uint8_t round, + uint64_t b, uint64_t* y_mod) { + static const uint8_t P[16] = { + 0x01, 0x02, 0x01, 0x00, 0x00, 0x02, 0x0a, 0x2c, + 0x00, 0x00, 0x00, 0x58, 0x00, 0x00, 0x00, 0x00, + }; + + uint8_t q[16] = {0}; + uint8_t y[16]; + uint8_t block[16]; + uint8_t r[16]; + + /* + * Q = T || [0]^{(-t-b-1) mod 16} || [i]_1 || [NUM(B)]_b + * Here t = 0 and b = ceil(44 / 8) = 6, so padding is 9 bytes. + */ + q[9] = round; + ff1_store_be48(b, q + 10); + + /* PRF(P || Q) = CBC-MAC_AES(P || Q), IV = 0. */ + if (!aes256_encrypt_block(ctx, P, y)) return false; + for (int i = 0; i < 16; i++) { + block[i] = y[i] ^ q[i]; + } + if (!aes256_encrypt_block(ctx, block, r)) return false; + + /* + * d = 4 * ceil(6 / 4) + 4 = 12, so S is the first 12 bytes of R. + * We only need NUM(S) modulo 2^44, i.e. the low 44 bits of R[0..11]. + */ + uint64_t low48 = 0; + for (int i = 6; i < 12; i++) { + low48 = (low48 << 8) | r[i]; + } + *y_mod = low48 & ZCASH_FF1_MASK44; + + memzero(q, sizeof(q)); + memzero(y, sizeof(y)); + memzero(block, sizeof(block)); + memzero(r, sizeof(r)); + return true; +} + +bool zcash_orchard_derive_diversifier(const uint8_t dk[32], + const uint8_t index_le[11], + uint8_t diversifier_out[11]) { + if (!dk || !index_le || !diversifier_out) return false; + + aes_encrypt_ctx ctx; + if (aes_encrypt_key256(dk, &ctx) != EXIT_SUCCESS) { + memzero(&ctx, sizeof(ctx)); + return false; + } + + uint64_t A = + ff1_bits_to_num(index_le, 0, ZCASH_FF1_HALF_BITS) & ZCASH_FF1_MASK44; + uint64_t B = + ff1_bits_to_num(index_le, ZCASH_FF1_HALF_BITS, ZCASH_FF1_HALF_BITS) & + ZCASH_FF1_MASK44; + + for (uint8_t round = 0; round < 10; round++) { + uint64_t y; + if (!ff1_round_y_mod_2_44(&ctx, round, B, &y)) { + memzero(&ctx, sizeof(ctx)); + return false; + } + uint64_t C = (A + y) & ZCASH_FF1_MASK44; + A = B; + B = C; + } + + memset(diversifier_out, 0, 11); + ff1_num_to_bits(A, diversifier_out, 0, ZCASH_FF1_HALF_BITS); + ff1_num_to_bits(B, diversifier_out, ZCASH_FF1_HALF_BITS, ZCASH_FF1_HALF_BITS); + + memzero(&ctx, sizeof(ctx)); + return true; +} + +static bool orchard_diversify_point(const uint8_t diversifier[11], + curve_point* gd) { + if (!diversifier || !gd) return false; + static const char domain[] = "z.cash:Orchard-gd"; + + if (pallas_group_hash(domain, diversifier, 11, gd) != 0) { + return false; + } + + if (pallas_point_is_identity(gd)) { + if (pallas_group_hash(domain, NULL, 0, gd) != 0 || + pallas_point_is_identity(gd)) { + memzero(gd, sizeof(*gd)); + return false; + } + } + + return true; +} + +bool zcash_orchard_diversify_hash(const uint8_t diversifier[11], + uint8_t gd_out[32]) { + if (!gd_out) return false; + + curve_point gd; + if (!orchard_diversify_point(diversifier, &gd)) { + return false; + } + + pallas_point_encode(&gd, gd_out); + memzero(&gd, sizeof(gd)); + return true; +} + +bool zcash_orchard_derive_transmission_key(const uint8_t ivk[32], + const uint8_t diversifier[11], + uint8_t gd_out[32], + uint8_t pkd_out[32]) { + if (!ivk || !pkd_out) return false; + + bignum256 ivk_scalar; + bn_read_le(ivk, &ivk_scalar); + bn_normalize(&ivk_scalar); + if (bn_is_zero(&ivk_scalar) || !bn_is_less(&ivk_scalar, &pallas_prime)) { + memzero(&ivk_scalar, sizeof(ivk_scalar)); + return false; + } + + curve_point gd; + if (!orchard_diversify_point(diversifier, &gd)) { + memzero(&ivk_scalar, sizeof(ivk_scalar)); + return false; + } + + curve_point pkd; + /* ivk is private viewing-key material. Do not use the variable-time + * public-data multiplier that Sinsemilla note verification relies on. */ + pallas_ct_point_mult(&ivk_scalar, &gd, &pkd); + if (pallas_point_is_identity(&pkd)) { + memzero(&ivk_scalar, sizeof(ivk_scalar)); + memzero(&gd, sizeof(gd)); + memzero(&pkd, sizeof(pkd)); + return false; + } + + if (gd_out) { + pallas_point_encode(&gd, gd_out); + } + pallas_point_encode(&pkd, pkd_out); + + memzero(&ivk_scalar, sizeof(ivk_scalar)); + memzero(&gd, sizeof(gd)); + memzero(&pkd, sizeof(pkd)); + return true; +} + +bool zcash_orchard_derive_ivk(const uint8_t ak[32], const uint8_t nk[32], + const uint8_t rivk[32], uint8_t ivk_out[32]) { + if (!ak || !nk || !rivk || !ivk_out) return false; + if ((ak[31] & 0x80) != 0) return false; + + if (pallas_sinsemilla_commit_ivk(ak, nk, rivk, ivk_out) != 0) { + return false; + } + + bignum256 ivk; + bn_read_le(ivk_out, &ivk); + bn_normalize(&ivk); + bool ok = !bn_is_zero(&ivk) && bn_is_less(&ivk, &pallas_prime); + memzero(&ivk, sizeof(ivk)); + if (!ok) { + memzero(ivk_out, 32); + } + return ok; +} + +bool zcash_orchard_derive_receiver(const uint8_t ak[32], const uint8_t nk[32], + const uint8_t rivk[32], const uint8_t dk[32], + const uint8_t index_le[11], + uint8_t receiver_out[43]) { + if (!receiver_out) return false; + + uint8_t diversifier[11]; + uint8_t ivk[32]; + uint8_t pkd[32]; + bool ok = zcash_orchard_derive_diversifier(dk, index_le, diversifier) && + zcash_orchard_derive_ivk(ak, nk, rivk, ivk) && + zcash_orchard_derive_transmission_key(ivk, diversifier, NULL, pkd); + + if (ok) { + memcpy(receiver_out, diversifier, sizeof(diversifier)); + memcpy(receiver_out + sizeof(diversifier), pkd, sizeof(pkd)); + } else { + memzero(receiver_out, 43); + } + + memzero(diversifier, sizeof(diversifier)); + memzero(ivk, sizeof(ivk)); + memzero(pkd, sizeof(pkd)); + return ok; +} + +bool zcash_orchard_derive_unified_address(const ZcashOrchardKeys* keys, + const uint8_t index_le[11], + const char* hrp, char* address_out, + size_t address_out_len) { + if (!keys || !index_le || !hrp || !address_out) return false; + + bignum256 ask_scalar; + curve_point ak_point; + bignum256 ak_x; + uint8_t ak[32]; + uint8_t receiver[43]; + + bn_read_le(keys->ask, &ask_scalar); + redpallas_scalar_mult_spendauth_G(&ask_scalar, &ak_point); + bn_copy(&ak_point.x, &ak_x); + bn_write_le(&ak_x, ak); + + bool ok = zcash_orchard_derive_receiver(ak, keys->nk, keys->rivk, keys->dk, + index_le, receiver); + if (ok) { + ok = zcash_zip316_encode_orchard_unified_address(hrp, receiver, address_out, + address_out_len) == 0; + } + if (!ok && address_out_len > 0) { + address_out[0] = '\0'; + } + + memzero(&ask_scalar, sizeof(ask_scalar)); + memzero(&ak_point, sizeof(ak_point)); + memzero(&ak_x, sizeof(ak_x)); + memzero(ak, sizeof(ak)); + memzero(receiver, sizeof(receiver)); + return ok; +} + +bool zcash_orchard_receiver_to_unified_address( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], const char* hrp, + char* address_out, size_t address_out_len) { + if (!receiver || !hrp || !address_out) return false; + return zcash_zip316_encode_orchard_unified_address(hrp, receiver, address_out, + address_out_len) == 0; +} + +static bool zcash_pack_orchard_note_commit_msg(const uint8_t receiver[43], + uint64_t value, + const uint8_t rho[32], + const uint8_t psi[32], + uint8_t msg[136]) { + memset(msg, 0, 136); + + /* bits 0..255: repr_P(g_d) */ + curve_point gd; + if (!orchard_diversify_point(receiver, &gd)) { + memzero(&gd, sizeof(gd)); + return false; + } + pallas_point_encode(&gd, msg); + memzero(&gd, sizeof(gd)); + + /* bits 256..511: repr_P(pk_d) */ + memcpy(msg + 32, receiver + 11, 32); + + /* bits 512..575: I2LEBSP_64(value) */ + for (int i = 0; i < 8; i++) { + msg[64 + i] = (uint8_t)((value >> (8 * i)) & 0xff); + } + + /* bits 576..830: I2LEBSP_255(rho) */ + memcpy(msg + 72, rho, 31); + msg[103] = rho[31] & 0x7f; + + /* bits 831..1085: I2LEBSP_255(psi), packed at bit offset 831. */ + uint8_t psi255[32]; + memcpy(psi255, psi, 32); + psi255[31] &= 0x7f; + for (int i = 0; i < 32; i++) { + msg[103 + i] |= (uint8_t)(psi255[i] << 7); + msg[104 + i] |= (uint8_t)(psi255[i] >> 1); + } + memzero(psi255, sizeof(psi255)); + return true; +} + +static bool zcash_orchard_family_compute_cmx_with_progress( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32], + bool ironwood, ZcashOrchardProgressCallback progress, + void* progress_context) { + if (!receiver || !rho || !rseed || !cmx_out) return false; + + uint8_t msg[136]; + uint8_t prf_in[137]; + uint8_t prf_out[64]; + uint8_t rcm[32]; + uint8_t psi[32]; + curve_point gd, q, r; + bool ok = false; + + /* psi is unchanged between V2 (Orchard) and V3 (Ironwood) notes. */ + memcpy(prf_in + 1, rho, 32); + prf_in[0] = 0x09; + prf_expand(rseed, prf_in, 33, prf_out); + to_base(prf_out, psi); + + if (ironwood) { + /* ZIP-2005 H_rcm binds V3 randomness to every note field. */ + if (!orchard_diversify_point(receiver, &gd)) goto cleanup; + prf_in[0] = 0x0B; + pallas_point_encode(&gd, prf_in + 1); + memcpy(prf_in + 33, receiver + 11, 32); + for (size_t i = 0; i < 8; i++) { + prf_in[65 + i] = (uint8_t)((value >> (8 * i)) & 0xff); + } + memcpy(prf_in + 73, rho, 32); + memcpy(prf_in + 105, psi, 32); + prf_expand(rseed, prf_in, sizeof(prf_in), prf_out); + } else { + prf_in[0] = 0x05; + prf_expand(rseed, prf_in, 33, prf_out); + } + to_scalar(prf_out, rcm); + + ok = zcash_pack_orchard_note_commit_msg(receiver, value, rho, psi, msg) && + pallas_group_hash("z.cash:SinsemillaQ", + (const uint8_t*)"z.cash:Orchard-NoteCommit-M", + strlen("z.cash:Orchard-NoteCommit-M"), &q) == 0 && + pallas_group_hash("z.cash:Orchard-NoteCommit-r", NULL, 0, &r) == 0 && + pallas_sinsemilla_short_commit_progress(&q, &r, msg, 1086, rcm, cmx_out, + progress, progress_context) == 0; + +cleanup: + if (!ok) memzero(cmx_out, 32); + memzero(msg, sizeof(msg)); + memzero(prf_in, sizeof(prf_in)); + memzero(prf_out, sizeof(prf_out)); + memzero(rcm, sizeof(rcm)); + memzero(psi, sizeof(psi)); + memzero(&gd, sizeof(gd)); + memzero(&q, sizeof(q)); + memzero(&r, sizeof(r)); + return ok; +} + +bool zcash_orchard_compute_cmx_with_progress( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32], + ZcashOrchardProgressCallback progress, void* progress_context) { + return zcash_orchard_family_compute_cmx_with_progress( + receiver, value, rho, rseed, cmx_out, false, progress, progress_context); +} + +bool zcash_orchard_compute_cmx( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32]) { + return zcash_orchard_compute_cmx_with_progress(receiver, value, rho, rseed, + cmx_out, NULL, NULL); +} + +bool zcash_ironwood_compute_cmx_with_progress( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32], + ZcashOrchardProgressCallback progress, void* progress_context) { + return zcash_orchard_family_compute_cmx_with_progress( + receiver, value, rho, rseed, cmx_out, true, progress, progress_context); +} + +bool zcash_ironwood_compute_cmx( + const uint8_t receiver[ZCASH_ORCHARD_RAW_RECEIVER_SIZE], uint64_t value, + const uint8_t rho[32], const uint8_t rseed[32], uint8_t cmx_out[32]) { + return zcash_ironwood_compute_cmx_with_progress(receiver, value, rho, rseed, + cmx_out, NULL, NULL); +} + +bool zcash_derive_orchard_keys_with_progress( + const uint8_t* seed, uint32_t seed_len, uint32_t account, + ZcashOrchardKeys* keys, ZcashOrchardProgressCallback progress, + void* progress_context) { + uint8_t I[64]; + uint8_t sk[32], chain_code[32]; + + /* Step 1: Master key from seed + * I = BLAKE2b-512("ZcashIP32Orchard", seed) */ + zip32_orchard_master(seed, seed_len, I); + memcpy(sk, I, 32); + memcpy(chain_code, I + 32, 32); + + /* Step 2: Derive path m_orchard / 32' / 133' / account' + * + * CKDOrchard child derivation (ZIP-32 hardened-only): + * I = PRF^expand(chain_code, [0x81] || sk || I2LEOSP32(index)) + * + * PRF^expand(sk, t) = BLAKE2b-512("Zcash_ExpandSeed", sk || t) + * + * So: I = BLAKE2b-512("Zcash_ExpandSeed", + * chain_code || 0x81 || sk || index_le) + */ + const uint32_t path[3] = { + 32 | ZIP32_HARDENED, /* Purpose (Orchard) */ + 133 | ZIP32_HARDENED, /* Coin type (Zcash) */ + account | ZIP32_HARDENED /* Account */ + }; + + for (int i = 0; i < 3; i++) { + /* Build PRF^expand input: [0x81] || sk || I2LEOSP32(index) */ + uint8_t child_input[1 + 32 + 4]; + child_input[0] = 0x81; /* ORCHARD_ZIP32_CHILD domain separator */ + memcpy(child_input + 1, sk, 32); + /* Little-endian index (I2LEOSP32) */ + uint32_t idx = path[i]; + child_input[33] = idx & 0xff; + child_input[34] = (idx >> 8) & 0xff; + child_input[35] = (idx >> 16) & 0xff; + child_input[36] = (idx >> 24) & 0xff; + + /* PRF^expand(chain_code, child_input) */ + prf_expand(chain_code, child_input, sizeof(child_input), I); + memcpy(sk, I, 32); + memcpy(chain_code, I + 32, 32); + + memzero(child_input, sizeof(child_input)); + } + + /* Step 3: Derive subkeys from final spending key */ + memcpy(keys->sk, sk, 32); + + uint8_t expanded[64]; + + /* ask = ToScalar(PRF^expand(sk, [0x06])) */ + uint8_t t_ask = 0x06; + prf_expand(sk, &t_ask, 1, expanded); + to_scalar(expanded, keys->ask); + uint8_t ak_bytes[32]; + + /* + * Zcash spec (§ 4.2.3): If [ask]*G_spendauth has odd y (ỹ = 1), + * negate ask so that the resulting ak always has ỹ = 0. + * This matches the orchard crate's SpendAuthorizingKey::from() behavior. + */ + { + bignum256 ask_test; + bn_read_le(keys->ask, &ask_test); + curve_point ak_test; + redpallas_scalar_mult_spendauth_G_progress(&ask_test, &ak_test, progress, + progress_context); + bignum256 ak_x; + bn_copy(&ak_test.x, &ak_x); + bn_write_le(&ak_x, ak_bytes); + if (bn_is_odd(&ak_test.y)) { + /* ask = order - ask (negate mod q) */ + bignum256 ask_val, neg_ask; + bn_read_le(keys->ask, &ask_val); + bn_subtract(&pallas_order, &ask_val, &neg_ask); + bn_write_le(&neg_ask, keys->ask); + memzero(&neg_ask, sizeof(neg_ask)); + memzero(&ask_val, sizeof(ask_val)); + } + memzero(&ask_test, sizeof(ask_test)); + memzero(&ak_test, sizeof(ak_test)); + memzero(&ak_x, sizeof(ak_x)); + } + /* Cache the public key produced by the normalization multiplication. This + * avoids repeating the same expensive secret-scalar operation for FVK + * export and lets signing derive rk from public ak + public alpha. */ + memcpy(keys->ak, ak_bytes, sizeof(keys->ak)); + + /* nk = ToBase(PRF^expand(sk, [0x07])) */ + uint8_t t_nk = 0x07; + prf_expand(sk, &t_nk, 1, expanded); + to_base(expanded, keys->nk); + + /* rivk = ToScalar(PRF^expand(sk, [0x08])) */ + uint8_t t_rivk = 0x08; + prf_expand(sk, &t_rivk, 1, expanded); + to_scalar(expanded, keys->rivk); + + /* + * dk = truncate_32(PRF^expand(rivk, [0x82] || I2LEOSP_256(ak) + * || I2LEOSP_256(nk))) + */ + uint8_t dk_input[1 + 32 + 32]; + dk_input[0] = 0x82; + memcpy(dk_input + 1, ak_bytes, 32); + memcpy(dk_input + 33, keys->nk, 32); + prf_expand(keys->rivk, dk_input, sizeof(dk_input), expanded); + memcpy(keys->dk, expanded, 32); + + /* Clean up */ + memzero(I, sizeof(I)); + memzero(sk, sizeof(sk)); + memzero(chain_code, sizeof(chain_code)); + memzero(expanded, sizeof(expanded)); + memzero(ak_bytes, sizeof(ak_bytes)); + memzero(dk_input, sizeof(dk_input)); + + return true; +} + +bool zcash_derive_orchard_keys(const uint8_t* seed, uint32_t seed_len, + uint32_t account, ZcashOrchardKeys* keys) { + return zcash_derive_orchard_keys_with_progress(seed, seed_len, account, keys, + NULL, NULL); +} + +static bool zcash_compute_shielded_sighash_inner( + const uint8_t header_digest[32], const uint8_t transparent_digest[32], + const uint8_t sapling_digest[32], const uint8_t orchard_digest[32], + const uint8_t* ironwood_digest, uint32_t branch_id, + uint8_t sighash_out[32]) { + if (!header_digest || !transparent_digest || !sapling_digest || + !orchard_digest || !sighash_out) { + return false; + } + Hasher h; + uint8_t personal[16]; + + memcpy(personal, "ZcashTxHash_", 12); + memcpy(personal + 12, &branch_id, 4); + + hasher_InitParam(&h, HASHER_BLAKE2B_PERSONAL, personal, 16); + hasher_Update(&h, header_digest, 32); + hasher_Update(&h, transparent_digest, 32); + hasher_Update(&h, sapling_digest, 32); + hasher_Update(&h, orchard_digest, 32); + if (ironwood_digest) hasher_Update(&h, ironwood_digest, 32); + hasher_Final(&h, sighash_out); + memzero(personal, sizeof(personal)); + return true; +} + +bool zcash_compute_shielded_sighash(const uint8_t header_digest[32], + const uint8_t transparent_digest[32], + const uint8_t sapling_digest[32], + const uint8_t orchard_digest[32], + uint32_t branch_id, + uint8_t sighash_out[32]) { + return zcash_compute_shielded_sighash_inner(header_digest, transparent_digest, + sapling_digest, orchard_digest, + NULL, branch_id, sighash_out); +} + +bool zcash_compute_v6_shielded_sighash(const uint8_t header_digest[32], + const uint8_t transparent_digest[32], + const uint8_t sapling_digest[32], + const uint8_t orchard_digest[32], + const uint8_t ironwood_digest[32], + uint32_t branch_id, + uint8_t sighash_out[32]) { + if (!ironwood_digest) return false; + return zcash_compute_shielded_sighash_inner( + header_digest, transparent_digest, sapling_digest, orchard_digest, + ironwood_digest, branch_id, sighash_out); +} + +static void zcash_write_u32_le(uint32_t value, uint8_t out[4]) { + out[0] = (uint8_t)(value & 0xff); + out[1] = (uint8_t)((value >> 8) & 0xff); + out[2] = (uint8_t)((value >> 16) & 0xff); + out[3] = (uint8_t)((value >> 24) & 0xff); +} + +static void zcash_write_u64_le(uint64_t value, uint8_t out[8]) { + for (size_t i = 0; i < 8; i++) { + out[i] = (uint8_t)((value >> (8 * i)) & 0xff); + } +} + +static size_t zcash_write_compact_size(size_t value, uint8_t out[9]) { + if (value < 253) { + out[0] = (uint8_t)value; + return 1; + } + + if (value <= 0xffff) { + out[0] = 0xfd; + out[1] = (uint8_t)(value & 0xff); + out[2] = (uint8_t)((value >> 8) & 0xff); + return 3; + } + + if (value <= 0xffffffff) { + out[0] = 0xfe; + out[1] = (uint8_t)(value & 0xff); + out[2] = (uint8_t)((value >> 8) & 0xff); + out[3] = (uint8_t)((value >> 16) & 0xff); + out[4] = (uint8_t)((value >> 24) & 0xff); + return 5; + } + + out[0] = 0xff; + uint64_t v = (uint64_t)value; + for (size_t i = 0; i < 8; i++) { + out[i + 1] = (uint8_t)((v >> (8 * i)) & 0xff); + } + return 9; +} + +static void zcash_blake2b_personal_256(const char personal[16], + const uint8_t* data, size_t data_len, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 32, personal, 16); + if (data_len > 0) { + blake2b_Update(&ctx, data, data_len); + } + blake2b_Final(&ctx, digest_out, 32); +} + +bool zcash_compute_header_digest(uint32_t version, uint32_t version_group_id, + uint32_t branch_id, uint32_t lock_time, + uint32_t expiry_height, + uint8_t digest_out[32]) { + if (!digest_out) return false; + + uint8_t header[20]; + zcash_write_u32_le(version | 0x80000000u, header); + zcash_write_u32_le(version_group_id, header + 4); + zcash_write_u32_le(branch_id, header + 8); + zcash_write_u32_le(lock_time, header + 12); + zcash_write_u32_le(expiry_height, header + 16); + + zcash_blake2b_personal_256("ZTxIdHeadersHash", header, sizeof(header), + digest_out); + memzero(header, sizeof(header)); + return true; +} + +static bool zcash_validate_transparent_digest_info( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs) { + if (n_inputs > 0 && !inputs) return false; + if (n_outputs > 0 && !outputs) return false; + + for (size_t i = 0; i < n_inputs; i++) { + if (!inputs[i].prevout_txid || + (inputs[i].script_pubkey_size > 0 && !inputs[i].script_pubkey)) { + return false; + } + } + + for (size_t i = 0; i < n_outputs; i++) { + if (outputs[i].script_pubkey_size > 0 && !outputs[i].script_pubkey) { + return false; + } + } + + return true; +} + +static void zcash_hash_transparent_prevouts( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + uint8_t le[4]; + blake2b_InitPersonal(&ctx, 32, "ZTxIdPrevoutHash", 16); + for (size_t i = 0; i < n_inputs; i++) { + blake2b_Update(&ctx, inputs[i].prevout_txid, 32); + zcash_write_u32_le(inputs[i].prevout_index, le); + blake2b_Update(&ctx, le, sizeof(le)); + } + blake2b_Final(&ctx, digest_out, 32); + memzero(le, sizeof(le)); +} + +static void zcash_hash_transparent_sequences( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + uint8_t le[4]; + blake2b_InitPersonal(&ctx, 32, "ZTxIdSequencHash", 16); + for (size_t i = 0; i < n_inputs; i++) { + zcash_write_u32_le(inputs[i].sequence, le); + blake2b_Update(&ctx, le, sizeof(le)); + } + blake2b_Final(&ctx, digest_out, 32); + memzero(le, sizeof(le)); +} + +static void zcash_hash_transparent_amounts( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + uint8_t le[8]; + blake2b_InitPersonal(&ctx, 32, "ZTxTrAmountsHash", 16); + for (size_t i = 0; i < n_inputs; i++) { + zcash_write_u64_le(inputs[i].value, le); + blake2b_Update(&ctx, le, sizeof(le)); + } + blake2b_Final(&ctx, digest_out, 32); + memzero(le, sizeof(le)); +} + +static void zcash_hash_transparent_scripts( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + uint8_t compact_size[9]; + blake2b_InitPersonal(&ctx, 32, "ZTxTrScriptsHash", 16); + for (size_t i = 0; i < n_inputs; i++) { + size_t compact_size_len = + zcash_write_compact_size(inputs[i].script_pubkey_size, compact_size); + blake2b_Update(&ctx, compact_size, compact_size_len); + if (inputs[i].script_pubkey_size > 0) { + blake2b_Update(&ctx, inputs[i].script_pubkey, + inputs[i].script_pubkey_size); + } + } + blake2b_Final(&ctx, digest_out, 32); + memzero(compact_size, sizeof(compact_size)); +} + +static void zcash_hash_transparent_outputs( + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint8_t digest_out[32]) { + BLAKE2B_CTX ctx; + uint8_t le[8]; + uint8_t compact_size[9]; + blake2b_InitPersonal(&ctx, 32, "ZTxIdOutputsHash", 16); + for (size_t i = 0; i < n_outputs; i++) { + zcash_write_u64_le(outputs[i].value, le); + blake2b_Update(&ctx, le, sizeof(le)); + size_t compact_size_len = + zcash_write_compact_size(outputs[i].script_pubkey_size, compact_size); + blake2b_Update(&ctx, compact_size, compact_size_len); + if (outputs[i].script_pubkey_size > 0) { + blake2b_Update(&ctx, outputs[i].script_pubkey, + outputs[i].script_pubkey_size); + } + } + blake2b_Final(&ctx, digest_out, 32); + memzero(le, sizeof(le)); + memzero(compact_size, sizeof(compact_size)); +} + +static bool zcash_hash_transparent_input( + const ZcashTransparentInputDigestInfo* input, uint8_t digest_out[32]) { + if (!input) return false; + + BLAKE2B_CTX ctx; + uint8_t le4[4]; + uint8_t le8[8]; + uint8_t compact_size[9]; + blake2b_InitPersonal(&ctx, 32, "Zcash___TxInHash", 16); + blake2b_Update(&ctx, input->prevout_txid, 32); + zcash_write_u32_le(input->prevout_index, le4); + blake2b_Update(&ctx, le4, sizeof(le4)); + zcash_write_u64_le(input->value, le8); + blake2b_Update(&ctx, le8, sizeof(le8)); + size_t compact_size_len = + zcash_write_compact_size(input->script_pubkey_size, compact_size); + blake2b_Update(&ctx, compact_size, compact_size_len); + if (input->script_pubkey_size > 0) { + blake2b_Update(&ctx, input->script_pubkey, input->script_pubkey_size); + } + zcash_write_u32_le(input->sequence, le4); + blake2b_Update(&ctx, le4, sizeof(le4)); + blake2b_Final(&ctx, digest_out, 32); + memzero(le4, sizeof(le4)); + memzero(le8, sizeof(le8)); + memzero(compact_size, sizeof(compact_size)); + return true; +} + +bool zcash_compute_transparent_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint8_t digest_out[32]) { + if (!digest_out || !zcash_validate_transparent_digest_info( + inputs, n_inputs, outputs, n_outputs)) { + return false; + } + + if (n_inputs == 0 && n_outputs == 0) { + zcash_blake2b_personal_256("ZTxIdTranspaHash", NULL, 0, digest_out); + return true; + } + + uint8_t prevouts_digest[32], sequence_digest[32], outputs_digest[32]; + zcash_hash_transparent_prevouts(inputs, n_inputs, prevouts_digest); + zcash_hash_transparent_sequences(inputs, n_inputs, sequence_digest); + zcash_hash_transparent_outputs(outputs, n_outputs, outputs_digest); + + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 32, "ZTxIdTranspaHash", 16); + blake2b_Update(&ctx, prevouts_digest, 32); + blake2b_Update(&ctx, sequence_digest, 32); + blake2b_Update(&ctx, outputs_digest, 32); + blake2b_Final(&ctx, digest_out, 32); + + memzero(prevouts_digest, sizeof(prevouts_digest)); + memzero(sequence_digest, sizeof(sequence_digest)); + memzero(outputs_digest, sizeof(outputs_digest)); + return true; +} + +/* ZIP-244 §4.9 / §4.10b: transparent_sig_digest for Orchard spend + * authorization. + * + * When n_inputs > 0, the Orchard sighash uses the S.2 form: + * BLAKE2b("ZTxIdTranspaHash", + * hash_type(0x01) || prevouts || amounts || scripts || sequences || + * outputs || empty_txin_digest) + * where empty_txin_digest = BLAKE2b("Zcash___TxInHash", ""). + * + * When n_inputs == 0 (deshield / private-send), falls back to T.1 form + * (no hash_type, amounts, scripts, or txin digest) — same as txid form. + * + * This differs from zcash_compute_transparent_sighash_digest which uses a + * per-input txin_sig_digest for transparent ECDSA signatures. + */ +bool zcash_compute_orchard_transparent_sig_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint8_t digest_out[32]) { + if (!digest_out || !zcash_validate_transparent_digest_info( + inputs, n_inputs, outputs, n_outputs)) { + return false; + } + + /* Empty-vin case (deshield, private): T.1 form is correct per §4.10b. */ + if (n_inputs == 0) { + return zcash_compute_transparent_digest(inputs, n_inputs, outputs, + n_outputs, digest_out); + } + + /* Non-empty vin (shield): S.2 form with empty txin_sig_digest. */ + const uint8_t sighash_type = 0x01; /* SIGHASH_ALL */ + uint8_t prevouts_digest[32], amounts_digest[32], scripts_digest[32]; + uint8_t sequence_digest[32], outputs_digest[32], empty_txin_digest[32]; + + zcash_hash_transparent_prevouts(inputs, n_inputs, prevouts_digest); + zcash_hash_transparent_amounts(inputs, n_inputs, amounts_digest); + zcash_hash_transparent_scripts(inputs, n_inputs, scripts_digest); + zcash_hash_transparent_sequences(inputs, n_inputs, sequence_digest); + zcash_hash_transparent_outputs(outputs, n_outputs, outputs_digest); + + /* Empty txin_sig_digest: BLAKE2b("Zcash___TxInHash", "") */ + zcash_blake2b_personal_256("Zcash___TxInHash", NULL, 0, empty_txin_digest); + + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 32, "ZTxIdTranspaHash", 16); + blake2b_Update(&ctx, &sighash_type, 1); + blake2b_Update(&ctx, prevouts_digest, 32); + blake2b_Update(&ctx, amounts_digest, 32); + blake2b_Update(&ctx, scripts_digest, 32); + blake2b_Update(&ctx, sequence_digest, 32); + blake2b_Update(&ctx, outputs_digest, 32); + blake2b_Update(&ctx, empty_txin_digest, 32); + blake2b_Final(&ctx, digest_out, 32); + + memzero(prevouts_digest, sizeof(prevouts_digest)); + memzero(amounts_digest, sizeof(amounts_digest)); + memzero(scripts_digest, sizeof(scripts_digest)); + memzero(sequence_digest, sizeof(sequence_digest)); + memzero(outputs_digest, sizeof(outputs_digest)); + memzero(empty_txin_digest, sizeof(empty_txin_digest)); + return true; +} + +bool zcash_compute_transparent_sighash_digest( + const ZcashTransparentInputDigestInfo* inputs, size_t n_inputs, + const ZcashTransparentOutputDigestInfo* outputs, size_t n_outputs, + uint32_t signable_input_index, uint8_t sighash_type, + uint8_t digest_out[32]) { + if (!digest_out || !zcash_validate_transparent_digest_info( + inputs, n_inputs, outputs, n_outputs)) { + return false; + } + + if (sighash_type != 0x01 || signable_input_index >= n_inputs) { + return false; + } + + uint8_t prevouts_digest[32], amounts_digest[32], scripts_digest[32]; + uint8_t sequence_digest[32], outputs_digest[32], txin_sig_digest[32]; + zcash_hash_transparent_prevouts(inputs, n_inputs, prevouts_digest); + zcash_hash_transparent_amounts(inputs, n_inputs, amounts_digest); + zcash_hash_transparent_scripts(inputs, n_inputs, scripts_digest); + zcash_hash_transparent_sequences(inputs, n_inputs, sequence_digest); + zcash_hash_transparent_outputs(outputs, n_outputs, outputs_digest); + + zcash_hash_transparent_input(&inputs[signable_input_index], txin_sig_digest); + + BLAKE2B_CTX ctx; + blake2b_InitPersonal(&ctx, 32, "ZTxIdTranspaHash", 16); + blake2b_Update(&ctx, &sighash_type, 1); + blake2b_Update(&ctx, prevouts_digest, 32); + blake2b_Update(&ctx, amounts_digest, 32); + blake2b_Update(&ctx, scripts_digest, 32); + blake2b_Update(&ctx, sequence_digest, 32); + blake2b_Update(&ctx, outputs_digest, 32); + blake2b_Update(&ctx, txin_sig_digest, 32); + blake2b_Final(&ctx, digest_out, 32); + + memzero(prevouts_digest, sizeof(prevouts_digest)); + memzero(amounts_digest, sizeof(amounts_digest)); + memzero(scripts_digest, sizeof(scripts_digest)); + memzero(sequence_digest, sizeof(sequence_digest)); + memzero(outputs_digest, sizeof(outputs_digest)); + memzero(txin_sig_digest, sizeof(txin_sig_digest)); + return true; +} + +ZcashPCZTSigningRequestStatus zcash_pczt_signing_request_status( + const ZcashPCZTSigningRequestMeta* meta) { + if (!meta || !meta->has_header_digest || !meta->has_orchard_digest) { + return ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS; + } + + if (meta->header_digest_size != 32 || meta->orchard_digest_size != 32) { + return ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE; + } + + if (meta->is_ironwood && + (!meta->has_ironwood_digest || meta->ironwood_digest_size != 32)) { + return ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS; + } + + if (meta->has_transparent_digest && meta->transparent_digest_size != 32) { + return ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE; + } + + (void)meta->sapling_digest_size; + if (meta->has_sapling_digest) { + return ZCASH_PCZT_SIGNING_REQUEST_UNSUPPORTED_SAPLING_COMPONENT; + } + + if (!meta->has_header_fields) { + return ZCASH_PCZT_SIGNING_REQUEST_MISSING_HEADER_FIELDS; + } + + if ((meta->n_transparent_inputs > 0 || meta->n_transparent_outputs > 0) && + (!meta->has_transparent_digest || meta->transparent_digest_size != 32)) { + return ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST; + } + + if (!meta->has_orchard_flags || meta->orchard_flags > 0xff || + !meta->has_orchard_value_balance || !meta->has_orchard_anchor || + meta->orchard_anchor_size != 32) { + return ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA; + } + + return ZCASH_PCZT_SIGNING_REQUEST_OK; +} + +bool zcash_pczt_signing_request_is_clear( + const ZcashPCZTSigningRequestMeta* meta) { + return zcash_pczt_signing_request_status(meta) == + ZCASH_PCZT_SIGNING_REQUEST_OK; +} + +/* + * ZIP-32 §6.1 seed fingerprint: + * + * SeedFingerprint := BLAKE2b-256( + * "Zcash_HD_Seed_FP", I2LEBSP_8(len(seed)) || seed) + * + * The 1-byte length prefix domain-separates seeds of different lengths that + * happen to share a prefix. + * + * Trivial seeds (all-zero, all-0xFF) and seeds outside [32, 252] bytes are + * rejected — these are nominally seeds but provide no security and are + * almost certainly bugs in the caller. + */ +bool zcash_seed_fingerprint_request_valid(bool present, size_t size) { + return !present || size == 32; +} + +bool zcash_calculate_seed_fingerprint(const uint8_t* seed, uint32_t seed_len, + uint8_t fingerprint_out[32]) { + if (!seed || !fingerprint_out) return false; + if (seed_len < 32 || seed_len > 252) return false; + + bool all_zero = true; + bool all_ff = true; + for (uint32_t i = 0; i < seed_len; i++) { + if (seed[i] != 0x00) all_zero = false; + if (seed[i] != 0xFF) all_ff = false; + if (!all_zero && !all_ff) break; + } + if (all_zero || all_ff) return false; + + BLAKE2B_CTX ctx; + if (blake2b_InitPersonal(&ctx, 32, "Zcash_HD_Seed_FP", 16) != 0) { + return false; + } + uint8_t len_byte = (uint8_t)seed_len; + blake2b_Update(&ctx, &len_byte, 1); + blake2b_Update(&ctx, seed, seed_len); + if (blake2b_Final(&ctx, fingerprint_out, 32) != 0) { + memzero(&ctx, sizeof(ctx)); + return false; + } + + memzero(&ctx, sizeof(ctx)); + return true; +} diff --git a/lib/rand/rng.c b/lib/rand/rng.c index 4975e5a48..0167050e5 100644 --- a/lib/rand/rng.c +++ b/lib/rand/rng.c @@ -64,9 +64,9 @@ static volatile bool rng_seed_error_seen = false; bool rng_seed_error_latched(void) { return rng_seed_error_seen; } +#ifdef EMULATOR static void rng_latch_seed_error(void) { rng_seed_error_seen = true; } -#ifdef EMULATOR void rng_test_power_on_reset(void) { rng_seed_error_seen = false; } void rng_test_observe_transient_error(void) { rng_latch_seed_error(); } void rng_test_observe_persistent_error(void) { @@ -75,6 +75,17 @@ void rng_test_observe_persistent_error(void) { } #endif +bool rng_persistent_error_step(uint32_t* samples) { + if (samples == NULL) return false; + if (++(*samples) < 100) return false; + + /* reset_rng() clears SEIS/CEIS. Preserve the fault before the caller takes + * that recovery action, exactly as the transient-error branch does. */ + rng_seed_error_seen = true; + *samples = 0; + return true; +} + void reset_rng(void) { #ifndef EMULATOR /* disable RNG */ @@ -114,18 +125,14 @@ uint32_t random32(void) { /* Reset RNG interrupt status bits (SECS, CECS errors no longer * exist). Record it FIRST: clearing the hardware latch is exactly * what makes this fault invisible to a later self-test. */ - rng_latch_seed_error(); + rng_seed_error_seen = true; RNG_SR &= ~(RNG_SR_SEIS | RNG_SR_CEIS); } else { /* RNG is not ready. Allow few more samples for RNG to come back alive * before resetting */ - if (++rng_samples >= 100) { - /* Resetting clears SEIS/CEIS, so preserve the evidence first. The - * software latch is boot-lifetime and reset_rng() must never clear it. - */ - rng_latch_seed_error(); + if (rng_persistent_error_step(&rng_samples)) { + /* RNG in hang state. Reset RNG */ reset_rng(); - rng_samples = 0; } } } 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/build/docker/device/release.sh b/scripts/build/docker/device/release.sh index 3689a1d95..86ce7dc1d 100755 --- a/scripts/build/docker/device/release.sh +++ b/scripts/build/docker/device/release.sh @@ -7,13 +7,18 @@ IMAGETAG=kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235 docker image inspect $IMAGETAG > /dev/null || docker pull $IMAGETAG +# Extra cmake flags pass straight through. The only alternate release product +# is bitcoin-only: ./release.sh -DKK_BITCOIN_ONLY=ON +EXTRA_CMAKE_FLAGS="$*" + docker run -t \ -v $(pwd):/root/keepkey-firmware:z \ $IMAGETAG /bin/sh -c "\ mkdir /root/build && cd /root/build && \ cmake -C /root/keepkey-firmware/cmake/caches/device.cmake /root/keepkey-firmware \ -DCMAKE_BUILD_TYPE=MinSizeRel \ - -DCMAKE_COLOR_MAKEFILE=ON &&\ + -DCMAKE_COLOR_MAKEFILE=ON \ + ${EXTRA_CMAKE_FLAGS} &&\ make && \ mkdir -p /root/keepkey-firmware/bin && \ cp -r /root/build /root/keepkey-firmware/bin/ && \ diff --git a/scripts/emulator/Dockerfile b/scripts/emulator/Dockerfile index d54c5d299..f23332d6d 100644 --- a/scripts/emulator/Dockerfile +++ b/scripts/emulator/Dockerfile @@ -17,7 +17,6 @@ RUN cmake -C ./cmake/caches/emulator.cmake . \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_C_COMPILER=clang \ -DCMAKE_CXX_COMPILER=clang++ \ - -DCMAKE_BUILD_TYPE=Debug \ ${coinsupport} \ -DCMAKE_COLOR_MAKEFILE=ON diff --git a/scripts/emulator/capture-clearsign-attestor.py b/scripts/emulator/capture-clearsign-attestor.py new file mode 100644 index 000000000..2d5f3dfc3 --- /dev/null +++ b/scripts/emulator/capture-clearsign-attestor.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Capture the maximum-boundary ClearSign attestor screens from kkemu. + +This is an emulator evidence tool, not a hardware provisioning tool. It wipes +and initializes the emulator connected at the supplied UDP endpoints. +""" + +import argparse +import importlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + + +# The pinned python-keepkey uses legacy generated descriptors, while the +# current device protocol is generated on demand below. Both compatibility +# switches must be set before either protobuf module is imported. +os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") +os.environ.setdefault("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK", "true") + +ROOT = Path(__file__).resolve().parents[2] +PYTHON_KEEPKEY = ROOT / "deps" / "python-keepkey" +DEVICE_PROTOCOL = ROOT / "deps" / "device-protocol" +ZOO_SCRIPTS = ROOT / "scripts" / "zoo" + +PROGRAM_ID = "99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2" +PROGRAM_BYTES = bytes.fromhex( + "792689378ecd51d80406eb0caa3b62795beb10b6c5dc96bc2e0df03cbfee1abf" +) +DISCRIMINATOR = bytes.fromhex("0d9e0ddf5fd51c06") +PROGRAM_NAME = "Boundary Program 123" +INSTRUCTION_NAME = "Review All Types 123" +ARGUMENTS = ( + (1, "u64 LE", "Amount1234567890"), + (2, "u8", "Flag123456789012"), + (3, "public key", "RecipientPubKey1"), + (4, "bytes32 hex", "OrderHash1234567"), +) +ACCOUNT_INDEX = 7 +ACCOUNT_LABEL = "VaultAccount1234" +SCREEN_NAMES = ( + "01-schema-identity.png", + "02-program-id-44chars.png", + "03-discriminator-8bytes.png", + "04-arg-u64-le-16char-label.png", + "05-arg-u8-16char-label.png", + "06-arg-public-key-16char-label.png", + "07-arg-bytes32-hex-16char-label.png", + "08-account-16char-label.png", +) + + +def generate_current_protocol(): + generated = tempfile.TemporaryDirectory(prefix="kk-attestor-proto-") + subprocess.run( + [ + "protoc", + "-I", + str(DEVICE_PROTOCOL), + "--python_out=" + generated.name, + str(DEVICE_PROTOCOL / "types.proto"), + str(DEVICE_PROTOCOL / "messages.proto"), + ], + check=True, + ) + sys.path.insert(0, generated.name) + module = importlib.import_module("messages_pb2") + return generated, module + + +def length_prefixed_text(value): + raw = value.encode("ascii") + if not 1 <= len(raw) <= 255: + raise ValueError("schema text length out of bounds") + return bytes([len(raw)]) + raw + + +def boundary_schema(): + payload = bytearray(b"KKSOLSC1") + payload.append(1) + payload.extend(PROGRAM_BYTES) + payload.append(len(DISCRIMINATOR)) + payload.extend(DISCRIMINATOR) + if len(PROGRAM_NAME) != 20 or len(INSTRUCTION_NAME) != 20: + raise ValueError( + "boundary program and instruction names must be 20 characters" + ) + payload.extend(length_prefixed_text(PROGRAM_NAME)) + payload.extend(length_prefixed_text(INSTRUCTION_NAME)) + payload.append(len(ARGUMENTS)) + for arg_type, _display_type, label in ARGUMENTS: + if len(label) != 16: + raise ValueError("boundary argument labels must be 16 characters") + payload.append(arg_type) + payload.extend(length_prefixed_text(label)) + if len(ACCOUNT_LABEL) != 16: + raise ValueError("boundary account label must be 16 characters") + payload.append(1) + payload.append(ACCOUNT_INDEX) + payload.extend(length_prefixed_text(ACCOUNT_LABEL)) + return bytes(payload) + + +def git_revision(path): + return subprocess.check_output( + ["git", "-C", str(path), "rev-parse", "HEAD"], text=True + ).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, help="directory for PNG evidence") + parser.add_argument( + "--main", + default=os.environ.get("KK_TRANSPORT_MAIN", "127.0.0.1:11044"), + help="kkemu main UDP endpoint", + ) + parser.add_argument( + "--debug", + default=os.environ.get("KK_TRANSPORT_DEBUG", "127.0.0.1:11045"), + help="kkemu debug UDP endpoint", + ) + args = parser.parse_args() + + generated, attestor_proto = generate_current_protocol() + try: + sys.path.insert(0, str(PYTHON_KEEPKEY)) + sys.path.insert(0, str(ZOO_SCRIPTS)) + + from keepkeylib import mapping + from keepkeylib import messages_pb2 as proto + from keepkeylib.client import KeepKeyDebuglinkClient + from keepkeylib.transport_udp import UDPTransport + from screenshot import capture_screenshot + + # The deliberately older pinned host package has no attestor wrappers. + # Register only the new request/response wire classes; framing, + # confirmations, and DebugLink remain the pinned host implementation. + mapping.map_class_to_type[attestor_proto.ClearsignAttestorSign] = 1702 + mapping.map_type_to_class[1703] = attestor_proto.ClearsignAttestorSignature + + output = Path(args.output).resolve() + output.mkdir(parents=True, exist_ok=True) + client = KeepKeyDebuglinkClient(UDPTransport(args.main)) + client.set_debuglink(UDPTransport(args.debug)) + + client.auto_button = True + client.wipe_device() + client.load_device_by_mnemonic( + mnemonic=("all " * 11 + "all").strip(), + pin="", + passphrase_protection=False, + label="RC21 OLED Gate", + language="english", + ) + client.apply_policy("AdvancedMode", 1) + client.auto_button = False + + response = client.call_raw( + attestor_proto.ClearsignAttestorSign(payload=boundary_schema()) + ) + for index, name in enumerate(SCREEN_NAMES): + if not isinstance(response, proto.ButtonRequest): + raise RuntimeError( + "screen %d expected ButtonRequest, got %s" + % (index + 1, type(response).__name__) + ) + time.sleep(0.2) + path = output / name + if not capture_screenshot(client.debug, str(path), scale=3): + raise RuntimeError("failed to capture " + name) + print(path) + client.debug.press_yes() + response = client.call_raw(proto.ButtonAck()) + + if not isinstance(response, attestor_proto.ClearsignAttestorSignature): + raise RuntimeError( + "expected attestor signature, got " + type(response).__name__ + ) + if len(response.signature) != 64 or len(response.public_key) != 33: + raise RuntimeError("attestor returned malformed key or signature") + + manifest = { + "firmware_commit": git_revision(ROOT), + "device_protocol_commit": git_revision(DEVICE_PROTOCOL), + "program_name": PROGRAM_NAME, + "program_name_characters": len(PROGRAM_NAME), + "instruction_name": INSTRUCTION_NAME, + "instruction_name_characters": len(INSTRUCTION_NAME), + "program_id": PROGRAM_ID, + "program_id_characters": len(PROGRAM_ID), + "discriminator_hex": DISCRIMINATOR.hex(), + "discriminator_bytes": len(DISCRIMINATOR), + "arguments": [ + {"type": display_type, "label": label, "label_characters": len(label)} + for _arg_type, display_type, label in ARGUMENTS + ], + "account": { + "index": ACCOUNT_INDEX, + "label": ACCOUNT_LABEL, + "label_characters": len(ACCOUNT_LABEL), + }, + "screens": list(SCREEN_NAMES), + "attestation_signature_bytes": len(response.signature), + "attestation_public_key_bytes": len(response.public_key), + } + with (output / "manifest.json").open("w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + print("captured %d screens; attestation completed" % len(SCREEN_NAMES)) + finally: + generated.cleanup() + + +if __name__ == "__main__": + main() diff --git a/scripts/emulator/capture-thor-percent.py b/scripts/emulator/capture-thor-percent.py new file mode 100644 index 000000000..bac5d6b56 --- /dev/null +++ b/scripts/emulator/capture-thor-percent.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Capture the THOR/Maya LP-withdraw percent confirm screens from kkemu. + +Evidence tool for the integer-percent rendering change (no float formats). +""" + +import os +import sys +import time +from pathlib import Path + +os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") +os.environ.setdefault("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK", "true") + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "deps" / "python-keepkey")) +sys.path.insert(0, str(ROOT / "scripts" / "zoo")) + +from keepkeylib.client import KeepKeyDebuglinkClient +from keepkeylib.transport_udp import UDPTransport +from keepkeylib import messages_pb2 as proto +from keepkeylib.tools import parse_path + + +def dump_layout(debug_client, filename): + """Save the raw 2048-byte 1bpp OLED layout; converted to PNG on the host.""" + state = debug_client._call(proto.DebugLinkGetState()) + if not state.layout: + return False + with open(filename, "wb") as f: + f.write(state.layout) + return True + +OUT = Path(sys.argv[1]).resolve() +OUT.mkdir(parents=True, exist_ok=True) + +main_ep = os.environ.get("KK_TRANSPORT_MAIN", "127.0.0.1:11044") +debug_ep = os.environ.get("KK_TRANSPORT_DEBUG", "127.0.0.1:11045") + +client = KeepKeyDebuglinkClient(UDPTransport(main_ep)) +client.set_debuglink(UDPTransport(debug_ep)) + +client.auto_button = True +client.wipe_device() +client.load_device_by_mnemonic( + mnemonic=("all " * 11 + "all").strip(), + pin="", + passphrase_protection=False, + label="percent evidence", + language="english", +) + +counter = {"n": 0} +real_press_yes = client.debug.press_yes + + +def capturing_press_yes(): + counter["n"] += 1 + time.sleep(0.2) + path = OUT / ("thor-withdraw-%02d.layout" % counter["n"]) + dump_layout(client.debug, str(path)) + print(path) + real_press_yes() + + +client.debug.press_yes = capturing_press_yes + + +THOR_ROUTER = "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146" + + +def _build_deposit_calldata(memo): + selector = bytes.fromhex("1fece7b4") + vault = bytes(12) + bytes.fromhex(THOR_ROUTER) + asset = bytes(32) + amount = (500000000000000000).to_bytes(32, "big") + memo_offset = (4 * 32).to_bytes(32, "big") + memo_bytes = memo.encode("ascii") + memo_len = len(memo_bytes).to_bytes(32, "big") + pad = ((len(memo_bytes) + 31) // 32) * 32 + return selector + vault + asset + amount + memo_offset + memo_len + \ + memo_bytes + bytes(pad - len(memo_bytes)) + + +from binascii import unhexlify + +client.ethereum_sign_tx( + n=parse_path("m/44'/60'/0'/0/0"), + nonce=1, + gas_price=50000000000, + gas_limit=300000, + to=unhexlify(THOR_ROUTER), + value=500000000000000000, + chain_id=1, + data=_build_deposit_calldata( + "WITHDRAW:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:2505" + ), +) +print("captured %d screens" % counter["n"]) diff --git a/scripts/emulator/docker-compose.bitcoin-only.yml b/scripts/emulator/docker-compose.bitcoin-only.yml index c89281de3..db7dcddb0 100644 --- a/scripts/emulator/docker-compose.bitcoin-only.yml +++ b/scripts/emulator/docker-compose.bitcoin-only.yml @@ -9,3 +9,7 @@ services: build: args: coinsupport: "-DKK_BITCOIN_ONLY=ON" + + python-keepkey: + environment: + KK_FIRMWARE_VARIANT: bitcoin-only diff --git a/scripts/emulator/firmware-unit.sh b/scripts/emulator/firmware-unit.sh index 02c07b11e..71d291c9f 100755 --- a/scripts/emulator/firmware-unit.sh +++ b/scripts/emulator/firmware-unit.sh @@ -1,8 +1,24 @@ #!/bin/sh +# +# The container's exit status IS the gate: CI runs this through +# docker compose up --exit-code-from firmware-unit +# and uses that code directly (see .github/workflows/ci.yml, FW_RC). +# +# This script used to end with `cp`, so the exit status was the COPY's, not the +# test run's. A failing `make xunit` wrote its real code into the status file +# below -- which nothing reads -- and the container still exited 0, so the suite +# could not fail this job. Capture the status, always extract the reports (the +# evidence matters most when tests fail), then exit with the status. mkdir -p /kkemu/test-reports/firmware-unit + make xunit RC=$? + echo "$RC" > /kkemu/test-reports/firmware-unit/status -cp -r unittests/*.xml /kkemu/test-reports/firmware-unit + +# Best-effort: a missing XML must not mask the test result below. +cp -r unittests/*.xml /kkemu/test-reports/firmware-unit 2>/dev/null || \ + echo "WARN: no firmware-unit XML to copy" + exit "$RC" diff --git a/scripts/emulator/python-keepkey-tests.sh b/scripts/emulator/python-keepkey-tests.sh index f0eedf3e4..c328b41d5 100755 --- a/scripts/emulator/python-keepkey-tests.sh +++ b/scripts/emulator/python-keepkey-tests.sh @@ -1,10 +1,25 @@ #!/bin/sh set -e +# Bound every test individually. +# +# A protocol/UI mismatch deadlocks: the firmware blocks waiting for a ButtonAck +# the test never sends (this release added confirmation screens the pinned suite +# does not acknowledge), and the test blocks reading a response that never comes. +# Without a per-test bound that is a 30-minute JOB timeout with no JUnit XML, so +# Phase 2 never completes and every file after the stall is unmeasured -- the +# absence of a result is indistinguishable from a pass. +# +# method=signal rather than thread: thread kills the process, so one deadlock +# still costs the rest of the run. signal raises inside the blocked test, which +# then FAILS BY NAME and the suite continues. Measured on the known-deadlocking +# THORChain file: "1 failed, 5 passed in 20.29s" instead of hanging forever. +# +# 60s is roughly 30x the slowest healthy file in this suite (multisig, ~2s). +# See #466. +PYTEST_TIMEOUT_ARGS="--timeout=60 --timeout-method=signal" + mkdir -p /kkemu/test-reports/python-keepkey -# This volume can survive retries. Stale frames would make the new report look -# more complete than the exact run really was, so every capture starts empty. -rm -rf /kkemu/test-reports/screenshots mkdir -p /kkemu/test-reports/screenshots # Wait for emulator @@ -20,39 +35,188 @@ done cd deps/python-keepkey/tests +# The tests run from this directory, while keepkeylib lives one level up. +# Make that package root explicit so direct imports work consistently in the +# standalone container (including tests collected before common.py is loaded). +export PYTHONPATH="..${PYTHONPATH:+:$PYTHONPATH}" + +# Diagnostic: verify SCREENSHOT flag reaches Python +echo "=== Pre-flight diagnostic ===" +KEEPKEY_SCREENSHOT=1 python3 -c " +import os, sys +sys.path.insert(0, '..') +print('KEEPKEY_SCREENSHOT env:', os.environ.get('KEEPKEY_SCREENSHOT', 'NOT SET')) +from keepkeylib.client import SCREENSHOT +print('SCREENSHOT global:', SCREENSHOT) +# Check if _capture_oled has debug logging +import inspect +from keepkeylib.client import DebugLinkMixin +src = inspect.getsource(DebugLinkMixin._capture_oled) +has_debug = '[SCREENSHOT]' in src +print('_capture_oled has debug logging:', has_debug) +print('_capture_oled first 200 chars:', repr(src[:200])) +" 2>&1 +echo "=== End diagnostic ===" + +# Phase 1: Screenshot captures driven by report SECTIONS (single source of truth) +# +# Use exact module::method selectors rather than a pytest -k expression. The latter +# can accidentally select unrelated tests whose names share common terms, weakening +# the per-test screenshot audit and making collection behavior depend on test names. +echo "=== Phase 1: Report-driven screenshot capture ===" +# 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 -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) -fi -if [ -z "$FW_VERSION" ]; then - echo "FATAL: firmware version could not be determined" - exit 1 + 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" + # grep -oP is a GNU extension. This container's grep is BusyBox, which has + # no -P, so the old command ALWAYS failed and `|| echo "7.14.0"` silently + # supplied a wrong version. Everything downstream keys off this: SECTIONS + # entries are filtered by ver_ge(fw_version, min_fw), so on the 7.14.2 + # release branch every test gated to 7.14.1 or later was excluded from the + # screenshot filter AND from report validation. That is why the suites this + # release changed captured no screens. + # + # Use sed only, and FAIL rather than defaulting: a wrong version here is + # invisible and silently narrows what CI checks. + FW_VERSION=$(sed -n 's/^[[:space:]]*VERSION[[:space:]]\{1,\}\([0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}\).*/\1/p' /kkemu/CMakeLists.txt | head -1) + if [ -z "$FW_VERSION" ]; then + echo "FATAL: could not read VERSION from /kkemu/CMakeLists.txt." + echo "Refusing to guess -- a wrong FW_VERSION silently narrows the" + echo "screenshot filter and the SECTIONS validation." + echo "1" > /kkemu/test-reports/python-keepkey/status + exit 1 + fi + echo "Detected FW_VERSION=$FW_VERSION from CMakeLists.txt" fi export FW_VERSION - -echo "=== Report-required OLED capture ===" SCREENSHOT_TESTS=$(python3 ../scripts/generate-test-report.py \ - --screenshot-test-list --fw-version="$FW_VERSION") + --screenshot-test-list --fw-version="$FW_VERSION") if [ -z "$SCREENSHOT_TESTS" ]; then - echo "FATAL: screenshot test list is empty" - exit 1 + echo "FATAL: screenshot test list is empty" + echo "1" > /kkemu/test-reports/python-keepkey/status + exit 1 fi KEEPKEY_SCREENSHOT=1 \ SCREENSHOT_DIR=/kkemu/test-reports/screenshots \ KEEPKEY_SCREENSHOT_TESTS="$SCREENSHOT_TESTS" \ +KK_EXPECT_PERSIST_REJECTED=1 \ KK_TRANSPORT_MAIN=kkemu:11044 \ KK_TRANSPORT_DEBUG=kkemu:11045 \ pytest -v --tb=short \ - --junitxml=/kkemu/test-reports/python-keepkey/junit-screenshots.xml + $PYTEST_TIMEOUT_ARGS \ + --junitxml=/kkemu/test-reports/python-keepkey/junit-screenshots.xml \ + -s 2>&1 || true +# pytest exit code is NOT the gate — screenshot count below is. +# Tests for features not yet merged (gated by requires_firmware/requires_message) +# may fail or skip here; the real check is: did screenshots get captured? + +# Gate: fail fast if screenshots broken +echo "=== Screenshot results ===" +find /kkemu/test-reports/screenshots -name '*.png' -ls 2>/dev/null || echo "NO SCREENSHOTS" +SCREENSHOT_COUNT=$(find /kkemu/test-reports/screenshots -name '*.png' 2>/dev/null | wc -l) +echo "Total PNGs: $SCREENSHOT_COUNT" +if [ "$SCREENSHOT_COUNT" -eq 0 ]; then + echo "FATAL: KEEPKEY_SCREENSHOT=1 but 0 PNGs captured. Screenshot pipeline is broken." + echo "1" > /kkemu/test-reports/python-keepkey/status + exit 1 +fi +# A total count > 0 cannot distinguish "captured everything" from "captured +# something". On the 7.14.2 rc30 artifact this gate passed with 345 PNGs while +# EVERY suite the release changed captured zero -- the rendering evidence for a +# release about what reaches the screen did not exist, and nothing said so. +# Audit per test: any SECTIONS entry that DECLARED screens must have captured +# some. Skipped tests are excluded; a version-gated test cannot draw. +echo "=== Screenshot audit (per-test) ===" python3 ../scripts/generate-test-report.py \ - --screenshot-audit=/kkemu/test-reports/screenshots \ - --audit-junit=/kkemu/test-reports/python-keepkey/junit-screenshots.xml \ - --fw-version="$FW_VERSION" + --screenshot-audit /kkemu/test-reports/screenshots \ + --audit-junit /kkemu/test-reports/python-keepkey/junit-screenshots.xml \ + --fw-version=$FW_VERSION || { + echo "FATAL: tests declared screens they did not capture (see list above)." + echo "1" > /kkemu/test-reports/python-keepkey/status + exit 1 +} -echo "=== Full Python integration suite ===" +# Phase 2: Full test suite — SECTIONS is the source of truth. +# pytest may exit non-zero (some tests fail before gating kicks in), +# so we capture the JUnit XML regardless, then validate against SECTIONS. +# Tests that skip via requires_message/requires_firmware are OK. +# Tests that fail or are missing from JUnit = CI failure. +echo "=== Phase 2: Full test suite ===" +set +e +KK_EXPECT_PERSIST_REJECTED=1 \ +KK_EXPECT_ENTROPY_BUDGET=1 \ KK_TRANSPORT_MAIN=kkemu:11044 \ KK_TRANSPORT_DEBUG=kkemu:11045 \ -pytest -v --junitxml=/kkemu/test-reports/python-keepkey/junit.xml +pytest -v $PYTEST_TIMEOUT_ARGS --junitxml=/kkemu/test-reports/python-keepkey/junit.xml +PYTEST_RC=$? + +# Merge in the native firmware unit results before validating or rendering. +# The test-reports volume is shared rw with the firmware-unit container, which +# runs first, so its XMLs are already here. Validating against the Python JUnit +# alone made every catalog entry naming a native unit test resolve to "missing", +# which is why no native test could ever be catalogued and all 432 of them were +# invisible to the report. +# +# If the native XMLs are absent this falls back to Python-only, and any native +# catalog entry then fails as "missing" -- i.e. it still fails closed, it does +# not quietly pass. +echo "=== Phase 2: Merge JUnit evidence ===" +MERGED=/kkemu/test-reports/junit-merged.xml +python3 - <<'PY' +import glob, os, xml.etree.ElementTree as ET +files = sorted(glob.glob('/kkemu/test-reports/python-keepkey/junit*.xml')) +native = sorted(glob.glob('/kkemu/test-reports/firmware-unit/*.xml')) +root = ET.Element('testsuites') +for f in files + native: + try: + for suite in ET.parse(f).iter('testsuite'): + root.append(suite) + except ET.ParseError: + print("WARN: skipping malformed %s" % f) +ET.ElementTree(root).write('/kkemu/test-reports/junit-merged.xml', + xml_declaration=True, encoding='unicode') +print("Merged %d Python + %d native JUnit file(s)" % (len(files), len(native))) +if not native: + print("WARN: no firmware-unit XMLs found; native catalog entries will " + "report as missing") +PY +[ -s "$MERGED" ] || MERGED=/kkemu/test-reports/python-keepkey/junit.xml -echo "0" > /kkemu/test-reports/python-keepkey/status +echo "=== Phase 2: Validate report catalog ===" +python3 ../scripts/generate-test-report.py \ + --junit="$MERGED" \ + ${FW_VERSION:+--fw-version=$FW_VERSION} \ + --validate-junit +CATALOG_RC=$? + +echo "=== Phase 2: Generate test report ===" +python3 ../scripts/generate-test-report.py \ + --junit="$MERGED" \ + ${FW_VERSION:+--fw-version=$FW_VERSION} \ + --screenshots=/kkemu/test-reports/screenshots \ + --output=/kkemu/test-reports/test-report.pdf +REPORT_RC=$? +set -e + +if [ "$PYTEST_RC" -eq 0 ] && [ "$CATALOG_RC" -eq 0 ] && [ "$REPORT_RC" -eq 0 ]; then + echo "0" > /kkemu/test-reports/python-keepkey/status +else + echo "1" > /kkemu/test-reports/python-keepkey/status +fi +if [ "$PYTEST_RC" -ne 0 ]; then + echo "pytest failed with exit code $PYTEST_RC" + exit "$PYTEST_RC" +fi +if [ "$CATALOG_RC" -ne 0 ]; then + echo "report catalog validation failed with exit code $CATALOG_RC" + exit "$CATALOG_RC" +fi +if [ "$REPORT_RC" -ne 0 ]; then + echo "test report generation failed with exit code $REPORT_RC" + exit "$REPORT_RC" +fi diff --git a/scripts/emulator/python-keepkey.Dockerfile b/scripts/emulator/python-keepkey.Dockerfile index a39ed94d3..d8bbd8969 100644 --- a/scripts/emulator/python-keepkey.Dockerfile +++ b/scripts/emulator/python-keepkey.Dockerfile @@ -17,7 +17,11 @@ FROM ${BASE_IMAGE} AS deps # compiles a C extension at install time and needs Python.h + a C toolchain # linked against musl. Verified locally against the pinned image. RUN apk add --no-cache python3-dev gcc musl-dev -RUN python3 -m pip install --no-cache-dir rlp eth-keys eth-utils pycryptodome +# Per-test timeouts turn protocol/UI deadlocks into named failures and keep the +# rest of the release evidence measurable. python-keepkey-tests.sh passes the +# plugin's --timeout options in both phases, so omitting it makes pytest reject +# the entire invocation before collecting a single test. +RUN python3 -m pip install --no-cache-dir rlp eth-keys eth-utils pycryptodome pytest-timeout FROM deps diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index bbec1acfa..3072e3573 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -1,340 +1,200 @@ #!/usr/bin/env python3 -"""Build fail-closed, self-binding 7.14.2 presign evidence.""" +""" +CI trigger for test report generation. -import datetime +The actual report generator lives in deps/python-keepkey/scripts/generate-test-report.py +(stdlib-only PDF writer with SECTIONS as single source of truth for test catalog, +screenshot filter, and report layout). + +This script finds the JUnit XML + screenshots from CI artifacts and calls through. +""" +import os +import sys import glob import hashlib import json -import os -from pathlib import Path import subprocess -import sys -import xml.etree.ElementTree as ET - +from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] -REPORT_GENERATOR = ( - ROOT / "deps" / "python-keepkey" / "scripts" / - "generate-test-report.py" +REPORT_GENERATOR = os.path.join( + os.path.dirname(__file__), '..', 'deps', 'python-keepkey', 'scripts', 'generate-test-report.py' ) -REPORT_DIR = ROOT / "test-report" -REPORT_PDF = REPORT_DIR / "test-report.pdf" -MERGED_JUNIT = REPORT_DIR / "junit-merged.xml" - -REQUIRED_CASES = { - "Ethereum.TransferAmountUsesTheRequestsSigningChain", - "Osmosis.RequiredValuesRejectEmptyAndNonDecimalAmounts", - "test_msg_ethereum_signtx_xfer.TestMsgEthereumSigntx." - "test_transfer_review_uses_signing_chain_asset", - "test_msg_osmosis_validation.TestOsmosisValidation." - "test_present_but_empty_amount_is_rejected_before_review", - "test_msg_osmosis_validation.TestOsmosisValidation." - "test_ibc_omitted_amount_and_receiver_are_rejected_before_review", - "test_msg_recoverydevice_cipher.TestDeviceRecovery." - "test_unknown_word_count_failure_aborts_recovery", -} - - -def fail(message): - raise RuntimeError(message) def sha256_file(path): digest = hashlib.sha256() - with open(path, "rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): + with open(path, 'rb') as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b''): digest.update(chunk) return digest.hexdigest() -def git(*args): - return subprocess.check_output( - ["git"] + list(args), cwd=str(ROOT), text=True).strip() - - -def case_status(testcase): - if testcase.find("failure") is not None: - return "fail" - if testcase.find("error") is not None: - return "error" - if testcase.find("skipped") is not None: - return "skip" - return "pass" - - -def merge_junit(paths): - root = ET.Element("testsuites") - cases = [] - inputs = [] - for path in paths: - try: - parsed = ET.parse(path) - except ET.ParseError as exc: - fail("malformed JUnit %s: %s" % (path, exc)) - source_root = parsed.getroot() - suites = list(source_root.iter("testsuite")) - if not suites: - fail("JUnit contains no suites: %s" % path) - if source_root.tag == "testsuite": - root.append(source_root) - else: - for suite in source_root.findall("testsuite"): - root.append(suite) - for testcase in source_root.iter("testcase"): - status = case_status(testcase) - skipped = testcase.find("skipped") - cases.append({ - "classname": testcase.get("classname", ""), - "name": testcase.get("name", ""), - "status": status, - "skip_reason": ( - skipped.get("message", "") if skipped is not None else "" - ), - }) - inputs.append({ - "path": str(path.relative_to(ROOT)), - "sha256": sha256_file(path), - }) - ET.ElementTree(root).write( - MERGED_JUNIT, xml_declaration=True, encoding="unicode") - return cases, inputs - - -def canonical_case_name(case): - return "%s.%s" % (case["classname"], case["name"]) - - -def validate_cases(cases): - failures = [case for case in cases - if case["status"] in ("fail", "error")] - if failures: - fail("authoritative JUnit has %d failure/error case(s)" % len(failures)) - passed = {canonical_case_name(case) for case in cases - if case["status"] == "pass"} - missing = sorted(required for required in REQUIRED_CASES - if not any(name.endswith(required) for name in passed)) - if missing: - fail("required 7.14.2 controls missing or not passing: %s" % - ", ".join(missing)) - - -def validate_screenshots(screenshot_root): - pngs = sorted(screenshot_root.rglob("*.png")) - if not pngs: - fail("no OLED PNGs were retained") - sequences = [] - for manifest_path in sorted(screenshot_root.rglob("frames.json")): - with open(manifest_path, "r", encoding="utf-8") as handle: - manifest = json.load(handle) - directory = manifest_path.parent - expected = manifest.get("frames", []) - actual_pngs = sorted(directory.glob("btn*.png")) - if manifest.get("frame_count") != len(expected): - fail("frame_count mismatch: %s" % manifest_path) - if [item.get("file") for item in expected] != [p.name for p in actual_pngs]: - fail("frame list mismatch: %s" % manifest_path) - for item, png in zip(expected, actual_pngs): - if item.get("sha256") != sha256_file(png): - fail("frame hash mismatch: %s" % png) - sequences.append({ - "path": str(directory.relative_to(ROOT)), - "manifest_sha256": sha256_file(manifest_path), - "frame_count": len(actual_pngs), - }) - if not sequences: - fail("OLED frames have no completeness manifests") - manifested = sum(item["frame_count"] for item in sequences) - if manifested != len(pngs): - fail("%d OLED PNGs exist but manifests account for %d" % - (len(pngs), manifested)) - return pngs, sequences - - def validate_arm_manifests(arm_dir, firmware_sha, python_sha): - required = {"full", "bitcoin-only"} - manifests = {} - for manifest_path in sorted(arm_dir.glob("*/arm-build-manifest.json")): + required = {'full', 'bitcoin-only'} + found = set() + for manifest_path in sorted(arm_dir.glob('*/arm-build-manifest.json')): artifact = manifest_path.parent.name matches = [variant for variant in required - if artifact.endswith("-" + variant)] + if artifact.endswith('-' + variant)] if len(matches) != 1: - fail("unrecognized ARM artifact directory: %s" % artifact) + raise RuntimeError('unrecognized ARM artifact directory: %s' % artifact) variant = matches[0] - if variant in manifests: - fail("duplicate ARM manifest for %s" % variant) - with open(manifest_path, "r", encoding="utf-8") as handle: + if variant in found: + raise RuntimeError('duplicate ARM manifest for %s' % variant) + with open(manifest_path, encoding='utf-8') as handle: manifest = json.load(handle) - if manifest.get("firmware_sha") != firmware_sha: - fail("ARM manifest firmware SHA does not match checkout: %s" % - artifact) - if manifest.get("python_sha") != python_sha: - fail("ARM manifest Python SHA does not match gitlink: %s" % - artifact) - if manifest.get("variant") != variant: - fail("ARM manifest variant does not match artifact: %s" % artifact) - files = manifest.get("files", []) + if manifest.get('variant') != variant: + raise RuntimeError('ARM manifest variant mismatch: %s' % artifact) + if manifest.get('firmware_sha') != firmware_sha: + raise RuntimeError('ARM manifest firmware SHA mismatch: %s' % artifact) + if manifest.get('python_sha') != python_sha: + raise RuntimeError('ARM manifest Python SHA mismatch: %s' % artifact) + files = manifest.get('files', []) if not files: - fail("ARM manifest contains no binaries: %s" % artifact) + raise RuntimeError('ARM manifest contains no binaries: %s' % artifact) for item in files: - path = manifest_path.parent / item.get("name", "") - if not path.is_file() or sha256_file(path) != item.get("sha256"): - fail("ARM artifact hash mismatch: %s" % path) - manifests[variant] = { - "artifact": artifact, - "manifest_path": manifest_path, - "manifest": manifest, - "manifest_sha256": sha256_file(manifest_path), - } - if set(manifests) != required: - fail("expected full and bitcoin-only ARM manifests, found: %s" % - ", ".join(sorted(manifests))) - return manifests - + binary = manifest_path.parent / item.get('name', '') + if (not binary.is_file() or + sha256_file(binary) != item.get('sha256')): + raise RuntimeError('ARM artifact hash mismatch: %s' % binary) + found.add(variant) + if found != required: + raise RuntimeError('expected full and bitcoin-only ARM manifests, found: %s' % + ', '.join(sorted(found))) + print('Validated full and bitcoin-only ARM artifact manifests') def main(): - if not REPORT_GENERATOR.is_file(): - fail("report generator submodule is not initialized") - - firmware_sha = git("rev-parse", "HEAD") - python_sha = git("rev-parse", "HEAD:deps/python-keepkey") - expected_firmware = os.environ.get("KK_FIRMWARE_SHA", firmware_sha) - expected_python = os.environ.get("KK_PYTHON_SHA", python_sha) - if expected_firmware != firmware_sha or expected_python != python_sha: - fail("workflow metadata does not match checked-out source") - - REPORT_DIR.mkdir(parents=True, exist_ok=True) - junit_paths = [ROOT / "test-reports" / "python-keepkey" / "junit.xml"] - junit_paths += [Path(path) for path in sorted(glob.glob( - str(ROOT / "test-reports" / "firmware-unit" / "*.xml")))] - junit_paths.append(ROOT / "test-reports" / "dylib-junit.xml") - missing_junit = [str(path) for path in junit_paths if not path.is_file()] - if missing_junit: - fail("required JUnit inputs missing: %s" % ", ".join(missing_junit)) - - cases, junit_inputs = merge_junit(junit_paths) - validate_cases(cases) - - screenshot_root = ROOT / "test-reports" / "screenshots" - pngs, sequences = validate_screenshots(screenshot_root) - - arm_dir = ROOT / "test-reports" / "arm" - arm_manifests = validate_arm_manifests( - arm_dir, firmware_sha, python_sha) - - wrapper_hash = sha256_file(Path(__file__)) - renderer_hash = sha256_file(REPORT_GENERATOR) - generator_hash = hashlib.sha256( - (wrapper_hash + renderer_hash).encode("ascii")).hexdigest() - arm_manifest_hash = hashlib.sha256(json.dumps({ - variant: item["manifest_sha256"] - for variant, item in sorted(arm_manifests.items()) - }, sort_keys=True).encode("ascii")).hexdigest() - run_url = os.environ.get("KK_RUN_URL", "") - fw_version = os.environ.get("FW_VERSION", "") - if not fw_version: - fail("FW_VERSION is required") - - screenshot_junit = ( - ROOT / "test-reports" / "python-keepkey" / - "junit-screenshots.xml") - if not screenshot_junit.is_file(): - fail("screenshot-selection JUnit is missing") - subprocess.run([ - sys.executable, str(REPORT_GENERATOR), - "--screenshot-audit=%s" % screenshot_root, - "--audit-junit=%s" % screenshot_junit, - "--fw-version=%s" % fw_version, - ], cwd=str(ROOT), check=True) - - subprocess.run([ - sys.executable, str(REPORT_GENERATOR), - "--validate-junit", - "--junit=%s" % MERGED_JUNIT, - "--fw-version=%s" % fw_version, - ], cwd=str(ROOT), check=True) - - subprocess.run([ - sys.executable, str(REPORT_GENERATOR), - "--output=%s" % REPORT_PDF, - "--junit=%s" % MERGED_JUNIT, - "--screenshots=%s" % screenshot_root, - "--fw-version=%s" % fw_version, - "--firmware-sha=%s" % firmware_sha, - "--python-sha=%s" % python_sha, - "--run-url=%s" % run_url, - "--generator-sha256=%s" % generator_hash, - "--arm-manifest-sha256=%s" % arm_manifest_hash, - ], cwd=str(ROOT), check=True) - if not REPORT_PDF.is_file() or REPORT_PDF.stat().st_size == 0: - fail("report PDF was not created") - - counts = { - status: sum(1 for case in cases if case["status"] == status) - for status in ("pass", "skip", "fail", "error") - } - counts["total"] = len(cases) - generated_at = datetime.datetime.now( - datetime.timezone.utc).isoformat().replace("+00:00", "Z") - evidence = { - "schema": 1, - "generated_at": generated_at, - "firmware_sha": firmware_sha, - "python_sha": python_sha, - "firmware_pr": os.environ.get("KK_FIRMWARE_PR", ""), - "python_pr": os.environ.get("KK_PYTHON_PR", ""), - "run_url": run_url, - "workflow_event": os.environ.get("KK_WORKFLOW_EVENT", ""), - "generators": { - "combined_sha256": generator_hash, - "wrapper_sha256": wrapper_hash, - "renderer_sha256": renderer_hash, - }, - "junit": { - "counts": counts, - "inputs": junit_inputs, - "merged_sha256": sha256_file(MERGED_JUNIT), - "skips": [case for case in cases if case["status"] == "skip"], - }, - "oled": { - "frame_count": len(pngs), - "selection_junit_sha256": sha256_file(screenshot_junit), - "frames": [{ - "path": str(path.relative_to(ROOT)), - "sha256": sha256_file(path), - } for path in pngs], - "sequences": sequences, - }, - "arm": { - "manifest_set_sha256": arm_manifest_hash, - "variants": { - variant: { - "artifact": item["artifact"], - "manifest_sha256": item["manifest_sha256"], - "files": item["manifest"]["files"], - } - for variant, item in sorted(arm_manifests.items()) - }, - }, - "pdf": { - "path": REPORT_PDF.name, - "sha256": sha256_file(REPORT_PDF), - }, - } - manifest_path = REPORT_DIR / "test-report-manifest.json" - with open(manifest_path, "w", encoding="utf-8") as handle: - json.dump(evidence, handle, sort_keys=True, indent=2) - handle.write("\n") - with open(REPORT_DIR / "test-report.pdf.sha256", "w", - encoding="ascii") as handle: - handle.write("%s test-report.pdf\n" % evidence["pdf"]["sha256"]) + if not os.path.exists(REPORT_GENERATOR): + print("ERROR: %s not found — is the python-keepkey submodule initialized?" % REPORT_GENERATOR, + file=sys.stderr) + sys.exit(1) - print("presign evidence: %d tests, %d OLED frames, PDF %s" % - (counts["total"], len(pngs), evidence["pdf"]["sha256"])) + firmware_sha = subprocess.check_output( + ['git', 'rev-parse', 'HEAD'], text=True).strip() + python_sha = subprocess.check_output( + ['git', 'rev-parse', 'HEAD:deps/python-keepkey'], text=True).strip() + try: + validate_arm_manifests(Path('test-reports/arm'), firmware_sha, python_sha) + except (OSError, RuntimeError, ValueError) as exc: + print('ERROR: %s' % exc, file=sys.stderr) + sys.exit(1) + # The release report is evidence, not a best-effort decoration. The + # canonical Python JUnit must exist; otherwise rendering an empty catalog + # produces a dangerously plausible "all pending" PDF. + python_junit = 'test-reports/python-keepkey/junit.xml' + if not os.path.isfile(python_junit) or os.path.getsize(python_junit) == 0: + print("ERROR: required Python JUnit evidence missing: %s" % python_junit, + file=sys.stderr) + sys.exit(1) -if __name__ == "__main__": - try: - main() - except (OSError, RuntimeError, subprocess.CalledProcessError) as exc: - print("ERROR: %s" % exc, file=sys.stderr) + # Collect JUnit XMLs from CI artifacts + junit_files = ( + glob.glob('test-reports/python-keepkey/junit*.xml') + + glob.glob('test-reports/firmware-unit/*.xml') + ) + + # Merge multiple JUnit XMLs into one for the report generator + merged = 'test-reports/junit-merged.xml' + if junit_files: + import xml.etree.ElementTree as ET + root = ET.Element('testsuites') + for jf in junit_files: + try: + tree = ET.parse(jf) + for suite in tree.iter('testsuite'): + root.append(suite) + except ET.ParseError: + print("WARN: skipping malformed %s" % jf, file=sys.stderr) + ET.ElementTree(root).write(merged, xml_declaration=True, encoding='unicode') + print("Merged %d JUnit files -> %s" % (len(junit_files), merged)) + else: + print("WARN: no JUnit XML files found", file=sys.stderr) + merged = None + + # Find screenshots directory + screenshot_dir = 'test-reports/screenshots' + if not os.path.isdir(screenshot_dir): + screenshot_dir = None + + # Build command + cmd = [sys.executable, REPORT_GENERATOR, '--output=test-report.pdf'] + if merged: + cmd.append('--junit=%s' % merged) + if screenshot_dir: + cmd.append('--screenshots=%s' % screenshot_dir) + fw_version = os.environ.get('FW_VERSION') + if fw_version: + cmd.append('--fw-version=%s' % fw_version) + + print("Running: %s" % ' '.join(cmd)) + result = subprocess.run(cmd) + + if result.returncode != 0: + print("ERROR: report generator exited %d" % result.returncode, file=sys.stderr) + sys.exit(result.returncode) + + if os.path.exists('test-report.pdf'): + size = os.path.getsize('test-report.pdf') + print("Generated test-report.pdf (%d bytes)" % size) + else: + print("ERROR: test-report.pdf not created", file=sys.stderr) sys.exit(1) + + # Render first so a failed candidate still has a truthful diagnostic PDF, + # then fail the job if any catalog entry failed or is missing. Deliberate + # feature/policy skips remain valid per the report generator contract. + # + # Validate against the SAME merged evidence the PDF was rendered from. It + # used to validate against the Python JUnit alone, so any catalog entry + # naming a native firmware unit test resolved to "missing" and the gate + # could never accept one -- which is half of why no native test was ever + # catalogued. The canonical-Python-evidence requirement is already + # enforced above, before the merge, so nothing is weakened here. + # Second run of the screenshot audit, on purpose. + # + # python-keepkey-tests.sh already runs it inside the emulator container, + # immediately after capture. That one answers "did the firmware draw?". + # This one answers a different question: "does the PDF being shipped have + # the screens it claims?" The report is built from a DOWNLOADED artifact, + # and a partial upload would render a report with declared-but-absent + # screens that nothing else checks -- the container gate has already + # passed and gone. + # + # Only when the screenshots artifact actually arrived: its download is + # continue-on-error, and a flaked upload must not be reported as a firmware + # that stopped drawing. + if screenshot_dir: + audit_cmd = [ + sys.executable, + REPORT_GENERATOR, + '--screenshot-audit=%s' % screenshot_dir, + '--audit-junit=%s' % (merged or python_junit), + ] + if fw_version: + audit_cmd.append('--fw-version=%s' % fw_version) + print("Auditing screens: %s" % ' '.join(audit_cmd)) + audit = subprocess.run(audit_cmd) + if audit.returncode != 0: + print("ERROR: declared OLED screens were not captured", file=sys.stderr) + sys.exit(audit.returncode) + else: + print("WARN: no screenshots artifact -- screen audit not run", file=sys.stderr) + + validate_cmd = [ + sys.executable, + REPORT_GENERATOR, + '--junit=%s' % (merged or python_junit), + '--validate-junit', + ] + if fw_version: + validate_cmd.append('--fw-version=%s' % fw_version) + print("Validating: %s" % ' '.join(validate_cmd)) + validation = subprocess.run(validate_cmd) + if validation.returncode != 0: + print("ERROR: report catalog validation failed", file=sys.stderr) + sys.exit(validation.returncode) + + +if __name__ == '__main__': + main() diff --git a/scripts/release/hash-manifest.sh b/scripts/release/hash-manifest.sh new file mode 100755 index 000000000..e4503cd23 --- /dev/null +++ b/scripts/release/hash-manifest.sh @@ -0,0 +1,272 @@ +#!/bin/sh +# Generate the published hash manifest for a directory of release artifacts. +# +# This exists as a script rather than as steps inside release.yml because the +# manifest has to be produced TWICE: once by CI over the unsigned build, and +# again by the key holders over the signed binaries they are about to upload. +# When only CI could generate it, the published full-image hash described the +# unsigned draft -- the binary nobody installs -- while the checklist quietly +# swapped the signed one in underneath it. That is the most likely origin of +# the wrong v7.14.1 hash that ended up pinned in KeepKey Vault. +# +# Usage: +# scripts/release/hash-manifest.sh [suffix] +# scripts/release/hash-manifest.sh --require-signed [suffix] +# scripts/release/hash-manifest.sh --self-test +# +# Writes HASHES.txt into . With --require-signed it exits non-zero +# unless every application firmware image carries three distinct signer slots +# and three non-zero signatures -- run it that way before publishing. +# +# Application metadata descriptor (include/keepkey/board/memory.h): +# 0x00 4 magic 'KPKY' 0x08 1 sig_index1 0x40 64 signature 1 +# 0x04 4 codelen (LE) 0x09 1 sig_index2 0x80 64 signature 2 +# 0x0A 1 sig_index3 0xC0 64 signature 3 +# 0x0B 1 sig_flag +set -eu + +sha256() { { command -v sha256sum >/dev/null && sha256sum; } || shasum -a 256; } +digest() { sha256 | awk '{print $1}'; } + +# EVERY od CALL PASSES -v. Without it od collapses repeated identical lines to +# a single '*', so 192 zero bytes render as one line of zeros plus '*' -- and +# the '*' survives `tr -d '0'`, which made an entirely unsigned image read as +# signed. That defect is the reason this file's checks are per-region below +# rather than one concatenated blob. +# +# Little-endian uint32 at byte offset $2 of file $1. NR==1 because od closes +# with a trailing offset line that awk would otherwise emit as a second value. +le32() { + od -v -An -tu1 -j"$2" -N4 "$1" | + awk 'NR == 1 {print $1 + $2 * 256 + $3 * 65536 + $4 * 16777216}' +} +u8() { od -v -An -tu1 -j"$2" -N1 "$1" | awk 'NR == 1 {print $1}'; } +is_kpky() { [ "$(od -v -An -c -N4 "$1" | tr -d ' \n')" = "KPKY" ]; } + +# True if the 64-byte signature slot $2 (0..2) of file $1 is not all zeroes. +sig_present() { + _off=$((64 + $2 * 64)) + [ -n "$(od -v -An -tx1 -j"$_off" -N64 "$1" | tr -d ' \n' | tr -d '0')" ] +} + +# A 3-of-5 quorum: three signer slots, each in the valid range 1..5, all +# distinct, and each of the three 64-byte signature regions independently +# non-zero. +# +# WHAT THIS PROVES, EXACTLY: that the canonical unsigned draft -- zero indices, +# zero signature area -- was not published. Nothing more. A region holding a +# single non-zero byte passes, so this cannot distinguish a real ECDSA +# signature from a placeholder, and it cannot detect a forgery at all. +# +# The five signing public keys are in include/keepkey/board/pubkeys.h, so real +# verification is not blocked on obtaining them -- it needs a host-side +# secp256k1 verifier over sha256 of the image, which nobody has written. Until +# that exists, do not let this check be described as proof the release is +# correctly signed. +has_quorum() { + _i1=$(u8 "$1" 8); _i2=$(u8 "$1" 9); _i3=$(u8 "$1" 10) + for _i in "$_i1" "$_i2" "$_i3"; do + [ "$_i" -ge 1 ] && [ "$_i" -le 5 ] || return 1 + done + [ "$_i1" -ne "$_i2" ] && [ "$_i1" -ne "$_i3" ] && [ "$_i2" -ne "$_i3" ] || return 1 + sig_present "$1" 0 && sig_present "$1" 1 && sig_present "$1" 2 +} +signer_slots() { printf '%s,%s,%s' "$(u8 "$1" 8)" "$(u8 "$1" 9)" "$(u8 "$1" 10)"; } + +fail() { echo "self-test: $1"; exit 1; } + +# Writes 64 non-zero bytes into signature slot $2 (0..2) of $1. +sign_slot() { + _o=$((64 + $2 * 64)) + dd if=/dev/zero bs=1 count=64 2>/dev/null | tr '\000' '\052' | + dd of="$1" bs=1 seek="$_o" conv=notrunc 2>/dev/null +} + +self_test() { + d=$(mktemp -d) + trap 'rm -rf "$d"' EXIT + # 256-byte descriptor + 4 bytes of "code": magic, codelen=4, no signatures. + printf 'KPKY\004\000\000\000' > "$d/f.bin" + dd if=/dev/zero bs=1 count=248 >> "$d/f.bin" 2>/dev/null + printf 'code' >> "$d/f.bin" + is_kpky "$d/f.bin" || fail "magic not detected" + [ "$(le32 "$d/f.bin" 4)" = "4" ] || fail "codelen misread" + has_quorum "$d/f.bin" && fail "unsigned image claimed quorum" + + # THE od REGRESSION. Valid distinct in-range slots, but the whole 192-byte + # signature area is still zero. Under `od` without -v that area renders as one + # zero line plus '*', and the '*' survived `tr -d '0'`, so this exact shape -- + # an unsigned binary with its indices filled in -- passed the gate. + printf '\001\002\004\001' | dd of="$d/f.bin" bs=1 seek=8 conv=notrunc 2>/dev/null + has_quorum "$d/f.bin" && fail "all-zero signature area passed quorum (od -v)" + + # One signature present is not three. The previous self-test wrote a single + # byte here and declared the quorum valid, which is why none of this was + # caught. + sign_slot "$d/f.bin" 0 + has_quorum "$d/f.bin" && fail "one signature passed a 3-of-5 quorum" + sign_slot "$d/f.bin" 1 + has_quorum "$d/f.bin" && fail "two signatures passed a 3-of-5 quorum" + sign_slot "$d/f.bin" 2 + has_quorum "$d/f.bin" || fail "three signed slots failed quorum" + + # Slots outside 1..5 are not signers, however non-zero. + printf '\001\002\006' | dd of="$d/f.bin" bs=1 seek=8 conv=notrunc 2>/dev/null + has_quorum "$d/f.bin" && fail "out-of-range signer slot 6 passed quorum" + printf '\001\002\000' | dd of="$d/f.bin" bs=1 seek=8 conv=notrunc 2>/dev/null + has_quorum "$d/f.bin" && fail "zero signer slot passed quorum" + + # Repeated slots are not a quorum, however non-zero. + printf '\001\001\004' | dd of="$d/f.bin" bs=1 seek=8 conv=notrunc 2>/dev/null + has_quorum "$d/f.bin" && fail "duplicate slots passed quorum" + + # And --require-signed must refuse a directory with no application image + # rather than reporting success over nothing. + e=$(mktemp -d) + if sh "$0" --require-signed "$e" 0.0.0 full "" >/dev/null 2>&1; then + rm -rf "$e"; fail "--require-signed passed an empty directory" + fi + rm -rf "$e" + + echo "self-test: ok" +} + +[ "${1:-}" = "--self-test" ] && { self_test; exit 0; } + +REQUIRE_SIGNED=0 +if [ "${1:-}" = "--require-signed" ]; then REQUIRE_SIGNED=1; shift; fi + +DIR=$1; VERSION=$2; VARIANT=$3; SUFFIX=${4:-} +cd "$DIR" + +OUT="HASHES${SUFFIX}.txt" +: > "$OUT" + +# Signed-ness is read off the artifacts rather than passed in, so the manifest +# cannot claim a state the bytes do not support. +# +# APPS counts application images. Without it "no unsigned image found" and "no +# image found at all" were the same answer, so --require-signed exited 0 on an +# empty directory and announced "Generated from the signed release artifacts" -- +# a gate that passes when there is nothing to gate. +# Exactly one application image, and it must be the one this invocation names. +# A directory holding both variants' artifacts -- which is precisely what the +# release job's merge-multiple download produces -- would otherwise hash the +# bitcoin-only image into the full variant's manifest and vice versa. +EXPECTED_APP="firmware.keepkey.v${VERSION}${SUFFIX}.bin" +UNSIGNED=0 +APPS=0 +for f in *.bin; do + [ -f "$f" ] || continue + is_kpky "$f" || continue + if [ "$f" != "$EXPECTED_APP" ]; then + echo "ERROR: unexpected application image '$f'; this manifest is for" >&2 + echo " '${EXPECTED_APP}'. Generate each variant from its own" >&2 + echo " directory, or the variants cross-contaminate." >&2 + exit 1 + fi + APPS=$((APPS + 1)) + has_quorum "$f" || UNSIGNED=1 +done + +if [ "$REQUIRE_SIGNED" -eq 1 ] && [ "$APPS" -eq 0 ]; then + echo "ERROR: no application image '${EXPECTED_APP}' in $(pwd) —" >&2 + echo " nothing to publish." >&2 + exit 1 +fi + +{ + echo "# KeepKey Firmware v${VERSION} (${VARIANT}) — Hash Manifest" + echo "#" + if [ "$UNSIGNED" -eq 1 ]; then + echo "# THESE ARE THE UNSIGNED BUILD ARTIFACTS. Signing rewrites the 256-byte" + echo "# metadata descriptor, which CHANGES every 'device image' and 'whole file'" + echo "# hash below. Regenerate this file from the signed binaries before" + echo "# publishing:" + echo "# scripts/release/hash-manifest.sh --require-signed . ${VERSION} ${VARIANT} ${SUFFIX}" + echo "# Only the 'payload' hash survives signing unchanged." + else + echo "# Generated from the signed release artifacts: ${APPS} application" + echo "# image, carrying three distinct signer slots in 1..5 and three" + echo "# non-empty signature regions." + echo "#" + echo "# That is a STRUCTURAL check with a narrow meaning: it proves the" + echo "# canonical UNSIGNED DRAFT was not published. It does NOT verify the" + echo "# signatures, and a region holding one non-zero byte would pass it." + fi + echo "" +} >> "$OUT" + +for f in *.bin *.elf; do + [ -f "$f" ] || continue + WHOLE=$(digest < "$f") + + if ! is_kpky "$f"; then + # No KPKY descriptor: a bootloader image or a build product. The + # device-image and payload framings below simply do not apply to it, and + # applying them is how 'tail -c +257' ended up recommended for + # bootloader.bin. + { + echo "$f (no KPKY application descriptor)" + echo " sha256 (whole file) $WHOLE" + echo " The file as published. This is NOT what Features.firmware_hash" + echo " reports, and 'tail -c +257' does not apply to it." + echo "" + } >> "$OUT" + continue + fi + + CODELEN=$(le32 "$f" 4) + SIZE=$(wc -c < "$f" | tr -d ' ') + + # head -c stops at EOF without complaining, so a truncated image would be + # hashed over fewer bytes than its own descriptor claims and published as if + # it were whole. + if [ "$SIZE" -lt $((256 + CODELEN)) ]; then + echo "ERROR: '$f' is ${SIZE} bytes but its descriptor claims 256+${CODELEN}" >&2 + echo " = $((256 + CODELEN)). Truncated image; refusing to hash it." >&2 + exit 1 + fi + + DEVICE=$(head -c $((256 + CODELEN)) "$f" | digest) + PAYLOAD=$(tail -c +257 "$f" | digest) + + if has_quorum "$f"; then + STATE="signed, signer slots $(signer_slots "$f")" + else + STATE="UNSIGNED" + fi + + { + echo "$f (application firmware, ${STATE})" + echo " sha256 (device image) $DEVICE" + echo " Compare against the firmware hash your device reports" + echo " (Features.firmware_hash, shown in KeepKey Vault)." + echo " Covers the 256-byte metadata descriptor -- signatures included --" + echo " plus codelen (${CODELEN}) bytes of application code." + echo " Signing CHANGES this hash." + echo " sha256 (whole file) $WHOLE" + if [ "$SIZE" -eq $((256 + CODELEN)) ]; then + echo " The file as published; identical to the device image hash above." + else + echo " The file as published. It is ${SIZE} bytes against a" + echo " 256+codelen device image of $((256 + CODELEN)), so the two hashes" + echo " DIFFER. Pin the device image hash, not this one." + fi + echo " sha256 (payload) $PAYLOAD" + echo " Compare against your own reproducible build, with" + echo " 'tail -c +257' applied to BOTH files -- a local build" + echo " has no signatures in its 256-byte descriptor." + echo " This proves the release binary came from the source." + echo " Signing does NOT change this hash, and it is NOT the hash" + echo " the device reports." + echo "" + } >> "$OUT" +done + +cat "$OUT" + +if [ "$REQUIRE_SIGNED" -eq 1 ] && [ "$UNSIGNED" -eq 1 ]; then + echo "ERROR: an application firmware image is missing its 3-of-5 quorum." >&2 + exit 1 +fi diff --git a/scripts/release/verify-signatures.py b/scripts/release/verify-signatures.py new file mode 100755 index 000000000..3c7c6728a --- /dev/null +++ b/scripts/release/verify-signatures.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Verify the 3-of-5 signatures on a KeepKey firmware image, host-side. + + scripts/release/verify-signatures.py [image.bin ...] + scripts/release/verify-signatures.py --self-test + +Why this exists +--------------- +`hash-manifest.sh --require-signed` is a STRUCTURAL gate: it proves the +canonical unsigned draft was not published, and a signature region holding one +non-zero byte passes it. It cannot detect a placeholder and it cannot detect a +forgery. This does the real check -- the same one the device performs in +`lib/board/signatures.c:signatures_ok()` -- so a release can be verified before +it ships rather than by a user's bootloader afterwards. + +What is verified, mirroring signatures_ok() exactly: + + * magic is 'KPKY' + * the three key indices are each in 1..PUBKEYS and mutually distinct + * digest = sha256(image[META_LEN : META_LEN + codelen]) + * each of the three 64-byte compact signatures verifies against the pubkey + its index selects + +Header layout (256-byte descriptor, from memory.h / hash-manifest.sh): + + 0x00 4 magic 'KPKY' 0x08 1 sig_index1 0x40 64 signature 1 + 0x04 4 codelen (LE) 0x09 1 sig_index2 0x80 64 signature 2 + 0x0A 1 sig_index3 0xC0 64 signature 3 + +The public keys are parsed out of `include/keepkey/board/pubkeys.h` at runtime +rather than duplicated here, so a key rotation cannot leave this script +verifying against a stale set. If the header ever stops parsing, that is a +failure, not a fallback. +""" + +import hashlib +import re +import sys +from pathlib import Path + +try: + from ecdsa import BadSignatureError, SECP256k1, VerifyingKey + from ecdsa.util import sigdecode_string +except ImportError: + sys.exit("error: needs `ecdsa` (pip install ecdsa) -- python-keepkey already depends on it") + +META_LEN = 256 +OFF_MAGIC, OFF_CODELEN = 0x00, 0x04 +OFF_SIGINDEX = (0x08, 0x09, 0x0A) +OFF_SIG = (0x40, 0x80, 0xC0) +SIG_LEN = 64 +MAGIC = b"KPKY" + +REPO = Path(__file__).resolve().parents[2] +PUBKEYS_H = REPO / "include" / "keepkey" / "board" / "pubkeys.h" + +# secp256k1 group order, for the low-S report. +N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + + +def load_pubkeys(path=PUBKEYS_H): + """Parse the pubkey table out of pubkeys.h. Uncompressed, 65 bytes, 0x04.""" + src = path.read_text() + body = src[src.index("static const uint8_t pubkey"):] + body = body[: body.index("};")] + keys, cur = [], [] + for value in re.findall(r"0x([0-9a-fA-F]{2})", body): + cur.append(int(value, 16)) + if len(cur) == 65: + keys.append(bytes(cur)) + cur = [] + if cur: + raise ValueError(f"trailing {len(cur)} bytes: pubkeys.h did not parse as 65-byte keys") + if not keys: + raise ValueError("no pubkeys parsed") + for i, k in enumerate(keys): + if k[0] != 0x04: + raise ValueError(f"pubkey {i + 1} is not uncompressed (0x{k[0]:02x})") + return keys + + +def verify_image(path, keys): + """Return (ok, [lines]). Mirrors signatures_ok().""" + out = [] + blob = path.read_bytes() + if len(blob) < META_LEN: + return False, [f" file is {len(blob)} bytes, shorter than the {META_LEN}-byte descriptor"] + + if blob[OFF_MAGIC:OFF_MAGIC + 4] != MAGIC: + return False, [f" bad magic {blob[OFF_MAGIC:OFF_MAGIC + 4]!r}, expected {MAGIC!r}"] + + codelen = int.from_bytes(blob[OFF_CODELEN:OFF_CODELEN + 4], "little") + if codelen == 0 or META_LEN + codelen > len(blob): + return False, [f" codelen {codelen} does not fit a {len(blob)}-byte file"] + out.append(f" codelen {codelen} bytes") + + indices = [blob[o] for o in OFF_SIGINDEX] + for n, idx in enumerate(indices, 1): + if not 1 <= idx <= len(keys): + return False, out + [f" sig_index{n} = {idx}, outside 1..{len(keys)}"] + if len(set(indices)) != len(indices): + return False, out + [f" duplicate key indices {indices} -- a 3-of-5 needs three distinct keys"] + out.append(f" keys {indices[0]}, {indices[1]}, {indices[2]}") + + digest = hashlib.sha256(blob[META_LEN:META_LEN + codelen]).digest() + out.append(f" digest {digest.hex()}") + + ok = True + for n, (idx, off) in enumerate(zip(indices, OFF_SIG), 1): + sig = blob[off:off + SIG_LEN] + vk = VerifyingKey.from_string(keys[idx - 1][1:], curve=SECP256k1) + try: + vk.verify_digest(sig, digest, sigdecode=sigdecode_string) + except BadSignatureError: + out.append(f" sig{n} (key {idx}) INVALID") + ok = False + continue + # Reported, not enforced: the device accepts either form here, so + # failing on it would reject images the device treats as valid. + s = int.from_bytes(sig[32:], "big") + note = "" if s <= N // 2 else " [high-S, non-canonical]" + out.append(f" sig{n} (key {idx}) ok{note}") + return ok, out + + +def self_test(): + """Prove the checks fire. Signs a fake image with throwaway keys, so it + exercises the real verify path rather than asserting on constants.""" + from ecdsa import SigningKey + from ecdsa.util import sigencode_string + import tempfile + + sks = [SigningKey.generate(curve=SECP256k1) for _ in range(5)] + keys = [b"\x04" + sk.get_verifying_key().to_string() for sk in sks] + code = b"\xa5" * 512 + digest = hashlib.sha256(code).digest() + + def build(indices, sign_with=None, corrupt=False): + img = bytearray(META_LEN + len(code)) + img[0:4] = MAGIC + img[OFF_CODELEN:OFF_CODELEN + 4] = len(code).to_bytes(4, "little") + for o, idx in zip(OFF_SIGINDEX, indices): + img[o] = idx + for off, idx in zip(OFF_SIG, sign_with or indices): + # Clamp only the fixture's choice of signer: out-of-range indices + # are what the header check under test is supposed to reject, so + # the builder must still produce a file for it to reject. + sk = sks[(idx - 1) % len(sks)] + sig = sk.sign_digest(digest, sigencode=sigencode_string) + img[off:off + SIG_LEN] = sig + img[META_LEN:] = code + if corrupt: + img[META_LEN] ^= 0xFF + return bytes(img) + + failures = [] + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "f.bin" + + def check(label, blob, expect): + p.write_bytes(blob) + got, _ = verify_image(p, keys) + if got != expect: + failures.append(f"{label}: expected {expect}, got {got}") + + check("a properly signed image verifies", build([1, 2, 3]), True) + check("a modified image fails", build([1, 2, 3], corrupt=True), False) + check("duplicate indices fail", build([1, 1, 2]), False) + check("index 0 fails", build([0, 2, 3]), False) + check("index 6 fails", build([6, 2, 3]), False) + # The one that matters: right structure, wrong signer. + check("a signature by the wrong key fails", build([1, 2, 3], sign_with=[1, 2, 4]), False) + # And the gate hash-manifest.sh cannot catch. + blob = bytearray(build([1, 2, 3])) + blob[OFF_SIG[2]:OFF_SIG[2] + SIG_LEN] = b"\x01" * SIG_LEN + check("a non-zero placeholder signature fails", bytes(blob), False) + + for f in failures: + print(f" FAIL {f}") + print(f"\nself-test: {'PASSED' if not failures else str(len(failures)) + ' FAILED'}") + return 1 if failures else 0 + + +def main(argv): + if "--self-test" in argv: + return self_test() + if len(argv) < 2: + return print(__doc__.strip()) or 2 + + keys = load_pubkeys() + print(f"{len(keys)} public keys from {PUBKEYS_H.relative_to(REPO)}\n") + + worst = 0 + for arg in argv[1:]: + path = Path(arg) + print(path) + if not path.is_file(): + print(" not a file") + worst = 1 + continue + ok, lines = verify_image(path, keys) + print("\n".join(lines)) + print(f" => {'SIGNED' if ok else 'NOT VERIFIED'}\n") + worst = worst or (0 if ok else 1) + return worst + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/blupdater/CMakeLists.txt b/tools/blupdater/CMakeLists.txt index 990529f70..d5dc99764 100644 --- a/tools/blupdater/CMakeLists.txt +++ b/tools/blupdater/CMakeLists.txt @@ -9,7 +9,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/blupdater.ld) diff --git a/tools/bootloader/CMakeLists.txt b/tools/bootloader/CMakeLists.txt index 60b2c46a8..e1f4180ae 100644 --- a/tools/bootloader/CMakeLists.txt +++ b/tools/bootloader/CMakeLists.txt @@ -10,7 +10,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/bootloader.ld) diff --git a/tools/bootstrap/CMakeLists.txt b/tools/bootstrap/CMakeLists.txt index 9b2853b12..393dee36d 100644 --- a/tools/bootstrap/CMakeLists.txt +++ b/tools/bootstrap/CMakeLists.txt @@ -7,7 +7,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/bootstrap.ld) diff --git a/tools/check_pallas_api_boundary.py b/tools/check_pallas_api_boundary.py new file mode 100644 index 000000000..4ed99b823 --- /dev/null +++ b/tools/check_pallas_api_boundary.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Enforce the RC18 split between public and secret Pallas operations.""" + +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def source(path): + return (ROOT / path).read_text(encoding="utf-8") + + +def function_body(text, name): + match = re.search(r"\b" + re.escape(name) + r"\s*\([^;]*?\)\s*\{", text, re.S) + if not match: + raise AssertionError("function not found: " + name) + start = match.end() - 1 + depth = 0 + for index in range(start, len(text)): + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + if depth == 0: + return text[start + 1:index] + raise AssertionError("unterminated function: " + name) + + +def code_only(text): + return re.sub(r"/\*.*?\*/|//[^\n]*", "", text, flags=re.S) + + +def require(body, token, where): + if token not in body: + raise AssertionError("{} must call {}".format(where, token)) + + +def forbid(body, token, where): + if token in body: + raise AssertionError("{} must not call {}".format(where, token)) + + +def main(): + pallas = source("deps/crypto/trezor-firmware/crypto/pallas.c") + sinsemilla = source("deps/crypto/trezor-firmware/crypto/pallas_sinsemilla.c") + redpallas = source("deps/crypto/trezor-firmware/crypto/redpallas.c") + zcash = source("lib/firmware/zcash.c") + zcash_fsm = source("lib/firmware/fsm_msg_zcash.h") + storage = source("lib/firmware/storage.c") + + # Public transaction data needs the fast compatibility implementation. + forbid(pallas, '"pallas_ct.h"', "pallas.c public compatibility path") + hash_to_point = code_only(function_body( + sinsemilla, "pallas_sinsemilla_hash_to_point_progress")) + require(hash_to_point, "sinsemilla_incomplete_add", + "Sinsemilla public hash path") + forbid(hash_to_point, "pallas_ct_", "Sinsemilla public hash path") + require(hash_to_point, "progress(", "Sinsemilla public hash progress") + incomplete_add = code_only(function_body(sinsemilla, + "sinsemilla_incomplete_add")) + require(incomplete_add, "pallas_point_add", "Sinsemilla public hash add") + forbid(incomplete_add, "pallas_ct_", "Sinsemilla public hash add") + + # Transaction note rcm is host-known; use the fast public path. The IVK's + # device-secret rivk has a separate helper that remains fixed-schedule. + commit = code_only(function_body( + sinsemilla, "pallas_sinsemilla_commit_progress")) + require(commit, "pallas_point_mult", "public Sinsemilla blinding") + require(commit, "pallas_point_add", "public Sinsemilla blinding") + forbid(commit, "pallas_ct_", "public Sinsemilla blinding") + require(commit, "pallas_sinsemilla_commit_prepare", + "public Sinsemilla progress propagation") + secret_commit = code_only(function_body( + sinsemilla, "pallas_sinsemilla_commit_secret_blind")) + require(secret_commit, "pallas_ct_point_mult", "secret IVK blinding") + require(secret_commit, "pallas_ct_point_add", "secret IVK blinding") + forbid(secret_commit, "pallas_point_mult(", "secret IVK blinding") + forbid(secret_commit, "pallas_point_add(", "secret IVK blinding") + commit_ivk = code_only(function_body(sinsemilla, + "pallas_sinsemilla_commit_ivk")) + require(commit_ivk, "pallas_sinsemilla_commit_secret_blind", + "IVK commitment") + forbid(commit_ivk, "pallas_sinsemilla_short_commit", "IVK commitment") + + # Authorization scalars, nonces, and randomized keys must never fall back + # to the variable-time public-data API. + spendauth = code_only(function_body(redpallas, "pallas_scalar_mult_spendauth")) + require(spendauth, "pallas_ct_point_mult", "RedPallas scalar multiplication") + forbid(spendauth, "pallas_point_mult(", "RedPallas scalar multiplication") + spendauth_progress = code_only(function_body( + redpallas, "redpallas_scalar_mult_spendauth_G_progress")) + require(spendauth_progress, "pallas_ct_point_mult_progress", + "progress-reporting RedPallas scalar multiplication") + forbid(spendauth_progress, "pallas_point_mult(", + "progress-reporting RedPallas scalar multiplication") + public_spendauth = code_only(function_body( + redpallas, "pallas_scalar_mult_spendauth_public")) + require(public_spendauth, "pallas_point_mult", + "public alpha scalar multiplication") + forbid(public_spendauth, "pallas_ct_", + "public alpha scalar multiplication") + + sign = code_only(function_body(redpallas, "redpallas_sign_digest")) + require(sign, "pallas_ct_add_mod_q", "redpallas_sign_digest") + for token in ("pallas_add_mod_q(", "pallas_mod_q(", "pallas_mul_mod_q("): + forbid(sign, token, "redpallas_sign_digest") + + sign_core = code_only(function_body(redpallas, + "redpallas_sign_with_rsk")) + for token in ("pallas_ct_add_mod_q", "pallas_ct_mul_mod_q"): + require(sign_core, token, "RedPallas signing core") + + # The nonce must come from the spec construction, not from a raw reduction. + require(sign_core, "redpallas_hash_nonce", "RedPallas signing core") + + # NEVER normalise a signing nonce. This gate used to REQUIRE + # pallas_ct_scalar_replace_zero_with_one() here, which institutionalised the + # defect as an invariant: a dead entropy source became the constant nonce 1 + # on every signature, and a nonce that is reused AND publicly known + # discloses the key from a single signature. A zero scalar must fail the + # signature instead. Requiring a helper by name checked the shape of the + # code; this checks the security property. + forbid(sign_core, "pallas_ct_scalar_replace_zero_with_one", + "RedPallas signing core") + forbid(sign_core, "random_buffer", "RedPallas signing core") + + # The nonce hash must wipe its BLAKE2b context, not just the digest buffer. + # blake2b_Final() clears its own scratch but leaves the finished state in + # ctx: h[0..7] IS the digest it serialized, and buf still holds the last + # input block, which contains T. Either one recovers the nonce r, and r plus + # the emitted signature gives up the randomized signing key via + # rsk = (s - r) / c. Found in review after the fix landed, so it is pinned + # here rather than left to the next reader to notice. + nonce_hash = code_only(function_body(redpallas, "redpallas_hash_nonce")) + require(nonce_hash, "memzero(&ctx", "RedPallas nonce hash") + require(nonce_hash, "memzero(hash_out", "RedPallas nonce hash") + for token in ("pallas_add_mod_q(", "pallas_mod_q(", "pallas_mul_mod_q("): + forbid(sign_core, token, "RedPallas signing core") + + optimized_sign = code_only(function_body( + redpallas, "redpallas_sign_digest_with_ak")) + require(optimized_sign, "redpallas_derive_rk_from_ak", + "optimized RedPallas signing") + require(optimized_sign, "pallas_ct_add_mod_q", + "optimized RedPallas signing") + forbid(optimized_sign, "pallas_scalar_mult_spendauth_public", + "optimized RedPallas signing") + + pczt_sign = code_only(function_body( + redpallas, "redpallas_sign_digest_for_rk")) + require(pczt_sign, "pallas_ct_add_mod_q", "PCZT RedPallas signing") + require(pczt_sign, "redpallas_sign_with_rsk", "PCZT RedPallas signing") + forbid(pczt_sign, "pallas_point_mult(", "PCZT RedPallas signing") + forbid(pczt_sign, "pallas_scalar_mult_spendauth_public", + "PCZT RedPallas signing") + action_handler = code_only(function_body(zcash_fsm, + "fsm_msgZcashPCZTAction")) + require(action_handler, "msg->has_is_spend", "PCZT action handler") + require(action_handler, "if (msg->is_spend)", "PCZT action handler") + # The action handler must sign through the rk-VALIDATING entry point. This + # gate previously required redpallas_sign_digest_for_rk() here, which pinned + # the weaker path as an invariant: _for_rk feeds the host's rk straight into + # the nonce and challenge hashes without ever checking it describes this + # device's key, so the device would authorize under a verification key that + # is not its own. _with_ak derives rk from the device's ak and alpha, + # refuses on mismatch, and signs with the derived value. + # + # This is the second time this file has been found requiring the weaker of + # two available implementations by name (see the normaliser note above). + # Requiring a function by name pins whichever one happened to be in use; + # forbid the unsafe one as well, so the gate states the property. + require(action_handler, "redpallas_sign_digest_with_ak", + "PCZT action handler") + forbid(action_handler, "redpallas_sign_digest_for_rk(", + "PCZT action handler") + require(action_handler, "signatures[zcash_signing.signature_count]", + "compact PCZT signature collection") + require(action_handler, "zcash_signing.signature_count++", + "compact PCZT signature collection") + require(action_handler, + "resp_signed->signatures_count = zcash_signing.signature_count", + "compact PCZT signature response") + output_verification = code_only(function_body( + zcash_fsm, "zcash_verify_and_confirm_orchard_output")) + require(output_verification, "zcash_orchard_compute_cmx_with_progress", + "interactive Orchard note verification") + forbid(output_verification, "zcash_orchard_compute_cmx(", + "interactive Orchard note verification") + # Orchard V2 and Ironwood V3 share the public Sinsemilla commitment path; + # only their rcm derivation differs. Keep the expensive implementation in + # one helper, and ensure both interactive wrappers route through it. + note_commitment = code_only(function_body( + zcash, "zcash_orchard_family_compute_cmx_with_progress")) + require(note_commitment, "pallas_sinsemilla_short_commit_progress", + "Orchard-family note verification progress") + for name in ("zcash_orchard_compute_cmx_with_progress", + "zcash_ironwood_compute_cmx_with_progress"): + wrapper = code_only(function_body(zcash, name)) + require(wrapper, "zcash_orchard_family_compute_cmx_with_progress", + name) + + derive_rk = code_only(function_body(redpallas, "redpallas_derive_rk")) + require(derive_rk, "pallas_ct_add_mod_q", "redpallas_derive_rk") + + # ZIP-32 key reduction and transmission-key derivation also process + # device-secret viewing/spending material. + for name in ("to_scalar", "to_base"): + body = code_only(function_body(zcash, name)) + require(body, "pallas_ct_", name) + key_derivation = code_only(function_body( + zcash, "zcash_derive_orchard_keys_with_progress")) + require(key_derivation, "redpallas_scalar_mult_spendauth_G_progress", + "Orchard key derivation") + forbid(key_derivation, "redpallas_scalar_mult_spendauth_G(", + "Orchard key derivation") + stored_key_derivation = code_only(function_body( + storage, "storage_zcashOrchardKeys")) + require(stored_key_derivation, "zcash_derive_orchard_keys_with_progress", + "interactive Orchard key derivation") + forbid(stored_key_derivation, "zcash_derive_orchard_keys(", + "interactive Orchard key derivation") + transmission = code_only(function_body(zcash, "zcash_orchard_derive_transmission_key")) + require(transmission, "pallas_ct_point_mult", "Orchard transmission-key derivation") + forbid(transmission, "pallas_point_mult(", "Orchard transmission-key derivation") + + print("Pallas API boundary: public Sinsemilla fast path and secret CT path verified") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except AssertionError as error: + print("Pallas API boundary violation: {}".format(error), file=sys.stderr) + sys.exit(1) diff --git a/tools/check_pallas_ct_disassembly.py b/tools/check_pallas_ct_disassembly.py new file mode 100644 index 000000000..fa95a5ee1 --- /dev/null +++ b/tools/check_pallas_ct_disassembly.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Enforce the fixed-schedule shape of the ARM Pallas scalar multiplier.""" + +import argparse +import re +import subprocess +import sys +from pathlib import Path + + +SYMBOL_RE = re.compile(r"^([0-9a-fA-F]+) <([^>]+)>:$") +CONDITIONAL_BRANCHES = { + "beq", + "bne", + "bcs", + "bcc", + "bmi", + "bpl", + "bvs", + "bvc", + "bhi", + "bls", + "bge", + "blt", + "bgt", + "ble", + "cbz", + "cbnz", +} +FORBIDDEN_VARIABLE_LATENCY = {"umull", "umlal", "smull", "smlal", "udiv", "sdiv"} + + +def parse_instruction(line): + parts = [part.strip() for part in line.split("\t") if part.strip()] + if len(parts) < 3 or not parts[0].endswith(":"): + return None + try: + address = int(parts[0][:-1], 16) + except ValueError: + return None + mnemonic = parts[2].split(".", 1)[0] + operands = parts[3] if len(parts) > 3 else "" + return address, mnemonic, operands + + +def symbol_instructions(disassembly, symbol): + lines = disassembly.splitlines() + start = None + for index, line in enumerate(lines): + match = SYMBOL_RE.match(line) + if match and match.group(2) == symbol: + start = index + 1 + break + if start is None: + return None + + instructions = [] + for line in lines[start:]: + if SYMBOL_RE.match(line): + break + instruction = parse_instruction(line) + if instruction is not None: + instructions.append(instruction) + return instructions + + +def branch_target(operands): + match = re.match(r"([0-9a-fA-F]+)", operands) + return int(match.group(1), 16) if match else None + + +def verify_full(disassembly): + symbol = "pallas_ct_point_mult" + instructions = symbol_instructions(disassembly, symbol) + if instructions is None: + raise ValueError(f"missing required ARM symbol: {symbol}") + + non_canary_branches = [] + for index, instruction in enumerate(instructions): + address, mnemonic, operands = instruction + if mnemonic not in CONDITIONAL_BRANCHES: + continue + + next_instruction = ( + instructions[index + 1] if index + 1 < len(instructions) else None + ) + if ( + next_instruction is not None + and next_instruction[1] == "bl" + and "<__stack_chk_fail>" in next_instruction[2] + ): + continue + non_canary_branches.append((address, mnemonic, branch_target(operands))) + + if len(non_canary_branches) != 1: + raise ValueError( + f"{symbol} must have one fixed loop branch; found " + f"{non_canary_branches}" + ) + + branch_address, mnemonic, target = non_canary_branches[0] + if target is None or target >= branch_address: + raise ValueError(f"{symbol} loop branch is not backward: {non_canary_branches[0]}") + + required_calls = { + "ct_point_double": None, + "ct_point_add_internal": None, + "ct_point_select": None, + } + for address, instruction_mnemonic, operands in instructions: + if instruction_mnemonic != "bl": + continue + for required in required_calls: + if f"<{required}>" in operands: + required_calls[required] = address + + missing = [name for name, address in required_calls.items() if address is None] + if missing: + raise ValueError(f"{symbol} is missing fixed-round calls: {missing}") + outside_loop = [ + name + for name, address in required_calls.items() + if not target <= address < branch_address + ] + if outside_loop: + raise ValueError(f"fixed-round calls moved outside scalar loop: {outside_loop}") + + progress_symbol = "pallas_ct_point_mult_progress" + progress_instructions = symbol_instructions(disassembly, progress_symbol) + if progress_instructions is None: + raise ValueError(f"missing required ARM symbol: {progress_symbol}") + progress_branches = [] + for index, instruction in enumerate(progress_instructions): + address, instruction_mnemonic, operands = instruction + if instruction_mnemonic not in CONDITIONAL_BRANCHES: + continue + next_instruction = ( + progress_instructions[index + 1] + if index + 1 < len(progress_instructions) + else None + ) + if ( + next_instruction is not None + and next_instruction[1] == "bl" + and "<__stack_chk_fail>" in next_instruction[2] + ): + continue + progress_branches.append( + (address, instruction_mnemonic, branch_target(operands)) + ) + if len(progress_branches) != 1: + raise ValueError( + f"{progress_symbol} must have one fixed loop branch; found " + f"{progress_branches}" + ) + progress_branch_address, _, progress_target = progress_branches[0] + if progress_target is None or progress_target >= progress_branch_address: + raise ValueError( + f"{progress_symbol} loop branch is not backward: " + f"{progress_branches[0]}" + ) + progress_required_calls = { + "ct_point_double": None, + "ct_point_add_internal": None, + "ct_point_select": None, + } + for address, instruction_mnemonic, operands in progress_instructions: + if instruction_mnemonic != "bl": + continue + for required in progress_required_calls: + if f"<{required}>" in operands: + progress_required_calls[required] = address + progress_missing = [ + name for name, address in progress_required_calls.items() if address is None + ] + if progress_missing: + raise ValueError( + f"{progress_symbol} is missing fixed-round calls: {progress_missing}" + ) + progress_outside_loop = [ + name + for name, address in progress_required_calls.items() + if not progress_target <= address < progress_branch_address + ] + if progress_outside_loop: + raise ValueError( + f"{progress_symbol} fixed-round calls moved outside scalar loop: " + f"{progress_outside_loop}" + ) + + # Secret-dependent selects are written as masks. On the pinned ARM build, + # every remaining conditional branch in this module must therefore be a + # backward, fixed-bound loop (apart from stack-canary failure branches). + unexpected_branches = [] + ct_symbols = [] + for line in disassembly.splitlines(): + match = SYMBOL_RE.match(line) + if match and match.group(2).startswith(("ct_", "pallas_ct_")): + ct_symbols.append(match.group(2)) + for ct_symbol in ct_symbols: + ct_instructions = symbol_instructions(disassembly, ct_symbol) + for index, instruction in enumerate(ct_instructions): + address, instruction_mnemonic, operands = instruction + if instruction_mnemonic not in CONDITIONAL_BRANCHES: + continue + next_instruction = ( + ct_instructions[index + 1] + if index + 1 < len(ct_instructions) + else None + ) + if ( + next_instruction is not None + and next_instruction[1] == "bl" + and "<__stack_chk_fail>" in next_instruction[2] + ): + continue + ct_target = branch_target(operands) + if ct_target is None or ct_target >= address: + unexpected_branches.append((ct_symbol, instruction)) + if unexpected_branches: + raise ValueError( + "secret arithmetic contains a non-loop conditional branch: " + f"{unexpected_branches}" + ) + + current_symbol = None + forbidden_instructions = [] + forbidden_conditional_execution = [] + for line in disassembly.splitlines(): + symbol_match = SYMBOL_RE.match(line) + if symbol_match: + current_symbol = symbol_match.group(2) + continue + if current_symbol is None or not current_symbol.startswith(("ct_", "pallas_ct_")): + continue + instruction = parse_instruction(line) + if instruction is None: + continue + if instruction[1] in FORBIDDEN_VARIABLE_LATENCY: + forbidden_instructions.append((current_symbol, instruction)) + if instruction[1].startswith("it") and current_symbol != "ct_fe_to_bn": + forbidden_conditional_execution.append((current_symbol, instruction)) + if forbidden_instructions: + raise ValueError( + "secret arithmetic contains variable-latency long multiply/divide: " + f"{forbidden_instructions}" + ) + if forbidden_conditional_execution: + raise ValueError( + "secret arithmetic contains conditional execution: " + f"{forbidden_conditional_execution}" + ) + + print( + "Pallas ARM disassembly gate: PASS " + f"(regular + progress multipliers each have one backward {mnemonic} " + "loop; double/add/select all inside; " + "only fixed backward loops; no secret IT; no long multiply/divide)" + ) + + +def verify_bitcoin_only(disassembly): + forbidden = ("pallas_ct_", "redpallas_", "pallas_point_") + present = [name for name in forbidden if f"<{name}" in disassembly] + if present: + raise ValueError(f"bitcoin-only image contains privacy symbols: {present}") + print("Pallas ARM disassembly gate: PASS (privacy code absent from bitcoin-only)") + + +def main(): + parser = argparse.ArgumentParser() + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--elf", type=Path) + source.add_argument("--disassembly", type=Path) + parser.add_argument("--objdump", default="arm-none-eabi-objdump") + parser.add_argument("--variant", choices=("full", "bitcoin-only"), required=True) + args = parser.parse_args() + + if args.disassembly: + disassembly = args.disassembly.read_text(encoding="utf-8") + else: + result = subprocess.run( + [args.objdump, "-d", str(args.elf)], + check=True, + stdout=subprocess.PIPE, + universal_newlines=True, + ) + disassembly = result.stdout + + if args.variant == "full": + verify_full(disassembly) + else: + verify_bitcoin_only(disassembly) + + +if __name__ == "__main__": + try: + main() + except (OSError, subprocess.CalledProcessError, ValueError) as error: + print(f"Pallas ARM disassembly gate: FAIL: {error}", file=sys.stderr) + sys.exit(1) diff --git a/tools/check_sram_budget.py b/tools/check_sram_budget.py new file mode 100644 index 000000000..8481a2348 --- /dev/null +++ b/tools/check_sram_budget.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""SRAM budget gate for ARM firmware builds. + +Fails CI when the runtime stack/heap reserve — the gap between the end of +static allocation (_ebss) and the top-of-RAM stack (_stack) — drops below the +per-variant budget, or when the largest single stack frame (-fstack-usage) +leaves less than the configured margin inside that reserve. + +Why this exists: RC7's privacy-enabled build shipped with an 11,232-byte gap +while msg_write() carried a 12,416-byte automatic TrezorFrameBuffer — every +USB response overwrote static memory, hard-faulting on boot. The linker also +ASSERTs a 16 KiB floor (tools/firmware/keepkey.ld); this script is the +observability + frame-margin half of that gate. + +Usage: + check_sram_budget.py --elf bin/...firmware.keepkey.elf \ + --su-tar bin/stack-usage.tgz --budgets tools/sram-budgets.json \ + --variant full +""" + +import argparse +import json +import sys +import tarfile + +from elftools.elf.elffile import ELFFile # pip install pyelftools + + +def read_symbols(elf_path): + with open(elf_path, "rb") as f: + elf = ELFFile(f) + symtab = elf.get_section_by_name(".symtab") + if symtab is None: + sys.exit(f"ERROR: {elf_path} has no .symtab") + wanted = {} + for sym in symtab.iter_symbols(): + if sym.name in ("_ebss", "_stack"): + wanted[sym.name] = sym["st_value"] + missing = {"_ebss", "_stack"} - set(wanted) + if missing: + sys.exit(f"ERROR: {elf_path} missing symbols: {sorted(missing)}") + return wanted + + +def largest_frames(su_tar_path, top_n=15): + """Parse GCC -fstack-usage records from a tar of .su files. + + Record format: ":::\t\t" + """ + frames = [] + with tarfile.open(su_tar_path, "r:*") as tar: + for member in tar: + if not member.name.endswith(".su") or not member.isfile(): + continue + data = tar.extractfile(member).read().decode("utf-8", "replace") + for line in data.splitlines(): + parts = line.rsplit("\t", 2) + if len(parts) != 3: + continue + loc, size, qual = parts + try: + frames.append((int(size), loc.split("/")[-1], qual)) + except ValueError: + continue + frames.sort(reverse=True) + return frames[:top_n] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--elf", required=True) + ap.add_argument("--su-tar", required=True) + ap.add_argument("--budgets", required=True) + ap.add_argument("--variant", required=True) + args = ap.parse_args() + + budgets = json.load(open(args.budgets)) + reserve_min = budgets.get("variants", {}).get(args.variant, {}).get( + "reserve_min", budgets["reserve_min"]) + frame_margin = budgets.get("variants", {}).get(args.variant, {}).get( + "frame_margin", budgets["frame_margin"]) + + syms = read_symbols(args.elf) + gap = syms["_stack"] - syms["_ebss"] + + frames = largest_frames(args.su_tar) + if not frames: + # An empty .su archive means -fstack-usage generation broke (or the + # tar glob went stale). Treating it as "largest frame = 0" would let + # the margin check false-pass — fail loudly instead. + sys.exit("ERROR: no -fstack-usage records found in " + f"{args.su_tar} — stack-usage generation is broken; " + "refusing to pass the frame-margin gate without data") + largest = frames[0][0] + + print(f"SRAM budget report — variant: {args.variant}") + print(f" _ebss = 0x{syms['_ebss']:08x}") + print(f" _stack = 0x{syms['_stack']:08x}") + print(f" stack/heap reserve (gap) = {gap:,} B " + f"(budget: >= {reserve_min:,} B)") + print(f" largest stack frame = {largest:,} B " + f"(gap - largest must be >= {frame_margin:,} B)") + print(" top stack frames (-fstack-usage):") + for size, loc, qual in frames: + print(f" {size:7,} B {qual:14s} {loc}") + + failed = False + if gap < reserve_min: + print(f"::error::SRAM gate: reserve {gap:,} B < budget " + f"{reserve_min:,} B for {args.variant}") + failed = True + if gap - largest < frame_margin: + print(f"::error::SRAM gate: reserve minus largest frame " + f"({gap:,} - {largest:,} = {gap - largest:,} B) < margin " + f"{frame_margin:,} B for {args.variant}") + failed = True + + if failed: + sys.exit(1) + print("SRAM budget gate: PASS") + + +if __name__ == "__main__": + main() diff --git a/tools/display_test/CMakeLists.txt b/tools/display_test/CMakeLists.txt index 0d280ab80..86da3d16d 100644 --- a/tools/display_test/CMakeLists.txt +++ b/tools/display_test/CMakeLists.txt @@ -6,7 +6,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/display_test.ld) diff --git a/tools/emulator/CMakeLists.txt b/tools/emulator/CMakeLists.txt index 63b80bd88..2882f47a1 100644 --- a/tools/emulator/CMakeLists.txt +++ b/tools/emulator/CMakeLists.txt @@ -6,7 +6,7 @@ if(${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(FIRMWARE_LIBS kkfirmware @@ -21,21 +21,43 @@ if(${KK_EMULATOR}) SecAESSTM32 kkrand) - # Standalone emulator binary (uses UDP sockets) - add_executable(kkemu ${sources}) + # Standalone emulator binary — UDP sockets on :11044/:11045 (used by firmware + # CI: python-keepkey UDP tests + OLED screenshots). NOT built on Windows: it + # depends on BSD sockets + signal(); the vault never uses it — the vault loads + # the dylib/DLL below instead. + if(NOT WIN32) + add_executable(kkemu ${sources}) - # Add linker flags for ARM64 Mac compatibility - if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") - target_link_options(kkemu PRIVATE "-Wl,-no_fixup_chains") - endif() + # Add linker flags for ARM64 Mac compatibility + if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") + target_link_options(kkemu PRIVATE "-Wl,-no_fixup_chains") + endif() - target_link_libraries(kkemu ${FIRMWARE_LIBS} kkemulator) + target_link_libraries(kkemu ${FIRMWARE_LIBS} kkemulator) + endif() - # Shared library (ring buffers, no sockets) for in-process FFI (vault) + # Shared library (ring buffers, no sockets) for in-process FFI — this is what + # the vault loads via bun:ffi (libkkemu.dylib on macOS, .so on Linux, .dll on + # Windows). if(KK_BUILD_DYLIB) - target_link_libraries(kkemulator_dylib ${FIRMWARE_LIBS}) - if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") - target_link_options(kkemulator_dylib PRIVATE "-Wl,-no_fixup_chains") + if(WIN32) + # kkrand and trezorcrypto cross-reference each other (random32 / + # random_uniform). GNU/MinGW ld resolves static archives left-to-right in + # a single pass, so wrap FIRMWARE_LIBS in a linker group. Use the raw + # --start-group/--end-group flags rather than the LINK_GROUP genex: the + # genex needs CMake >= 3.24, but this repo's cmake_minimum_required is + # 3.7.2. macOS ld64 is multi-pass and needs neither. + # Also: MinGW exports nothing from a DLL by default (unlike Mach-O/ELF), + # so export the kkemu_* FFI entry points; and link the Windows CSPRNG + # (bcrypt, BCryptGenRandom) used by emulator/random.c on _WIN32. + target_link_libraries(kkemulator_dylib + -Wl,--start-group ${FIRMWARE_LIBS} -Wl,--end-group bcrypt) + target_link_options(kkemulator_dylib PRIVATE "-Wl,--export-all-symbols") + else() + target_link_libraries(kkemulator_dylib ${FIRMWARE_LIBS}) + if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") + target_link_options(kkemulator_dylib PRIVATE "-Wl,-no_fixup_chains") + endif() endif() endif() endif() diff --git a/tools/firmware/CMakeLists.txt b/tools/firmware/CMakeLists.txt index 18c50e411..0db0bf6e2 100644 --- a/tools/firmware/CMakeLists.txt +++ b/tools/firmware/CMakeLists.txt @@ -9,12 +9,14 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/keepkey.ld) + # Link map for the CI SRAM report/artifact (pairs with the .ld's 16 KiB + # stack-reserve ASSERT and the -fstack-usage frame report). set(CMAKE_EXE_LINKER_FLAGS - "${CMAKE_EXE_LINKER_FLAGS} -T${linker_script} -L${CMAKE_BINARY_DIR}/lib") + "${CMAKE_EXE_LINKER_FLAGS} -T${linker_script} -L${CMAKE_BINARY_DIR}/lib -Wl,-Map=${CMAKE_BINARY_DIR}/bin/firmware.keepkey.map,--cref") set(LINK_FLAGS kkfirmware diff --git a/tools/firmware/keepkey.ld b/tools/firmware/keepkey.ld index 964b43676..9538bc4cd 100644 --- a/tools/firmware/keepkey.ld +++ b/tools/firmware/keepkey.ld @@ -71,3 +71,13 @@ _buttonusr_isr = _comram_end - 4; _data_size = SIZEOF(.data); _codelen = SIZEOF(.text) + SIZEOF(.data) + SIZEOF(.ARM.exidx) + SIZEOF(.version); + +/* Runtime SRAM gate: everything between the end of static allocation (.bss) + * and the top-of-RAM stack is the ONLY memory the running firmware has for + * call frames. RC7's privacy-enabled build shipped with an 11.2 KB gap while + * msg_write() put a 12.4 KB frame on the stack — a guaranteed boot-path + * overwrite of static memory. Never again: require a 16 KiB reserve at link + * time, for every variant. (Initial limit — replace with measured worst-case + * high-water + margin once the -fstack-usage CI reporting has data.) */ +ASSERT((_stack - _ebss) >= 0x4000, + "Insufficient runtime SRAM: require 16 KiB stack/heap reserve between _ebss and _stack"); diff --git a/tools/merge-direction-adjudicated.txt b/tools/merge-direction-adjudicated.txt new file mode 100644 index 000000000..b4de351f8 --- /dev/null +++ b/tools/merge-direction-adjudicated.txt @@ -0,0 +1,41 @@ +# Files merge_direction_gate.py flags that have been LOOKED AT and cleared. +# +# A flag means "the merged tree equals one side while the other side had real +# churn". That is a question, not a verdict: alpha is 218 commits ahead of +# upstream develop and 0 behind, so some of alpha's changes reached the fork's +# develop by another route and taking develop's file loses nothing. +# +# Anything flagged and NOT listed here has not been examined. Add a line only +# after checking, and say what you checked. +# +# format: # + +# --- took develop's file; alpha's changes to it were already present there --- +lib/board/confirm_sm.c # develop's renderer-measured pager supersedes alpha's calc_str_page model; recorded decision, #428 reopened 3x through that seam +lib/firmware/binance.c # develop carries alpha's denom validators plus more +lib/firmware/ethereum_contracts/zxswap.c # develop's churn is a superset +lib/firmware/fsm_msg_tendermint.h # equivalent +unittests/firmware/cosmos.cpp # equivalent +lib/firmware/fsm_msg_binance.h # equivalent +lib/firmware/ethereum_contracts/zxtransERC20.c # develop's #468 tail-binding is the newer fix +lib/firmware/fsm_msg_cosmos.h # equivalent +include/keepkey/board/confirm_sm.h # matches develop's pager, above +unittests/firmware/eos.cpp # equivalent +lib/firmware/ethereum_contracts/saproxy.c # equivalent +scripts/emulator/python-keepkey.Dockerfile # develop pins the builder image by digest + +# --- took alpha's file; every develop-only symbol verified still declared --- +lib/firmware/ethereum_contracts/zxliquidtx.c # alpha has #431 chain binding and #435 deadline fix already; strictly more screens +include/keepkey/firmware/solana.h # develop added nothing alpha lacks +lib/firmware/authenticator.c # develop's is OLDER; alpha has 5 checks develop dropped. develop's only addition (CANCELED, wipeAuthData) is present as AUTH_CANCELLED +include/keepkey/firmware/signtx_tendermint.h # TENDERMINT_SIGNING_* enum present, 2 users in lib/ +include/keepkey/firmware/osmosis.h # 3-arg osmosis_signTxUpdateMsgSend present +include/keepkey/firmware/authenticator.h # see authenticator.c +include/keepkey/firmware/binance.h # binance_isValidDenom + binance_validateTransfer both declared and used +include/keepkey/firmware/eos.h # eos_isSupportedAction + eos_unknownActionPolicyAllows both declared and used +scripts/build/docker/device/release.sh # kktech/firmware@sha256 digest pin present +unittests/board/CMakeLists.txt # kkemulator link dep present +scripts/emulator/Dockerfile # kktech/firmware@sha256 digest pin present +scripts/build/docker/device/debug.sh # digest pin present +scripts/build/docker/emulator/debug.sh # digest pin present +lib/emulator/setup.c # develop's inline /dev/urandom loop moved to lib/emulator/random.c with the same EINTR + short-read handling, plus a Windows BCryptGenRandom path develop lacks diff --git a/tools/merge_direction_gate.py b/tools/merge_direction_gate.py new file mode 100644 index 000000000..2fe232e26 --- /dev/null +++ b/tools/merge_direction_gate.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Direction-of-resolution audit. + +gate.py asks "is this symbol still defined?" -- which a file taken wholesale +from the wrong side passes trivially, because the DEFINITION survives while +every CALLER came from the other branch. + +This asks the question that actually decides the merge: for every file BOTH +branches changed, which side did the merged tree end up equal to, and how much +of the other side's work went with it? +""" +import subprocess, sys, os + +BASE, ALPHA, DEV = '1af2ffe7de', '681df4a0a', 'bd3a1d6e9' + +def sh(*a): + return subprocess.run(a, capture_output=True, text=True).stdout + +def changed(a, b): + return set(sh('git', 'diff', '--name-only', a, b).split()) + +def blob(rev, f): + return sh('git', 'show', f'{rev}:{f}') + +def churn(a, b, f): + out = sh('git', 'diff', '--numstat', a, b, '--', f).split() + return int(out[0]) + int(out[1]) if len(out) >= 2 and out[0].isdigit() else 0 + +ok = set() +try: + for line in open(os.path.join(os.path.dirname(__file__), + 'merge-direction-adjudicated.txt')): + line = line.split('#')[0].strip() + if line: + ok.add(line) +except FileNotFoundError: + pass + +both = sorted(changed(BASE, ALPHA) & changed(BASE, DEV)) +rows = [] +for f in both: + try: + cur = open(f, errors='replace').read() + except IsADirectoryError: + continue # submodule gitlink, not a file this gate can compare + except FileNotFoundError: + rows.append((f, 'DELETED', churn(BASE, ALPHA, f), churn(BASE, DEV, f))) + continue + a_churn, d_churn = churn(BASE, ALPHA, f), churn(BASE, DEV, f) + if cur == blob(ALPHA, f): + side = 'alpha-verbatim' + elif cur == blob(DEV, f): + side = 'DEVELOP-VERBATIM' + else: + side = 'merged' + if f not in ok: + rows.append((f, side, a_churn, d_churn)) + +def show(title, sel): + hits = [r for r in rows if sel(r)] + print(f'\n=== {title}: {len(hits)} ===') + for f, side, a, d in sorted(hits, key=lambda r: -(r[2] + r[3])): + print(f' {side:<17} alpha:{a:<5} develop:{d:<5} {f}') + return hits + +print(f'{len(both)} files changed by BOTH branches, ' + f'{len(ok)} already adjudicated (tools/merge-direction-adjudicated.txt)\n' + + '=' * 72) +bad_a = show('alpha work DROPPED (took develop verbatim, alpha had changed it)', + lambda r: r[1] == 'DEVELOP-VERBATIM') +bad_d = show('develop work AT RISK (took alpha verbatim, develop had changed it)', + lambda r: r[1] == 'alpha-verbatim') +show('genuinely merged', lambda r: r[1] == 'merged') +show('deleted', lambda r: r[1] == 'DELETED') + +print('\n' + '=' * 72) +print(f'{len(bad_a)} files took develop wholesale over alpha changes') +print(f'{len(bad_d)} files took alpha wholesale over develop changes') +sys.exit(1 if (bad_a or bad_d) else 0) diff --git a/tools/merge_symbol_gate.py b/tools/merge_symbol_gate.py new file mode 100644 index 000000000..ea7044223 --- /dev/null +++ b/tools/merge_symbol_gate.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Symbol-survival gate for the alpha<-develop merge. + +A symbol alpha defined may only be dropped if nothing in the merged tree still +references it. + +READ THIS BEFORE TRUSTING A GREEN RUN. This gate is necessary and NOT +sufficient. It reached 0 while 24 of 79 contested files had been taken +byte-identical from the wrong side, because: + + - Both branches define the SAME function names. A file swapped wholesale keeps + every name and only weakens the bodies, so nothing is ever "referenced but + undefined". + - A static function dropped together with its only callers scores as a SAFE + DROP. That is how nine EIP-712 type-validation helpers vanished silently. + - A symbol whose DEFINITION survives while its CALL SITES came from the other + side is invisible here. That is how the whole Maya EVM branch became + unreachable dead code with the gate still green. + +Run tools/merge_direction_gate.py FIRST. It asks the question that actually +decides a merge: which side did each contested FILE come from. + +It also OVER-reports: it does not evaluate #if guards and does not look inside +deps/, so a platform-guarded or vendored definition reads as missing. +""" +import subprocess, re, sys, os, collections + +OLD = '681df4a0a' +DEF = re.compile(r'^[A-Za-z_][\w \*]*\s+\**(\w+)\s*\([^;]*\)\s*\{', re.M) + +def sh(*a): + return subprocess.run(a, capture_output=True, text=True).stdout + +def defs_in(src): + return set(DEF.findall(src)) + +# 1. baseline: every function alpha defined +base = {} +for f in sh('git','ls-tree','-r','--name-only',OLD,'lib/','include/').split(): + if not f.endswith(('.c','.h')): continue + for s in defs_in(sh('git','show',f'{OLD}:{f}')): + base.setdefault(s, f) + +# 2. merged tree: definitions + all text +now_defs, corpus = set(), {} +for root in ('lib','include','unittests','tools'): + for dp,_,fns in os.walk(root): + if 'deps' in dp.split(os.sep): continue + for fn in fns: + if not fn.endswith(('.c','.h','.cpp','.cc')): continue + p = os.path.join(dp,fn) + src = open(p, errors='replace').read() + corpus[p] = src + if dp.startswith(('lib','include')): now_defs |= defs_in(src) + +# 3. dropped symbols that are still referenced +regressions = collections.defaultdict(list) +for sym, f in sorted(base.items()): + if sym in now_defs: continue + hits = [] + pat = re.compile(r'\b%s\s*\(' % re.escape(sym)) + for p, src in corpus.items(): + n = len(pat.findall(src)) + if n: hits.append((p, n)) + if hits: + regressions[(f, sym)] = hits + +test_only = {k: v for k, v in regressions.items() + if all(p.startswith(('unittests','tools')) for p, _ in v)} +real = {k: v for k, v in regressions.items() if k not in test_only} + +print(f'REGRESSIONS: {len(regressions)} (real: {len(real)}, test-only: {len(test_only)})\n') +for label, group in (('REAL -- called from lib/', real), ('TEST-ONLY', test_only)): + print(f'== {label} ==') + for (f, sym), hits in sorted(group.items()): + n = sum(h for _, h in hits) + print(f' {f:<48} {sym:<42} {n} site(s)') + print() +sys.exit(1 if real else 0) diff --git a/tools/sram-budgets.json b/tools/sram-budgets.json new file mode 100644 index 000000000..f5b5b2277 --- /dev/null +++ b/tools/sram-budgets.json @@ -0,0 +1,9 @@ +{ + "_comment": "Per-product SRAM budgets enforced by tools/check_sram_budget.py in CI (and a 16 KiB linker ASSERT in tools/firmware/keepkey.ld). reserve_min = minimum bytes between _ebss and _stack; frame_margin = minimum bytes left after subtracting the largest -fstack-usage frame from the reserve. Initial limits chosen after the RC7 privacy-enabled overflow (11,232 B gap vs a 12,416 B msg_write frame); replace with measured worst-case high-water + margin once hardware instrumentation reports real numbers. Any change to these budgets, and any single-commit SRAM increase above 256 B, needs explicit review.", + "reserve_min": 16384, + "frame_margin": 4096, + "variants": { + "full": {}, + "bitcoin-only": {} + } +} diff --git a/unittests/board/CMakeLists.txt b/unittests/board/CMakeLists.txt index ddad5512d..65fb7c8a3 100644 --- a/unittests/board/CMakeLists.txt +++ b/unittests/board/CMakeLists.txt @@ -5,7 +5,7 @@ set(sources include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) add_executable(board-unit ${sources}) target_link_libraries(board-unit diff --git a/unittests/board/board.cpp b/unittests/board/board.cpp index 7fac041d1..a49997970 100644 --- a/unittests/board/board.cpp +++ b/unittests/board/board.cpp @@ -1,18 +1,20 @@ +// gtest first: confirm_sm.h defines an isprint() macro that collides with the +// standard library's declaration if the C++ headers are pulled in after it. #include "gtest/gtest.h" #include -#include - +#include #include +#include extern "C" { #include "keepkey/board/confirm_sm.h" #include "keepkey/board/font.h" #include "keepkey/board/keepkey_board.h" -#include "keepkey/board/util.h" +#include "keepkey/board/keepkey_display.h" #include "keepkey/board/layout.h" #include "keepkey/board/timer.h" -#include "keepkey/board/keepkey_display.h" +#include "keepkey/board/util.h" #include "keepkey/firmware/app_confirm.h" } @@ -20,25 +22,101 @@ TEST(Board, Shutdown) { EXPECT_EXIT(shutdown(), ::testing::ExitedWithCode(1), ""); } -TEST(Board, MonochromeEvidencePreservesGrayscaleForeground) { - for (uint16_t y = 0; y < 4; y++) { - for (uint16_t x = 0; x < 4; x++) { - EXPECT_FALSE(display_mono_pixel_is_lit(0x00, x, y)); - EXPECT_TRUE(display_mono_pixel_is_lit(0xFF, x, y)); - } +// Exactly BODY_ROWS rows of body text fit on the display: the body starts at +// TOP_MARGIN + font_height + BODY_TOP_MARGIN and advances by font_height + +// BODY_FONT_LINE_PADDING, so row 4 begins at y=66 on a 64px-tall screen and +// draw_char_with_shift() refuses to draw it. Nothing announces that - +// draw_string() simply stops - so a body of BODY_ROWS+1 rows loses its tail +// silently. confirm_helper() pages such bodies instead; these are the +// properties that split has to hold. +namespace { + +constexpr uint32_t kRows = BODY_ROWS; +constexpr uint16_t kWidth = BODY_WIDTH; + +// Real bodies from ethereum.c's layoutEthereumConfirmTx(). All four sit within +// a few characters of the limit, which is why the overflow is value-dependent +// and went unnoticed: swap wstETH for ETH, or 1000000 for 1, and it fits. +const char *const kOverflowing[] = { + // "Unlock full %s balance for withdrawal by %s?" + "Unlock full wstETH balance for withdrawal by " + "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984?", + // "Approve withdrawal of up to %s by %s?" + "Approve withdrawal of up to 1000000 USDC by " + "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984?", + "Approve withdrawal of up to 0.000000000000000001 ETH by " + "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984?", +}; + +// NOTE (alpha<-develop merge): three tests were removed here, and the +// property they asserted is currently UNCOVERED. +// +// They walked alpha's pagination entry point (confirm_body_split) and +// asserted that joining every page reproduces the body exactly and that a +// fitting body is never split. Those are the right properties. develop's +// pager (page_body_confirm) is static inside confirm_sm.c and has no such +// entry point, so the tests could not be retargeted mechanically. +// +// Re-express them against the shipped pager rather than leaving this gap: +// the cheapest route is to expose page_take() for test builds and assert the +// same two properties over it. + +} // namespace + +// The bug: these bodies do not fit, so today they are drawn in part. +TEST(Board, ConfirmBodiesThatOverflowAreDetected) { + for (const char *body : kOverflowing) { + EXPECT_GT(calc_str_line(get_body_font(), body, kWidth), kRows) + << "|" << body << "| no longer overflows; pick a new vector rather " + << "than deleting this case"; } - EXPECT_TRUE(display_mono_pixel_is_lit(0x11, 0, 0)); - EXPECT_FALSE(display_mono_pixel_is_lit(0x11, 1, 0)); - EXPECT_FALSE(display_mono_pixel_is_lit(0x77, 1, 0)); - EXPECT_TRUE(display_mono_pixel_is_lit(0x99, 1, 0)); +} + +// The fix: paging discloses every character. Dropping the ERC-20 spender's +// last three hex digits is what lets a look-alike address pass review. + +// calc_crc32() is what storage_commit() uses to decide a flash write survived, +// so the emulator has to compute what the STM32 peripheral computes. It did +// not: it ran a reflected zlib CRC-32 over word_len *bytes*, meaning a 643-word +// buffer was covered as 643 bytes. The storage suite could not tell a correct +// length from a truncated one, which is precisely the bug the V17 CRC fix was +// about. +// +// These vectors are CRC-32/MPEG-2 (poly 0x04C11DB7, init 0xFFFFFFFF, no +// reflection, no final XOR) over each word's big-endian bytes — what the +// peripheral produces for `CRC_DR = word`. Reading the buffer as uint32_t +// rather than as bytes keeps this independent of host endianness. +TEST(Board, Crc32MatchesTheStm32Peripheral) { + const uint32_t one[] = {0x12345678}; // bytes 12 34 56 78 + EXPECT_EQ(0xDF8A8A2Bu, calc_crc32(one, 1)); + + const uint32_t two[] = {0x12345678, 0x9ABCDEF0}; // ... 9A BC DE F0 + EXPECT_EQ(0x7D24A31Bu, calc_crc32(two, 2)); +} + +// storage_commit() marshals a 2572-byte buffer — 643 words — holding a +// 2569-byte V17 record, so the last meaningful byte is index 2568. It reaches +// storage_wipe() when the CRC disagrees, so a byte outside the CRC is a byte +// whose corruption surfaces later as a decrypt failure instead. +TEST(Board, Crc32CoversTheFinalByteOfTheV17Record) { + alignas(uint32_t) uint8_t buf[2572] = {}; + const uint32_t clean643 = calc_crc32(buf, 643); + const uint32_t clean642 = calc_crc32(buf, 642); + + buf[2568] = 0x01; + + EXPECT_NE(clean643, calc_crc32(buf, 643)) << "byte 2568 is outside the CRC"; + // The regression itself: at sizeof(flash_temp)==2570 the integer division + // gave 642 words = 2568 bytes, and byte 2568 changed nothing. + EXPECT_EQ(clean642, calc_crc32(buf, 642)); } // confirm_body_fits() asks the real renderer whether the body will fit, so it // needs the canvas the renderer draws into. board_init() does this on the // device; here we do the same two steps in the same order. Without it -// layout_get_canvas() is NULL and every measurement is meaningless rather than -// merely wrong, so this fixture is a precondition for the tests below, not -// decoration. +// layout_get_canvas() is NULL and every measurement is meaningless rather +// than merely wrong, so this fixture is a precondition for the tests below, +// not decoration. class BodyFits : public ::testing::Test { protected: void SetUp() override { @@ -47,8 +125,8 @@ class BodyFits : public ::testing::Test { timer_init(); layout_init(display_canvas_init()); // layout_init() starts a 1ms animation tick. These tests only measure - // geometry, and letting the tick run would repaint the canvas underneath - // them. + // geometry, and letting the tick run would repaint the canvas + // underneath them. ualarm(0, 0); signal(SIGALRM, SIG_IGN); ready = true; @@ -82,9 +160,10 @@ TEST_F(BodyFits, ConfirmBodyFits) { // Regression: draw_string_walk() advanced str_write unconditionally, so a // REJECTED final glyph was still consumed and the walk then saw '\0' and // reported that everything fitted. The failure is exactly one glyph wide, - // which is why the earlier three-way sweep of 3,510 bodies missed it: it only - // shows at the precise boundary. 117 digits fill three rows; the 118th is the - // first glyph that cannot be placed and must be reported as not fitting. + // which is why the earlier three-way sweep of 3,510 bodies missed it: it + // only shows at the precise boundary. 117 digits fill three rows; the 118th + // is the first glyph that cannot be placed and must be reported as not + // fitting. std::string digits; for (size_t i = 0; i < 118; i++) digits += "0123456789"[i % 10]; EXPECT_TRUE(confirm_body_fits(digits.substr(0, 117).c_str(), BODY_WIDTH)); @@ -92,61 +171,48 @@ TEST_F(BodyFits, ConfirmBodyFits) { << "a body overflowing by exactly one glyph must not report as fitting"; } -TEST(Board, ConfirmationFormattingRefusesAnySourceLoss) { - const std::string one_too_many(BODY_CHAR_MAX, 'A'); - - // Source overflow must return before confirm() sends a ButtonRequest or - // enters the interactive confirmation state machine. - EXPECT_FALSE(confirm(ButtonRequestType_ButtonRequest_Other, "Overflow", "%s", - one_too_many.c_str())); - - // Expansion is measured after formatting, not from the format string or any - // one argument. This is the shape used by multi-field confirmation bodies. - const std::string left(175, 'L'); - const std::string right(175, 'R'); - EXPECT_FALSE(confirm(ButtonRequestType_ButtonRequest_Other, "Overflow", - "%s::%s", left.c_str(), right.c_str())); -} - -TEST_F(BodyFits, ConstantPowerSeedRowsAreCompleteAndPagedAtRowBoundaries) { - EXPECT_EQ(CONSTANT_POWER_BODY_WIDTH, - KEEPKEY_DISPLAY_WIDTH - (128 + LEFT_MARGIN)); - EXPECT_EQ(CONSTANT_POWER_BODY_WIDTH, 124); - - static const char group[] = - " 1.mushroom 2.mushroom\n" - " 3.mushroom 4.mushroom\n" - " 5.mushroom 6.mushroom\n"; - std::string reassembled; - const char* p = group; - size_t pages = 0; - while (*p) { - const size_t take = confirm_constant_power_subpage_take(p); - ASSERT_GT(take, 0u); - ASSERT_LE(take, strlen(p)); - const std::string page(p, take); - EXPECT_TRUE(page.back() == '\n' || take == strlen(p)); - EXPECT_TRUE(confirm_body_fits_constant_power(page.c_str(), - CONSTANT_POWER_BODY_WIDTH)); - reassembled += page; - p += take; - ASSERT_LT(++pages, 32u); - } - EXPECT_EQ(reassembled, std::string(group)); - EXPECT_GT(pages, 1u); - - const std::string unsplittable = - " 1.mushroom 2.mushroom 3.mushroom 4.mushroom\n"; - EXPECT_FALSE(confirm_body_fits_constant_power(unsplittable.c_str(), - CONSTANT_POWER_BODY_WIDTH)); - EXPECT_EQ(confirm_constant_power_subpage_take(unsplittable.c_str()), 0u); +// Constant-power screens draw from x = 128 + LEFT_MARGIN, because the display +// driver mirrors the right half of the canvas onto the panel. Only +// KEEPKEY_DISPLAY_WIDTH - (128 + LEFT_MARGIN) px exists past that origin, not +// BODY_WIDTH. A body measured from the LEFT margin can therefore be declared +// to fit and still be clipped when it is drawn on the right half. +// +// This is not hypothetical: the seed-backup pages are drawn by exactly that +// layout. Replaying the real font tables and the real placement rules over +// 200,000 random 24-word mnemonics, 1.712% produce a page the renderer clips +// and 0.646% never show one of the words at all, because draw_string_walk() +// stops at the first rejected glyph and drops every character after it, +// including whole later lines. No ellipsis, no warning, no page indicator. +// +// The page below is from one of those mnemonics: 39 of its 41 characters are +// placed, so a user copying their backup writes down "24.observ". +TEST_F(BodyFits, ConstantPowerBodyFitsMeasuresFromItsOwnOrigin) { + static const char kClippedBackupPage[] = + " 22.second\n 23.together 24.observe\n"; + + // From the left margin it fits -- which is why the completeness check, hard + // gated to layout_standard_notification, saw nothing wrong with it. + EXPECT_TRUE(confirm_body_fits(kClippedBackupPage, BODY_WIDTH)) + << "if this ever fails, the test no longer demonstrates the blind spot " + "it exists to pin"; + + // Measured where it is actually drawn, it does not. + EXPECT_FALSE(confirm_body_fits_constant_power(kClippedBackupPage, BODY_WIDTH)) + << "a constant-power page the renderer clips must report as not fitting, " + "so the confirm layer pages it instead of silently dropping the tail " + "of the user's seed"; + + // Control: a short body fits under both probes, so the constant-power probe + // is not simply refusing everything. + EXPECT_TRUE(confirm_body_fits(" 1.abandon", BODY_WIDTH)); + EXPECT_TRUE(confirm_body_fits_constant_power(" 1.abandon", BODY_WIDTH)); } // Regression: calc_str_line() accumulated into a uint8_t while returning // uint32_t, so a body carrying 255 newlines wrapped the count back to 0 and -// confirm_body_fits() reported that it fitted. The 352-byte confirm buffer has -// room for a benign prefix, 255 newlines and a hidden suffix, so the "Cut Off" -// warning was skippable by a host that chose its whitespace. +// confirm_body_fits() reported that it fitted. The 352-byte confirm buffer +// has room for a benign prefix, 255 newlines and a hidden suffix, so the "Cut +// Off" warning was skippable by a host that chose its whitespace. // // Every count here must exceed BODY_ROWS, including the ones that land on and // around an 8-bit boundary. @@ -194,16 +260,18 @@ TEST_F(BodyFits, MeasurementTracksTheRendererNotALineCount) { if (fits) { EXPECT_TRUE(confirm_body_fits(("HEAD" + std::string(pad, ' ')).c_str(), BODY_WIDTH)) - << "dropping the tail made a fitting body stop fitting, pad=" << pad; + << "dropping the tail made a fitting body stop fitting, pad=" + << pad; } else { EXPECT_FALSE(confirm_body_fits(padded.c_str(), BODY_WIDTH_WITH_ICON)) - << "less room turned a clipped body into a fitting one, pad=" << pad; + << "less room turned a clipped body into a fitting one, pad=" + << pad; } } // A body of pure newlines draws nothing at all. The body starts on row 24 - // and each newline steps 14, so the third lands the cursor at 66 -- past the - // last row that can hold a 10px glyph. Up to and including that third + // and each newline steps 14, so the third lands the cursor at 66 -- past + // the last row that can hold a 10px glyph. Up to and including that third // newline every character is still consumed, and nothing has been dropped, // so the body is blank but complete. EXPECT_TRUE(confirm_body_fits("\n\n\n", BODY_WIDTH)); @@ -227,40 +295,14 @@ TEST_F(BodyFits, MeasurementTracksTheRendererNotALineCount) { } } -TEST_F(BodyFits, PagerCanExceedItsOwnPageCap) { - // page_body_confirm() refuses a body needing more than 99 pages rather than - // stopping the count there, because a truncated count makes page 100 the - // "last" page and puts the approving hold on a prefix. - // - // That bound is reachable, which is the point of this test: page_take() sizes - // a page by the largest prefix confirm_body_fits() accepts, and for newlines - // that is three -- they consume rows without drawing a glyph. A body filling - // BODY_CHAR_MAX therefore needs ceil(351 / 3) = 117 pages. - // - // The refusal itself cannot be asserted here: page_body_confirm() is static - // and reaching it means driving real confirm screens, which this binary has - // no canvas or input for. What is asserted is the arithmetic the cap depends - // on, so that a future change to BODY_ROWS or BODY_CHAR_MAX that quietly - // moves the bound fails here rather than in the field. - EXPECT_TRUE(confirm_body_fits(std::string(3, '\n').c_str(), BODY_WIDTH)); - EXPECT_FALSE(confirm_body_fits(std::string(4, '\n').c_str(), BODY_WIDTH)); - - const size_t chars_per_page = 3; - const size_t worst_case_body = BODY_CHAR_MAX - 1; - const size_t pages_needed = - (worst_case_body + chars_per_page - 1) / chars_per_page; - EXPECT_GT(pages_needed, 99u) - << "the 99-page cap is unreachable, so the refusal is dead code"; -} - -static std::string FormatEveryPage(const std::string& input, size_t* pages) { +static std::string FormatEveryPage(const std::string &input, size_t *pages) { std::string rendered; size_t offset = 0; *pages = 0; while (offset < input.size()) { char page[BODY_CHAR_MAX]; const size_t take = confirm_bytes_format_page( - reinterpret_cast(input.data()) + offset, + reinterpret_cast(input.data()) + offset, input.size() - offset, page, sizeof(page)); EXPECT_GT(take, 0u); if (take == 0) break; @@ -273,66 +315,6 @@ static std::string FormatEveryPage(const std::string& input, size_t* pages) { return rendered; } -TEST(Board, EscapeMakesLeadingWhitespaceVisible) { - // The passphrase screen used "%51s", which right-pads to 51 columns, and the - // renderer drops spaces at the start of a wrapped line. "secret" and - // " secret" could therefore draw the same pixels while deriving DIFFERENT - // wallets. confirm_bytes_escape() is what removes that ambiguity, so the two - // must not be able to produce the same string. - char a[64], b[64]; - ASSERT_TRUE(confirm_bytes_escape(reinterpret_cast("secret"), - 6, a, sizeof(a))); - ASSERT_TRUE(confirm_bytes_escape(reinterpret_cast(" secret"), - 7, b, sizeof(b))); - EXPECT_STREQ(a, "secret"); - EXPECT_STREQ(b, "\\x20secret"); - EXPECT_STRNE(a, b); - - // Trailing whitespace is equally invisible on screen and equally load-bearing - // in the derivation. - ASSERT_TRUE(confirm_bytes_escape(reinterpret_cast("secret "), - 7, b, sizeof(b))); - EXPECT_STREQ(b, "secret\\x20"); - EXPECT_STRNE(a, b); - - // A backslash is escaped too, so an escape sequence typed INTO the - // passphrase cannot impersonate one this function produced. The four input - // bytes are 0x5C 'x' '2' '0', spelled out rather than written as a C - // literal so the test cannot be read two ways. - static const uint8_t kFakeEscape[] = {0x5C, 'x', '2', '0'}; - ASSERT_TRUE( - confirm_bytes_escape(kFakeEscape, sizeof(kFakeEscape), b, sizeof(b))); - EXPECT_STREQ(b, "\\x5Cx20"); - - // Zero bytes are inside the declared length and must show, not terminate. - ASSERT_TRUE(confirm_bytes_escape(reinterpret_cast("a\0b"), 3, - b, sizeof(b))); - EXPECT_STREQ(b, "a\\x00b"); - - // Empty input is a valid, empty escape -- the caller decides how to say so. - ASSERT_TRUE(confirm_bytes_escape(nullptr, 0, b, sizeof(b))); - EXPECT_STREQ(b, ""); - - // Fails rather than truncating: half a secret is the ambiguity all over - // again. Four bytes of output hold one escape and its NUL, never two. - char small[5]; - EXPECT_TRUE(confirm_bytes_escape(reinterpret_cast(" "), 1, - small, sizeof(small))); - EXPECT_STREQ(small, "\\x20"); - EXPECT_FALSE(confirm_bytes_escape(reinterpret_cast(" "), 2, - small, sizeof(small))); - EXPECT_STREQ(small, ""); - - // The production buffer must hold the longest possible passphrase: 50 - // visible characters, every one of them escaped. - const std::string worst(50, ' '); - char full[4 * 50 + 1]; - ASSERT_TRUE( - confirm_bytes_escape(reinterpret_cast(worst.data()), - worst.size(), full, sizeof(full))); - EXPECT_EQ(strlen(full), 200u); -} - TEST(Board, ExactBytePagesEscapeRendererWhitespaceAndNul) { static const char raw[] = " benign\nlogin\\\0authorization"; const std::string payload(raw, sizeof(raw) - 1); @@ -354,81 +336,36 @@ TEST(Board, ExactBytePagesNeverApproveOnlyAPrefix) { EXPECT_EQ(rendered.size(), 21u + 900u * 4u + 4u); } -TEST(Board, PostPreviewMutationChangesExactByteReview) { - std::string payload_a(160, 'A'); - std::string payload_b = payload_a; - payload_b[96] = 'B'; - - ASSERT_EQ(payload_a.size(), payload_b.size()); - ASSERT_EQ(0, memcmp(payload_a.data(), payload_b.data(), 32)); - - size_t pages_a = 0; - size_t pages_b = 0; - const std::string review_a = FormatEveryPage(payload_a, &pages_a); - const std::string review_b = FormatEveryPage(payload_b, &pages_b); - - EXPECT_EQ(pages_a, pages_b); - EXPECT_GT(pages_a, 1u); - EXPECT_NE(review_a, review_b) - << "a byte after the old 32-byte Solana preview must change a page"; -} - -TEST(Board, IdentityKeySelectionDisclosesEveryKeySelector) { - IdentityType identity{}; - identity.has_index = true; - identity.index = UINT32_MAX; - identity.has_path = true; - memset(identity.path, 'p', sizeof(identity.path) - 1); - - char selection[CONFIRM_SIGN_IDENTITY_KEY]; - ASSERT_TRUE(format_sign_identity_key_selection(&identity, "ed25519", - selection, sizeof(selection))); - EXPECT_STREQ(selection, - "Index: 4294967295\nCurve: ed25519\nPath: shown next"); - - // The production path uses confirm_bytes(), whose page formatter must retain - // the complete maximum-size path rather than a BODY_CHAR_MAX prefix. - size_t pages = 0; - const std::string path(identity.path); - EXPECT_EQ(FormatEveryPage(path, &pages), path); - EXPECT_EQ(path.size(), sizeof(identity.path) - 1); - EXPECT_GT(pages, 1u); - - identity.has_path = false; - ASSERT_TRUE(format_sign_identity_key_selection(&identity, "secp256k1", - selection, sizeof(selection))); - EXPECT_STREQ(selection, "Index: 4294967295\nCurve: secp256k1\nPath: none"); -} - // base_to_precision() previously used strlcpy(dst, src, n) to copy n DIGITS. -// strlcpy's third argument is the total destination size including the NUL, so -// it copied n-1 and dropped the last digit: a signed "1" rendered 0.00000 and -// "1234567" rendered 1.23456. It also terminated at dest[dest_len], one byte -// past a buffer whose supplied capacity is dest_len. +// strlcpy's third argument is the total destination size including the NUL, +// so it copied n-1 and dropped the last digit: a signed "1" rendered 0.00000 +// and "1234567" rendered 1.23456. It also terminated at dest[dest_len], one +// byte past a buffer whose supplied capacity is dest_len. TEST(Board, BaseToPrecisionKeepsEveryDigit) { uint8_t out[64]; // Fewer digits than the precision: zero-padded fraction, no digit lost. memset(out, 0xAA, sizeof(out)); - ASSERT_EQ(0, base_to_precision(out, (const uint8_t*)"1", sizeof(out), 1, 6)); - EXPECT_EQ(std::string((char*)out), "0.000001"); + ASSERT_EQ(0, + base_to_precision(out, (const uint8_t *)"1", sizeof(out), 1, 6)); + EXPECT_EQ(std::string((char *)out), "0.000001"); // Exactly at the boundary. memset(out, 0xAA, sizeof(out)); - ASSERT_EQ( - 0, base_to_precision(out, (const uint8_t*)"123456", sizeof(out), 6, 6)); - EXPECT_EQ(std::string((char*)out), "0.123456"); + ASSERT_EQ(0, base_to_precision(out, (const uint8_t *)"123456", sizeof(out), + 6, 6)); + EXPECT_EQ(std::string((char *)out), "0.123456"); // One past the boundary: the last digit must survive. memset(out, 0xAA, sizeof(out)); - ASSERT_EQ( - 0, base_to_precision(out, (const uint8_t*)"1234567", sizeof(out), 7, 6)); - EXPECT_EQ(std::string((char*)out), "1.234567"); + ASSERT_EQ(0, base_to_precision(out, (const uint8_t *)"1234567", sizeof(out), + 7, 6)); + EXPECT_EQ(std::string((char *)out), "1.234567"); memset(out, 0xAA, sizeof(out)); - ASSERT_EQ(0, base_to_precision(out, (const uint8_t*)"100000000", sizeof(out), - 9, 6)); - EXPECT_EQ(std::string((char*)out), "100.000000"); + ASSERT_EQ(0, base_to_precision(out, (const uint8_t *)"100000000", + sizeof(out), 9, 6)); + EXPECT_EQ(std::string((char *)out), "100.000000"); } // The NUL must land inside the supplied capacity, never at dest[dest_len]. @@ -437,15 +374,15 @@ TEST(Board, BaseToPrecisionRespectsCapacity) { // "1.234567" is 8 chars + NUL = 9; a capacity of 9 is exactly enough. memset(buf, 0xAA, sizeof(buf)); - ASSERT_EQ(0, base_to_precision(buf, (const uint8_t*)"1234567", 9, 7, 6)); - EXPECT_EQ(std::string((char*)buf), "1.234567"); + ASSERT_EQ(0, base_to_precision(buf, (const uint8_t *)"1234567", 9, 7, 6)); + EXPECT_EQ(std::string((char *)buf), "1.234567"); EXPECT_EQ(buf[9], 0xAA) << "wrote past the supplied capacity"; // One byte short must be refused, not truncated. memset(buf, 0xAA, sizeof(buf)); - EXPECT_EQ(-1, base_to_precision(buf, (const uint8_t*)"1234567", 8, 7, 6)); + EXPECT_EQ(-1, base_to_precision(buf, (const uint8_t *)"1234567", 8, 7, 6)); EXPECT_EQ(buf[0], 0xAA) << "buffer touched on the refusal path"; - EXPECT_EQ(-1, base_to_precision(NULL, (const uint8_t*)"1", 16, 1, 6)); + EXPECT_EQ(-1, base_to_precision(NULL, (const uint8_t *)"1", 16, 1, 6)); EXPECT_EQ(-1, base_to_precision(buf, NULL, 16, 1, 6)); } diff --git a/unittests/crypto/CMakeLists.txt b/unittests/crypto/CMakeLists.txt index 22821adc7..ecb6cadfe 100644 --- a/unittests/crypto/CMakeLists.txt +++ b/unittests/crypto/CMakeLists.txt @@ -6,7 +6,7 @@ set(sources include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) add_executable(crypto-unit ${sources}) target_link_libraries(crypto-unit @@ -17,9 +17,42 @@ target_link_libraries(crypto-unit kkboard.keepkey kkvariant.keepkey kkvariant.salt - kkboard - qrcodegenerator kkrand kkemulator + qrcodegenerator trezorcrypto kktransport) + +# Orchard/Pallas engine + its unit test only exist when privacy is built in. +if(${KK_ZCASH_PRIVACY}) + # Compile the constant-time implementation a second time with test-only + # operation counters. Keeping this separate from trezorcrypto ensures the + # counters are never present in firmware or the normal emulator binary. + add_executable(pallas-ct-unit + pallas_ct.cpp + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto/pallas_ct.c + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto/memzero.c) + target_compile_definitions(pallas-ct-unit PRIVATE PALLAS_CT_TESTING) + target_include_directories(pallas-ct-unit PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_BINARY_DIR}/include + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) + target_link_libraries(pallas-ct-unit gtest_main) + + add_executable(zcash-crypto-unit + ../firmware/zcash.cpp + ${CMAKE_SOURCE_DIR}/lib/firmware/zcash.c + ${CMAKE_SOURCE_DIR}/lib/emulator/random.c) + target_include_directories(zcash-crypto-unit PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/lib/firmware + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto/ed25519-donna) + target_link_libraries(zcash-crypto-unit + gtest_main + trezorcrypto + kkrand) + if(WIN32) + target_link_libraries(zcash-crypto-unit bcrypt) + endif() +endif() diff --git a/unittests/crypto/pallas_ct.cpp b/unittests/crypto/pallas_ct.cpp new file mode 100644 index 000000000..150626fda --- /dev/null +++ b/unittests/crypto/pallas_ct.cpp @@ -0,0 +1,257 @@ +extern "C" { +#include "pallas_ct.h" +} + +#include +#include +#include + +#include "gtest/gtest.h" + +namespace { + +const curve_point kPallasGenerator = { + {/* x = p - 1 */ {0x00000000, 0x09698768, 0x133e46e6, 0x0d31f812, + 0x00000224, 0x00000000, 0x00000000, 0x00000000, + 0x00400000}}, + {/* y = 2 */ {0x00000002, 0x00000000, 0x00000000, 0x00000000, 0x00000000, + 0x00000000, 0x00000000, 0x00000000, 0x00000000}}, +}; + +const bignum256 kPallasPrime = {{0x00000001, 0x09698768, 0x133e46e6, 0x0d31f812, + 0x00000224, 0x00000000, 0x00000000, 0x00000000, + 0x00400000}}; + +const bignum256 kPallasOrder = {{0x00000001, 0x02375908, 0x052a3763, 0x0d31f813, + 0x00000224, 0x00000000, 0x00000000, 0x00000000, + 0x00400000}}; + +bignum256 ScalarWithBit(unsigned bit) { + bignum256 scalar = {{0}}; + scalar.val[bit / BN_BITS_PER_LIMB] = UINT32_C(1) << (bit % BN_BITS_PER_LIMB); + return scalar; +} + +bignum256 DenseScalar() { + bignum256 scalar; + for (size_t i = 0; i < BN_LIMBS - 1; ++i) { + scalar.val[i] = BN_LIMB_MASK; + } + scalar.val[BN_LIMBS - 1] = (UINT32_C(1) << 22) - 1; + return scalar; +} + +bignum256 Max256() { + bignum256 value; + for (size_t i = 0; i < BN_LIMBS - 1; ++i) { + value.val[i] = BN_LIMB_MASK; + } + value.val[BN_LIMBS - 1] = UINT32_C(0x00ffffff); + return value; +} + +bool PointsEqual(const curve_point& lhs, const curve_point& rhs) { + return std::memcmp(&lhs, &rhs, sizeof(lhs)) == 0; +} + +bool BignumsEqual(const bignum256& lhs, const bignum256& rhs) { + return std::memcmp(&lhs, &rhs, sizeof(lhs)) == 0; +} + +void ExpectCountsEqual(const pallas_ct_counts& lhs, + const pallas_ct_counts& rhs) { + EXPECT_EQ(lhs.field_add, rhs.field_add); + EXPECT_EQ(lhs.field_sub, rhs.field_sub); + EXPECT_EQ(lhs.field_mul, rhs.field_mul); + EXPECT_EQ(lhs.field_select, rhs.field_select); + EXPECT_EQ(lhs.point_add, rhs.point_add); + EXPECT_EQ(lhs.point_double, rhs.point_double); + EXPECT_EQ(lhs.scalar_round, rhs.scalar_round); +} + +pallas_ct_counts MultiplyAndCount(const bignum256& scalar, + curve_point* result) { + pallas_ct_counts counts; + pallas_ct_test_reset_counts(); + pallas_ct_point_mult(&scalar, &kPallasGenerator, result); + pallas_ct_test_get_counts(&counts); + return counts; +} + +struct ProgressCapture { + uint32_t calls = 0; + uint32_t last_completed = 0; + uint32_t last_total = 0; + bool monotonic = true; +}; + +void CaptureProgress(uint32_t completed, uint32_t total, void* context) { + auto* capture = static_cast(context); + if (completed <= capture->last_completed) capture->monotonic = false; + capture->calls++; + capture->last_completed = completed; + capture->last_total = total; +} + +TEST(PallasConstantTime, ScalarScheduleDoesNotDependOnSecretBits) { + const bignum256 zero = {{0}}; + const bignum256 one = ScalarWithBit(0); + const bignum256 low_sparse = ScalarWithBit(17); + const bignum256 high_sparse = ScalarWithBit(254); + const bignum256 dense = DenseScalar(); + const bignum256 max_256 = Max256(); + bignum256 order_plus_one = kPallasOrder; + ++order_plus_one.val[0]; + const std::array scalars = { + zero, one, low_sparse, high_sparse, + dense, max_256, kPallasOrder, order_plus_one, + }; + + curve_point result; + const pallas_ct_counts baseline = MultiplyAndCount(scalars[0], &result); + EXPECT_EQ(255u, baseline.scalar_round); + EXPECT_EQ(255u, baseline.point_add); + EXPECT_EQ(510u, baseline.point_double); + + for (size_t i = 1; i < scalars.size(); ++i) { + const pallas_ct_counts counts = MultiplyAndCount(scalars[i], &result); + ExpectCountsEqual(baseline, counts); + } +} + +TEST(PallasConstantTime, ProgressVariantMatchesAndReportsEveryFixedRound) { + const bignum256 scalar = DenseScalar(); + curve_point expected, actual; + pallas_ct_point_mult(&scalar, &kPallasGenerator, &expected); + + ProgressCapture progress; + pallas_ct_point_mult_progress(&scalar, &kPallasGenerator, &actual, + CaptureProgress, &progress); + + EXPECT_TRUE(PointsEqual(expected, actual)); + EXPECT_TRUE(progress.monotonic); + EXPECT_EQ(255u, progress.calls); + EXPECT_EQ(255u, progress.last_completed); + EXPECT_EQ(255u, progress.last_total); +} + +TEST(PallasConstantTime, ZeroAndOneScalarResultsAreCanonical) { + const bignum256 zero = {{0}}; + const bignum256 one = ScalarWithBit(0); + const curve_point identity = {{{0}}, {{0}}}; + curve_point result; + + MultiplyAndCount(zero, &result); + EXPECT_TRUE(PointsEqual(identity, result)); + + MultiplyAndCount(one, &result); + EXPECT_TRUE(PointsEqual(kPallasGenerator, result)); +} + +TEST(PallasConstantTime, PointAdditionHandlesExceptionalCases) { + const curve_point identity = {{{0}}, {{0}}}; + curve_point result; + + pallas_ct_point_add(&identity, &kPallasGenerator, &result); + EXPECT_TRUE(PointsEqual(kPallasGenerator, result)); + + pallas_ct_point_add(&kPallasGenerator, &identity, &result); + EXPECT_TRUE(PointsEqual(kPallasGenerator, result)); + + curve_point inverse = kPallasGenerator; + inverse.y.val[0] = 0x1fffffff; + inverse.y.val[1] = 0x09698767; + inverse.y.val[2] = 0x133e46e6; + inverse.y.val[3] = 0x0d31f812; + inverse.y.val[4] = 0x00000224; + inverse.y.val[8] = 0x00400000; + pallas_ct_point_add(&kPallasGenerator, &inverse, &result); + EXPECT_TRUE(PointsEqual(identity, result)); +} + +TEST(PallasConstantTime, FieldArithmeticCanonicalizesBoundaryValues) { + const bignum256 zero = {{0}}; + const bignum256 one = ScalarWithBit(0); + bignum256 prime_plus_one = kPallasPrime; + bignum256 prime_minus_one = kPallasPrime; + bignum256 prime_minus_two = kPallasPrime; + const bignum256 max_256 = Max256(); + const bignum256 max_reduced = {{0x1ffffffc, 0x03c369c7, 0x06452b4d, + 0x186a17c8, 0x1ffff992, 0x1fffffff, + 0x1fffffff, 0x1fffffff, 0x003fffff}}; + bignum256 result; + ++prime_plus_one.val[0]; + --prime_minus_one.val[0]; + prime_minus_two.val[0] = BN_LIMB_MASK; + --prime_minus_two.val[1]; + + result = kPallasPrime; + pallas_ct_mod_p(&result); + EXPECT_TRUE(BignumsEqual(zero, result)); + + result = prime_plus_one; + pallas_ct_mod_p(&result); + EXPECT_TRUE(BignumsEqual(one, result)); + + result = max_256; + pallas_ct_mod_p(&result); + EXPECT_TRUE(BignumsEqual(max_reduced, result)); + + result = prime_minus_one; + pallas_ct_mul_mod_p(&result, &prime_minus_one); + EXPECT_TRUE(BignumsEqual(one, result)); + + result = prime_minus_one; + pallas_ct_inv_mod_p(&result); + EXPECT_TRUE(BignumsEqual(prime_minus_one, result)); + + pallas_ct_add_mod_p(&prime_minus_one, &prime_minus_one, &result); + EXPECT_TRUE(BignumsEqual(prime_minus_two, result)); + + pallas_ct_sub_mod_p(&zero, &one, &result); + EXPECT_TRUE(BignumsEqual(prime_minus_one, result)); +} + +TEST(PallasConstantTime, ScalarArithmeticAndMultiplicationReduceModOrder) { + const bignum256 zero = {{0}}; + const bignum256 one = ScalarWithBit(0); + bignum256 order_plus_one = kPallasOrder; + bignum256 order_minus_one = kPallasOrder; + const bignum256 max_256 = Max256(); + const bignum256 max_reduced = {{0x1ffffffc, 0x1959f4e7, 0x108159d6, + 0x186a17c6, 0x1ffff992, 0x1fffffff, + 0x1fffffff, 0x1fffffff, 0x003fffff}}; + bignum256 result; + curve_point point; + const curve_point identity = {{{0}}, {{0}}}; + ++order_plus_one.val[0]; + --order_minus_one.val[0]; + + result = kPallasOrder; + pallas_ct_mod_q(&result); + EXPECT_TRUE(BignumsEqual(zero, result)); + + result = order_plus_one; + pallas_ct_mod_q(&result); + EXPECT_TRUE(BignumsEqual(one, result)); + + result = max_256; + pallas_ct_mod_q(&result); + EXPECT_TRUE(BignumsEqual(max_reduced, result)); + + result = order_minus_one; + pallas_ct_mul_mod_q(&result, &order_minus_one); + EXPECT_TRUE(BignumsEqual(one, result)); + + result = order_minus_one; + pallas_ct_add_mod_q(&result, &one); + EXPECT_TRUE(BignumsEqual(zero, result)); + + MultiplyAndCount(kPallasOrder, &point); + EXPECT_TRUE(PointsEqual(identity, point)); + + MultiplyAndCount(order_plus_one, &point); + EXPECT_TRUE(PointsEqual(kPallasGenerator, point)); +} + +} // namespace diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 62757dabb..4dc588f7a 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -1,12 +1,15 @@ +# Only tests that build against BOTH firmware variants belong here. The +# multi-chain suites are appended below under NOT KK_BITCOIN_ONLY -- the merge +# had leaked eight of them (binance, coins, cosmos, eos, ethereum, nano, ripple, +# solana) into this unconditional list as well, so the bitcoin-only build +# compiled them anyway and died on ethereum_address_checksum, which is compiled +# out of that image. recovery.cpp was also listed twice. set(sources - authenticator.cpp - confirm_test_utils.cpp - fsm.cpp dice.cpp recovery.cpp rng_health.cpp - setup_ceremony.cpp signing.cpp + setup_ceremony.cpp storage.cpp test_board.cpp transaction.cpp @@ -20,24 +23,37 @@ set(sources # multi-chain coin AND token table, not just the Bitcoin rows. if(NOT ${KK_BITCOIN_ONLY}) list(APPEND sources + authenticator.cpp binance.cpp coins.cpp cosmos.cpp + eip712.cpp eos.cpp ethereum.cpp + hive.cpp mayachain.cpp nano.cpp osmosis.cpp ripple.cpp + signed_metadata.cpp solana.cpp - thorchain.cpp) + 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. KK_BITCOIN_ONLY forces +# KK_ZCASH_PRIVACY off (see the top-level CMakeLists), so the bitcoin-only build +# skips it. +if(${KK_ZCASH_PRIVACY}) + list(APPEND sources zcash.cpp) endif() include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/lib/firmware ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) add_executable(firmware-unit ${sources}) target_link_libraries(firmware-unit diff --git a/unittests/firmware/authenticator.cpp b/unittests/firmware/authenticator.cpp index 2a0b01014..e5615cb61 100644 --- a/unittests/firmware/authenticator.cpp +++ b/unittests/firmware/authenticator.cpp @@ -3,7 +3,6 @@ extern "C" { #include "trezor/crypto/sha2.h" #include "keepkey/firmware/authenticator.h" -#include "keepkey/firmware/fsm.h" #include "keepkey/firmware/storage.h" void setup(void); @@ -11,8 +10,7 @@ void setup(void); #include "gtest/gtest.h" -#include - +// Shared emulator confirmation driver from thorchain.cpp. bool kkconfirm_preload(int nYes, int nNo); int kkconfirm_drain(void); @@ -25,68 +23,89 @@ static void ensure_auth_storage_initialized(void) { } } -TEST(Authenticator, AuthorizationLossClearsAndReloadsPersistentCache) { +TEST(Authenticator, WipeCancellationFailsClosed) { ensure_auth_storage_initialized(); - ASSERT_TRUE(kkconfirm_preload(1, 0)); - ASSERT_EQ(NOERR, wipeAuthData()); - ASSERT_EQ(0, kkconfirm_drain()); + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_EQ(AUTH_CANCELLED, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); +} - char account_seed[] = "example:alice:JBSWY3DPEHPK3PXP"; +TEST(Authenticator, AddAndRemoveCancellationFailsClosed) { + ensure_auth_storage_initialized(); ASSERT_TRUE(kkconfirm_preload(1, 0)); - ASSERT_EQ(NOERR, addAuthAccount(account_seed)); - ASSERT_EQ(0, kkconfirm_drain()); - ASSERT_FALSE(authenticator_cache_is_empty()); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); + + char cancelled_add[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_EQ(AUTH_CANCELLED, addAuthAccount(cancelled_add)); + EXPECT_EQ(0, kkconfirm_drain()); char account[DOMAIN_SIZE + ACCOUNT_SIZE + 2] = {0}; - authenticator_clear_cache(); - ASSERT_TRUE(authenticator_cache_is_empty()); + EXPECT_EQ(NOACC, getAuthAccount("0", account)); + + char accepted_add[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_EQ(NOERR, addAuthAccount(accepted_add)); + EXPECT_EQ(0, kkconfirm_drain()); + + char cancelled_remove[] = "example:alice"; + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_EQ(AUTH_CANCELLED, removeAuthAccount(cancelled_remove)); + EXPECT_EQ(0, kkconfirm_drain()); EXPECT_EQ(NOERR, getAuthAccount("0", account)); EXPECT_STREQ("example:alice", account); - EXPECT_FALSE(authenticator_cache_is_empty()); - - const struct { - const char* name; - void (*revoke)(void); - } authorization_losses[] = { - {"ClearSession/lock", [] { session_clear(/*clear_pin=*/true); }}, - {"Initialize", [] { fsm_msgInitialize(nullptr); }}, - {"Cancel", [] { fsm_msgCancel(nullptr); }}, - }; - - for (const auto& loss : authorization_losses) { - SCOPED_TRACE(loss.name); - authenticator_test_seed_cache(); - ASSERT_FALSE(authenticator_cache_is_empty()); - loss.revoke(); - ASSERT_TRUE(authenticator_cache_is_empty()); - } ASSERT_TRUE(kkconfirm_preload(1, 0)); - ASSERT_EQ(NOERR, wipeAuthData()); - ASSERT_EQ(0, kkconfirm_drain()); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); } -TEST(Authenticator, RejectedOtpReviewReturnsNoOtp) { +TEST(Authenticator, RejectsAmbiguousDisplayFieldsBeforeMutation) { + char long_domain[] = "domain-is-too-long:alice:JBSWY3DPEHPK3PXP"; + EXPECT_EQ(TOKERR, addAuthAccount(long_domain)); + + char control_domain[] = "bad\ndomain:alice:JBSWY3DPEHPK3PXP"; + EXPECT_EQ(TOKERR, addAuthAccount(control_domain)); + + char long_account[] = "example:account-is-too-long:JBSWY3DPEHPK3PXP"; + EXPECT_EQ(TOKERR, addAuthAccount(long_account)); + + char remove_long[] = "example:account-is-too-long"; + EXPECT_EQ(TOKERR, removeAuthAccount(remove_long)); + + char remove_control[] = "example:bad\naccount"; + EXPECT_EQ(TOKERR, removeAuthAccount(remove_control)); +} + +TEST(Authenticator, RejectsWeakAndDuplicateSecrets) { ensure_auth_storage_initialized(); ASSERT_TRUE(kkconfirm_preload(1, 0)); - ASSERT_EQ(NOERR, wipeAuthData()); - ASSERT_EQ(0, kkconfirm_drain()); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); - char account_seed[] = "example:alice:JBSWY3DPEHPK3PXP"; - ASSERT_TRUE(kkconfirm_preload(1, 0)); - ASSERT_EQ(NOERR, addAuthAccount(account_seed)); - ASSERT_EQ(0, kkconfirm_drain()); + char weak[] = "example:weak:MY"; + EXPECT_EQ(BADSECRET, addAuthAccount(weak)); - char request[] = "example:alice:1:30"; - char otp[9]; - memset(otp, 0xA5, sizeof(otp)); - ASSERT_TRUE(kkconfirm_preload(0, 1)); - EXPECT_EQ(CANCELED, generateOTP(request, otp)); + // The final invalid block fails after earlier blocks have decoded; the + // implementation must still take its cleanup path. + char partially_decoded[] = "example:invalid:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PX!"; + EXPECT_EQ(BADSECRET, addAuthAccount(partially_decoded)); + + char first[] = "example:alice:JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_EQ(NOERR, addAuthAccount(first)); EXPECT_EQ(0, kkconfirm_drain()); - const char zeros[9] = {0}; - EXPECT_EQ(0, memcmp(otp, zeros, sizeof(otp))); + char duplicate[] = "example:alice:KRSXG5DSNFXGOIDBKRSXG5DSNFXGOIDB"; + EXPECT_EQ(DUPLICATE, addAuthAccount(duplicate)); + + char account[DOMAIN_SIZE + ACCOUNT_SIZE + 2] = {0}; + EXPECT_EQ(NOACC, getAuthAccount("1", account)); + + char remove[] = "example:alice"; ASSERT_TRUE(kkconfirm_preload(1, 0)); - EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(NOERR, removeAuthAccount(remove)); EXPECT_EQ(0, kkconfirm_drain()); + EXPECT_EQ(NOACC, getAuthAccount("0", account)); } diff --git a/unittests/firmware/binance.cpp b/unittests/firmware/binance.cpp index cecafc932..1d6f50a8d 100644 --- a/unittests/firmware/binance.cpp +++ b/unittests/firmware/binance.cpp @@ -7,6 +7,7 @@ extern "C" { #include "gtest/gtest.h" #include +#include "trezor/crypto/secp256k1.h" static BinanceTransferMsg transfer(const char* denom, int64_t amount) { BinanceTransferMsg msg = {}; @@ -32,6 +33,7 @@ static BinanceTransferMsg transfer(const char* denom, int64_t amount) { TEST(Binance, DenomBoundsAndGrammar) { EXPECT_TRUE(binance_isValidDenom("BNB")); EXPECT_TRUE(binance_isValidDenom("RUNE-B1A")); + EXPECT_TRUE(binance_isValidDenom("ABCDEFGH-123")); EXPECT_TRUE(binance_isValidDenom("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); EXPECT_FALSE(binance_isValidDenom("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")); EXPECT_FALSE(binance_isValidDenom("bnb")); @@ -44,6 +46,8 @@ TEST(Binance, TransferValidationFailsClosed) { BinanceTransferMsg msg = transfer("RUNE-B1A", 1000000000); EXPECT_TRUE(binance_validateTransfer(&msg)); + msg = transfer("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 1000000000); + EXPECT_TRUE(binance_validateTransfer(&msg)); msg = transfer("BNB", 0); EXPECT_FALSE(binance_validateTransfer(&msg)); msg = transfer("BNB", -1); diff --git a/unittests/firmware/coins.cpp b/unittests/firmware/coins.cpp index dbb727b11..2bdef3557 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/eip712.cpp b/unittests/firmware/eip712.cpp new file mode 100644 index 000000000..2b5c093cd --- /dev/null +++ b/unittests/firmware/eip712.cpp @@ -0,0 +1,108 @@ +extern "C" { +#include "keepkey/firmware/eip712.h" +} + +#include "gtest/gtest.h" + +#include + +TEST(EIP712, AddressRequiresCanonicalTwentyByteHex) { + uint8_t encoded[32] = {0}; + ASSERT_EQ(SUCCESS, + encAddress("0x00112233445566778899aabbccddeeff00112233", encoded)); + for (size_t i = 0; i < 12; i++) EXPECT_EQ(0, encoded[i]); + EXPECT_EQ(0x00, encoded[12]); + EXPECT_EQ(0x11, encoded[13]); + EXPECT_EQ(0x33, encoded[31]); + + EXPECT_NE(SUCCESS, encAddress("0x112233", encoded)); + EXPECT_NE(SUCCESS, + encAddress("00112233445566778899aabbccddeeff00112233", encoded)); + EXPECT_NE(SUCCESS, + encAddress("0x00112233445566778899aabbccddeeff0011223g", encoded)); + EXPECT_NE(SUCCESS, encAddress("0x00112233445566778899aabbccddeeff0011223344", + encoded)); +} + +TEST(EIP712, DynamicBytesRequireCompleteHexOctets) { + uint8_t encoded[32] = {0}; + EXPECT_EQ(SUCCESS, encodeBytes("0x", encoded)); + EXPECT_EQ(SUCCESS, encodeBytes("0x00a1FF", encoded)); + EXPECT_NE(SUCCESS, encodeBytes("00a1", encoded)); + EXPECT_NE(SUCCESS, encodeBytes("0x0", encoded)); + EXPECT_NE(SUCCESS, encodeBytes("0x0z", encoded)); +} + +TEST(EIP712, FixedBytesRequireExactDeclaredLength) { + uint8_t encoded[32]; + memset(encoded, 0xa5, sizeof(encoded)); + ASSERT_EQ(SUCCESS, encodeBytesN("bytes4", "0x0011aAff", encoded)); + EXPECT_EQ(0x00, encoded[0]); + EXPECT_EQ(0x11, encoded[1]); + EXPECT_EQ(0xaa, encoded[2]); + EXPECT_EQ(0xff, encoded[3]); + for (size_t i = 4; i < sizeof(encoded); i++) EXPECT_EQ(0, encoded[i]); + + EXPECT_NE(SUCCESS, encodeBytesN("bytes4", "0x0011aa", encoded)); + EXPECT_NE(SUCCESS, encodeBytesN("bytes4", "0x0011aaff00", encoded)); + EXPECT_NE(SUCCESS, encodeBytesN("bytes0", "0x", encoded)); + EXPECT_NE(SUCCESS, encodeBytesN("bytes33", "0x", encoded)); + EXPECT_NE(SUCCESS, encodeBytesN("bytes4294967297", "0x00", encoded)); + EXPECT_NE(SUCCESS, encodeBytesN("bytes4x", "0x0011aaff", encoded)); +} + +TEST(EIP712, IntegerWidthsCannotWrapIntoValidTypes) { + char types_json[] = + "{\"types\":{\"Test\":[{\"name\":\"value\"," + "\"type\":\"uint4294967552\"}]}}"; + char values_json[] = "{\"message\":{\"value\":\"1\"}}"; + json_t type_nodes[12] = {}; + json_t value_nodes[8] = {}; + const json_t* types = json_create(types_json, type_nodes, 12); + const json_t* values = json_create(values_json, value_nodes, 8); + ASSERT_NE(nullptr, types); + ASSERT_NE(nullptr, values); + + uint8_t hash[32] = {}; + EXPECT_NE(SUCCESS, encode(types, values, "Test", hash)); +} + +TEST(EIP712, FixedStructArraysRequireExactCardinality) { + char types_json[] = + "{\"types\":{" + "\"Person\":[{\"name\":\"name\",\"type\":\"string\"}]," + "\"Group\":[{\"name\":\"members\",\"type\":\"Person[2]\"}]}}"; + char too_few_json[] = "{\"message\":{\"members\":[{\"name\":\"Alice\"}]}}"; + char too_many_json[] = + "{\"message\":{\"members\":[{\"name\":\"Alice\"}," + "{\"name\":\"Bob\"},{\"name\":\"Carol\"}]}}"; + json_t type_nodes[24] = {}; + json_t too_few_nodes[12] = {}; + json_t too_many_nodes[20] = {}; + const json_t* types = json_create(types_json, type_nodes, 24); + const json_t* too_few = json_create(too_few_json, too_few_nodes, 12); + const json_t* too_many = json_create(too_many_json, too_many_nodes, 20); + ASSERT_NE(nullptr, types); + ASSERT_NE(nullptr, too_few); + ASSERT_NE(nullptr, too_many); + + uint8_t hash[32] = {}; + EXPECT_NE(SUCCESS, encode(types, too_few, "Group", hash)); + EXPECT_NE(SUCCESS, encode(types, too_many, "Group", hash)); +} + +TEST(EIP712, MissingTypedValueFailsWithoutDereferencingNull) { + char types_json[] = + "{\"types\":{\"Mail\":[{\"name\":\"from\",\"type\":\"address\"}," + "{\"name\":\"note\",\"type\":\"string\"}]}}"; + char values_json[] = "{\"message\":{\"note\":\"hello\"}}"; + json_t type_nodes[16] = {}; + json_t value_nodes[8] = {}; + const json_t* types = json_create(types_json, type_nodes, 16); + const json_t* values = json_create(values_json, value_nodes, 8); + ASSERT_NE(nullptr, types); + ASSERT_NE(nullptr, values); + + uint8_t hash[32] = {}; + EXPECT_EQ(JSON_TYPE_WNOVAL, encode(types, values, "Mail", hash)); +} diff --git a/unittests/firmware/ethereum.cpp b/unittests/firmware/ethereum.cpp index bac814b96..663d0c0ef 100644 --- a/unittests/firmware/ethereum.cpp +++ b/unittests/firmware/ethereum.cpp @@ -1,11 +1,12 @@ extern "C" { +#include "keepkey/firmware/ethereum.h" +#include "keepkey/firmware/ethereum_contracts/zxappliquid.h" +#include "keepkey/firmware/ethereum_contracts/zxliquidtx.h" +#include "keepkey/firmware/ethereum_tokens.h" #include "keepkey/firmware/eip712.h" #include "keepkey/firmware/ethereum.h" #include "keepkey/firmware/ethereum_contracts.h" -#include "keepkey/firmware/ethereum_contracts/saproxy.h" -#include "keepkey/firmware/ethereum_contracts/thortx.h" #include "keepkey/firmware/ethereum_contracts/zxtransERC20.h" -#include "keepkey/firmware/ethereum_tokens.h" #include "keepkey/firmware/tron.h" #include "trezor/crypto/address.h" #include "messages-ethereum.pb.h" @@ -16,6 +17,9 @@ extern "C" { #include #include +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); + static uint8_t bin_from_ascii(char c) { if ('a' <= c && c <= 'f') return c - 'a' + 0xa; @@ -92,26 +96,6 @@ TEST(Ethereum, AmountFormattingNeverReturnsBlank) { EXPECT_STREQ("AMOUNT TOO LARGE TO DISPLAY", rendered); } -TEST(Ethereum, ContractAmountCallsitesFailClosedAtDisplayBoundary) { - uint8_t max_word[32]; - std::memset(max_word, 0xff, sizeof(max_word)); - char rendered[41]; - - EXPECT_FALSE(sa_formatUint256(max_word, "", rendered, sizeof(rendered))); - EXPECT_FALSE( - sa_formatUint256(max_word, " Token Units", rendered, sizeof(rendered))); - EXPECT_FALSE( - thor_formatUnknownAssetAmount(max_word, rendered, sizeof(rendered))); - - uint8_t one[32] = {}; - one[31] = 1; - ASSERT_TRUE( - sa_formatUint256(one, " Token Units", rendered, sizeof(rendered))); - EXPECT_STREQ("1 Token Units", rendered); - ASSERT_TRUE(thor_formatUnknownAssetAmount(one, rendered, sizeof(rendered))); - EXPECT_STREQ("1 unformatted", rendered); -} - TEST(Ethereum, NativeAmountsUseTheSigningChainsTicker) { bignum256 amount; bn_read_uint64(1500000000000000000ULL, &amount); @@ -121,6 +105,14 @@ TEST(Ethereum, NativeAmountsUseTheSigningChainsTicker) { sizeof(rendered))); EXPECT_STREQ("1.5 AVAX", rendered); + ASSERT_TRUE( + ethereumFormatAmount(&amount, nullptr, 10, rendered, sizeof(rendered))); + EXPECT_STREQ("1.5 ETH", rendered); + + ASSERT_TRUE(ethereumFormatAmount(&amount, nullptr, 8453, rendered, + sizeof(rendered))); + EXPECT_STREQ("1.5 ETH", rendered); + ASSERT_TRUE(ethereumFormatAmount(&amount, nullptr, 42161, rendered, sizeof(rendered))); EXPECT_STREQ("1.5 ETH", rendered); @@ -158,6 +150,212 @@ TEST(Ethereum, TransferAmountUsesTheRequestsSigningChain) { EXPECT_STREQ("1.5 MATIC", rendered); } +TEST(Ethereum, TypedHashSigningRequiresAdvancedMode) { + EXPECT_FALSE(ethereum_typed_hash_policy_allows(false)); + EXPECT_TRUE(ethereum_typed_hash_policy_allows(true)); +} + +TEST(Ethereum, DomainOnlyPrimaryTypeRequiresExactMatch) { + EXPECT_TRUE(ethereum_eip712_is_domain_primary_type("EIP712Domain")); + EXPECT_FALSE(ethereum_eip712_is_domain_primary_type("EIP")); + EXPECT_FALSE(ethereum_eip712_is_domain_primary_type("EIP712Domain[]")); + EXPECT_FALSE(ethereum_eip712_is_domain_primary_type("")); + EXPECT_FALSE(ethereum_eip712_is_domain_primary_type(nullptr)); +} + +static const uint8_t DAI_MAINNET_ADDRESS[20] = { + 0x6b, 0x17, 0x54, 0x74, 0xe8, 0x90, 0x94, 0xc4, 0x4d, 0xa9, + 0x8b, 0x95, 0x4e, 0xed, 0xea, 0xc4, 0x95, 0x27, 0x1d, 0x0f}; +static const uint8_t USDC_MAINNET_ADDRESS[20] = { + 0xa0, 0xb8, 0x69, 0x91, 0xc6, 0x21, 0x8b, 0x36, 0xc1, 0xd1, + 0x9d, 0x4a, 0x2e, 0x9e, 0xb0, 0xce, 0x36, 0x06, 0xeb, 0x48}; + +static EthereumSignTx liquidity_tx( + bool known_token, bool add = true, + const uint8_t* token_address = DAI_MAINNET_ADDRESS) { + EthereumSignTx msg; + memset(&msg, 0, sizeof(msg)); + msg.has_chain_id = true; + msg.chain_id = 1; + msg.has_to = true; + msg.to.size = 20; + memcpy(msg.to.bytes, UNISWAP_ROUTER_ADDRESS, 20); + msg.has_data_initial_chunk = true; + msg.data_initial_chunk.size = 4 + 6 * 32; + memcpy(msg.data_initial_chunk.bytes, + add ? "\xf3\x05\xd7\x19" : "\x02\x75\x1c\xec", 4); + + const TokenType* token = tokenByChainAddress(1, token_address); + EXPECT_NE(UnknownToken, token); + if (token == UnknownToken) return msg; + uint8_t unknown[20]; + memset(unknown, 0xa5, sizeof(unknown)); + memcpy( + msg.data_initial_chunk.bytes + 4 + 32 - 20, + known_token ? reinterpret_cast(token->address) : unknown, + 20); + + // Token desired/minimum and native minimum. + msg.data_initial_chunk.bytes[4 + 2 * 32 - 1] = 1; + msg.data_initial_chunk.bytes[4 + 3 * 32 - 1] = 1; + msg.data_initial_chunk.bytes[4 + 4 * 32 - 1] = 1; + // Recipient and deadline. + memset(msg.data_initial_chunk.bytes + 4 + 5 * 32 - 20, 0x11, 20); + msg.data_initial_chunk.bytes[4 + 6 * 32 - 1] = 1; + msg.has_value = true; + if (add) { + msg.value.size = 1; + msg.value.bytes[0] = 1; + } + return msg; +} + +static void set_word_u64(EthereumSignTx& msg, size_t word, uint64_t value) { + uint8_t* out = msg.data_initial_chunk.bytes + 4 + word * 32; + memset(out, 0, 32); + for (size_t i = 0; i < 8; i++) { + out[31 - i] = static_cast(value); + value >>= 8; + } +} + +static EthereumSignTx approve_liquidity_tx() { + EthereumSignTx msg; + memset(&msg, 0, sizeof(msg)); + msg.has_chain_id = true; + msg.chain_id = 1; + msg.has_to = true; + msg.to.size = 20; + // Canonical mainnet DAI/WETH Uniswap V2 pair. + const uint8_t pair[20] = {0xa4, 0x78, 0xc2, 0x97, 0x5a, 0xb1, 0xea, + 0x89, 0xe8, 0x19, 0x68, 0x11, 0xf5, 0x1a, + 0x7b, 0x7a, 0xde, 0x33, 0xeb, 0x11}; + memcpy(msg.to.bytes, pair, sizeof(pair)); + msg.has_data_initial_chunk = true; + msg.data_initial_chunk.size = 4 + 2 * 32; + memcpy(msg.data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4); + memcpy(msg.data_initial_chunk.bytes + 4 + 12, UNISWAP_ROUTER_ADDRESS, 20); + msg.data_initial_chunk.bytes[4 + 2 * 32 - 1] = 1; + msg.has_value = true; + return msg; +} + +TEST(Ethereum, LiquiditySelectorChecksDeclaredCalldataLength) { + EthereumSignTx msg; + memset(&msg, 0, sizeof(msg)); + msg.has_to = true; + msg.to.size = 20; + memcpy(msg.to.bytes, UNISWAP_ROUTER_ADDRESS, 20); + msg.has_data_initial_chunk = true; + msg.data_initial_chunk.size = 3; + memcpy(msg.data_initial_chunk.bytes, "\xf3\x05\xd7", 3); + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); + + msg.data_initial_chunk.size = 4; + memcpy(msg.data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4); + EXPECT_FALSE(zx_isZxApproveLiquid(&msg)); + + msg.data_initial_chunk.size = 4 + 2 * 32 + 1; + memcpy(msg.data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4); + memcpy(msg.data_initial_chunk.bytes + 4 + 32 - 20, UNISWAP_ROUTER_ADDRESS, + 20); + EXPECT_FALSE(zx_isZxApproveLiquid(&msg)); + + msg.data_initial_chunk.size = 4 + 6 * 32 + 1; + memcpy(msg.data_initial_chunk.bytes, "\xf3\x05\xd7\x19", 4); + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); +} + +TEST(Ethereum, LiquidityCancellationFailsClosed) { + EthereumSignTx msg = liquidity_tx(true); + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_FALSE(zx_confirmZxLiquidTx(msg.data_initial_chunk.size, &msg)); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Ethereum, LiquidityRejectsUnknownTokenBeforeConfirmation) { + EthereumSignTx msg = liquidity_tx(false); + EXPECT_FALSE(zx_confirmZxLiquidTx(msg.data_initial_chunk.size, &msg)); +} + +TEST(Ethereum, LiquidityClearSigningIsMainnetOnly) { + EthereumSignTx msg = liquidity_tx(true); + EXPECT_TRUE(zx_isZxLiquidTx(&msg)); + + msg.chain_id = 137; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); + msg.chain_id = 1; + msg.has_chain_id = false; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); +} + +TEST(Ethereum, LiquidityRejectsTruncatedDeadlineAndNoncanonicalAddresses) { + EthereumSignTx msg = liquidity_tx(true); + msg.data_initial_chunk.bytes[4 + 5 * 32] = 1; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); + EXPECT_FALSE(zx_confirmZxLiquidTx(msg.data_initial_chunk.size, &msg)); + + msg = liquidity_tx(true); + msg.data_initial_chunk.bytes[4] = 1; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); + + msg = liquidity_tx(true); + msg.data_initial_chunk.bytes[4 + 4 * 32] = 1; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); +} + +TEST(Ethereum, RemoveLiquidityRejectsNativeValue) { + EthereumSignTx msg = liquidity_tx(true, false); + EXPECT_TRUE(zx_isZxLiquidTx(&msg)); + msg.value.size = 1; + msg.value.bytes[0] = 1; + EXPECT_FALSE(zx_isZxLiquidTx(&msg)); +} + +TEST(Ethereum, RemoveLiquidityFormatsPrimaryAmountAsLpTokens) { + EthereumSignTx add = liquidity_tx(true, true, USDC_MAINNET_ADDRESS); + set_word_u64(add, 1, UINT64_C(1000000000000000000)); + char formatted[96]; + ASSERT_TRUE( + zx_formatZxLiquidityPrimaryAmount(&add, formatted, sizeof(formatted))); + EXPECT_STREQ("1000000000000 USDC", formatted); + + EthereumSignTx remove = liquidity_tx(true, false, USDC_MAINNET_ADDRESS); + set_word_u64(remove, 1, UINT64_C(1000000000000000000)); + ASSERT_TRUE( + zx_formatZxLiquidityPrimaryAmount(&remove, formatted, sizeof(formatted))); + EXPECT_STREQ("1 LP", formatted); +} + +TEST(Ethereum, LiquidityFormatsFullUint256WithoutBlankConfirmation) { + EthereumSignTx msg = liquidity_tx(true); + memset(msg.data_initial_chunk.bytes + 4 + 32, 0xff, 32); + char formatted[96]; + ASSERT_TRUE( + zx_formatZxLiquidityPrimaryAmount(&msg, formatted, sizeof(formatted))); + EXPECT_GT(strlen(formatted), 32u); + + ASSERT_TRUE(kkconfirm_preload(0, 1)); + EXPECT_FALSE(zx_confirmZxLiquidTx(msg.data_initial_chunk.size, &msg)); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Ethereum, LpApprovalRequiresMainnetDerivedPairAndCanonicalSpender) { + EthereumSignTx msg = approve_liquidity_tx(); + EXPECT_TRUE(zx_isZxApproveLiquid(&msg)); + + msg.to.bytes[0] ^= 1; + EXPECT_FALSE(zx_isZxApproveLiquid(&msg)); + + msg = approve_liquidity_tx(); + msg.chain_id = 137; + EXPECT_FALSE(zx_isZxApproveLiquid(&msg)); + + msg = approve_liquidity_tx(); + msg.data_initial_chunk.bytes[4] = 1; + EXPECT_FALSE(zx_isZxApproveLiquid(&msg)); +} + TEST(Ethereum, Eip712AddressRequiresCanonicalTwentyByteHex) { uint8_t encoded[32] = {0}; ASSERT_EQ(SUCCESS, @@ -172,8 +370,9 @@ TEST(Ethereum, Eip712AddressRequiresCanonicalTwentyByteHex) { encAddress("00112233445566778899aabbccddeeff00112233", encoded)); EXPECT_NE(SUCCESS, encAddress("0x00112233445566778899aabbccddeeff0011223g", encoded)); - EXPECT_NE(SUCCESS, encAddress("0x00112233445566778899aabbccddeeff0011223344", - encoded)); + EXPECT_NE(SUCCESS, encAddress( + "0x00112233445566778899aabbccddeeff0011223344", + encoded)); } // Every EIP-712 field screen used to be a review(), which calls @@ -212,99 +411,11 @@ static const char kTUSD[] = static const char kTGBP[] = "\x00\x00\x00\x00\x44\x13\x78\x00\x8E\xA6\x7F\x42\x84\xA5\x79\x32\xB1\xc0" "\x00\xa5"; -static const uint8_t kNativePseudoAddress[20] = { - 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, - 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee, 0xee}; -TEST(Ethereum, TransferDisplayDoesNotAliasHighChainTokenMetadata) { - EthereumSignTx msg = EthereumSignTx{}; - msg.has_chain_id = true; - msg.chain_id = 257; - msg.has_to = true; - msg.to.size = 20; - std::memcpy(msg.to.bytes, kTUSD, msg.to.size); - msg.has_data_initial_chunk = true; - msg.data_initial_chunk.size = 68; - std::memcpy(msg.data_initial_chunk.bytes, "\xa9\x05\x9c\xbb", 4); - msg.data_initial_chunk.bytes[67] = 1; - msg.address_type = OutputAddressType_TRANSFER; - - ASSERT_TRUE(ethereum_isStandardERC20Transfer(&msg)); - char rendered[32]; - ASSERT_TRUE(ethereumFormatTransferAmount(&msg, rendered, sizeof(rendered))); - EXPECT_STREQ("Unknown token value", rendered); -} - -TEST(Ethereum, NativePseudoAddressCallsRenderUnknownOffMainnet) { - static const uint8_t selectors[][4] = { - {0xa9, 0x05, 0x9c, 0xbb}, /* transfer(address,uint256) */ - {0x09, 0x5e, 0xa7, 0xb3}, /* approve(address,uint256) */ - }; - - for (size_t i = 0; i < sizeof(selectors) / sizeof(selectors[0]); ++i) { - EthereumSignTx msg = EthereumSignTx{}; - msg.has_chain_id = true; - msg.chain_id = 257; - msg.has_to = true; - msg.to.size = sizeof(kNativePseudoAddress); - std::memcpy(msg.to.bytes, kNativePseudoAddress, msg.to.size); - msg.has_data_initial_chunk = true; - msg.data_initial_chunk.size = 68; - std::memcpy(msg.data_initial_chunk.bytes, selectors[i], 4); - msg.data_initial_chunk.bytes[67] = 1; - - if (i == 0) { - ASSERT_TRUE(ethereum_isStandardERC20Transfer(&msg)); - } else { - ASSERT_FALSE(ethereum_isStandardERC20Transfer(&msg)); - } - - const TokenType* token = tokenByChainAddress(msg.chain_id, msg.to.bytes); - ASSERT_EQ(UnknownToken, token); - - bignum256 amount; - bn_from_bytes(msg.data_initial_chunk.bytes + 36, 32, &amount); - char rendered[32]; - ASSERT_TRUE(ethereumFormatAmount(&amount, token, msg.chain_id, rendered, - sizeof(rendered))); - EXPECT_STREQ("Unknown token value", rendered); - } -} - -TEST(Ethereum, NativePseudoAddressTransferFormatterIsUnknownOffMainnet) { - EthereumSignTx msg = EthereumSignTx{}; - msg.has_chain_id = true; - msg.chain_id = 257; - msg.has_to = true; - msg.to.size = sizeof(kNativePseudoAddress); - std::memcpy(msg.to.bytes, kNativePseudoAddress, msg.to.size); - msg.has_data_initial_chunk = true; - msg.data_initial_chunk.size = 68; - std::memcpy(msg.data_initial_chunk.bytes, "\xa9\x05\x9c\xbb", 4); - msg.data_initial_chunk.bytes[67] = 1; - msg.address_type = OutputAddressType_TRANSFER; - - ASSERT_TRUE(ethereum_isStandardERC20Transfer(&msg)); - char rendered[32]; - ASSERT_TRUE(ethereumFormatTransferAmount(&msg, rendered, sizeof(rendered))); - EXPECT_STREQ("Unknown token value", rendered); -} - -TEST(Ethereum, ThorchainNativeAssetUsesOnlyItsZeroAddressSentinel) { - static const uint8_t kZeroAddress[20] = {}; - static const uint8_t kTokenAddress[20] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; - - EXPECT_TRUE(thor_assetIsNative(kZeroAddress)); - EXPECT_FALSE(thor_assetIsNative(kNativePseudoAddress)); - EXPECT_FALSE(thor_assetIsNative(kTokenAddress)); - EXPECT_FALSE(thor_assetIsNative(nullptr)); -} - -// A canonical transformERC20 call with one transformation whose data is one -// byte. The transformation byte is deliberately outside the four static words -// that the retired decoder displayed. -static void MakeTransformErc20(EthereumSignTx* msg, uint8_t transform_byte) { +// transformERC20(address,address,uint256,uint256,(uint32,bytes)[]) — the two +// address words carry the token in their low 20 bytes. +static void MakeTransformErc20(EthereumSignTx* msg, const char* in_token, + const char* out_token) { *msg = EthereumSignTx{}; msg->has_to = true; msg->to.size = 20; @@ -312,63 +423,66 @@ static void MakeTransformErc20(EthereumSignTx* msg, uint8_t transform_byte) { msg->has_chain_id = true; msg->chain_id = 1; msg->has_data_initial_chunk = true; - msg->data_initial_chunk.size = 4 + 11 * 32; + msg->data_initial_chunk.size = 4 + 4 * 32; std::memcpy(msg->data_initial_chunk.bytes, "\x41\x55\x65\xb0", 4); - std::memcpy(msg->data_initial_chunk.bytes + 4 + 12, kTUSD, 20); - std::memcpy(msg->data_initial_chunk.bytes + 4 + 32 + 12, kTGBP, 20); - msg->data_initial_chunk.bytes[4 + 3 * 32 - 1] = 1; // input amount - msg->data_initial_chunk.bytes[4 + 4 * 32 - 1] = 1; // minimum output - msg->data_initial_chunk.bytes[4 + 5 * 32 - 1] = 0xa0; // array offset - msg->data_initial_chunk.bytes[4 + 6 * 32 - 1] = 1; // array length - msg->data_initial_chunk.bytes[4 + 7 * 32 - 1] = 0x20; // element offset - msg->data_initial_chunk.bytes[4 + 8 * 32 - 1] = 1; // deployment nonce - msg->data_initial_chunk.bytes[4 + 9 * 32 - 1] = 0x40; // data offset - msg->data_initial_chunk.bytes[4 + 10 * 32 - 1] = 1; // data length - msg->data_initial_chunk.bytes[4 + 10 * 32] = transform_byte; -} - -TEST(Ethereum, TransformErc20AlwaysRequiresAdvancedMode) { - EthereumSignTx first, second; - MakeTransformErc20(&first, 0x41); - MakeTransformErc20(&second, 0x42); - - ASSERT_EQ(first.data_initial_chunk.size, second.data_initial_chunk.size); - ASSERT_EQ(0, std::memcmp(first.data_initial_chunk.bytes, - second.data_initial_chunk.bytes, - first.data_initial_chunk.size - 32)); - ASSERT_NE(0, std::memcmp(first.data_initial_chunk.bytes, - second.data_initial_chunk.bytes, - first.data_initial_chunk.size)); + if (in_token) std::memcpy(msg->data_initial_chunk.bytes + 4 + 12, in_token, 20); + if (out_token) + std::memcpy(msg->data_initial_chunk.bytes + 4 + 32 + 12, out_token, 20); +} + +TEST(Ethereum, TransformErc20RequiresCompleteCalldataForClearSigning) { + EthereumSignTx msg; + MakeTransformErc20(&msg, kTUSD, kTGBP); + EXPECT_TRUE( + ethereum_contractHandled(msg.data_initial_chunk.size, &msg, nullptr)); EXPECT_FALSE( - ethereum_contractHandled(first.data_initial_chunk.size, &first, nullptr)); - EXPECT_FALSE(ethereum_contractHandled(second.data_initial_chunk.size, &second, - nullptr)); + ethereum_contractHandled(msg.data_initial_chunk.size + 1, &msg, nullptr)); } -TEST(Ethereum, MakerDaoSelectorsAreNotSpecializedForPointRelease) { - struct MakerCall { - const uint8_t selector[4]; - size_t argument_count; - }; - static const MakerCall kCalls[] = { - {{0xc7, 0x40, 0x73, 0xa1}, 1}, // open(address) - {{0x1b, 0x96, 0x81, 0x60}, 5}, // wipeAndFree(...,address) - }; - - for (const MakerCall& call : kCalls) { - EthereumSignTx msg = EthereumSignTx{}; - msg.has_chain_id = true; - msg.chain_id = 1; - msg.has_to = true; - msg.to.size = 20; - msg.has_data_initial_chunk = true; - msg.data_initial_chunk.size = 4 + call.argument_count * 32; - std::memcpy(msg.data_initial_chunk.bytes, call.selector, - sizeof(call.selector)); - - EXPECT_FALSE( - ethereum_contractHandled(msg.data_initial_chunk.size, &msg, nullptr)); +// The decoder shows four values and hides the transformations[] body. That is +// only defensible because the input amount and minimum output amount bound the +// outcome — and ethereumFormatAmount() renders the literal "Unknown token +// value" whenever tokenByChainAddress() misses, so an unresolved token turns +// the bound into nothing while the calldata still executes. +// +// Gating on the lookup rather than on a chain allowlist keeps this correct +// however the tables change. It matters in practice: the generated table +// carries ~1924 entries for chain 1, three each for BSC and Polygon, and NONE +// for Base, Arbitrum or Avalanche, so on those chains every pair fails here. +TEST(Ethereum, TransformErc20RequiresBothTokensResolvable) { + EthereumSignTx msg; + + // Both known -> the device can name what it is showing. + MakeTransformErc20(&msg, kTUSD, kTGBP); + EXPECT_TRUE(ethereum_contractHandled(msg.data_initial_chunk.size, &msg, + nullptr)); + + // Either side unknown -> refuse to claim it, so ethereum.c falls through to + // the raw-calldata path (AdvancedMode-gated, bytes shown). + MakeTransformErc20(&msg, nullptr, kTGBP); + EXPECT_FALSE(ethereum_contractHandled(msg.data_initial_chunk.size, &msg, + nullptr)) + << "unknown INPUT token must not clear-sign"; + + MakeTransformErc20(&msg, kTUSD, nullptr); + EXPECT_FALSE(ethereum_contractHandled(msg.data_initial_chunk.size, &msg, + nullptr)) + << "unknown OUTPUT token must not clear-sign"; + + MakeTransformErc20(&msg, nullptr, nullptr); + EXPECT_FALSE(ethereum_contractHandled(msg.data_initial_chunk.size, &msg, + nullptr)); + + // A chain with no token table entries at all cannot name either asset, so it + // must refuse even though 0x deploys the same proxy there. This is what the + // chain allowlist was previously being asked to approximate. + for (uint32_t cid : {8453u, 42161u, 43114u}) { + MakeTransformErc20(&msg, kTUSD, kTGBP); + msg.chain_id = cid; + EXPECT_FALSE(ethereum_contractHandled(msg.data_initial_chunk.size, &msg, + nullptr)) + << "chain " << cid << " has no token entries; nothing is nameable"; } } @@ -393,8 +507,8 @@ extern "C" { } // The 0x Exchange Proxy lives at the same address on many chains, so the two 0x -// decoders cannot be pinned to mainnet the way the Uniswap and Sablier ones -// are. Optimism is the trap: 0x deploys a DIFFERENT proxy there +// decoders cannot be pinned to mainnet the way the Uniswap and Sablier ones are. +// Optimism is the trap: 0x deploys a DIFFERENT proxy there // (0xdef1abe32c034e558cdd535791643c58a13acc10), so allowing chain 10 for // ZXSWAP_ADDRESS would narrate an unrelated contract. TEST(Ethereum, ZxExchangeProxyChainAllowlist) { @@ -405,8 +519,7 @@ TEST(Ethereum, ZxExchangeProxyChainAllowlist) { EXPECT_TRUE(zx_isExchangeProxyChain(42161)); // Arbitrum EXPECT_TRUE(zx_isExchangeProxyChain(43114)); // Avalanche - EXPECT_FALSE(zx_isExchangeProxyChain(10)) - << "Optimism uses a different 0x proxy"; + EXPECT_FALSE(zx_isExchangeProxyChain(10)) << "Optimism uses a different 0x proxy"; // Default-deny: anything unlisted falls through to generic disclosure. EXPECT_FALSE(zx_isExchangeProxyChain(0)); @@ -415,31 +528,3 @@ TEST(Ethereum, ZxExchangeProxyChainAllowlist) { EXPECT_FALSE(zx_isExchangeProxyChain(59144)); EXPECT_FALSE(zx_isExchangeProxyChain(0xFFFFFFFFu)); } - -TEST(Ethereum, NativePseudoAddressIsStrictlyChainScoped) { - EXPECT_EQ(tokenByChainAddress(1, kNativePseudoAddress), EthTestToken); - EXPECT_EQ(tokenByChainAddress(56, kNativePseudoAddress), UnknownToken); - EXPECT_EQ(tokenByChainAddress(137, kNativePseudoAddress), UnknownToken); - EXPECT_EQ(tokenByChainAddress(257, kNativePseudoAddress), UnknownToken); - - /* The sentinel is ETH metadata and must remain a chain-1-only value. */ - EXPECT_STREQ(EthTestToken->ticker, " ETH"); - EXPECT_TRUE(zx_tokenLabelsThisChain(1, EthTestToken)); - EXPECT_FALSE(zx_tokenLabelsThisChain(56, EthTestToken)); - EXPECT_FALSE(zx_tokenLabelsThisChain(137, EthTestToken)); - EXPECT_FALSE(zx_tokenLabelsThisChain(8453, EthTestToken)); - EXPECT_FALSE(zx_tokenLabelsThisChain(42161, EthTestToken)); - EXPECT_FALSE(zx_tokenLabelsThisChain(43114, EthTestToken)); - - /* Unresolved and NULL stay refused, on every chain -- this helper replaced - the UnknownToken check, so it has to still do that job. */ - EXPECT_FALSE(zx_tokenLabelsThisChain(1, UnknownToken)); - EXPECT_FALSE(zx_tokenLabelsThisChain(56, UnknownToken)); - EXPECT_FALSE(zx_tokenLabelsThisChain(1, NULL)); - - /* An ordinary chain-1 table entry is unaffected. */ - const TokenType* usdc = NULL; - if (tokenByTicker(1, "USDC", &usdc) && usdc != UnknownToken) { - EXPECT_TRUE(zx_tokenLabelsThisChain(1, usdc)); - } -} diff --git a/unittests/firmware/hive.cpp b/unittests/firmware/hive.cpp new file mode 100644 index 000000000..4a90cdba0 --- /dev/null +++ b/unittests/firmware/hive.cpp @@ -0,0 +1,983 @@ +extern "C" { +#include "keepkey/board/font.h" +#include "keepkey/board/layout.h" +#include "keepkey/firmware/hive.h" +} + +#include "gtest/gtest.h" + +#include +#include +#include +#include + +namespace { + +void append_varint(std::vector& out, uint32_t value) { + do { + uint8_t byte = static_cast(value & 0x7f); + value >>= 7; + if (value != 0) byte |= 0x80; + out.push_back(byte); + } while (value != 0); +} + +void append_u16_le(std::vector& out, uint16_t value) { + out.push_back(static_cast(value)); + out.push_back(static_cast(value >> 8)); +} + +void append_u32_le(std::vector& out, uint32_t value) { + for (int i = 0; i < 4; i++) { + out.push_back(static_cast(value >> (8 * i))); + } +} + +void append_string(std::vector& out, const std::string& value) { + append_varint(out, static_cast(value.size())); + out.insert(out.end(), value.begin(), value.end()); +} + +std::string slice(const uint8_t* value, uint16_t len) { + return std::string(reinterpret_cast(value), len); +} + +std::vector comment_tx(const std::string& parent_author, + const std::string& parent_permlink, + const std::string& author, + const std::string& permlink, + const std::string& title, + const std::string& body, + const std::string& json_metadata) { + std::vector tx; + append_u16_le(tx, 12345); + append_u32_le(tx, 67890); + append_u32_le(tx, 1700000000); + append_varint(tx, 1); + append_varint(tx, HIVE_OP_COMMENT); + append_string(tx, parent_author); + append_string(tx, parent_permlink); + append_string(tx, author); + append_string(tx, permlink); + append_string(tx, title); + append_string(tx, body); + append_string(tx, json_metadata); + append_varint(tx, 0); + return tx; +} + +// Call sites pass DISPLAY symbols ("HIVE"/"HBD") because that is what the test +// is about; this helper writes what the chain actually serializes. Verified +// against hived itself via condenser_api.get_transaction_hex — see +// Hive.SerializationMatchesHived. +std::string wire_symbol(const std::string& display) { + if (display == "HIVE") return "STEEM"; + if (display == "HBD") return "SBD"; + return display; +} + +void append_asset(std::vector& out, int64_t amount, uint8_t precision, + const std::string& symbol) { + const std::string wire = wire_symbol(symbol); + uint64_t raw = static_cast(amount); + for (int i = 0; i < 8; i++) { + out.push_back(static_cast(raw >> (8 * i))); + } + out.push_back(precision); + for (size_t i = 0; i < 7; i++) { + out.push_back(i < wire.size() ? static_cast(wire[i]) : 0); + } +} + +// Wrap already-serialized ops in the 10-byte TaPoS header, op count and the +// empty extensions varint that hive_parseOperations expects. +std::vector wrap_ops(const std::vector>& ops) { + std::vector tx; + append_u16_le(tx, 12345); + append_u32_le(tx, 67890); + append_u32_le(tx, 1700000000); + append_varint(tx, static_cast(ops.size())); + for (const std::vector& op : ops) { + tx.insert(tx.end(), op.begin(), op.end()); + } + append_varint(tx, 0); + return tx; +} + +std::vector limit_order_create_op( + const std::string& owner, uint32_t orderid, int64_t sell, + const std::string& sell_symbol, int64_t receive, + const std::string& receive_symbol, bool fill_or_kill, uint32_t expiration) { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, owner); + append_u32_le(op, orderid); + append_asset(op, sell, 3, sell_symbol); + append_asset(op, receive, 3, receive_symbol); + op.push_back(fill_or_kill ? 1 : 0); + append_u32_le(op, expiration); + return op; +} + +// A limit order priced in VESTS at its CORRECT precision (6), so the +// rejection comes from the symbol whitelist rather than the precision check. +std::vector limit_order_vests_op() { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, "alice"); + append_u32_le(op, 1); + append_asset(op, 100, 6, "VESTS"); + append_asset(op, 100, 3, "HBD"); + op.push_back(0); + append_u32_le(op, 1); + return op; +} + +// transfer_to_vesting with a caller-chosen symbol/precision, so the asset +// validator can be probed with values a correct host would never send. +std::vector power_up_op(int64_t amount, uint8_t precision, + const std::string& symbol) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_VESTING); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, amount, precision, symbol); + return op; +} + +std::vector comment_op(const std::string& author, + const std::string& permlink) { + std::vector op; + append_varint(op, HIVE_OP_COMMENT); + append_string(op, ""); + append_string(op, "hive-100"); + append_string(op, author); + append_string(op, permlink); + append_string(op, "Title"); + append_string(op, "Body"); + append_string(op, "{}"); + return op; +} + +// beneficiaries: (account, basis-point weight) pairs; empty = no extension. +std::vector comment_options_op( + const std::string& author, const std::string& permlink, + const std::vector>& beneficiaries) { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, author); + append_string(op, permlink); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + if (beneficiaries.empty()) { + append_varint(op, 0); + } else { + append_varint(op, 1); + append_varint(op, 0); + append_varint(op, static_cast(beneficiaries.size())); + for (const auto& b : beneficiaries) { + append_string(op, b.first); + append_u16_le(op, b.second); + } + } + return op; +} + +std::vector account_update2_op(const std::string& json_metadata, + const std::string& posting_metadata, + bool authority_present) { + std::vector op; + append_varint(op, HIVE_OP_ACCOUNT_UPDATE2); + append_string(op, "alice"); + op.push_back(authority_present ? 1 : 0); + op.push_back(0); + op.push_back(0); + op.push_back(0); + append_string(op, json_metadata); + append_string(op, posting_metadata); + append_varint(op, 0); + return op; +} + +std::vector custom_json_op( + const std::vector& active_auths, + const std::vector& posting_auths, const std::string& id, + const std::string& json) { + std::vector op; + append_varint(op, HIVE_OP_CUSTOM_JSON); + append_varint(op, static_cast(active_auths.size())); + for (const std::string& auth : active_auths) append_string(op, auth); + append_varint(op, static_cast(posting_auths.size())); + for (const std::string& auth : posting_auths) append_string(op, auth); + append_string(op, id); + append_string(op, json); + return op; +} + +} // namespace + +TEST(Hive, Slip48PathValidation) { + uint32_t path[5] = {HIVE_SLIP48_PURPOSE, HIVE_SLIP48_NETWORK, + HIVE_ROLE_ACTIVE, 0x80000007u, 0x80000000u}; + + EXPECT_TRUE(hive_slip48_path_valid(path, 5)); + EXPECT_TRUE(hive_slip48_path_valid_for_role(path, 5, HIVE_ROLE_ACTIVE)); + EXPECT_FALSE(hive_slip48_path_valid_for_role(path, 5, HIVE_ROLE_OWNER)); + EXPECT_FALSE(hive_slip48_path_valid(path, 4)); + + path[0] = 0x8000002cu; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[0] = HIVE_SLIP48_PURPOSE; + path[1] = 0x8000003cu; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[1] = HIVE_SLIP48_NETWORK; + path[2] = 0x80000002u; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[2] = HIVE_ROLE_ACTIVE; + path[3] = 7; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); + path[3] = 0x80000007u; + path[4] = 0; + EXPECT_FALSE(hive_slip48_path_valid(path, 5)); +} + +TEST(Hive, CommentParserRetainsEveryDisplayedField) { + std::vector tx = comment_tx( + "parent-author", "parent-permlink", "reply-author", "reply-permlink", + "Reply title", "Complete reply body", "{\"tags\":[\"keepkey\"]}"); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(1, parsed.num_ops); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_FALSE(op.is_top_level); + EXPECT_EQ("reply-author", slice(op.acct, op.acct_len)); + EXPECT_EQ("parent-author", slice(op.parent_author, op.parent_author_len)); + EXPECT_EQ("parent-permlink", + slice(op.parent_permlink, op.parent_permlink_len)); + EXPECT_EQ("reply-permlink", slice(op.permlink, op.permlink_len)); + EXPECT_EQ("Reply title", slice(op.target, op.target_len)); + EXPECT_EQ("Complete reply body", slice(op.detail, op.detail_len)); + EXPECT_EQ("{\"tags\":[\"keepkey\"]}", + slice(op.json_metadata, op.json_metadata_len)); +} + +// Message signing is restricted to printable ASCII so a message can never be a +// binary transaction preimage (chain_id || serialized_tx) on any chain id. +TEST(Hive, MessagePrintableAcceptsAsciiRejectsBinary) { + const char* login = "keepkey-login-challenge:1700000000"; + EXPECT_TRUE(hive_message_is_printable(reinterpret_cast(login), + strlen(login))); + + // Empty message is trivially printable. + EXPECT_TRUE( + hive_message_is_printable(reinterpret_cast(""), 0)); + + // Any non-printable byte (control char / high bit) is refused. + const uint8_t withNul[] = {'h', 'i', 0x00, 'x'}; + EXPECT_FALSE(hive_message_is_printable(withNul, sizeof(withNul))); + const uint8_t highBit[] = {'o', 'k', 0x80}; + EXPECT_FALSE(hive_message_is_printable(highBit, sizeof(highBit))); + + // The oracle vector: a "message" that begins with the binary mainnet chain id + // (beeab0de00...) followed by a serialized tx. The leading 0xbe/0xea/0x00 + // bytes are non-printable, so this can never be signed as a message. + const uint8_t chainIdPrefixed[] = {0xbe, 0xea, 0xb0, 0xde, 0x00, + 0x00, 0x00, 't', 'x'}; + EXPECT_FALSE( + hive_message_is_printable(chainIdPrefixed, sizeof(chainIdPrefixed))); +} + +TEST(Hive, TopLevelCommentRetainsCategoryAndEmptyTitle) { + std::vector tx = comment_tx("", "hive-123456", "post-author", + "post-permlink", "", "Post body", "{}"); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_TRUE(op.is_top_level); + EXPECT_EQ(0, op.parent_author_len); + EXPECT_EQ("hive-123456", slice(op.parent_permlink, op.parent_permlink_len)); + EXPECT_EQ("post-permlink", slice(op.permlink, op.permlink_len)); + EXPECT_EQ(0, op.target_len); + EXPECT_EQ("{}", slice(op.json_metadata, op.json_metadata_len)); +} + +TEST(Hive, RejectsNonCanonicalVarints) { + std::vector op; + append_varint(op, HIVE_OP_VOTE); + append_string(op, "alice"); + append_string(op, "bob"); + append_string(op, "post"); + append_u16_le(op, 10000); + + HiveParsedTx parsed; + + // Operation count 1 encoded as 0x81 0x00 instead of canonical 0x01. + std::vector overlong_count = wrap_ops({op}); + overlong_count[10] = 0x81; + overlong_count.insert(overlong_count.begin() + 11, 0x00); + EXPECT_NE(nullptr, hive_parseOperations(overlong_count.data(), + overlong_count.size(), &parsed)); + + // The voter string length 5 encoded as 0x85 0x00. + std::vector overlong_string = wrap_ops({op}); + overlong_string[12] = 0x85; + overlong_string.insert(overlong_string.begin() + 13, 0x00); + EXPECT_NE(nullptr, hive_parseOperations(overlong_string.data(), + overlong_string.size(), &parsed)); +} + +TEST(Hive, RejectsAccountNamesThatCanSpoofTheDisplay) { + HiveParsedTx parsed; + const std::vector invalid = { + "al\nice", std::string("ali\0ce", 6), "Alice", "alice-", ".alice", "a"}; + for (const std::string& account : invalid) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_SAVINGS); + append_string(op, account); + append_string(op, "bob"); + append_asset(op, 1000, 3, "HIVE"); + append_string(op, ""); + std::vector tx = wrap_ops({op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } +} + +TEST(Hive, CustomJsonRetainsAndBoundsEveryAuthorization) { + HiveParsedTx parsed; + std::vector tx = + wrap_ops({custom_json_op({}, {"alice", "bob", "carol"}, "follow", "[]")}); + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + const HiveTxOp& op = parsed.ops[0]; + ASSERT_EQ(3, op.n_auths); + EXPECT_EQ("alice", slice(op.auth_acct[0], op.auth_acct_len[0])); + EXPECT_EQ("bob", slice(op.auth_acct[1], op.auth_acct_len[1])); + EXPECT_EQ("carol", slice(op.auth_acct[2], op.auth_acct_len[2])); + EXPECT_FALSE(parsed.needs_active); + + std::vector too_many = wrap_ops({custom_json_op( + {}, {"alice", "bob", "carol", "dave", "erin"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(too_many.data(), too_many.size(), &parsed)); + + std::vector unsorted = + wrap_ops({custom_json_op({}, {"bob", "alice"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(unsorted.data(), unsorted.size(), &parsed)); + + std::vector duplicate = + wrap_ops({custom_json_op({}, {"alice", "alice"}, "follow", "[]")}); + EXPECT_NE(nullptr, + hive_parseOperations(duplicate.data(), duplicate.size(), &parsed)); +} + +TEST(Hive, DisplayPaginationUsesRenderedBodyRows) { + const std::string payload = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_GT(calc_str_line(get_body_font(), payload.c_str(), BODY_WIDTH), + BODY_ROWS); + + std::string reconstructed; + size_t offset = 0; + unsigned pages = 0; + while (offset < payload.size()) { + size_t take = calc_str_page(get_body_font(), payload.data() + offset, + payload.size() - offset, BODY_WIDTH, BODY_ROWS); + ASSERT_GT(take, 0u); + const std::string page = payload.substr(offset, take); + EXPECT_LE(calc_str_line(get_body_font(), page.c_str(), BODY_WIDTH), + BODY_ROWS); + reconstructed += page; + offset += take; + pages++; + } + + EXPECT_GT(pages, 1u); + EXPECT_EQ(payload, reconstructed); +} + +// ── Phase-3 op table ──────────────────────────────────────────────────────── + +// The op that started this: a HIVE->HBD internal-market swap. Every field the +// approval screen shows must survive the parse. +TEST(Hive, LimitOrderCreateRetainsEveryDisplayedField) { + std::vector tx = wrap_ops({limit_order_create_op( + "alice", 42, 1500, "HIVE", 400, "HBD", true, 1700003600)}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(1, parsed.num_ops); + const HiveTxOp& op = parsed.ops[0]; + EXPECT_EQ(HIVE_OP_LIMIT_ORDER_CREATE, op.op_type); + EXPECT_EQ("alice", slice(op.acct, op.acct_len)); + EXPECT_EQ(42u, op.req_id); + EXPECT_EQ(1700003600u, op.expiration); + EXPECT_TRUE(op.flag); // fill_or_kill + ASSERT_EQ(2, op.n_assets); + EXPECT_EQ(1500u, hive_assetAmount(op.assets[0])); + EXPECT_STREQ("HIVE", hive_assetSymbol(op.assets[0])); + EXPECT_EQ(3, hive_assetPrecision(op.assets[0])); + EXPECT_EQ(400u, hive_assetAmount(op.assets[1])); + EXPECT_STREQ("HBD", hive_assetSymbol(op.assets[1])); + // Trading needs the active key. + EXPECT_TRUE(parsed.needs_active); +} + +TEST(Hive, LimitOrderRejectsDegenerateOrders) { + HiveParsedTx parsed; + + // A same-symbol pair is a no-op trade on screen but still burns the fill. + std::vector same = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 100, "HIVE", false, 1)}); + EXPECT_NE(nullptr, hive_parseOperations(same.data(), same.size(), &parsed)); + + std::vector zero_sell = wrap_ops( + {limit_order_create_op("alice", 1, 0, "HIVE", 100, "HBD", false, 1)}); + EXPECT_NE(nullptr, + hive_parseOperations(zero_sell.data(), zero_sell.size(), &parsed)); + + std::vector zero_recv = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 0, "HBD", false, 1)}); + EXPECT_NE(nullptr, + hive_parseOperations(zero_recv.data(), zero_recv.size(), &parsed)); + + // VESTS never trades on the internal market. + std::vector vests = wrap_ops({limit_order_vests_op()}); + EXPECT_NE(nullptr, hive_parseOperations(vests.data(), vests.size(), &parsed)); +} + +TEST(Hive, LimitOrderCancelParses) { + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CANCEL); + append_string(op, "alice"); + append_u32_le(op, 42); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ(42u, parsed.ops[0].req_id); + EXPECT_TRUE(parsed.needs_active); +} + +// The asset validator is what stops a host from moving the decimal point or +// swapping a ~2000x-more-valuable symbol behind an identical-looking number. +TEST(Hive, AssetValidatorPinsSymbolAndPrecision) { + HiveParsedTx parsed; + + std::vector ok = wrap_ops({power_up_op(1000, 3, "HIVE")}); + EXPECT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + + // transfer_to_vesting is HIVE-only; HBD and VESTS are out of the whitelist. + std::vector hbd = wrap_ops({power_up_op(1000, 3, "HBD")}); + EXPECT_NE(nullptr, hive_parseOperations(hbd.data(), hbd.size(), &parsed)); + std::vector vests = wrap_ops({power_up_op(1000, 6, "VESTS")}); + EXPECT_NE(nullptr, hive_parseOperations(vests.data(), vests.size(), &parsed)); + + // Right symbol, wrong precision: 1000 would render as 0.001 vs 1.000. + std::vector prec = wrap_ops({power_up_op(1000, 6, "HIVE")}); + EXPECT_NE(nullptr, hive_parseOperations(prec.data(), prec.size(), &parsed)); + + // A negative int64 would print as an enormous positive number. + std::vector negative = wrap_ops({power_up_op(-1000, 3, "HIVE")}); + EXPECT_NE(nullptr, + hive_parseOperations(negative.data(), negative.size(), &parsed)); + + // Unknown symbol, and a longer symbol sharing an accepted prefix. + std::vector unknown = wrap_ops({power_up_op(1000, 3, "SBD")}); + EXPECT_NE(nullptr, + hive_parseOperations(unknown.data(), unknown.size(), &parsed)); + std::vector prefixed = wrap_ops({power_up_op(1000, 3, "HIVEX")}); + EXPECT_NE(nullptr, + hive_parseOperations(prefixed.data(), prefixed.size(), &parsed)); +} + +// Zero is a real instruction for some ops and nonsense for others; the parser +// must not apply one blanket rule. +TEST(Hive, ZeroAmountSemanticsDifferPerOp) { + HiveParsedTx parsed; + + // Zero HIVE power-up: nothing to do, reject. + std::vector power_up = wrap_ops({power_up_op(0, 3, "HIVE")}); + EXPECT_NE(nullptr, + hive_parseOperations(power_up.data(), power_up.size(), &parsed)); + + // Zero VESTS withdraw_vesting: cancels an in-progress power-down, accept. + std::vector stop_pd; + { + std::vector op; + append_varint(op, HIVE_OP_WITHDRAW_VESTING); + append_string(op, "alice"); + append_asset(op, 0, 6, "VESTS"); + stop_pd = wrap_ops({op}); + } + EXPECT_EQ(nullptr, + hive_parseOperations(stop_pd.data(), stop_pd.size(), &parsed)); + + // Zero VESTS delegation: removes an existing delegation, accept. + std::vector undelegate; + { + std::vector op; + append_varint(op, HIVE_OP_DELEGATE_VESTING_SHARES); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, 0, 6, "VESTS"); + undelegate = wrap_ops({op}); + } + EXPECT_EQ(nullptr, hive_parseOperations(undelegate.data(), undelegate.size(), + &parsed)); + + // claim_reward_balance with all three at zero: nothing to claim, reject. + std::vector empty_claim; + { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 0, 3, "HIVE"); + append_asset(op, 0, 3, "HBD"); + append_asset(op, 0, 6, "VESTS"); + empty_claim = wrap_ops({op}); + } + EXPECT_NE(nullptr, hive_parseOperations(empty_claim.data(), + empty_claim.size(), &parsed)); +} + +TEST(Hive, ClaimRewardBalanceKeepsAssetOrder) { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 1234, 3, "HIVE"); + append_asset(op, 5678, 3, "HBD"); + append_asset(op, 90123456, 6, "VESTS"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + ASSERT_EQ(3, parsed.ops[0].n_assets); + EXPECT_EQ(1234u, hive_assetAmount(parsed.ops[0].assets[0])); + EXPECT_STREQ("HIVE", hive_assetSymbol(parsed.ops[0].assets[0])); + EXPECT_EQ(5678u, hive_assetAmount(parsed.ops[0].assets[1])); + EXPECT_STREQ("HBD", hive_assetSymbol(parsed.ops[0].assets[1])); + EXPECT_EQ(90123456u, hive_assetAmount(parsed.ops[0].assets[2])); + EXPECT_STREQ("VESTS", hive_assetSymbol(parsed.ops[0].assets[2])); + // Claiming rewards is a posting-tier action. + EXPECT_FALSE(parsed.needs_active); +} + +// SECURITY: comment_options redirects a post's payout. Detached from its +// comment it could retarget a post the user published earlier and is not +// reviewing on screen. +TEST(Hive, CommentOptionsMustBindToItsComment) { + HiveParsedTx parsed; + + std::vector alone = + wrap_ops({comment_options_op("alice", "my-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(alone.data(), alone.size(), &parsed)); + + std::vector wrong_permlink = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "other-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(wrong_permlink.data(), + wrong_permlink.size(), &parsed)); + + std::vector wrong_author = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("mallory", "my-post", {})}); + EXPECT_NE(nullptr, hive_parseOperations(wrong_author.data(), + wrong_author.size(), &parsed)); + + std::vector ok = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", {})}); + ASSERT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + EXPECT_EQ(2, parsed.num_ops); + EXPECT_EQ(10000, parsed.ops[1].weight); // percent_hbd + EXPECT_FALSE(parsed.needs_active); +} + +TEST(Hive, CommentOptionsBeneficiaryRules) { + HiveParsedTx parsed; + + std::vector ok = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 1000}, {"zoe", 500}})}); + ASSERT_EQ(nullptr, hive_parseOperations(ok.data(), ok.size(), &parsed)); + ASSERT_EQ(2, parsed.ops[1].n_benef); + EXPECT_EQ("aaron", slice(parsed.ops[1].benef_acct[0], + parsed.ops[1].benef_acct_len[0])); + EXPECT_EQ(1000, parsed.ops[1].benef_weight[0]); + EXPECT_EQ("zoe", slice(parsed.ops[1].benef_acct[1], + parsed.ops[1].benef_acct_len[1])); + + // hived requires strictly ascending names; unsorted is rejected on-chain. + std::vector unsorted = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"zoe", 500}, {"aaron", 1000}})}); + EXPECT_NE(nullptr, + hive_parseOperations(unsorted.data(), unsorted.size(), &parsed)); + + // Duplicates are the same violation. + std::vector duped = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 500}, {"aaron", 500}})}); + EXPECT_NE(nullptr, hive_parseOperations(duped.data(), duped.size(), &parsed)); + + // Weights may not add up to more than 100%. + std::vector overweight = + wrap_ops({comment_op("alice", "my-post"), + comment_options_op("alice", "my-post", + {{"aaron", 6000}, {"zoe", 5000}})}); + EXPECT_NE(nullptr, hive_parseOperations(overweight.data(), overweight.size(), + &parsed)); +} + +// SECURITY: account_update2 can rotate account keys. Only the profile-metadata +// form is in the table — the op-9/10 exclusion applied field-level. +TEST(Hive, AccountUpdate2RejectsAuthorityChanges) { + HiveParsedTx parsed; + + std::vector authority = + wrap_ops({account_update2_op("{\"profile\":{}}", "", true)}); + EXPECT_NE(nullptr, + hive_parseOperations(authority.data(), authority.size(), &parsed)); + + std::vector empty = wrap_ops({account_update2_op("", "", false)}); + EXPECT_NE(nullptr, hive_parseOperations(empty.data(), empty.size(), &parsed)); + + // json_metadata is an active-key field. + std::vector active = + wrap_ops({account_update2_op("{\"profile\":{}}", "", false)}); + ASSERT_EQ(nullptr, + hive_parseOperations(active.data(), active.size(), &parsed)); + EXPECT_TRUE(parsed.needs_active); + + // posting_json_metadata alone stays on the posting tier. + std::vector posting = + wrap_ops({account_update2_op("", "{\"profile\":{}}", false)}); + ASSERT_EQ(nullptr, + hive_parseOperations(posting.data(), posting.size(), &parsed)); + EXPECT_FALSE(parsed.needs_active); +} + +TEST(Hive, SavingsWithdrawRetainsDisplayedFields) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_FROM_SAVINGS); + append_string(op, "alice"); + append_u32_le(op, 7); + append_string(op, "bob"); + append_asset(op, 2500, 3, "HBD"); + append_string(op, "rent"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + // request_id sits BETWEEN from and to on the wire — the easiest field-order + // bug to make in this op, and the one that would swap displayed accounts. + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ(7u, parsed.ops[0].req_id); + EXPECT_EQ("bob", slice(parsed.ops[0].target, parsed.ops[0].target_len)); + EXPECT_EQ("rent", slice(parsed.ops[0].detail, parsed.ops[0].detail_len)); + EXPECT_EQ(2500u, hive_assetAmount(parsed.ops[0].assets[0])); + EXPECT_TRUE(parsed.needs_active); +} + +TEST(Hive, SavingsDepositRetainsDisplayedFields) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_SAVINGS); + append_string(op, "alice"); + append_string(op, "bob"); + append_asset(op, 1500, 3, "HIVE"); + append_string(op, ""); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ("alice", slice(parsed.ops[0].acct, parsed.ops[0].acct_len)); + EXPECT_EQ("bob", slice(parsed.ops[0].target, parsed.ops[0].target_len)); + EXPECT_EQ(0, parsed.ops[0].detail_len); // empty memo is legal + EXPECT_EQ(1500u, hive_assetAmount(parsed.ops[0].assets[0])); +} + +// An empty `to` means "power up to self" on Hive, not a malformed field. +TEST(Hive, PowerUpAcceptsEmptyDestination) { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_TO_VESTING); + append_string(op, "alice"); + append_string(op, ""); + append_asset(op, 1000, 3, "HIVE"); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_EQ(0, parsed.ops[0].target_len); +} + +// Truncating any op body by one byte must be refused, never partially parsed: +// the signature covers the whole buffer, so a short read would mean signing +// bytes the device never looked at. +TEST(Hive, TruncatedOpBodiesRejected) { + std::vector> bodies; + bodies.push_back( + limit_order_create_op("alice", 1, 100, "HIVE", 50, "HBD", false, 9)); + bodies.push_back(power_up_op(1000, 3, "HIVE")); + { + std::vector op; + append_varint(op, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(op, "alice"); + append_asset(op, 1, 3, "HIVE"); + append_asset(op, 1, 3, "HBD"); + append_asset(op, 1, 6, "VESTS"); + bodies.push_back(op); + } + { + std::vector op; + append_varint(op, HIVE_OP_TRANSFER_FROM_SAVINGS); + append_string(op, "alice"); + append_u32_le(op, 7); + append_string(op, "bob"); + append_asset(op, 2500, 3, "HBD"); + append_string(op, "memo"); + bodies.push_back(op); + } + + HiveParsedTx parsed; + for (const std::vector& body : bodies) { + ASSERT_EQ(nullptr, hive_parseOperations(wrap_ops({body}).data(), + wrap_ops({body}).size(), &parsed)); + for (size_t cut = 1; cut < body.size(); cut++) { + std::vector truncated(body.begin(), body.end() - cut); + std::vector tx = wrap_ops({truncated}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)) + << "op type " << (unsigned)body[0] << " truncated by " << cut; + } + } +} + +TEST(Hive, CommentOptionsExtensionShapeRejected) { + HiveParsedTx parsed; + const std::vector comment = comment_op("alice", "my-post"); + + // More than one extension could split beneficiaries past a per-extension cap. + { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 2); + std::vector tx = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + // Only comment_payout_beneficiaries (tag 0) is in the table. + { + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 1); + append_varint(op, 1); // tag != 0 + std::vector tx = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + // Zero beneficiaries in a present extension is malformed, not "none". + { + std::vector tx = + wrap_ops({comment, comment_options_op("alice", "my-post", {})}); + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + std::vector op; + append_varint(op, HIVE_OP_COMMENT_OPTIONS); + append_string(op, "alice"); + append_string(op, "my-post"); + append_asset(op, 1000000, 3, "HBD"); + append_u16_le(op, 10000); + op.push_back(1); + op.push_back(1); + append_varint(op, 1); + append_varint(op, 0); + append_varint(op, 0); // n_benef = 0 + std::vector bad = wrap_ops({comment, op}); + EXPECT_NE(nullptr, hive_parseOperations(bad.data(), bad.size(), &parsed)); + } +} + +TEST(Hive, AccountUpdate2RejectsNonEmptyExtensions) { + std::vector op; + append_varint(op, HIVE_OP_ACCOUNT_UPDATE2); + append_string(op, "alice"); + for (int i = 0; i < 4; i++) op.push_back(0); + append_string(op, "{\"profile\":{}}"); + append_string(op, ""); + append_varint(op, 1); // extensions must be empty + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// Graphene bools are one byte; anything but 0/1 is a host serializer bug. +TEST(Hive, RejectsNonCanonicalBool) { + // limit_order_create's fill_or_kill byte, set to 2. + std::vector op; + append_varint(op, HIVE_OP_LIMIT_ORDER_CREATE); + append_string(op, "alice"); + append_u32_le(op, 1); + append_asset(op, 100, 3, "HIVE"); + append_asset(op, 50, 3, "HBD"); + op.push_back(2); + append_u32_le(op, 9); + std::vector tx = wrap_ops({op}); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +TEST(Hive, MixedTierOpsRejected) { + // vote is posting-tier, convert is active-tier; one signature cannot + // satisfy both post-HF28. + std::vector vote; + append_varint(vote, HIVE_OP_VOTE); + append_string(vote, "alice"); + append_string(vote, "bob"); + append_string(vote, "a-post"); + append_u16_le(vote, 10000); + + std::vector convert; + append_varint(convert, HIVE_OP_CONVERT); + append_string(convert, "alice"); + append_u32_le(convert, 1); + append_asset(convert, 1000, 3, "HBD"); + + std::vector tx = wrap_ops({vote, convert}); + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +TEST(Hive, ExcludedAndUnknownOpsStillRejected) { + HiveParsedTx parsed; + + // Ops 2/9/10 keep their dedicated message types — never fold them in. + for (uint32_t excluded : {static_cast(HIVE_OP_TRANSFER), + static_cast(HIVE_OP_ACCOUNT_CREATE), + static_cast(HIVE_OP_ACCOUNT_UPDATE)}) { + std::vector op; + append_varint(op, excluded); + append_string(op, "alice"); + std::vector tx = wrap_ops({op}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + } + + // Anything outside the table is refused; there is no blind-sign fallback. + // 49 = recurrent_transfer, a real op deliberately not in the table. + std::vector unknown; + append_varint(unknown, 49); + append_string(unknown, "alice"); + std::vector tx = wrap_ops({unknown}); + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// Trailing bytes after a well-formed op must not be silently accepted: the +// signature covers them, so what the device displays would be a subset of +// what it signs. +TEST(Hive, TrailingBytesRejected) { + std::vector tx = wrap_ops( + {limit_order_create_op("alice", 1, 100, "HIVE", 50, "HBD", false, 1)}); + tx.push_back(0xff); + + HiveParsedTx parsed; + EXPECT_NE(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); +} + +// --------------------------------------------------------------------------- +// Golden vectors produced by hived itself: +// +// curl -X POST https://api.hive.blog -H 'Content-Type: application/json' \ +// -d '{"jsonrpc":"2.0","method":"condenser_api.get_transaction_hex", +// "params":[],"id":1}' +// +// These exist because our serializer and this parser were byte-exact mirrors +// of EACH OTHER while both disagreed with the chain: we wrote "HIVE"/"HBD" +// where hived writes "STEEM"/"SBD". Two wrongs cancelled and every test +// passed, but the device signed bytes hived could not validate — it reported +// "missing required active authority", because signature recovery over +// different bytes yields a key in no authority. A vector the chain generated +// is the only kind that can catch that class of bug. +// --------------------------------------------------------------------------- + +std::vector from_hex(const std::string& hex) { + std::vector out; + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + out.push_back( + static_cast(std::stoul(hex.substr(i, 2), nullptr, 16))); + } + return out; +} + +// Header shared by both vectors below: ref_block_num 4660 / prefix 0xdeadbeef +// (0/0 for the second) and expiration 2021-01-14T02:19:44. +// +// get_transaction_hex serializes a full transaction, so its output ends with a +// varint count of the `signatures` array. The device is handed the digest +// preimage, which stops after the extensions varint — so the trailing "00" +// from hived's hex is dropped in the goldens below. Everything before it must +// match byte for byte. +TEST(Hive, SerializationMatchesHivedLimitOrderCreate) { + const std::string golden = + "3412efbeadde40aaff5f010505616c6963652a000000dc05000000000000035354" + "45454d00009001000000000000035342440000000001b0f5536500"; + + std::vector tx; + append_u16_le(tx, 4660); + append_u32_le(tx, 0xdeadbeef); + append_u32_le(tx, 0x5fffaa40); + append_varint(tx, 1); + std::vector op = limit_order_create_op("alice", 42, 1500, "HIVE", + 400, "HBD", true, 0x6553f5b0); + tx.insert(tx.end(), op.begin(), op.end()); + append_varint(tx, 0); // extensions + + EXPECT_EQ(from_hex(golden), tx); + + // and the parser accepts what the chain produces + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_STREQ("HIVE", hive_assetSymbol(parsed.ops[0].assets[0])); + EXPECT_STREQ("HBD", hive_assetSymbol(parsed.ops[0].assets[1])); +} + +TEST(Hive, SerializationMatchesHivedClaimRewardBalance) { + const std::string golden = + "00000000000040aaff5f012705616c696365e8030000000000000353544545" + "4d0000d0070000000000000353424400000000c0c62d0000000000065645535453" + "000000"; + + std::vector tx; + append_u16_le(tx, 0); + append_u32_le(tx, 0); + append_u32_le(tx, 0x5fffaa40); + append_varint(tx, 1); + append_varint(tx, HIVE_OP_CLAIM_REWARD_BALANCE); + append_string(tx, "alice"); + append_asset(tx, 1000, 3, "HIVE"); + append_asset(tx, 2000, 3, "HBD"); + append_asset(tx, 3000000, 6, "VESTS"); + append_varint(tx, 0); // extensions + + EXPECT_EQ(from_hex(golden), tx); + + HiveParsedTx parsed; + ASSERT_EQ(nullptr, hive_parseOperations(tx.data(), tx.size(), &parsed)); + EXPECT_STREQ("VESTS", hive_assetSymbol(parsed.ops[0].assets[2])); +} diff --git a/unittests/firmware/mayachain.cpp b/unittests/firmware/mayachain.cpp index 74cddfa58..78904afe0 100644 --- a/unittests/firmware/mayachain.cpp +++ b/unittests/firmware/mayachain.cpp @@ -2,13 +2,18 @@ extern "C" { #include "keepkey/firmware/coins.h" #include "keepkey/firmware/mayachain.h" #include "keepkey/firmware/tendermint.h" -#include "trezor/crypto/ecdsa.h" #include "trezor/crypto/secp256k1.h" -#include "trezor/crypto/sha2.h" } #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, FormatsOnlyCacaoWithTenDecimals) { char rendered[96]; @@ -85,18 +90,6 @@ TEST(Mayachain, MemoWithMisdeclaredLengthIsRefused) { static const char kNoDot[] = "SWAP:ETH:dest"; EXPECT_EQ(MAYACHAIN_MEMO_UNPARSED, mayachain_parseConfirmMemo(kNoDot, sizeof(kNoDot) - 1)); - - /* A second dot outside the chain/asset field is not this grammar either. */ - static const char kExtraDot[] = "SWAP:ETH.USDT:de.st:limit"; - EXPECT_EQ(MAYACHAIN_MEMO_UNPARSED, - mayachain_parseConfirmMemo(kExtraDot, sizeof(kExtraDot) - 1)); -} - -TEST(Mayachain, MemoWithEmptyPositionalFieldIsNotStructured) { - static const char kEmptyLimit[] = - "=:ETH.ETH:0x41e5560054824ea6b0732e656e3ad64e20e94e45::affiliate:75"; - EXPECT_EQ(MAYACHAIN_MEMO_UNPARSED, - mayachain_parseConfirmMemo(kEmptyLimit, sizeof(kEmptyLimit) - 1)); } TEST(Mayachain, StructuredMemoRequiresExactSafeTokensAndCanonicalBps) { @@ -172,10 +165,6 @@ TEST(Mayachain, MayachainSignTx) { }; ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); - /* "cacao" is the denomination this vector was recorded with: the third - parameter was added by 50164a2ee, which replaced a hardcoded "cacao" in - the JSON with a caller-supplied denom. Passing it reproduces the exact - bytes the expected signature below was computed over. */ ASSERT_TRUE(mayachain_signTxUpdateMsgSend( 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", "cacao")); @@ -184,16 +173,17 @@ TEST(Mayachain, MayachainSignTx) { ASSERT_TRUE(mayachain_signTxFinalize(public_key, signature)); - /* This file was never in the build, so this vector was never checked and it - does not match. Recomputed independently of this firmware: SHA256 of the - amino StdSignDoc - {"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":"maya1ls33...", - "to_address":"maya1g9el..."}}],"sequence":"19"} - signed with RFC6979-deterministic secp256k1 and low-S normalised. The - device produces the same 64 bytes. */ + // 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*)"\xdf\x2f\x66\x37\x03\x08\x32\xd2\xce\x87\xfe\x47\x8d" @@ -204,13 +194,26 @@ TEST(Mayachain, MayachainSignTx) { 64) == 0); } -TEST(Mayachain, LongestValidDenomSerializes) { - /* The amount/denom segment is the longest thing - mayachain_signTxUpdateMsgSend() formats, and its scratch buffer used to be - 65 bytes against a documented 124-byte maximum. tendermint_snprintf() fails - closed, so nothing was mis-signed -- but the refusal came after the - confirmation screen had already been approved. A denomination at the - protocol maximum must serialize, not fail late. */ +// 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, @@ -235,162 +238,109 @@ TEST(Mayachain, LongestValidDenomSerializes) { true, "", true, 19, true, 1}; - ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); - /* 68 visible characters: MayachainMsgSend.denom's max_size of 69 less NUL. */ - char denom[69]; - std::memset(denom, 'a', 68); - denom[68] = '\0'; - ASSERT_EQ(68u, std::strlen(denom)); + ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); + EXPECT_FALSE(mayachain_signTxUpdateMsgSend( + 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", "cacao\"")); - /* A uint64 at its widest, so the segment is at its documented maximum. */ + ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); EXPECT_TRUE(mayachain_signTxUpdateMsgSend( - 18446744073709551615ULL, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", - denom)); + 100, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", "")); } -TEST(Mayachain, MultiMessageSignTxSeparatesMsgsWithComma) { - /* Regression for the missing comma between "msgs":[...] entries: before the - has_message guard, two MsgSends serialized back-to-back ("}}{") and the - user approved a signature over invalid JSON. The expected document below - is constructed BY HAND in this test -- independent of the serializer under - test -- and signed with the same key, so the comparison fails if the - serializer's bytes drift from the amino StdSignDoc in any way, comma - included. */ - 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); +/* ===================================================================== * + * mayachain_parseConfirmMemo — swap-memo clear-signing. + * Mirrors the thorchain.cpp memo tests; see kkconfirm_preload docs there. + * ===================================================================== */ - const MayachainSignTx msg = { - 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, - true, 6359, // account_number - true, "mayachain-mainnet-v1", // chain_id - true, 3000, // fee_amount - true, 200000, // gas - true, "", // memo - true, 19, // sequence - true, 2 // msg_count - }; - ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); - EXPECT_FALSE(mayachain_signingIsFinished()); +static bool parseMayaMemo(const char* memo, size_t size) { + return mayachain_parseConfirmMemo(memo, size) == MAYACHAIN_MEMO_CONFIRMED; +} +/* strlen(memo), NOT strlen(memo) + 1 -- see the same note in thorchain.cpp. + * Maya inherited THORChain's memo grammar and its canonical-length refusal. */ +static bool parseMayaMemo(const char* memo) { + return parseMayaMemo(memo, strlen(memo)); +} - const char* const to = "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k"; - ASSERT_TRUE(mayachain_signTxUpdateMsgSend(100, to, "cacao")); - EXPECT_FALSE(mayachain_signingIsFinished()); - ASSERT_TRUE(mayachain_signTxUpdateMsgSend(42, to, "cacao")); - EXPECT_TRUE(mayachain_signingIsFinished()); +// Classic full-form swap memo = 4 screens (4th is the affiliate fee screen), +// but the asset screen is 4 rows against a 3-row body, so it pages into +// 1/2 + 2/2 = 5 presses. See thorchain.cpp for the same memo. +TEST(Mayachain, MemoSwapFullFormShowsAffiliate) { + ASSERT_TRUE(kkconfirm_preload(5, 0)); + EXPECT_TRUE( + parseMayaMemo("SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:" + "0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); +} - uint8_t public_key[33]; - uint8_t signature[64]; - ASSERT_TRUE(mayachain_signTxFinalize(public_key, signature)); +// 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()); +} - char from[46]; - ASSERT_TRUE(tendermint_getAddress(&node, "maya", from)); - - char doc[1024]; - int n = snprintf( - doc, sizeof(doc), - "{\"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\":\"%s\",\"to_address\":" - "\"%s\"}}," - "{\"type\":\"mayachain/MsgSend\",\"value\":{\"amount\":[{\"amount\":" - "\"42\",\"denom\":\"cacao\"}],\"from_address\":\"%s\",\"to_address\":" - "\"%s\"}}" - "],\"sequence\":\"19\"}", - from, to, from, to); - ASSERT_GT(n, 0); - ASSERT_LT((size_t)n, sizeof(doc)); - - uint8_t hash[SHA256_DIGEST_LENGTH]; - sha256_Raw((const uint8_t*)doc, (size_t)n, hash); - uint8_t expected[64]; - ASSERT_EQ(0, ecdsa_sign_digest(&secp256k1, node.private_key, hash, expected, - NULL, NULL)); - EXPECT_EQ(0, memcmp(signature, expected, 64)); - - mayachain_signAbort(); +// 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()); } -TEST(Mayachain, ZeroOrOmittedMessagesFailInitialization) { - 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); +// 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()); +} - MayachainSignTx msg = { - 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, - true, 6359, - true, "mayachain-mainnet-v1", - true, 3000, - true, 200000, - true, "", - true, 19, - true, 0 // msg_count - }; - EXPECT_FALSE(mayachain_signTxInit(&node, &msg)); - EXPECT_FALSE(mayachain_signingIsInited()); - EXPECT_FALSE(mayachain_signingIsFinished()); - EXPECT_FALSE(mayachain_signTxUpdateMsgSend(1, "ignored", "cacao")); - - msg.has_msg_count = false; - msg.msg_count = 1; - EXPECT_FALSE(mayachain_signTxInit(&node, &msg)); - EXPECT_FALSE(mayachain_signingIsInited()); - - msg.has_msg_count = true; - strcpy(msg.chain_id, ""); - EXPECT_FALSE(mayachain_signTxInit(&node, &msg)); - strcpy(msg.chain_id, "maya\nchain"); - EXPECT_FALSE(mayachain_signTxInit(&node, &msg)); +// 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()); } -TEST(Mayachain, DepositAssetAndSignerFailClosed) { - HDNode node = {}; - node.curve = &secp256k1_info; - MayachainSignTx msg = {}; - msg.has_chain_id = true; - strcpy(msg.chain_id, "mayachain"); - msg.has_msg_count = true; - msg.msg_count = 1; - ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); +// 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); + + /* 6 presses, not 4: the 240-char destination needs 8 rows, so its screen + * pages 3 ways (1 + 3 + 1 + 1). Every byte of the memo reaches the screen. */ + ASSERT_TRUE(kkconfirm_preload(6, 0)); + EXPECT_TRUE(parseMayaMemo(memo.c_str(), memo.size())); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} - MayachainMsgDeposit deposit = {}; - deposit.has_asset = true; - strcpy(deposit.asset, "ETH.ETH\n"); - deposit.has_signer = true; - strcpy(deposit.signer, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k"); - EXPECT_FALSE(mayachain_signTxUpdateMsgDeposit(&deposit)); - - strcpy(deposit.asset, "ETH.ETH"); - strcpy(deposit.signer, "thor18vhdczjut44gpsy804crfhnd5nq003nzf5s36n"); - EXPECT_FALSE(mayachain_signTxUpdateMsgDeposit(&deposit)); - - strcpy(deposit.signer, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k"); - EXPECT_TRUE(mayachain_signTxUpdateMsgDeposit(&deposit)); - EXPECT_TRUE(mayachain_signingIsFinished()); - mayachain_signAbort(); +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()); } diff --git a/unittests/firmware/osmosis.cpp b/unittests/firmware/osmosis.cpp index 93c788cf9..1f54034ee 100644 --- a/unittests/firmware/osmosis.cpp +++ b/unittests/firmware/osmosis.cpp @@ -1,127 +1,161 @@ extern "C" { -#include "keepkey/firmware/coins.h" +// interface.h first: it is what neutralises the `delete` field in +// messages.pb.h, which is a keyword in C++. +#include "keepkey/transport/interface.h" +#include "keepkey/board/util.h" +#include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/osmosis.h" -#include "keepkey/firmware/tendermint.h" -#include "messages-osmosis.pb.h" -#include "trezor/crypto/ecdsa.h" #include "trezor/crypto/secp256k1.h" -#include "trezor/crypto/sha2.h" } #include "gtest/gtest.h" -#include -static HDNode testNode(void) { +#include + +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); + +static std::string fmt(const char *value, const char *denom) { + char out[OSMOSIS_AMOUNT_STR_LEN] = {0}; + EXPECT_TRUE(osmosis_formatAmount(out, sizeof(out), value, denom)); + return std::string(out); +} + +TEST(Osmosis, FormatAmountScalesUosmo) { + EXPECT_EQ(fmt("1500000", "uosmo"), "1.500000 OSMO"); + EXPECT_EQ(fmt("1000000", "uosmo"), "1.000000 OSMO"); + EXPECT_EQ(fmt("0", "uosmo"), "0.000000 OSMO"); + // Sub-unit amounts keep every digit rather than collapsing to zero. + EXPECT_EQ(fmt("500", "uosmo"), "0.000500 OSMO"); + EXPECT_EQ(fmt("1", "uosmo"), "0.000001 OSMO"); +} + +/* + * The reason this formatter exists. A float carries ~7 significant decimal + * digits, so the old atof() + "%.6f" path rendered large amounts rounded on + * the screen the user approves — 123456789.123456 OSMO came out as + * 123456792.000000. Integer formatting is exact at any magnitude. + */ +TEST(Osmosis, FormatAmountIsExactBeyondFloatPrecision) { + EXPECT_EQ(fmt("123456789123456", "uosmo"), "123456789.123456 OSMO"); + EXPECT_EQ(fmt("999999999999999", "uosmo"), "999999999.999999 OSMO"); + EXPECT_EQ(fmt("18446744073709551615", "uosmo"), "18446744073709.551615 OSMO"); +} + +TEST(Osmosis, FormatAmountLeavesUnknownDenomsAlone) { + // The device does not know the precision of an arbitrary denom, so the + // base-unit integer is shown verbatim — never scaled by a guess. + EXPECT_EQ(fmt("1500000", "uatom"), "1500000 uatom"); + EXPECT_EQ( + fmt("42", "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA6"), + "42 ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA6"); + // "uosmo" must match exactly — a lookalike denom is not OSMO. + EXPECT_EQ(fmt("1500000", "uosmox"), "1500000 uosmox"); +} + +TEST(Osmosis, FormatAmountRejectsNoncanonicalOrOutOfSchemaValues) { + const char *invalid[] = {"", + "01", + "+1", + "-1", + " 1", + "1 ", + "0x1", + "1a", + "18446744073709551616", + "123456789012345678901234567890123"}; + for (const char *value : invalid) { + char out[OSMOSIS_AMOUNT_STR_LEN] = "unchanged"; + EXPECT_FALSE(osmosis_formatAmount(out, sizeof(out), value, "uosmo")); + EXPECT_STREQ(out, ""); + } + + char out[OSMOSIS_AMOUNT_STR_LEN] = {0}; + EXPECT_FALSE(osmosis_formatAmount(out, sizeof(out), "1", "bad denom")); + EXPECT_FALSE(osmosis_formatAmount(out, sizeof(out), "1", "bad\"denom")); + EXPECT_FALSE(osmosis_formatAmount( + out, sizeof(out), "1", + "ibc/12345678901234567890123456789012345678901234567890123456789012345")); + EXPECT_FALSE(osmosis_formatAmount(out, 4, "1", "uosmo")); +} + +TEST(Osmosis, BaseToPrecisionPreservesMaxLpAmountAndCanary) { + struct { + uint8_t out[34]; + uint8_t canary; + } guarded = {{0}, 0xa5}; + const char value[] = "12345678901234567890123456789012"; + + ASSERT_EQ(0, base_to_precision(guarded.out, (const uint8_t *)value, + sizeof(guarded.out), strlen(value), 18)); + EXPECT_STREQ((const char *)guarded.out, "12345678901234.567890123456789012"); + EXPECT_EQ(guarded.canary, 0xa5); +} + +TEST(Osmosis, BaseToPrecisionRejectsTruncationAndNoncanonicalValues) { + uint8_t out[34] = {0}; + const char max_value[] = "12345678901234567890123456789012"; + EXPECT_LT(base_to_precision(out, (const uint8_t *)max_value, sizeof(out) - 1, + strlen(max_value), 18), + 0); + EXPECT_LT(base_to_precision(out, (const uint8_t *)"01", sizeof(out), 2, 18), + 0); + EXPECT_LT(base_to_precision(out, (const uint8_t *)"1x", sizeof(out), 2, 18), + 0); +} + +TEST(Osmosis, MaxSwapAssetsAreRendererPagedCompletely) { + const char denom[] = + "ibc/1234567890123456789012345678901234567890123456789012345678901234"; + static_assert(sizeof(denom) - 1 == OSMOSIS_MAX_DENOM_LEN, + "fixture must exercise the schema maximum"); + char token[OSMOSIS_AMOUNT_STR_LEN] = {0}; + ASSERT_TRUE(osmosis_formatAmount(token, sizeof(token), + "12345678901234567890123456789012", denom)); + + // The old combined sentence required more than the OLED's three rows. Each + // 101-character asset now gets its own measured page, so both signed values + // are fully accepted in exactly two independent confirmations. + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(confirm_bytes(ButtonRequestType_ButtonRequest_Other, "Swap Input", + (const uint8_t *)token, strlen(token))); + EXPECT_TRUE(confirm_bytes(ButtonRequestType_ButtonRequest_Other, + "Minimum Output", (const uint8_t *)token, + strlen(token))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Osmosis, MsgSendSignsCanonicalNonNativeDenomination) { HDNode node = { 0, 0, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 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}, + 0x8b, 0x6c, 0x69, 0x5c, 0x71, 0x72, 0x03, 0x02, 0xf1, 0x76}, + {0}, + {0}, &secp256k1_info}; hdnode_fill_public_key(&node); - return node; -} -TEST(Osmosis, MultiMessageSignTxSeparatesMsgsWithComma) { - /* Regression for the missing comma between "msgs":[...] entries: before the - has_message guard, two MsgSends serialized back-to-back ("}}{") and the - user approved a signature over invalid JSON. The expected document below - is constructed BY HAND -- independent of the serializer under test -- and - signed with the same key, so the comparison fails if the serializer's - bytes drift from the amino StdSignDoc in any way, comma included. */ - HDNode node = testNode(); - - const OsmosisSignTx msg = { - 5, {0x80000000 | 44, 0x80000000 | 118, 0x80000000, 0, 0}, - true, 251252, // account_number - true, "osmosis-1", // chain_id - true, 5000, // fee_amount - true, 300000, // gas - true, "", // memo - true, 4, // sequence - true, 2 // msg_count - }; - ASSERT_TRUE(osmosis_signTxInit(&node, &msg)); - EXPECT_FALSE(osmosis_signingIsFinished()); - - const char* const to = "osmo1g9el7lzjwh9yun2c4jjzhy09j98vkhfx8tzcpt"; - ASSERT_TRUE(osmosis_signTxUpdateMsgSend("100", to, "uosmo")); - EXPECT_FALSE(osmosis_signingIsFinished()); - ASSERT_TRUE(osmosis_signTxUpdateMsgSend("42", to, "uosmo")); - EXPECT_TRUE(osmosis_signingIsFinished()); - - uint8_t public_key[33]; - uint8_t signature[64]; - ASSERT_TRUE(osmosis_signTxFinalize(public_key, signature)); - - char from[46]; - ASSERT_TRUE(tendermint_getAddress(&node, "osmo", from)); - - char doc[1024]; - int n = snprintf( - doc, sizeof(doc), - "{\"account_number\":\"251252\",\"chain_id\":\"osmosis-1\"," - "\"fee\":{\"amount\":[{\"amount\":\"5000\",\"denom\":\"uosmo\"}]," - "\"gas\":\"300000\"},\"memo\":\"\",\"msgs\":[" - "{\"type\":\"cosmos-sdk/MsgSend\",\"value\":{\"amount\":[{\"amount\":" - "\"100\",\"denom\":\"uosmo\"}],\"from_address\":\"%s\",\"to_address\":" - "\"%s\"}}," - "{\"type\":\"cosmos-sdk/MsgSend\",\"value\":{\"amount\":[{\"amount\":" - "\"42\",\"denom\":\"uosmo\"}],\"from_address\":\"%s\",\"to_address\":" - "\"%s\"}}" - "],\"sequence\":\"4\"}", - from, to, from, to); - ASSERT_GT(n, 0); - ASSERT_LT((size_t)n, sizeof(doc)); - - uint8_t hash[SHA256_DIGEST_LENGTH]; - sha256_Raw((const uint8_t*)doc, (size_t)n, hash); - uint8_t expected[64]; - ASSERT_EQ(0, ecdsa_sign_digest(&secp256k1, node.private_key, hash, expected, - NULL, NULL)); - EXPECT_EQ(0, memcmp(signature, expected, 64)); - - osmosis_signAbort(); -} - -TEST(Osmosis, ZeroOrOmittedMessagesFailInitialization) { - HDNode node = testNode(); - - OsmosisSignTx msg = { - 5, {0x80000000 | 44, 0x80000000 | 118, 0x80000000, 0, 0}, - true, 251252, - true, "osmosis-1", - true, 5000, - true, 300000, - true, "", - true, 4, - true, 0 // msg_count - }; - EXPECT_FALSE(osmosis_signTxInit(&node, &msg)); - EXPECT_FALSE(osmosis_signingIsInited()); - EXPECT_FALSE(osmosis_signingIsFinished()); - EXPECT_FALSE(osmosis_signTxUpdateMsgSend("1", "ignored", "uosmo")); - - msg.has_msg_count = false; + OsmosisSignTx msg = {}; + msg.account_number = 0; + msg.has_chain_id = true; + strlcpy(msg.chain_id, "osmosis-1", sizeof(msg.chain_id)); + msg.fee_amount = 800; + msg.gas = 290000; + msg.has_memo = true; + msg.sequence = 0; + msg.has_msg_count = true; msg.msg_count = 1; - EXPECT_FALSE(osmosis_signTxInit(&node, &msg)); - EXPECT_FALSE(osmosis_signingIsInited()); + ASSERT_TRUE(osmosis_signTxInit(&node, &msg)); - msg.has_msg_count = true; - strcpy(msg.chain_id, ""); - EXPECT_FALSE(osmosis_signTxInit(&node, &msg)); - strcpy(msg.chain_id, "osmosis\n1"); - EXPECT_FALSE(osmosis_signTxInit(&node, &msg)); - strcpy(msg.chain_id, "osmosis-1"); - EXPECT_TRUE(osmosis_signTxInit(&node, &msg)); - osmosis_signAbort(); + const char denom[] = + "ibc/1234567890123456789012345678901234567890123456789012345678901234"; + static_assert(sizeof(denom) - 1 == OSMOSIS_MAX_DENOM_LEN, + "fixture must exercise the schema maximum"); + EXPECT_TRUE(osmosis_signTxUpdateMsgSend( + "7", "osmo1rs7fckgznkaxs4sq02pexwjgar43p5wnkx9s92", denom)); } TEST(Osmosis, RequiredValuesRejectEmptyAndNonDecimalAmounts) { diff --git a/unittests/firmware/rng_health.cpp b/unittests/firmware/rng_health.cpp index abd2cfd5d..4ef66f6b0 100644 --- a/unittests/firmware/rng_health.cpp +++ b/unittests/firmware/rng_health.cpp @@ -33,6 +33,19 @@ TEST(RngHealth, RejectsEmptyAndNull) { EXPECT_FALSE(rng_health_analyze(&b, 0)); } +TEST(RngHealth, PersistentHardwareErrorLatchesBeforeReset) { + rng_test_power_on_reset(); + uint32_t samples = 0; + for (uint32_t i = 0; i < 99; ++i) { + EXPECT_FALSE(rng_persistent_error_step(&samples)); + } + EXPECT_FALSE(rng_seed_error_latched()); + EXPECT_TRUE(rng_persistent_error_step(&samples)); + EXPECT_EQ(samples, 0U); + EXPECT_TRUE(rng_seed_error_latched()); + rng_test_power_on_reset(); +} + TEST(RngHealth, AcceptsNonDegenerateSample) { auto v = pseudo(RNG_HEALTH_SAMPLE_BYTES); EXPECT_TRUE(rng_health_analyze(v.data(), v.size())); diff --git a/unittests/firmware/signed_metadata.cpp b/unittests/firmware/signed_metadata.cpp new file mode 100644 index 000000000..db609de40 --- /dev/null +++ b/unittests/firmware/signed_metadata.cpp @@ -0,0 +1,1780 @@ +/* + * Unit tests for the EVM clear-signing ("Insight") signed-metadata module. + * + * Phase 1 ships with NO built-in verification keys: every signer is loaded + * at runtime (signed_metadata_store_signer, + * reached in production through the user-confirmed LoadClearsignSigner FSM + * handler). The fixture loads the CI test key (02e3b3015c...ab5107) into + * slot 3 with alias "CI Test"; all vectors are signed in-process with the + * matching private key (f6d19e15...068a260) and embed key_id=3. + * + * No OLED/button I/O is exercised: signed_metadata_process() and + * signed_metadata_matches_tx() never draw, and signed_metadata_confirm() is + * only called on its no-I/O early-return guards. The relied-path enforce truth + * table is tested through the pure, exported signed_metadata_enforce_decision() + * (see SECTION 2), since relied_on_metadata is only set inside confirm()'s + * interactive tail. + */ + +extern "C" { +#include "messages-ethereum.pb.h" /* full EthereumSignTx definition */ +#include "keepkey/board/draw.h" /* draw_bitmap_mono_rle (icon decoder) */ +#include "keepkey/board/layout.h" /* LEFT_MARGIN_WITH_ICON */ +#include "keepkey/firmware/signed_metadata.h" +#include "keepkey/firmware/solana.h" /* SolanaTokenInfo, solana_token_info_trusted */ +#include "keepkey/firmware/storage.h" +#include "trezor/crypto/ecdsa.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/sha2.h" + +void setup(void); +} + +#include "gtest/gtest.h" + +#include +#include +#include +#include +#include + +namespace { + +/* Test signing key. Its compressed pubkey is loaded into slot 3 by the fixture. + */ +const uint8_t TEST_PRIV[32] = {0xf6, 0xd1, 0x9e, 0x15, 0xa4, 0x38, 0x5f, 0x03, + 0xb7, 0x8b, 0x5a, 0x1e, 0x16, 0x14, 0xe7, 0xd9, + 0xa1, 0x04, 0xd8, 0x1f, 0x73, 0x24, 0x49, 0x87, + 0x56, 0xe5, 0x71, 0x90, 0x40, 0x68, 0xa2, 0x60}; + +/* Compressed pubkey of TEST_PRIV; loaded into slot 3 by the fixture. */ +const uint8_t EXPECTED_SLOT3_PUB[33] = { + 0x02, 0xe3, 0xb3, 0x01, 0x5c, 0x47, 0xdd, 0xca, 0xab, 0xe4, 0xf8, + 0xe8, 0x72, 0xf1, 0xed, 0x8f, 0x09, 0xca, 0x14, 0x5a, 0x8d, 0x81, + 0x77, 0x0d, 0x92, 0x21, 0x3d, 0x56, 0xda, 0x31, 0xab, 0x51, 0x07}; + +const uint8_t TEST_KEY_ID = 3; + +/* Deterministic, opaque test data. Only internal consistency matters. */ +const uint8_t CONTRACT_A[20] = {0xa0, 0xb8, 0x69, 0x91, 0xc6, 0x21, 0x8b, + 0x36, 0xc1, 0xd1, 0x9d, 0x4a, 0x2e, 0x9e, + 0xb0, 0xce, 0x36, 0x06, 0xeb, 0x48}; +const uint8_t CONTRACT_B[20] = {0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11}; +const uint8_t SEL_TRANSFER[4] = {0xa9, 0x05, 0x9c, 0xbb}; +const uint8_t SEL_APPROVE[4] = {0x09, 0x5e, 0xa7, 0xb3}; +const uint8_t TX_HASH[32] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20}; +const uint8_t RECIPIENT[20] = {0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, + 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, + 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53}; +const uint8_t AMOUNT32[32] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0x03, 0xe8}; + +/* ---- byte writers ------------------------------------------------------- */ + +void put_u8(std::vector& v, uint8_t x) { v.push_back(x); } +void put_be16(std::vector& v, uint16_t x) { + v.push_back((uint8_t)(x >> 8)); + v.push_back((uint8_t)(x & 0xff)); +} +void put_be32(std::vector& v, uint32_t x) { + v.push_back((uint8_t)(x >> 24)); + v.push_back((uint8_t)(x >> 16)); + v.push_back((uint8_t)(x >> 8)); + v.push_back((uint8_t)(x & 0xff)); +} +void put_bytes(std::vector& v, const uint8_t* b, size_t n) { + v.insert(v.end(), b, b + n); +} + +/* ---- metadata builder --------------------------------------------------- */ + +struct Arg { + std::string name; + uint8_t format; + std::vector value; + int value_len_override; // -1 => use value.size() +}; + +Arg mk_arg(const std::string& name, uint8_t format, const uint8_t* value, + size_t value_len) { + Arg a; + a.name = name; + a.format = format; + a.value.assign(value, value + value_len); + a.value_len_override = -1; + return a; +} + +struct Spec { + uint8_t version; + uint32_t chain_id; + std::vector contract; + std::vector selector; + std::vector tx_hash; + std::string method; + std::vector args; + uint8_t classification; + uint32_t timestamp; + uint8_t key_id; + int method_len_override; // -1 => use method.size() + int num_args_override; // -1 => use args.size() +}; + +/* Canonical VERIFIED metadata: transfer(to:ADDRESS, amount:AMOUNT) on chain 1. + */ +Spec base_spec() { + Spec s; + s.version = 0x01; + s.chain_id = 1; + s.contract.assign(CONTRACT_A, CONTRACT_A + 20); + s.selector.assign(SEL_TRANSFER, SEL_TRANSFER + 4); + s.tx_hash.assign(TX_HASH, TX_HASH + 32); + s.method = "transfer"; + s.args.push_back(mk_arg("to", ARG_FORMAT_ADDRESS, RECIPIENT, 20)); + s.args.push_back(mk_arg("amount", ARG_FORMAT_AMOUNT, AMOUNT32, 32)); + s.classification = METADATA_VERIFIED; + s.timestamp = 0; + s.key_id = TEST_KEY_ID; + s.method_len_override = -1; + s.num_args_override = -1; + return s; +} + +/* Serialize the signed region (version .. key_id), exactly matching + * parse_metadata_binary() / serialize_metadata(). */ +std::vector build_body(const Spec& s) { + std::vector b; + put_u8(b, s.version); + put_be32(b, s.chain_id); + put_bytes(b, s.contract.data(), s.contract.size()); + put_bytes(b, s.selector.data(), s.selector.size()); + put_bytes(b, s.tx_hash.data(), s.tx_hash.size()); + + uint16_t mlen = s.method_len_override >= 0 ? (uint16_t)s.method_len_override + : (uint16_t)s.method.size(); + put_be16(b, mlen); + put_bytes(b, (const uint8_t*)s.method.data(), s.method.size()); + + uint8_t na = s.num_args_override >= 0 ? (uint8_t)s.num_args_override + : (uint8_t)s.args.size(); + put_u8(b, na); + for (const Arg& a : s.args) { + put_u8(b, (uint8_t)a.name.size()); + put_bytes(b, (const uint8_t*)a.name.data(), a.name.size()); + put_u8(b, a.format); + uint16_t vl = a.value_len_override >= 0 ? (uint16_t)a.value_len_override + : (uint16_t)a.value.size(); + put_be16(b, vl); + put_bytes(b, a.value.data(), a.value.size()); + } + + put_u8(b, s.classification); + put_be32(b, s.timestamp); + put_u8(b, s.key_id); + return b; +} + +/* sha256(body) -> ecdsa sign with TEST_PRIV -> append sig(64) + recovery(1). + * Mirrors signed_metadata_process(): signed_len = payload_len - 64 - 1. */ +std::vector sign_body(std::vector body) { + uint8_t digest[32]; + sha256_Raw(body.data(), body.size(), digest); + uint8_t sig[64]; + uint8_t pby = 0; + int rc = ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, &pby, NULL); + EXPECT_EQ(rc, 0); + body.insert(body.end(), sig, sig + 64); + body.push_back((uint8_t)(27 + pby)); + return body; +} + +std::vector base_blob() { return sign_body(build_body(base_spec())); } + +void make_msg(EthereumSignTx* msg, const uint8_t contract[20], + const uint8_t* data, size_t data_len, bool has_chain, + uint32_t chain) { + memset(msg, 0, sizeof(*msg)); + msg->has_to = true; + msg->to.size = 20; + memcpy(msg->to.bytes, contract, 20); + msg->has_data_initial_chunk = true; + msg->data_initial_chunk.size = (pb_size_t)data_len; + memcpy(msg->data_initial_chunk.bytes, data, data_len); + msg->has_chain_id = has_chain; + msg->chain_id = chain; +} + +/* A standard transfer() calldata chunk that matches base_spec(). */ +void make_matching_msg(EthereumSignTx* msg) { + uint8_t data[68]; + memcpy(data, SEL_TRANSFER, 4); + memset(data + 4, 0, sizeof(data) - 4); + make_msg(msg, CONTRACT_A, data, sizeof(data), /*has_chain=*/true, 1); +} + +const char* TEST_ALIAS = "CI Test"; + +void set_advanced_mode_for_test(bool enabled) { + /* The full xunit binary may already have initialized emulator flash in an + * earlier fixture (notably Authenticator). Re-running storage_init() then + * attempts to migrate/decrypt an already-live shadow store. The allocation + * is the shared source of truth, and also keeps this suite runnable alone. */ + if (storage_getLocation() == FLASH_INVALID) { + setup(); + storage_init(); + } + ASSERT_TRUE(storage_setPolicy("AdvancedMode", enabled)); +} + +class SignedMetadataTest : public ::testing::Test { + protected: + void SetUp() override { + set_advanced_mode_for_test(true); + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + NULL, 0, 0, 0, false); + } + void TearDown() override { + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); + } + + void ExpectMalformed(const std::vector& blob, uint8_t key_id) { + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), key_id), + METADATA_MALFORMED); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_EQ(signed_metadata_get(), nullptr); + } +}; + +/* ===================================================================== * + * signed_metadata_process — happy path via a runtime-loaded signer + * ===================================================================== */ + +TEST_F(SignedMetadataTest, DerivedPubkeyMatchesSlot3) { + uint8_t pub[33]; + ecdsa_get_public_key33(&secp256k1, TEST_PRIV, pub); + EXPECT_EQ(memcmp(pub, EXPECTED_SLOT3_PUB, sizeof(pub)), 0) + << "TEST_PRIV must derive the loaded slot-3 test pubkey"; +} + +TEST_F(SignedMetadataTest, ValidVerifiedSlot3) { + std::vector blob = base_blob(); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_TRUE(signed_metadata_available()); + const SignedMetadata* m = signed_metadata_get(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->classification, METADATA_VERIFIED); + EXPECT_EQ(m->chain_id, 1u); + EXPECT_STREQ(m->method_name, "transfer"); + EXPECT_EQ(m->num_args, 2); + EXPECT_EQ(memcmp(m->contract_address, CONTRACT_A, 20), 0); + EXPECT_EQ(memcmp(m->selector, SEL_TRANSFER, 4), 0); + EXPECT_EQ(memcmp(m->tx_hash, TX_HASH, 32), 0); + EXPECT_EQ(m->key_id, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, RuntimeMetadataIsInertOutsideAdvancedMode) { + std::vector blob = base_blob(); + set_advanced_mode_for_test(false); + ExpectMalformed(blob, TEST_KEY_ID); + + const uint8_t data[] = "advanced-mode-gate"; + uint8_t digest[32]; + uint8_t sig[64]; + sha256_Raw(data, sizeof(data) - 1, digest); + ASSERT_EQ(ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, NULL, NULL), + 0); + EXPECT_FALSE(signed_metadata_verify_attestation( + TEST_KEY_ID, data, sizeof(data) - 1, sig, sizeof(sig))); + + set_advanced_mode_for_test(true); +} + +TEST_F(SignedMetadataTest, ValidOpaqueClassification) { + Spec s = base_spec(); + s.classification = METADATA_OPAQUE; // 0 + std::vector blob = sign_body(build_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_OPAQUE); + EXPECT_TRUE(signed_metadata_available()); // available, but not VERIFIED + EXPECT_NE(signed_metadata_get(), nullptr); +} + +TEST_F(SignedMetadataTest, SelfDeclaredMalformedWithValidSignature) { + /* A trusted signer can self-declare MALFORMED(2). Signature verifies, so + * process() returns MALFORMED but leaves the (inert) metadata available. It + * must never be displayed or relied upon. */ + Spec s = base_spec(); + s.classification = METADATA_MALFORMED; // 2 + std::vector blob = sign_body(build_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_MALFORMED); + EXPECT_TRUE(signed_metadata_available()); + EXPECT_NE(signed_metadata_get(), nullptr); + + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); // gated on VERIFIED + EXPECT_FALSE(signed_metadata_confirm()); // gated on VERIFIED +} + +/* ===================================================================== * + * signed_metadata_process — key-slot guards + * ===================================================================== */ + +TEST_F(SignedMetadataTest, KeyIdOutOfRange) { + ExpectMalformed(base_blob(), /*key_id=*/4); // >= METADATA_MAX_KEYS +} + +TEST_F(SignedMetadataTest, EmptyRotationSlot) { + Spec s = base_spec(); + s.key_id = 1; // slot 1: no built-in key, nothing loaded + ExpectMalformed(sign_body(build_body(s)), /*key_id=*/1); +} + +TEST_F(SignedMetadataTest, NullPayload) { + EXPECT_EQ(signed_metadata_process(nullptr, 200, TEST_KEY_ID), + METADATA_MALFORMED); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_EQ(signed_metadata_get(), nullptr); +} + +TEST_F(SignedMetadataTest, EmbeddedKeyIdMismatch) { + Spec s = base_spec(); + s.key_id = 2; // embedded != protocol key_id (3) + ExpectMalformed(sign_body(build_body(s)), /*key_id=*/3); +} + +TEST_F(SignedMetadataTest, SignatureVerificationFails) { + std::vector blob = base_blob(); + blob[146] ^= 0x01; // flip first signature byte (sig starts after 146B body) + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* ===================================================================== * + * signed_metadata_process — length guards + * ===================================================================== */ + +TEST_F(SignedMetadataTest, PayloadShorterThan65) { + std::vector blob = base_blob(); + blob.resize(64); // process() early guard: payload_len < 65 + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, PayloadBetween65And135) { + std::vector blob = base_blob(); + blob.resize(100); // passes <65 guard, fails parser <136 minimum + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, TrailingByteAfterRecovery) { + std::vector blob = base_blob(); + blob.push_back(0x00); // cursor != end + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, MissingRecoveryByte) { + std::vector blob = base_blob(); + blob.pop_back(); // truncated tail: read recovery fails + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* ===================================================================== * + * parse_metadata_binary — field guards (all re-signed so the PARSE guard, + * not the signature check, is what rejects the blob) + * ===================================================================== */ + +TEST_F(SignedMetadataTest, BadVersion) { + Spec s = base_spec(); + s.version = 0x02; + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, EmptyMethodName) { + Spec s = base_spec(); + s.method = ""; // 2-byte length prefix == 0 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, MethodNameTooLong) { + Spec s = base_spec(); + s.method = std::string(65, 'A'); // > METADATA_MAX_METHOD_LEN (64) + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, MethodNameLengthOverrun) { + /* Length prefix claims 64 but only "transfer" (8B) is the method; the read + * consumes downstream bytes and parsing misaligns -> MALFORMED. The clean + * read_string short-read guard is unreachable under the >=136 floor (after + * the 63-byte fixed prefix at least 73 bytes always remain), so this pins + * the observable contract rather than a specific internal branch. The + * corrupted signature byte makes rejection deterministic even in the + * vanishingly unlikely event the misaligned parse re-aligns to the end. */ + Spec s = base_spec(); + s.method_len_override = 64; + std::vector blob = sign_body(build_body(s)); + blob[blob.size() - 2] ^= 0xFF; // ensure verify cannot pass + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, NumArgsTooMany) { + Spec s = base_spec(); + s.num_args_override = 9; // > METADATA_MAX_ARGS (8) + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgNameEmpty) { + Spec s = base_spec(); + s.args[0] = mk_arg("", ARG_FORMAT_ADDRESS, RECIPIENT, 20); // name_len == 0 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgNameTooLong) { + Spec s = base_spec(); + std::string long_name(33, 'x'); // > METADATA_MAX_ARG_NAME_LEN (32) + s.args[0] = mk_arg(long_name, ARG_FORMAT_ADDRESS, RECIPIENT, 20); + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgFormatOutOfRange) { + Spec s = base_spec(); + s.args[0].format = 6; // > ARG_FORMAT_TOKEN_AMOUNT (5) + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +/* ---- ARG_FORMAT_STRING (attested printable label) ----------------------- */ + +TEST_F(SignedMetadataTest, StringArgAccepted) { + Spec s = base_spec(); + const char* label = "Uniswap V2"; + s.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, (const uint8_t*)label, + strlen(label)); + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + const SignedMetadata* m = signed_metadata_get(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->args[0].format, ARG_FORMAT_STRING); + EXPECT_EQ(memcmp(m->args[0].value, label, strlen(label)), 0); +} + +TEST_F(SignedMetadataTest, StringArgRejectsUnprintableAndPercent) { + const uint8_t nl[] = {'a', '\n', 'b'}; + Spec s = base_spec(); + s.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, nl, sizeof(nl)); + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); + + const uint8_t pct[] = {'a', '%', 's'}; + Spec s2 = base_spec(); + s2.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, pct, sizeof(pct)); + ExpectMalformed(sign_body(build_body(s2)), TEST_KEY_ID); + + Spec s3 = base_spec(); + s3.args[0] = mk_arg("protocol", ARG_FORMAT_STRING, pct, 0); // empty string + ExpectMalformed(sign_body(build_body(s3)), TEST_KEY_ID); +} + +/* ---- ARG_FORMAT_TOKEN_AMOUNT (decimals + symbol + amount) --------------- */ + +std::vector token_amount_value(uint8_t decimals, + const std::string& symbol, + const std::vector& amount) { + std::vector v; + v.push_back(decimals); + v.push_back((uint8_t)symbol.size()); + v.insert(v.end(), symbol.begin(), symbol.end()); + v.insert(v.end(), amount.begin(), amount.end()); + return v; +} + +TEST_F(SignedMetadataTest, TokenAmountAccepted) { + /* 1.00 USDC: 1000000 raw, 6 decimals */ + std::vector amt = {0x0F, 0x42, 0x40}; + std::vector val = token_amount_value(6, "USDC", amt); + Spec s = base_spec(); + s.args[1] = mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, val.data(), val.size()); + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + const SignedMetadata* m = signed_metadata_get(); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->args[1].format, ARG_FORMAT_TOKEN_AMOUNT); + EXPECT_EQ(m->args[1].value_len, val.size()); +} + +TEST_F(SignedMetadataTest, TokenAmountUnlimited32BytesAccepted) { + /* UNLIMITED approve: 32 x 0xFF + symbol -> value_len 38 (> old 32 cap) */ + std::vector amt(32, 0xFF); + std::vector val = token_amount_value(6, "USDC", amt); + EXPECT_EQ(val.size(), 38u); // 1+1+4+32 + Spec s = base_spec(); + s.args[1] = mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, val.data(), val.size()); + std::vector blob = sign_body(build_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); +} + +TEST_F(SignedMetadataTest, TokenAmountRejectsBadLayout) { + Spec s = base_spec(); + /* symbol chars outside [A-Za-z0-9] */ + std::vector bad_sym = token_amount_value(6, "US-C", {0x01}); + s.args[1] = + mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, bad_sym.data(), bad_sym.size()); + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); + + /* decimals > 36 */ + Spec s2 = base_spec(); + std::vector bad_dec = token_amount_value(37, "USDC", {0x01}); + s2.args[1] = + mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, bad_dec.data(), bad_dec.size()); + ExpectMalformed(sign_body(build_body(s2)), TEST_KEY_ID); + + /* symbol_len runs past the value (no amount bytes left) */ + Spec s3 = base_spec(); + std::vector no_amt = {6, 4, 'U', 'S', 'D', 'C'}; + s3.args[1] = + mk_arg("amount", ARG_FORMAT_TOKEN_AMOUNT, no_amt.data(), no_amt.size()); + ExpectMalformed(sign_body(build_body(s3)), TEST_KEY_ID); + + /* legacy formats must NOT accept the larger 44-byte cap */ + Spec s4 = base_spec(); + std::vector big(40, 0xAB); + s4.args[1] = mk_arg("amount", ARG_FORMAT_AMOUNT, big.data(), big.size()); + ExpectMalformed(sign_body(build_body(s4)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgValueTooLong) { + Spec s = base_spec(); + uint8_t big[33] = {0}; + s.args[0] = mk_arg("to", ARG_FORMAT_BYTES, big, 33); // > 32 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ArgValueLengthOverrun) { + /* value_len prefix claims 32 but only 4 value bytes follow; the read eats + * into the fixed tail and parsing misaligns -> MALFORMED. As with the method + * case, the read_bytes short-read guard is dominated by the >=71-byte fixed + * tail, so this asserts the observable MALFORMED outcome. */ + Spec s = base_spec(); + uint8_t four[4] = {0xde, 0xad, 0xbe, 0xef}; + Arg a = mk_arg("amount", ARG_FORMAT_AMOUNT, four, 4); + a.value_len_override = 32; + s.args[1] = a; + std::vector blob = sign_body(build_body(s)); + blob[blob.size() - 2] ^= 0xFF; // ensure verify cannot pass + ExpectMalformed(blob, TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, ClassificationOutOfRange) { + Spec s = base_spec(); + s.classification = 3; // > 2 + ExpectMalformed(sign_body(build_body(s)), TEST_KEY_ID); +} + +/* ===================================================================== * + * signed_metadata_matches_tx — display gate + * ===================================================================== */ + +TEST_F(SignedMetadataTest, MatchesTxAllBindingsMatch) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxNotAvailable) { + signed_metadata_clear(); + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxNullMsg) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_FALSE(signed_metadata_matches_tx(nullptr)); +} + +TEST_F(SignedMetadataTest, MatchesTxNotVerifiedClassification) { + Spec s = base_spec(); + s.classification = METADATA_OPAQUE; + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_OPAQUE); + EthereumSignTx msg; + make_matching_msg(&msg); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongToSize) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + make_matching_msg(&msg); + msg.to.size = 19; // not 20 (e.g. contract-create has 0) + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxDataTooShortForSelector) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + make_matching_msg(&msg); + msg.data_initial_chunk.size = 3; // < 4 + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongContract) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + uint8_t data[68]; + memcpy(data, SEL_TRANSFER, 4); + memset(data + 4, 0, sizeof(data) - 4); + EthereumSignTx msg; + make_msg(&msg, CONTRACT_B, data, sizeof(data), true, 1); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongSelector) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + uint8_t data[68]; + memcpy(data, SEL_APPROVE, 4); // approve, not transfer + memset(data + 4, 0, sizeof(data) - 4); + EthereumSignTx msg; + make_msg(&msg, CONTRACT_A, data, sizeof(data), true, 1); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +TEST_F(SignedMetadataTest, MatchesTxWrongChainId) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + uint8_t data[68]; + memcpy(data, SEL_TRANSFER, 4); + memset(data + 4, 0, sizeof(data) - 4); + + EthereumSignTx wrong_chain; + make_msg(&wrong_chain, CONTRACT_A, data, sizeof(data), true, 137); + EXPECT_FALSE(signed_metadata_matches_tx(&wrong_chain)); + + EthereumSignTx no_chain; + make_msg(&no_chain, CONTRACT_A, data, sizeof(data), false, + 0); // treated as 0 + EXPECT_FALSE(signed_metadata_matches_tx(&no_chain)); +} + +/* ===================================================================== * + * signed_metadata_confirm — no-I/O early guards + * ===================================================================== */ + +TEST_F(SignedMetadataTest, ConfirmNotAvailable) { + signed_metadata_clear(); + EXPECT_FALSE(signed_metadata_confirm()); +} + +TEST_F(SignedMetadataTest, ConfirmNotVerified) { + Spec s = base_spec(); + s.classification = METADATA_OPAQUE; + std::vector blob = sign_body(build_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_OPAQUE); + EXPECT_FALSE(signed_metadata_confirm()); +} + +/* ===================================================================== * + * signed_metadata_enforce — module-level not-relied path (reachable + * without confirm()'s interactive tail) and clear() reset + * ===================================================================== */ + +TEST_F(SignedMetadataTest, EnforceNotReliedAlwaysAllows) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + ASSERT_FALSE(signed_metadata_relied()); // process() never sets relied + + uint8_t wrong[32]; + memcpy(wrong, TX_HASH, 32); + wrong[0] ^= 0xFF; + EXPECT_TRUE(signed_metadata_enforce(TX_HASH)); + EXPECT_TRUE(signed_metadata_enforce(wrong)); + EXPECT_TRUE(signed_metadata_enforce(nullptr)); +} + +TEST_F(SignedMetadataTest, ClearResetsAllState) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + ASSERT_TRUE(signed_metadata_available()); + + signed_metadata_clear(); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_FALSE(signed_metadata_relied()); + EXPECT_EQ(signed_metadata_get(), nullptr); + EXPECT_TRUE(signed_metadata_enforce(TX_HASH)); // not relied +} + +/* ===================================================================== * + * Runtime signer loading — the phase-1 trust path + * ===================================================================== */ + +TEST_F(SignedMetadataTest, NoSignerLoadedRejects) { + signed_metadata_clear_signers(); // undo the fixture's load + ExpectMalformed(base_blob(), TEST_KEY_ID); +} + +TEST_F(SignedMetadataTest, FromLoadedSignerTracksMetadata) { + EXPECT_FALSE(signed_metadata_from_loaded_signer()); // nothing processed + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_TRUE(signed_metadata_from_loaded_signer()); + signed_metadata_clear(); + EXPECT_FALSE(signed_metadata_from_loaded_signer()); +} + +TEST_F(SignedMetadataTest, ClearSignersDropsKeyAndMetadata) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + signed_metadata_clear_signers(); + EXPECT_FALSE(signed_metadata_available()); + EXPECT_EQ(signed_metadata_get(), nullptr); + ExpectMalformed(blob, TEST_KEY_ID); // the key itself is gone too +} + +TEST_F(SignedMetadataTest, StoreSignerReplacementInvalidatesOldKey) { + std::vector blob = base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + uint8_t priv2[32]; + memcpy(priv2, TEST_PRIV, sizeof(priv2)); + priv2[31] ^= 0x5a; // a different valid scalar + uint8_t pub2[33]; + ecdsa_get_public_key33(&secp256k1, priv2, pub2); + signed_metadata_store_signer(TEST_KEY_ID, pub2, "Replacement", NULL, 0, 0, 0, + false); + + /* Replacing a signer drops metadata the old one verified... */ + EXPECT_FALSE(signed_metadata_available()); + /* ...and the old key no longer verifies anything. */ + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* ---- signed_metadata_signer_valid (pure) ------------------------------ */ + +/* + * ── Identity-icon decoder hardening ──────────────────────────────────────── + * + * The clearsign identity icon is HOST-SUPPLIED and is rendered on the trust + * screen, so the decoder is an attack surface reachable before the user has + * approved anything. Regression guards for two review findings: + * + * (1) A 0x80 (n = -128) packet is undecodable: draw_bitmap_mono_rle's counter + * is int8_t, so -(-128) wraps back to -128 and breaks its `> 0` invariant. + * Under NDEBUG the assert is compiled out and decoding proceeded with a + * negative counter (signed-overflow UB). Must fail closed instead. + * (2) icon_width must not exceed LEFT_MARGIN_WITH_ICON: text starts at x=40 + * and the icon is drawn AFTER the text, so a wider icon overwrites the + * alias / fingerprint / "NOT verified by KeepKey" warning. + */ +namespace { + +struct IconCanvas { + uint8_t buf[64 * 256]; + Canvas canvas; + IconCanvas() { + memset(buf, 0, sizeof(buf)); + canvas.buffer = buf; + canvas.width = 256; + canvas.height = 64; + canvas.dirty = false; + } +}; + +bool decode_icon(const std::vector& data, uint16_t w, uint16_t h, + IconCanvas* ic) { + Image img; + img.w = w; + img.h = h; + img.length = (uint32_t)data.size(); + img.data = data.data(); + AnimationFrame frame; + frame.x = 0; + frame.y = 0; + frame.duration = 0; + frame.color = 100; /* value*100/100 => data bytes land verbatim */ + frame.image = &img; + return draw_bitmap_mono_rle(&ic->canvas, &frame, /*erase=*/false); +} + +} // namespace + +TEST(SignedMetadataIcon, GoldenVectorDecodes) { + /* The vector published in messages-ethereum.proto: 03 FF FF 00 (w=2,h=2). */ + IconCanvas ic; + ASSERT_TRUE(decode_icon({0x03, 0xFF, 0xFF, 0x00}, 2, 2, &ic)); + EXPECT_EQ(ic.buf[0 * 256 + 0], 0xFF); + EXPECT_EQ(ic.buf[0 * 256 + 1], 0xFF); + EXPECT_EQ(ic.buf[1 * 256 + 0], 0xFF); + EXPECT_EQ(ic.buf[1 * 256 + 1], 0x00); +} + +TEST(SignedMetadataIcon, LiteralOf128IsRejected) { + /* n = 0x80 = -128. Spec-valid under the old doc, undecodable in fact: + * previously asserted (debug) or decoded with a negative counter (NDEBUG). */ + std::vector data; + data.push_back(0x80); + for (int i = 0; i < 128; i++) data.push_back(0xAA); + IconCanvas ic; + EXPECT_FALSE(decode_icon(data, 128, 1, &ic)); +} + +TEST(SignedMetadataIcon, ZeroCountIsRejected) { + /* n == 0 leaves both counters at 0 and hits the same broken invariant. */ + IconCanvas ic; + EXPECT_FALSE(decode_icon({0x00, 0xFF}, 1, 1, &ic)); +} + +TEST(SignedMetadataIcon, MaxLiteralOf127Decodes) { + /* The boundary that IS valid: n = -127 (0x81). */ + std::vector data; + data.push_back(0x81); + for (int i = 0; i < 127; i++) data.push_back((uint8_t)i); + IconCanvas ic; + ASSERT_TRUE(decode_icon(data, 127, 1, &ic)); + EXPECT_EQ(ic.buf[0], 0x00); + EXPECT_EQ(ic.buf[126], 126); +} + +TEST(SignedMetadataIcon, MaxRunOf127Decodes) { + std::vector data{0x7F, 0x5A}; + IconCanvas ic; + ASSERT_TRUE(decode_icon(data, 127, 1, &ic)); + EXPECT_EQ(ic.buf[0], 0x5A); + EXPECT_EQ(ic.buf[126], 0x5A); +} + +TEST(SignedMetadataIcon, TruncatedStreamIsRejected) { + IconCanvas ic; + EXPECT_FALSE(decode_icon({0x08, 0xFF}, 4, 4, &ic)); /* claims 8, has 2 */ +} + +/* ── Exact-validation guards (review round 2) ────────────────────────────── + * The render path is lenient by construction: it fills the canvas and stops, + * so it cannot reject a final run that straddles the image or trailing packets. + * Callers gate on the validator, so the validator must be exact. */ + +TEST(SignedMetadataIcon, StraddlingRunIsRejected) { + /* 05 FF for a 2x2: a RUN of 5 into a 4-pixel image. The draw loop would fill + * 4 and report success; the stream is not well-formed. */ + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x05\xFF", 2, 2, 2)); + IconCanvas ic; + EXPECT_FALSE(decode_icon({0x05, 0xFF}, 2, 2, &ic)); +} + +TEST(SignedMetadataIcon, TrailingPacketsAreRejected) { + /* Exactly fills 2x2, then carries an unread packet. */ + EXPECT_FALSE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\x04\xFF\x01\xAA", 4, 2, 2)); +} + +TEST(SignedMetadataIcon, TruncatedLiteralBodyIsRejected) { + /* n = -3 promises 3 value bytes, only 2 present. */ + EXPECT_FALSE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\xFD\x01\x02", 3, 3, 1)); +} + +TEST(SignedMetadataIcon, MissingRunValueByteIsRejected) { + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x04", 1, 4, 1)); +} + +TEST(SignedMetadataIcon, ValidatorAcceptsExactStreams) { + /* The golden vector, and the valid boundaries. */ + EXPECT_TRUE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\x03\xFF\xFF\x00", 4, 2, 2)); + EXPECT_TRUE( + draw_bitmap_mono_rle_valid((const uint8_t*)"\x7F\x5A", 2, 127, 1)); + std::vector lit; + lit.push_back(0x81); + for (int i = 0; i < 127; i++) lit.push_back((uint8_t)i); + EXPECT_TRUE( + draw_bitmap_mono_rle_valid(lit.data(), (uint32_t)lit.size(), 127, 1)); +} + +TEST(SignedMetadataIcon, ValidatorRejectsUndecodableAndZeroCounts) { + std::vector lit128; + lit128.push_back(0x80); + for (int i = 0; i < 128; i++) lit128.push_back(0xAA); + EXPECT_FALSE(draw_bitmap_mono_rle_valid(lit128.data(), + (uint32_t)lit128.size(), 128, 1)); + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x00\xFF", 2, 1, 1)); + /* The 1x1 accept-and-persist case: 80 FF was previously stored despite never + * rendering, because only size+dims were checked at the trust boundary. */ + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x80\xFF", 2, 1, 1)); +} + +TEST(SignedMetadataIcon, ValidatorRejectsDegenerateGeometry) { + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x01\xFF", 2, 0, 1)); + EXPECT_FALSE(draw_bitmap_mono_rle_valid((const uint8_t*)"\x01\xFF", 2, 1, 0)); + EXPECT_FALSE(draw_bitmap_mono_rle_valid(NULL, 0, 1, 1)); +} + +TEST(SignedMetadataIcon, IconColumnCapIsNarrowerThanTheIconHeight) { + /* The width cap is the 40px text column, NOT the 64px height. A 64px-wide + * icon at x=0 would span into the text that begins at x=40 and, because the + * icon is drawn after the text, erase the "NOT verified" warning. */ + EXPECT_EQ(LEFT_MARGIN_WITH_ICON, 40); + EXPECT_LT(LEFT_MARGIN_WITH_ICON, 64); +} + +TEST(SignedMetadataSignerValid, AcceptsValidCompressedKeyAllSlots) { + for (uint8_t slot = 0; slot < METADATA_MAX_KEYS; slot++) { + EXPECT_TRUE( + signed_metadata_signer_valid(slot, EXPECTED_SLOT3_PUB, 33, "CI Test")) + << "slot " << (int)slot; + } +} + +TEST(SignedMetadataSignerValid, RejectsKeyIdOutOfRange) { + EXPECT_FALSE(signed_metadata_signer_valid(METADATA_MAX_KEYS, + EXPECTED_SLOT3_PUB, 33, "CI Test")); +} + +TEST(SignedMetadataSignerValid, RejectsWrongPubkeyLength) { + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 32, "CI Test")); + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 65, "CI Test")); + EXPECT_FALSE(signed_metadata_signer_valid(0, nullptr, 33, "CI Test")); +} + +TEST(SignedMetadataSignerValid, RejectsNonCompressedPrefix) { + /* 0x04 would make ecdsa_read_pubkey read 65 bytes from a 33-byte buffer — + * the prefix guard must reject it before the parser ever runs. */ + uint8_t bad[33]; + memcpy(bad, EXPECTED_SLOT3_PUB, sizeof(bad)); + bad[0] = 0x04; + EXPECT_FALSE(signed_metadata_signer_valid(0, bad, 33, "CI Test")); + bad[0] = 0x00; // the "empty slot" sentinel must never load as a key + EXPECT_FALSE(signed_metadata_signer_valid(0, bad, 33, "CI Test")); +} + +TEST(SignedMetadataSignerValid, RejectsBadAlias) { + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, nullptr)); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "")); + std::string too_long(METADATA_ALIAS_MAX_LEN + 1, 'a'); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, + too_long.c_str())); + std::string max_len(METADATA_ALIAS_MAX_LEN, 'a'); + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, max_len.c_str())); + /* Realistic aliases (letters/digits/space/-/_) are accepted. */ + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "Pioneer")); + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "KeepKey Swap")); + EXPECT_TRUE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "my-signer_1")); + /* Rendered inside quotes on the trust screen — control chars, '%', and + * semantic-injection punctuation (quote breakout, "." / "(" appending a + * false "verified by KeepKey." claim) are all rejected. */ + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "a\nb")); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "a%sb")); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, + "a\x7f" + "b")); + EXPECT_FALSE(signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, + "x' verified by KeepKey. Safe (")); + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "safe.KeepKey")); + EXPECT_FALSE( + signed_metadata_signer_valid(0, EXPECTED_SLOT3_PUB, 33, "trust(me)")); +} + +TEST(SignedMetadataSignerStore, RejectsPersistenceBeforeSessionMutation) { + signed_metadata_clear_signers(); + EXPECT_FALSE(signed_metadata_store_signer( + TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, nullptr, 0, 0, 0, true)); + EXPECT_EQ(signed_metadata_signer_alias(TEST_KEY_ID), nullptr); + char fingerprint[METADATA_FINGERPRINT_LEN]; + EXPECT_FALSE(signed_metadata_signer_fingerprint(TEST_KEY_ID, fingerprint)); + signed_metadata_clear_signers(); +} + +/* ---- signed_metadata_pubkey_fingerprint -------------------------------- */ + +TEST(SignedMetadataFingerprint, IsSha256Prefix) { + char fp[METADATA_FINGERPRINT_LEN]; + signed_metadata_pubkey_fingerprint(EXPECTED_SLOT3_PUB, fp); + + uint8_t digest[32]; + sha256_Raw(EXPECTED_SLOT3_PUB, 33, digest); + char expected[METADATA_FINGERPRINT_LEN]; + snprintf(expected, sizeof(expected), "%02X%02X%02X%02X", digest[0], digest[1], + digest[2], digest[3]); + EXPECT_STREQ(fp, expected); +} + +/* ===================================================================== * + * signed_metadata_enforce_decision — pure enforce truth table (SECTION 2). + * Exercises the relied==true cases that confirm()'s OLED/button I/O makes + * unreachable from the module-state API in a unit test. + * ===================================================================== */ + +TEST(SignedMetadataEnforce, NotReliedAlwaysAllow) { + uint8_t h[32] = {0}; + uint8_t hw[32] = {1}; + EXPECT_TRUE( + signed_metadata_enforce_decision(false, true, METADATA_VERIFIED, h, h)); + EXPECT_TRUE(signed_metadata_enforce_decision(false, false, METADATA_OPAQUE, + nullptr, nullptr)); + EXPECT_TRUE( + signed_metadata_enforce_decision(false, true, METADATA_VERIFIED, h, hw)); +} + +TEST(SignedMetadataEnforce, ReliedHashMatches) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_TRUE( + signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, h, h)); +} + +TEST(SignedMetadataEnforce, ReliedHashMismatch) { + uint8_t stored[32]; + memcpy(stored, TX_HASH, 32); + uint8_t got[32]; + memcpy(got, TX_HASH, 32); + got[0] ^= 0x01; + EXPECT_FALSE(signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, + stored, got)); +} + +TEST(SignedMetadataEnforce, ReliedHashNull) { + uint8_t stored[32]; + memcpy(stored, TX_HASH, 32); + EXPECT_FALSE(signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, + stored, nullptr)); +} + +TEST(SignedMetadataEnforce, ReliedNotAvailable) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_FALSE( + signed_metadata_enforce_decision(true, false, METADATA_VERIFIED, h, h)); +} + +TEST(SignedMetadataEnforce, ReliedNotVerified) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_FALSE( + signed_metadata_enforce_decision(true, true, METADATA_OPAQUE, h, h)); + EXPECT_FALSE( + signed_metadata_enforce_decision(true, true, METADATA_MALFORMED, h, h)); +} + +TEST(SignedMetadataEnforce, ReliedStoredHashNull) { + uint8_t h[32]; + memcpy(h, TX_HASH, 32); + EXPECT_FALSE(signed_metadata_enforce_decision(true, true, METADATA_VERIFIED, + nullptr, h)); +} + +/* ===================================================================== * + * SECTION 3 — v2 static-schema blobs + on-device calldata decode + * + * v2 carries NO tx_hash and NO argument values. The blob attests only the + * static schema (chainId, contract, selector, method, per-arg name + display + * format [+ static decimals/symbol]); the device decodes the actual argument + * values from the calldata it is about to sign. These tests drive the full + * parse -> verify -> signed_metadata_matches_tx (which decodes) path and check + * the decoded MetadataArg values, plus the malformed/rejection cases. + * ===================================================================== */ + +struct V2Arg { + std::string name; + uint8_t format; + uint8_t decimals; /* TOKEN_AMOUNT only */ + std::string symbol; /* TOKEN_AMOUNT only */ +}; + +V2Arg v2_addr(const std::string& name) { + return V2Arg{name, ARG_FORMAT_ADDRESS, 0, ""}; +} +V2Arg v2_token(const std::string& name, uint8_t decimals, + const std::string& symbol) { + return V2Arg{name, ARG_FORMAT_TOKEN_AMOUNT, decimals, symbol}; +} + +struct V2Spec { + uint32_t chain_id; + std::vector contract; + std::vector selector; + std::string method; + std::vector args; + uint8_t classification; + uint8_t key_id; + int num_args_override; // -1 => use args.size() +}; + +V2Spec v2_base_spec() { + V2Spec s; + s.chain_id = 1; + s.contract.assign(CONTRACT_A, CONTRACT_A + 20); + s.selector.assign(SEL_TRANSFER, SEL_TRANSFER + 4); + s.method = "transfer"; + s.args.push_back(v2_addr("to")); + s.args.push_back(v2_token("amount", 6, "USDC")); + s.classification = METADATA_VERIFIED; + s.key_id = TEST_KEY_ID; + s.num_args_override = -1; + return s; +} + +std::vector build_v2_body(const V2Spec& s) { + std::vector b; + put_u8(b, METADATA_VERSION_SCHEMA); + put_be32(b, s.chain_id); + put_bytes(b, s.contract.data(), s.contract.size()); + put_bytes(b, s.selector.data(), s.selector.size()); + put_be16(b, (uint16_t)s.method.size()); + put_bytes(b, (const uint8_t*)s.method.data(), s.method.size()); + put_u8(b, s.num_args_override >= 0 ? (uint8_t)s.num_args_override + : (uint8_t)s.args.size()); + for (const V2Arg& a : s.args) { + put_u8(b, (uint8_t)a.name.size()); + put_bytes(b, (const uint8_t*)a.name.data(), a.name.size()); + put_u8(b, a.format); + if (a.format == ARG_FORMAT_TOKEN_AMOUNT) { + put_u8(b, a.decimals); + put_u8(b, (uint8_t)a.symbol.size()); + put_bytes(b, (const uint8_t*)a.symbol.data(), a.symbol.size()); + } + } + put_u8(b, s.classification); + put_be32(b, 0); // timestamp + put_u8(b, s.key_id); + return b; +} + +std::vector v2_base_blob() { + return sign_body(build_v2_body(v2_base_spec())); +} + +/* ABI calldata: selector + one 32-byte head word per arg. */ +void put_addr_word(std::vector& d, const uint8_t addr[20]) { + for (int i = 0; i < 12; i++) d.push_back(0); + d.insert(d.end(), addr, addr + 20); +} + +/* Canonical transfer(to=RECIPIENT, amount=AMOUNT32) calldata (4 + 64 = 68). */ +std::vector v2_transfer_calldata() { + std::vector d(SEL_TRANSFER, SEL_TRANSFER + 4); + put_addr_word(d, RECIPIENT); + d.insert(d.end(), AMOUNT32, AMOUNT32 + 32); + return d; +} + +void make_v2_msg(EthereumSignTx* msg, const uint8_t contract[20], + const std::vector& data, bool has_len, + uint32_t data_length) { + memset(msg, 0, sizeof(*msg)); + msg->has_to = true; + msg->to.size = 20; + memcpy(msg->to.bytes, contract, 20); + msg->has_data_initial_chunk = true; + msg->data_initial_chunk.size = (pb_size_t)data.size(); + memcpy(msg->data_initial_chunk.bytes, data.data(), data.size()); + msg->has_chain_id = true; + msg->chain_id = 1; + msg->has_data_length = has_len; + msg->data_length = has_len ? data_length : 0; +} + +/* Happy path: parse+verify a v2 blob, then matches_tx decodes the args from the + * transfer calldata and populates stored_metadata. */ +TEST_F(SignedMetadataTest, V2SchemaDecodesTransferArgs) { + std::vector blob = v2_base_blob(); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_TRUE(signed_metadata_available()); + + const SignedMetadata* md = signed_metadata_get(); + ASSERT_NE(md, nullptr); + EXPECT_EQ(md->version, METADATA_VERSION_SCHEMA); + EXPECT_EQ(md->num_args, 2); + EXPECT_EQ(md->args[0].value_len, 0); // undecoded before matches_tx + + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + + EXPECT_EQ(md->args[0].format, ARG_FORMAT_ADDRESS); + EXPECT_EQ(md->args[0].value_len, 20); + EXPECT_EQ(memcmp(md->args[0].value, RECIPIENT, 20), 0); + + EXPECT_EQ(md->args[1].format, ARG_FORMAT_TOKEN_AMOUNT); + EXPECT_EQ(md->args[1].value_len, 2 + 4 + 32); + EXPECT_EQ(md->args[1].value[0], 6); // decimals + EXPECT_EQ(md->args[1].value[1], 4); // symlen + EXPECT_EQ(memcmp(md->args[1].value + 2, "USDC", 4), 0); + EXPECT_EQ(memcmp(md->args[1].value + 6, AMOUNT32, 32), 0); +} + +/* THE v2 drain preventer, restated. + * + * A v2 schema commits to calldata only — never to msg->value — so it cannot + * bind a payable call's amount. The original guard refused any nonzero value, + * which meant every value-bearing route (a Relay ETH->SOL bridge deposit, for + * one) was forced to blind-sign: precisely the transactions most worth + * reviewing. Refusing was not what kept funds safe; SHOWING the amount is. + * + * So the match now succeeds and the schema reports that the tx moves value. + * ethereum.c consumes that to keep the native amount/recipient screen instead + * of suppressing it, so the user sees the decoded call AND the ETH leaving. + * The amount is read from the transaction being signed, so nothing unattested + * reaches the screen and the schema stays transaction-independent. */ +TEST_F(SignedMetadataTest, V2SchemaPayableKeepsValueScreen) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + msg.has_value = true; + msg.value.size = 1; + msg.value.bytes[0] = 0x01; // 1 wei — any nonzero value is "payable" + + /* Clear-signs, AND flags that the amount screen must still run. */ + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_TRUE(signed_metadata_schema_moves_value()); + + /* Zero value: same match, but no extra screen is demanded — proving the + * flag tracks the value rather than being always-on. */ + msg.value.size = 0; + msg.has_value = false; + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_FALSE(signed_metadata_schema_moves_value()); +} + +/* A large, realistic value must set the flag too — not just a 1-wei probe. */ +TEST_F(SignedMetadataTest, V2SchemaPayableFlagsRealisticValue) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + msg.has_value = true; + /* 0.00798 ETH = 0x1c5145d9b6b3ff — the Relay ETH->SOL deposit from a real + * quote, whose blind-signing prompted this change. */ + const uint8_t kValue[] = {0x1c, 0x51, 0x45, 0xd9, 0xb6, 0xb3, 0xff}; + msg.value.size = sizeof(kValue); + memcpy(msg.value.bytes, kValue, sizeof(kValue)); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_TRUE(signed_metadata_schema_moves_value()); +} + +/* THE transaction this whole path exists for: a real Relay ETH->SOL bridge + * deposit, captured from api.relay.link on 2026-07-27. + * + * to 0x4cd00e387622c35bddb9b4c962c136462338bc31 + * value 7980129999999999 wei (0.00798 ETH) <-- PAYABLE + * calldata 0x49290c1c + address(depositor) + bytes32(orderId) = 68 bytes + * + * Three things had to be true for this to clear-sign, and each was a real + * blocker: the call is payable (was refused outright), one arg is an opaque + * word (BYTES was not accepted in the v2 arg parser), and 4 + 2*32 must + * exactly equal the calldata length (structural completeness). */ +TEST_F(SignedMetadataTest, V2SchemaDecodesRelayEthToSolanaDeposit) { + const uint8_t RELAY_ROUTER[20] = {0x4c, 0xd0, 0x0e, 0x38, 0x76, 0x22, 0xc3, + 0x5b, 0xdd, 0xb9, 0xb4, 0x96, 0x2c, 0x13, + 0x64, 0x62, 0x33, 0x8b, 0xc3, 0x31}; + const uint8_t SEL[4] = {0x49, 0x29, 0x0c, 0x1c}; + /* depositor 0x909Ef6B32DfDc12CA86aA710b54c991af3C5F82E */ + const uint8_t DEPOSITOR[20] = {0x90, 0x9e, 0xf6, 0xb3, 0x2d, 0xfd, 0xc1, + 0x2c, 0xa8, 0x6a, 0xa7, 0x10, 0xb5, 0x4c, + 0x99, 0x1a, 0xf3, 0xc5, 0xf8, 0x2e}; + /* orderId 0x8a2c1211...cb1, verbatim from the quote */ + const uint8_t ORDER_ID[32] = {0x8a, 0x2c, 0x12, 0x11, 0x97, 0xef, 0xc9, 0x5c, + 0x42, 0xf5, 0x31, 0x42, 0xab, 0x40, 0x97, 0x35, + 0xee, 0x35, 0x32, 0x87, 0xf8, 0x77, 0xed, 0x4d, + 0x35, 0x1f, 0x63, 0x09, 0x4d, 0x5b, 0xfc, 0xb1}; + + V2Spec s = v2_base_spec(); + s.contract.assign(RELAY_ROUTER, RELAY_ROUTER + 20); + s.selector.assign(SEL, SEL + 4); + s.method = "bridgeDeposit"; + s.args.clear(); + s.args.push_back(v2_addr("depositor")); + s.args.push_back(V2Arg{"orderId", ARG_FORMAT_BYTES, 0, ""}); + + std::vector blob = sign_body(build_v2_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + std::vector data(SEL, SEL + 4); + put_addr_word(data, DEPOSITOR); + data.insert(data.end(), ORDER_ID, ORDER_ID + 32); + ASSERT_EQ(data.size(), 68u); /* 4 + 2*32, exactly — no remainder */ + + EthereumSignTx msg; + make_v2_msg(&msg, RELAY_ROUTER, data, /*has_len=*/true, + (uint32_t)data.size()); + /* 0.00798 ETH — the payable part that used to force blind-signing. */ + const uint8_t VALUE[] = {0x1c, 0x51, 0x45, 0xd9, 0xb6, 0xb3, 0xff}; + msg.has_value = true; + msg.value.size = sizeof(VALUE); + memcpy(msg.value.bytes, VALUE, sizeof(VALUE)); + + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + /* ...and the ETH amount screen must still run, since the schema cannot + * bind the value. */ + EXPECT_TRUE(signed_metadata_schema_moves_value()); + + const SignedMetadata* md = signed_metadata_get(); + ASSERT_NE(md, nullptr); + EXPECT_EQ(md->num_args, 2); + EXPECT_EQ(md->args[0].format, ARG_FORMAT_ADDRESS); + EXPECT_EQ(md->args[0].value_len, 20); + EXPECT_EQ(memcmp(md->args[0].value, DEPOSITOR, 20), 0); + EXPECT_EQ(md->args[1].format, ARG_FORMAT_BYTES); + EXPECT_EQ(md->args[1].value_len, 32); + EXPECT_EQ(memcmp(md->args[1].value, ORDER_ID, 32), 0); +} + +/* Relay solver swap: selector 0x02d5f05f(token address, amount, requestId) — + * three fixed single words, EXACTLY the shape pulled from real relay traffic + * (100-byte calldata: 4 + 3*32, zero remainder, verified across 22 live + * samples). Proves a v2 static schema clear-signs a relay swap: the device + * decodes token+amount+id from the very calldata it is about to sign — no + * tx_hash, no per-tx online signer, schema signed once offline. This is the + * "add a new service via a signed payload" path for a NON-native contract + * (relay is not in ethereum_contractHandled). */ +TEST_F(SignedMetadataTest, V2SchemaDecodesRelaySolverArgs) { + const uint8_t RELAY_SOLVER[20] = {0x4c, 0xd0, 0x0e, 0x38, 0x76, 0x22, 0xc3, + 0x5b, 0xdd, 0xb9, 0xb4, 0x96, 0x2c, 0x13, + 0x64, 0x62, 0x33, 0x8b, 0xc3, 0x31}; + const uint8_t SEL_RELAY[4] = {0x02, 0xd5, 0xf0, 0x5f}; + uint8_t REQ_ID[32] = {0}; // requestId 0x...cd7c from a real sample + REQ_ID[30] = 0xcd; + REQ_ID[31] = 0x7c; + + V2Spec s = v2_base_spec(); + s.contract.assign(RELAY_SOLVER, RELAY_SOLVER + 20); + s.selector.assign(SEL_RELAY, SEL_RELAY + 4); + s.method = "relaySwap"; + s.args.clear(); + s.args.push_back(v2_addr("token")); + s.args.push_back(v2_token("amount", 6, "USDC")); + s.args.push_back(V2Arg{"requestId", ARG_FORMAT_AMOUNT, 0, ""}); + + std::vector blob = sign_body(build_v2_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + + const SignedMetadata* md = signed_metadata_get(); + ASSERT_NE(md, nullptr); + EXPECT_EQ(md->version, METADATA_VERSION_SCHEMA); + EXPECT_EQ(md->num_args, 3); + + // Real relay calldata: selector + token(USDC=CONTRACT_A) + amount + + // requestId. + std::vector data(SEL_RELAY, SEL_RELAY + 4); + put_addr_word(data, CONTRACT_A); + data.insert(data.end(), AMOUNT32, AMOUNT32 + 32); + data.insert(data.end(), REQ_ID, REQ_ID + 32); + EXPECT_EQ(data.size(), 100u); + + EthereumSignTx msg; + make_v2_msg(&msg, RELAY_SOLVER, data, /*has_len=*/true, + (uint32_t)data.size()); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + + // token → full 20-byte USDC address (never truncated). + EXPECT_EQ(md->args[0].format, ARG_FORMAT_ADDRESS); + EXPECT_EQ(md->args[0].value_len, 20); + EXPECT_EQ(memcmp(md->args[0].value, CONTRACT_A, 20), 0); + + // amount → TOKEN_AMOUNT [decimals=6, "USDC", 32-byte amount]. + EXPECT_EQ(md->args[1].format, ARG_FORMAT_TOKEN_AMOUNT); + EXPECT_EQ(md->args[1].value[0], 6); + EXPECT_EQ(md->args[1].value[1], 4); + EXPECT_EQ(memcmp(md->args[1].value + 2, "USDC", 4), 0); + EXPECT_EQ(memcmp(md->args[1].value + 6, AMOUNT32, 32), 0); + + // requestId → raw 32-byte AMOUNT word. + EXPECT_EQ(md->args[2].format, ARG_FORMAT_AMOUNT); + EXPECT_EQ(md->args[2].value_len, 32); + EXPECT_EQ(memcmp(md->args[2].value, REQ_ID, 32), 0); +} + +/* has_data_length omitted but the initial chunk IS the whole calldata: allowed. + */ +TEST_F(SignedMetadataTest, V2AcceptsNoDataLengthWhenChunkComplete) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/false, 0); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); +} + +/* Reject when the tx claims MORE calldata than the schema accounts for. */ +TEST_F(SignedMetadataTest, V2RejectsExtraCalldataLength) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); // 68 bytes + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, 100); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* Reject a partial initial chunk (rest would stream later). */ +TEST_F(SignedMetadataTest, V2RejectsPartialInitialChunk) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + data.resize(40); // selector + partial first word + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, 68); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* Reject an ABI address word with non-zero high bytes. */ +TEST_F(SignedMetadataTest, V2RejectsDirtyAddressWord) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + data[4] = 0x01; // first (should-be-zero) byte of the address word + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* Wrong selector in calldata -> matches_tx fails before decode. */ +TEST_F(SignedMetadataTest, V2RejectsSelectorMismatch) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + memcpy(data.data(), SEL_APPROVE, 4); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_FALSE(signed_metadata_matches_tx(&msg)); +} + +/* An unsupported display format -> MALFORMED. + * + * v2 renders fixed single ABI words only: ADDRESS, AMOUNT, BYTES and + * TOKEN_AMOUNT. STRING is dynamic (offset + length + payload), so it cannot be + * read from one 32-byte word and must stay out of scope — accepting it would + * break the "declared widths equal the calldata length" rule that makes a + * schema safe without a tx_hash. An out-of-range format byte must fail too. */ +TEST_F(SignedMetadataTest, V2RejectsUnsupportedFormat) { + V2Spec s = v2_base_spec(); + s.args[1] = V2Arg{"data", ARG_FORMAT_STRING, 0, ""}; + std::vector blob = sign_body(build_v2_body(s)); + ExpectMalformed(blob, TEST_KEY_ID); + + V2Spec bogus = v2_base_spec(); + bogus.args[1] = V2Arg{"data", (ArgFormat)0x7f, 0, ""}; + std::vector blob2 = sign_body(build_v2_body(bogus)); + ExpectMalformed(blob2, TEST_KEY_ID); +} + +/* BYTES IS supported in v2: an opaque fixed word (a router's order id) still + * occupies exactly one ABI word, so it neither breaks structural completeness + * nor needs a dynamic decoder. */ +TEST_F(SignedMetadataTest, V2AcceptsBytesArg) { + V2Spec s = v2_base_spec(); + s.args[1] = V2Arg{"orderId", ARG_FORMAT_BYTES, 0, ""}; + std::vector blob = sign_body(build_v2_body(s)); + EXPECT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); +} + +/* Tampered v2 body must fail the signature check. */ +TEST_F(SignedMetadataTest, V2RejectsTamperedBody) { + std::vector blob = v2_base_blob(); + blob[5] ^= 0xFF; // flip a contract-address byte in the signed region + ExpectMalformed(blob, TEST_KEY_ID); +} + +/* Zero-arg v2 schema (selector-only call): valid, decodes nothing. */ +TEST_F(SignedMetadataTest, V2ZeroArgsSelectorOnly) { + V2Spec s = v2_base_spec(); + s.args.clear(); + s.method = "poke"; + std::vector blob = sign_body(build_v2_body(s)); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data(SEL_TRANSFER, SEL_TRANSFER + 4); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, 4); + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + EXPECT_EQ(signed_metadata_get()->num_args, 0); +} + +/* matches_tx() must be idempotent: a second call decodes to the same values + * (regression for the TOKEN_AMOUNT prefix that used to grow on each call). */ +TEST_F(SignedMetadataTest, V2MatchesTxIsIdempotent) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EthereumSignTx msg; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&msg, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); + const SignedMetadata* md = signed_metadata_get(); + uint16_t len_addr = md->args[0].value_len, len_tok = md->args[1].value_len; + + EXPECT_TRUE(signed_metadata_matches_tx(&msg)); // second call + EXPECT_EQ(md->args[0].value_len, len_addr); + EXPECT_EQ(md->args[1].value_len, len_tok); + EXPECT_EQ(md->args[1].value_len, 2 + 4 + 32); + EXPECT_EQ(memcmp(md->args[0].value, RECIPIENT, 20), 0); + EXPECT_EQ(memcmp(md->args[1].value + 6, AMOUNT32, 32), 0); +} + +/* The v2 decode flag must reflect ONLY the latest matches_tx() call: a + * successful decode followed by a mismatching tx must leave it false, so a + * stale "decoded" proof can never survive into enforce. */ +TEST_F(SignedMetadataTest, V2SchemaDecodedFlagNotStaleAfterMismatch) { + std::vector blob = v2_base_blob(); + ASSERT_EQ(signed_metadata_process(blob.data(), blob.size(), TEST_KEY_ID), + METADATA_VERIFIED); + EXPECT_FALSE(signed_metadata_schema_decoded()); // not decoded yet + + EthereumSignTx ok; + std::vector data = v2_transfer_calldata(); + make_v2_msg(&ok, CONTRACT_A, data, /*has_len=*/true, (uint32_t)data.size()); + ASSERT_TRUE(signed_metadata_matches_tx(&ok)); + EXPECT_TRUE(signed_metadata_schema_decoded()); // decoded this tx + + /* Now a tx that fails an EARLY binding (wrong contract) — before the decode + * branch. The flag must be cleared, not left over from the match above. */ + EthereumSignTx bad; + make_v2_msg(&bad, CONTRACT_B, data, /*has_len=*/true, (uint32_t)data.size()); + EXPECT_FALSE(signed_metadata_matches_tx(&bad)); + EXPECT_FALSE(signed_metadata_schema_decoded()); +} + +/* ---- v2 enforce truth table (pure, no I/O) ------------------------------ */ +/* Signature: (relied, available, decoded, classification). */ + +TEST(SignedMetadataEnforceSchema, NotReliedAlwaysAllow) { + EXPECT_TRUE(signed_metadata_enforce_schema_decision(false, true, true, + METADATA_VERIFIED)); + EXPECT_TRUE(signed_metadata_enforce_schema_decision(false, false, false, + METADATA_OPAQUE)); +} + +TEST(SignedMetadataEnforceSchema, ReliedVerifiedDecodedAllow) { + EXPECT_TRUE(signed_metadata_enforce_schema_decision(true, true, true, + METADATA_VERIFIED)); +} + +TEST(SignedMetadataEnforceSchema, ReliedButNotDecodedFails) { + /* The core hardening: relied + available + VERIFIED but decode never ran. */ + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, true, false, + METADATA_VERIFIED)); +} + +TEST(SignedMetadataEnforceSchema, ReliedButUnavailableOrUnverifiedFails) { + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, false, true, + METADATA_VERIFIED)); + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, true, true, + METADATA_OPAQUE)); + EXPECT_FALSE(signed_metadata_enforce_schema_decision(true, true, true, + METADATA_MALFORMED)); +} + +// Generic attestation primitive (used by the Solana signed-token-definition +// path): a valid signature from a loaded signer verifies; tampering, an +// unloaded key_id, or a wrong signature length are all rejected. +TEST(SignedMetadataAttestation, VerifiesValidRejectsTampered) { + set_advanced_mode_for_test(true); + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + nullptr, 0, 0, 0, false); + + const uint8_t data[] = "KeepKeySolanaTokenDef/1|mint|decimals|USDC"; + const size_t len = sizeof(data) - 1; + uint8_t digest[32]; + sha256_Raw(data, len, digest); + uint8_t sig[64]; + uint8_t pby; + ASSERT_EQ( + 0, ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, &pby, nullptr)); + + EXPECT_TRUE(signed_metadata_verify_attestation(TEST_KEY_ID, data, len, sig, + sizeof(sig))); + + std::vector bad(data, data + len); + bad[0] ^= 0x01; + EXPECT_FALSE(signed_metadata_verify_attestation(TEST_KEY_ID, bad.data(), len, + sig, sizeof(sig))); + EXPECT_FALSE(signed_metadata_verify_attestation((uint8_t)(TEST_KEY_ID + 1), + data, len, sig, sizeof(sig))); + EXPECT_FALSE( + signed_metadata_verify_attestation(TEST_KEY_ID, data, len, sig, 63)); + + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); +} + +// End-to-end test of the production Solana token-definition path: builds the +// exact domain-separated preimage solana_token_info_trusted() reconstructs, +// signs it, and checks acceptance + every rejection branch. +TEST(SolanaTokenDef, TrustedOnlyWithValidAttestation) { + set_advanced_mode_for_test(true); + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + nullptr, 0, 0, 0, false); + + SolanaTokenInfo ti; + memset(&ti, 0, sizeof(ti)); + ti.has_mint = true; + ti.mint.size = 32; + memset(ti.mint.bytes, 0xAB, 32); + ti.has_symbol = true; + strcpy(ti.symbol, "USDC"); + ti.has_decimals = true; + ti.decimals = 6; + ti.has_signer_key_id = true; + ti.signer_key_id = TEST_KEY_ID; + + // Canonical preimage: tag || mint(32) || decimals(le32) || symbol. + std::vector pre; + const char* tag = "KeepKeySolanaTokenDef/1"; + pre.insert(pre.end(), tag, tag + strlen(tag)); + pre.insert(pre.end(), ti.mint.bytes, ti.mint.bytes + 32); + pre.push_back(6); + pre.push_back(0); + pre.push_back(0); + pre.push_back(0); + pre.insert(pre.end(), ti.symbol, ti.symbol + strlen(ti.symbol)); + + uint8_t digest[32]; + sha256_Raw(pre.data(), pre.size(), digest); + uint8_t sig[64]; + uint8_t pby; + ASSERT_EQ( + 0, ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, &pby, nullptr)); + ti.has_signature = true; + ti.signature.size = 64; + memcpy(ti.signature.bytes, sig, 64); + + EXPECT_TRUE(solana_token_info_trusted(&ti)); + + // Attested-tuple disagreement: a different decimals no longer matches the + // sig. + ti.decimals = 9; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + ti.decimals = 6; + EXPECT_TRUE(solana_token_info_trusted(&ti)); + + // Corrupted signature. + ti.signature.bytes[10] ^= 0x40; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + ti.signature.bytes[10] ^= 0x40; + + // Out-of-range signer slot (256 would narrow to slot 0 without the guard). + ti.signer_key_id = 256; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + ti.signer_key_id = TEST_KEY_ID; + + // No attestation -> not trusted (the caller falls back to unsigned display). + ti.has_signature = false; + EXPECT_FALSE(solana_token_info_trusted(&ti)); + + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); +} + +/* ===================================================================== * + * Clearsign attestor: the issuer/verifier digest contract + * + * fsm_msgClearsignAttestorSign signs sha256(payload) as a 64-byte compact + * ECDSA signature; verifying devices check it through + * signed_metadata_verify_attestation. Those two constructions living in + * different files is exactly how SignIdentity ended up unusable for this + * (Bitcoin message header + double hash, 65 bytes). This pins the contract + * so a change on either side fails here rather than in the field. + * ===================================================================== */ + +TEST(ClearsignAttestor, SignedSchemaVerifiesOnTheVerifyingDevice) { + set_advanced_mode_for_test(true); + /* Smallest valid KKSOLSC1 payload: no args, no accounts. What matters here + * is the digest construction, not the schema body. */ + std::vector payload; + auto push = [&](const void* p, size_t n) { + const uint8_t* b = static_cast(p); + payload.insert(payload.end(), b, b + n); + }; + push("KKSOLSC1", 8); + payload.push_back(1); /* version */ + payload.insert(payload.end(), 32, 0x42); + payload.push_back(1); /* disc_len */ + payload.push_back(0x0d); /* discriminator */ + payload.push_back(5); + push("Relay", 5); + payload.push_back(7); + push("deposit", 7); + payload.push_back(0); /* no args */ + payload.push_back(0); /* no accounts */ + + SolanaInstrSchema schema; + ASSERT_TRUE(solana_parseInstrSchema(payload.data(), payload.size(), &schema)) + << "the attestor refuses to sign what it cannot parse"; + + /* Issuer side, byte for byte what the handler does. */ + uint8_t digest[32]; + sha256_Raw(payload.data(), payload.size(), digest); + uint8_t sig[64]; + ASSERT_EQ(ecdsa_sign_digest(&secp256k1, TEST_PRIV, digest, sig, NULL, NULL), + 0); + + /* Verifier side. */ + signed_metadata_clear_signers(); + signed_metadata_store_signer(TEST_KEY_ID, EXPECTED_SLOT3_PUB, TEST_ALIAS, + NULL, 0, 0, 0, false); + EXPECT_TRUE(signed_metadata_verify_attestation( + TEST_KEY_ID, payload.data(), payload.size(), sig, sizeof(sig))); + + /* A schema the attestor never saw must not ride the same signature. */ + payload[9] ^= 0x01; /* first byte of the program id */ + EXPECT_FALSE(signed_metadata_verify_attestation( + TEST_KEY_ID, payload.data(), payload.size(), sig, sizeof(sig))); + + signed_metadata_clear_signers(); + set_advanced_mode_for_test(false); +} + +} // namespace diff --git a/unittests/firmware/signing.cpp b/unittests/firmware/signing.cpp index ec1d3d2a9..b03ffd3a1 100644 --- a/unittests/firmware/signing.cpp +++ b/unittests/firmware/signing.cpp @@ -49,47 +49,13 @@ TEST(Signing, LegacyChangeMayNotClaimTaprootScriptType) { EXPECT_TRUE(Forbidden(84, 84, OutputScriptType_PAYTOTAPROOT)); } -TEST(Signing, ScriptTypeChecksumEncodingIsCanonicalFourByteLittleEndian) { - uint8_t encoded[4] = {0}; - signing_checksum_script_type_bytes(static_cast(0x01020304), - encoded); - const uint8_t expected[4] = {0x04, 0x03, 0x02, 0x01}; - EXPECT_EQ(0, memcmp(encoded, expected, sizeof(expected))); - EXPECT_EQ(4u, sizeof(encoded)); -} - -TEST(Signing, RejectsInvalidMultisigQuorumOnExternalAndChangeOutputs) { - for (bool internal : {false, true}) { - TxOutputType output = TxOutputType_init_zero; - output.has_multisig = true; - output.script_type = OutputScriptType_PAYTOMULTISIG; - output.multisig.has_m = true; - output.multisig.m = 2; - output.multisig.pubkeys_count = 3; - if (internal) { - output.address_n_count = 1; - output.address_n[0] = H(0); - } else { - output.has_address = true; - strcpy(output.address, "external"); - } - EXPECT_TRUE(signing_output_multisig_quorum_is_valid(&output)); - - output.multisig.m = 0; - EXPECT_FALSE(signing_output_multisig_quorum_is_valid(&output)); - output.multisig.m = 4; - EXPECT_FALSE(signing_output_multisig_quorum_is_valid(&output)); - output.multisig.m = 1; - output.multisig.pubkeys_count = 0; - EXPECT_FALSE(signing_output_multisig_quorum_is_valid(&output)); - output.multisig.pubkeys_count = 16; - EXPECT_FALSE(signing_output_multisig_quorum_is_valid(&output)); - } -} - -TEST(Signing, AbortScrubsAllInstrumentedSignerState) { - signing_test_seed_state(); - ASSERT_FALSE(signing_test_state_is_cleared()); - signing_abort(); - EXPECT_TRUE(signing_test_state_is_cleared()); +TEST(Signing, ScriptTypeChecksumEncodingIsAbiIndependent) { + uint8_t encoded[4] = {0xff, 0xff, 0xff, 0xff}; + signing_encode_script_type(InputScriptType_SPENDTAPROOT, encoded); + const uint32_t value = (uint32_t)InputScriptType_SPENDTAPROOT; + EXPECT_EQ(encoded[0], (uint8_t)value); + EXPECT_EQ(encoded[1], (uint8_t)(value >> 8)); + EXPECT_EQ(encoded[2], (uint8_t)(value >> 16)); + EXPECT_EQ(encoded[3], (uint8_t)(value >> 24)); + EXPECT_EQ(sizeof(encoded), 4U); } diff --git a/unittests/firmware/solana.cpp b/unittests/firmware/solana.cpp index cca7f9e1b..c0a4dac44 100644 --- a/unittests/firmware/solana.cpp +++ b/unittests/firmware/solana.cpp @@ -1,7 +1,6 @@ extern "C" { #include "keepkey/firmware/solana.h" #include "trezor/crypto/memzero.h" -#include "trezor/crypto/ed25519-donna/ed25519.h" } #include "gtest/gtest.h" @@ -20,13 +19,79 @@ TEST(Solana, FormatAmount) { EXPECT_STREQ(buf, "2.500000000 SOL"); } +/* The property here is that the amount is scaled by the decimals carried in + the signed instruction -- not that trailing zeros are trimmed. Trimming was + an older rendering detail on one branch; it is gone, because "1 USDC" hides + the scale the base-unit count was divided by while "1.000000 USDC" states + it. Every fractional place the scale produces is now shown. */ +TEST(Solana, FormatTokenAmountUsesSignedDecimals) { + char buf[48]; + + solana_formatTokenAmount(buf, sizeof(buf), 2000, "USDC", 6); + EXPECT_STREQ(buf, "0.002000 USDC"); + + solana_formatTokenAmount(buf, sizeof(buf), 1000000, "USDC", 6); + EXPECT_STREQ(buf, "1.000000 USDC"); + + solana_formatTokenAmount(buf, sizeof(buf), 2000, "tokens", 2); + EXPECT_STREQ(buf, "20.00 tokens"); +} + +TEST(Solana, MainnetUsdcIsFirmwareKnown) { + const uint8_t usdc_mint[32] = { + 0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, 0x3d, 0x65, 0xf3, + 0x6a, 0xab, 0xc9, 0x74, 0x31, 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, + 0xe0, 0xe4, 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61}; + const SolanaKnownToken* token = solana_findKnownToken(usdc_mint); + ASSERT_NE(token, nullptr); + EXPECT_STREQ(token->symbol, "USDC"); + EXPECT_EQ(token->decimals, 6); + + uint8_t unknown[32] = {0}; + EXPECT_EQ(solana_findKnownToken(unknown), nullptr); +} + +TEST(Solana, DerivesAndMatchesAssociatedTokenRecipientOwner) { + /* Vector independently produced by @solana/web3.js + * PublicKey.findProgramAddressSync with bump 251. */ + const uint8_t owner[32] = {0xea, 0x4a, 0x6c, 0x63, 0xe2, 0x9c, 0x52, 0x0a, + 0xbe, 0xf5, 0x50, 0x7b, 0x13, 0x2e, 0xc5, 0xf9, + 0x95, 0x47, 0x76, 0xae, 0xbe, 0xbe, 0x7b, 0x92, + 0x42, 0x1e, 0xea, 0x69, 0x14, 0x46, 0xd2, 0x2c}; + const uint8_t mint[32] = {0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, + 0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31, + 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4, + 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61}; + const uint8_t expected_ata[32] = { + 0x67, 0x30, 0x2e, 0x49, 0x18, 0x94, 0xd7, 0x49, 0x2e, 0xa6, 0xbe, + 0x4f, 0x91, 0x4e, 0xa4, 0xf4, 0x5f, 0xa1, 0x42, 0xe6, 0x45, 0x86, + 0x7c, 0x91, 0x64, 0xa2, 0x76, 0xd5, 0xdd, 0x76, 0xf0, 0x76}; + + uint8_t derived[32] = {0}; + ASSERT_TRUE(solana_deriveAssociatedTokenAddress(owner, SOL_TOKEN_PROGRAM, + mint, derived)); + EXPECT_EQ(memcmp(derived, expected_ata, sizeof(derived)), 0); + + SolanaSignTx msg = SolanaSignTx_init_zero; + msg.token_recipient_owner_count = 1; + msg.token_recipient_owner[0].size = sizeof(owner); + memcpy(msg.token_recipient_owner[0].bytes, owner, sizeof(owner)); + uint8_t matched[32] = {0}; + ASSERT_TRUE(solana_findTokenRecipientOwner(&msg, SOL_TOKEN_PROGRAM, mint, + expected_ata, matched)); + EXPECT_EQ(memcmp(matched, owner, sizeof(matched)), 0); + + uint8_t wrong_destination[32]; + memset(wrong_destination, 0x44, sizeof(wrong_destination)); + memset(matched, 0xaa, sizeof(matched)); + EXPECT_FALSE(solana_findTokenRecipientOwner(&msg, SOL_TOKEN_PROGRAM, mint, + wrong_destination, matched)); + for (uint8_t byte : matched) EXPECT_EQ(byte, 0xaa); +} + TEST(Solana, FormatTokenAmountNeverShowsZeroForNonzero) { char buf[64]; - /* Zero decimals is already an exact base-unit/token count. */ - solana_formatTokenAmount(buf, sizeof(buf), 1, "tokens", 0); - EXPECT_STREQ(buf, "1 tokens"); - /* The defect: at more than nine decimals the formatter divided the fraction down and printed the result, so a real transfer could render as zero. amount=1 decimals=18 became "0.000000000 tokens" while the signed @@ -62,19 +127,6 @@ TEST(Solana, FormatTokenAmountNeverShowsZeroForNonzero) { /* Zero really is zero, at any scale. */ solana_formatTokenAmount(buf, sizeof(buf), 0, "tokens", 18); EXPECT_STREQ(buf, "0.000000000 tokens"); - - /* The on-chain decimals field is a uint8_t and is not capped at 18. Values - outside the formatter's supported range must retain their signed scale. */ - solana_formatTokenAmount(buf, sizeof(buf), 1, "tokens", 19); - EXPECT_STREQ(buf, "1 base units (19 decimals) tokens"); - - solana_formatTokenAmount(buf, sizeof(buf), 0, "tokens", 255); - EXPECT_STREQ(buf, "0 base units (255 decimals) tokens"); - - /* The production caller also uses 64 bytes, so the longest fallback is not - silently truncated before it reaches the confirmation pager. */ - solana_formatTokenAmount(buf, sizeof(buf), UINT64_MAX, "tokens", 255); - EXPECT_STREQ(buf, "18446744073709551615 base units (255 decimals) tokens"); } TEST(Solana, ParseSystemTransfer) { @@ -158,160 +210,6 @@ TEST(Solana, ParseSystemTransfer) { EXPECT_TRUE(memcmp(tx.instructions[0].to, expected_to, 32) == 0); } -TEST(Solana, RecognizedInstructionMissingAccountsIsOpaque) { - uint8_t raw[160]; - size_t pos = 0; - raw[pos++] = 1; /* one required signer */ - raw[pos++] = 0; - raw[pos++] = 1; /* system program is readonly */ - raw[pos++] = 2; /* signer + system program */ - memset(raw + pos, 0x11, SOL_PUBKEY_SIZE); - pos += SOL_PUBKEY_SIZE; - memcpy(raw + pos, SOL_SYSTEM_PROGRAM, SOL_PUBKEY_SIZE); - pos += SOL_PUBKEY_SIZE; - memset(raw + pos, 0xBB, SOL_PUBKEY_SIZE); - pos += SOL_PUBKEY_SIZE; - raw[pos++] = 1; /* one instruction */ - raw[pos++] = 1; /* system program */ - raw[pos++] = 1; /* only source; destination is missing */ - raw[pos++] = 0; - raw[pos++] = 12; - raw[pos++] = SOL_SYS_TRANSFER; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0; - memset(raw + pos, 0, 8); - pos += 8; - - SolanaParsedTx tx; - EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_UNKNOWN); -} - -static size_t BuildMemoTx(uint8_t* raw, const uint8_t* memo, size_t memo_len) { - size_t pos = 0; - raw[pos++] = 1; /* one required signer */ - raw[pos++] = 0; - raw[pos++] = 1; /* memo program is readonly */ - raw[pos++] = 2; /* signer + memo program */ - memset(raw + pos, 0x11, SOL_PUBKEY_SIZE); - pos += SOL_PUBKEY_SIZE; - memcpy(raw + pos, SOL_MEMO_PROGRAM, SOL_PUBKEY_SIZE); - pos += SOL_PUBKEY_SIZE; - memset(raw + pos, 0xbb, SOL_PUBKEY_SIZE); - pos += SOL_PUBKEY_SIZE; - raw[pos++] = 1; /* one instruction */ - raw[pos++] = 1; /* memo program */ - raw[pos++] = 0; /* no account indices */ - raw[pos++] = (uint8_t)memo_len; - memcpy(raw + pos, memo, memo_len); - pos += memo_len; - return pos; -} - -TEST(Solana, MemoRetainsEverySignedByteForReview) { - uint8_t memo_a[80]; - uint8_t memo_b[80]; - memset(memo_a, 'A', sizeof(memo_a)); - memcpy(memo_b, memo_a, sizeof(memo_b)); - memo_b[64] = 'B'; /* same length and first 32 bytes, different signed tail */ - - uint8_t raw_a[256]; - uint8_t raw_b[256]; - const size_t len_a = BuildMemoTx(raw_a, memo_a, sizeof(memo_a)); - const size_t len_b = BuildMemoTx(raw_b, memo_b, sizeof(memo_b)); - ASSERT_EQ(len_a, len_b); - - SolanaParsedTx tx_a; - SolanaParsedTx tx_b; - ASSERT_EQ(solana_inspectTx(raw_a, len_a, &tx_a), SOL_TX_REVIEW_VERIFIED); - ASSERT_EQ(solana_inspectTx(raw_b, len_b, &tx_b), SOL_TX_REVIEW_VERIFIED); - ASSERT_EQ(tx_a.instructions[0].type, SOL_INSTR_MEMO); - ASSERT_EQ(tx_b.instructions[0].type, SOL_INSTR_MEMO); - ASSERT_EQ(tx_a.instructions[0].data_len, sizeof(memo_a)); - ASSERT_EQ(tx_b.instructions[0].data_len, sizeof(memo_b)); - EXPECT_EQ(0, memcmp(tx_a.instructions[0].data, memo_a, sizeof(memo_a))); - EXPECT_EQ(0, memcmp(tx_b.instructions[0].data, memo_b, sizeof(memo_b))); - EXPECT_NE(0, memcmp(tx_a.instructions[0].data, tx_b.instructions[0].data, - sizeof(memo_a))); -} - -TEST(Solana, CreateAccountRetainsEveryDisplayedSecurityField) { - uint8_t raw[256]; - size_t pos = 0; - - raw[pos++] = 1; - raw[pos++] = 0; - raw[pos++] = 1; - raw[pos++] = 3; - memset(raw + pos, 0x11, 32); - pos += 32; - memset(raw + pos, 0x22, 32); - pos += 32; - memset(raw + pos, 0, 32); - pos += 32; - memset(raw + pos, 0xbb, 32); - pos += 32; - - raw[pos++] = 1; - raw[pos++] = 2; - raw[pos++] = 2; - raw[pos++] = 0; - raw[pos++] = 1; - raw[pos++] = 52; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0x00; - raw[pos++] = 0xca; - raw[pos++] = 0x9a; - raw[pos++] = 0x3b; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0x00; - raw[pos++] = 0x02; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0; - memset(raw + pos, 0x33, 32); - pos += 32; - - SolanaParsedTx tx; - ASSERT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); - ASSERT_EQ(tx.instructions[0].type, SOL_INSTR_SYSTEM_CREATE_ACCOUNT); - EXPECT_EQ(tx.instructions[0].lamports, 1000000000ULL); - EXPECT_EQ(tx.instructions[0].extra_value, 512ULL); - EXPECT_EQ(0, memcmp(tx.instructions[0].to, raw + 4 + 32, 32)); - uint8_t owner[32]; - memset(owner, 0x33, sizeof(owner)); - EXPECT_EQ(0, memcmp(tx.instructions[0].extra, owner, sizeof(owner))); - - uint8_t prefixed[257]; - prefixed[0] = 0; - memcpy(prefixed + 1, raw, pos); - EXPECT_EQ(solana_inspectTx(prefixed, pos + 1, &tx), SOL_TX_REVIEW_VERIFIED); - - HDNode node = {}; - node.private_key[0] = 1; - ed25519_publickey(node.private_key, node.public_key + 1); - SolanaSignTx msg = {}; - msg.has_raw_tx = true; - msg.raw_tx.size = pos + 1; - memcpy(msg.raw_tx.bytes, prefixed, msg.raw_tx.size); - SolanaSignedTx resp = {}; - ASSERT_TRUE(solana_signTx(&node, &msg, &resp)); - EXPECT_EQ(0, ed25519_sign_open(raw, pos, node.public_key + 1, - resp.signature.bytes)); - EXPECT_NE(0, ed25519_sign_open(prefixed, pos + 1, node.public_key + 1, - resp.signature.bytes)); -} - TEST(Solana, ParseMultiInstruction) { /* Transaction with 2 system transfers */ uint8_t raw[512]; @@ -437,234 +335,142 @@ TEST(Solana, ParseSPLTokenTransfer) { raw[pos++] = 0x00; SolanaParsedTx tx; + /* Unchecked SPL Transfer carries no signed mint (the token being moved is not + * provable), so the transaction is now OPAQUE — it requires AdvancedMode + * blind-signing rather than clear-signing. The instruction is still parsed. + */ EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); - ASSERT_FALSE(solana_parseTx(raw, pos, &tx)); EXPECT_EQ(tx.num_instructions, 1); EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_TRANSFER); EXPECT_EQ(tx.instructions[0].amount, 1000000ULL); } -/* Build a one-instruction transaction against the SPL Token program whose - instruction data is exactly `data`. Returns the raw length. */ -static size_t BuildTokenTx(uint8_t* raw, const uint8_t* data, size_t data_len) { +TEST(Solana, Token2022TransferCheckedIsOpaque) { + /* A Token-2022 TransferChecked can invoke an undisclosed transfer hook / fee, + * so it must NOT clear-sign (only legacy SPL Token TransferChecked does). */ + uint8_t raw[512]; size_t pos = 0; raw[pos++] = 1; raw[pos++] = 0; raw[pos++] = 1; - - raw[pos++] = 5; /* 5 accounts */ + raw[pos++] = 5; /* source, mint, dest, authority, token-2022 program */ memset(raw + pos, 0x11, 32); - pos += 32; /* source ATA */ + pos += 32; memset(raw + pos, 0x22, 32); - pos += 32; /* mint */ + pos += 32; memset(raw + pos, 0x33, 32); - pos += 32; /* dest ATA */ + pos += 32; memset(raw + pos, 0x44, 32); - pos += 32; /* authority */ - memcpy(raw + pos, SOL_TOKEN_PROGRAM, 32); - pos += 32; /* token program */ - + pos += 32; + memcpy(raw + pos, SOL_TOKEN_2022_PROGRAM, 32); + pos += 32; memset(raw + pos, 0xBB, 32); - pos += 32; /* blockhash */ - + pos += 32; raw[pos++] = 1; /* 1 instruction */ - raw[pos++] = 4; /* program index = token program */ - raw[pos++] = 4; /* 4 account indices */ + raw[pos++] = 4; /* program index = token-2022 */ + raw[pos++] = 4; /* 4 accounts */ raw[pos++] = 0; raw[pos++] = 1; raw[pos++] = 2; raw[pos++] = 3; - raw[pos++] = (uint8_t)data_len; - memcpy(raw + pos, data, data_len); - pos += data_len; - return pos; -} - -TEST(Solana, OverlongFixedLayoutInstructionIsOpaque) { - /* A recognised instruction whose data field is LONGER than its on-chain - layout used to decode anyway: the prefix was read and confirmed, the tail - was signed with solana_signTx() covering the whole raw_tx, and -- the part - that actually mattered -- has_unknown was never set, so the transaction - was classified VERIFIED and never met the opaque/blind-sign path. The - appended bytes appeared on no screen and cost the sender nothing, because - SPL's unpack reads its fields and drops the tail. - - solana_inspectTx() rather than solana_parseTx() throughout: parseTx is - just `inspectTx == VERIFIED`, so it cannot distinguish "decoded as opaque" - from "malformed", which is the whole distinction under test here. */ - SolanaParsedTx tx; - uint8_t raw[512]; - size_t len; - - /* TransferChecked is exactly 10 bytes: tag + u64 amount + decimals. */ - static const uint8_t kExact[10] = {12, 0x40, 0x42, 0x0F, 0, 0, 0, 0, 0, 6}; - len = BuildTokenTx(raw, kExact, sizeof(kExact)); - ASSERT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); - ASSERT_EQ(tx.num_instructions, 1); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_TRANSFER_CHECKED); - EXPECT_EQ(tx.instructions[0].amount, 1000000ULL); - EXPECT_EQ(tx.instructions[0].extra_u8, 6); - - /* One appended byte. Same displayed amount, same displayed decimals, one - more signed byte -- and that must be enough to lose clear-signing. */ - static const uint8_t kOverlong[11] = {12, 0x40, 0x42, 0x0F, 0, 0, - 0, 0, 0, 6, 0xAB}; - len = BuildTokenTx(raw, kOverlong, sizeof(kOverlong)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_UNKNOWN); - - /* Short is refused as it always was; the rule is now symmetric. */ - static const uint8_t kShort[9] = {12, 0x40, 0x42, 0x0F, 0, 0, 0, 0, 0}; - len = BuildTokenTx(raw, kShort, sizeof(kShort)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_UNKNOWN); - - /* Plain Transfer is 9 bytes and follows the same rule. */ - static const uint8_t kTransfer9[9] = {3, 0x40, 0x42, 0x0F, 0, 0, 0, 0, 0}; - len = BuildTokenTx(raw, kTransfer9, sizeof(kTransfer9)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_TRANSFER); - - static const uint8_t kTransfer10[10] = {3, 0x40, 0x42, 0x0F, 0, - 0, 0, 0, 0, 0x99}; - len = BuildTokenTx(raw, kTransfer10, sizeof(kTransfer10)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_UNKNOWN); - - /* Revoke carries a tag and nothing else. */ - static const uint8_t kRevoke[1] = {5}; - len = BuildTokenTx(raw, kRevoke, sizeof(kRevoke)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_REVOKE); - - static const uint8_t kRevokePadded[2] = {5, 0x00}; - len = BuildTokenTx(raw, kRevokePadded, sizeof(kRevokePadded)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_UNKNOWN); - - /* SetAuthority: the COption discriminant and the length must agree. */ - static const uint8_t kSetAuthNone[3] = {6, 2, 0}; - len = BuildTokenTx(raw, kSetAuthNone, sizeof(kSetAuthNone)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_SET_AUTHORITY); - - uint8_t set_auth_some[35] = {6, 2, 1}; - memset(set_auth_some + 3, 0x77, 32); - len = BuildTokenTx(raw, set_auth_some, sizeof(set_auth_some)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_SET_AUTHORITY); - - /* "Some" with no key, and "None" carrying one, are both refused. */ - static const uint8_t kSetAuthSomeNoKey[3] = {6, 2, 1}; - len = BuildTokenTx(raw, kSetAuthSomeNoKey, sizeof(kSetAuthSomeNoKey)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_UNKNOWN); - - uint8_t set_auth_none_with_key[35] = {6, 2, 0}; - memset(set_auth_none_with_key + 3, 0x77, 32); - len = - BuildTokenTx(raw, set_auth_none_with_key, sizeof(set_auth_none_with_key)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_UNKNOWN); -} - -TEST(Solana, Token2022TransferCheckedIsOpaque) { - uint8_t raw[512]; - static const uint8_t kChecked[10] = {12, 0x40, 0x42, 0x0F, 0, 0, 0, 0, 0, 6}; - size_t len = BuildTokenTx(raw, kChecked, sizeof(kChecked)); - /* BuildTokenTx stores the program as account index 4. */ - memcpy(raw + 4 + (4 * SOL_PUBKEY_SIZE), SOL_TOKEN_2022_PROGRAM, - SOL_PUBKEY_SIZE); - SolanaParsedTx tx; - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_TRANSFER_CHECKED); -} + raw[pos++] = 10; /* data length */ + raw[pos++] = 12; /* TransferChecked */ + raw[pos++] = 0x40; + raw[pos++] = 0x42; + raw[pos++] = 0x0F; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 6; /* decimals */ -TEST(Solana, TokenMintAndBurnRemainOpaqueWithoutOpcodeBoundDisplay) { - uint8_t raw[512]; SolanaParsedTx tx; - - static const uint8_t kMintUnchecked[9] = {7, 1, 0, 0, 0, 0, 0, 0, 0}; - size_t len = BuildTokenTx(raw, kMintUnchecked, sizeof(kMintUnchecked)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_MINT_TO); - - static const uint8_t kMintChecked[10] = {14, 1, 0, 0, 0, 0, 0, 0, 0, 6}; - len = BuildTokenTx(raw, kMintChecked, sizeof(kMintChecked)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_MINT_TO); - EXPECT_EQ(tx.instructions[0].extra_u8, 6); - - static const uint8_t kBurnUnchecked[9] = {8, 1, 0, 0, 0, 0, 0, 0, 0}; - len = BuildTokenTx(raw, kBurnUnchecked, sizeof(kBurnUnchecked)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_BURN); - - static const uint8_t kBurnChecked[10] = {15, 1, 0, 0, 0, 0, 0, 0, 0, 6}; - len = BuildTokenTx(raw, kBurnChecked, sizeof(kBurnChecked)); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_TOKEN_BURN); - EXPECT_EQ(tx.instructions[0].extra_u8, 6); + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); } -static size_t BuildVoteUpdateValidatorTx(uint8_t* raw, uint16_t data_len) { +/* Helper: build a Vote UpdateValidatorIdentity tx with the given instruction + * data length (4 = canonical; >4 = trailing bytes). Accounts: vote(0), + * new-validator(1), authority(2), vote-program. */ +static size_t build_vote_update_validator(uint8_t* raw, uint16_t data_len) { size_t pos = 0; raw[pos++] = 1; raw[pos++] = 0; raw[pos++] = 1; raw[pos++] = 4; memset(raw + pos, 0x11, 32); - pos += 32; /* vote account */ + pos += 32; /* vote account (idx 0) */ memset(raw + pos, 0x22, 32); - pos += 32; /* new validator identity */ + pos += 32; /* new validator (idx 1) */ memset(raw + pos, 0x33, 32); - pos += 32; /* authority */ + pos += 32; /* authority (idx 2) */ memcpy(raw + pos, SOL_VOTE_PROGRAM, 32); pos += 32; memset(raw + pos, 0xBB, 32); - pos += 32; + pos += 32; /* blockhash */ raw[pos++] = 1; - raw[pos++] = 3; - raw[pos++] = 3; + raw[pos++] = 3; /* program index = vote */ + raw[pos++] = 3; /* 3 accounts */ raw[pos++] = 0; raw[pos++] = 1; raw[pos++] = 2; raw[pos++] = (uint8_t)data_len; - raw[pos++] = 4; /* UpdateValidatorIdentity, little-endian u32 */ + raw[pos++] = 4; /* UpdateValidatorIdentity discriminator (le32) */ raw[pos++] = 0; raw[pos++] = 0; raw[pos++] = 0; - for (uint16_t i = 4; i < data_len; i++) raw[pos++] = 0x77; + for (uint16_t i = 4; i < data_len; i++) raw[pos++] = 0x77; /* trailing */ return pos; } TEST(Solana, VoteUpdateValidatorReadsAccountNotData) { uint8_t raw[512]; - size_t len = BuildVoteUpdateValidatorTx(raw, 4); + size_t pos = build_vote_update_validator(raw, 4); /* canonical */ SolanaParsedTx tx; - ASSERT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_VOTE_UPDATE_VALIDATOR); + /* The new validator must be account index 1 (0x22..), never fabricated data. + */ uint8_t expected[32]; - memset(expected, 0x22, sizeof(expected)); - EXPECT_EQ(0, memcmp(tx.instructions[0].extra, expected, sizeof(expected))); - - len = BuildVoteUpdateValidatorTx(raw, 36); - EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); + memset(expected, 0x22, 32); + EXPECT_EQ(0, memcmp(tx.instructions[0].extra, expected, 32)); } -TEST(Solana, PriorityFeeCalculationIsRoundedAndOverflowSafe) { +TEST(Solana, VoteUpdateValidatorRejectsTrailingBytes) { + uint8_t raw[512]; + /* 4-byte discriminator + 32 fabricated bytes — used to be displayed as a + * fake validator; now non-canonical, so the tx is opaque (blind-sign only). + */ + size_t pos = build_vote_update_validator(raw, 36); SolanaParsedTx tx; - memset(&tx, 0, sizeof(tx)); - tx.num_instructions = 2; - tx.instructions[0].type = SOL_INSTR_COMPUTE_BUDGET_UNIT_LIMIT; - tx.instructions[0].extra_value = 1400000; - tx.instructions[1].type = SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE; - tx.instructions[1].extra_value = 50000000; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); +} + +TEST(Solana, PriorityFeeOverflowSafe) { + uint64_t fee = 0; + /* The wrap-to-zero case: price=UINT64_MAX, limit=1. A naive + * (price*limit + 999999)/1e6 wraps to 0; the real fee is 18446.744073710 SOL + * (= 18446744073710 lamports) and must be shown, not hidden. */ + EXPECT_TRUE(solana_priority_fee_lamports(UINT64_MAX, 1, &fee)); + EXPECT_EQ(fee, 18446744073710ULL); + + /* Typical fee: 1000 micro-lamports/CU * 200000 CU / 1e6 = 200 lamports. */ + EXPECT_TRUE(solana_priority_fee_lamports(1000, 200000, &fee)); + EXPECT_EQ(fee, 200ULL); + + /* Sub-lamport fee rounds UP (fees are charged even for one CU). */ + EXPECT_TRUE(solana_priority_fee_lamports(1, 1, &fee)); + EXPECT_EQ(fee, 1ULL); + + /* A fee that truly exceeds u64 lamports is rejected, never saturated. */ + EXPECT_FALSE(solana_priority_fee_lamports(UINT64_MAX, UINT64_MAX, &fee)); +} + +TEST(Solana, PriorityFeeUsesRuntimeImplicitLimit) { + SolanaParsedTx tx = {}; uint64_t fee = 0; bool has_fee = false; - ASSERT_TRUE(solana_calculatePriorityFee(&tx, &fee, &has_fee)); - EXPECT_TRUE(has_fee); - EXPECT_EQ(fee, 70000000ULL); /* With no explicit limit, use the limit the RUNTIME will request: 200,000 compute units per non-ComputeBudget instruction, capped at 1,400,000. @@ -696,23 +502,6 @@ TEST(Solana, PriorityFeeCalculationIsRoundedAndOverflowSafe) { ASSERT_TRUE(solana_calculatePriorityFee(&tx, &fee, &has_fee)); EXPECT_TRUE(has_fee); EXPECT_EQ(fee, 2800000ULL); /* capped: 2 * 1,400,000 */ - - memset(&tx, 0, sizeof(tx)); - - tx.num_instructions = 2; - tx.instructions[0].type = SOL_INSTR_COMPUTE_BUDGET_UNIT_LIMIT; - tx.instructions[0].extra_value = 1; - tx.instructions[1].type = SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE; - tx.instructions[1].extra_value = 1; - ASSERT_TRUE(solana_calculatePriorityFee(&tx, &fee, &has_fee)); - EXPECT_EQ(fee, 1ULL); /* ceil(1 micro-lamport) */ - - tx.instructions[0].extra_value = UINT32_MAX; - tx.instructions[1].extra_value = UINT64_MAX; - EXPECT_FALSE(solana_calculatePriorityFee(&tx, &fee, &has_fee)); - - tx.instructions[0].type = SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE; - EXPECT_FALSE(solana_calculatePriorityFee(&tx, &fee, &has_fee)); } TEST(Solana, ParseAssociatedTokenAccountCreate) { @@ -733,9 +522,9 @@ TEST(Solana, ParseAssociatedTokenAccountCreate) { memset(raw + pos, 0x44, 32); pos += 32; /* mint */ memcpy(raw + pos, SOL_SYSTEM_PROGRAM, 32); - pos += 32; /* system program account */ + pos += 32; /* system program */ memcpy(raw + pos, SOL_TOKEN_PROGRAM, 32); - pos += 32; /* legacy token program account */ + pos += 32; /* token program */ memcpy(raw + pos, SOL_ATA_PROGRAM, 32); pos += 32; /* program */ @@ -744,13 +533,8 @@ TEST(Solana, ParseAssociatedTokenAccountCreate) { raw[pos++] = 1; raw[pos++] = 6; /* ata program */ - raw[pos++] = 6; /* 6 canonical account indices */ - raw[pos++] = 0; - raw[pos++] = 1; - raw[pos++] = 2; - raw[pos++] = 3; - raw[pos++] = 4; - raw[pos++] = 5; + raw[pos++] = 6; /* 6 account indices */ + for (int i = 0; i < 6; i++) raw[pos++] = (uint8_t)i; raw[pos++] = 0; /* empty data */ SolanaParsedTx tx; @@ -758,65 +542,6 @@ TEST(Solana, ParseAssociatedTokenAccountCreate) { ASSERT_TRUE(solana_parseTx(raw, pos, &tx)); EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_ATA_CREATE); EXPECT_TRUE(tx.instructions[0].has_mint); - - /* The token-program account is security-relevant even though the invoked - * instruction belongs to the ATA program. Token-2022 ATA creation is not - * presented as a verified legacy account creation. */ - memcpy(raw + 4 + (5 * SOL_PUBKEY_SIZE), SOL_TOKEN_2022_PROGRAM, - SOL_PUBKEY_SIZE); - EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); -} - -static size_t BuildAuthorizeTx(uint8_t* raw, const uint8_t* program, - uint32_t instruction) { - size_t pos = 0; - raw[pos++] = 1; - raw[pos++] = 0; - raw[pos++] = 1; - raw[pos++] = 4; - memset(raw + pos, 0x11, 32); - pos += 32; /* stake/vote account */ - memset(raw + pos, 0x22, 32); - pos += 32; /* clock sysvar */ - memset(raw + pos, 0x33, 32); - pos += 32; /* current authority */ - memcpy(raw + pos, program, 32); - pos += 32; - memset(raw + pos, 0xBB, 32); - pos += 32; - raw[pos++] = 1; - raw[pos++] = 3; - raw[pos++] = 3; - raw[pos++] = 0; - raw[pos++] = 1; - raw[pos++] = 2; - raw[pos++] = 40; - raw[pos++] = (uint8_t)instruction; - raw[pos++] = 0; - raw[pos++] = 0; - raw[pos++] = 0; - memset(raw + pos, 0x44, 32); /* new authority */ - pos += 32; - memset(raw + pos, 0, 4); /* staker/voter role */ - pos += 4; - return pos; -} - -TEST(Solana, AuthorizeUsesAuthorityNotClockSysvar) { - uint8_t raw[512]; - uint8_t expected[32]; - memset(expected, 0x33, sizeof(expected)); - SolanaParsedTx tx; - - size_t len = BuildAuthorizeTx(raw, SOL_STAKE_PROGRAM, SOL_STAKE_AUTHORIZE_IX); - ASSERT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); - EXPECT_EQ(0, - memcmp(tx.instructions[0].authority, expected, sizeof(expected))); - - len = BuildAuthorizeTx(raw, SOL_VOTE_PROGRAM, SOL_VOTE_AUTHORIZE_IX); - ASSERT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); - EXPECT_EQ(0, - memcmp(tx.instructions[0].authority, expected, sizeof(expected))); } TEST(Solana, ParseComputeBudgetUnitPrice) { @@ -1027,15 +752,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; @@ -1076,12 +813,114 @@ TEST(Solana, VersionedMessageIsOpaque) { raw[pos++] = 0; /* zero lookup tables */ + /* A v0 message whose instructions touch only static accounts is as + * verifiable as a legacy message — swap providers build these. */ SolanaParsedTx tx; - EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); - EXPECT_FALSE(solana_parseTx(raw, pos, &tx)); + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); + EXPECT_TRUE(solana_parseTx(raw, pos, &tx)); + ASSERT_EQ(tx.num_instructions, 1); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_SYSTEM_TRANSFER); + EXPECT_EQ(tx.instructions[0].lamports, 1000000000ULL); + uint8_t expected_to[32]; + memset(expected_to, 0x22, 32); + EXPECT_EQ(memcmp(tx.instructions[0].to, expected_to, 32), 0); +} + +TEST(Solana, X402ZeroLookupV0UsdcPaymentIsVerified) { + /* Self-contained x402 shape: sponsor fee payer + user authority, compute + * limit, compute price, SPL TransferChecked, memo, and zero ALT entries. */ + const uint8_t usdc_mint[32] = { + 0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, 0x3d, 0x65, 0xf3, + 0x6a, 0xab, 0xc9, 0x74, 0x31, 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, + 0xe0, 0xe4, 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x2f, 0x5d, 0x61}; + const uint8_t destination_ata[32] = { + 0x67, 0x30, 0x2e, 0x49, 0x18, 0x94, 0xd7, 0x49, 0x2e, 0xa6, 0xbe, + 0x4f, 0x91, 0x4e, 0xa4, 0xf4, 0x5f, 0xa1, 0x42, 0xe6, 0x45, 0x86, + 0x7c, 0x91, 0x64, 0xa2, 0x76, 0xd5, 0xdd, 0x76, 0xf0, 0x76}; + uint8_t raw[512]; + size_t pos = 0; + raw[pos++] = 0x80; /* v0 */ + raw[pos++] = 2; /* sponsor + token authority */ + raw[pos++] = 0; + raw[pos++] = 3; /* compute, token and memo programs are readonly */ + + raw[pos++] = 8; + memset(raw + pos, 0x10, 32); /* sponsor / fee payer */ + pos += 32; + memset(raw + pos, 0x20, 32); /* user token authority */ + pos += 32; + memset(raw + pos, 0x30, 32); /* source token account */ + pos += 32; + memcpy(raw + pos, destination_ata, 32); + pos += 32; + memcpy(raw + pos, usdc_mint, 32); + pos += 32; + memcpy(raw + pos, SOL_COMPUTE_BUDGET_PROGRAM, 32); + pos += 32; + memcpy(raw + pos, SOL_TOKEN_PROGRAM, 32); + pos += 32; + memcpy(raw + pos, SOL_MEMO_PROGRAM, 32); + pos += 32; + memset(raw + pos, 0xbb, 32); /* recent blockhash */ + pos += 32; + + raw[pos++] = 4; /* instructions */ + + raw[pos++] = 5; /* ComputeBudget::SetComputeUnitLimit */ + raw[pos++] = 0; + raw[pos++] = 5; + raw[pos++] = SOL_CB_SET_COMPUTE_UNIT_LIMIT; + raw[pos++] = 0xc0; + raw[pos++] = 0xd4; + raw[pos++] = 0x01; + raw[pos++] = 0x00; /* 120000 */ + + raw[pos++] = 5; /* ComputeBudget::SetComputeUnitPrice */ + raw[pos++] = 0; + raw[pos++] = 9; + raw[pos++] = SOL_CB_SET_COMPUTE_UNIT_PRICE; + raw[pos++] = 0xe8; + raw[pos++] = 0x03; + for (int i = 0; i < 6; i++) raw[pos++] = 0; /* 1000 micro-lamports */ + + raw[pos++] = 6; /* SPL Token::TransferChecked */ + raw[pos++] = 4; + raw[pos++] = 2; /* source */ + raw[pos++] = 4; /* mint */ + raw[pos++] = 3; /* destination ATA */ + raw[pos++] = 1; /* authority */ + raw[pos++] = 10; + raw[pos++] = SOL_TOKEN_TRANSFER_CHECKED_IX; + raw[pos++] = 0xd0; + raw[pos++] = 0x07; + for (int i = 0; i < 6; i++) raw[pos++] = 0; /* amount 2000 */ + raw[pos++] = 6; /* decimals */ + + raw[pos++] = 7; /* Memo */ + raw[pos++] = 1; + raw[pos++] = 1; /* authority signer */ + const char* x402_memo = "00112233445566778899aabbccddeeff"; + const size_t x402_memo_len = strlen(x402_memo); + raw[pos++] = (uint8_t)x402_memo_len; + memcpy(raw + pos, x402_memo, x402_memo_len); + pos += x402_memo_len; + + raw[pos++] = 0; /* zero address-lookup tables */ + + SolanaParsedTx tx; + ASSERT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); + ASSERT_EQ(tx.num_instructions, 4); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_COMPUTE_BUDGET_UNIT_LIMIT); + EXPECT_EQ(tx.instructions[1].type, SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE); + ASSERT_EQ(tx.instructions[2].type, SOL_INSTR_TOKEN_TRANSFER_CHECKED); + EXPECT_EQ(tx.instructions[2].amount, 2000); + EXPECT_EQ(tx.instructions[2].extra_u8, 6); + EXPECT_EQ(memcmp(tx.instructions[2].mint, usdc_mint, 32), 0); + EXPECT_EQ(memcmp(tx.instructions[2].to, destination_ata, 32), 0); + EXPECT_EQ(tx.instructions[3].type, SOL_INSTR_MEMO); } -TEST(Solana, VersionedMessageWithLookupTableIsOpaque) { +TEST(Solana, VersionedMessageWithUnreferencedLookupTableIsOpaque) { uint8_t raw[256]; size_t pos = 0; @@ -1129,11 +968,128 @@ TEST(Solana, VersionedMessageWithLookupTableIsOpaque) { raw[pos++] = 1; raw[pos++] = 2; + /* x402 clear-sign support is deliberately zero-LUT only. Even an + * unreferenced table keeps the message behind the opaque AdvancedMode gate + * until the device can resolve and authenticate lookup-table state. */ + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); + EXPECT_FALSE(solana_parseTx(raw, pos, &tx)); +} + +TEST(Solana, VersionedInstructionUsingLookupAccountIsOpaque) { + uint8_t raw[256]; + size_t pos = 0; + + raw[pos++] = 0x80; /* v0 prefix */ + raw[pos++] = 1; + raw[pos++] = 0; + raw[pos++] = 1; + + raw[pos++] = 3; /* static accounts */ + memset(raw + pos, 0x11, 32); + pos += 32; + memset(raw + pos, 0x22, 32); + pos += 32; + memset(raw + pos, 0x00, 32); + pos += 32; + + memset(raw + pos, 0xBB, 32); + pos += 32; + + raw[pos++] = 1; /* instructions */ + raw[pos++] = 2; /* program = system (static) */ + raw[pos++] = 2; + raw[pos++] = 0; + raw[pos++] = 3; /* index 3 = first lookup-table account */ + raw[pos++] = 12; + raw[pos++] = 2; + raw[pos++] = 0; + raw[pos++] = 0; + raw[pos++] = 0; + raw[pos++] = 0x00; + raw[pos++] = 0xCA; + raw[pos++] = 0x9A; + raw[pos++] = 0x3B; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + + raw[pos++] = 1; /* one lookup table */ + memset(raw + pos, 0x55, 32); + pos += 32; + raw[pos++] = 1; + raw[pos++] = 0; + raw[pos++] = 0; + + /* The recipient lives in a lookup table the device cannot resolve — + * must be opaque (blind-signable under AdvancedMode), NOT malformed, + * and NEVER verified. */ SolanaParsedTx tx; EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); EXPECT_FALSE(solana_parseTx(raw, pos, &tx)); } +TEST(Solana, MemoBodyCaptured) { + /* Legacy tx: system transfer + memo instruction (THORChain-style swap + * memo). The parser must expose the memo bytes for display. */ + const char* memo = "=:ETH.ETH:0x1234:0/1/0:kk:75"; + uint8_t raw[512]; + size_t pos = 0; + + raw[pos++] = 1; + raw[pos++] = 0; + raw[pos++] = 2; /* system + memo programs readonly */ + + raw[pos++] = 4; /* accounts: sender, recipient, system, memo */ + memset(raw + pos, 0x11, 32); + pos += 32; + memset(raw + pos, 0x22, 32); + pos += 32; + memset(raw + pos, 0x00, 32); /* system program */ + pos += 32; + memcpy(raw + pos, SOL_MEMO_PROGRAM, 32); + pos += 32; + + memset(raw + pos, 0xBB, 32); /* blockhash */ + pos += 32; + + raw[pos++] = 2; /* two instructions */ + + /* transfer */ + raw[pos++] = 2; + raw[pos++] = 2; + raw[pos++] = 0; + raw[pos++] = 1; + raw[pos++] = 12; + raw[pos++] = 2; + raw[pos++] = 0; + raw[pos++] = 0; + raw[pos++] = 0; + raw[pos++] = 0x00; + raw[pos++] = 0xCA; + raw[pos++] = 0x9A; + raw[pos++] = 0x3B; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + raw[pos++] = 0x00; + + /* memo */ + raw[pos++] = 3; /* program = memo */ + raw[pos++] = 0; /* no accounts */ + raw[pos++] = (uint8_t)strlen(memo); + memcpy(raw + pos, memo, strlen(memo)); + pos += strlen(memo); + + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); + ASSERT_EQ(tx.num_instructions, 2); + EXPECT_EQ(tx.instructions[1].type, SOL_INSTR_MEMO); + ASSERT_EQ(tx.instructions[1].data_len, strlen(memo)); + EXPECT_EQ(memcmp(tx.instructions[1].data, memo, strlen(memo)), 0); +} + TEST(Solana, MalformedVersionedLookupTableRejects) { uint8_t raw[256]; size_t pos = 0; @@ -1163,3 +1119,435 @@ TEST(Solana, MalformedVersionedLookupTableRejects) { EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_MALFORMED); EXPECT_FALSE(solana_parseTx(raw, pos, &tx)); } + +/* ===================================================================== + * Review-round-12 regression tests: the forced-opaque set and the + * canonical-shape guards. A future refactor that silently drops any of + * these gates fails here, not in the field. + * ===================================================================== */ + +/* Build a single-instruction tx over `program`, with `n_accounts` distinct + * accounts fed to the instruction, plus a fee-payer signer and the program + * account. instr_data holds the opcode + operands. Returns the byte length. */ +static size_t build_single_instr_tx(uint8_t* raw, const uint8_t* program, + int n_accounts, const uint8_t* instr_data, + uint8_t data_len) { + size_t pos = 0; + raw[pos++] = 1; /* num_required_sigs */ + raw[pos++] = 0; /* num_readonly_signed */ + raw[pos++] = 1; /* num_readonly_unsigned (program) */ + const int total_accts = n_accounts + 1 /* program */; + raw[pos++] = (uint8_t)total_accts; /* compact-u16 account count */ + for (int i = 0; i < n_accounts; i++) { /* instruction accounts */ + memset(raw + pos, 0x11 + i, 32); + pos += 32; + } + memcpy(raw + pos, program, 32); /* program account (last) */ + pos += 32; + memset(raw + pos, 0xBB, 32); /* recent blockhash */ + pos += 32; + raw[pos++] = 1; /* 1 instruction */ + raw[pos++] = (uint8_t)n_accounts; /* program index (last account) */ + raw[pos++] = (uint8_t)n_accounts; /* account-index count */ + for (int i = 0; i < n_accounts; i++) { /* account indices 0..n-1 */ + raw[pos++] = (uint8_t)i; + } + raw[pos++] = data_len; + memcpy(raw + pos, instr_data, data_len); + pos += data_len; + return pos; +} + +/* Legacy SPL TransferChecked with the canonical 10-byte data (opcode + amount + * + decimals) and all four accounts clear-signs. */ +TEST(Solana, TransferCheckedCanonicalIsVerified) { + uint8_t d[10] = { + SOL_TOKEN_TRANSFER_CHECKED_IX, 0x40, 0x42, 0x0F, 0, 0, 0, 0, 6}; + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, SOL_TOKEN_PROGRAM, 4, d, sizeof(d)); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); +} + +/* A 9-byte TransferChecked (no decimals byte) is non-canonical: it must NOT + * classify VERIFIED (which would skip the mint screen) — force opaque. */ +TEST(Solana, TransferCheckedShortDataIsOpaque) { + uint8_t d[9] = {SOL_TOKEN_TRANSFER_CHECKED_IX, 0x40, 0x42, 0x0F, 0, 0, 0, 0}; + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, SOL_TOKEN_PROGRAM, 4, d, sizeof(d)); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); +} + +/* A TransferChecked with fewer than 4 accounts would read a zeroed mint / + * destination (displayed as 1111..) — force opaque instead of clear-signing a + * fabricated recipient. */ +TEST(Solana, TransferCheckedShortAccountsIsOpaque) { + uint8_t d[10] = { + SOL_TOKEN_TRANSFER_CHECKED_IX, 0x40, 0x42, 0x0F, 0, 0, 0, 0, 6}; + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, SOL_TOKEN_PROGRAM, 3, d, sizeof(d)); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); +} + +/* StakeAuthorize needs >= 40 data bytes (type(4) + new-authority(32) + + * role(4)); a 36-byte encoding would read the role word out of bounds, so it + * must not be accepted as a canonical authorize. */ +TEST(Solana, StakeAuthorizeShortDataIsOpaque) { + uint8_t d[36] = {SOL_STAKE_AUTHORIZE_IX, 0, 0, 0}; + memset(d + 4, 0x77, 32); /* new authority, role word missing */ + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, SOL_STAKE_PROGRAM, 3, d, sizeof(d)); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); +} + +/* The same StakeAuthorize with the full 40-byte canonical encoding clear-signs + * (role = staker), proving the rejection above is the length guard. */ +TEST(Solana, StakeAuthorizeCanonicalIsVerified) { + uint8_t d[40] = {SOL_STAKE_AUTHORIZE_IX, 0, 0, 0}; + memset(d + 4, 0x77, 32); /* new authority */ + /* d[36..39] = role 0 (staker), already zero */ + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, SOL_STAKE_PROGRAM, 3, d, sizeof(d)); + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_VERIFIED); +} + +/* ── KKSOLSC1 reusable instruction schemas ──────────────────────────── + * + * Vector is the real Relay bridge deposit captured from api.relay.link on + * 2026-07-27: program 99vQwtBwYtrqqD9YSXbdum3KBdxPAVxYTaQ3cfnJSrN2, 48 bytes + * of data = 8-byte discriminator + u64 amount + 32-byte order id. The amount + * word tracked the requested input exactly across three different quotes. + */ +static const uint8_t kRelayDisc[8] = {0x0d, 0x9e, 0x0d, 0xdf, + 0x5f, 0xd5, 0x1c, 0x06}; + +/* Build a KKSOLSC1 payload: one u64 arg ("Amount") and one account ("Vault"). + */ +static size_t build_relay_schema(uint8_t* out, const uint8_t* program, + uint8_t n_args = 1) { + size_t p = 0; + memcpy(out + p, "KKSOLSC1", 8); + p += 8; + out[p++] = 1; /* version */ + memcpy(out + p, program, 32); + p += 32; + out[p++] = 8; /* disc_len */ + memcpy(out + p, kRelayDisc, 8); + p += 8; + out[p++] = 5; + memcpy(out + p, "Relay", 5); + p += 5; /* program name */ + out[p++] = 7; + memcpy(out + p, "deposit", 7); + p += 7; /* instruction name */ + out[p++] = n_args; + if (n_args >= 1) { + out[p++] = SOL_SCHEMA_ARG_U64; + out[p++] = 6; + memcpy(out + p, "Amount", 6); + p += 6; + } + if (n_args >= 2) { + out[p++] = SOL_SCHEMA_ARG_OPAQUE32; + out[p++] = 5; + memcpy(out + p, "Order", 5); + p += 5; + } + out[p++] = 1; /* one displayed account */ + out[p++] = 0; /* index 0 */ + out[p++] = 5; + memcpy(out + p, "Vault", 5); + p += 5; + return p; +} + +/* Relay's instruction data: discriminator + amount + 32-byte order id. */ +static void build_relay_data(uint8_t* d, uint64_t amount) { + memcpy(d, kRelayDisc, 8); + for (int i = 0; i < 8; i++) d[8 + i] = (uint8_t)(amount >> (8 * i)); + memset(d + 16, 0xAB, 32); +} + +TEST(Solana, SchemaParsesCanonicalPayload) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t blob[256]; + size_t len = build_relay_schema(blob, program, 2); + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + EXPECT_EQ(s.disc_len, 8); + EXPECT_EQ(s.num_args, 2); + EXPECT_EQ(s.num_accounts, 1); + EXPECT_STREQ(s.program_name, "Relay"); + EXPECT_STREQ(s.instruction_name, "deposit"); + EXPECT_STREQ(s.args[0].label, "Amount"); +} + +TEST(Solana, SchemaRejectsTrailingBytes) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t blob[256]; + size_t len = build_relay_schema(blob, program, 2); + blob[len] = 0x00; /* one byte too many */ + SolanaInstrSchema s; + EXPECT_FALSE(solana_parseInstrSchema(blob, len + 1, &s)); +} + +TEST(Solana, SchemaRejectsUnsafeLabel) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t blob[256]; + size_t len = build_relay_schema(blob, program, 1); + /* Corrupt the "Amount" label with a format specifier. */ + for (size_t i = 0; i + 6 <= len; i++) { + if (memcmp(blob + i, "Amount", 6) == 0) { + blob[i] = '%'; + break; + } + } + SolanaInstrSchema s; + EXPECT_FALSE(solana_parseInstrSchema(blob, len, &s)); +} + +/* The core safety property: a schema that does not account for every byte of + * the instruction data must NOT apply. Here the data is Relay's real 48 bytes + * but the schema declares only the 8-byte amount, leaving 32 bytes unexplained. + */ +TEST(Solana, SchemaRejectsIncompleteCoverage) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t d[48]; + build_relay_data(d, 526490980ULL); + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, program, 2, d, sizeof(d)); + SolanaParsedTx tx; + ASSERT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); + + uint8_t blob[256]; + size_t len = + build_relay_schema(blob, program, 1); /* amount only: 8+8 != 48 */ + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + uint8_t idx = 0xFF; + EXPECT_FALSE(solana_schemaApplies(&s, &tx, &idx)); +} + +/* Full coverage (8 disc + 8 amount + 32 order = 48) applies, and the amount is + * readable straight out of the signed bytes. */ +TEST(Solana, SchemaAppliesWithFullCoverage) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t d[48]; + build_relay_data(d, 526490980ULL); + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, program, 2, d, sizeof(d)); + SolanaParsedTx tx; + ASSERT_EQ(solana_inspectTx(raw, pos, &tx), SOL_TX_REVIEW_OPAQUE); + + uint8_t blob[256]; + size_t len = build_relay_schema(blob, program, 2); + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + uint8_t idx = 0xFF; + ASSERT_TRUE(solana_schemaApplies(&s, &tx, &idx)); + EXPECT_EQ(idx, 0); + + uint64_t amount = 0; + const SolanaParsedInstruction* ix = &tx.instructions[idx]; + for (int i = 0; i < 8; i++) { + amount |= ((uint64_t)ix->data[s.disc_len + i]) << (8 * i); + } + EXPECT_EQ(amount, 526490980ULL); +} + +/* A schema for a different program must never match. */ +TEST(Solana, SchemaRejectsProgramMismatch) { + uint8_t program[32], other[32]; + memset(program, 0x42, sizeof(program)); + memset(other, 0x43, sizeof(other)); + uint8_t d[48]; + build_relay_data(d, 1ULL); + uint8_t raw[512]; + size_t pos = build_single_instr_tx(raw, program, 2, d, sizeof(d)); + SolanaParsedTx tx; + solana_inspectTx(raw, pos, &tx); + + uint8_t blob[256]; + size_t len = build_relay_schema(blob, other, 2); + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + uint8_t idx = 0xFF; + EXPECT_FALSE(solana_schemaApplies(&s, &tx, &idx)); +} + +/* An account index the instruction doesn't have must not be displayable. */ +TEST(Solana, SchemaRejectsOutOfRangeAccount) { + uint8_t program[32]; + memset(program, 0x42, sizeof(program)); + uint8_t d[48]; + build_relay_data(d, 1ULL); + uint8_t raw[512]; + /* Only ONE instruction account, but the schema displays index 0..; bump the + * schema's account index past the end. */ + size_t pos = build_single_instr_tx(raw, program, 1, d, sizeof(d)); + SolanaParsedTx tx; + solana_inspectTx(raw, pos, &tx); + + uint8_t blob[256]; + size_t len = build_relay_schema(blob, program, 2); + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + s.accounts[0].index = 9; /* beyond this instruction's account list */ + uint8_t idx = 0xFF; + EXPECT_FALSE(solana_schemaApplies(&s, &tx, &idx)); +} + +/* Cross-language parity: these exact bytes are emitted by the KeepKey SDK's + * KKSOLSC1 serializer (keepkey-sdk tests/fixtures/solana-schema.js, catalog + * entries relayDepositNative / relayDepositToken). The SDK and this parser are + * independent implementations of the same format — if either drifts, the host + * ships a schema the device refuses, or worse renders differently than the + * signer intended. Regenerate with: + * node -e "const f=require('./tests/fixtures/solana-schema'); + * console.log(f.serializeSchema(f.CATALOG.relayDepositNative).toString('hex'))" + */ +static size_t hex_to_bytes(const char* hex, uint8_t* out, size_t out_max) { + size_t n = strlen(hex) / 2; + if (n > out_max) return 0; + for (size_t i = 0; i < n; i++) { + unsigned v = 0; + sscanf(hex + 2 * i, "%2x", &v); + out[i] = (uint8_t)v; + } + return n; +} + +TEST(Solana, SchemaParsesSdkSerializedPayloadNative) { + /* Verbatim output of the SDK serializer — do not hand-edit. */ + const char* kSdkHex = + "4b4b534f4c53433101792689378ecd51d80406eb0caa3b62795beb10b6c5dc96bc2e0df0" + "3cbfee1abf" + "080d9e0ddf5fd51c06" + "0c52656c617920427269646765" + "0d6465706f7369744e6174697665" + "020106416d6f756e7404054f7264657201" + "03055661756c74"; + uint8_t blob[256]; + size_t len = hex_to_bytes(kSdkHex, blob, sizeof(blob)); + ASSERT_EQ(len, 101u); + + SolanaInstrSchema s; + ASSERT_TRUE(solana_parseInstrSchema(blob, len, &s)); + EXPECT_STREQ(s.program_name, "Relay Bridge"); + EXPECT_STREQ(s.instruction_name, "depositNative"); + EXPECT_EQ(s.disc_len, 8); + EXPECT_EQ(s.num_args, 2); + EXPECT_EQ(s.args[0].type, SOL_SCHEMA_ARG_U64); + EXPECT_STREQ(s.args[0].label, "Amount"); + EXPECT_EQ(s.args[1].type, SOL_SCHEMA_ARG_OPAQUE32); + EXPECT_STREQ(s.args[1].label, "Order"); + EXPECT_EQ(s.num_accounts, 1); + EXPECT_EQ(s.accounts[0].index, 3); + EXPECT_STREQ(s.accounts[0].label, "Vault"); + + /* Coverage must equal Relay's real 48-byte instruction data. */ + uint32_t covered = s.disc_len; + for (uint8_t i = 0; i < s.num_args; i++) { + covered += solana_schemaArgWidth(s.args[i].type); + } + EXPECT_EQ(covered, 48u); +} + +/* An SPL token transfer whose recipient may not have an associated token + * account: wallets prepend CreateAssociatedTokenAccountIdempotent (data [1]), + * then TransferChecked. This is what Pioneer builds for a USDT swap deposit, + * and it is the ordinary shape of a token send to a fresh address. + * + * Idempotent takes the SAME accounts as Create in the same order and creates + * the same account — it only declines to fail when one already exists — so it + * displays identically. Rejecting it made ONE unrecognised instruction force + * the entire transaction opaque, so a fully decodable SPL transfer + * blind-signed ("Enable AdvancedMode to blind-sign"). + */ +static size_t build_ata_then_transfer_tx(uint8_t* raw, uint8_t ata_ix_byte, + bool include_ata_byte) { + /* accounts: 0..3 transfer accounts, 4 = System, 5 = Token, 6 = ATA */ + const int transfer_accounts = 4; + const int total_accounts = 7; + size_t pos = 0; + raw[pos++] = 1; /* num_required_sigs */ + raw[pos++] = 0; + raw[pos++] = 3; /* System, Token and ATA programs are readonly unsigned */ + raw[pos++] = (uint8_t)total_accounts; + for (int i = 0; i < transfer_accounts; i++) { + memset(raw + pos, 0x11 + i, 32); + pos += 32; + } + memcpy(raw + pos, SOL_SYSTEM_PROGRAM, 32); + pos += 32; + memcpy(raw + pos, SOL_TOKEN_PROGRAM, 32); + pos += 32; + memcpy(raw + pos, SOL_ATA_PROGRAM, 32); + pos += 32; + memset(raw + pos, 0xBB, 32); /* recent blockhash */ + pos += 32; + + raw[pos++] = 2; /* two instructions */ + + /* 1) ATA create (idempotent or classic) — canonical six accounts */ + raw[pos++] = 6; /* ATA program index */ + raw[pos++] = 6; + for (int i = 0; i < 6; i++) raw[pos++] = (uint8_t)i; + if (include_ata_byte) { + raw[pos++] = 1; /* data_len */ + raw[pos++] = ata_ix_byte; + } else { + raw[pos++] = 0; /* empty data = legacy Create */ + } + + /* 2) TransferChecked: [12, amount u64 LE, decimals] over 4 accounts */ + raw[pos++] = 5; /* token program index */ + raw[pos++] = (uint8_t)transfer_accounts; + for (int i = 0; i < transfer_accounts; i++) raw[pos++] = (uint8_t)i; + raw[pos++] = 10; /* data_len */ + raw[pos++] = SOL_TOKEN_TRANSFER_CHECKED_IX; + for (int i = 0; i < 8; i++) raw[pos++] = (i == 0) ? 0x40 : 0x00; /* amount */ + raw[pos++] = 6; /* decimals (USDT) */ + return pos; +} + +TEST(Solana, AtaCreateIdempotentThenTransferIsVerified) { + uint8_t raw[1024]; + size_t len = + build_ata_then_transfer_tx(raw, 1, true); /* 1 = CreateIdempotent */ + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); + ASSERT_EQ(tx.num_instructions, 2); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_ATA_CREATE); + EXPECT_EQ(tx.instructions[1].type, SOL_INSTR_TOKEN_TRANSFER_CHECKED); +} + +TEST(Solana, AtaCreateClassicStillVerified) { + uint8_t raw[1024]; + size_t len = build_ata_then_transfer_tx(raw, 0, true); /* 0 = Create */ + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_ATA_CREATE); + + len = build_ata_then_transfer_tx(raw, 0, false); /* legacy empty data */ + EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_VERIFIED); + EXPECT_EQ(tx.instructions[0].type, SOL_INSTR_ATA_CREATE); +} + +/* RecoverNested (2) and anything else stays unknown: different accounts and + * different meaning, so it must not borrow the create screens. */ +TEST(Solana, AtaUnknownInstructionStillOpaque) { + uint8_t raw[1024]; + size_t len = build_ata_then_transfer_tx(raw, 2, true); /* RecoverNested */ + SolanaParsedTx tx; + EXPECT_EQ(solana_inspectTx(raw, len, &tx), SOL_TX_REVIEW_OPAQUE); +} diff --git a/unittests/firmware/storage.cpp b/unittests/firmware/storage.cpp index b04af1ebe..089436cb5 100644 --- a/unittests/firmware/storage.cpp +++ b/unittests/firmware/storage.cpp @@ -6,6 +6,9 @@ extern "C" { #include "trezor/crypto/aes/aes.h" #include "types.pb.h" #include "storage.h" + +/* Emulator flash bring-up, same forward declaration signed_metadata.cpp uses. */ +void setup(void); } #include "gtest/gtest.h" @@ -13,6 +16,7 @@ extern "C" { #include #include +#include using ::testing::ElementsAreArray; @@ -195,9 +199,12 @@ TEST(Storage, ReadStorageV1) { // Decrypt upgraded storage. uint8_t wrapping_key[64]; - storage_deriveWrappingKey("123456789", wrapping_key, dst.pub.sca_hardened, - dst.pub.v15_16_trans, - dst.pub.random_salt, ""); // strongest pin evar + storage_deriveWrappingKey( + "123456789", wrapping_key, dst.pub.sca_hardened, + /* The V1 upgrade path re-wraps through storage_setPin_impl, so this must + track whatever production wraps with -- not a fixed version. */ + storage_activePinKdfVersion(dst.pub.v15_16_trans, dst.pub.pin_kdf_v2), + dst.pub.random_salt, ""); // strongest pin evar storage_unwrapStorageKey(wrapping_key, dst.pub.wrapped_storage_key, session.storageKey); storage_secMigrate(&session, &dst, /*encrypt=*/false); @@ -368,6 +375,189 @@ TEST(Storage, SetPolicy) { EXPECT_EQ(storage.pub.policies[3].enabled, true); } +// AdvancedMode is the blind-sign gate (ethereum.c, eos.c, solana.c). It lives +// in the PUBLIC storage section, which has no authenticated integrity against +// physical flash modification -- the same reason RC18 refused to persist +// clear-sign trust anchors there. So it is session-scoped: never written, and +// never restored, no matter what the flash says. +// +// Two directions, and the second is the one that matters. A comment in +// storage.c does not stop someone reinstating `flags & (1u << 12)` in a new +// storage version; this test does. +TEST(Storage, AdvancedModeIsNeverRestoredFromFlash) { + // storage_setPolicy matches by name against the GLOBAL shadow config, whose + // policy table is zeroed until storage_init() populates it. Same guarded + // idiom as signed_metadata.cpp: re-running storage_init() over an already + // live shadow would try to migrate and decrypt it. + if (storage_getLocation() == FLASH_INVALID) { + setup(); + storage_init(); + } + + ConfigFlash start; + memset(&start, 0, sizeof(start)); + memcpy(start.meta.magic, "stor", 4); + start.storage.version = STORAGE_VERSION; + start.storage.encrypted_sec_version = STORAGE_VERSION; + storage_resetPolicies(&start.storage); + + // Enable it in the shadow config, which is what the writer serializes from + // (storage_writeStorageV16Plaintext reads policy state via + // storage_isPolicyEnabled, i.e. from the shadow, not from `start`). + // shadow_config is process-global and 16 production call sites read it, so + // the restore must survive an ASSERT_* early return out of the TEST body. + struct RestorePolicy { + ~RestorePolicy() { storage_setPolicy("AdvancedMode", false); } + } restore_policy; + + ASSERT_TRUE(storage_setPolicy("AdvancedMode", true)); + ASSERT_TRUE(storage_isPolicyEnabled("AdvancedMode")) + << "test precondition: the writer must have something to leak"; + + std::vector flash(2570); + memset(&flash[0], 0, flash.size()); + storage_writeV17((char *)&flash[0], flash.size(), &start); + + // 1. The writer must not persist it. Storage begins at +44, flags at +4. + uint32_t flags = 0; + memcpy(&flags, &flash[44 + 4], sizeof(flags)); + EXPECT_EQ(flags & (1u << 12), 0u) + << "AdvancedMode was written to flash; it must be session-scoped"; + + // 2. The reader must ignore the bit even when it IS set -- an upgraded + // device, or one an attacker wrote to directly. + flags |= (1u << 12); + memcpy(&flash[44 + 4], &flags, sizeof(flags)); + + // Every reader that resolves policies[3] gets the same buffer. V11 and V16 + // are separate storage_readPolicyV2 call sites on live migration paths, so + // testing only V17 would let someone reinstate `flags & (1u << 12)` in one of + // the others with the suite still green -- the exact regression this guards. + struct { + const char *name; + void (*read)(ConfigFlash *, const char *, size_t); + } readers[] = { + {"V11", storage_readV11}, + {"V16", storage_readV16}, + {"V17", storage_readV17}, + }; + + for (const auto &r : readers) { + ConfigFlash end; + memset(&end, 0, sizeof(end)); + r.read(&end, (const char *)&flash[0], flash.size()); + + EXPECT_EQ(std::string(end.storage.pub.policies[3].policy_name), + "AdvancedMode") + << r.name; + EXPECT_FALSE(end.storage.pub.policies[3].enabled) + << r.name << ": a flash bit re-enabled blind signing across a reboot"; + } + + // 3. Ignoring the stale bit is not enough -- it must be SCRUBBED. Firmware + // <= 7.15 still reads bit 12 as the policy, so a downgrade would boot with + // blind signing already on. storage_fromFlash must therefore report + // SUS_Updated (which makes storage_init commit, and the writer zeroes it) + // rather than SUS_Valid, which commits nothing. + { + static char sector[STORAGE_SECTOR_LEN]; + memset(sector, 0, sizeof(sector)); + memcpy(sector, "stor", 4); + uint32_t v = STORAGE_VERSION; + memcpy(sector + 44, &v, sizeof(v)); + + SessionState ss; + ConfigFlash out; + + uint32_t clean = 0; + memcpy(sector + 48, &clean, sizeof(clean)); + memset(&ss, 0, sizeof(ss)); + memset(&out, 0, sizeof(out)); + EXPECT_EQ(storage_fromFlash(&ss, &out, sector), SUS_Valid) + << "a clean V17 sector must not force a needless flash write"; + + uint32_t stale = (1u << 12); + memcpy(sector + 48, &stale, sizeof(stale)); + memset(&ss, 0, sizeof(ss)); + memset(&out, 0, sizeof(out)); + EXPECT_EQ(storage_fromFlash(&ss, &out, sector), SUS_Updated) + << "a stale AdvancedMode bit must force a commit that scrubs it"; + } +} + +// The legacy (version 2-10) storage record carried a policy NAME in flash. +// storage_upgradePolicies only fills entries from policies_count upward, and +// storage_isPolicyEnabled_impl returns on the first name match scanning from +// index 0 -- so a record naming itself "AdvancedMode" answered before the real +// entry at index 3, re-enabling blind signing straight out of unauthenticated +// flash. That defeats session-scoping entirely, so it is tested separately. +TEST(Storage, LegacyPolicyRecordCannotNameAdvancedMode) { + std::vector buf(852, 0); + + // version 2 selects the legacy reader (version 1 never read the record). + buf[0] = 2; + + // The attacker-controlled legacy policy record at +464: + // +0 has_policy_name, +1..15 policy_name, +16 has_enabled, +17 enabled + buf[464] = 1; + const char *name = "AdvancedMode"; + memcpy(&buf[465], name, strlen(name)); + buf[480] = 1; + buf[481] = 1; + + SessionState ss; + memset(&ss, 0, sizeof(ss)); + Storage storage; + memset(&storage, 0, sizeof(storage)); + + storage_readStorageV1(&ss, &storage, &buf[0], buf.size()); + storage_upgradePolicies(&storage); + + EXPECT_NE(std::string(storage.pub.policies[0].policy_name), "AdvancedMode") + << "flash controlled a policy NAME; it shadows the real AdvancedMode " + "entry"; + EXPECT_FALSE( + storage_isPolicyEnabled_impl(storage.pub.policies, "AdvancedMode")) + << "a crafted legacy record enabled blind signing"; +} + +// AdvancedMode is scoped to the UNLOCKED session, not just the power cycle: +// session_clear revokes the runtime ClearSign signers it authorizes, so it must +// disarm the policy too, or the screensaver locks and blind signing is still +// armed after the PIN goes back in. +TEST(Storage, SessionClearDisarmsAdvancedMode) { + Storage storage; + memset(&storage, 0, sizeof(storage)); + storage_resetPolicies(&storage); + + ASSERT_TRUE( + storage_setPolicy_impl(storage.pub.policies, "AdvancedMode", true)); + ASSERT_TRUE( + storage_isPolicyEnabled_impl(storage.pub.policies, "AdvancedMode")); + + SessionState ss; + + // A soft re-init must NOT disarm it. fsm_msgInitialize calls + // session_clear(false) and hosts send Initialize routinely -- disarming there + // would demand a fresh button press before every operation. + memset(&ss, 0, sizeof(ss)); + session_clear_impl(&ss, &storage, /*clear_pin=*/false); + EXPECT_TRUE( + storage_isPolicyEnabled_impl(storage.pub.policies, "AdvancedMode")) + << "Initialize disarmed AdvancedMode; blind signing is now unusable"; + + // A lock must. + memset(&ss, 0, sizeof(ss)); + session_clear_impl(&ss, &storage, /*clear_pin=*/true); + EXPECT_FALSE( + storage_isPolicyEnabled_impl(storage.pub.policies, "AdvancedMode")) + << "locking left blind signing armed"; + // Unrelated policies are untouched: this disarms one capability, it is not a + // policy reset. + EXPECT_TRUE( + storage_isPolicyEnabled_impl(storage.pub.policies, "Pin Caching")); +} + TEST(Storage, ResetCache) { Cache src; memset(&src, 0xCC, sizeof(src)); @@ -466,7 +656,10 @@ TEST(Storage, StorageUpgrade_Normal) { uint8_t wrapping_key[64]; storage_deriveWrappingKey( "123456789", wrapping_key, shadow.storage.pub.sca_hardened, - shadow.storage.pub.v15_16_trans, + /* The V1 upgrade path re-wraps through storage_setPin_impl, so this must + track whatever production wraps with -- not a fixed version. */ + storage_activePinKdfVersion(shadow.storage.pub.v15_16_trans, + shadow.storage.pub.pin_kdf_v2), shadow.storage.pub.random_salt, ""); // strongest pin evar storage_unwrapStorageKey(wrapping_key, shadow.storage.pub.wrapped_storage_key, session.storageKey); @@ -491,12 +684,82 @@ TEST(Storage, StorageUpgrade_Normal) { EXPECT_EQ(memcmp(shadow.meta.magic, "stor", 4), 0); EXPECT_EQ(std::string(shadow.storage.pub.policies[0].policy_name), "ShapeShift"); - EXPECT_EQ(shadow.storage.pub.policies[0].enabled, true); + // Was `true` here, read straight out of the legacy flash record. Policy state + // is no longer trusted from flash at any version (see + // LegacyPolicyRecordCannotNameAdvancedMode), so this is now the compiled + // default. Nothing regresses: every V11+ reader already forced ShapeShift to + // false, so the migrated `true` never survived the first commit -- it was + // transient and inconsistent with what the very next boot would see. + EXPECT_EQ(shadow.storage.pub.policies[0].enabled, false); EXPECT_EQ(std::string(shadow.storage.pub.policies[1].policy_name), "Pin Caching"); EXPECT_EQ(shadow.storage.pub.policies[1].enabled, true); } +#if !BITCOIN_ONLY +// A seed created under bitcoin-only firmware is stamped in a reserved version +// band. Multi-chain firmware must REFUSE it (SUS_BitcoinOnlyLocked), not load +// it and not silently reset it here -- the seed stays intact in flash until an +// explicit wipe. This is the core anti-downgrade guarantee. +TEST(Storage, BitcoinOnlyBandRefused) { + // storage_fromFlash always reads STORAGE_SECTOR_LEN from `flash` (in the + // firmware it points to a full flash sector), so the buffer must be a full + // sector or the version-17 read below runs off the end. + static char flash[STORAGE_SECTOR_LEN]; + memset(flash, 0, sizeof(flash)); + memcpy(flash, "stor", 4); // STORAGE_MAGIC_STR + uint32_t v = STORAGE_VERSION_BTC_ONLY; + flash[44] = (char)(v & 0xff); + flash[45] = (char)((v >> 8) & 0xff); + flash[46] = (char)((v >> 16) & 0xff); + flash[47] = (char)((v >> 24) & 0xff); + + SessionState session; + memset(&session, 0, sizeof(session)); + ConfigFlash shadow; + EXPECT_EQ(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); + + // A normal (below-band) version is still handled as before. + flash[44] = 17; + flash[45] = flash[46] = flash[47] = 0; + EXPECT_NE(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); +} +#endif + +#if BITCOIN_ONLY +// On bitcoin-only firmware, an in-band wallet stamped at an OLDER underlying +// version (which is exactly what an existing wallet looks like after a +// STORAGE_VERSION bump) must still load and migrate — never be refused, which +// would lock the user out of their own wallet. A NEWER in-band version is +// refused (downgrade guard), never wiped. +TEST(Storage, BitcoinOnlyBandMigrates) { + static char flash[STORAGE_SECTOR_LEN]; + SessionState session; + ConfigFlash shadow; + + // Older in-band version (underlying < STORAGE_VERSION): migrate, not refuse. + memset(flash, 0, sizeof(flash)); + memcpy(flash, "stor", 4); + uint32_t older = STORAGE_VERSION_BTC_ONLY_BASE + (STORAGE_VERSION - 1); + memcpy(flash + 44, &older, + 4); // test host is little-endian, matches read_u32_le + memset(&session, 0, sizeof(session)); + EXPECT_NE(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); + + // Our own current in-band version: loads (not refused). + uint32_t current = STORAGE_VERSION_BTC_ONLY; + memcpy(flash + 44, ¤t, 4); + memset(&session, 0, sizeof(session)); + EXPECT_NE(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); + + // A newer in-band version than this firmware understands: refuse. + uint32_t newer = STORAGE_VERSION_BTC_ONLY_BASE + (STORAGE_VERSION + 1); + memcpy(flash + 44, &newer, 4); + memset(&session, 0, sizeof(session)); + EXPECT_EQ(storage_fromFlash(&session, &shadow, flash), SUS_BitcoinOnlyLocked); +} +#endif + TEST(Storage, StorageRoundTrip) { ConfigFlash start; memset(&start, 0xAB, sizeof(start)); @@ -530,7 +793,7 @@ TEST(Storage, StorageRoundTrip) { uint8_t wrapping_key[64]; storage_deriveWrappingKey("", wrapping_key, start.storage.pub.sca_hardened, - start.storage.pub.v15_16_trans, + PIN_KDF_V15, start.storage.pub.random_salt, ""); storage_unwrapStorageKey(wrapping_key, start.storage.pub.wrapped_storage_key, session.storageKey); @@ -556,6 +819,7 @@ TEST(Storage, StorageRoundTrip) { printf("\n"); #endif + // clang-format off const uint8_t expected_flash[] = { 0x73, 0x74, 0x6f, 0x72, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, @@ -653,7 +917,7 @@ TEST(Storage, StorageRoundTrip) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, 0xe4, 0x8d, 0xfe, 0xcf, 0xd0, 0x54, 0x71, + 0x00, 0x00, 0x00, 0x00, 0x00, STORAGE_VERSION, 0x00, 0x00, 0x00, 0xe4, 0x8d, 0xfe, 0xcf, 0xd0, 0x54, 0x71, 0x50, 0xcb, 0x12, 0x84, 0xfa, 0x5f, 0xbf, 0xcb, 0x09, 0xca, 0x00, 0xf1, 0x37, 0xe4, 0x8f, 0x5e, 0xf9, 0x81, 0x57, 0x26, 0xb6, 0x7b, 0x8e, 0x03, 0x44, 0x9a, 0x2a, 0x7c, 0xf4, 0x3c, 0x79, 0x87, 0x5d, 0x26, 0xae, 0x9b, 0x4b, 0xb4, 0xd2, 0xc4, 0x67, 0x97, 0xe7, 0x6b, 0x6c, 0x4c, 0xbe, 0x68, @@ -719,6 +983,7 @@ TEST(Storage, StorageRoundTrip) { 0x7c, 0x20, 0x50, 0x7c, 0x85, 0xc1, 0x44, 0xaa, 0xfb, 0xf8, 0xeb, 0x20, 0x16, 0x8d, 0x72, 0x8c, 0xd2, 0xbe, 0xc2, 0xea, 0x44, 0xed, 0x7b, 0x94, 0x21, 0x00, }; + // clang-format on // If storage isn't correct, let's get an idea of where the failure is for (int i=0; ipresent = true; + a->key_id = 1; + memset(a->pubkey, 0x42, sizeof(a->pubkey)); + strcpy(a->alias, "CI Test"); + a->icon_w = 32; + a->icon_h = 32; + a->icon_len = 2; + a->icon[0] = 0x01; + a->icon[1] = 0xFF; + + std::vector flash(3480, 0); + storage_writeV18((char*)&flash[0], flash.size(), &start); + const size_t identity_block_off = 44 + 1501 + V17_ENCSEC_SIZE; + const size_t identity_block_len = + PERSISTENT_IDENTITY_COUNT * (71 + CLEARSIGN_ICON_MAX); + for (size_t i = 0; i < identity_block_len; i++) { + ASSERT_EQ(0, flash[identity_block_off + i]) << "byte " << i; + } + + // Simulate attacker-controlled legacy flash. Deserialization must scrub the + // full in-memory block rather than parse or expose any of it. + memset(&flash[identity_block_off], 0xA5, identity_block_len); + ConfigFlash end; + memset(&end, 0xCC, sizeof(end)); + storage_readV18(&end, (const char*)&flash[0], flash.size()); + const uint8_t* retired = + reinterpret_cast(end.storage.pub.clearsign_identities); + for (size_t i = 0; i < sizeof(end.storage.pub.clearsign_identities); i++) { + ASSERT_EQ(0, retired[i]) << "byte " << i; + } + for (int k = 0; k < PERSISTENT_IDENTITY_COUNT; k++) { + const ClearsignIdentity* r = &end.storage.pub.clearsign_identities[k]; + ASSERT_FALSE(r->present) << "present " << k; + } +} + +TEST(Storage, PinKdfV2FlagIsVersionedInV19) { + ConfigFlash start; + memset(&start, 0, sizeof(start)); + memcpy(start.meta.magic, "stor", 4); + start.storage.version = STORAGE_VERSION; + start.storage.pub.pin_kdf_v2 = true; + + std::vector flash(3480, 0); + storage_writeV19((char*)&flash[0], flash.size(), &start); + + ConfigFlash end; + memset(&end, 0, sizeof(end)); + storage_readV19(&end, (const char*)&flash[0], flash.size()); + EXPECT_TRUE(end.storage.pub.pin_kdf_v2); + + memset(&end, 0xCC, sizeof(end)); + storage_readV18(&end, (const char*)&flash[0], flash.size()); + EXPECT_FALSE(end.storage.pub.pin_kdf_v2); +} + +// The wallet lockout this branch fixes lived on the serialize/reboot boundary: +// storage_setPin_impl() produced a wrap the V17 record could not describe, and +// nothing noticed until the next boot re-derived the wrapping key from the +// persisted flags and every PIN failed. Every one of the tests above stays in +// RAM, so none of them could see it. +// +// This is the whole round trip, in the order the device performs it: create, +// set a PIN, serialize the V17 record exactly as storage_commit() does, reload +// it into fresh state as a boot would, unlock, and recover the secrets. +TEST(Storage, PinUnlocksAfterRebootUnderV17) { + ConfigFlash cfg; + SessionState ss; + memset(&cfg, 0, sizeof(cfg)); + memset(&ss, 0, sizeof(ss)); + memcpy(cfg.meta.magic, "stor", 4); + + storage_reset_impl(&ss, &cfg); + + // Something recognisable to recover. has_mnemonic stays false so the reload + // does not detour through u2froot derivation; the mnemonic still rides + // through the encrypted section either way. + cfg.storage.has_sec = true; + strlcpy(cfg.storage.sec.mnemonic, "all all all all all all all all all all all all", + sizeof(cfg.storage.sec.mnemonic)); + + storage_setPin_impl(&ss, &cfg.storage, "1234"); + + uint8_t key_before_reboot[64]; + memcpy(key_before_reboot, ss.storageKey, sizeof(key_before_reboot)); + + // storage_commit()'s buffer, same size, same writer. + std::vector flash(2572, 0); + storage_writeV17(&flash[0], flash.size(), &cfg); + + // Reboot: nothing carries over but the flash sector. + ConfigFlash reloaded; + SessionState fresh; + memset(&reloaded, 0, sizeof(reloaded)); + memset(&fresh, 0, sizeof(fresh)); + ASSERT_EQ(SUS_Valid, storage_fromFlash(&fresh, &reloaded, &flash[0])) + << "V17 is the current version; reading it back must not migrate"; + + bool sca_hardened = reloaded.storage.pub.sca_hardened; + bool v15_16_trans = reloaded.storage.pub.v15_16_trans; + bool pin_kdf_v2 = reloaded.storage.pub.pin_kdf_v2; + + EXPECT_EQ(PIN_WRONG, + storage_isPinCorrect_impl( + "9999", reloaded.storage.pub.wrapped_storage_key, + reloaded.storage.pub.storage_key_fingerprint, &sca_hardened, + &v15_16_trans, &pin_kdf_v2, fresh.storageKey, + reloaded.storage.pub.random_salt)); + + ASSERT_EQ(PIN_GOOD, + storage_isPinCorrect_impl( + "1234", reloaded.storage.pub.wrapped_storage_key, + reloaded.storage.pub.storage_key_fingerprint, &sca_hardened, + &v15_16_trans, &pin_kdf_v2, fresh.storageKey, + reloaded.storage.pub.random_salt)) + << "the PIN set before the reboot no longer opens the wallet"; + EXPECT_EQ(0, memcmp(fresh.storageKey, key_before_reboot, + sizeof(key_before_reboot))); + + // A wrap that survives unwrapping still has to decrypt the secrets: on a + // fingerprint mismatch storage_secMigrate() wipes and shuts down. + storage_secMigrate(&fresh, &reloaded.storage, /*encrypt=*/false); + EXPECT_STREQ("all all all all all all all all all all all all", + reloaded.storage.sec.mnemonic); + + memzero(key_before_reboot, sizeof(key_before_reboot)); +} diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index cfd43cbad..bf3ba4f96 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -1,38 +1,192 @@ extern "C" { +#include "keepkey/board/messages.h" +#include "keepkey/board/usb.h" #include "keepkey/firmware/coins.h" +#include "keepkey/firmware/app_confirm.h" +#include "keepkey/firmware/ethereum_contracts/thortx.h" +#include "keepkey/firmware/fsm.h" #include "keepkey/firmware/thorchain.h" #include "keepkey/firmware/tendermint.h" -#include "trezor/crypto/ecdsa.h" +#include "messages-ethereum.pb.h" #include "trezor/crypto/secp256k1.h" -#include "trezor/crypto/sha2.h" + +// From keepkey_board.h, which we can't include here: its shutdown(void) +// declaration clashes with sys/socket.h's shutdown(int, int). +void kk_board_init(void); } #include "gtest/gtest.h" #include +#include +#include + +#include +#include +#include +#include -// Mirrors THORCHAIN_MEMO_MAX inside thorchain_parseConfirmMemo(). +// Mirrors the bound inside thorchain_parseConfirmMemo(). static const size_t THORCHAIN_MEMO_MAX_FOR_TEST = 256; -TEST(Thorchain, AmountFormattingCoversProtocolMaximumAndFailsClosed) { - char max_asset[THORCHAIN_ASSET_SUFFIX_LEN] = {}; - std::memset(max_asset, 'A', sizeof(max_asset) - 1); +/* + * 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 HANG the test until the + * CI job hits its timeout and reports "cancelled", which reads like flake + * rather than a wrong expectation — so get the count right). + * + * Screen counts are value-dependent now that confirm() pages a body too long + * for BODY_ROWS: the same format string is one screen for a 3-row body and + * two for a 4-row one. Long test vectors are the ones to check. + * + * These helpers have external linkage so mayachain.cpp can share the + * one-time board/usb initialization. + */ + +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); +} - char rendered[21 + THORCHAIN_ASSET_SUFFIX_LEN + 1]; - ASSERT_TRUE(thorchain_formatAmount(UINT64_MAX, max_asset, rendered, - sizeof(rendered))); - EXPECT_NE(std::string::npos, std::string(rendered).find(max_asset)); +/* One ButtonAck + one DebugLinkDecision, i.e. what a single screen eats. */ +#define KKCONFIRM_MSGS_PER_SCREEN 2 + +// Queue nYes accepted screens followed by nNo rejected screens, plus one +// trailing rejection as a sentinel. +// +// The sentinel is what keeps a wrong count cheap. A screen the test did not +// budget for consumes it, is rejected, and the code under test returns false +// immediately, so the test FAILS in milliseconds. Without it that extra +// screen blocks forever on an answer nobody queued and the only symptom is a +// CI job burning its whole timeout and reporting "cancelled" — which reads +// like infrastructure flake rather than a wrong expectation. confirm() paging +// long bodies makes screen counts value-dependent, so this is a mistake worth +// catching in the harness instead of in a 30-minute timeout. +bool kkconfirm_preload(int nYes, int nNo) { + static bool initialized = false; + if (!initialized) { + 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; + } - char too_small[8]; - EXPECT_FALSE(thorchain_formatAmount(UINT64_MAX, max_asset, too_small, - sizeof(too_small))); - EXPECT_FALSE(thorchain_formatAmount(1, "", rendered, sizeof(rendered))); - EXPECT_FALSE( - thorchain_formatAmount(1, "ETH.ETH\n", rendered, sizeof(rendered))); - EXPECT_FALSE( - thorchain_formatAmount(1, "ETH.\"ETH", rendered, sizeof(rendered))); + // Start from a known-empty queue. The socket and its queue are process-wide + // and shared with every other file that uses this driver, so a test that + // never reached its kkconfirm_drain() — a fatal ASSERT between preload and + // drain, or a test that simply forgot to drain — would otherwise hand its + // leftovers to whichever test ran next, and the verdict for that test would + // depend on what preceded it. Anything still queued here was sent at least + // one test ago and has long since been delivered, so a non-blocking sweep is + // enough; the grace window in kkconfirm_drain() is what covers packets sent + // moments earlier. + { + uint8_t stale[MSG_TINY_BFR_SZ]; + // volatile for the same reason as in kkconfirm_drain(): 0xFFFF is outside + // the MessageType enum, so the compiler may fold the comparison away. + volatile uint16_t id; + while ((id = (uint16_t)check_for_tiny_msg(stale)) != MSG_TINY_TYPE_ERROR) { + } + } + + static const uint8_t yes[] = {0x08, 0x01}; // DebugLinkDecision.yes_no + static const uint8_t no[] = {0x08, 0x00}; + for (int i = 0; i < nYes + nNo + 1; i++) { + if (!kkconfirm_sendTiny(MessageType_MessageType_ButtonAck, NULL, 0)) + return false; + const uint8_t* decision = (i < nYes) ? yes : no; + if (!kkconfirm_sendTiny(MessageType_MessageType_DebugLinkDecision, decision, + 2)) + return false; + } + return true; } +// Consume and count any tiny messages left in the queue, discounting the +// sentinel kkconfirm_preload() always queues. 0 keeps meaning exactly what it +// meant before — every preloaded screen was shown and no more. A NEGATIVE +// count means the sentinel was consumed: more screens than the test expected. +// +// An empty read is NOT proof the queue is empty. The emulator reads its UDP +// socket with MSG_DONTWAIT, and loopback delivery is asynchronous (the +// datagram is handed to the network input thread by sendto(), not deposited +// in the receiving socket's buffer by it). A test whose code under test shows +// ZERO screens never blocks anywhere, so it can poll microseconds after +// preload() and see nothing yet: the old "break on the first empty read" +// counted 0 packets and reported -2 — "you showed one screen too many" — for +// a refusal that in fact showed no screen at all. That misreads a harness +// race as a disclosure bug, and pointed at the one direction this file must +// never be edited in. So wait out a grace period after the last packet before +// declaring the queue drained. +// +// This can only ever count MORE packets, never fewer, so it cannot hide an +// extra screen: a screen that really ran consumed its two packets, and no +// amount of waiting brings those back. +#define KKCONFIRM_DRAIN_GRACE_US 200000 /* 200ms after the last packet seen */ +int kkconfirm_drain(void) { + uint8_t buf[MSG_TINY_BFR_SZ]; + int n = 0; + int idle_us = 0; + while (idle_us < KKCONFIRM_DRAIN_GRACE_US) { + // 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) { + n++; + idle_us = 0; // restart the grace window after every packet + continue; + } + usleep(1000); + idle_us += 1000; + } + return n - KKCONFIRM_MSGS_PER_SCREEN; +} + +// 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, MemoWithEmbeddedNulIsNotParsed) { + /* The control at the end of this test drives real confirm screens, so the + board/usb one-time init inside kkconfirm_preload() has to have run before + any of it: without it confirm()'s message path trips + "MessagesMap != NULL" and ABORTS the whole binary. + + Budget the WHOLE test with one preload: 0 screens for the two refusals + plus the 3 screens the control's ADD memo confirms. That makes the count + assert both halves at once -- a refusal that displayed anything would eat + an accept pair, leaving the control short and landing it on the reject + sentinel, so the CONFIRMED expectation below fails. */ + ASSERT_TRUE(kkconfirm_preload(3, 0)); + /* thorchain_parseConfirmMemo() copies an explicit byte count and then hands the buffer to strtok, which stops at the first NUL. A memo such as "=:ETH.ETH::0\0:affiliate:75" is signed in FULL -- the EVM caller @@ -61,18 +215,18 @@ TEST(Thorchain, MemoWithEmbeddedNulIsNotParsed) { EXPECT_EQ(THORCHAIN_MEMO_UNPARSED, thorchain_parseConfirmMemo(kTrailingNul, sizeof(kTrailingNul) - 1)); - /* The SAME memo with a truthful length parses normally -- the control that - shows the rule rejects the misdeclaration, not the memo. It cannot be - asserted here: parsing succeeds, so the function goes on to draw confirm - screens, and this binary has no canvas and nothing to answer them with. - The control runs where a device can answer: - python-keepkey - tests/test_msg_thorchain_signtx.py::test_sign_eth_add_liquidity signs this - exact memo declared at its truthful 58 bytes (ABI length word 0x3a). Same - bytes, one byte less declared, opposite outcome. + /* The SAME memo with a truthful length parses normally. This is the control: + it shows the rule rejects the misdeclaration, not the memo. - Everything below stays inside the unit harness because each case returns - before any screen is drawn. */ + Parsing it is not silent -- it is the ADD-liquidity branch, which confirms + 3 screens (asset+chain, paired address, affiliate fee) before returning + CONFIRMED. drain() == 0 therefore proves BOTH that the truthful-length + path disclosed all 3 (which is what makes refusing the misdeclared length + lossless) and that the two refusals above disclosed nothing: UNPARSED + means nothing was displayed and nothing was confirmed. */ + EXPECT_EQ(THORCHAIN_MEMO_CONFIRMED, + thorchain_parseConfirmMemo(kTrailingNul, sizeof(kTrailingNul) - 2)); + EXPECT_EQ(0, kkconfirm_drain()); /* Over-long memos are refused rather than truncated. */ static const char kOversize[THORCHAIN_MEMO_MAX_FOR_TEST + 1] = {'=', ':', 'E', @@ -101,29 +255,6 @@ TEST(Thorchain, MemoWithEmbeddedNulIsNotParsed) { static const char kNoDot[] = "SWAP:ETH:dest"; EXPECT_EQ(THORCHAIN_MEMO_UNPARSED, thorchain_parseConfirmMemo(kNoDot, sizeof(kNoDot) - 1)); - - /* A second dot outside the chain/asset field is not this grammar either. */ - static const char kExtraDot[] = "SWAP:ETH.USDT:de.st:limit"; - EXPECT_EQ(THORCHAIN_MEMO_UNPARSED, - thorchain_parseConfirmMemo(kExtraDot, sizeof(kExtraDot) - 1)); -} - -TEST(Thorchain, MemoWithEmptyPositionalFieldIsNotStructured) { - /* `::` is meaningful in the live grammar: here it omits the limit before - affiliate `t`. strtok() used to collapse it and show `t` as the limit. - Until the structured parser preserves positions, this must take the raw - UTXO path or be refused by the EVM caller. */ - static const char kEmptyLimit[] = - "=:ETH.ETH:0x41e5560054824ea6b0732e656e3ad64e20e94e45::t:10"; - EXPECT_EQ(THORCHAIN_MEMO_UNPARSED, - thorchain_parseConfirmMemo(kEmptyLimit, sizeof(kEmptyLimit) - 1)); - - /* The current savers grammar also uses an explicitly empty field before - affiliate data. It must never be compacted into different labels. */ - static const char kSaversAffiliate[] = "+:BTC/BTC::t:10"; - EXPECT_EQ(THORCHAIN_MEMO_UNPARSED, - thorchain_parseConfirmMemo(kSaversAffiliate, - sizeof(kSaversAffiliate) - 1)); } TEST(Thorchain, StructuredMemoRequiresExactSafeTokensAndCanonicalBps) { @@ -168,180 +299,600 @@ TEST(Thorchain, ThorchainGetAddress) { &secp256k1_info}; char addr[46]; ASSERT_TRUE(tendermint_getAddress(&node, "thor", addr)); - /* This file was never in the build, so this vector was never checked. Its - old expectation, "...fn8nzm88u80q", is 42 characters and fails its own - bech32 checksum -- a 20-byte payload encodes to 43. The value below is - bech32(hrp="thor", ripemd160(sha256(pubkey))) computed independently of - this firmware; the device agrees with it. */ EXPECT_EQ(std::string("thor1am058pdux3hyulcmfgj4m3hhrlfn8nzmpq9u6l"), addr); } -TEST(Thorchain, ThorchainSignTx) { - HDNode node = { - 0, - 0, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - {0x04, 0xde, 0xc0, 0xcc, 0x01, 0x3c, 0xd8, 0xab, 0x70, 0x87, 0xca, - 0x14, 0x96, 0x0b, 0x76, 0x8c, 0x3d, 0x83, 0x45, 0x24, 0x48, 0xaa, - 0x00, 0x64, 0xda, 0xe6, 0xfb, 0x04, 0xb5, 0xd9, 0x34, 0x76}, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - &secp256k1_info}; +// Shared fixtures +static const HDNode kSignNode = { + 0, + 0, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0x04, 0xde, 0xc0, 0xcc, 0x01, 0x3c, 0xd8, 0xab, 0x70, 0x87, 0xca, + 0x14, 0x96, 0x0b, 0x76, 0x8c, 0x3d, 0x83, 0x45, 0x24, 0x48, 0xaa, + 0x00, 0x64, 0xda, 0xe6, 0xfb, 0x04, 0xb5, 0xd9, 0x34, 0x76}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + &secp256k1_info}; + +static const ThorchainSignTx kSignTx = { + 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, + true, 0, + true, "thorchain", + true, 5000, + true, 200000, + true, "", + true, 0, + true, 1}; + +static const char* kToAddr = "thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v"; + +// Denom validation: only [a-z0-9./\-] is allowed; anything else is rejected +TEST(Thorchain, ThorchainDenomValidation) { + EXPECT_TRUE(thorchain_isValidDenom("rune")); + EXPECT_TRUE(thorchain_isValidDenom("tcy")); + EXPECT_TRUE(thorchain_isValidDenom("rujira")); + EXPECT_TRUE(thorchain_isValidDenom("eth.eth")); + EXPECT_TRUE(thorchain_isValidDenom("btc/btc")); + EXPECT_TRUE(thorchain_isValidDenom("cross-chain")); + + EXPECT_FALSE(thorchain_isValidDenom("")); // empty → caller uses "rune" + EXPECT_FALSE(thorchain_isValidDenom("RUNE")); // uppercase rejected + EXPECT_FALSE(thorchain_isValidDenom("rune\"")); // quote injection + EXPECT_FALSE(thorchain_isValidDenom("rune\\n")); // backslash injection + EXPECT_FALSE(thorchain_isValidDenom(" rune")); // leading space + EXPECT_FALSE(thorchain_isValidDenom("ru ne")); // embedded space +} + +// Invalid denom must cause thorchain_signTxUpdateMsgSend to return false +TEST(Thorchain, ThorchainSignTxInvalidDenom) { + HDNode node = kSignNode; hdnode_fill_public_key(&node); - const ThorchainSignTx msg = { - 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, // address_n - true, 0, // account_number - true, "thorchain", // chain_id - true, 5000, // fee_amount - true, 200000, // gas - true, "", // memo - true, 0, // sequence - true, 1 // msg_count - }; - ASSERT_TRUE(thorchain_signTxInit(&node, &msg)); - - /* The old recipient, "thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", is the - well-known cosmos1 test address with its prefix hand-edited to "thor" and - the cosmos checksum left behind. It fails bech32_decode(), so this call - returned false and the test could never have passed -- which nobody - noticed, because the file was not compiled. Same 20-byte payload, - correct thor checksum. */ - ASSERT_TRUE(thorchain_signTxUpdateMsgSend( - 100000, "thor18vhdczjut44gpsy804crfhnd5nq003nzf5s36n")); - - uint8_t public_key[33]; - uint8_t signature[64]; - - ASSERT_TRUE(thorchain_signTxFinalize(public_key, signature)); - - /* Recomputed for the corrected recipient, and independently of this - firmware: SHA256 of the amino StdSignDoc - {"account_number":"0","chain_id":"thorchain","fee":{...,"denom":"rune"}], - "gas":"200000"},"memo":"","msgs":[{"type":"thorchain/MsgSend",...}], - "sequence":"0"} - signed with RFC6979-deterministic secp256k1 and low-S normalised. The - device produces the same 64 bytes. */ + 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. + * ===================================================================== */ + +/* thorchain_parseConfirmMemo returns a THREE-valued ThorchainMemoResult, and + * THORCHAIN_MEMO_CONFIRMED is 0 -- so returning it as a bool inverts the sense + * of every test in this file. Compare against the enum. UNPARSED and CANCELLED + * both read as false here, which is what these tests mean by "not confirmed"; + * the tests that care which one it is check the enum directly. */ +static bool parseMemo(const char* memo, size_t size) { + return thorchain_parseConfirmMemo(memo, size) == THORCHAIN_MEMO_CONFIRMED; +} +/* strlen(memo), NOT strlen(memo) + 1. `size` is the DECLARED length and every + * byte inside it is covered by the signature, so declaring the terminator is + * declaring a byte the memo does not contain. The device refuses that as a + * non-canonical length (a length word that does not describe its own content), + * which is deliberate -- exempting a trailing NUL to make fixtures pass is + * exactly what the release invariant forbids. The fixture is what was wrong. */ +static bool parseMemo(const char* memo) { + return parseMemo(memo, strlen(memo)); +} + +// Classic full-form swap memo: asset + dest + limit + affiliate + fee bps +// = 4 screens (the 4th is the new affiliate fee screen), but the asset screen +// pages: "Confirm swap asset USDT-0xdac...ec7\n on chain ETH" is 4 rows and a +// body only gets BODY_ROWS=3, so it is shown as 1/2 + 2/2 = 5 presses. Before +// confirm() paged, that 4th row — the tail of the USDT contract address — was +// simply dropped from the screen. +TEST(Thorchain, MemoSwapFullFormShowsAffiliate) { + ASSERT_TRUE(kkconfirm_preload(5, 0)); EXPECT_TRUE( - memcmp(signature, - (uint8_t*)"\xbd\x32\x29\xe7\xf5\x31\xdb\x80\xc2\x74\xff\xc5\xfc" - "\x6f\x43\xbf\x0f\xbc\xf9\x93\x4c\xca\x60\x3b\x40\xd6" - "\x58\x3a\x7b\xb2\x75\xac\x51\xe9\xbe\xf7\x6f\xed\x97" - "\xab\x1a\x73\x1e\xc8\x7e\x40\x53\x15\xac\xa1\x1c\x92" - "\x34\x6c\xef\xee\x16\x01\x35\x0f\x80\x3b\x3e\x5b", - 64) == 0); + parseMemo("SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:" + "0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75")); + EXPECT_EQ(0, kkconfirm_drain()); } -TEST(Thorchain, MultiMessageSignTxSeparatesMsgsWithComma) { - HDNode node = { - 0, - 0, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - {0x04, 0xde, 0xc0, 0xcc, 0x01, 0x3c, 0xd8, 0xab, 0x70, 0x87, 0xca, - 0x14, 0x96, 0x0b, 0x76, 0x8c, 0x3d, 0x83, 0x45, 0x24, 0x48, 0xaa, - 0x00, 0x64, 0xda, 0xe6, 0xfb, 0x04, 0xb5, 0xd9, 0x34, 0x76}, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - &secp256k1_info}; - hdnode_fill_public_key(&node); +// An affiliate FEE with an EMPTY affiliate slot must still be disclosed. The +// bytes "75" are inside the signed length whether or not the slot naming their +// recipient is filled in, and the empty-field-preserving split keeps them in +// field 5 rather than shifting them into the limit. Gating the screen on the +// affiliate alone showed the user no fee at all. +TEST(Thorchain, MemoSwapFeeWithEmptyAffiliateIsStillShown) { + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(parseMemo("=:ETH.ETH:0xdest:0::75")); + EXPECT_EQ(0, kkconfirm_drain()); +} - const ThorchainSignTx msg = { - 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, - true, 0, - true, "thorchain", - true, 5000, - true, 200000, - true, "", - true, 0, - true, 2}; - ASSERT_TRUE(thorchain_signTxInit(&node, &msg)); - - const char* const to = "thor18vhdczjut44gpsy804crfhnd5nq003nzf5s36n"; - ASSERT_TRUE(thorchain_signTxUpdateMsgSend(100000, to)); - ASSERT_TRUE(thorchain_signTxUpdateMsgSend(42, to)); - ASSERT_TRUE(thorchain_signingIsFinished()); - - uint8_t public_key[33]; - uint8_t signature[64]; - ASSERT_TRUE(thorchain_signTxFinalize(public_key, signature)); - - char from[46]; - ASSERT_TRUE(tendermint_getAddress(&node, "thor", from)); - char doc[1024]; - const int n = snprintf( - doc, sizeof(doc), - "{\"account_number\":\"0\",\"chain_id\":\"thorchain\"," - "\"fee\":{\"amount\":[{\"amount\":\"5000\",\"denom\":\"rune\"}]," - "\"gas\":\"200000\"},\"memo\":\"\",\"msgs\":[" - "{\"type\":\"thorchain/MsgSend\",\"value\":{\"amount\":[{\"amount\":" - "\"100000\",\"denom\":\"rune\"}],\"from_address\":\"%s\"," - "\"to_address\":\"%s\"}}," - "{\"type\":\"thorchain/MsgSend\",\"value\":{\"amount\":[{\"amount\":" - "\"42\",\"denom\":\"rune\"}],\"from_address\":\"%s\"," - "\"to_address\":\"%s\"}}],\"sequence\":\"0\"}", - from, to, from, to); - ASSERT_GT(n, 0); - ASSERT_LT((size_t)n, sizeof(doc)); - - uint8_t hash[SHA256_DIGEST_LENGTH]; - sha256_Raw((const uint8_t*)doc, (size_t)n, hash); - uint8_t expected[64]; - ASSERT_EQ(0, ecdsa_sign_digest(&secp256k1, node.private_key, hash, expected, - nullptr, nullptr)); - EXPECT_EQ(0, memcmp(signature, expected, sizeof(expected))); - thorchain_signAbort(); +// The same memo without the fee: one screen fewer, which is what makes the +// count above evidence that the fee got its own screen. +TEST(Thorchain, MemoSwapNoFeeIsThreeScreens) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE(parseMemo("=:ETH.ETH:0xdest:0")); + EXPECT_EQ(0, kkconfirm_drain()); } -TEST(Thorchain, ZeroOrOmittedMessagesFailInitialization) { - HDNode node = {}; - ThorchainSignTx msg = {0, {}, true, 0, true, "thorchain", true, 0, - true, 0, true, "", true, 0, true, 0}; - EXPECT_FALSE(thorchain_signTxInit(&node, &msg)); - EXPECT_FALSE(thorchain_signingIsInited()); - EXPECT_FALSE(thorchain_signingIsFinished()); - EXPECT_FALSE(thorchain_signTxUpdateMsgSend(1, "ignored")); - - msg.has_msg_count = false; - msg.msg_count = 1; - EXPECT_FALSE(thorchain_signTxInit(&node, &msg)); - EXPECT_FALSE(thorchain_signingIsInited()); - - msg.has_msg_count = true; - strcpy(msg.chain_id, ""); - EXPECT_FALSE(thorchain_signTxInit(&node, &msg)); - strcpy(msg.chain_id, "thor\nchain"); - EXPECT_FALSE(thorchain_signTxInit(&node, &msg)); -} - -TEST(Thorchain, DepositAssetAndSignerFailClosed) { - HDNode node = {}; - node.curve = &secp256k1_info; - ThorchainSignTx msg = {}; - msg.has_chain_id = true; - strcpy(msg.chain_id, "thorchain"); - msg.has_msg_count = true; - msg.msg_count = 1; - ASSERT_TRUE(thorchain_signTxInit(&node, &msg)); - - ThorchainMsgDeposit deposit = {}; - deposit.has_asset = true; - strcpy(deposit.asset, "ETH.\"ETH"); - deposit.has_signer = true; - strcpy(deposit.signer, "thor18vhdczjut44gpsy804crfhnd5nq003nzf5s36n"); - EXPECT_FALSE(thorchain_signTxUpdateMsgDeposit(&deposit)); - - strcpy(deposit.asset, "ETH.ETH"); - strcpy(deposit.signer, "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v"); - EXPECT_FALSE(thorchain_signTxUpdateMsgDeposit(&deposit)); - - strcpy(deposit.signer, "thor18vhdczjut44gpsy804crfhnd5nq003nzf5s36n"); - EXPECT_TRUE(thorchain_signTxUpdateMsgDeposit(&deposit)); - EXPECT_TRUE(thorchain_signingIsFinished()); - thorchain_signAbort(); +// 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); + + /* 6 presses, not 4: the 240-char destination needs 8 rows, so its screen + * pages 3 ways (1 + 3 + 1 + 1). Every byte of the memo reaches the screen. */ + ASSERT_TRUE(kkconfirm_preload(6, 0)); + EXPECT_TRUE(parseMemo(memo.c_str(), memo.size())); /* no NUL counted */ + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Oversized input (> 256) is rejected outright +TEST(Thorchain, MemoOversized) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("SWAP:ETH.ETH:0xdest:420", 257)); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Symmetric withdraw: pool + basis points on a single screen. +TEST(Thorchain, MemoWithdrawSymmetric) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(parseMemo("WITHDRAW:BTC.BTC:10000")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Asymmetric withdraw: the 4th field selects a SINGLE-SIDED payout asset — +// it directs money, so it gets its own screen instead of signing unseen with +// screens identical to the symmetric form. +TEST(Thorchain, MemoWithdrawAsymmetricShowsPayoutAsset) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(parseMemo("-:BTC.BTC:10000:THOR.RUNE")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Rejecting the payout-asset screen aborts the withdrawal. +TEST(Thorchain, MemoWithdrawAsymmetricRejectPropagates) { + ASSERT_TRUE(kkconfirm_preload(1, 1)); // approve summary, reject asset + EXPECT_FALSE(parseMemo("wd:BTC.BTC:5000:BTC.BTC")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// More fields than any withdraw grammar defines cannot be labeled and must +// not be hidden — mirrors the SWAP (>9) and ADD (>5) caps. +TEST(Thorchain, MemoWithdrawTooManyFieldsRejected) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("WITHDRAW:BTC.BTC:10000:THOR.RUNE:extra")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// DEX-aggregator swap: aggregator addr, final token and min-out are all +// router-executed and must be shown — asset/chain + dest + limit + affiliate + +// aggregator + final + min = 7 screens (none hidden). +TEST(Thorchain, MemoSwapAggregatorShowsAllFields) { + ASSERT_TRUE(kkconfirm_preload(7, 0)); + EXPECT_TRUE(parseMemo( + "SWAP:ETH.ETH:0xdest:420:kk:75:0xaggregator:0xfinaltoken:1000")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// A '|' outbound-memo suffix (MinAmountOut|OUTBOUND_MEMO) is forwarded to the +// outbound contract and can contain ':' our split would scatter. It must be +// disclosed in full: swap header + the fully-paged raw memo = 2 screens here +// (memo < one page). Nothing falls back to blind-signing. +TEST(Thorchain, MemoSwapPipeOutboundIsFullyPaged) { + ASSERT_TRUE(kkconfirm_preload(2, 0)); + const char memo[] = "=:ETH.ETH:0xdest|OUT:0xfinal:1"; // ':' after the pipe + EXPECT_TRUE(parseMemo(memo, strlen(memo))); // no NUL in the paged bytes + EXPECT_EQ(0, kkconfirm_drain()); +} + +// More fields than any swap grammar defines (>9) is structure we cannot label; +// refuse it rather than sign an undisplayed tail. Rejected before any screen. +TEST(Thorchain, MemoSwapTooManyFieldsRejected) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("SWAP:ETH.ETH:a:b:c:d:e:f:g:h")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD:POOL:PAIREDADDR:AFFILIATE:FEE — affiliate + fee must not be hidden: +// add asset + pool + affiliate-fee = 3 screens. +TEST(Thorchain, MemoAddShowsAffiliateAndFee) { + ASSERT_TRUE(kkconfirm_preload(3, 0)); + EXPECT_TRUE( + parseMemo("ADD:BTC.BTC:thor18vhdczjut44gpsy804crfhnd5nq003nz0nf20v" + ":affil:50")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// ADD with more than its 5 defined fields is refused (no hidden tail). +TEST(Thorchain, MemoAddTooManyFieldsRejected) { + ASSERT_TRUE(kkconfirm_preload(0, 0)); + EXPECT_FALSE(parseMemo("ADD:BTC.BTC:pool:affil:50:extra")); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// The full-memo pager is the authoritative disclosure the native THOR/MAYA +// handlers page after their structured summary. A short ASCII memo is one page. +TEST(Thorchain, FullMemoShortAsciiIsOnePage) { + const char memo[] = "=:ETH.ETH:0xdest:420:kk:75"; + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo, strlen(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// A memo too long for one screen is paged, and every byte lands on some page. +// +// The vector is a real DEX-aggregator swap memo (the 9-field grammar +// thorchain_parseConfirmMemo documents), 206 printable bytes. The pager +// measures rendered rows, so the break point is not a byte count: it fills +// three BODY_ROWS rows, which lands at 121 bytes on page 1 and the remaining +// 85 on page 2. +// +// It used to be a 67-byte '%'-and-space string carried over from the branch +// whose pager rendered a space AS a space, so word-wrap pushed the last word +// onto a fourth row and forced a second page. This pager escapes every byte +// outside 0x21..0x7e, so a space is disclosed as the four glyphs "\x20" and +// there is no word-wrap for it to exploit — that 67-byte memo now measures to +// three rows and is one page that shows all 67 bytes. Nothing is hidden by +// that, so this test needed a vector that genuinely exceeds one screen; it is +// asserting that paging happens and is complete, not that any particular +// string is two screens. +TEST(Thorchain, FullMemoLongAsciiPagesAll) { + const char memo[] = + "=:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7" + ":0x41e5560054824ea6b0732e656e3ad64e20e94e45:420/1/0:kk:75" + ":0x1111111254eeb25477b68fb85ed929f73a960582" + ":0xdac17f958d2ee523a2206206994597c13d831ec7:100000000"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo, strlen(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Rejecting any page aborts the whole disclosure (so the handler aborts +// signing). Same two-page vector as above, for the same reason. +TEST(Thorchain, FullMemoRejectPropagates) { + const char memo[] = + "=:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7" + ":0x41e5560054824ea6b0732e656e3ad64e20e94e45:420/1/0:kk:75" + ":0x1111111254eeb25477b68fb85ed929f73a960582" + ":0xdac17f958d2ee523a2206206994597c13d831ec7:100000000"; + ASSERT_TRUE(kkconfirm_preload(1, 1)); // approve page 1, reject page 2 + EXPECT_FALSE(thorchain_confirm_full_memo("Memo", memo, strlen(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Non-printable memo bytes are disclosed in complete renderer-measured hex +// pages, never hidden behind a byte-count summary. +// +// Four pages, not two: this pager spells a non-printable byte as the escape +// "\x01" — four glyphs, unambiguously not text — where the other branch +// emitted a bare two-digit "01" that a printable memo could imitate. Four +// glyphs per byte is 29 bytes to a three-row page, so 100 bytes is +// 29+29+29+13. The count went UP because each byte is disclosed more +// explicitly; do not shrink it back by shortening the escape. +TEST(Thorchain, FullMemoBinaryPagesAsHex) { + char memo[100]; + memset(memo, 0x01, sizeof(memo)); + ASSERT_TRUE(kkconfirm_preload(4, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", memo, sizeof(memo))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// An empty memo must show a single "(empty)" screen — not fall through to the +// hex branch, which would pass an uninitialized buffer to %s. +TEST(Thorchain, FullMemoEmptyShowsEmpty) { + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_TRUE(thorchain_confirm_full_memo("Memo", "", 0)); + EXPECT_EQ(0, kkconfirm_drain()); +} + +// Renderer-aware paging must split a payload the OLED cannot fit, rather than +// trusting a byte count. This began as the 69-byte word-wrap exploit from the +// second-pass audit, where a byte-count pager called it one screen while the +// renderer pushed the final signed word onto a fourth row. +// +// That exact vector no longer pages, and the reason matters: this tree renders +// bytes through develop's confirm_byte_token(), which escapes everything +// outside 0x21..0x7e -- SPACE included -- as the four glyphs \x20. With no +// literal space left there is no word-wrap point, so the original exploit is +// closed by the escaping rather than by the pager, and the 67-byte payload now +// measures as a single page that FITS (verified: 1 screen). +// +// The payload is doubled to 134 bytes so it genuinely overflows and the pager +// is still the thing under test. Measured, not assumed: 2 pages exactly. +// If you change this vector, re-measure -- preload one screen too few and the +// test hangs instead of failing. +TEST(Confirmation, ExactLengthPagerMeasuresRenderedRows) { + const char payload[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%" + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_TRUE(kkconfirm_preload(2, 0)); + EXPECT_TRUE(confirm_bytes(ButtonRequestType_ButtonRequest_SignMessage, + "Signed Message", (const uint8_t*)payload, + strlen(payload))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +TEST(Confirmation, ExactLengthPagerRejectPropagates) { + const char payload[] = + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%" + "%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%"; + ASSERT_TRUE(kkconfirm_preload(1, 1)); + EXPECT_FALSE(confirm_bytes(ButtonRequestType_ButtonRequest_SignMessage, + "Signed Message", (const uint8_t*)payload, + strlen(payload))); + EXPECT_EQ(0, kkconfirm_drain()); +} + +/* ===================================================================== + * thor_isThorchainTx — chain-scoped router pin. + * + * A THORChain deposit uses a DIFFERENT router address on every EVM chain, + * so the pin must match on (chain_id, address) together. Before this was + * chain-scoped, only Ethereum-mainnet deposits ever matched and an + * Avalanche deposit fell into the blind-sign gate (the AVAX->ETH bug). + * ===================================================================== */ + +// Lowercase-hex 40-char router -> 20 raw bytes. +static void hex20(const char* hex, uint8_t out[20]) { + for (int i = 0; i < 20; i++) { + auto nib = [](char c) -> int { + return c <= '9' ? c - '0' : (c | 0x20) - 'a' + 10; + }; + out[i] = (uint8_t)((nib(hex[i * 2]) << 4) | nib(hex[i * 2 + 1])); + } +} + +static void make_deposit_msg(EthereumSignTx* msg, const uint8_t to[20], + const uint8_t* data, size_t data_len, + uint32_t chain_id, bool has_chain) { + memset(msg, 0, sizeof(*msg)); + msg->has_to = true; + msg->to.size = 20; + memcpy(msg->to.bytes, to, 20); + msg->has_data_initial_chunk = true; + msg->data_initial_chunk.size = (pb_size_t)data_len; + memcpy(msg->data_initial_chunk.bytes, data, data_len); + msg->has_chain_id = has_chain; + msg->chain_id = chain_id; +} + +static const char* THOR_ETH_ROUTER = "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146"; +static const char* THOR_AVAX_ROUTER = + "00dc6100103bc402d490aee3f9a5560cbd91f1d4"; +static const uint8_t DEPOSIT_WITH_EXPIRY[4] = {0x44, 0xbc, 0x93, 0x7b}; + +TEST(Thorchain, IsThorchainTxEthRouterOnEthereum) { + uint8_t to[20]; + hex20(THOR_ETH_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 1, true); + EXPECT_TRUE(thor_isThorchainTx(&msg)); +} + +TEST(Thorchain, IsThorchainTxAvaxRouterOnAvalanche) { + uint8_t to[20]; + hex20(THOR_AVAX_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 43114, true); + EXPECT_TRUE(thor_isThorchainTx(&msg)); // the AVAX->ETH bug fix +} + +// The AVAX router on the Ethereum chain (or vice versa) must NOT match — the +// pin is (chain, address) together, so a router borrowed onto the wrong chain +// can't inherit the trusted deposit UX. +TEST(Thorchain, IsThorchainTxRejectsRouterOnWrongChain) { + uint8_t avax[20], eth[20]; + hex20(THOR_AVAX_ROUTER, avax); + hex20(THOR_ETH_ROUTER, eth); + EthereumSignTx msg; + make_deposit_msg(&msg, avax, DEPOSIT_WITH_EXPIRY, 4, 1, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); // AVAX router, ETH chain + make_deposit_msg(&msg, eth, DEPOSIT_WITH_EXPIRY, 4, 43114, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); // ETH router, AVAX chain +} + +// A chain with no pinned THORChain router never clear-signs (falls to blind +// sign), even with a real deposit selector to some address. +TEST(Thorchain, IsThorchainTxRejectsUnpinnedChain) { + uint8_t to[20]; + hex20(THOR_ETH_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 137 /*polygon*/, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); +} + +// A tx with NO chain_id at all gets no router: ethereum.c defaults an absent +// chain_id to mainnet for hashing, but an identity pin must never be +// inherited from a default the host merely omitted. +TEST(Thorchain, IsThorchainTxRejectsMissingChainId) { + uint8_t to[20]; + hex20(THOR_ETH_ROUTER, to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 0, false); + EXPECT_FALSE(thor_isThorchainTx(&msg)); +} + +// A random contract carrying the deposit selector must not match — this is the +// drain-vector guard the pin exists for. +TEST(Thorchain, IsThorchainTxRejectsUnpinnedAddress) { + uint8_t to[20]; + hex20("00000000000000000000000000000000deadbeef", to); + EthereumSignTx msg; + make_deposit_msg(&msg, to, DEPOSIT_WITH_EXPIRY, 4, 43114, true); + EXPECT_FALSE(thor_isThorchainTx(&msg)); +} + +/* ===================================================================== + * thor_confirmThorTx on the Avalanche router — the full confirm path + * (router label, vault, native amount, structured memo, raw memo pages) + * runs for a non-mainnet deposit, and the exact-end memo bounds hold. + * ===================================================================== */ + +// Assemble a canonical depositWithExpiry(address,address,uint256,string, +// uint256) calldata. declared_len overrides the ABI memo-length word so the +// adversarial case (length says more than is present) can be exercised. +static std::vector build_thor_deposit(const uint8_t vault[20], + const std::string& memo, + uint32_t declared_len) { + std::vector d(DEPOSIT_WITH_EXPIRY, DEPOSIT_WITH_EXPIRY + 4); + auto push_word = [&](const uint8_t* w) { d.insert(d.end(), w, w + 32); }; + auto push_u = [&](uint64_t v) { + uint8_t w[32] = {0}; + for (int i = 0; i < 8; i++) w[31 - i] = (uint8_t)((v >> (8 * i)) & 0xff); + push_word(w); + }; + uint8_t vw[32] = {0}; + memcpy(vw + 12, vault, 20); + push_word(vw); // word0: vault + push_u(0); // word1: asset = native (address zero) + push_u(1000000000ULL); // word2: amount (router-ignored hint for native) + push_u(0xa0); // word3: memo offset (canonical for expiry variant) + push_u(1893456000ULL); // word4: expiry + push_u(declared_len); // word5: memo length + d.insert(d.end(), memo.begin(), memo.end()); + while (d.size() % 32 != 4) d.push_back(0); // pad memo to a 32-byte boundary + return d; +} + +// A 67-byte memo (longer than the once-hardcoded 64) must display in full +// through the memo screens, not silently truncate its trailing fields — on the +// AVALANCHE router, proving the whole confirm path is chain-scoped. +TEST(Thorchain, ConfirmThorTxAvaxLongMemoDecodesFully) { + uint8_t vault[20]; + hex20("15a18266c5331ac3a7f6bc5cdf25bcc55561b4fa", vault); + const std::string memo = + "=:ETH.ETH:0x141D9959cAe3853b035000490C03991eB70Fc4aC:323935:keep:30"; + ASSERT_EQ(memo.size(), 67u); + auto data = build_thor_deposit(vault, memo, (uint32_t)memo.size()); + + uint8_t avax[20]; + hex20(THOR_AVAX_ROUTER, avax); + EthereumSignTx msg; + make_deposit_msg(&msg, avax, data.data(), data.size(), 43114, true); + + ASSERT_TRUE(kkconfirm_preload(12, 0)); // generous; extras drain below + EXPECT_TRUE(thor_confirmThorTx((uint32_t)data.size(), &msg)); + kkconfirm_drain(); +} + +// A memo-length word claiming more bytes than are present must be REJECTED — +// otherwise the router would execute a longer memo than the device displayed +// (display-vs-execute divergence). Fail closed -> blind-sign path. +TEST(Thorchain, ConfirmThorTxRejectsOverlongDeclaredMemo) { + uint8_t vault[20]; + hex20("15a18266c5331ac3a7f6bc5cdf25bcc55561b4fa", vault); + const std::string memo = "=:ETH.ETH:0xdest:0:keep:30"; + // Declare 200 bytes while only ~26 (padded to 32) are present. + auto data = build_thor_deposit(vault, memo, 200); + + uint8_t avax[20]; + hex20(THOR_AVAX_ROUTER, avax); + EthereumSignTx msg; + make_deposit_msg(&msg, avax, data.data(), data.size(), 43114, true); + + ASSERT_TRUE(kkconfirm_preload(12, 0)); + EXPECT_FALSE(thor_confirmThorTx((uint32_t)data.size(), &msg)); + kkconfirm_drain(); } diff --git a/unittests/firmware/transaction.cpp b/unittests/firmware/transaction.cpp index bc2c792cb..c0b2076f1 100644 --- a/unittests/firmware/transaction.cpp +++ b/unittests/firmware/transaction.cpp @@ -22,27 +22,28 @@ TEST(Transaction, TaprootInputWeightIncludesWitness) { ASSERT_EQ(230U, tx_input_weight(&coin, &input)); } -TEST(Transaction, UnsupportedOmniDisclosesTheCompleteRawPayload) { - std::vector payload(220, 0x00); - memcpy(payload.data(), "omni", 4); - payload[7] = 1; // unsupported transaction type, not Simple Send +TEST(Transaction, MultisigQuorumRejectsUnsatisfiableScripts) { + MultisigRedeemScriptType multisig = MultisigRedeemScriptType_init_zero; + CoinType coin = CoinType_init_zero; + uint8_t output[512] = {0}; + uint8_t hash[32] = {0}; - size_t pages = 0; - size_t offset = 0; - while (offset < payload.size()) { - char page[BODY_CHAR_MAX]; - const size_t take = confirm_bytes_format_page( - payload.data() + offset, payload.size() - offset, page, sizeof(page)); - ASSERT_GT(take, 0u); - offset += take; - pages++; - } - ASSERT_GT(pages, 1u); + multisig.has_m = true; + multisig.m = 2; + multisig.pubkeys_count = 1; + EXPECT_FALSE(transaction_multisig_quorum_is_valid(&multisig)); + EXPECT_EQ(compile_script_multisig(&coin, &multisig, output), 0U); + EXPECT_EQ(compile_script_multisig_hash(&coin, &multisig, hash), 0U); + + multisig.m = 0; + EXPECT_FALSE(transaction_multisig_quorum_is_valid(&multisig)); + multisig.m = 16; + multisig.pubkeys_count = 16; + EXPECT_FALSE(transaction_multisig_quorum_is_valid(&multisig)); - ASSERT_TRUE(kkconfirm_preload(static_cast(pages), 0)); - EXPECT_TRUE(confirm_omni(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Confirm OMNI", payload.data(), payload.size())); - EXPECT_EQ(0, kkconfirm_drain()); + multisig.m = 2; + multisig.pubkeys_count = 3; + EXPECT_TRUE(transaction_multisig_quorum_is_valid(&multisig)); } TEST(Transaction, MultisigCompilersRejectUnsatisfiableQuorums) { 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..aed72b116 --- /dev/null +++ b/unittests/firmware/zcash.cpp @@ -0,0 +1,2204 @@ +extern "C" { +#include "keepkey/firmware/zcash.h" +#include "trezor/crypto/bignum.h" +#include "trezor/crypto/blake2b.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/pallas.h" +#include "trezor/crypto/pallas_sinsemilla.h" +#include "trezor/crypto/pallas_swu.h" +#include "trezor/crypto/redpallas.h" +#include "trezor/crypto/zcash_zip316.h" +} + +#include "gtest/gtest.h" +#include + +/* ── Pallas curve constants ──────────────────────────────────────── */ + +/* Pallas base field prime p (LE) */ +static const uint8_t PALLAS_P_LE[32] = { + 0x01, 0x00, 0x00, 0x00, 0xed, 0x30, 0x2d, 0x99, 0x1b, 0xf9, 0x4c, + 0x09, 0xfc, 0x98, 0x46, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, +}; + +/* Pallas scalar field order q (LE) */ +static const uint8_t PALLAS_Q_LE[32] = { + 0x01, 0x00, 0x00, 0x00, 0x21, 0xeb, 0x46, 0x8c, 0xdd, 0xa8, 0x94, + 0x09, 0xfc, 0x98, 0x46, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, +}; + +/* Sinsemilla primitive vectors generated with sinsemilla 0.1.0. */ +static const uint8_t SINSEMILLA_COMMIT_IVK_Q_X[32] = { + 0xf2, 0x82, 0x0f, 0x79, 0x92, 0x2f, 0xcb, 0x6b, 0x32, 0xa2, 0x28, + 0x51, 0x24, 0xcc, 0x1b, 0x42, 0xfa, 0x41, 0xa2, 0x5a, 0xb8, 0x81, + 0xcc, 0x7d, 0x11, 0xc8, 0xa9, 0x4a, 0xf1, 0x0c, 0xbc, 0x05, +}; + +static const uint8_t SINSEMILLA_COMMIT_IVK_Q_Y[32] = { + 0xbe, 0xde, 0xad, 0xcf, 0xce, 0xe5, 0x5a, 0xbe, 0xf1, 0xa5, 0x6d, + 0xc9, 0x1d, 0x35, 0xc4, 0x46, 0x4b, 0x05, 0xde, 0x20, 0x46, 0x07, + 0x59, 0xef, 0xe6, 0xbe, 0x1a, 0xd4, 0xf6, 0x4c, 0x01, 0x1b, +}; + +static const uint8_t SINSEMILLA_COMMIT_IVK_R_X[32] = { + 0x18, 0xa1, 0xf8, 0x5f, 0x6e, 0x48, 0x23, 0x98, 0xc7, 0xed, 0x1a, + 0xd3, 0xe2, 0x7f, 0x95, 0x02, 0x48, 0x89, 0x80, 0x40, 0x0a, 0x29, + 0x34, 0x16, 0x4e, 0x13, 0x70, 0x50, 0xcd, 0x2c, 0xa2, 0x25, +}; + +static const uint8_t SINSEMILLA_COMMIT_IVK_R_Y[32] = { + 0xa9, 0xdd, 0x7f, 0xe3, 0xb3, 0x93, 0xe7, 0x3f, 0xc7, 0xa6, 0x58, + 0x1b, 0xfb, 0x42, 0x44, 0x6b, 0x94, 0x57, 0x4b, 0x28, 0xc4, 0x90, + 0xc8, 0xc2, 0xeb, 0xfa, 0xa2, 0x66, 0x99, 0xd2, 0xcf, 0x29, +}; + +static const uint8_t SINSEMILLA_MSG_ONE_BIT[1] = {0x01}; +static const uint8_t SINSEMILLA_MSG_TEN_BITS[2] = {0xa5, 0x02}; +static const uint8_t SINSEMILLA_MSG_TWENTY_THREE_BITS[3] = {0x5a, 0xc3, 0x3f}; + +static const uint8_t SINSEMILLA_ZERO_BLIND[32] = {0}; +static const uint8_t SINSEMILLA_NONZERO_BLIND[32] = { + 0x21, 0x43, 0x65, 0x87, 0xa9, 0xcb, 0xed, 0x0f, 0x10, 0x32, 0x54, + 0x76, 0x98, 0xba, 0xdc, 0xfe, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef, 0xf0, 0xde, 0xbc, 0x9a, 0x78, 0x56, 0x34, 0x12, +}; + +#define SINSEMILLA_EMPTY_HASH_POINT SINSEMILLA_COMMIT_IVK_Q_X +#define SINSEMILLA_EMPTY_HASH SINSEMILLA_COMMIT_IVK_Q_X + +static const uint8_t SINSEMILLA_ONE_BIT_HASH_POINT[32] = { + 0xa6, 0x59, 0xf2, 0xb8, 0xa8, 0x92, 0xba, 0x43, 0x86, 0xca, 0x91, + 0x01, 0x6d, 0x68, 0xa8, 0xa4, 0xd2, 0x51, 0x38, 0x55, 0xaf, 0x29, + 0x15, 0x90, 0xd8, 0x2c, 0x50, 0xb9, 0x02, 0x26, 0x94, 0xb2, +}; + +static const uint8_t SINSEMILLA_ONE_BIT_HASH[32] = { + 0xa6, 0x59, 0xf2, 0xb8, 0xa8, 0x92, 0xba, 0x43, 0x86, 0xca, 0x91, + 0x01, 0x6d, 0x68, 0xa8, 0xa4, 0xd2, 0x51, 0x38, 0x55, 0xaf, 0x29, + 0x15, 0x90, 0xd8, 0x2c, 0x50, 0xb9, 0x02, 0x26, 0x94, 0x32, +}; + +static const uint8_t SINSEMILLA_TEN_BITS_HASH_POINT[32] = { + 0x16, 0xad, 0xea, 0x6c, 0xce, 0x33, 0x1c, 0xb2, 0x5c, 0xcb, 0x62, + 0x3e, 0x55, 0x61, 0x96, 0x98, 0x2c, 0xbb, 0xa0, 0x30, 0x18, 0xd9, + 0x49, 0x53, 0x5b, 0x4a, 0x56, 0x3b, 0x05, 0x73, 0x04, 0x85, +}; + +static const uint8_t SINSEMILLA_TEN_BITS_HASH[32] = { + 0x16, 0xad, 0xea, 0x6c, 0xce, 0x33, 0x1c, 0xb2, 0x5c, 0xcb, 0x62, + 0x3e, 0x55, 0x61, 0x96, 0x98, 0x2c, 0xbb, 0xa0, 0x30, 0x18, 0xd9, + 0x49, 0x53, 0x5b, 0x4a, 0x56, 0x3b, 0x05, 0x73, 0x04, 0x05, +}; + +static const uint8_t SINSEMILLA_TWENTY_THREE_BITS_HASH_POINT[32] = { + 0x1b, 0x2f, 0x70, 0x0a, 0x30, 0xc4, 0x5a, 0x5e, 0x7f, 0x98, 0x6e, + 0x13, 0xf9, 0xe8, 0xec, 0x5e, 0x95, 0xc9, 0xb1, 0xf0, 0x77, 0x3b, + 0x76, 0x39, 0x81, 0xbb, 0x59, 0x9a, 0x2e, 0xd7, 0xab, 0xb5, +}; + +static const uint8_t SINSEMILLA_TWENTY_THREE_BITS_HASH[32] = { + 0x1b, 0x2f, 0x70, 0x0a, 0x30, 0xc4, 0x5a, 0x5e, 0x7f, 0x98, 0x6e, + 0x13, 0xf9, 0xe8, 0xec, 0x5e, 0x95, 0xc9, 0xb1, 0xf0, 0x77, 0x3b, + 0x76, 0x39, 0x81, 0xbb, 0x59, 0x9a, 0x2e, 0xd7, 0xab, 0x35, +}; + +static const uint8_t SINSEMILLA_TWENTY_THREE_BITS_COMMIT_POINT[32] = { + 0x38, 0x2f, 0xe5, 0xd4, 0x2a, 0xe2, 0x0b, 0x82, 0x21, 0x6f, 0x86, + 0xb5, 0xba, 0xd0, 0xa4, 0xce, 0x14, 0x8a, 0x5f, 0x1a, 0x8e, 0xae, + 0xc0, 0x30, 0x67, 0xae, 0xaa, 0x2c, 0x67, 0xdd, 0xc1, 0x0a, +}; + +#define SINSEMILLA_TWENTY_THREE_BITS_SHORT_COMMIT \ + SINSEMILLA_TWENTY_THREE_BITS_COMMIT_POINT + +/* F4Jumble vectors from f4jumble 0.1.1 / zcash-test-vectors. */ +static const uint8_t F4JUMBLE_48_NORMAL[48] = { + 0x5d, 0x7a, 0x8f, 0x73, 0x9a, 0x2d, 0x9e, 0x94, 0x5b, 0x0c, 0xe1, 0x52, + 0xa8, 0x04, 0x9e, 0x29, 0x4c, 0x4d, 0x6e, 0x66, 0xb1, 0x64, 0x93, 0x9d, + 0xaf, 0xfa, 0x2e, 0xf6, 0xee, 0x69, 0x21, 0x48, 0x1c, 0xdd, 0x86, 0xb3, + 0xcc, 0x43, 0x18, 0xd9, 0x61, 0x4f, 0xc8, 0x20, 0x90, 0x5d, 0x04, 0x2b, +}; + +static const uint8_t F4JUMBLE_48_JUMBLED[48] = { + 0x03, 0x04, 0xd0, 0x29, 0x14, 0x1b, 0x99, 0x5d, 0xa5, 0x38, 0x7c, 0x12, + 0x59, 0x70, 0x67, 0x35, 0x04, 0xd6, 0xc7, 0x64, 0xd9, 0x1e, 0xa6, 0xc0, + 0x82, 0x12, 0x37, 0x70, 0xc7, 0x13, 0x9c, 0xcd, 0x88, 0xee, 0x27, 0x36, + 0x8c, 0xd0, 0xc0, 0x92, 0x1a, 0x04, 0x44, 0xc8, 0xe5, 0x85, 0x8d, 0x22, +}; + +static const uint8_t F4JUMBLE_64_NORMAL[64] = { + 0xb1, 0xef, 0x9c, 0xa3, 0xf2, 0x49, 0x88, 0xc7, 0xb3, 0x53, 0x42, + 0x01, 0xcf, 0xb1, 0xcd, 0x8d, 0xbf, 0x69, 0xb8, 0x25, 0x0c, 0x18, + 0xef, 0x41, 0x29, 0x4c, 0xa9, 0x79, 0x93, 0xdb, 0x54, 0x6c, 0x1f, + 0xe0, 0x1f, 0x7e, 0x9c, 0x8e, 0x36, 0xd6, 0xa5, 0xe2, 0x9d, 0x4e, + 0x30, 0xa7, 0x35, 0x94, 0xbf, 0x50, 0x98, 0x42, 0x1c, 0x69, 0x37, + 0x8a, 0xf1, 0xe4, 0x0f, 0x64, 0xe1, 0x25, 0x94, 0x6f, +}; + +static const uint8_t F4JUMBLE_64_JUMBLED[64] = { + 0x52, 0x71, 0xfa, 0x33, 0x21, 0xf3, 0xad, 0xbc, 0xfb, 0x07, 0x51, + 0x96, 0x88, 0x3d, 0x54, 0x2b, 0x43, 0x8e, 0xc6, 0x33, 0x91, 0x76, + 0x53, 0x7d, 0xaf, 0x85, 0x98, 0x41, 0xfe, 0x6a, 0x56, 0x22, 0x2b, + 0xff, 0x76, 0xd1, 0x66, 0x2b, 0x55, 0x09, 0xa9, 0xe1, 0x07, 0x9e, + 0x44, 0x6e, 0xee, 0xdd, 0x2e, 0x68, 0x3c, 0x31, 0xaa, 0xe3, 0xee, + 0x18, 0x51, 0xd7, 0x95, 0x43, 0x28, 0x52, 0x6b, 0xe1, +}; + +/* Compare two 32-byte LE values: return -1 if a < b, 0 if equal, 1 if a > b */ +static int cmp_le256(const uint8_t a[32], const uint8_t b[32]) { + for (int i = 31; i >= 0; i--) { + if (a[i] < b[i]) return -1; + if (a[i] > b[i]) return 1; + } + return 0; +} + +/* ── Reference Test Vectors ──────────────────────────────────────── */ + +/* + * Mnemonic: "all all all all all all all all all all all all" + * BIP-39 seed (PBKDF2, no passphrase), 64 bytes: + */ +static const uint8_t SEED_ALL[64] = { + 0xc7, 0x6c, 0x4a, 0xc4, 0xf4, 0xe4, 0xa0, 0x0d, 0x6b, 0x27, 0x4d, + 0x5c, 0x39, 0xc7, 0x00, 0xbb, 0x4a, 0x7d, 0xdc, 0x04, 0xfb, 0xc6, + 0xf7, 0x8e, 0x85, 0xca, 0x75, 0x00, 0x7b, 0x5b, 0x49, 0x5f, 0x74, + 0xa9, 0x04, 0x3e, 0xeb, 0x77, 0xbd, 0xd5, 0x3a, 0xa6, 0xfc, 0x3a, + 0x0e, 0x31, 0x46, 0x22, 0x70, 0x31, 0x6f, 0xa0, 0x4b, 0x8c, 0x19, + 0x11, 0x4c, 0x87, 0x98, 0x70, 0x6c, 0xd0, 0x2a, 0xc8, +}; + +/* + * Expected FVK for "all" mnemonic, account 0. + * Generated by the orchard Rust crate (authoritative ZIP-32). + */ +static const uint8_t EXPECTED_AK_ALL_0[32] = { + 0x05, 0x7a, 0xb0, 0x51, 0xd4, 0xfb, 0xb0, 0x20, 0x5d, 0x28, 0x64, + 0x8b, 0xac, 0xbc, 0x64, 0x71, 0xb5, 0x33, 0x47, 0x6c, 0x27, 0xbe, + 0xca, 0x33, 0xe5, 0xb9, 0xf5, 0x11, 0xd8, 0x55, 0x67, 0x2b, +}; + +static const uint8_t EXPECTED_NK_ALL_0[32] = { + 0x34, 0xa3, 0x5a, 0x0b, 0xda, 0x50, 0x27, 0x3b, 0x03, 0x19, 0xaf, + 0xa7, 0xa7, 0x0f, 0x86, 0xb6, 0xb1, 0x62, 0xeb, 0x31, 0x1d, 0x26, + 0x3d, 0x8f, 0x63, 0x21, 0xde, 0xf0, 0x02, 0x28, 0xba, 0x25, +}; + +static const uint8_t EXPECTED_RIVK_ALL_0[32] = { + 0x46, 0xbd, 0x2b, 0xd5, 0xe6, 0xec, 0xa5, 0xef, 0x03, 0xe1, 0x8c, + 0xd7, 0x65, 0x95, 0x51, 0x9e, 0xa9, 0x67, 0x06, 0xc5, 0x82, 0x6a, + 0x93, 0xba, 0x4d, 0xca, 0x94, 0x7d, 0x71, 0x1a, 0x7c, 0x0a, +}; + +static const uint8_t EXPECTED_IVK_ALL_0[32] = { + 0xa8, 0xe2, 0xea, 0x36, 0x48, 0x8b, 0x9e, 0xb4, 0x61, 0x47, 0x60, + 0x5b, 0xa1, 0x50, 0x40, 0x37, 0xd0, 0x88, 0x1e, 0x98, 0x1b, 0x6e, + 0x58, 0x47, 0xb9, 0xf5, 0xc1, 0xbe, 0xb5, 0xd0, 0x43, 0x35, +}; + +static const uint8_t EXPECTED_DK_ALL_0[32] = { + 0xe8, 0x52, 0xed, 0xd7, 0x82, 0xd6, 0xeb, 0x92, 0x12, 0x82, 0x21, + 0x9b, 0x8a, 0x9c, 0x38, 0x0e, 0x03, 0xfc, 0xc4, 0x76, 0x60, 0xfe, + 0x67, 0xaf, 0x1b, 0xa4, 0x77, 0x80, 0x2b, 0xb0, 0x6c, 0xe7, +}; + +static const uint8_t EXPECTED_DIVERSIFIER_ALL_0[11] = { + 0xda, 0x97, 0x30, 0x31, 0x63, 0x4a, 0x89, 0x38, 0xad, 0x1c, 0x48, +}; + +/* FF1-AES256 Orchard diversifier vectors generated with zcash-test-vectors. + * Parameters: radix = 2, n = 88, tweak = "", rounds = 10. + * Inputs and outputs are LEBS2OSP_88 byte encodings. + */ +struct OrchardFf1Vector { + uint8_t dk[32]; + uint8_t index[11]; + uint8_t diversifier[11]; +}; + +static const OrchardFf1Vector ORCHARD_FF1_VECTORS[] = { + {{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xdc, 0xe7, 0x7e, 0xbc, 0xec, 0x0a, 0x26, 0xaf, 0xd6, 0x99, 0x8c}}, + {{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x63, 0x73, 0x8a, 0xa5, 0xf7, 0xbe, 0x22, 0xe1, 0xac, 0xdc, 0x0b}}, + {{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, + 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, + 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xd7, 0x39, 0xcc, 0xc2, 0xb8, 0x4d, 0x5d, 0x1a, 0xe5, 0x4a, 0x95}}, + {{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, + 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, + 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}, + {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a}, + {0xc8, 0xff, 0x0b, 0x01, 0x96, 0x01, 0x30, 0x12, 0x76, 0x38, 0xc7}}, +}; + +static const uint8_t XMD_ABC_96[96] = { + 0x48, 0x50, 0x5e, 0x62, 0xfe, 0x0c, 0xe6, 0x64, 0xb6, 0x80, 0xf1, 0xf9, + 0xe6, 0x37, 0x43, 0x91, 0xa6, 0x09, 0x57, 0x5e, 0x53, 0x5c, 0xfd, 0x55, + 0xea, 0xd4, 0x49, 0xa4, 0x18, 0x43, 0xc7, 0x0d, 0x65, 0x3a, 0x08, 0x5d, + 0x09, 0xb1, 0x9f, 0x3f, 0x8d, 0x4d, 0x0a, 0xe4, 0x4f, 0x6a, 0xcf, 0x48, + 0xca, 0xfd, 0xb2, 0x8b, 0x8e, 0xea, 0x01, 0xe3, 0x6a, 0xf4, 0xf5, 0xfc, + 0xda, 0xcc, 0xf1, 0x45, 0x2a, 0x87, 0xc0, 0x8c, 0xc1, 0x0c, 0x9a, 0x03, + 0x7f, 0x3f, 0x03, 0x69, 0xf6, 0xb0, 0x43, 0xfb, 0xfc, 0x59, 0x81, 0xb6, + 0x0d, 0x50, 0xd7, 0xbd, 0x00, 0x4a, 0x59, 0x71, 0x3b, 0x1e, 0xcc, 0x25, +}; + +static const uint8_t SWU_0_X_LE[32] = { + 0x6e, 0x09, 0x9b, 0x51, 0x33, 0x34, 0xca, 0x85, 0xf4, 0x27, 0xa7, + 0xde, 0x25, 0x25, 0xf4, 0xf5, 0x8a, 0x9a, 0x12, 0x39, 0xb3, 0x95, + 0x52, 0xe2, 0x52, 0x6c, 0xf5, 0x34, 0xa5, 0xa6, 0xc1, 0x28, +}; + +static const uint8_t SWU_0_Y_LE[32] = { + 0x8d, 0xae, 0xc5, 0x6a, 0xee, 0xa1, 0x4f, 0x08, 0xc7, 0xb7, 0x07, + 0x02, 0x27, 0x9c, 0xd2, 0x15, 0xd3, 0x3f, 0x08, 0x27, 0x09, 0x7f, + 0x7d, 0x3c, 0xc6, 0x53, 0x66, 0xee, 0x8b, 0x65, 0xfc, 0x3b, +}; + +static const uint8_t SWU_0_Z_LE[32] = { + 0x36, 0xef, 0xcd, 0xd8, 0x0c, 0x25, 0x5f, 0x8a, 0x6f, 0x74, 0x7d, + 0xda, 0x72, 0x54, 0x11, 0x5d, 0x9d, 0xa1, 0x34, 0x85, 0x31, 0xb1, + 0x57, 0x41, 0x10, 0xdc, 0x16, 0x04, 0xa1, 0x3b, 0x4b, 0x05, +}; + +static const uint8_t SWU_1_X_LE[32] = { + 0x05, 0x15, 0x56, 0xa3, 0xa5, 0xb9, 0x13, 0x79, 0x83, 0x80, 0x82, + 0x06, 0x71, 0xb0, 0x64, 0x6d, 0x85, 0xa1, 0x26, 0xc0, 0x67, 0xe9, + 0xf5, 0x4a, 0x53, 0x76, 0xe8, 0x57, 0x59, 0xba, 0x0c, 0x01, +}; + +static const uint8_t SWU_1_Y_LE[32] = { + 0x81, 0x9c, 0xcc, 0x5d, 0x51, 0x6d, 0xfa, 0x76, 0xe9, 0x78, 0x80, + 0xb0, 0xd6, 0x14, 0x75, 0x54, 0x6a, 0xf4, 0xeb, 0x65, 0xa0, 0x65, + 0x6e, 0x7d, 0x8e, 0x11, 0xd3, 0x9c, 0x1f, 0xc6, 0x2f, 0x06, +}; + +static const uint8_t SWU_1_Z_LE[32] = { + 0x88, 0x36, 0xa7, 0x29, 0x9a, 0xbc, 0x75, 0x7c, 0x3a, 0x75, 0xe1, + 0x3d, 0x62, 0xf5, 0xcf, 0x5c, 0x60, 0x93, 0x77, 0x3e, 0x52, 0x4e, + 0x1c, 0x10, 0xc3, 0x50, 0x12, 0x31, 0x8c, 0xcb, 0x86, 0x3f, +}; + +static const uint8_t HASH_ZCASH_TEST_KEEPKEY_ORCHARD[32] = { + 0x3f, 0x2b, 0x48, 0x57, 0x9d, 0xe7, 0x3e, 0x09, 0xdb, 0x63, 0x57, + 0xfe, 0x92, 0x5d, 0x16, 0x93, 0x25, 0xde, 0xc9, 0x04, 0x66, 0xa3, + 0xfe, 0xfd, 0x6c, 0x2f, 0xe9, 0x3f, 0x2d, 0x60, 0xef, 0x33, +}; + +// Fixed non-zero T (80 bytes, per the Zcash spec's RedDSA nonce input). +// redpallas_sign_* take T from the caller, so these vectors are deterministic +// instead of depending on the RNG. T is NOT the nonce: the signer derives +// r = H*(T || rk || M), so reusing T here is safe as long as the message or +// the key differs -- which is exactly the property RepeatedT_* below asserts. +static const uint8_t kRedPallasTestT[80] = { + 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, 0x21, 0x22, 0x23, 0x24, + 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, + 0x3d, 0x3e, 0x3f, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50}; + +static const uint8_t ORCHARD_GD_EMPTY[32] = { + 0x3f, 0x90, 0xd3, 0xe5, 0x80, 0xd5, 0x6a, 0x66, 0x2b, 0x27, 0x36, + 0x91, 0xd8, 0xd1, 0xe3, 0x34, 0x75, 0x30, 0x83, 0xe9, 0xbf, 0x4c, + 0x17, 0x2e, 0x7d, 0xae, 0xfc, 0x0f, 0x06, 0x08, 0xcf, 0x97, +}; + +static const uint8_t ORCHARD_GD_ALL_ACCOUNT0_J0[32] = { + 0x26, 0x8e, 0xd9, 0xf9, 0x01, 0xfd, 0xb4, 0xe9, 0xb3, 0xf0, 0x70, + 0xd9, 0x5f, 0x1b, 0x8d, 0x98, 0x35, 0x3c, 0xb8, 0xa2, 0x02, 0xac, + 0x1c, 0x97, 0xbd, 0xb1, 0x26, 0x9f, 0x85, 0x93, 0xd6, 0x30, +}; + +static const uint8_t ORCHARD_GD_FF1_ZERO_ZERO[32] = { + 0xa4, 0x58, 0x99, 0x84, 0x3c, 0xde, 0x1f, 0xaf, 0x52, 0x42, 0x6e, + 0x27, 0xd4, 0x17, 0x96, 0xb5, 0x2a, 0xaf, 0x39, 0xf1, 0x47, 0x9c, + 0xe0, 0x69, 0xd7, 0xa9, 0xda, 0x4e, 0xef, 0xc3, 0xf8, 0x3d, +}; + +/* Orchard ivk/d/g_d/pk_d vectors generated with orchard 0.12.0. */ +struct OrchardReceiverVector { + uint8_t ivk[32]; + uint8_t diversifier[11]; + uint8_t gd[32]; + uint8_t pkd[32]; +}; + +static const OrchardReceiverVector ORCHARD_RECEIVER_VECTORS[] = { + {{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xd8, 0xe1, 0x01, 0x7d, 0x45, 0x32, 0xab, 0x65, 0xe0, 0xe5, 0x38}, + {0x7d, 0x70, 0x35, 0xca, 0x4a, 0x40, 0x9d, 0xe0, 0x65, 0x40, 0xdf, + 0xd1, 0x6e, 0x8c, 0x2d, 0xd9, 0xa9, 0x34, 0xee, 0x17, 0xfa, 0xfb, + 0x8e, 0xd0, 0xd7, 0x85, 0x6d, 0x16, 0x1c, 0x9a, 0x02, 0x2b}, + {0x7d, 0x70, 0x35, 0xca, 0x4a, 0x40, 0x9d, 0xe0, 0x65, 0x40, 0xdf, + 0xd1, 0x6e, 0x8c, 0x2d, 0xd9, 0xa9, 0x34, 0xee, 0x17, 0xfa, 0xfb, + 0x8e, 0xd0, 0xd7, 0x85, 0x6d, 0x16, 0x1c, 0x9a, 0x02, 0x2b}}, + {{0x42, 0x7a, 0x1d, 0xb3, 0x94, 0x6f, 0x20, 0xe5, 0x88, 0x30, 0xc2, + 0x91, 0x76, 0x11, 0x5d, 0x04, 0xf8, 0xbc, 0x9a, 0x21, 0x0e, 0x73, + 0xd5, 0x4c, 0x06, 0x9b, 0xa8, 0x17, 0x2e, 0x45, 0x00, 0x10}, + {0xe3, 0x63, 0x1b, 0x5e, 0xdd, 0x66, 0x95, 0xf0, 0xf0, 0x0d, 0x8d}, + {0xe7, 0xb6, 0x5d, 0xda, 0x4b, 0xc5, 0x39, 0xc0, 0xf4, 0x0c, 0x6a, + 0xdf, 0xaa, 0x41, 0xaa, 0x11, 0xd2, 0xf5, 0x27, 0xc8, 0x8a, 0xd0, + 0x10, 0xec, 0xb5, 0xe3, 0x8c, 0xbe, 0x38, 0x18, 0xdd, 0x31}, + {0x36, 0xc5, 0x49, 0x3f, 0x2b, 0x53, 0xaf, 0x23, 0x7b, 0x86, 0x5a, + 0xe1, 0x17, 0xc3, 0x05, 0x14, 0x8b, 0x78, 0xb2, 0x10, 0x84, 0x7c, + 0x86, 0xa5, 0xce, 0x24, 0xfa, 0x12, 0xa9, 0x1f, 0xf5, 0x87}}, + {{0xfe, 0xff, 0xff, 0xff, 0x38, 0x6d, 0x78, 0x34, 0xad, 0x14, 0x19, + 0xe4, 0x0b, 0x35, 0x2c, 0x99, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f}, + {0x65, 0x92, 0x89, 0x70, 0xbe, 0x78, 0x36, 0x96, 0xe0, 0x2f, 0xd1}, + {0xc9, 0xb4, 0xb5, 0x0a, 0x61, 0x9d, 0xc3, 0x4c, 0x60, 0xd4, 0xa8, + 0x30, 0x0d, 0x56, 0x60, 0x12, 0x77, 0xd7, 0x02, 0xa7, 0x5e, 0xb5, + 0xcf, 0xe1, 0x77, 0x22, 0xa7, 0x1d, 0xb7, 0x3f, 0x36, 0x32}, + {0x17, 0xcb, 0x58, 0x55, 0x9a, 0xf4, 0xd2, 0xcc, 0x6e, 0x1f, 0x24, + 0xa7, 0xe5, 0xab, 0x4c, 0x83, 0x33, 0x3c, 0x25, 0x16, 0xd3, 0x64, + 0x00, 0x6f, 0x9c, 0xee, 0x24, 0x70, 0x3c, 0xe4, 0xfc, 0xba}}, +}; + +/* Orchard ak/nk/rivk -> ivk vectors generated with orchard 0.12.0. */ +struct OrchardIvkVector { + uint8_t ak[32]; + uint8_t nk[32]; + uint8_t rivk[32]; + uint8_t ivk[32]; +}; + +static const OrchardIvkVector ORCHARD_IVK_VECTORS[] = { + {{0x87, 0x77, 0xe2, 0x15, 0x10, 0x1d, 0xf4, 0x5a, 0xa4, 0x68, 0xbb, + 0x10, 0xb2, 0xf9, 0x3f, 0xfe, 0x08, 0xa2, 0xf7, 0x9e, 0xbf, 0xf0, + 0x95, 0xaa, 0xeb, 0x74, 0x73, 0xc7, 0x71, 0x34, 0x96, 0x21}, + {0xbb, 0xca, 0x15, 0x2c, 0xfb, 0xf9, 0x81, 0x18, 0x19, 0xcc, 0x62, + 0x44, 0x34, 0xd1, 0x23, 0x75, 0x77, 0xc1, 0x38, 0x05, 0xcc, 0x3d, + 0xed, 0x44, 0x4e, 0x75, 0x5a, 0x6b, 0x78, 0xfa, 0xcd, 0x16}, + {0x8c, 0xa7, 0xfb, 0xba, 0x26, 0x47, 0x0f, 0xea, 0x0b, 0x10, 0xd3, + 0x0d, 0xb2, 0x73, 0x66, 0xec, 0x65, 0x04, 0x0c, 0x72, 0xa0, 0x9a, + 0xd8, 0x42, 0x58, 0x88, 0xef, 0x26, 0xf1, 0xc0, 0x79, 0x3f}, + {0xa1, 0xf8, 0x75, 0x87, 0x29, 0x73, 0xea, 0x49, 0x2d, 0xe3, 0xbe, + 0x5c, 0xce, 0xcf, 0xe5, 0x56, 0x79, 0x10, 0x24, 0x4c, 0xb6, 0x02, + 0x99, 0x4c, 0x58, 0x00, 0xf6, 0x8c, 0x64, 0x38, 0xb9, 0x1b}}, + {{0x6e, 0xbb, 0x83, 0x3c, 0x1d, 0x2f, 0x84, 0x33, 0x08, 0x0a, 0xbc, + 0xea, 0xbe, 0x47, 0x90, 0x60, 0x97, 0xf9, 0x06, 0x78, 0xd6, 0x03, + 0xf5, 0x77, 0xd0, 0x48, 0x6c, 0x91, 0x11, 0x73, 0x7b, 0x07}, + {0xf2, 0x26, 0xa3, 0xf8, 0x79, 0xeb, 0xe2, 0x1a, 0xbf, 0xaf, 0xcc, + 0xb6, 0xc5, 0x21, 0xca, 0x74, 0x9e, 0x63, 0xac, 0x17, 0xfd, 0x2c, + 0xd1, 0x78, 0x70, 0xaa, 0x72, 0xde, 0x12, 0xd8, 0x33, 0x0d}, + {0x04, 0x7c, 0x00, 0xab, 0x5e, 0x0f, 0xec, 0xa6, 0x1a, 0x46, 0x18, + 0x58, 0xbb, 0x0b, 0x15, 0xd5, 0x5f, 0x29, 0x76, 0x3a, 0x0a, 0x28, + 0x28, 0x25, 0xac, 0xeb, 0xd5, 0x86, 0x98, 0x93, 0x7d, 0x24}, + {0xa1, 0x75, 0x8f, 0x83, 0xad, 0xbd, 0x24, 0x89, 0x87, 0xc3, 0x6b, + 0xbf, 0x52, 0x41, 0xc1, 0x29, 0x9e, 0xfa, 0x96, 0xf2, 0x4c, 0x8c, + 0xfb, 0xb5, 0x51, 0x17, 0x23, 0x90, 0x9c, 0xc1, 0xe2, 0x02}}, + {{0xa4, 0x1c, 0xc0, 0xc3, 0x80, 0x0f, 0xf8, 0x9a, 0x88, 0xd7, 0xae, + 0x02, 0xff, 0x33, 0x6f, 0xdb, 0xd5, 0xbc, 0xe8, 0x9d, 0x9e, 0x8d, + 0xd4, 0xeb, 0x27, 0x8b, 0x4c, 0xd5, 0xc3, 0x7e, 0xc7, 0x20}, + {0x41, 0x5e, 0x75, 0x22, 0x27, 0xcb, 0x69, 0x65, 0x2e, 0x2a, 0xfa, + 0x94, 0x81, 0x6f, 0x63, 0x0d, 0xce, 0xc1, 0xac, 0xdf, 0x3c, 0x3f, + 0xb0, 0x2e, 0x1e, 0x6b, 0x04, 0x6e, 0x12, 0xa4, 0x31, 0x11}, + {0x92, 0x76, 0xa5, 0xb7, 0x55, 0xa1, 0x54, 0x63, 0xab, 0x59, 0xf0, + 0xe7, 0x22, 0x1f, 0x65, 0x80, 0x65, 0x7c, 0x05, 0x3f, 0xdb, 0x74, + 0x40, 0x12, 0xb3, 0xc1, 0x64, 0x8c, 0x75, 0x78, 0xd1, 0x22}, + {0xa8, 0x4f, 0x85, 0xd1, 0x57, 0xba, 0x71, 0x66, 0x5b, 0x31, 0x0b, + 0xd2, 0x12, 0x15, 0xad, 0x58, 0x82, 0x3b, 0x29, 0x8f, 0x44, 0x98, + 0xd5, 0x0d, 0x63, 0xad, 0xc9, 0x4d, 0x34, 0xeb, 0x93, 0x0a}}, +}; + +/* Orchard raw receiver vectors generated with orchard 0.12.0. */ +struct OrchardReceiverAssemblyVector { + const uint8_t* ak; + const uint8_t* nk; + const uint8_t* rivk; + const uint8_t* dk; + uint8_t index[11]; + uint8_t receiver[43]; +}; + +static const uint8_t ORCHARD_ASSEMBLY_DK_2[32] = { + 0x6c, 0x50, 0x3c, 0x95, 0x19, 0x0a, 0x74, 0x1d, 0x5f, 0x54, 0x87, + 0x59, 0xeb, 0x46, 0x4a, 0xa5, 0x36, 0x3b, 0xcd, 0xbc, 0x91, 0xa6, + 0x98, 0x7b, 0xd0, 0x7f, 0x67, 0x7b, 0x37, 0x59, 0xc2, 0x08, +}; + +static const uint8_t ORCHARD_ASSEMBLY_DK_3[32] = { + 0x41, 0xb7, 0x06, 0x56, 0xe2, 0x02, 0xaa, 0xcd, 0x0d, 0x92, 0x3b, + 0x7c, 0x95, 0xc0, 0xfc, 0x17, 0xa2, 0x13, 0xaf, 0x97, 0x3a, 0xd4, + 0xf8, 0x3f, 0xeb, 0x47, 0xdd, 0xf8, 0x3b, 0xb1, 0x68, 0xe4, +}; + +static const OrchardReceiverAssemblyVector ORCHARD_RECEIVER_ASSEMBLY_VECTORS[] = + { + {EXPECTED_AK_ALL_0, + EXPECTED_NK_ALL_0, + EXPECTED_RIVK_ALL_0, + EXPECTED_DK_ALL_0, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xda, 0x97, 0x30, 0x31, 0x63, 0x4a, 0x89, 0x38, 0xad, 0x1c, 0x48, + 0x0f, 0x97, 0x87, 0x80, 0x69, 0x3e, 0xc7, 0x70, 0x9b, 0xa5, 0xca, + 0xf5, 0x8d, 0x8a, 0x7e, 0xb9, 0x45, 0x58, 0x6c, 0xbe, 0xd6, 0x45, + 0x52, 0x0f, 0x17, 0x38, 0x74, 0x37, 0xbc, 0xfd, 0xc2, 0x16}}, + {EXPECTED_AK_ALL_0, + EXPECTED_NK_ALL_0, + EXPECTED_RIVK_ALL_0, + EXPECTED_DK_ALL_0, + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xbb, 0x0c, 0x08, 0xc2, 0x0f, 0x07, 0x8f, 0x59, 0x89, 0x39, 0x1c, + 0x36, 0x91, 0xb8, 0x97, 0xea, 0xcf, 0x28, 0x9a, 0x02, 0x02, 0x2f, + 0x45, 0xb3, 0xb1, 0x3f, 0x5f, 0xa1, 0xaa, 0xd5, 0x95, 0x9f, 0xaa, + 0x29, 0x01, 0x56, 0xc2, 0x40, 0xb8, 0xae, 0x1c, 0x07, 0x25}}, + {ORCHARD_IVK_VECTORS[1].ak, + ORCHARD_IVK_VECTORS[1].nk, + ORCHARD_IVK_VECTORS[1].rivk, + ORCHARD_ASSEMBLY_DK_2, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0x45, 0x59, 0x02, 0x9c, 0x0b, 0x5d, 0xbf, 0x94, 0x1c, 0x5a, 0xd1, + 0x81, 0xa5, 0xfe, 0x8f, 0x45, 0xb3, 0x46, 0x30, 0xf2, 0x9d, 0x0c, + 0x8d, 0xd8, 0xdc, 0x1c, 0xc3, 0x57, 0x33, 0x86, 0xf4, 0x16, 0xcb, + 0x32, 0x41, 0x33, 0x15, 0x6d, 0x72, 0x3d, 0xf5, 0xe6, 0x2d}}, + {ORCHARD_IVK_VECTORS[2].ak, + ORCHARD_IVK_VECTORS[2].nk, + ORCHARD_IVK_VECTORS[2].rivk, + ORCHARD_ASSEMBLY_DK_3, + {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, + {0xcb, 0xd5, 0xfc, 0x34, 0xc7, 0x26, 0x1d, 0x3f, 0xdb, 0x23, 0xd2, + 0xb8, 0x14, 0xad, 0xcb, 0xfc, 0x2d, 0x8e, 0x17, 0x2c, 0x79, 0xee, + 0x8e, 0x2e, 0x3f, 0xe7, 0xd8, 0xb1, 0xda, 0xd5, 0xb6, 0x67, 0x8e, + 0x22, 0x6c, 0xa7, 0xa3, 0x99, 0x6b, 0x1e, 0x62, 0x4f, 0x35}}, +}; + +/* Orchard-only unified address vectors generated with zcash_address 0.10.1. */ +static const char ORCHARD_ONLY_UA_MAINNET_0[] = + "u1uzslnccvrw4r2y2kgjz7fm477xcnzge9z45scm4e6l6c63ren0ru29teedxw5vxu7c8xch" + "p3ec2pu3wkgldc5zphwtm4w3fchcwrl26c"; +static const char ORCHARD_ONLY_UA_TESTNET_0[] = + "utest1deyej6qvxfnewfhgdc987fgpq407u374vzvtvgjuv86vj0gs9tcej04hk7nr5msm5fzg" + "335j70mddjnqj48zjsj5zl2362w4zcd2ks8c"; +static const char ORCHARD_ONLY_UA_MAINNET_1[] = + "u19whtuck5ry2d53xa348ecvfgsudtk8vt2qexe9w50lzwkzxx3lxcn60ztjfe2m33e0jz4xd" + "4kxe3yhz65xq9jzvjcrtjrhvrf5mzat26"; +static const char ORCHARD_ONLY_UA_TESTNET_1[] = + "utest1ff5jzt4pr5hzgz8688052pjtq0plzk3va9hgssprp3ps2lluhy3u6ej7eh3njfgqp3" + "ar4lm8muxu352nmuqt2c5n92w4ngf44qwtjl0p"; + +/* ── ZIP-32 Derivation Tests ─────────────────────────────────────── */ + +TEST(Zcash, DeriveOrchardKeys_ReferenceVector_Account0) { + /* + * Reference vector test: derive keys from known "all" mnemonic seed + * and compare against values from the orchard Rust crate. + */ + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + /* nk must match reference */ + EXPECT_TRUE(memcmp(keys.nk, EXPECTED_NK_ALL_0, 32) == 0) + << "nk mismatch for all-mnemonic account 0"; + + EXPECT_TRUE(memcmp(keys.ak, EXPECTED_AK_ALL_0, 32) == 0) + << "cached ak mismatch for all-mnemonic account 0"; + + /* rivk must match reference */ + EXPECT_TRUE(memcmp(keys.rivk, EXPECTED_RIVK_ALL_0, 32) == 0) + << "rivk mismatch for all-mnemonic account 0"; + + uint8_t ivk[32]; + ASSERT_TRUE( + zcash_orchard_derive_ivk(EXPECTED_AK_ALL_0, keys.nk, keys.rivk, ivk)); + EXPECT_TRUE(memcmp(ivk, EXPECTED_IVK_ALL_0, 32) == 0) + << "ivk mismatch for all-mnemonic account 0"; + + EXPECT_TRUE(memcmp(keys.dk, EXPECTED_DK_ALL_0, 32) == 0) + << "dk mismatch for all-mnemonic account 0"; + + uint8_t diversifier[11]; + uint8_t index0[11] = {0}; + ASSERT_TRUE(zcash_orchard_derive_diversifier(keys.dk, index0, diversifier)); + EXPECT_TRUE(memcmp(diversifier, EXPECTED_DIVERSIFIER_ALL_0, 11) == 0) + << "default diversifier mismatch for all-mnemonic account 0"; + + /* Compute ak = [ask]*G and verify against reference */ + bignum256 ask_scalar; + bn_read_le(keys.ask, &ask_scalar); + curve_point ak_point; + redpallas_scalar_mult_spendauth_G(&ask_scalar, &ak_point); + + uint8_t ak_bytes[32]; + bignum256 x_copy; + bn_copy(&ak_point.x, &x_copy); + bn_write_le(&x_copy, ak_bytes); + EXPECT_EQ(ak_bytes[31] & 0x80, 0) + << "ak sign bit must be 0 after ask normalization"; + + EXPECT_TRUE(memcmp(ak_bytes, EXPECTED_AK_ALL_0, 32) == 0) + << "ak mismatch for all-mnemonic account 0"; + + memzero(diversifier, sizeof(diversifier)); + memzero(ivk, sizeof(ivk)); + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, DeriveOrchardKeys_DifferentAccounts) { + ZcashOrchardKeys keys0, keys1; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys0)); + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 1, &keys1)); + + /* Different accounts must produce different spending keys */ + EXPECT_TRUE(memcmp(keys0.sk, keys1.sk, 32) != 0) + << "Account 0 and 1 must have different sk"; + EXPECT_TRUE(memcmp(keys0.ask, keys1.ask, 32) != 0) + << "Account 0 and 1 must have different ask"; + EXPECT_TRUE(memcmp(keys0.nk, keys1.nk, 32) != 0) + << "Account 0 and 1 must have different nk"; + + memzero(&keys0, sizeof(keys0)); + memzero(&keys1, sizeof(keys1)); +} + +TEST(Zcash, DeriveOrchardKeys_DifferentSeeds) { + /* Use a different seed (all zeros) */ + uint8_t zero_seed[64]; + memset(zero_seed, 0, sizeof(zero_seed)); + + ZcashOrchardKeys keys_all, keys_zero; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys_all)); + ASSERT_TRUE(zcash_derive_orchard_keys(zero_seed, 64, 0, &keys_zero)); + + EXPECT_TRUE(memcmp(keys_all.sk, keys_zero.sk, 32) != 0) + << "Different seeds must produce different sk"; + + memzero(&keys_all, sizeof(keys_all)); + memzero(&keys_zero, sizeof(keys_zero)); +} + +TEST(Zcash, DeriveOrchardKeys_Deterministic) { + ZcashOrchardKeys keys1, keys2; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys1)); + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys2)); + + EXPECT_TRUE(memcmp(keys1.sk, keys2.sk, 32) == 0); + EXPECT_TRUE(memcmp(keys1.ask, keys2.ask, 32) == 0); + EXPECT_TRUE(memcmp(keys1.ak, keys2.ak, 32) == 0); + EXPECT_TRUE(memcmp(keys1.nk, keys2.nk, 32) == 0); + EXPECT_TRUE(memcmp(keys1.rivk, keys2.rivk, 32) == 0); + EXPECT_TRUE(memcmp(keys1.dk, keys2.dk, 32) == 0); + + memzero(&keys1, sizeof(keys1)); + memzero(&keys2, sizeof(keys2)); +} + +TEST(Zcash, DeriveOrchardKeys_DerivesDiversifierKey) { + ZcashOrchardKeys keys0, keys1; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys0)); + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 1, &keys1)); + + uint8_t zero[32] = {0}; + EXPECT_TRUE(memcmp(keys0.dk, zero, 32) != 0) + << "Diversifier key must be populated"; + EXPECT_TRUE(memcmp(keys0.dk, keys1.dk, 32) != 0) + << "Different accounts must produce different diversifier keys"; + + memzero(&keys0, sizeof(keys0)); + memzero(&keys1, sizeof(keys1)); +} + +TEST(Zcash, OrchardDiversifier_FF1ReferenceVectors) { + for (const auto& tv : ORCHARD_FF1_VECTORS) { + uint8_t actual[11]; + ASSERT_TRUE(zcash_orchard_derive_diversifier(tv.dk, tv.index, actual)); + EXPECT_TRUE(memcmp(actual, tv.diversifier, sizeof(actual)) == 0); + memzero(actual, sizeof(actual)); + } +} + +TEST(Zcash, OrchardDiversifier_DeterministicAndDistinct) { + const uint8_t index0[11] = {0}; + const uint8_t index1[11] = {1}; + uint8_t d0[11], d0_again[11], d1[11]; + + ASSERT_TRUE( + zcash_orchard_derive_diversifier(ORCHARD_FF1_VECTORS[2].dk, index0, d0)); + ASSERT_TRUE(zcash_orchard_derive_diversifier(ORCHARD_FF1_VECTORS[2].dk, + index0, d0_again)); + ASSERT_TRUE( + zcash_orchard_derive_diversifier(ORCHARD_FF1_VECTORS[2].dk, index1, d1)); + + EXPECT_TRUE(memcmp(d0, d0_again, sizeof(d0)) == 0); + EXPECT_TRUE(memcmp(d0, d1, sizeof(d0)) != 0); + + memzero(d0, sizeof(d0)); + memzero(d0_again, sizeof(d0_again)); + memzero(d1, sizeof(d1)); +} + +TEST(Zcash, ExpandMessageXmdBlake2b_ReferenceVector) { + const uint8_t msg[] = {'a', 'b', 'c'}; + const uint8_t dst[] = "z.cash:test-pallas_XMD:BLAKE2b_SSWU_RO_"; + uint8_t out[96]; + + ASSERT_EQ(pallas_expand_message_xmd_blake2b( + msg, sizeof(msg), dst, sizeof(dst) - 1, out, sizeof(out)), + 0); + EXPECT_TRUE(memcmp(out, XMD_ABC_96, sizeof(out)) == 0); +} + +static void expect_bn_le(const bignum256* value, const uint8_t expected[32]) { + uint8_t actual[32]; + bignum256 tmp; + bn_copy(value, &tmp); + bn_write_le(&tmp, actual); + EXPECT_TRUE(memcmp(actual, expected, 32) == 0); + memzero(actual, sizeof(actual)); + memzero(&tmp, sizeof(tmp)); +} + +static void load_curve_point_from_xy(const uint8_t x[32], const uint8_t y[32], + curve_point* out) { + bn_read_le(x, &out->x); + bn_read_le(y, &out->y); + bn_normalize(&out->x); + bn_normalize(&out->y); +} + +TEST(Zcash, PallasSimpleSwu_ReferenceVectors) { + uint8_t u0[32] = {0}; + uint8_t u1[32] = {0}; + u1[0] = 1; + + pallas_jacobian_point p0, p1; + ASSERT_EQ(pallas_map_to_curve_simple_swu(u0, &p0), 0); + ASSERT_EQ(pallas_map_to_curve_simple_swu(u1, &p1), 0); + + expect_bn_le(&p0.x, SWU_0_X_LE); + expect_bn_le(&p0.y, SWU_0_Y_LE); + expect_bn_le(&p0.z, SWU_0_Z_LE); + expect_bn_le(&p1.x, SWU_1_X_LE); + expect_bn_le(&p1.y, SWU_1_Y_LE); + expect_bn_le(&p1.z, SWU_1_Z_LE); + + memzero(&p0, sizeof(p0)); + memzero(&p1, sizeof(p1)); +} + +TEST(Zcash, PallasGroupHash_RegressionVector) { + const uint8_t msg[] = "KeepKey Orchard test vector"; + 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_KEEPKEY_ORCHARD, sizeof(encoded)) == 0); + + memzero(&p, sizeof(p)); + memzero(encoded, sizeof(encoded)); +} + +struct SinsemillaPrimitiveVector { + const uint8_t* msg; + size_t msg_bits; + const uint8_t* blind; + const uint8_t* hash_point; + const uint8_t* hash; + const uint8_t* commit_point; + const uint8_t* short_commit; +}; + +TEST(Zcash, SinsemillaPrimitives_ReferenceVectors) { + const SinsemillaPrimitiveVector vectors[] = { + {nullptr, 0, SINSEMILLA_ZERO_BLIND, SINSEMILLA_EMPTY_HASH_POINT, + SINSEMILLA_EMPTY_HASH, SINSEMILLA_EMPTY_HASH_POINT, + SINSEMILLA_EMPTY_HASH}, + {SINSEMILLA_MSG_ONE_BIT, 1, SINSEMILLA_ZERO_BLIND, + SINSEMILLA_ONE_BIT_HASH_POINT, SINSEMILLA_ONE_BIT_HASH, + SINSEMILLA_ONE_BIT_HASH_POINT, SINSEMILLA_ONE_BIT_HASH}, + {SINSEMILLA_MSG_TEN_BITS, 10, SINSEMILLA_ZERO_BLIND, + SINSEMILLA_TEN_BITS_HASH_POINT, SINSEMILLA_TEN_BITS_HASH, + SINSEMILLA_TEN_BITS_HASH_POINT, SINSEMILLA_TEN_BITS_HASH}, + {SINSEMILLA_MSG_TWENTY_THREE_BITS, 23, SINSEMILLA_NONZERO_BLIND, + SINSEMILLA_TWENTY_THREE_BITS_HASH_POINT, + SINSEMILLA_TWENTY_THREE_BITS_HASH, + SINSEMILLA_TWENTY_THREE_BITS_COMMIT_POINT, + SINSEMILLA_TWENTY_THREE_BITS_SHORT_COMMIT}, + }; + + curve_point q, r; + load_curve_point_from_xy(SINSEMILLA_COMMIT_IVK_Q_X, SINSEMILLA_COMMIT_IVK_Q_Y, + &q); + load_curve_point_from_xy(SINSEMILLA_COMMIT_IVK_R_X, SINSEMILLA_COMMIT_IVK_R_Y, + &r); + + for (const auto& vector : vectors) { + curve_point hash_point, commit_point; + uint8_t encoded[32]; + uint8_t hash[32]; + uint8_t short_commit[32]; + + ASSERT_EQ(pallas_sinsemilla_hash_to_point(&q, vector.msg, vector.msg_bits, + &hash_point), + 0); + pallas_point_encode(&hash_point, encoded); + EXPECT_TRUE(memcmp(encoded, vector.hash_point, sizeof(encoded)) == 0); + + ASSERT_EQ(pallas_sinsemilla_hash(&q, vector.msg, vector.msg_bits, hash), 0); + EXPECT_TRUE(memcmp(hash, vector.hash, sizeof(hash)) == 0); + + ASSERT_EQ(pallas_sinsemilla_commit(&q, &r, vector.msg, vector.msg_bits, + vector.blind, &commit_point), + 0); + pallas_point_encode(&commit_point, encoded); + EXPECT_TRUE(memcmp(encoded, vector.commit_point, sizeof(encoded)) == 0); + + ASSERT_EQ( + pallas_sinsemilla_short_commit(&q, &r, vector.msg, vector.msg_bits, + vector.blind, short_commit), + 0); + EXPECT_TRUE( + memcmp(short_commit, vector.short_commit, sizeof(short_commit)) == 0); + + memzero(&hash_point, sizeof(hash_point)); + memzero(&commit_point, sizeof(commit_point)); + memzero(encoded, sizeof(encoded)); + memzero(hash, sizeof(hash)); + memzero(short_commit, sizeof(short_commit)); + } + + memzero(&q, sizeof(q)); + memzero(&r, sizeof(r)); +} + +TEST(Zcash, SinsemillaPrimitives_RejectInvalidInputs) { + curve_point q, r, out; + load_curve_point_from_xy(SINSEMILLA_COMMIT_IVK_Q_X, SINSEMILLA_COMMIT_IVK_Q_Y, + &q); + load_curve_point_from_xy(SINSEMILLA_COMMIT_IVK_R_X, SINSEMILLA_COMMIT_IVK_R_Y, + &r); + + EXPECT_EQ( + pallas_sinsemilla_hash_to_point(&q, SINSEMILLA_MSG_ONE_BIT, + PALLAS_SINSEMILLA_MAX_BITS + 1, &out), + -1); + + curve_point identity = {}; + EXPECT_EQ(pallas_sinsemilla_hash_to_point(&identity, SINSEMILLA_MSG_ONE_BIT, + 1, &out), + -1); + EXPECT_EQ(pallas_sinsemilla_commit(&q, &identity, SINSEMILLA_MSG_ONE_BIT, 1, + SINSEMILLA_ZERO_BLIND, &out), + -1); + EXPECT_EQ(pallas_sinsemilla_commit(&q, &r, SINSEMILLA_MSG_ONE_BIT, 1, + PALLAS_Q_LE, &out), + -1); + + memzero(&q, sizeof(q)); + memzero(&r, sizeof(r)); + memzero(&out, sizeof(out)); + memzero(&identity, sizeof(identity)); +} + +TEST(Zcash, Zip316F4Jumble_ReferenceVectors) { + uint8_t buf48[sizeof(F4JUMBLE_48_NORMAL)]; + memcpy(buf48, F4JUMBLE_48_NORMAL, sizeof(buf48)); + ASSERT_EQ(zcash_zip316_f4jumble(buf48, sizeof(buf48)), 0); + EXPECT_TRUE(memcmp(buf48, F4JUMBLE_48_JUMBLED, sizeof(buf48)) == 0); + ASSERT_EQ(zcash_zip316_f4jumble_inv(buf48, sizeof(buf48)), 0); + EXPECT_TRUE(memcmp(buf48, F4JUMBLE_48_NORMAL, sizeof(buf48)) == 0); + + uint8_t buf64[sizeof(F4JUMBLE_64_NORMAL)]; + memcpy(buf64, F4JUMBLE_64_NORMAL, sizeof(buf64)); + ASSERT_EQ(zcash_zip316_f4jumble(buf64, sizeof(buf64)), 0); + EXPECT_TRUE(memcmp(buf64, F4JUMBLE_64_JUMBLED, sizeof(buf64)) == 0); + ASSERT_EQ(zcash_zip316_f4jumble_inv(buf64, sizeof(buf64)), 0); + EXPECT_TRUE(memcmp(buf64, F4JUMBLE_64_NORMAL, sizeof(buf64)) == 0); + + memzero(buf48, sizeof(buf48)); + memzero(buf64, sizeof(buf64)); +} + +TEST(Zcash, Zip316F4Jumble_RejectsInvalidLengths) { + uint8_t too_short[ZCASH_ZIP316_F4JUMBLE_MIN_LEN - 1] = {0}; + EXPECT_EQ(zcash_zip316_f4jumble(too_short, sizeof(too_short)), -1); + EXPECT_EQ(zcash_zip316_f4jumble_inv(too_short, sizeof(too_short)), -1); + EXPECT_EQ(zcash_zip316_f4jumble(nullptr, ZCASH_ZIP316_F4JUMBLE_MIN_LEN), -1); +} + +TEST(Zcash, Zip316OrchardOnlyUnifiedAddress_ReferenceVectors) { + char address[ZCASH_ZIP316_ORCHARD_ONLY_MAX_ADDRESS_SIZE]; + + ASSERT_EQ(zcash_zip316_encode_orchard_unified_address( + "u", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, address, + sizeof(address)), + 0); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_MAINNET_0); + + ASSERT_EQ(zcash_zip316_encode_orchard_unified_address( + "utest", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, address, + sizeof(address)), + 0); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_0); + + ASSERT_EQ(zcash_zip316_encode_orchard_unified_address( + "u", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[1].receiver, address, + sizeof(address)), + 0); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_MAINNET_1); + + ASSERT_EQ(zcash_zip316_encode_orchard_unified_address( + "utest", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[1].receiver, address, + sizeof(address)), + 0); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_1); + + memzero(address, sizeof(address)); +} + +TEST(Zcash, Zip316OrchardOnlyUnifiedAddress_RejectsInvalidInputs) { + char address[ZCASH_ZIP316_ORCHARD_ONLY_MAX_ADDRESS_SIZE]; + char too_small[16]; + char long_hrp[ZCASH_ZIP316_PADDING_LEN + 2]; + memset(long_hrp, 'a', sizeof(long_hrp) - 1); + long_hrp[sizeof(long_hrp) - 1] = 0; + + EXPECT_EQ(zcash_zip316_encode_orchard_unified_address( + "u", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, too_small, + sizeof(too_small)), + -1); + EXPECT_EQ(zcash_zip316_encode_orchard_unified_address( + long_hrp, ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, + address, sizeof(address)), + -1); + EXPECT_EQ(zcash_zip316_encode_orchard_unified_address( + "U", ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].receiver, address, + sizeof(address)), + -1); + + memzero(address, sizeof(address)); + memzero(too_small, sizeof(too_small)); + memzero(long_hrp, sizeof(long_hrp)); +} + +TEST(Zcash, OrchardUnifiedAddress_FromDerivedKeys) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + char address[ZCASH_ZIP316_ORCHARD_ONLY_MAX_ADDRESS_SIZE]; + const uint8_t index0[11] = {0}; + const uint8_t index1[11] = {1}; + + ASSERT_TRUE(zcash_orchard_derive_unified_address(&keys, index0, "u", address, + sizeof(address))); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_MAINNET_0); + + ASSERT_TRUE(zcash_orchard_derive_unified_address(&keys, index0, "utest", + address, sizeof(address))); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_0); + + ASSERT_TRUE(zcash_orchard_derive_unified_address(&keys, index1, "u", address, + sizeof(address))); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_MAINNET_1); + + ASSERT_TRUE(zcash_orchard_derive_unified_address(&keys, index1, "utest", + address, sizeof(address))); + EXPECT_STREQ(address, ORCHARD_ONLY_UA_TESTNET_1); + + memzero(address, sizeof(address)); + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, OrchardUnifiedAddress_RejectsInvalidInputs) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + char address[ZCASH_ZIP316_ORCHARD_ONLY_MAX_ADDRESS_SIZE]; + char too_small[16]; + const uint8_t index0[11] = {0}; + + EXPECT_FALSE(zcash_orchard_derive_unified_address(nullptr, index0, "u", + address, sizeof(address))); + EXPECT_FALSE(zcash_orchard_derive_unified_address(&keys, nullptr, "u", + address, sizeof(address))); + EXPECT_FALSE(zcash_orchard_derive_unified_address(&keys, index0, nullptr, + address, sizeof(address))); + EXPECT_FALSE(zcash_orchard_derive_unified_address(&keys, index0, "u", nullptr, + sizeof(address))); + EXPECT_FALSE(zcash_orchard_derive_unified_address( + &keys, index0, "u", too_small, sizeof(too_small))); + + memzero(address, sizeof(address)); + memzero(too_small, sizeof(too_small)); + memzero(&keys, sizeof(keys)); +} + +struct OrchardNoteProgressCapture { + uint32_t calls = 0; + uint32_t last = 0; + uint32_t total = 0; + bool monotonic = true; +}; + +static void capture_orchard_note_progress(uint32_t completed, uint32_t total, + void* context) { + auto* capture = static_cast(context); + if (capture->calls > 0 && completed < capture->last) { + capture->monotonic = false; + } + capture->calls++; + capture->last = completed; + capture->total = total; +} + +TEST(Zcash, OrchardNoteCommitment_KnownVectorAndProgress) { + const uint8_t recipient[ZCASH_ORCHARD_RAW_RECEIVER_SIZE] = { + 0x3c, 0x15, 0x0e, 0x60, 0x98, 0xb8, 0x61, 0x71, 0x6c, 0xc7, 0xf6, + 0x28, 0x35, 0xf6, 0x9f, 0xeb, 0x30, 0x21, 0x93, 0xc9, 0x26, 0x60, + 0x44, 0x4f, 0x26, 0x62, 0x4f, 0xd1, 0x3e, 0x00, 0xea, 0x7a, 0xc7, + 0x74, 0xcd, 0x55, 0x07, 0x4d, 0x63, 0x67, 0xef, 0xef, 0x37}; + const uint64_t value = 12345678; + const uint8_t rho[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00}; + const uint8_t rseed[32] = {0xca, 0xfe, 0xba, 0xbe, 0xde, 0xad, 0xbe, 0xef, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}; + const uint8_t expected_cmx[32] = { + 0x02, 0xde, 0xfb, 0x39, 0xc8, 0xf2, 0xe1, 0xec, 0xc9, 0x45, 0x18, + 0x93, 0x73, 0xcf, 0x2a, 0x8e, 0x21, 0xd4, 0xe1, 0x54, 0x39, 0x8e, + 0xfa, 0x16, 0x21, 0xd5, 0xfb, 0x98, 0x9e, 0x1d, 0xeb, 0x36}; + + uint8_t cmx[32]; + OrchardNoteProgressCapture progress; + ASSERT_TRUE(zcash_orchard_compute_cmx_with_progress( + recipient, value, rho, rseed, cmx, capture_orchard_note_progress, + &progress)); + EXPECT_TRUE(memcmp(cmx, expected_cmx, sizeof(cmx)) == 0); + EXPECT_TRUE(progress.monotonic); + EXPECT_EQ(109u, progress.calls); + EXPECT_EQ(109u, progress.last); + EXPECT_EQ(109u, progress.total); + + uint8_t tampered[ZCASH_ORCHARD_RAW_RECEIVER_SIZE]; + memcpy(tampered, recipient, sizeof(tampered)); + tampered[0] ^= 0x01; + ASSERT_TRUE(zcash_orchard_compute_cmx(tampered, value, rho, rseed, cmx)); + EXPECT_TRUE(memcmp(cmx, expected_cmx, sizeof(cmx)) != 0); + + memzero(cmx, sizeof(cmx)); + memzero(tampered, sizeof(tampered)); +} + +TEST(Zcash, IronwoodNoteCommitment_V3KnownVector) { + const uint8_t recipient[ZCASH_ORCHARD_RAW_RECEIVER_SIZE] = { + 0x3c, 0x15, 0x0e, 0x60, 0x98, 0xb8, 0x61, 0x71, 0x6c, 0xc7, 0xf6, + 0x28, 0x35, 0xf6, 0x9f, 0xeb, 0x30, 0x21, 0x93, 0xc9, 0x26, 0x60, + 0x44, 0x4f, 0x26, 0x62, 0x4f, 0xd1, 0x3e, 0x00, 0xea, 0x7a, 0xc7, + 0x74, 0xcd, 0x55, 0x07, 0x4d, 0x63, 0x67, 0xef, 0xef, 0x37}; + const uint8_t rho[32] = { + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00}; + const uint8_t rseed[32] = { + 0xca, 0xfe, 0xba, 0xbe, 0xde, 0xad, 0xbe, 0xef, + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18}; + const uint8_t expected_cmx[32] = { + 0x89, 0x6e, 0xe3, 0x45, 0xd8, 0xb0, 0x40, 0x98, + 0x72, 0x17, 0x25, 0x37, 0x66, 0x6a, 0x48, 0x24, + 0x09, 0x66, 0x1a, 0x22, 0xad, 0x77, 0xc0, 0x98, + 0x96, 0xa3, 0xe7, 0x17, 0x65, 0xf1, 0x86, 0x33}; + + uint8_t cmx[32] = {0}; + ASSERT_TRUE( + zcash_ironwood_compute_cmx(recipient, 12345678, rho, rseed, cmx)); + EXPECT_TRUE(memcmp(cmx, expected_cmx, sizeof(cmx)) == 0); + memzero(cmx, sizeof(cmx)); +} + +TEST(Zcash, OrchardReceiverToUnifiedAddress_KnownVector) { + const uint8_t recipient[ZCASH_ORCHARD_RAW_RECEIVER_SIZE] = { + 0x3c, 0x15, 0x0e, 0x60, 0x98, 0xb8, 0x61, 0x71, 0x6c, 0xc7, 0xf6, + 0x28, 0x35, 0xf6, 0x9f, 0xeb, 0x30, 0x21, 0x93, 0xc9, 0x26, 0x60, + 0x44, 0x4f, 0x26, 0x62, 0x4f, 0xd1, 0x3e, 0x00, 0xea, 0x7a, 0xc7, + 0x74, 0xcd, 0x55, 0x07, 0x4d, 0x63, 0x67, 0xef, 0xef, 0x37}; + char address[ZCASH_ORCHARD_UNIFIED_ADDRESS_SIZE]; + + ASSERT_TRUE(zcash_orchard_receiver_to_unified_address(recipient, "u", address, + sizeof(address))); + EXPECT_STREQ(address, + "u1ut4h93zg5670tyqss7tneru3t7h6dk62r9hhyxyrpv3nwwe9dnyj5l0ruwygf" + "74gp5f3zklj5xly4h8h54un3asugt9mn6gwfqsq3wq7"); + + EXPECT_FALSE( + zcash_orchard_receiver_to_unified_address(recipient, "u", address, 16)); + memzero(address, sizeof(address)); +} + +TEST(Zcash, OrchardDiversifyHash_ReferenceVectors) { + uint8_t gd[32]; + ASSERT_TRUE(zcash_orchard_diversify_hash(EXPECTED_DIVERSIFIER_ALL_0, gd)); + EXPECT_TRUE(memcmp(gd, ORCHARD_GD_ALL_ACCOUNT0_J0, sizeof(gd)) == 0); + + ASSERT_TRUE( + zcash_orchard_diversify_hash(ORCHARD_FF1_VECTORS[0].diversifier, gd)); + EXPECT_TRUE(memcmp(gd, ORCHARD_GD_FF1_ZERO_ZERO, sizeof(gd)) == 0); + + curve_point empty; + ASSERT_EQ(pallas_group_hash("z.cash:Orchard-gd", NULL, 0, &empty), 0); + pallas_point_encode(&empty, gd); + EXPECT_TRUE(memcmp(gd, ORCHARD_GD_EMPTY, sizeof(gd)) == 0); + + memzero(gd, sizeof(gd)); + memzero(&empty, sizeof(empty)); +} + +TEST(Zcash, OrchardTransmissionKey_ReferenceVectors) { + for (const auto& vector : ORCHARD_RECEIVER_VECTORS) { + uint8_t gd[32]; + uint8_t pkd[32]; + ASSERT_TRUE(zcash_orchard_derive_transmission_key( + vector.ivk, vector.diversifier, gd, pkd)); + EXPECT_TRUE(memcmp(gd, vector.gd, sizeof(gd)) == 0); + EXPECT_TRUE(memcmp(pkd, vector.pkd, sizeof(pkd)) == 0); + + uint8_t pkd_without_gd[32]; + ASSERT_TRUE(zcash_orchard_derive_transmission_key( + vector.ivk, vector.diversifier, nullptr, pkd_without_gd)); + EXPECT_TRUE(memcmp(pkd_without_gd, vector.pkd, sizeof(pkd_without_gd)) == + 0); + + memzero(gd, sizeof(gd)); + memzero(pkd, sizeof(pkd)); + memzero(pkd_without_gd, sizeof(pkd_without_gd)); + } +} + +TEST(Zcash, OrchardTransmissionKey_RejectsZeroIvk) { + uint8_t zero_ivk[32] = {0}; + uint8_t gd[32]; + uint8_t pkd[32]; + EXPECT_FALSE(zcash_orchard_derive_transmission_key( + zero_ivk, ORCHARD_RECEIVER_VECTORS[0].diversifier, gd, pkd)); +} + +TEST(Zcash, OrchardIvk_ReferenceVectors) { + for (const auto& vector : ORCHARD_IVK_VECTORS) { + uint8_t ivk[32]; + ASSERT_TRUE( + zcash_orchard_derive_ivk(vector.ak, vector.nk, vector.rivk, ivk)); + EXPECT_TRUE(memcmp(ivk, vector.ivk, sizeof(ivk)) == 0); + memzero(ivk, sizeof(ivk)); + } +} + +TEST(Zcash, OrchardIvk_RejectsInvalidAkEncoding) { + uint8_t bad_ak[32]; + memcpy(bad_ak, ORCHARD_IVK_VECTORS[0].ak, sizeof(bad_ak)); + bad_ak[31] |= 0x80; + + uint8_t ivk[32]; + EXPECT_FALSE(zcash_orchard_derive_ivk(bad_ak, ORCHARD_IVK_VECTORS[0].nk, + ORCHARD_IVK_VECTORS[0].rivk, ivk)); + memzero(bad_ak, sizeof(bad_ak)); + memzero(ivk, sizeof(ivk)); +} + +TEST(Zcash, OrchardReceiver_ReferenceVectors) { + for (const auto& vector : ORCHARD_RECEIVER_ASSEMBLY_VECTORS) { + uint8_t receiver[43]; + ASSERT_TRUE(zcash_orchard_derive_receiver( + vector.ak, vector.nk, vector.rivk, vector.dk, vector.index, receiver)); + EXPECT_TRUE(memcmp(receiver, vector.receiver, sizeof(receiver)) == 0); + memzero(receiver, sizeof(receiver)); + } +} + +TEST(Zcash, OrchardReceiver_RejectsInvalidAkEncoding) { + uint8_t bad_ak[32]; + memcpy(bad_ak, ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].ak, sizeof(bad_ak)); + bad_ak[31] |= 0x80; + + uint8_t receiver[43]; + EXPECT_FALSE(zcash_orchard_derive_receiver( + bad_ak, ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].nk, + ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].rivk, + ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].dk, + ORCHARD_RECEIVER_ASSEMBLY_VECTORS[0].index, receiver)); + memzero(bad_ak, sizeof(bad_ak)); + memzero(receiver, sizeof(receiver)); +} + +/* ── Field Range Tests ───────────────────────────────────────────── */ + +TEST(Zcash, DeriveOrchardKeys_FieldRanges) { + /* Test multiple accounts to increase coverage of edge cases */ + for (uint32_t account = 0; account < 5; account++) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, account, &keys)); + + /* nk must be < Pallas base field prime p */ + EXPECT_LT(cmp_le256(keys.nk, PALLAS_P_LE), 0) + << "nk must be < p for account " << account; + + /* rivk must be < Pallas scalar field order q */ + EXPECT_LT(cmp_le256(keys.rivk, PALLAS_Q_LE), 0) + << "rivk must be < q for account " << account; + + /* ask must be < Pallas scalar field order q */ + EXPECT_LT(cmp_le256(keys.ask, PALLAS_Q_LE), 0) + << "ask must be < q for account " << account; + + /* ask must be nonzero (astronomically unlikely, but verify) */ + uint8_t zero[32] = {0}; + EXPECT_TRUE(memcmp(keys.ask, zero, 32) != 0) + << "ask must be nonzero for account " << account; + + memzero(&keys, sizeof(keys)); + } +} + +TEST(Zcash, AkSignBit_AlwaysClear) { + /* + * For every account, compute ak = [ask]*G and verify the sign bit + * is always clear. This is the invariant that the ask negation + * in zcash_derive_orchard_keys() is supposed to enforce. + */ + for (uint32_t account = 0; account < 10; account++) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, account, &keys)); + + bignum256 ask_scalar; + bn_read_le(keys.ask, &ask_scalar); + curve_point ak_point; + redpallas_scalar_mult_spendauth_G(&ask_scalar, &ak_point); + + /* Check y parity: must be even (sign bit = 0) */ + EXPECT_FALSE(bn_is_odd(&ak_point.y)) + << "ak y-coordinate must be even for account " << account; + + /* Check serialized sign bit */ + uint8_t ak_bytes[32]; + bignum256 x_copy; + bn_copy(&ak_point.x, &x_copy); + bn_write_le(&x_copy, ak_bytes); + + EXPECT_EQ(ak_bytes[31] & 0x80, 0) + << "ak sign bit must be clear for account " << account; + + memzero(&keys, sizeof(keys)); + } +} + +/* ── PCZT Signing Policy Tests ───────────────────────────────────── */ + +static ZcashPCZTSigningRequestMeta clear_pczt_meta(void) { + ZcashPCZTSigningRequestMeta meta = {}; + meta.has_header_digest = true; + meta.header_digest_size = 32; + meta.has_orchard_digest = true; + meta.orchard_digest_size = 32; + meta.has_orchard_flags = true; + meta.has_orchard_value_balance = true; + meta.has_orchard_anchor = true; + meta.orchard_anchor_size = 32; + meta.has_header_fields = true; + meta.n_transparent_inputs = 0; + meta.n_transparent_outputs = 0; + return meta; +} + +TEST(Zcash, PCZTSigningPolicy_AcceptsVerifiedShieldedOnlyRequest) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_OK); + EXPECT_TRUE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsMissingTransactionDigests) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + meta.has_header_digest = false; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); + + meta = clear_pczt_meta(); + meta.orchard_digest_size = 31; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RequiresIronwoodDigestForV6Pool) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + meta.is_ironwood = true; + + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS); + + meta.has_ironwood_digest = true; + meta.ironwood_digest_size = 32; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_OK); + + meta.ironwood_digest_size = 31; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TX_DIGESTS); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsMissingPlaintextHeaderFields) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + meta.has_header_fields = false; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_HEADER_FIELDS); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsMissingOrchardMetadata) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + meta.has_orchard_anchor = false; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); + + meta = clear_pczt_meta(); + meta.has_orchard_flags = false; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_ORCHARD_METADATA); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsInvalidOptionalDigests) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + meta.has_transparent_digest = true; + meta.transparent_digest_size = 31; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_INVALID_DIGEST_SIZE); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsSaplingComponent) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + + meta.has_sapling_digest = true; + meta.sapling_digest_size = 32; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_UNSUPPORTED_SAPLING_COMPONENT); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); +} + +TEST(Zcash, PCZTSigningPolicy_RejectsTransparentComponentsWithoutDigest) { + ZcashPCZTSigningRequestMeta meta = clear_pczt_meta(); + meta.n_transparent_inputs = 1; + + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); + + meta.has_transparent_digest = true; + meta.transparent_digest_size = 32; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_OK); + EXPECT_TRUE(zcash_pczt_signing_request_is_clear(&meta)); + + meta = clear_pczt_meta(); + meta.n_transparent_outputs = 1; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_MISSING_TRANSPARENT_DIGEST); + EXPECT_FALSE(zcash_pczt_signing_request_is_clear(&meta)); + + meta.has_transparent_digest = true; + meta.transparent_digest_size = 32; + EXPECT_EQ(zcash_pczt_signing_request_status(&meta), + ZCASH_PCZT_SIGNING_REQUEST_OK); + EXPECT_TRUE(zcash_pczt_signing_request_is_clear(&meta)); +} + +static const uint8_t ZIP244_EXPECTED_HEADER_DIGEST[32] = { + 0x44, 0x4b, 0xe9, 0x38, 0x88, 0x1d, 0xc9, 0xf2, 0x0a, 0xed, 0x88, + 0x0c, 0x3a, 0x05, 0x94, 0xe5, 0xc1, 0x22, 0x3e, 0xff, 0xc5, 0x75, + 0xef, 0x05, 0xda, 0xae, 0xe3, 0x45, 0x1b, 0xa2, 0xf4, 0x93}; + +static const uint8_t ZIP244_EXPECTED_EMPTY_TRANSPARENT_DIGEST[32] = { + 0xc3, 0x3f, 0x2e, 0x95, 0x70, 0x5f, 0xaa, 0xb3, 0x5f, 0x8d, 0x53, + 0x3f, 0xa6, 0x1e, 0x95, 0xc3, 0xb7, 0xaa, 0xba, 0x07, 0x76, 0xb8, + 0x74, 0xa9, 0xf7, 0x4f, 0xc1, 0x27, 0x84, 0x37, 0x6a, 0x59}; + +static const uint8_t ZIP244_EXPECTED_TRANSPARENT_DIGEST[32] = { + 0xfa, 0xe5, 0x37, 0x7f, 0xa9, 0x3c, 0xc0, 0xc3, 0x1d, 0x30, 0x39, + 0x42, 0x21, 0x57, 0xce, 0x4b, 0x9e, 0x7b, 0x12, 0x57, 0x00, 0x9f, + 0x15, 0x90, 0xe1, 0x62, 0x95, 0x62, 0x55, 0xbb, 0x2e, 0x84}; + +static const uint8_t ZIP244_EXPECTED_TRANSPARENT_SIGHASH_0[32] = { + 0x37, 0xa9, 0xc4, 0xec, 0x61, 0x87, 0x07, 0x20, 0x5b, 0xcb, 0x47, + 0x7b, 0xea, 0x4f, 0xda, 0x6d, 0x61, 0x01, 0x62, 0xea, 0xaa, 0x5c, + 0x9f, 0x33, 0xe5, 0x59, 0x69, 0x02, 0x6e, 0x47, 0x6f, 0x23}; + +static const uint8_t ZIP244_EXPECTED_TRANSPARENT_SIGHASH_1[32] = { + 0x29, 0x4d, 0xb7, 0xaa, 0xf1, 0x65, 0x37, 0x4e, 0x02, 0xda, 0xe1, + 0x6f, 0xf3, 0xdd, 0x97, 0x78, 0x8f, 0x4f, 0x5e, 0x2d, 0xc4, 0xe1, + 0xb3, 0xf6, 0x62, 0x73, 0x9e, 0xd3, 0x5b, 0x82, 0x08, 0x2f}; + +static const uint8_t ZIP244_P2PKH_SCRIPT_11[25] = { + 0x76, 0xa9, 0x14, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x88, 0xac}; + +static const uint8_t ZIP244_P2SH_SCRIPT_22[23] = { + 0xa9, 0x14, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, + 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x87}; + +static const uint8_t ZIP244_P2PKH_SCRIPT_33[25] = { + 0x76, 0xa9, 0x14, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, + 0x33, 0x33, 0x33, 0x33, 0x33, 0x88, 0xac}; + +static const uint8_t ZIP244_P2SH_SCRIPT_44[23] = { + 0xa9, 0x14, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, + 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x87}; + +static void fill_zip244_txids(uint8_t txid0[32], uint8_t txid1[32]) { + for (size_t i = 0; i < 32; i++) { + txid0[i] = (uint8_t)i; + txid1[i] = (uint8_t)(i + 32); + } +} + +static void make_zip244_transparent_fixture( + ZcashTransparentInputDigestInfo inputs[2], + ZcashTransparentOutputDigestInfo outputs[2], uint8_t txid0[32], + uint8_t txid1[32]) { + fill_zip244_txids(txid0, txid1); + + inputs[0].prevout_txid = txid0; + inputs[0].prevout_index = 2; + inputs[0].sequence = 0xfffffffe; + inputs[0].value = 1234567890ULL; + inputs[0].script_pubkey = ZIP244_P2PKH_SCRIPT_11; + inputs[0].script_pubkey_size = sizeof(ZIP244_P2PKH_SCRIPT_11); + + inputs[1].prevout_txid = txid1; + inputs[1].prevout_index = 7; + inputs[1].sequence = 0xfffffffd; + inputs[1].value = 987654321ULL; + inputs[1].script_pubkey = ZIP244_P2SH_SCRIPT_22; + inputs[1].script_pubkey_size = sizeof(ZIP244_P2SH_SCRIPT_22); + + outputs[0].value = 2000000000ULL; + outputs[0].script_pubkey = ZIP244_P2PKH_SCRIPT_33; + outputs[0].script_pubkey_size = sizeof(ZIP244_P2PKH_SCRIPT_33); + + outputs[1].value = 1111111ULL; + outputs[1].script_pubkey = ZIP244_P2SH_SCRIPT_44; + outputs[1].script_pubkey_size = sizeof(ZIP244_P2SH_SCRIPT_44); +} + +TEST(Zcash, ComputeHeaderDigest_FromPlaintextFields) { + uint8_t digest[32] = {0}; + + ASSERT_TRUE(zcash_compute_header_digest(5, 0x26a7270a, 0xc2d6d0b4, 123456, + 987654, digest)); + EXPECT_TRUE(memcmp(digest, ZIP244_EXPECTED_HEADER_DIGEST, 32) == 0); +} + +TEST(Zcash, ComputeTransparentDigest_DistinctFromPerInputSighash) { + ZcashTransparentInputDigestInfo inputs[2] = {}; + ZcashTransparentOutputDigestInfo outputs[2] = {}; + uint8_t txid0[32], txid1[32]; + make_zip244_transparent_fixture(inputs, outputs, txid0, txid1); + + uint8_t digest[32] = {0}; + uint8_t sighash0[32] = {0}; + uint8_t sighash1[32] = {0}; + + ASSERT_TRUE(zcash_compute_transparent_digest(inputs, 2, outputs, 2, digest)); + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 0, + 0x01, sighash0)); + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 1, + 0x01, sighash1)); + + EXPECT_TRUE(memcmp(digest, ZIP244_EXPECTED_TRANSPARENT_DIGEST, 32) == 0); + EXPECT_TRUE(memcmp(sighash0, ZIP244_EXPECTED_TRANSPARENT_SIGHASH_0, 32) == 0); + EXPECT_TRUE(memcmp(sighash1, ZIP244_EXPECTED_TRANSPARENT_SIGHASH_1, 32) == 0); + EXPECT_TRUE(memcmp(digest, sighash0, 32) != 0); + EXPECT_TRUE(memcmp(sighash0, sighash1, 32) != 0); +} + +TEST(Zcash, ComputeTransparentDigest_EmptyBundle) { + uint8_t digest[32] = {0}; + + ASSERT_TRUE(zcash_compute_transparent_digest(NULL, 0, NULL, 0, digest)); + EXPECT_TRUE(memcmp(digest, ZIP244_EXPECTED_EMPTY_TRANSPARENT_DIGEST, 32) == + 0); +} + +TEST(Zcash, ComputeTransparentSighash_RejectsUnsupportedRequest) { + ZcashTransparentInputDigestInfo inputs[2] = {}; + ZcashTransparentOutputDigestInfo outputs[2] = {}; + uint8_t txid0[32], txid1[32], digest[32]; + make_zip244_transparent_fixture(inputs, outputs, txid0, txid1); + + EXPECT_FALSE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, + 2, 0x01, digest)); + EXPECT_FALSE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, + 0, 0x02, digest)); +} + +TEST(Zcash, ComputeTransparentSighash_CommitsToOutputScriptAndValue) { + ZcashTransparentInputDigestInfo inputs[2] = {}; + ZcashTransparentOutputDigestInfo outputs[2] = {}; + uint8_t txid0[32], txid1[32]; + make_zip244_transparent_fixture(inputs, outputs, txid0, txid1); + + uint8_t original[32] = {0}; + uint8_t changed_script[32] = {0}; + uint8_t changed_value[32] = {0}; + + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 0, + 0x01, original)); + + outputs[0].script_pubkey = ZIP244_P2SH_SCRIPT_44; + outputs[0].script_pubkey_size = sizeof(ZIP244_P2SH_SCRIPT_44); + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 0, + 0x01, changed_script)); + EXPECT_TRUE(memcmp(original, changed_script, 32) != 0); + + outputs[0].script_pubkey = ZIP244_P2PKH_SCRIPT_33; + outputs[0].script_pubkey_size = sizeof(ZIP244_P2PKH_SCRIPT_33); + outputs[0].value++; + ASSERT_TRUE(zcash_compute_transparent_sighash_digest(inputs, 2, outputs, 2, 0, + 0x01, changed_value)); + EXPECT_TRUE(memcmp(original, changed_value, 32) != 0); +} + +/* ── Sighash Computation Tests ───────────────────────────────────── */ + +TEST(Zcash, ComputeShieldedSighash_Deterministic) { + uint8_t header[32], transparent[32], sapling[32], orchard[32]; + memset(header, 0x01, 32); + memset(transparent, 0x02, 32); + memset(sapling, 0x03, 32); + memset(orchard, 0x04, 32); + + uint32_t branch_id = 0x37519621; /* NU5 */ + + uint8_t sighash1[32], sighash2[32]; + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash1)); + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash2)); + + EXPECT_TRUE(memcmp(sighash1, sighash2, 32) == 0) + << "Sighash must be deterministic"; +} + +TEST(Zcash, ComputeV6ShieldedSighash_KnownVector) { + uint8_t header[32], transparent[32], sapling[32], orchard[32], ironwood[32]; + memset(header, 0x11, sizeof(header)); + memset(transparent, 0x22, sizeof(transparent)); + memset(sapling, 0x33, sizeof(sapling)); + memset(orchard, 0x44, sizeof(orchard)); + memset(ironwood, 0x55, sizeof(ironwood)); + const uint8_t expected[32] = { + 0xdc, 0x07, 0x66, 0x98, 0xdb, 0xe0, 0x8b, 0x6d, + 0xcd, 0x23, 0xf5, 0xa1, 0xb6, 0xbb, 0xae, 0x41, + 0xf7, 0xb1, 0x23, 0xd8, 0xb2, 0x47, 0xf3, 0x88, + 0x7f, 0x7c, 0xa2, 0xbb, 0x68, 0xb5, 0xdc, 0xaa}; + + uint8_t sighash[32] = {0}; + ASSERT_TRUE(zcash_compute_v6_shielded_sighash( + header, transparent, sapling, orchard, ironwood, 0x37a5165b, + sighash)); + EXPECT_TRUE(memcmp(sighash, expected, sizeof(sighash)) == 0); +} + +TEST(Zcash, ComputeShieldedSighash_DifferentInputs) { + uint8_t header[32], transparent[32], sapling[32], orchard[32]; + memset(header, 0x01, 32); + memset(transparent, 0x02, 32); + memset(sapling, 0x03, 32); + memset(orchard, 0x04, 32); + + uint32_t branch_id = 0x37519621; + uint8_t sighash_a[32], sighash_b[32]; + + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash_a)); + + /* Change one byte in the orchard digest */ + orchard[0] ^= 0xff; + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash_b)); + + EXPECT_TRUE(memcmp(sighash_a, sighash_b, 32) != 0) + << "Different orchard digests must produce different sighashes"; +} + +TEST(Zcash, ComputeShieldedSighash_DifferentBranchId) { + uint8_t header[32], transparent[32], sapling[32], orchard[32]; + memset(header, 0x01, 32); + memset(transparent, 0x02, 32); + memset(sapling, 0x03, 32); + memset(orchard, 0x04, 32); + + uint8_t sighash_nu5[32], sighash_nu6[32]; + + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, 0x37519621, sighash_nu5)); + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, 0xC4D97411, sighash_nu6)); + + EXPECT_TRUE(memcmp(sighash_nu5, sighash_nu6, 32) != 0) + << "Different branch IDs must produce different sighashes"; +} + +TEST(Zcash, ComputeShieldedSighash_KnownVector) { + /* + * ZIP-244 sighash test vector. + * + * The sighash personalization is "ZcashTxHash_" || branch_id_LE. + * For NU5 (branch_id = 0x37519621): + * personalization = "ZcashTxHash_" || 0x21965137 + * + * Input: BLAKE2b-256(personalization, header || transparent || sapling || + * orchard) where each digest is 32 bytes of zeros. + */ + uint8_t header[32] = {0}; + uint8_t transparent[32] = {0}; + uint8_t sapling[32] = {0}; + uint8_t orchard[32] = {0}; + uint32_t branch_id = 0x37519621; + + uint8_t sighash[32]; + ASSERT_TRUE(zcash_compute_shielded_sighash(header, transparent, sapling, + orchard, branch_id, sighash)); + + /* + * Independently verified: BLAKE2b-256 with personalization + * "ZcashTxHash_\x21\x96\x51\x37" over 128 zero bytes. + * + * This is a self-consistency check — the value was computed by + * running the same BLAKE2b-256 offline. If the sighash function + * changes its algorithm, this test will catch it. + */ + uint8_t expected[32]; + BLAKE2B_CTX ctx; + uint8_t personal[16]; + memcpy(personal, "ZcashTxHash_", 12); + memcpy(personal + 12, &branch_id, 4); + blake2b_InitPersonal(&ctx, 32, personal, 16); + blake2b_Update(&ctx, header, 32); + blake2b_Update(&ctx, transparent, 32); + blake2b_Update(&ctx, sapling, 32); + blake2b_Update(&ctx, orchard, 32); + blake2b_Final(&ctx, expected, 32); + + EXPECT_TRUE(memcmp(sighash, expected, 32) == 0) + << "Sighash must match direct BLAKE2b computation"; +} + +/* ── RedPallas Signing Smoke Test ────────────────────────────────── */ + +struct RedPallasProgressCapture { + uint32_t calls = 0; + uint32_t last = 0; + uint32_t total = 0; + bool monotonic = true; +}; + +static void capture_redpallas_progress(uint32_t completed, uint32_t total, + void* context) { + auto* capture = static_cast(context); + if (capture->calls > 0 && completed < capture->last) { + capture->monotonic = false; + } + capture->calls++; + capture->last = completed; + capture->total = total; +} + +TEST(Zcash, OrchardKeyDerivationReportsFixedProgress) { + ZcashOrchardKeys keys; + RedPallasProgressCapture progress; + + ASSERT_TRUE(zcash_derive_orchard_keys_with_progress( + SEED_ALL, 64, 0, &keys, capture_redpallas_progress, &progress)); + EXPECT_TRUE(progress.monotonic); + EXPECT_EQ(255u, progress.calls); + EXPECT_EQ(255u, progress.last); + EXPECT_EQ(255u, progress.total); + EXPECT_EQ(0, memcmp(keys.ak, EXPECTED_AK_ALL_0, sizeof(keys.ak))); + + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, RedPallasPublicRkPathMatchesAndReportsFixedProgress) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t alpha[32]; + memset(alpha, 0x01, sizeof(alpha)); + alpha[31] = 0; + uint8_t sighash[32]; + memset(sighash, 0xA5, sizeof(sighash)); + + uint8_t public_rk[32], secret_reference_rk[32]; + ASSERT_EQ(redpallas_derive_rk_from_ak(keys.ak, alpha, public_rk), 0); + ASSERT_EQ(redpallas_derive_rk(keys.ask, alpha, secret_reference_rk), 0); + EXPECT_EQ(memcmp(public_rk, secret_reference_rk, sizeof(public_rk)), 0); + + RedPallasProgressCapture progress; + uint8_t signature[64]; + ASSERT_EQ(redpallas_sign_digest_with_ak( + keys.ask, keys.ak, alpha, public_rk, sighash, kRedPallasTestT, signature, + capture_redpallas_progress, &progress), + 0); + EXPECT_TRUE(progress.monotonic); + EXPECT_EQ(257u, progress.calls); + EXPECT_EQ(1000u, progress.last); + EXPECT_EQ(1000u, progress.total); + EXPECT_EQ(redpallas_verify_digest(public_rk, sighash, signature), 0); + + uint8_t wrong_rk[32]; + memcpy(wrong_rk, public_rk, sizeof(wrong_rk)); + wrong_rk[0] ^= 1; + EXPECT_NE(redpallas_sign_digest_with_ak(keys.ask, keys.ak, alpha, wrong_rk, + sighash, kRedPallasTestT, signature, nullptr, nullptr), + 0); + + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, RedPallasPcztPathUsesBoundRkAndReportsFixedProgress) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t alpha[32]; + memset(alpha, 0x31, sizeof(alpha)); + alpha[31] = 0; + uint8_t sighash[32]; + memset(sighash, 0x5A, sizeof(sighash)); + uint8_t rk[32]; + ASSERT_EQ(redpallas_derive_rk(keys.ask, alpha, rk), 0); + + RedPallasProgressCapture progress; + uint8_t signature[64]; + ASSERT_EQ( + redpallas_sign_digest_for_rk(keys.ask, alpha, rk, sighash, kRedPallasTestT, signature, + capture_redpallas_progress, &progress), + 0); + EXPECT_TRUE(progress.monotonic); + EXPECT_EQ(256u, progress.calls); + EXPECT_EQ(1000u, progress.last); + EXPECT_EQ(1000u, progress.total); + EXPECT_EQ(redpallas_verify_digest(rk, sighash, signature), 0); + + uint8_t wrong_rk[32]; + memcpy(wrong_rk, rk, sizeof(wrong_rk)); + wrong_rk[0] ^= 1; + ASSERT_EQ(redpallas_sign_digest_for_rk(keys.ask, alpha, wrong_rk, sighash, + kRedPallasTestT, + signature, nullptr, nullptr), + 0); + EXPECT_NE(redpallas_verify_digest(rk, sighash, signature), 0); + + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, RedPallasSign_ProducesVerifiableSignature) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + /* Construct a fake sighash and alpha */ + uint8_t sighash[32]; + memset(sighash, 0xAB, 32); + + uint8_t alpha[32]; + memset(alpha, 0x01, 32); + /* Ensure alpha is a valid scalar (< q) */ + alpha[31] = 0x00; + + uint8_t signature[64]; + int ret = redpallas_sign_digest(keys.ask, alpha, sighash, kRedPallasTestT, 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) { + /* + * Signing is repeatable and must stay that way. The construction is HEDGED, + * not randomized: r = H*(T || rk || M) is a pure function of its inputs, so + * a fixed T over one message reproduces one signature. (Production varies T + * per signature; that is the caller's job, not the signer's.) + * + * Verify that repeated calls all succeed and produce valid, nonzero + * signatures. RedPallasNonce_SameInputs_Deterministic asserts the equality + * itself. + */ + 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, kRedPallasTestT, 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, kRedPallasTestT, sig_a), 0); + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha, sighash_b, kRedPallasTestT, sig_b), 0); + + EXPECT_TRUE(memcmp(sig_a, sig_b, 64) != 0) + << "Different sighash must produce different signatures"; + + memzero(&keys, sizeof(keys)); +} + +/* ZIP-244 and ZIP-229 empty-bundle digests. + * + * A bundle with no components hashes the EMPTY string under its own + * personalization. The device pins the digest of every pool it does not stream + * and verify, so that it never signs a sighash committing to a bundle it has + * not inspected -- transparent and Sapling were already pinned this way, and + * Orchard-under-Ironwood is pinned by EMPTY_ORCHARD_DIGEST_V6 in + * fsm_msg_zcash.h. + * + * These expected bytes are NOT taken from our own constants; they are the + * specification values, so this test catches a mistyped literal as well as a + * wrong personalization string. A wrong Orchard value would reject every + * Ironwood transaction, which is safe but would look like an Ironwood bug. + */ +TEST(Zcash, EmptyBundleDigests_MatchZip244AndZip229) { + struct Case { + const char* personal; + const char* expect_hex; + }; + const Case cases[] = { + {"ZTxIdTranspaHash", + "c33f2e95705faab35f8d533fa61e95c3b7aaba0776b874a9f74fc12784376a59"}, + {"ZTxIdSaplingHash", + "6f2fc8f98feafd94e74a0df4bed74391ee0b5a69945e4ced8ca8a095206f00ae"}, + {"ZTxIdOrchardHash", + "9fbe4ed13b0c08e671c11a3407d84e1117cd45028a2eee1b9feae78b48a6e2c1"}, + {"ZTxIdOrchardH_v6", + "a3367d2fdea2910159fc5026e9bf1fccd3e28ce5e6de46bfb71587230eea9515"}, + {"ZTxIdIronwd_H_v6", + "b9cfe643ce45b28c33190f0d5223e475972f2a149dc54404fd8365521f8416c5"}, + }; + + for (const Case& c : cases) { + BLAKE2B_CTX ctx; + ASSERT_EQ(blake2b_InitPersonal(&ctx, 32, c.personal, 16), 0) + << "personalization " << c.personal; + uint8_t out[32]; + ASSERT_EQ(blake2b_Final(&ctx, out, 32), 0) << c.personal; + + char hex[65]; + for (int i = 0; i < 32; i++) { + snprintf(hex + 2 * i, 3, "%02x", out[i]); + } + EXPECT_STREQ(hex, c.expect_hex) + << "empty-bundle digest for " << c.personal + << " does not match the transaction digest specification"; + } +} + +/* --- RedDSA nonce derivation ------------------------------------- * + * + * These are the regression tests for the nonce defect. The signer used to + * reduce 32 raw entropy bytes straight to a scalar, so the nonce was a + * function of the caller's randomness ALONE. Under that code every assertion + * below on R (the nonce commitment, sig[0..31]) failed: R was byte-identical + * across different messages and even across different KEYS, and two + * signatures sharing an R disclose the signing key by + * + * ask + alpha = (s1 - s2) / (c1 - c2) + * + * which is arithmetic an observer can do from public data. The fix hashes the + * randomness together with the verification key and the message, + * r = H*(T || rk || M), so R moves whenever either does. + * + * These tests assert the property that denies the attack its input. They do + * not mount the recovery itself -- that would need an independent BLAKE2b, + * wide reduction and inverse mod q reimplemented here, and it would catch + * nothing this does not. + */ + +/* Same T, different message. THE test: this is the reuse that discloses the + * key, and it is the one a caller can hit for real -- an RNG that repeats, + * a replayed draw, a device signing two actions from one entropy pool. */ +TEST(Zcash, RedPallasNonce_RepeatedT_DifferentMessage_DifferentR) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t alpha[32]; + memset(alpha, 0x03, 32); + alpha[31] = 0x00; + + uint8_t sighash_a[32], sighash_b[32]; + memset(sighash_a, 0x11, 32); + memset(sighash_b, 0x22, 32); + + uint8_t sig_a[64], sig_b[64]; + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha, sighash_a, kRedPallasTestT, + sig_a), 0); + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha, sighash_b, kRedPallasTestT, + sig_b), 0); + + EXPECT_NE(memcmp(sig_a, sig_b, 32), 0) + << "Identical T over different messages reused the nonce commitment R. " + "Two such signatures disclose the signing key."; + + memzero(&keys, sizeof(keys)); +} + +/* Same T, same message, different key: r must bind the verification key too, + * or one entropy pool shared across accounts leaks across them. */ +TEST(Zcash, RedPallasNonce_RepeatedT_DifferentKey_DifferentR) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t sighash[32]; + memset(sighash, 0x44, 32); + + /* Two different randomizers => two different rk from one ask. */ + uint8_t alpha_a[32], alpha_b[32]; + memset(alpha_a, 0x05, 32); + alpha_a[31] = 0x00; + memset(alpha_b, 0x06, 32); + alpha_b[31] = 0x00; + + uint8_t sig_a[64], sig_b[64]; + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha_a, sighash, kRedPallasTestT, + sig_a), 0); + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha_b, sighash, kRedPallasTestT, + sig_b), 0); + + EXPECT_NE(memcmp(sig_a, sig_b, 32), 0) + << "R did not bind the verification key: one T reused across two " + "randomized keys repeated the nonce."; + + memzero(&keys, sizeof(keys)); +} + +/* The construction is hedged, not randomized: identical inputs reproduce the + * signature exactly. This is what makes the vectors above deterministic, and + * it is the control for the two tests above -- without it, "R differs" could + * be satisfied by an unrelated source of variation. */ +TEST(Zcash, RedPallasNonce_SameInputs_Deterministic) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t alpha[32]; + memset(alpha, 0x07, 32); + alpha[31] = 0x00; + uint8_t sighash[32]; + memset(sighash, 0x55, 32); + + uint8_t sig_a[64], sig_b[64]; + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha, sighash, kRedPallasTestT, + sig_a), 0); + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha, sighash, kRedPallasTestT, + sig_b), 0); + + EXPECT_EQ(memcmp(sig_a, sig_b, 64), 0) + << "Identical (ask, alpha, M, T) must reproduce the signature exactly"; + + memzero(&keys, sizeof(keys)); +} + +/* A dead entropy source must FAIL the signature, never be normalised into a + * usable nonce. The removed pallas_ct_scalar_replace_zero_with_one() turned + * exactly this input into the constant nonce 1 on every signature -- reused + * AND publicly known, which discloses the key from a SINGLE signature. */ +TEST(Zcash, RedPallasNonce_AllZeroT_Refused) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t alpha[32]; + memset(alpha, 0x08, 32); + alpha[31] = 0x00; + uint8_t sighash[32]; + memset(sighash, 0x66, 32); + + uint8_t zero_T[80] = {0}; + uint8_t sig[64]; + memset(sig, 0xEE, sizeof(sig)); + + EXPECT_NE(redpallas_sign_digest(keys.ask, alpha, sighash, zero_T, sig), 0) + << "An all-zero T is a dead entropy source and must not produce a " + "signature"; + + uint8_t untouched[64]; + memset(untouched, 0xEE, sizeof(untouched)); + EXPECT_EQ(memcmp(sig, untouched, 64), 0) + << "A refused signature must not write to the output buffer"; + + memzero(&keys, sizeof(keys)); +} + +TEST(Zcash, RedPallasNonce_NullT_Refused) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t alpha[32]; + memset(alpha, 0x09, 32); + alpha[31] = 0x00; + uint8_t sighash[32]; + memset(sighash, 0x77, 32); + uint8_t sig[64]; + + EXPECT_NE(redpallas_sign_digest(keys.ask, alpha, sighash, nullptr, sig), 0) + << "A NULL T must be refused, not dereferenced"; + + memzero(&keys, sizeof(keys)); +} + +/* Signatures produced from a repeated T are still valid signatures -- the fix + * changes which nonce is used, not whether the result verifies. */ +TEST(Zcash, RedPallasNonce_RepeatedT_StillVerifies) { + ZcashOrchardKeys keys; + ASSERT_TRUE(zcash_derive_orchard_keys(SEED_ALL, 64, 0, &keys)); + + uint8_t alpha[32]; + memset(alpha, 0x0a, 32); + alpha[31] = 0x00; + + uint8_t rk[32]; + ASSERT_EQ(redpallas_derive_rk(keys.ask, alpha, rk), 0); + + const uint8_t fills[2] = {0x31, 0x32}; + for (int i = 0; i < 2; i++) { + uint8_t sighash[32]; + memset(sighash, fills[i], 32); + uint8_t sig[64]; + ASSERT_EQ(redpallas_sign_digest(keys.ask, alpha, sighash, kRedPallasTestT, + sig), 0); + EXPECT_EQ(redpallas_verify_digest(rk, sighash, sig), 0) + << "Signature " << i << " from a repeated T must still verify"; + } + + memzero(&keys, sizeof(keys)); +} + +/* ─── Seed Fingerprint (ZIP-32 §6.1) ─────────────────────────────── */ + +/* Reference vector: matches keystone3-firmware + * rust/keystore/src/algorithms/zcash/mod.rs test_keystore_derive_zcash_ufvk + * Seed: 000102...1f (32 bytes) + * Fingerprint: deff604c246710f7176dead02aa746f2fd8d5389f7072556dcb555fdbe5e3ae3 + */ +TEST(Zcash, SeedFingerprint_ReferenceVector) { + uint8_t seed[32]; + for (int i = 0; i < 32; i++) seed[i] = (uint8_t)i; + + uint8_t expected[32] = { + 0xde, 0xff, 0x60, 0x4c, 0x24, 0x67, 0x10, 0xf7, 0x17, 0x6d, 0xea, + 0xd0, 0x2a, 0xa7, 0x46, 0xf2, 0xfd, 0x8d, 0x53, 0x89, 0xf7, 0x07, + 0x25, 0x56, 0xdc, 0xb5, 0x55, 0xfd, 0xbe, 0x5e, 0x3a, 0xe3, + }; + + uint8_t fp[32]; + ASSERT_TRUE(zcash_calculate_seed_fingerprint(seed, 32, fp)); + EXPECT_EQ(memcmp(fp, expected, 32), 0); +} + +TEST(Zcash, SeedFingerprintRequestRequiresExactSizeWhenPresent) { + EXPECT_TRUE(zcash_seed_fingerprint_request_valid(false, 0)); + EXPECT_TRUE(zcash_seed_fingerprint_request_valid(true, 32)); + EXPECT_FALSE(zcash_seed_fingerprint_request_valid(true, 0)); + EXPECT_FALSE(zcash_seed_fingerprint_request_valid(true, 31)); + EXPECT_FALSE(zcash_seed_fingerprint_request_valid(true, 33)); +} + +TEST(Zcash, SeedFingerprint_RejectAllZero) { + uint8_t seed[32] = {0}; + uint8_t fp[32]; + EXPECT_FALSE(zcash_calculate_seed_fingerprint(seed, 32, fp)); +} + +TEST(Zcash, SeedFingerprint_RejectAllFF) { + uint8_t seed[32]; + memset(seed, 0xFF, 32); + uint8_t fp[32]; + EXPECT_FALSE(zcash_calculate_seed_fingerprint(seed, 32, fp)); +} + +TEST(Zcash, SeedFingerprint_RejectShortSeed) { + uint8_t seed[31]; + for (int i = 0; i < 31; i++) seed[i] = (uint8_t)(i + 1); + uint8_t fp[32]; + EXPECT_FALSE(zcash_calculate_seed_fingerprint(seed, 31, fp)); +} + +TEST(Zcash, SeedFingerprint_RejectLongSeed) { + uint8_t seed[253]; + for (int i = 0; i < 253; i++) seed[i] = (uint8_t)(i & 0xFF); + uint8_t fp[32]; + EXPECT_FALSE(zcash_calculate_seed_fingerprint(seed, 253, fp)); +} + +TEST(Zcash, SeedFingerprint_DeterministicAcrossCalls) { + uint8_t seed[64]; + for (int i = 0; i < 64; i++) seed[i] = (uint8_t)(0xAA ^ i); + + uint8_t fp_a[32], fp_b[32]; + ASSERT_TRUE(zcash_calculate_seed_fingerprint(seed, 64, fp_a)); + ASSERT_TRUE(zcash_calculate_seed_fingerprint(seed, 64, fp_b)); + EXPECT_EQ(memcmp(fp_a, fp_b, 32), 0); +} + +TEST(Zcash, SeedFingerprint_DiffersForDifferentSeeds) { + uint8_t seed_a[64]; + uint8_t seed_b[64]; + for (int i = 0; i < 64; i++) { + seed_a[i] = (uint8_t)i; + seed_b[i] = (uint8_t)(i + 1); + } + + uint8_t fp_a[32], fp_b[32]; + ASSERT_TRUE(zcash_calculate_seed_fingerprint(seed_a, 64, fp_a)); + ASSERT_TRUE(zcash_calculate_seed_fingerprint(seed_b, 64, fp_b)); + EXPECT_NE(memcmp(fp_a, fp_b, 32), 0); +} + +/* ===================================================================== * + * zcash_compute_orchard_transparent_sig_digest — ZIP-244 S.2/T.1 + * + * This is the site of the historical shield-fix (S.2 vs T.1 selection by + * vin count). It had no test caller, so a refactor could silently swap the + * two forms. These lock in: (a) empty-vin -> T.1 (equals the standalone + * transparent digest), (b) non-empty-vin -> S.2 (distinct from T.1 and + * stable), and (c) validation refusal on malformed info. + * ===================================================================== */ + +// A P2PKH-shaped script_pubkey (25 bytes) for the fixtures. +static const uint8_t kScriptPubkey[25] = { + 0x76, 0xa9, 0x14, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x88, 0xac}; +static const uint8_t kPrevoutTxid[32] = { + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00}; + +// Empty vin (deshield): the Orchard transparent-sig digest is the T.1 form, +// which is exactly zcash_compute_transparent_digest over the same components. +TEST(Zcash, OrchardTransparentSigDigest_EmptyVinMatchesT1) { + ZcashTransparentOutputDigestInfo out = {/*value=*/50000, kScriptPubkey, + sizeof(kScriptPubkey)}; + + uint8_t s2_digest[32], t1_digest[32]; + ASSERT_TRUE(zcash_compute_orchard_transparent_sig_digest(nullptr, 0, &out, 1, + s2_digest)); + ASSERT_TRUE(zcash_compute_transparent_digest(nullptr, 0, &out, 1, t1_digest)); + EXPECT_EQ(memcmp(s2_digest, t1_digest, 32), 0); +} + +// Non-empty vin (shield): the S.2 form is used and MUST differ from the T.1 +// form over the identical inputs/outputs (the whole point of the shield fix), +// and it must be deterministic. +TEST(Zcash, OrchardTransparentSigDigest_NonEmptyVinIsS2NotT1) { + ZcashTransparentInputDigestInfo in = { + kPrevoutTxid, /*prevout_index=*/0, /*sequence=*/0xffffffff, + /*value=*/100000, kScriptPubkey, sizeof(kScriptPubkey)}; + ZcashTransparentOutputDigestInfo out = {/*value=*/50000, kScriptPubkey, + sizeof(kScriptPubkey)}; + + uint8_t s2_digest[32], t1_digest[32], s2_again[32]; + ASSERT_TRUE( + zcash_compute_orchard_transparent_sig_digest(&in, 1, &out, 1, s2_digest)); + ASSERT_TRUE(zcash_compute_transparent_digest(&in, 1, &out, 1, t1_digest)); + ASSERT_TRUE( + zcash_compute_orchard_transparent_sig_digest(&in, 1, &out, 1, s2_again)); + EXPECT_NE(memcmp(s2_digest, t1_digest, 32), 0); // S.2 != T.1 (the fix) + EXPECT_EQ(memcmp(s2_digest, s2_again, 32), 0); // deterministic +} + +// Malformed digest info (a nonzero script with a NULL pointer) is refused. +TEST(Zcash, OrchardTransparentSigDigest_RejectsMalformedInfo) { + ZcashTransparentOutputDigestInfo bad = {/*value=*/1, + /*script_pubkey=*/nullptr, + /*script_pubkey_size=*/25}; + uint8_t digest[32]; + EXPECT_FALSE(zcash_compute_orchard_transparent_sig_digest(nullptr, 0, &bad, 1, + digest)); +}