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
205 changes: 204 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,208 @@ jobs:
working-directory: scripts/emulator
run: docker compose down -v || true

# ═══════════════════════════════════════════════════════════
# STAGE 3a-bis: DYLIB TESTS — libkkemu shared lib via python-keepkey
# ═══════════════════════════════════════════════════════════
# Builds the firmware emulator as a shared library (libkkemu.dylib on
# macOS / .so on Linux when supported) and runs the dylib-specific
# screenshot regression tests in python-keepkey.
#
# macOS-only for now. The Linux build hits a duplicate-symbol link
# error: __stack_chk_guard is defined by both lib/board/keepkey_board.c
# and lib/emulator/setup.c. The Apple linker silently picks one
# (matching local macos arm64 builds); GNU ld is strict and fails.
# Fixing requires deduping the symbol — out of scope for this PR.
# TODO: enable ubuntu-latest in a follow-up after the symbol cleanup.
#
# Why a separate job from python-integration-tests:
# - Different artifact: .dylib (in-process FFI), not the kkemu
# UDP binary the existing job builds.
# - Different transport in tests: KK_TRANSPORT=dylib instead of UDP.
# - Catches a class of bugs the UDP path hides — the standalone
# kkemu binary has its own poll thread; the dylib does not, so
# caller-driven polling correctness only surfaces here.
#
# Toolchain pinning rationale:
# - protoc 3.21.x (matches protobuf 3.20 wire format the firmware
# 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).
# - KK_DEBUG_LINK=ON (default OFF; without it,
# 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]
runs-on: macos-latest
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Init 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/qrenc/QR-Code-generator
git submodule update --init deps/sca-hardening/SecAESSTM32
# CMakeLists.txt requires googletest unconditionally even when
# we only build the dylib target — the project's add_subdirectory
# for it runs at configure time, before any target selection.
git submodule update --init deps/googletest

- name: Setup Python 3.10
uses: actions/setup-python@v5
with:
python-version: '3.10'

- name: Install pinned Python deps
run: |
python -m pip install --upgrade pip
# Strict version pins — see job-level comment above. `requests`
# is needed by lib/firmware's ethereum_tokens.def build step
# (it fetches token-list JSON at compile time).
pip install "protobuf==3.20.3" "nanopb==0.3.9.4.post3" requests

- name: Install pinned protoc 3.21
run: |
# macos-latest is currently arm64. Pin to that explicitly so a
# future runner image swap doesn't silently switch to x86 and
# break the wire-format expectation downstream.
# Protobuf renamed releases from v3.21.x → v21.x at this point
# (the protoc "version reset"), so the tag and the file name
# both drop the leading 3.
PROTOC_VERSION=21.12
PROTOC_ASSET="protoc-${PROTOC_VERSION}-osx-aarch_64.zip"
curl -sSL -fL -o /tmp/protoc.zip \
"https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/${PROTOC_ASSET}"
# `-f` makes curl fail-fast on HTTP errors so we don't unzip
# a 404 HTML page.
file /tmp/protoc.zip
sudo unzip -o /tmp/protoc.zip -d /usr/local
sudo chmod +x /usr/local/bin/protoc
protoc --version

- name: Build nanopb generator's own .pb2 files
run: |
# The pip-installed nanopb 0.3.9.4.post3 ships nanopb.proto +
# plugin.proto but NOT the generated *_pb2.py files. Without
# them, `nanopb_generator.py` blows up at import time with
# "ImportError: attempted relative import with no known parent
# package" the first time the firmware build invokes it.
#
# We MUST regenerate with the pinned protoc 3.21 — the system
# protoc on github-runners is too new and produces .pb2 files
# that require a newer protobuf runtime than the 3.20.3 we
# pinned (which would fail with "Descriptors cannot be created
# directly. ... please regenerate with protoc >= 3.19.0 ...
# OR downgrade protobuf to 3.20.x").
NANOPB_PROTO_DIR="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator/proto"
cd "$NANOPB_PROTO_DIR"
/usr/local/bin/protoc --python_out=. nanopb.proto
/usr/local/bin/protoc --python_out=. plugin.proto
ls -la nanopb_pb2.py plugin_pb2.py

- name: Verify build tools
run: |
# macos runners pre-install cmake + Xcode CLI tools (clang,
# ld, etc). Just sanity-check the versions; no install needed.
cmake --version
clang --version

