From f75dd420b0c99e0021e77228fa0bfd247341f6ea Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 26 Apr 2026 23:00:13 -0500 Subject: [PATCH 01/14] feat(emulator): libkkemu shared library + native macOS build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an in-process emulator library (libkkemu, .dylib on macOS / .so on linux) so a host process can drive firmware logic via FFI instead of spinning up a separate kkemu binary and talking over UDP. Used by vault v11 to host the emulator inside the Bun process for the wallet onboarding + Zcash testing flows; also lets researchers fuzz / drive firmware behavior from arbitrary host code without the network hop. Build: cmake -DKK_EMULATOR=1 -DKK_BUILD_DYLIB=1 -B build-emu . cmake --build build-emu --target kkemulator_dylib The standalone `kkemu` UDP-driven binary continues to build with KK_EMULATOR=1 alone, byte-identical to before. Files: - include/keepkey/emulator/libkkemu.h, lib/emulator/libkkemu.c C API exposed to host: init(seed), step(), screenshot capture (display_refresh frames packed 8bpp->1bpp into a 256x64x1 buffer), ringbuf-backed message I/O. - lib/emulator/ringbuf.{c,h} Lock-free single-producer/single-consumer byte ring used by libkkemu's host I/O paths (replaces the UDP sockets in dylib mode). - lib/emulator/setup.c setup_urandom_only() — RNG init for libkkemu callers that provide their own flash buffer (no flash mmap). - lib/emulator/udp.c Behind #ifdef KKEMU_DYLIB: emulatorSocketInit/Read/Write become thin trampolines to the libkkemu ring buffers. Standalone build is unchanged. Adds optional KEEPKEY_UDP_PORT env override for the kkemu binary so multiple emulators can share a host. - lib/emulator/CMakeLists.txt, tools/emulator/CMakeLists.txt Define the kkemulator_dylib target and refactor kkemu's link command to share FIRMWARE_LIBS between standalone and dylib builds. - CMakeLists.txt cmake_policy(SET CMP0079 NEW) — required so tools/emulator can link kkemulator_dylib which is defined in lib/emulator. KK_BUILD_DYLIB option (default OFF) guards the whole dylib path; absent the flag, the build is identical to fork develop. - include/keepkey/emulator/setup.h Forward decl for setup_urandom_only. Out of scope (separate concerns even though they touch nearby files): - Version bump 7.14.0 -> 7.15.0 - BITCOIN_ONLY define (BTC-only firmware variant) - DebugLink screenshot canvas semantics (next commit) - CI screenshot pipeline / zoo workflows --- CMakeLists.txt | 8 + include/keepkey/emulator/libkkemu.h | 114 ++++++++++++ include/keepkey/emulator/setup.h | 1 + lib/emulator/CMakeLists.txt | 27 +++ lib/emulator/libkkemu.c | 259 ++++++++++++++++++++++++++++ lib/emulator/ringbuf.c | 43 +++++ lib/emulator/ringbuf.h | 26 +++ lib/emulator/setup.c | 5 + lib/emulator/udp.c | 37 +++- tools/emulator/CMakeLists.txt | 30 ++-- 10 files changed, 538 insertions(+), 12 deletions(-) create mode 100644 include/keepkey/emulator/libkkemu.h create mode 100644 lib/emulator/libkkemu.c create mode 100644 lib/emulator/ringbuf.c create mode 100644 lib/emulator/ringbuf.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7139272b3..ceb099e90 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,6 +17,7 @@ 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) set(LIBOPENCM3_PATH 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..4a3730696 --- /dev/null +++ b/lib/emulator/libkkemu.c @@ -0,0 +1,259 @@ +/* + * 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 +#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 */ + +/* ── 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 swapping secrets to disk */ + mlock(flash_buf, flash_len); + + /* 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(); + + /* Unlock memory (host should zero + free after this) */ + 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). + */ + static uint8_t packed[2048]; + + 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(packed, 0, sizeof(packed)); + for (int x = 0; x < 256; x++) { + for (int y = 0; y < 64; y++) { + if (c->buffer[y * 256 + x] > 0) { + packed[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); + } + } + } + + if (width) *width = 256; + if (height) *height = 64; + return packed; +} + +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..851e4d9b7 --- /dev/null +++ b/lib/emulator/ringbuf.h @@ -0,0 +1,26 @@ +/* + * 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 */ +#define RINGBUF_CAPACITY 32 /* 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/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() From bab2262e7c590cd2895ddffb0135223777e38798 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 26 Apr 2026 23:00:28 -0500 Subject: [PATCH 02/14] fix(emulator): clear canvas semantics for DebugLinkGetState screenshot capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fsm_msgDebugLinkGetState previously called force_animation_start() + animate() before capturing the canvas (or wrapped both in `if (is_animating())`). Both forms produce the same bug for non-animated screens (warning_static, address displays, etc.): - The confirm() loop already calls animate() before sending the ButtonRequest that triggers DebugLinkGetState. - When the layout is static (no animation queued) and we still call force_animation_start() + animate() in the DebugLink handler, we overwrite the static canvas with a stale animation frame OR a no-op, depending on queue state — either way, the screenshot the host captures is not what the user is actually seeing. Replace with a single display_refresh() call. The canvas already holds the right pixels by the time DebugLinkGetState fires; we just need the framebuffer synced before the host reads it. Production firmware (compiled with KK_DEBUG_LINK=0) is unaffected — the function is excluded from non-debug builds entirely. Was the missing piece for the CI screenshot pipeline correctly capturing warning + confirmation screens with their as-rendered text instead of animation frames or empty buffers. --- lib/firmware/fsm_msg_debug.h | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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. From 9c48febe8952fc6e66ffa6b4b79e943c8270e2e0 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 26 Apr 2026 23:19:55 -0500 Subject: [PATCH 03/14] fix(emulator): size libkkemu ring buffers for the largest synchronous response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps RINGBUF_CAPACITY from 32 to 128. The previous value left effective room for 31 HID reports between writer and reader; DebugLinkGetState now serializes a 2048-byte `layout` plus the rest of DebugLinkState (~2.7 KB total payload, ~44 reports after the per-report sync prefix + continuation byte). Pushes past slot 31 returned 0 from emulatorSocketWrite(), but upstream lib/board/usb.c::msg_debug_write() ignores the return — so the host saw a silently-truncated screenshot capture instead of a clean failure. 128 gives ~3x headroom on the worst current response, accommodates DebugLinkFlashDumpResponse (1024-byte chunks) plus future field growth, and stays a power of two so the modulo in ringbuf_push/pop is a cheap mask. Memory cost: 4 rings × 128 × 64 = 32 KB. Trivial next to the existing 128 KB frame_ring. Out of scope: making msg_debug_write() propagate emulatorSocketWrite() failures is the right long-term fix and belongs in a separate PR (it's upstream code, not introduced by libkkemu). Until then, sizing the ring so it never overflows on a legitimate response is the pragmatic guard. --- lib/emulator/ringbuf.h | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/emulator/ringbuf.h b/lib/emulator/ringbuf.h index 851e4d9b7..a4e9699a3 100644 --- a/lib/emulator/ringbuf.h +++ b/lib/emulator/ringbuf.h @@ -10,7 +10,24 @@ #include #define RINGBUF_SLOT_SIZE 64 /* HID report size */ -#define RINGBUF_CAPACITY 32 /* max queued messages */ + +/* + * 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]; From 21fb5fcc4405bbbc1f1389df67f87d856974aad9 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 26 Apr 2026 23:20:06 -0500 Subject: [PATCH 04/14] fix(emulator): set CMAKE_POSITION_INDEPENDENT_CODE globally for KK_BUILD_DYLIB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous CMakeLists.txt only marked kkemulator_dylib itself as PIC, but it links static archives (kkfirmware, kkboard, kkrand, kktransport, trezorcrypto, qrcodegenerator, SecAESSTM32, kkemulator, kkvariant.*) defined in sibling directories. macOS happens to be lenient and produces a working .dylib regardless, but Linux's link step against a non-PIC archive fails with "recompile with -fPIC". Set CMAKE_POSITION_INDEPENDENT_CODE=ON globally when KK_BUILD_DYLIB is on, BEFORE add_subdirectory(lib) below — every static target picks it up at definition time. CMP0079 (already set above) lets the dylib reach across directories to link them. When KK_BUILD_DYLIB=0 (the default), nothing changes — static libs build without -fPIC as before. --- CMakeLists.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index ceb099e90..d783ef746 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) + +# 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") From 46f53301e6a6ef1eebb78adefee46f8b030eaab4 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 26 Apr 2026 23:20:32 -0500 Subject: [PATCH 05/14] fix(emulator): surface mlock failure + zero sensitive buffers on shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related hardening changes in libkkemu's lifecycle: 1. mlock() failure logged kkemu_init() previously called mlock() on the host flash buffer and ignored the return. Many platforms cap unprivileged mlock at a few MB (RLIMIT_MEMLOCK), and silent failure means the seed / FVK / PIN derivation state in the buffer can be swapped to disk without anyone knowing. Now we check the return and log to stderr on failure ("flash buffer may be swapped to disk; do not load production secrets"). Init still succeeds — a dev/CI environment that hits the rlimit shouldn't be blocked from running the emulator. Production hosts of libkkemu are expected to treat the logged failure as a security warning and refuse to load secrets. 2. Zero sensitive static buffers on kkemu_shutdown() In dylib mode the library lives inside a long-running host process (e.g. the Bun runtime in vault). Static rings, the frame ring, the last-packed dedup buffer, and the display-pack scratch all have static storage duration — they outlive the emulator session and are visible to the host's memory image (core dumps, ptrace, GC roots). They retain: - rb_main_*: PIN, passphrase, signing inputs/outputs - rb_debug_*: mnemonic + recovery-cipher state in DEBUG_LINK builds - frame_ring + last_packed + display_packed_scratch: rendered OLED bytes including PIN matrix, recovery words, address confirms, signing summaries Promoted the previously function-static `packed[2048]` in kkemu_get_display() to a file-scope `display_packed_scratch` so shutdown can reach it. All five buffers now go through trezor-crypto's memzero() (compiler-can't-optimize-out) before munlock — the same primitive the firmware uses everywhere it clears key material. The host-owned flash buffer is still left for the caller to zero (documented contract — host may want post-mortem inspection). --- lib/emulator/libkkemu.c | 73 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/lib/emulator/libkkemu.c b/lib/emulator/libkkemu.c index 4a3730696..c5a19158c 100644 --- a/lib/emulator/libkkemu.c +++ b/lib/emulator/libkkemu.c @@ -19,8 +19,11 @@ #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 */ @@ -54,6 +57,12 @@ 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 ──────────────────────────────────────── */ /* @@ -132,8 +141,22 @@ int kkemu_init(uint8_t *flash_buf, size_t flash_len) { /* Point firmware's flash pointer at the host-provided buffer */ emulator_flash_base = flash_buf; - /* Lock memory to prevent swapping secrets to disk */ - mlock(flash_buf, flash_len); + /* + * 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(); @@ -173,7 +196,40 @@ void kkemu_shutdown(void) { /* Flush any pending storage to the flash buffer */ storage_commit(); - /* Unlock memory (host should zero + free after this) */ + /* + * 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; @@ -223,26 +279,27 @@ const uint8_t *kkemu_get_display(int *width, int *height) { * 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. */ - static uint8_t packed[2048]; - 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(packed, 0, sizeof(packed)); + 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) { - packed[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); + display_packed_scratch[x + (y / 8) * 256] |= (uint8_t)(1u << (y % 8)); } } } if (width) *width = 256; if (height) *height = 64; - return packed; + return display_packed_scratch; } int kkemu_pop_frame(uint8_t *out_packed) { From 5694566d3053d30627a32976d9de9d2a83992efa Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 27 Apr 2026 16:02:28 -0500 Subject: [PATCH 06/14] ci(emulator): pin python-keepkey + add native-dylib screenshot test job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps deps/python-keepkey to bithighlander/master 73f2a2d (the merge commit of BitHighlander/python-keepkey#14), which adds the DylibTransport for in-process libkkemu testing plus four screenshot regression tests targeting this PR's own changes: - test_layout_round_trip_fits_through_ring → covers RINGBUF_CAPACITY - test_layout_repeated_reads_no_truncation - test_layout_stable_across_idle_reads → covers fsm_msg_debug animate→display_refresh - test_layout_features_dont_corrupt_capture → iface separation Adds a new CI job, python-dylib-tests, that builds libkkemu natively (NOT in the existing Docker emulator image — different artifact, different toolchain) and runs the four tests against it. Joins publish-emulator's gate so a Docker push can't ship without the dylib path being green. Pinned tooling (matches the firmware's own .python-version + the .proto generator the repo uses): Python 3.10, protobuf 3.20.3, nanopb 0.3.9.4.post3, protoc 3.21.12 KK_DEBUG_LINK=ON at cmake time (default OFF — without it, fsm_msgDebugLinkGetState is excluded and read_layout() hangs). generate-test-report's needs list is intentionally NOT updated — that job's PDF aggregation only knows about the existing JUnit + screenshot schema. The dylib JUnit uploads as its own artifact instead. Folding into the report is a follow-up. --- .github/workflows/ci.yml | 122 ++++++++++++++++++++++++++++++++++++++- deps/python-keepkey | 2 +- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f358ddc5..72c13ccc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -463,6 +463,126 @@ 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.so) and + # runs the dylib-specific screenshot regression tests in python-keepkey. + # + # Why a separate job from python-integration-tests: + # - Different artifact: .so / .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: ubuntu-latest + timeout-minutes: 20 + 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 + + - 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. + pip install "protobuf==3.20.3" "nanopb==0.3.9.4.post3" + + - name: Install pinned protoc 3.21 + run: | + # Ubuntu's apt protobuf-compiler is too old/new depending on + # release; pull the release zip directly to lock the version. + PROTOC_VERSION=3.21.12 + curl -sSL -o /tmp/protoc.zip \ + "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip" + sudo unzip -o /tmp/protoc.zip -d /usr/local + sudo chmod +x /usr/local/bin/protoc + protoc --version + + - name: Install build deps + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq cmake build-essential + + - name: Configure cmake (KK_EMULATOR + KK_BUILD_DYLIB + KK_DEBUG_LINK) + run: | + # nanopb_generator and protoc-gen-nanopb live under the pip + # site-packages; expose them via PATH so cmake's nanopb glue + # finds them. + export PATH="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator:$(python -m site --user-base)/bin:$PATH" + # 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. + 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="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator:$(python -m site --user-base)/bin:$PATH" + cmake --build build-emu --target kkemulator_dylib -j$(nproc) + ls -la build-emu/lib/libkkemu* || ls -la build-emu/lib/emulator/libkkemu* || true + # Surface the resolved binary path for the run step. + DYLIB=$(find build-emu -name 'libkkemu.so' -o -name 'libkkemu.dylib' | head -1) + test -f "$DYLIB" || (echo "::error::libkkemu artifact not found" && exit 1) + echo "DYLIB_PATH=$(pwd)/$DYLIB" >> $GITHUB_ENV + + - name: Install python-keepkey + working-directory: deps/python-keepkey + run: pip install -e . + + - 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 +646,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/deps/python-keepkey b/deps/python-keepkey index 62cc0d894..73f2a2d0b 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 62cc0d894421df6f1686e66f90f242604625b16a +Subproject commit 73f2a2d0bde79ca41125e0a455d45db285ee6b6a From 860cb8a077ceae1eee711a11183675d9fc851a0d Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 27 Apr 2026 16:19:40 -0500 Subject: [PATCH 07/14] fix(ci): correct protoc 3.21 download URL (version reset) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first python-dylib-tests run failed in 20s on the protoc download: unzip reported "End-of-central-directory signature not found" because the URL 404'd and curl wrote the HTML error page to /tmp/protoc.zip. Root cause: protobuf renamed releases from v3.21.x → v21.x at this release ("protoc version reset" — the project decoupled protoc's version from the C++ runtime's). The tag is `v21.12`, not `v3.21.12`, and the file inside is `protoc-21.12-linux-x86_64.zip`, not `protoc-3.21.12-...`. Also added `curl -f` so the next typo fails the step on the HTTP error instead of letting unzip belly-flop on a 404 page, plus a `file` sanity-check on the downloaded archive. --- .github/workflows/ci.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72c13ccc4..9d5492ab0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -519,9 +519,15 @@ jobs: run: | # Ubuntu's apt protobuf-compiler is too old/new depending on # release; pull the release zip directly to lock the version. - PROTOC_VERSION=3.21.12 - curl -sSL -o /tmp/protoc.zip \ + # 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 + curl -sSL -fL -o /tmp/protoc.zip \ "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip" + # `-f` makes curl fail-fast on HTTP errors so we don't unzip a + # 404 HTML page like the previous run did. + file /tmp/protoc.zip sudo unzip -o /tmp/protoc.zip -d /usr/local sudo chmod +x /usr/local/bin/protoc protoc --version From f92fd20cbd3b76ccdb65fdab1aa3c61ddbf0fb53 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 27 Apr 2026 16:30:37 -0500 Subject: [PATCH 08/14] fix(ci): init googletest submodule for python-dylib-tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top-level CMakeLists.txt requires googletest unconditionally — the add_subdirectory(deps/googletest) call runs at configure time, before any target selection. Even though we only build kkemulator_dylib, configure fails with 'googletest missing' if the submodule isn't init'd. Caught by the second CI run on PR #217: protoc URL fix landed (config got past stage 1 of cmake), then died on the gtest check at line 57. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d5492ab0..aced9851a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -503,6 +503,10 @@ jobs: 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 From 7ad75010c10a3e7ab3b10b83d7a7c1787f4aa96f Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 27 Apr 2026 17:01:43 -0500 Subject: [PATCH 09/14] fix(ci): generate nanopb's own .pb2 files with pinned protoc 3.21 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pip-installed nanopb 0.3.9.4.post3 ships nanopb.proto + plugin.proto but NOT the generated nanopb_pb2.py / plugin_pb2.py files. Without them, nanopb_generator.py fails at import with: ImportError: attempted relative import with no known parent package at line 37 (`from .proto import nanopb_pb2, plugin_pb2`). The fix is to regenerate them — but with the PINNED protoc 3.21, NOT the github-runner's system protoc (which is too new and produces .pb2 files that require a protobuf runtime newer than the 3.20.3 we pinned, manifesting as 'Descriptors cannot be created directly'). Same trap I hit locally before this CI work landed; documented inline so the next person doesn't have to rediscover it. --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aced9851a..e4df9950d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -536,6 +536,26 @@ jobs: 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: Install build deps run: | sudo apt-get update -qq From 2980aa82e8d698e4a685d1cca004b2410e8ed930 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 27 Apr 2026 17:11:37 -0500 Subject: [PATCH 10/14] fix(ci): pip-install requests + use bin/protoc-gen-nanopb (not generator/) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures from the third dylib CI run: 1. ModuleNotFoundError: requests ethereum_tokens.def build step fetches token lists at compile time via Python; needs requests pip-installed. 2. ImportError: attempted relative import with no known parent package The protoc-gen-nanopb that protoc was finding via PATH was the COPY in /nanopb/generator/ — a raw .py file that fails when invoked as a top-level script. The pip install's console-script wrapper at /bin/protoc-gen-nanopb loads nanopb_generator as a module, so relative imports resolve. The previous PATH override prepended the broken generator/ dir ahead of bin/. Removed both PATH manipulations — setup-python already puts the python bin dir on PATH, which is where the working wrapper lives. Added `which protoc-gen-nanopb` to surface the resolved path in CI logs for any future debugging. The pb2 regeneration step still runs (the *_pb2.py files don't ship with the pip install), but the regenerated files now get loaded via the correct module path. --- .github/workflows/ci.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4df9950d..380b1599e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -516,8 +516,10 @@ jobs: - name: Install pinned Python deps run: | python -m pip install --upgrade pip - # Strict version pins — see job-level comment above. - pip install "protobuf==3.20.3" "nanopb==0.3.9.4.post3" + # 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: | @@ -563,14 +565,21 @@ jobs: - name: Configure cmake (KK_EMULATOR + KK_BUILD_DYLIB + KK_DEBUG_LINK) run: | - # nanopb_generator and protoc-gen-nanopb live under the pip - # site-packages; expose them via PATH so cmake's nanopb glue - # finds them. - export PATH="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator:$(python -m site --user-base)/bin:$PATH" + # protoc-gen-nanopb is the protoc plugin protoc invokes via PATH. + # The pip install creates a proper console-script wrapper at + # `/bin/protoc-gen-nanopb` that loads nanopb_generator + # AS A MODULE — relative imports inside it resolve. The COPY + # of the same script under `/nanopb/generator/` + # is a raw .py file: when invoked as a top-level script via + # PATH it fails with `ImportError: attempted relative import + # with no known parent package`. + # setup-python already puts the bin dir on PATH; we + # explicitly do NOT add the generator dir. # 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. + which protoc-gen-nanopb cmake \ -DKK_EMULATOR=1 \ -DKK_BUILD_DYLIB=1 \ @@ -580,7 +589,6 @@ jobs: - name: Build kkemulator_dylib run: | - export PATH="$(python -c 'import os, nanopb; print(os.path.dirname(nanopb.__file__))')/generator:$(python -m site --user-base)/bin:$PATH" cmake --build build-emu --target kkemulator_dylib -j$(nproc) ls -la build-emu/lib/libkkemu* || ls -la build-emu/lib/emulator/libkkemu* || true # Surface the resolved binary path for the run step. From 9481ff198d4f36c50de4aa388d73f00975149f95 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 27 Apr 2026 17:21:15 -0500 Subject: [PATCH 11/14] fix(ci): append nanopb generator/ to PATH (don't prepend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmake CMakeLists.txt:78 does find_program for nanopb_generator.py (specifically with the .py extension), which only exists in the pip package's /nanopb/generator/ directory — not in the console-script bin/ dir. Last fix removed generator/ from PATH entirely to avoid its broken protoc-gen-nanopb winning over the working bin/ wrapper. That flipped the failure: cmake configure now reports 'Must install nanopb 0.3.9.4, and put nanopb-nanopb-0.3.9.4/generator on your PATH'. Right answer: APPEND generator/ to PATH, so: - bin/ comes first → bin/protoc-gen-nanopb (working wrapper) wins - generator/ comes last → only place nanopb_generator.py exists, cmake's find_program resolves it there Added `which protoc-gen-nanopb` and `which nanopb_generator.py` to the configure step so future CI logs make the resolution unambiguous. --- .github/workflows/ci.yml | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 380b1599e..7764a2750 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -565,21 +565,28 @@ jobs: - name: Configure cmake (KK_EMULATOR + KK_BUILD_DYLIB + KK_DEBUG_LINK) run: | - # protoc-gen-nanopb is the protoc plugin protoc invokes via PATH. - # The pip install creates a proper console-script wrapper at - # `/bin/protoc-gen-nanopb` that loads nanopb_generator - # AS A MODULE — relative imports inside it resolve. The COPY - # of the same script under `/nanopb/generator/` - # is a raw .py file: when invoked as a top-level script via - # PATH it fails with `ImportError: attempted relative import - # with no known parent package`. - # setup-python already puts the bin dir on PATH; we - # explicitly do NOT add the generator dir. + # 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 \ @@ -589,6 +596,7 @@ 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$(nproc) ls -la build-emu/lib/libkkemu* || ls -la build-emu/lib/emulator/libkkemu* || true # Surface the resolved binary path for the run step. From e001a7f26cdc0e9124ad4046e7dbdc4b4964ca60 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 27 Apr 2026 17:33:09 -0500 Subject: [PATCH 12/14] fix(ci): scope python-dylib-tests to macos-latest (Linux link error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux build hits a duplicate-symbol link error: __stack_chk_guard is defined by both lib/board/keepkey_board.c (uintptr_t) and lib/emulator/setup.c (uint32_t). Apple's linker silently picks one (matches local macos arm64 builds that work); GNU ld is strict and fails with 'multiple definition'. Fixing requires deduping the symbol — out of scope for the current PR which is about the libkkemu runtime + screenshot tests, not the firmware's stack-canary plumbing. This commit: - runs-on: ubuntu-latest → macos-latest (arm64) - protoc download URL: linux-x86_64 → osx-aarch_64 - drops apt-get install (macos pre-installs cmake + clang) - replaces nproc with sysctl -n hw.ncpu - find prefers libkkemu.dylib, .so as fallback for future cross-plat - added inline comment + TODO documenting the Linux follow-up Once the __stack_chk_guard symbol is deduped (likely just remove emulator/setup.c's redefinition since lib/board's already provides the on-device version that emulator inherits), this job can flip back to a matrix that runs both ubuntu-latest + macos-latest. --- .github/workflows/ci.yml | 46 ++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7764a2750..2d0e5c735 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -466,11 +466,19 @@ jobs: # ═══════════════════════════════════════════════════════════ # STAGE 3a-bis: DYLIB TESTS — libkkemu shared lib via python-keepkey # ═══════════════════════════════════════════════════════════ - # Builds the firmware emulator as a shared library (libkkemu.so) and - # runs the dylib-specific screenshot regression tests in 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: .so / .dylib (in-process FFI), not the kkemu + # - 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 @@ -488,8 +496,8 @@ jobs: # read_layout() call hangs the test). python-dylib-tests: needs: [lint-format, static-analysis, check-submodules, secret-scan] - runs-on: ubuntu-latest - timeout-minutes: 20 + runs-on: macos-latest + timeout-minutes: 25 steps: - name: Checkout uses: actions/checkout@v6 @@ -523,16 +531,18 @@ jobs: - name: Install pinned protoc 3.21 run: | - # Ubuntu's apt protobuf-compiler is too old/new depending on - # release; pull the release zip directly to lock the version. + # 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-${PROTOC_VERSION}-linux-x86_64.zip" - # `-f` makes curl fail-fast on HTTP errors so we don't unzip a - # 404 HTML page like the previous run did. + "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 @@ -558,10 +568,12 @@ jobs: /usr/local/bin/protoc --python_out=. plugin.proto ls -la nanopb_pb2.py plugin_pb2.py - - name: Install build deps + - name: Verify build tools run: | - sudo apt-get update -qq - sudo apt-get install -y -qq cmake build-essential + # 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: | @@ -597,10 +609,12 @@ 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$(nproc) + 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. - DYLIB=$(find build-emu -name 'libkkemu.so' -o -name 'libkkemu.dylib' | head -1) + # 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 From 4333ca0757f27b27ac0077b94453ea7bd62c5d35 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 27 Apr 2026 18:20:16 -0500 Subject: [PATCH 13/14] fix(ci): pip install pytest in dylib job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Job hit "No module named pytest" on macos runner. The existing python-integration-tests job runs in a Docker image with pytest baked in; this new dylib job is on a vanilla macos-latest runner so it needs explicit install. Pin pytest-timeout too — even though it can't actually break the dylib's C busy-loop (per the long rationale in test_dylib_confirm_flow.py's @unittest.skip), it's used transitively by some pytest features. --- .github/workflows/ci.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d0e5c735..6c8726ad0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -618,9 +618,18 @@ jobs: test -f "$DYLIB" || (echo "::error::libkkemu artifact not found" && exit 1) echo "DYLIB_PATH=$(pwd)/$DYLIB" >> $GITHUB_ENV - - name: Install python-keepkey + - name: Install python-keepkey + pytest working-directory: deps/python-keepkey - run: pip install -e . + 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 From ea2e423258d8fef75402c75089ea8e35fadbda1e Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 27 Apr 2026 18:32:55 -0500 Subject: [PATCH 14/14] ci(emulator): upload libkkemu.dylib as a downloadable artifact The build step was producing libkkemu.dylib but only test results were being uploaded. Add an upload-artifact step so reviewers / vault devs / external auditors can download the dylib straight from the PR's CI run without rebuilding the toolchain locally. Artifact name: libkkemu- (avoids overwrite across PR pushes) Path: env DYLIB_PATH (set by the build step) Retention: 30 days Trigger: always() so a downstream test failure doesn't lose the binary that just built successfully if-no-files-found: error catches a future build-step regression that silently drops the artifact. --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c8726ad0..bc627814d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -618,6 +618,19 @@ jobs: 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: |