diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 784473e79..7e0c5e4e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,27 +5,29 @@ # Stage 1: GATE (seconds, no Docker) # ├─ lint-format clang-format diff check # ├─ static-analysis cppcheck static analysis +# ├─ crypto-tests pinned fork crypto vectors + ASan/UBSan # ├─ secret-scan gitleaks credential detection # └─ check-submodules verify all deps present # # Stage 2: BUILD (parallel, gated by Stage 1) -# ├─ build-emulator Docker image → artifact -# └─ build-arm-firmware cross-compile → .bin/.elf (downloadable) +# ├─ build-emulator Docker image → artifact [matrix: full / bitcoin-only] +# └─ build-arm-firmware cross-compile → .bin/.elf (downloadable) [same matrix] # # Stage 3: TEST (parallel, gated by Stage 2) -# ├─ unit-tests GoogleTest (make xunit) -# └─ python-integration full test suite +# ├─ unit-tests GoogleTest (make xunit) [same matrix — proves each +# │ variant's coin/token gating actually compiles+passes] +# └─ python-integration full test suite (full/default variant only) # # Stage 4: PUBLISH (manual trigger, all tests must pass) -# └─ publish-emulator DockerHub push (workflow_dispatch only) +# └─ publish-emulator DockerHub push, full/default variant only (workflow_dispatch only) name: CI on: push: - branches: [master, develop, 'feature/**', 'fix/**', 'release/**', 'hotfix/**'] + branches: [master, develop, alpha, 'feature/**', 'fix/**', 'release/**', 'hotfix/**'] pull_request: - branches: [master, develop] + branches: [master, develop, alpha] workflow_dispatch: inputs: publish_emulator: @@ -35,7 +37,7 @@ on: default: false env: - BASE_IMAGE: kktech/firmware:v15 + BASE_IMAGE: kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 EMU_IMAGE: kkemu-ci jobs: @@ -48,7 +50,7 @@ jobs: timeout-minutes: 3 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -81,17 +83,21 @@ jobs: timeout-minutes: 2 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 - name: Install gitleaks run: | - GITLEAKS_VERSION=$(curl -sSf https://api.github.com/repos/gitleaks/gitleaks/releases/latest \ - | grep -oP '"tag_name":\s*"v\K[^"]+') - curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ - | tar -xz -C /usr/local/bin gitleaks + GITLEAKS_VERSION=8.30.1 + GITLEAKS_SHA256=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + GITLEAKS_ARCHIVE="${RUNNER_TEMP}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -sSfL --retry 3 --retry-all-errors \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + -o "${GITLEAKS_ARCHIVE}" + echo "${GITLEAKS_SHA256} ${GITLEAKS_ARCHIVE}" | sha256sum --check --strict + tar -xzf "${GITLEAKS_ARCHIVE}" -C /usr/local/bin gitleaks gitleaks version - name: Run gitleaks @@ -99,14 +105,17 @@ jobs: static-analysis: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} submodules: false + - name: Init crypto submodule + run: git submodule update --init deps/crypto/trezor-firmware + - name: Install cppcheck run: sudo apt-get update && sudo apt-get install -y cppcheck @@ -143,15 +152,17 @@ jobs: : "${ERRORS:=0}" "${WARNINGS:=0}" "${STYLE:=0}" "${PERF:=0}" "${PORT:=0}" TOTAL=$((ERRORS + WARNINGS + STYLE + PERF + PORT)) - echo "## cppcheck summary" >> "$GITHUB_STEP_SUMMARY" - echo "| Severity | Count |" >> "$GITHUB_STEP_SUMMARY" - echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY" - echo "| error | $ERRORS |" >> "$GITHUB_STEP_SUMMARY" - echo "| warning | $WARNINGS |" >> "$GITHUB_STEP_SUMMARY" - echo "| style | $STYLE |" >> "$GITHUB_STEP_SUMMARY" - echo "| performance | $PERF |" >> "$GITHUB_STEP_SUMMARY" - echo "| portability | $PORT |" >> "$GITHUB_STEP_SUMMARY" - echo "| **total** | **$TOTAL** |" >> "$GITHUB_STEP_SUMMARY" + { + echo "## cppcheck summary" + echo "| Severity | Count |" + echo "|----------|-------|" + echo "| error | $ERRORS |" + echo "| warning | $WARNINGS |" + echo "| style | $STYLE |" + echo "| performance | $PERF |" + echo "| portability | $PORT |" + echo "| **total** | **$TOTAL** |" + } >> "$GITHUB_STEP_SUMMARY" # Print findings as GitHub annotations cat cppcheck_report.txt @@ -169,7 +180,7 @@ jobs: echo "cppcheck: clean — zero findings" - name: Upload cppcheck report - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: cppcheck-report @@ -181,10 +192,13 @@ jobs: timeout-minutes: 2 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Init crypto submodule + run: git submodule update --init deps/crypto/trezor-firmware + - name: Verify submodules are declared run: | echo "Checking required submodule declarations..." @@ -207,17 +221,108 @@ jobs: done [ "$FAILED" = "0" ] || exit 1 + - name: Enforce RC18 security invariants + run: | + if git grep -n -E \ + 'uses:[[:space:]]+[^#[:space:]]+@(v[0-9]+|main|master)([[:space:]#]|$)' \ + -- .github/workflows; then + echo "::error::Every GitHub Action must be pinned to a full commit SHA" + exit 1 + fi + if git grep -n -E 'kktech/firmware:v[0-9]+' -- \ + .github/workflows scripts/build/docker scripts/emulator; then + echo "::error::The firmware builder must be pinned by manifest digest" + exit 1 + fi + if git grep -n -E 'return[[:space:]]+random\(\)' -- \ + lib/rand/rng.c; then + echo "::error::Emulator cryptography must not use libc random()" + exit 1 + fi + if git grep -n -E \ + 'storage_(get|upsert)ClearsignIdentity|persistent_identity_for' \ + -- lib include; then + echo "::error::Unauthenticated persistent clearsign trust is retired" + exit 1 + fi + if git grep -n -F 'option(KK_ZCASH_PRIVACY' -- CMakeLists.txt; then + echo "::error::Zcash privacy must not become a third release choice" + exit 1 + fi + grep -q 'set(KK_ZCASH_PRIVACY ON)' CMakeLists.txt + grep -q 'set(KK_ZCASH_PRIVACY OFF)' CMakeLists.txt + + crypto-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Init crypto submodule + run: git submodule update --init deps/crypto/trezor-firmware + + - name: Install crypto test dependencies + run: sudo apt-get update && sudo apt-get install -y check libssl-dev pkg-config valgrind + + - name: Run optimized crypto suite + working-directory: deps/crypto/trezor-firmware/crypto + run: | + make clean + make VALGRIND=0 tests/test_check tests/test_pallas_ct + ./tests/test_check + ./tests/test_pallas_ct + + - name: Verify Pallas secret flow with Valgrind + working-directory: deps/crypto/trezor-firmware/crypto + run: | + make clean + make VALGRIND=1 OPTFLAGS='-O2 -g' tests/test_pallas_ct + valgrind --quiet --error-exitcode=1 --track-origins=yes \ + ./tests/test_pallas_ct + + - name: Run crypto suite with ASan and UBSan + working-directory: deps/crypto/trezor-firmware/crypto + run: | + make clean + make CC='gcc -fsanitize=address,undefined' VALGRIND=0 \ + OPTFLAGS='-O1 -g -fno-omit-frame-pointer' \ + tests/test_check tests/test_pallas_ct + # Check's default per-test timeout is too short for the two exhaustive + # codepoint tests under sanitizer instrumentation on shared runners. + # Keep the job-level timeout as the hard upper bound. + CK_DEFAULT_TIMEOUT=30 \ + ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 \ + UBSAN_OPTIONS=halt_on_error=1 ./tests/test_check + ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 \ + UBSAN_OPTIONS=halt_on_error=1 ./tests/test_pallas_ct + # ═══════════════════════════════════════════════════════════ # STAGE 2: BUILD — compile only after gate passes # ═══════════════════════════════════════════════════════════ build-emulator: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + name: build-emulator${{ matrix.label }} + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + # Regular/full includes every supported chain, including Zcash + # shielded/Orchard. Bitcoin-only is the sole reduced build. + - variant: full + label: "" + cmake_flags: "" + - variant: bitcoin-only + label: " (bitcoin-only)" + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -231,11 +336,11 @@ jobs: git submodule update --init deps/sca-hardening/SecAESSTM32 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -250,30 +355,42 @@ jobs: if: steps.cache-base.outputs.cache-hit == 'true' run: docker load -i /tmp/base-image.tar - - name: Build emulator image + - name: Build emulator image (${{ matrix.variant }}) run: | docker build \ - -t ${{ env.EMU_IMAGE }} \ + -t ${{ env.EMU_IMAGE }}-${{ matrix.variant }} \ + --build-arg coinsupport="${{ matrix.cmake_flags }}" \ -f scripts/emulator/Dockerfile \ . - name: Save emulator image - run: docker save ${{ env.EMU_IMAGE }} -o /tmp/emu-image.tar + run: docker save ${{ env.EMU_IMAGE }}-${{ matrix.variant }} -o /tmp/emu-image.tar - name: Upload emulator image artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: emu-image + name: emu-image-${{ matrix.variant }} path: /tmp/emu-image.tar retention-days: 1 build-arm-firmware: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + name: build-arm-firmware${{ matrix.label }} + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - variant: full + label: "" + cmake_flags: "" + - variant: bitcoin-only + label: " (bitcoin-only)" + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -288,7 +405,7 @@ jobs: - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -312,7 +429,7 @@ jobs: echo "git_short=${GIT_SHORT}" >> "$GITHUB_OUTPUT" echo "Firmware version: ${FW_VERSION} (${GIT_SHORT})" - - name: Cross-compile firmware for ARM + - name: Cross-compile firmware for ARM (${{ matrix.variant }}) run: | docker run --rm \ -v ${{ github.workspace }}:/root/keepkey-firmware:z \ @@ -320,34 +437,55 @@ jobs: mkdir /root/build && cd /root/build && \ cmake -C /root/keepkey-firmware/cmake/caches/device.cmake /root/keepkey-firmware \ -DCMAKE_BUILD_TYPE=MinSizeRel \ - -DCMAKE_COLOR_MAKEFILE=ON && \ + -DCMAKE_COLOR_MAKEFILE=ON \ + ${{ matrix.cmake_flags }} && \ make && \ mkdir -p /root/keepkey-firmware/bin && \ cp bin/*.bin /root/keepkey-firmware/bin/ && \ cp bin/*.elf /root/keepkey-firmware/bin/ && \ + cp bin/*.map /root/keepkey-firmware/bin/ 2>/dev/null || true && \ + arm-none-eabi-size -A bin/firmware.keepkey.elf > /root/keepkey-firmware/bin/firmware.keepkey.size.txt 2>/dev/null || true && \ + find . -name '*.su' -print0 | tar czf /root/keepkey-firmware/bin/stack-usage.tgz --null -T - && \ chmod -R a+rw /root/keepkey-firmware/bin" + # SRAM budget gate — RC7's privacy-enabled build hard-faulted on boot + # because static SRAM left an 11.2 KB gap while msg_write() carried a + # 12.4 KB stack frame. keepkey.ld now ASSERTs a 16 KiB reserve at link + # time; this step reports the numbers and enforces the frame margin + # (tools/sram-budgets.json). + - name: SRAM budget gate (${{ matrix.variant }}) + run: | + pip install --quiet pyelftools + python3 tools/check_sram_budget.py \ + --elf bin/firmware.keepkey.elf \ + --su-tar bin/stack-usage.tgz \ + --budgets tools/sram-budgets.json \ + --variant "${{ matrix.variant }}" + - name: Rename firmware artifacts run: | cd bin for f in *.bin; do [ -f "$f" ] || continue - mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${f}" + mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${{ matrix.variant }}-${f}" done for f in *.elf; do [ -f "$f" ] || continue - mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${f}" + mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${{ matrix.variant }}-${f}" done ls -lh - echo "::notice::Firmware v${{ steps.version.outputs.fw_version }} built successfully" + echo "::notice::Firmware v${{ steps.version.outputs.fw_version }} (${{ matrix.variant }}) built successfully" - name: Upload firmware artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: firmware-v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }} + name: firmware-v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${{ matrix.variant }} path: | bin/*.bin bin/*.elf + bin/*.map + bin/*.size.txt + bin/stack-usage.tgz retention-days: 90 # ═══════════════════════════════════════════════════════════ @@ -355,47 +493,58 @@ jobs: # ═══════════════════════════════════════════════════════════ unit-tests: + name: unit-tests${{ matrix.label }} needs: build-emulator runs-on: ubuntu-latest timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - variant: full + label: "" + cmake_flags: "" + - variant: bitcoin-only + label: " (bitcoin-only)" + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - name: Download emulator image - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: emu-image + name: emu-image-${{ matrix.variant }} path: /tmp - name: Load emulator image run: docker load -i /tmp/emu-image.tar - - name: Run unit tests + - name: Run unit tests (${{ matrix.variant }}) run: | # make xunit returns non-zero if any test fails — capture # exit code so JUnit XML still gets copied for reporting docker run --rm \ -v ${{ github.workspace }}/test-reports:/kkemu/test-reports \ --entrypoint /bin/sh \ - ${{ env.EMU_IMAGE }} \ + ${{ env.EMU_IMAGE }}-${{ matrix.variant }} \ -c "mkdir -p /kkemu/test-reports/firmware-unit && \ make xunit; RC=\$?; \ cp -r unittests/*.xml /kkemu/test-reports/firmware-unit/ 2>/dev/null; \ exit \$RC" - name: Upload unit test results - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: - name: unit-test-results + name: unit-test-results-${{ matrix.variant }} path: test-reports/firmware-unit/ retention-days: 30 python-integration-tests: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -442,7 +591,7 @@ jobs: [ "$FW_RC" -eq 0 ] && [ "$PY_RC" -eq 0 ] || exit 1 - name: Upload Python test results - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() with: name: python-test-results @@ -450,7 +599,7 @@ jobs: retention-days: 30 - name: Upload OLED screenshots - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: oled-screenshots @@ -495,12 +644,12 @@ jobs: # fsm_msgDebugLinkGetState is excluded from the build and any # read_layout() call hangs the test). python-dylib-tests: - needs: [lint-format, static-analysis, check-submodules, secret-scan] + needs: [lint-format, static-analysis, check-submodules, secret-scan, crypto-tests] runs-on: macos-latest timeout-minutes: 25 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -517,7 +666,7 @@ jobs: git submodule update --init deps/googletest - name: Setup Python 3.10 - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.10' @@ -596,7 +745,8 @@ jobs: # it fsm_msgDebugLinkGetState is excluded from the build. # CMAKE_POLICY_VERSION_MINIMUM works around vendored # googletest's pre-3.5 policy declaration. - export PATH="$PATH:$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" + NANOPB_GENERATOR_DIR="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" + export PATH="$PATH:$NANOPB_GENERATOR_DIR" which protoc-gen-nanopb which nanopb_generator.py cmake \ @@ -608,15 +758,17 @@ jobs: - name: Build kkemulator_dylib run: | - export PATH="$PATH:$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" - cmake --build build-emu --target kkemulator_dylib -j$(sysctl -n hw.ncpu) + NANOPB_GENERATOR_DIR="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator" + export PATH="$PATH:$NANOPB_GENERATOR_DIR" + CPU_COUNT="$(sysctl -n hw.ncpu)" + cmake --build build-emu --target kkemulator_dylib -j"$CPU_COUNT" ls -la build-emu/lib/libkkemu* || ls -la build-emu/lib/emulator/libkkemu* || true # Surface the resolved binary path for the run step. macOS # produces .dylib; .so is preserved as a fallback for when this # job goes cross-platform. - DYLIB=$(find build-emu -name 'libkkemu.dylib' -o -name 'libkkemu.so' | head -1) + DYLIB="$(find build-emu \( -name 'libkkemu.dylib' -o -name 'libkkemu.so' \) -print -quit)" test -f "$DYLIB" || (echo "::error::libkkemu artifact not found" && exit 1) - echo "DYLIB_PATH=$(pwd)/$DYLIB" >> $GITHUB_ENV + echo "DYLIB_PATH=$(pwd)/$DYLIB" >> "$GITHUB_ENV" - name: Upload libkkemu.dylib # Always upload, even on later test failure — the binary is @@ -624,9 +776,9 @@ jobs: # PR. Tagged with the short commit SHA so multiple PR pushes # don't overwrite each other when a reviewer downloads them. if: always() && env.DYLIB_PATH != '' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: libkkemu-${{ github.sha }} + name: libkkemu-${{ github.event.pull_request.head.sha || github.sha }} path: ${{ env.DYLIB_PATH }} retention-days: 30 if-no-files-found: error @@ -657,7 +809,7 @@ jobs: -v --tb=short --junit-xml=../../../test-reports/dylib-junit.xml - name: Upload dylib test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: python-dylib-test-results @@ -676,26 +828,28 @@ jobs: timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Download unit test results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 continue-on-error: true with: - name: unit-test-results + # Report covers the regular/full build. Bitcoin-only is built and + # unit-tested in its own matrix leg but does not get a PDF. + name: unit-test-results-full path: test-reports/firmware-unit/ - name: Download python test results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 continue-on-error: true with: name: python-test-results path: test-reports/python-keepkey/ - name: Download OLED screenshots - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 continue-on-error: true with: name: oled-screenshots @@ -717,7 +871,7 @@ jobs: run: python3 scripts/generate-test-report.py - name: Upload test report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: test-report @@ -737,14 +891,15 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Download emulator image - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: emu-image + # Publish the regular/full image, including Zcash privacy support. + name: emu-image-full path: /tmp - name: Load emulator image @@ -759,11 +914,11 @@ jobs: - name: Tag images for publish run: | - docker tag ${{ env.EMU_IMAGE }} kktech/kkemu:latest - docker tag ${{ env.EMU_IMAGE }} kktech/kkemu:v${{ steps.version.outputs.fw_version }} + docker tag ${{ env.EMU_IMAGE }}-full kktech/kkemu:latest + docker tag ${{ env.EMU_IMAGE }}-full kktech/kkemu:v${{ steps.version.outputs.fw_version }} - name: Login to DockerHub - uses: docker/login-action@v4 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 with: username: ${{ secrets.KK_DOCKERHUB_USER }} password: ${{ secrets.KK_DOCKERHUB_PASS }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee6c178c7..eb08d05f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,14 @@ # KeepKey Firmware Release Pipeline # -# Triggered by version tags (v*) on master. +# Triggered by version tags (v*). # Builds firmware, computes reproducible hashes, # and creates a draft GitHub Release with all artifacts. # -# Git-flow: tag master after merging a release/* or hotfix/* branch. +# Tag a commit only after its CI workflow has completed successfully. # # Usage: -# git tag v7.11.0 -# git push origin v7.11.0 +# git tag -a v7.15.0-rc17 -m "KeepKey firmware 7.15.0 RC17 test candidate" +# git push origin v7.15.0-rc17 name: Release @@ -18,7 +18,7 @@ on: - 'v*' env: - BASE_IMAGE: kktech/firmware:v15 + BASE_IMAGE: kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 permissions: contents: write @@ -29,19 +29,53 @@ jobs: timeout-minutes: 3 outputs: fw_version: ${{ steps.version.outputs.fw_version }} + tag_name: ${{ steps.version.outputs.tag_name }} + is_prerelease: ${{ steps.version.outputs.is_prerelease }} steps: - - uses: actions/checkout@v6 - with: - submodules: recursive + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Extract and verify version id: version run: | TAG_VERSION="${GITHUB_REF_NAME#v}" FW_VERSION=$(sed -n '/^project/,/)/p' CMakeLists.txt | grep -oP '\d+\.\d+\.\d+') - echo "fw_version=${FW_VERSION}" >> "$GITHUB_OUTPUT" + IS_PRERELEASE=false + RC_PREFIX="${FW_VERSION}-rc" + if [ "$TAG_VERSION" != "$FW_VERSION" ]; then - echo "::error::Tag (${TAG_VERSION}) != CMakeLists.txt (${FW_VERSION})" + if [[ "$TAG_VERSION" != "${RC_PREFIX}"* ]]; then + echo "::error::Tag (${TAG_VERSION}) must be ${FW_VERSION} or ${RC_PREFIX}" + exit 1 + fi + + RC_NUMBER="${TAG_VERSION#"${RC_PREFIX}"}" + if ! [[ "$RC_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid release-candidate tag (${TAG_VERSION}); expected ${RC_PREFIX}" + exit 1 + fi + IS_PRERELEASE=true + fi + + { + echo "fw_version=${FW_VERSION}" + echo "tag_name=${GITHUB_REF_NAME}" + echo "is_prerelease=${IS_PRERELEASE}" + } >> "$GITHUB_OUTPUT" + + - name: Require green CI on the tagged commit + env: + GH_TOKEN: ${{ github.token }} + run: | + # A tag on a red (or untested) commit must not produce release + # artifacts. The tagged SHA already ran the CI workflow on its + # branch push; require that run to exist and have succeeded. + CONCLUSION=$(gh run list --repo "$GITHUB_REPOSITORY" \ + --workflow CI --commit "$GITHUB_SHA" \ + --json status,conclusion \ + --jq '[.[] | select(.status == "completed")] | map(.conclusion) | first') + echo "CI conclusion for $GITHUB_SHA: ${CONCLUSION:-none}" + if [ "$CONCLUSION" != "success" ]; then + echo "::error::No successful CI run found for ${GITHUB_SHA} — refusing to release." exit 1 fi @@ -49,14 +83,29 @@ jobs: needs: validate runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - variant: full + cmake_flags: "" + - variant: bitcoin-only + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - - uses: actions/checkout@v6 - with: - submodules: recursive + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Init required submodules + run: | + git submodule update --init deps/crypto/trezor-firmware + git submodule update --init deps/device-protocol + git submodule update --init --recursive deps/python-keepkey + git submodule update --init deps/googletest + git submodule update --init deps/qrenc/QR-Code-generator + git submodule update --init deps/sca-hardening/SecAESSTM32 - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -71,7 +120,7 @@ jobs: if: steps.cache-base.outputs.cache-hit == 'true' run: docker load -i /tmp/base-image.tar - - name: Cross-compile firmware + - name: Cross-compile firmware (${{ matrix.variant }}) run: | docker run --rm \ -v ${{ github.workspace }}:/root/keepkey-firmware:z \ @@ -79,18 +128,31 @@ jobs: mkdir /root/build && cd /root/build && \ cmake -C /root/keepkey-firmware/cmake/caches/device.cmake /root/keepkey-firmware \ -DCMAKE_BUILD_TYPE=MinSizeRel \ - -DCMAKE_COLOR_MAKEFILE=ON && \ + -DCMAKE_COLOR_MAKEFILE=ON \ + ${{ matrix.cmake_flags }} && \ make && \ mkdir -p /root/keepkey-firmware/release && \ cp bin/firmware.keepkey.bin /root/keepkey-firmware/release/ && \ cp bin/firmware.keepkey.elf /root/keepkey-firmware/release/ && \ cp bin/bootloader.bin /root/keepkey-firmware/release/ 2>/dev/null || true && \ + find . -name '*.su' -print0 | tar czf /root/keepkey-firmware/release/stack-usage.tgz --null -T - && \ chmod -R a+rw /root/keepkey-firmware/release" + # Same SRAM budget gate CI enforces (rc8 boot-fault class): release + # artifacts must clear it too, not just the 16 KiB linker ASSERT. + - name: SRAM budget gate (${{ matrix.variant }}) + run: | + pip install --quiet pyelftools + python3 tools/check_sram_budget.py \ + --elf release/firmware.keepkey.elf \ + --su-tar release/stack-usage.tgz \ + --budgets tools/sram-budgets.json \ + --variant "${{ matrix.variant }}" + - name: Compute hashes working-directory: release run: | - echo "# KeepKey Firmware v${{ needs.validate.outputs.fw_version }} — Hash Manifest" > HASHES.txt + echo "# KeepKey Firmware v${{ needs.validate.outputs.fw_version }} (${{ matrix.variant }}) — Hash Manifest" > HASHES.txt echo "" >> HASHES.txt for f in *.bin; do [ -f "$f" ] || continue @@ -109,15 +171,33 @@ jobs: working-directory: release run: | VER="${{ needs.validate.outputs.fw_version }}" - [ -f firmware.keepkey.bin ] && mv firmware.keepkey.bin "firmware.keepkey.v${VER}.bin" - [ -f firmware.keepkey.elf ] && mv firmware.keepkey.elf "firmware.keepkey.v${VER}.elf" - [ -f bootloader.bin ] && mv bootloader.bin "bootloader.v${VER}.bin" + VARIANT="${{ matrix.variant }}" + [ -f firmware.keepkey.bin ] && mv firmware.keepkey.bin "firmware.keepkey.v${VER}-${VARIANT}.bin" + [ -f firmware.keepkey.elf ] && mv firmware.keepkey.elf "firmware.keepkey.v${VER}-${VARIANT}.elf" + [ -f bootloader.bin ] && mv bootloader.bin "bootloader.v${VER}-${VARIANT}.bin" + mv HASHES.txt "HASHES-${VARIANT}.txt" ls -lh + - name: Record build provenance + working-directory: release + run: | + VARIANT="${{ matrix.variant }}" + { + echo "firmware_commit=${GITHUB_SHA}" + echo "source_repository=${GITHUB_REPOSITORY}" + echo "source_ref=${GITHUB_REF}" + echo "workflow_ref=${GITHUB_WORKFLOW_REF}" + echo "builder_image=${BASE_IMAGE}" + echo "runner_image=${ImageOS:-unknown}" + echo "variant=${VARIANT}" + echo "cmake_flags=${{ matrix.cmake_flags }}" + } > "PROVENANCE-${VARIANT}.txt" + cat "PROVENANCE-${VARIANT}.txt" + - name: Upload release artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: release-firmware + name: release-firmware-${{ matrix.variant }} path: release/* retention-days: 90 @@ -125,14 +205,29 @@ jobs: needs: validate runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - variant: full + cmake_flags: "" + - variant: bitcoin-only + cmake_flags: "-DKK_BITCOIN_ONLY=ON" steps: - - uses: actions/checkout@v6 - with: - submodules: recursive + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Init required submodules + run: | + git submodule update --init deps/crypto/trezor-firmware + git submodule update --init deps/device-protocol + git submodule update --init --recursive deps/python-keepkey + git submodule update --init deps/googletest + git submodule update --init deps/qrenc/QR-Code-generator + git submodule update --init deps/sca-hardening/SecAESSTM32 - name: Cache base image id: cache-base - uses: actions/cache@v5 + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: /tmp/base-image.tar key: base-image-${{ env.BASE_IMAGE }} @@ -147,10 +242,12 @@ jobs: if: steps.cache-base.outputs.cache-hit == 'true' run: docker load -i /tmp/base-image.tar - - name: Build and test emulator + - name: Build and test emulator (${{ matrix.variant }}) run: | - docker build -t kkemu-release -f scripts/emulator/Dockerfile . - docker run --rm --entrypoint /bin/sh kkemu-release \ + docker build -t kkemu-release-${{ matrix.variant }} \ + --build-arg coinsupport="${{ matrix.cmake_flags }}" \ + -f scripts/emulator/Dockerfile . + docker run --rm --entrypoint /bin/sh kkemu-release-${{ matrix.variant }} \ -c "make xunit; RC=\$?; exit \$RC" create-release: @@ -158,35 +255,47 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Download firmware artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: release-firmware + pattern: release-firmware-* path: artifacts + merge-multiple: true - name: Prepare release assets run: | mkdir -p release-assets - cp artifacts/*.bin artifacts/*.elf artifacts/HASHES.txt release-assets/ + cp artifacts/*.bin artifacts/*.elf artifacts/HASHES-*.txt \ + artifacts/PROVENANCE-*.txt release-assets/ ls -lh release-assets/ - name: Generate release body run: | VER="${{ needs.validate.outputs.fw_version }}" cat > release-body.md <.txt \`\`\` - > **DRAFT** — firmware must be signed by 3/5 key holders before publishing. + > **DRAFT TEST CANDIDATE** — RC artifacts are unsigned and intended for + > release-candidate testing. Firmware must be signed by 3/5 key holders + > before publishing a production release. - ### Signing Checklist + ### Signing Checklist (per variant) - [ ] Built on multiple machines, hashes match - [ ] Signed on air-gapped machine (3/5 signers) - [ ] Storage upgrade tested on production device @@ -195,10 +304,11 @@ jobs: EOF - name: Create draft release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: draft: true - name: "Firmware v${{ needs.validate.outputs.fw_version }}" + prerelease: ${{ needs.validate.outputs.is_prerelease }} + name: "Firmware ${{ needs.validate.outputs.tag_name }}" body_path: release-body.md files: release-assets/* fail_on_unmatched_files: true diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..87c0d8489 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,36 @@ +title = "KeepKey firmware Gitleaks configuration" + +[extend] +useDefault = true + +[[allowlists]] +description = "Published AES test vectors in current and historical trezor-crypto layouts" +targetRules = ["generic-api-key"] +condition = "AND" +regexTarget = "line" +paths = [ + '''^deps/crypto/trezor-firmware/crypto/aes/aestst\.c$''', + '''^deps/crypto/trezor-crypto/aes/aestst\.c$''', +] +regexes = ['''(?i)^[[:space:]]*//[[:space:]]*key[[:space:]]*=[[:space:]]*[0-9a-f]+[[:space:]]*$'''] + +[[allowlists]] +description = "Ed25519 C type names in current and historical trezor-crypto layouts" +targetRules = ["generic-api-key"] +condition = "AND" +regexTarget = "line" +paths = [ + '''^deps/crypto/trezor-firmware/crypto/ed25519-donna/ed25519-(blake2b|keccak|sha3)\.h$''', + '''^deps/crypto/trezor-firmware/crypto/ed25519-donna/ed25519\.[ch]$''', + '''^deps/crypto/trezor-crypto/ed25519-donna/ed25519-(blake2b|keccak|sha3)\.h$''', + '''^deps/crypto/trezor-crypto/ed25519-donna/ed25519\.[ch]$''', +] +regexes = ['''ed25519.*secret_key.*signature'''] + +[[allowlists]] +description = "RC21 release provenance names an exact public python-keepkey git commit" +targetRules = ["generic-api-key"] +condition = "AND" +regexTarget = "line" +paths = ['''^docs/security/7\.15\.0-rc21-clearsign-release-control\.md$'''] +regexes = ['''^[[:space:]]*-[[:space:]]*python-keepkey:[[:space:]]*`c406a1ba9120da410c356dbff7f4d4bd1e1758fa`\.[[:space:]]*$'''] diff --git a/.gitmodules b/.gitmodules index 2d6c4446a..a6fdbfb17 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,10 +1,10 @@ [submodule "deps/device-protocol"] path = deps/device-protocol -url = https://github.com/keepkey/device-protocol.git -branch = master + url = https://github.com/BitHighlander/device-protocol.git +branch = up/release-protocol [submodule "deps/trezor-firmware"] path = deps/crypto/trezor-firmware -url = https://github.com/keepkey/trezor-firmware.git +url = https://github.com/BitHighlander/trezor-firmware.git [submodule "googletest"] path = deps/googletest url = https://github.com/google/googletest.git @@ -13,8 +13,8 @@ path = code-signing-keys url = https://github.com/keepkey/code-signing-keys.git [submodule "deps/python-keepkey"] path = deps/python-keepkey -url = https://github.com/keepkey/python-keepkey.git -branch = master +url = https://github.com/BitHighlander/python-keepkey.git +branch = reconcile/upstream-sync [submodule "deps/qrenc/QR-Code-generator"] path = deps/qrenc/QR-Code-generator url = https://github.com/keepkey/QR-Code-generator.git diff --git a/CMakeLists.txt b/CMakeLists.txt index cee2e661e..dd7413452 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ endif() project( KeepKeyFirmware - VERSION 7.14.1 + VERSION 7.15.0 LANGUAGES C CXX ASM) set(BOOTLOADER_MAJOR_VERSION 2) @@ -20,6 +20,17 @@ option(KK_EMULATOR "Build the emulator" OFF) option(KK_BUILD_DYLIB "Build libkkemu shared library (.dylib/.so)" OFF) option(KK_DEBUG_LINK "Build with debug-link enabled" OFF) option(KK_BUILD_FUZZERS "Build the fuzzers?" OFF) +option(KK_BITCOIN_ONLY "Build Bitcoin-only firmware (strip all non-BTC coins)" OFF) +# Zcash shielded/Orchard support is part of the regular firmware. It is an +# internal compile selection, not a third release variant: bitcoin-only strips +# the Zcash coin and privacy engine; every regular device/emulator build ships +# both. The open constant-time Pallas audit finding remains a release gate for +# RC18, but it must not silently change the product being audited. +if(KK_BITCOIN_ONLY) + set(KK_ZCASH_PRIVACY OFF) +else() + set(KK_ZCASH_PRIVACY ON) +endif() # When building the dylib, every static lib it links (kkfirmware, kkboard, # trezorcrypto, kkrand, kktransport, qrcodegenerator, SecAESSTM32, ...) must @@ -62,7 +73,7 @@ endif() if(NOT EXISTS ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto/Makefile) message( FATAL_ERROR - " trezor-crypto missing. Need to 'git submodule update --init --recursive" + "trezor-crypto fork missing. Run 'git submodule update --init deps/crypto/trezor-firmware'" ) endif() @@ -84,6 +95,13 @@ endif() if(${KK_EMULATOR}) add_definitions(-DEMULATOR) add_definitions(-DCONFIDENTIAL=) + # macOS/BSD declare strlcpy/strlcat in ; glibc (Linux) and MinGW + # (Windows) do not. Force-include the prototypes so the ~20 call sites build + # without -Werror=implicit-function-declaration (definitions come from + # lib/board/strlcpy.c + strlcat.c). Apple already has them in . + if(NOT APPLE) + add_compile_options(-include ${CMAKE_SOURCE_DIR}/include/keepkey/board/bsd_compat.h) + endif() else() add_definitions(-DCONFIDENTIAL=__attribute__\(\(section\("confidential"\)\)\)) endif() @@ -97,13 +115,21 @@ add_definitions(-DED25519_FORCE_32BIT=1) add_definitions(-DUSE_PRECOMPUTED_CP=0) -add_definitions(-DUSE_ETHEREUM=1) +if(${KK_BITCOIN_ONLY}) + # Bitcoin-only: strip the coin-specific trezor-crypto primitives whose only + # callers (ethereum.c / nano.c) are compiled out below. KECCAK stays on -- + # marginal size win, and it is a generic hash we don't want to risk. + add_definitions(-DUSE_ETHEREUM=0) + add_definitions(-DUSE_NANO=0) +else() + add_definitions(-DUSE_ETHEREUM=1) + add_definitions(-DUSE_NANO=1) +endif() add_definitions(-DUSE_KECCAK=1) add_definitions(-DUSE_GRAPHENE=0) add_definitions(-DUSE_CARDANO=0) add_definitions(-DUSE_MONERO=0) add_definitions(-DUSE_NEM=0) -add_definitions(-DUSE_NANO=1) add_definitions(-DRAND_PLATFORM_INDEPENDENT=0) @@ -123,12 +149,34 @@ add_definitions(-DBIP39_WORDLIST_PADDED=1) add_definitions(-DAES_128=1) +# NOTE: AES table size is selected per release product below. The regular +# image includes Zcash and its Pallas curve arithmetic, so it uses the smaller +# AES tables to preserve flash headroom. Bitcoin-only keeps FOUR_TABLES AES. + if(${KK_DEBUG_LINK}) add_definitions(-DDEBUG_LINK=1) else() add_definitions(-DDEBUG_LINK=0) endif() +# Value macros (always defined 0/1) -- device builds use -Wundef -Werror, so an +# undefined identifier in `#if` is a hard error. Guard code with `#if FLAG`. +if(${KK_BITCOIN_ONLY}) + add_definitions(-DBITCOIN_ONLY=1) +else() + add_definitions(-DBITCOIN_ONLY=0) +endif() + +if(${KK_ZCASH_PRIVACY}) + add_definitions(-DZCASH_PRIVACY=1) + # The Orchard engine leaves the regular image tightest on flash; shrink the + # Gladman AES lookup tables from 4KB to 1KB each (-15,360 bytes ROM, + # slightly slower AES). Bitcoin-only keeps the fast FOUR_TABLES. + add_definitions(-DAES_SMALL_TABLES) +else() + add_definitions(-DZCASH_PRIVACY=0) +endif() + if("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") add_definitions(-DDEBUG_ON) add_definitions(-DMEMORY_PROTECT=0) @@ -161,6 +209,12 @@ if(NOT ${KK_EMULATOR}) link_directories(${LIBOPENCM3_PATH}/lib) include_directories(${LIBOPENCM3_PATH}/include) + # Emit per-function stack-frame sizes (.su files) on device builds. CI's + # SRAM budget gate (tools/check_sram_budget.py) reports the largest frames + # and fails when the linker-asserted stack reserve minus the largest frame + # leaves less than the configured margin. See tools/firmware/keepkey.ld. + add_compile_options(-fstack-usage) + # Dummy empty libraries for stack smashing protection support, since we # implement __stack_chk_guard and __stack_chk_fail ourselves. file(WRITE ${CMAKE_BINARY_DIR}/ssp.c "") diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..a5a41ad99 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# Convenience targets — mirrors CI jobs so failures are caught locally. +# +# CI pins clang-format-20. Use that version if available, otherwise fall back. +# To install: brew install llvm@20 or apt-get install clang-format-20 +CLANG_FORMAT ?= $(shell command -v clang-format-20 2>/dev/null || echo clang-format) + +# Directories and exclusions must match .github/workflows/ci.yml lint-format job. +LINT_DIRS := include/keepkey lib/firmware lib/board lib/transport/src +LINT_SOURCES := $(shell find $(LINT_DIRS) -name '*.c' -o -name '*.h' 2>/dev/null \ + | grep -v generated | grep -v '\.pb\.') + +.PHONY: lint format help + +## lint: Check formatting (same rules as CI). Exits non-zero on any violation. +lint: + @echo "clang-format version: $$($(CLANG_FORMAT) --version)" + @FAILED=0; \ + for f in $(LINT_SOURCES); do \ + if ! $(CLANG_FORMAT) --style=file --dry-run --Werror "$$f" 2>/dev/null; then \ + echo " NEEDS FORMAT: $$f"; \ + FAILED=1; \ + fi; \ + done; \ + if [ "$$FAILED" = "1" ]; then \ + echo ""; \ + echo "Run 'make format' to fix all files."; \ + exit 1; \ + else \ + echo "All files pass clang-format check."; \ + fi + +## format: Auto-fix formatting in-place for all source files. +format: + @echo "Formatting $(LINT_DIRS)..." + @for f in $(LINT_SOURCES); do \ + $(CLANG_FORMAT) --style=file -i "$$f"; \ + done + @echo "Done. Review changes with: git diff" + +## help: List available targets. +help: + @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## / make /' diff --git a/cmake/caches/device.cmake b/cmake/caches/device.cmake index 436a23bce..8265191dc 100644 --- a/cmake/caches/device.cmake +++ b/cmake/caches/device.cmake @@ -45,7 +45,14 @@ set(WARN_FLAGS -Werror") -set(KK_C_FLAGS "${ARCH_FLAGS} -std=gnu99 ${WARN_FLAGS}" CACHE STRING "") +# Newlib's snprintf unconditionally links the float engine (_svfprintf_r, +# _dtoa_r, soft-double libgcc, malloc) — ~22 KB of ROM with zero %f users in +# the firmware. Route all callers to the integer-only siprintf family instead. +# %lld/%llu still work (this toolchain's libc.a compiles the integer engine +# with long-long support). Device builds only; host/emulator keep libc printf. +set(PRINTF_FLAGS "-Dsnprintf=sniprintf -Dvsnprintf=vsniprintf") + +set(KK_C_FLAGS "${ARCH_FLAGS} -std=gnu99 ${WARN_FLAGS} ${PRINTF_FLAGS}" CACHE STRING "") set(KK_CXX_FLAGS "${ARCH_FLAGS} -std=gnu++11 ${WARN_FLAGS} \ -fno-exceptions \ -fno-rtti \ diff --git a/cmake/toolchains/mingw-w64-x86_64.cmake b/cmake/toolchains/mingw-w64-x86_64.cmake new file mode 100644 index 000000000..974f1a49c --- /dev/null +++ b/cmake/toolchains/mingw-w64-x86_64.cmake @@ -0,0 +1,40 @@ +# MinGW-w64 cross-compile toolchain for the Windows emulator DLL (libkkemu.dll, +# x86_64). Lets us cross-build the Windows DLL from the existing macOS/Linux +# emulator build host — no Windows runner required. +# +# Usage: +# cmake -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/mingw-w64-x86_64.cmake \ +# -DKK_EMULATOR=ON -DKK_BUILD_DYLIB=ON -DKK_DEBUG_LINK=ON ... +# cmake --build --target kkemulator_dylib +# +# Install MinGW: `brew install mingw-w64` (macOS) / `apt-get install mingw-w64`. +# +# Only the kkemulator_dylib target is meant to cross-compile. The standalone +# UDP `kkemu` binary is gated out on Windows (tools/emulator/CMakeLists.txt). + +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_PROCESSOR x86_64) + +set(TOOLCHAIN_PREFIX x86_64-w64-mingw32) +find_program(CMAKE_C_COMPILER NAMES ${TOOLCHAIN_PREFIX}-gcc) +find_program(CMAKE_CXX_COMPILER NAMES ${TOOLCHAIN_PREFIX}-g++) +find_program(CMAKE_RC_COMPILER NAMES ${TOOLCHAIN_PREFIX}-windres) + +if(NOT CMAKE_C_COMPILER) + message(FATAL_ERROR + "${TOOLCHAIN_PREFIX}-gcc not found. Install MinGW-w64 " + "(brew install mingw-w64 / apt-get install mingw-w64).") +endif() + +# Derive the target sysroot from the compiler location so this works across +# Homebrew versions and Linux package layouts. +get_filename_component(_kk_cc "${CMAKE_C_COMPILER}" REALPATH) +get_filename_component(_kk_bin "${_kk_cc}" DIRECTORY) +get_filename_component(_kk_root "${_kk_bin}/.." ABSOLUTE) +set(CMAKE_FIND_ROOT_PATH "${_kk_root}/${TOOLCHAIN_PREFIX}") + +# Find host programs on the host; libraries/headers in the target sysroot. +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) diff --git a/deps/crypto/CMakeLists.txt b/deps/crypto/CMakeLists.txt index cd735668c..d726b39bc 100644 --- a/deps/crypto/CMakeLists.txt +++ b/deps/crypto/CMakeLists.txt @@ -68,7 +68,6 @@ set(sources include_directories( ${CMAKE_CURRENT_SOURCE_DIR}/trezor-firmware/crypto - ${CMAKE_CURRENT_SOURCE_DIR}/trezor-firmware/ ${CMAKE_CURRENT_SOURCE_DIR}/trezor-firmware/crypto/ed25519-donna ${OPENSSL_INCLUDE_DIR}) diff --git a/deps/crypto/trezor-firmware b/deps/crypto/trezor-firmware index 03d8a55a8..cdc05bebe 160000 --- a/deps/crypto/trezor-firmware +++ b/deps/crypto/trezor-firmware @@ -1 +1 @@ -Subproject commit 03d8a55a832fb61bb89477ef7239a80ecb367080 +Subproject commit cdc05bebe9e6989cf711e1b5bea6324fd09f848e diff --git a/deps/device-protocol b/deps/device-protocol index d637b7829..cf308fd5e 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit d637b78291a423fd8119df9935a9365be8a7758e +Subproject commit cf308fd5e76f3f2e02ba38347ceccaa382b08505 diff --git a/deps/python-keepkey b/deps/python-keepkey index fabd6c618..b44f1b367 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit fabd6c6189b7f1b3ea7cbd1d372fc13729761178 +Subproject commit b44f1b367e7b8ce98d88a49b3457443335365d8f diff --git a/docs/README.md b/docs/README.md index e456f6ad5..d2ccbb7ec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,3 +5,4 @@ * [Supported Coins](Coins.md) * [Host Communications](Host.md) * [Release Process](Release.md) +* [7.15.0 RC18 Release Shape](security/7.15.0-rc18-release-shape.md) diff --git a/fuzzer/firmware/CMakeLists.txt b/fuzzer/firmware/CMakeLists.txt index e38b4f8f5..99591be6a 100644 --- a/fuzzer/firmware/CMakeLists.txt +++ b/fuzzer/firmware/CMakeLists.txt @@ -2,7 +2,7 @@ include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/lib/firmware ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(libraries kkfirmware diff --git a/include/keepkey/transport/messages-ethereum.options b/include/keepkey/transport/messages-ethereum.options index 65b6a1a2f..e4a769b7d 100644 --- a/include/keepkey/transport/messages-ethereum.options +++ b/include/keepkey/transport/messages-ethereum.options @@ -49,4 +49,7 @@ Ethereum712TypesValues.eip712data max_size:2048 EthereumTxMetadata.signed_payload max_size:1024 EthereumMetadataAck.display_summary max_size:32 +LoadClearsignSigner.pubkey max_size:33 +LoadClearsignSigner.alias max_size:32 +LoadClearsignSigner.icon max_size:384 diff --git a/include/keepkey/transport/messages-ripple.options b/include/keepkey/transport/messages-ripple.options index b3f2e1987..219014bf6 100644 --- a/include/keepkey/transport/messages-ripple.options +++ b/include/keepkey/transport/messages-ripple.options @@ -6,5 +6,7 @@ RippleSignTx.address_n max_count:8 RipplePayment.destination max_size:36 +RippleSignTx.memo max_size:200 + RippleSignedTx.signature max_size:75 RippleSignedTx.serialized_tx max_size:1024 diff --git a/include/keepkey/transport/messages-solana.options b/include/keepkey/transport/messages-solana.options index ded9d68c1..0886fb61b 100644 --- a/include/keepkey/transport/messages-solana.options +++ b/include/keepkey/transport/messages-solana.options @@ -5,11 +5,16 @@ SolanaAddress.address max_size:64 SolanaTokenInfo.mint max_size:32 SolanaTokenInfo.symbol max_size:13 +SolanaTokenInfo.signature max_size:64 SolanaSignTx.address_n max_count:8 SolanaSignTx.coin_name max_size:21 SolanaSignTx.raw_tx max_size:2048 SolanaSignTx.token_info max_count:4 +SolanaSignTx.schema_payload max_size:256 +SolanaSignTx.schema_signature max_size:64 +SolanaSignTx.token_recipient_owner max_count:4 +SolanaSignTx.token_recipient_owner max_size:32 SolanaSignedTx.signature max_size:64 diff --git a/include/keepkey/transport/messages-thorchain.options b/include/keepkey/transport/messages-thorchain.options index 14cb39b18..c17a9368c 100644 --- a/include/keepkey/transport/messages-thorchain.options +++ b/include/keepkey/transport/messages-thorchain.options @@ -8,6 +8,7 @@ ThorchainSignTx.memo max_size:256 ThorchainMsgSend.from_address max_size:46 ThorchainMsgSend.to_address max_size:46 +ThorchainMsgSend.denom max_size:69 ThorchainMsgDeposit.asset max_size:20 ThorchainMsgDeposit.memo max_size:256 diff --git a/include/keepkey/transport/messages.options b/include/keepkey/transport/messages.options index 525b39161..71944508a 100644 --- a/include/keepkey/transport/messages.options +++ b/include/keepkey/transport/messages.options @@ -27,7 +27,7 @@ PinMatrixAck.pin max_size:10 PassphraseAck.passphrase max_size:51 -Entropy.entropy max_size:1024 +Entropy.entropy max_size:8192 GetPublicKey.address_n max_count:8 GetPublicKey.ecdsa_curve_name max_size:32 @@ -120,6 +120,11 @@ DebugLinkState.recovery_cipher max_size:27 DebugLinkState.recovery_auto_completed_word max_size:12 DebugLinkState.firmware_hash max_size:32 DebugLinkState.storage_hash max_size:32 +DebugLinkState.dice_digest max_size:32 + +# Sized so the decoded struct stays within MSG_TINY_BFR_SZ (64B): the tiny +# message path pb_decodes DebugLinkDecision straight into that buffer. +DebugLinkDecision.input max_size:41 DebugLinkFlashDumpResponse.data max_size:1024 @@ -133,3 +138,11 @@ FlashWrite.data max_size:1024 FlashHashResponse.data max_size:32 Bip85Mnemonic.mnemonic max_size:241 + +# ClearSign attestor. The payload cap is the largest +# KKSOLSC1 schema: magic(8)+version(1)+program(32)+disc(1+8)+2 names(2*21)+ +# args(1+4*18)+accounts(1+4*18) = 238 bytes. +ClearsignAttestorPublicKey.public_key max_size:33 +ClearsignAttestorSign.payload max_size:256 +ClearsignAttestorSignature.signature max_size:64 +ClearsignAttestorSignature.public_key max_size:33 diff --git a/lib/board/CMakeLists.txt b/lib/board/CMakeLists.txt index 10c30b424..bb2c376dc 100644 --- a/lib/board/CMakeLists.txt +++ b/lib/board/CMakeLists.txt @@ -51,7 +51,7 @@ endif() include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto ${CMAKE_CURRENT_SOURCE_DIR}) add_library(kkboard ${sources}) diff --git a/lib/emulator/CMakeLists.txt b/lib/emulator/CMakeLists.txt index fa5909a17..68cef0295 100644 --- a/lib/emulator/CMakeLists.txt +++ b/lib/emulator/CMakeLists.txt @@ -34,7 +34,14 @@ if(${KK_EMULATOR}) target_include_directories(kkemulator_dylib PRIVATE ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) + # libkkemu.c runs the firmware event loop on a dedicated thread (POSIX + # pthreads; Win32 CreateThread under MinGW). Threads::Threads is a no-op on + # the Win32 path and pulls in -lpthread on POSIX. Use the PLAIN signature — + # tools/emulator/CMakeLists.txt links this same target plainly, and CMake + # forbids mixing keyword (PRIVATE) and plain target_link_libraries calls. + find_package(Threads REQUIRED) + target_link_libraries(kkemulator_dylib Threads::Threads) set_target_properties(kkemulator_dylib PROPERTIES OUTPUT_NAME "kkemu" POSITION_INDEPENDENT_CODE ON) diff --git a/lib/firmware/CMakeLists.txt b/lib/firmware/CMakeLists.txt index f23ebcfb8..d9f0d7a19 100644 --- a/lib/firmware/CMakeLists.txt +++ b/lib/firmware/CMakeLists.txt @@ -49,7 +49,7 @@ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/scm_revision.h.in" include_directories( ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto ${CMAKE_SOURCE_DIR}/lib/firmware ${CMAKE_CURRENT_BINARY_DIR}) diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index ea3072b07..186810161 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -531,8 +531,7 @@ void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg) { /* Ed25519 sign */ uint8_t sig[SOL_SIG_SIZE]; - ed25519_sign(msg->message.bytes, msg->message.size, node->private_key, - node->public_key + 1, sig); + ed25519_sign(msg->message.bytes, msg->message.size, node->private_key, sig); resp->has_signature = true; resp->signature.size = SOL_SIG_SIZE; diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index abadf4cfe..94952a0f3 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -665,8 +665,7 @@ bool solana_signTx(const HDNode* node, const SolanaSignTx* msg, /* Ed25519 sign the raw transaction message directly * (Solana signs the serialized message, not a hash of it) */ uint8_t sig[SOL_SIG_SIZE]; - ed25519_sign(msg->raw_tx.bytes, msg->raw_tx.size, node->private_key, - node->public_key + 1, sig); + ed25519_sign(msg->raw_tx.bytes, msg->raw_tx.size, node->private_key, sig); resp->has_signature = true; resp->signature.size = SOL_SIG_SIZE; @@ -739,7 +738,7 @@ bool solana_offchain_message_sign(const HDNode* node, off += msg->message.size; uint8_t sig[SOL_SIG_SIZE]; - ed25519_sign(envelope, off, node->private_key, node->public_key + 1, sig); + ed25519_sign(envelope, off, node->private_key, sig); resp->has_public_key = true; resp->public_key.size = SOL_PUBKEY_SIZE; diff --git a/lib/firmware/ton.c b/lib/firmware/ton.c index 043e41c9d..354d7a4b4 100644 --- a/lib/firmware/ton.c +++ b/lib/firmware/ton.c @@ -240,7 +240,7 @@ bool ton_signTx(const HDNode* node, const TonSignTx* msg, TonSignedTx* resp) { // Ed25519 sign the transaction ed25519_signature signature; ed25519_sign(msg->raw_tx.bytes, msg->raw_tx.size, node->private_key, - &node->public_key[1], signature); + signature); // Copy signature to response (64 bytes) resp->has_signature = true; @@ -273,7 +273,7 @@ bool ton_message_sign(const HDNode* node, const TonSignMessage* msg, ed25519_signature signature; ed25519_sign(msg->message.bytes, msg->message.size, node->private_key, - &node->public_key[1], signature); + signature); resp->has_public_key = true; resp->public_key.size = 32; diff --git a/scripts/build/docker/device/debug.sh b/scripts/build/docker/device/debug.sh index 0150e9138..8e0ca528d 100755 --- a/scripts/build/docker/device/debug.sh +++ b/scripts/build/docker/device/debug.sh @@ -3,7 +3,7 @@ KEEPKEY_FIRMWARE="$(dirname "$(dirname "$(dirname "$(dirname "$( cd "$(dirname "$0")" ; pwd -P )")")")")" cd $KEEPKEY_FIRMWARE -IMAGETAG=kktech/firmware:v15 +IMAGETAG=kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 docker image inspect $IMAGETAG > /dev/null || docker pull $IMAGETAG diff --git a/scripts/build/docker/device/release.sh b/scripts/build/docker/device/release.sh index 18cfe53dc..86ce7dc1d 100755 --- a/scripts/build/docker/device/release.sh +++ b/scripts/build/docker/device/release.sh @@ -3,17 +3,22 @@ KEEPKEY_FIRMWARE="$(dirname "$(dirname "$(dirname "$(dirname "$( cd "$(dirname "$0")" ; pwd -P )")")")")" cd $KEEPKEY_FIRMWARE -IMAGETAG=kktech/firmware:v15 +IMAGETAG=kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 docker image inspect $IMAGETAG > /dev/null || docker pull $IMAGETAG +# Extra cmake flags pass straight through. The only alternate release product +# is bitcoin-only: ./release.sh -DKK_BITCOIN_ONLY=ON +EXTRA_CMAKE_FLAGS="$*" + docker run -t \ -v $(pwd):/root/keepkey-firmware:z \ $IMAGETAG /bin/sh -c "\ mkdir /root/build && cd /root/build && \ cmake -C /root/keepkey-firmware/cmake/caches/device.cmake /root/keepkey-firmware \ -DCMAKE_BUILD_TYPE=MinSizeRel \ - -DCMAKE_COLOR_MAKEFILE=ON &&\ + -DCMAKE_COLOR_MAKEFILE=ON \ + ${EXTRA_CMAKE_FLAGS} &&\ make && \ mkdir -p /root/keepkey-firmware/bin && \ cp -r /root/build /root/keepkey-firmware/bin/ && \ diff --git a/scripts/build/docker/emulator/debug.sh b/scripts/build/docker/emulator/debug.sh index 9fec8264d..e4ad8ace0 100755 --- a/scripts/build/docker/emulator/debug.sh +++ b/scripts/build/docker/emulator/debug.sh @@ -3,7 +3,7 @@ KEEPKEY_FIRMWARE="$(dirname "$(dirname "$(dirname "$(dirname "$( cd "$(dirname "$0")" ; pwd -P )")")")")" cd $KEEPKEY_FIRMWARE -IMAGETAG=kktech/firmware:v15 +IMAGETAG=kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 docker pull $IMAGETAG diff --git a/scripts/emulator/Dockerfile b/scripts/emulator/Dockerfile index 53c6bdd99..81e325ee8 100644 --- a/scripts/emulator/Dockerfile +++ b/scripts/emulator/Dockerfile @@ -1,4 +1,4 @@ -FROM kktech/firmware:v15 +FROM kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 WORKDIR /kkemu COPY ./ /kkemu @@ -17,4 +17,3 @@ RUN make -j EXPOSE 11044/udp 11045/udp EXPOSE 5000 CMD ["/kkemu/scripts/emulator/run.sh"] - diff --git a/scripts/emulator/python-keepkey-tests.sh b/scripts/emulator/python-keepkey-tests.sh index 28b122fc9..489a64674 100755 --- a/scripts/emulator/python-keepkey-tests.sh +++ b/scripts/emulator/python-keepkey-tests.sh @@ -17,6 +17,11 @@ done cd deps/python-keepkey/tests +# The tests run from this directory, while keepkeylib lives one level up. +# Make that package root explicit so direct imports work consistently in the +# standalone container (including tests collected before common.py is loaded). +export PYTHONPATH="..${PYTHONPATH:+:$PYTHONPATH}" + # Diagnostic: verify SCREENSHOT flag reaches Python echo "=== Pre-flight diagnostic ===" KEEPKEY_SCREENSHOT=1 python3 -c " @@ -41,9 +46,14 @@ echo "=== End diagnostic ===" # expression for every test with non-empty screenshot expectations. Adding screenshots # to a test in SECTIONS automatically includes it here — no manual filter maintenance. echo "=== Phase 1: Report-driven screenshot capture ===" -# Detect firmware version from CMakeLists if not set in env +# Detect firmware version from CMakeLists if not set in env. +# NOTE: grep -oE (POSIX ERE), NOT -oP — this runs in the Alpine/busybox +# python-keepkey container where grep has no -P (PCRE). With -P grep errored +# and the version silently fell back to 7.14.0, so every 7.15.0 section +# (Hive, EVM clear-signing) was excluded from screenshot capture. if [ -z "$FW_VERSION" ]; then - FW_VERSION=$(sed -n '/^project/,/)/p' /kkemu/CMakeLists.txt | grep -oP '\d+\.\d+\.\d+' || echo "7.14.0") + FW_VERSION=$(sed -n '/^project/,/)/p' /kkemu/CMakeLists.txt | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + [ -z "$FW_VERSION" ] && FW_VERSION="7.14.0" echo "Detected FW_VERSION=$FW_VERSION from CMakeLists.txt" fi export FW_VERSION @@ -55,6 +65,7 @@ fi echo "Filter: $SCREENSHOT_FILTER" KEEPKEY_SCREENSHOT=1 \ SCREENSHOT_DIR=/kkemu/test-reports/screenshots \ +KK_EXPECT_PERSIST_REJECTED=1 \ KK_TRANSPORT_MAIN=kkemu:11044 \ KK_TRANSPORT_DEBUG=kkemu:11045 \ pytest -v --tb=short \ @@ -82,18 +93,44 @@ fi # Tests that skip via requires_message/requires_firmware are OK. # Tests that fail or are missing from JUnit = CI failure. echo "=== Phase 2: Full test suite ===" +set +e +KK_EXPECT_PERSIST_REJECTED=1 \ +KK_EXPECT_ENTROPY_BUDGET=1 \ KK_TRANSPORT_MAIN=kkemu:11044 \ KK_TRANSPORT_DEBUG=kkemu:11045 \ pytest -v --junitxml=/kkemu/test-reports/python-keepkey/junit.xml PYTEST_RC=$? +echo "=== Phase 2: Validate report catalog ===" +python3 ../scripts/generate-test-report.py \ + --junit=/kkemu/test-reports/python-keepkey/junit.xml \ + ${FW_VERSION:+--fw-version=$FW_VERSION} \ + --validate-junit +CATALOG_RC=$? + echo "=== Phase 2: Generate test report ===" python3 ../scripts/generate-test-report.py \ --junit=/kkemu/test-reports/python-keepkey/junit.xml \ - ${FW_VERSION:+--fw-version=$FW_VERSION} || true + ${FW_VERSION:+--fw-version=$FW_VERSION} \ + --screenshots=/kkemu/test-reports/screenshots \ + --output=/kkemu/test-reports/test-report.pdf +REPORT_RC=$? +set -e -echo "$PYTEST_RC" > /kkemu/test-reports/python-keepkey/status +if [ "$PYTEST_RC" -eq 0 ] && [ "$CATALOG_RC" -eq 0 ] && [ "$REPORT_RC" -eq 0 ]; then + echo "0" > /kkemu/test-reports/python-keepkey/status +else + echo "1" > /kkemu/test-reports/python-keepkey/status +fi if [ "$PYTEST_RC" -ne 0 ]; then echo "pytest failed with exit code $PYTEST_RC" exit "$PYTEST_RC" fi +if [ "$CATALOG_RC" -ne 0 ]; then + echo "report catalog validation failed with exit code $CATALOG_RC" + exit "$CATALOG_RC" +fi +if [ "$REPORT_RC" -ne 0 ]; then + echo "test report generation failed with exit code $REPORT_RC" + exit "$REPORT_RC" +fi diff --git a/scripts/emulator/python-keepkey.Dockerfile b/scripts/emulator/python-keepkey.Dockerfile index f67f9ba0d..0ba90447e 100644 --- a/scripts/emulator/python-keepkey.Dockerfile +++ b/scripts/emulator/python-keepkey.Dockerfile @@ -1,4 +1,4 @@ -FROM kktech/firmware:v15 +FROM kktech/firmware@sha256:7438e53933d47d53157ed6d96d864cb208597e62dce26235ace09d1063427fa2 # Extra Python deps needed by tests that aren't in the shared base image. # - rlp + eth-keys + eth-utils: build the canonical EIP-1559 type-2 pre-image diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 2803adce8..fe772f7d5 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -23,6 +23,15 @@ def main(): file=sys.stderr) sys.exit(1) + # The release report is evidence, not a best-effort decoration. The + # canonical Python JUnit must exist; otherwise rendering an empty catalog + # produces a dangerously plausible "all pending" PDF. + python_junit = 'test-reports/python-keepkey/junit.xml' + if not os.path.isfile(python_junit) or os.path.getsize(python_junit) == 0: + print("ERROR: required Python JUnit evidence missing: %s" % python_junit, + file=sys.stderr) + sys.exit(1) + # Collect JUnit XMLs from CI artifacts junit_files = ( glob.glob('test-reports/python-keepkey/junit*.xml') + @@ -65,9 +74,9 @@ def main(): print("Running: %s" % ' '.join(cmd)) result = subprocess.run(cmd) - # Don't exit non-zero -- let the report be uploaded even with partial results if result.returncode != 0: - print("WARN: report generator exited %d" % result.returncode, file=sys.stderr) + print("ERROR: report generator exited %d" % result.returncode, file=sys.stderr) + sys.exit(result.returncode) if os.path.exists('test-report.pdf'): size = os.path.getsize('test-report.pdf') @@ -76,6 +85,23 @@ def main(): print("ERROR: test-report.pdf not created", file=sys.stderr) sys.exit(1) + # Render first so a failed candidate still has a truthful diagnostic PDF, + # then fail the job if any catalog entry failed or is missing. Deliberate + # feature/policy skips remain valid per the report generator contract. + validate_cmd = [ + sys.executable, + REPORT_GENERATOR, + '--junit=%s' % python_junit, + '--validate-junit', + ] + if fw_version: + validate_cmd.append('--fw-version=%s' % fw_version) + print("Validating: %s" % ' '.join(validate_cmd)) + validation = subprocess.run(validate_cmd) + if validation.returncode != 0: + print("ERROR: report catalog validation failed", file=sys.stderr) + sys.exit(validation.returncode) + if __name__ == '__main__': main() diff --git a/tools/blupdater/CMakeLists.txt b/tools/blupdater/CMakeLists.txt index 990529f70..d5dc99764 100644 --- a/tools/blupdater/CMakeLists.txt +++ b/tools/blupdater/CMakeLists.txt @@ -9,7 +9,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/blupdater.ld) diff --git a/tools/bootloader/CMakeLists.txt b/tools/bootloader/CMakeLists.txt index 60b2c46a8..e1f4180ae 100644 --- a/tools/bootloader/CMakeLists.txt +++ b/tools/bootloader/CMakeLists.txt @@ -10,7 +10,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/bootloader.ld) diff --git a/tools/bootstrap/CMakeLists.txt b/tools/bootstrap/CMakeLists.txt index 9b2853b12..393dee36d 100644 --- a/tools/bootstrap/CMakeLists.txt +++ b/tools/bootstrap/CMakeLists.txt @@ -7,7 +7,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/bootstrap.ld) diff --git a/tools/check_sram_budget.py b/tools/check_sram_budget.py new file mode 100644 index 000000000..8481a2348 --- /dev/null +++ b/tools/check_sram_budget.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""SRAM budget gate for ARM firmware builds. + +Fails CI when the runtime stack/heap reserve — the gap between the end of +static allocation (_ebss) and the top-of-RAM stack (_stack) — drops below the +per-variant budget, or when the largest single stack frame (-fstack-usage) +leaves less than the configured margin inside that reserve. + +Why this exists: RC7's privacy-enabled build shipped with an 11,232-byte gap +while msg_write() carried a 12,416-byte automatic TrezorFrameBuffer — every +USB response overwrote static memory, hard-faulting on boot. The linker also +ASSERTs a 16 KiB floor (tools/firmware/keepkey.ld); this script is the +observability + frame-margin half of that gate. + +Usage: + check_sram_budget.py --elf bin/...firmware.keepkey.elf \ + --su-tar bin/stack-usage.tgz --budgets tools/sram-budgets.json \ + --variant full +""" + +import argparse +import json +import sys +import tarfile + +from elftools.elf.elffile import ELFFile # pip install pyelftools + + +def read_symbols(elf_path): + with open(elf_path, "rb") as f: + elf = ELFFile(f) + symtab = elf.get_section_by_name(".symtab") + if symtab is None: + sys.exit(f"ERROR: {elf_path} has no .symtab") + wanted = {} + for sym in symtab.iter_symbols(): + if sym.name in ("_ebss", "_stack"): + wanted[sym.name] = sym["st_value"] + missing = {"_ebss", "_stack"} - set(wanted) + if missing: + sys.exit(f"ERROR: {elf_path} missing symbols: {sorted(missing)}") + return wanted + + +def largest_frames(su_tar_path, top_n=15): + """Parse GCC -fstack-usage records from a tar of .su files. + + Record format: ":::\t\t" + """ + frames = [] + with tarfile.open(su_tar_path, "r:*") as tar: + for member in tar: + if not member.name.endswith(".su") or not member.isfile(): + continue + data = tar.extractfile(member).read().decode("utf-8", "replace") + for line in data.splitlines(): + parts = line.rsplit("\t", 2) + if len(parts) != 3: + continue + loc, size, qual = parts + try: + frames.append((int(size), loc.split("/")[-1], qual)) + except ValueError: + continue + frames.sort(reverse=True) + return frames[:top_n] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--elf", required=True) + ap.add_argument("--su-tar", required=True) + ap.add_argument("--budgets", required=True) + ap.add_argument("--variant", required=True) + args = ap.parse_args() + + budgets = json.load(open(args.budgets)) + reserve_min = budgets.get("variants", {}).get(args.variant, {}).get( + "reserve_min", budgets["reserve_min"]) + frame_margin = budgets.get("variants", {}).get(args.variant, {}).get( + "frame_margin", budgets["frame_margin"]) + + syms = read_symbols(args.elf) + gap = syms["_stack"] - syms["_ebss"] + + frames = largest_frames(args.su_tar) + if not frames: + # An empty .su archive means -fstack-usage generation broke (or the + # tar glob went stale). Treating it as "largest frame = 0" would let + # the margin check false-pass — fail loudly instead. + sys.exit("ERROR: no -fstack-usage records found in " + f"{args.su_tar} — stack-usage generation is broken; " + "refusing to pass the frame-margin gate without data") + largest = frames[0][0] + + print(f"SRAM budget report — variant: {args.variant}") + print(f" _ebss = 0x{syms['_ebss']:08x}") + print(f" _stack = 0x{syms['_stack']:08x}") + print(f" stack/heap reserve (gap) = {gap:,} B " + f"(budget: >= {reserve_min:,} B)") + print(f" largest stack frame = {largest:,} B " + f"(gap - largest must be >= {frame_margin:,} B)") + print(" top stack frames (-fstack-usage):") + for size, loc, qual in frames: + print(f" {size:7,} B {qual:14s} {loc}") + + failed = False + if gap < reserve_min: + print(f"::error::SRAM gate: reserve {gap:,} B < budget " + f"{reserve_min:,} B for {args.variant}") + failed = True + if gap - largest < frame_margin: + print(f"::error::SRAM gate: reserve minus largest frame " + f"({gap:,} - {largest:,} = {gap - largest:,} B) < margin " + f"{frame_margin:,} B for {args.variant}") + failed = True + + if failed: + sys.exit(1) + print("SRAM budget gate: PASS") + + +if __name__ == "__main__": + main() diff --git a/tools/display_test/CMakeLists.txt b/tools/display_test/CMakeLists.txt index 0d280ab80..86da3d16d 100644 --- a/tools/display_test/CMakeLists.txt +++ b/tools/display_test/CMakeLists.txt @@ -6,7 +6,7 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/display_test.ld) diff --git a/tools/emulator/CMakeLists.txt b/tools/emulator/CMakeLists.txt index 63b80bd88..2882f47a1 100644 --- a/tools/emulator/CMakeLists.txt +++ b/tools/emulator/CMakeLists.txt @@ -6,7 +6,7 @@ if(${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(FIRMWARE_LIBS kkfirmware @@ -21,21 +21,43 @@ if(${KK_EMULATOR}) SecAESSTM32 kkrand) - # Standalone emulator binary (uses UDP sockets) - add_executable(kkemu ${sources}) + # Standalone emulator binary — UDP sockets on :11044/:11045 (used by firmware + # CI: python-keepkey UDP tests + OLED screenshots). NOT built on Windows: it + # depends on BSD sockets + signal(); the vault never uses it — the vault loads + # the dylib/DLL below instead. + if(NOT WIN32) + add_executable(kkemu ${sources}) - # Add linker flags for ARM64 Mac compatibility - if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") - target_link_options(kkemu PRIVATE "-Wl,-no_fixup_chains") - endif() + # Add linker flags for ARM64 Mac compatibility + if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") + target_link_options(kkemu PRIVATE "-Wl,-no_fixup_chains") + endif() - target_link_libraries(kkemu ${FIRMWARE_LIBS} kkemulator) + target_link_libraries(kkemu ${FIRMWARE_LIBS} kkemulator) + endif() - # Shared library (ring buffers, no sockets) for in-process FFI (vault) + # Shared library (ring buffers, no sockets) for in-process FFI — this is what + # the vault loads via bun:ffi (libkkemu.dylib on macOS, .so on Linux, .dll on + # Windows). if(KK_BUILD_DYLIB) - target_link_libraries(kkemulator_dylib ${FIRMWARE_LIBS}) - if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") - target_link_options(kkemulator_dylib PRIVATE "-Wl,-no_fixup_chains") + if(WIN32) + # kkrand and trezorcrypto cross-reference each other (random32 / + # random_uniform). GNU/MinGW ld resolves static archives left-to-right in + # a single pass, so wrap FIRMWARE_LIBS in a linker group. Use the raw + # --start-group/--end-group flags rather than the LINK_GROUP genex: the + # genex needs CMake >= 3.24, but this repo's cmake_minimum_required is + # 3.7.2. macOS ld64 is multi-pass and needs neither. + # Also: MinGW exports nothing from a DLL by default (unlike Mach-O/ELF), + # so export the kkemu_* FFI entry points; and link the Windows CSPRNG + # (bcrypt, BCryptGenRandom) used by emulator/random.c on _WIN32. + target_link_libraries(kkemulator_dylib + -Wl,--start-group ${FIRMWARE_LIBS} -Wl,--end-group bcrypt) + target_link_options(kkemulator_dylib PRIVATE "-Wl,--export-all-symbols") + else() + target_link_libraries(kkemulator_dylib ${FIRMWARE_LIBS}) + if(APPLE AND CMAKE_SYSTEM_PROCESSOR MATCHES "arm64") + target_link_options(kkemulator_dylib PRIVATE "-Wl,-no_fixup_chains") + endif() endif() endif() endif() diff --git a/tools/firmware/CMakeLists.txt b/tools/firmware/CMakeLists.txt index 18c50e411..0db0bf6e2 100644 --- a/tools/firmware/CMakeLists.txt +++ b/tools/firmware/CMakeLists.txt @@ -9,12 +9,14 @@ if(NOT ${KK_EMULATOR}) include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) set(linker_script ${CMAKE_CURRENT_SOURCE_DIR}/keepkey.ld) + # Link map for the CI SRAM report/artifact (pairs with the .ld's 16 KiB + # stack-reserve ASSERT and the -fstack-usage frame report). set(CMAKE_EXE_LINKER_FLAGS - "${CMAKE_EXE_LINKER_FLAGS} -T${linker_script} -L${CMAKE_BINARY_DIR}/lib") + "${CMAKE_EXE_LINKER_FLAGS} -T${linker_script} -L${CMAKE_BINARY_DIR}/lib -Wl,-Map=${CMAKE_BINARY_DIR}/bin/firmware.keepkey.map,--cref") set(LINK_FLAGS kkfirmware diff --git a/tools/sram-budgets.json b/tools/sram-budgets.json new file mode 100644 index 000000000..f5b5b2277 --- /dev/null +++ b/tools/sram-budgets.json @@ -0,0 +1,9 @@ +{ + "_comment": "Per-product SRAM budgets enforced by tools/check_sram_budget.py in CI (and a 16 KiB linker ASSERT in tools/firmware/keepkey.ld). reserve_min = minimum bytes between _ebss and _stack; frame_margin = minimum bytes left after subtracting the largest -fstack-usage frame from the reserve. Initial limits chosen after the RC7 privacy-enabled overflow (11,232 B gap vs a 12,416 B msg_write frame); replace with measured worst-case high-water + margin once hardware instrumentation reports real numbers. Any change to these budgets, and any single-commit SRAM increase above 256 B, needs explicit review.", + "reserve_min": 16384, + "frame_margin": 4096, + "variants": { + "full": {}, + "bitcoin-only": {} + } +} diff --git a/unittests/board/CMakeLists.txt b/unittests/board/CMakeLists.txt index 81e294dec..65fb7c8a3 100644 --- a/unittests/board/CMakeLists.txt +++ b/unittests/board/CMakeLists.txt @@ -5,7 +5,7 @@ set(sources include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) add_executable(board-unit ${sources}) target_link_libraries(board-unit @@ -20,6 +20,6 @@ target_link_libraries(board-unit trezorcrypto qrcodegenerator SecAESSTM32 - kkemulator kkrand + kkemulator kktransport) diff --git a/unittests/crypto/CMakeLists.txt b/unittests/crypto/CMakeLists.txt index 782549f28..4f2390d70 100644 --- a/unittests/crypto/CMakeLists.txt +++ b/unittests/crypto/CMakeLists.txt @@ -5,7 +5,7 @@ set(sources include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) add_executable(crypto-unit ${sources}) target_link_libraries(crypto-unit diff --git a/unittests/firmware/CMakeLists.txt b/unittests/firmware/CMakeLists.txt index 647d72571..5ef9e8489 100644 --- a/unittests/firmware/CMakeLists.txt +++ b/unittests/firmware/CMakeLists.txt @@ -14,7 +14,7 @@ include_directories( ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/lib/firmware ${CMAKE_BINARY_DIR}/include - ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-firmware/crypto) add_executable(firmware-unit ${sources}) target_link_libraries(firmware-unit