- name: Configure cmake (KK_EMULATOR + KK_BUILD_DYLIB + KK_DEBUG_LINK)
run: |
# Two PATH entries are at play, and order matters:
#
# 1. The pip install puts a proper console-script wrapper at
# `<pyenv>/bin/protoc-gen-nanopb` that loads nanopb_generator
# AS A MODULE so relative imports resolve. setup-python
# already adds this bin dir to PATH.
#
# 2. cmake's `find_program(NANOPB_GENERATOR nanopb_generator.py)`
# wants the .py extension, which only exists in
# `<site-packages>/nanopb/generator/`. We APPEND that dir
# to PATH (NOT prepend) so the bin/ wrapper still wins for
# the `protoc-gen-nanopb` name resolution. The generator/
# dir contains its own `protoc-gen-nanopb` raw script that
# fails with a relative-import error if it wins.
#
# KK_DEBUG_LINK=ON is REQUIRED for screenshot tests — without
# 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"
which protoc-gen-nanopb
which nanopb_generator.py
cmake \
-DKK_EMULATOR=1 \
-DKK_BUILD_DYLIB=1 \
-DKK_DEBUG_LINK=ON \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-B build-emu .

- 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)
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)
test -f "$DYLIB" || (echo "::error::libkkemu artifact not found" && exit 1)
echo "DYLIB_PATH=$(pwd)/$DYLIB" >> $GITHUB_ENV

- name: Upload libkkemu.dylib
# Always upload, even on later test failure — the binary is
# what vault and external auditors actually consume from this
# PR. Tagged with the short commit SHA so multiple PR pushes
# don't overwrite each other when a reviewer downloads them.
if: always() && env.DYLIB_PATH != ''
uses: actions/upload-artifact@v4
with:
name: libkkemu-${{ github.sha }}
path: ${{ env.DYLIB_PATH }}
retention-days: 30
if-no-files-found: error

- name: Install python-keepkey + pytest
working-directory: deps/python-keepkey
run: |
pip install -e .
# python-keepkey's setup.py doesn't list pytest as a dep;
# the existing python-integration-tests job runs inside a
# Docker image that has it baked. We're running on a vanilla
# macos runner so we install it explicitly. Pin pytest-timeout
# too — even though pytest-timeout can't break the dylib's C
# busy-loop (documented at length in the test_dylib_confirm_flow
# skip rationale), it's a transitive dep some tests use.
pip install pytest pytest-timeout

- name: Run dylib screenshot tests
working-directory: deps/python-keepkey/tests
env:
KK_TRANSPORT: dylib
KK_DYLIB: ${{ env.DYLIB_PATH }}
run: |
# `keepkeylib/` on PYTHONPATH so the package's relative-style
# imports inside generated *_pb2.py files resolve.
PYTHONPATH=../keepkeylib:.. python -m pytest \
test_dylib_screenshot.py \
-v --tb=short --junit-xml=../../../test-reports/dylib-junit.xml

- name: Upload dylib test results
uses: actions/upload-artifact@v4
if: always()
with:
name: python-dylib-test-results
path: test-reports/dylib-junit.xml
retention-days: 30
if-no-files-found: warn

# ═══════════════════════════════════════════════════════════
# STAGE 3b: TEST REPORT — generate PDF from test artifacts
# ═══════════════════════════════════════════════════════════
Expand Down Expand Up @@ -511,6 +713,7 @@ jobs:
- name: Generate test report PDF
env:
FW_VERSION: ${{ steps.version.outputs.fw_version }}
KK_BUILD_LABEL: ${{ github.head_ref || github.ref_name }}@${{ github.event.pull_request.head.sha || github.sha }}
run: python3 scripts/generate-test-report.py

- name: Upload test report
Expand All @@ -526,7 +729,7 @@ jobs:
# ═══════════════════════════════════════════════════════════

publish-emulator:
needs: [unit-tests, python-integration-tests, build-arm-firmware]
needs: [unit-tests, python-integration-tests, python-dylib-tests, build-arm-firmware]
if: >-
github.event_name == 'workflow_dispatch' &&
github.event.inputs.publish_emulator == 'true'
Expand Down
19 changes: 19 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
cmake_minimum_required(VERSION 3.7.2)

# CMP0079: allow target_link_libraries() to reference targets defined in
# a different directory. Required so tools/emulator can add firmware libs
# to kkemulator_dylib (defined in lib/emulator).
if(POLICY CMP0079)
cmake_policy(SET CMP0079 NEW)
endif()

project(
KeepKeyFirmware
VERSION 7.14.1
Expand All @@ -10,8 +17,20 @@ set(BOOTLOADER_MINOR_VERSION 1)
set(BOOTLOADER_PATCH_VERSION 5)

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)

