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 82c9ec4d4..c7a964f37 100644 --- a/lib/firmware/fsm_msg_common.h +++ b/lib/firmware/fsm_msg_common.h @@ -538,22 +538,67 @@ 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) + +/* 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) { - 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 && entropy_press_free_allowed()) { + 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/python-keepkey-tests.sh b/scripts/emulator/python-keepkey-tests.sh index 905496dd7..489a64674 100755 --- a/scripts/emulator/python-keepkey-tests.sh +++ b/scripts/emulator/python-keepkey-tests.sh @@ -95,6 +95,7 @@ fi 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 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()