Skip to content

Commit ebc0db7

Browse files
committed
feat(transport): add DylibTransport for in-process libkkemu testing
Same firmware as the standalone UDP kkemu binary, loaded in-process via ctypes. Lets python-keepkey exercise the firmware contract that the keepkey-vault FFI path imposes — most importantly, the caller-driven polling model (no daemon thread to call kkemu_poll for you). - keepkeylib/transport_dylib.py: DylibState (process-wide singleton over ctypes-loaded libkkemu) + DylibTransport (one per iface 0/1). Pumps kkemu_poll on every read/write so the firmware actually makes forward progress on caller turns. - tests/config.py: KK_TRANSPORT=dylib KK_DYLIB=/path/to/libkkemu.dylib routes the same fixture to the FFI transport instead of UDP. - tests/test_dylib_confirm_flow.py: regression for the confirm-flow contract (Initialize, WipeDevice, LoadDevice, GetAddress). Skipped unless KK_TRANSPORT=dylib so it won't break the default UDP run. Reproduces the keepkey-vault hang deterministically: Initialize round- trips fine, wipe_device hangs because confirm_helper busy-loops on a ButtonAck the dylib silently consumed but never delivered. Caught in ~10s, no electrobun / bun stack required. Run: cd tests && KK_TRANSPORT=dylib KK_DYLIB=.../libkkemu.dylib \ PYTHONPATH=..:../keepkeylib python3 -m pytest \ test_dylib_confirm_flow.py -v
1 parent 698e9a4 commit ebc0db7

3 files changed

Lines changed: 342 additions & 0 deletions

File tree