# When building the dylib, every static lib it links (kkfirmware, kkboard,
# trezorcrypto, kkrand, kktransport, qrcodegenerator, SecAESSTM32, ...) must
# itself be compiled with -fPIC. macOS happens to be lenient and will produce
# a working .dylib without it; Linux's link step against a non-PIC archive
# fails with "recompile with -fPIC". Set this BEFORE add_subdirectory(lib)
# below so all targets pick it up at definition time. CMP0079 (already set
# above) lets us reach across directories to link them.
if(KK_BUILD_DYLIB)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
endif()
set(LIBOPENCM3_PATH
/root/libopencm3
CACHE PATH "Path to an already-built libopencm3")
Expand Down
122 changes: 122 additions & 0 deletions include/keepkey/emulator/libkkemu.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* libkkemu — KeepKey firmware emulator as a shared library.
*
* The host process provides a pre-allocated 1MB flash buffer.
* All I/O goes through ring buffers (no UDP sockets).
* Single-threaded: call kkemu_poll() from your event loop.
*/
#ifndef LIBKKEMU_H
#define LIBKKEMU_H

#include <stdint.h>
#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

#define KKEMU_FLASH_SIZE (1024 * 1024) /* 1 MB */
#define KKEMU_PACKET_SIZE 64 /* HID report size */
#define KKEMU_IFACE_MAIN 0
#define KKEMU_IFACE_DEBUG 1

/**
* Initialize the emulator with a host-provided flash buffer.
*
* @param flash_buf Pointer to 1MB buffer (host-owned, must remain valid).
* If contents are all 0xFF, treated as fresh/erased device.
* If contents are from a previous session, device state is
* restored.
* @param flash_len Must be KKEMU_FLASH_SIZE (1048576).
* @return 0 on success, -1 on error.
*
* After this call, the emulator is ready to process messages via
* kkemu_write() + kkemu_poll() + kkemu_read().
*/
int kkemu_init(uint8_t* flash_buf, size_t flash_len);

/**
* Shut down the emulator. Flushes pending storage writes to the
* flash buffer. After this call, the host should encrypt and persist
* the flash buffer, then zero it.
*/
void kkemu_shutdown(void);

/**
* Write a 64-byte HID report into the emulator's input queue.
*
* @param data Exactly 64 bytes.
* @param len Must be 64.
* @param iface KKEMU_IFACE_MAIN (0) or KKEMU_IFACE_DEBUG (1).
* @return 0 on success, -1 if queue is full.
*/
int kkemu_write(const uint8_t* data, size_t len, int iface);

/**
* Read a 64-byte HID report from the emulator's output queue.
*
* Non-blocking. Returns 0 immediately if no output is available.
*
* @param buf Buffer of at least 64 bytes.
* @param len Must be 64.
* @param iface KKEMU_IFACE_MAIN (0) or KKEMU_IFACE_DEBUG (1).
* @return Number of bytes read (64), or 0 if queue is empty.
*/
int kkemu_read(uint8_t* buf, size_t len, int iface);

/**
* Run one iteration of the firmware event loop.
*
* Drains the input queue, dispatches messages through the FSM,
* queues output messages, and updates display/animations.
*
* Call this at 10-60 Hz from your event loop.
*
* @return 0 on success, or -1 if the emulator is not initialized.
*/
int kkemu_poll(void);

/**
* Get the OLED framebuffer (256x64, 1-bit per pixel = 2048 bytes).
*
* This returns a pointer to internal scratch storage containing a snapshot
* of the current display in packed SSD1306 page format.
*
* @param width Receives 256.
* @param height Receives 64.
* @return Pointer to framebuffer data. The pointer remains valid only until
* the next call to kkemu_get_display(), which overwrites the same
* scratch buffer. Calling kkemu_poll() may update the emulator's
* display state, but it does not refresh previously returned data
* in place; call kkemu_get_display() again after kkemu_poll() to
* obtain an updated framebuffer snapshot. Returns NULL if emulator
* is not initialized.
*/
const uint8_t* kkemu_get_display(int* width, int* height);

/**
* Pop the next captured framebuffer from the display capture ring.
*
* Every display_refresh() inside the firmware (including those that fire
* inside confirm_helper's busy loop within a single kkemu_poll() call)
* snapshots the canvas into a ring buffer. Adjacent identical frames
* are deduplicated. This lets the host see intermediate screen states
* (confirm dialogs, cipher prompts, recovery screens) that would
* otherwise be invisible — they exist only inside synchronous C calls.
*
* @param out_packed Buffer of at least 2048 bytes (256x64, 1-bit packed
* SSD1306 page format — same as kkemu_get_display).
* @return 1 if a frame was popped, 0 if the ring is empty.
*/
int kkemu_pop_frame(uint8_t* out_packed);

/**
* Check if the emulator has been initialized.
*/
int kkemu_is_running(void);

#ifdef __cplusplus
}
#endif

#endif /* LIBKKEMU_H */
1 change: 1 addition & 0 deletions include/keepkey/emulator/setup.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
#define KEEPKEY_EMULATOR_SETUP_H

void setup(void);
void setup_urandom_only(void); /* For libkkemu: init RNG without flash mmap */

#endif
Loading
Loading