Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 107 additions & 26 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,20 +93,48 @@ jobs:
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: |
# PINNED. Tracking releases/latest means a new upstream ruleset can turn
# this gate red with no change to this repository -- which is exactly what
# happened: a newer generic-api-key rule began flagging published BIP32 test
# vectors in 2014/2018 history, and because every build job declares
# `needs: [.., secret-scan]`, the whole build and test graph was SKIPPED
# rather than failed. Bump deliberately, with the scan re-verified. See #424.
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
Expand Down Expand Up @@ -290,6 +324,17 @@ 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
Expand Down Expand Up @@ -338,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 && \
Expand All @@ -363,7 +409,9 @@ jobs:
- name: Upload firmware artifacts
uses: actions/upload-artifact@v7
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
Expand Down Expand Up @@ -412,6 +460,18 @@ jobs:
needs: [lint-format, static-analysis, check-submodules, secret-scan]
runs-on: ubuntu-latest
timeout-minutes: 30
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
Expand All @@ -430,23 +490,31 @@ 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
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=$?

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 ${{ github.workspace }}/test-reports/screenshots -name '*.png' 2>/dev/null | wc -l
find "$REPORT_ROOT/screenshots" -name '*.png' 2>/dev/null | wc -l
echo "PNGs on host"

echo "firmware-unit exit code: $FW_RC"
Expand All @@ -464,23 +532,36 @@ jobs:
uses: actions/upload-artifact@v7
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@v7
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@v4
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: warn

- 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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ jobs:
- name: Prepare release assets
run: |
mkdir -p release-assets
cp artifacts/*.bin artifacts/*.elf artifacts/HASHES.txt release-assets/
cp artifacts/*.bin artifacts/*.elf artifacts/HASHES*.txt release-assets/
ls -lh release-assets/

- name: Generate release body
Expand Down
90 changes: 90 additions & 0 deletions docs/DiceEntropy.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading