From 2f9269f64af5d9bc1a23ab34dfd1a6f2157e8b2f Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 2 Aug 2026 19:02:21 -0300 Subject: [PATCH 1/4] feat(rng): make RNG-source selection unmissable, and auditable from a host Response to the July 2026 COLDCARD incident (~1,367 BTC across 4,585 addresses). That was not a broken RNG: a board config left the hardware-RNG macro defined-but-zero, the supporting library tested only whether the macro was *defined* rather than enabled, and seed generation silently used the wrong source for five years. The substituted generator passed every statistical test -- it was simply seeded with ~40 bits -- so no amount of host-side entropy testing would have found it. Only the build configuration was wrong, and nobody could check. KeepKey is not exposed the way Coldcard was: reset.c mixes host entropy into the seed unconditionally (SHA256(int_entropy || ext_entropy)), so even a dead device RNG still yields a 256-bit seed, and random32() has no weak-PRNG fallback -- the emulator branch uses the host OS CSPRNG and aborts rather than degrading. This change hardens the two things the incident showed actually matter. 1. lib/rand/rng.c -- compile-time assertion on RNG *selection*. __arm__ comes from the compiler's own target definition, not from a board config or CMake option, so a mistaken -DEMULATOR cannot satisfy both conditions: firmware targeting the STM32 can only ever compile the RNG_DR path. Zero ROM, zero RAM. 2. GetEntropy is now auditable. It confirmed on every call, capped at 1 KiB, which made bulk RNG audits (bias tests, birthday/collision scans) impossible on real hardware -- so nobody ever ran one. Raise Entropy.entropy to 8 KiB and allow 64 KiB per boot without a press; the confirm returns once that budget is spent, and a replug refreshes it. Both are RAM-neutral: msg_resp and frame_arena.tx are already sized to MAX_FRAME_SIZE (12 KiB), and sizeof(Entropy) goes 1026 -> 8194, still under the existing _Static_assert. Seven messages already carry 2 KiB fields. The press was never protecting a secret -- the bytes are drawn fresh and discarded, never reused as key material, and the STM32 RNG is a free-running noise source rather than a seeded DRBG, so observing output reveals nothing about other draws. What it did buy is a cap on bias characterization: random32() returns RNG_DR raw with no whitening. A per-boot budget keeps that cap against a remote hostile host (which cannot replug) while leaving an audit ample room. Verified on kkemu via scripts/emulator/entropy-budget-check.py: 64 KiB collected in 8 x 8 KiB calls with no button press, all blocks distinct, and the next call correctly falls back to ButtonRequest. --- include/keepkey/transport/messages.options | 2 +- lib/firmware/fsm_msg_common.h | 39 ++++++-- lib/rand/rng.c | 26 +++++ scripts/emulator/entropy-budget-check.py | 110 +++++++++++++++++++++ 4 files changed, 168 insertions(+), 9 deletions(-) create mode 100755 scripts/emulator/entropy-budget-check.py diff --git a/include/keepkey/transport/messages.options b/include/keepkey/transport/messages.options index 73e4815c8..7ac6ff0a9 100644 --- a/include/keepkey/transport/messages.options +++ b/include/keepkey/transport/messages.options @@ -27,7 +27,7 @@ PinMatrixAck.pin max_size:10 PassphraseAck.passphrase max_size:51 -Entropy.entropy max_size:1024 +Entropy.entropy max_size:8192 GetPublicKey.address_n max_count:8 GetPublicKey.ecdsa_curve_name max_size:32 diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 1547486a9..6c49bc717 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -532,22 +532,45 @@ void fsm_msgFirmwareUpload(FirmwareUpload* msg) { "Not in bootloader mode"); } +/* Bytes of entropy a host may collect per boot without a button press. + * + * Auditing the RNG (bias tests, birthday/collision scans) needs bulk + * samples, and a press per kilobyte made that impossible on real hardware + * -- so nobody ever checked. The returned bytes are drawn fresh and + * discarded; they are never reused as key material, and the STM32 RNG is a + * free-running noise source rather than a seeded DRBG, so observing output + * reveals nothing about past or future draws. + * + * What the press did still buy is a cap on bias characterization: random32() + * returns RNG_DR raw with no whitening, and unlimited raw output lets a + * hostile host measure that bias precisely. A per-boot budget keeps that + * cap against a remote malicious host (which cannot replug) while leaving + * an audit plenty of room. Once spent, the confirm comes back; replug to + * refresh. */ +#define ENTROPY_FREE_BUDGET (64 * 1024) + void fsm_msgGetEntropy(GetEntropy* msg) { - if (!confirm(ButtonRequestType_ButtonRequest_GetEntropy, "Generate Entropy", - "Do you want to generate and return entropy using the hardware " - "RNG?")) { - fsm_sendFailure(FailureType_Failure_ActionCancelled, "Entropy cancelled"); - layoutHome(); - return; - } + static uint32_t free_budget = ENTROPY_FREE_BUDGET; - RESP_INIT(Entropy); uint32_t len = msg->size; if (len > ENTROPY_BUF) { len = ENTROPY_BUF; } + if (len <= free_budget) { + free_budget -= len; + } else if (!confirm(ButtonRequestType_ButtonRequest_GetEntropy, + "Generate Entropy", + "Do you want to generate and return entropy using the " + "hardware RNG?")) { + fsm_sendFailure(FailureType_Failure_ActionCancelled, "Entropy cancelled"); + layoutHome(); + return; + } + + RESP_INIT(Entropy); + resp->entropy.size = len; random_buffer(resp->entropy.bytes, len); msg_write(MessageType_MessageType_Entropy, resp); diff --git a/lib/rand/rng.c b/lib/rand/rng.c index 8f93faccb..71948958c 100644 --- a/lib/rand/rng.c +++ b/lib/rand/rng.c @@ -31,6 +31,32 @@ #include #endif +/* random32() has two implementations selected by a build flag: the STM32 + * hardware RNG, and -- under EMULATOR -- the host OS CSPRNG. Neither is a + * weak PRNG today, and the emulator branch deliberately aborts rather than + * degrading to libc random(). + * + * This assertion guards the *selection*, not either implementation. The + * July 2026 COLDCARD incident was not a broken RNG: a board config left + * the hardware-RNG macro defined-but-zero, the supporting library tested + * only whether that macro was *defined* rather than enabled, and seed + * generation silently used the wrong source for five years (~1,367 BTC + * drained across 4,585 addresses). Nothing about the output looked wrong + * -- the substituted generator passed every statistical test, it was just + * seeded with ~40 bits -- so no amount of host-side entropy testing could + * have caught it. Only the build configuration was wrong. + * + * The lesson is that "which RNG did we actually compile in" deserves a + * check the build cannot silently get wrong. __arm__ comes from the + * compiler's own target definition rather than from any board config or + * CMake option, so a mistaken -DEMULATOR cannot satisfy both conditions: + * firmware targeting the STM32 can only ever compile the RNG_DR path. + * Hosted emulator builds (x86_64 / __aarch64__) are unaffected. */ +#if defined(EMULATOR) && defined(__arm__) +#error \ + "EMULATOR selects the host-CSPRNG random32(); ARM firmware must use the STM32 hardware RNG" +#endif + void reset_rng(void) { #ifndef EMULATOR /* disable RNG */ diff --git a/scripts/emulator/entropy-budget-check.py b/scripts/emulator/entropy-budget-check.py new file mode 100755 index 000000000..969336c81 --- /dev/null +++ b/scripts/emulator/entropy-budget-check.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Functional check for the GetEntropy per-boot budget (fsm_msg_common.h). + +Drives a standalone kkemu over UDP with raw wire frames -- no regenerated +protos needed. Asserts: + 1. GetEntropy(8192) returns 8192 bytes (the raised Entropy.entropy cap). + 2. The first ENTROPY_FREE_BUDGET (64 KiB) needs no button press. + 3. The very next call falls back to a ButtonRequest. + 4. Bytes actually differ between calls (emulator RNG is not stuck). +""" +import os, socket, struct, subprocess, sys, tempfile, time + +PORT = int(os.environ.get("KEEPKEY_UDP_PORT", "31044")) +KKEMU = sys.argv[1] if len(sys.argv) > 1 else "build/bin/kkemu" + +GET_ENTROPY, ENTROPY, BUTTON_REQUEST, FAILURE, INITIALIZE = 9, 10, 26, 3, 0 + + +def frames(msg_type, body): + """Split a message into 64-byte HID-style frames.""" + head = b"\x3f##" + struct.pack(">HL", msg_type, len(body)) + payload = head + body + out = [] + first, rest = payload[:64], payload[64:] + out.append(first.ljust(64, b"\x00")) + while rest: + chunk, rest = rest[:63], rest[63:] + out.append((b"\x3f" + chunk).ljust(64, b"\x00")) + return out + + +def call(sock, msg_type, body=b"", timeout=5.0): + for f in frames(msg_type, body): + sock.send(f) + sock.settimeout(timeout) + pkt = sock.recv(64) + assert pkt[:3] == b"\x3f##", f"bad response header {pkt[:3]!r}" + rtype, rlen = struct.unpack(">HL", pkt[3:9]) + data = pkt[9:] + while len(data) < rlen: + data += sock.recv(64)[1:] + return rtype, data[:rlen] + + +def varint(n): + out = b"" + while True: + b = n & 0x7F + n >>= 7 + out += bytes([b | (0x80 if n else 0)]) + if not n: + return out + + +def get_entropy(sock, size): + return call(sock, GET_ENTROPY, b"\x08" + varint(size)) + + +def parse_entropy(body): + assert body[0] == 0x0A, f"expected field 1 bytes, got {body[0]:#x}" + i, shift, ln = 1, 0, 0 + while True: + b = body[i]; i += 1 + ln |= (b & 0x7F) << shift; shift += 7 + if not (b & 0x80): + break + return body[i:i + ln] + + +def main(): + workdir = tempfile.mkdtemp(prefix="kkemu-entropy-") + emu = subprocess.Popen([os.path.abspath(KKEMU)], cwd=workdir, + env={**os.environ, "KEEPKEY_UDP_PORT": str(PORT)}, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + time.sleep(1.5) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect(("127.0.0.1", PORT)) + call(sock, INITIALIZE) + + CHUNK, BUDGET = 8192, 64 * 1024 + n_free = BUDGET // CHUNK + seen = [] + for i in range(n_free): + rtype, body = get_entropy(sock, CHUNK) + assert rtype == ENTROPY, ( + f"call {i+1}/{n_free} within budget returned type {rtype}, " + f"expected Entropy({ENTROPY}) with no press") + ent = parse_entropy(body) + assert len(ent) == CHUNK, f"got {len(ent)} bytes, expected {CHUNK}" + seen.append(ent) + print(f" [ok] {n_free} x {CHUNK}B = {n_free*CHUNK} bytes, no button press") + print(f" [ok] Entropy.entropy cap raised: {CHUNK} bytes in one call") + + assert len(set(seen)) == n_free, "repeated entropy block across calls!" + print(f" [ok] all {n_free} blocks distinct (RNG not stuck)") + + rtype, _ = get_entropy(sock, CHUNK) + assert rtype == BUTTON_REQUEST, ( + f"budget exhausted but got type {rtype}, expected " + f"ButtonRequest({BUTTON_REQUEST}) -- budget is not enforced!") + print(f" [ok] budget exhausted -> ButtonRequest (confirm restored)") + print("\nPASS") + return 0 + finally: + emu.terminate() + + +if __name__ == "__main__": + sys.exit(main()) From 686f70133ec8880f3947738c6e34fd0ab06b99f5 Mon Sep 17 00:00:00 2001 From: highlander Date: Sun, 2 Aug 2026 19:51:00 -0300 Subject: [PATCH 2/4] fix(rng): require a press for entropy on a locked device Self-review of the press-free path found a regression this PR introduced. GetEntropy has no PIN gate and no initialization gate -- the button press WAS the human gate. Dropping it unconditionally meant anyone holding a locked, initialized device could harvest raw RNG_DR output silently, and replug to refresh the budget and repeat. The exposure is bounded (ECDSA nonces are RFC6979-deterministic, so bias cannot weaken signatures, and the returned bytes are never key material), but it is a real change in what a locked device does with no user present, and it is not what the press-free path is for. Restrict press-free collection to states where there is either nothing to protect or a user demonstrably present: - uninitialized: no seed exists yet. This is the case that motivated the change -- auditing the RNG *before* trusting it to generate a seed. - no PIN configured: nothing is locked, so the press guards nothing that physical possession does not already defeat. - PIN already cached this session: the user is right there. An initialized, PIN-protected, locked device now falls back to the confirm exactly as before this PR. entropy-budget-check.py grows a case for it: load a seed with a PIN, ClearSession, then assert a fresh-budget GetEntropy still returns ButtonRequest. Verified the assertion has teeth by forcing the predicate true and confirming the check fails (returns Entropy instead). --- lib/firmware/fsm_msg_common.h | 24 +++++++- scripts/emulator/entropy-budget-check.py | 71 +++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/lib/firmware/fsm_msg_common.h b/lib/firmware/fsm_msg_common.h index 6c49bc717..4d827e5d4 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -549,6 +549,28 @@ void fsm_msgFirmwareUpload(FirmwareUpload* msg) { * refresh. */ #define ENTROPY_FREE_BUDGET (64 * 1024) +/* Whether the budget above may be spent without a press. + * + * GetEntropy has no PIN or initialization gate -- the button press WAS the + * human gate. Dropping it unconditionally would let someone holding a locked + * device harvest raw RNG output silently, and replug to repeat, so restrict + * the press-free path to states where there is either nothing to protect or + * a user demonstrably present: + * + * - uninitialized: no seed exists yet. This is the case that matters -- + * auditing the RNG *before* trusting it to generate a seed. + * - no PIN configured: nothing is locked, so the press guards nothing that + * physical possession does not already defeat. + * - PIN already entered this session: the user is right there. + * + * An initialized, PIN-protected, locked device is the stolen / evil-maid + * case and falls back to the confirm exactly as before. */ +static bool entropy_press_free_allowed(void) { + if (!storage_isInitialized()) return true; + if (!storage_hasPin()) return true; + return session_isPinCached(); +} + void fsm_msgGetEntropy(GetEntropy* msg) { static uint32_t free_budget = ENTROPY_FREE_BUDGET; @@ -558,7 +580,7 @@ void fsm_msgGetEntropy(GetEntropy* msg) { len = ENTROPY_BUF; } - if (len <= free_budget) { + if (len <= free_budget && entropy_press_free_allowed()) { free_budget -= len; } else if (!confirm(ButtonRequestType_ButtonRequest_GetEntropy, "Generate Entropy", diff --git a/scripts/emulator/entropy-budget-check.py b/scripts/emulator/entropy-budget-check.py index 969336c81..fd0330b71 100755 --- a/scripts/emulator/entropy-budget-check.py +++ b/scripts/emulator/entropy-budget-check.py @@ -14,6 +14,8 @@ KKEMU = sys.argv[1] if len(sys.argv) > 1 else "build/bin/kkemu" GET_ENTROPY, ENTROPY, BUTTON_REQUEST, FAILURE, INITIALIZE = 9, 10, 26, 3, 0 +SUCCESS, LOAD_DEVICE, CLEAR_SESSION = 2, 13, 24 +BUTTON_ACK, DEBUG_LINK_DECISION = 27, 100 def frames(msg_type, body): @@ -29,9 +31,12 @@ def frames(msg_type, body): return out -def call(sock, msg_type, body=b"", timeout=5.0): +def send(sock, msg_type, body=b""): for f in frames(msg_type, body): sock.send(f) + + +def recv(sock, timeout=5.0): sock.settimeout(timeout) pkt = sock.recv(64) assert pkt[:3] == b"\x3f##", f"bad response header {pkt[:3]!r}" @@ -42,6 +47,27 @@ def call(sock, msg_type, body=b"", timeout=5.0): return rtype, data[:rlen] +def call(sock, msg_type, body=b"", timeout=5.0): + send(sock, msg_type, body) + return recv(sock, timeout) + + +def call_confirmed(sock, dbg, msg_type, body=b"", timeout=15.0): + """Call a message that shows a confirm, approving it over DebugLink. + + confirm() emits ButtonRequest and then blocks on a physical press, so the + host must both ButtonAck and simulate the press (DebugLinkDecision). + """ + rtype, _ = call(sock, msg_type, body, timeout) + if rtype != BUTTON_REQUEST: + return rtype + send(sock, BUTTON_ACK) + time.sleep(0.3) + send(dbg, DEBUG_LINK_DECISION, b"\x08\x01") # yes_no = true + rtype, _ = recv(sock, timeout) + return rtype + + def varint(n): out = b"" while True: @@ -100,6 +126,49 @@ def main(): f"budget exhausted but got type {rtype}, expected " f"ButtonRequest({BUTTON_REQUEST}) -- budget is not enforced!") print(f" [ok] budget exhausted -> ButtonRequest (confirm restored)") + + # ── The security predicate: a LOCKED device must still require a press. + # + # GetEntropy has no PIN gate of its own; the button WAS the gate. So + # entropy_press_free_allowed() is what stops someone holding a locked + # device from harvesting raw RNG silently and replugging to repeat. + # Restart the emulator (fresh budget), load a seed WITH a PIN, drop the + # session, and confirm the press-free path is refused. + emu.terminate() + emu.wait(timeout=10) + emu2 = subprocess.Popen([os.path.abspath(KKEMU)], cwd=workdir, + env={**os.environ, "KEEPKEY_UDP_PORT": str(PORT)}, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + time.sleep(1.5) + s2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s2.connect(("127.0.0.1", PORT)) + dbg = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + dbg.connect(("127.0.0.1", PORT + 1)) + call(s2, INITIALIZE) + + # LoadDevice{ mnemonic(1), pin(3), skip_checksum(7) } + mn = ("alcohol woman abuse must during monitor noble " + "actual mixed trade anger aisle").encode() + body = (b"\x0a" + varint(len(mn)) + mn + + b"\x1a" + varint(4) + b"1234" + + b"\x38\x01") + rtype = call_confirmed(s2, dbg, LOAD_DEVICE, body) + assert rtype == SUCCESS, f"LoadDevice returned type {rtype}, expected Success" + + # ClearSession drops the cached PIN -> device is initialized + locked. + call(s2, CLEAR_SESSION) + + rtype, _ = get_entropy(s2, CHUNK) + assert rtype == BUTTON_REQUEST, ( + f"LOCKED device returned type {rtype} with a fresh budget -- " + f"expected ButtonRequest({BUTTON_REQUEST}). Press-free entropy " + f"is reachable on a locked device: silent RNG harvest by anyone " + f"holding the device.") + print(" [ok] locked device -> ButtonRequest (no silent harvest)") + finally: + emu2.terminate() + print("\nPASS") return 0 finally: From f88148f75e3de25f7317171183f2320d8247a3cb Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 3 Aug 2026 14:08:34 -0300 Subject: [PATCH 3/4] ci(rng): gate entropy evidence in canonical suite --- scripts/emulator/entropy-budget-check.py | 179 ----------------------- scripts/emulator/python-keepkey-tests.sh | 29 +++- scripts/generate-test-report.py | 30 +++- 3 files changed, 55 insertions(+), 183 deletions(-) delete mode 100755 scripts/emulator/entropy-budget-check.py diff --git a/scripts/emulator/entropy-budget-check.py b/scripts/emulator/entropy-budget-check.py deleted file mode 100755 index fd0330b71..000000000 --- a/scripts/emulator/entropy-budget-check.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env python3 -"""Functional check for the GetEntropy per-boot budget (fsm_msg_common.h). - -Drives a standalone kkemu over UDP with raw wire frames -- no regenerated -protos needed. Asserts: - 1. GetEntropy(8192) returns 8192 bytes (the raised Entropy.entropy cap). - 2. The first ENTROPY_FREE_BUDGET (64 KiB) needs no button press. - 3. The very next call falls back to a ButtonRequest. - 4. Bytes actually differ between calls (emulator RNG is not stuck). -""" -import os, socket, struct, subprocess, sys, tempfile, time - -PORT = int(os.environ.get("KEEPKEY_UDP_PORT", "31044")) -KKEMU = sys.argv[1] if len(sys.argv) > 1 else "build/bin/kkemu" - -GET_ENTROPY, ENTROPY, BUTTON_REQUEST, FAILURE, INITIALIZE = 9, 10, 26, 3, 0 -SUCCESS, LOAD_DEVICE, CLEAR_SESSION = 2, 13, 24 -BUTTON_ACK, DEBUG_LINK_DECISION = 27, 100 - - -def frames(msg_type, body): - """Split a message into 64-byte HID-style frames.""" - head = b"\x3f##" + struct.pack(">HL", msg_type, len(body)) - payload = head + body - out = [] - first, rest = payload[:64], payload[64:] - out.append(first.ljust(64, b"\x00")) - while rest: - chunk, rest = rest[:63], rest[63:] - out.append((b"\x3f" + chunk).ljust(64, b"\x00")) - return out - - -def send(sock, msg_type, body=b""): - for f in frames(msg_type, body): - sock.send(f) - - -def recv(sock, timeout=5.0): - sock.settimeout(timeout) - pkt = sock.recv(64) - assert pkt[:3] == b"\x3f##", f"bad response header {pkt[:3]!r}" - rtype, rlen = struct.unpack(">HL", pkt[3:9]) - data = pkt[9:] - while len(data) < rlen: - data += sock.recv(64)[1:] - return rtype, data[:rlen] - - -def call(sock, msg_type, body=b"", timeout=5.0): - send(sock, msg_type, body) - return recv(sock, timeout) - - -def call_confirmed(sock, dbg, msg_type, body=b"", timeout=15.0): - """Call a message that shows a confirm, approving it over DebugLink. - - confirm() emits ButtonRequest and then blocks on a physical press, so the - host must both ButtonAck and simulate the press (DebugLinkDecision). - """ - rtype, _ = call(sock, msg_type, body, timeout) - if rtype != BUTTON_REQUEST: - return rtype - send(sock, BUTTON_ACK) - time.sleep(0.3) - send(dbg, DEBUG_LINK_DECISION, b"\x08\x01") # yes_no = true - rtype, _ = recv(sock, timeout) - return rtype - - -def varint(n): - out = b"" - while True: - b = n & 0x7F - n >>= 7 - out += bytes([b | (0x80 if n else 0)]) - if not n: - return out - - -def get_entropy(sock, size): - return call(sock, GET_ENTROPY, b"\x08" + varint(size)) - - -def parse_entropy(body): - assert body[0] == 0x0A, f"expected field 1 bytes, got {body[0]:#x}" - i, shift, ln = 1, 0, 0 - while True: - b = body[i]; i += 1 - ln |= (b & 0x7F) << shift; shift += 7 - if not (b & 0x80): - break - return body[i:i + ln] - - -def main(): - workdir = tempfile.mkdtemp(prefix="kkemu-entropy-") - emu = subprocess.Popen([os.path.abspath(KKEMU)], cwd=workdir, - env={**os.environ, "KEEPKEY_UDP_PORT": str(PORT)}, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - try: - time.sleep(1.5) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.connect(("127.0.0.1", PORT)) - call(sock, INITIALIZE) - - CHUNK, BUDGET = 8192, 64 * 1024 - n_free = BUDGET // CHUNK - seen = [] - for i in range(n_free): - rtype, body = get_entropy(sock, CHUNK) - assert rtype == ENTROPY, ( - f"call {i+1}/{n_free} within budget returned type {rtype}, " - f"expected Entropy({ENTROPY}) with no press") - ent = parse_entropy(body) - assert len(ent) == CHUNK, f"got {len(ent)} bytes, expected {CHUNK}" - seen.append(ent) - print(f" [ok] {n_free} x {CHUNK}B = {n_free*CHUNK} bytes, no button press") - print(f" [ok] Entropy.entropy cap raised: {CHUNK} bytes in one call") - - assert len(set(seen)) == n_free, "repeated entropy block across calls!" - print(f" [ok] all {n_free} blocks distinct (RNG not stuck)") - - rtype, _ = get_entropy(sock, CHUNK) - assert rtype == BUTTON_REQUEST, ( - f"budget exhausted but got type {rtype}, expected " - f"ButtonRequest({BUTTON_REQUEST}) -- budget is not enforced!") - print(f" [ok] budget exhausted -> ButtonRequest (confirm restored)") - - # ── The security predicate: a LOCKED device must still require a press. - # - # GetEntropy has no PIN gate of its own; the button WAS the gate. So - # entropy_press_free_allowed() is what stops someone holding a locked - # device from harvesting raw RNG silently and replugging to repeat. - # Restart the emulator (fresh budget), load a seed WITH a PIN, drop the - # session, and confirm the press-free path is refused. - emu.terminate() - emu.wait(timeout=10) - emu2 = subprocess.Popen([os.path.abspath(KKEMU)], cwd=workdir, - env={**os.environ, "KEEPKEY_UDP_PORT": str(PORT)}, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - try: - time.sleep(1.5) - s2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s2.connect(("127.0.0.1", PORT)) - dbg = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - dbg.connect(("127.0.0.1", PORT + 1)) - call(s2, INITIALIZE) - - # LoadDevice{ mnemonic(1), pin(3), skip_checksum(7) } - mn = ("alcohol woman abuse must during monitor noble " - "actual mixed trade anger aisle").encode() - body = (b"\x0a" + varint(len(mn)) + mn + - b"\x1a" + varint(4) + b"1234" + - b"\x38\x01") - rtype = call_confirmed(s2, dbg, LOAD_DEVICE, body) - assert rtype == SUCCESS, f"LoadDevice returned type {rtype}, expected Success" - - # ClearSession drops the cached PIN -> device is initialized + locked. - call(s2, CLEAR_SESSION) - - rtype, _ = get_entropy(s2, CHUNK) - assert rtype == BUTTON_REQUEST, ( - f"LOCKED device returned type {rtype} with a fresh budget -- " - f"expected ButtonRequest({BUTTON_REQUEST}). Press-free entropy " - f"is reachable on a locked device: silent RNG harvest by anyone " - f"holding the device.") - print(" [ok] locked device -> ButtonRequest (no silent harvest)") - finally: - emu2.terminate() - - print("\nPASS") - return 0 - finally: - emu.terminate() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/emulator/python-keepkey-tests.sh b/scripts/emulator/python-keepkey-tests.sh index e23497084..489a64674 100755 --- a/scripts/emulator/python-keepkey-tests.sh +++ b/scripts/emulator/python-keepkey-tests.sh @@ -93,19 +93,44 @@ fi # Tests that skip via requires_message/requires_firmware are OK. # Tests that fail or are missing from JUnit = CI failure. echo "=== Phase 2: Full test suite ===" +set +e KK_EXPECT_PERSIST_REJECTED=1 \ +KK_EXPECT_ENTROPY_BUDGET=1 \ KK_TRANSPORT_MAIN=kkemu:11044 \ KK_TRANSPORT_DEBUG=kkemu:11045 \ pytest -v --junitxml=/kkemu/test-reports/python-keepkey/junit.xml PYTEST_RC=$? +echo "=== Phase 2: Validate report catalog ===" +python3 ../scripts/generate-test-report.py \ + --junit=/kkemu/test-reports/python-keepkey/junit.xml \ + ${FW_VERSION:+--fw-version=$FW_VERSION} \ + --validate-junit +CATALOG_RC=$? + echo "=== Phase 2: Generate test report ===" python3 ../scripts/generate-test-report.py \ --junit=/kkemu/test-reports/python-keepkey/junit.xml \ - ${FW_VERSION:+--fw-version=$FW_VERSION} || true + ${FW_VERSION:+--fw-version=$FW_VERSION} \ + --screenshots=/kkemu/test-reports/screenshots \ + --output=/kkemu/test-reports/test-report.pdf +REPORT_RC=$? +set -e -echo "$PYTEST_RC" > /kkemu/test-reports/python-keepkey/status +if [ "$PYTEST_RC" -eq 0 ] && [ "$CATALOG_RC" -eq 0 ] && [ "$REPORT_RC" -eq 0 ]; then + echo "0" > /kkemu/test-reports/python-keepkey/status +else + echo "1" > /kkemu/test-reports/python-keepkey/status +fi if [ "$PYTEST_RC" -ne 0 ]; then echo "pytest failed with exit code $PYTEST_RC" exit "$PYTEST_RC" fi +if [ "$CATALOG_RC" -ne 0 ]; then + echo "report catalog validation failed with exit code $CATALOG_RC" + exit "$CATALOG_RC" +fi +if [ "$REPORT_RC" -ne 0 ]; then + echo "test report generation failed with exit code $REPORT_RC" + exit "$REPORT_RC" +fi diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 2803adce8..fe772f7d5 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -23,6 +23,15 @@ def main(): file=sys.stderr) sys.exit(1) + # The release report is evidence, not a best-effort decoration. The + # canonical Python JUnit must exist; otherwise rendering an empty catalog + # produces a dangerously plausible "all pending" PDF. + python_junit = 'test-reports/python-keepkey/junit.xml' + if not os.path.isfile(python_junit) or os.path.getsize(python_junit) == 0: + print("ERROR: required Python JUnit evidence missing: %s" % python_junit, + file=sys.stderr) + sys.exit(1) + # Collect JUnit XMLs from CI artifacts junit_files = ( glob.glob('test-reports/python-keepkey/junit*.xml') + @@ -65,9 +74,9 @@ def main(): print("Running: %s" % ' '.join(cmd)) result = subprocess.run(cmd) - # Don't exit non-zero -- let the report be uploaded even with partial results if result.returncode != 0: - print("WARN: report generator exited %d" % result.returncode, file=sys.stderr) + print("ERROR: report generator exited %d" % result.returncode, file=sys.stderr) + sys.exit(result.returncode) if os.path.exists('test-report.pdf'): size = os.path.getsize('test-report.pdf') @@ -76,6 +85,23 @@ def main(): print("ERROR: test-report.pdf not created", file=sys.stderr) sys.exit(1) + # Render first so a failed candidate still has a truthful diagnostic PDF, + # then fail the job if any catalog entry failed or is missing. Deliberate + # feature/policy skips remain valid per the report generator contract. + validate_cmd = [ + sys.executable, + REPORT_GENERATOR, + '--junit=%s' % python_junit, + '--validate-junit', + ] + if fw_version: + validate_cmd.append('--fw-version=%s' % fw_version) + print("Validating: %s" % ' '.join(validate_cmd)) + validation = subprocess.run(validate_cmd) + if validation.returncode != 0: + print("ERROR: report catalog validation failed", file=sys.stderr) + sys.exit(validation.returncode) + if __name__ == '__main__': main() From 73188376f8c1057cdd643275b984c29d3c33a745 Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 3 Aug 2026 14:11:16 -0300 Subject: [PATCH 4/4] test(rng): pin canonical C27 entropy evidence --- deps/python-keepkey | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/python-keepkey b/deps/python-keepkey index d88a073a5..7e35103ed 160000 --- a/deps/python-keepkey +++ b/deps/python-keepkey @@ -1 +1 @@ -Subproject commit d88a073a5af2d83e0f1f19574665ca1a44789414 +Subproject commit 7e35103ed796a98ff39799902b552bc2083ce1b8