Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion include/keepkey/transport/messages.options
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 53 additions & 8 deletions lib/firmware/fsm_msg_common.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions lib/rand/rng.c
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@
#include <libopencm3/stm32/f2/rng.h>
#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 */
Expand Down
1 change: 1 addition & 0 deletions scripts/emulator/python-keepkey-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 28 additions & 2 deletions scripts/generate-test-report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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') +
Expand Down Expand Up @@ -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')
Expand All @@ -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()
Loading