keepkeylib/transport_dylib.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
"""DylibTransport — talk to libkkemu.dylib (or libkkemu.so) over FFI ringbuffers.
2+
3+
This is the same firmware the standalone ``kkemu`` UDP binary runs, but loaded
4+
in-process. Two transports cover the two ringbuffer pairs the dylib exposes:
5+
6+
* iface 0 (main): rb_main_in / rb_main_out — host ↔ firmware protocol
7+
* iface 1 (debug): rb_debug_in / rb_debug_out — DebugLink
8+
9+
The vault uses this same FFI surface from Bun. Adding a Python transport that
10+
mirrors it lets ``python-keepkey`` exercise the firmware contract that the
11+
dylib path imposes — most importantly, the *caller-driven polling* model:
12+
nothing happens inside the firmware until the host calls ``kkemu_poll``. UDP
13+
hides this behind a thread inside ``kkemu``; the dylib does not.
14+
15+
Usage
16+
-----
17+
::
18+
19+
from keepkeylib.transport_dylib import DylibState, DylibTransport
20+
21+
state = DylibState.get_or_init('/path/to/libkkemu.dylib')
22+
main_transport = DylibTransport(state, iface=0)
23+
debug_transport = DylibTransport(state, iface=1)
24+
client = KeepKeyDebugClient(main_transport)
25+
client.set_debuglink(DebugLink(debug_transport))
26+
27+
A *single* ``DylibState`` is shared between the two transports — the dylib's
28+
``kkemu_init`` may only be called once per process. Re-initialising means
29+
restarting the test process (or factory-resetting via ``reset_flash``).
30+
"""
31+
32+
from __future__ import print_function
33+
34+
import ctypes
35+
import os
36+
import struct
37+
import threading
38+
import time
39+
40+
from .transport import Transport, ConnectionError
41+
42+
43+
# ── Dylib singleton ─────────────────────────────────────────────────────────
44+
45+
46+
PACKET_SIZE = 64
47+
FLASH_SIZE = 1 << 20 # 1 MB
48+
49+
# Max time we'll spin in kkemu_poll() looking for a frame on this iface.
50+
# Has to cover firmware-internal busy-loops (confirm_helper polls usbPoll
51+
# in a tight C loop — we just need the next outbound frame to land).
52+
_POLL_TIMEOUT_S = 30.0
53+
_POLL_QUANTUM_S = 0.001 # 1 ms — keep latency low without burning CPU
54+
55+
56+
class DylibState(object):
57+
"""Process-wide ``libkkemu.dylib`` handle.
58+
59+
Holds the ctypes binding and the (locked) flash buffer. Only one instance
60+
is allowed per process because ``kkemu_init`` is single-shot. Use
61+
:func:`get_or_init` rather than the constructor.
62+
"""
63+
64+
_instance = None
65+
_lock = threading.Lock()
66+
67+
def __init__(self, dylib_path):
68+
if not os.path.exists(dylib_path):
69+
raise ConnectionError("dylib not found: %s" % dylib_path)
70+
71+
self.lib = ctypes.CDLL(dylib_path)
72+
73+
self.lib.kkemu_init.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
74+
self.lib.kkemu_init.restype = ctypes.c_int
75+
76+
self.lib.kkemu_shutdown.argtypes = []
77+
self.lib.kkemu_shutdown.restype = None
78+
79+
self.lib.kkemu_poll.argtypes = []
80+
self.lib.kkemu_poll.restype = ctypes.c_int
81+
82+
self.lib.kkemu_is_running.argtypes = []
83+
self.lib.kkemu_is_running.restype = ctypes.c_int
84+
85+
self.lib.kkemu_write.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_int]
86+
self.lib.kkemu_write.restype = ctypes.c_int
87+
88+
self.lib.kkemu_read.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_int]
89+
self.lib.kkemu_read.restype = ctypes.c_int
90+
91+
self.lib.kkemu_get_display.argtypes = [
92+
ctypes.POINTER(ctypes.c_int),
93+
ctypes.POINTER(ctypes.c_int),
94+
]
95+
self.lib.kkemu_get_display.restype = ctypes.c_void_p
96+
97+
# Allocate flash as 0xFF (erased NOR state). Held by the singleton so
98+
# GC doesn't free it underneath the firmware's still-live mlock.
99+
self.flash = (ctypes.c_uint8 * FLASH_SIZE)(*([0xFF] * FLASH_SIZE))
100+
101+
rc = self.lib.kkemu_init(ctypes.cast(self.flash, ctypes.c_void_p), FLASH_SIZE)
102+
if rc != 0:
103+
raise ConnectionError("kkemu_init failed: %d" % rc)
104+
105+
# Single mutex around every FFI call. The dylib's internals aren't
106+
# thread-safe; main + debug transport may both poll/read concurrently.
107+
self.io_lock = threading.Lock()
108+
109+
# Pump a few ticks so the firmware finishes its boot sequence (loads
110+
# storage, draws home screen) before the first test touches it.
111+
with self.io_lock:
112+
for _ in range(8):
113+
self.lib.kkemu_poll()
114+
115+
@classmethod
116+
def get_or_init(cls, dylib_path):
117+
"""Return the per-process singleton, creating it on first call.
118+
119+
Subsequent calls ignore ``dylib_path`` — the dylib is single-shot and
120+
re-loading risks UB (mlock'd flash buffer would dangle).
121+
"""
122+
with cls._lock:
123+
if cls._instance is None:
124+
cls._instance = cls(dylib_path)
125+
return cls._instance
126+
127+
def shutdown(self):
128+
"""Tear down the firmware. Used by tests; not safe to re-init after."""
129+
with self.io_lock:
130+
self.lib.kkemu_shutdown()
131+
132+
133+
# ── Transport ───────────────────────────────────────────────────────────────
134+
135+
136+
class DylibTransport(Transport):
137+
"""One transport per (DylibState, iface) pair.
138+
139+
``iface=0`` is the main protocol channel, ``iface=1`` is DebugLink.
140+
"""
141+
142+
def __init__(self, state, iface=0, *args, **kwargs):
143+
if not isinstance(state, DylibState):
144+
raise TypeError("state must be a DylibState")
145+
if iface not in (0, 1):
146+
raise ValueError("iface must be 0 (main) or 1 (debug)")
147+
148+
self.state = state
149+
self.iface = iface
150+
self.read_buffer = b""
151+
152+
# Transport.__init__ calls self._open(); device arg is just metadata.
153+
super(DylibTransport, self).__init__("dylib:iface=%d" % iface, *args, **kwargs)
154+
155+
# ── Transport hooks ─────────────────────────────────────────────────
156+
157+
def _open(self):
158+
# Nothing to do — the dylib was opened when DylibState was created.
159+
pass
160+
161+
def _close(self):
162+
# Don't shut the dylib down on close; the singleton outlives us.
163+
self.read_buffer = b""
164+
165+
def ready_to_read(self):
166+
# Drive the firmware once so any pending outbound frame surfaces
167+
# in the ringbuffer. Without this, nothing ever appears to be ready.
168+
with self.state.io_lock:
169+
self.state.lib.kkemu_poll()
170+
buf = (ctypes.c_uint8 * PACKET_SIZE)()
171+
n = self.state.lib.kkemu_read(buf, PACKET_SIZE, self.iface)
172+
if n > 0:
173+
# Stash the frame so the next _read sees it without losing data.
174+
self.read_buffer += bytes(buf[:n])
175+
return bool(self.read_buffer)
176+
177+
# ── Wire protocol ───────────────────────────────────────────────────
178+
179+
def _write(self, msg, protobuf_msg):
180+
"""Chunk ``msg`` into 64-byte HID frames and shove them at the firmware.
181+
182+
``msg`` already starts with ``"##"`` + msg-type + length (see
183+
``Transport.write``). The first chunk needs a leading ``"?"`` marker;
184+
continuation chunks just get their leading ``"?"`` to round out
185+
the 64-byte HID report.
186+
"""
187+
# 63 bytes per chunk + leading '?' = 64 bytes per HID frame
188+
for chunk in [msg[i : i + 63] for i in range(0, len(msg), 63)]:
189+
chunk = chunk + b"\0" * (63 - len(chunk))
190+
frame = b"?" + chunk
191+
assert len(frame) == PACKET_SIZE
192+
with self.state.io_lock:
193+
rc = self.state.lib.kkemu_write(frame, PACKET_SIZE, self.iface)
194+
if rc != 0:
195+
raise ConnectionError(
196+
"kkemu_write failed (iface=%d, rc=%d)" % (self.iface, rc)
197+
)
198+
# Pump immediately so the firmware can start consuming this
199+
# chunk before the next one arrives. Required because the
200+
# caller (not a daemon) is the only thing driving the FSM.
201+
self.state.lib.kkemu_poll()
202+
203+
def _read(self):
204+
"""Read one full message — header parse drives chunk reassembly."""
205+
try:
206+
(msg_type, datalen) = self._read_headers(_FrameStream(self))
207+
payload = self._read_bytes(datalen)
208+
return (msg_type, payload)
209+
except Exception as exc:
210+
print("DylibTransport._read failed: %s" % exc)
211+
raise
212+
213+
# ── Internals ───────────────────────────────────────────────────────
214+
215+
def _read_bytes(self, length):
216+
"""Block until ``length`` payload bytes have been gathered."""
217+
deadline = time.time() + _POLL_TIMEOUT_S
218+
while len(self.read_buffer) < length:
219+
if time.time() > deadline:
220+
raise ConnectionError(
221+
"Timed out reading %d bytes from iface %d" % (length, self.iface)
222+
)
223+
self._pump_one()
224+
out = self.read_buffer[:length]
225+
self.read_buffer = self.read_buffer[length:]
226+
return out
227+
228+
def _pump_one(self):
229+
"""Run one poll/read cycle. Strips the leading '?' HID marker."""
230+
with self.state.io_lock:
231+
self.state.lib.kkemu_poll()
232+
buf = (ctypes.c_uint8 * PACKET_SIZE)()
233+
n = self.state.lib.kkemu_read(buf, PACKET_SIZE, self.iface)
234+
if n > 0:
235+
# Drop the leading '?' marker; rest is payload.
236+
self.read_buffer += bytes(buf[1:n])
237+
return
238+
# No frame available — back off briefly so we don't spin a hot loop.
239+
time.sleep(_POLL_QUANTUM_S)
240+
241+
242+
class _FrameStream(object):
243+
"""File-like adapter so Transport._read_headers can drive _pump_one."""
244+
245+
def __init__(self, transport):
246+
self.transport = transport
247+
248+
def read(self, n):
249+
return self.transport._read_bytes(n)

