From 94d7aab14279b8bf4067d66fa028eeab2b927d6a Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:00:38 +0200 Subject: [PATCH] improve RNG --- zephcore/adapters/rng/ZephyrRNG.cpp | 293 ++++++++++++++---- zephcore/adapters/rng/ZephyrRNG.h | 41 ++- zephcore/boards/common/zephcore_common.conf | 5 + zephcore/tools/rng_selftest/CMakeLists.txt | 117 +++++++ .../rng_selftest/boards/rak4631/board.conf | 16 + .../rng_selftest/boards/rak4631/board.overlay | 49 +++ zephcore/tools/rng_selftest/prj.conf | 83 +++++ zephcore/tools/rng_selftest/src/main.cpp | 292 +++++++++++++++++ 8 files changed, 831 insertions(+), 65 deletions(-) create mode 100644 zephcore/tools/rng_selftest/CMakeLists.txt create mode 100644 zephcore/tools/rng_selftest/boards/rak4631/board.conf create mode 100644 zephcore/tools/rng_selftest/boards/rak4631/board.overlay create mode 100644 zephcore/tools/rng_selftest/prj.conf create mode 100644 zephcore/tools/rng_selftest/src/main.cpp diff --git a/zephcore/adapters/rng/ZephyrRNG.cpp b/zephcore/adapters/rng/ZephyrRNG.cpp index 6c8702e..6d0fbf0 100644 --- a/zephcore/adapters/rng/ZephyrRNG.cpp +++ b/zephcore/adapters/rng/ZephyrRNG.cpp @@ -8,6 +8,7 @@ #include #include #include +#include /* portable CPU cycle counter for the beat */ #include #include #include @@ -16,8 +17,12 @@ /* Pre-RF entropy for the ESP32 HWRNG — see esp32_entropy_begin() below. * Source file is added to the build by CMakeLists.txt (ESP32 only). */ #include +/* RTC-slow clock read for the two-clock beat entropy source (sample_rtc_beat). + * esp_rtc_get_time_us() links in an app build (verified via the selftest). */ +#include #endif + BUILD_ASSERT(IS_ENABLED(CONFIG_CSPRNG_ENABLED), "ZephyrRNG requires CONFIG_CSPRNG_ENABLED for cryptographic key derivation"); @@ -42,11 +47,13 @@ void ZephyrRNG::random(uint8_t *dest, size_t sz) * Generator" — Linux kernel's jitterentropy_rng. NIST SP 800-90B has * a compliance class for this entropy source type. * - * Per-sample min-entropy on simple in-order embedded CPUs (Cortex-M, - * RISC-V, Xtensa) is conservatively 0.1-0.3 bits per cycle-counter - * delta. At 200ms × 160MHz / 1000 cycles per sample = 32,000 samples - * × 0.1 bits = 3,200 estimated bits. 256 needed for Ed25519 → 12× - * margin even pessimistically. + * IMPORTANT — this source only works where k_cycle_get_32() is CROSS-DOMAIN + * from the CPU. On nRF it is the 32.768 kHz RTC read from a 64 MHz core, so + * the read latency itself jitters and this carries real entropy. On ESP32 + * k_cycle_get_32() is CCOUNT, the CPU's own cycle counter — same domain, so + * the loop is deterministic and this yields ~0 bits (measured on hardware: + * thousands of identical deltas). ESP32 therefore uses sample_rtc_beat() + * instead and never calls this function. * * Health check (NIST SP 800-90B style): online repetition count + a * distinct-value check tracked across all samples in the window with @@ -58,6 +65,27 @@ void ZephyrRNG::random(uint8_t *dest, size_t sz) /* Health statistics for one jitter window. Timing statistics only — never * pool contents or derived key material. Reporting these is standard practice * for a NIST SP 800-90B style noise source; reporting the bytes would not be. */ +/* Per-stage health reporting can be silenced. The node wants it — it fires + * once, at first-boot identity generation, and is the only record of what the + * entropy sources actually did. The selftest tool calls mixIdentitySeed + * thousands of times and must be able to shut it up after the first few, or + * the summary drowns in ~12 lines x N. */ +static bool s_seed_report_quiet; + +#define RNG_RPT(...) do { if (!s_seed_report_quiet) printk(__VA_ARGS__); } while (0) + +void ZephyrRNG::setSeedHealthQuiet(bool quiet) +{ + s_seed_report_quiet = quiet; +} + +#if defined(ZEPHCORE_RNG_TEST_HOOKS) +/* When set, the HWRNG contribution to mixIdentitySeed is zeroed after each + * draw — see the header. Test scaffolding, compiled out of production. */ +static bool s_test_kill_hwrng; +void ZephyrRNG::setTestKillHWRNG(bool kill) { s_test_kill_hwrng = kill; } +#endif + #define JITTER_HIST_SLOTS 16 struct jitter_stats { @@ -115,11 +143,14 @@ static bool sample_cpu_jitter(uint8_t *pool, size_t pool_size, while (k_uptime_get() < deadline) { uint32_t t1 = k_cycle_get_32(); - /* Variable-time work. The iteration count comes from the - * accumulator, which carries the PREVIOUS measurement (see the - * feedback step below), so the amount of work done here depends - * on observed hardware nondeterminism rather than on a fixed - * sequence. */ + /* Variable-time work — iteration count depends on the accumulator, + * so timing depends on hardware nondeterminism (cache, branch + * prediction, ISR firing). This ONLY carries entropy where the + * cycle counter is cross-domain from the CPU: on nRF k_cycle_get_32 + * is the 32.768 kHz RTC read from the 64 MHz core, so the read + * latency itself jitters. On ESP32 it is CCOUNT — same domain, no + * jitter, deltas repeat by the thousand (measured) — which is why + * ESP32 uses sample_rtc_beat() instead and never calls this. */ volatile uint32_t a = accum; uint32_t iters = (accum & 0x7f); for (uint32_t i = 0; i < iters; i++) { @@ -129,25 +160,6 @@ static bool sample_cpu_jitter(uint8_t *pool, size_t pool_size, uint32_t t2 = k_cycle_get_32(); uint32_t delta = t2 - t1; - /* FEEDBACK — the load-bearing line. Without it `accum` evolves - * as a pure LCG from a single seed: the iteration count becomes a - * fixed sequence, and on any CPU where reading the cycle counter - * is cheap and same-domain (ESP32 CCOUNT) a warm cache makes each - * `iters` value yield an identical `delta`. Deltas then repeat - * and the health check below correctly fails — which is exactly - * what was observed on every ESP32 board. - * - * Folding the measurement back in closes the loop, so timing - * variation propagates into subsequent work. This is the - * mechanism the cited jitterentropy design relies on and which - * the original implementation omitted. - * - * nRF was unaffected: its k_cycle_get_32() is a 32.768 kHz RTC in - * a different clock domain, so the read latency itself varies - * with domain phase and supplied the nondeterminism this line - * now provides everywhere. */ - accum ^= delta; - /* Mix into entropy pool */ pool[idx++ % pool_size] ^= (uint8_t)delta; pool[idx++ % pool_size] ^= (uint8_t)(delta >> 8); @@ -220,6 +232,136 @@ static bool sample_cpu_jitter(uint8_t *pool, size_t pool_size, return ok; } +/* ===== Universal two-clock beat entropy ================================== + * + * One physical entropy source for every board: count CPU cycles elapsed across + * a fixed interval of an INDEPENDENT low-frequency oscillator. The two clocks + * come from different sources, so the count fluctuates with the slow + * oscillator's phase noise — real physical entropy, not the deterministic + * same-domain loop that CPU-jitter degenerates to where the cycle counter and + * CPU share a clock (measured dead on ESP32: thousands of identical deltas). + * + * FAST counter = timing_counter_get() — portable CPU cycle counter (DWT on + * Cortex-M, CCOUNT on Xtensa; both at CPU frequency). Needs + * CONFIG_TIMING_FUNCTIONS and a one-time timing_init()/timing_start(). + * + * SLOW clock = an oscillator in a DIFFERENT domain from the CPU, selected by a + * principled rule so the choice is coherent across boards: + * - ESP32: the RTC-slow oscillator via esp_rtc_get_time_us() (internal + * ~136 kHz RC, independent of the XTAL->PLL CPU path). + * - Any board whose Zephyr system timer runs < 1 MHz: that timer IS a + * low-frequency oscillator cross-domain from the CPU (e.g. nRF's + * 32.768 kHz RTC off LFXO/LFRC), so k_cycle_get_32() is a valid slow + * clock. REQUIRES the LF clock to be LFXO/LFRC, not synthesised from + * HFCLK — true for every BLE-capable nRF config; the health check below + * catches it if a board ever violates that. + * - Otherwise (system timer at CPU frequency, e.g. bare SysTick): no + * independent slow clock is identified, and sample_platform_entropy() + * falls back to CPU-jitter (unchanged behaviour, no regression). + * + * Window = 500 us, from an on-hardware ESP32 sweep (memory/findings.md): + * 120/250/500/1000 us gave 1.85/3.04/3.81/5.14 bits/sample; 500 us is the knee. + * Full 32-bit delta is mixed. On ESP32 this is a SECONDARY source (the + * bootloader_random-seeded HWRNG is primary); on nRF it is the strong + * non-HWRNG leg. Reuses jitter_stats + report_jitter. + * + * CAVEAT (memory/findings.md): the health stats show the beat VARIES, not that + * it is random — the selftest output-diversity run is what validates it. */ +#if defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) +#define BEAT_SLOW_HZ 1000000ULL +static inline uint64_t beat_slow_ticks(void) { return esp_rtc_get_time_us(); } +#define HAVE_TWO_CLOCK_BEAT 1 +#elif (CONFIG_SYS_CLOCK_HW_CYCLES_PER_SEC < 1000000) +#define BEAT_SLOW_HZ ((uint64_t)CONFIG_SYS_CLOCK_HW_CYCLES_PER_SEC) +static inline uint64_t beat_slow_ticks(void) { return k_cycle_get_32(); } +#define HAVE_TWO_CLOCK_BEAT 1 +#endif + +#ifdef HAVE_TWO_CLOCK_BEAT +#define BEAT_WINDOW_US 500 +/* slow-clock ticks per window; >= 1 guaranteed for any BEAT_SLOW_HZ >= 2 kHz */ +#define BEAT_WINDOW_TICKS ((uint32_t)((BEAT_SLOW_HZ * BEAT_WINDOW_US) / 1000000ULL)) + +static bool sample_two_clock_beat(uint8_t *pool, size_t pool_size, + size_t pool_offset, uint32_t duration_ms, + struct jitter_stats *st = nullptr) +{ + /* Enable the CPU cycle counter once (DWT on Cortex-M; no-op-ish on + * Xtensa where CCOUNT always runs). */ + static bool timing_ready; + if (!timing_ready) { + timing_init(); + timing_start(); + timing_ready = true; + } + + int64_t deadline = k_uptime_get() + duration_ms; + size_t idx = pool_offset; + uint32_t min_delta = UINT32_MAX, max_delta = 0; + uint32_t prev_delta = 0; + int cur_consec = 0, max_consec = 0; + uint32_t distinct[8] = {0}; + int n_distinct = 0, n_samples = 0; + + while (k_uptime_get() < deadline) { + uint64_t s0 = beat_slow_ticks(); + uint32_t f0 = (uint32_t)timing_counter_get(); + while ((beat_slow_ticks() - s0) < BEAT_WINDOW_TICKS) { + /* CPU cycle counter advances while the independent slow + * oscillator defines the window; the two drift */ + } + uint32_t f1 = (uint32_t)timing_counter_get(); + uint32_t delta = f1 - f0; + + /* Mix the FULL delta — entropy spans ~11 bits, not the low byte. */ + pool[idx++ % pool_size] ^= (uint8_t)delta; + pool[idx++ % pool_size] ^= (uint8_t)(delta >> 8); + pool[idx++ % pool_size] ^= (uint8_t)(delta >> 16); + pool[idx++ % pool_size] ^= (uint8_t)(delta >> 24); + + /* Health stats on the low 14 bits (where the beat lives): a + * frozen/domain-locked slow clock freezes the delta and trips + * the repetition count. */ + uint32_t d14 = delta & 0x3FFF; + if (n_samples > 0 && d14 == prev_delta) { + if (++cur_consec > max_consec) max_consec = cur_consec; + } else { + cur_consec = 1; + } + prev_delta = d14; + + if (n_distinct < 8) { + bool found = false; + for (int j = 0; j < n_distinct; j++) { + if (distinct[j] == d14) { found = true; break; } + } + if (!found) distinct[n_distinct++] = d14; + } + + if (d14 < min_delta) min_delta = d14; + if (d14 > max_delta) max_delta = d14; + + n_samples++; + } + + bool ok = (n_samples >= 16) /* enough samples */ + && (max_consec < 32) /* slow clock not frozen */ + && (n_distinct >= 5); /* beat actually varies */ + + if (st) { + st->n_samples = n_samples; + st->n_distinct = n_distinct; + st->max_consec = max_consec; + st->min_delta = (n_samples > 0) ? min_delta : 0; + st->max_delta = max_delta; + st->mcv_count = 0; /* span/distinct are the beat's health */ + st->untracked = 0; + st->ok = ok; + } + return ok; +} +#endif /* HAVE_TWO_CLOCK_BEAT */ + /* Count distinct byte values in a buffer — a repetition/adaptive-proportion * style health indicator for a CSPRNG draw. A stuck source collapses this to * 1. Deliberately coarse: one integer per draw, which detects catastrophic @@ -241,7 +383,7 @@ static int distinct_bytes(const uint8_t *buf, size_t len) * and no amount of sampling will help. */ static void report_jitter(const char *label, const struct jitter_stats *st) { - printk("[RNG] %s: samples=%d distinct=%d/8 maxrep=%d " + RNG_RPT("[RNG] %s: samples=%d distinct=%d/8 maxrep=%d " "delta=[%u..%u] -> %s\n", label, st->n_samples, st->n_distinct, st->max_consec, st->min_delta, st->max_delta, st->ok ? "PASS" : "FAIL"); @@ -258,7 +400,11 @@ static void report_jitter(const char *label, const struct jitter_stats *st) * any input look uniform, so output statistics would read perfect even * for a near-zero-entropy seed. Entropy is a property of the source. */ if (st->n_samples <= 0 || st->mcv_count == 0) { - printk("[RNG] entropy est: n/a (no samples)\n"); + /* mcv_count == 0 is the two-clock beat: its health is the + * span/distinct/maxrep line above, not an MCV estimate (that + * would need a 16k-slot histogram in the key path). Per-sample + * entropy is characterised offline by the selftest window sweep. + * Nothing meaningful to print here. */ return; } @@ -270,7 +416,7 @@ static void report_jitter(const char *label, const struct jitter_stats *st) uint64_t total_mb = (uint64_t)per_mb * (uint64_t)st->n_samples; uint32_t total_bits = (uint32_t)(total_mb / 1000u); - printk("[RNG] entropy est: %u.%03u bits/sample x %d = ~%u bits " + RNG_RPT("[RNG] entropy est: %u.%03u bits/sample x %d = ~%u bits " "(need 256)%s\n", per_mb / 1000u, per_mb % 1000u, st->n_samples, total_bits, st->untracked ? " [histogram overflowed — est. is optimistic]" : ""); @@ -365,6 +511,29 @@ static int extract_via_aes_ctr(const uint8_t *pool, size_t pool_len, return ret; } +/* Platform entropy sampler + its label. Every board with an identified + * independent slow clock uses the SAME two-clock beat (ESP32 RTC-slow, nRF and + * other <1 MHz-systimer boards their RTC). Boards without one fall back to + * CPU-jitter, which only carries entropy where k_cycle_get_32 is itself cross- + * domain. Both yield jitter_stats, so the stages and report are identical. */ +#ifdef HAVE_TWO_CLOCK_BEAT +#define ENTROPY_SRC_LABEL "beat " +#else +#define ENTROPY_SRC_LABEL "jitter" +#endif + +static inline bool sample_platform_entropy(uint8_t *pool, size_t pool_size, + size_t pool_offset, + uint32_t duration_ms, + struct jitter_stats *st) +{ +#ifdef HAVE_TWO_CLOCK_BEAT + return sample_two_clock_beat(pool, pool_size, pool_offset, duration_ms, st); +#else + return sample_cpu_jitter(pool, pool_size, pool_offset, duration_ms, st); +#endif +} + void ZephyrRNG::mixIdentitySeed(uint8_t *out, size_t out_len, const uint8_t *extra, size_t extra_len) { @@ -395,17 +564,20 @@ void ZephyrRNG::mixIdentitySeed(uint8_t *out, size_t out_len, * battery read on ESP32. */ #if defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) bootloader_random_enable(); - printk("[RNG] === identity seed health ===\n"); - printk("[RNG] esp32 pre-RF entropy (bootloader_random): ENABLED\n"); + RNG_RPT("[RNG] === identity seed health ===\n"); + RNG_RPT("[RNG] esp32 pre-RF entropy (bootloader_random): ENABLED\n"); #else - printk("[RNG] === identity seed health ===\n"); - printk("[RNG] platform TRNG is radio-independent (no pre-RF workaround needed)\n"); + RNG_RPT("[RNG] === identity seed health ===\n"); + RNG_RPT("[RNG] platform TRNG is radio-independent (no pre-RF workaround needed)\n"); #endif /* Stage 1: early CSPRNG (strong on nRF/MG24; on ESP32 this is only real * because bootloader_random_enable() above is feeding the HWRNG) */ int rc1 = sys_csrand_get(pool, 64); - printk("[RNG] stage1 csrand : rc=%d distinct=%d/64\n", +#if defined(ZEPHCORE_RNG_TEST_HOOKS) + if (s_test_kill_hwrng) memset(pool, 0, 64); /* simulate dead HWRNG */ +#endif + RNG_RPT("[RNG] stage1 csrand : rc=%d distinct=%d/64\n", rc1, distinct_bytes(pool, 64)); /* Stage 2: HWINFO unique device ID — uniqueness across devices */ @@ -416,50 +588,57 @@ void ZephyrRNG::mixIdentitySeed(uint8_t *out, size_t out_len, } /* NOT secret — this is the efuse/FICR serial, public and printed at boot. * It contributes uniqueness between devices, never unpredictability. */ - printk("[RNG] stage2 hwinfo id : %d bytes (public — uniqueness only)\n", + RNG_RPT("[RNG] stage2 hwinfo id : %d bytes (public — uniqueness only)\n", (int)devid_len); - /* Stage 3: caller-supplied entropy (e.g. ADC LSB noise) */ + /* Stage 3: caller-supplied entropy. A hook for a caller that has its own + * physical noise (e.g. an externally sampled ADC/RF value); unused today, + * so normally a no-op. Kept because it costs nothing when null and gives + * a board a way to inject a source without touching this file. The + * internal battery-ADC experiment was removed — a driven divider yielded + * no reliable entropy and did not justify the complexity in the key path. */ if (extra && extra_len > 0) { size_t n = (extra_len < 32) ? extra_len : 32; for (size_t i = 0; i < n; i++) pool[80 + i] ^= extra[i]; - printk("[RNG] stage3 extra : %d bytes\n", (int)n); - } else { - printk("[RNG] stage3 extra : none\n"); + RNG_RPT("[RNG] stage3 extra : %d bytes\n", (int)n); } - /* Stage 4: CPU cycle-counter jitter, 200ms. + /* Stage 4: hardware-timing entropy, 200ms. RTC two-clock beat on ESP32, + * CPU cycle-counter jitter elsewhere (sample_platform_entropy). * * Skipped on POSIX arch (native_sim / Linux): the simulated clock only * advances when Zephyr threads yield, so k_uptime_get() is frozen while * this loop spins → infinite loop. On Linux we have /dev/urandom (via * sys_csrand_get in stages 1 and 5) which is a far stronger source than - * jitter sampling anyway. */ + * this sampling anyway. */ #ifndef CONFIG_ARCH_POSIX struct jitter_stats js = {}; - bool health_ok = sample_cpu_jitter(pool, sizeof(pool), 112, 200, &js); - report_jitter("stage4 jitter 200ms", &js); + bool health_ok = sample_platform_entropy(pool, sizeof(pool), 112, 200, &js); + report_jitter("stage4 " ENTROPY_SRC_LABEL " 200ms", &js); if (!health_ok) { - printk("[RNG] stage4 FAILED — resampling at 400ms\n"); - health_ok = sample_cpu_jitter(pool, sizeof(pool), 112, 400, &js); - report_jitter("stage4 jitter 400ms", &js); + RNG_RPT("[RNG] stage4 FAILED — resampling at 400ms\n"); + health_ok = sample_platform_entropy(pool, sizeof(pool), 112, 400, &js); + report_jitter("stage4 " ENTROPY_SRC_LABEL " 400ms", &js); if (!health_ok) { - printk("[RNG] stage4 STILL FAILING — continuing with mixed sources\n"); + RNG_RPT("[RNG] stage4 STILL FAILING — continuing with mixed sources\n"); } } #endif /* CONFIG_ARCH_POSIX */ /* Stage 5: late CSPRNG — catches any mid-boot radio init that - * warmed the TRNG during the 200ms jitter window */ + * warmed the TRNG during the stage-4 window */ int rc5 = sys_csrand_get(pool + 368, 64); - printk("[RNG] stage5 csrand : rc=%d distinct=%d/64\n", +#if defined(ZEPHCORE_RNG_TEST_HOOKS) + if (s_test_kill_hwrng) memset(pool + 368, 0, 64); /* simulate dead HWRNG */ +#endif + RNG_RPT("[RNG] stage5 csrand : rc=%d distinct=%d/64\n", rc5, distinct_bytes(pool + 368, 64)); - /* Stage 6: second jitter sample, independent timing window */ + /* Stage 6: second hardware-timing sample, independent window */ #ifndef CONFIG_ARCH_POSIX struct jitter_stats js6 = {}; - (void)sample_cpu_jitter(pool, sizeof(pool), 432, 50, &js6); - report_jitter("stage6 jitter 50ms", &js6); + (void)sample_platform_entropy(pool, sizeof(pool), 432, 50, &js6); + report_jitter("stage6 " ENTROPY_SRC_LABEL " 50ms", &js6); #endif /* CONFIG_ARCH_POSIX */ /* Collection done — release the SAR ADC before anything else needs it. @@ -467,9 +646,9 @@ void ZephyrRNG::mixIdentitySeed(uint8_t *out, size_t out_len, * reboots, so there is no path that leaves it enabled. */ #if defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) bootloader_random_disable(); - printk("[RNG] esp32 pre-RF entropy: DISABLED (ADC released)\n"); + RNG_RPT("[RNG] esp32 pre-RF entropy: DISABLED (ADC released)\n"); #endif - printk("[RNG] === end (extracting %u bytes via AES-256-CTR) ===\n", + RNG_RPT("[RNG] === end (extracting %u bytes via AES-256-CTR) ===\n", (unsigned)out_len); /* Final conditioning: AES-256-CTR over the pool. Extracts a 32-byte diff --git a/zephcore/adapters/rng/ZephyrRNG.h b/zephcore/adapters/rng/ZephyrRNG.h index 7a7ef8c..4544e8a 100644 --- a/zephcore/adapters/rng/ZephyrRNG.h +++ b/zephcore/adapters/rng/ZephyrRNG.h @@ -17,16 +17,20 @@ public: /* Layered entropy mixer for one-time first-boot identity-key * generation. Combines: - * 1. sys_csrand_get (early) — CSPRNG, strong on nRF/MG24 - * 2. HWINFO unique device ID — per-device uniqueness + * 1. sys_csrand_get (early) — CSPRNG; on ESP32 only real + * because bootloader_random seeds + * the HWRNG (else pre-RF = 0 bits) + * 2. HWINFO unique device ID — per-device uniqueness (public) * 3. Optional caller-supplied data — e.g. external noise samples - * 4. CPU cycle-counter jitter — main entropy source - * (NIST SP 800-90B class) - * 5. sys_csrand_get (late) — catches mid-boot radio init - * 6. CPU cycle-counter jitter #2 — independent timing window + * 4. Hardware-timing entropy — RTC two-clock beat on ESP32, + * CPU-jitter on nRF (NIST 90B class) + * 5. sys_csrand_get (late) — second HWRNG draw + * 6. Hardware-timing entropy #2 — independent window * Conditioned via AES-256-CTR (SHA-256(pool) → key, ECB on counter). - * NIST-style health checks on jitter samples; reboots on degenerate - * output. Blocks for ~250ms — only called at first-boot identity gen. + * On ESP32 the primary source is the bootloader_random-seeded HWRNG + * (stages 1+5); the beat (4+6) is a physical second source. On nRF the + * CSPRNG and CPU-jitter are both independently strong. Health-checked; + * reboots on degenerate output. Blocks ~450ms — first-boot identity only. * * Output is suitable as an Ed25519 seed regardless of platform * TRNG state at boot. */ @@ -34,6 +38,27 @@ public: const uint8_t *extra = nullptr, size_t extra_len = 0); +#if defined(ZEPHCORE_RNG_TEST_HOOKS) + /* TEST ONLY — enabled by a compile define, set only by tools/rng_selftest + * (never by production builds). When active, mixIdentitySeed zeroes the + * HWRNG (sys_csrand_get) contribution to the pool, so a diversity test + * measures the two-clock beat alone. Answers "if the hardware RNG returns + * nothing, does ZephyrRNG still produce diverse keys?" On a single device + * the device-id (stage 2) is constant across runs, so the beat is then the + * ONLY varying source. */ + static void setTestKillHWRNG(bool kill); +#endif + + /* Silence mixIdentitySeed's per-stage entropy health report. + * + * Default OFF — the node prints it once, at first-boot identity + * generation, and it is the only record of what the entropy sources + * actually did for a key that is then permanent. Do not silence it there. + * + * Exists for tools/rng_selftest, which calls mixIdentitySeed thousands of + * times: without this the ~12 lines per seed bury the summary. */ + static void setSeedHealthQuiet(bool quiet); + /* End-to-end first-boot identity generation. Mixes a fresh seed, * derives the Ed25519 keypair, and retries (up to 100×) if the * MeshCore protocol-reserved 0x00/0xFF public-key prefix happens diff --git a/zephcore/boards/common/zephcore_common.conf b/zephcore/boards/common/zephcore_common.conf index f913416..1d5f015 100644 --- a/zephcore/boards/common/zephcore_common.conf +++ b/zephcore/boards/common/zephcore_common.conf @@ -35,6 +35,11 @@ CONFIG_ADC=y CONFIG_HWINFO=y CONFIG_REBOOT=y +# Portable CPU cycle counter (DWT on Cortex-M, CCOUNT on Xtensa) — the fast +# clock of ZephyrRNG's universal two-clock beat entropy source. Small: enables +# the DWT counter on ARM; no timekeeping impact. +CONFIG_TIMING_FUNCTIONS=y + # ========== LoRa ========== CONFIG_LORA=y CONFIG_LORA_MODULE_BACKEND_NATIVE=y diff --git a/zephcore/tools/rng_selftest/CMakeLists.txt b/zephcore/tools/rng_selftest/CMakeLists.txt new file mode 100644 index 0000000..f99f00e --- /dev/null +++ b/zephcore/tools/rng_selftest/CMakeLists.txt @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: MIT +# ZephCore RNG selftest — entropy diversity test for identity-seed generation. +# +# Runs ZephyrRNG::mixIdentitySeed N times and checks the results for exact +# duplicates and pairwise correlation. Boots, runs, prints, halts. It is a +# separate image ON PURPOSE: each seed costs ~250 ms of jitter sampling, so +# N=1024 pins the CPU for ~4 minutes — unacceptable inside a live node, where +# it would starve the mesh loop, BLE and LoRa RX. +# +# Build: +# west build -b rak4631 zephcore/tools/rng_selftest --pristine +# west build -b xiao_esp32s3/esp32s3/procpu zephcore/tools/rng_selftest --pristine +# +# Sample count (default 1024, ~4 min): +# west build ... zephcore/tools/rng_selftest -- -DRNG_SELFTEST_N=5000 +# +# Flash: nRF52 — drag build/zephyr/zephyr.uf2 onto the UF2 drive +# ESP32 — west flash --esp-device COMx +# +# The tool never reads or writes stored identity: a device under test keeps +# whatever key it already had. Reflash normal firmware afterwards. + +cmake_minimum_required(VERSION 3.20.0) + +# Reuse the main project's custom board definitions +list(APPEND BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../..) + +# Strip the board qualifier (e.g. "/esp32s3/procpu") to get the base name +string(REPLACE "/" ";" BOARD_PARTS ${BOARD}) +list(GET BOARD_PARTS 0 BOARD_BASE) + +# Tool-local board overlay ONLY — deliberately not the node's board.overlay. +# +# The node overlay declares /fstab, radio, sensor and partition nodes that this +# tool does not enable (CONFIG_FLASH=n, SPI=n, I2C=n), so reusing it fails on +# an undefined lfs_partition label. Boards needing console plumbing get a +# minimal overlay under boards// here; everything else just uses the +# upstream board default, which already provides a console. +set(_SELFTEST_OVERLAY + "${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_BASE}/board.overlay") +if(EXISTS "${_SELFTEST_OVERLAY}") + set(EXTRA_DTC_OVERLAY_FILE "${_SELFTEST_OVERLAY}" CACHE STRING "" FORCE) + message(STATUS "RNG selftest overlay: ${_SELFTEST_OVERLAY}") +else() + message(STATUS "RNG selftest: using upstream board console (no overlay)") +endif() + +# Per-board conf (UF2 output, code partition) — nRF52 needs it, ESP32 must not +# have it. See boards/rak4631/board.conf. +set(_SELFTEST_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_BASE}/board.conf") +if(EXISTS "${_SELFTEST_CONF}") + set(EXTRA_CONF_FILE "${_SELFTEST_CONF}" CACHE STRING "" FORCE) + message(STATUS "RNG selftest conf: ${_SELFTEST_CONF}") +endif() + +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) +project(zephcore_rng_selftest) + +# The point of this tool is to exercise the SHIPPING code, not a copy of it. +# ZephyrRNG.cpp and Utils.cpp are compiled straight from the main tree, so the +# algorithm under test is byte-for-byte the one the node runs. +target_sources(app PRIVATE + src/main.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../adapters/rng/ZephyrRNG.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../src/Utils.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../src/Identity.cpp +) + +# ZephyrRNG.cpp also carries generateFirstBootIdentity(), which derives the +# Ed25519 keypair via LocalIdentity -> monocypher. We only call +# mixIdentitySeed() here, but linking the real dependency is more honest than +# leaning on --gc-sections to make the undefined symbols disappear — and it +# keeps the door open to testing derived keypairs, not just seeds. +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../lib/monocypher monocypher) +target_link_libraries(app PRIVATE monocypher) + +target_include_directories(app PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/../../include + ${CMAKE_CURRENT_SOURCE_DIR}/../../adapters/rng + ${CMAKE_CURRENT_SOURCE_DIR}/../../lib/monocypher +) + +# ESP32 only: the HWRNG needs the SAR-ADC entropy source enabled before RF is +# up. Same source file the main build pulls in — see zephcore/CMakeLists.txt. +if(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) + set(_ESP_BOOTLOADER_RANDOM + "${ZEPHYR_HAL_ESPRESSIF_MODULE_DIR}/components/bootloader_support/src/bootloader_random_${CONFIG_SOC_SERIES}.c") + if(EXISTS "${_ESP_BOOTLOADER_RANDOM}") + message(STATUS "RNG selftest: ESP32 pre-RF entropy (${CONFIG_SOC_SERIES})") + target_sources(app PRIVATE "${_ESP_BOOTLOADER_RANDOM}") + else() + message(WARNING "RNG selftest: no bootloader_random source for ${CONFIG_SOC_SERIES}") + endif() +endif() + +# Sample count. 1024 (~4 min) detects an effective-entropy floor up to ~20 bits +# by the birthday bound — comfortably past the ~15-bit Debian OpenSSL failure. +if(NOT DEFINED RNG_SELFTEST_N) + set(RNG_SELFTEST_N 1024) +endif() +target_compile_definitions(app PRIVATE RNG_SELFTEST_N=${RNG_SELFTEST_N}) +message(STATUS "RNG selftest: N=${RNG_SELFTEST_N} (~${RNG_SELFTEST_N} x 250ms)") + +# "Dead HWRNG" mode: zero the hardware-RNG contribution so the diversity run +# measures the two-clock beat ALONE — the definitive answer to "if the HWRNG +# returns nothing, does ZephyrRNG still produce diverse keys?". Needs the +# ZephyrRNG test hook compiled in. +# west build ... zephcore/tools/rng_selftest -- -DRNG_KILL_HWRNG=1 +if(RNG_KILL_HWRNG) + # RNG_KILL_HWRNG -> main.cpp calls the setter; ZEPHCORE_RNG_TEST_HOOKS -> + # compiles the setter into ZephyrRNG (both are part of this app target, so + # the define reaches ZephyrRNG.cpp). Node builds define neither -> the hook + # is entirely absent from production. + target_compile_definitions(app PRIVATE RNG_KILL_HWRNG=1 ZEPHCORE_RNG_TEST_HOOKS=1) + message(STATUS "RNG selftest: KILL_HWRNG — beat-alone measurement") +endif() diff --git a/zephcore/tools/rng_selftest/boards/rak4631/board.conf b/zephcore/tools/rng_selftest/boards/rak4631/board.conf new file mode 100644 index 0000000..93f3a15 --- /dev/null +++ b/zephcore/tools/rng_selftest/boards/rak4631/board.conf @@ -0,0 +1,16 @@ +# RAK4631 — RNG selftest board config +# +# UF2 output lives here rather than in prj.conf: it is nRF52-with-bootloader +# specific, and on ESP32 uf2conv fails outright (no UF2 family for that SoC). +# +# CONFIG_FLASH is enabled ONLY so the devicetree flash partitions resolve — +# uf2conv derives its base address from CONFIG_FLASH_LOAD_OFFSET, which +# USE_DT_CODE_PARTITION reads from the `zephyr,code-partition` chosen node. +# With flash off the offset comes out empty and uf2conv aborts with +# "argument -b/--base: expected one argument". +# +# This does NOT let the tool touch stored data: CONFIG_FILE_SYSTEM stays off, +# nothing mounts /lfs, and the tool never reads or writes an identity. +CONFIG_FLASH=y +CONFIG_USE_DT_CODE_PARTITION=y +CONFIG_BUILD_OUTPUT_UF2=y diff --git a/zephcore/tools/rng_selftest/boards/rak4631/board.overlay b/zephcore/tools/rng_selftest/boards/rak4631/board.overlay new file mode 100644 index 0000000..a929225 --- /dev/null +++ b/zephcore/tools/rng_selftest/boards/rak4631/board.overlay @@ -0,0 +1,49 @@ +/* + * RAK4631 — RNG selftest console overlay + * SPDX-License-Identifier: MIT + * + * Minimal ON PURPOSE. The node's own board.overlay is NOT reused here: it + * declares an /fstab entry, radio, sensors and partitions, none of which this + * tool enables (CONFIG_FLASH=n, CONFIG_SPI=n ...) — pulling it in fails the + * build on an undefined lfs_partition label. Same reasoning as the LR1110 + * updater's per-board overlays. + * + * RAK4631 connects USB straight to the nRF52840, so the console has to ride + * USB CDC ACM; uart0's physical pins are not reachable on a plain WisBlock + * base board. + */ + +/* Same flash layout as the node build. + * + * NOT just for UF2 packaging: RAK4631 boots through the Adafruit nRF52 + * bootloader, so the application must be LINKED at the bootloader's app + * offset. Without a code partition the image links at 0 and would not run even + * if uf2conv managed to package it (it does not — the missing + * CONFIG_FLASH_LOAD_OFFSET is what produces "argument -b/--base: expected one + * argument"). SoftDevice v6 layout, matching boards/nrf52840/rak4631. + * + * The delete-nodes are required by that dtsi's own contract (see its header): + * it redefines partitions the upstream board DTS already labels, and without + * removing those first the build fails with a duplicate 'storage_partition' + * label. */ +/delete-node/ &boot_partition; +/delete-node/ &slot0_partition; +/delete-node/ &slot1_partition; +/delete-node/ &storage_partition; + +#include "../../../../boards/common/nrf52_partitions_sdv6.dtsi" + +/ { + chosen { + zephyr,code-partition = &code_partition; + zephyr,console = &cdc_acm_uart; + zephyr,shell-uart = &cdc_acm_uart; + }; + +}; + +&zephyr_udc0 { + cdc_acm_uart: cdc_acm_uart { + compatible = "zephyr,cdc-acm-uart"; + }; +}; diff --git a/zephcore/tools/rng_selftest/prj.conf b/zephcore/tools/rng_selftest/prj.conf new file mode 100644 index 0000000..0da2e33 --- /dev/null +++ b/zephcore/tools/rng_selftest/prj.conf @@ -0,0 +1,83 @@ +# ZephCore RNG selftest — minimal config. +# +# Exercises the REAL ZephyrRNG::mixIdentitySeed path, so it needs exactly what +# that function needs and nothing else: a CSPRNG, hwinfo, and PSA crypto for +# the AES-256-CTR conditioning step. + +# Entropy / CSPRNG — sys_csrand_get(). +# +# CONFIG_CSPRNG_ENABLED is NOT set here: it is a promptless result symbol +# ("default y depends on ENTROPY_HAS_DRIVER", subsys/random/Kconfig) that +# reports whether a real hardware entropy driver made it into the build. +# Assigning it directly is a Kconfig error. Ask for the driver instead and let +# it resolve — ZephyrRNG.cpp BUILD_ASSERTs on the result, so a platform without +# one fails loudly at compile time rather than silently testing a stub. +CONFIG_ENTROPY_GENERATOR=y +CONFIG_CSPRNG_NEEDED=y + +# hwinfo — stage 2 device ID +CONFIG_HWINFO=y + +# Portable CPU cycle counter — fast clock of the universal two-clock beat. +# Must match the node (zephcore_common.conf) so the tool tests the real path. +CONFIG_TIMING_FUNCTIONS=y + +# PSA crypto — mirrors boards/common/zephcore_common.conf. mixIdentitySeed +# conditions the pool with SHA-256 + AES-256-ECB via PSA; without these the +# extraction step fails and the function panics. +CONFIG_MBEDTLS=y +CONFIG_MBEDTLS_PSA_CRYPTO_C=y +CONFIG_PSA_WANT_ALG_SHA_256=y +CONFIG_PSA_WANT_ALG_ECB_NO_PADDING=y +CONFIG_PSA_WANT_KEY_TYPE_AES=y + +# sys_reboot() — Utils::cryptoPanicReboot() calls it when AES-CTR extraction +# fails or the seed comes out degenerate. Kept live rather than stubbed: that +# panic is part of the behaviour under test, and a tool that silently continued +# past it would report a pass on a seed the node would have refused. +CONFIG_REBOOT=y + +# C++ — ZephyrRNG and Utils are C++ +CONFIG_CPP=y +CONFIG_STD_CPP17=y +CONFIG_REQUIRES_FULL_LIBC=y + +# Console only. No log subsystem: mixIdentitySeed reports via printk, which +# keeps the tool's output identical to what the node prints. +CONFIG_SERIAL=y +CONFIG_CONSOLE=y +CONFIG_UART_CONSOLE=y +CONFIG_PRINTK=y +CONFIG_LOG=n + +# CONFIG_ASSERT stays off: on ESP32 the Espressif blob trips false kswap.h +# assertions, and this tool has no reason to differ from the node. +CONFIG_ASSERT=n + +# Main thread needs room for the 512-byte pool, the 256-byte distinct_bytes +# table and the jitter histogram, plus the fingerprint scratch in main(). +CONFIG_MAIN_STACK_SIZE=4096 + +# NOTE: no CONFIG_BUILD_OUTPUT_UF2 here. UF2 is an nRF52-with-bootloader thing +# and needs a code partition to derive its base address; ESP32 has no UF2 +# family at all and fails uf2conv outright. It lives in the per-board conf +# (boards/rak4631/board.conf) instead. ESP32 flashes with `west flash`. + +# USB CDC ACM console — nRF52840 boards (RAK4631) route console over USB +# because the UART pins are not reachable on a plain base board. Harmless on +# ESP32, which uses its own console and ignores these. +CONFIG_USB_DEVICE_STACK_NEXT=y +CONFIG_UART_LINE_CTRL=y +CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=y +CONFIG_CDC_ACM_SERIAL_ENABLE_AT_BOOT=y + +# Explicitly off — this tool must not touch the radio, storage or BLE. In +# particular it must never mount /lfs: it does not read or write any stored +# identity, so a device under test keeps whatever it already had. +CONFIG_BT=n +CONFIG_FLASH=n +CONFIG_FILE_SYSTEM=n +CONFIG_SPI=n +CONFIG_I2C=n +CONFIG_SENSOR=n +CONFIG_GNSS=n diff --git a/zephcore/tools/rng_selftest/src/main.cpp b/zephcore/tools/rng_selftest/src/main.cpp new file mode 100644 index 0000000..a3b1c1c --- /dev/null +++ b/zephcore/tools/rng_selftest/src/main.cpp @@ -0,0 +1,292 @@ +/* + * ZephCore RNG selftest + * SPDX-License-Identifier: MIT + * + * Generates N identity seeds through the real ZephyrRNG::mixIdentitySeed and + * checks them for the failure mode that actually matters: lack of diversity. + * + * WHY NOT "test one key and see if it looks random": + * mixIdentitySeed ends in AES-256-CTR conditioning, and a cryptographic + * conditioner makes ANY input look uniform. A seed built from one bit of real + * entropy passes every statistical test you can point at it. Entropy is a + * property of the generating process, not of the bytes produced — which is why + * NIST SP 800-90B health-tests the noise source and does not accept output + * testing as evidence. + * + * The real-world failure of a weak generator is not "the key looks wrong", it + * is "the key is not unique" — Debian OpenSSL 2008 (~15 bits, 32767 possible + * keys) was found by noticing duplicates, and "Mining your Ps and Qs" (2012) + * found thousands of embedded devices sharing keys because they generated them + * at boot before entropy was available. That is exactly our situation, so that + * is exactly what this measures. + * + * WHY REPEAT ON ONE DEVICE rather than compare across devices: + * stage 2 of the mixer folds in the hwinfo device ID, which differs between + * devices but is constant on one. A cross-device comparison would therefore + * look healthy even with zero real entropy — the device IDs alone would + * separate the keys and mask the bug. Repeating on a single device holds that + * differentiator constant and isolates the sources under suspicion. + * + * The tool never reads or writes stored identity. A device under test keeps + * whatever key it already had. + */ + +#include +#include +#include + +#include + +#if defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) +/* RTC-slow-clock beat diagnostic. ESP32's k_cycle_get_32() is CCOUNT, same + * clock domain as the CPU, so CPU-jitter sampling measures a deterministic + * loop (proven on hardware: maxrep in the thousands, ~0 bits). The RTC slow + * clock is a SEPARATE oscillator (internal ~136 kHz RC, or a 32 kHz crystal) + * that drifts independently of the main-crystal->PLL->240 MHz path — so the + * number of CPU cycles that elapse across a fixed RTC interval fluctuates, and + * that fluctuation is real physical entropy. This is what nRF gets for free + * (its system timer IS the 32 kHz RTC). This diagnostic MEASURES that beat; it + * does not touch identity generation. */ +#include +#define HAVE_RTC_BEAT 1 +#endif + +#ifndef RNG_SELFTEST_N +#define RNG_SELFTEST_N 1024 +#endif + +#define SEED_LEN 32 /* Ed25519 seed size — what identity gen uses */ +#define FP_LEN 8 /* fingerprint bytes kept per seed */ + +/* 8-byte fingerprints, not 4. At N=5000 a 4-byte fingerprint has a ~0.3% + * chance of colliding BY ACCIDENT (5000^2 / 2 / 2^32), which would report a + * spurious duplicate and discredit the whole run. At 8 bytes the accidental + * rate is ~10^-12 — any duplicate reported is a real one. */ +static uint8_t fingerprints[RNG_SELFTEST_N][FP_LEN]; + +static int popcount8(uint8_t x) +{ + int n = 0; + while (x) { n += x & 1; x >>= 1; } + return n; +} + +/* Hamming distance between two seeds, in bits. Two independent 256-bit values + * differ in ~128 bits on average; a distribution pulled well below that means + * the seeds are correlated even when no exact duplicate ever appears. */ +static int hamming(const uint8_t *a, const uint8_t *b, size_t len) +{ + int d = 0; + for (size_t i = 0; i < len; i++) d += popcount8(a[i] ^ b[i]); + return d; +} + +#ifdef HAVE_RTC_BEAT +/* -log2(mcv/n) in milli-bits — SP 800-90B Most Common Value min-entropy, + * integer math. Understates slightly (linear log2 interpolation), the safe + * direction for an entropy claim. */ +static uint32_t min_entropy_mb(uint32_t mcv, uint32_t n) +{ + if (mcv == 0 || mcv >= n) return 0; + uint32_t ratio_q10 = (uint32_t)(((uint64_t)n * 1024u) / mcv); /* (n/mcv)<<10 */ + uint32_t ip = 31u - (uint32_t)__builtin_clz(ratio_q10); + uint32_t base = 1u << ip; + uint32_t frac = (uint32_t)(((uint64_t)(ratio_q10 - base) * 1000u) / base); + uint32_t lg = ip * 1000u + frac; /* log2(ratio_q10)*1000 */ + return (lg > 10000u) ? (lg - 10000u) : 0u; /* subtract log2(1024) */ +} + +/* 14-bit fold of the delta (0x3FFF = 16384 slots). The first run showed a + * ~1681-cycle span, so the entropy lives across ~11 bits, not the low 8 — the + * low-byte-only estimate threw most of it away. 14 bits covers spans up to + * 16384 without wrapping; a wider span folds and only ever UNDERcounts + * distinctness (merges values), which understates entropy — safe. uint16_t is + * enough since per-window sample count < 65535. */ +static uint16_t beat_hist[16384]; + +/* Sweep several RTC-window sizes and report full-delta min-entropy for each, + * so we can pick the parameter before touching the key path. Characterization + * ONLY — nothing here feeds identity generation. */ +static void rtc_beat_diagnostic(void) +{ + const int K = 4096; /* samples per window */ + const uint32_t windows_us[] = { 120, 250, 500, 1000 }; + + printk("\n"); + printk("--------------------------------------------------------\n"); + printk(" RTC-beat window sweep (ESP32) — %d samples per window\n", K); + printk(" CPU cycles per fixed RTC-slow interval; full-delta entropy\n"); + printk("--------------------------------------------------------\n"); + printk(" window span distinct mcv bits/samp bits/sec\n"); + printk(" ------ ---- -------- ------- --------- --------\n"); + + for (size_t w = 0; w < ARRAY_SIZE(windows_us); w++) { + uint32_t win = windows_us[w]; + memset(beat_hist, 0, sizeof(beat_hist)); + uint32_t cyc_min = UINT32_MAX, cyc_max = 0; + + for (int i = 0; i < K; i++) { + uint64_t r0 = esp_rtc_get_time_us(); + uint32_t c0 = k_cycle_get_32(); + while ((esp_rtc_get_time_us() - r0) < win) { + /* CPU clock advances while the independent RTC + * oscillator defines the window; they drift */ + } + uint32_t c1 = k_cycle_get_32(); + uint32_t d = c1 - c0; + + if (d < cyc_min) cyc_min = d; + if (d > cyc_max) cyc_max = d; + beat_hist[d & 0x3FFF]++; + } + + uint32_t mcv = 0; + int distinct = 0; + for (int v = 0; v < 16384; v++) { + if (beat_hist[v]) distinct++; + if (beat_hist[v] > mcv) mcv = beat_hist[v]; + } + uint32_t per_mb = min_entropy_mb(mcv, K); + /* bits/sec = bits/sample * 1e6 / window_us */ + uint32_t bits_per_sec = (uint32_t)(((uint64_t)per_mb * 1000000u) + / (win * 1000u)); + + printk(" %4u us %5u %6d %6u %u.%03u %u\n", + win, cyc_max - cyc_min, distinct, mcv, + per_mb / 1000u, per_mb % 1000u, bits_per_sec); + } + + printk("--------------------------------------------------------\n"); + printk(" pick: highest bits/sample for cleanest per-sample quality,\n"); + printk(" or highest bits/sec if throughput-bound (200ms budget needs\n"); + printk(" >256 bits, so any row over ~1300 bits/sec has 10x margin).\n"); + printk("\n"); +} +#endif /* HAVE_RTC_BEAT */ + +int main(void) +{ + /* Console settle — USB CDC enumerates after boot on some boards. */ + k_msleep(2000); + +#ifdef HAVE_RTC_BEAT + /* Characterize the candidate second source BEFORE the diversity run, + * so its numbers are the first thing on the console. */ + rtc_beat_diagnostic(); +#endif + + printk("\n"); + printk("========================================================\n"); + printk(" ZephCore RNG selftest — identity seed diversity\n"); + printk(" N=%d seeds x 32 bytes, ~250 ms each (~%d s total)\n", + RNG_SELFTEST_N, (RNG_SELFTEST_N * 250) / 1000); + printk("========================================================\n\n"); + +#ifdef RNG_KILL_HWRNG + /* Zero the HWRNG contribution: the diversity run below then measures the + * two-clock beat ALONE. On this single device the device-id (stage 2) is + * constant across all seeds, so if this run still shows 0 duplicates and + * Hamming ~128, the beat by itself carries the key — i.e. ZephyrRNG + * survives a completely dead hardware RNG. */ + mesh::ZephyrRNG::setTestKillHWRNG(true); + printk("*** KILL_HWRNG: hardware RNG zeroed — measuring the beat ALONE ***\n\n"); +#endif + + uint8_t seed[SEED_LEN]; + uint8_t prev[SEED_LEN]; + + int duplicates = 0; + int ham_min = 10000; + int ham_max = -1; + int64_t ham_sum = 0; + int ham_n = 0; + int first_dup_a = -1, first_dup_b = -1; + + int64_t t_start = k_uptime_get(); + + for (int i = 0; i < RNG_SELFTEST_N; i++) { + /* Per-stage health output comes from mixIdentitySeed itself. Let + * it through for the first two seeds so the sources are on the + * record, then actually silence it — at N=1024 that is ~12,000 + * lines that would bury the summary. */ + if (i == 2) { + printk("\n[selftest] per-stage output silenced from here " + "(%d more seeds); summary follows at the end\n\n", + RNG_SELFTEST_N - 2); + mesh::ZephyrRNG::setSeedHealthQuiet(true); + } + + mesh::ZephyrRNG::mixIdentitySeed(seed, sizeof(seed)); + + /* Fingerprint + duplicate scan against everything so far. */ + memcpy(fingerprints[i], seed, FP_LEN); + for (int j = 0; j < i; j++) { + if (memcmp(fingerprints[i], fingerprints[j], FP_LEN) == 0) { + duplicates++; + if (first_dup_a < 0) { first_dup_a = j; first_dup_b = i; } + break; + } + } + + /* Consecutive Hamming distance. */ + if (i > 0) { + int d = hamming(seed, prev, SEED_LEN); + if (d < ham_min) ham_min = d; + if (d > ham_max) ham_max = d; + ham_sum += d; + ham_n++; + } + memcpy(prev, seed, SEED_LEN); + + /* Progress every 10%, so a long run visibly lives. */ + int step = RNG_SELFTEST_N / 10; + if (step > 0 && ((i + 1) % step) == 0) { + printk("[selftest] %d/%d seeds (%d%%) dup=%d\n", + i + 1, RNG_SELFTEST_N, + ((i + 1) * 100) / RNG_SELFTEST_N, duplicates); + } + } + + int64_t elapsed = k_uptime_get() - t_start; + + /* Mean to 2 decimals without floating point. */ + int ham_mean_x100 = ham_n ? (int)((ham_sum * 100) / ham_n) : 0; + + printk("\n"); + printk("========================================================\n"); + printk(" RESULTS (%d seeds in %lld s)\n", RNG_SELFTEST_N, elapsed / 1000); + printk("--------------------------------------------------------\n"); + printk(" exact duplicates : %d\n", duplicates); + if (duplicates) { + printk(" first collision: seed #%d == seed #%d\n", + first_dup_a, first_dup_b); + } + printk(" hamming distance : min=%d max=%d mean=%d.%02d (expect ~128)\n", + ham_min, ham_max, ham_mean_x100 / 100, ham_mean_x100 % 100); + printk("--------------------------------------------------------\n"); + + /* Verdict. Deliberately conservative wording: passing means "no evidence + * of a problem at this sample count", never "the entropy is good". A + * clean run at N only rules out an effective-entropy floor below about + * 2*log2(N) bits — see the note in CMakeLists.txt. */ + bool bad_dup = (duplicates > 0); + bool bad_ham = (ham_n > 0) && (ham_mean_x100 < 11000 || ham_mean_x100 > 14500); + + if (bad_dup) { + printk(" VERDICT: FAIL — duplicate seeds. The generator is\n"); + printk(" producing a small key space. Do NOT ship.\n"); + } else if (bad_ham) { + printk(" VERDICT: FAIL — seeds are correlated (mean far from\n"); + printk(" 128 bits). Do NOT ship.\n"); + } else { + printk(" VERDICT: no evidence of correlation at N=%d.\n", + RNG_SELFTEST_N); + printk(" Rules out an entropy floor below ~%d bits.\n", + 2 * (31 - __builtin_clz(RNG_SELFTEST_N))); + printk(" This is NOT proof the entropy is sufficient.\n"); + } + printk("========================================================\n"); + printk("\n[selftest] done — halting. Reflash normal firmware.\n"); + + return 0; +}