diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f358ddc5..bc627814d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 + # `/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 + # `/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 # ═══════════════════════════════════════════════════════════ @@ -526,7 +728,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' diff --git a/CMakeLists.txt b/CMakeLists.txt index 7139272b3..d783ef746 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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.0 @@ -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") diff --git a/include/keepkey/emulator/libkkemu.h b/include/keepkey/emulator/libkkemu.h new file mode 100644 index 000000000..f0f0020d9 --- /dev/null +++ b/include/keepkey/emulator/libkkemu.h @@ -0,0 +1,114 @@ +/* + * 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 +#include + +#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 Number of messages processed, or -1 on error. + */ +int kkemu_poll(void); + +/** + * Get the OLED framebuffer (256x64, 1-bit per pixel = 2048 bytes). + * + * @param width Receives 256. + * @param height Receives 64. + * @return Pointer to framebuffer (valid until next kkemu_poll). + * 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 */ diff --git a/include/keepkey/emulator/setup.h b/include/keepkey/emulator/setup.h index 57e0b949f..fadd9843c 100644 --- a/include/keepkey/emulator/setup.h +++ b/include/keepkey/emulator/setup.h @@ -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 diff --git a/lib/emulator/CMakeLists.txt b/lib/emulator/CMakeLists.txt index feac9053d..fa5909a17 100644 --- a/lib/emulator/CMakeLists.txt +++ b/lib/emulator/CMakeLists.txt @@ -13,4 +13,31 @@ if(${KK_EMULATOR}) add_library(kkemulator ${sources}) + # ── Shared library target (libkkemu.dylib / libkkemu.so) ────────── + if(KK_BUILD_DYLIB) + set(dylib_sources + oled.c + udp.c + setup.c + ringbuf.c + libkkemu.c) + + if(NOT ${KK_HAVE_STRLCPY}) + set(dylib_sources ${dylib_sources} strlcpy.c) + endif() + if(NOT ${KK_HAVE_STRLCAT}) + set(dylib_sources ${dylib_sources} strlcat.c) + endif() + + add_library(kkemulator_dylib SHARED ${dylib_sources}) + target_compile_definitions(kkemulator_dylib PRIVATE KKEMU_DYLIB=1) + target_include_directories(kkemulator_dylib PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_BINARY_DIR}/include + ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) + set_target_properties(kkemulator_dylib PROPERTIES + OUTPUT_NAME "kkemu" + POSITION_INDEPENDENT_CODE ON) + endif() + endif() diff --git a/lib/emulator/libkkemu.c b/lib/emulator/libkkemu.c new file mode 100644 index 000000000..c5a19158c --- /dev/null +++ b/lib/emulator/libkkemu.c @@ -0,0 +1,316 @@ +/* + * libkkemu — KeepKey firmware emulator as a shared library. + * + * Replaces main() with kkemu_init/poll/shutdown. Uses ring buffers + * instead of UDP sockets for message I/O. + */ +#include "keepkey/emulator/libkkemu.h" +#include "keepkey/emulator/emulator.h" +#include "keepkey/emulator/setup.h" +#include "keepkey/board/canvas.h" +#include "keepkey/board/keepkey_board.h" +#include "keepkey/board/keepkey_display.h" +#include "keepkey/board/keepkey_flash.h" +#include "keepkey/board/layout.h" +#include "keepkey/board/usb.h" +#include "keepkey/board/memory.h" +#include "keepkey/board/timer.h" +#include "keepkey/firmware/home_sm.h" +#include "keepkey/firmware/storage.h" +#include "keepkey/rand/rng.h" +#include "ringbuf.h" +#include "trezor/crypto/memzero.h" + +#include +#include +#include +#include + +/* Defined in firmware — we just need the declaration */ +extern void fsm_init(void); + +/* ── Ring buffers (replace UDP sockets) ─────────────────────────────── */ + +static RingBuf rb_main_in; /* host → firmware (main interface) */ +static RingBuf rb_main_out; /* firmware → host (main interface) */ +static RingBuf rb_debug_in; /* host → firmware (debug link) */ +static RingBuf rb_debug_out; /* firmware → host (debug link) */ + +static int libkkemu_initialized = 0; + +/* ── Display capture ring ───────────────────────────────────────────── */ + +/* + * Captures every display_refresh() into a ring of 1-bit packed snapshots. + * The host drains via kkemu_pop_frame(). Adjacent identical frames are + * skipped so an idle firmware doesn't spam the ring. + * + * Sized for ~4 seconds at 16ms refresh; if the host falls behind the + * oldest frames are dropped (write advances past read). + */ +#define FRAME_PACKED_SIZE 2048 +#define FRAME_RING_SIZE 64 + +static uint8_t frame_ring[FRAME_RING_SIZE][FRAME_PACKED_SIZE]; +static uint8_t last_packed[FRAME_PACKED_SIZE]; +static int last_packed_valid = 0; +static uint32_t frame_write_idx = 0; /* monotonic, mod FRAME_RING_SIZE for slot */ +static uint32_t frame_read_idx = 0; /* monotonic */ + +/* + * Scratch returned by kkemu_get_display(). File-scope (not function-static) + * so kkemu_shutdown() can zero it alongside the other display buffers. + */ +static uint8_t display_packed_scratch[FRAME_PACKED_SIZE]; + +/* ── Replacement I/O functions ──────────────────────────────────────── */ + +/* + * These replace the UDP socket functions in emulator/udp.c. + * When building as a shared library, we link against these instead. + */ + +void libkkemu_socketInit(void) { + ringbuf_init(&rb_main_in); + ringbuf_init(&rb_main_out); + ringbuf_init(&rb_debug_in); + ringbuf_init(&rb_debug_out); +} + +size_t libkkemu_socketRead(int *iface, void *buffer, size_t size) { + if (ringbuf_pop(&rb_main_in, (uint8_t *)buffer, size)) { + *iface = 0; + return size < RINGBUF_SLOT_SIZE ? size : RINGBUF_SLOT_SIZE; + } + if (ringbuf_pop(&rb_debug_in, (uint8_t *)buffer, size)) { + *iface = 1; + return size < RINGBUF_SLOT_SIZE ? size : RINGBUF_SLOT_SIZE; + } + return 0; +} + +size_t libkkemu_socketWrite(int iface, const void *buffer, size_t size) { + RingBuf *rb = (iface == 0) ? &rb_main_out : &rb_debug_out; + if (!ringbuf_push(rb, (const uint8_t *)buffer, size)) + return 0; + return size; +} + +/* ── Display capture callback ───────────────────────────────────────── */ + +/* + * Pack the 8-bpp grayscale canvas (256x64 = 16384 bytes) into the + * 1-bit SSD1306 page format the host wants. Skip if identical to the + * last frame we captured. Called from display_refresh() on every poll + * and on every iteration of confirm_helper's busy loop. + */ +static void libkkemu_capture_frame(const uint8_t *canvas_buf) { + if (!canvas_buf) return; + + uint8_t *slot = frame_ring[frame_write_idx % FRAME_RING_SIZE]; + memset(slot, 0, FRAME_PACKED_SIZE); + for (int x = 0; x < 256; x++) { + for (int y = 0; y < 64; y++) { + if (canvas_buf[y * 256 + x] > 0) { + slot[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); + } + } + } + + /* Dedup: skip if identical to last captured */ + if (last_packed_valid && memcmp(slot, last_packed, FRAME_PACKED_SIZE) == 0) { + return; + } + memcpy(last_packed, slot, FRAME_PACKED_SIZE); + last_packed_valid = 1; + + frame_write_idx++; + /* Drop oldest if host fell behind */ + if (frame_write_idx - frame_read_idx > FRAME_RING_SIZE) { + frame_read_idx = frame_write_idx - FRAME_RING_SIZE; + } +} + +/* ── Public API ─────────────────────────────────────────────────────── */ + +int kkemu_init(uint8_t *flash_buf, size_t flash_len) { + if (flash_len != KKEMU_FLASH_SIZE) return -1; + if (!flash_buf) return -1; + if (libkkemu_initialized) return -1; + + /* Point firmware's flash pointer at the host-provided buffer */ + emulator_flash_base = flash_buf; + + /* + * Lock memory to prevent secrets in the flash buffer (seed, FVK, PIN + * derivation state) from being swapped out. Failure is non-fatal — many + * platforms cap unprivileged mlock at a few MB (RLIMIT_MEMLOCK), and a + * dev/CI environment that hits the cap shouldn't break emulator usage. + * We DO log to stderr so the host can decide to escalate (raise the + * rlimit, run with CAP_IPC_LOCK, etc.) before signing real material. + * Production hosts of libkkemu should treat a logged failure as a + * security warning and refuse to load secrets. + */ + if (mlock(flash_buf, flash_len) != 0) { + fprintf(stderr, + "[libkkemu] mlock(%zu bytes) failed: %s — flash buffer may be " + "swapped to disk; do not load production secrets\n", + flash_len, strerror(errno)); + } + + /* Initialize ring buffers (replaces UDP socket init) */ + libkkemu_socketInit(); + + /* Reset frame capture state */ + frame_write_idx = 0; + frame_read_idx = 0; + last_packed_valid = 0; + + /* Initialize /dev/urandom for RNG */ + setup_urandom_only(); + + /* Board init (timers, etc.) */ + kk_board_init(); + + /* Hook display_refresh() so every canvas update is captured into + * our ring buffer. Must be set before storage_init/fsm_init/ + * layoutHomeForced so the boot screens get captured too. */ + display_set_dump_callback(libkkemu_capture_frame); + + /* Load storage from flash buffer */ + storage_init(); + + /* Initialize message handler FSM */ + fsm_init(); + + /* Draw initial home screen */ + layoutHomeForced(); + + libkkemu_initialized = 1; + return 0; +} + +void kkemu_shutdown(void) { + if (!libkkemu_initialized) return; + + /* Flush any pending storage to the flash buffer */ + storage_commit(); + + /* + * Zero every static buffer that could hold sensitive material before + * we tear down. In dylib mode this library lives inside a long-running + * host process — the static rings, frame ring, and packed-display + * scratch can outlive the emulator session and be visible to the rest + * of the host's memory image (core dumps, ptrace, GC roots in a Bun + * runtime, etc.). Specifically: + * + * - rb_main_in / rb_main_out: PIN, passphrase, signing inputs/outputs + * - rb_debug_in / rb_debug_out: mnemonic + recovery state when + * KK_DEBUG_LINK builds are loaded + * - frame_ring / last_packed: rendered OLED bytes for every screen, + * including PIN matrix, recovery words, + * address confirms, signing summaries + * + * memzero() is the trezor-crypto helper that the compiler can't optimize + * out. Same primitive used throughout the firmware to clear key material. + */ + memzero(&rb_main_in, sizeof(rb_main_in)); + memzero(&rb_main_out, sizeof(rb_main_out)); + memzero(&rb_debug_in, sizeof(rb_debug_in)); + memzero(&rb_debug_out, sizeof(rb_debug_out)); + memzero(frame_ring, sizeof(frame_ring)); + memzero(last_packed, sizeof(last_packed)); + memzero(display_packed_scratch, sizeof(display_packed_scratch)); + last_packed_valid = 0; + frame_write_idx = 0; + frame_read_idx = 0; + + /* + * Unlock + caller is responsible for zeroing the host-owned flash buffer + * after this returns. We explicitly DO NOT zero it here — the host may + * want to inspect / persist post-mortem state. Documented contract. + */ + if (emulator_flash_base) { + munlock(emulator_flash_base, KKEMU_FLASH_SIZE); + emulator_flash_base = NULL; + } + + libkkemu_initialized = 0; +} + +int kkemu_write(const uint8_t *data, size_t len, int iface) { + if (!libkkemu_initialized) return -1; + if (len != KKEMU_PACKET_SIZE) return -1; + + RingBuf *rb = (iface == KKEMU_IFACE_MAIN) ? &rb_main_in : &rb_debug_in; + return ringbuf_push(rb, data, len) ? 0 : -1; +} + +int kkemu_read(uint8_t *buf, size_t len, int iface) { + if (!libkkemu_initialized) return 0; + if (len < KKEMU_PACKET_SIZE) return 0; + + RingBuf *rb = (iface == KKEMU_IFACE_MAIN) ? &rb_main_out : &rb_debug_out; + return ringbuf_pop(rb, buf, KKEMU_PACKET_SIZE) ? KKEMU_PACKET_SIZE : 0; +} + +int kkemu_poll(void) { + if (!libkkemu_initialized) return -1; + + /* + * This is the same as exec() in main.cpp: + * usbPoll() — reads input, dispatches through FSM + * animate() — updates screen animations + * display_refresh() — renders framebuffer + * + * usbPoll() internally calls emulatorSocketRead() which we've + * replaced with libkkemu_socketRead() via the ring buffers. + */ + usbPoll(); + animate(); + display_refresh(); + + return 0; +} + +const uint8_t *kkemu_get_display(int *width, int *height) { + /* + * Pack the firmware's 8-bpp grayscale canvas (256×64 = 16384 bytes) into + * the 1-bit packed layout vault expects (2048 bytes). Same format + * DebugLinkGetState.layout uses: byte index = x + (y/8)*256, + * bit within byte = y%8 (LSB = top row of the 8-pixel column). + * + * Output goes into the file-scope `display_packed_scratch` so + * kkemu_shutdown() can zero it on teardown alongside the frame ring. + */ + if (!libkkemu_initialized) { if (width) *width = 0; if (height) *height = 0; return NULL; } + + const Canvas *c = display_canvas(); + if (!c || !c->buffer) { if (width) *width = 0; if (height) *height = 0; return NULL; } + + memset(display_packed_scratch, 0, sizeof(display_packed_scratch)); + for (int x = 0; x < 256; x++) { + for (int y = 0; y < 64; y++) { + if (c->buffer[y * 256 + x] > 0) { + display_packed_scratch[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); + } + } + } + + if (width) *width = 256; + if (height) *height = 64; + return display_packed_scratch; +} + +int kkemu_pop_frame(uint8_t *out_packed) { + if (!libkkemu_initialized || !out_packed) return 0; + if (frame_read_idx == frame_write_idx) return 0; + const uint8_t *slot = frame_ring[frame_read_idx % FRAME_RING_SIZE]; + memcpy(out_packed, slot, FRAME_PACKED_SIZE); + frame_read_idx++; + return 1; +} + +int kkemu_is_running(void) { + return libkkemu_initialized; +} diff --git a/lib/emulator/ringbuf.c b/lib/emulator/ringbuf.c new file mode 100644 index 000000000..472652d4f --- /dev/null +++ b/lib/emulator/ringbuf.c @@ -0,0 +1,43 @@ +/* + * Lock-free SPSC ring buffer for 64-byte HID reports. + */ +#include "ringbuf.h" +#include + +void ringbuf_init(RingBuf *rb) { + memset(rb, 0, sizeof(*rb)); +} + +bool ringbuf_push(RingBuf *rb, const uint8_t *msg, size_t len) { + if (len > RINGBUF_SLOT_SIZE) return false; + + uint32_t head = rb->head; + uint32_t next = (head + 1) % RINGBUF_CAPACITY; + + if (next == rb->tail) return false; /* full */ + + memcpy(rb->data[head], msg, len); + if (len < RINGBUF_SLOT_SIZE) + memset(rb->data[head] + len, 0, RINGBUF_SLOT_SIZE - len); + + __sync_synchronize(); /* memory barrier before publishing head */ + rb->head = next; + return true; +} + +bool ringbuf_pop(RingBuf *rb, uint8_t *msg, size_t len) { + uint32_t tail = rb->tail; + + if (tail == rb->head) return false; /* empty */ + + size_t copy = len < RINGBUF_SLOT_SIZE ? len : RINGBUF_SLOT_SIZE; + memcpy(msg, rb->data[tail], copy); + + __sync_synchronize(); /* memory barrier before advancing tail */ + rb->tail = (tail + 1) % RINGBUF_CAPACITY; + return true; +} + +bool ringbuf_empty(const RingBuf *rb) { + return rb->head == rb->tail; +} diff --git a/lib/emulator/ringbuf.h b/lib/emulator/ringbuf.h new file mode 100644 index 000000000..a4e9699a3 --- /dev/null +++ b/lib/emulator/ringbuf.h @@ -0,0 +1,43 @@ +/* + * Lock-free single-producer single-consumer ring buffer for HID reports. + * Used by libkkemu to pass 64-byte messages between host and firmware. + */ +#ifndef RINGBUF_H +#define RINGBUF_H + +#include +#include +#include + +#define RINGBUF_SLOT_SIZE 64 /* HID report size */ + +/* + * Capacity must hold the largest synchronous response the firmware emits in + * a single dispatch. The driver: DebugLinkGetState now serializes a 2048-byte + * `layout` field plus the rest of DebugLinkState (~2.7 KB total payload), and + * we want headroom for DebugLinkFlashDumpResponse (1024-byte chunks) and for + * any future field growth. With ~62 bytes of payload per HID report after the + * sync prefix + continuation byte, 2.7 KB is ~44 reports. The previous value + * of 32 left effective room for 31 reports, so DebugLinkGetState was being + * truncated mid-screenshot — emulatorSocketWrite() returned 0 but the + * upstream `msg_debug_write()` ignored the failure, so the host saw a + * silently-clipped response. + * + * 128 gives ~3x headroom on the worst current response, costs 4 * 8 KB = + * 32 KB of RAM across the four rings, and keeps the slot index a power of + * two so the modulo in ringbuf_push/pop remains a cheap mask. + */ +#define RINGBUF_CAPACITY 128 /* max queued messages */ + +typedef struct { + uint8_t data[RINGBUF_CAPACITY][RINGBUF_SLOT_SIZE]; + volatile uint32_t head; /* written by producer */ + volatile uint32_t tail; /* written by consumer */ +} RingBuf; + +void ringbuf_init(RingBuf *rb); +bool ringbuf_push(RingBuf *rb, const uint8_t *msg, size_t len); +bool ringbuf_pop(RingBuf *rb, uint8_t *msg, size_t len); +bool ringbuf_empty(const RingBuf *rb); + +#endif diff --git a/lib/emulator/setup.c b/lib/emulator/setup.c index 45f601c31..c7faeec22 100644 --- a/lib/emulator/setup.c +++ b/lib/emulator/setup.c @@ -43,6 +43,11 @@ void setup(void) { setup_flash(); } +/* For libkkemu: init RNG only (flash buffer provided by host) */ +void setup_urandom_only(void) { + setup_urandom(); +} + void emulatorRandom(void *buffer, size_t size) { ssize_t n = read(urandom, buffer, size); if (n < 0 || ((size_t)n) != size) { diff --git a/lib/emulator/udp.c b/lib/emulator/udp.c index 7b61b6623..50c3616a2 100644 --- a/lib/emulator/udp.c +++ b/lib/emulator/udp.c @@ -24,7 +24,9 @@ #include #include +#ifndef KEEPKEY_UDP_PORT #define KEEPKEY_UDP_PORT 11044 +#endif struct usb_socket { int fd; @@ -94,10 +96,40 @@ static size_t socket_read(struct usb_socket *sock, void *buffer, size_t size) { return n; } +#ifdef KKEMU_DYLIB +/* + * Dylib mode: I/O goes through ring buffers managed by libkkemu.c. + * These are thin trampolines to the libkkemu_socket* functions. + */ +extern void libkkemu_socketInit(void); +extern size_t libkkemu_socketRead(int *iface, void *buffer, size_t size); +extern size_t libkkemu_socketWrite(int iface, const void *buffer, size_t size); + +void emulatorSocketInit(void) { libkkemu_socketInit(); } + +size_t emulatorSocketRead(int *iface, void *buffer, size_t size) { + return libkkemu_socketRead(iface, buffer, size); +} + +size_t emulatorSocketWrite(int iface, const void *buffer, size_t size) { + return libkkemu_socketWrite(iface, buffer, size); +} + +#else +/* Standard mode: UDP sockets (standalone kkemu binary) */ + void emulatorSocketInit(void) { - usb_main.fd = socket_setup(KEEPKEY_UDP_PORT); + int port = KEEPKEY_UDP_PORT; + const char *env_port = getenv("KEEPKEY_UDP_PORT"); + if (env_port) { + int p = atoi(env_port); + if (p > 0 && p < 65535) port = p; + } + fprintf(stderr, "Emulator listening on UDP ports %d (main) and %d (debug)\n", + port, port + 1); + usb_main.fd = socket_setup(port); usb_main.fromlen = 0; - usb_debug.fd = socket_setup(KEEPKEY_UDP_PORT + 1); + usb_debug.fd = socket_setup(port + 1); usb_debug.fromlen = 0; } @@ -126,3 +158,4 @@ size_t emulatorSocketWrite(int iface, const void *buffer, size_t size) { } return 0; } +#endif diff --git a/lib/firmware/fsm_msg_debug.h b/lib/firmware/fsm_msg_debug.h index 8eaa0b312..3a1635c9d 100644 --- a/lib/firmware/fsm_msg_debug.h +++ b/lib/firmware/fsm_msg_debug.h @@ -46,14 +46,12 @@ void fsm_msgDebugLinkGetState(DebugLinkGetState* msg) { resp->storage_hash.size = memory_storage_hash(resp->storage_hash.bytes, storage_getLocation()); - /* Render pending animations ONLY if the animation queue is active. - * Static layouts (warning screens, address displays) write directly - * to the canvas — calling animate() unconditionally overwrites them - * with stale animation frames. Only run animations when queued. */ - if (is_animating()) { - force_animation_start(); - animate(); - } + /* Just refresh the display — don't force animations. + * The confirm() loop already ran animate() before sending ButtonRequest, + * so the canvas has the correct content. Calling force_animation_start() + * + animate() here would either: (a) do nothing if the queue is empty, + * or (b) re-run an animation that overwrites static content. + * display_refresh() ensures the framebuffer is synced for reading. */ display_refresh(); /* Pack 256x64 grayscale canvas into 1bpp layout for screenshot capture. diff --git a/tools/emulator/CMakeLists.txt b/tools/emulator/CMakeLists.txt index 16aff0c9a..63b80bd88 100644 --- a/tools/emulator/CMakeLists.txt +++ b/tools/emulator/CMakeLists.txt @@ -8,14 +8,7 @@ if(${KK_EMULATOR}) ${CMAKE_BINARY_DIR}/include ${CMAKE_SOURCE_DIR}/deps/crypto/trezor-crypto) - 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() - - target_link_libraries(kkemu + set(FIRMWARE_LIBS kkfirmware kkfirmware.keepkey kkboard @@ -26,6 +19,23 @@ if(${KK_EMULATOR}) trezorcrypto qrcodegenerator SecAESSTM32 - kkrand - kkemulator) + kkrand) + + # Standalone emulator binary (uses UDP sockets) + 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() + + target_link_libraries(kkemu ${FIRMWARE_LIBS} kkemulator) + + # Shared library (ring buffers, no sockets) for in-process FFI (vault) + 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") + endif() + endif() endif()