From f75dd420b0c99e0021e77228fa0bfd247341f6ea Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 26 Apr 2026 23:00:13 -0500 Subject: [PATCH 01/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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: | From 326ea181ac4ec72b0e417dc65c80398a54fa9877 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 19:19:06 -0500 Subject: [PATCH 15/42] chore(deps): pin python-keepkey to fork master (d4eda86) Repoint deps/python-keepkey from upstream keepkey/python-keepkey to the BitHighlander fork. Fork master is now a strict superset of upstream master: upstream/master (a6c6602) + feat(transport): add DylibTransport for in-process libkkemu testing + test(dylib): screenshot regression for ringbuf capacity + canvas semantics + fix(dylib): address PR #14 review DylibTransport enables in-process testing against libkkemu without an external transport, used by upcoming integration tests. CI on the firmware fork will pull from BitHighlander/python-keepkey going forward. --- .gitmodules | 2 +- deps/python-keepkey | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 2d6c4446a..387b597c7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,7 +13,7 @@ path = code-signing-keys url = https://github.com/keepkey/code-signing-keys.git [submodule "deps/python-keepkey"] path = deps/python-keepkey -url = https://github.com/keepkey/python-keepkey.git +url = https://github.com/BitHighlander/python-keepkey.git branch = master [submodule "deps/qrenc/QR-Code-generator"] path = deps/qrenc/QR-Code-generator diff --git a/deps/python-keepkey b/deps/python-keepkey index 62cc0d894..d4eda864e 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit 62cc0d894421df6f1686e66f90f242604625b16a +Subproject commit d4eda864e4e4f0e62cd41576e710500f172d2ce1 From a813587c591fecd68f87798a1caf567f44b2f1a8 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 20:56:49 -0500 Subject: [PATCH 16/42] chore(deps): pin device-protocol to fork branch with TRON SignMessage proto Bumps deps/device-protocol to BitHighlander/device-protocol@feat/message-signing-parity which adds (among others) TronSignMessage / TronMessageSignature / TronVerifyMessage proto definitions used by the next commit. Per the existing fork pattern (cf. deps/python-keepkey), submodule URL is switched from upstream to the BitHighlander fork so the new branch is fetchable. Kept on a fork branch until firmware integration is validated; upstream device-protocol PR will follow. --- .gitmodules | 4 ++-- deps/device-protocol | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index 387b597c7..b02cce16e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "deps/device-protocol"] path = deps/device-protocol -url = https://github.com/keepkey/device-protocol.git -branch = master +url = https://github.com/BitHighlander/device-protocol.git +branch = feat/message-signing-parity [submodule "deps/trezor-firmware"] path = deps/crypto/trezor-firmware url = https://github.com/keepkey/trezor-firmware.git diff --git a/deps/device-protocol b/deps/device-protocol index bf8646b81..c0ef415fb 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit bf8646b817401d4623e0977ddb4c961b1101121f +Subproject commit c0ef415fb77517d666200c2ffca381130d1b1733 From f157952df0c2bbcc13e93c7ec876236c35e7df41 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 20:57:12 -0500 Subject: [PATCH 17/42] feat(tron): TIP-191 SignMessage and VerifyMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements TIP-191 personal_sign for TRON, mirroring the Ethereum implementation in lib/firmware/ethereum.c — only the prefix differs: hash = keccak256("\x19TRON Signed Message:\n" || ASCII(len) || message) The signature shape is identical to Ethereum's: 65 bytes (r || s || v) where v = 27 + recovery_id, secp256k1 over the keccak digest. Adds: - tron_message_sign() / tron_message_verify() in tron.c - fsm_msgTronSignMessage() / fsm_msgTronVerifyMessage() in fsm_msg_tron.h - MSG_IN/MSG_OUT entries in messagemap.def UX: - Sign: PIN check, path validation (m/44'/195'/...), printable/hex display on the OLED (truncated to 38×3 chars), user confirm before signing. - Verify: address + signature recovered host-style, confirms signer address then displays the verified message. Returns Success or Failure. Verification recovers the secp256k1 pubkey from (sig, hash), derives the canonical TRON address (keccak256 of uncompressed pubkey, last 20 bytes, prefixed with 0x41, Base58Check-encoded), and compares against the claim. The TIP-712 typed-data signing variant (TronSignTypedHash, IDs 1407/1408) is left for a follow-up PR. --- include/keepkey/firmware/tron.h | 17 ++++ lib/firmware/fsm_msg_tron.h | 128 ++++++++++++++++++++++++++++ lib/firmware/messagemap.def | 3 + lib/firmware/tron.c | 144 ++++++++++++++++++++++++++++++++ 4 files changed, 292 insertions(+) diff --git a/include/keepkey/firmware/tron.h b/include/keepkey/firmware/tron.h index 759680b85..afb56f0b9 100644 --- a/include/keepkey/firmware/tron.h +++ b/include/keepkey/firmware/tron.h @@ -56,4 +56,21 @@ void tron_formatAmount(char* buf, size_t len, uint64_t amount); */ bool tron_signTx(const HDNode* node, const TronSignTx* msg, TronSignedTx* resp); +/** + * Sign an arbitrary message using TIP-191 personal_sign. + * Hash = keccak256("\x19TRON Signed Message:\n" + ASCII(len) + message) + * @param node HD node containing private key + * @param msg TronSignMessage request + * @param resp TronMessageSignature response (signature + Base58Check address) + * @return true on success + */ +bool tron_message_sign(const HDNode* node, const TronSignMessage* msg, + TronMessageSignature* resp); + +/** + * Verify a TIP-191 signature against the claimed Base58Check TRON address. + * @return 0 on success, 1 on malformed input, 2 on signature/address mismatch + */ +int tron_message_verify(const TronVerifyMessage* msg); + #endif diff --git a/lib/firmware/fsm_msg_tron.h b/lib/firmware/fsm_msg_tron.h index 849603225..a2fc6462a 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -138,3 +138,131 @@ void fsm_msgTronSignTx(TronSignTx* msg) { msg_write(MessageType_MessageType_TronSignedTx, resp); layoutHome(); } + +#ifndef TRON_MSG_DISPLAY_MAX +#define TRON_MSG_DISPLAY_MAX (38 * 3) // mirrors ETH MSG_MAX (3 lines × 38 chars) +#endif + +void fsm_msgTronSignMessage(TronSignMessage* msg) { + RESP_INIT(TronMessageSignature); + + CHECK_INITIALIZED + + CHECK_PIN + + // Validate path: m/44'/195'/... + if (msg->address_n_count < 3 || msg->address_n[0] != (0x80000000 | 44) || + msg->address_n[1] != (0x80000000 | 195)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Invalid TRON path (expected m/44'/195'/...)")); + layoutHome(); + return; + } + + char msgBuf[TRON_MSG_DISPLAY_MAX + 1] = {0}; + const char* typeIndicator; + bool canPrint = true; + unsigned ctr; + + for (ctr = 0; ctr < msg->message.size; ctr++) { + if (isprint(msg->message.bytes[ctr]) == false) { + canPrint = false; + break; + } + } + + if (canPrint) { + typeIndicator = "Sign TRON Message"; + unsigned copy = msg->message.size; + if (copy > TRON_MSG_DISPLAY_MAX) copy = TRON_MSG_DISPLAY_MAX; + memcpy(msgBuf, msg->message.bytes, copy); + msgBuf[copy] = '\0'; + } else { + typeIndicator = "Sign TRON Bytes"; + unsigned hexBytes = msg->message.size; + if (hexBytes * 2 > TRON_MSG_DISPLAY_MAX) { + hexBytes = TRON_MSG_DISPLAY_MAX / 2; + } + for (ctr = 0; ctr < hexBytes; ctr++) { + snprintf(&msgBuf[2 * ctr], 3, "%02x", msg->message.bytes[ctr]); + } + } + + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeIndicator), + "%s", msgBuf)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + if (!tron_message_sign(node, msg, resp)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, _("TRON message signing failed")); + layoutHome(); + return; + } + + memzero(node, sizeof(*node)); + msg_write(MessageType_MessageType_TronMessageSignature, resp); + layoutHome(); +} + +void fsm_msgTronVerifyMessage(const TronVerifyMessage* msg) { + CHECK_PARAM(msg->has_address, _("No address provided")); + CHECK_PARAM(msg->has_message, _("No message provided")); + CHECK_PARAM(msg->has_signature, _("No signature provided")); + + if (tron_message_verify(msg) != 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Invalid signature")); + return; + } + + if (!confirm_address(_("Confirm Signer"), msg->address)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + char msgBuf[TRON_MSG_DISPLAY_MAX + 1] = {0}; + const char* typeIndicator; + bool canPrint = true; + unsigned ctr; + + for (ctr = 0; ctr < msg->message.size; ctr++) { + if (isprint(msg->message.bytes[ctr]) == false) { + canPrint = false; + break; + } + } + + if (canPrint) { + typeIndicator = "Message Verified"; + unsigned copy = msg->message.size; + if (copy > TRON_MSG_DISPLAY_MAX) copy = TRON_MSG_DISPLAY_MAX; + memcpy(msgBuf, msg->message.bytes, copy); + msgBuf[copy] = '\0'; + } else { + typeIndicator = "Bytes Verified"; + unsigned hexBytes = msg->message.size; + if (hexBytes * 2 > TRON_MSG_DISPLAY_MAX) { + hexBytes = TRON_MSG_DISPLAY_MAX / 2; + } + for (ctr = 0; ctr < hexBytes; ctr++) { + snprintf(&msgBuf[2 * ctr], 3, "%02x", msg->message.bytes[ctr]); + } + } + + if (!confirm(ButtonRequestType_ButtonRequest_Other, _(typeIndicator), "%s", + msgBuf)) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + fsm_sendSuccess(_("Message verified")); + layoutHome(); +} diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 18de20abf..bbba23c6d 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -135,9 +135,12 @@ /* TRON */ MSG_IN(MessageType_MessageType_TronGetAddress, TronGetAddress, fsm_msgTronGetAddress) MSG_IN(MessageType_MessageType_TronSignTx, TronSignTx, fsm_msgTronSignTx) + MSG_IN(MessageType_MessageType_TronSignMessage, TronSignMessage, fsm_msgTronSignMessage) + MSG_IN(MessageType_MessageType_TronVerifyMessage, TronVerifyMessage, fsm_msgTronVerifyMessage) MSG_OUT(MessageType_MessageType_TronAddress, TronAddress, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_TronSignedTx, TronSignedTx, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_TronMessageSignature, TronMessageSignature, NO_PROCESS_FUNC) /* TON */ MSG_IN(MessageType_MessageType_TonGetAddress, TonGetAddress, fsm_msgTonGetAddress) diff --git a/lib/firmware/tron.c b/lib/firmware/tron.c index d44974fe7..779cd90ac 100644 --- a/lib/firmware/tron.c +++ b/lib/firmware/tron.c @@ -130,3 +130,147 @@ bool tron_signTx(const HDNode* node, const TronSignTx* msg, return true; } + +static int tron_is_canonic(uint8_t v, uint8_t signature[64]) { + (void)v; + (void)signature; + return 0; +} + +/** + * Compute the TIP-191 personal_sign hash: + * keccak256("\x19TRON Signed Message:\n" || ASCII(len) || message) + * + * Mirrors ethereum_message_hash() — only the prefix differs. + */ +static void tron_message_hash(const uint8_t* message, size_t message_len, + uint8_t hash[32]) { + struct SHA3_CTX ctx; + uint8_t c; + + sha3_256_Init(&ctx); + sha3_Update(&ctx, (const uint8_t*)"\x19" "TRON Signed Message:\n", 22); + if (message_len >= 1000000000) { + c = '0' + message_len / 1000000000 % 10; + sha3_Update(&ctx, &c, 1); + } + if (message_len >= 100000000) { + c = '0' + message_len / 100000000 % 10; + sha3_Update(&ctx, &c, 1); + } + if (message_len >= 10000000) { + c = '0' + message_len / 10000000 % 10; + sha3_Update(&ctx, &c, 1); + } + if (message_len >= 1000000) { + c = '0' + message_len / 1000000 % 10; + sha3_Update(&ctx, &c, 1); + } + if (message_len >= 100000) { + c = '0' + message_len / 100000 % 10; + sha3_Update(&ctx, &c, 1); + } + if (message_len >= 10000) { + c = '0' + message_len / 10000 % 10; + sha3_Update(&ctx, &c, 1); + } + if (message_len >= 1000) { + c = '0' + message_len / 1000 % 10; + sha3_Update(&ctx, &c, 1); + } + if (message_len >= 100) { + c = '0' + message_len / 100 % 10; + sha3_Update(&ctx, &c, 1); + } + if (message_len >= 10) { + c = '0' + message_len / 10 % 10; + sha3_Update(&ctx, &c, 1); + } + c = '0' + message_len % 10; + sha3_Update(&ctx, &c, 1); + sha3_Update(&ctx, message, message_len); + keccak_Final(&ctx, hash); +} + +/** + * Sign an arbitrary message under TIP-191 personal_sign. + * Output signature is 65 bytes (r || s || v) where v = 27 + recovery_id. + */ +bool tron_message_sign(const HDNode* node, const TronSignMessage* msg, + TronMessageSignature* resp) { + if (!node || !msg || !resp) { + return false; + } + + // Caller must have populated node->public_key (hdnode_fill_public_key). + char address[TRON_ADDRESS_MAX_LEN]; + if (!tron_getAddress(node->public_key, address, sizeof(address))) { + return false; + } + + uint8_t hash[32]; + tron_message_hash(msg->message.bytes, msg->message.size, hash); + + uint8_t v; + if (ecdsa_sign_digest(&secp256k1, node->private_key, hash, + resp->signature.bytes, &v, tron_is_canonic) != 0) { + memzero(hash, sizeof(hash)); + return false; + } + + resp->signature.bytes[64] = 27 + v; + resp->signature.size = 65; + resp->has_signature = true; + + strlcpy(resp->address, address, sizeof(resp->address)); + resp->has_address = true; + + memzero(hash, sizeof(hash)); + return true; +} + +/** + * Verify a TIP-191 signature against the claimed Base58Check TRON address. + * Returns 0 on success, non-zero on malformed input or signature mismatch. + */ +int tron_message_verify(const TronVerifyMessage* msg) { + if (!msg || msg->signature.size != 65) { + return 1; + } + + uint8_t pubkey[65]; + uint8_t hash[32]; + + tron_message_hash(msg->message.bytes, msg->message.size, hash); + + uint8_t v = msg->signature.bytes[64]; + if (v >= 27) { + v -= 27; + } + if (v >= 2 || ecdsa_recover_pub_from_sig(&secp256k1, pubkey, + msg->signature.bytes, hash, v) != 0) { + memzero(hash, sizeof(hash)); + return 2; + } + + uint8_t addr_hash[32]; + keccak_256(pubkey + 1, 64, addr_hash); + + uint8_t addr_bytes[21]; + addr_bytes[0] = TRON_ADDRESS_PREFIX; + memcpy(addr_bytes + 1, addr_hash + 12, 20); + + char recovered_addr[TRON_ADDRESS_MAX_LEN]; + if (!base58_encode_check(addr_bytes, 21, HASHER_SHA2D, recovered_addr, + sizeof(recovered_addr))) { + memzero(hash, sizeof(hash)); + memzero(addr_hash, sizeof(addr_hash)); + return 2; + } + + int rv = (strcmp(recovered_addr, msg->address) != 0) ? 2 : 0; + + memzero(hash, sizeof(hash)); + memzero(addr_hash, sizeof(addr_hash)); + return rv; +} From dc1b5f47ce950c3f5a6df49f2b909c9a58ec85a7 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 21:24:13 -0500 Subject: [PATCH 18/42] chore(deps): re-pin device-protocol to fork master (post-merge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork device-protocol master was just synced with upstream (PR-equivalent merge of keepkey/master into BitHighlander/master) and then absorbed the feat/message-signing-parity branch via a no-ff merge. Bumps submodule pin to fork master tip (e0bf5a4) so this branch tracks the canonical fork state. Also switches the .gitmodules tracking branch from feat/message-signing-parity to master — that branch is now folded into master and no longer needs separate tracking. --- .gitmodules | 2 +- deps/device-protocol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index b02cce16e..fd06985f2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "deps/device-protocol"] path = deps/device-protocol url = https://github.com/BitHighlander/device-protocol.git -branch = feat/message-signing-parity +branch = master [submodule "deps/trezor-firmware"] path = deps/crypto/trezor-firmware url = https://github.com/keepkey/trezor-firmware.git diff --git a/deps/device-protocol b/deps/device-protocol index c0ef415fb..e0bf5a482 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit c0ef415fb77517d666200c2ffca381130d1b1733 +Subproject commit e0bf5a4821871e86757fca47767be58ffdc0d681 From ae4075b46b83540d3f4066a12811951eda88d5e1 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 21:32:41 -0500 Subject: [PATCH 19/42] fix(tron): tron_is_canonic must accept recovery IDs 0/1 (review fix) The ecdsa_sign_digest() canonical-check contract: non-zero = accept, zero = retry with fresh nonce. The previous implementation returned 0 unconditionally, which caused ecdsa_sign_digest to retry forever and ultimately fail every TronSignMessage request. Match ethereum_is_canonic exactly: return (v & 2) == 0, which accepts v in {0, 1} and rejects v in {2, 3}. Verifiers across the TRON/EVM ecosystem expect v in {0,1,27,28}; restricting to canonical recovery IDs at the producer side avoids interop issues. Reported in PR #221 review. --- lib/firmware/tron.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/firmware/tron.c b/lib/firmware/tron.c index 779cd90ac..fed28d03b 100644 --- a/lib/firmware/tron.c +++ b/lib/firmware/tron.c @@ -132,9 +132,12 @@ bool tron_signTx(const HDNode* node, const TronSignTx* msg, } static int tron_is_canonic(uint8_t v, uint8_t signature[64]) { - (void)v; + // Match ethereum_is_canonic: accept only recovery IDs 0 and 1 (reject + // 2 and 3). Mirrors verifier expectations across the TRON/EVM ecosystem. + // Returning non-zero means "canonical, accept"; ecdsa_sign_digest retries + // when this returns 0, so a permanent 0 here causes signing to fail. (void)signature; - return 0; + return (v & 2) == 0; } /** From 21f8a4f031d433cc50c008d638020ef7f4e180c1 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 21:46:36 -0500 Subject: [PATCH 20/42] chore(deps): bump python-keepkey to fork branch with TRON message-signing tests Pins deps/python-keepkey to BitHighlander/python-keepkey@feat/tron-signmessage (ca1063eb), which adds: - Regenerated proto bindings from device-protocol fork master (incl. TronSignMessage/MessageSignature/VerifyMessage/SignTypedHash IDs) - tron_sign_message() / tron_verify_message() client methods - tests/test_msg_tron_signmessage.py with round-trip + rejection cases This wires up the test suite that exercises the TIP-191 implementation introduced in this PR. --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index d4eda864e..ca1063eb9 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit d4eda864e4e4f0e62cd41576e710500f172d2ce1 +Subproject commit ca1063eb9a623f5fe8cc9f092762bbfb43c5b211 From 4ba312dd14670287c1352f853a25ff4cb6b4f4f7 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 21:51:45 -0500 Subject: [PATCH 21/42] style(tron): apply clang-format Three line-wrap adjustments to satisfy lint-format CI gate: - tron.c: ecdsa_recover_pub_from_sig argument wrapping - fsm_msg_tron.h: TRON_MSG_DISPLAY_MAX macro continuation - fsm_msg_tron.h: fsm_sendFailure line break No semantic changes. --- lib/firmware/fsm_msg_tron.h | 6 ++++-- lib/firmware/tron.c | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/firmware/fsm_msg_tron.h b/lib/firmware/fsm_msg_tron.h index a2fc6462a..59278b21c 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -140,7 +140,8 @@ void fsm_msgTronSignTx(TronSignTx* msg) { } #ifndef TRON_MSG_DISPLAY_MAX -#define TRON_MSG_DISPLAY_MAX (38 * 3) // mirrors ETH MSG_MAX (3 lines × 38 chars) +#define TRON_MSG_DISPLAY_MAX \ + (38 * 3) // mirrors ETH MSG_MAX (3 lines × 38 chars) #endif void fsm_msgTronSignMessage(TronSignMessage* msg) { @@ -202,7 +203,8 @@ void fsm_msgTronSignMessage(TronSignMessage* msg) { if (!tron_message_sign(node, msg, resp)) { memzero(node, sizeof(*node)); - fsm_sendFailure(FailureType_Failure_Other, _("TRON message signing failed")); + fsm_sendFailure(FailureType_Failure_Other, + _("TRON message signing failed")); layoutHome(); return; } diff --git a/lib/firmware/tron.c b/lib/firmware/tron.c index fed28d03b..852f3440d 100644 --- a/lib/firmware/tron.c +++ b/lib/firmware/tron.c @@ -250,8 +250,8 @@ int tron_message_verify(const TronVerifyMessage* msg) { if (v >= 27) { v -= 27; } - if (v >= 2 || ecdsa_recover_pub_from_sig(&secp256k1, pubkey, - msg->signature.bytes, hash, v) != 0) { + if (v >= 2 || ecdsa_recover_pub_from_sig( + &secp256k1, pubkey, msg->signature.bytes, hash, v) != 0) { memzero(hash, sizeof(hash)); return 2; } From 9ac8e253baaf6fdadd8e1f77d51383048e1bcc16 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 22:06:26 -0500 Subject: [PATCH 22/42] chore(deps): pin device-protocol to fork master for TIP-712 protos Bumps deps/device-protocol to BitHighlander/device-protocol@master (e0bf5a4), which contains TronSignTypedHash / TronTypedDataSignature (IDs 1407/1408) proto definitions used by the next commit. --- .gitmodules | 2 +- deps/device-protocol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 387b597c7..fd06985f2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "deps/device-protocol"] path = deps/device-protocol -url = https://github.com/keepkey/device-protocol.git +url = https://github.com/BitHighlander/device-protocol.git branch = master [submodule "deps/trezor-firmware"] path = deps/crypto/trezor-firmware diff --git a/deps/device-protocol b/deps/device-protocol index bf8646b81..e0bf5a482 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit bf8646b817401d4623e0977ddb4c961b1101121f +Subproject commit e0bf5a4821871e86757fca47767be58ffdc0d681 From 5b39ce4a94ac70497a8d525469f5ab81b62e3803 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 22:06:26 -0500 Subject: [PATCH 23/42] feat(tron): TIP-712 SignTypedHash (typed-data hash mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements TIP-712 typed-data signing via the host-pre-hashed mode, mirroring EthereumSignTypedHash exactly — TIP-712 uses the identical '\x19\x01' prefix as EIP-712: digest = keccak256('\x19\x01' || domain_separator_hash || message_hash) sig = secp256k1_sign(digest) → 65 bytes (r || s || 27+v) The host computes domain + message hashes per the TIP-712 spec; the device assembles the digest, displays both hashes for user confirmation on the OLED, and signs. Adds: - tron_typed_hash() / tron_typed_hash_sign() in tron.c - fsm_msgTronSignTypedHash() in fsm_msg_tron.h - MSG_IN/MSG_OUT entries in messagemap.def UX (mirrors fsm_msgEthereumSignTypedHash): - Confirm Base58Check signer address - Confirm 64-char hex domain separator hash - Confirm 64-char hex message hash (or 'No message' for domain-only typed data) Out of scope: full on-device typed-data walker (Tron712TypesValues analog of Ethereum712TypesValues). Hash mode covers the dapp use case where the host renders the typed data; full mode would let the device walk the JSON and is significantly more work — separate PR if needed. --- include/keepkey/firmware/tron.h | 15 ++++++ lib/firmware/fsm_msg_tron.h | 93 +++++++++++++++++++++++++++++++++ lib/firmware/messagemap.def | 2 + lib/firmware/tron.c | 72 +++++++++++++++++++++++++ 4 files changed, 182 insertions(+) diff --git a/include/keepkey/firmware/tron.h b/include/keepkey/firmware/tron.h index 759680b85..faf674b00 100644 --- a/include/keepkey/firmware/tron.h +++ b/include/keepkey/firmware/tron.h @@ -56,4 +56,19 @@ void tron_formatAmount(char* buf, size_t len, uint64_t amount); */ bool tron_signTx(const HDNode* node, const TronSignTx* msg, TronSignedTx* resp); +/** + * Sign a TIP-712 typed-data digest in hash mode. + * Host pre-computes the domain separator hash + message hash per the + * TIP-712 spec; the device assembles + * keccak256("\x19\x01" || domain_separator_hash || message_hash) + * and signs with secp256k1. + * + * @param node HD node with public_key filled + * @param msg TronSignTypedHash request + * @param resp TronTypedDataSignature response (signature + Base58Check address) + * @return true on success + */ +bool tron_typed_hash_sign(const HDNode* node, const TronSignTypedHash* msg, + TronTypedDataSignature* resp); + #endif diff --git a/lib/firmware/fsm_msg_tron.h b/lib/firmware/fsm_msg_tron.h index 849603225..84e72e585 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -138,3 +138,96 @@ void fsm_msgTronSignTx(TronSignTx* msg) { msg_write(MessageType_MessageType_TronSignedTx, resp); layoutHome(); } + +void fsm_msgTronSignTypedHash(const TronSignTypedHash* msg) { + RESP_INIT(TronTypedDataSignature); + + CHECK_INITIALIZED + + CHECK_PIN + + // Validate path: m/44'/195'/... + if (msg->address_n_count < 3 || msg->address_n[0] != (0x80000000 | 44) || + msg->address_n[1] != (0x80000000 | 195)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Invalid TRON path (expected m/44'/195'/...)")); + layoutHome(); + return; + } + + if (msg->domain_separator_hash.size != 32 || + (msg->has_message_hash && msg->message_hash.size != 32)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Invalid TIP-712 hash length")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(SECP256K1_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + // Derive Base58Check address for confirm dialog + response. + char address[TRON_ADDRESS_MAX_LEN]; + if (!tron_getAddress(node->public_key, address, sizeof(address))) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, _("Address derivation failed")); + layoutHome(); + return; + } + + if (!confirm(ButtonRequestType_ButtonRequest_Other, "Verify Address", + "Confirm address: %s", address)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + // Show domain separator hash as 64-char hex. + char str[64 + 1]; + for (int ctr = 0; ctr < 64 / 2; ctr++) { + snprintf(&str[2 * ctr], 3, "%02x", msg->domain_separator_hash.bytes[ctr]); + } + if (!confirm(ButtonRequestType_ButtonRequest_Other, "TIP-712 domain", + "Confirm hash digest: %s", str)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + + if (msg->has_message_hash) { + for (int ctr = 0; ctr < 64 / 2; ctr++) { + snprintf(&str[2 * ctr], 3, "%02x", msg->message_hash.bytes[ctr]); + } + if (!confirm(ButtonRequestType_ButtonRequest_Other, "TIP-712 message", + "Confirm hash digest: %s", str)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } else { + if (!confirm(ButtonRequestType_ButtonRequest_Other, "TIP-712 message", + "Confirm: No message")) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } + + if (!tron_typed_hash_sign(node, msg, resp)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, + _("TIP-712 hash signing failed")); + layoutHome(); + return; + } + + memzero(node, sizeof(*node)); + msg_write(MessageType_MessageType_TronTypedDataSignature, resp); + layoutHome(); +} diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 18de20abf..d060a7100 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -135,9 +135,11 @@ /* TRON */ MSG_IN(MessageType_MessageType_TronGetAddress, TronGetAddress, fsm_msgTronGetAddress) MSG_IN(MessageType_MessageType_TronSignTx, TronSignTx, fsm_msgTronSignTx) + MSG_IN(MessageType_MessageType_TronSignTypedHash, TronSignTypedHash, fsm_msgTronSignTypedHash) MSG_OUT(MessageType_MessageType_TronAddress, TronAddress, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_TronSignedTx, TronSignedTx, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_TronTypedDataSignature, TronTypedDataSignature, NO_PROCESS_FUNC) /* TON */ MSG_IN(MessageType_MessageType_TonGetAddress, TonGetAddress, fsm_msgTonGetAddress) diff --git a/lib/firmware/tron.c b/lib/firmware/tron.c index d44974fe7..a949609d8 100644 --- a/lib/firmware/tron.c +++ b/lib/firmware/tron.c @@ -130,3 +130,75 @@ bool tron_signTx(const HDNode* node, const TronSignTx* msg, return true; } + +static int tron_is_canonic_typed(uint8_t v, uint8_t signature[64]) { + // Mirror ethereum_is_canonic: accept recovery IDs 0/1, reject 2/3. + // Returning non-zero = "canonical, accept"; zero = "retry". + (void)signature; + return (v & 2) == 0; +} + +/** + * Compute the TIP-712 typed-data digest: + * keccak256("\x19\x01" || domain_separator_hash || message_hash) + * + * If the typed-data primaryType is the EIP712Domain itself, message_hash + * is empty/absent and the digest folds in only the domain separator. + * + * Mirrors ethereum_typed_hash() — TIP-712 uses the same '\x19\x01' prefix + * as EIP-712. + */ +static void tron_typed_hash(const uint8_t domain_separator_hash[32], + const uint8_t message_hash[32], + bool has_message_hash, uint8_t hash[32]) { + struct SHA3_CTX ctx = {0}; + sha3_256_Init(&ctx); + sha3_Update(&ctx, (const uint8_t*)"\x19\x01", 2); + sha3_Update(&ctx, domain_separator_hash, 32); + if (has_message_hash) { + sha3_Update(&ctx, message_hash, 32); + } + keccak_Final(&ctx, hash); +} + +/** + * Sign a TIP-712 typed-data digest. Host pre-computes the domain + * separator hash and message hash per the TIP-712 spec; the device just + * signs the assembled digest with secp256k1 + recovery id. + * + * Caller must have populated node->public_key (hdnode_fill_public_key). + */ +bool tron_typed_hash_sign(const HDNode* node, const TronSignTypedHash* msg, + TronTypedDataSignature* resp) { + if (!node || !msg || !resp) { + return false; + } + if (msg->domain_separator_hash.size != 32 || + (msg->has_message_hash && msg->message_hash.size != 32)) { + return false; + } + + char address[TRON_ADDRESS_MAX_LEN]; + if (!tron_getAddress(node->public_key, address, sizeof(address))) { + return false; + } + + uint8_t hash[32] = {0}; + tron_typed_hash(msg->domain_separator_hash.bytes, msg->message_hash.bytes, + msg->has_message_hash, hash); + + uint8_t v = 0; + if (ecdsa_sign_digest(&secp256k1, node->private_key, hash, + resp->signature.bytes, &v, + tron_is_canonic_typed) != 0) { + memzero(hash, sizeof(hash)); + return false; + } + + resp->signature.bytes[64] = 27 + v; + resp->signature.size = 65; + strlcpy(resp->address, address, sizeof(resp->address)); + + memzero(hash, sizeof(hash)); + return true; +} From 152f53d89cb3183ec04ec45d64bbebc33c8a489b Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 22:13:28 -0500 Subject: [PATCH 24/42] chore(deps): pin device-protocol to fork master for TonSignMessage proto Bumps deps/device-protocol to BitHighlander/device-protocol@master (e0bf5a4), which contains TonSignMessage / TonMessageSignature (IDs 1504/1505) used by the next commit. --- .gitmodules | 2 +- deps/device-protocol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 387b597c7..fd06985f2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "deps/device-protocol"] path = deps/device-protocol -url = https://github.com/keepkey/device-protocol.git +url = https://github.com/BitHighlander/device-protocol.git branch = master [submodule "deps/trezor-firmware"] path = deps/crypto/trezor-firmware diff --git a/deps/device-protocol b/deps/device-protocol index bf8646b81..e0bf5a482 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit bf8646b817401d4623e0977ddb4c961b1101121f +Subproject commit e0bf5a4821871e86757fca47767be58ffdc0d681 From f27990ae46b36c02c1f9e67b793bac53a7e3c73e Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 22:13:29 -0500 Subject: [PATCH 25/42] feat(ton): Ed25519 SignMessage with AdvancedMode policy gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements TON arbitrary-message Ed25519 signing. The handler fences behind storage_isPolicyEnabled('AdvancedMode'), mirroring the SolanaSignMessage pattern in fsm_msg_solana.h:461 — bare Ed25519 over message bytes lacks domain separation and is indistinguishable on the wire from a signed transaction. The proper domain-separated path is TON Connect's ton_proof envelope, which deserves its own proto + handler. This primitive exists so dapps that already drive TonSignMessage on Trezor / Ledger have parity here, but the policy gate blocks naive use. Adds: - ton_message_sign() in lib/firmware/ton.c (raw ed25519_sign over msg->message; returns signature + Ed25519 public_key) - fsm_msgTonSignMessage() in fsm_msg_ton.h with: * PIN check, path validation (m/44'/607'/...) * AdvancedMode policy gate * Printable text vs hex preview confirm dialog (mirrors Solana) - MSG_IN/MSG_OUT entries in messagemap.def UX: - AdvancedMode disabled → review banner + Failure_ActionCancelled - AdvancedMode enabled → 'Sign TON Message' (printable) or 'Sign TON Bytes' (hex preview, truncated to 32 bytes + length suffix) --- include/keepkey/firmware/ton.h | 16 ++++++ lib/firmware/fsm_msg_ton.h | 92 ++++++++++++++++++++++++++++++++++ lib/firmware/messagemap.def | 2 + lib/firmware/ton.c | 34 +++++++++++++ 4 files changed, 144 insertions(+) diff --git a/include/keepkey/firmware/ton.h b/include/keepkey/firmware/ton.h index 7b9d42870..77c8feb50 100644 --- a/include/keepkey/firmware/ton.h +++ b/include/keepkey/firmware/ton.h @@ -65,4 +65,20 @@ void ton_formatAmount(char* buf, size_t len, uint64_t amount); */ bool ton_signTx(const HDNode* node, const TonSignTx* msg, TonSignedTx* resp); +/** + * Sign an arbitrary message with raw Ed25519 (no domain separation). + * + * Caller MUST gate this behind the AdvancedMode policy — bare Ed25519 + * over message bytes is indistinguishable from signing a transaction. + * The proper domain-separated path is TON Connect's ton_proof envelope, + * which is not yet implemented. + * + * @param node HD node with public_key filled + * @param msg TonSignMessage request + * @param resp TonMessageSignature response (signature + Ed25519 public key) + * @return true on success + */ +bool ton_message_sign(const HDNode* node, const TonSignMessage* msg, + TonMessageSignature* resp); + #endif diff --git a/lib/firmware/fsm_msg_ton.h b/lib/firmware/fsm_msg_ton.h index a0384b4f0..5f4844896 100644 --- a/lib/firmware/fsm_msg_ton.h +++ b/lib/firmware/fsm_msg_ton.h @@ -148,3 +148,95 @@ void fsm_msgTonSignTx(TonSignTx* msg) { msg_write(MessageType_MessageType_TonSignedTx, resp); layoutHome(); } + +void fsm_msgTonSignMessage(const TonSignMessage* msg) { + RESP_INIT(TonMessageSignature); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_message || msg->message.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Missing message")); + layoutHome(); + return; + } + + // Validate path: m/44'/607'/... + if (msg->address_n_count < 2 || msg->address_n[0] != (0x80000000 | 44) || + msg->address_n[1] != (0x80000000 | 607)) { + fsm_sendFailure(FailureType_Failure_Other, + _("Invalid TON path (expected m/44'/607'/...)")); + layoutHome(); + return; + } + + /* AdvancedMode gate: TON message signing is bare Ed25519 over arbitrary + * bytes — no domain separation. A signed message is indistinguishable + * over the wire from a signed transaction. Same fence as SolanaSignMessage + * (see fsm_msg_solana.h:461) until TON Connect's ton_proof envelope is + * added as a separate handler. */ + if (!storage_isPolicyEnabled("AdvancedMode")) { + (void)review(ButtonRequestType_ButtonRequest_Other, "Blocked", + "TON message signing is experimental. " + "Enable AdvancedMode in device settings."); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Message signing disabled by policy")); + layoutHome(); + return; + } + + HDNode* node = fsm_getDerivedNode(ED25519_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + /* Always require on-device confirmation. Display message content if + * printable, hex preview otherwise. */ + { + char msgBuf[129] = {0}; + const char* typeLabel; + bool printable = true; + for (unsigned i = 0; i < msg->message.size; i++) { + if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { + printable = false; + break; + } + } + if (printable && msg->message.size <= sizeof(msgBuf) - 1) { + typeLabel = "Sign TON Message"; + memcpy(msgBuf, msg->message.bytes, msg->message.size); + msgBuf[msg->message.size] = '\0'; + } else { + typeLabel = "Sign TON Bytes"; + unsigned show = msg->message.size; + if (show > 32) show = 32; + for (unsigned i = 0; i < show; i++) { + snprintf(&msgBuf[2 * i], 3, "%02x", msg->message.bytes[i]); + } + msgBuf[2 * show] = '\0'; + if (msg->message.size > 32) { + snprintf(&msgBuf[64], sizeof(msgBuf) - 64, "... (%u bytes)", + (unsigned)msg->message.size); + } + } + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeLabel), + "%s", msgBuf)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } + + if (!ton_message_sign(node, msg, resp)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, _("TON message signing failed")); + layoutHome(); + return; + } + + memzero(node, sizeof(*node)); + msg_write(MessageType_MessageType_TonMessageSignature, resp); + layoutHome(); +} diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 18de20abf..9e7f2f904 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -142,9 +142,11 @@ /* TON */ MSG_IN(MessageType_MessageType_TonGetAddress, TonGetAddress, fsm_msgTonGetAddress) MSG_IN(MessageType_MessageType_TonSignTx, TonSignTx, fsm_msgTonSignTx) + MSG_IN(MessageType_MessageType_TonSignMessage, TonSignMessage, fsm_msgTonSignMessage) MSG_OUT(MessageType_MessageType_TonAddress, TonAddress, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_TonSignedTx, TonSignedTx, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_TonMessageSignature, TonMessageSignature, NO_PROCESS_FUNC) /* Solana */ MSG_IN(MessageType_MessageType_SolanaGetAddress, SolanaGetAddress, fsm_msgSolanaGetAddress) diff --git a/lib/firmware/ton.c b/lib/firmware/ton.c index 32354a71b..043e41c9d 100644 --- a/lib/firmware/ton.c +++ b/lib/firmware/ton.c @@ -252,3 +252,37 @@ bool ton_signTx(const HDNode* node, const TonSignTx* msg, TonSignedTx* resp) { return true; } + +/** + * Sign an arbitrary message with raw Ed25519. + * + * NOTE: this is a bare Ed25519 signature over message bytes, with NO + * domain separation. A signed "message" is indistinguishable from a + * signed transaction over the wire — TON Connect's `ton_proof` envelope + * (with its own prefix + workchain + address binding) is the proper + * domain-separated path and should be added as a separate proto+handler + * before this primitive is exposed without an AdvancedMode gate. + * + * Caller must have populated node->public_key via hdnode_fill_public_key. + */ +bool ton_message_sign(const HDNode* node, const TonSignMessage* msg, + TonMessageSignature* resp) { + if (!node || !msg || !resp) { + return false; + } + + ed25519_signature signature; + ed25519_sign(msg->message.bytes, msg->message.size, node->private_key, + &node->public_key[1], signature); + + resp->has_public_key = true; + resp->public_key.size = 32; + memcpy(resp->public_key.bytes, &node->public_key[1], 32); + + resp->has_signature = true; + resp->signature.size = 64; + memcpy(resp->signature.bytes, signature, 64); + + memzero(signature, sizeof(signature)); + return true; +} From 8eb33b5b1950495ef85dea9be908d84e02306eee Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 22:21:58 -0500 Subject: [PATCH 26/42] chore(deps): pin device-protocol to fork master for SolanaSignOffchainMessage proto Bumps deps/device-protocol to BitHighlander/device-protocol@master (e0bf5a4), which contains SolanaSignOffchainMessage / SolanaOffchainMessageSignature (IDs 756/757) used by the next commit. --- .gitmodules | 2 +- deps/device-protocol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 387b597c7..fd06985f2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "deps/device-protocol"] path = deps/device-protocol -url = https://github.com/keepkey/device-protocol.git +url = https://github.com/BitHighlander/device-protocol.git branch = master [submodule "deps/trezor-firmware"] path = deps/crypto/trezor-firmware diff --git a/deps/device-protocol b/deps/device-protocol index bf8646b81..e0bf5a482 160000 --- a/deps/device-protocol +++ b/deps/device-protocol @@ -1 +1 @@ -Subproject commit bf8646b817401d4623e0977ddb4c961b1101121f +Subproject commit e0bf5a4821871e86757fca47767be58ffdc0d681 From a5224da79d9932ed1e199fde1c0ba4f9cf938725 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 22:21:58 -0500 Subject: [PATCH 27/42] feat(solana): SignOffchainMessage with domain-separated envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Solana off-chain message spec (https://docs.solana.com/wallet-guide/sign-offchain-message). Device signs over: '\xff' || 'solana offchain' || version:u8 || format:u8 || length:u16 LE || message bytes The 0xFF lead byte is invalid as a Solana transaction prefix, providing the domain separation that bare SolanaSignMessage (754/755) lacks. Per fsm_msg_solana.h:461 the existing SignMessage path is gated behind the AdvancedMode policy because of that gap; this primitive carries its own domain separator, so NO AdvancedMode gate is required. Adds: - solana_offchain_message_sign() in lib/firmware/solana.c (envelope construction + Ed25519 sign, allocates 1232-byte stack buffer worst case) - fsm_msgSolanaSignOffchainMessage() in fsm_msg_solana.h with: * PIN check, path warning for non-standard derivation * Format validation (0=ASCII, 1=UTF-8 limited; format 2 rejected) * Version validation (only 0 defined) * Length validation (1212-byte spec ceiling) * Printable text vs hex preview confirm dialog - MSG_IN/MSG_OUT entries in messagemap.def Format support: - 0 = restricted ASCII (printable-only) — fully renderable on OLED - 1 = UTF-8 limited — printable bytes render, non-printable falls through to hex preview - 2 = UTF-8 extended (Ledger-only blind sign) — explicitly rejected; device requires renderable content. Out of scope: deprecating bare SolanaSignMessage. Once dapps migrate to the offchain envelope, the AdvancedMode gate on SignMessage can be tightened further or the handler removed entirely. Separate PR. --- include/keepkey/firmware/solana.h | 13 ++++ lib/firmware/fsm_msg_solana.h | 111 ++++++++++++++++++++++++++++++ lib/firmware/messagemap.def | 2 + lib/firmware/solana.c | 79 +++++++++++++++++++++ 4 files changed, 205 insertions(+) diff --git a/include/keepkey/firmware/solana.h b/include/keepkey/firmware/solana.h index 042645e3e..a56611dad 100644 --- a/include/keepkey/firmware/solana.h +++ b/include/keepkey/firmware/solana.h @@ -199,4 +199,17 @@ const SolanaTokenInfo* solana_findTokenInfo( bool solana_signTx(const HDNode* node, const SolanaSignTx* msg, SolanaSignedTx* resp); +/* Sign a Solana off-chain message with domain separation. + * + * Builds the spec envelope (0xFF || "solana offchain" || version || format + * || length:u16 || message) and Ed25519-signs it. Format 2 (extended + * UTF-8) is rejected — only formats 0 (ASCII) and 1 (UTF-8 limited) are + * supported on this device. + * + * Caller must have populated node->public_key (hdnode_fill_public_key). + */ +bool solana_offchain_message_sign(const HDNode* node, + const SolanaSignOffchainMessage* msg, + SolanaOffchainMessageSignature* resp); + #endif /* KEEPKEY_FIRMWARE_SOLANA_H */ diff --git a/lib/firmware/fsm_msg_solana.h b/lib/firmware/fsm_msg_solana.h index bb00eb894..ea3072b07 100644 --- a/lib/firmware/fsm_msg_solana.h +++ b/lib/firmware/fsm_msg_solana.h @@ -546,3 +546,114 @@ void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg) { msg_write(MessageType_MessageType_SolanaMessageSignature, resp); layoutHome(); } + +void fsm_msgSolanaSignOffchainMessage(const SolanaSignOffchainMessage* msg) { + RESP_INIT(SolanaOffchainMessageSignature); + + CHECK_INITIALIZED + CHECK_PIN + + if (!msg->has_message || msg->message.size == 0) { + fsm_sendFailure(FailureType_Failure_SyntaxError, _("Missing message")); + layoutHome(); + return; + } + + /* Validate format upfront so the user sees a meaningful error rather + * than a generic signing failure. The envelope's 0xFF prefix provides + * the domain separation that bare SolanaSignMessage lacks, so NO + * AdvancedMode gate is required here — that fence was a band-aid for + * the missing envelope. */ + uint32_t format = msg->has_message_format ? msg->message_format : 0; + if (format != 0 && format != 1) { + fsm_sendFailure( + FailureType_Failure_Other, + _("Off-chain format 2 (extended UTF-8) not supported on device")); + layoutHome(); + return; + } + + uint32_t version = msg->has_version ? msg->version : 0; + if (version != 0) { + fsm_sendFailure(FailureType_Failure_Other, + _("Unsupported off-chain message version")); + layoutHome(); + return; + } + + if (msg->message.size > 1212) { + fsm_sendFailure(FailureType_Failure_Other, + _("Off-chain message exceeds 1212-byte limit")); + layoutHome(); + return; + } + + /* Path validation: warn on non-standard derivation, mirroring the + * existing SolanaSignMessage handler. */ + if (!solana_pathIsStandard(msg->address_n, msg->address_n_count)) { + if (!confirm(ButtonRequestType_ButtonRequest_Other, "WARNING", + "Non-standard Solana derivation path. Continue?")) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, NULL); + layoutHome(); + return; + } + } + + HDNode* node = fsm_getDerivedNode(ED25519_NAME, msg->address_n, + msg->address_n_count, NULL); + if (!node) return; + hdnode_fill_public_key(node); + + /* Confirm dialog. Format 0 (ASCII) is always renderable; format 1 + * (UTF-8 limited) we render as printable bytes only — non-printable + * sequences fall through to a hex preview to avoid encoding + * surprises on the OLED. */ + { + char msgBuf[129] = {0}; + const char* typeLabel; + bool printable = true; + for (unsigned i = 0; i < msg->message.size; i++) { + if (msg->message.bytes[i] < 0x20 || msg->message.bytes[i] > 0x7e) { + printable = false; + break; + } + } + if (printable && msg->message.size <= sizeof(msgBuf) - 1) { + typeLabel = "Off-chain Message"; + memcpy(msgBuf, msg->message.bytes, msg->message.size); + msgBuf[msg->message.size] = '\0'; + } else { + typeLabel = "Off-chain Bytes"; + unsigned show = msg->message.size; + if (show > 32) show = 32; + for (unsigned i = 0; i < show; i++) { + snprintf(&msgBuf[2 * i], 3, "%02x", msg->message.bytes[i]); + } + msgBuf[2 * show] = '\0'; + if (msg->message.size > 32) { + snprintf(&msgBuf[64], sizeof(msgBuf) - 64, "... (%u bytes)", + (unsigned)msg->message.size); + } + } + if (!confirm(ButtonRequestType_ButtonRequest_ProtectCall, _(typeLabel), + "%s", msgBuf)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_ActionCancelled, + _("Signing cancelled")); + layoutHome(); + return; + } + } + + if (!solana_offchain_message_sign(node, msg, resp)) { + memzero(node, sizeof(*node)); + fsm_sendFailure(FailureType_Failure_Other, + _("Off-chain message signing failed")); + layoutHome(); + return; + } + + memzero(node, sizeof(*node)); + msg_write(MessageType_MessageType_SolanaOffchainMessageSignature, resp); + layoutHome(); +} diff --git a/lib/firmware/messagemap.def b/lib/firmware/messagemap.def index 18de20abf..6c645ce72 100644 --- a/lib/firmware/messagemap.def +++ b/lib/firmware/messagemap.def @@ -150,10 +150,12 @@ MSG_IN(MessageType_MessageType_SolanaGetAddress, SolanaGetAddress, fsm_msgSolanaGetAddress) MSG_IN(MessageType_MessageType_SolanaSignTx, SolanaSignTx, fsm_msgSolanaSignTx) MSG_IN(MessageType_MessageType_SolanaSignMessage, SolanaSignMessage, fsm_msgSolanaSignMessage) + MSG_IN(MessageType_MessageType_SolanaSignOffchainMessage, SolanaSignOffchainMessage, fsm_msgSolanaSignOffchainMessage) MSG_OUT(MessageType_MessageType_SolanaAddress, SolanaAddress, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_SolanaSignedTx, SolanaSignedTx, NO_PROCESS_FUNC) MSG_OUT(MessageType_MessageType_SolanaMessageSignature, SolanaMessageSignature, NO_PROCESS_FUNC) + MSG_OUT(MessageType_MessageType_SolanaOffchainMessageSignature, SolanaOffchainMessageSignature, NO_PROCESS_FUNC) #if DEBUG_LINK /* Debug Messages */ diff --git a/lib/firmware/solana.c b/lib/firmware/solana.c index 4b8ee78c1..abadf4cfe 100644 --- a/lib/firmware/solana.c +++ b/lib/firmware/solana.c @@ -674,3 +674,82 @@ bool solana_signTx(const HDNode* node, const SolanaSignTx* msg, return true; } + +/* ------------------------------------------------------------------ */ +/* Off-chain message signing (domain-separated) */ +/* ------------------------------------------------------------------ */ + +/* Per the Solana off-chain message spec, the device signs over the + * envelope: + * + * "\xff" || "solana offchain" || version:u8 || format:u8 + * || length:u16 LE || message bytes + * + * The 0xff lead byte is invalid as a Solana transaction prefix, so a + * signed off-chain message can NEVER be replayed as a transaction — + * this is the domain separation that bare SolanaSignMessage lacks. */ + +#define SOL_OFFCHAIN_PREFIX_LEN 1 +#define SOL_OFFCHAIN_TAG "solana offchain" +#define SOL_OFFCHAIN_TAG_LEN 15 +#define SOL_OFFCHAIN_HEADER_LEN \ + (SOL_OFFCHAIN_PREFIX_LEN + SOL_OFFCHAIN_TAG_LEN + 1 /*version*/ \ + + 1 /*format*/ + 2 /*length*/) + +#define SOL_OFFCHAIN_FORMAT_ASCII 0 +#define SOL_OFFCHAIN_FORMAT_UTF8_LIMITED 1 +#define SOL_OFFCHAIN_FORMAT_UTF8_EXTENDED 2 /* not supported on this device */ +#define SOL_OFFCHAIN_MAX_MSG_LEN 1212 /* spec ceiling for fmt 0 / 1 */ + +#define SOL_OFFCHAIN_ENVELOPE_MAX \ + (SOL_OFFCHAIN_HEADER_LEN + SOL_OFFCHAIN_MAX_MSG_LEN) + +bool solana_offchain_message_sign(const HDNode* node, + const SolanaSignOffchainMessage* msg, + SolanaOffchainMessageSignature* resp) { + if (!node || !msg || !resp) return false; + if (!msg->has_message || msg->message.size == 0) return false; + if (msg->message.size > SOL_OFFCHAIN_MAX_MSG_LEN) return false; + + /* Reject format 2 — extended UTF-8 mode is Ledger-only blind-sign and + * the proto's max_size cap (1212) wouldn't permit its real ceiling + * anyway. Force callers onto format 0 or 1. */ + uint8_t format = msg->has_message_format + ? (uint8_t)(msg->message_format & 0xFF) + : SOL_OFFCHAIN_FORMAT_ASCII; + if (format != SOL_OFFCHAIN_FORMAT_ASCII && + format != SOL_OFFCHAIN_FORMAT_UTF8_LIMITED) { + return false; + } + + uint8_t version = msg->has_version ? (uint8_t)(msg->version & 0xFF) : 0; + if (version != 0) return false; /* spec: only version 0 defined */ + + uint8_t envelope[SOL_OFFCHAIN_ENVELOPE_MAX]; + size_t off = 0; + envelope[off++] = 0xFF; + memcpy(&envelope[off], SOL_OFFCHAIN_TAG, SOL_OFFCHAIN_TAG_LEN); + off += SOL_OFFCHAIN_TAG_LEN; + envelope[off++] = version; + envelope[off++] = format; + /* length is u16 little-endian */ + envelope[off++] = (uint8_t)(msg->message.size & 0xFF); + envelope[off++] = (uint8_t)((msg->message.size >> 8) & 0xFF); + memcpy(&envelope[off], msg->message.bytes, msg->message.size); + off += msg->message.size; + + uint8_t sig[SOL_SIG_SIZE]; + ed25519_sign(envelope, off, node->private_key, node->public_key + 1, sig); + + resp->has_public_key = true; + resp->public_key.size = SOL_PUBKEY_SIZE; + memcpy(resp->public_key.bytes, node->public_key + 1, SOL_PUBKEY_SIZE); + + resp->has_signature = true; + resp->signature.size = SOL_SIG_SIZE; + memcpy(resp->signature.bytes, sig, SOL_SIG_SIZE); + + memzero(envelope, sizeof(envelope)); + memzero(sig, sizeof(sig)); + return true; +} From d55377a92dd6aa2171df5b15f77bdd19d8ebbe1d Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 23:42:36 -0500 Subject: [PATCH 28/42] fix(transport): add nanopb options for new message-signing fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The firmware build copies .options from include/keepkey/transport/ — NOT from the device-protocol submodule. After bumping the submodule to fork master (which adds TIP-191/TIP-712/TON SignMessage/Solana offchain protos), nanopb saw new bytes/string/repeated fields with no max_size caps and emitted them as pb_callback_t. The transport sanity check ('! grep -r pb_callback_t' in lib/transport/CMakeLists.txt) failed the build. Adds local options entries covering every new field across all four chains. Each PR pins to the same fork master (e0bf5a4) so all four chains' protos are generated even when a given PR only adds firmware code for one — the options additions must be the union. Verified locally with kktech/firmware:v15 — make kktransport.pb passes. --- .../keepkey/transport/messages-solana.options | 7 +++++++ .../keepkey/transport/messages-ton.options | 7 +++++++ .../keepkey/transport/messages-tron.options | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/include/keepkey/transport/messages-solana.options b/include/keepkey/transport/messages-solana.options index 7c9e3978f..ded9d68c1 100644 --- a/include/keepkey/transport/messages-solana.options +++ b/include/keepkey/transport/messages-solana.options @@ -19,3 +19,10 @@ SolanaSignMessage.message max_size:1024 SolanaMessageSignature.public_key max_size:32 SolanaMessageSignature.signature max_size:64 + +SolanaSignOffchainMessage.address_n max_count:8 +SolanaSignOffchainMessage.coin_name max_size:21 +SolanaSignOffchainMessage.message max_size:1212 + +SolanaOffchainMessageSignature.public_key max_size:32 +SolanaOffchainMessageSignature.signature max_size:64 diff --git a/include/keepkey/transport/messages-ton.options b/include/keepkey/transport/messages-ton.options index 413a14939..adbf08a55 100644 --- a/include/keepkey/transport/messages-ton.options +++ b/include/keepkey/transport/messages-ton.options @@ -11,3 +11,10 @@ TonSignTx.to_address max_size:50 TonSignTx.memo max_size:121 TonSignedTx.signature max_size:64 + +TonSignMessage.address_n max_count:8 +TonSignMessage.coin_name max_size:21 +TonSignMessage.message max_size:1024 + +TonMessageSignature.public_key max_size:32 +TonMessageSignature.signature max_size:64 diff --git a/include/keepkey/transport/messages-tron.options b/include/keepkey/transport/messages-tron.options index 9cd09facb..464d3ba5d 100644 --- a/include/keepkey/transport/messages-tron.options +++ b/include/keepkey/transport/messages-tron.options @@ -19,3 +19,22 @@ TronSignTx.data max_size:256 TronSignedTx.signature max_size:65 TronSignedTx.serialized_tx max_size:1024 + +TronSignMessage.address_n max_count:8 +TronSignMessage.coin_name max_size:21 +TronSignMessage.message max_size:1024 + +TronMessageSignature.address max_size:35 +TronMessageSignature.signature max_size:65 + +TronVerifyMessage.address max_size:35 +TronVerifyMessage.signature max_size:65 +TronVerifyMessage.message max_size:1024 + +TronSignTypedHash.address_n max_count:8 +TronSignTypedHash.coin_name max_size:21 +TronSignTypedHash.domain_separator_hash max_size:32 +TronSignTypedHash.message_hash max_size:32 + +TronTypedDataSignature.address max_size:35 +TronTypedDataSignature.signature max_size:65 From 8772395ca81312198b8fe3d2d101b1504d325c22 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 23:42:57 -0500 Subject: [PATCH 29/42] fix(transport): add nanopb options for new message-signing fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The firmware build copies .options from include/keepkey/transport/ — NOT from the device-protocol submodule. After bumping the submodule to fork master (which adds TIP-191/TIP-712/TON SignMessage/Solana offchain protos), nanopb saw new bytes/string/repeated fields with no max_size caps and emitted them as pb_callback_t. The transport sanity check ('! grep -r pb_callback_t' in lib/transport/CMakeLists.txt) failed the build. Adds local options entries covering every new field across all four chains. Each PR pins to the same fork master (e0bf5a4) so all four chains' protos are generated even when a given PR only adds firmware code for one — the options additions must be the union. Verified locally with kktech/firmware:v15 — make kktransport.pb passes. --- .../keepkey/transport/messages-solana.options | 7 +++++++ .../keepkey/transport/messages-ton.options | 7 +++++++ .../keepkey/transport/messages-tron.options | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/include/keepkey/transport/messages-solana.options b/include/keepkey/transport/messages-solana.options index 7c9e3978f..ded9d68c1 100644 --- a/include/keepkey/transport/messages-solana.options +++ b/include/keepkey/transport/messages-solana.options @@ -19,3 +19,10 @@ SolanaSignMessage.message max_size:1024 SolanaMessageSignature.public_key max_size:32 SolanaMessageSignature.signature max_size:64 + +SolanaSignOffchainMessage.address_n max_count:8 +SolanaSignOffchainMessage.coin_name max_size:21 +SolanaSignOffchainMessage.message max_size:1212 + +SolanaOffchainMessageSignature.public_key max_size:32 +SolanaOffchainMessageSignature.signature max_size:64 diff --git a/include/keepkey/transport/messages-ton.options b/include/keepkey/transport/messages-ton.options index 413a14939..adbf08a55 100644 --- a/include/keepkey/transport/messages-ton.options +++ b/include/keepkey/transport/messages-ton.options @@ -11,3 +11,10 @@ TonSignTx.to_address max_size:50 TonSignTx.memo max_size:121 TonSignedTx.signature max_size:64 + +TonSignMessage.address_n max_count:8 +TonSignMessage.coin_name max_size:21 +TonSignMessage.message max_size:1024 + +TonMessageSignature.public_key max_size:32 +TonMessageSignature.signature max_size:64 diff --git a/include/keepkey/transport/messages-tron.options b/include/keepkey/transport/messages-tron.options index 9cd09facb..464d3ba5d 100644 --- a/include/keepkey/transport/messages-tron.options +++ b/include/keepkey/transport/messages-tron.options @@ -19,3 +19,22 @@ TronSignTx.data max_size:256 TronSignedTx.signature max_size:65 TronSignedTx.serialized_tx max_size:1024 + +TronSignMessage.address_n max_count:8 +TronSignMessage.coin_name max_size:21 +TronSignMessage.message max_size:1024 + +TronMessageSignature.address max_size:35 +TronMessageSignature.signature max_size:65 + +TronVerifyMessage.address max_size:35 +TronVerifyMessage.signature max_size:65 +TronVerifyMessage.message max_size:1024 + +TronSignTypedHash.address_n max_count:8 +TronSignTypedHash.coin_name max_size:21 +TronSignTypedHash.domain_separator_hash max_size:32 +TronSignTypedHash.message_hash max_size:32 + +TronTypedDataSignature.address max_size:35 +TronTypedDataSignature.signature max_size:65 From f6c82aba22595eca077c627b13448ec289f83fd6 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 23:42:58 -0500 Subject: [PATCH 30/42] fix(transport): add nanopb options for new message-signing fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The firmware build copies .options from include/keepkey/transport/ — NOT from the device-protocol submodule. After bumping the submodule to fork master (which adds TIP-191/TIP-712/TON SignMessage/Solana offchain protos), nanopb saw new bytes/string/repeated fields with no max_size caps and emitted them as pb_callback_t. The transport sanity check ('! grep -r pb_callback_t' in lib/transport/CMakeLists.txt) failed the build. Adds local options entries covering every new field across all four chains. Each PR pins to the same fork master (e0bf5a4) so all four chains' protos are generated even when a given PR only adds firmware code for one — the options additions must be the union. Verified locally with kktech/firmware:v15 — make kktransport.pb passes. --- .../keepkey/transport/messages-solana.options | 7 +++++++ .../keepkey/transport/messages-ton.options | 7 +++++++ .../keepkey/transport/messages-tron.options | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/include/keepkey/transport/messages-solana.options b/include/keepkey/transport/messages-solana.options index 7c9e3978f..ded9d68c1 100644 --- a/include/keepkey/transport/messages-solana.options +++ b/include/keepkey/transport/messages-solana.options @@ -19,3 +19,10 @@ SolanaSignMessage.message max_size:1024 SolanaMessageSignature.public_key max_size:32 SolanaMessageSignature.signature max_size:64 + +SolanaSignOffchainMessage.address_n max_count:8 +SolanaSignOffchainMessage.coin_name max_size:21 +SolanaSignOffchainMessage.message max_size:1212 + +SolanaOffchainMessageSignature.public_key max_size:32 +SolanaOffchainMessageSignature.signature max_size:64 diff --git a/include/keepkey/transport/messages-ton.options b/include/keepkey/transport/messages-ton.options index 413a14939..adbf08a55 100644 --- a/include/keepkey/transport/messages-ton.options +++ b/include/keepkey/transport/messages-ton.options @@ -11,3 +11,10 @@ TonSignTx.to_address max_size:50 TonSignTx.memo max_size:121 TonSignedTx.signature max_size:64 + +TonSignMessage.address_n max_count:8 +TonSignMessage.coin_name max_size:21 +TonSignMessage.message max_size:1024 + +TonMessageSignature.public_key max_size:32 +TonMessageSignature.signature max_size:64 diff --git a/include/keepkey/transport/messages-tron.options b/include/keepkey/transport/messages-tron.options index 9cd09facb..464d3ba5d 100644 --- a/include/keepkey/transport/messages-tron.options +++ b/include/keepkey/transport/messages-tron.options @@ -19,3 +19,22 @@ TronSignTx.data max_size:256 TronSignedTx.signature max_size:65 TronSignedTx.serialized_tx max_size:1024 + +TronSignMessage.address_n max_count:8 +TronSignMessage.coin_name max_size:21 +TronSignMessage.message max_size:1024 + +TronMessageSignature.address max_size:35 +TronMessageSignature.signature max_size:65 + +TronVerifyMessage.address max_size:35 +TronVerifyMessage.signature max_size:65 +TronVerifyMessage.message max_size:1024 + +TronSignTypedHash.address_n max_count:8 +TronSignTypedHash.coin_name max_size:21 +TronSignTypedHash.domain_separator_hash max_size:32 +TronSignTypedHash.message_hash max_size:32 + +TronTypedDataSignature.address max_size:35 +TronTypedDataSignature.signature max_size:65 From 84b7f22343bdb080eb997dee0332a028711c4887 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 23:42:58 -0500 Subject: [PATCH 31/42] fix(transport): add nanopb options for new message-signing fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The firmware build copies .options from include/keepkey/transport/ — NOT from the device-protocol submodule. After bumping the submodule to fork master (which adds TIP-191/TIP-712/TON SignMessage/Solana offchain protos), nanopb saw new bytes/string/repeated fields with no max_size caps and emitted them as pb_callback_t. The transport sanity check ('! grep -r pb_callback_t' in lib/transport/CMakeLists.txt) failed the build. Adds local options entries covering every new field across all four chains. Each PR pins to the same fork master (e0bf5a4) so all four chains' protos are generated even when a given PR only adds firmware code for one — the options additions must be the union. Verified locally with kktech/firmware:v15 — make kktransport.pb passes. --- .../keepkey/transport/messages-solana.options | 7 +++++++ .../keepkey/transport/messages-ton.options | 7 +++++++ .../keepkey/transport/messages-tron.options | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/include/keepkey/transport/messages-solana.options b/include/keepkey/transport/messages-solana.options index 7c9e3978f..ded9d68c1 100644 --- a/include/keepkey/transport/messages-solana.options +++ b/include/keepkey/transport/messages-solana.options @@ -19,3 +19,10 @@ SolanaSignMessage.message max_size:1024 SolanaMessageSignature.public_key max_size:32 SolanaMessageSignature.signature max_size:64 + +SolanaSignOffchainMessage.address_n max_count:8 +SolanaSignOffchainMessage.coin_name max_size:21 +SolanaSignOffchainMessage.message max_size:1212 + +SolanaOffchainMessageSignature.public_key max_size:32 +SolanaOffchainMessageSignature.signature max_size:64 diff --git a/include/keepkey/transport/messages-ton.options b/include/keepkey/transport/messages-ton.options index 413a14939..adbf08a55 100644 --- a/include/keepkey/transport/messages-ton.options +++ b/include/keepkey/transport/messages-ton.options @@ -11,3 +11,10 @@ TonSignTx.to_address max_size:50 TonSignTx.memo max_size:121 TonSignedTx.signature max_size:64 + +TonSignMessage.address_n max_count:8 +TonSignMessage.coin_name max_size:21 +TonSignMessage.message max_size:1024 + +TonMessageSignature.public_key max_size:32 +TonMessageSignature.signature max_size:64 diff --git a/include/keepkey/transport/messages-tron.options b/include/keepkey/transport/messages-tron.options index 9cd09facb..464d3ba5d 100644 --- a/include/keepkey/transport/messages-tron.options +++ b/include/keepkey/transport/messages-tron.options @@ -19,3 +19,22 @@ TronSignTx.data max_size:256 TronSignedTx.signature max_size:65 TronSignedTx.serialized_tx max_size:1024 + +TronSignMessage.address_n max_count:8 +TronSignMessage.coin_name max_size:21 +TronSignMessage.message max_size:1024 + +TronMessageSignature.address max_size:35 +TronMessageSignature.signature max_size:65 + +TronVerifyMessage.address max_size:35 +TronVerifyMessage.signature max_size:65 +TronVerifyMessage.message max_size:1024 + +TronSignTypedHash.address_n max_count:8 +TronSignTypedHash.coin_name max_size:21 +TronSignTypedHash.domain_separator_hash max_size:32 +TronSignTypedHash.message_hash max_size:32 + +TronTypedDataSignature.address max_size:35 +TronTypedDataSignature.signature max_size:65 From b38faab93741d8d6907a09ffd93caf9c9b001792 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 23:52:52 -0500 Subject: [PATCH 32/42] fix(fsm): forward-declare TronSignMessage / TronVerifyMessage handlers messagemap.def is included by translation units that don't see fsm_msg_tron.h (which is only #include'd from fsm.c). Without forward decls in fsm.h, the MSG_IN entries referencing fsm_msgTronSignMessage / fsm_msgTronVerifyMessage trigger 'undeclared here (not in a function)' errors at -Werror. Mirrors the existing pattern for fsm_msgTronSignTx / fsm_msgSolanaSignMessage (fsm.h:122,127). Verified locally: kkfirmware target builds clean. --- include/keepkey/firmware/fsm.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index ca80eb7db..f9dfa0d42 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -120,6 +120,8 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg); void fsm_msgTronGetAddress(const TronGetAddress* msg); void fsm_msgTronSignTx(TronSignTx* msg); +void fsm_msgTronSignMessage(TronSignMessage* msg); +void fsm_msgTronVerifyMessage(const TronVerifyMessage* msg); void fsm_msgTonGetAddress(const TonGetAddress* msg); void fsm_msgTonSignTx(TonSignTx* msg); void fsm_msgSolanaGetAddress(const SolanaGetAddress* msg); From f48230d720f4ac18286d8ca0c41f46f2949d5b0d Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 23:53:52 -0500 Subject: [PATCH 33/42] fix(fsm): forward-declare TronSignTypedHash handler messagemap.def is included by translation units that don't see fsm_msg_tron.h. Without a forward decl in fsm.h, the MSG_IN entry triggers 'fsm_msgTronSignTypedHash undeclared' at -Werror. Mirrors fsm.h:122 pattern. --- include/keepkey/firmware/fsm.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index ca80eb7db..d735f16d0 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -120,6 +120,7 @@ void fsm_msgMayachainMsgAck(const MayachainMsgAck* msg); void fsm_msgTronGetAddress(const TronGetAddress* msg); void fsm_msgTronSignTx(TronSignTx* msg); +void fsm_msgTronSignTypedHash(const TronSignTypedHash* msg); void fsm_msgTonGetAddress(const TonGetAddress* msg); void fsm_msgTonSignTx(TonSignTx* msg); void fsm_msgSolanaGetAddress(const SolanaGetAddress* msg); From e6d9a64206fd71ac02f3ae7969595ad431c78c9a Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 23:53:53 -0500 Subject: [PATCH 34/42] fix(fsm): forward-declare TonSignMessage handler messagemap.def is included by translation units that don't see fsm_msg_ton.h. Without a forward decl in fsm.h, the MSG_IN entry triggers 'fsm_msgTonSignMessage undeclared' at -Werror. Mirrors fsm.h:124 pattern. --- include/keepkey/firmware/fsm.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index ca80eb7db..fcff36e6a 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -122,6 +122,7 @@ void fsm_msgTronGetAddress(const TronGetAddress* msg); void fsm_msgTronSignTx(TronSignTx* msg); void fsm_msgTonGetAddress(const TonGetAddress* msg); void fsm_msgTonSignTx(TonSignTx* msg); +void fsm_msgTonSignMessage(const TonSignMessage* msg); void fsm_msgSolanaGetAddress(const SolanaGetAddress* msg); void fsm_msgSolanaSignTx(const SolanaSignTx* msg); void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg); From ab7d9fa7701561d62fc910f53d80ca5c20c0ec42 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 28 Apr 2026 23:53:54 -0500 Subject: [PATCH 35/42] fix(fsm): forward-declare SolanaSignOffchainMessage handler messagemap.def is included by translation units that don't see fsm_msg_solana.h. Without a forward decl in fsm.h, the MSG_IN entry triggers 'fsm_msgSolanaSignOffchainMessage undeclared' at -Werror. Mirrors fsm.h:127 pattern. --- include/keepkey/firmware/fsm.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/keepkey/firmware/fsm.h b/include/keepkey/firmware/fsm.h index ca80eb7db..b8ab020c2 100644 --- a/include/keepkey/firmware/fsm.h +++ b/include/keepkey/firmware/fsm.h @@ -125,6 +125,7 @@ void fsm_msgTonSignTx(TonSignTx* msg); void fsm_msgSolanaGetAddress(const SolanaGetAddress* msg); void fsm_msgSolanaSignTx(const SolanaSignTx* msg); void fsm_msgSolanaSignMessage(const SolanaSignMessage* msg); +void fsm_msgSolanaSignOffchainMessage(const SolanaSignOffchainMessage* msg); #if DEBUG_LINK // void fsm_msgDebugLinkDecision(DebugLinkDecision *msg); From ff3c243d7bd29a4f7fe1229888c8c4fff8999463 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 00:57:07 -0500 Subject: [PATCH 36/42] chore(deps,ci): bump python-keepkey to 4da59c8 + add KK_BUILD_LABEL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls in scripts/generate-test-report.py SECTIONS additions for the message-signing parity work, plus client methods + tests for all four chains (T6-T18 TRON, N8-N13 TON, S13-S19 Solana). Adds KK_BUILD_LABEL env to the test-report job so per-PR PDFs are no longer byte-identical. Addresses 5 issues found in the previous CI test reports: 1. New tests silently absent (no SECTIONS entry) — fixed 2. PR PDFs byte-identical — KK_BUILD_LABEL fixes 3. Firmware version stuck at 7.14.0 — partially fixed via build label 4. SECTIONS hardcoded inventory — entries added for new tests 5. Section descriptions stale — TRON/TON/Solana descriptions updated --- .github/workflows/ci.yml | 1 + deps/python-keepkey | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f358ddc5..ee537468e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -511,6 +511,7 @@ jobs: - name: Generate test report PDF env: FW_VERSION: ${{ steps.version.outputs.fw_version }} + KK_BUILD_LABEL: ${{ github.head_ref || github.ref_name }}@${{ github.event.pull_request.head.sha || github.sha }} run: python3 scripts/generate-test-report.py - name: Upload test report diff --git a/deps/python-keepkey b/deps/python-keepkey index ca1063eb9..4da59c8d9 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit ca1063eb9a623f5fe8cc9f092762bbfb43c5b211 +Subproject commit 4da59c8d98a0055c06ad7dc5006d5204e678b925 From 614d4ca72adfbe40a09ea26745171f909757971d Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 00:57:07 -0500 Subject: [PATCH 37/42] chore(deps,ci): bump python-keepkey to 4da59c8 + add KK_BUILD_LABEL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls in scripts/generate-test-report.py SECTIONS additions for the message-signing parity work, plus client methods + tests for all four chains (T6-T18 TRON, N8-N13 TON, S13-S19 Solana). Adds KK_BUILD_LABEL env to the test-report job so per-PR PDFs are no longer byte-identical. Addresses 5 issues found in the previous CI test reports: 1. New tests silently absent (no SECTIONS entry) — fixed 2. PR PDFs byte-identical — KK_BUILD_LABEL fixes 3. Firmware version stuck at 7.14.0 — partially fixed via build label 4. SECTIONS hardcoded inventory — entries added for new tests 5. Section descriptions stale — TRON/TON/Solana descriptions updated --- .github/workflows/ci.yml | 1 + deps/python-keepkey | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f358ddc5..ee537468e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -511,6 +511,7 @@ jobs: - name: Generate test report PDF env: FW_VERSION: ${{ steps.version.outputs.fw_version }} + KK_BUILD_LABEL: ${{ github.head_ref || github.ref_name }}@${{ github.event.pull_request.head.sha || github.sha }} run: python3 scripts/generate-test-report.py - name: Upload test report diff --git a/deps/python-keepkey b/deps/python-keepkey index d4eda864e..4da59c8d9 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit d4eda864e4e4f0e62cd41576e710500f172d2ce1 +Subproject commit 4da59c8d98a0055c06ad7dc5006d5204e678b925 From 0353709f08afee5e0c2b58cc172f8658a3dfb246 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 00:57:07 -0500 Subject: [PATCH 38/42] chore(deps,ci): bump python-keepkey to 4da59c8 + add KK_BUILD_LABEL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls in scripts/generate-test-report.py SECTIONS additions for the message-signing parity work, plus client methods + tests for all four chains (T6-T18 TRON, N8-N13 TON, S13-S19 Solana). Adds KK_BUILD_LABEL env to the test-report job so per-PR PDFs are no longer byte-identical. Addresses 5 issues found in the previous CI test reports: 1. New tests silently absent (no SECTIONS entry) — fixed 2. PR PDFs byte-identical — KK_BUILD_LABEL fixes 3. Firmware version stuck at 7.14.0 — partially fixed via build label 4. SECTIONS hardcoded inventory — entries added for new tests 5. Section descriptions stale — TRON/TON/Solana descriptions updated --- .github/workflows/ci.yml | 1 + deps/python-keepkey | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f358ddc5..ee537468e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -511,6 +511,7 @@ jobs: - name: Generate test report PDF env: FW_VERSION: ${{ steps.version.outputs.fw_version }} + KK_BUILD_LABEL: ${{ github.head_ref || github.ref_name }}@${{ github.event.pull_request.head.sha || github.sha }} run: python3 scripts/generate-test-report.py - name: Upload test report diff --git a/deps/python-keepkey b/deps/python-keepkey index d4eda864e..4da59c8d9 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit d4eda864e4e4f0e62cd41576e710500f172d2ce1 +Subproject commit 4da59c8d98a0055c06ad7dc5006d5204e678b925 From 2c97fa9d2b1f2f75187367363b54ae28bdd9f3d0 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 00:57:08 -0500 Subject: [PATCH 39/42] chore(deps,ci): bump python-keepkey to 4da59c8 + add KK_BUILD_LABEL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls in scripts/generate-test-report.py SECTIONS additions for the message-signing parity work, plus client methods + tests for all four chains (T6-T18 TRON, N8-N13 TON, S13-S19 Solana). Adds KK_BUILD_LABEL env to the test-report job so per-PR PDFs are no longer byte-identical. Addresses 5 issues found in the previous CI test reports: 1. New tests silently absent (no SECTIONS entry) — fixed 2. PR PDFs byte-identical — KK_BUILD_LABEL fixes 3. Firmware version stuck at 7.14.0 — partially fixed via build label 4. SECTIONS hardcoded inventory — entries added for new tests 5. Section descriptions stale — TRON/TON/Solana descriptions updated --- .github/workflows/ci.yml | 1 + deps/python-keepkey | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f358ddc5..ee537468e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -511,6 +511,7 @@ jobs: - name: Generate test report PDF env: FW_VERSION: ${{ steps.version.outputs.fw_version }} + KK_BUILD_LABEL: ${{ github.head_ref || github.ref_name }}@${{ github.event.pull_request.head.sha || github.sha }} run: python3 scripts/generate-test-report.py - name: Upload test report diff --git a/deps/python-keepkey b/deps/python-keepkey index d4eda864e..4da59c8d9 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit d4eda864e4e4f0e62cd41576e710500f172d2ce1 +Subproject commit 4da59c8d98a0055c06ad7dc5006d5204e678b925 From 4e4c42bca1dbe6723bd8f3dc1e2a8693ccf1d687 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 16:18:54 -0500 Subject: [PATCH 40/42] chore(release): bump version to 7.14.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Practice release integrating: - PR #217: libkkemu shared library + native macOS build - PR #221: TRON TIP-191 SignMessage + VerifyMessage - PR #222: TRON TIP-712 SignTypedHash - PR #223: TON Ed25519 SignMessage (AdvancedMode-gated) - PR #224: Solana SignOffchainMessage (domain-separated envelope) PR #215 (CI artifact / kitchen-sink) deferred — not on critical path for this practice run. This release branch is for fork-only testing; original feature branches remain untouched for upstream PRs. --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d783ef746..cee2e661e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ endif() project( KeepKeyFirmware - VERSION 7.14.0 + VERSION 7.14.1 LANGUAGES C CXX ASM) set(BOOTLOADER_MAJOR_VERSION 2) From 80828614edeac862cadd6ce6751d4efcff6e6f75 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 16:38:58 -0500 Subject: [PATCH 41/42] style: apply clang-format to libkkemu, fsm_msg_tron, zxappliquid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #217's libkkemu/* files weren't pre-formatted; my PR #222 merge resolution appended TIP-712 handler with a stray trailing line in fsm_msg_tron.h; zxappliquid.c is pre-existing debt that the strict CI gate now catches at -Werror. No semantic changes — pure formatting normalization needed to pass the lint-format CI gate on integration branches that combine multiple feature PRs. --- lib/emulator/libkkemu.c | 402 +++++++++--------- lib/emulator/ringbuf.c | 46 +- lib/emulator/ringbuf.h | 18 +- lib/emulator/setup.c | 6 +- lib/emulator/udp.c | 30 +- lib/firmware/ethereum_contracts/zxappliquid.c | 20 +- lib/firmware/fsm_msg_tron.h | 1 - 7 files changed, 261 insertions(+), 262 deletions(-) diff --git a/lib/emulator/libkkemu.c b/lib/emulator/libkkemu.c index c5a19158c..be45bf608 100644 --- a/lib/emulator/libkkemu.c +++ b/lib/emulator/libkkemu.c @@ -31,10 +31,10 @@ 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 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; @@ -54,8 +54,9 @@ static int libkkemu_initialized = 0; 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 */ +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) @@ -71,29 +72,28 @@ static uint8_t display_packed_scratch[FRAME_PACKED_SIZE]; */ void libkkemu_socketInit(void) { - ringbuf_init(&rb_main_in); - ringbuf_init(&rb_main_out); - ringbuf_init(&rb_debug_in); - ringbuf_init(&rb_debug_out); + 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_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; +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 ───────────────────────────────────────── */ @@ -104,213 +104,219 @@ size_t libkkemu_socketWrite(int iface, const void *buffer, size_t size) { * 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; +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, +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(); + /* 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; + /* Reset frame capture state */ + frame_write_idx = 0; + frame_read_idx = 0; + last_packed_valid = 0; - /* Initialize /dev/urandom for RNG */ - setup_urandom_only(); + /* Initialize /dev/urandom for RNG */ + setup_urandom_only(); - /* Board init (timers, etc.) */ - kk_board_init(); + /* 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); + /* 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(); + /* Load storage from flash buffer */ + storage_init(); - /* Initialize message handler FSM */ - fsm_init(); + /* Initialize message handler FSM */ + fsm_init(); - /* Draw initial home screen */ - layoutHomeForced(); + /* Draw initial home screen */ + layoutHomeForced(); - libkkemu_initialized = 1; - return 0; + 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; + 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; +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; + 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; +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; + 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; + 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)); - } - } +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; + 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_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; -} +int kkemu_is_running(void) { return libkkemu_initialized; } diff --git a/lib/emulator/ringbuf.c b/lib/emulator/ringbuf.c index 472652d4f..779a06e81 100644 --- a/lib/emulator/ringbuf.c +++ b/lib/emulator/ringbuf.c @@ -4,40 +4,36 @@ #include "ringbuf.h" #include -void ringbuf_init(RingBuf *rb) { - memset(rb, 0, sizeof(*rb)); -} +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; +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; + uint32_t head = rb->head; + uint32_t next = (head + 1) % RINGBUF_CAPACITY; - if (next == rb->tail) return false; /* full */ + 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); + 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; + __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; +bool ringbuf_pop(RingBuf* rb, uint8_t* msg, size_t len) { + uint32_t tail = rb->tail; - if (tail == rb->head) return false; /* empty */ + if (tail == rb->head) return false; /* empty */ - size_t copy = len < RINGBUF_SLOT_SIZE ? len : RINGBUF_SLOT_SIZE; - memcpy(msg, rb->data[tail], copy); + 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; + __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; -} +bool ringbuf_empty(const RingBuf* rb) { return rb->head == rb->tail; } diff --git a/lib/emulator/ringbuf.h b/lib/emulator/ringbuf.h index a4e9699a3..45cddad56 100644 --- a/lib/emulator/ringbuf.h +++ b/lib/emulator/ringbuf.h @@ -9,7 +9,7 @@ #include #include -#define RINGBUF_SLOT_SIZE 64 /* HID report size */ +#define RINGBUF_SLOT_SIZE 64 /* HID report size */ /* * Capacity must hold the largest synchronous response the firmware emits in @@ -27,17 +27,17 @@ * 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 */ +#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 */ + 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); +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 c7faeec22..1b4d657d4 100644 --- a/lib/emulator/setup.c +++ b/lib/emulator/setup.c @@ -44,11 +44,9 @@ void setup(void) { } /* For libkkemu: init RNG only (flash buffer provided by host) */ -void setup_urandom_only(void) { - setup_urandom(); -} +void setup_urandom_only(void) { setup_urandom(); } -void emulatorRandom(void *buffer, size_t size) { +void emulatorRandom(void* buffer, size_t size) { ssize_t n = read(urandom, buffer, size); if (n < 0 || ((size_t)n) != size) { perror("Failed to read /dev/urandom"); diff --git a/lib/emulator/udp.c b/lib/emulator/udp.c index 50c3616a2..671c6437b 100644 --- a/lib/emulator/udp.c +++ b/lib/emulator/udp.c @@ -50,7 +50,7 @@ static int socket_setup(int port) { addr.sin_addr.s_addr = htonl(INADDR_ANY); // addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { perror("Failed to bind socket"); exit(1); } @@ -58,11 +58,11 @@ static int socket_setup(int port) { return fd; } -static size_t socket_write(struct usb_socket *sock, const void *buffer, +static size_t socket_write(struct usb_socket* sock, const void* buffer, size_t size) { if (sock->fromlen > 0) { ssize_t n = sendto(sock->fd, buffer, size, MSG_DONTWAIT, - (const struct sockaddr *)&sock->from, sock->fromlen); + (const struct sockaddr*)&sock->from, sock->fromlen); if (n < 0 || ((size_t)n) != size) { perror("Failed to write socket"); return 0; @@ -72,10 +72,10 @@ static size_t socket_write(struct usb_socket *sock, const void *buffer, return size; } -static size_t socket_read(struct usb_socket *sock, void *buffer, size_t size) { +static size_t socket_read(struct usb_socket* sock, void* buffer, size_t size) { sock->fromlen = sizeof(sock->from); ssize_t n = recvfrom(sock->fd, buffer, size, MSG_DONTWAIT, - (struct sockaddr *)&sock->from, &sock->fromlen); + (struct sockaddr*)&sock->from, &sock->fromlen); if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK) { @@ -101,18 +101,18 @@ static size_t socket_read(struct usb_socket *sock, void *buffer, size_t size) { * 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); +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 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); +size_t emulatorSocketWrite(int iface, const void* buffer, size_t size) { + return libkkemu_socketWrite(iface, buffer, size); } #else @@ -120,7 +120,7 @@ size_t emulatorSocketWrite(int iface, const void *buffer, size_t size) { void emulatorSocketInit(void) { int port = KEEPKEY_UDP_PORT; - const char *env_port = getenv("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; @@ -133,7 +133,7 @@ void emulatorSocketInit(void) { usb_debug.fromlen = 0; } -size_t emulatorSocketRead(int *iface, void *buffer, size_t size) { +size_t emulatorSocketRead(int* iface, void* buffer, size_t size) { size_t n = socket_read(&usb_main, buffer, size); if (n > 0) { *iface = 0; @@ -149,7 +149,7 @@ size_t emulatorSocketRead(int *iface, void *buffer, size_t size) { return 0; } -size_t emulatorSocketWrite(int iface, const void *buffer, size_t size) { +size_t emulatorSocketWrite(int iface, const void* buffer, size_t size) { if (iface == 0) { return socket_write(&usb_main, buffer, size); } diff --git a/lib/firmware/ethereum_contracts/zxappliquid.c b/lib/firmware/ethereum_contracts/zxappliquid.c index 76d7d3cda..aee74e0b0 100644 --- a/lib/firmware/ethereum_contracts/zxappliquid.c +++ b/lib/firmware/ethereum_contracts/zxappliquid.c @@ -35,7 +35,7 @@ #include "trezor/crypto/sha3.h" bool zx_confirmApproveLiquidity(uint32_t data_total, - const EthereumSignTx *msg) { + const EthereumSignTx* msg) { (void)data_total; const char *to, *tikstr, *poolstr, *allowance, *amt; unsigned char data[40]; @@ -47,14 +47,14 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, const TokenType *WETH, *ttoken; if (!tokenByTicker(msg->chain_id, "WETH", &WETH)) return false; - wethord = read_be((const uint8_t *)WETH->address); - to = (const char *)msg->to.bytes; + wethord = read_be((const uint8_t*)WETH->address); + to = (const char*)msg->to.bytes; tokctr = 0; while (tokctr != -1) { ttoken = tokenIter(&tokctr); // https://uniswap.org/docs/v2/smart-contract-integration/getting-pair-addresses/ - uint32_t ttokenord = read_be((const uint8_t *)ttoken->address); + uint32_t ttokenord = read_be((const uint8_t*)ttoken->address); if (ttokenord < wethord) { memcpy(data, ttoken->address, 20); memcpy(&data[20], WETH->address, 20); @@ -65,7 +65,7 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, keccak_256(data, sizeof(data), tokdigest); SHA3_CTX ctx = {0}; keccak_256_Init(&ctx); - keccak_Update(&ctx, (unsigned char *)"\xff", 1); + keccak_Update(&ctx, (unsigned char*)"\xff", 1); keccak_Update(&ctx, (unsigned char *)"\x5C\x69\xbE\xe7\x01\xef\x81\x4a\x2B\x6a\x3E\xDD\x4B\x16\x52\xCB\x9c\xc5\xaA\x6f", 20); keccak_Update(&ctx, tokdigest, sizeof(tokdigest)); keccak_Update(&ctx, (unsigned char *)"\x96\xe8\xac\x42\x77\x19\x8f\xf8\xb6\xf7\x85\x47\x8a\xa9\xa3\x9f\x40\x3c\xb7\x68\xdd\x02\xcb\xee\x32\x6c\x3e\x7d\xa3\x48\x84\x5f", 32); @@ -87,8 +87,8 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, poolstr = digestStr; } - allowance = (char *)(msg->data_initial_chunk.bytes + 4 + 32); - if (memcmp(allowance, (uint8_t *)&MAX_ALLOWANCE, 32) == 0) { + allowance = (char*)(msg->data_initial_chunk.bytes + 4 + 32); + if (memcmp(allowance, (uint8_t*)&MAX_ALLOWANCE, 32) == 0) { amt = "full balance"; } else { for (ctr = 0; ctr < 32; ctr++) { @@ -97,7 +97,7 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, amt = amtStr; } - const char *appStr = "uniswap approve liquidity"; + const char* appStr = "uniswap approve liquidity"; confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, "Amount: %s", amt); confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, @@ -105,9 +105,9 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, return true; } -bool zx_isZxApproveLiquid(const EthereumSignTx *msg) { +bool zx_isZxApproveLiquid(const EthereumSignTx* msg) { if (memcmp(msg->data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4) == 0) - if (memcmp((uint8_t *)(msg->data_initial_chunk.bytes + 4 + 32 - 20), + if (memcmp((uint8_t*)(msg->data_initial_chunk.bytes + 4 + 32 - 20), UNISWAP_ROUTER_ADDRESS, 20) == 0) return true; return false; diff --git a/lib/firmware/fsm_msg_tron.h b/lib/firmware/fsm_msg_tron.h index a62385efd..aee20c602 100644 --- a/lib/firmware/fsm_msg_tron.h +++ b/lib/firmware/fsm_msg_tron.h @@ -360,4 +360,3 @@ void fsm_msgTronSignTypedHash(const TronSignTypedHash* msg) { msg_write(MessageType_MessageType_TronTypedDataSignature, resp); layoutHome(); } - From de297b7abbbcb0db6fa63ea3865e9926407702c1 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 22:06:57 -0500 Subject: [PATCH 42/42] style(eth): apply clang-format to zxappliquid.c (pointer-asterisk side) --- lib/firmware/ethereum_contracts/zxappliquid.c | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/firmware/ethereum_contracts/zxappliquid.c b/lib/firmware/ethereum_contracts/zxappliquid.c index aee74e0b0..76d7d3cda 100644 --- a/lib/firmware/ethereum_contracts/zxappliquid.c +++ b/lib/firmware/ethereum_contracts/zxappliquid.c @@ -35,7 +35,7 @@ #include "trezor/crypto/sha3.h" bool zx_confirmApproveLiquidity(uint32_t data_total, - const EthereumSignTx* msg) { + const EthereumSignTx *msg) { (void)data_total; const char *to, *tikstr, *poolstr, *allowance, *amt; unsigned char data[40]; @@ -47,14 +47,14 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, const TokenType *WETH, *ttoken; if (!tokenByTicker(msg->chain_id, "WETH", &WETH)) return false; - wethord = read_be((const uint8_t*)WETH->address); - to = (const char*)msg->to.bytes; + wethord = read_be((const uint8_t *)WETH->address); + to = (const char *)msg->to.bytes; tokctr = 0; while (tokctr != -1) { ttoken = tokenIter(&tokctr); // https://uniswap.org/docs/v2/smart-contract-integration/getting-pair-addresses/ - uint32_t ttokenord = read_be((const uint8_t*)ttoken->address); + uint32_t ttokenord = read_be((const uint8_t *)ttoken->address); if (ttokenord < wethord) { memcpy(data, ttoken->address, 20); memcpy(&data[20], WETH->address, 20); @@ -65,7 +65,7 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, keccak_256(data, sizeof(data), tokdigest); SHA3_CTX ctx = {0}; keccak_256_Init(&ctx); - keccak_Update(&ctx, (unsigned char*)"\xff", 1); + keccak_Update(&ctx, (unsigned char *)"\xff", 1); keccak_Update(&ctx, (unsigned char *)"\x5C\x69\xbE\xe7\x01\xef\x81\x4a\x2B\x6a\x3E\xDD\x4B\x16\x52\xCB\x9c\xc5\xaA\x6f", 20); keccak_Update(&ctx, tokdigest, sizeof(tokdigest)); keccak_Update(&ctx, (unsigned char *)"\x96\xe8\xac\x42\x77\x19\x8f\xf8\xb6\xf7\x85\x47\x8a\xa9\xa3\x9f\x40\x3c\xb7\x68\xdd\x02\xcb\xee\x32\x6c\x3e\x7d\xa3\x48\x84\x5f", 32); @@ -87,8 +87,8 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, poolstr = digestStr; } - allowance = (char*)(msg->data_initial_chunk.bytes + 4 + 32); - if (memcmp(allowance, (uint8_t*)&MAX_ALLOWANCE, 32) == 0) { + allowance = (char *)(msg->data_initial_chunk.bytes + 4 + 32); + if (memcmp(allowance, (uint8_t *)&MAX_ALLOWANCE, 32) == 0) { amt = "full balance"; } else { for (ctr = 0; ctr < 32; ctr++) { @@ -97,7 +97,7 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, amt = amtStr; } - const char* appStr = "uniswap approve liquidity"; + const char *appStr = "uniswap approve liquidity"; confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, "Amount: %s", amt); confirm(ButtonRequestType_ButtonRequest_ConfirmOutput, appStr, @@ -105,9 +105,9 @@ bool zx_confirmApproveLiquidity(uint32_t data_total, return true; } -bool zx_isZxApproveLiquid(const EthereumSignTx* msg) { +bool zx_isZxApproveLiquid(const EthereumSignTx *msg) { if (memcmp(msg->data_initial_chunk.bytes, "\x09\x5e\xa7\xb3", 4) == 0) - if (memcmp((uint8_t*)(msg->data_initial_chunk.bytes + 4 + 32 - 20), + if (memcmp((uint8_t *)(msg->data_initial_chunk.bytes + 4 + 32 - 20), UNISWAP_ROUTER_ADDRESS, 20) == 0) return true; return false;