tests/config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,25 @@
7373
DEBUG_TRANSPORT = WebUsbTransport
7474
DEBUG_TRANSPORT_ARGS = (webusb_devices[0],)
7575
DEBUG_TRANSPORT_KWARGS = {'debug_link': True}
76+
elif os.getenv('KK_TRANSPORT') == 'dylib':
77+
# In-process FFI transport against libkkemu.dylib (or libkkemu.so).
78+
# Same firmware as UDP, different transport — exposes caller-driven
79+
# polling bugs that the UDP daemon hides behind its own poll thread.
80+
print('Using Emulator (dylib FFI)')
81+
from keepkeylib.transport_dylib import DylibState, DylibTransport
82+
_dylib_path = os.getenv('KK_DYLIB')
83+
if not _dylib_path:
84+
raise RuntimeError(
85+
"KK_TRANSPORT=dylib requires KK_DYLIB=/path/to/libkkemu.dylib"
86+
)
87+
_dylib_state = DylibState.get_or_init(_dylib_path)
88+
TRANSPORT = DylibTransport
89+
TRANSPORT_ARGS = (_dylib_state, 0)
90+
TRANSPORT_KWARGS = {}
91+
DEBUG_TRANSPORT = DylibTransport
92+
DEBUG_TRANSPORT_ARGS = (_dylib_state, 1)
93+
DEBUG_TRANSPORT_KWARGS = {}
94+
7695
else:
7796
print('Using Emulator')
7897
TRANSPORT = UDPTransport

