diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..e3f1a4375 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,47 @@ +# Copilot code review instructions + +How reviews on this repository should be conducted. These govern the review +process itself, not what counts as good code. + +## Report everything in one pass + +Report every finding you have in a single review. Do not hold findings back for a +later round. + +- Review the complete diff — every changed file, every changed hunk. Do not stop + partway through. +- Finding several problems early is not a reason to end the review. It is a reason + to keep going. +- Do not sample. If a file has twelve problems, report twelve. +- Comment on every instance of a repeated problem, not just the first. If the same + mistake appears in six places, mark all six — the author fixes what is marked, so + "and similar issues elsewhere" leaves five defects in the branch. + +## Include minor and uncertain findings + +Do not filter down to only the findings you are most confident about. + +- Report low-severity and minor issues alongside significant ones. +- Report a finding you are less than certain about, and say plainly what you are + unsure of. A finding the author dismisses in ten seconds costs far less than an + entire extra review cycle. +- Label each finding's severity so the author can triage quickly instead of having + to weigh every comment equally. + +## Converge in as few rounds as possible + +Every additional review round costs the author a full cycle. Target: + +- **One round** — ideal. Everything surfaced on the first review. +- **Two or three rounds** — acceptable. +- **Five rounds** — the absolute ceiling. + +A later round should raise only problems introduced by the previous round's fixes. +A problem that was present in the original diff and went unmentioned until round +three is a review failure, not a thorough review. + +## Do not trade thoroughness for brevity + +None of the above is a reason to scrutinize less carefully. The goal is more +findings per review, not faster reviews. Depth of analysis stays the same; what +changes is that all of it arrives at once. \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d858bc43a..44c30c189 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,12 @@ env: BASE_IMAGE: kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 EMU_IMAGE: kkemu-ci +# Least-privilege default. Jobs in this workflow only need repository reads; +# publishing uses DockerHub credentials and does not need a write-capable +# GITHUB_TOKEN. +permissions: + contents: read + jobs: # ═══════════════════════════════════════════════════════════ # STAGE 1: GATE — kill bad PRs in seconds @@ -48,7 +54,7 @@ jobs: timeout-minutes: 3 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -81,27 +87,61 @@ jobs: timeout-minutes: 2 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 - name: Install gitleaks + # 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.0 - curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ - | tar -xz -C /usr/local/bin gitleaks - gitleaks version + GITLEAKS_VERSION=8.30.1 + GITLEAKS_SHA256=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + GITLEAKS_ARCHIVE=/tmp/gitleaks.tar.gz + curl --fail --show-error --location \ + "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}" - name: Run gitleaks - run: gitleaks detect --source . --verbose --redact + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + set -euo pipefail + + # fetch-depth: 0 makes every fork ref available. An unscoped scan + # therefore walks disconnected keepkey-stack histories that are not + # ancestors of this firmware change (#544). Scan only the revisions + # introduced by the triggering event. + if [ "$EVENT_NAME" = "pull_request" ]; then + LOG_OPTS="${PR_BASE_SHA}..${PR_HEAD_SHA}" + elif [ "$EVENT_NAME" = "push" ] && + [[ ! "$PUSH_BEFORE_SHA" =~ ^0+$ ]]; then + LOG_OPTS="${PUSH_BEFORE_SHA}..${GITHUB_SHA}" + elif [ "$EVENT_NAME" = "push" ]; then + LOG_OPTS="${GITHUB_SHA}" + else + gitleaks detect --source . --no-git --verbose --redact + exit 0 + fi + + echo "Scanning revision range: ${LOG_OPTS}" + gitleaks detect --source . --log-opts="${LOG_OPTS}" --verbose --redact static-analysis: runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: false @@ -168,7 +208,7 @@ jobs: echo "cppcheck: clean — zero findings" - name: Upload cppcheck report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a if: always() with: name: cppcheck-report @@ -180,7 +220,7 @@ jobs: timeout-minutes: 2 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -206,6 +246,20 @@ jobs: done [ "$FAILED" = "0" ] || exit 1 + - name: Enforce RNG source invariants + run: | + if grep -q 'RAND_PLATFORM_INDEPENDENT=0' CMakeLists.txt; then + echo "::error::RAND_PLATFORM_INDEPENDENT is a definedness switch, not a value toggle" + exit 1 + fi + grep -q 'add_definitions(-DRAND_PLATFORM_INDEPENDENT)' CMakeLists.txt + grep -q '#ifndef RAND_PLATFORM_INDEPENDENT' lib/rand/rng.c + grep -q 'defined(EMULATOR) && defined(__arm__)' lib/rand/rng.c + if grep -En 'return[[:space:]]+random\(\)' lib/rand/rng.c; then + echo "::error::Emulator cryptography must not use libc random()" + exit 1 + fi + # ═══════════════════════════════════════════════════════════ # STAGE 2: BUILD — compile only after gate passes # ═══════════════════════════════════════════════════════════ @@ -216,7 +270,7 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -230,11 +284,11 @@ jobs: git submodule update --init deps/sca-hardening/SecAESSTM32 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -260,7 +314,7 @@ jobs: run: docker save ${{ env.EMU_IMAGE }} -o /tmp/emu-image.tar - name: Upload emulator image artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: emu-image path: /tmp/emu-image.tar @@ -270,9 +324,20 @@ jobs: needs: [lint-format, static-analysis, check-submodules, secret-scan] 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: + - variant: full + cmake_flags: "" + - variant: bitcoin-only + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -287,7 +352,7 @@ jobs: - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -318,6 +383,7 @@ jobs: ${{ 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 && \ make && \ @@ -341,6 +407,8 @@ jobs: echo "::notice::Firmware v${{ steps.version.outputs.fw_version }} built successfully" - name: Bind ARM outputs to source commits + env: + ARM_VARIANT: ${{ matrix.variant }} run: | python3 - <<'PY' import datetime @@ -375,6 +443,7 @@ jobs: "python_sha": subprocess.check_output( ["git", "rev-parse", "HEAD:deps/python-keepkey"], text=True).strip(), + "variant": os.environ["ARM_VARIANT"], "files": files, } with open("bin/arm-build-manifest.json", "w", encoding="utf-8") as handle: @@ -383,9 +452,11 @@ jobs: PY - name: Upload firmware artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: firmware-v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }} + # 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 @@ -402,7 +473,7 @@ jobs: timeout-minutes: 10 steps: - name: Download emulator image - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: emu-image path: /tmp @@ -424,7 +495,7 @@ jobs: exit \$RC" - name: Upload unit test results - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a if: always() with: name: unit-test-results @@ -435,9 +506,21 @@ jobs: needs: [lint-format, static-analysis, check-submodules, secret-scan] runs-on: ubuntu-latest timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - variant: full + project: kkci-full + python_artifact: python-test-results + oled_artifact: oled-screenshots + - variant: bitcoin-only + project: kkci-bitcoin-only + python_artifact: python-test-results-bitcoin-only + oled_artifact: oled-screenshots-bitcoin-only steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -453,23 +536,35 @@ jobs: - name: Build and run tests (docker compose) working-directory: scripts/emulator run: | + 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 up --build --exit-code-from firmware-unit firmware-unit; FW_RC=$? - docker compose up --build --exit-code-from python-keepkey python-keepkey; PY_RC=$? + docker compose "${COMPOSE_ARGS[@]}" -p ${{ matrix.project }} \ + 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 - mkdir -p ${{ github.workspace }}/test-reports + REPORT_ROOT=${{ github.workspace }}/test-reports/${{ matrix.variant }} + mkdir -p "$REPORT_ROOT" echo "=== Extracting test reports from Docker ===" - PY_CONTAINER=$(docker compose ps -a -q python-keepkey) - FW_CONTAINER=$(docker compose ps -a -q firmware-unit) + PY_CONTAINER=$(docker compose "${COMPOSE_ARGS[@]}" -p ${{ matrix.project }} ps -a -q python-keepkey) + FW_CONTAINER=$(docker compose "${COMPOSE_ARGS[@]}" -p ${{ matrix.project }} ps -a -q firmware-unit) - docker cp "$FW_CONTAINER":/kkemu/test-reports/. ${{ github.workspace }}/test-reports/ || echo "WARN: firmware-unit docker cp failed" - docker cp "$PY_CONTAINER":/kkemu/test-reports/. ${{ github.workspace }}/test-reports/ || echo "WARN: python-keepkey docker cp failed" + docker cp "$FW_CONTAINER":/kkemu/test-reports/. "$REPORT_ROOT/" || echo "WARN: firmware-unit docker cp failed" + docker cp "$PY_CONTAINER":/kkemu/test-reports/. "$REPORT_ROOT/" || echo "WARN: python-keepkey docker cp failed" echo "=== Extracted files ===" - find ${{ github.workspace }}/test-reports -type f | head -30 + find "$REPORT_ROOT" -type f | head -30 + echo "=== Screenshot PNGs ===" + find "$REPORT_ROOT/screenshots" -name '*.png' 2>/dev/null | wc -l + echo "PNGs on host" + echo "firmware-unit exit code: $FW_RC" echo "python-keepkey exit code: $PY_RC" @@ -482,26 +577,39 @@ jobs: [ "$FW_RC" -eq 0 ] && [ "$PY_RC" -eq 0 ] || exit 1 - name: Upload Python test results - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a if: always() with: - name: python-test-results - path: test-reports/python-keepkey/ + name: ${{ matrix.python_artifact }} + path: test-reports/${{ matrix.variant }}/python-keepkey/ + retention-days: 30 + + - name: Upload native test results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + if: always() + with: + name: firmware-unit-results-${{ matrix.variant }} + path: test-reports/${{ matrix.variant }}/firmware-unit/ retention-days: 30 - name: Upload OLED screenshots - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a if: always() with: - name: oled-screenshots - path: test-reports/screenshots/ + name: ${{ matrix.oled_artifact }} + path: test-reports/${{ matrix.variant }}/screenshots/ retention-days: 90 if-no-files-found: error - name: Tear down if: always() working-directory: scripts/emulator - run: docker compose down -v || true + run: | + COMPOSE_ARGS=(-f docker-compose.yml) + if [ "${{ matrix.variant }}" = "bitcoin-only" ]; then + COMPOSE_ARGS+=(-f docker-compose.bitcoin-only.yml) + fi + docker compose "${COMPOSE_ARGS[@]}" -p ${{ matrix.project }} down -v || true # ═══════════════════════════════════════════════════════════ # STAGE 3a-bis: DYLIB TESTS — libkkemu shared lib via python-keepkey @@ -530,7 +638,12 @@ jobs: # pb2 files expect; newer protoc generates Python that requires # newer protobuf runtime, which breaks the python-keepkey suite). # - protobuf 3.20.3 (Python runtime — strict pin). - # - nanopb 0.3.9.4.post3 (the proto generator the firmware build uses). + # - nanopb 0.3.9.4.post3 — NOTE: this is NOT the version the firmware + # builder image uses. The pinned image builds nanopb-0.3.9.8 from source + # (see Dockerfile). This job installs a different generator via pip, so + # headers produced here are not byte-comparable with the firmware build's. + # Tracked in GH #425; do not treat this job's output as provenance for + # the firmware artifacts. # - KK_DEBUG_LINK=ON (default OFF; without it, # fsm_msgDebugLinkGetState is excluded from the build and any # read_layout() call hangs the test). @@ -540,7 +653,7 @@ jobs: timeout-minutes: 25 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -557,7 +670,7 @@ jobs: git submodule update --init deps/googletest - name: Setup Python 3.10 - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: python-version: '3.10' @@ -664,7 +777,7 @@ jobs: # PR. Tagged with the short commit SHA so multiple PR pushes # don't overwrite each other when a reviewer downloads them. if: always() && env.DYLIB_PATH != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: libkkemu-${{ github.event.pull_request.head.sha || github.sha }} path: ${{ env.DYLIB_PATH }} @@ -697,7 +810,7 @@ jobs: -v --tb=short --junit-xml=../../../test-reports/dylib-junit.xml - name: Upload dylib test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: name: python-dylib-test-results @@ -716,40 +829,42 @@ jobs: timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Download unit test results - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: unit-test-results path: test-reports/firmware-unit/ - name: Download python test results - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: python-test-results path: test-reports/python-keepkey/ - name: Download OLED screenshots - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: oled-screenshots path: test-reports/screenshots/ - name: Download dylib test results - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: python-dylib-test-results path: test-reports/ - name: Download ARM firmware - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: firmware-v* path: test-reports/arm/ - merge-multiple: true + # Preserve one directory per product. Full and bitcoin-only contain + # identically named outputs whose bytes intentionally differ. + merge-multiple: false - name: Extract firmware version id: version @@ -771,11 +886,11 @@ jobs: 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/219 + KK_PYTHON_PR: https://github.com/keepkey/python-keepkey/pull/197 run: python3 scripts/generate-test-report.py - name: Upload test report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 if: always() with: name: test-report @@ -802,6 +917,68 @@ jobs: # ═══════════════════════════════════════════════════════════ # STAGE 4: PUBLISH — manual trigger only, all tests must pass + + # ═══════════════════════════════════════════════════════════ + # GATE: one authoritative answer for the whole graph + # ═══════════════════════════════════════════════════════════ + # + # Every build and test job declares `needs: [lint-format, static-analysis, + # check-submodules, secret-scan]`. When one of those gate jobs fails, GitHub + # marks the whole downstream graph SKIPPED rather than failed -- and a skipped + # job is not a red check. The run summary then shows green ticks on whatever + # finished, which reads as healthy unless someone opens the job list. + # + # That has now happened three times on the 7.14.2 line: gitleaks failing on + # develop, lint-format timing out inside its apt.llvm.org install (#471), and + # gitleaks again after an unpinned upstream bump. Each time the ARM build, + # the unit tests and both python suites produced NOTHING while the run looked + # partially green. + # + # This job exists so that cannot happen quietly. It needs every required job, + # runs with `if: always()` so it executes even when they skip, and fails + # unless each one reports exactly `success`. failure, cancelled and skipped + # are all treated as not-success, because for a required job they are. + # + # publish-emulator is deliberately absent: it is workflow_dispatch-only and + # is legitimately skipped on every push and pull_request. + # + # Point branch protection at THIS job rather than the individual ones. It is + # the only check whose green means "the entire graph ran and passed". + ci-gate: + name: CI gate + if: always() + needs: + - lint-format + - secret-scan + - static-analysis + - check-submodules + - build-emulator + - build-arm-firmware + - unit-tests + - python-integration-tests + - python-dylib-tests + - generate-test-report + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Assert every required job succeeded + env: + NEEDS_JSON: ${{ toJSON(needs) }} + run: | + set -euo pipefail + echo "$NEEDS_JSON" | jq -r 'to_entries[] | "\(.value.result)\t\(.key)"' | sort + echo + 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 "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 + fi + echo "All required jobs reported success." + # ═══════════════════════════════════════════════════════════ publish-emulator: @@ -813,12 +990,12 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Download emulator image - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: emu-image path: /tmp @@ -839,7 +1016,7 @@ jobs: docker tag ${{ env.EMU_IMAGE }} kktech/kkemu:v${{ steps.version.outputs.fw_version }} - name: Login to DockerHub - uses: docker/login-action@v4 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 with: username: ${{ secrets.KK_DOCKERHUB_USER }} password: ${{ secrets.KK_DOCKERHUB_PASS }} diff --git a/.github/workflows/mirror-base-image.yml b/.github/workflows/mirror-base-image.yml new file mode 100644 index 000000000..f06ebb317 --- /dev/null +++ b/.github/workflows/mirror-base-image.yml @@ -0,0 +1,68 @@ +# Mirrors the firmware build base image from Docker Hub into this org's GHCR. +# +# Why: every CI job that compiles anything starts by pulling ~650 MB of base +# image. Served from Docker Hub that measured ~34s per job; GHCR serves it to +# GitHub-hosted runners over the same network and does not apply Docker Hub's +# anonymous pull limits. +# +# CI treats the mirror as optional -- ci.yml falls back to Docker Hub with a +# warning if the pull fails -- so this workflow never becomes a hard +# dependency of the build. Run it once to populate the mirror, and again +# whenever BASE_IMAGE in ci.yml is bumped. +name: Mirror base image + +on: + workflow_dispatch: + inputs: + source_image: + description: 'Docker Hub image to mirror (must match BASE_IMAGE in ci.yml)' + required: true + type: string + default: 'kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2' + +permissions: + contents: read + packages: write + +jobs: + mirror: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Log in to GHCR + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull, tag and push + env: + SOURCE: ${{ inputs.source_image }} + run: | + set -euo pipefail + + # The source is constrained to the upstream base image, and the + # destination name is fixed rather than derived from the input. + # + # Both matter. ci.yml pulls BASE_IMAGE_MIRROR and builds firmware + # from it, so whatever lands at ghcr.io//firmware: is + # trusted by every subsequent build. Deriving the destination from + # the input -- as this previously did, via ${SOURCE##*/} -- meant a + # dispatch of an attacker-controlled image would resolve to that same + # destination and overwrite the image CI trusts. Dispatch needs + # write access, but "a writer can typo" and "a writer can silently + # replace the firmware build base" are different blast radii. + if [[ ! "${SOURCE}" =~ ^kktech/firmware@sha256:[0-9a-f]{64}$ ]]; then + echo "::error::refusing to mirror '${SOURCE}'. This workflow only mirrors a digest-pinned kktech/firmware image." + exit 1 + fi + + DIGEST="${SOURCE##*@sha256:}" + DEST="ghcr.io/${{ github.repository_owner }}/firmware:sha256-${DIGEST}" + echo "mirroring ${SOURCE} -> ${DEST}" + docker pull "${SOURCE}" + docker tag "${SOURCE}" "${DEST}" + docker push "${DEST}" + echo "::notice::Mirrored ${SOURCE} to ${DEST}" + echo "Set BASE_IMAGE_MIRROR in ci.yml to ${DEST} if it differs." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 170aa22b3..f9319365f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: ci_run_id: ${{ steps.evidence.outputs.ci_run_id }} arm_artifact: ${{ steps.evidence.outputs.arm_artifact }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: submodules: recursive @@ -92,22 +92,30 @@ jobs: 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)] - if len(arm) != 1: - raise SystemExit('expected one exact ARM artifact, found %r' % arm) + 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_artifact=%s\n' % arm[0]) + 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 env: GH_TOKEN: ${{ github.token }} CI_RUN_ID: ${{ steps.evidence.outputs.ci_run_id }} - ARM_ARTIFACT: ${{ steps.evidence.outputs.arm_artifact }} + 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_ARTIFACT" --dir audited-arm + --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 @@ -140,25 +148,51 @@ jobs: pdf = report_dir / manifest['pdf']['path'] if digest(pdf) != manifest['pdf']['sha256']: raise SystemExit('presign PDF hash mismatch') - arm_manifest = arm_dir / 'arm-build-manifest.json' - if digest(arm_manifest) != manifest['arm']['manifest_sha256']: - raise SystemExit('ARM manifest hash mismatch') - expected = {item['name']: item['sha256'] - for item in manifest['arm']['files']} - actual = {path.name: digest(path) - for path in arm_dir.iterdir() - if path.suffix in ('.bin', '.elf')} - if not expected or actual != expected: - raise SystemExit('audited ARM artifact set or hash mismatch') - print('exact presign evidence and ARM binaries verified') + 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 build-firmware: needs: validate runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # 'suffix' names the published files; 'variant' is the internal build + # selector. The default build takes an EMPTY suffix so its assets keep + # the names every previous release used (v7.14.x shipped + # firmware.keepkey.bin). Only bitcoin-only is qualified, because it is + # the unusual one. + - variant: full + suffix: "" + cmake_flags: "" + - variant: bitcoin-only + suffix: "-bitcoin-only" + cmake_flags: "-DKK_BITCOIN_ONLY=ON" timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 - name: Download audited release inputs env: @@ -206,34 +240,48 @@ jobs: - name: Compute hashes working-directory: release run: | - echo "# KeepKey Firmware v${{ needs.validate.outputs.fw_version }} — Hash Manifest" > HASHES.txt - echo "" >> HASHES.txt - for f in *.bin; do - [ -f "$f" ] || continue - FULL_HASH=$(sha256sum "$f" | awk '{print $1}') - echo "sha256 (full) $f $FULL_HASH" >> HASHES.txt - 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" >> HASHES.txt - fi - echo "" >> HASHES.txt - done - cat HASHES.txt + 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" - name: Rename artifacts working-directory: release run: | VER="${{ needs.validate.outputs.fw_version }}" - [ -f firmware.keepkey.bin ] && mv firmware.keepkey.bin "firmware.keepkey.v${VER}.bin" - [ -f firmware.keepkey.elf ] && mv firmware.keepkey.elf "firmware.keepkey.v${VER}.elf" + 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 - name: Upload release artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: release-firmware + # Per-variant, because upload-artifact v4+ makes names immutable: two + # matrix legs writing one name is a hard failure, not a merge. + name: release-firmware-${{ matrix.variant }} path: release/* retention-days: 90 @@ -242,13 +290,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: submodules: recursive - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -274,19 +322,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 - name: Download firmware artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: - name: release-firmware + pattern: release-firmware-* path: artifacts + merge-multiple: true - name: Prepare release assets run: | mkdir -p release-assets cp artifacts/*.bin artifacts/*.elf artifacts/*.pdf artifacts/*.json \ - artifacts/HASHES.txt release-assets/ + artifacts/HASHES*.txt release-assets/ ls -lh release-assets/ - name: Generate release body @@ -315,7 +364,7 @@ jobs: EOF - name: Create draft release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 with: draft: true name: "Firmware v${{ needs.validate.outputs.fw_version }}" diff --git a/.gitignore b/.gitignore index 24e7efbbd..6a4df67a2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ build .DS_Store .vscode/ +.claude/ + +# cppcheck output (static-analysis writes this at repo root in CI) +cppcheck_report.txt +cppcheck_annotations.txt diff --git a/.gitleaks.toml b/.gitleaks.toml index 58b545e4d..acbb4e09a 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,22 +1,43 @@ -title = "KeepKey firmware gitleaks configuration" +# gitleaks configuration for keepkey-firmware +# +# Extends the stock gitleaks rule set — every default rule stays active. This +# file only adds narrowly-scoped allowlists for false positives that recur +# across commits, which .gitleaksignore cannot express: its entries are keyed +# by commit SHA, so a rebased, cherry-picked, or merged commit reintroduces the +# same finding under a new fingerprint. Prefer .gitleaksignore for a genuine +# one-off; add here only when the same content keeps coming back. [extend] useDefault = true -# Published U2F attestation material and historical vendored test vectors. +# Published U2F attestation material and vendored third-party code. +# +# Deliberately NOT allowlisted: any first-party test tree. An earlier revision +# of this branch carried '''^tests/''' here, which turned off secret detection +# for every first-party test present or future -- a real credential committed +# under it would have passed CI. No such directory exists in this repository, +# so the entry exempted nothing and only stood ready to exempt something later. +# If a fixture ever does trip a rule, allowlist that path or its fingerprint, +# not the tree above it. [[allowlists]] -description = "Public device material and third-party historical fixtures" +description = "Public device material and vendored third-party code" paths = [ '''include/keepkey/firmware/u2f/trezordevkey\.pem''', '''include/keepkey/firmware/u2f/u2f_keys\.h''', '''^deps/''', - '''^tests/''', ] +# Release/security docs record the exact submodule commits a build pins, e.g. +# - python-keepkey: `c406a1ba9120da410c356dbff7f4d4bd1e1758fa`; +# A 40-char lowercase hex git SHA has enough entropy to trip generic-api-key. +# Scoped with condition = "AND" so this only exempts SHA-shaped strings inside +# markdown docs — a real credential in docs/ is still reported. +# +# This supersedes an earlier, narrower rule on the release branch that matched +# only python-keepkey pins under generic-api-key; every string that one +# exempted is a subset of this one. [[allowlists]] -description = "Documented python-keepkey commit pins" -targetRules = ["generic-api-key"] +description = "Git commit SHAs quoted in markdown docs" condition = "AND" -paths = ['''^docs/'''] -regexes = ['''python-keepkey[^\n]{0,16}`[0-9a-f]{40}`'''] -regexTarget = "match" +paths = ['''(^|/)docs/.*\.md$'''] +regexes = ['''\b[0-9a-f]{40}\b'''] diff --git a/.gitmodules b/.gitmodules index 2d6c4446a..b9bd6bd1f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,7 +14,7 @@ url = https://github.com/keepkey/code-signing-keys.git [submodule "deps/python-keepkey"] path = deps/python-keepkey url = https://github.com/keepkey/python-keepkey.git -branch = master +branch = reconcile/upstream-sync [submodule "deps/qrenc/QR-Code-generator"] path = deps/qrenc/QR-Code-generator url = https://github.com/keepkey/QR-Code-generator.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 684065668..413dae82e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ endif() project( KeepKeyFirmware - VERSION 7.14.2 + VERSION 7.14.3 LANGUAGES C CXX ASM) set(BOOTLOADER_MAJOR_VERSION 2) @@ -20,6 +20,8 @@ option(KK_EMULATOR "Build the emulator" OFF) option(KK_BUILD_DYLIB "Build libkkemu shared library (.dylib/.so)" OFF) option(KK_DEBUG_LINK "Build with debug-link enabled" OFF) option(KK_BUILD_FUZZERS "Build the fuzzers?" OFF) +option(KK_BITCOIN_ONLY "Build Bitcoin-only firmware (strip all non-BTC coins)" + OFF) # When building the dylib, every static lib it links (kkfirmware, kkboard, # trezorcrypto, kkrand, kktransport, qrcodegenerator, SecAESSTM32, ...) must @@ -97,13 +99,21 @@ add_definitions(-DED25519_FORCE_32BIT=1) add_definitions(-DUSE_PRECOMPUTED_CP=0) -add_definitions(-DUSE_ETHEREUM=1) +if(${KK_BITCOIN_ONLY}) + # Strip the coin-specific trezor-crypto primitives whose only callers + # (ethereum.c / nano.c) are themselves compiled out of this image. KECCAK + # stays on: it is a generic hash, and the saving is not worth the risk. + add_definitions(-DUSE_ETHEREUM=0) + add_definitions(-DUSE_NANO=0) +else() + add_definitions(-DUSE_ETHEREUM=1) + add_definitions(-DUSE_NANO=1) +endif() add_definitions(-DUSE_KECCAK=1) add_definitions(-DUSE_GRAPHENE=0) add_definitions(-DUSE_CARDANO=0) add_definitions(-DUSE_MONERO=0) add_definitions(-DUSE_NEM=0) -add_definitions(-DUSE_NANO=1) # 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 @@ -133,6 +143,16 @@ 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. +if(${KK_BITCOIN_ONLY}) + add_definitions(-DBITCOIN_ONLY=1) +else() + add_definitions(-DBITCOIN_ONLY=0) +endif() + if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") add_definitions(-DDEBUG_ON) add_definitions(-DMEMORY_PROTECT=0) diff --git a/deps/crypto/CMakeLists.txt b/deps/crypto/CMakeLists.txt index cd735668c..2a9fbff97 100644 --- a/deps/crypto/CMakeLists.txt +++ b/deps/crypto/CMakeLists.txt @@ -32,6 +32,7 @@ set(sources #trezor-firmware/crypto/tests/test_openssl.c #trezor-firmware/crypto/tests/test_speed.c trezor-firmware/crypto/secp256k1.c + trezor-firmware/crypto/bip340.c trezor-firmware/crypto/bignum.c trezor-firmware/crypto/segwit_addr.c trezor-firmware/crypto/ripemd160.c diff --git a/deps/crypto/trezor-firmware b/deps/crypto/trezor-firmware index 03d8a55a8..cdc05bebe 160000 --- a/deps/crypto/trezor-firmware +++ b/deps/crypto/trezor-firmware @@ -1 +1 @@ -Subproject commit 03d8a55a832fb61bb89477ef7239a80ecb367080 +Subproject commit cdc05bebe9e6989cf711e1b5bea6324fd09f848e diff --git a/deps/device-protocol b/deps/device-protocol index f2c3c005a..8545cd5b6 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit f2c3c005ad824df11aec9592a8b62066323ba282 +Subproject commit 8545cd5b615f5832374afbf06387a3f28869285e diff --git a/deps/python-keepkey b/deps/python-keepkey index 92e8745e5..9c3982035 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 92e8745e56985a05049a0f458226a29f94ca3532 +Subproject commit 9c3982035664dbec44c6d1d60db6edb9fa59713c diff --git a/docs/DiceEntropy.md b/docs/DiceEntropy.md new file mode 100644 index 000000000..c3394591b --- /dev/null +++ b/docs/DiceEntropy.md @@ -0,0 +1,90 @@ +# 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. + +## What happens + +`reset.c:reset_init()`, when `dice_entropy` is set: + +1. `dice_input_collect()` gathers rolls on the device's own button — short press + selects 1-6, long press commits. 50 rolls for a 12-word seed, 75 for 18, 99 + for 24 (`dice_rolls_for_strength`). Rolls are stored as ASCII `'1'`-`'6'`, + one byte each. +2. `dice_digest = SHA256(rolls)`. The first 8 bytes are shown on the OLED as 16 + hex characters, with the roll count, on a confirm screen. +3. `dice_mix(int_entropy, rolls, count)` replaces the internal entropy with + `SHA256(int_entropy || rolls)` (`dice_input.c:138`). +4. Only then does the device send `EntropyRequest`, so the host's contribution + arrives strictly after the device has committed to its own. + +Cancelling at any point aborts the reset and zeroes the buffers. Nothing is +stored. + +## What the digest proves + +The digest is over the rolls, and nothing else. A user who wrote their rolls +down can recompute it: + +``` +printf '536142...' | shasum -a 256 # first 16 hex chars == displayed digest +``` + +A match proves the device recorded exactly that sequence, in that order, with +none dropped or substituted. That is the whole purpose of the digest, and it is +worth doing — it catches a device that quietly ignores button presses. + +## What the digest does not prove + +It does not prove the rolls reached the seed. `dice_mix()` is a separate step, +and neither `int_entropy` nor the mixed result is ever displayed. Firmware that +showed a correct digest and then skipped the mix would look identical from the +outside. + +This is deliberate. An earlier revision displayed the mixed internal entropy and +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()`. + +The roll digest is safe by contrast because it hashes the user's own input, not +seed material. + +## Why there is no tool for this + +There cannot be a host-side verifier for the mixing step, and adding one would +be a security regression rather than a feature. + +Any such tool would need the device to disclose seed-derived material for the +host to check against — which is the exact disclosure the design refuses. A +verifier that instead reports "the device says it mixed" proves nothing: it +relays a claim from the component whose honesty is in question. Worse, it +manufactures false assurance, and a user who trusts a green checkmark is in a +worse position than one who knows the mix is unverified. + +So the assurance chain is not a tool. It is: + +1. **The digest** proves your rolls were captured. +2. **The published source** proves what the firmware does with them. +3. **The firmware hash** proves the binary you are running is that source. + +Step 2 is the one that carries the weight, and it is not delegable — the user +verifies the code, or nobody does. Step 3 is what `Features.firmware_hash` and +the vault's `firmwareVerified` field exist for; unreleased RC builds report +`false` because their hashes are not in the shipped table. + +## Scope + +Dice cannot make the seed worse: the mix is a hash over both sources, so the +result is at least as unpredictable as the RNG alone. They are worth the effort +only if the RNG is what you distrust — and you are trusting the same firmware +either way. diff --git a/docs/security/7.14.3-bitcoin-only-dice-audit-sop.md b/docs/security/7.14.3-bitcoin-only-dice-audit-sop.md new file mode 100644 index 000000000..ab5fe6903 --- /dev/null +++ b/docs/security/7.14.3-bitcoin-only-dice-audit-sop.md @@ -0,0 +1,159 @@ +# Firmware 7.14.3 Bitcoin-only + Dice Audit SOP + +## Purpose and audit identity + +This document is the repeatable security-audit contract for the KeepKey +firmware 7.14.3 Bitcoin-only image with on-device Dice entropy. It is part of +the reviewed tree so the scope cannot silently diverge from the code under +review. + +The initial immutable audit baseline is +`2c4b7021041bd14667dd6d9c1ac282974e24d6c0` from +`BitHighlander/keepkey-firmware:release/7.14.3-bitcoin-only`. Every evidence +record MUST also name the final tested head and all submodule object IDs. + +The security objective is narrower and stronger than “the tests pass”: + +1. the Bitcoin-only binary exposes only the intended Bitcoin, setup, Dice, + authenticator, U2F, and device-management surfaces; +2. every byte that affects a Bitcoin signature is either semantically decoded + or displayed exactly before approval; +3. Dice input is mixed into seed entropy with an unambiguous ceremony state, + is wiped on every exit, and cannot be neutralized or replayed by the host; +4. secrets and signing state are erased when authorization is lost; +5. build, test, report, and release gates fail closed and describe the exact + bytes being promoted. + +## In-scope review ledger + +“Line review” means reading every executable line in the named unit at the +recorded commit, including error paths and compile-time branches. Line numbers +are navigation aids; function/file anchors remain authoritative after edits. + +| Surface | Required line-review unit | Security invariant | +| --- | --- | --- | +| Variant selection | `CMakeLists.txt` options/definitions and `lib/firmware/CMakeLists.txt` in full | `BITCOIN_ONLY` is always 0 or 1; non-Bitcoin engines are absent from the link, not merely hidden at runtime. | +| Protocol reachability | `lib/firmware/messagemap.def`, `fsm.c`, `fsm_msg_coin.h`, and Bitcoin-reachable portions of `fsm_msg_common.h` in full | No compiled or registered handler reaches an excluded coin engine; initialization, cancellation, and debug messages cannot cross ceremony boundaries. | +| Bitcoin signing | `lib/firmware/signing.c`, `transaction.c`, `txin_check.c`, and `crypto.c` in full | Inputs, outputs, quorum, script type, change, fee weight, Taproot sighash, serialized result, and every OP_RETURN byte are bound to approval. | +| Confirmation renderer | `lib/firmware/app_confirm.c` and `app_layout.c` in full | Size-delimited data never becomes an unbounded C string; pagination cannot truncate, omit, or approve only a prefix. | +| Dice ceremony | `lib/firmware/dice_input.c`, `reset.c`, and `include/keepkey/firmware/dice_input.h` in full | Roll count and alphabet are exact; cancel/re-entry disarms; roll buffers/digests are wiped; mixing occurs before host entropy; Dice and internal-entropy display cannot be combined. | +| RNG boundary | `lib/rand/rng.c`, `rng_health.c`, their headers, and every `random_buffer_checked` call site | The shipping ARM build selects hardware RNG; health failure is latched; rejected bytes are wiped; all documented key-material draws use the checked path. | +| Setup/recovery/storage | `reset.c`, `recovery_cipher.c`, `storage.c`, and their public headers in full | Staged settings commit atomically with the seed; failure/cancel paths wipe confidential data; the Bitcoin-only storage band cannot be downgraded or opened by a regular build. | +| Session secrets | `authenticator.c`, `storage.c` session functions, `fsm.c` lock/wipe handlers, and `transaction.c` caches | Lock, wipe, initialize, and session clear remove seed-derived nodes, signing state, authenticator secrets, PIN-derived material, and Dice state. | +| Wire parser | `include/pb.h`, `lib/transport/pb_decode.c`, generated field descriptors, and all Bitcoin/setup `.options` entries | Declared byte/string/count limits are exact; odd-sized buffers, oneofs, and repeated fields cannot cross schema capacity. | +| Firmware authenticity | `lib/board/signatures.c`, bootloader checks, `code-signing-keys` pin, `.github/workflows/release.yml`, and manifest generation | Signature quorum and key identity fail closed; every published variant has a hash; CI cannot overwrite or relabel unverified bytes. | +| Test trust | `.github/workflows/ci.yml`, `scripts/emulator/*`, native CMake test manifests, and pinned Python test/report selection | Both variants compile; Bitcoin-only native tests execute; Dice device tests execute rather than skip; any test failure reaches a nonzero CI gate; reports require native and screenshot evidence. | +| Dependency identity | `.gitmodules` plus every gitlink | A clean checkout resolves every exact object from its committed public remote; tests cannot silently substitute a branch tip. | + +The regular multi-coin image is regression-built because the release workflow +publishes both variants. Non-Bitcoin transaction semantics are not certified by +this audit; changes that affect those engines require their own audit record. + +## Required procedure + +1. Fetch the fork branch and record `git rev-parse HEAD`, `git submodule status + --recursive`, Docker image digest, compiler versions, and host architecture. +2. Start from a clean isolated worktree. Local submodule URL overrides MUST be + replaced by the committed `.gitmodules` URLs before claiming reproducibility. +3. Build Docker-first with the pinned base image. Build the emulator and ARM + firmware with `KK_BITCOIN_ONLY=ON`; also regression-build the regular image. +4. Enumerate the Bitcoin-only link inputs and message map. Compare them against + the regular image so excluded handlers are proven absent. +5. Perform the line review in the ledger above. For each user-controlled length, + count, enum, script type, state flag, and pointer, identify validation before + first use and the failure cleanup path. +6. Trace every signature-affecting field from protobuf decode through hashing + and OLED approval. Adversarially test boundary values, streamed/tail data, + embedded NULs, non-ASCII bytes, malformed quorum, cancel/re-entry, and + unsupported semantic payloads. +7. Trace Dice rolls from button classification through digest display, entropy + mixing, host `EntropyAck`, mnemonic creation, commit, and every abort path. +8. Positive-control every CI gate: introduce a known failing native test in a + disposable tree and prove Compose and the aggregate gate fail nonzero. +9. File each confirmed defect in `BitHighlander/keepkey-firmware` before or with + its fix. The issue must name the affected branch/head, exploit or failure + condition, security impact, regression test, and resolving commit/PR. +10. Re-run the entire clean suite at the immutable final head. Evidence from an + earlier head may support diagnosis but cannot satisfy the merge gate. + +## Auditor-of-auditor control + +A second review MUST reconcile claims against code, tracker state, artifacts, +and immutable commit IDs. It must not treat elapsed time, a green aggregate +badge, the existence of screenshots, or another auditor's prose as evidence by +itself. It verifies that: + +- every claimed test actually executed and did not skip; +- every report row maps to an artifact from the same head; +- every issue marked resolved has a reachable fix and regression test; +- dirty worktrees, stale submodule URLs, cached Docker layers, and unrelated + containers did not alter the result; +- waiting loops terminate on a state transition or report a real blocker. + +## Merge gates and current findings + +PR #604 remains a draft until all gates below are satisfied: + +- [ ] clean Bitcoin-only ARM build and emulator link at the final head; +- [ ] clean regular ARM build as a regression boundary; +- [ ] Bitcoin-only native, Dice/RNG, Python integration, screenshot, parser, + and signing regressions pass with zero failures; +- [ ] native/Compose failures are proven to propagate nonzero; +- [ ] static analysis, format, submodule, secret-scan, and aggregate CI gates + are green or a narrowly documented repository-history exception is + approved by a maintainer; +- [ ] release manifests identify both variant bytes and signing provenance; +- [ ] all in-scope findings are closed by the final reachable commit. + +Confirmed findings at the initial baseline: + +| Issue | Finding | Required resolution | +| --- | --- | --- | +| #528 | Compose masks `make xunit` failures because the script exits with `cp` status. | Preserve reports and exit with the captured test status. | +| #449 | Host-controlled multisig `m` reaches fee-weight accounting before complete quorum validation. | Validate `1 <= m <= n <= 15` before first use. | +| #531 | Binary/size-delimited OP_RETURN confirmation truncates or treats bytes as a C string. | Route the exact declared length through the byte pager. | +| #597 | Unsupported Omni payloads are approved behind a generic “Unknown Transaction” screen. | Display every raw payload byte before signing. | +| #446 | Token generation can emit an empty table while the build remains green. | Reject missing or zero-row generated definitions before linking either release variant. | +| #608 | CI only links the Bitcoin-only image; every Python Bitcoin-only product test is version-skipped at 7.14.3. | Run native and Python integration legs against `KK_BITCOIN_ONLY=ON`, lower the exact companion gate, and require the results. | +| #609 | The Bitcoin-only product still advertises and accepts compiled-out altcoin and token table entries. | Compile the coin/token tables down to Bitcoin and Testnet and bound `GetCoinTable` to those rows. | +| #610 | An OP_RETURN-only signing request leaves the duplicate-transaction digest context finalized, corrupting the next signing request. | Reset the current input digest at every signing boundary while preserving the prior completed transaction used by the warning. | +| #544 | An unscoped full-history gitleaks run scans disconnected keepkey-stack refs and blocks every firmware build/test lane. | Scan only the event revision range; do not suppress findings; checksum and version-assert the single scanner binary. | + +## Evidence commands + +The final PR record must include outputs or artifacts equivalent to: + +```sh +git status --short +git rev-parse HEAD +git submodule status --recursive + +docker build --build-arg coinsupport=-DKK_BITCOIN_ONLY=ON \ + -f scripts/emulator/Dockerfile . +docker run --rm --entrypoint /bin/sh -c 'make xunit' + +docker compose -f scripts/emulator/docker-compose.yml up --build \ + --exit-code-from python-keepkey python-keepkey +``` + +CI URLs, test counts, skipped-test identities, screenshot counts, artifact +digests, and the final firmware/hash-manifest names belong in the PR body or a +final attestation comment. A result without the exact final head is incomplete. + +## Candidate evidence (not the final attestation) + +The corrected source candidate was built on an ARM64 host through the pinned +AMD64 Docker base +`kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2`. +The Bitcoin-only emulator/native image produced these results: + +- firmware native: 56 passed; +- board native: 9 passed; +- crypto native: 18 passed; +- Python product suite: 253 passed, 415 skipped, 0 failed; +- the Dice device flow, OP_RETURN cross-transaction regression, same-request + control, and structured/raw many-output controls all executed and passed. + +This evidence authorizes publication of a draft for independent CI review. It +does not satisfy the final-head gate above: the PR checks and final attestation +must bind the same results to the signed commit ultimately proposed for merge. diff --git a/include/keepkey/board/confirm_sm.h b/include/keepkey/board/confirm_sm.h index a2a77d1a1..63de8312b 100644 --- a/include/keepkey/board/confirm_sm.h +++ b/include/keepkey/board/confirm_sm.h @@ -144,6 +144,23 @@ bool confirm_with_custom_layout(layout_notification_t layout_notification_func, const char* request_body, ...) __attribute__((format(printf, 4, 5))); +/// Address/xpub verification, custom layout -- the layout is HONORED. +/// +/// Unlike confirm_with_custom_layout(), which routes consent screens through +/// the measured standard renderer, this keeps the caller's renderer so the +/// address QR code survives. Use it only for screens that display a +/// device-derived public value for checking; anything the owner is consenting +/// to sign belongs on confirm_with_custom_layout(). +/// \param layout_notification_func Layout callback. +/// \param type The kind of button request to send to the host. +/// \param request_title Title of confirm message. +/// \param request_body Body of confirm message. +/// \returns true iff the device confirmed. +bool confirm_address_with_custom_layout( + layout_notification_t layout_notification_func, ButtonRequestType type, + const char* request_title, const char* request_body, ...) + __attribute__((format(printf, 4, 5))); + /// User confirmation. /// /// Does not message the host for ButtonAcks. diff --git a/include/keepkey/firmware/app_confirm.h b/include/keepkey/firmware/app_confirm.h index 4d1777655..452eb5809 100644 --- a/include/keepkey/firmware/app_confirm.h +++ b/include/keepkey/firmware/app_confirm.h @@ -53,15 +53,6 @@ bool format_sign_identity_key_selection(const IdentityType* identity, bool confirm_sign_identity(const IdentityType* identity, const char* challenge, const char* curve); -/** - * Render the largest screen-sized prefix of a byte string. - * - * Whitespace, backslashes, controls, and non-ASCII bytes use an unambiguous - * \xNN spelling. This prevents the OLED renderer from discarding leading - * spaces or interpreting newlines while preserving readable printable text. - * - * \returns the number of input bytes represented in out, or zero on error. - */ /// Escape every byte of `data` into `out`, exactly as confirm_bytes() renders /// it, but without paging: bytes outside 0x21..0x7E, and '\\' itself, become a /// four-glyph \\xNN escape. @@ -79,6 +70,15 @@ bool confirm_sign_identity(const IdentityType* identity, const char* challenge, bool confirm_bytes_escape(const uint8_t* data, size_t size, char* out, size_t out_len); +/** + * Render the largest screen-sized prefix of a byte string. + * + * Whitespace, backslashes, controls, and non-ASCII bytes use an unambiguous + * \xNN spelling. This prevents the OLED renderer from discarding leading + * spaces or interpreting newlines while preserving readable printable text. + * + * \returns the number of input bytes represented in out, or zero on error. + */ size_t confirm_bytes_format_page(const uint8_t* data, size_t size, char* out, size_t out_len); diff --git a/include/keepkey/firmware/authenticator.h b/include/keepkey/firmware/authenticator.h index 0a63868c5..ccd285f58 100644 --- a/include/keepkey/firmware/authenticator.h +++ b/include/keepkey/firmware/authenticator.h @@ -18,6 +18,8 @@ #ifndef __AUTHENTICATOR_H__ #define __AUTHENTICATOR_H__ +#include + // WARNING: Changing these defines changes the size of authStruct, which in turn // changes the secret storage size in saved in flash. These value must be // coordinated with the size of uint8_t encrypted_sec[] in in @@ -70,8 +72,12 @@ unsigned addAuthAccount(char* accountWithSeed); unsigned getAuthAccount(const char* slotStr, char acc[]); unsigned removeAuthAccount(char* domAcc); unsigned wipeAuthData(void); +/* Drop plaintext TOTP state without modifying encrypted persistent accounts. + * The next authorized operation must reload it from storage. */ void authenticator_clear_cache(void); #if DEBUG_LINK void getAuthSlot(char* authSlotData); +bool authenticator_cache_is_empty(void); +void authenticator_test_seed_cache(void); #endif #endif diff --git a/include/keepkey/firmware/binance.h b/include/keepkey/firmware/binance.h index 035b37094..31a15a148 100644 --- a/include/keepkey/firmware/binance.h +++ b/include/keepkey/firmware/binance.h @@ -17,12 +17,20 @@ typedef struct _BinanceTransferMsg_BinanceCoin BinanceCoin; bool binance_isValidDenom(const char* denom); bool binance_validateTransfer(const BinanceTransferMsg* transfer); bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg); + +/// The single bech32 prefix this session's chain_id permits, or NULL when no +/// session is active. Every input and output address must carry it. +const char* binance_sessionAddressPrefix(void); bool binance_serializeCoin(const BinanceCoin* coin); bool binance_serializeInputOutput(const BinanceInputOutput* io); bool binance_signTxUpdateTransfer(const BinanceTransferMsg* _msg); bool binance_signTxUpdateMsgSend(const uint64_t amount, const char* to_address); bool binance_signTxFinalize(uint8_t* public_key, uint8_t* signature); bool binance_signingIsInited(void); + +/// True iff `address` is the account this session's key signs as. Use for a +/// transfer's input, which is its authority. +bool binance_addressIsSigner(const char* address); bool binance_signingIsFinished(void); void binance_signAbort(void); const BinanceSignTx* binance_getBinanceSignTx(void); diff --git a/include/keepkey/firmware/coins.def b/include/keepkey/firmware/coins.def index 5d872061c..4d3102c08 100644 --- a/include/keepkey/firmware/coins.def +++ b/include/keepkey/firmware/coins.def @@ -2,6 +2,7 @@ //coin_name coin_shortcut address_type maxfee_kb p2sh signed_message_header bip44_account_path forkid/chain_id decimals contract_address xpub_magic segwit force_bip143 curve_name cashaddr_prefix bech32_prefix decred xpub_magic_segwit_p2sh xpub_mmagic_segwit_native nanoaddr_prefix taproot X(true, "Bitcoin", true, "BTC", true, 0, true, 100000, true, 5, true, "Bitcoin Signed Message:\n", true, 0x80000000, false, 0, true, 8, false, NO_CONTRACT, true, 76067358, true, true, true, false, true, SECP256K1_STRING, false, "", true, "bc", false, false, true, 77429938, true, 78792518, false, "", true, true ) X(true, "Testnet", true, "TEST", true, 111, true, 10000000, true, 196, true, "Bitcoin Signed Message:\n", true, 0x80000001, false, 0, true, 8, false, NO_CONTRACT, true, 70617039, true, true, true, false, true, SECP256K1_STRING, false, "", true, "tb", false, false, true, 71979618, true, 73342198, false, "", true, true ) +#if !BITCOIN_ONLY X(true, "BitcoinCash", true, "BCH", true, 0, true, 500000, true, 5, true, "Bitcoin Signed Message:\n", true, 0x80000091, true, 0, true, 8, false, NO_CONTRACT, true, 76067358, true, false, true, true, true, SECP256K1_STRING, true, "bitcoincash", false, "", false, false, false, 0, false, 0, false, "", true, false ) X(true, "Namecoin", true, "NMC", true, 52, true, 10000000, true, 5, true, "Namecoin Signed Message:\n", true, 0x80000007, false, 0, true, 8, false, NO_CONTRACT, true, 27108450, true, false, true, false, true, SECP256K1_STRING, false, "", false, "", false, false, false, 0, false, 0, false, "", true, false ) X(true, "Litecoin", true, "LTC", true, 48, true, 1000000, true, 50, true, "Litecoin Signed Message:\n", true, 0x80000002, false, 0, true, 8, false, NO_CONTRACT, true, 27108450, true, true, true, false, true, SECP256K1_STRING, false, "", true, "ltc", false, false, true, 28471030, true, 78792518, false, "", true, false ) @@ -47,6 +48,7 @@ X(true, "Terra", true, "LUNA", false, NA, false, NA, false, N X(true, "Kava", true, "KAVA", false, NA, false, NA, false, NA, false, {0}, true, 0x800001cb, false, 0, true, 6, false, NO_CONTRACT, false, 0, false, false, false, false, true, SECP256K1_STRING, false, "", false, "kava", false, false, false, 0, false, 0, false, "", true, false ) X(true, "Secret", true, "SCRT", false, NA, false, NA, false, NA, false, {0}, true, 0x80000211, false, 0, true, 6, false, NO_CONTRACT, false, 0, false, false, false, false, true, SECP256K1_STRING, false, "", false, "secret", false, false, false, 0, false, 0, false, "", true, false ) X(true, "MAYAChain", true, "CACAO", false, NA, false, NA, false, NA, false, {0}, true, 0x800003a3, false, 0, true, 10, false, NO_CONTRACT, false, 0, false, false, false, false, true, SECP256K1_STRING, false, "", false, "maya", false, false, false, 0, false, 0, false, "", true, false ) +#endif #undef X #undef NO_CONTRACT diff --git a/include/keepkey/firmware/coins.h b/include/keepkey/firmware/coins.h index ef446a3c7..e7ef132f2 100644 --- a/include/keepkey/firmware/coins.h +++ b/include/keepkey/firmware/coins.h @@ -44,9 +44,11 @@ enum { CONCAT(CoinIndex, __COUNTER__), #include "keepkey/firmware/coins.def" +#if !BITCOIN_ONLY #define X(INDEX, NAME, SYMBOL, DECIMALS, CONTRACT_ADDRESS) \ CONCAT(CoinIndex, __COUNTER__), #include "keepkey/firmware/tokens.def" +#endif CoinIndexLast, CoinIndexFirst = 0 diff --git a/include/keepkey/firmware/dice_input.h b/include/keepkey/firmware/dice_input.h new file mode 100644 index 000000000..3ef80c7aa --- /dev/null +++ b/include/keepkey/firmware/dice_input.h @@ -0,0 +1,49 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library. If not, see . + */ + +#ifndef KEEPKEY_FIRMWARE_DICE_INPUT_H +#define KEEPKEY_FIRMWARE_DICE_INPUT_H + +#include +#include + +/* d6 carries log2(6) = 2.585 bits per roll; targets follow the Coldcard + * convention of 50 rolls per 128-bit seed and 99 per 256-bit. */ +#define DICE_MAX_ROLLS 99 + +/// Number of rolls required for a given seed strength (128/192/256). +uint32_t dice_rolls_for_strength(uint32_t strength_bits); + +/// Collect `target` dice rolls on the device with the single button: +/// short press advances the 1-6/UNDO selector, holding the button commits +/// the selection. Announces itself with ButtonRequest_DiceRoll and accepts +/// input only after the host's ButtonAck. Under DEBUG_LINK, characters +/// '1'-'6' and 'u' (undo) arriving in DebugLinkDecision.input are treated +/// as committed selections. +/// +/// Fills `rolls` with `target` ASCII digits '1'-'6' (no terminator is +/// appended past target; the caller owns zeroization). Returns false if the +/// host cancelled (Cancel/Initialize). +bool dice_input_collect(char *rolls, uint32_t target); + +/// entropy = SHA256(entropy[32] || rolls[count]); the caller displays or +/// commits only the post-mix value. +void dice_mix(uint8_t entropy[32], const char *rolls, uint32_t count); + +#endif diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index e352e2555..6b3f65c7c 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -23,6 +23,13 @@ #include "keepkey/transport/interface.h" #include "keepkey/board/messages.h" +/* Scrub the function-static HDNode used by synchronous FSM derivations. */ +void fsm_clearDerivedNode(void); +#if DEBUG_LINK +void fsm_test_seedDerivedNode(void); +bool fsm_test_derivedNodeIsZero(void); +#endif + #define RESP_INIT(TYPE) \ TYPE* resp = (TYPE*)msg_resp; \ _Static_assert(sizeof(msg_resp) >= sizeof(TYPE), #TYPE " is too large"); \ diff --git a/include/keepkey/firmware/mayachain.h b/include/keepkey/firmware/mayachain.h index b564a6896..0d54890da 100644 --- a/include/keepkey/firmware/mayachain.h +++ b/include/keepkey/firmware/mayachain.h @@ -27,6 +27,10 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, bool mayachain_signTxUpdateMsgDeposit(const MayachainMsgDeposit* depmsg); bool mayachain_signTxFinalize(uint8_t* public_key, uint8_t* signature); bool mayachain_signingIsInited(void); + +/// True iff `address` is the account this session's key signs as. Use for +/// MsgDeposit's `signer`, which is serialized verbatim as the authority. +bool mayachain_addressIsSigner(const char* address); bool mayachain_signingIsFinished(void); void mayachain_signAbort(void); const MayachainSignTx* mayachain_getMayachainSignTx(void); diff --git a/include/keepkey/firmware/osmosis.h b/include/keepkey/firmware/osmosis.h index 349016114..28fa0597a 100644 --- a/include/keepkey/firmware/osmosis.h +++ b/include/keepkey/firmware/osmosis.h @@ -80,4 +80,15 @@ bool osmosis_validate_required_text(bool has_value, const char* value); /// Amino coin amounts are non-empty unsigned base-10 integer strings. bool osmosis_validate_amount(bool has_value, const char* value); +/// Safe text AND a bech32 account address on this session's network. +bool osmosis_validate_account_address(bool has_value, const char* value); + +/// Safe text AND a bech32 "valoper" operator address on this session's +/// network. +bool osmosis_validate_validator_address(bool has_value, const char* value); + +/// True iff `address` is the account this session's key signs as. Use for +/// `sender` fields, which are signed verbatim but never displayed. +bool osmosis_address_is_signer(const char* address); + #endif diff --git a/include/keepkey/firmware/recovery_cipher.h b/include/keepkey/firmware/recovery_cipher.h index a332b862c..08d8b56a6 100644 --- a/include/keepkey/firmware/recovery_cipher.h +++ b/include/keepkey/firmware/recovery_cipher.h @@ -47,6 +47,8 @@ void recovery_cipher_reset(void); void recovery_cipher_abort(void); #if DEBUG_LINK +void recovery_cipher_test_set_word_fragments(void); +bool recovery_cipher_test_word_fragments_are_zero(void); const char* recovery_get_cipher(void); const char* recovery_get_auto_completed_word(void); #endif diff --git a/include/keepkey/firmware/reset.h b/include/keepkey/firmware/reset.h index e8a672b9f..e950d1076 100644 --- a/include/keepkey/firmware/reset.h +++ b/include/keepkey/firmware/reset.h @@ -83,12 +83,21 @@ 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); + 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); +/// \returns 32 and fills \a digest with SHA-256 of the roll string, or 0 if +/// the current ceremony collected no dice. Cleared by setup_abort(). +uint32_t reset_get_dice_digest(uint8_t* digest); #endif diff --git a/include/keepkey/firmware/ripple.h b/include/keepkey/firmware/ripple.h index 409052c4a..b16a5a1d7 100644 --- a/include/keepkey/firmware/ripple.h +++ b/include/keepkey/firmware/ripple.h @@ -29,6 +29,18 @@ #define RIPPLE_DECIMALS 6 +/* Version byte of a classic XRP account address. ripple_getAddress() encodes + it and ripple_serializeAddress() discards it, so anything else would sign a + different account than the screen showed. */ +#define RIPPLE_ADDRESS_VERSION 0x00 + +/* The largest drop amount ripple_serializeAmount() can encode. Above this the + value collides with the bits that flag "XRP" and "positive", so the + serializer would emit a different amount than the one supplied. It guarded + this with assert(), which compiles out of release builds -- so the bound has + to be enforced by the message handler instead. */ +#define RIPPLE_MAX_DROPS 100000000000ULL + #define RIPPLE_FLAG_FULLY_CANONICAL 0x80000000 typedef enum { @@ -59,7 +71,13 @@ extern const RippleFieldMapping RFM_destinationTag; bool ripple_getAddress(const uint8_t public_key[33], char address[MAX_ADDR_SIZE]); -void ripple_formatAmount(char* buf, size_t len, uint64_t amount); +/// Render `amount` drops as XRP. +/// \returns false if it does not fit `buf`, in which case the transaction must +/// be refused: an amount the device cannot render is not one it can show. +bool ripple_formatAmount(char* buf, size_t len, uint64_t amount); + +/// True iff `address` decodes to the 21 raw bytes the serializer requires. +bool ripple_validateAddress(const char* address); void ripple_serializeType(bool* ok, uint8_t** buf, const uint8_t* end, const RippleFieldMapping* m); @@ -90,6 +108,8 @@ bool ripple_serialize(uint8_t** buf, const uint8_t* end, const RippleSignTx* tx, const char* source_address, const uint8_t* pubkey, const uint8_t* sig, size_t sig_len); -void ripple_signTx(const HDNode* node, RippleSignTx* tx, RippleSignedTx* resp); +/// \returns false if the transaction could not be serialized or signed, in +/// which case `resp` is incomplete and must not be sent as a success. +bool ripple_signTx(const HDNode* node, RippleSignTx* tx, RippleSignedTx* resp); #endif diff --git a/include/keepkey/firmware/signing.h b/include/keepkey/firmware/signing.h index d3dc67671..2a4433887 100644 --- a/include/keepkey/firmware/signing.h +++ b/include/keepkey/firmware/signing.h @@ -23,8 +23,27 @@ #include "trezor/crypto/bip32.h" #include "keepkey/transport/interface.h" -#include #include +#include +#include + +/// Exposed for unit tests: pure predicate, no signing state involved. +bool isCrossAccountSegwitChangeForbidden(const uint32_t* lhs_address_n, + size_t lhs_address_n_count, + const uint32_t* rhs_address_n, + size_t rhs_address_n_count, + OutputScriptType rhs_script_type); + +/// 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 + void signing_init(const SignTx* msg, const CoinType* _coin, const HDNode* _root); void signing_abort(void); diff --git a/include/keepkey/firmware/signtx_tendermint.h b/include/keepkey/firmware/signtx_tendermint.h index 082fb4970..98934959b 100644 --- a/include/keepkey/firmware/signtx_tendermint.h +++ b/include/keepkey/firmware/signtx_tendermint.h @@ -49,6 +49,10 @@ bool tendermint_signTxUpdateMsgIBCTransfer( const char* chainstr, const char* denom, const char* msgTypePrefix); bool tendermint_signTxFinalize(uint8_t* public_key, uint8_t* signature); bool tendermint_signingIsInited(TendermintSigningType type); + +/// True iff `address` is the account this session signs as under +/// `chain_prefix`. Use for `sender` fields, which are signed verbatim. +bool tendermint_addressIsSigner(const char* address, const char* chain_prefix); bool tendermint_signingConfigMatches(const char* chain_name, const char* denom, const char* message_type_prefix); bool tendermint_signingIsFinished(void); diff --git a/include/keepkey/firmware/storage.h b/include/keepkey/firmware/storage.h index f78786197..cc3e0f096 100644 --- a/include/keepkey/firmware/storage.h +++ b/include/keepkey/firmware/storage.h @@ -27,6 +27,27 @@ #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. */ +#define STORAGE_VERSION_LAST_SHIPPED 17 + +/* A seed CREATED under bitcoin-only firmware is stamped with a version in a + * reserved band (base + the normal version). Multi-chain firmware that knows + * the band refuses to load it and requires an explicit wipe; older multi-chain + * firmware treats it as an unknown version and resets. Either way a seed born + * on bitcoin-only firmware is never usable by multi-chain code. A pre-existing + * multi-chain wallet keeps its normal version and stays portable (it was + * already multi-chain-exposed). Multi-chain versions MUST stay below the band + * forever (static-asserted in storage.c). */ +#define STORAGE_VERSION_BTC_ONLY_BASE 10000 +#define STORAGE_VERSION_BTC_ONLY \ + (STORAGE_VERSION_BTC_ONLY_BASE + STORAGE_VERSION) #define STORAGE_RETRIES 3 #define RANDOM_SALT_LEN 32 @@ -48,6 +69,19 @@ void storage_reset(void); /// \brief Clear storage. void storage_wipe(void); +/// \brief True when flash holds storage this build must refuse to load or +/// overwrite -- a bitcoin-only wallet seen by multi-chain firmware, or a newer +/// in-band wallet than this build understands. +/// +/// Handlers that CREATE a seed must check this and refuse. The device looks +/// uninitialized while locked (the RAM shadow was reset, so +/// storage_isInitialized() is false), and storage_commit() silently declines to +/// 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. +/// +/// Cleared only by storage_wipe(). +bool storage_isBitcoinOnlyLocked(void); + /// \brief Clear storage key and storage key fingerprint. void storage_clearKeys(void); diff --git a/include/keepkey/firmware/tendermint.h b/include/keepkey/firmware/tendermint.h index c4102cc63..2bb39b4b3 100644 --- a/include/keepkey/firmware/tendermint.h +++ b/include/keepkey/firmware/tendermint.h @@ -38,6 +38,19 @@ bool tendermint_validateSafeText(const char* value); /** Validate a Bech32 address and bind it to the expected human-readable part. */ +/// Well-formed bech32 (charset, length, checksum) with ANY human-readable +/// part. Use only where an arbitrary HRP is intended -- an IBC receiver on a +/// counterparty chain. Where the network is known, use +/// tendermint_validateBech32Address(), which also pins the prefix and the +/// 20-byte account length. +bool tendermint_bech32IsWellFormed(const char* address); + +/// A validator operator address: a 20-byte account payload under the +/// "valoper" HRP. Use for every validator_address, +/// validator_src_address and validator_dst_address before it is serialized. +bool tendermint_validateValidatorAddress(const char* address, + const char* chain_prefix); + bool tendermint_validateBech32Address(const char* address, const char* expected_prefix); diff --git a/include/keepkey/firmware/thorchain.h b/include/keepkey/firmware/thorchain.h index d945694d7..bf3a1f95a 100644 --- a/include/keepkey/firmware/thorchain.h +++ b/include/keepkey/firmware/thorchain.h @@ -24,6 +24,10 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, bool thorchain_signTxUpdateMsgDeposit(const ThorchainMsgDeposit* depmsg); bool thorchain_signTxFinalize(uint8_t* public_key, uint8_t* signature); bool thorchain_signingIsInited(void); + +/// True iff `address` is the account this session's key signs as. Use for +/// MsgDeposit's `signer`, which is serialized verbatim as the authority. +bool thorchain_addressIsSigner(const char* address); bool thorchain_signingIsFinished(void); void thorchain_signAbort(void); const ThorchainSignTx* thorchain_getThorchainSignTx(void); diff --git a/include/keepkey/firmware/transaction.h b/include/keepkey/firmware/transaction.h index cba06b308..9de826349 100644 --- a/include/keepkey/firmware/transaction.h +++ b/include/keepkey/firmware/transaction.h @@ -67,6 +67,8 @@ uint32_t compile_script_sig(uint32_t address_type, const uint8_t* pubkeyhash, uint32_t compile_script_multisig(const CoinType* coin, const MultisigRedeemScriptType* multisig, uint8_t* out); +/// Shared wire-boundary invariant for every Bitcoin multisig script. +bool multisig_quorum_is_valid(const MultisigRedeemScriptType* multisig); uint32_t compile_script_multisig_hash(const CoinType* coin, const MultisigRedeemScriptType* multisig, uint8_t* hash); @@ -80,6 +82,11 @@ uint32_t serialize_script_multisig(const CoinType* coin, int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, TxOutputBinType* out, bool needs_confirm); +bool fill_input_script_pubkey(const CoinType* coin, const HDNode* root, + const TxInputType* in, uint8_t* script_pubkey, + size_t* script_pubkey_len, + size_t script_pubkey_size); + uint32_t tx_prevout_hash(Hasher* hasher, const TxInputType* input); uint32_t tx_script_hash(Hasher* hasher, uint32_t size, const uint8_t* data); uint32_t tx_sequence_hash(Hasher* hasher, const TxInputType* input); diff --git a/include/keepkey/firmware/txin_check.h b/include/keepkey/firmware/txin_check.h index 95cb9bb8f..72df652b2 100644 --- a/include/keepkey/firmware/txin_check.h +++ b/include/keepkey/firmware/txin_check.h @@ -29,6 +29,7 @@ void txin_dgst_addto(const uint8_t* data, size_t len); void txin_dgst_initialize(void); +void txin_dgst_reset_current(void); 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); diff --git a/include/keepkey/rand/rng.h b/include/keepkey/rand/rng.h index 93f1854a2..0297aaf7c 100644 --- a/include/keepkey/rand/rng.h +++ b/include/keepkey/rand/rng.h @@ -24,8 +24,27 @@ #include /// Reset the hardware random number generator +#include + void reset_rng(void); +/// Boot-lifetime record that the RNG reported a seed or clock error. +/// +/// RNG_SR_SEIS latches in hardware only until it is cleared, and random32() +/// clears it whenever the underlying condition has gone -- so a self-test +/// reading RNG_SR alone cannot see a transient fault that random32() already +/// recovered from. This mirror is set at the moment the hardware latch is +/// cleared and is never cleared itself: recovery is a power cycle. +bool rng_seed_error_latched(void); + +#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. +void rng_test_power_on_reset(void); +void rng_test_observe_transient_error(void); +void rng_test_observe_persistent_error(void); +#endif + void random_permute_char(char* str, size_t len); void random_permute_u16(uint16_t* buf, size_t count); diff --git a/include/keepkey/rand/rng_health.h b/include/keepkey/rand/rng_health.h new file mode 100644 index 000000000..0a65c6f62 --- /dev/null +++ b/include/keepkey/rand/rng_health.h @@ -0,0 +1,143 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library. If not, see . + */ + +#ifndef KEEPKEY_RAND_RNG_HEALTH_H +#define KEEPKEY_RAND_RNG_HEALTH_H + +#include +#include +#include + +/* Bytes drawn for the seed-time self-test: two full APT windows. */ +#define RNG_HEALTH_SAMPLE_BYTES 1024 + +/* SP 800-90B continuous-test parameters, derived for H = 8 bits/byte and + * alpha = 2^-30. Both derivations are spelled out in rng_health.c so a + * reviewer can recompute them rather than trust a copied table. */ +#define RNG_HEALTH_RCT_CUTOFF 5 +#define RNG_HEALTH_APT_WINDOW 512 +/* Counts samples FOLLOWING the window reference, so this is NIST's inclusive + * cutoff minus one. Exact tail P(X >= 16) = 3.891e-10 <= 2^-30 for + * X ~ Binomial(511, 1/256). See the derivation in rng_health.c. */ +#define RNG_HEALTH_APT_CUTOFF 16 + +/* Streaming health-test state. Constant size: there is deliberately no sample + * buffer anywhere in this module. */ +typedef struct { + uint8_t rct_prev; + uint8_t apt_ref; + uint32_t rct_run; + uint32_t apt_following; + uint32_t apt_pos; + uint32_t total; + /* RCT and APT keep INDEPENDENT initialisation state. Sharing one flag let + * the APT window boundary reset the repetition counter, so a run straddling + * byte 512 went undetected. RCT is continuous over the whole stream; only + * APT is windowed. */ + bool rct_started; + bool apt_started; + bool ok; +} RngHealthCtx; + +void rng_health_init(RngHealthCtx* ctx); +void rng_health_update(RngHealthCtx* ctx, const uint8_t* buf, size_t len); +/// Wipes ctx. Returns false if any window failed or no data was seen. +bool rng_health_final(RngHealthCtx* ctx); + +/// Report which random32() implementation is actually running and whether it +/// is alive. On STM32 this reads the RNG peripheral's own control and status +/// registers; the answer does not depend on any build-configuration macro +/// having the value its name suggests. Returns false if the peripheral is +/// disabled, latching an error, or not producing fresh data. +/// +/// SCOPE: detects an accidentally mis-built or dead generator. It is not an +/// attestation -- firmware that lies can return whatever it likes. +bool rng_source_live(void); + +/// SP 800-90B repetition-count and adaptive-proportion tests over `buf`. +/// Pure function, no I/O: this is the unit-tested half. +/// +/// SCOPE: catches a stuck or grossly degenerate source. A healthy-looking +/// generator with a tiny seed passes -- no output test detects that. +bool rng_health_analyze(const uint8_t* buf, size_t len); + +/// The latched, boot-lifetime verdict on this device's generator. +/// +/// The full gate -- rng_source_live() plus rng_health_analyze() over a freshly +/// drawn RNG_HEALTH_SAMPLE_BYTES sample -- runs ONCE, on first use, and the +/// answer is remembered. Every draw made through random_buffer_checked() is +/// folded into a continuous SP 800-90B test, and a failure there latches the +/// verdict to failed for the rest of the boot. Recovery is a reboot, +/// deliberately: a source that failed must not be retried until it passes. +/// +/// SCOPE — OPT-IN, AND THIS IS THE COMPLETE LIST. +/// +/// Covered, because each of these calls random_buffer_checked() by name: +/// - the device half of the seed reset_init() +/// - the storage encryption key storage_setPin_impl() +/// - the wipe-code key storage_setWipeCode_impl() +/// - the PIN-KDF salt storage_readStorageV1(), the V1 +/// upgrade path that mints one +/// - the U2F key-handle derivation path generateKeyHandle() +/// - the one-shot OTP randomness block flash_collectHWEntropy() +/// - the RedPallas spend-auth T fsm_msg_zcash.h, the is_spend path +/// +/// NOT covered: everything else in the tree and in deps/, because plain +/// random_buffer() and random32() are unchecked exactly as on develop. +/// +/// Adding a new key-material draw does NOT inherit this gate. You must route +/// it through random_buffer_checked() deliberately. Inverting the default so +/// that coverage was automatic was built for 7.15 and descoped: it can hang +/// or brick the bootloader when the generator has failed and there is no +/// defined degraded-RNG recovery mode yet. +bool rng_health_check(void); + +/// Fold \p len bytes of freshly drawn output into the boot-lifetime continuous +/// SP 800-90B state, latching the verdict to failed if the RCT or APT trips. +/// +/// random_buffer_checked() calls this on every draw it makes. The initial 1 KiB +/// gate only says the source was healthy at boot; the continuous test is what +/// notices a source that degenerates afterwards. +/// +/// Returns false if these very bytes tripped the test, so the caller can refuse +/// to return them. The triggering draw is part of the degenerate run -- handing +/// it back and aborting only on the NEXT call means a run that trips on the +/// last word of a buffer delivers that whole buffer first. +bool rng_health_observe(const uint8_t* buf, size_t len); + +/// Draw \p len bytes and report failure instead of halting, for the paths that +/// have somewhere better to go: a host-visible error, or a one-shot write that +/// should simply be skipped and retried on a later healthy boot. Returns false +/// with \p buf zeroed. +/// +/// THIS IS THE ONLY CHECKED DRAW. Plain random_buffer() and random32() are +/// NOT checked -- they behave exactly as on develop. A previous revision of +/// this branch inverted that and was descoped from 7.15, and this sentence +/// used to say the opposite; if you are reaching for entropy that must be +/// gated, you have to call this function by name. +bool random_buffer_checked(uint8_t* buf, size_t len); + +#ifdef EMULATOR +/// Test-only: force the latched verdict. `false` stands in for a generator +/// that failed its self-test, which is otherwise unreachable from a host build; +/// `true` re-arms the continuous state. +void rng_health_force_verdict(bool passed); +#endif + +#endif diff --git a/include/keepkey/transport/messages-ethereum.options b/include/keepkey/transport/messages-ethereum.options index 65b6a1a2f..373947696 100644 --- a/include/keepkey/transport/messages-ethereum.options +++ b/include/keepkey/transport/messages-ethereum.options @@ -49,4 +49,6 @@ Ethereum712TypesValues.eip712data max_size:2048 EthereumTxMetadata.signed_payload max_size:1024 EthereumMetadataAck.display_summary max_size:32 - +LoadClearsignSigner.pubkey max_size:33 +LoadClearsignSigner.alias max_size:32 +LoadClearsignSigner.icon max_size:384 diff --git a/include/keepkey/transport/messages-ripple.options b/include/keepkey/transport/messages-ripple.options index b3f2e1987..219014bf6 100644 --- a/include/keepkey/transport/messages-ripple.options +++ b/include/keepkey/transport/messages-ripple.options @@ -6,5 +6,7 @@ RippleSignTx.address_n max_count:8 RipplePayment.destination max_size:36 +RippleSignTx.memo max_size:200 + RippleSignedTx.signature max_size:75 RippleSignedTx.serialized_tx max_size:1024 diff --git a/include/keepkey/transport/messages-solana.options b/include/keepkey/transport/messages-solana.options index ded9d68c1..0bbf7b02e 100644 --- a/include/keepkey/transport/messages-solana.options +++ b/include/keepkey/transport/messages-solana.options @@ -5,11 +5,16 @@ SolanaAddress.address max_size:64 SolanaTokenInfo.mint max_size:32 SolanaTokenInfo.symbol max_size:13 +SolanaTokenInfo.signature max_size:64 SolanaSignTx.address_n max_count:8 SolanaSignTx.coin_name max_size:21 SolanaSignTx.raw_tx max_size:2048 SolanaSignTx.token_info max_count:4 +SolanaSignTx.schema_payload max_size:256 +SolanaSignTx.schema_signature max_size:64 +SolanaSignTx.token_recipient_owner max_count:4 +SolanaSignTx.token_recipient_owner max_size:32 SolanaSignedTx.signature max_size:64 @@ -26,3 +31,7 @@ SolanaSignOffchainMessage.message max_size:1212 SolanaOffchainMessageSignature.public_key max_size:32 SolanaOffchainMessageSignature.signature max_size:64 + +SolanaSignTx.lut_account max_count:8 +SolanaSignTx.lut_account max_size:32 +SolanaSignTx.lut_signature max_size:64 diff --git a/include/keepkey/transport/messages-thorchain.options b/include/keepkey/transport/messages-thorchain.options index 14cb39b18..c17a9368c 100644 --- a/include/keepkey/transport/messages-thorchain.options +++ b/include/keepkey/transport/messages-thorchain.options @@ -8,6 +8,7 @@ ThorchainSignTx.memo max_size:256 ThorchainMsgSend.from_address max_size:46 ThorchainMsgSend.to_address max_size:46 +ThorchainMsgSend.denom max_size:69 ThorchainMsgDeposit.asset max_size:20 ThorchainMsgDeposit.memo max_size:256 diff --git a/include/keepkey/transport/messages.options b/include/keepkey/transport/messages.options index 525b39161..ad21feade 100644 --- a/include/keepkey/transport/messages.options +++ b/include/keepkey/transport/messages.options @@ -120,6 +120,11 @@ DebugLinkState.recovery_cipher max_size:27 DebugLinkState.recovery_auto_completed_word max_size:12 DebugLinkState.firmware_hash max_size:32 DebugLinkState.storage_hash max_size:32 +DebugLinkState.dice_digest max_size:32 + +# Sized so the decoded struct stays within MSG_TINY_BFR_SZ (64B): the tiny +# message path pb_decodes DebugLinkDecision straight into that buffer. +DebugLinkDecision.input max_size:41 DebugLinkFlashDumpResponse.data max_size:1024 @@ -133,3 +138,12 @@ FlashWrite.data max_size:1024 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 +# 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. +ClearsignAttestorPublicKey.public_key max_size:33 +ClearsignAttestorSign.payload max_size:256 +ClearsignAttestorSignature.signature max_size:64 +ClearsignAttestorSignature.public_key max_size:33 diff --git a/lib/board/confirm_sm.c b/lib/board/confirm_sm.c index 3558809ba..d54e6e92a 100644 --- a/lib/board/confirm_sm.c +++ b/lib/board/confirm_sm.c @@ -710,8 +710,11 @@ bool confirm_with_custom_layout(layout_notification_t layout_notification_func, const char* request_body, ...) { /* Custom renderers do not expose their placement geometry, so the confirm * state machine cannot prove that they drew the complete body. Route every - * consent screen through the measured standard renderer instead: bespoke - * address/amount styling is not worth silently clipping signed fields. */ + * TRANSACTION-CONSENT screen through the measured standard renderer instead: + * bespoke amount styling is not worth silently clipping signed fields. + * + * Address and xpub display screens do NOT come through here -- see + * confirm_address_with_custom_layout() below for why they must not. */ (void)layout_notification_func; button_request_acked = false; @@ -738,6 +741,53 @@ bool confirm_with_custom_layout(layout_notification_t layout_notification_func, return ret; } +bool confirm_address_with_custom_layout( + layout_notification_t layout_notification_func, ButtonRequestType type, + const char* request_title, const char* request_body, ...) { + /* Address and xpub verification screens keep their own renderer. + * + * The measured fallback in confirm_with_custom_layout() exists to stop a + * bespoke layout from silently clipping a field the owner is CONSENTING to + * sign. An address screen is not that: it displays a public value the device + * itself derived, for the owner to check against what the host claims, and + * nothing is signed by looking at it. Routing these through the standard + * renderer had a cost that the safety argument does not pay for -- the five + * address layouts draw the address as a QR code through layout_address(), + * and the standard renderer draws no QR at all. Scanning that code is how + * the address is actually used, so the fallback removed the feature rather + * than hardening it. + * + * Clipping is still handled, just by the layout rather than the pager: these + * renderers wrap the address with draw_string() and drop to the body font + * when it will not fit bold. + * + * confirm_helper() already applies its measured/paged path only to + * layout_standard_notification, so handing it a custom layout renders + * exactly as it did before this release line. */ + button_request_acked = false; + + va_list vl; + va_start(vl, request_body); + const bool formatted = format_body(request_body, vl); + va_end(vl); + if (!formatted) { + memzero(strbuf, sizeof(strbuf)); + return false; + } + + /* Send button request */ + ButtonRequest resp; + memset(&resp, 0, sizeof(ButtonRequest)); + resp.has_code = true; + resp.code = type; + msg_write(MessageType_MessageType_ButtonRequest, &resp); + + bool ret = confirm_helper(request_title, strbuf, layout_notification_func, + false, NO_ICON, false, true); + memzero(strbuf, sizeof(strbuf)); + return ret; +} + bool confirm_without_button_request(const char* request_title, const char* request_body, ...) { button_request_acked = true; diff --git a/lib/board/keepkey_flash.c b/lib/board/keepkey_flash.c index 31243d3bd..be2844696 100644 --- a/lib/board/keepkey_flash.c +++ b/lib/board/keepkey_flash.c @@ -32,6 +32,7 @@ #include "keepkey/board/supervise.h" #include "keepkey/board/util.h" #include "keepkey/rand/rng.h" +#include "keepkey/rand/rng_health.h" #include "trezor/crypto/memzero.h" #include "trezor/crypto/rand.h" @@ -329,10 +330,17 @@ void flash_collectHWEntropy(bool privileged) { // set entropy in the OTP randomness block if (!flash_otp_is_locked(FLASH_OTP_BLOCK_RANDOMNESS)) { uint8_t entropy[FLASH_OTP_BLOCK_SIZE] = {0}; - random_buffer(entropy, FLASH_OTP_BLOCK_SIZE); - flash_otp_write(FLASH_OTP_BLOCK_RANDOMNESS, 0, entropy, - FLASH_OTP_BLOCK_SIZE); - flash_otp_lock(FLASH_OTP_BLOCK_RANDOMNESS); + /* Written once and then locked forever, and it feeds the PIN KDF salt + * via flash_readHWEntropy(). A block filled from a dead generator can + * never be corrected, so on a failed draw write nothing: the block stays + * unlocked and a later healthy boot claims it. Halting is wrong here -- + * this runs before kk_board_init(), so there is no display to warn on. */ + if (random_buffer_checked(entropy, FLASH_OTP_BLOCK_SIZE)) { + flash_otp_write(FLASH_OTP_BLOCK_RANDOMNESS, 0, entropy, + FLASH_OTP_BLOCK_SIZE); + flash_otp_lock(FLASH_OTP_BLOCK_RANDOMNESS); + } + memzero(entropy, sizeof(entropy)); } // collect entropy from OTP randomness block flash_otp_read(FLASH_OTP_BLOCK_RANDOMNESS, 0, HW_ENTROPY_DATA + 12, diff --git a/lib/emulator/libkkemu.c b/lib/emulator/libkkemu.c index 2a62eb63b..4730f9a05 100644 --- a/lib/emulator/libkkemu.c +++ b/lib/emulator/libkkemu.c @@ -15,6 +15,7 @@ #include "keepkey/board/usb.h" #include "keepkey/board/memory.h" #include "keepkey/board/timer.h" +#include "keepkey/firmware/fsm.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/storage.h" #include "keepkey/rand/rng.h" @@ -53,6 +54,9 @@ static int libkkemu_initialized = 0; 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 */ @@ -107,23 +111,34 @@ size_t libkkemu_socketWrite(int iface, const void* buffer, size_t size) { static void libkkemu_capture_frame(const uint8_t* canvas_buf) { if (!canvas_buf) return; - uint8_t* slot = frame_ring[frame_write_idx % FRAME_RING_SIZE]; - memset(slot, 0, FRAME_PACKED_SIZE); + /* Pack into 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. */ + memset(capture_scratch, 0, FRAME_PACKED_SIZE); for (int x = 0; x < 256; x++) { for (int y = 0; y < 64; y++) { if (display_mono_pixel_is_lit(canvas_buf[y * 256 + x], x, y)) { - slot[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); + capture_scratch[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); } } } /* Dedup: skip if identical to last captured */ - if (last_packed_valid && memcmp(slot, last_packed, FRAME_PACKED_SIZE) == 0) { + if (last_packed_valid && + memcmp(capture_scratch, last_packed, FRAME_PACKED_SIZE) == 0) { return; } - memcpy(last_packed, slot, 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) { @@ -193,6 +208,22 @@ int kkemu_init(uint8_t* flash_buf, size_t flash_len) { void kkemu_shutdown(void) { if (!libkkemu_initialized) return; + /* + * End any workflow still in flight BEFORE anything else. + * + * The buffer scrubbing below covers the transport rings and the frame ring, + * but signing state and fsm_derived_node -- the shared derived private-key + * scratch -- live behind fsm_abort_workflows(), which nothing here was + * calling. In the dylib case this file is written for, the library sits in a + * long-running host process, so a shutdown/init cycle would carry an old + * workflow and its key material across into the next session. That is the + * same exposure the comment below describes, and it needs the same answer. + * + * Before storage_commit() so the committed image reflects the aborted state + * rather than a half-finished ceremony. + */ + fsm_abort_workflows(); + /* Flush any pending storage to the flash buffer */ storage_commit(); @@ -220,6 +251,7 @@ void kkemu_shutdown(void) { memzero(&rb_debug_out, sizeof(rb_debug_out)); memzero(frame_ring, sizeof(frame_ring)); memzero(last_packed, sizeof(last_packed)); + memzero(capture_scratch, sizeof(capture_scratch)); memzero(display_packed_scratch, sizeof(display_packed_scratch)); last_packed_valid = 0; frame_write_idx = 0; diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index f23ebcfb8..816412844 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -2,48 +2,54 @@ set(sources app_confirm.c app_layout.c authenticator.c - binance.c coins.c crypto.c - eip712.c - eos.c - eos-contracts/eosio.system.c - eos-contracts/eosio.token.c - ethereum.c - ethereum_contracts.c - ethereum_contracts/makerdao.c - ethereum_contracts/saproxy.c - ethereum_contracts/zxappliquid.c - ethereum_contracts/thortx.c - ethereum_contracts/zxliquidtx.c - ethereum_contracts/zxtransERC20.c - ethereum_contracts/zxswap.c - ethereum_tokens.c + dice_input.c fsm.c home_sm.c - mayachain.c - nano.c - osmosis.c passphrase_sm.c pin_sm.c policy.c recovery_cipher.c reset.c - ripple.c - ripple_base58.c signing.c - signtx_tendermint.c - solana.c storage.c - tron.c - ton.c - tendermint.c - thorchain.c tiny-json.c transaction.c txin_check.c u2f.c) +# Non-Bitcoin coin families -- excluded from the bitcoin-only image. +if(NOT ${KK_BITCOIN_ONLY}) + list(APPEND sources + binance.c + eip712.c + eos.c + eos-contracts/eosio.system.c + eos-contracts/eosio.token.c + ethereum.c + ethereum_contracts.c + ethereum_contracts/makerdao.c + ethereum_contracts/saproxy.c + ethereum_contracts/zxappliquid.c + ethereum_contracts/thortx.c + ethereum_contracts/zxliquidtx.c + ethereum_contracts/zxtransERC20.c + ethereum_contracts/zxswap.c + ethereum_tokens.c + mayachain.c + nano.c + osmosis.c + ripple.c + ripple_base58.c + signtx_tendermint.c + solana.c + tron.c + ton.c + tendermint.c + thorchain.c) +endif() + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/scm_revision.h.in" "${CMAKE_CURRENT_BINARY_DIR}/scm_revision.h" @ONLY) @@ -62,6 +68,9 @@ set(UNISWAP_TOKENS ${CMAKE_BINARY_DIR}/include/keepkey/firmware/uniswap_tokens) add_custom_target(ethereum_tokens.def COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/include/keepkey/firmware COMMAND python3 ${CMAKE_SOURCE_DIR}/deps/python-keepkey/keepkeylib/eth/ethereum_tokens.py ${ETHEREUM_TOKENS}.def - COMMAND python3 ${CMAKE_SOURCE_DIR}/deps/python-keepkey/keepkeylib/eth/uniswap_tokens.py ${UNISWAP_TOKENS}.def) + COMMAND python3 ${CMAKE_SOURCE_DIR}/deps/python-keepkey/keepkeylib/eth/uniswap_tokens.py ${UNISWAP_TOKENS}.def + COMMAND python3 ${CMAKE_SOURCE_DIR}/scripts/verify-token-def.py + ${ETHEREUM_TOKENS}.def ${UNISWAP_TOKENS}.def + COMMENT "Generating and verifying firmware token tables") add_library(kkfirmware.keepkey variant/keepkey/resources.c) diff --git a/lib/firmware/app_confirm.c b/lib/firmware/app_confirm.c index a1c62be49..496b5d326 100644 --- a/lib/firmware/app_confirm.c +++ b/lib/firmware/app_confirm.c @@ -254,9 +254,9 @@ bool confirm_load_device(bool is_node) { * */ bool confirm_xpub(const char* node_str, const char* xpub) { - return confirm_with_custom_layout(&layout_xpub_notification, - ButtonRequestType_ButtonRequest_Address, - node_str, "%s", xpub); + return confirm_address_with_custom_layout( + &layout_xpub_notification, ButtonRequestType_ButtonRequest_Address, + node_str, "%s", xpub); } /* @@ -270,9 +270,9 @@ bool confirm_xpub(const char* node_str, const char* xpub) { * */ bool confirm_cosmos_address(const char* desc, const char* address) { - return confirm_with_custom_layout(&layout_cosmos_address_notification, - ButtonRequestType_ButtonRequest_Address, - desc, "%s", address); + return confirm_address_with_custom_layout( + &layout_cosmos_address_notification, + ButtonRequestType_ButtonRequest_Address, desc, "%s", address); } /* @@ -286,9 +286,9 @@ bool confirm_cosmos_address(const char* desc, const char* address) { * */ bool confirm_osmosis_address(const char* desc, const char* address) { - return confirm_with_custom_layout(&layout_osmosis_address_notification, - ButtonRequestType_ButtonRequest_Address, - desc, "%s", address); + return confirm_address_with_custom_layout( + &layout_osmosis_address_notification, + ButtonRequestType_ButtonRequest_Address, desc, "%s", address); } /* @@ -302,9 +302,9 @@ bool confirm_osmosis_address(const char* desc, const char* address) { * */ bool confirm_ethereum_address(const char* desc, const char* address) { - return confirm_with_custom_layout(&layout_ethereum_address_notification, - ButtonRequestType_ButtonRequest_Address, - desc, "%s", address); + return confirm_address_with_custom_layout( + &layout_ethereum_address_notification, + ButtonRequestType_ButtonRequest_Address, desc, "%s", address); } /* @@ -318,9 +318,9 @@ bool confirm_ethereum_address(const char* desc, const char* address) { * */ bool confirm_nano_address(const char* desc, const char* address) { - return confirm_with_custom_layout(&layout_nano_address_notification, - ButtonRequestType_ButtonRequest_Address, - desc, "%s", address); + return confirm_address_with_custom_layout( + &layout_nano_address_notification, + ButtonRequestType_ButtonRequest_Address, desc, "%s", address); } /* @@ -334,9 +334,9 @@ bool confirm_nano_address(const char* desc, const char* address) { * */ bool confirm_address(const char* desc, const char* address) { - return confirm_with_custom_layout(&layout_address_notification, - ButtonRequestType_ButtonRequest_Address, - desc, "%s", address); + return confirm_address_with_custom_layout( + &layout_address_notification, ButtonRequestType_ButtonRequest_Address, + desc, "%s", address); } /* @@ -359,12 +359,44 @@ bool format_sign_identity_key_selection(const IdentityType* identity, return needed >= 0 && (size_t)needed < out_len; } +/* Every field cryptoIdentityFingerprint() hashes has to be renderable without + ambiguity, because the screen that shows it is the only thing standing + between two identities that derive DIFFERENT keys. + + proto goes into a title and host/port/user are concatenated into an ordinary + body, both drawn as layout text: a byte below 0x20 is invisible, a leading + space is dropped at a line start, and a newline re-wraps everything after + it. So "ssh"/"ssh\n", or a user with a trailing space, can present the same + approval while selecting different keys. + + path and the visual challenge already avoid this by going through + confirm_bytes(). Putting the other four on their own escaped pages would add + four screens to every identity signature; instead require them to be what + they always are in practice -- URI components with no space and no control + byte -- and refuse anything else before a screen is drawn. 0x21..0x7E is the + same range confirm_bytes() renders literally. */ +static bool identity_field_is_unambiguous(bool has_value, const char* value) { + if (!has_value || !value) return true; /* absent is unambiguous */ + for (const unsigned char* p = (const unsigned char*)value; *p; ++p) { + if (*p < 0x21 || *p > 0x7e) return false; + } + return true; +} + bool confirm_sign_identity(const IdentityType* identity, const char* challenge, const char* curve) { char title[CONFIRM_SIGN_IDENTITY_TITLE], body[CONFIRM_SIGN_IDENTITY_BODY]; if (!identity || !curve) return false; + /* Refuse before anything is shown -- see identity_field_is_unambiguous(). */ + if (!identity_field_is_unambiguous(identity->has_proto, identity->proto) || + !identity_field_is_unambiguous(identity->has_host, identity->host) || + !identity_field_is_unambiguous(identity->has_port, identity->port) || + !identity_field_is_unambiguous(identity->has_user, identity->user)) { + return false; + } + /* These values select the key and signing algorithm. Keep them out of the * free-form identity body so the maximum-size path can be reviewed by the * exact-byte pager instead of being shortened by a printf buffer. */ @@ -385,10 +417,17 @@ bool confirm_sign_identity(const IdentityType* identity, const char* challenge, return false; } - /* Format protocol */ + /* Format protocol -- verbatim, NOT uppercased. + * + * cryptoIdentityFingerprint() hashes identity->proto exactly as the host + * sent it, so "ssh" and "SSH" select DIFFERENT keys. kk_strupr() made both + * render as "SSH login to: ", so the one screen that names the protocol + * could not distinguish two identities that sign with different keys. + * Canonicalizing the other way is not open to us: the fingerprint is what + * derives every existing identity key, and changing its input would strand + * them. So show the bytes that are actually hashed. */ if (identity->has_proto && identity->proto[0]) { strlcpy(title, identity->proto, sizeof(title)); - kk_strupr(title); strlcat(title, " login to: ", sizeof(title)); } else { strlcpy(title, "Login to: ", sizeof(title)); @@ -416,26 +455,29 @@ bool confirm_sign_identity(const IdentityType* identity, const char* challenge, strlcat(body, "\n", sizeof(body)); } - /* Preserve the established single identity/challenge screen when it can be - * formatted without loss. A maximum-size challenge does not fit the shared - * confirmation buffer after host and user metadata; in that case confirm the - * metadata first and page every challenge byte separately. */ + /* EVERY visual challenge goes through the exact-byte pager. + * + * The challenge is hashed into the signature on the non-SSH/GPG identity + * path, so the screen has to be able to tell two different challenges apart. + * Short ones used to be strlcat'd into `body` and drawn with "%s", which + * makes them layout text rather than bytes: a control byte is invisible, a + * run of spaces collapses at a line start, and a newline re-wraps everything + * around it. Two distinct signed challenges could therefore produce an + * identical approval, and only challenges too long for the shared buffer got + * the treatment that would have shown the difference. + * + * Confirm the identity metadata on its own screen -- always, so the title + * still names the protocol even when there is no host or user -- then page + * the challenge with confirm_bytes(), which escapes every byte outside + * 0x21..0x7E. */ if (challenge && challenge[0]) { - const size_t body_len = strlen(body); - const size_t challenge_len = strlen(challenge); - if (body_len + challenge_len < BODY_CHAR_MAX) { - strlcat(body, challenge, sizeof(body)); - return confirm(ButtonRequestType_ButtonRequest_SignIdentity, title, "%s", - body); - } - - if (body_len != 0 && !confirm(ButtonRequestType_ButtonRequest_SignIdentity, - title, "%s", body)) { + if (!confirm(ButtonRequestType_ButtonRequest_SignIdentity, title, "%s", + body)) { return false; } return confirm_bytes(ButtonRequestType_ButtonRequest_SignIdentity, "Visual Challenge", (const uint8_t*)challenge, - challenge_len); + strlen(challenge)); } return confirm(ButtonRequestType_ButtonRequest_SignIdentity, title, "%s", diff --git a/lib/firmware/app_layout.c b/lib/firmware/app_layout.c index 25b0db18b..ee211cc19 100644 --- a/lib/firmware/app_layout.c +++ b/lib/firmware/app_layout.c @@ -628,8 +628,35 @@ void layout_address_notification(const char* desc, const char* address, sp.y += font_height(address_font) + ADDRESS_TOP_MARGIN; sp.x = LEFT_MARGIN; sp.color = BODY_COLOR; + + /* Bech32 addresses longer than one line (p2wsh and p2tr are both 62 chars) + did not fit: draw_string() stops at the bottom of the canvas and drops the + remainder SILENTLY, so the user verified a prefix while the QR beside it + encoded the whole address. + Close the padding between lines rather than moving the block up -- the QR + is drawn last and would overwrite the start of a raised first line. */ + uint16_t address_line_height = + font_height(address_font) + BODY_FONT_LINE_PADDING; + { + const uint32_t lines = + calc_str_line(address_font, address, TRANSACTION_WIDTH); + if (lines > ONE_LINE) { + /* Close the inter-line padding first: raising the block is what collides + with the QR, which is drawn afterwards and would overwrite the start of + the first line. */ + address_line_height = font_height(address_font); + const uint16_t bottom = + sp.y + (lines - 1) * address_line_height + font_height(address_font); + if (bottom > KEEPKEY_DISPLAY_HEIGHT) { + /* Still short: raise by the minimum that fits, no more. */ + const uint16_t overflow = bottom - KEEPKEY_DISPLAY_HEIGHT; + sp.y = (sp.y > overflow) ? sp.y - overflow : 0; + } + } + } + draw_string(canvas, address_font, address, &sp, TRANSACTION_WIDTH, - font_height(address_font) + BODY_FONT_LINE_PADDING); + address_line_height); /* Draw description */ if (strcmp(desc, "") != 0) { diff --git a/lib/firmware/authenticator.c b/lib/firmware/authenticator.c index aae6ce3e2..fc9be84b9 100644 --- a/lib/firmware/authenticator.c +++ b/lib/firmware/authenticator.c @@ -43,6 +43,26 @@ static CONFIDENTIAL authType authData[AUTHDATA_SIZE] = {0}; static bool localAuthdataUpdate = true; /* initialization trick, only need to fetch a local copy once successfully */ + +void authenticator_clear_cache(void) { + memzero(authData, sizeof(authData)); + localAuthdataUpdate = true; +} + +#if DEBUG_LINK +bool authenticator_cache_is_empty(void) { + const uint8_t* bytes = (const uint8_t*)authData; + uint8_t aggregate = 0; + for (size_t i = 0; i < sizeof(authData); i++) aggregate |= bytes[i]; + return aggregate == 0 && localAuthdataUpdate; +} + +void authenticator_test_seed_cache(void) { + memset(authData, 0xA5, sizeof(authData)); + localAuthdataUpdate = false; +} +#endif + static bool getAuthData(void) { if (localAuthdataUpdate) { if (storage_getAuthData(authData)) { @@ -57,11 +77,6 @@ static bool getAuthData(void) { static void setAuthData(void) { storage_setAuthData(authData); } -void authenticator_clear_cache(void) { - memzero(authData, sizeof(authData)); - localAuthdataUpdate = true; -} - static unsigned authenticator_cancel(void) { /* A nested confirmation refusal does not pass through fsm_msgCancel(), so it * must revoke the decrypted authenticator cache itself. */ @@ -112,11 +127,9 @@ unsigned addAuthAccount(char* accountWithSeed) { * message decode buffer until another USB message arrives. */ const size_t sourceLen = strlen(accountWithSeed); char *domain, *account, *seedStr; - unsigned slot; - char authSecret[AUTHSECRET_SIZE_MAX] = { - 0}; // 128-bit key len is the recommended minimum, this is room for - // 160-bit - size_t authSecretLen; + unsigned slot = AUTHDATA_SIZE; + char authSecret[AUTHSECRET_SIZE_MAX] = {0}; + size_t authSecretLen = 0; unsigned result = UNKERR; // accountWithSeed should be of the form "domain:account:seedStr" @@ -199,44 +212,48 @@ unsigned addAuthAccount(char* accountWithSeed) { unsigned generateOTP(char* accountWithMsg, char otpStr[]) { const char *domain, *account, *tIntervalStr, *tRemainStr; - uint8_t hmac[SHA1_DIGEST_LENGTH]; // hmac-sha1 digest length is 160 bits - unsigned slot; - uint32_t t0; + uint8_t hmac[SHA1_DIGEST_LENGTH] = {0}; + uint8_t tIntervalBytes[8] = {0}; + char otp_candidate[9] = {0}; + char otp_display[10] = {0}; + char account_display[DOMAIN_SIZE + ACCOUNT_SIZE + 2] = {0}; + unsigned slot = AUTHDATA_SIZE; + uint32_t t0 = getSysTime(); + unsigned result = TOKERR; - t0 = getSysTime(); + memzero(otpStr, 9); // accountWithSeed should be of the form "domain:account:msgStr" domain = strtok(accountWithMsg, ":"); // get the domain string token if (NULL == domain) { - return TOKERR; + goto cleanup; } account = strtok(NULL, ":"); // get the account string token if (NULL == account) { - return TOKERR; + goto cleanup; } if (0 == strlen(account)) { - return TOKERR; + goto cleanup; } tIntervalStr = strtok(NULL, ":"); // get the message string string token if (NULL == tIntervalStr) { - return TOKERR; + goto cleanup; } if (0 == strlen(tIntervalStr)) { - return TOKERR; + goto cleanup; } tRemainStr = strtok(NULL, ""); // get the message string string token if (NULL == tRemainStr) { - return TOKERR; + goto cleanup; } if (0 == (strlen(tRemainStr))) { - return TOKERR; + goto cleanup; } // convert time interval string to long int long tIntervalVal = strtol(tIntervalStr, NULL, 10); // get big endian representation - uint8_t tIntervalBytes[8] = {0}; tIntervalBytes[4] = (tIntervalVal >> 24) & 0xff; tIntervalBytes[5] = (tIntervalVal >> 16) & 0xff; tIntervalBytes[6] = (tIntervalVal >> 8) & 0xff; @@ -247,7 +264,8 @@ unsigned generateOTP(char* accountWithMsg, char otpStr[]) { if (!getAuthData()) { // in theory an OTP could be requested on a dirty local // copy - return BADPASS; // fingerprint did not match, passphrase incorrect + result = BADPASS; + goto cleanup; } // look for account @@ -259,7 +277,8 @@ unsigned generateOTP(char* accountWithMsg, char otpStr[]) { } if (slot == AUTHDATA_SIZE) { - return NOACC; // account not found + result = NOACC; + goto cleanup; } #if DEBUG_LINK @@ -285,16 +304,12 @@ unsigned generateOTP(char* accountWithMsg, char otpStr[]) { } unsigned otp = bin_code % (unsigned long)modnum; - snprintf(otpStr, 9, "%06u", otp); - char otpStrLarge[10] = {0}; - // snprintf(otpStrLarge, 9, "\x19%06u", otp); - snprintf(otpStrLarge, 9, "%06u", otp); + snprintf(otp_candidate, sizeof(otp_candidate), "%06u", otp); + snprintf(otp_display, sizeof(otp_display), "%06u", otp); if (!review_immediate(ButtonRequestType_ButtonRequest_Other, "display OTP", "Press button to display OTP")) { - memzero(hmac, sizeof(hmac)); - memzero(otpStrLarge, sizeof(otpStrLarge)); - memzero(otpStr, 9); - return authenticator_cancel(); + result = CANCELED; + goto cleanup; } // Check to see if user needs to regenerate OTP @@ -303,26 +318,33 @@ unsigned generateOTP(char* accountWithMsg, char otpStr[]) { if (tRemainVal < 4) { if (!review_immediate(ButtonRequestType_ButtonRequest_Other, "OTP Timeout", "OTP time slice timed out, regenerate OTP")) { - memzero(hmac, sizeof(hmac)); - memzero(otpStrLarge, sizeof(otpStrLarge)); - memzero(otpStr, 9); - return authenticator_cancel(); + result = CANCELED; + goto cleanup; } } else { - char accStr[DOMAIN_SIZE + ACCOUNT_SIZE + 2] = {0}; - strncpy(accStr, authData[slot].domain, DOMAIN_SIZE); - strcat(accStr, " "); - strncat(accStr, authData[slot].account, ACCOUNT_SIZE); + strncpy(account_display, authData[slot].domain, DOMAIN_SIZE); + strcat(account_display, " "); + strncat(account_display, authData[slot].account, ACCOUNT_SIZE); unsigned remainingdmSec = tRemainVal * 10; // how many 1/10 secs remaining - layoutProgressForAuth(otpStrLarge, accStr, (1000 * remainingdmSec) / 300); + layoutProgressForAuth(otp_display, account_display, + (1000 * remainingdmSec) / 300); for (; remainingdmSec > 0; remainingdmSec--) { delay_ms(100); - layoutProgressForAuth(otpStrLarge, accStr, (1000 * remainingdmSec) / 300); + layoutProgressForAuth(otp_display, account_display, + (1000 * remainingdmSec) / 300); } } + strlcpy(otpStr, otp_candidate, 9); + result = NOERR; + +cleanup: memzero(hmac, sizeof(hmac)); - memzero(otpStrLarge, sizeof(otpStrLarge)); - return NOERR; + memzero(tIntervalBytes, sizeof(tIntervalBytes)); + 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; } unsigned getAuthAccount(const char* slotStr, char acc[]) { diff --git a/lib/firmware/binance.c b/lib/firmware/binance.c index 8344935b4..ee22169f6 100644 --- a/lib/firmware/binance.c +++ b/lib/firmware/binance.c @@ -48,6 +48,27 @@ bool binance_validateTransfer(const BinanceTransferMsg* transfer) { binance_isValidDenom(input_coin->denom); } +/* The address prefix this session's chain_id domain-binds the signature to. + * + * Accepting "bnb" or "tbnb" per address, independently, let one transfer mix + * networks and tied neither address to the chain_id inside the sign document: + * a mainnet envelope could display and sign a tbnb recipient. Derive the one + * permitted prefix from the chain_id once, here, and hold every input and + * output to it. An unrecognised chain_id has no prefix to derive, so it is + * refused rather than guessed -- the safe direction, and these three are the + * only chain ids BNB Beacon Chain ever used. */ +static const char* binance_addressPrefixForChain(const char* chain_id) { + if (!chain_id) return NULL; + if (strcmp(chain_id, "Binance-Chain-Tigris") == 0) return "bnb"; + if (strcmp(chain_id, "Binance-Chain-Ganges") == 0) return "tbnb"; + if (strcmp(chain_id, "Binance-Chain-Nile") == 0) return "tbnb"; + return NULL; +} + +static const char* address_prefix; + +const char* binance_sessionAddressPrefix(void) { return address_prefix; } + bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg) { binance_signAbort(); if (!_node || !_msg || !_msg->has_msg_count || _msg->msg_count == 0 || @@ -57,6 +78,9 @@ bool binance_signTxInit(const HDNode* _node, const BinanceSignTx* _msg) { return false; } + address_prefix = binance_addressPrefixForChain(_msg->chain_id); + if (!address_prefix) return false; + msgs_remaining = _msg->msg_count; memcpy(&node, _node, sizeof(node)); @@ -107,10 +131,24 @@ bool binance_serializeCoin(const BinanceCoin* coin) { } bool binance_serializeInputOutput(const BinanceInputOutput* io) { - size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; - if (!bech32_decode(hrp, decoded, &decoded_len, io->address)) { + /* io->address is written verbatim into the signed JSON immediately below, so + it has to be a Binance ACCOUNT address, not merely a string with a valid + bech32 checksum. + + The previous bare bech32_decode() into hrp[45]/decoded[38] checked neither + the network nor the payload length, and both buffers were undersized for + what a host can send -- see tendermint_bech32DecodeChecked(). A wrong-HRP + address, a module address, or a punctuation-bearing HRP therefore reached + the signed document. + + The permitted prefix is the ONE that this session's chain_id selects (see + binance_addressPrefixForChain()), not "bnb or tbnb" per address: taking + them independently let a single transfer mix networks and bound neither + address to the chain_id the signature is domain-separated by. Everything + else is refused -- another chain's prefix, a validator or module address, + and any payload that is not a 20-byte account. */ + if (!address_prefix) return false; + if (!tendermint_validateBech32Address(io->address, address_prefix)) { return false; } @@ -198,6 +236,20 @@ bool binance_signTxFinalize(uint8_t* public_key, uint8_t* signature) { NULL) == 0; } +/* The account this session's key signs as. + * + * A transfer's input is its authority. Checking only that it is a well-formed + * address on the session's network let a host obtain a signature over an input + * the device cannot represent, and no screen shows the input address, so + * nothing would have revealed it. */ +bool binance_addressIsSigner(const char* address) { + if (!initialized || !address || !address_prefix) return false; + + char expected[46] = {0}; + if (!tendermint_getAddress(&node, address_prefix, expected)) return false; + return strcmp(address, expected) == 0; +} + bool binance_signingIsInited(void) { return initialized; } bool binance_signingIsFinished(void) { @@ -208,6 +260,7 @@ void binance_signAbort(void) { initialized = false; has_message = false; msgs_remaining = 0; + address_prefix = NULL; memzero(&msg, sizeof(msg)); memzero(&node, sizeof(node)); } diff --git a/lib/firmware/coins.c b/lib/firmware/coins.c index e5c0eb5c8..e51b852b2 100644 --- a/lib/firmware/coins.c +++ b/lib/firmware/coins.c @@ -85,6 +85,7 @@ const CoinType coins[COINS_COUNT] = { TAPROOT}, #include "keepkey/firmware/coins.def" +#if !BITCOIN_ONLY #define X(INDEX, NAME, SYMBOL, DECIMALS, CONTRACT_ADDRESS) \ { \ true, \ @@ -131,6 +132,7 @@ const CoinType coins[COINS_COUNT] = { false, /* has_taproot, taproot*/ \ }, #include "keepkey/firmware/tokens.def" +#endif }; _Static_assert(sizeof(coins) / sizeof(coins[0]) == COINS_COUNT, @@ -226,6 +228,22 @@ static bool path_mismatched(const CoinType* coin, const uint32_t* address_n, return mismatch; } + // m/86' : BIP86 Taproot + // m / purpose' / bip44_account_path' / account' / change / address_index + if (address_n[0] == (0x80000000 + 86)) { + mismatch |= !coin->has_segwit || !coin->segwit; + mismatch |= !coin->has_bech32_prefix; + mismatch |= !coin->has_taproot || !coin->taproot; + mismatch |= (address_n_count != (whole_account ? 3 : 5)); + mismatch |= (address_n[1] != coin->bip44_account_path); + mismatch |= (address_n[2] & 0x80000000) == 0; + if (!whole_account) { + mismatch |= (address_n[3] & 0x80000000) == 0x80000000; + mismatch |= (address_n[4] & 0x80000000) == 0x80000000; + } + return mismatch; + } + return false; } diff --git a/lib/firmware/dice_input.c b/lib/firmware/dice_input.c new file mode 100644 index 000000000..996ef540b --- /dev/null +++ b/lib/firmware/dice_input.c @@ -0,0 +1,387 @@ +/* + * This file is part of the KeepKey project. + * + * Copyright (C) 2026 KeepKey + * + * This library is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library. If not, see . + */ + +#include "keepkey/firmware/dice_input.h" + +#include "keepkey/board/draw.h" +#include "keepkey/board/font.h" +#include "keepkey/board/keepkey_button.h" +#include "keepkey/board/keepkey_display.h" +#include "keepkey/board/layout.h" +#include "keepkey/board/messages.h" +#include "keepkey/board/supervise.h" +#include "keepkey/board/timer.h" +#include "keepkey/transport/interface.h" +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/sha2.h" + +#include +#include + +#define _(X) (X) + +/* Selector positions 0-5 are digits '1'-'6'; 6 is UNDO. */ +#define DICE_POSITIONS 7 +#define DICE_UNDO_POS 6 + +/* Holding this long commits the selection; edges closer together than the + * debounce window are contact bounce. Distinct from CONFIRM_TIMEOUT_MS on + * purpose: a 1200ms hold per roll makes 99 rolls a slog. */ +#define DICE_HOLD_MS 800 +#define DICE_DEBOUNCE_MS 30 + +/* The screen runs with display_constant_power(true): the display driver + * fills x<128 with the INVERSE of x>=128 at refresh time so total lit + * pixels stay constant (OLED power side-channel defense — same reason the + * PIN matrix lives on the right half). All drawing must stay in x>=128. */ +#define DICE_LEFT 130 +#define DICE_CELL_SIZE 15 +#define DICE_CELL_GAP 2 +#define DICE_GRID_Y 14 +#define DICE_STATUS_Y 33 +#define DICE_BAR_X DICE_LEFT +#define DICE_BAR_Y 48 +#define DICE_BAR_W (7 * DICE_CELL_SIZE + 6 * DICE_CELL_GAP) +#define DICE_BAR_H 6 + +extern bool reset_msg_stack; + +/* Button state shared with the ISR. Every classification decision (short vs + * hold) is made exactly once per press cycle and guarded by dice_committed, + * so a press can never produce both an advance and a commit. The UI loop + * reads and drains these under masked interrupts. */ +static volatile bool dice_accept; /* host has ButtonAck'd the screen */ +static volatile bool dice_pressed; +static volatile bool dice_committed; /* this press cycle already classified */ +static volatile uint32_t dice_press_start; +static volatile uint32_t dice_release_time; +static volatile bool dice_have_release; +static volatile uint8_t dice_short_events; +static volatile uint8_t dice_hold_events; + +#ifndef EMULATOR +static void dice_on_press(void *context) { + (void)context; + uint32_t now = getSysTime(); + /* Mirror confirm_sm: input is dead until the host acks the request, so a + * press begun before the ack cannot accrue hold time toward a commit. */ + if (!dice_accept || dice_pressed) { + return; + } + dice_pressed = true; + if (dice_have_release && now - dice_release_time < DICE_DEBOUNCE_MS) { + /* Release-edge bounce: the release that just queued an event was not a + * real one. Retract it and continue the original press cycle — the UI + * loop is barred from consuming events until the line has settled for + * DICE_DEBOUNCE_MS, so it cannot have acted on it yet. */ + if (!dice_committed && dice_short_events > 0) { + dice_short_events--; + } + return; + } + dice_press_start = now; + dice_committed = false; +} + +static void dice_on_release(void *context) { + (void)context; + uint32_t now = getSysTime(); + if (!dice_accept || !dice_pressed) { + return; + } + dice_pressed = false; + dice_release_time = now; + dice_have_release = true; + if (dice_committed) { + return; /* the UI loop already committed this hold while it was held */ + } + uint32_t held = now - dice_press_start; + if (held >= DICE_HOLD_MS) { + /* A hold completed inside the UI-loop poll gap still counts. */ + dice_committed = true; + if (dice_hold_events < 8) { + dice_hold_events++; + } + } else if (held >= DICE_DEBOUNCE_MS && dice_short_events < 8) { + dice_short_events++; + } +} +#endif + +uint32_t dice_rolls_for_strength(uint32_t strength_bits) { + switch (strength_bits) { + case 128: + return 50; + case 192: + return 75; + default: + return 99; /* 256 */ + } +} + +void dice_mix(uint8_t entropy[32], const char *rolls, uint32_t count) { + SHA256_CTX ctx; + sha256_Init(&ctx); + sha256_Update(&ctx, entropy, 32); + sha256_Update(&ctx, (const uint8_t *)rolls, count); + sha256_Final(&ctx, entropy); + memzero(&ctx, sizeof(ctx)); +} + +static void dice_draw_screen(uint32_t count, uint32_t target, uint8_t position, + const char *status, uint16_t hold_permil) { + Canvas *canvas = layout_get_canvas(); + char line[32]; + + layout_clear(); + display_constant_power(true); + + DrawableParams p = {.color = 0xFF, .x = DICE_LEFT, .y = 0}; + /* Clamped: the final commit redraws before the loop re-tests its + * condition, which would otherwise render an impossible "ROLL 100/99". */ + snprintf(line, sizeof(line), "ROLL %lu/%lu", + (unsigned long)(count < target ? count + 1 : target), + (unsigned long)target); + draw_string(canvas, get_title_font(), line, &p, 0, 10); + + for (uint8_t i = 0; i < DICE_POSITIONS; i++) { + uint16_t cx = DICE_LEFT + i * (DICE_CELL_SIZE + DICE_CELL_GAP); + bool active = (i == position); + /* Inverse video marks the active cell: white box, ink-black glyph. + * Gray levels collapse to white in the 1bpp DebugLink capture, so the + * machine-checkable signal must be geometry, not shade. */ + draw_box_simple(canvas, active ? 0xFF : 0x22, cx, DICE_GRID_Y, + DICE_CELL_SIZE, DICE_CELL_SIZE); + uint8_t ink = active ? 0x00 : 0xFF; + if (i < DICE_UNDO_POS) { + /* pin_font '1' is 4px wide where '2'-'6' are 8px (font.c) — center + * each on its own metric rather than on the common case. */ + uint16_t glyph_w = (i == 0) ? 4 : 8; + draw_char_simple(canvas, get_pin_font(), (char)('1' + i), ink, + cx + (DICE_CELL_SIZE - glyph_w) / 2, DICE_GRID_Y + 2); + } else { + draw_char_simple(canvas, get_title_font(), '<', ink, cx + 5, + DICE_GRID_Y + 3); + } + } + + p.color = 0xFF; + p.x = DICE_LEFT; + p.y = DICE_STATUS_Y; + draw_string(canvas, get_body_font(), status, &p, DICE_BAR_W, 10); + + if (hold_permil > 0) { + draw_box_simple(canvas, 0xCC, DICE_BAR_X, DICE_BAR_Y, DICE_BAR_W, + DICE_BAR_H); + draw_box_simple(canvas, 0x00, DICE_BAR_X + 1, DICE_BAR_Y + 1, + DICE_BAR_W - 2, DICE_BAR_H - 2); + uint16_t fill = + (uint16_t)(((uint32_t)(DICE_BAR_W - 2) * hold_permil) / 1000); + if (fill > 0) { + draw_box_simple(canvas, 0xFF, DICE_BAR_X + 1, DICE_BAR_Y + 1, fill, + DICE_BAR_H - 2); + } + } + + display_refresh(); +} + +bool dice_input_collect(char *rolls, uint32_t target) { + uint32_t count = 0; + uint8_t position = 0; + bool ret = false; + bool redraw = true; + uint16_t last_bar_permil = 0; + char status[48]; + static CONFIDENTIAL uint8_t msg_tiny_buf[MSG_TINY_BFR_SZ]; + +#if DEBUG_LINK + _Static_assert(sizeof(DebugLinkDecision) <= MSG_TINY_BFR_SZ, + "DebugLinkDecision must fit the tiny message buffer"); +#endif + + if (target > DICE_MAX_ROLLS) { + return false; + } + + reset_msg_stack = false; + + dice_accept = false; + dice_pressed = false; + dice_committed = false; + dice_press_start = 0; + dice_release_time = 0; + dice_have_release = false; + dice_short_events = 0; + dice_hold_events = 0; + + call_leaving_handler(); + + snprintf(status, sizeof(status), _("PRESS next HOLD ok")); + +#ifndef EMULATOR + keepkey_button_set_on_press_handler(&dice_on_press, NULL); + keepkey_button_set_on_release_handler(&dice_on_release, NULL); +#endif + + ButtonRequest br; + memset(&br, 0, sizeof(br)); + br.has_code = true; + br.code = ButtonRequestType_ButtonRequest_DiceRoll; + msg_write(MessageType_MessageType_ButtonRequest, &br); + + while (count < target) { + bool pressed; + uint32_t held = 0; + uint8_t shorts = 0; + uint8_t holds; + + /* One critical section performs the whole read-classify-drain step, so + * the in-flight hold below cannot also be classified by the release ISR + * (and vice versa): whoever gets there first sets dice_committed. */ +#ifndef EMULATOR + svc_disable_interrupts(); +#endif + { + uint32_t now = getSysTime(); + pressed = dice_pressed; + if (pressed) { + held = now - dice_press_start; + if (!dice_committed && held >= DICE_HOLD_MS) { + dice_committed = true; + if (dice_hold_events < 8) { + dice_hold_events++; + } + } + } + /* Queued short presses stay queued until a debounce window has passed + * since the release that produced them, giving dice_on_press the + * chance to retract a bounce-generated one before it is acted on. + * Deliberately NOT conditioned on the button being up: a retraction + * can only happen inside that window, so once it closes the count is + * final. Waiting for the button to be released instead would let a + * tap-then-hold commit the digit the tap was meant to move off of. */ + if (dice_have_release && now - dice_release_time >= DICE_DEBOUNCE_MS) { + shorts = dice_short_events; + dice_short_events = 0; + } + holds = dice_hold_events; + dice_hold_events = 0; + } +#ifndef EMULATOR + svc_enable_interrupts(); +#endif + + uint16_t tiny_msg = check_for_tiny_msg(msg_tiny_buf); + switch (tiny_msg) { + case MessageType_MessageType_ButtonAck: + dice_accept = true; /* arms the button ISRs and debug injection */ + break; + + case MessageType_MessageType_Cancel: + case MessageType_MessageType_Initialize: + if (tiny_msg == MessageType_MessageType_Initialize) { + reset_msg_stack = true; + } + goto dice_exit; + +#if DEBUG_LINK + case MessageType_MessageType_DebugLinkDecision: { + const DebugLinkDecision *dld = (const DebugLinkDecision *)msg_tiny_buf; + if (dice_accept && dld->has_input) { + for (const char *c = dld->input; *c != '\0' && count < target; c++) { + if (*c >= '1' && *c <= '6') { + rolls[count++] = *c; + snprintf(status, sizeof(status), _("Entered %c (%lu)"), *c, + (unsigned long)count); + } else if (*c == 'u' && count > 0) { + count--; + snprintf(status, sizeof(status), _("Removed #%lu"), + (unsigned long)(count + 1)); + } + } + redraw = true; + } + break; + } + + case MessageType_MessageType_DebugLinkGetState: + call_msg_debug_link_get_state_handler( + (DebugLinkGetState *)msg_tiny_buf); + break; +#endif + + default: + break; + } + + if (shorts > 0) { + position = (uint8_t)((position + shorts) % DICE_POSITIONS); + redraw = true; + } + + /* Commits arrive either from the in-flight check above or from a release + * that completed inside the poll gap; both funnel through here, and + * dice_committed guarantees at most one per press. */ + while (holds-- > 0 && count < target) { + if (position < DICE_UNDO_POS) { + rolls[count++] = (char)('1' + position); + snprintf(status, sizeof(status), _("Entered %c (%lu)"), + (char)('1' + position), (unsigned long)count); + } else if (count > 0) { + count--; + snprintf(status, sizeof(status), _("Removed #%lu"), + (unsigned long)(count + 1)); + } else { + snprintf(status, sizeof(status), _("Nothing to undo")); + } + redraw = true; + } + + uint16_t bar_permil = 0; + if (pressed && held < DICE_HOLD_MS) { + bar_permil = (uint16_t)((held * 1000) / DICE_HOLD_MS); + } else if (pressed) { + bar_permil = 1000; /* held past the threshold: keep the bar full */ + } + + /* Quantize the bar so idle passes stay refresh-free. */ + bar_permil = (uint16_t)(bar_permil - (bar_permil % 50)); + if (redraw || bar_permil != last_bar_permil) { + dice_draw_screen(count, target, position, status, bar_permil); + last_bar_permil = bar_permil; + redraw = false; + } + + animate(); + display_refresh(); + } + + ret = true; + +dice_exit: + dice_accept = false; +#ifndef EMULATOR + keepkey_button_set_on_press_handler(NULL, NULL); + keepkey_button_set_on_release_handler(NULL, NULL); +#endif + memzero(status, sizeof(status)); + memzero(msg_tiny_buf, sizeof(msg_tiny_buf)); + return ret; +} diff --git a/lib/firmware/eos.c b/lib/firmware/eos.c index 973316c3e..31ec40060 100644 --- a/lib/firmware/eos.c +++ b/lib/firmware/eos.c @@ -530,6 +530,37 @@ bool eos_compileActionUnknown(const EosActionCommon* common, char title[MEDIUM_STR_BUF]; snprintf(title, sizeof(title), "%s:%s", account, name); + /* Show the AUTHORITIES this action runs under. + * + * unknown_common.authorization[] is part of the signed transaction and is + * compared across chunks and hashed into the preimage, but the screen + * below names only the contract, the action, a byte count and a data + * fingerprint. So an AdvancedMode owner approving an opaque action could + * not see which of their permissions it was being executed with -- the one + * part of an unknown action that says how much it is allowed to do. + * EosActionCommon carries at most 16 of them. */ + for (pb_size_t i = 0; i < unknown_common.authorization_count; i++) { + const EosPermissionLevel* auth = &unknown_common.authorization[i]; + char actor[EOS_NAME_STR_SIZE]; + char permission[EOS_NAME_STR_SIZE]; + CHECK_PARAM_RET(auth->has_actor && eos_formatName(auth->actor, actor), + "Invalid authorization actor", false); + CHECK_PARAM_RET( + auth->has_permission && eos_formatName(auth->permission, permission), + "Invalid authorization permission", false); + + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmEosAction, title, + "Authorized by %s@%s (%u of %u)", actor, permission, + (unsigned)(i + 1), + (unsigned)unknown_common.authorization_count)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, + "Action Cancelled"); + eos_signingAbort(); + layoutHome(); + return false; + } + } + static uint8_t hash[32]; hasher_Final(&hasher_unknown, hash); diff --git a/lib/firmware/ethereum.c b/lib/firmware/ethereum.c index 90ca2c1b2..16d24b0e8 100644 --- a/lib/firmware/ethereum.c +++ b/lib/firmware/ethereum.c @@ -58,7 +58,18 @@ bool ethereum_typed_hash_policy_allows(bool advanced_mode) { */ bool ethereum_structured_eip712_enabled(void) { return false; } -#define MAX_CHAIN_ID 2147483630 +/* 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: + * + * 2 * 2147483629 + 35 + 1 == 4294967294 <= UINT32_MAX, fits + * 2 * 2147483630 + 35 + 1 == 4294967296 wraps to 0 + * + * The old bound of 2147483630 sat exactly on that wrap: with v == 0 it lands + * on UINT32_MAX, and with v == 1 it reports a recovery id of 0 for a signature + * the device really made. A verifier recovers the wrong address from that, so + * the signature is silently unverifiable rather than refused. */ +#define MAX_CHAIN_ID 2147483629 #define ETHEREUM_TX_TYPE_LEGACY 0UL #define ETHEREUM_TX_TYPE_EIP_2930 1UL @@ -443,6 +454,29 @@ bool ethereumFormatAmount(const bignum256* amnt, const TokenType* token, suffix = " AVAX"; break; // Avalanche C-Chain } + + /* No case matched: this chain's native asset has no name here. + * + * Falling through with suffix == NULL made bn_format() render a bare + * 18-decimal number -- "Send 0.05 to 0xABC" -- which names no asset and + * no network, on a screen that is the whole basis for the signature. + * Both the value and the gas fee go through this function with + * token == NULL, so an unmapped chain got two unlabelled numbers. + * + * Adding more cases does not fix this; the fallback has to stop being + * silent. Refusing is not right either: every caller treats false as a + * hard refusal, so a chain merely missing from this list -- a new L2, + * say -- would become unsignable, including its gas. + * + * So state exactly what is known. Wei is the base unit of every EVM + * chain regardless of what its native asset is called, so the amount + * stays exact and carries a correct unit; what is dropped is the claim + * to know the asset's name. This is the same rendering sub-gwei amounts + * already get a few lines above. */ + if (!suffix) { + suffix = " Wei"; + decimals = 0; + } } } if (!bn_format(amnt, NULL, suffix, decimals, 0, false, buf, buflen)) { @@ -642,6 +676,21 @@ static bool ethereum_signing_check(const EthereumSignTx* msg) { return false; } + /* The same sanity check, for the field the EIP-1559 fee screen actually + multiplies. confirmEthereumTx() feeds max_fee_per_gas into + bn_multiply(&val, &gas, &secp256k1.prime), which reduces its product + modulo the curve prime. The legacy bound above never reaches it: a 1559 + transaction carries no gas_price, so gas_price.size is 0 and a 32-byte + max_fee_per_gas paired with a 32-byte gas_limit passes untouched. The + product then wraps and the approval screen names a gas cost that is not + the one being signed -- the display diverges from the signature, which is + the one thing this release line exists to prevent. Hold the 1559 pair to + the same 30-byte budget. */ + if (msg->has_max_fee_per_gas && + msg->max_fee_per_gas.size + msg->gas_limit.size > 30) { + return false; + } + return true; } diff --git a/lib/firmware/ethereum_contracts/saproxy.c b/lib/firmware/ethereum_contracts/saproxy.c index ac6e244fc..3d4a3a875 100644 --- a/lib/firmware/ethereum_contracts/saproxy.c +++ b/lib/firmware/ethereum_contracts/saproxy.c @@ -79,25 +79,31 @@ bool sa_confirmWithdrawFromSalary(uint32_t data_total, * be able to drift apart. See sa_withdrawFromSalaryExtentOk(). */ if (!sa_withdrawFromSalaryExtentOk(msg)) return false; - char confStr[41]; - // confirm raw unformatted numbers - /* bn_format() BLANKS its output buffer and returns 0 when the value does - * not fit -- ignoring the return renders an EMPTY amount on the - * confirmation screen, the one rendering a user cannot read as wrong. */ - if (!sa_formatUint256(msg->data_initial_chunk.bytes + 4, "", confStr, - sizeof(confStr))) + /* Format BOTH values before either screen. + * + * bn_format() blanks its output and returns 0 when the value does not fit, + * so an unrenderable amount is a refusal. Doing the second format after the + * first confirmation meant a large but perfectly valid uint256 amount failed + * only once the salary ID had been approved -- and the Ethereum dispatcher + * reports that late failure as ActionCancelled, so the owner is told they + * cancelled something they had in fact approved. Non-interactive work + * belongs before the first screen. */ + char idStr[41]; + char amountStr[41]; + if (!sa_formatUint256(msg->data_initial_chunk.bytes + 4, "", idStr, + sizeof(idStr))) return false; + if (!sa_formatUint256(msg->data_initial_chunk.bytes + 4 + 32, " Token Units", + amountStr, sizeof(amountStr))) + return false; + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Sablier", - "Salary ID %s", confStr)) { + "Salary ID %s", idStr)) { return false; } - // confirm raw unformatted numbers - if (!sa_formatUint256(msg->data_initial_chunk.bytes + 4 + 32, " Token Units", - confStr, sizeof(confStr))) - return false; if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Sablier", - "Withdraw Amount %s", confStr)) { + "Withdraw Amount %s", amountStr)) { return false; } return true; diff --git a/lib/firmware/ethereum_contracts/thortx.c b/lib/firmware/ethereum_contracts/thortx.c index 3973d939f..25fc2cdd5 100644 --- a/lib/firmware/ethereum_contracts/thortx.c +++ b/lib/firmware/ethereum_contracts/thortx.c @@ -142,33 +142,17 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { thorchainData = (uint8_t*)(msg->data_initial_chunk.bytes + 4 + (is_expiry ? 6 : 5) * 32); - // Start confirmations - for (ctr = 0; ctr < 20; ctr++) { - snprintf(&confStr[ctr * 2], 3, "%02x", msg->to.bytes[ctr]); - } - /* 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) { - conf = "Thorchain router"; - } else { - conf = confStr; - } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data", - "Routing through %s", conf)) { - return false; - } - - // just display token address and amount as string - for (ctr = 0; ctr < 20; ctr++) { - snprintf(&confStr[ctr * 2], 3, "%02x", vaultAddress[ctr]); - } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data", - "Using Asgard vault %s", confStr)) { - return false; - } - + /* Everything non-interactive FIRST, so an unrenderable call fails before any + * approval is taken. + * + * The amount used to be formatted after the router, vault and asset screens + * had been approved, and the expiry word validated after that. bn_format() + * refuses a value it cannot render, and ethereum.c turns a false return from + * this decoder into ActionCancelled -- so a large but valid amount, or a + * non-canonical expiry, told the owner they had cancelled a transaction they + * had already approved three screens of. Resolve the asset, render the + * amount, and check the expiry up here; the confirmations below then only + * display what is already known to be displayable. */ assetAddress = contractAssetAddress; /* The THORChain ABI uses the zero address to mean this signing chain's * native asset. Resolve that router-specific meaning directly instead of @@ -181,40 +165,18 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { assetToken = tokenByChainAddress(msg->chain_id, assetAddress); } + char amountStr[41]; if (assetToken == UnknownToken) { - // just display token address and amount as string - for (ctr = 0; ctr < 20; ctr++) { - snprintf(&confStr[ctr * 2], 3, "%02x", assetAddress[ctr]); - } - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "from asset %s", confStr)) { - return false; - } - // We don't know what the exponent should be so just confirm raw unformatted - // number - /* bn_format() BLANKS its output buffer and returns 0 when the value - * does not fit -- ignoring the return renders an EMPTY amount on the - * confirmation screen, the one rendering a user cannot read as wrong. - * Never leave the caller a blank amount. */ + /* We don't know what the exponent should be, so confirm the raw + * unformatted number. */ if (!thor_formatUnknownAssetAmount( - msg->data_initial_chunk.bytes + 4 + 2 * 32, confStr, - sizeof(confStr))) - return false; - - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "amount %s", confStr)) { + msg->data_initial_chunk.bytes + 4 + 2 * 32, amountStr, + sizeof(amountStr))) return false; - } - } else { - if (!ethereumFormatAmount(&Amount, assetToken, msg->chain_id, confStr, - sizeof(confStr))) + if (!ethereumFormatAmount(&Amount, assetToken, msg->chain_id, amountStr, + sizeof(amountStr))) return false; - - if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "Confirm sending %s", confStr)) { - return false; - } } /* depositWithExpiry() carries a fifth head word the deposit() variant does @@ -230,6 +192,7 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { * because a far-future expiry displayed as a small epoch is worse than no * screen at all -- it reads as "already expired" when it means the * opposite. */ + char expiry_str[21] = {0}; if (is_expiry) { const uint8_t* expiry_word = msg->data_initial_chunk.bytes + 4 + 4 * 32; for (size_t i = 0; i < 24; i++) { @@ -240,7 +203,6 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { expiry = (expiry << 8) | expiry_word[i]; } - char expiry_str[21] = {0}; char tmp[21]; int len = 0; if (expiry == 0) { @@ -254,10 +216,60 @@ bool thor_confirmThorTx(uint32_t data_total, const EthereumSignTx* msg) { for (int i = 0; i < len; i++) { expiry_str[i] = tmp[len - 1 - i]; } + } + + // 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) { + conf = "Thorchain router"; + } else { + conf = confStr; + } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data", + "Routing through %s", conf)) { + return false; + } + + // just display token address and amount as string + for (ctr = 0; ctr < 20; ctr++) { + snprintf(&confStr[ctr * 2], 3, "%02x", vaultAddress[ctr]); + } + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Thorchain data", + "Using Asgard vault %s", confStr)) { + return false; + } + + if (assetToken == UnknownToken) { + // just display token address and amount as string + for (ctr = 0; ctr < 20; ctr++) { + snprintf(&confStr[ctr * 2], 3, "%02x", assetAddress[ctr]); + } if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, - "Thorchain data", "Expiry epoch %s", expiry_str)) { + "Thorchain data", "from asset %s", confStr)) { return false; } + + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain data", "amount %s", amountStr)) { + return false; + } + + } else { + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain data", "Confirm sending %s", amountStr)) { + return false; + } + } + + if (is_expiry && !confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, + "Thorchain data", "Expiry epoch %s", expiry_str)) { + return false; } /* Pass the memo's true ABI length, not a fixed 64. There is no raw-memo diff --git a/lib/firmware/ethereum_contracts/zxswap.c b/lib/firmware/ethereum_contracts/zxswap.c index 0fc4e02e7..06e46e132 100644 --- a/lib/firmware/ethereum_contracts/zxswap.c +++ b/lib/firmware/ethereum_contracts/zxswap.c @@ -53,6 +53,7 @@ static bool isSellToUniswapCall(const EthereumSignTx* msg) { */ static bool zxswap_resolveBothTokens(const EthereumSignTx* msg, const TokenType** from, + const TokenType** via, const TokenType** to, const char** exchange) { /* Everything read before the token count is known lives in the selector plus @@ -114,6 +115,25 @@ static bool zxswap_resolveBothTokens(const EthereumSignTx* msg, const TokenType* t = tokenByChainAddress( msg->chain_id, msg->data_initial_chunk.bytes + 4 + (6 + adder) * 32 + 12); + /* The MIDDLE token of a three-token route, which the screen used to omit. + * + * sellToUniswap() executes one swap per adjacent pair, so tokens[1] selects + * the pair contracts the trade actually routes through. Reading only + * tokens[0] and tokens[last] meant every tokens[1] produced the same + * "Sell X / Buy at least Y" screen while the route underneath it changed -- + * a different set of pools, a different counterparty, the same approval. + * + * Resolve it on the same terms as the endpoints, and hold it to the same + * chain-scoped check: an unresolvable hop makes the whole call + * undisplayable, so it falls through to the AdvancedMode raw-calldata path + * rather than being shown as a two-token trade it is not. */ + const TokenType* v = NULL; + if (adder) { + v = tokenByChainAddress(msg->chain_id, + msg->data_initial_chunk.bytes + 4 + 6 * 32 + 12); + if (!zx_tokenLabelsThisChain(msg->chain_id, v)) return false; + } + /* Not just "resolved" -- resolved to metadata for this exact chain. The * lookup is chain-scoped, and this second check keeps the decoder fail-closed * if a future caller ever supplies metadata directly. */ @@ -122,6 +142,7 @@ static bool zxswap_resolveBothTokens(const EthereumSignTx* msg, return false; if (from) *from = f; + if (via) *via = v; if (to) *to = t; if (exchange) *exchange = (isSushi == 0) ? "Uniswap" : "Sushiswap"; return true; @@ -145,7 +166,7 @@ bool zx_isZxSwap(const EthereumSignTx* msg) { here is what makes it fall through to the raw-calldata path, which is AdvancedMode-gated and shows the bytes; refusing in the confirm would be read as a user cancel (see ethereum.c, ethereum_contractConfirmed). */ - return zxswap_resolveBothTokens(msg, NULL, NULL, NULL); + return zxswap_resolveBothTokens(msg, NULL, NULL, NULL, NULL); } bool zx_confirmZxSwap(uint32_t data_total, const EthereumSignTx* msg) { @@ -170,9 +191,9 @@ bool zx_confirmZxSwap(uint32_t data_total, const EthereumSignTx* msg) { return false; } - const TokenType *from, *to; + const TokenType *from, *via, *to; const char* exchange; - if (!zxswap_resolveBothTokens(msg, &from, &to, &exchange)) return false; + if (!zxswap_resolveBothTokens(msg, &from, &via, &to, &exchange)) return false; char constr1[40], constr2[40]; @@ -199,6 +220,23 @@ bool zx_confirmZxSwap(uint32_t data_total, const EthereumSignTx* msg) { return false; } + /* Name the intermediate hop on its own screen. The amounts above bound only + the ends of the route; this is the asset the trade passes through, and it + is as much a part of what is being signed as they are. + + Tickers in the generated table lead with a space (" USDC") because + ethereumFormatAmount() appends them straight after a number. Step over it + rather than emitting "Route via USDC". */ + if (via) { + const char* via_ticker = via->ticker ? via->ticker : ""; + while (*via_ticker == ' ') via_ticker++; + if (*via_ticker == '\0') return false; + if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, exchange, + "Route via %s", via_ticker)) { + return false; + } + } + /* Anything past the ABI encoding is signed but describes nothing this screen * asserted. In practice it is 0x's 68-byte affiliate suffix, present on every * quote their API returns, which is why refusing it outright is not an option diff --git a/lib/firmware/fsm.c b/lib/firmware/fsm.c index 98618a17a..e2ad77edc 100644 --- a/lib/firmware/fsm.c +++ b/lib/firmware/fsm.c @@ -101,6 +101,27 @@ #define _(X) (X) static uint8_t msg_resp[MAX_FRAME_SIZE] __attribute__((aligned(4))); +/* Shared scratch returned by fsm_getDerivedNode(). It may hold a root or + * derived private key after any chain handler, so session revocation scrubs it + * centrally. */ +static HDNode CONFIDENTIAL fsm_derived_node; + +void fsm_clearDerivedNode(void) { + memzero(&fsm_derived_node, sizeof(fsm_derived_node)); +} + +#if DEBUG_LINK +void fsm_test_seedDerivedNode(void) { + memset(&fsm_derived_node, 0xA5, sizeof(fsm_derived_node)); +} + +bool fsm_test_derivedNodeIsZero(void) { + const uint8_t* bytes = (const uint8_t*)&fsm_derived_node; + uint8_t aggregate = 0; + for (size_t i = 0; i < sizeof(fsm_derived_node); i++) aggregate |= bytes[i]; + return aggregate == 0; +} +#endif #define CHECK_INITIALIZED \ if (!storage_isInitialized()) { \ @@ -109,11 +130,34 @@ static uint8_t msg_resp[MAX_FRAME_SIZE] __attribute__((aligned(4))); return; \ } -#define CHECK_NOT_INITIALIZED \ - if (storage_isInitialized()) { \ - fsm_sendFailure(FailureType_Failure_UnexpectedMessage, \ - "Device is already initialized. Use Wipe first."); \ - return; \ +#define CHECK_NOT_INITIALIZED \ + if (storage_isInitialized()) { \ + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, \ + "Device is already initialized. Use Wipe first."); \ + return; \ + } \ + /* A locked bitcoin-only wallet leaves the device LOOKING uninitialized: \ + * the RAM shadow was reset at boot, so storage_isInitialized() is \ + * false. Refuse here, loudly, before the user does the work -- a \ + * ceremony allowed to run would end in storage_commit() declining to \ + * write and the handler reporting success anyway. */ \ + if (storage_isBitcoinOnlyLocked()) { \ + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, \ + "Bitcoin-only wallet present. Use Wipe first."); \ + return; \ + } + +/* Only the two ceremony STARTS use this. Every other message that persists + * anything is handled structurally instead: storage_commit() aborts an armed + * ceremony, so a handler that writes can never have its write consumed by + * one -- the worst it can do is end it. */ +#define CHECK_NO_CEREMONY \ + if (setup_isArmed()) { \ + fsm_sendFailure(FailureType_Failure_UnexpectedMessage, \ + "Device is in the middle of setup. Send " \ + "Initialize or Cancel first."); \ + layoutHome(); \ + return; \ } /* Only the two ceremony STARTS use this. Every other message that persists @@ -176,11 +220,6 @@ static const MessagesMap_t MessagesMap[] = { extern bool reset_msg_stack; -/* Shared scratch returned by fsm_getDerivedNode(). It may hold a root or - * derived private key after any chain handler, not only the streaming Bitcoin - * signer, so session revocation must scrub it centrally. */ -static HDNode CONFIDENTIAL fsm_derived_node; - static const CoinType* fsm_getCoin(bool has_name, const char* name) { const CoinType* coin; if (has_name) { @@ -204,6 +243,14 @@ static HDNode* fsm_getDerivedNode(const char* curve, const uint32_t* address_n, *fingerprint = 0; } + /* Every failure below returns NULL, so the caller has no pointer with which + * to clear this scratch -- only this function can. Leaving it dirty left a + * root or half-derived private key resident until whatever happened to + * overwrite it next: storage_getRootNode() may write before it fails, and by + * the time hdnode_private_ckd_cached() can fail the root is definitely + * there. Scrub on entry, and on each failure after a possible write. */ + memzero(&fsm_derived_node, sizeof(fsm_derived_node)); + if (!get_curve_by_name(curve)) { fsm_sendFailure(FailureType_Failure_SyntaxError, "Unknown ecdsa curve"); layoutHome(); @@ -211,6 +258,7 @@ static HDNode* fsm_getDerivedNode(const char* curve, const uint32_t* address_n, } if (!storage_getRootNode(curve, true, &fsm_derived_node)) { + memzero(&fsm_derived_node, sizeof(fsm_derived_node)); fsm_sendFailure(FailureType_Failure_NotInitialized, "Device not initialized or passphrase request cancelled"); layoutHome(); @@ -223,6 +271,7 @@ static HDNode* fsm_getDerivedNode(const char* curve, const uint32_t* address_n, if (hdnode_private_ckd_cached(&fsm_derived_node, address_n, address_n_count, fingerprint) == 0) { + memzero(&fsm_derived_node, sizeof(fsm_derived_node)); fsm_sendFailure(FailureType_Failure_Other, "Failed to derive private key"); layoutHome(); return 0; @@ -295,6 +344,7 @@ void fsm_sendFailure(FailureType code, const char* text) { void fsm_abort_workflows(void) { setup_abort(); signing_abort(); +#if !BITCOIN_ONLY ethereum_signing_abort(); nano_signingAbort(); binance_signAbort(); @@ -303,6 +353,7 @@ void fsm_abort_workflows(void) { thorchain_signAbort(); mayachain_signAbort(); eos_signingAbort(); +#endif authenticator_clear_cache(); memzero(&fsm_derived_node, sizeof(fsm_derived_node)); } @@ -311,15 +362,28 @@ void fsm_msgClearSession(ClearSession* msg) { (void)msg; fsm_abort_workflows(); session_clear(/*clear_pin=*/true); + /* Several abort routines -- Binance, Tendermint, Osmosis, THORChain, + MAYAChain, EOS, Nano -- only clear state and touch no layout, so without + this the approval screen of the transaction just cancelled stays on the + OLED, describing an operation that no longer exists. + + Done here and in fsm_msgCancel() rather than inside fsm_abort_workflows(), + because that is also called from toggle_screensaver(), which draws the + screensaver immediately afterwards. */ + layoutHome(); fsm_sendSuccess("Session cleared"); } +// Always-on handlers: Bitcoin and common device messages (fsm_msg_coin, +// fsm_msg_common), CipherKeyValue/identity (fsm_msg_crypto) and debug-link. +// None of these is a coin engine. #include "fsm_msg_common.h" #include "fsm_msg_coin.h" -#include "fsm_msg_ethereum.h" -#include "fsm_msg_nano.h" #include "fsm_msg_crypto.h" #include "fsm_msg_debug.h" +#if !BITCOIN_ONLY +#include "fsm_msg_ethereum.h" +#include "fsm_msg_nano.h" #include "fsm_msg_eos.h" #include "fsm_msg_cosmos.h" #include "fsm_msg_osmosis.h" @@ -331,3 +395,12 @@ void fsm_msgClearSession(ClearSession* msg) { #include "fsm_msg_tron.h" #include "fsm_msg_ton.h" #include "fsm_msg_solana.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. +void ethereum_signing_abort(void) {} +void tendermint_signAbort(void) {} +void eos_signingAbort(void) {} +#endif // !BITCOIN_ONLY diff --git a/lib/firmware/fsm_msg_binance.h b/lib/firmware/fsm_msg_binance.h index 586e6f572..ac0fccf47 100644 --- a/lib/firmware/fsm_msg_binance.h +++ b/lib/firmware/fsm_msg_binance.h @@ -134,6 +134,29 @@ void fsm_msgBinanceTransferMsg(const BinanceTransferMsg* msg) { layoutHome(); return; } + /* Validate both addresses BEFORE the screen. binance_validateTransfer() + checks structure only, and the bech32/network check lives in + binance_serializeInputOutput(), which runs after this approval -- so a + malformed or wrong-network recipient was displayed and approved, then + rejected. */ + { + const char* const pfix = binance_sessionAddressPrefix(); + /* The input is the transfer's AUTHORITY, and no screen displays it, + so being well formed on the right network is not enough -- it has to + be the account this session's key signs as. The output is the + recipient and is shown, so it only needs to be a valid address on + the same network. */ + if (!pfix || + !tendermint_validateBech32Address(msg->inputs[0].address, pfix) || + !binance_addressIsSigner(msg->inputs[0].address) || + !tendermint_validateBech32Address(msg->outputs[0].address, pfix)) { + binance_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid Binance transfer address"); + layoutHome(); + return; + } + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->outputs[0].address)) { diff --git a/lib/firmware/fsm_msg_coin.h b/lib/firmware/fsm_msg_coin.h index 3cca0a3a8..fe13701e0 100644 --- a/lib/firmware/fsm_msg_coin.h +++ b/lib/firmware/fsm_msg_coin.h @@ -198,6 +198,21 @@ static bool path_mismatched(const CoinType* coin, const GetAddress* msg) { return mismatch; } + // m/86' : BIP86 Taproot + // m / purpose' / bip44_account_path' / account' / change / address_index + if (msg->address_n[0] == (0x80000000 + 86)) { + mismatch |= (msg->script_type != InputScriptType_SPENDTAPROOT); + mismatch |= !coin->has_segwit || !coin->segwit; + mismatch |= !coin->has_bech32_prefix; + mismatch |= !coin->has_taproot || !coin->taproot; + mismatch |= (msg->address_n_count != 5); + mismatch |= (msg->address_n[1] != coin->bip44_account_path); + mismatch |= (msg->address_n[2] & 0x80000000) == 0; + mismatch |= (msg->address_n[3] & 0x80000000) == 0x80000000; + mismatch |= (msg->address_n[4] & 0x80000000) == 0x80000000; + return mismatch; + } + return false; } @@ -280,6 +295,18 @@ void fsm_msgSignMessage(SignMessage* msg) { CHECK_INITIALIZED + /* A zero-length message is not a message. confirm_bytes() renders size 0 as + the literal "(empty)" and returns whatever the owner pressed, so without + this the device would sign a payload no screen ever showed -- the same + hole already closed on the TON and Solana paths. (`message` is a required + field here, so nanopb rejects an omitted one during decode; only the empty + case reaches this far.) */ + if (msg->message.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Missing message")); + layoutHome(); + return; + } + const CoinType* coin = fsm_getCoin(msg->has_coin_name, msg->coin_name); if (!coin) return; diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index ac8c85344..9644f7459 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -36,10 +36,29 @@ void fsm_msgGetFeatures(GetFeatures* msg) { resp->has_model = true; strlcpy(resp->model, model(), sizeof(resp->model)); + /* Taproot capability. Reported directly so a host does not have to infer + P2TR support from a firmware version -- that inference breaks whenever the + feature is retargeted to a different release. */ + resp->has_supports_taproot = true; + resp->supports_taproot = true; + /* Variant Name */ resp->has_firmware_variant = true; +#if BITCOIN_ONLY + /* Report the established KeepKeyBTC / EmulatorBTC names rather than the + board variant, so that existing hosts recognise a bitcoin-only image and + skip multi-chain-only behaviour instead of offering it features this + firmware does not implement. */ +#ifdef EMULATOR + strlcpy(resp->firmware_variant, "EmulatorBTC", + sizeof(resp->firmware_variant)); +#else + strlcpy(resp->firmware_variant, "KeepKeyBTC", sizeof(resp->firmware_variant)); +#endif +#else strlcpy(resp->firmware_variant, variant_getName(), sizeof(resp->firmware_variant)); +#endif /* Security settings */ resp->has_pin_protection = true; @@ -120,6 +139,12 @@ void fsm_msgGetFeatures(GetFeatures* msg) { void fsm_msgGetCoinTable(GetCoinTable* msg) { RESP_INIT(CoinTable); +#if BITCOIN_ONLY + const size_t coin_table_count = COINS_COUNT; +#else + const size_t coin_table_count = COINS_COUNT + TOKENS_COUNT; +#endif + CHECK_PARAM(msg->has_start == msg->has_end, "Incorrect GetCoinTable parameters"); @@ -127,9 +152,8 @@ void fsm_msgGetCoinTable(GetCoinTable* msg) { resp->chunk_size = sizeof(resp->table) / sizeof(resp->table[0]); if (msg->has_start && msg->has_end) { - if (COINS_COUNT + TOKENS_COUNT <= msg->start || - COINS_COUNT + TOKENS_COUNT < msg->end || msg->end < msg->start || - resp->chunk_size < msg->end - msg->start) { + if (coin_table_count <= msg->start || coin_table_count < msg->end || + msg->end < msg->start || resp->chunk_size < msg->end - msg->start) { fsm_sendFailure(FailureType_Failure_Other, "Incorrect GetCoinTable parameters"); layoutHome(); @@ -138,7 +162,7 @@ void fsm_msgGetCoinTable(GetCoinTable* msg) { } resp->has_num_coins = true; - resp->num_coins = COINS_COUNT + TOKENS_COUNT; + resp->num_coins = coin_table_count; if (msg->has_start && msg->has_end) { resp->table_count = msg->end - msg->start; @@ -146,8 +170,10 @@ void fsm_msgGetCoinTable(GetCoinTable* msg) { for (size_t i = 0; i < msg->end - msg->start; i++) { if (msg->start + i < COINS_COUNT) { resp->table[i] = coins[msg->start + i]; +#if !BITCOIN_ONLY } else if (msg->start + i - COINS_COUNT < TOKENS_COUNT) { coinFromToken(&resp->table[i], &tokens[msg->start + i - COINS_COUNT]); +#endif } } } @@ -164,6 +190,7 @@ static bool isValidModelNumber(const char* model) { bool checkPassphrase(void) { if (!passphrase_protect()) { + authenticator_clear_cache(); fsm_sendFailure(FailureType_Failure_ActionCancelled, "authenticator needs passphrase"); layoutHome(); @@ -445,6 +472,18 @@ void fsm_msgChangeWipeCode(ChangeWipeCode* msg) { void fsm_msgWipeDevice(WipeDevice* msg) { (void)msg; + /* Supersede active work when the request ARRIVES, not when it succeeds. + * + * Aborting only on the wipe path left the cancel path resumable: a + * WipeDevice that interrupts a streamed signing session puts its own screen + * up, and if the owner declines the wipe the handler returns with the old + * session still live. The host then sends the TxAck it was already holding + * and the interrupted signing continues -- across a screen that said nothing + * about that transaction. A new top-level ceremony ends whatever preceded + * it, exactly as the Bitcoin and Ethereum signing starts do; whether the + * owner then approves the wipe is a separate question. */ + fsm_abort_workflows(); + if (!confirm(ButtonRequestType_ButtonRequest_WipeDevice, "Wipe Device", "Do you want to erase your private keys and settings?")) { fsm_sendFailure(FailureType_Failure_ActionCancelled, "Wipe cancelled"); @@ -454,6 +493,7 @@ void fsm_msgWipeDevice(WipeDevice* msg) { /* Wipe device */ fsm_abort_workflows(); + session_clear(/*clear_pin=*/true); storage_wipe(); storage_reset(); storage_resetUuid(); @@ -538,7 +578,8 @@ void fsm_msgResetDevice(ResetDevice* msg) { msg->has_no_backup ? msg->no_backup : false, msg->has_auto_lock_delay_ms ? msg->auto_lock_delay_ms : STORAGE_DEFAULT_SCREENSAVER_TIMEOUT, - msg->has_u2f_counter ? msg->u2f_counter : 0); + msg->has_u2f_counter ? msg->u2f_counter : 0, + msg->has_dice_entropy && msg->dice_entropy); } void fsm_msgEntropyAck(EntropyAck* msg) { @@ -552,6 +593,10 @@ void fsm_msgEntropyAck(EntropyAck* msg) { void fsm_msgCancel(Cancel* msg) { (void)msg; fsm_abort_workflows(); + /* See fsm_msgClearSession(): the abort routines for Binance, Tendermint, + Osmosis, THORChain, MAYAChain, EOS and Nano have no layout side effect, so + the cancelled transaction's approval screen would otherwise stay up. */ + layoutHome(); fsm_sendFailure(FailureType_Failure_ActionCancelled, "Aborted"); } @@ -654,6 +699,16 @@ void fsm_msgRecoveryDevice(RecoveryDevice* msg) { CHECK_NOT_INITIALIZED } + /* CHECK_NO_CEREMONY above refuses a recovery that would collide with an + * armed setup ceremony, but setup_isArmed() knows nothing about signing. A + * dry run is permitted on an initialized device, so it can start while a + * streamed signing session is waiting on its next ACK -- and run to + * completion with that session still resumable afterwards. Abort here rather + * than inside the macro so the refusal keeps its meaning, and abort only + * after both init-state checks have passed: a recovery that is about to be + * rejected must not tear down work it never replaces. */ + fsm_abort_workflows(); + recovery_cipher_init( msg->has_word_count ? msg->word_count : 0, msg->has_passphrase_protection && msg->passphrase_protection, diff --git a/lib/firmware/fsm_msg_cosmos.h b/lib/firmware/fsm_msg_cosmos.h index 960e3a17e..ba044d78b 100644 --- a/lib/firmware/fsm_msg_cosmos.h +++ b/lib/firmware/fsm_msg_cosmos.h @@ -5,6 +5,19 @@ static void cosmos_formatAmount(uint64_t amount, char* out, size_t out_len) { } } +/* An IBC revision counter as it must appear in the signed Amino JSON: a + non-empty run of digits with no leading zero beyond the single value "0". + Anything else either injects into the document (it is interpolated with a + bare "%s") or gives one counter several spellings on screen. */ +static bool cosmos_validate_unsigned_decimal(const char* value) { + if (!value || value[0] == '\0') return false; + for (const char* p = value; *p; ++p) { + if (*p < '0' || *p > '9') return false; + } + if (value[0] == '0' && value[1] != '\0') return false; + return true; +} + void fsm_msgCosmosGetAddress(const CosmosGetAddress* msg) { RESP_INIT(CosmosAddress); @@ -117,8 +130,22 @@ void fsm_msgCosmosSignTx(const CosmosSignTx* msg) { void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { // Confirm transaction basics - CHECK_PARAM(tendermint_signingIsInited(TENDERMINT_SIGNING_COSMOS), - "Cosmos signing not in progress"); + /* A continuation for the WRONG protocol is terminal for the session it + found, not just for itself. + + Cosmos and generic Tendermint share one signer in signtx_tendermint.c, + told apart only by signing_type. CHECK_PARAM sends a failure and returns, + leaving that shared state initialized: a Cosmos session survived a + Tendermint ACK (and vice versa), the UI went home, and the session stayed + resumable by a later stale ACK of its own protocol. Clear the shared + signer first, the way every malformed-ACK path below already does. */ + if (!tendermint_signingIsInited(TENDERMINT_SIGNING_COSMOS)) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_Other, + "Cosmos signing not in progress"); + layoutHome(); + return; + } const CoinType* coin = fsm_getCoin(true, "Cosmos"); if (!coin) { @@ -140,6 +167,20 @@ void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { default: { char amount_str[32]; cosmos_formatAmount(msg->send.amount, amount_str, sizeof(amount_str)); + /* Validate the recipient BEFORE the screen, not in the serializer. + tendermint_signTxUpdateMsgSend() already refuses a + malformed or wrong-network address, but it runs after this + confirmation, so the owner approved a transfer that was then + rejected. This release line's rule is that an invalid signed value + fails before approval, so the same check moves ahead of the + screen. */ + if (!tendermint_validateBech32Address(msg->send.to_address, "cosmos")) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid Cosmos recipient address"); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->send.to_address)) { @@ -380,12 +421,40 @@ void fsm_msgCosmosMsgAck(const CosmosMsgAck* msg) { } } else if (msg->has_ibc_transfer) { /** Confirm required transaction parameters exist */ + /* Presence alone is not enough. Every one of these strings is copied + into the signed Amino JSON by tendermint_signTxUpdateMsgIBCTransfer() + with a bare "%s" through tendermint_snprintf(), which -- unlike + tendermint_sha256UpdateEscaped() -- does no escaping. A receiver or + source_channel carrying a quote or a backslash therefore writes JSON + structure into the document the device signs, and a control byte or an + empty value makes the confirmation screens ambiguous about what that + document says. tendermint_validateSafeText() is the same gate the + Osmosis IBC path already applies to these exact fields; the revision + counters are digit strings, so hold them to that as well. */ + /* The receiver has to be well-formed bech32 BEFORE any screen opens. + The serializer refuses a malformed one, but it runs after every IBC + approval has already been taken, so the owner approved a transfer + that was then rejected. Its HRP belongs to the counterparty chain, + so only well-formedness can be checked here -- that is exactly what + the serializer checks, moved ahead of the confirmations. */ if (!msg->ibc_transfer.has_sender || !msg->ibc_transfer.has_receiver || !msg->ibc_transfer.has_source_channel || !msg->ibc_transfer.has_source_port || !msg->ibc_transfer.has_revision_height || !msg->ibc_transfer.has_revision_number || !msg->ibc_transfer.has_denom || !msg->ibc_transfer.has_amount || + /* `sender` is the authority the message acts as and is written into + the signed JSON verbatim, but no screen shows it. Safe text alone + left an arbitrary printable value surviving every approval. Bind it + to the account this session signs as -- the serializer now refuses a + mismatch too, but that happens after the screens. */ + !tendermint_addressIsSigner(msg->ibc_transfer.sender, "cosmos") || + !tendermint_validateSafeText(msg->ibc_transfer.receiver) || + !tendermint_validateSafeText(msg->ibc_transfer.source_channel) || + !tendermint_validateSafeText(msg->ibc_transfer.source_port) || + !tendermint_bech32IsWellFormed(msg->ibc_transfer.receiver) || + !cosmos_validate_unsigned_decimal(msg->ibc_transfer.revision_height) || + !cosmos_validate_unsigned_decimal(msg->ibc_transfer.revision_number) || strcmp(msg->ibc_transfer.denom, "uatom") != 0) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, diff --git a/lib/firmware/fsm_msg_crypto.h b/lib/firmware/fsm_msg_crypto.h index 2d7dfcbfd..2f74f59fa 100644 --- a/lib/firmware/fsm_msg_crypto.h +++ b/lib/firmware/fsm_msg_crypto.h @@ -65,9 +65,32 @@ void fsm_msgSignIdentity(SignIdentity* msg) { 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 + or an unsupported curve collected a full approval -- and, for the curve, a + 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) { + fsm_sendFailure(FailureType_Failure_Other, "Invalid identity"); + layoutHome(); + return; + } + + if (!get_curve_by_name(curve)) { + memzero(hash, sizeof(hash)); + fsm_sendFailure(FailureType_Failure_SyntaxError, "Unknown ecdsa curve"); + layoutHome(); + return; + } + if (!confirm_sign_identity( &(msg->identity), msg->has_challenge_visual ? msg->challenge_visual : 0, curve)) { + memzero(hash, sizeof(hash)); fsm_sendFailure(FailureType_Failure_ActionCancelled, "Sign identity cancelled"); layoutHome(); @@ -76,14 +99,6 @@ void fsm_msgSignIdentity(SignIdentity* msg) { CHECK_PIN - uint8_t hash[32]; - if (!msg->has_identity || - cryptoIdentityFingerprint(&(msg->identity), hash) == 0) { - fsm_sendFailure(FailureType_Failure_Other, "Invalid identity"); - layoutHome(); - return; - } - uint32_t address_n[5]; address_n[0] = 0x80000000 | 13; address_n[1] = 0x80000000 | hash[0] | (hash[1] << 8) | (hash[2] << 16) | diff --git a/lib/firmware/fsm_msg_debug.h b/lib/firmware/fsm_msg_debug.h index 3d1916161..13dc98e58 100644 --- a/lib/firmware/fsm_msg_debug.h +++ b/lib/firmware/fsm_msg_debug.h @@ -19,6 +19,9 @@ void fsm_msgDebugLinkGetState(DebugLinkGetState* msg) { resp->has_reset_word = true; strlcpy(resp->reset_word, reset_get_word(), sizeof(resp->reset_word)); + resp->dice_digest.size = reset_get_dice_digest(resp->dice_digest.bytes); + resp->has_dice_digest = resp->dice_digest.size > 0; + if (storage_hasMnemonic()) { resp->has_mnemonic = true; strlcpy(resp->mnemonic, storage_getMnemonic(), sizeof(resp->mnemonic)); diff --git a/lib/firmware/fsm_msg_ethereum.h b/lib/firmware/fsm_msg_ethereum.h index c676bb180..5b9ad01e1 100644 --- a/lib/firmware/fsm_msg_ethereum.h +++ b/lib/firmware/fsm_msg_ethereum.h @@ -125,9 +125,21 @@ void fsm_msgEthereumGetAddress(EthereumGetAddress* msg) { msg->address_n_count, NULL); if (!node) return; - resp->address.size = 20; + /* Build the whole answer in LOCALS and commit it to `resp` only after the + * confirmation. + * + * `resp` aliases fsm.c's single msg_resp buffer, and confirm_* below runs a + * message loop: every DebugLink request the emulator harness makes while a + * screen is up is dispatched from inside it, and those handlers RESP_INIT + * the same buffer. Anything staged in `resp` before the screen is therefore + * live across an arbitrary number of foreign writes to it -- which is how + * EthereumAddress.address_str reached the host as undecodable bytes. + * + * fsm_msgNanoGetAddress() already builds into a local and assigns after its + * confirm; this handler was the one that staged first. */ + uint8_t pubkeyhash[20] = {0}; - if (!hdnode_get_ethereum_pubkeyhash(node, resp->address.bytes)) { + if (!hdnode_get_ethereum_pubkeyhash(node, pubkeyhash)) { memzero(node, sizeof(*node)); return; } @@ -153,11 +165,7 @@ void fsm_msgEthereumGetAddress(EthereumGetAddress* msg) { } char address[43] = {'0', 'x'}; - ethereum_address_checksum(resp->address.bytes, address + 2, rskip60, - chain_id); - - resp->has_address_str = true; - strlcpy(resp->address_str, address, sizeof(resp->address_str)); + ethereum_address_checksum(pubkeyhash, address + 2, rskip60, chain_id); if (msg->has_show_display && msg->show_display) { char node_str[NODE_STRING_LENGTH]; @@ -181,6 +189,13 @@ void fsm_msgEthereumGetAddress(EthereumGetAddress* msg) { } memzero(node, sizeof(*node)); + + /* Only now, with no further message loop between here and the write. */ + resp->address.size = sizeof(pubkeyhash); + memcpy(resp->address.bytes, pubkeyhash, sizeof(pubkeyhash)); + resp->has_address_str = true; + strlcpy(resp->address_str, address, sizeof(resp->address_str)); + msg_write(MessageType_MessageType_EthereumAddress, resp); layoutHome(); } @@ -192,6 +207,18 @@ void fsm_msgEthereumSignMessage(EthereumSignMessage* msg) { CHECK_PIN + /* A zero-length message is not a message. confirm_bytes() renders size 0 as + the literal "(empty)" and returns whatever the owner pressed, so without + this the device would sign a payload no screen ever showed -- the same + hole already closed on the TON and Solana paths. (`message` is a required + field here, so nanopb rejects an omitted one during decode; only the empty + case reaches this far.) */ + if (msg->message.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Missing message")); + layoutHome(); + return; + } + /* Merge note (#432 vs this branch): release/7.14.2 gated Ethereum message * signing behind AdvancedMode, which blocks every Sign-In-With-Ethereum flow * on a default device until the user explicitly enables blind signing. @@ -275,12 +302,20 @@ void fsm_msgEthereumSignTypedHash(const EthereumSignTypedHash* msg) { return; } - const HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, - msg->address_n_count, NULL); + /* Not const: every exit past this point has to scrub the node. + * + * `node` is the shared fsm_derived_node scratch. A Cancel answered at any of + * the confirmations below is consumed by confirm_screen() and returned as a + * refusal -- it never reaches fsm_msgCancel(), so nothing else runs + * fsm_abort_workflows() on the way out. Each early return here was therefore + * leaving a derived private key resident, and so was the success path. */ + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); if (!node) return; uint8_t pubkeyhash[20] = {0}; if (!hdnode_get_ethereum_pubkeyhash(node, pubkeyhash)) { + memzero(node, sizeof(*node)); layoutHome(); return; } @@ -296,6 +331,7 @@ void fsm_msgEthereumSignTypedHash(const EthereumSignTypedHash* msg) { if (!confirm(ButtonRequestType_ButtonRequest_Other, "Verify Address", "Confirm address: %s", resp->address)) { + memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; @@ -306,6 +342,7 @@ void fsm_msgEthereumSignTypedHash(const EthereumSignTypedHash* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Typed Data domain", "Confirm hash digest: %s", str)) { + memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; @@ -317,6 +354,7 @@ void fsm_msgEthereumSignTypedHash(const EthereumSignTypedHash* msg) { } if (!confirm(ButtonRequestType_ButtonRequest_Other, "Typed Data message", "Confirm hash digest: %s", str)) { + memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; @@ -324,6 +362,7 @@ void fsm_msgEthereumSignTypedHash(const EthereumSignTypedHash* msg) { } else { if (!confirm(ButtonRequestType_ButtonRequest_Other, "Typed Data message", "Confirm: No message")) { + memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); layoutHome(); return; @@ -331,6 +370,7 @@ void fsm_msgEthereumSignTypedHash(const EthereumSignTypedHash* msg) { } ethereum_typed_hash_sign(msg, node, resp); + memzero(node, sizeof(*node)); layoutHome(); } @@ -355,12 +395,17 @@ void fsm_msgEthereum712TypesValues(Ethereum712TypesValues* msg) { return; } - const HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, - msg->address_n_count, NULL); + /* Not const, for the same reason as fsm_msgEthereumSignTypedHash() above: + * this is the shared fsm_derived_node scratch and every exit has to scrub + * it. e712_types_values() runs its own confirmations, and a Cancel answered + * there never reaches fsm_msgCancel(). */ + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); if (!node) return; uint8_t pubkeyhash[20] = {0}; if (!hdnode_get_ethereum_pubkeyhash(node, pubkeyhash)) { + memzero(node, sizeof(*node)); layoutHome(); return; } @@ -370,6 +415,7 @@ void fsm_msgEthereum712TypesValues(Ethereum712TypesValues* msg) { ethereum_address_checksum(pubkeyhash, resp->address + 2, false, 0); e712_types_values(msg, resp, node); + memzero(node, sizeof(*node)); layoutHome(); } diff --git a/lib/firmware/fsm_msg_mayachain.h b/lib/firmware/fsm_msg_mayachain.h index 3c214bc7b..68d996804 100644 --- a/lib/firmware/fsm_msg_mayachain.h +++ b/lib/firmware/fsm_msg_mayachain.h @@ -172,6 +172,22 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { layoutHome(); return; } + /* Validate the recipient BEFORE the screen, not in the serializer. + mayachain_signTxUpdateMsgSend() already refuses a + malformed or wrong-network address, but it runs after this + confirmation, so the owner approved a transfer that was then + rejected. This release line's rule is that an invalid signed value + fails before approval, so the same check moves ahead of the + screen. */ + if (!tendermint_validateBech32Address( + msg->send.to_address, + sign_tx->has_testnet && sign_tx->testnet ? "smaya" : "maya")) { + mayachain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid MAYAChain recipient address"); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->send.to_address)) { @@ -196,8 +212,15 @@ 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) || - !tendermint_validateBech32Address(msg->deposit.signer, signer_prefix)) { + !tendermint_validateBech32Address(msg->deposit.signer, signer_prefix) || + !mayachain_addressIsSigner(msg->deposit.signer)) { mayachain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, "Invalid MAYAChain deposit fields"); @@ -276,9 +299,18 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg) { return; } - if (sign_tx->has_memo && !msg->deposit.has_memo) { - // See if we can parse the tx memo. This memo ignored if deposit msg has - // memo + /* 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) { diff --git a/lib/firmware/fsm_msg_osmosis.h b/lib/firmware/fsm_msg_osmosis.h index c3d29c084..baaf4011d 100644 --- a/lib/firmware/fsm_msg_osmosis.h +++ b/lib/firmware/fsm_msg_osmosis.h @@ -164,6 +164,17 @@ void fsm_msgOsmosisSignTx(const OsmosisSignTx* msg) { layoutHome(); } +/* A `sender` is the authority the message acts as. It is copied into the signed + document verbatim and no LP, swap or IBC screen ever showed it, so the owner + approved a document naming an account no screen mentioned. There is exactly + one account this session can legitimately act as -- the one whose key signs + -- so bind it rather than adding a screen to every flow. A mismatch could + not produce a valid transaction anyway. */ +static bool osmosis_validate_sender(bool has_value, const char* value) { + return osmosis_validate_required_text(has_value, value) && + osmosis_address_is_signer(value); +} + void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { /** Confirm transaction basics */ CHECK_PARAM(osmosis_signingIsInited(), "Signing not in progress"); @@ -177,8 +188,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { /** Confirm required transaction parameters exist */ if (msg->has_send) { - if (!osmosis_validate_required_text(msg->send.has_to_address, - msg->send.to_address) || + if (!osmosis_validate_account_address(msg->send.has_to_address, + msg->send.to_address) || !osmosis_validate_amount(msg->send.has_amount, msg->send.amount) || !osmosis_validate_required_text(msg->send.has_denom, msg->send.denom)) { osmosis_signAbort(); @@ -214,10 +225,10 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } else if (msg->has_delegate) { /** Confirm required transaction parameters exist */ - if (!osmosis_validate_required_text(msg->delegate.has_delegator_address, - msg->delegate.delegator_address) || - !osmosis_validate_required_text(msg->delegate.has_validator_address, - msg->delegate.validator_address) || + if (!osmosis_validate_account_address(msg->delegate.has_delegator_address, + msg->delegate.delegator_address) || + !osmosis_validate_validator_address(msg->delegate.has_validator_address, + msg->delegate.validator_address) || !osmosis_validate_amount(msg->delegate.has_amount, msg->delegate.amount) || !osmosis_validate_required_text(msg->delegate.has_denom, @@ -270,10 +281,11 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } } else if (msg->has_undelegate) { /** Confirm required transaction parameters exist */ - if (!osmosis_validate_required_text(msg->undelegate.has_delegator_address, - msg->undelegate.delegator_address) || - !osmosis_validate_required_text(msg->undelegate.has_validator_address, - msg->undelegate.validator_address) || + if (!osmosis_validate_account_address(msg->undelegate.has_delegator_address, + msg->undelegate.delegator_address) || + !osmosis_validate_validator_address( + msg->undelegate.has_validator_address, + msg->undelegate.validator_address) || !osmosis_validate_amount(msg->undelegate.has_amount, msg->undelegate.amount) || !osmosis_validate_required_text(msg->undelegate.has_denom, @@ -326,8 +338,7 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } } else if (msg->has_lp_add) { /** Confirm required transaction parameters exist */ - if (!osmosis_validate_required_text(msg->lp_add.has_sender, - msg->lp_add.sender) || + if (!osmosis_validate_sender(msg->lp_add.has_sender, msg->lp_add.sender) || !msg->lp_add.has_pool_id || !osmosis_validate_amount(msg->lp_add.has_share_out_amount, msg->lp_add.share_out_amount) || @@ -416,8 +427,8 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } } else if (msg->has_lp_remove) { /** Confirm required transaction parameters exist */ - if (!osmosis_validate_required_text(msg->lp_remove.has_sender, - msg->lp_remove.sender) || + if (!osmosis_validate_sender(msg->lp_remove.has_sender, + msg->lp_remove.sender) || !msg->lp_remove.has_pool_id || !osmosis_validate_amount(msg->lp_remove.has_share_in_amount, msg->lp_remove.share_in_amount) || @@ -505,12 +516,12 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } } else if (msg->has_redelegate) { /** Confirm required transaction parameters exist */ - if (!osmosis_validate_required_text(msg->redelegate.has_delegator_address, - msg->redelegate.delegator_address) || - !osmosis_validate_required_text( + if (!osmosis_validate_account_address(msg->redelegate.has_delegator_address, + msg->redelegate.delegator_address) || + !osmosis_validate_validator_address( msg->redelegate.has_validator_src_address, msg->redelegate.validator_src_address) || - !osmosis_validate_required_text( + !osmosis_validate_validator_address( msg->redelegate.has_validator_dst_address, msg->redelegate.validator_dst_address) || !osmosis_validate_amount(msg->redelegate.has_amount, @@ -573,10 +584,10 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } } else if (msg->has_rewards) { /** Confirm required transaction parameters exist */ - if (!osmosis_validate_required_text(msg->rewards.has_delegator_address, - msg->rewards.delegator_address) || - !osmosis_validate_required_text(msg->rewards.has_validator_address, - msg->rewards.validator_address)) { + if (!osmosis_validate_account_address(msg->rewards.has_delegator_address, + msg->rewards.delegator_address) || + !osmosis_validate_validator_address(msg->rewards.has_validator_address, + msg->rewards.validator_address)) { osmosis_signAbort(); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Message is missing required parameters")); @@ -619,8 +630,7 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } } else if (msg->has_swap) { /** Confirm required transaction parameters exist */ - if (!osmosis_validate_required_text(msg->swap.has_sender, - msg->swap.sender) || + if (!osmosis_validate_sender(msg->swap.has_sender, msg->swap.sender) || !msg->swap.has_pool_id || !osmosis_validate_required_text(msg->swap.has_token_out_denom, msg->swap.token_out_denom) || @@ -676,14 +686,21 @@ void fsm_msgOsmosisMsgAck(const OsmosisMsgAck* msg) { } else if (msg->has_ibc_transfer) { /** Confirm required transaction parameters exist */ - if (!osmosis_validate_required_text(msg->ibc_transfer.has_sender, - msg->ibc_transfer.sender) || + /* The receiver has to be well-formed bech32 BEFORE any screen opens. + The serializer refuses a malformed one, but it runs after every IBC + approval has already been taken, so the owner approved a transfer + that was then rejected. Its HRP belongs to the counterparty chain, + so only well-formedness can be checked here -- that is exactly what + the serializer checks, moved ahead of the confirmations. */ + if (!osmosis_validate_sender(msg->ibc_transfer.has_sender, + msg->ibc_transfer.sender) || !osmosis_validate_required_text(msg->ibc_transfer.has_receiver, msg->ibc_transfer.receiver) || !osmosis_validate_required_text(msg->ibc_transfer.has_source_channel, msg->ibc_transfer.source_channel) || !osmosis_validate_required_text(msg->ibc_transfer.has_source_port, msg->ibc_transfer.source_port) || + !tendermint_bech32IsWellFormed(msg->ibc_transfer.receiver) || !osmosis_validate_amount(msg->ibc_transfer.has_revision_height, msg->ibc_transfer.revision_height) || !osmosis_validate_amount(msg->ibc_transfer.has_revision_number, diff --git a/lib/firmware/fsm_msg_ripple.h b/lib/firmware/fsm_msg_ripple.h index ddd25af35..3c8b59055 100644 --- a/lib/firmware/fsm_msg_ripple.h +++ b/lib/firmware/fsm_msg_ripple.h @@ -81,19 +81,58 @@ void fsm_msgRippleSignTx(RippleSignTx* msg) { if (!node) return; hdnode_fill_public_key(node); + /* Absent fields are not zero-valued fields. Without these, an omitted + payment/amount/destination reached the screens as 0 XRP to an empty + address, and ripple_serialize() simply omitted what was missing -- so the + owner approved one transaction and the device signed another. The + destination is checked here too: ripple_serializeAddress() enforces the + 21-byte decode with assert(), which is compiled out of release builds, and + runs only after both confirmations. */ + if (!msg->has_payment || !msg->payment.has_amount || + !msg->payment.has_destination || + !ripple_validateAddress(msg->payment.destination)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Payment amount and destination are required")); + layoutHome(); + return; + } + if (!msg->has_fee || msg->fee < RIPPLE_MIN_FEE || msg->fee > RIPPLE_MAX_FEE) { memzero(node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_SyntaxError, _("Fee must be between 10 and 1,000,000 drops")); + layoutHome(); return; } - char amount_string[20 + 4 + 1]; - ripple_formatAmount(amount_string, sizeof(amount_string), - msg->payment.amount); + /* Above RIPPLE_MAX_DROPS the serializer's own bound is exceeded; it guarded + that with assert(), which is compiled out of release builds, so the amount + would be encoded differently from the one supplied. Refuse here instead, + before anything is shown. */ + if (msg->payment.amount > RIPPLE_MAX_DROPS) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Amount exceeds the largest XRP value this device can " + "sign")); + layoutHome(); + return; + } + /* Both renders must succeed BEFORE any confirmation. These used to be void + calls, so an unrenderable amount put "AMOUNT TOO LARGE TO DISPLAY" on the + screen and the numeric amount into the signature. */ + char amount_string[20 + 4 + 1]; char fee_string[20 + 4 + 1]; - ripple_formatAmount(fee_string, sizeof(fee_string), msg->fee); + if (!ripple_formatAmount(amount_string, sizeof(amount_string), + msg->payment.amount) || + !ripple_formatAmount(fee_string, sizeof(fee_string), msg->fee)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Cannot display this XRP amount")); + layoutHome(); + return; + } if (needs_confirm) { if (!confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, "Send", @@ -118,7 +157,15 @@ void fsm_msgRippleSignTx(RippleSignTx* msg) { return; } - ripple_signTx(node, msg, resp); + /* A failed sign left has_signature/has_serialized_tx false, and the response + went out anyway -- the host saw an empty success where an error belonged. + */ + if (!ripple_signTx(node, msg, resp)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, _("Ripple signing failed")); + layoutHome(); + return; + } memzero(node, sizeof(*node)); msg_write(MessageType_MessageType_RippleSignedTx, resp); layoutHome(); diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index 415ad65e9..83b80a034 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -388,6 +388,59 @@ static bool solana_confirmInstruction(const SolanaParsedInstruction* pi, } /* Validate Solana derivation path: m/44'/501'/account'[/change'] */ +/* Off-chain message format 0: restricted ASCII -- printable, space included. */ +static bool solana_offchain_payload_is_ascii(const uint8_t* data, size_t size) { + for (size_t i = 0; i < size; i++) { + if (data[i] < 0x20 || data[i] > 0x7e) return false; + } + return true; +} + +/* Off-chain message format 1: well-formed UTF-8. Rejects overlong encodings, + surrogate halves, and anything above U+10FFFF, so the bytes the device + signs really are the text the screen claims they are. */ +static bool solana_offchain_payload_is_utf8(const uint8_t* data, size_t size) { + size_t i = 0; + while (i < size) { + const uint8_t c = data[i]; + size_t extra; + uint32_t cp; + + if (c < 0x80) { + i++; + continue; + } else if ((c & 0xe0) == 0xc0) { + extra = 1; + cp = c & 0x1fu; + } else if ((c & 0xf0) == 0xe0) { + extra = 2; + cp = c & 0x0fu; + } else if ((c & 0xf8) == 0xf0) { + extra = 3; + cp = c & 0x07u; + } else { + return false; /* continuation byte or 5+ byte lead */ + } + + if (i + extra >= size) return false; + for (size_t k = 1; k <= extra; k++) { + const uint8_t cc = data[i + k]; + if ((cc & 0xc0) != 0x80) return false; + cp = (cp << 6) | (cc & 0x3fu); + } + + /* Shortest form only, no surrogates, within Unicode range. */ + if (extra == 1 && cp < 0x80u) return false; + if (extra == 2 && cp < 0x800u) return false; + if (extra == 3 && cp < 0x10000u) return false; + if (cp > 0x10ffffu) return false; + if (cp >= 0xd800u && cp <= 0xdfffu) return false; + + i += extra + 1; + } + return true; +} + static bool solana_pathIsStandard(const uint32_t* path, size_t count) { if (count < 3 || count > 4) return false; if (path[0] != (0x80000000 | 44)) return false; /* 44' */ @@ -645,8 +698,7 @@ void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg) { /* Ed25519 sign */ uint8_t sig[SOL_SIG_SIZE]; - ed25519_sign(msg->message.bytes, msg->message.size, node->private_key, - node->public_key + 1, sig); + ed25519_sign(msg->message.bytes, msg->message.size, node->private_key, sig); resp->has_signature = true; resp->signature.size = SOL_SIG_SIZE; @@ -702,6 +754,26 @@ void fsm_msgSolanaSignOffchainMessage(const SolanaSignOffchainMessage* msg) { return; } + /* The format tag is part of the signed envelope and is named on the + confirmation screen ("Format: ASCII"), but nothing checked that the + payload actually is that format. A host could declare restricted ASCII + and sign arbitrary binary, or declare UTF-8 and sign malformed UTF-8, and + the device would vouch for the label either way. Check the bytes against + the tag they travel under, before anything is confirmed or signed. */ + const bool payload_matches_format = + (format == 0) ? solana_offchain_payload_is_ascii(msg->message.bytes, + msg->message.size) + : solana_offchain_payload_is_utf8(msg->message.bytes, + msg->message.size); + if (!payload_matches_format) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + format == 0 + ? _("Message is not restricted ASCII (format 0)") + : _("Message is not valid UTF-8 (format 1)")); + layoutHome(); + return; + } + /* Path validation: warn on non-standard derivation, mirroring the * existing SolanaSignMessage handler. */ if (!solana_pathIsStandard(msg->address_n, msg->address_n_count)) { diff --git a/lib/firmware/fsm_msg_tendermint.h b/lib/firmware/fsm_msg_tendermint.h index 91a658cce..499ce90d2 100644 --- a/lib/firmware/fsm_msg_tendermint.h +++ b/lib/firmware/fsm_msg_tendermint.h @@ -10,6 +10,21 @@ void fsm_msgTendermintGetAddress(const TendermintGetAddress* msg) { if (!coin) { return; } + /* The HRP comes from the coin, not from chain_name -- coinByName() matches + case-insensitively, so a request naming "Cosmos" would otherwise derive + "Cosmos1..." addresses that no Cosmos node would accept, and that the + signing path would then refuse as wrong-network. */ + /* Gate on the STRING, not coin->has_bech32_prefix. In coins.def the + tendermint family carries a populated prefix behind a false flag -- + Cosmos is `false, "cosmos"`, Osmosis `false, "osmo"`, THORChain + `false, "thor"` -- while Binance and Bitcoin set the flag. Requiring the + flag would refuse every Cosmos transaction. */ + if (coin->bech32_prefix[0] == '\0') { + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Coin has no bech32 prefix"); + layoutHome(); + return; + } HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, msg->address_n_count, NULL); if (!node) { @@ -18,7 +33,7 @@ void fsm_msgTendermintGetAddress(const TendermintGetAddress* msg) { hdnode_fill_public_key(node); - if (!tendermint_getAddress(node, msg->chain_name, resp->address)) { + if (!tendermint_getAddress(node, coin->bech32_prefix, resp->address)) { memzero((void*)node, sizeof(*node)); fsm_sendFailure(FailureType_Failure_FirmwareError, _("Can't encode address")); @@ -115,8 +130,22 @@ void fsm_msgTendermintSignTx(const TendermintSignTx* msg) { void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { // Confirm transaction basics - CHECK_PARAM(tendermint_signingIsInited(TENDERMINT_SIGNING_GENERIC), - "Tendermint signing not in progress"); + /* A continuation for the WRONG protocol is terminal for the session it + found, not just for itself. + + Cosmos and generic Tendermint share one signer in signtx_tendermint.c, + told apart only by signing_type. CHECK_PARAM sends a failure and returns, + leaving that shared state initialized: a Cosmos session survived a + Tendermint ACK (and vice versa), the UI went home, and the session stayed + resumable by a later stale ACK of its own protocol. Clear the shared + signer first, the way every malformed-ACK path below already does. */ + if (!tendermint_signingIsInited(TENDERMINT_SIGNING_GENERIC)) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_Other, + "Tendermint signing not in progress"); + layoutHome(); + return; + } const TendermintSignTx* sign_tx = (const TendermintSignTx*)tendermint_getSignTx(); if (!msg->has_chain_name || !msg->has_denom || @@ -148,6 +177,27 @@ void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { return; } + /* Addresses are built from the coin's bech32 prefix, never from chain_name. + * + * coinByName() matches with strncasecmp(), so "Cosmos" and "cosmos" both + * resolve to the same coin -- but only one of them is the HRP. Using + * chain_name for address work makes correctness depend on the case the host + * happened to send: a request naming "Cosmos" would reject every valid + * cosmos1... recipient here and derive a "Cosmos1..." sender in the + * serializer. coin->bech32_prefix is the single authority for both. */ + /* Gate on the STRING, not coin->has_bech32_prefix. In coins.def the + tendermint family carries a populated prefix behind a false flag -- + Cosmos is `false, "cosmos"`, Osmosis `false, "osmo"`, THORChain + `false, "thor"` -- while Binance and Bitcoin set the flag. Requiring the + flag would refuse every Cosmos transaction. */ + if (coin->bech32_prefix[0] == '\0') { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Coin has no bech32 prefix"); + layoutHome(); + return; + } + switch (msg->send.address_type) { case OutputAddressType_TRANSFER: default: { @@ -162,6 +212,21 @@ void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { layoutHome(); return; } + /* Validate the recipient BEFORE the screen, not in the serializer. + tendermint_signTxUpdateMsgSend() already refuses a + malformed or wrong-network address, but it runs after this + confirmation, so the owner approved a transfer that was then + rejected. This release line's rule is that an invalid signed value + fails before approval, so the same check moves ahead of the + screen. */ + if (!tendermint_validateBech32Address(msg->send.to_address, + coin->bech32_prefix)) { + tendermint_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid Tendermint recipient address"); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->send.to_address)) { @@ -176,7 +241,7 @@ void fsm_msgTendermintMsgAck(const TendermintMsgAck* msg) { } if (!tendermint_signTxUpdateMsgSend(msg->send.amount, msg->send.to_address, - msg->chain_name, msg->denom, + coin->bech32_prefix, msg->denom, msg->message_type_prefix)) { tendermint_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, diff --git a/lib/firmware/fsm_msg_thorchain.h b/lib/firmware/fsm_msg_thorchain.h index c5564897f..ee4667a25 100644 --- a/lib/firmware/fsm_msg_thorchain.h +++ b/lib/firmware/fsm_msg_thorchain.h @@ -155,6 +155,22 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { layoutHome(); return; } + /* Validate the recipient BEFORE the screen, not in the serializer. + thorchain_signTxUpdateMsgSend() already refuses a + malformed or wrong-network address, but it runs after this + confirmation, so the owner approved a transfer that was then + rejected. This release line's rule is that an invalid signed value + fails before approval, so the same check moves ahead of the + screen. */ + if (!tendermint_validateBech32Address( + msg->send.to_address, + sign_tx->has_testnet && sign_tx->testnet ? "tthor" : "thor")) { + thorchain_signAbort(); + fsm_sendFailure(FailureType_Failure_SyntaxError, + "Invalid THORChain recipient address"); + layoutHome(); + return; + } if (!confirm_transaction_output( ButtonRequestType_ButtonRequest_ConfirmOutput, amount_str, msg->send.to_address)) { @@ -179,8 +195,15 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { } else if (msg->has_deposit) { const char* const signer_prefix = sign_tx->has_testnet && sign_tx->testnet ? "tthor" : "thor"; + /* 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) || - !tendermint_validateBech32Address(msg->deposit.signer, signer_prefix)) { + !tendermint_validateBech32Address(msg->deposit.signer, signer_prefix) || + !thorchain_addressIsSigner(msg->deposit.signer)) { thorchain_signAbort(); fsm_sendFailure(FailureType_Failure_SyntaxError, "Invalid THORChain deposit fields"); @@ -262,9 +285,18 @@ void fsm_msgThorchainMsgAck(const ThorchainMsgAck* msg) { return; } - if (sign_tx->has_memo && !msg->deposit.has_memo) { - // See if we can parse the tx memo. This memo ignored if deposit msg has - // memo + /* 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. /* strnlen, not sizeof -- see the deposit path above. */ ThorchainMemoResult memo_result = thorchain_parseConfirmMemo( sign_tx->memo, strnlen(sign_tx->memo, sizeof(sign_tx->memo))); diff --git a/lib/firmware/fsm_msg_tron.h b/lib/firmware/fsm_msg_tron.h index 6969de3ac..142ebf6e2 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -158,6 +158,16 @@ void fsm_msgTronSignMessage(TronSignMessage* msg) { CHECK_PIN + /* An omitted or zero-length message is not a message. confirm_bytes() + renders size 0 as the literal "(empty)" and returns whatever the owner + pressed, so without this the device would sign a payload no screen ever + showed -- the same hole already closed on the TON and Solana paths. */ + if (!msg->has_message || msg->message.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Missing message")); + layoutHome(); + return; + } + /* Merge note (#432 vs this branch): #432 gated TRON message signing behind * AdvancedMode because the message was a blind sign. It is not any more — * confirm_bytes() below paginates and displays EVERY signed byte, which is diff --git a/lib/firmware/mayachain.c b/lib/firmware/mayachain.c index dc1648828..88c1ff3b5 100644 --- a/lib/firmware/mayachain.c +++ b/lib/firmware/mayachain.c @@ -128,14 +128,24 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, const char mainnetp[] = "maya"; const char testnetp[] = "smaya"; const char* pfix; - char buffer[64 + 1]; - - size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; - if (!bech32_decode(hrp, decoded, &decoded_len, to_address)) { - return false; - } + /* Sized for the amount/denom segment below, which is the longest thing this + function formats: + + "amount":[{"amount":" 21 + 20 + ","denom":" 11 + 68 (MayachainMsgSend.denom max_size 69) + "}] 3 = 123, + NUL = 124 + + It was 65. tendermint_snprintf() fails closed when its output does not + fit, so nothing was ever mis-signed -- but the failure landed AFTER + fsm_msgMayachainMsgAck() had already shown the amount and taken the + owner's approval, so a long yet perfectly valid denomination was approved + and only then refused. This branch's rule is that anything unrenderable + fails BEFORE the confirmation, so make the segment fit its own documented + maximum. Unlike THORChain, which hardcodes "rune", this denom is + host-supplied, which is why only MAYAChain hits it. */ + char buffer[128]; char from_address[46]; @@ -144,6 +154,18 @@ bool mayachain_signTxUpdateMsgSend(const uint64_t amount, pfix = testnetp; } + /* Validate the recipient against THIS network's prefix and the 20-byte + account length, before it reaches the bare "%s" JSON serialization below. + This used to be a bare bech32_decode() into hrp[45]/decoded[38], which + both overflowed on host-chosen input and checked neither the network nor + the payload length -- so a wrong-chain address, a module or operator + address, or a punctuation-bearing HRP all passed straight into the signed + document. Select the prefix first so there is something to check against. + */ + if (!tendermint_validateBech32Address(to_address, pfix)) { + return false; + } + if (!tendermint_getAddress(&node, pfix, from_address)) { return false; } @@ -241,6 +263,22 @@ bool mayachain_signTxFinalize(uint8_t* public_key, uint8_t* signature) { NULL) == 0; } +/* The account this session's key signs as. + * + * MsgDeposit's `signer` is serialized verbatim as the message authority, so a + * merely well-formed thor/maya address let the device sign a document for an + * account it cannot represent -- and the confirmation labels that address as + * though it were a destination. There is exactly one authority a session can + * act as; require the host to name it. */ +bool mayachain_addressIsSigner(const char* address) { + if (!initialized || !address) return false; + + char expected[46] = {0}; + if (!tendermint_getAddress(&node, testnet ? "smaya" : "maya", expected)) + return false; + return strcmp(address, expected) == 0; +} + bool mayachain_signingIsInited(void) { return initialized; } bool mayachain_signingIsFinished(void) { @@ -275,6 +313,49 @@ static bool mayachain_memo_has_empty_component(const char* memo, size_t size) { return false; } +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. */ + if (!memo || size == 0) return false; + + size_t field = 0; + size_t dots_total = 0; + size_t dots_in_asset_field = 0; + + 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++; + } + } + + return dots_total == 1 && dots_in_asset_field == 1; +} + static bool mayachain_memo_is_structured_text(const char* memo, size_t size) { if (!memo || size == 0) return false; @@ -333,7 +414,8 @@ MayachainMemoResult mayachain_parseConfirmMemo(const char* swapStr, the memzero below. */ if (size >= sizeof(memoBuf) || mayachain_memo_has_empty_component(swapStr, size) || - !mayachain_memo_is_structured_text(swapStr, size)) { + !mayachain_memo_is_structured_text(swapStr, size) || + !mayachain_memo_has_canonical_separators(swapStr, size)) { return MAYACHAIN_MEMO_UNPARSED; } memzero(memoBuf, sizeof(memoBuf)); diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index ee82cc2bd..d8b8f9134 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -33,6 +33,7 @@ MSG_IN(MessageType_MessageType_RecoveryDevice, RecoveryDevice, fsm_msgRecoveryDevice) MSG_IN(MessageType_MessageType_CharacterAck, CharacterAck, fsm_msgCharacterAck) MSG_IN(MessageType_MessageType_ApplyPolicies, ApplyPolicies, fsm_msgApplyPolicies) +#if !BITCOIN_ONLY MSG_IN(MessageType_MessageType_EthereumGetAddress, EthereumGetAddress, fsm_msgEthereumGetAddress) MSG_IN(MessageType_MessageType_EthereumSignTx, EthereumSignTx, fsm_msgEthereumSignTx) MSG_IN(MessageType_MessageType_EthereumTxAck, EthereumTxAck, fsm_msgEthereumTxAck) @@ -72,6 +73,7 @@ MSG_IN(MessageType_MessageType_MayachainGetAddress, MayachainGetAddress, fsm_msgMayachainGetAddress) MSG_IN(MessageType_MessageType_MayachainSignTx, MayachainSignTx, fsm_msgMayachainSignTx) MSG_IN(MessageType_MessageType_MayachainMsgAck, MayachainMsgAck, fsm_msgMayachainMsgAck) +#endif // !BITCOIN_ONLY /* Normal Out Messages */ MSG_OUT(MessageType_MessageType_Success, Success, NO_PROCESS_FUNC) @@ -95,6 +97,7 @@ MSG_OUT(MessageType_MessageType_PassphraseRequest, PassphraseRequest, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_WordRequest, WordRequest, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_CharacterRequest, CharacterRequest, NO_PROCESS_FUNC) +#if !BITCOIN_ONLY MSG_OUT(MessageType_MessageType_EthereumAddress, EthereumAddress, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_EthereumTxRequest, EthereumTxRequest, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_EthereumMessageSignature, EthereumMessageSignature, NO_PROCESS_FUNC) @@ -163,6 +166,7 @@ MSG_OUT(MessageType_MessageType_SolanaSignedTx, SolanaSignedTx, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_SolanaMessageSignature, SolanaMessageSignature, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_SolanaOffchainMessageSignature, SolanaOffchainMessageSignature, NO_PROCESS_FUNC) +#endif // !BITCOIN_ONLY #if DEBUG_LINK /* Debug Messages */ diff --git a/lib/firmware/osmosis.c b/lib/firmware/osmosis.c index 71f8a4ff3..135ec22cd 100644 --- a/lib/firmware/osmosis.c +++ b/lib/firmware/osmosis.c @@ -50,9 +50,57 @@ bool osmosis_validate_amount(bool has_value, const char* value) { for (const char* p = value; *p; ++p) { if (*p < '0' || *p > '9') return false; } + + /* Require the canonical decimal spelling: no leading zeros, except for the + single value "0" itself. + + base_to_precision() places the decimal point a fixed `precision` digits + from the right, so a padded amount keeps its value -- "0000001" and "1" + both render 0.000001 OSMO. What it does not keep is its spelling: the + padding survives into the screen, so "00000001" shows as "00.000001 OSMO" + and "0001000000" as "0001.000000 OSMO". The signed JSON carries the + padded string verbatim, so a host can pick which of many renderings of + one amount the owner is shown, and two devices handed the same transfer + can display it differently. An amount screen must have exactly one + spelling. Nothing legitimate needs the padding: the Cosmos SDK emits + canonical integers. */ + if (value[0] == '0' && value[1] != '\0') return false; + return true; } +/* The network prefix this session signs under. */ +static const char* osmosis_sessionPrefix(void) { + return testnet ? "tosmo" : "osmo"; +} + +bool osmosis_validate_account_address(bool has_value, const char* value) { + return osmosis_validate_required_text(has_value, value) && + tendermint_validateBech32Address(value, osmosis_sessionPrefix()); +} + +bool osmosis_validate_validator_address(bool has_value, const char* value) { + return osmosis_validate_required_text(has_value, value) && + tendermint_validateValidatorAddress(value, osmosis_sessionPrefix()); +} + +/* A `sender` field is the AUTHORITY the message acts as, and it is copied into + the signed document verbatim. The LP, swap and IBC paths never showed it, so + the owner approved a document naming an account no screen mentioned. + Displaying it would add a screen to every one of those flows; binding it is + both stronger and free, because there is only one account this session can + legitimately act as -- the one whose key signs. A mismatch could never + produce a valid transaction anyway, so refusing it costs nothing. */ +bool osmosis_address_is_signer(const char* address) { + if (!initialized || !address) return false; + + char expected[46] = {0}; + if (!tendermint_getAddress(&node, osmosis_sessionPrefix(), expected)) { + return false; + } + return strcmp(address, expected) == 0; +} + bool osmosis_signTxInit(const HDNode* _node, const OsmosisSignTx* _msg) { osmosis_signAbort(); if (!_node || !_msg || !_msg->has_msg_count || _msg->msg_count == 0 || @@ -128,10 +176,15 @@ bool osmosis_signTxUpdateMsgSend(const char* amount, const char* to_address, const char* pfix; char buffer[64 + 1]; - size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; - if (!bech32_decode(hrp, decoded, &decoded_len, to_address)) { + /* Validate against THIS network's prefix and the 20-byte account length + before the address reaches the bare "%s" JSON serialization below. This + was a bare bech32_decode() into hrp[45]/decoded[38]: both undersized for + host-chosen input (see tendermint_bech32DecodeChecked()), and neither the + 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, + testnet ? testnetp : mainnetp)) { return false; } @@ -196,11 +249,21 @@ bool osmosis_signTxUpdateMsgDelegate(const char* amount, const char* pfix; char buffer[128] = {0}; - size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; - - if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { + /* Validate against THIS network's prefix and the 20-byte account length + before the address reaches the bare "%s" JSON serialization below. This + was a bare bech32_decode() into hrp[45]/decoded[38]: both undersized for + host-chosen input (see tendermint_bech32DecodeChecked()), and neither the + 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(delegator_address, + testnet ? testnetp : mainnetp)) { + return false; + } + /* The validator operator is interpolated into the signed document with the + same bare "%s" as the delegator above, so it needs the same gate. */ + if (!tendermint_validateValidatorAddress(validator_address, + testnet ? testnetp : mainnetp)) { return false; } @@ -266,11 +329,21 @@ bool osmosis_signTxUpdateMsgUndelegate(const char* amount, const char* pfix; char buffer[128] = {0}; - size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; - - if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { + /* Validate against THIS network's prefix and the 20-byte account length + before the address reaches the bare "%s" JSON serialization below. This + was a bare bech32_decode() into hrp[45]/decoded[38]: both undersized for + host-chosen input (see tendermint_bech32DecodeChecked()), and neither the + 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(delegator_address, + testnet ? testnetp : mainnetp)) { + return false; + } + /* The validator operator is interpolated into the signed document with the + same bare "%s" as the delegator above, so it needs the same gate. */ + if (!tendermint_validateValidatorAddress(validator_address, + testnet ? testnetp : mainnetp)) { return false; } @@ -336,11 +409,23 @@ bool osmosis_signTxUpdateMsgRedelegate(const char* amount, const char* pfix; char buffer[128] = {0}; - size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; - - if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { + /* Validate against THIS network's prefix and the 20-byte account length + before the address reaches the bare "%s" JSON serialization below. This + was a bare bech32_decode() into hrp[45]/decoded[38]: both undersized for + host-chosen input (see tendermint_bech32DecodeChecked()), and neither the + 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(delegator_address, + testnet ? testnetp : mainnetp)) { + return false; + } + /* Both validator operators are interpolated with the same bare "%s" as the + delegator above; neither was checked. */ + if (!tendermint_validateValidatorAddress(validator_src_address, + testnet ? testnetp : mainnetp) || + !tendermint_validateValidatorAddress(validator_dst_address, + testnet ? testnetp : mainnetp)) { return false; } @@ -522,11 +607,21 @@ bool osmosis_signTxUpdateMsgRewards(const char* delegator_address, const char* pfix; char buffer[128] = {0}; - size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; - - if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { + /* Validate against THIS network's prefix and the 20-byte account length + before the address reaches the bare "%s" JSON serialization below. This + was a bare bech32_decode() into hrp[45]/decoded[38]: both undersized for + host-chosen input (see tendermint_bech32DecodeChecked()), and neither the + 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(delegator_address, + testnet ? testnetp : mainnetp)) { + return false; + } + /* The validator operator is interpolated into the signed document with the + same bare "%s" as the delegator above, so it needs the same gate. */ + if (!tendermint_validateValidatorAddress(validator_address, + testnet ? testnetp : mainnetp)) { return false; } @@ -589,11 +684,13 @@ bool osmosis_signTxUpdateMsgIBCTransfer(const char* amount, const char* sender, const char* pfix; char buffer[128] = {0}; - size_t decoded_len; - char hrp[45] = {0}; - uint8_t decoded[38] = {0}; - - if (!bech32_decode(hrp, decoded, &decoded_len, receiver)) { + /* An IBC receiver lives on the COUNTERPARTY chain, so its human-readable + part is deliberately not one of ours and cannot be pinned to a prefix. + What can be fixed is the decode itself: the previous bare bech32_decode() + wrote into hrp[45]/decoded[38], both of which a host can overrun (see + tendermint_bech32DecodeChecked()). Check well-formedness with bounded + buffers instead. */ + if (!tendermint_bech32IsWellFormed(receiver)) { return false; } diff --git a/lib/firmware/recovery_cipher.c b/lib/firmware/recovery_cipher.c index 931d92906..291b4dd71 100644 --- a/lib/firmware/recovery_cipher.c +++ b/lib/firmware/recovery_cipher.c @@ -53,27 +53,14 @@ static CONFIDENTIAL char mnemonic[MNEMONIC_BUF]; static char english_alphabet[ENGLISH_ALPHABET_BUF] = "abcdefghijklmnopqrstuvwxyz"; static CONFIDENTIAL char cipher[ENGLISH_ALPHABET_BUF]; - -/* Recovery scratch, at file scope so recovery_cipher_reset() can reach it. - * - * These were function statics. That gives them the same lifetime -- they - * outlive the call either way -- but put them out of reach of the abort path, - * so setup_abort() could not honour its own contract ("memzero ... the - * recovery buffers", reset.h). rc_coded_word and rc_decoded_word were cleared - * only under `if (!mnemonic[0])`, i.e. at the START of the next recovery, so - * cancelling mid-word left the characters entered so far resident until - * another recovery began or the device rebooted. - * - * Hoisting changes no semantics: same storage duration, same zero - * initialisation, same values across calls. It only makes them clearable. The - * rc_ prefix keeps them from shadowing the identically-named parameters of - * get_current_word() and format_current_word(). */ -static CONFIDENTIAL char rc_coded_word[12]; -static CONFIDENTIAL char rc_decoded_word[12]; -static CONFIDENTIAL char rc_current_word[CURRENT_WORD_BUF]; -static CONFIDENTIAL char rc_formatted_word[CURRENT_WORD_BUF + 10]; -static CONFIDENTIAL char rc_new_mnemonic[MNEMONIC_BUF]; -static CONFIDENTIAL char rc_temp_word[CURRENT_WORD_BUF]; +static int uncyphered_word_count = 0; +static bool definitely_using_cipher = false; +static CONFIDENTIAL char coded_word[12]; +static CONFIDENTIAL char decoded_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]; +static CONFIDENTIAL char temp_word_scratch[CURRENT_WORD_BUF]; #if DEBUG_LINK static char auto_completed_word[CURRENT_WORD_BUF]; @@ -90,13 +77,17 @@ void recovery_cipher_reset(void) { word_count = 0; memzero(mnemonic, sizeof(mnemonic)); memzero(cipher, sizeof(cipher)); - /* Every buffer that can hold seed material or a partially entered word. */ - memzero(rc_coded_word, sizeof(rc_coded_word)); - memzero(rc_decoded_word, sizeof(rc_decoded_word)); - memzero(rc_current_word, sizeof(rc_current_word)); - memzero(rc_formatted_word, sizeof(rc_formatted_word)); - memzero(rc_new_mnemonic, sizeof(rc_new_mnemonic)); - memzero(rc_temp_word, sizeof(rc_temp_word)); + uncyphered_word_count = 0; + definitely_using_cipher = false; + memzero(coded_word, sizeof(coded_word)); + memzero(decoded_word, sizeof(decoded_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)); + memzero(temp_word_scratch, sizeof(temp_word_scratch)); +#if DEBUG_LINK + memzero(auto_completed_word, sizeof(auto_completed_word)); +#endif } /* The `if (!dry_run) storage_reset();` that used to open this function is @@ -109,7 +100,7 @@ void recovery_cipher_abort(void) { setup_abort(); } /// Formats the passed word to show position in mnemonic as well as characters /// left. /// -/// \param current_word[in] The string to format. +/// \param current_word[in] The string to format. /// \param auto_completed[in] Whether to format as an auto completed word. static void format_current_word(uint32_t word_pos, const char* current_word, bool auto_completed, @@ -155,7 +146,7 @@ static uint32_t get_current_word_pos(void) { } /// \returns the current word being entered by parsing the mnemonic thus far -/// \param current_word[out] Array to populate with current word. +/// \param current_word[out] Array to populate with current word. static void get_current_word(char* current_word) { char* pos = strrchr(mnemonic, ' '); @@ -357,11 +348,11 @@ void next_character(void) { strlcpy(cipher, english_alphabet, ENGLISH_ALPHABET_BUF); random_permute_char(cipher, strlen(cipher)); - get_current_word(rc_current_word); + get_current_word(current_word_scratch); /* Words should never be longer than 4 characters */ - if (strlen(rc_current_word) > 4) { - memzero(rc_current_word, sizeof(rc_current_word)); + if (strlen(current_word_scratch) > 4) { + memzero(current_word_scratch, sizeof(current_word_scratch)); recovery_cipher_abort(); fsm_sendFailure(FailureType_Failure_SyntaxError, @@ -383,32 +374,32 @@ void next_character(void) { memset(&resp, 0, sizeof(CharacterRequest)); resp.word_pos = word_pos; - resp.character_pos = strlen(rc_current_word); + resp.character_pos = strlen(current_word_scratch); msg_write(MessageType_MessageType_CharacterRequest, &resp); /* Attempt to auto complete if we have at least 3 characters */ bool auto_completed = false; - if (strlen(rc_current_word) >= 3) { - auto_completed = attempt_auto_complete(rc_current_word); + if (strlen(current_word_scratch) >= 3) { + auto_completed = attempt_auto_complete(current_word_scratch); } #if DEBUG_LINK if (auto_completed) { - strlcpy(auto_completed_word, rc_current_word, CURRENT_WORD_BUF); + strlcpy(auto_completed_word, current_word_scratch, CURRENT_WORD_BUF); } else { auto_completed_word[0] = '\0'; } #endif /* Format current word and display it along with cipher */ - format_current_word(word_pos, rc_current_word, auto_completed, - &rc_formatted_word); - memzero(rc_current_word, sizeof(rc_current_word)); + format_current_word(word_pos, current_word_scratch, auto_completed, + &formatted_word_scratch); + memzero(current_word_scratch, sizeof(current_word_scratch)); /* Show cipher and partial word */ - layout_cipher(rc_formatted_word, cipher); - memzero(rc_formatted_word, sizeof(rc_formatted_word)); + layout_cipher(formatted_word_scratch, cipher); + memzero(formatted_word_scratch, sizeof(formatted_word_scratch)); } /* @@ -448,14 +439,11 @@ void recovery_character(const char* character) { } // Count of words we think the user has entered without using the cipher: - static int uncyphered_word_count = 0; - static bool definitely_using_cipher = false; - if (!mnemonic[0]) { uncyphered_word_count = 0; definitely_using_cipher = false; - memzero(rc_coded_word, sizeof(rc_coded_word)); - memzero(rc_decoded_word, sizeof(rc_decoded_word)); + memzero(coded_word, sizeof(coded_word)); + memzero(decoded_word, sizeof(decoded_word)); } char decoded_character[2] = " "; @@ -463,16 +451,16 @@ void recovery_character(const char* character) { // Decode character using cipher if not space decoded_character[0] = english_alphabet[(int)(pos - cipher)]; - strlcat(rc_coded_word, character, sizeof(rc_coded_word)); - strlcat(rc_decoded_word, decoded_character, sizeof(rc_decoded_word)); + strlcat(coded_word, character, sizeof(coded_word)); + strlcat(decoded_word, decoded_character, sizeof(decoded_word)); - if (enforce_wordlist && 4 <= strlen(rc_coded_word)) { + if (enforce_wordlist && 4 <= strlen(coded_word)) { // Check & bail if the user is entering their seed without using the // cipher. Note that for each word, this can give false positives about // ~0.4% of the time (2048/26^4). - bool maybe_not_using_cipher = attempt_auto_complete(rc_coded_word); - bool maybe_using_cipher = attempt_auto_complete(rc_decoded_word); + bool maybe_not_using_cipher = attempt_auto_complete(coded_word); + bool maybe_using_cipher = attempt_auto_complete(decoded_word); if (!maybe_not_using_cipher && maybe_using_cipher) { // Decrease the overall false positive rate by detecting that a @@ -490,8 +478,8 @@ void recovery_character(const char* character) { } } } else { - memzero(rc_coded_word, sizeof(rc_coded_word)); - memzero(rc_decoded_word, sizeof(rc_decoded_word)); + memzero(coded_word, sizeof(coded_word)); + memzero(decoded_word, sizeof(decoded_word)); if (word_count && words_entered == word_count) { strlcat(mnemonic, " ", MNEMONIC_BUF); @@ -584,23 +572,23 @@ void recovery_cipher_finalize(void) { volatile bool auto_completed = true; - memzero(rc_new_mnemonic, sizeof(rc_new_mnemonic)); - memzero(rc_temp_word, sizeof(rc_temp_word)); + memzero(final_mnemonic_scratch, sizeof(final_mnemonic_scratch)); + memzero(temp_word_scratch, sizeof(temp_word_scratch)); /* Attempt to autocomplete each word */ char* tok = strtok(mnemonic, " "); while (tok) { - strlcpy(rc_temp_word, tok, CURRENT_WORD_BUF); + strlcpy(temp_word_scratch, tok, CURRENT_WORD_BUF); - auto_completed &= attempt_auto_complete(rc_temp_word); + auto_completed &= attempt_auto_complete(temp_word_scratch); - strlcat(rc_new_mnemonic, rc_temp_word, MNEMONIC_BUF); - strlcat(rc_new_mnemonic, " ", MNEMONIC_BUF); + strlcat(final_mnemonic_scratch, temp_word_scratch, MNEMONIC_BUF); + strlcat(final_mnemonic_scratch, " ", MNEMONIC_BUF); tok = strtok(NULL, " "); } - memzero(rc_temp_word, sizeof(rc_temp_word)); + memzero(temp_word_scratch, sizeof(temp_word_scratch)); if (!auto_completed && !enforce_wordlist) { fsm_sendFailure(FailureType_Failure_SyntaxError, @@ -612,23 +600,25 @@ void recovery_cipher_finalize(void) { } /* Truncate additional space at the end */ - rc_new_mnemonic[MAX(1u, strnlen(rc_new_mnemonic, sizeof(rc_new_mnemonic))) - - 1u] = '\0'; - if (!dry_run && (!enforce_wordlist || mnemonic_check(rc_new_mnemonic))) { + final_mnemonic_scratch[MAX(1u, strnlen(final_mnemonic_scratch, + sizeof(final_mnemonic_scratch))) - + 1u] = '\0'; + if (!dry_run && + (!enforce_wordlist || mnemonic_check(final_mnemonic_scratch))) { /* Commit point: the settings staged at the start of THIS ceremony and * the seed the user typed word by word land together, or neither lands. * setup_commit() disarms before it writes. */ - setup_commit(rc_new_mnemonic, /*imported=*/!enforce_wordlist); - memzero(rc_new_mnemonic, sizeof(rc_new_mnemonic)); + setup_commit(final_mnemonic_scratch, /*imported=*/!enforce_wordlist); + memzero(final_mnemonic_scratch, sizeof(final_mnemonic_scratch)); fsm_sendSuccess("Device recovered"); } else if (dry_run) { - bool match = - storage_isInitialized() && storage_containsMnemonic(rc_new_mnemonic); + bool match = storage_isInitialized() && + storage_containsMnemonic(final_mnemonic_scratch); if (match) { review(ButtonRequestType_ButtonRequest_Other, "Recovery Dry Run", "The seed is valid and MATCHES the one in the device."); fsm_sendSuccess("The seed is valid and matches the one in the device."); - } else if (mnemonic_check(rc_new_mnemonic)) { + } else if (mnemonic_check(final_mnemonic_scratch)) { review(ButtonRequestType_ButtonRequest_Other, "Recovery Dry Run", "The seed is valid, but DOES NOT MATCH the one in the device."); fsm_sendFailure( @@ -641,7 +631,7 @@ void recovery_cipher_finalize(void) { FailureType_Failure_Other, "The seed is invalid, and does not match the one in the device."); } - memzero(rc_new_mnemonic, sizeof(rc_new_mnemonic)); + memzero(final_mnemonic_scratch, sizeof(final_mnemonic_scratch)); } else { /* Nothing reached storage: the staged settings and the mnemonic are * still only in RAM, and the common cleanup below discards both. */ @@ -649,13 +639,47 @@ void recovery_cipher_finalize(void) { "Invalid mnemonic, are words in correct order?"); } - memzero(rc_new_mnemonic, sizeof(rc_new_mnemonic)); + memzero(final_mnemonic_scratch, sizeof(final_mnemonic_scratch)); /* Idempotent: the success path already disarmed inside setup_commit(). */ setup_abort(); layoutHome(); } #if DEBUG_LINK +void recovery_cipher_test_set_word_fragments(void) { + memset(mnemonic, 0x3C, sizeof(mnemonic)); + memset(coded_word, 0xA5, sizeof(coded_word)); + memset(decoded_word, 0x5A, sizeof(decoded_word)); + memset(current_word_scratch, 0xA5, sizeof(current_word_scratch)); + memset(formatted_word_scratch, 0x5A, sizeof(formatted_word_scratch)); + memset(final_mnemonic_scratch, 0xA5, sizeof(final_mnemonic_scratch)); + memset(temp_word_scratch, 0x5A, sizeof(temp_word_scratch)); + memset(auto_completed_word, 0xA5, sizeof(auto_completed_word)); +} + +bool recovery_cipher_test_word_fragments_are_zero(void) { + uint8_t aggregate = 0; + for (size_t i = 0; i < sizeof(mnemonic); i++) { + aggregate |= (uint8_t)mnemonic[i]; + } + for (size_t i = 0; i < sizeof(coded_word); i++) { + aggregate |= (uint8_t)coded_word[i]; + aggregate |= (uint8_t)decoded_word[i]; + } + for (size_t i = 0; i < sizeof(current_word_scratch); i++) { + aggregate |= (uint8_t)current_word_scratch[i]; + aggregate |= (uint8_t)temp_word_scratch[i]; + aggregate |= (uint8_t)auto_completed_word[i]; + } + for (size_t i = 0; i < sizeof(formatted_word_scratch); i++) { + aggregate |= (uint8_t)formatted_word_scratch[i]; + } + for (size_t i = 0; i < sizeof(final_mnemonic_scratch); i++) { + aggregate |= (uint8_t)final_mnemonic_scratch[i]; + } + return aggregate == 0; +} + /* * recovery_get_cipher() - Gets current cipher being show on display * diff --git a/lib/firmware/reset.c b/lib/firmware/reset.c index ce368df51..170d68351 100644 --- a/lib/firmware/reset.c +++ b/lib/firmware/reset.c @@ -21,6 +21,7 @@ #include "keepkey/board/keepkey_board.h" #include "keepkey/board/messages.h" #include "keepkey/board/util.h" +#include "keepkey/firmware/dice_input.h" #include "keepkey/firmware/fsm.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/pin_sm.h" @@ -28,6 +29,7 @@ #include "keepkey/firmware/reset.h" #include "keepkey/firmware/storage.h" #include "keepkey/rand/rng.h" +#include "keepkey/rand/rng_health.h" #include "keepkey/transport/interface.h" #include "trezor/crypto/bip39.h" #include "trezor/crypto/memzero.h" @@ -67,6 +69,18 @@ static uint32_t strength; static uint8_t CONFIDENTIAL int_entropy[32]; static char CONFIDENTIAL current_words[MNEMONIC_BY_SCREEN_BUF]; +/* SHA-256 of the ASCII roll string, shown to the user and exposed over + * DebugLink. A digest of secret input is not the input, but it is a + * verification oracle for a 99-symbol space, so it is treated as + * confidential and cleared as soon as the reset that produced it ends. */ +static uint8_t CONFIDENTIAL dice_digest[32]; +static bool has_dice_digest = false; + +static void dice_digest_clear(void) { + memzero(dice_digest, sizeof(dice_digest)); + has_dice_digest = false; +} + bool setup_isArmed(void) { return setup.kind != SETUP_NONE; } bool setup_isArmedAs(SetupKind kind) { @@ -77,6 +91,7 @@ void setup_abort(void) { /* The recovery half owns its own word buffers. Clearing them is a memzero * too; like everything here it touches no storage. */ recovery_cipher_reset(); + mnemonic_clear(); memzero(&setup, sizeof(setup)); memzero(int_entropy, sizeof(int_entropy)); @@ -85,6 +100,10 @@ void setup_abort(void) { * `mnemo` buffer. A cancelled/error ceremony has no owner for that secret, * so the common abort path must clear it along with the setup scratch. */ mnemonic_clear(); + /* The roll digest is ceremony state like the rest: it only describes the + * reset that produced it, and leaving it live would keep serving it over + * DebugLink for the rest of the boot. */ + dice_digest_clear(); strength = 0; } @@ -177,11 +196,8 @@ void setup_commit(const char* mnemonic, bool imported) { 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) { - /* Retained in the wire/API signature for compatibility. Revealing the - * device entropy lets a host that supplies external entropy reconstruct the - * seed preimage, so 7.14.2 deliberately ignores this legacy request. */ - (void)display_random; + 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, @@ -190,11 +206,41 @@ 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 * ceremony back by doing nothing at all: setup.kind is still SETUP_NONE, - * so no later message can consume what was staged. */ + * so no later message can consume what was staged. + * + * This is also the whole of the abandoned-ceremony fix. There is no + * separate awaiting_entropy flag left to get out of step with: the ONLY + * armed-ness is setup.kind, it is set by the single setup_arm() at the + * bottom of this function -- after every screen, dice included -- and + * reset_entropy() is gated on it through setup_require(). An abort at any + * screen therefore leaves nothing armed for a later EntropyAck to consume, + * and setup_stage() refuses outright to start a second ceremony on top of + * an armed one, so an in-flight reset's entropy can never be overwritten + * by a re-entrant one. */ if (!setup_stage(passphrase_protection, language, label, _auto_lock_delay_ms, _u2f_counter, _no_backup)) { return; @@ -221,9 +267,110 @@ void reset_init(bool display_random, uint32_t _strength, } } - random_buffer(int_entropy, 32); + /* Asked here rather than only inside the draw below so the host gets a real + * error message instead of a halted device: this is the one key-material path + * with somewhere to report a failure to. + * + * This does NOT prove the generator is unpredictable; see the scope note at + * the top of lib/rand/rng_health.c. It proves it is present and not stuck. */ + if (!rng_health_check()) { + /* FirmwareError, not SyntaxError/Other: nothing about the request is + * wrong. The device's own entropy source failed its self-test, which is a + * hardware/firmware fault the host cannot correct by retrying. */ + setup_abort(); + fsm_sendFailure( + FailureType_Failure_FirmwareError, + _("Random number generator self-test failed; cannot create a wallet")); + layoutHome(); + return; + } + + /* The gate above and this draw are deliberately not separable: the check + * cannot be edited out of this function while leaving the draw behind. */ + if (!random_buffer_checked(int_entropy, 32)) { + /* The draw may have written part of int_entropy before failing. */ + setup_abort(); + fsm_sendFailure( + FailureType_Failure_FirmwareError, + _("Random number generator self-test failed; cannot create a wallet")); + layoutHome(); + return; + } + + /* 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. + * + * The digest needs no clear here -- setup_stage() above ran setup_abort(), + * which zeroes it. */ + if (dice_entropy) { + static char CONFIDENTIAL dice_rolls[DICE_MAX_ROLLS]; + static char CONFIDENTIAL digest_hex[17]; + uint32_t rolls_needed = dice_rolls_for_strength(strength); + + if (!dice_input_collect(dice_rolls, rolls_needed)) { + memzero(dice_rolls, sizeof(dice_rolls)); + /* setup_abort() is the whole rollback -- staged settings, int_entropy, + * strength, roll digest. Load-bearing: the tiny-message pump that + * accepted the Cancel/Initialize does not dispatch fsm_msgCancel, so + * nothing else has aborted the ceremony at this point. */ + setup_abort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Reset cancelled")); + layoutHome(); + return; + } + + sha256_Raw((const uint8_t*)dice_rolls, rolls_needed, dice_digest); + has_dice_digest = true; + + data2hex(dice_digest, 8, digest_hex); + bool confirmed = + confirm(ButtonRequestType_ButtonRequest_DiceRoll, _("Dice Rolls"), + _("%lu rolls recorded.\nDigest: %s"), + (unsigned long)rolls_needed, digest_hex); + memzero(digest_hex, sizeof(digest_hex)); + if (!confirmed) { + memzero(dice_rolls, sizeof(dice_rolls)); + setup_abort(); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Reset cancelled")); + layoutHome(); + return; + } + + dice_mix(int_entropy, dice_rolls, rolls_needed); + 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(); fsm_sendFailure(FailureType_Failure_ActionCancelled, _("PINs do not match")); @@ -272,7 +419,7 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { * nothing to reset -- and a host-reachable wipe is not a rollback. */ setup_abort(); layoutHome(); - return; + goto exit; } } @@ -368,12 +515,15 @@ void reset_entropy(const uint8_t* ext_entropy, uint32_t len) { fsm_sendSuccess(_("Device reset")); exit: + /* 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_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(); layoutHome(); } @@ -384,4 +534,12 @@ uint32_t reset_get_int_entropy(uint8_t* entropy) { } const char* reset_get_word(void) { return current_words; } + +uint32_t reset_get_dice_digest(uint8_t* digest) { + if (!has_dice_digest) { + return 0; + } + memcpy(digest, dice_digest, 32); + return 32; +} #endif diff --git a/lib/firmware/ripple.c b/lib/firmware/ripple.c index 31041f358..c7e9c5c65 100644 --- a/lib/firmware/ripple.c +++ b/lib/firmware/ripple.c @@ -21,6 +21,7 @@ #include "keepkey/firmware/ripple_base58.h" #include "trezor/crypto/base58.h" +#include "trezor/crypto/memzero.h" #include "trezor/crypto/secp256k1.h" #include @@ -56,12 +57,44 @@ bool ripple_getAddress(const uint8_t public_key[33], return true; } -void ripple_formatAmount(char* buf, size_t len, uint64_t amount) { +/* An address the serializer will actually accept. + * + * ripple_serializeAddress() decodes and requires exactly 21 raw bytes, but that + * runs after both confirmations -- and it guards the length with assert(), + * which is compiled out of release builds. So a malformed destination was + * displayed and approved before anything checked it. Same check, available + * early. */ +bool ripple_validateAddress(const char* address) { + if (!address || address[0] == '\0') return false; + uint8_t addr_raw[MAX_ADDR_RAW_SIZE]; + const uint32_t len = + ripple_decode_check(address, HASHER_SHA2D, addr_raw, MAX_ADDR_RAW_SIZE); + /* The version byte matters as much as the length. ripple_serializeAddress() + drops addr_raw[0] and signs only the 20 bytes after it, so an address with + a valid checksum but a NON-ZERO version would be displayed exactly as the + host supplied it while the signature committed to the account those 20 + bytes name under version 0 -- a different destination from the one on the + screen. RIPPLE_ADDRESS_VERSION is what ripple_getAddress() itself encodes, + so this accepts exactly the classic account addresses the device can + produce. */ + const bool ok = (len == 21) && (addr_raw[0] == RIPPLE_ADDRESS_VERSION); + memzero(addr_raw, sizeof(addr_raw)); + return ok; +} + +bool ripple_formatAmount(char* buf, size_t len, uint64_t amount) { bignum256 val; bn_read_uint64(amount, &val); if (!bn_format(&val, NULL, " XRP", RIPPLE_DECIMALS, 0, false, buf, len)) { + /* Keep the sentinel for anything that still wants to draw something, but + report the failure: writing "AMOUNT TOO LARGE TO DISPLAY" into a void + function meant fsm_msgRippleSignTx() could not tell, so it showed that + string and signed the numeric amount anyway. An amount the device cannot + render is not an amount it can ask anyone to approve. */ strlcpy(buf, "AMOUNT TOO LARGE TO DISPLAY", len); + return false; } + return true; } static void append_u8(bool* ok, uint8_t** buf, const uint8_t* end, @@ -228,9 +261,9 @@ bool ripple_serialize(uint8_t** buf, const uint8_t* end, const RippleSignTx* tx, return ok; } -void ripple_signTx(const HDNode* node, RippleSignTx* tx, RippleSignedTx* resp) { +bool ripple_signTx(const HDNode* node, RippleSignTx* tx, RippleSignedTx* resp) { const curve_info* curve = get_curve_by_name("secp256k1"); - if (!curve) return; + if (!curve) return false; // Set canonical flag, since trezor-crypto ECDSA implementation returns // fully-canonical signatures, thereby enforcing it in the transaction @@ -249,13 +282,13 @@ void ripple_signTx(const HDNode* node, RippleSignTx* tx, RippleSignedTx* resp) { memcpy(resp->serialized_tx.bytes, "\x53\x54\x58\x00", 4); char source_address[MAX_ADDR_SIZE]; - if (!ripple_getAddress(node->public_key, source_address)) return; + if (!ripple_getAddress(node->public_key, source_address)) return false; uint8_t* buf = resp->serialized_tx.bytes + 4; size_t len = sizeof(resp->serialized_tx.bytes) - 4; if (!ripple_serialize(&buf, buf + len, tx, source_address, node->public_key, NULL, 0)) - return; + return false; // Ripple uses the first half of SHA512 uint8_t hash[64]; @@ -265,7 +298,7 @@ void ripple_signTx(const HDNode* node, RippleSignTx* tx, RippleSignedTx* resp) { if (ecdsa_sign_digest(&secp256k1, node->private_key, hash, sig, NULL, NULL) != 0) { // Failure - return; + return false; } resp->signature.size = ecdsa_sig_to_der(sig, resp->signature.bytes); @@ -277,8 +310,9 @@ void ripple_signTx(const HDNode* node, RippleSignTx* tx, RippleSignedTx* resp) { len = sizeof(resp->serialized_tx); if (!ripple_serialize(&buf, buf + len, tx, source_address, node->public_key, resp->signature.bytes, resp->signature.size)) - return; + return false; resp->has_serialized_tx = true; resp->serialized_tx.size = buf - resp->serialized_tx.bytes; + return true; } diff --git a/lib/firmware/signing.c b/lib/firmware/signing.c index cf1c49688..41c6bdf85 100644 --- a/lib/firmware/signing.c +++ b/lib/firmware/signing.c @@ -26,13 +26,13 @@ #include "keepkey/firmware/app_confirm.h" #include "keepkey/firmware/coins.h" #include "keepkey/firmware/crypto.h" -#include "keepkey/firmware/crypto.h" #include "keepkey/firmware/fsm.h" #include "keepkey/firmware/home_sm.h" #include "keepkey/firmware/policy.h" #include "keepkey/firmware/signing.h" #include "keepkey/firmware/txin_check.h" #include "keepkey/firmware/transaction.h" +#include "trezor/crypto/bip340.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/memzero.h" #include "trezor/crypto/secp256k1.h" @@ -58,7 +58,16 @@ static uint32_t inputs_count; static uint32_t outputs_count; static const CoinType* coin; static const curve_info* curve; -static const HDNode* root; +/* The signer's OWN copy of the signing root, not the caller's node. + * + * signing_init() takes `const HDNode *`, so the node it is handed belongs to + * the caller. signing_abort() has to scrub the master private key, and it + * cannot do that through that pointer: casting away const to write through a + * genuinely read-only node is undefined behavior, and a caller whose node was + * automatic -- as unittests/firmware/fsm.cpp's stack `root` is -- would be + * written through after its lifetime ended. Copy on the way in and scrub what + * we own on the way out; the caller's node stays the caller's business. */ +static CONFIDENTIAL HDNode root; static CONFIDENTIAL HDNode node; static bool signing = false; enum { @@ -82,12 +91,33 @@ static TxInputType input; static TxOutputBinType bin_output; static TxStruct to, tp, ti; static Hasher hasher_prevouts, hasher_sequence, hasher_outputs, hasher_check; +/* BIP-341 commits to every input's amount and scriptPubKey, which BIP-143 + does not, so taproot needs two accumulators segwit never required. These + are SHA256_CTX rather than Hasher because BIP-341 fixes them to plain + SHA256: a Hasher carries a union sized by GROESTL512_CTX and would cost + ~1.2 KB of SRAM here for no benefit. */ +static SHA256_CTX ctx_amounts, ctx_scriptpubkeys; +/* BIP-143 hashes prevouts/sequences/outputs with DOUBLE sha256 (hasher_sign is + HASHER_SHA2D for Bitcoin); BIP-341 specifies SINGLE sha256. The BIP-143 + accumulators therefore cannot be reused -- doing so yields a valid signature + over the wrong commitment. Hence a parallel set. */ +static SHA256_CTX ctx_prevouts_tr, ctx_sequences_tr, ctx_outputs_tr; static uint8_t CONFIDENTIAL privkey[32]; static uint8_t pubkey[33], sig[64]; static uint8_t hash_prevouts[32], hash_sequence[32], hash_outputs[32]; +static uint8_t hash_amounts[32], hash_scriptpubkeys[32]; +static uint8_t hash_prevouts_tr[32], hash_sequences_tr[32], hash_outputs_tr[32]; static uint8_t hash_prefix[32]; static uint8_t hash_check[32]; static uint64_t to_spend, authorized_bip143_in, spending, change_spend; +static bool has_taproot_input, missing_bip341_input_amount; +/* + * Taproot signatures must never be reachable before phase 1 has completed + * the physical transaction-summary confirmation. Keep this as independent + * state instead of inferring it from signing_stage so a malformed or + * corrupted stage transition fails closed at the Schnorr signing boundary. + */ +static bool taproot_transaction_confirmed; static uint32_t version = 1; static uint32_t lock_time = 0; static uint32_t expiry = 0; @@ -101,6 +131,7 @@ static uint8_t multisig_fp[32]; static uint32_t in_address_n[8]; static size_t in_address_n_count; static uint32_t tx_weight; +static int signing_update_ctr; /* A marker for in_address_n_count to indicate a mismatch in bip32 paths in input */ @@ -113,6 +144,10 @@ static uint32_t tx_weight; use and still allow to quickly brute-force the correct bip32 path. */ #define BIP32_MAX_LAST_ELEMENT 1000000 +/* BIP-341 SIGHASH_DEFAULT: commits to all outputs, and unlike SIGHASH_ALL + its byte is omitted from the witness entirely. */ +#define SIGHASH_DEFAULT_TAPROOT 0 + /* transaction header size: 4 byte version */ #define TXSIZE_HEADER 4 /* transaction footer size: 4 byte lock time */ @@ -408,6 +443,18 @@ void phase1_request_next_input(void) { // compute segwit hashPrevouts & hashSequence hasher_Final(&hasher_prevouts, hash_prevouts); hasher_Final(&hasher_sequence, hash_sequence); + if (has_taproot_input && missing_bip341_input_amount) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Taproot transaction input without amount")); + signing_abort(); + return; + } + if (coin->has_taproot && coin->taproot) { + sha256_Final(&ctx_amounts, hash_amounts); + sha256_Final(&ctx_scriptpubkeys, hash_scriptpubkeys); + sha256_Final(&ctx_prevouts_tr, hash_prevouts_tr); + sha256_Final(&ctx_sequences_tr, hash_sequences_tr); + } hasher_Final(&hasher_check, hash_check); // init hashOutputs hasher_Reset(&hasher_outputs); @@ -427,10 +474,11 @@ void phase2_request_next_input(void) { /// Compares two BIP32 paths, returning true iff there is something mismatched /// about the mixed-mode change. -static bool isCrossAccountSegwitChangeForbidden( - const uint32_t* lhs_address_n, size_t lhs_address_n_count, - const uint32_t* rhs_address_n, size_t rhs_address_n_count, - OutputScriptType rhs_script_type) { +bool isCrossAccountSegwitChangeForbidden(const uint32_t* lhs_address_n, + size_t lhs_address_n_count, + const uint32_t* rhs_address_n, + size_t rhs_address_n_count, + OutputScriptType rhs_script_type) { (void)lhs_address_n; size_t count = rhs_address_n_count; @@ -456,6 +504,10 @@ static bool isCrossAccountSegwitChangeForbidden( rhs_script_type != OutputScriptType_PAYTOWITNESS) return true; + if (out_purpose == (0x80000000 | 86) && + rhs_script_type != OutputScriptType_PAYTOTAPROOT) + return true; + return false; } @@ -564,7 +616,12 @@ bool check_change_bip32_path(const TxOutputType* toutput) { toutput->address_n[count - 1] <= BIP32_MAX_LAST_ELEMENT); } -bool compile_input_script_sig(TxInputType* tinput) { +/* Re-validates the input against what phase 1 saw, then derives its node into + `node`. Split out of compile_input_script_sig() so taproot can reuse the + checks and the derivation without building a scriptSig -- a taproot input + has an empty one, and skipping this would also skip the guard that the + host has not swapped address_n between phases. */ +static bool prepare_input_node(TxInputType* tinput) { if (!multisig_fp_mismatch) { // check that this is still multisig uint8_t h[32]; @@ -587,13 +644,20 @@ bool compile_input_script_sig(TxInputType* tinput) { return false; } } - memcpy(&node, root, sizeof(HDNode)); + memcpy(&node, &root, sizeof(HDNode)); if (hdnode_private_ckd_cached(&node, tinput->address_n, tinput->address_n_count, NULL) == 0) { // Failed to derive private key return false; } hdnode_fill_public_key(&node); + return true; +} + +bool compile_input_script_sig(TxInputType* tinput) { + if (!prepare_input_node(tinput)) { + return false; + } if (tinput->has_multisig) { tinput->script_sig.size = compile_script_multisig(coin, &(tinput->multisig), tinput->script_sig.bytes); @@ -611,7 +675,8 @@ void signing_init(const SignTx* msg, const CoinType* _coin, inputs_count = msg->inputs_count; outputs_count = msg->outputs_count; coin = _coin; - root = _root; + memzero(&root, sizeof(root)); + if (_root) memcpy(&root, _root, sizeof(root)); version = msg->version; lock_time = msg->lock_time; expiry = msg->expiry; @@ -646,6 +711,9 @@ void signing_init(const SignTx* msg, const CoinType* _coin, spending = 0; change_spend = 0; authorized_bip143_in = 0; + has_taproot_input = false; + missing_bip341_input_amount = false; + taproot_transaction_confirmed = false; memset(&input, 0, sizeof(TxInputType)); memset(&resp, 0, sizeof(TxRequest)); @@ -660,6 +728,12 @@ void signing_init(const SignTx* msg, const CoinType* _coin, multisig_fp_mismatch = false; next_nonsegwit_input = 0xffffffff; + /* An OP_RETURN-only transaction never reaches the payment-output path that + * normally resets this context. Start each signing request with a fresh + * current digest while preserving the previous completed transaction used + * by the duplicate-output warning. */ + txin_dgst_reset_current(); + curve = get_curve_by_name(coin->curve_name); if (!curve) curve = get_curve_by_name(SECP256K1_NAME); @@ -692,6 +766,26 @@ void signing_init(const SignTx* msg, const CoinType* _coin, hasher_Init(&hasher_check, curve->hasher_sign); } + /* BIP-341 fixes these as plain SHA256, independent of the coin's signing + hasher, so they are initialised separately on purpose. + + Initialised on the taproot condition alone, and deliberately outside the + overwintered branch above. Every update and finalise site for these five + contexts is gated only on `coin->has_taproot && coin->taproot`, but the + initialisation used to sit inside the non-overwintered `else`. Since + `overwintered` comes from the host on SignTx, setting it on a taproot + coin skipped the init while leaving the use sites live, so the sighash + was built from whatever these static contexts held -- uninitialised on + the first signature after boot, and carried over from the previous + transaction after that. */ + if (coin->has_taproot && coin->taproot) { + sha256_Init(&ctx_amounts); + sha256_Init(&ctx_scriptpubkeys); + sha256_Init(&ctx_prevouts_tr); + sha256_Init(&ctx_sequences_tr); + sha256_Init(&ctx_outputs_tr); + } + layoutProgressSwipe(_("Signing transaction"), 0); send_req_1_input(); @@ -715,11 +809,26 @@ static bool is_multisig_output_script_type(const TxOutputType* txoutput) { return false; } +bool signing_output_multisig_quorum_is_valid(const TxOutputType* txoutput) { + return txoutput != NULL && (!txoutput->has_multisig || + multisig_quorum_is_valid(&txoutput->multisig)); +} + +void signing_checksum_script_type_bytes(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 is_internal_input_script_type(const TxInputType* txinput) { if (txinput->script_type == InputScriptType_SPENDADDRESS || txinput->script_type == InputScriptType_SPENDMULTISIG || txinput->script_type == InputScriptType_SPENDP2SHWITNESS || - txinput->script_type == InputScriptType_SPENDWITNESS) { + txinput->script_type == InputScriptType_SPENDWITNESS || + txinput->script_type == InputScriptType_SPENDTAPROOT) { return true; } return false; @@ -729,7 +838,8 @@ static bool is_change_output_script_type(const TxOutputType* txoutput) { if (txoutput->script_type == OutputScriptType_PAYTOADDRESS || txoutput->script_type == OutputScriptType_PAYTOMULTISIG || txoutput->script_type == OutputScriptType_PAYTOP2SHWITNESS || - txoutput->script_type == OutputScriptType_PAYTOWITNESS) { + txoutput->script_type == OutputScriptType_PAYTOWITNESS || + txoutput->script_type == OutputScriptType_PAYTOTAPROOT) { return true; } return false; @@ -737,7 +847,8 @@ static bool is_change_output_script_type(const TxOutputType* txoutput) { static bool is_segwit_input_script_type(const TxInputType* txinput) { if (txinput->script_type == InputScriptType_SPENDP2SHWITNESS || - txinput->script_type == InputScriptType_SPENDWITNESS) { + txinput->script_type == InputScriptType_SPENDWITNESS || + txinput->script_type == InputScriptType_SPENDTAPROOT) { return true; } return false; @@ -757,6 +868,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 @@ -794,6 +915,13 @@ static bool signing_validate_input(const TxInputType* txinput) { } } + if (txinput->script_type == InputScriptType_SPENDTAPROOT && + (!coin->has_taproot || !coin->taproot)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Taproot not enabled on this coin.")); + signing_abort(); + return false; + } if (is_segwit_input_script_type(txinput)) { if (!coin->has_segwit) { fsm_sendFailure(FailureType_Failure_Other, @@ -813,6 +941,12 @@ static bool signing_validate_input(const TxInputType* txinput) { } static bool signing_validate_output(const TxOutputType* txoutput) { + if (!signing_output_multisig_quorum_is_valid(txoutput)) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Invalid multisig quorum")); + signing_abort(); + return false; + } if (txoutput->has_multisig && !is_multisig_output_script_type(txoutput)) { fsm_sendFailure(FailureType_Failure_UnexpectedMessage, _("Multisig field provided but not expected.")); @@ -828,6 +962,16 @@ static bool signing_validate_output(const TxOutputType* txoutput) { return false; } + // has_taproot is the nanopb presence flag, not the value. Every coin in + // coins.def sets it, so both fields must be checked. + if (txoutput->script_type == OutputScriptType_PAYTOTAPROOT && + (!coin->has_taproot || !coin->taproot)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Taproot not enabled on this coin.")); + signing_abort(); + return false; + } + if (txoutput->script_type == OutputScriptType_PAYTOOPRETURN) { if (txoutput->has_address || (txoutput->address_n_count > 0) || txoutput->has_multisig) { @@ -837,14 +981,6 @@ static bool signing_validate_output(const TxOutputType* txoutput) { return false; } - if (txoutput->script_type == OutputScriptType_PAYTOTAPROOT && - !coin->has_taproot) { - fsm_sendFailure(FailureType_Failure_Other, - _("Taproot not enabled on this coin.")); - signing_abort(); - return false; - } - if (txoutput->amount != 0) { fsm_sendFailure(FailureType_Failure_Other, _("OP_RETURN output with non-zero amount")); @@ -913,6 +1049,36 @@ static bool signing_check_input(TxInputType* txinput) { // compute segwit hashPrevouts & hashSequence tx_prevout_hash(&hasher_prevouts, txinput); tx_sequence_hash(&hasher_sequence, txinput); + // BIP-341 commits to the amount and scriptPubKey of EVERY input, not just + // the taproot ones, so these must be accumulated for all of them. Only + // coins with taproot enabled pay the per-input derivation, and + // hdnode_private_ckd_cached keeps repeated derivations along one account + // path cheap. + if (coin->has_taproot && coin->taproot) { + has_taproot_input |= txinput->script_type == InputScriptType_SPENDTAPROOT; + missing_bip341_input_amount |= !txinput->has_amount; + uint8_t script_pubkey[64]; + size_t script_pubkey_len = 0; + if (!fill_input_script_pubkey(coin, &root, txinput, script_pubkey, + &script_pubkey_len, sizeof(script_pubkey))) { + fsm_sendFailure(FailureType_Failure_Other, + _("Failed to derive input scriptPubKey")); + signing_abort(); + return false; + } + /* outpoint: prev_hash is carried display-order and goes out reversed, + matching tx_prevout_hash() */ + for (int i = 0; i < 32; i++) { + sha256_Update(&ctx_prevouts_tr, &txinput->prev_hash.bytes[31 - i], 1); + } + sha256_Update(&ctx_prevouts_tr, (const uint8_t*)&txinput->prev_index, 4); + sha256_Update(&ctx_sequences_tr, (const uint8_t*)&txinput->sequence, 4); + sha256_Update(&ctx_amounts, (const uint8_t*)&txinput->amount, 8); + uint8_t lenbuf[5]; + uint32_t lenlen = ser_length(script_pubkey_len, lenbuf); + sha256_Update(&ctx_scriptpubkeys, lenbuf, lenlen); + sha256_Update(&ctx_scriptpubkeys, script_pubkey, script_pubkey_len); + } if (coin->decred) { if (txinput->decred_script_version > 0) { fsm_sendFailure(FailureType_Failure_SyntaxError, @@ -933,8 +1099,10 @@ static bool signing_check_input(TxInputType* txinput) { // hash prevout and script type to check it later (relevant for fee // computation) tx_prevout_hash(&hasher_check, txinput); - hasher_Update(&hasher_check, (const uint8_t*)&txinput->script_type, - sizeof(&txinput->script_type)); + uint8_t script_type_bytes[4]; + signing_checksum_script_type_bytes(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; } @@ -996,7 +1164,7 @@ static bool signing_check_output(TxOutputType* txoutput) { } spending += txoutput->amount; int co = - run_policy_compile_output(coin, root, txoutput, &bin_output, !is_change); + run_policy_compile_output(coin, &root, txoutput, &bin_output, !is_change); if (!is_change) { layoutProgress(_("Signing transaction"), progress); } @@ -1017,6 +1185,15 @@ static bool signing_check_output(TxOutputType* txoutput) { } // compute segwit hashOuts tx_output_hash(&hasher_outputs, &bin_output, coin->decred); + /* BIP-341's sha_outputs: single sha256 over amount || ser_script */ + if (coin->has_taproot && coin->taproot) { + sha256_Update(&ctx_outputs_tr, (const uint8_t*)&bin_output.amount, 8); + uint8_t lenbuf[5]; + uint32_t lenlen = ser_length(bin_output.script_pubkey.size, lenbuf); + sha256_Update(&ctx_outputs_tr, lenbuf, lenlen); + sha256_Update(&ctx_outputs_tr, bin_output.script_pubkey.bytes, + bin_output.script_pubkey.size); + } return true; } @@ -1053,6 +1230,9 @@ static bool signing_check_fee(void) { signing_abort(); return false; } + if (has_taproot_input) { + taproot_transaction_confirmed = true; + } return true; } @@ -1076,6 +1256,9 @@ static void phase1_request_next_output(void) { tx_hash_final(&ti, hash_prefix, false); } hasher_Final(&hasher_outputs, hash_outputs); + if (coin->has_taproot && coin->taproot) { + sha256_Final(&ctx_outputs_tr, hash_outputs_tr); + } if (!signing_check_fee()) { return; } @@ -1092,6 +1275,14 @@ static void phase1_request_next_output(void) { } } +/* BIP-341 key-path sighash. The assembly itself lives in bip341_sighash() + so the field ordering is unit-testable against the published vectors. */ +static void signing_hash_bip341(uint32_t input_index, uint8_t* hash) { + bip341_sighash(SIGHASH_DEFAULT_TAPROOT, version, lock_time, hash_prevouts_tr, + hash_amounts, hash_scriptpubkeys, hash_sequences_tr, + hash_outputs_tr, input_index, hash); +} + static void signing_hash_bip143(const TxInputType* txinput, uint8_t* hash) { uint32_t hash_type = signing_hash_type(); Hasher hasher_preimage; @@ -1266,7 +1457,78 @@ static bool signing_sign_input(void) { static bool signing_sign_segwit_input(TxInputType* txinput) { // idx1: index to sign - if (is_segwit_input_script_type(txinput)) { + if (txinput->script_type == InputScriptType_SPENDTAPROOT) { + if (!taproot_transaction_confirmed) { + fsm_sendFailure(FailureType_Failure_Other, + _("Taproot transaction was not confirmed")); + signing_abort(); + return false; + } + /* No scriptSig to build, but the same re-validation and derivation the + other input types get. */ + if (!prepare_input_node(txinput)) { + fsm_sendFailure(FailureType_Failure_Other, _("Failed to compile input")); + signing_abort(); + return false; + } + if (txinput->amount > authorized_bip143_in) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Transaction has changed during signing")); + signing_abort(); + return false; + } + authorized_bip143_in -= txinput->amount; + + uint8_t hash[32]; + signing_hash_bip341(idx1, hash); + + /* Sign with the BIP-86 tweaked key, so the signature verifies against the + output key committed to in the scriptPubKey rather than the internal + key. */ + static CONFIDENTIAL uint8_t tweaked[32]; + if (bip340_tweak_seckey(curve->params, node.private_key, + /*merkle_root=*/NULL, tweaked) != 0) { + memzero(tweaked, sizeof(tweaked)); + fsm_sendFailure(FailureType_Failure_Other, _("Failed to tweak key")); + signing_abort(); + return false; + } + /* aux = NULL means an all-zero aux_rand, i.e. deterministic signing. That + is spec-permitted and matches this firmware's ECDSA, which is RFC6979 + deterministic; the nonce still depends on the private key and the + message, so it is never reused across different transactions. Fresh + randomness would only add side-channel hardening, at the cost of making + signatures unreproducible and so untestable against a published + vector. */ + int sign_ret = bip340_sign(curve->params, tweaked, hash, sizeof(hash), + /*aux=*/NULL, sig); + memzero(tweaked, sizeof(tweaked)); + if (sign_ret != 0) { + fsm_sendFailure(FailureType_Failure_Other, _("Signing failed")); + signing_abort(); + return false; + } + + resp.has_serialized = true; + resp.serialized.has_signature_index = true; + resp.serialized.signature_index = idx1; + resp.serialized.has_signature = true; + /* signing_txack() memsets resp, and this branch bypasses + signing_sign_hash(), which is where every other input type sets this. + Without it nanopb omits serialized_tx and the host loses the witness + and the tx footer. */ + resp.serialized.has_serialized_tx = true; + resp.serialized.signature.size = 64; + memcpy(resp.serialized.signature.bytes, sig, 64); + + /* Witness is a single 64-byte element. SIGHASH_DEFAULT omits the trailing + sighash byte entirely -- appending 0x00 would be a different, invalid + signature. */ + uint32_t r = 0; + r += ser_length(1, resp.serialized.serialized_tx.bytes + r); + r += tx_serialize_script(64, sig, resp.serialized.serialized_tx.bytes + r); + resp.serialized.serialized_tx.size = r; + } else if (is_segwit_input_script_type(txinput)) { if (!compile_input_script_sig(txinput)) { fsm_sendFailure(FailureType_Failure_Other, _("Failed to compile input")); signing_abort(); @@ -1379,10 +1641,9 @@ void signing_txack(TransactionType* tx) { return; } - static int update_ctr = 0; - if (update_ctr++ == 20) { + if (signing_update_ctr++ == 20) { layoutProgress(_("Signing transaction"), progress); - update_ctr = 0; + signing_update_ctr = 0; } memset(&resp, 0, sizeof(TxRequest)); @@ -1436,6 +1697,7 @@ void signing_txack(TransactionType* tx) { send_req_2_prev_meta(); } } else if (tx->inputs[0].script_type == InputScriptType_SPENDWITNESS || + tx->inputs[0].script_type == InputScriptType_SPENDTAPROOT || tx->inputs[0].script_type == InputScriptType_SPENDP2SHWITNESS) { if (coin->decred) { @@ -1568,6 +1830,26 @@ void signing_txack(TransactionType* tx) { signing_abort(); return; } + /* BIP-341 commits to every input's amount and scriptPubKey. For a + mixed legacy+Taproot transaction the host must provide the legacy + amount, and it must describe the actual prevout rather than an + invented commitment that would produce an invalid signature. */ + if (coin->has_taproot && coin->taproot && input.has_amount) { + uint8_t expected_script[64]; + size_t expected_script_len = 0; + if (input.amount != tx->bin_outputs[0].amount || + !fill_input_script_pubkey(coin, &root, &input, expected_script, + &expected_script_len, + sizeof(expected_script)) || + expected_script_len != tx->bin_outputs[0].script_pubkey.size || + memcmp(expected_script, tx->bin_outputs[0].script_pubkey.bytes, + expected_script_len) != 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, + _("Input amount or script does not match prevout")); + signing_abort(); + return; + } + } to_spend += tx->bin_outputs[0].amount; } if (idx2 < tp.outputs_len - 1) { @@ -1600,7 +1882,6 @@ void signing_txack(TransactionType* tx) { } return; case STAGE_REQUEST_3_OUTPUT: - txin_dgst_final(); if (!signing_validate_output(&tx->outputs[0]) || @@ -1624,8 +1905,12 @@ void signing_txack(TransactionType* tx) { } // check prevouts and script type tx_prevout_hash(&hasher_check, tx->inputs); - hasher_Update(&hasher_check, (const uint8_t*)&tx->inputs[0].script_type, - sizeof(&tx->inputs[0].script_type)); + uint8_t script_type_bytes[4]; + signing_checksum_script_type_bytes(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, @@ -1674,7 +1959,7 @@ void signing_txack(TransactionType* tx) { progress = 500 + ((signatures * progress_step + (inputs_count + idx2) * progress_meta_step) >> PROGRESS_PRECISION); - int co = run_policy_compile_output(coin, root, tx->outputs, &bin_output, + int co = run_policy_compile_output(coin, &root, tx->outputs, &bin_output, false); if (co <= TXOUT_COMPILE_ERROR) { send_fsm_co_error_message(co); @@ -1700,7 +1985,7 @@ void signing_txack(TransactionType* tx) { signatures++; progress = 500 + ((signatures * progress_step) >> PROGRESS_PRECISION); layoutProgress(_("Signing transaction"), progress); - update_ctr = 0; + signing_update_ctr = 0; if (idx1 < inputs_count - 1) { idx1++; phase2_request_next_input(); @@ -1767,7 +2052,7 @@ void signing_txack(TransactionType* tx) { signatures++; progress = 500 + ((signatures * progress_step) >> PROGRESS_PRECISION); layoutProgress(_("Signing transaction"), progress); - update_ctr = 0; + signing_update_ctr = 0; } else if (tx->inputs[0].script_type == InputScriptType_SPENDP2SHWITNESS && !tx->inputs[0].has_multisig) { @@ -1821,7 +2106,7 @@ void signing_txack(TransactionType* tx) { if (!signing_validate_output(&tx->outputs[0])) { return; } - co = run_policy_compile_output(coin, root, tx->outputs, &bin_output, + co = run_policy_compile_output(coin, &root, tx->outputs, &bin_output, false); if (co <= TXOUT_COMPILE_ERROR) { send_fsm_co_error_message(co); @@ -1854,7 +2139,7 @@ void signing_txack(TransactionType* tx) { signatures++; progress = 500 + ((signatures * progress_step) >> PROGRESS_PRECISION); layoutProgress(_("Signing transaction"), progress); - update_ctr = 0; + signing_update_ctr = 0; if (idx1 < inputs_count - 1) { idx1++; send_req_segwit_witness(); @@ -1913,7 +2198,7 @@ void signing_txack(TransactionType* tx) { signatures++; progress = 500 + ((signatures * progress_step) >> PROGRESS_PRECISION); layoutProgress(_("Signing transaction"), progress); - update_ctr = 0; + signing_update_ctr = 0; if (idx1 < inputs_count - 1) { idx1++; send_req_decred_witness(); @@ -1931,48 +2216,165 @@ void signing_txack(TransactionType* tx) { void signing_abort(void) { if (signing) { layoutHome(); - signing = false; } - /* root points at fsm_getDerivedNode()'s static HDNode. Clearing only the - * pointer leaves the master private key resident across cancellation, - * ClearSession, lock, or wipe. */ - if (root) memzero((void*)root, sizeof(*root)); + fsm_clearDerivedNode(); + txin_dgst_reset_current(); + memzero(&inputs_count, sizeof(inputs_count)); + memzero(&outputs_count, sizeof(outputs_count)); + memzero(&coin, sizeof(coin)); + memzero(&curve, sizeof(curve)); + /* Scrub the signer's own copy of the master key, so it does not stay + * resident across cancellation, ClearSession, lock, or wipe. */ memzero(&root, sizeof(root)); memzero(&node, sizeof(node)); + memzero(&signing, sizeof(signing)); + memzero(&signing_stage, sizeof(signing_stage)); + memzero(&idx1, sizeof(idx1)); + memzero(&idx2, sizeof(idx2)); + memzero(&signatures, sizeof(signatures)); + memzero(&resp, sizeof(resp)); + memzero(&input, sizeof(input)); + memzero(&bin_output, sizeof(bin_output)); + memzero(&to, sizeof(to)); + memzero(&tp, sizeof(tp)); + memzero(&ti, sizeof(ti)); + memzero(&hasher_prevouts, sizeof(hasher_prevouts)); + memzero(&hasher_sequence, sizeof(hasher_sequence)); + memzero(&hasher_outputs, sizeof(hasher_outputs)); + memzero(&hasher_check, sizeof(hasher_check)); + memzero(&ctx_amounts, sizeof(ctx_amounts)); + memzero(&ctx_scriptpubkeys, sizeof(ctx_scriptpubkeys)); + memzero(&ctx_prevouts_tr, sizeof(ctx_prevouts_tr)); + memzero(&ctx_sequences_tr, sizeof(ctx_sequences_tr)); + memzero(&ctx_outputs_tr, sizeof(ctx_outputs_tr)); memzero(privkey, sizeof(privkey)); memzero(pubkey, sizeof(pubkey)); memzero(sig, sizeof(sig)); memzero(hash_prevouts, sizeof(hash_prevouts)); memzero(hash_sequence, sizeof(hash_sequence)); memzero(hash_outputs, sizeof(hash_outputs)); + memzero(hash_amounts, sizeof(hash_amounts)); + memzero(hash_scriptpubkeys, sizeof(hash_scriptpubkeys)); + memzero(hash_prevouts_tr, sizeof(hash_prevouts_tr)); + memzero(hash_sequences_tr, sizeof(hash_sequences_tr)); + memzero(hash_outputs_tr, sizeof(hash_outputs_tr)); memzero(hash_prefix, sizeof(hash_prefix)); memzero(hash_check, sizeof(hash_check)); + memzero(&to_spend, sizeof(to_spend)); + memzero(&authorized_bip143_in, sizeof(authorized_bip143_in)); + memzero(&spending, sizeof(spending)); + memzero(&change_spend, sizeof(change_spend)); + memzero(&has_taproot_input, sizeof(has_taproot_input)); + memzero(&missing_bip341_input_amount, sizeof(missing_bip341_input_amount)); + memzero(&taproot_transaction_confirmed, + sizeof(taproot_transaction_confirmed)); + memzero(&version, sizeof(version)); + memzero(&lock_time, sizeof(lock_time)); + memzero(&expiry, sizeof(expiry)); + memzero(&overwintered, sizeof(overwintered)); + memzero(&version_group_id, sizeof(version_group_id)); + memzero(&branch_id, sizeof(branch_id)); + memzero(&next_nonsegwit_input, sizeof(next_nonsegwit_input)); + memzero(&progress, sizeof(progress)); + memzero(&progress_step, sizeof(progress_step)); + memzero(&progress_meta_step, sizeof(progress_meta_step)); + memzero(&multisig_fp_set, sizeof(multisig_fp_set)); + memzero(&multisig_fp_mismatch, sizeof(multisig_fp_mismatch)); memzero(multisig_fp, sizeof(multisig_fp)); memzero(in_address_n, sizeof(in_address_n)); - memzero(&input, sizeof(input)); - memzero(&bin_output, sizeof(bin_output)); - memzero(&resp, sizeof(resp)); - memzero(&to, sizeof(to)); - memzero(&tp, sizeof(tp)); - memzero(&ti, sizeof(ti)); - memzero(&hasher_prevouts, sizeof(hasher_prevouts)); - memzero(&hasher_sequence, sizeof(hasher_sequence)); - memzero(&hasher_outputs, sizeof(hasher_outputs)); - memzero(&hasher_check, sizeof(hasher_check)); - inputs_count = outputs_count = 0; - signing_stage = STAGE_REQUEST_1_INPUT; - idx1 = idx2 = signatures = 0; - to_spend = authorized_bip143_in = spending = change_spend = 0; - version = 1; - lock_time = expiry = version_group_id = branch_id = 0; - overwintered = false; - next_nonsegwit_input = 0; - progress = progress_step = progress_meta_step = 0; - multisig_fp_set = multisig_fp_mismatch = false; - in_address_n_count = 0; - tx_weight = 0; - coin = NULL; - curve = NULL; + memzero(&in_address_n_count, sizeof(in_address_n_count)); + memzero(&tx_weight, sizeof(tx_weight)); + 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; +static bool signing_test_bytes_are_zero(const void* ptr, size_t len) { + const uint8_t* bytes = (const uint8_t*)ptr; + uint8_t aggregate = 0; + for (size_t i = 0; i < len; i++) aggregate |= bytes[i]; + return aggregate == 0; +} + +void signing_test_seed_state(void) { + coin = &signing_test_coin; + curve = &signing_test_curve; + memset(&root, 0xA5, sizeof(root)); + signing = true; + signing_stage = STAGE_REQUEST_5_OUTPUT; + memset(&node, 0xA5, sizeof(node)); + memset(&resp, 0xA5, sizeof(resp)); + memset(&input, 0xA5, sizeof(input)); + memset(&bin_output, 0xA5, sizeof(bin_output)); + memset(&to, 0xA5, sizeof(to)); + memset(&tp, 0xA5, sizeof(tp)); + memset(&ti, 0xA5, sizeof(ti)); + memset(&hasher_prevouts, 0xA5, sizeof(hasher_prevouts)); + memset(&hasher_sequence, 0xA5, sizeof(hasher_sequence)); + memset(&hasher_outputs, 0xA5, sizeof(hasher_outputs)); + memset(&hasher_check, 0xA5, sizeof(hasher_check)); + memset(&ctx_amounts, 0xA5, sizeof(ctx_amounts)); + memset(&ctx_scriptpubkeys, 0xA5, sizeof(ctx_scriptpubkeys)); + memset(&ctx_prevouts_tr, 0xA5, sizeof(ctx_prevouts_tr)); + memset(&ctx_sequences_tr, 0xA5, sizeof(ctx_sequences_tr)); + memset(&ctx_outputs_tr, 0xA5, sizeof(ctx_outputs_tr)); + memset(privkey, 0xA5, sizeof(privkey)); + memset(pubkey, 0xA5, sizeof(pubkey)); + memset(sig, 0xA5, sizeof(sig)); + memset(hash_prevouts, 0xA5, sizeof(hash_prevouts)); + memset(hash_sequence, 0xA5, sizeof(hash_sequence)); + memset(hash_outputs, 0xA5, sizeof(hash_outputs)); + memset(hash_amounts, 0xA5, sizeof(hash_amounts)); + memset(hash_scriptpubkeys, 0xA5, sizeof(hash_scriptpubkeys)); + memset(hash_prevouts_tr, 0xA5, sizeof(hash_prevouts_tr)); + memset(hash_sequences_tr, 0xA5, sizeof(hash_sequences_tr)); + memset(hash_outputs_tr, 0xA5, sizeof(hash_outputs_tr)); + memset(hash_prefix, 0xA5, sizeof(hash_prefix)); + memset(hash_check, 0xA5, sizeof(hash_check)); + memset(multisig_fp, 0xA5, sizeof(multisig_fp)); + memset(in_address_n, 0xA5, sizeof(in_address_n)); + inputs_count = outputs_count = idx1 = idx2 = signatures = 1; + to_spend = authorized_bip143_in = spending = change_spend = 1; + version = lock_time = expiry = version_group_id = branch_id = 1; + next_nonsegwit_input = progress = progress_step = progress_meta_step = 1; + tx_weight = 1; + in_address_n_count = 1; + has_taproot_input = missing_bip341_input_amount = true; + taproot_transaction_confirmed = overwintered = true; + multisig_fp_set = multisig_fp_mismatch = true; + signing_update_ctr = 1; + fsm_test_seedDerivedNode(); +} + +bool signing_test_state_is_cleared(void) { +#define IS_ZERO(V) signing_test_bytes_are_zero(&(V), sizeof(V)) + return IS_ZERO(node) && IS_ZERO(resp) && IS_ZERO(input) && + IS_ZERO(bin_output) && IS_ZERO(to) && IS_ZERO(tp) && IS_ZERO(ti) && + coin == NULL && curve == NULL && IS_ZERO(root) && !signing && + signing_stage == 0 && IS_ZERO(hasher_prevouts) && + IS_ZERO(hasher_sequence) && IS_ZERO(hasher_outputs) && + IS_ZERO(hasher_check) && IS_ZERO(ctx_amounts) && + IS_ZERO(ctx_scriptpubkeys) && IS_ZERO(ctx_prevouts_tr) && + IS_ZERO(ctx_sequences_tr) && IS_ZERO(ctx_outputs_tr) && + IS_ZERO(privkey) && IS_ZERO(pubkey) && IS_ZERO(sig) && + IS_ZERO(hash_prevouts) && IS_ZERO(hash_sequence) && + IS_ZERO(hash_outputs) && IS_ZERO(hash_amounts) && + IS_ZERO(hash_scriptpubkeys) && IS_ZERO(hash_prevouts_tr) && + IS_ZERO(hash_sequences_tr) && IS_ZERO(hash_outputs_tr) && + IS_ZERO(hash_prefix) && IS_ZERO(hash_check) && IS_ZERO(multisig_fp) && + IS_ZERO(in_address_n) && inputs_count == 0 && outputs_count == 0 && + idx1 == 0 && idx2 == 0 && signatures == 0 && to_spend == 0 && + authorized_bip143_in == 0 && spending == 0 && change_spend == 0 && + version == 0 && lock_time == 0 && expiry == 0 && + version_group_id == 0 && branch_id == 0 && next_nonsegwit_input == 0 && + progress == 0 && progress_step == 0 && progress_meta_step == 0 && + tx_weight == 0 && in_address_n_count == 0 && !has_taproot_input && + !missing_bip341_input_amount && !taproot_transaction_confirmed && + !overwintered && !multisig_fp_set && !multisig_fp_mismatch && + signing_update_ctr == 0 && fsm_test_derivedNodeIsZero(); +#undef IS_ZERO +} +#endif diff --git a/lib/firmware/signtx_tendermint.c b/lib/firmware/signtx_tendermint.c index 7a90e44fd..669a957fd 100644 --- a/lib/firmware/signtx_tendermint.c +++ b/lib/firmware/signtx_tendermint.c @@ -150,11 +150,14 @@ bool tendermint_signTxUpdateMsgSend(const uint64_t amount, const char* msgTypePrefix) { if (!tendermint_canUpdate()) return false; char buffer[128]; - size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; - - if (!bech32_decode(hrp, decoded, &decoded_len, to_address)) { + /* Validate against the caller's chain prefix (chainstr) and the 20-byte + account length before the address reaches the bare "%s" JSON serialization + below. This was a bare bech32_decode() into hrp[45]/decoded[38]: both + undersized for host-chosen input (see tendermint_bech32DecodeChecked()), + and neither the 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, chainstr)) { return false; } @@ -215,11 +218,19 @@ bool tendermint_signTxUpdateMsgDelegate(const uint64_t amount, const char* msgTypePrefix) { if (!tendermint_canUpdate()) return false; char buffer[128]; - size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; - - if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { + /* Validate against the caller's chain prefix (chainstr) and the 20-byte + account length before the address reaches the bare "%s" JSON serialization + below. This was a bare bech32_decode() into hrp[45]/decoded[38]: both + undersized for host-chosen input (see tendermint_bech32DecodeChecked()), + and neither the 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(delegator_address, chainstr)) { + return false; + } + /* The validator operator is interpolated into the signed document with the + same bare "%s" as the delegator above, so it needs the same gate. */ + if (!tendermint_validateValidatorAddress(validator_address, chainstr)) { return false; } @@ -280,11 +291,19 @@ bool tendermint_signTxUpdateMsgUndelegate(const uint64_t amount, const char* msgTypePrefix) { if (!tendermint_canUpdate()) return false; char buffer[128]; - size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; - - if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { + /* Validate against the caller's chain prefix (chainstr) and the 20-byte + account length before the address reaches the bare "%s" JSON serialization + below. This was a bare bech32_decode() into hrp[45]/decoded[38]: both + undersized for host-chosen input (see tendermint_bech32DecodeChecked()), + and neither the 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(delegator_address, chainstr)) { + return false; + } + /* The validator operator is interpolated into the signed document with the + same bare "%s" as the delegator above, so it needs the same gate. */ + if (!tendermint_validateValidatorAddress(validator_address, chainstr)) { return false; } @@ -344,11 +363,20 @@ bool tendermint_signTxUpdateMsgRedelegate( const char* chainstr, const char* denom, const char* msgTypePrefix) { if (!tendermint_canUpdate()) return false; char buffer[128]; - size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; - - if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { + /* Validate against the caller's chain prefix (chainstr) and the 20-byte + account length before the address reaches the bare "%s" JSON serialization + below. This was a bare bech32_decode() into hrp[45]/decoded[38]: both + undersized for host-chosen input (see tendermint_bech32DecodeChecked()), + and neither the 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(delegator_address, chainstr)) { + return false; + } + /* Both validator operators are interpolated with the same bare "%s" as the + delegator above; neither was checked. */ + if (!tendermint_validateValidatorAddress(validator_src_address, chainstr) || + !tendermint_validateValidatorAddress(validator_dst_address, chainstr)) { return false; } @@ -417,11 +445,19 @@ bool tendermint_signTxUpdateMsgRewards(const uint64_t* amount, const char* msgTypePrefix) { if (!tendermint_canUpdate()) return false; char buffer[128]; - size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; - - if (!bech32_decode(hrp, decoded, &decoded_len, delegator_address)) { + /* Validate against the caller's chain prefix (chainstr) and the 20-byte + account length before the address reaches the bare "%s" JSON serialization + below. This was a bare bech32_decode() into hrp[45]/decoded[38]: both + undersized for host-chosen input (see tendermint_bech32DecodeChecked()), + and neither the 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(delegator_address, chainstr)) { + return false; + } + /* The validator operator is interpolated into the signed document with the + same bare "%s" as the delegator above, so it needs the same gate. */ + if (!tendermint_validateValidatorAddress(validator_address, chainstr)) { return false; } @@ -485,11 +521,13 @@ bool tendermint_signTxUpdateMsgIBCTransfer( const char* chainstr, const char* denom, const char* msgTypePrefix) { if (!tendermint_canUpdate()) return false; char buffer[128]; - size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; - - if (!bech32_decode(hrp, decoded, &decoded_len, receiver)) { + /* An IBC receiver lives on the COUNTERPARTY chain, so its human-readable + part is deliberately not one of ours and cannot be pinned to a prefix. + What can be fixed is the decode itself: the previous bare bech32_decode() + wrote into hrp[45]/decoded[38], both of which a host can overrun (see + tendermint_bech32DecodeChecked()). Check well-formedness with bounded + buffers instead. */ + if (!tendermint_bech32IsWellFormed(receiver)) { return false; } @@ -503,6 +541,17 @@ bool tendermint_signTxUpdateMsgIBCTransfer( return false; } + /* The sender must be THIS signer. + * + * from_address was derived here and then thrown away: the JSON below writes + * the host-supplied `sender` verbatim, and no screen displayed it. So an + * arbitrary sender survived every approval and produced a signed IBC message + * naming an account that cannot authorize it. There is exactly one account + * this session can act as; require the host to name it. */ + if (!sender || strcmp(sender, from_address) != 0) { + return false; + } + if (has_message) { sha256_Update(&ctx, (uint8_t*)",", 1); } @@ -582,6 +631,17 @@ bool tendermint_signTxFinalize(uint8_t* public_key, uint8_t* signature) { NULL) == 0; } +/* The account this session signs as, under `chain_prefix`. Handlers use it to + refuse a mismatched `sender` BEFORE any screen opens, rather than letting the + serializer refuse it after the approval. */ +bool tendermint_addressIsSigner(const char* address, const char* chain_prefix) { + if (!address || !chain_prefix) return false; + + char expected[54] = {0}; + if (!tendermint_getAddress(&node, chain_prefix, expected)) return false; + return strcmp(address, expected) == 0; +} + bool tendermint_signingIsInited(TendermintSigningType type) { return initialized && signing_type == type; } diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index 378560d15..d67254390 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -799,17 +799,34 @@ void solana_formatTokenAmount(char* buf, size_t len, uint64_t amount, 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; +} + 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 = 1400000u; + 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++; + } if (pi->type == SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE) { if (seen_price) return false; seen_price = true; @@ -820,6 +837,21 @@ bool solana_calculatePriorityFee(const SolanaParsedTx* tx, uint64_t* fee_out, limit = pi->extra_value; } } + + 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; + } + if (!seen_price || price == 0) return true; uint64_t whole = price / divisor; @@ -854,8 +886,7 @@ bool solana_signTx(const HDNode* node, const SolanaSignTx* msg, /* 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, node->public_key + 1, - sig); + ed25519_sign(message, message_len, node->private_key, sig); resp->has_signature = true; resp->signature.size = SOL_SIG_SIZE; @@ -928,7 +959,7 @@ bool solana_offchain_message_sign(const HDNode* node, off += msg->message.size; uint8_t sig[SOL_SIG_SIZE]; - ed25519_sign(envelope, off, node->private_key, node->public_key + 1, sig); + ed25519_sign(envelope, off, node->private_key, sig); resp->has_public_key = true; resp->public_key.size = SOL_PUBKEY_SIZE; diff --git a/lib/firmware/storage.c b/lib/firmware/storage.c index 91cbf26a2..abeb2b3f1 100644 --- a/lib/firmware/storage.c +++ b/lib/firmware/storage.c @@ -41,12 +41,15 @@ #include "keepkey/board/memory.h" #include "keepkey/board/util.h" #include "keepkey/board/variant.h" +#include "keepkey/firmware/authenticator.h" #include "keepkey/firmware/fsm.h" #include "keepkey/firmware/passphrase_sm.h" #include "keepkey/firmware/policy.h" #include "keepkey/firmware/reset.h" +#include "keepkey/firmware/signing.h" #include "keepkey/firmware/u2f.h" #include "keepkey/rand/rng.h" +#include "keepkey/rand/rng_health.h" #include "keepkey/transport/interface.h" #include "trezor/crypto/aes/aes.h" #include "trezor/crypto/bip32.h" @@ -87,10 +90,38 @@ static SessionState CONFIDENTIAL session; static Allocation storage_location = FLASH_INVALID; /* Shadow memory for configuration data in storage partition */ +_Static_assert(STORAGE_VERSION >= STORAGE_VERSION_LAST_SHIPPED, + "STORAGE_VERSION went below a version that already shipped; " + "every device in the field would read its blob as an unknown " + "future format and wipe. Raise the version, do not lower the " + "floor."); + _Static_assert(sizeof(ConfigFlash) <= FLASH_STORAGE_LEN, "ConfigFlash struct is too large for storage partition"); static ConfigFlash CONFIDENTIAL shadow_config; +/* This firmware found storage in flash it must refuse to load or overwrite + * until the user explicitly wipes: a bitcoin-only wallet seen by multi-chain + * firmware, or (on bitcoin-only firmware) a newer in-band wallet than this + * build understands. Set from the SUS_BitcoinOnlyLocked path in either build. + */ +static bool btc_only_locked = false; + +bool storage_isBitcoinOnlyLocked(void) { return btc_only_locked; } + +// Stamp a newly-created seed into the reserved bitcoin-only version band so +// multi-chain firmware refuses it (see storage_fromFlash). Called only from +// seed-creation paths, so a pre-existing multi-chain wallet migrated under +// bitcoin-only firmware keeps its normal, portable version. No-op (but still +// referenced, so no -Wunused) in multi-chain builds. +#if BITCOIN_ONLY +static void storage_stampBitcoinOnlySeed(void) { + shadow_config.storage.version = STORAGE_VERSION_BTC_ONLY; +} +#else +static void storage_stampBitcoinOnlySeed(void) {} +#endif + #if DEBUG_LINK // These won't survive resets like the stuff in flash would, but thats a // reasonable compromise given how testing works. @@ -189,8 +220,14 @@ enum StorageVersion { StorageVersion_NONE, #define STORAGE_VERSION_ENTRY(VAL) StorageVersion_##VAL, #include "storage_versions.inc" + StorageVersion_BTC_ONLY, // reserved band, never in storage_versions.inc }; +// The normal storage version must stay below the bitcoin-only band, or a +// bitcoin-only wallet would become loadable by multi-chain firmware. +_Static_assert(STORAGE_VERSION < STORAGE_VERSION_BTC_ONLY_BASE, + "storage version must stay below the bitcoin-only band"); + static enum StorageVersion version_from_int(int version) { #define STORAGE_VERSION_LAST(VAL) \ _Static_assert(VAL == STORAGE_VERSION, \ @@ -198,6 +235,12 @@ static enum StorageVersion version_from_int(int version) { "storage_versions.inc"); #include "storage_versions.inc" + // Any version in the reserved bitcoin-only band maps here regardless of + // build; storage_fromFlash decides load-vs-refuse from the exact value, so + // an in-band firmware downgrade refuses rather than silently wiping a newer + // bitcoin-only wallet. + if (version >= STORAGE_VERSION_BTC_ONLY_BASE) return StorageVersion_BTC_ONLY; + switch (version) { #define STORAGE_VERSION_ENTRY(VAL) \ case VAL: \ @@ -449,6 +492,27 @@ pintest_t storage_isWipeCodeCorrect_impl(const char* wipe_code, return ret; } +/* 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: + * 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 + * 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. + * + * These call sites are void and have no way to report a failure, so they halt + * -- the same disposition storage_secMigrate() takes when secrets fail to + * decrypt. The halt lives here rather than in kkrand because kkrand must not + * reference kkboard: GNU ld resolves static archives in one left-to-right + * pass and kkboard is listed first everywhere, so a UI call from that library + * fails to link in crypto-unit and board-unit. */ +static void storage_drawKeyMaterial(uint8_t* buf, size_t len) { + if (random_buffer_checked(buf, len)) return; + layout_warning_static("RNG self-test failed. Reboot device!"); + shutdown(); +} + void storage_secMigrate(SessionState* ss, Storage* storage, bool encrypt) { static CONFIDENTIAL char scratch[V17_ENCSEC_SIZE]; _Static_assert(sizeof(scratch) == sizeof(storage->encrypted_sec), @@ -767,7 +831,7 @@ 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"); - random_buffer(storage->pub.random_salt, 32); + storage_drawKeyMaterial(storage->pub.random_salt, 32); storage->has_sec = true; @@ -1157,8 +1221,10 @@ StorageUpdateStatus storage_fromFlash(SessionState* ss, ConfigFlash* dst, const char* flash) { memzero(dst, sizeof(*dst)); - // Load config values from active config node. - enum StorageVersion version = version_from_int(read_u32_le(flash + 44)); + // Load config values from active config node. The raw value is kept because + // the bitcoin-only arm needs the exact stored number, not its classification. + uint32_t raw_version = read_u32_le(flash + 44); + enum StorageVersion version = version_from_int(raw_version); switch (version) { case StorageVersion_1: @@ -1208,6 +1274,41 @@ StorageUpdateStatus storage_fromFlash(SessionState* ss, ConfigFlash* dst, dst->storage.version = STORAGE_VERSION; return dst->storage.version == version ? SUS_Valid : SUS_Updated; + case StorageVersion_BTC_ONLY: +#if BITCOIN_ONLY + { + // Our own bitcoin-only wallet. The stored wire version is the multi-chain + // storage version plus the band base, so recover the underlying layout + // version and load it through the normal migration chain. Exact-matching + // STORAGE_VERSION_BTC_ONLY here would lock every existing bitcoin-only + // wallet out of its own firmware on the next STORAGE_VERSION bump. + uint32_t underlying = raw_version - STORAGE_VERSION_BTC_ONLY_BASE; + if (underlying > (uint32_t)STORAGE_VERSION) { + // A newer bitcoin-only wallet than this firmware understands: refuse + // rather than wipe, so a firmware downgrade never destroys it. + return SUS_BitcoinOnlyLocked; + } + // Read via the reader matching the underlying version (same mapping as + // the multi-chain arms above), then keep the band stamp so multi-chain + // firmware still refuses it. + if (underlying <= 15) { + storage_readV11(dst, flash, STORAGE_SECTOR_LEN); + } else if (underlying == 16) { + storage_readV16(dst, flash, STORAGE_SECTOR_LEN); + } else { + storage_readV17(dst, flash, STORAGE_SECTOR_LEN); + } + dst->storage.version = STORAGE_VERSION_BTC_ONLY; + return (underlying == (uint32_t)STORAGE_VERSION) ? SUS_Valid + : SUS_Updated; + } +#else + // Written by bitcoin-only firmware: refuse to load. The wallet stays + // intact in flash (reflash bitcoin-only firmware to recover it); using + // multi-chain firmware requires an explicit wipe. + return SUS_BitcoinOnlyLocked; +#endif + case StorageVersion_NONE: return SUS_Invalid; @@ -1345,6 +1446,20 @@ void storage_init(void) { // that it's available on next boot without conversion. storage_commit(); break; + case SUS_BitcoinOnlyLocked: + // Bitcoin-only wallet in flash: act as an uninitialized, locked device. + // Do NOT commit -- flash stays untouched so reflashing bitcoin-only + // firmware recovers the wallet; leaving requires an explicit wipe. + btc_only_locked = true; + storage_reset(); + // storage_fromFlash() memzeroed the WHOLE shadow config, meta included, + // and storage_reset() clears only .storage. Every loading arm restores + // the metadata via storage_readMeta(); this arm returns before reaching + // one, so without this the device reports an EMPTY device_id -- the same + // empty id on every locked device, which silently merges distinct + // devices in any host keyed on it. The sector is known active here. + storage_readMeta(&shadow_config.meta, flash, STORAGE_SECTOR_LEN); + break; } if (!storage_hasPin()) { @@ -1391,6 +1506,9 @@ void storage_wipe(void) { flash_erase_word(FLASH_STORAGE1); flash_erase_word(FLASH_STORAGE2); flash_erase_word(FLASH_STORAGE3); + + // The bitcoin-only wallet (if any) is gone; the device may be used freely. + btc_only_locked = false; } void storage_clearKeys(void) { @@ -1406,6 +1524,13 @@ 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(); if (PIN_REWRAP == session_clear_impl(&session, &shadow_config.storage, clear_pin)) { storage_commit(); @@ -1473,6 +1598,10 @@ void storage_commit(void) { * storage, so this cannot recurse. */ 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). + 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]; @@ -1637,6 +1766,10 @@ void storage_loadDevice(LoadDevice* msg) { memset(&session.seed, 0, sizeof(session.seed)); } + if (msg->has_node || msg->has_mnemonic) { + storage_stampBitcoinOnlySeed(); + } + if (msg->has_language) { storage_setLanguage(msg->language); } @@ -1748,7 +1881,7 @@ void storage_setPin_impl(SessionState* ss, Storage* storage, const char* pin) { _("Encrypting Secrets")); // Derive a new storageKey. - random_buffer(ss->storageKey, 64); + storage_drawKeyMaterial(ss->storageKey, 64); // Wrap the new storageKey. storage_wrapStorageKey(wrapping_key, ss->storageKey, @@ -1808,7 +1941,7 @@ void storage_setWipeCode_impl(SessionState* ss, Storage* storage, _("Updating Wipe Code")); // Derive a new wipe code key . - random_buffer(scratch_key, 64); + storage_drawKeyMaterial(scratch_key, 64); // Wrap the new wipe code key. storage_wrapStorageKey(wrapping_key, scratch_key, @@ -2007,6 +2140,7 @@ void storage_setMnemonicFromWords(const char (*words)[12], shadow_config.storage.pub.has_mnemonic = true; shadow_config.storage.has_sec = true; + storage_stampBitcoinOnlySeed(); storage_compute_u2froot(&session, shadow_config.storage.sec.mnemonic, &shadow_config.storage.pub.u2froot); @@ -2024,6 +2158,7 @@ void storage_setMnemonic(const char* m) { #endif shadow_config.storage.pub.has_mnemonic = true; shadow_config.storage.has_sec = true; + storage_stampBitcoinOnlySeed(); storage_compute_u2froot(&session, shadow_config.storage.sec.mnemonic, &shadow_config.storage.pub.u2froot); diff --git a/lib/firmware/storage.h b/lib/firmware/storage.h index 10b84217b..fba52b872 100644 --- a/lib/firmware/storage.h +++ b/lib/firmware/storage.h @@ -185,6 +185,9 @@ typedef enum { SUS_Invalid, SUS_Valid, SUS_Updated, + /// Storage was written by bitcoin-only firmware and this build must not load + /// it. The wallet stays INTACT in flash -- this is a refusal, never a wipe. + SUS_BitcoinOnlyLocked, } StorageUpdateStatus; /// \brief Copy configuration from storage partition in flash memory to shadow diff --git a/lib/firmware/tendermint.c b/lib/firmware/tendermint.c index a1f87ebe4..f975571f7 100644 --- a/lib/firmware/tendermint.c +++ b/lib/firmware/tendermint.c @@ -2,6 +2,7 @@ #include "keepkey/firmware/fsm.h" #include "trezor/crypto/segwit_addr.h" +#include "trezor/crypto/memzero.h" #include "trezor/crypto/sha2.h" #include @@ -74,31 +75,157 @@ bool tendermint_validateSafeText(const char* value) { return true; } +/* A Tendermint account address is the 20-byte RIPEMD-160 of the public key, + carried as base32: 20 * 8 / 5 == 32 five-bit groups. */ +#define TENDERMINT_ACCOUNT_ADDRESS_GROUPS 32 + +/* The longest bech32 string this firmware will look at. The widest address + field any protobuf message can deliver is 52 characters (Cosmos and Osmosis + `receiver`/`to_address`, max_size 53); 90 is the bech32 spec's own limit and + leaves room without inviting a larger buffer. */ +#define TENDERMINT_BECH32_MAX_INPUT 90 + +/* Decode into buffers big enough for anything bech32_decode() can write. + * + * bech32_decode() takes NO capacity argument. It writes one byte per data + * character into `data`, and its header states the required size as + * strlen(input) - 8; the only length it ever rejects is an HRP longer than + * BECH32_MAX_HRP_LEN, which is 83. Every call site in this firmware declared + * `char hrp[45]` and `uint8_t decoded[38]`, so both buffers were undersized + * against what a host can send: + * + * - an address whose HRP is 45..83 characters overwrites `hrp`; + * - a 52-character address with a short HRP yields 44 groups and overwrites + * `decoded`. + * + * Both are stack overwrites with host-chosen content, reachable directly from + * a signing message. Gate the length first, then decode into buffers sized for + * the worst case that survives that gate. */ +static bool tendermint_bech32DecodeChecked(const char* address, char* hrp_out, + size_t* groups) { + if (!address) return false; + const size_t len = strnlen(address, TENDERMINT_BECH32_MAX_INPUT + 1); + if (len < 8 || len > TENDERMINT_BECH32_MAX_INPUT) return false; + + uint8_t decoded[TENDERMINT_BECH32_MAX_INPUT] = {0}; + size_t decoded_len = 0; + if (bech32_decode(hrp_out, decoded, &decoded_len, address) != + BECH32_ENCODING_BECH32) { + memzero(decoded, sizeof(decoded)); + return false; + } + memzero(decoded, sizeof(decoded)); + if (groups) *groups = decoded_len; + return true; +} + +/* Well-formedness only: correct charset, length and checksum, nothing about + which network the address belongs to. For the one case where an arbitrary + HRP is the point -- an IBC receiver on a counterparty chain this device has + no prefix for. Everywhere the network IS known, use + tendermint_validateBech32Address() instead. */ +bool tendermint_bech32IsWellFormed(const char* address) { + char hrp[BECH32_MAX_HRP_LEN + 1] = {0}; + return tendermint_bech32DecodeChecked(address, hrp, NULL); +} + bool tendermint_validateBech32Address(const char* address, const char* expected_prefix) { if (!address || !expected_prefix || expected_prefix[0] == '\0') return false; - char hrp[84] = {0}; - uint8_t decoded[65] = {0}; + char hrp[BECH32_MAX_HRP_LEN + 1] = {0}; size_t decoded_len = 0; - return bech32_decode(hrp, decoded, &decoded_len, address) == 1 && - strcmp(hrp, expected_prefix) == 0; + if (!tendermint_bech32DecodeChecked(address, hrp, &decoded_len)) return false; + if (strcmp(hrp, expected_prefix) != 0) return false; + + /* A correct checksum and the right HRP still do not make it an ACCOUNT + address. bech32_decode() hands back the raw five-bit groups and succeeds + for any payload length, so a validator-operator address, a longer module + address, or an arbitrary blob under the same prefix all passed -- and the + THORChain and MAYAChain deposit paths accept the result as a signer. + tendermint_getAddress() above encodes a 20-byte RIPEMD-160 hash, which is + exactly 20 * 8 / 5 == 32 groups; require the same of anything the device + is asked to treat as an account. */ + return decoded_len == TENDERMINT_ACCOUNT_ADDRESS_GROUPS; +} + +/* A validator OPERATOR address: the same 20-byte account payload as a normal + address, under the "valoper" prefix rather than "". + + Every MsgDelegate / MsgUndelegate / MsgBeginRedelegate / MsgWithdrawReward + serializer interpolates these with a bare "%s" into the document it hashes, + exactly as it does the delegator -- but only the delegator was ever checked. + A wrong-network operator, a plain account address where an operator belongs, + or a value carrying JSON punctuation therefore reached the signed bytes. */ +bool tendermint_validateValidatorAddress(const char* address, + const char* chain_prefix) { + if (!address || !chain_prefix || chain_prefix[0] == '\0') return false; + + char expected[BECH32_MAX_HRP_LEN + 1]; + const int n = snprintf(expected, sizeof(expected), "%svaloper", chain_prefix); + if (n < 0 || (size_t)n >= sizeof(expected)) return false; + + return tendermint_validateBech32Address(address, expected); } void tendermint_sha256UpdateEscaped(SHA256_CTX* ctx, const char* s, size_t len) { + static const char kHexDigits[] = "0123456789abcdef"; + for (size_t i = 0; i != len; i++) { - if (s[i] == '"') { + const uint8_t c = (uint8_t)s[i]; + + if (c == '"') { sha256_Update(ctx, (const uint8_t*)"\\\"", 2); - } else if (s[i] == '\\') { + } else if (c == '\\') { sha256_Update(ctx, (const uint8_t*)"\\\\", 2); + } else if (c < 0x20) { + /* RFC 8259 forbids a raw byte below 0x20 inside a JSON string, but this + escaper only ever handled the quote and the backslash and passed every + control byte through untouched. Memos are not run through + tendermint_validateSafeText(), so a host-supplied newline or tab was + hashed verbatim: the device signed a document that is not valid JSON, + after showing the owner a screen whose layout that same newline had + already altered. + + Emit the escapes the format requires -- the five short forms, then + \u00XX for the rest. Only memos that already produced invalid JSON + change shape here; anything a chain would have accepted hashes exactly + as before. */ + switch (c) { + case '\b': + sha256_Update(ctx, (const uint8_t*)"\\b", 2); + break; + case '\f': + sha256_Update(ctx, (const uint8_t*)"\\f", 2); + break; + case '\n': + sha256_Update(ctx, (const uint8_t*)"\\n", 2); + break; + case '\r': + sha256_Update(ctx, (const uint8_t*)"\\r", 2); + break; + case '\t': + sha256_Update(ctx, (const uint8_t*)"\\t", 2); + break; + default: { + const uint8_t esc[6] = {'\\', + 'u', + '0', + '0', + (uint8_t)kHexDigits[(c >> 4) & 0x0f], + (uint8_t)kHexDigits[c & 0x0f]}; + sha256_Update(ctx, esc, sizeof(esc)); + break; + } + } } else { // The copy here is required (as opposed to a cast), since the // source is a character array, and sha256_Update uses it as if it // were an array of uint8_t, which would violate the strict aliasing // rule. - const uint8_t c = s[i]; - sha256_Update(ctx, &c, 1); + const uint8_t b = c; + sha256_Update(ctx, &b, 1); } } } diff --git a/lib/firmware/thorchain.c b/lib/firmware/thorchain.c index b36b8b335..1c1fe7b95 100644 --- a/lib/firmware/thorchain.c +++ b/lib/firmware/thorchain.c @@ -125,13 +125,6 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, const char* pfix; char buffer[64 + 1]; - size_t decoded_len; - char hrp[45]; - uint8_t decoded[38]; - if (!bech32_decode(hrp, decoded, &decoded_len, to_address)) { - return false; - } - char from_address[46]; pfix = mainnetp; @@ -139,6 +132,18 @@ bool thorchain_signTxUpdateMsgSend(const uint64_t amount, pfix = testnetp; } + /* Validate the recipient against THIS network's prefix and the 20-byte + account length, before it reaches the bare "%s" JSON serialization below. + This used to be a bare bech32_decode() into hrp[45]/decoded[38], which + both overflowed on host-chosen input and checked neither the network nor + the payload length -- so a wrong-chain address, a module or operator + address, or a punctuation-bearing HRP all passed straight into the signed + document. Select the prefix first so there is something to check against. + */ + if (!tendermint_validateBech32Address(to_address, pfix)) { + return false; + } + if (!tendermint_getAddress(&node, pfix, from_address)) { return false; } @@ -231,6 +236,22 @@ bool thorchain_signTxFinalize(uint8_t* public_key, uint8_t* signature) { NULL) == 0; } +/* The account this session's key signs as. + * + * MsgDeposit's `signer` is serialized verbatim as the message authority, so a + * merely well-formed thor/maya address let the device sign a document for an + * account it cannot represent -- and the confirmation labels that address as + * though it were a destination. There is exactly one authority a session can + * act as; require the host to name it. */ +bool thorchain_addressIsSigner(const char* address) { + if (!initialized || !address) return false; + + char expected[46] = {0}; + if (!tendermint_getAddress(&node, testnet ? "tthor" : "thor", expected)) + return false; + return strcmp(address, expected) == 0; +} + bool thorchain_signingIsInited(void) { return initialized; } bool thorchain_signingIsFinished(void) { @@ -271,6 +292,49 @@ static bool thorchain_memo_has_empty_component(const char* memo, size_t size) { return false; } +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. */ + if (!memo || size == 0) return false; + + size_t field = 0; + size_t dots_total = 0; + size_t dots_in_asset_field = 0; + + 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++; + } + } + + return dots_total == 1 && dots_in_asset_field == 1; +} + static bool thorchain_memo_is_structured_text(const char* memo, size_t size) { if (!memo || size == 0) return false; @@ -331,7 +395,8 @@ ThorchainMemoResult thorchain_parseConfirmMemo(const char* swapStr, if (size > THORCHAIN_MEMO_MAX || thorchain_memo_has_empty_component(swapStr, size) || - !thorchain_memo_is_structured_text(swapStr, size)) { + !thorchain_memo_is_structured_text(swapStr, size) || + !thorchain_memo_has_canonical_separators(swapStr, size)) { return THORCHAIN_MEMO_UNPARSED; } memzero(memoBuf, sizeof(memoBuf)); diff --git a/lib/firmware/ton.c b/lib/firmware/ton.c index 66d4bedf4..25fb5d787 100644 --- a/lib/firmware/ton.c +++ b/lib/firmware/ton.c @@ -242,7 +242,7 @@ bool ton_signTx(const HDNode* node, const TonSignTx* msg, TonSignedTx* resp) { // Ed25519 sign the transaction ed25519_signature signature; ed25519_sign(msg->raw_tx.bytes, msg->raw_tx.size, node->private_key, - &node->public_key[1], signature); + signature); // Copy signature to response (64 bytes) resp->has_signature = true; @@ -275,7 +275,7 @@ bool ton_message_sign(const HDNode* node, const TonSignMessage* msg, ed25519_signature signature; ed25519_sign(msg->message.bytes, msg->message.size, node->private_key, - &node->public_key[1], signature); + signature); resp->has_public_key = true; resp->public_key.size = 32; diff --git a/lib/firmware/transaction.c b/lib/firmware/transaction.c index eeda2473f..33ca0662d 100644 --- a/lib/firmware/transaction.c +++ b/lib/firmware/transaction.c @@ -32,6 +32,7 @@ #include "keepkey/transport/interface.h" #include "trezor/crypto/address.h" #include "trezor/crypto/base58.h" +#include "trezor/crypto/bip340.h" #include "trezor/crypto/cash_addr.h" #include "trezor/crypto/ecdsa.h" #include "trezor/crypto/memzero.h" @@ -43,6 +44,7 @@ #define _(X) (X) #define SEGWIT_VERSION_0 0 +#define SEGWIT_VERSION_1 1 #define CASHADDR_P2KH (0) #define CASHADDR_P2SH (8) @@ -113,6 +115,95 @@ uint32_t op_push(uint32_t i, uint8_t* out) { return 5; } +bool address_to_script_pubkey(const CoinType* coin, const char* address, + uint8_t* script_pubkey, size_t* script_pubkey_len, + size_t script_pubkey_size) { + uint8_t addr_raw[MAX_ADDR_RAW_SIZE]; + size_t addr_raw_len; + int witver; + + const curve_info* curve = get_curve_by_name(coin->curve_name); + if (!curve) return false; + + // Deliberately narrower than compile_output's output-side decoding: this is + // only reached for inputs of a coin with taproot enabled, i.e. Bitcoin and + // Testnet, so cashaddr and the BCH burn warning do not apply. Kept separate + // rather than factored out of compile_output because that block interleaves + // confirm-and-cancel UX with the script building. + if (coin->has_bech32_prefix && + segwit_addr_decode(&witver, addr_raw, &addr_raw_len, coin->bech32_prefix, + address)) { + // push witness version (OP_0 = 0, OP_i = 80 + i), then the program + if (addr_raw_len + 2 > script_pubkey_size) { + return false; + } + script_pubkey[0] = witver == 0 ? 0 : 80 + witver; + script_pubkey[1] = addr_raw_len; + memcpy(script_pubkey + 2, addr_raw, addr_raw_len); + *script_pubkey_len = addr_raw_len + 2; + return true; + } + + addr_raw_len = base58_decode_check(address, curve->hasher_base58, addr_raw, + MAX_ADDR_RAW_SIZE); + + if (coin->has_address_type && + addr_raw_len == 20 + address_prefix_bytes_len(coin->address_type) && + address_check_prefix(addr_raw, coin->address_type)) { + if (25 > script_pubkey_size) { + return false; + } + script_pubkey[0] = 0x76; // OP_DUP + script_pubkey[1] = 0xA9; // OP_HASH_160 + script_pubkey[2] = 0x14; // pushing 20 bytes + memcpy(script_pubkey + 3, + addr_raw + address_prefix_bytes_len(coin->address_type), 20); + script_pubkey[23] = 0x88; // OP_EQUALVERIFY + script_pubkey[24] = 0xAC; // OP_CHECKSIG + *script_pubkey_len = 25; + return true; + } + + if (coin->has_address_type_p2sh && + addr_raw_len == 20 + address_prefix_bytes_len(coin->address_type_p2sh) && + address_check_prefix(addr_raw, coin->address_type_p2sh)) { + if (23 > script_pubkey_size) { + return false; + } + script_pubkey[0] = 0xA9; // OP_HASH_160 + script_pubkey[1] = 0x14; // pushing 20 bytes + memcpy(script_pubkey + 2, + addr_raw + address_prefix_bytes_len(coin->address_type_p2sh), 20); + script_pubkey[22] = 0x87; // OP_EQUAL + *script_pubkey_len = 23; + return true; + } + + return false; +} + +bool fill_input_script_pubkey(const CoinType* coin, const HDNode* root, + const TxInputType* in, uint8_t* script_pubkey, + size_t* script_pubkey_len, + size_t script_pubkey_size) { + static CONFIDENTIAL HDNode node; + char address[MAX_ADDR_SIZE] = {0}; + bool res; + + memcpy(&node, root, sizeof(HDNode)); + res = hdnode_private_ckd_cached(&node, in->address_n, in->address_n_count, + NULL) != 0; + if (res) { + hdnode_fill_public_key(&node); // returns void in this tree + } + res = res && compute_address(coin, in->script_type, &node, in->has_multisig, + &in->multisig, address); + memzero(&node, sizeof(node)); + + return res && address_to_script_pubkey(coin, address, script_pubkey, + script_pubkey_len, script_pubkey_size); +} + bool compute_address(const CoinType* coin, InputScriptType script_type, const HDNode* node, bool has_multisig, const MultisigRedeemScriptType* multisig, @@ -125,6 +216,11 @@ bool compute_address(const CoinType* coin, InputScriptType script_type, if (has_multisig) { size_t prelen; + // No taproot multisig. Without this the request would fall through to + // the p2sh branch below and hand back a p2sh address for a taproot ask. + if (script_type == InputScriptType_SPENDTAPROOT) { + return 0; + } if (cryptoMultisigPubkeyIndex(coin, multisig, node->public_key) < 0) { return 0; } @@ -186,9 +282,28 @@ bool compute_address(const CoinType* coin, InputScriptType script_type, return 0; } } else if (script_type == InputScriptType_SPENDTAPROOT) { - // we don't handle spendtaproot input types - return 0; - + // p2tr: the witness program is the BIP-86 tweaked output key, bech32m + // encoded at witness version 1. + if ((!coin->has_segwit || !coin->segwit) || !coin->has_bech32_prefix) { + return 0; + } + if (!coin->has_taproot || !coin->taproot) { + return 0; + } + uint8_t output_key[32]; + // node->public_key is compressed; bytes 1..33 are the x-only internal key. + // BIP-341 defines the internal key as x-only, so the odd-y case resolves + // to its even-y counterpart here and in the signer alike. + if (bip340_tweak_pubkey(curve->params, node->public_key + 1, + /*merkle_root=*/NULL, output_key) != 0) { + return 0; + } + // Exactly 32 bytes: segwit_addr_encode only length-checks the witness + // program for version 0, so a wrong length would encode silently. + if (!segwit_addr_encode(address, coin->bech32_prefix, SEGWIT_VERSION_1, + output_key, sizeof(output_key))) { + return 0; + } } else if (script_type == InputScriptType_SPENDP2SHWITNESS) { // segwit p2wpkh embedded in p2sh if (!coin->has_segwit || !coin->segwit) { @@ -238,6 +353,7 @@ int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, } } else { // is this thorchain data? +#if !BITCOIN_ONLY ThorchainMemoResult memo_result = thorchain_parseConfirmMemo((const char*)in->op_return_data.bytes, (size_t)in->op_return_data.size); @@ -253,6 +369,16 @@ int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, return -1; // user aborted } } +#else + // Bitcoin-only decodes no THORChain memo, so there is no friendly + // screen to show. Fall back to confirming the raw OP_RETURN payload: + // the bytes still have to be approved, they are just not interpreted. + if (!confirm_data(ButtonRequestType_ButtonRequest_ConfirmOutput, + _("Confirm OP_RETURN"), in->op_return_data.bytes, + in->op_return_data.size)) { + return -1; // user aborted + } +#endif } } uint32_t r = 0; @@ -283,6 +409,9 @@ int compile_output(const CoinType* coin, const HDNode* root, TxOutputType* in, case OutputScriptType_PAYTOP2SHWITNESS: input_script_type = InputScriptType_SPENDP2SHWITNESS; break; + case OutputScriptType_PAYTOTAPROOT: + input_script_type = InputScriptType_SPENDTAPROOT; + break; default: return 0; // failed to compile output } @@ -488,14 +617,19 @@ uint32_t compile_script_sig(uint32_t address_type, const uint8_t* pubkeyhash, } // if out == NULL just compute the length +bool 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 <= n && n <= 15; +} + uint32_t compile_script_multisig(const CoinType* coin, const MultisigRedeemScriptType* multisig, uint8_t* out) { - if (!multisig->has_m) return 0; + if (!multisig_quorum_is_valid(multisig)) return 0; const uint32_t m = multisig->m; const uint32_t n = multisig->pubkeys_count; - if (m < 1 || m > 15) return 0; - if (n < 1 || n > 15) return 0; uint32_t r = 0; if (out) { out[r] = 0x50 + m; @@ -522,11 +656,9 @@ 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->has_m) return 0; + if (!multisig_quorum_is_valid(multisig)) return 0; const uint32_t m = multisig->m; const uint32_t n = multisig->pubkeys_count; - if (m < 1 || m > 15) return 0; - if (n < 1 || n > 15) return 0; const curve_info* curve = get_curve_by_name(coin->curve_name); if (!curve) return 0; @@ -987,6 +1119,9 @@ uint32_t tx_input_weight(const CoinType* coin, const TxInputType* txinput) { weight += 4; // empty input script } weight += input_script_size; // discounted witness + } else if (txinput->script_type == InputScriptType_SPENDTAPROOT) { + weight += 4; // empty scriptSig length in the non-witness serialization + weight += 2 + TXSIZE_SCHNORR_SIGNATURE; // stack count, item length, sig } return weight; } diff --git a/lib/firmware/txin_check.c b/lib/firmware/txin_check.c index a2d0430d7..fe01d8077 100644 --- a/lib/firmware/txin_check.c +++ b/lib/firmware/txin_check.c @@ -36,13 +36,19 @@ static char last_amount_str[AMT_STR_LEN]; /* spend value of last tx */ static char last_addr_str[ADDR_STR_LEN]; /* last spend-to address */ static SHA256_CTX txin_hash_ctx; +// Reset only the in-progress digest, preserving the prior transaction used by +// the duplicate-output warning. +void txin_dgst_reset_current(void) { + memzero(txin_current_digest, SHA256_DIGEST_LENGTH); + sha256_Init(&txin_hash_ctx); +} + // initialize the txin digest machine void txin_dgst_initialize(void) { - memzero(txin_current_digest, SHA256_DIGEST_LENGTH); memzero(txin_last_digest, SHA256_DIGEST_LENGTH); memzero(last_amount_str, AMT_STR_LEN); memzero(last_addr_str, ADDR_STR_LEN); - sha256_Init(&txin_hash_ctx); + txin_dgst_reset_current(); return; } @@ -89,7 +95,6 @@ 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); memcpy(last_addr_str, addr_str, ADDR_STR_LEN); - memzero(txin_current_digest, SHA256_DIGEST_LENGTH); - sha256_Init(&txin_hash_ctx); + txin_dgst_reset_current(); return; } diff --git a/lib/firmware/u2f.c b/lib/firmware/u2f.c index 34a8971f5..64bf8aa0e 100644 --- a/lib/firmware/u2f.c +++ b/lib/firmware/u2f.c @@ -35,6 +35,7 @@ #include "keepkey/firmware/storage.h" #include "keepkey/firmware/u2f/u2f.h" #include "keepkey/firmware/u2f/u2f_keys.h" +#include "keepkey/rand/rng_health.h" #include "trezor/crypto/bip39.h" #include "trezor/crypto/bip39_english.h" #include "trezor/crypto/ecdsa.h" @@ -524,10 +525,18 @@ static const HDNode* generateKeyHandle(const uint8_t app_id[], uint8_t keybase[U2F_APPID_SIZE + KEY_PATH_LEN]; // Derivation path is m/U2F'/r'/r'/r'/r'/r'/r'/r'/r' + // + // The path IS the secret here -- the key handle is public and an attacker who + // can predict the path derives the credential -- so it draws through the RNG + // gate rather than random32(). Registration fails rather than minting a + // credential on an untrusted generator. uint32_t key_path[KEY_PATH_ENTRIES]; + if (!random_buffer_checked((uint8_t*)key_path, sizeof(key_path))) { + debugLog(0, "", "ERR: RNG self-test failed"); + return NULL; + } for (uint32_t i = 0; i < KEY_PATH_ENTRIES; i++) { - // high bit for hardened keys - key_path[i] = 0x80000000 | random32(); + key_path[i] |= 0x80000000; // high bit for hardened keys } // First half of keyhandle is key_path diff --git a/lib/rand/CMakeLists.txt b/lib/rand/CMakeLists.txt index 3c9e6eab1..ebfd2ce4e 100644 --- a/lib/rand/CMakeLists.txt +++ b/lib/rand/CMakeLists.txt @@ -1,5 +1,6 @@ set(sources - rng.c) + rng.c + rng_health.c) include_directories( ${CMAKE_SOURCE_DIR}/include diff --git a/lib/rand/rng.c b/lib/rand/rng.c index 6e5963793..4975e5a48 100644 --- a/lib/rand/rng.c +++ b/lib/rand/rng.c @@ -48,6 +48,33 @@ "RAND_PLATFORM_INDEPENDENT must be defined; otherwise trezor-crypto compiles its insecure test LCG" #endif +/* Software mirror of the hardware's sticky seed/clock error. + * + * RNG_SR_SEIS latches in hardware, but only until someone clears it -- and both + * random32() below and reset_rng() do, as they must to keep drawing. random32() + * runs constantly, so by the time a self-test reads RNG_SR the evidence of a + * transient fault is usually gone, and rng_source_live() was documented as + * catching exactly that case. Record it here instead, where it cannot be + * cleared by the recovery path that observed it. + * + * Boot-lifetime and one-way on purpose: a noise source that failed its own + * continuous test once is not trusted again until the device is power-cycled. + */ +static volatile bool rng_seed_error_seen = false; + +bool rng_seed_error_latched(void) { return rng_seed_error_seen; } + +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) { + rng_latch_seed_error(); + reset_rng(); +} +#endif + void reset_rng(void) { #ifndef EMULATOR /* disable RNG */ @@ -84,13 +111,19 @@ uint32_t random32(void) { new = RNG_DR; } } else if ((rng_sr_img & (RNG_SR_SECS | RNG_SR_CECS)) == 0) { - /* Reset RNG interrupt status bits (SECS, CECS errors no longer exist) */ + /* 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_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) { - /* RNG in hang state. Reset RNG */ + /* 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(); reset_rng(); rng_samples = 0; } @@ -107,6 +140,22 @@ uint32_t random32(void) { #endif } +#if defined(EMULATOR) && !defined(__APPLE__) +/* trezor-crypto declares random_buffer() as a weak symbol so platforms can + * supply their own. GNU/MinGW ld will NOT extract a weak definition from a + * static archive to satisfy a strong reference (fsm.c/reset.c/storage.c), + * which breaks the Linux .so and Windows .dll links. Provide a strong + * definition here — identical to trezor-crypto's, built on our random32(). + * macOS ld64 resolves the weak one fine, so it's left untouched there. */ +void random_buffer(uint8_t* buf, size_t len) { + uint32_t r = 0; + for (size_t i = 0; i < len; i++) { + if (i % 4 == 0) r = random32(); + buf[i] = (r >> ((i % 4) * 8)) & 0xff; + } +} +#endif + // I miss C++ templates sooo bad. #define RANDOM_PERMUTE(BUFF, COUNT) \ do { \ diff --git a/lib/rand/rng_health.c b/lib/rand/rng_health.c new file mode 100644 index 000000000..acba970aa --- /dev/null +++ b/lib/rand/rng_health.c @@ -0,0 +1,337 @@ +/* + * 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 . + */ + +/* Seed-time RNG self-test. + * + * WHAT THIS CATCHES, AND WHAT IT DOES NOT. Two different failures, two + * different checks, and the difference matters enough to state up front: + * + * rng_source_live() -- the generator is the STM32 hardware peripheral + * and that peripheral is enabled and producing. + * rng_health_analyze() -- the output is not stuck or degenerate. + * + * Neither one detects a *healthy-looking generator with a tiny seed*. That + * was the July 2026 COLDCARD failure: a board config left the hardware-RNG + * macro defined-but-zero, a software PRNG was substituted, and it passed + * every statistical test because that is what a CSPRNG does -- it was simply + * seeded with ~40 bits. Recovering a 40-bit seed pool from output requires + * searching it; by collision that is ~2^20 independent seedings. No amount of + * sampling substitutes. + * + * What stops that failure here is upstream, in rng.c: two #error guards that + * fail the *build* when the RNG source selection is wrong. rng_source_live() + * is the runtime backstop for a future configuration that gets past them -- + * on a build where the hardware path was never enabled, RNG_CR_RNGEN reads + * back clear and this refuses to produce a seed. + * + * The #ifndef EMULATOR below is not the kind of config macro this file exists + * to distrust. rng.c asserts `#if defined(EMULATOR) && defined(__arm__)` is a + * compile error, and __arm__ comes from the compiler's own target definition, + * so ARM firmware always compiles the register path. There is no build of the + * shipping firmware in which these checks are absent. + */ + +#include "keepkey/rand/rng.h" +#include "keepkey/rand/rng_health.h" + +#include + +#include "trezor/crypto/memzero.h" +#include "trezor/crypto/rand.h" + +#ifndef EMULATOR +#include +#include +#include +#endif + +bool rng_source_live(void) { +#ifndef EMULATOR + /* The peripheral clock being off reads back as a clear RNGEN, so this one + * test covers both "never enabled" and "enabled then lost its clock". */ + if ((RNG_CR & RNG_CR_RNGEN) == 0) { + return false; + } + + /* Seed/clock error, hardware latch AND software mirror. + * + * RNG_SR_SEIS latches in hardware, but random32() clears it whenever the + * underlying SECS/CECS condition has gone -- it has to, to keep drawing -- + * and random32() runs constantly. So the hardware bit alone does NOT make a + * transient fault visible here, which is what this check was documented as + * doing. rng_seed_error_latched() is the boot-lifetime software mirror, set + * at the moment the hardware latch is cleared and never cleared itself. */ + if ((RNG_SR & (RNG_SR_SEIS | RNG_SR_CEIS)) || rng_seed_error_latched()) { + return false; + } + + /* Fresh data, bounded wait. random32() spins forever by design; a self-test + * must be able to conclude "dead" rather than hang the device before the + * user has been told anything. + * + * SEIS/CEIS is re-read on every iteration, not just once before the loop: a + * seed or clock fault can latch while we are sampling, and a sample drawn + * after the fault must not be accepted merely because the register was clean + * when we started. */ + uint32_t first = 0; + bool have_first = false; + for (uint32_t tries = 0; tries < 100000; tries++) { + const uint32_t sr = RNG_SR; + if (sr & (RNG_SR_SEIS | RNG_SR_CEIS)) return false; + if (sr & RNG_SR_DRDY) { + uint32_t sample = RNG_DR; + if (!have_first) { + first = sample; + have_first = true; + } else if (sample != first) { + return true; + } + } + } + return false; +#else + /* Returning a bare `true` here made every caller's check a constant-folded + * always-false branch, which static analysis correctly flags. Draw from the + * same source the emulator actually uses and require it to vary: + * emulatorRandom() aborts on failure, so this is cheap, but it is a real + * check rather than an assertion, and it keeps the caller's branch meaningful + * in both builds. */ + uint32_t a = 0, b = 0; + random_buffer((uint8_t*)&a, sizeof(a)); + random_buffer((uint8_t*)&b, sizeof(b)); + return a != b; +#endif +} + +/* SP 800-90B 4.4.1, repetition count. + * + * Cutoff C = 1 + ceil(-log2(alpha) / H). With H = 8 bits per byte and + * alpha = 2^-30: 1 + ceil(30/8) = 5. Fail on five identical consecutive bytes. + * + * False positives on a good source: about len * 2^-32 for a run of five, i.e. + * ~2.4e-7 over a 1024-byte sample. This gate blocks wallet creation, so alpha + * sits at the strict end of NIST's 2^-20..2^-40 range: a spurious block is a + * support ticket and must stay rare enough never to become one. + * + * SP 800-90B 4.4.2, adaptive proportion. + * + * NIST's counter is initialised to 1 because it INCLUDES the window's + * reference sample. This implementation counts only the W-1 samples that + * FOLLOW the reference, so its cutoff is NIST's minus one, and the two must + * not be conflated -- an earlier revision of this file initialised the counter + * to 1 while using the following-matches cutoff, which failed a window at 15 + * following matches instead of 16 and gave alpha = 3.227e-9, roughly 3.5x + * looser than the 2^-30 it claimed. + * + * Exact tail, X ~ Binomial(W-1 = 511, p = 1/256): + * + * P(X >= 15) = 3.227e-9 P(X >= 16) = 3.891e-10 + * alpha = 2^-30 = 9.313e-10 + * + * so 16 following matches is the smallest cutoff meeting alpha. (Do not cite a + * cross-check against NIST's published table without restating the counter + * convention: under NIST's inclusive counter the same derivation gives 17 at + * alpha = 2^-30 and 14 at alpha = 2^-20.) + * + * Both tests run as STREAMING state so no sample buffer exists. The device has + * a 16 KiB reserve gate and a history of boot faults from large automatic + * buffers; a 1 KiB stack frame here is not worth a constant-space alternative. + */ + +void rng_health_init(RngHealthCtx* ctx) { + if (ctx == NULL) return; + memzero(ctx, sizeof(*ctx)); + ctx->ok = true; + ctx->rct_started = false; + ctx->apt_started = false; +} + +void rng_health_update(RngHealthCtx* ctx, const uint8_t* buf, size_t len) { + if (ctx == NULL || buf == NULL || !ctx->ok) return; + + for (size_t i = 0; i < len; i++) { + const uint8_t b = buf[i]; + ctx->total++; + + /* Repetition count: continuous over the whole stream, never reset by the + * APT window. A run straddling a window boundary must still fail. */ + if (!ctx->rct_started) { + ctx->rct_started = true; + ctx->rct_prev = b; + ctx->rct_run = 1; + } else if (b == ctx->rct_prev) { + if (++ctx->rct_run >= RNG_HEALTH_RCT_CUTOFF) { + ctx->ok = false; + return; + } + } else { + ctx->rct_prev = b; + ctx->rct_run = 1; + } + + /* Adaptive proportion: windowed, counting only samples FOLLOWING the + * window's reference sample. */ + if (!ctx->apt_started) { + ctx->apt_started = true; + ctx->apt_ref = b; + ctx->apt_following = 0; + ctx->apt_pos = 1; /* the reference occupies slot 0 of the window */ + continue; + } + + if (b == ctx->apt_ref && ++ctx->apt_following >= RNG_HEALTH_APT_CUTOFF) { + ctx->ok = false; + return; + } + + if (++ctx->apt_pos >= RNG_HEALTH_APT_WINDOW) { + ctx->apt_started = false; /* next byte becomes a new window's reference */ + } + } +} + +bool rng_health_final(RngHealthCtx* ctx) { + if (ctx == NULL) return false; + const bool ok = ctx->ok && ctx->total > 0; + memzero(ctx, sizeof(*ctx)); + return ok; +} + +bool rng_health_analyze(const uint8_t* buf, size_t len) { + if (buf == NULL || len == 0) return false; + RngHealthCtx ctx; + rng_health_init(&ctx); + rng_health_update(&ctx, buf, len); + return rng_health_final(&ctx); +} + +static bool rng_health_gate(void) { + if (!rng_source_live()) return false; + + /* Drawn in small chunks and folded immediately: no 1 KiB frame, no static + * buffer, O(1) state regardless of RNG_HEALTH_SAMPLE_BYTES. */ + RngHealthCtx ctx; + rng_health_init(&ctx); + + uint8_t chunk[32]; + for (size_t drawn = 0; drawn < RNG_HEALTH_SAMPLE_BYTES; + drawn += sizeof(chunk)) { + random_buffer(chunk, sizeof(chunk)); + rng_health_update(&ctx, chunk, sizeof(chunk)); + } + memzero(chunk, sizeof(chunk)); + return rng_health_final(&ctx); +} + +/* THE CENTRALISED VERDICT. + * + * The first version of this gate sat in one place -- the generate-mnemonic + * path -- and every other producer of key material drew from random_buffer() + * directly: recovery and import, LoadDevice, PIN and wipe-code changes, U2F key + * handles, the OTP randomness block. So the check was real but its scope was + * one code path, and the honest claim was never "this device will not create + * key material on a broken generator". + * + * The verdict is therefore computed ONCE and latched, and every covered site + * consumes it through random_buffer_checked() -- which avoids re-running a + * 1 KiB sample per draw. + * + * COVERAGE IS OPT-IN, NOT INHERITED. Each call site opts in by name; the + * complete list is in rng_health.h. Making coverage automatic -- inverting + * random32() so that everything, including deps/, was gated by construction -- + * was built for 7.15 and descoped, so a new key-material draw added tomorrow + * gets NO protection unless its author routes it here on purpose. Do not + * describe this module as wallet-wide; two earlier revisions did, and both + * claims were wrong. + * + * The latch is per boot and one-way. A generator that fails is not retried + * until it passes -- retrying until success is how a marginal source talks its + * way in. */ +static enum { RNG_UNTESTED = 0, RNG_PASSED, RNG_FAILED } rng_verdict; + +/* Continuous SP 800-90B state over every byte drawn through + * random_buffer_checked() this boot -- which is not every byte of key material + * in the device, only the opted-in sites listed in rng_health.h. + * This is what the RCT and APT are actually specified for: they run on the + * output as it is produced, not only on a one-time sample. Constant space -- + * the streaming context holds no buffer -- so covering every draw costs + * nothing over covering one. */ +static RngHealthCtx rng_continuous; + +bool rng_health_check(void) { + /* A hardware continuous-test fault is terminal for this boot. Check the + * one-way mirror even after the initial statistical verdict was cached. */ + if (rng_seed_error_latched()) { + rng_verdict = RNG_FAILED; + return false; + } + if (rng_verdict == RNG_UNTESTED) { + if (rng_health_gate()) { + rng_verdict = RNG_PASSED; + rng_health_init(&rng_continuous); + } else { + rng_verdict = RNG_FAILED; + } + } + return rng_verdict == RNG_PASSED && rng_continuous.ok; +} + +bool random_buffer_checked(uint8_t* buf, size_t len) { + if (buf == NULL) return false; + + if (!rng_health_check()) { + memzero(buf, len); + return false; + } + + random_buffer(buf, len); + + /* Observe THESE bytes and refuse them if they are what tripped the test. The + * triggering draw is part of the degenerate run, so returning it and failing + * only on the next call would hand the caller exactly the output the test + * just rejected. Wiping also means a caller that ignores the return value + * still cannot walk away with key material from a failed source. */ + if (!rng_health_observe(buf, len)) { + memzero(buf, len); + return false; + } + return true; +} + +bool rng_health_observe(const uint8_t* buf, size_t len) { + if (rng_verdict != RNG_PASSED) return false; + rng_health_update(&rng_continuous, buf, len); + if (!rng_continuous.ok) { + rng_verdict = RNG_FAILED; + return false; + } + return true; +} + +#ifdef EMULATOR +void rng_health_force_verdict(bool passed) { + if (passed) { + rng_verdict = RNG_PASSED; + rng_health_init(&rng_continuous); + } else { + rng_verdict = RNG_FAILED; + memzero(&rng_continuous, sizeof(rng_continuous)); + } +} +#endif diff --git a/scripts/emulator/Dockerfile b/scripts/emulator/Dockerfile index 813b6a9bc..d54c5d299 100644 --- a/scripts/emulator/Dockerfile +++ b/scripts/emulator/Dockerfile @@ -1,4 +1,13 @@ -FROM kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 +# Parameterised for the same reason as python-keepkey.Dockerfile: CI resolves +# the base through a GHCR mirror and must be able to point FROM at it, while a +# plain `docker build` keeps working unchanged. +# +# The DEFAULT is a digest, not the :v15 tag develop carries. A release build +# has to name an immutable base. A moving release tag may resolve to this +# digest today, but a signed release should not depend on that. CI overrides +# it with the mirror it resolved, which is the same image either way. +ARG BASE_IMAGE=kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 +FROM ${BASE_IMAGE} WORKDIR /kkemu COPY ./ /kkemu @@ -17,4 +26,3 @@ RUN make -j EXPOSE 11044/udp 11045/udp EXPOSE 5000 CMD ["/kkemu/scripts/emulator/run.sh"] - diff --git a/scripts/emulator/capture-dice-flow.py b/scripts/emulator/capture-dice-flow.py new file mode 100644 index 000000000..47821a819 --- /dev/null +++ b/scripts/emulator/capture-dice-flow.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Capture the on-device dice-entry screens from kkemu. + +Evidence tool for the dice_entropy ResetDevice flow: drives a full reset with +device-side dice collection via DebugLinkDecision.input injection and saves +the OLED at each interesting state. +""" + +import hashlib +import os +import sys +import time +from pathlib import Path + +os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") +os.environ.setdefault("TEMPORARILY_DISABLE_PROTOBUF_VERSION_CHECK", "true") + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "deps" / "python-keepkey")) + +from keepkeylib.client import KeepKeyDebuglinkClient, _write_png +from keepkeylib.transport_udp import UDPTransport +from keepkeylib import messages_pb2 as proto + +OUT = Path(sys.argv[1]).resolve() +OUT.mkdir(parents=True, exist_ok=True) + +client = KeepKeyDebuglinkClient( + UDPTransport(os.environ.get("KK_TRANSPORT_MAIN", "127.0.0.1:11044"))) +client.set_debuglink( + UDPTransport(os.environ.get("KK_TRANSPORT_DEBUG", "127.0.0.1:11045"))) + + +def snap(name): + time.sleep(0.3) + layout = client.debug.read_layout() + rows = [] + for y in range(64): + row = bytearray(256) + for x in range(256): + b = layout[x + (y // 8) * 256] + if isinstance(b, str): + b = ord(b) + if (b >> (y % 8)) & 1: + row[x] = 255 + rows.append(bytes(row)) + path = OUT / name + with open(path, "wb") as f: + f.write(_write_png(str(path), 256, 64, rows)) + print(path) + + +client.auto_button = True +client.wipe_device() +client.auto_button = False + +ret = client.call_raw(proto.ResetDevice( + display_random=True, strength=256, passphrase_protection=False, + pin_protection=False, language='english', label='dice evidence', + dice_entropy=True)) +assert isinstance(ret, proto.ButtonRequest), ret + +client.transport.write(proto.ButtonAck()) +time.sleep(0.3) +snap("01-dice-screen-initial.png") + +client.debug.press_input("123") +snap("02-after-three-rolls.png") + +client.debug.press_input("u") +snap("03-after-undo.png") + +rolls = "123456" * 17 # 102, extras past 99 dropped; net = 2 + 99 capped +client.debug.press_input(rolls[:40]) +time.sleep(0.2) +client.debug.press_input(rolls[40:80]) +time.sleep(0.2) +client.debug.press_input(rolls[80:]) +resp = client.transport.read_blocking() +assert isinstance(resp, proto.ButtonRequest), resp +snap("04-digest-confirm.png") + +client.debug.press_yes() +ret = client.call_raw(proto.ButtonAck()) +assert isinstance(ret, proto.ButtonRequest), ret # post-mix entropy display +snap("05-postmix-internal-entropy.png") + +client.debug.press_yes() +ret = client.call_raw(proto.ButtonAck()) +assert isinstance(ret, proto.EntropyRequest), ret +ret = client.call_raw(proto.EntropyAck(entropy=b'E' * 32)) + +assert isinstance(ret, proto.ButtonRequest), ret +snap("06-backup-explainer.png") +client.debug.press_yes() +ret = client.call_raw(proto.ButtonAck()) +while isinstance(ret, proto.ButtonRequest): + client.debug.press_yes() + ret = client.call_raw(proto.ButtonAck()) +assert isinstance(ret, proto.Success), ret +print("flow complete:", ret.message) diff --git a/scripts/emulator/docker-compose.bitcoin-only.yml b/scripts/emulator/docker-compose.bitcoin-only.yml new file mode 100644 index 000000000..c89281de3 --- /dev/null +++ b/scripts/emulator/docker-compose.bitcoin-only.yml @@ -0,0 +1,11 @@ +services: + kkemu: + image: kktech/kkemu-bitcoin-only:latest + build: + args: + coinsupport: "-DKK_BITCOIN_ONLY=ON" + + firmware-unit: + build: + args: + coinsupport: "-DKK_BITCOIN_ONLY=ON" diff --git a/scripts/emulator/docker-compose.yml b/scripts/emulator/docker-compose.yml index cdae9ab83..cbaca3482 100644 --- a/scripts/emulator/docker-compose.yml +++ b/scripts/emulator/docker-compose.yml @@ -13,11 +13,22 @@ services: - "127.0.0.1:5000:5000" healthcheck: test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:5000/health')\" || exit 1"] - interval: 3s + # The emulator is listening well inside a second; a 3s interval meant + # the integration suite sat waiting on poll granularity, not on the + # service. retries is raised alongside so shortening the interval does + # not also shorten how long a slow start is tolerated -- a failing check + # can burn up to `timeout` before the next `interval`, so the ceiling is + # roughly start_period + retries * (timeout + interval), not + # retries * interval. + interval: 1s timeout: 3s - retries: 20 - start_period: 10s + retries: 40 + start_period: 3s python-keepkey: + # Named so CI can build it once (`docker compose build python-keepkey`) + # and then `up` without --build, which would otherwise also force a + # rebuild of kkemu — the expensive one. + image: kktech/kkemu-tests:latest build: context: '../../' dockerfile: 'scripts/emulator/python-keepkey.Dockerfile' @@ -29,6 +40,9 @@ services: kkemu: condition: service_healthy firmware-unit: + # Local-dev convenience only — CI runs `make xunit` directly against the + # image it just built, rather than paying for a second compose build. + image: kktech/kkemu:latest build: context: '../../' dockerfile: 'scripts/emulator/Dockerfile' diff --git a/scripts/emulator/firmware-unit.sh b/scripts/emulator/firmware-unit.sh index c3c9374a6..02c07b11e 100755 --- a/scripts/emulator/firmware-unit.sh +++ b/scripts/emulator/firmware-unit.sh @@ -2,5 +2,7 @@ mkdir -p /kkemu/test-reports/firmware-unit make xunit -echo "$?" > /kkemu/test-reports/firmware-unit/status +RC=$? +echo "$RC" > /kkemu/test-reports/firmware-unit/status cp -r unittests/*.xml /kkemu/test-reports/firmware-unit +exit "$RC" diff --git a/scripts/emulator/python-keepkey.Dockerfile b/scripts/emulator/python-keepkey.Dockerfile index 0ba90447e..a39ed94d3 100644 --- a/scripts/emulator/python-keepkey.Dockerfile +++ b/scripts/emulator/python-keepkey.Dockerfile @@ -1,4 +1,13 @@ -FROM kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 +# Split into a `deps` stage on purpose. The dependency layers below are +# stable across commits and worth caching; the COPY layer beneath them ships +# the whole build context and is invalidated by every commit, so exporting it +# to a layer cache is pure cost with no possible hit. CI caches `deps` only. +# Parameterised because buildx's docker-container driver resolves FROM +# against a registry rather than the local daemon, so CI must be able to +# point it at the GHCR mirror explicitly. The default keeps plain +# `docker build` and local use working unchanged. +ARG BASE_IMAGE=kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 +FROM ${BASE_IMAGE} AS deps # Extra Python deps needed by tests that aren't in the shared base image. # - rlp + eth-keys + eth-utils: build the canonical EIP-1559 type-2 pre-image @@ -10,6 +19,8 @@ FROM kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace0 RUN apk add --no-cache python3-dev gcc musl-dev RUN python3 -m pip install --no-cache-dir rlp eth-keys eth-utils pycryptodome +FROM deps + WORKDIR /kkemu COPY ./ /kkemu diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 9c5acd102..bbec1acfa 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -150,24 +150,45 @@ def validate_screenshots(screenshot_root): return pngs, sequences -def validate_arm_manifest(arm_dir, firmware_sha, python_sha): - manifest_path = arm_dir / "arm-build-manifest.json" - if not manifest_path.is_file(): - fail("ARM build manifest is missing") - with open(manifest_path, "r", 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") - if manifest.get("python_sha") != python_sha: - fail("ARM manifest Python SHA does not match gitlink") - files = manifest.get("files", []) - if not files: - fail("ARM manifest contains no binaries") - for item in files: - path = arm_dir / item.get("name", "") - if not path.is_file() or sha256_file(path) != item.get("sha256"): - fail("ARM artifact hash mismatch: %s" % path) - return manifest_path, manifest +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")): + artifact = manifest_path.parent.name + matches = [variant for variant in required + if artifact.endswith("-" + variant)] + if len(matches) != 1: + fail("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: + 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 not files: + fail("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 def main(): @@ -197,14 +218,17 @@ def main(): pngs, sequences = validate_screenshots(screenshot_root) arm_dir = ROOT / "test-reports" / "arm" - arm_manifest_path, arm_manifest = validate_arm_manifest( + 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 = sha256_file(arm_manifest_path) + 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: @@ -281,8 +305,15 @@ def main(): "sequences": sequences, }, "arm": { - "manifest_sha256": arm_manifest_hash, - "files": arm_manifest["files"], + "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, diff --git a/scripts/verify-token-def.py b/scripts/verify-token-def.py new file mode 100644 index 000000000..cebe901cb --- /dev/null +++ b/scripts/verify-token-def.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +"""Reject generated firmware token tables that contain no usable rows.""" + +from __future__ import print_function + +import os +import sys + + +def verify(path): + if not os.path.isfile(path): + raise RuntimeError("token definition was not generated: %s" % path) + + with open(path, "r") as source: + lines = [line.strip() for line in source] + + if not any(line.startswith("X(") for line in lines): + raise RuntimeError("token definition contains zero rows: %s" % path) + + +def main(argv): + if len(argv) < 2: + print("usage: %s TOKEN_DEF [...]" % argv[0], file=sys.stderr) + return 2 + try: + for path in argv[1:]: + verify(path) + except (IOError, OSError, RuntimeError) as error: + print(str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/unittests/crypto/CMakeLists.txt b/unittests/crypto/CMakeLists.txt index 504ebf378..22821adc7 100644 --- a/unittests/crypto/CMakeLists.txt +++ b/unittests/crypto/CMakeLists.txt @@ -1,4 +1,5 @@ set(sources + bip340.cpp rand.cpp vuln1845.cpp) diff --git a/unittests/crypto/bip340.cpp b/unittests/crypto/bip340.cpp new file mode 100644 index 000000000..89aae7da8 --- /dev/null +++ b/unittests/crypto/bip340.cpp @@ -0,0 +1,500 @@ +// Official BIP-340 test vectors, verbatim from +// https://github.com/bitcoin/bips/blob/master/bip-0340/test-vectors.csv +// +// Vectors with a secret key are signed and the signature compared byte for +// byte (BIP-340 signing is deterministic given aux_rand). Every vector, +// with or without a secret key, is run through verification. + +#include + +extern "C" { +#include "trezor/crypto/bip32.h" +#include "trezor/crypto/bip340.h" +#include "trezor/crypto/bip39.h" +#include "trezor/crypto/curves.h" +#include "trezor/crypto/ecdsa.h" +#include "trezor/crypto/secp256k1.h" +#include "trezor/crypto/segwit_addr.h" +} + +#include "gtest/gtest.h" + +#include +#include +#include +#include + +namespace { + +std::vector unhex(const std::string &s) { + std::vector out; + out.reserve(s.size() / 2); + for (size_t i = 0; i + 1 < s.size(); i += 2) { + out.push_back((uint8_t)std::stoul(s.substr(i, 2), nullptr, 16)); + } + return out; +} + +std::string hex(const uint8_t *p, size_t len) { + static const char *digits = "0123456789ABCDEF"; + std::string out; + for (size_t i = 0; i < len; i++) { + out += digits[p[i] >> 4]; + out += digits[p[i] & 0x0f]; + } + return out; +} + +struct Vector { + int index; + const char *seckey; // empty when the vector is verify-only + const char *pubkey; + const char *aux; + const char *msg; + const char *sig; + bool valid; + const char *comment; +}; + +// 100 bytes of 0x99, the message of vector 18. +const char *kMsg100 = + "9999999999999999999999999999999999999999999999999999999999999999" + "9999999999999999999999999999999999999999999999999999999999999999" + "9999999999999999999999999999999999999999999999999999999999999999" + "99999999"; + +const Vector kVectors[] = { + {0, "0000000000000000000000000000000000000000000000000000000000000003", + "F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000000", + "E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA8215" + "25F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0", + true, ""}, + {1, "B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "0000000000000000000000000000000000000000000000000000000000000001", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE3341" + "8906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A", + true, ""}, + {2, "C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9", + "DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8", + "C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906", + "7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C", + "5831AAEED7B44BB74E5EAB94BA9D4294C49BCF2A60728D8B4C200F50DD313C1B" + "AB745879A5AD954A72C45A91C3A51D3C7ADEA98D82F8481E0E1E03674A6F3FB7", + true, ""}, + {3, "0B432B2677937381AEF05BB02A66ECD012773062CF3FA2549E44F58ED2401710", + "25D1DFF95105F5253C4022F628A996AD3A0D95FBF21D468A1B33F8C160D8F517", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", + "7EB0509757E246F19449885651611CB965ECC1A187DD51B64FDA1EDC9637D5EC" + "97582B9CB13DB3933705B32BA982AF5AF25FD78881EBB32771FC5922EFC66EA3", + true, "test fails if msg is reduced modulo p or n"}, + {4, "", "D69C3509BB99E412E68B0FE8544E72837DFA30746D8BE2AA65975F29D22DC7B9", + "", "4DF3C3F68FCC83B27E9D42C90431A72499F17875C81A599B566C9889B9696703", + "00000000000000000000003B78CE563F89A0ED9414F5AA28AD0D96D6795F9C63" + "76AFB1548AF603B3EB45C9F8207DEE1060CB71C04E80F593060B07D28308D7F4", + true, ""}, + {5, "", "EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34", + "", "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "public key not on the curve"}, + {6, "", "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "", "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "FFF97BD5755EEEA420453A14355235D382F6472F8568A18B2F057A1460297556" + "3CC27944640AC607CD107AE10923D9EF7A73C643E166BE5EBEAFA34B1AC553E2", + false, "has_even_y(R) is false"}, + {7, "", "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "", "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "1FA62E331EDBC21C394792D2AB1100A7B432B013DF3F6FF4F99FCB33E0E1515F" + "28890B3EDB6E7189B630448B515CE4F8622A954CFE545735AAEA5134FCCDB2BD", + false, "negated message"}, + {8, "", "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "", "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "961764B3AA9B2FFCB6EF947B6887A226E8D7C93E00C5ED0C1834FF0D0C2E6DA6", + false, "negated s value"}, + {9, "", "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "", "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "0000000000000000000000000000000000000000000000000000000000000000" + "123DDA8328AF9C23A94C1FEECFD123BA4FB73476F0D594DCB65C6425BD186051", + false, "sG - eP is infinite, x(inf) as 0"}, + {10, "", "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "", "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "0000000000000000000000000000000000000000000000000000000000000001" + "7615FBAF5AE28864013C099742DEADB4DBA87F11AC6754F93780D5A1837CF197", + false, "sG - eP is infinite, x(inf) as 1"}, + {11, "", "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "", "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "4A298DACAE57395A15D0795DDBFD1DCB564DA82B0F269BC70A74F8220429BA1D" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "sig[0:32] is not an X coordinate on the curve"}, + {12, "", "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "", "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "sig[0:32] is equal to field size"}, + {13, "", "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "", "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", + false, "sig[32:64] is equal to curve order"}, + {14, "", "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30", + "", "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E177769" + "69E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", + false, "public key exceeds the field size"}, + {15, "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", "", + "71535DB165ECD9FBBC046E5FFAEA61186BB6AD436732FCCC25291A55895464CF" + "6069CE26BF03466228F19A3A62DB8A649F2D560FAC652827D1AF0574E427AB63", + true, "message of size 0"}, + {16, "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", "11", + "08A20A0AFEF64124649232E0693C583AB1B9934AE63B4C3511F3AE1134C6A303" + "EA3173BFEA6683BD101FA5AA5DBC1996FE7CACFC5A577D33EC14564CEC2BACBF", + true, "message of size 1"}, + {17, "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", + "0102030405060708090A0B0C0D0E0F1011", + "5130F39A4059B43BC7CAC09A19ECE52B5D8699D1A71E3C52DA9AFDB6B50AC370" + "C4A482B77BF960F8681540E25B6771ECE1E5A37FD80E5A51897C5566A97EA5A5", + true, "message of size 17"}, + {18, "0340034003400340034003400340034003400340034003400340034003400340", + "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + "0000000000000000000000000000000000000000000000000000000000000000", + kMsg100, + "403B12B0D8555A344175EA7EC746566303321E5DBFA8BE6F091635163ECA79A8" + "585ED3E3170807E7C03B720FC54C7B23897FCBA0E9D0B4A06894CFD249F22367", + true, "message of size 100"}, +}; + +} // namespace + +// Official BIP-86 test vectors, from +// https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki +// (mnemonic "abandon abandon ... about", account m/86'/0'/0'). +// HD derivation is covered at the firmware level; what is pinned here is the +// tweak and the bech32m encoding that turn an internal key into an address. +TEST(BIP340, BIP86Vectors) { + const struct { + const char *path; + const char *internal_key; + const char *output_key; + const char *address; + } vectors[] = { + {"m/86'/0'/0'/0/0", + "cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115", + "a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c", + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"}, + {"m/86'/0'/0'/0/1", + "83dfe85a3151d2517290da461fe2815591ef69f2b18a2ce63f01697a8b313145", + "a82f29944d65b86ae6b5e5cc75e294ead6c59391a1edc5e016e3498c67fc7bbb", + "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh"}, + {"m/86'/0'/0'/1/0", + "399f1b2f4393f29a18c937859c5dd8a77350103157eb880f02e8c08214277cef", + "882d74e5d0572d5a816cef0041a96b6c1de832f6f9676d9605c44d5e9a97d3dc", + "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7"}, + }; + + for (const auto &v : vectors) { + std::vector internal = unhex(v.internal_key); + uint8_t out[BIP340_XONLY_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, internal.data(), nullptr, out)) + << v.path; + + std::string got = hex(out, sizeof(out)); + std::transform(got.begin(), got.end(), got.begin(), ::tolower); + ASSERT_EQ(std::string(v.output_key), got) << v.path; + + // Witness version 1 + 32 bytes must come out bech32m, i.e. a bc1p address. + char address[MAX_ADDR_SIZE] = {0}; + ASSERT_EQ(1, segwit_addr_encode(address, "bc", 1, out, sizeof(out))) + << v.path; + ASSERT_EQ(std::string(v.address), std::string(address)) << v.path; + } +} + +// The same BIP-86 vectors driven from the mnemonic, so HD derivation and the +// x-only convention are covered too. compute_address() feeds +// node->public_key + 1 to bip340_tweak_pubkey(); this pins that the byte after +// the compressed prefix really is the internal key BIP-86 expects, which an +// off-by-one would otherwise turn into a valid-looking wrong address. +TEST(BIP340, BIP86FromMnemonic) { + const char *mnemonic = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon about"; + const struct { + uint32_t change; + uint32_t index; + const char *address; + } vectors[] = { + {0, 0, "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"}, + {0, 1, "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh"}, + {1, 0, "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7"}, + }; + + uint8_t seed[64] = {0}; + mnemonic_to_seed(mnemonic, "", seed, nullptr); + + for (const auto &v : vectors) { + HDNode node = {0}; + ASSERT_EQ(1, hdnode_from_seed(seed, sizeof(seed), SECP256K1_NAME, &node)); + // m/86'/0'/0'/change/index + ASSERT_EQ(1, hdnode_private_ckd(&node, 0x80000000 + 86)); + ASSERT_EQ(1, hdnode_private_ckd(&node, 0x80000000 + 0)); + ASSERT_EQ(1, hdnode_private_ckd(&node, 0x80000000 + 0)); + ASSERT_EQ(1, hdnode_private_ckd(&node, v.change)); + ASSERT_EQ(1, hdnode_private_ckd(&node, v.index)); + hdnode_fill_public_key(&node); + + uint8_t out[BIP340_XONLY_LENGTH] = {0}; + ASSERT_EQ( + 0, bip340_tweak_pubkey(&secp256k1, node.public_key + 1, nullptr, out)); + + char address[MAX_ADDR_SIZE] = {0}; + ASSERT_EQ(1, segwit_addr_encode(address, "bc", 1, out, sizeof(out))); + ASSERT_EQ(std::string(v.address), std::string(address)) + << "change=" << v.change << " index=" << v.index; + } +} + +// Official BIP-341 key-path spending vector, input index 4, from +// https://github.com/bitcoin/bips/blob/master/bip-0341/wallet-test-vectors.json +// +// This is the only published input that uses SIGHASH_DEFAULT (hashType 0), so +// it is the one that pins our signing configuration end to end. It carries a +// merkle root, which is why bip340_tweak_seckey/pubkey take one -- without it +// there is no published witness to check the sigmsg field ordering against, +// and a transposed field yields a perfectly valid signature over the wrong +// transaction. +namespace bip341 { +const char *kInternalPrivkey = + "f36bb07a11e469ce941d16b63b11b9b9120a84d9d87cff2c84a8d4affb438f4e"; +const char *kInternalPubkey = + "e0dfe2300b0dd746a3f8674dfd4525623639042569d829c7f0eed9602d263e6f"; +const char *kMerkleRoot = + "ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2"; +const char *kTweakedPrivkey = + "a8e7aa924f0d58854185a490e6c41f6efb7b675c0f3331b7f14b549400b4d501"; +const char *kSigHash = + "4f900a0bae3f1446fd48490c2958b5a023228f01661cda3496a11da502a7f7ef"; +const char *kWitness = + "b4010dd48a617db09926f729e79c33ae0b4e94b79f04a1ae93ede6315eb3669d" + "e185a17d2b0ac9ee09fd4c64b678a0b61a0a86fa888a273c8511be83bfd6810f"; +const char *kHashPrevouts = + "e3b33bb4ef3a52ad1fffb555c0d82828eb22737036eaeb02a235d82b909c4c3f"; +const char *kHashAmounts = + "58a6964a4f5f8f0b642ded0a8a553be7622a719da71d1f5befcefcdee8e0fde6"; +const char *kHashScriptPubkeys = + "23ad0f61ad2bca5ba6a7693f50fce988e17c3780bf2b1e720cfbb38fbdd52e21"; +const char *kHashSequences = + "18959c7221ab5ce9e26c3cd67b22c24f8baa54bac281d8e6b05e400e6c3a957e"; +const char *kHashOutputs = + "a2e6dab7c1f0dcd297c8d61647fd17d821541ea69c3cc37dcbad7f90d4eb4bc5"; +const uint32_t kVersion = 2; +const uint32_t kLockTime = 500000000; +const uint32_t kInputIndex = 4; + +std::string lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), ::tolower); + return s; +} +} // namespace bip341 + +TEST(BIP341, TweakSeckey) { + std::vector sk = unhex(bip341::kInternalPrivkey); + std::vector root = unhex(bip341::kMerkleRoot); + uint8_t pk[BIP340_XONLY_LENGTH] = {0}; + uint8_t tweaked[32] = {0}; + + ASSERT_EQ(0, bip340_get_xonly_pubkey(&secp256k1, sk.data(), pk)); + ASSERT_EQ(std::string(bip341::kInternalPubkey), + bip341::lower(hex(pk, sizeof(pk)))); + + ASSERT_EQ(0, + bip340_tweak_seckey(&secp256k1, sk.data(), root.data(), tweaked)); + ASSERT_EQ(std::string(bip341::kTweakedPrivkey), + bip341::lower(hex(tweaked, sizeof(tweaked)))); + + // The tweaked private key must correspond to the tweaked public key, or the + // signature verifies under a key that does not own the output. + uint8_t from_seckey[BIP340_XONLY_LENGTH] = {0}; + uint8_t from_pubkey[BIP340_XONLY_LENGTH] = {0}; + ASSERT_EQ(0, bip340_get_xonly_pubkey(&secp256k1, tweaked, from_seckey)); + ASSERT_EQ(0, bip340_tweak_pubkey(&secp256k1, pk, root.data(), from_pubkey)); + ASSERT_EQ(0, memcmp(from_seckey, from_pubkey, BIP340_XONLY_LENGTH)); +} + +TEST(BIP341, Sighash) { + std::vector prevouts = unhex(bip341::kHashPrevouts); + std::vector amounts = unhex(bip341::kHashAmounts); + std::vector spks = unhex(bip341::kHashScriptPubkeys); + std::vector seqs = unhex(bip341::kHashSequences); + std::vector outs = unhex(bip341::kHashOutputs); + uint8_t hash[SHA256_DIGEST_LENGTH] = {0}; + + bip341_sighash(/*hash_type=*/0, bip341::kVersion, bip341::kLockTime, + prevouts.data(), amounts.data(), spks.data(), seqs.data(), + outs.data(), bip341::kInputIndex, hash); + + ASSERT_EQ(std::string(bip341::kSigHash), + bip341::lower(hex(hash, sizeof(hash)))); +} + +TEST(BIP341, KeyPathSignatureMatchesPublishedWitness) { + std::vector sk = unhex(bip341::kInternalPrivkey); + std::vector root = unhex(bip341::kMerkleRoot); + std::vector sighash = unhex(bip341::kSigHash); + uint8_t tweaked[32] = {0}; + uint8_t sig[BIP340_SIG_LENGTH] = {0}; + + ASSERT_EQ(0, + bip340_tweak_seckey(&secp256k1, sk.data(), root.data(), tweaked)); + // BIP-341's vectors are generated with an all-zero aux_rand. + ASSERT_EQ(0, bip340_sign(&secp256k1, tweaked, sighash.data(), sighash.size(), + nullptr, sig)); + ASSERT_EQ(std::string(bip341::kWitness), + bip341::lower(hex(sig, sizeof(sig)))); +} + +TEST(BIP340, TweakRejectsInvalidInternalKey) { + // Vector 5's x coordinate, which is not on the curve. + std::vector bad = + unhex("EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34"); + // And an x coordinate past the field size (vector 14). + std::vector too_big = + unhex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30"); + uint8_t out[BIP340_XONLY_LENGTH] = {0}; + + ASSERT_NE(0, bip340_tweak_pubkey(&secp256k1, bad.data(), nullptr, out)); + ASSERT_NE(0, bip340_tweak_pubkey(&secp256k1, too_big.data(), nullptr, out)); +} + +TEST(BIP340, TaggedHash) { + // tagged_hash("BIP0340/challenge", "") == SHA256(h || h) where + // h = SHA256("BIP0340/challenge"). Pins the double-tag construction. + uint8_t out[SHA256_DIGEST_LENGTH] = {0}; + uint8_t tag_hash[SHA256_DIGEST_LENGTH] = {0}; + uint8_t expected[SHA256_DIGEST_LENGTH] = {0}; + uint8_t doubled[2 * SHA256_DIGEST_LENGTH] = {0}; + + bip340_tagged_hash("BIP0340/challenge", nullptr, 0, out); + + sha256_Raw((const uint8_t *)"BIP0340/challenge", 17, tag_hash); + memcpy(doubled, tag_hash, sizeof(tag_hash)); + memcpy(doubled + sizeof(tag_hash), tag_hash, sizeof(tag_hash)); + sha256_Raw(doubled, sizeof(doubled), expected); + + ASSERT_EQ(0, memcmp(out, expected, sizeof(expected))); +} + +TEST(BIP340, XOnlyPubkey) { + for (const auto &v : kVectors) { + if (v.seckey[0] == '\0') continue; + + std::vector sk = unhex(v.seckey); + uint8_t pk[BIP340_XONLY_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_get_xonly_pubkey(&secp256k1, sk.data(), pk)) + << "vector " << v.index; + ASSERT_EQ(std::string(v.pubkey), hex(pk, sizeof(pk))) + << "vector " << v.index; + } +} + +TEST(BIP340, Sign) { + for (const auto &v : kVectors) { + if (v.seckey[0] == '\0') continue; + + std::vector sk = unhex(v.seckey); + std::vector aux = unhex(v.aux); + std::vector msg = unhex(v.msg); + uint8_t sig[BIP340_SIG_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_sign(&secp256k1, sk.data(), msg.data(), msg.size(), + aux.data(), sig)) + << "vector " << v.index << ": " << v.comment; + ASSERT_EQ(std::string(v.sig), hex(sig, sizeof(sig))) + << "vector " << v.index << ": " << v.comment; + } +} + +TEST(BIP340, Verify) { + for (const auto &v : kVectors) { + std::vector pk = unhex(v.pubkey); + std::vector msg = unhex(v.msg); + std::vector sig = unhex(v.sig); + + int ret = bip340_verify(&secp256k1, pk.data(), msg.data(), msg.size(), + sig.data()); + if (v.valid) { + ASSERT_EQ(0, ret) << "vector " << v.index << ": " << v.comment; + } else { + ASSERT_NE(0, ret) << "vector " << v.index << ": " << v.comment; + } + } +} + +TEST(BIP340, SignRejectsOutOfRangeKeys) { + const uint8_t zero[32] = {0}; + // n, the curve order -- the first scalar that is out of range. + const uint8_t order[32] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, + 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, + 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41}; + const uint8_t msg[32] = {0}; + uint8_t sig[BIP340_SIG_LENGTH] = {0}; + + // Pre-fill, so the zero-on-failure check below cannot pass vacuously. + memset(sig, 0xFF, sizeof(sig)); + + ASSERT_NE(0, bip340_sign(&secp256k1, zero, msg, sizeof(msg), nullptr, sig)); + ASSERT_NE(0, bip340_sign(&secp256k1, order, msg, sizeof(msg), nullptr, sig)); + + // A rejected signing attempt must not leave anything in the output buffer. + uint8_t empty[BIP340_SIG_LENGTH] = {0}; + ASSERT_EQ(0, memcmp(sig, empty, sizeof(sig))); +} + +TEST(BIP340, XOnlyPubkeyZeroesOnFailure) { + const uint8_t zero[32] = {0}; + uint8_t pk[BIP340_XONLY_LENGTH]; + + memset(pk, 0xFF, sizeof(pk)); + ASSERT_NE(0, bip340_get_xonly_pubkey(&secp256k1, zero, pk)); + + uint8_t empty[BIP340_XONLY_LENGTH] = {0}; + ASSERT_EQ(0, memcmp(pk, empty, sizeof(pk))); +} + +TEST(BIP340, ZeroSTakesTheSpecPath) { + // s == 0 is in range per BIP-340 and carries no special guard: verification + // must compute R = -eP and reject on the x-coordinate comparison, not bail + // out early. Pins the absence of a guard that would deviate from the spec. + std::vector pk = unhex(kVectors[1].pubkey); + std::vector msg = unhex(kVectors[1].msg); + std::vector sig = unhex(kVectors[1].sig); + memset(sig.data() + 32, 0, 32); + + ASSERT_NE(0, bip340_verify(&secp256k1, pk.data(), msg.data(), msg.size(), + sig.data())); +} + +TEST(BIP340, NullAuxMatchesZeroAux) { + std::vector sk = unhex(kVectors[0].seckey); + std::vector msg = unhex(kVectors[0].msg); + uint8_t with_null[BIP340_SIG_LENGTH] = {0}; + + ASSERT_EQ(0, bip340_sign(&secp256k1, sk.data(), msg.data(), msg.size(), + nullptr, with_null)); + // Vector 0 uses an all-zero aux_rand, so NULL must reproduce it exactly. + ASSERT_EQ(std::string(kVectors[0].sig), hex(with_null, sizeof(with_null))); +} diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 36b09708b..62757dabb 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -1,26 +1,38 @@ -# Every .cpp in this directory belongs here. thorchain.cpp and mayachain.cpp -# existed on disk since 2019/2021 but were never listed, so nothing in them -# ever compiled -- including the memo-disclosure regression added by this -# release. A test that is not built is not a test. set(sources - binance.cpp - coins.cpp - cosmos.cpp - eos.cpp - ethereum.cpp + authenticator.cpp + confirm_test_utils.cpp fsm.cpp - mayachain.cpp - osmosis.cpp - nano.cpp + dice.cpp recovery.cpp + rng_health.cpp setup_ceremony.cpp - ripple.cpp - solana.cpp + signing.cpp storage.cpp - thorchain.cpp + test_board.cpp + transaction.cpp usb_rx.cpp u2f.cpp) +# Suites for the coin engines the bitcoin-only image compiles out. Building +# them against that image fails at the first reference to an absent symbol +# (ethereum_address_checksum, tokenByTicker, ...), so they follow their +# subjects out of the build. coins.cpp is here because it asserts the +# multi-chain coin AND token table, not just the Bitcoin rows. +if(NOT ${KK_BITCOIN_ONLY}) + list(APPEND sources + binance.cpp + coins.cpp + cosmos.cpp + eos.cpp + ethereum.cpp + mayachain.cpp + nano.cpp + osmosis.cpp + ripple.cpp + solana.cpp + thorchain.cpp) +endif() + include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/lib/firmware diff --git a/unittests/firmware/authenticator.cpp b/unittests/firmware/authenticator.cpp new file mode 100644 index 000000000..2a0b01014 --- /dev/null +++ b/unittests/firmware/authenticator.cpp @@ -0,0 +1,92 @@ +extern "C" { +#include + +#include "trezor/crypto/sha2.h" +#include "keepkey/firmware/authenticator.h" +#include "keepkey/firmware/fsm.h" +#include "keepkey/firmware/storage.h" + +void setup(void); +} + +#include "gtest/gtest.h" + +#include + +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); + +static void ensure_auth_storage_initialized(void) { + static bool initialized = false; + if (!initialized) { + setup(); + storage_init(); + initialized = true; + } +} + +TEST(Authenticator, AuthorizationLossClearsAndReloadsPersistentCache) { + ensure_auth_storage_initialized(); + ASSERT_TRUE(kkconfirm_preload(1, 0)); + ASSERT_EQ(NOERR, wipeAuthData()); + ASSERT_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()); + ASSERT_FALSE(authenticator_cache_is_empty()); + + char account[DOMAIN_SIZE + ACCOUNT_SIZE + 2] = {0}; + authenticator_clear_cache(); + ASSERT_TRUE(authenticator_cache_is_empty()); + 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()); +} + +TEST(Authenticator, RejectedOtpReviewReturnsNoOtp) { + ensure_auth_storage_initialized(); + ASSERT_TRUE(kkconfirm_preload(1, 0)); + ASSERT_EQ(NOERR, wipeAuthData()); + ASSERT_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 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)); + EXPECT_EQ(0, kkconfirm_drain()); + const char zeros[9] = {0}; + EXPECT_EQ(0, memcmp(otp, zeros, sizeof(otp))); + + ASSERT_TRUE(kkconfirm_preload(1, 0)); + EXPECT_EQ(NOERR, wipeAuthData()); + EXPECT_EQ(0, kkconfirm_drain()); +} diff --git a/unittests/firmware/confirm_test_utils.cpp b/unittests/firmware/confirm_test_utils.cpp new file mode 100644 index 000000000..317bee884 --- /dev/null +++ b/unittests/firmware/confirm_test_utils.cpp @@ -0,0 +1,108 @@ +extern "C" { +#include "keepkey/board/messages.h" +#include "keepkey/board/usb.h" +#include "keepkey/firmware/fsm.h" +#include "messages.pb.h" +} + +#include +#include +#include +#include +#include + +// The board bootstrap lives in test_board.cpp and runs at most once per +// binary: a second kk_board_init()/timer_init() relinks the already-linked +// runnables[] and the queue walk in post_periodic() never returns. +void kk_test_board_init(void); + +/* + * 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 + * consumes exactly one pair. The trailing rejection sentinel makes an + * under-budgeted test fail quickly instead of hanging until CI timeout. + * + * This source is unconditional because both full and bitcoin-only suites now + * exercise security disclosures through confirm_bytes(). + */ + +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); +} + +/* One ButtonAck + one DebugLinkDecision, i.e. what a single screen eats. */ +#define KKCONFIRM_MSGS_PER_SCREEN 2 + +bool kkconfirm_preload(int nYes, int nNo) { + static bool initialized = false; + if (!initialized) { + kk_test_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; + } + + // Start from a known-empty queue so a failed preceding test cannot lend its + // decisions to the next one. + { + uint8_t stale[MSG_TINY_BFR_SZ]; + 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; +} + +// Wait after the last packet because loopback delivery is asynchronous. The +// final pair is the rejection sentinel and is discounted from the result. +#define KKCONFIRM_DRAIN_GRACE_US 200000 +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 uint16_t id = (uint16_t)check_for_tiny_msg(buf); + if (id != MSG_TINY_TYPE_ERROR) { + n++; + idle_us = 0; + continue; + } + usleep(1000); + idle_us += 1000; + } + return n - KKCONFIRM_MSGS_PER_SCREEN; +} diff --git a/unittests/firmware/cosmos.cpp b/unittests/firmware/cosmos.cpp index 3c2b78178..ead07e34d 100644 --- a/unittests/firmware/cosmos.cpp +++ b/unittests/firmware/cosmos.cpp @@ -24,6 +24,77 @@ TEST(Cosmos, HostTextMustBeSafeForJsonAndDisplay) { "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", "cosmos")); EXPECT_FALSE(tendermint_validateBech32Address( "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", "thor")); + + /* A good checksum and the right HRP do not make it an account address. An + account is a 20-byte hash == 32 five-bit groups; every other payload + length must be refused, or a deposit signer could be an operator address + or an arbitrary blob. These three carry valid bech32 checksums. */ + EXPECT_TRUE(tendermint_validateBech32Address( + "cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnrql8a", "cosmos")); + EXPECT_FALSE(tendermint_validateBech32Address( + "cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnl07mr", "cosmos")); + EXPECT_FALSE(tendermint_validateBech32Address( + "cosmos1qqqqqqqqqqqqqqqqqqqqe9efq6", "cosmos")); + + /* Well-formedness with an arbitrary HRP, for IBC receivers on counterparty + chains. Still bounded, still checksum-checked. */ + EXPECT_TRUE(tendermint_bech32IsWellFormed( + "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v")); + EXPECT_TRUE(tendermint_bech32IsWellFormed( + "cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnrql8a")); + EXPECT_FALSE(tendermint_bech32IsWellFormed(nullptr)); + EXPECT_FALSE(tendermint_bech32IsWellFormed("")); + EXPECT_FALSE(tendermint_bech32IsWellFormed("not-bech32")); + /* A bad checksum on an otherwise well-shaped string. */ + EXPECT_FALSE(tendermint_bech32IsWellFormed( + "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20w")); + + /* Validator operators carry the same 20-byte payload under a "valoper" + HRP. They are serialized with the same bare "%s" as the delegator, so they + get the same gate. */ + EXPECT_TRUE(tendermint_validateValidatorAddress( + "cosmosvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqkh52tw", "cosmos")); + /* A plain account address is not an operator address. */ + EXPECT_FALSE(tendermint_validateValidatorAddress( + "cosmos1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnrql8a", "cosmos")); + /* Right shape, wrong network. */ + EXPECT_FALSE(tendermint_validateValidatorAddress( + "cosmosvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqkh52tw", "osmo")); + /* Right prefix, wrong payload length. */ + EXPECT_FALSE(tendermint_validateValidatorAddress( + "cosmosvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmzjrgd", + "cosmos")); + EXPECT_FALSE(tendermint_validateValidatorAddress(nullptr, "cosmos")); + EXPECT_FALSE(tendermint_validateValidatorAddress( + "cosmosvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqkh52tw", "")); +} + +TEST(Cosmos, ChainNameIsNotTheBech32Prefix) { + /* coinByName() matches case-insensitively, so a TendermintSignTx naming + "Cosmos", "cosmos" or "COSMOS" all resolve to the same coin -- but only + one spelling is the HRP. Using chain_name for address work therefore made + correctness depend on the case the host happened to send: valid cosmos1... + recipients would be rejected and a "Cosmos1..." sender derived. The + handlers use coin->bech32_prefix for both instead. */ + for (const char* spelling : {"Cosmos", "cosmos", "COSMOS"}) { + const CoinType* coin = coinByName(spelling); + ASSERT_NE(nullptr, coin) << spelling; + /* NOTE: has_bech32_prefix is FALSE for the tendermint family even though + the string is populated (coins.def has Cosmos as `false, "cosmos"`). + Anything gating on that flag would refuse every Cosmos transaction, so + the handlers gate on the string being non-empty. */ + EXPECT_FALSE(coin->has_bech32_prefix) << spelling; + EXPECT_STREQ("cosmos", coin->bech32_prefix) << spelling; + + /* A real mainnet account address validates against the prefix... */ + EXPECT_TRUE(tendermint_validateBech32Address( + "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", coin->bech32_prefix)) + << spelling; + } + + /* ...and would NOT have validated against the capitalized chain_name. */ + EXPECT_FALSE(tendermint_validateBech32Address( + "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", "Cosmos")); } TEST(Cosmos, CosmosGetAddress) { diff --git a/unittests/firmware/dice.cpp b/unittests/firmware/dice.cpp new file mode 100644 index 000000000..93654062e --- /dev/null +++ b/unittests/firmware/dice.cpp @@ -0,0 +1,64 @@ +extern "C" { +#include "keepkey/firmware/dice_input.h" +} + +#include "gtest/gtest.h" + +#include +#include + +static std::string hexlify(const uint8_t *bytes, size_t len) { + static const char *alph = "0123456789abcdef"; + std::string out; + for (size_t i = 0; i < len; i++) { + out += alph[bytes[i] >> 4]; + out += alph[bytes[i] & 0xF]; + } + return out; +} + +TEST(Dice, RollsForStrength) { + // d6 = 2.585 bits/roll; Coldcard-convention targets. + EXPECT_EQ(dice_rolls_for_strength(128), 50u); + EXPECT_EQ(dice_rolls_for_strength(192), 75u); + EXPECT_EQ(dice_rolls_for_strength(256), 99u); +} + +TEST(Dice, MixZeroEntropyVector) { + // SHA256(0x00*32 || "123456") + uint8_t entropy[32]; + memset(entropy, 0, sizeof(entropy)); + dice_mix(entropy, "123456", 6); + EXPECT_EQ(hexlify(entropy, 32), + "16ba88244e0230b0fc84868b703a0e32c344be1b0284f2e67e59715f123748d6"); +} + +TEST(Dice, MixNonZeroEntropyVector) { + // SHA256(0x00..0x1f || "654321165243") + uint8_t entropy[32]; + for (int i = 0; i < 32; i++) entropy[i] = (uint8_t)i; + dice_mix(entropy, "654321165243", 12); + EXPECT_EQ(hexlify(entropy, 32), + "d1ab5a0b7f106313b6ba44d6863c5d1b90397d9e4a0f87a0a6baa25bad00ae97"); +} + +TEST(Dice, MixDependsOnRolls) { + uint8_t a[32], b[32]; + memset(a, 0xAB, sizeof(a)); + memset(b, 0xAB, sizeof(b)); + dice_mix(a, "111111", 6); + dice_mix(b, "111112", 6); + EXPECT_NE(0, memcmp(a, b, 32)); +} + +TEST(Dice, MixUsesExactCount) { + // Only `count` bytes of the roll buffer may contribute. + uint8_t a[32], b[32]; + memset(a, 0, sizeof(a)); + memset(b, 0, sizeof(b)); + const char rolls_a[8] = {'1', '2', '3', '4', '5', '6', '1', '2'}; + const char rolls_b[8] = {'1', '2', '3', '4', '5', '6', '6', '5'}; + dice_mix(a, rolls_a, 6); + dice_mix(b, rolls_b, 6); + EXPECT_EQ(0, memcmp(a, b, 32)); +} diff --git a/unittests/firmware/ethereum.cpp b/unittests/firmware/ethereum.cpp index 9d9f2c2f2..bac814b96 100644 --- a/unittests/firmware/ethereum.cpp +++ b/unittests/firmware/ethereum.cpp @@ -60,8 +60,19 @@ TEST(Ethereum, ChainIdValidationCoversPresenceAndBounds) { msg.chain_id = 1; EXPECT_TRUE(ethereum_chainIdIsValid(&msg)); - msg.chain_id = 2147483630u; + /* The boundary is where v + 2 * chain_id + 35 stops fitting in a uint32_t + at the worst-case v == 1. Pin both sides of it, in 64-bit arithmetic so + the check itself cannot wrap. */ + msg.chain_id = 2147483629u; EXPECT_TRUE(ethereum_chainIdIsValid(&msg)); + EXPECT_EQ(2ull * 2147483629ull + 35ull + 1ull, 4294967294ull); + + /* One higher wraps to 0: a recovery id the device never produced. */ + EXPECT_EQ(2ull * 2147483630ull + 35ull + 1ull, 4294967296ull); + EXPECT_EQ(static_cast(2ull * 2147483630ull + 35ull + 1ull), 0u); + + msg.chain_id = 2147483630u; + EXPECT_FALSE(ethereum_chainIdIsValid(&msg)); msg.chain_id = 2147483631u; EXPECT_FALSE(ethereum_chainIdIsValid(&msg)); @@ -113,6 +124,17 @@ TEST(Ethereum, NativeAmountsUseTheSigningChainsTicker) { ASSERT_TRUE(ethereumFormatAmount(&amount, nullptr, 42161, rendered, sizeof(rendered))); EXPECT_STREQ("1.5 ETH", rendered); + + /* An unmapped chain must never render a bare, unit-less number. Wei is the + base unit of every EVM chain, so the amount stays exact while the device + stops claiming to know an asset name it does not have. */ + ASSERT_TRUE(ethereumFormatAmount(&amount, nullptr, 59144, rendered, + sizeof(rendered))); + EXPECT_STREQ("1500000000000000000 Wei", rendered); + + ASSERT_TRUE( + ethereumFormatAmount(&amount, nullptr, 257, rendered, sizeof(rendered))); + EXPECT_STREQ("1500000000000000000 Wei", rendered); } TEST(Ethereum, TransferAmountUsesTheRequestsSigningChain) { diff --git a/unittests/firmware/fsm.cpp b/unittests/firmware/fsm.cpp index a4b60187f..9cccbb6ab 100644 --- a/unittests/firmware/fsm.cpp +++ b/unittests/firmware/fsm.cpp @@ -1,7 +1,4 @@ extern "C" { -#include "keepkey/board/keepkey_board.h" -#include "keepkey/board/layout.h" -#include "keepkey/board/timer.h" #include "keepkey/transport/interface.h" #include "trezor/crypto/sha2.h" #include "keepkey/firmware/authenticator.h" @@ -23,6 +20,10 @@ extern "C" { #include +// The shared bootstrap initializes the canvas and timer queues exactly once. +// Calling timer_init() again relinks the static runnable nodes into a cycle. +void kk_test_board_init(void); + TEST(Fsm, AuthenticatorCredentialSourceIsWipedOnEveryExit) { char credential[] = "site:user:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; ASSERT_EQ(LARGESEED, addAuthAccount(credential)); @@ -32,6 +33,7 @@ TEST(Fsm, AuthenticatorCredentialSourceIsWipedOnEveryExit) { } } +#if !BITCOIN_ONLY TEST(Fsm, AbortWorkflowsClearsEveryObservableSigningSession) { HDNode node = {}; node.curve = &secp256k1_info; @@ -102,6 +104,7 @@ TEST(Fsm, AbortWorkflowsClearsEveryObservableSigningSession) { EXPECT_FALSE(mayachain_signingIsInited()); EXPECT_FALSE(eos_signingIsInited()); } +#endif TEST(Fsm, MissingBitcoinAckPayloadTerminatesSigning) { fsm_init(); @@ -127,15 +130,10 @@ TEST(Fsm, MissingBitcoinAckPayloadTerminatesSigning) { } TEST(Fsm, AutoLockTerminatesSigningWhileWaitingAwayFromHome) { - /* Production initializes the OLED before the main loop can auto-lock. The - * firmware unit binary does not, so mirror that board precondition before - * toggle_screensaver() draws its terminal state. */ - static bool display_ready = false; - if (!display_ready) { - timer_init(); - layout_init(display_canvas_init()); - display_ready = true; - } + /* Production initializes the OLED before the main loop can auto-lock. Use + * the firmware suite's one-time board bootstrap to mirror that precondition + * without reinitializing and corrupting the static timer queues. */ + kk_test_board_init(); fsm_init(); layoutHomeForced(); @@ -186,6 +184,7 @@ TEST(Fsm, InvalidSecondBitcoinStartTerminatesOldSigning) { EXPECT_FALSE(signing_is_active()); } +#if !BITCOIN_ONLY TEST(Fsm, MissingEosCommonTerminatesSigning) { fsm_init(); @@ -206,3 +205,4 @@ TEST(Fsm, MissingEosCommonTerminatesSigning) { fsm_msgEosTxActionAck(&stale); EXPECT_FALSE(eos_signingIsInited()); } +#endif diff --git a/unittests/firmware/mayachain.cpp b/unittests/firmware/mayachain.cpp index a14d85682..74cddfa58 100644 --- a/unittests/firmware/mayachain.cpp +++ b/unittests/firmware/mayachain.cpp @@ -71,6 +71,25 @@ TEST(Mayachain, MemoWithMisdeclaredLengthIsRefused) { EXPECT_EQ( MAYACHAIN_MEMO_UNPARSED, mayachain_parseConfirmMemo(kTooFewFields, sizeof(kTooFewFields) - 1)); + + /* A colon where the chain/asset dot belongs shifts every later field. The + tokenizer splits on ":." interchangeably, so this yields the same three + tokens as "SWAP:ETH.USDT:dest:limit" and would be reviewed as asset USDT + on chain ETH -- while the protocol reads USDT as the DESTINATION. It has + to reach the raw-byte path instead. */ + static const char kColonForDot[] = "SWAP:ETH:USDT:dest:limit"; + EXPECT_EQ(MAYACHAIN_MEMO_UNPARSED, + mayachain_parseConfirmMemo(kColonForDot, sizeof(kColonForDot) - 1)); + + /* No dot at all is the same defect. */ + 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) { @@ -185,6 +204,51 @@ 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. */ + HDNode node = { + 0, + 0, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0xb9, 0x9a, 0x39, 0x3a, 0x5a, 0x53, 0x0d, 0x90, 0xef, 0x6e, 0x46, + 0x4e, 0x8e, 0x2f, 0x2b, 0x8b, 0x5c, 0x64, 0xa7, 0x97, 0x29, 0xcd, + 0x60, 0x3b, 0x1f, 0xba, 0x33, 0x81, 0x7d, 0x1a, 0x75, 0xa1}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + &secp256k1_info}; + hdnode_fill_public_key(&node); + + const MayachainSignTx msg = { + 5, {0x80000000 | 44, 0x80000000 | 931, 0x80000000, 0, 0}, + true, 6359, + true, "mayachain-mainnet-v1", + true, 3000, + true, 200000, + true, "", + true, 19, + true, 1}; + ASSERT_TRUE(mayachain_signTxInit(&node, &msg)); + + /* 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)); + + /* A uint64 at its widest, so the segment is at its documented maximum. */ + EXPECT_TRUE(mayachain_signTxUpdateMsgSend( + 18446744073709551615ULL, "maya1g9el7lzjwh9yun2c4jjzhy09j98vkhfxfqkl5k", + denom)); +} + TEST(Mayachain, MultiMessageSignTxSeparatesMsgsWithComma) { /* Regression for the missing comma between "msgs":[...] entries: before the has_message guard, two MsgSends serialized back-to-back ("}}{") and the diff --git a/unittests/firmware/osmosis.cpp b/unittests/firmware/osmosis.cpp index a1b7f7743..93c788cf9 100644 --- a/unittests/firmware/osmosis.cpp +++ b/unittests/firmware/osmosis.cpp @@ -141,4 +141,13 @@ TEST(Osmosis, RequiredValuesRejectEmptyAndNonDecimalAmounts) { EXPECT_FALSE(osmosis_validate_amount(true, "1e6")); EXPECT_TRUE(osmosis_validate_amount(true, "0")); EXPECT_TRUE(osmosis_validate_amount(true, "1000000")); + + /* Noncanonical padding renders differently for the same value -- + "00000001" reaches the screen as "00.000001 OSMO" -- so one amount would + have several spellings and the host would pick which one the owner sees. */ + EXPECT_FALSE(osmosis_validate_amount(true, "01")); + EXPECT_FALSE(osmosis_validate_amount(true, "0000001")); + EXPECT_FALSE(osmosis_validate_amount(true, "00000001")); + EXPECT_FALSE(osmosis_validate_amount(true, "0001000000")); + EXPECT_FALSE(osmosis_validate_amount(true, "00")); } diff --git a/unittests/firmware/rng_health.cpp b/unittests/firmware/rng_health.cpp new file mode 100644 index 000000000..abd2cfd5d --- /dev/null +++ b/unittests/firmware/rng_health.cpp @@ -0,0 +1,272 @@ +extern "C" { +#include "keepkey/rand/rng.h" +#include "keepkey/rand/rng_health.h" +#include "trezor/crypto/rand.h" +} + +#include + +#include +#include +#include +#include +#include + +namespace { + +// Deterministic filler: an LCG is fine here because these vectors only need to +// be non-degenerate, not cryptographic. Using a fixed sequence keeps the test +// from being flaky on a bad draw. +std::vector pseudo(size_t len, uint32_t seed = 1) { + std::vector v(len); + uint32_t s = seed; + for (size_t i = 0; i < len; i++) { + s = s * 1664525u + 1013904223u; + v[i] = static_cast(s >> 24); + } + return v; +} + +TEST(RngHealth, RejectsEmptyAndNull) { + EXPECT_FALSE(rng_health_analyze(nullptr, 32)); + const uint8_t b = 0; + EXPECT_FALSE(rng_health_analyze(&b, 0)); +} + +TEST(RngHealth, AcceptsNonDegenerateSample) { + auto v = pseudo(RNG_HEALTH_SAMPLE_BYTES); + EXPECT_TRUE(rng_health_analyze(v.data(), v.size())); +} + +// A dead peripheral reading back a constant is the failure this exists for. +TEST(RngHealth, RejectsAllZeros) { + std::vector v(RNG_HEALTH_SAMPLE_BYTES, 0x00); + EXPECT_FALSE(rng_health_analyze(v.data(), v.size())); +} + +TEST(RngHealth, RejectsStuckHighByte) { + std::vector v(RNG_HEALTH_SAMPLE_BYTES, 0xFF); + EXPECT_FALSE(rng_health_analyze(v.data(), v.size())); +} + +// RCT boundary: cutoff is 5, so a run of 4 must pass and 5 must fail. +TEST(RngHealth, RctCutoffIsExact) { + auto ok = pseudo(RNG_HEALTH_SAMPLE_BYTES, 7); + for (int i = 0; i < RNG_HEALTH_RCT_CUTOFF - 1; i++) ok[100 + i] = 0xA5; + // Neighbours must differ or the run is longer than intended. + ok[99] = 0x11; + ok[100 + RNG_HEALTH_RCT_CUTOFF - 1] = 0x22; + EXPECT_TRUE(rng_health_analyze(ok.data(), ok.size())); + + auto bad = ok; + for (int i = 0; i < RNG_HEALTH_RCT_CUTOFF; i++) bad[100 + i] = 0xA5; + bad[100 + RNG_HEALTH_RCT_CUTOFF] = 0x22; + EXPECT_FALSE(rng_health_analyze(bad.data(), bad.size())); +} + +// APT boundary: 16 occurrences of the window's reference byte inside one +// 512-byte window fails; 15 passes. Spread them out so RCT stays quiet. +TEST(RngHealth, AptCutoffIsExact) { + const uint8_t ref = 0x5A; + + auto build = [&](uint32_t extra) { + auto v = pseudo(RNG_HEALTH_APT_WINDOW, 3); + // Clear any incidental matches so the count is exactly what we plant. + for (auto& b : v) + if (b == ref) b = ref ^ 0x01; + v[0] = ref; // the window reference, which is NOT itself counted + for (uint32_t i = 0; i < extra; i++) v[8 + i * 16] = ref; + return v; + }; + + // The cutoff counts samples FOLLOWING the reference, so cutoff-1 following + // matches must pass and exactly cutoff must fail. + auto ok = build(RNG_HEALTH_APT_CUTOFF - 1); + EXPECT_TRUE(rng_health_analyze(ok.data(), ok.size())); + + auto bad = build(RNG_HEALTH_APT_CUTOFF); + EXPECT_FALSE(rng_health_analyze(bad.data(), bad.size())); +} + +// THE LIMITATION, PINNED AS A TEST. +// +// This stream comes from a generator with a 16-bit seed -- only 65536 possible +// outputs in the whole universe of them -- and the health test passes it. That +// is not a bug to fix later; no output test detects a small internal state, and +// the Coldcard failure of July 2026 was this shape with ~40 bits. If someone +// ever "fixes" this expectation to EXPECT_FALSE, the check they added is +// measuring something other than what it claims. +// +// The defenses that do cover this live elsewhere: the #error build guards in +// lib/rand/rng.c and rng_source_live() in lib/rand/rng_health.c. +TEST(RngHealth, PassesTinySeedGeneratorByDesign) { + auto v = pseudo(RNG_HEALTH_SAMPLE_BYTES, 0xBEEF); + EXPECT_TRUE(rng_health_analyze(v.data(), v.size())); +} + +// REGRESSION: RCT state must not reset at the APT window boundary. An earlier +// version shared one "started" flag between both tests, so the byte after each +// 512-sample window reset the run counter and a repeat spanning the boundary +// went unnoticed. +TEST(RngHealth, RctSpansAptWindowBoundary) { + auto v = pseudo(RNG_HEALTH_SAMPLE_BYTES, 21); + const size_t b = RNG_HEALTH_APT_WINDOW; // first byte of the second window + // Straddle the boundary: cutoff identical bytes ending just past it. + for (size_t i = 0; i < RNG_HEALTH_RCT_CUTOFF; i++) v[b - 2 + i] = 0x7E; + v[b - 3] = 0x11; + v[b - 2 + RNG_HEALTH_RCT_CUTOFF] = 0x22; + EXPECT_FALSE(rng_health_analyze(v.data(), v.size())); +} + +// Chunked feeding must be identical to one-shot feeding, including at chunk +// sizes that do not divide the window. +TEST(RngHealth, IrregularChunkingMatchesOneShot) { + auto v = pseudo(RNG_HEALTH_SAMPLE_BYTES, 33); + const size_t b = RNG_HEALTH_APT_WINDOW; + for (size_t i = 0; i < RNG_HEALTH_RCT_CUTOFF; i++) v[b - 2 + i] = 0x5C; + v[b - 3] = 0x11; + v[b - 2 + RNG_HEALTH_RCT_CUTOFF] = 0x22; + + for (size_t chunk : {size_t(1), size_t(7), size_t(32), size_t(511)}) { + RngHealthCtx ctx; + rng_health_init(&ctx); + for (size_t off = 0; off < v.size(); off += chunk) { + size_t n = std::min(chunk, v.size() - off); + rng_health_update(&ctx, v.data() + off, n); + } + EXPECT_FALSE(rng_health_final(&ctx)) << "chunk size " << chunk; + } +} + +// SCOPE, PINNED AS TESTS. +// +// random_buffer_checked() is the ONLY checked path. Plain random_buffer() is +// unchecked, exactly as on develop -- inverting that was tried for 7.15 and +// descoped. So these tests describe what the seed-time gate does for the draws +// routed through it, and deliberately claim nothing about the rest of the tree. +// What matters is that it fails CLOSED rather than handing back whatever the +// source produced. + +TEST(RngHealth, CheckedDrawFillsFromAHealthySource) { + rng_health_force_verdict(true); + uint8_t a[64] = {0}; + uint8_t b[64] = {0}; + ASSERT_TRUE(random_buffer_checked(a, sizeof(a))); + ASSERT_TRUE(random_buffer_checked(b, sizeof(b))); + EXPECT_NE(0, memcmp(a, b, sizeof(a))) << "two draws matched — RNG broken?"; +} + +// The property the whole change exists for: on a failed verdict the caller gets +// false AND a zeroed buffer, so a caller that ignores the return value still +// cannot walk away with key material from a source that did not pass. +TEST(RngHealth, CheckedDrawFailsClosedAndWipes) { + rng_health_force_verdict(false); + uint8_t buf[64]; + memset(buf, 0xAB, sizeof(buf)); + + EXPECT_FALSE(random_buffer_checked(buf, sizeof(buf))); + + const uint8_t zeros[64] = {0}; + EXPECT_EQ(0, memcmp(buf, zeros, sizeof(buf))) + << "buffer kept its contents after a refused draw"; + EXPECT_FALSE(rng_health_check()); + + rng_health_force_verdict(true); +} + +TEST(RngHealth, CheckedDrawRejectsNull) { + rng_health_force_verdict(true); + EXPECT_FALSE(random_buffer_checked(nullptr, 32)); +} + +TEST(RngHealth, TransientHardwareFaultRemainsLatched) { + rng_test_power_on_reset(); + rng_health_force_verdict(true); + rng_test_observe_transient_error(); + + uint8_t buf[32]; + memset(buf, 0xAB, sizeof(buf)); + EXPECT_FALSE(random_buffer_checked(buf, sizeof(buf))); + const uint8_t zeros[32] = {0}; + EXPECT_EQ(0, memcmp(buf, zeros, sizeof(buf))); + EXPECT_TRUE(rng_seed_error_latched()); +} + +TEST(RngHealth, PersistentHardwareFaultLatchesBeforeReset) { + rng_test_power_on_reset(); + rng_health_force_verdict(true); + rng_test_observe_persistent_error(); + + uint8_t buf[32]; + memset(buf, 0xAB, sizeof(buf)); + EXPECT_FALSE(random_buffer_checked(buf, sizeof(buf))) + << "a healthy-looking post-reset word escaped the boot fault latch"; + const uint8_t zeros[32] = {0}; + EXPECT_EQ(0, memcmp(buf, zeros, sizeof(buf))); + EXPECT_TRUE(rng_seed_error_latched()); + + // Leave the process in a fresh-boot state for later tests. + rng_test_power_on_reset(); + rng_health_force_verdict(true); +} + +// THE CONTINUOUS TEST, ON THE DEFAULT PATH. The boot gate only says the source +// was healthy once; the RCT and APT exist to notice one that goes degenerate +// afterwards. An earlier revision folded bytes into the continuous state only +// inside random_buffer_checked(), so the ordinary path -- which is the one +// RedPallas, ECDSA blinding and SecAESSTM32 take -- enforced the boot verdict +// and nothing else. +TEST(RngHealth, DegenerateOutputAfterTheGateLatchesFailure) { + rng_health_force_verdict(true); + ASSERT_TRUE(rng_health_check()); + + // A stuck run of exactly the RCT cutoff, as a dying source would emit. + const uint8_t stuck[RNG_HEALTH_RCT_CUTOFF] = {0x7E, 0x7E, 0x7E, 0x7E, 0x7E}; + rng_health_observe(stuck, sizeof(stuck)); + + EXPECT_FALSE(rng_health_check()) + << "a stuck run observed after the gate did not latch the verdict"; + rng_health_force_verdict(true); +} + +// And the same at the unit level: the call that trips reports the failure, +// which is what random32() branches on. +TEST(RngHealth, ObserveReportsTheTrippingCall) { + rng_health_force_verdict(true); + const uint8_t fine[4] = {0x01, 0x02, 0x03, 0x04}; + EXPECT_TRUE(rng_health_observe(fine, sizeof(fine))); + + uint8_t stuck[RNG_HEALTH_RCT_CUTOFF]; + memset(stuck, 0x7E, sizeof(stuck)); + EXPECT_FALSE(rng_health_observe(stuck, sizeof(stuck))) + << "the observing call that tripped the test reported success"; + + rng_health_force_verdict(true); +} + +// The triggering draw must not be returned. random_buffer_checked() observes +// the bytes it just produced, and if THOSE bytes tripped the test they are +// wiped rather than handed over -- the triggering draw is part of the +// degenerate run, so returning it and failing on the next call would deliver +// exactly the output the test rejected. +TEST(RngHealth, TrippingBytesAreWipedNotReturned) { + rng_health_force_verdict(true); + const uint8_t fine[4] = {0x01, 0x02, 0x03, 0x04}; + EXPECT_TRUE(rng_health_observe(fine, sizeof(fine))); + + uint8_t stuck[RNG_HEALTH_RCT_CUTOFF]; + memset(stuck, 0x7E, sizeof(stuck)); + EXPECT_FALSE(rng_health_observe(stuck, sizeof(stuck))) + << "the observing call that tripped the test reported success"; + + // With the verdict latched, the next checked draw refuses and wipes. + uint8_t buf[64]; + memset(buf, 0xAB, sizeof(buf)); + EXPECT_FALSE(random_buffer_checked(buf, sizeof(buf))); + const uint8_t zeros[64] = {0}; + EXPECT_EQ(0, memcmp(buf, zeros, sizeof(buf))); + + rng_health_force_verdict(true); +} + +} // namespace diff --git a/unittests/firmware/setup_ceremony.cpp b/unittests/firmware/setup_ceremony.cpp index cac811b01..5bffba41d 100644 --- a/unittests/firmware/setup_ceremony.cpp +++ b/unittests/firmware/setup_ceremony.cpp @@ -33,6 +33,7 @@ extern "C" { #include "keepkey/board/keepkey_board.h" #include "keepkey/firmware/fsm.h" +#include "keepkey/firmware/recovery_cipher.h" #include "keepkey/firmware/reset.h" #include "trezor/crypto/bip39.h" } @@ -157,4 +158,30 @@ TEST_F(SetupCeremony, MessagePermutationsLeaveNothingArmed) { } } +TEST_F(SetupCeremony, AbortWipesBip39MnemonicAndRecoveryFragments) { + const uint8_t entropy[16] = {0}; + const char* mnemonic = mnemonic_from_data(entropy, sizeof(entropy)); + ASSERT_NE(nullptr, mnemonic); + ASSERT_NE('\0', mnemonic[0]); + recovery_cipher_test_set_word_fragments(); + ASSERT_FALSE(recovery_cipher_test_word_fragments_are_zero()); + + setup_abort(); + + EXPECT_EQ('\0', mnemonic[0]); + EXPECT_TRUE(recovery_cipher_test_word_fragments_are_zero()); + EXPECT_FALSE(setup_isArmed()); +} + +TEST_F(SetupCeremony, InvalidRecoveryWordCountDisarmsCeremony) { + ASSERT_TRUE(setup_stage(false, "english", "recovery", 0, 0, false)); + setup_arm(SETUP_RECOVERY); + ASSERT_TRUE(setup_isArmedAs(SETUP_RECOVERY)); + + recovery_cipher_finalize(); + + EXPECT_FALSE(setup_isArmed()); + EXPECT_TRUE(recovery_cipher_test_word_fragments_are_zero()); +} + } // namespace diff --git a/unittests/firmware/signing.cpp b/unittests/firmware/signing.cpp new file mode 100644 index 000000000..ec1d3d2a9 --- /dev/null +++ b/unittests/firmware/signing.cpp @@ -0,0 +1,95 @@ +extern "C" { +#include "keepkey/firmware/signing.h" +} + +#include "gtest/gtest.h" + +#include + +namespace { + +constexpr uint32_t H(uint32_t i) { return 0x80000000 | i; } + +// m/'/0'/0'/1/0 -- a first change address in the first account. +struct ChangePath { + uint32_t n[5]; + explicit ChangePath(uint32_t purpose) : n{H(purpose), H(0), H(0), 1, 0} {} +}; + +bool Forbidden(uint32_t in_purpose, uint32_t out_purpose, + OutputScriptType out_script_type) { + ChangePath in(in_purpose), out(out_purpose); + return isCrossAccountSegwitChangeForbidden(in.n, 5, out.n, 5, + out_script_type); +} + +} // namespace + +// Regression: a BIP86 change path paired with any non-taproot script type used +// to fall through to the generic path check, which accepted it as change. That +// suppressed the output confirmation screen while serializing the change to a +// script no BIP86 wallet ever scans for. +TEST(Signing, TaprootChangeMustUseTaprootScriptType) { + EXPECT_TRUE(Forbidden(86, 86, OutputScriptType_PAYTOADDRESS)); + EXPECT_TRUE(Forbidden(86, 86, OutputScriptType_PAYTOWITNESS)); + EXPECT_TRUE(Forbidden(86, 86, OutputScriptType_PAYTOP2SHWITNESS)); +} + +TEST(Signing, MatchedPurposeAndScriptTypeAreAllowed) { + EXPECT_FALSE(Forbidden(86, 86, OutputScriptType_PAYTOTAPROOT)); + EXPECT_FALSE(Forbidden(44, 44, OutputScriptType_PAYTOADDRESS)); + EXPECT_FALSE(Forbidden(49, 49, OutputScriptType_PAYTOP2SHWITNESS)); + EXPECT_FALSE(Forbidden(84, 84, OutputScriptType_PAYTOWITNESS)); +} + +// The pre-taproot direction of the same rule, kept honest by this test. +TEST(Signing, LegacyChangeMayNotClaimTaprootScriptType) { + EXPECT_TRUE(Forbidden(44, 44, OutputScriptType_PAYTOTAPROOT)); + EXPECT_TRUE(Forbidden(49, 49, OutputScriptType_PAYTOTAPROOT)); + EXPECT_TRUE(Forbidden(84, 84, OutputScriptType_PAYTOTAPROOT)); +} + +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()); +} diff --git a/unittests/firmware/solana.cpp b/unittests/firmware/solana.cpp index e4600f61e..cca7f9e1b 100644 --- a/unittests/firmware/solana.cpp +++ b/unittests/firmware/solana.cpp @@ -666,14 +666,38 @@ TEST(Solana, PriorityFeeCalculationIsRoundedAndOverflowSafe) { EXPECT_TRUE(has_fee); EXPECT_EQ(fee, 70000000ULL); - /* No explicit limit uses the protocol maximum so the displayed liability - cannot understate the fee. */ - tx.num_instructions = 1; - tx.instructions[0].type = SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE; - tx.instructions[0].extra_value = 2000000; + /* With no explicit limit, use the limit the RUNTIME will request: 200,000 + compute units per non-ComputeBudget instruction, capped at 1,400,000. + + This replaces an earlier rule that assumed the 1,400,000 cap whenever + SetComputeUnitLimit was absent. That could not understate the fee, but it + overstated it badly -- a transfer alongside a unit-price instruction is + charged on 200,000 CUs and was shown as seven times that. Deriving the + limit still cannot understate what the runtime charges, because it is + exactly what the runtime charges. */ + memset(&tx, 0, sizeof(tx)); + tx.num_instructions = 2; + tx.instructions[0].type = SOL_INSTR_SYSTEM_TRANSFER; + tx.instructions[1].type = SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE; + tx.instructions[1].extra_value = 2000000; + ASSERT_TRUE(solana_calculatePriorityFee(&tx, &fee, &has_fee)); + EXPECT_TRUE(has_fee); + EXPECT_EQ(fee, 400000ULL); /* 2 lamports/CU * 1 * 200,000 CUs */ + + /* Seven non-budget instructions reach the 1,400,000 cap exactly, which is + also the most SOL_MAX_INSTRUCTIONS (8) allows alongside a price + instruction. The clamp stays as defence rather than as a reachable path. */ + memset(&tx, 0, sizeof(tx)); + tx.num_instructions = 8; + for (int i = 0; i < 7; i++) + tx.instructions[i].type = SOL_INSTR_SYSTEM_TRANSFER; + tx.instructions[7].type = SOL_INSTR_COMPUTE_BUDGET_UNIT_PRICE; + tx.instructions[7].extra_value = 2000000; ASSERT_TRUE(solana_calculatePriorityFee(&tx, &fee, &has_fee)); EXPECT_TRUE(has_fee); - EXPECT_EQ(fee, 2800000ULL); + 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; diff --git a/unittests/firmware/test_board.cpp b/unittests/firmware/test_board.cpp new file mode 100644 index 000000000..8ed463ae5 --- /dev/null +++ b/unittests/firmware/test_board.cpp @@ -0,0 +1,28 @@ +/* + * One board bootstrap per test binary. + * + * kk_board_init() calls kk_timer_init(), and timer_init() does the same work: + * both push the three static runnables[] nodes onto free_queue + * unconditionally. A SECOND bootstrap therefore relinks nodes that are already + * linked -- free_queue and active_queue become circular, and the + * runnable_queue_get() walk inside post_periodic() never returns. + * + * That is a hang, not a failure. Keep the guard in this single translation unit + * so every confirmation test shares one bootstrap. Nothing else in + * unittests/firmware may call kk_board_init() or timer_init() directly. + * + * No includes on purpose: keepkey_board.h declares shutdown(void), which + * clashes with sys/socket.h, and this file has already cost one build on + * include order. + */ + +// keepkey_board.c is compiled as C. This declaration must therefore carry C +// linkage even though the test seam itself is C++. +extern "C" void kk_board_init(void); // lib/board/keepkey_board.c + +void kk_test_board_init(void) { + static bool initialized = false; + if (initialized) return; + kk_board_init(); + initialized = true; +} diff --git a/unittests/firmware/thorchain.cpp b/unittests/firmware/thorchain.cpp index 71790f3e9..cfd43cbad 100644 --- a/unittests/firmware/thorchain.cpp +++ b/unittests/firmware/thorchain.cpp @@ -87,6 +87,25 @@ TEST(Thorchain, MemoWithEmbeddedNulIsNotParsed) { EXPECT_EQ( THORCHAIN_MEMO_UNPARSED, thorchain_parseConfirmMemo(kTooFewFields, sizeof(kTooFewFields) - 1)); + + /* A colon where the chain/asset dot belongs shifts every later field. The + tokenizer splits on ":." interchangeably, so this yields the same three + tokens as "SWAP:ETH.USDT:dest:limit" and would be reviewed as asset USDT + on chain ETH -- while the protocol reads USDT as the DESTINATION. It has + to reach the raw-byte path instead. */ + static const char kColonForDot[] = "SWAP:ETH:USDT:dest:limit"; + EXPECT_EQ(THORCHAIN_MEMO_UNPARSED, + thorchain_parseConfirmMemo(kColonForDot, sizeof(kColonForDot) - 1)); + + /* No dot at all is the same defect. */ + 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) { diff --git a/unittests/firmware/transaction.cpp b/unittests/firmware/transaction.cpp new file mode 100644 index 000000000..bc2c792cb --- /dev/null +++ b/unittests/firmware/transaction.cpp @@ -0,0 +1,71 @@ +#include "gtest/gtest.h" + +#include +#include + +extern "C" { +#include "keepkey/board/confirm_sm.h" +#include "keepkey/firmware/app_confirm.h" +#include "keepkey/firmware/transaction.h" +} + +bool kkconfirm_preload(int nYes, int nNo); +int kkconfirm_drain(void); + +TEST(Transaction, TaprootInputWeightIncludesWitness) { + CoinType coin = CoinType_init_zero; + TxInputType input = TxInputType_init_zero; + input.script_type = InputScriptType_SPENDTAPROOT; + + // 41 non-witness bytes * 4 plus a one-item witness containing the fixed + // 64-byte SIGHASH_DEFAULT Schnorr signature. + ASSERT_EQ(230U, tx_input_weight(&coin, &input)); +} + +TEST(Transaction, UnsupportedOmniDisclosesTheCompleteRawPayload) { + std::vector payload(220, 0x00); + memcpy(payload.data(), "omni", 4); + payload[7] = 1; // unsupported transaction type, not Simple Send + + 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); + + 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()); +} + +TEST(Transaction, MultisigCompilersRejectUnsatisfiableQuorums) { + MultisigRedeemScriptType multisig = MultisigRedeemScriptType_init_zero; + uint8_t output[256] = {0}; + uint8_t hash[32] = {0}; + + struct InvalidQuorum { + bool has_m; + uint32_t m; + pb_size_t n; + }; + const InvalidQuorum invalid[] = { + {false, 1, 1}, {true, 0, 1}, {true, 1, 0}, + {true, 2, 1}, {true, 1, 16}, {true, 16, 16}, + }; + + for (const auto& test : invalid) { + multisig.has_m = test.has_m; + multisig.m = test.m; + multisig.pubkeys_count = test.n; + EXPECT_FALSE(multisig_quorum_is_valid(&multisig)); + EXPECT_EQ(0u, compile_script_multisig(nullptr, &multisig, output)); + EXPECT_EQ(0u, compile_script_multisig_hash(nullptr, &multisig, hash)); + } +}