tests/test_dylib_confirm_flow.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Regression test for the dylib confirm-flow contract.
2+
3+
Exercises the exact sequence the keepkey-vault FFI path runs:
4+
5+
1. Initialize — Features round-trip (no confirm)
6+
2. WipeDevice — needs one confirm (BA on iface 0 + DLD on iface 1)
7+
3. LoadDevice — needs one confirm
8+
4. GetAddress — Features cache + xpub derivation, no confirm
9+
10+
Each step calls into ``confirm_helper`` inside the firmware while the
11+
caller (this test process) is the only thing driving ``kkemu_poll``. The
12+
exact same firmware passes the UDP-transport tests because the standalone
13+
``kkemu`` binary has its own poll thread; the dylib path doesn't, so any
14+
busy-loop in confirm_helper that waits on a frame the dylib silently
15+
dropped will hang here.
16+
17+
Skips automatically when ``KK_TRANSPORT != 'dylib'`` so the file is safe
18+
to keep in the regular pytest run.
19+
"""
20+
21+
import os
22+
import unittest
23+
24+
import config
25+
26+
27+
@unittest.skipUnless(
28+
os.environ.get("KK_TRANSPORT") == "dylib",
29+
"dylib confirm-flow regression — set KK_TRANSPORT=dylib KK_DYLIB=...",
30+
)
31+
class TestDylibConfirmFlow(unittest.TestCase):
32+
"""Skipped under the default UDP transport; the UDP daemon hides the
33+
polling contract that this test specifically validates."""
34+
35+
# We import lazily so the module loads even when KK_TRANSPORT != 'dylib'
36+
# (config.py only constructs the dylib state on demand in that branch).
37+
def setUp(self):
38+
# Late import — `common` is heavy (it eagerly wipes the device on
39+
# construction) and would defeat the skip above.
40+
import common # noqa: WPS433
41+
42+
self._common = common
43+
self.test = common.KeepKeyTest("setUp")
44+
self.test.setUp()
45+
self.client = self.test.client
46+
47+
def tearDown(self):
48+
self.test.tearDown()
49+
50+
def test_features_round_trip(self):
51+
"""The connection itself works; Features should have firmware fields."""
52+
self.client.init_device()
53+
f = self.client.features
54+
self.assertGreaterEqual(f.major_version, 7)
55+
56+
def test_load_device_with_auto_confirm(self):
57+
"""The full LoadDevice flow — confirm_helper must exit cleanly.
58+
59+
This is the exact path the vault hangs on. If the dylib's tiny-msg
60+
dispatch is broken, this test hangs (eventually pytest's timeout
61+
kills it) instead of returning.
62+
"""
63+
# KeepKeyTest.setUp already wipes; load a known mnemonic on top.
64+
self.test.setup_mnemonic_nopin_nopassphrase()
65+
# Round-trip something that requires the seed — confirms LoadDevice
66+
# actually committed instead of bouncing off a confirm timeout.
67+
addr = self.client.get_address("Bitcoin", [])
68+
# Valid mainnet P2PKH addresses start with '1' and are 26-35 chars.
69+
self.assertTrue(addr.startswith("1"))
70+
self.assertGreaterEqual(len(addr), 26)
71+
72+
73+
if __name__ == "__main__":
74+
unittest.main()

0 commit comments

Comments
 (0)