diff --git a/releasenotes/RELEASE_NOTES_1.16.6-zephcore.md b/releasenotes/RELEASE_NOTES_1.16.6-zephcore.md index af83cc6..a8a8d76 100644 --- a/releasenotes/RELEASE_NOTES_1.16.6-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.16.6-zephcore.md @@ -23,9 +23,9 @@ --- -A maintenance release: an important **admin-password fix**, **GPS standby power savings**, and the -removal of **Adaptive Power Control** — plus two new boards, **muzi works R1 Neo** and an experimental -first cut at the **ThinkNode M9**. +A maintenance release: an important **admin-password fix**, a substantial **identity-key randomness +hardening** on ESP32, **GPS standby power savings**, and the removal of **Adaptive Power Control** — +plus two new boards, **muzi works R1 Neo** and an experimental first cut at the **ThinkNode M9**. ## Highlights @@ -45,6 +45,41 @@ The one place autocapitalization genuinely hurts — typing commands to the V-Co which capitalizes the first letter of every line — is handled narrowly instead: only character 0 of a V-Contact chat line is folded. No command takes an argument at position 0, so no value can be touched. +### Identity keys are now generated with hardened, hardware-verified randomness + +Your node's identity — the Ed25519 keypair that *is* your node on the mesh — is generated once, on first +boot, and kept for life. An investigation into how the randomness behind that key is gathered found that +**ESP32 boards were generating identities with far less entropy than intended.** Two independent problems, +both fixed in this release: + +- **The ESP32 hardware RNG was running unseeded.** It only produces true random numbers while the radio + (WiFi/BT) is active — and identity generation runs before the radio comes up (on repeaters, the radio + never comes up at all). Key generation now feeds the RNG from the chip's ADC noise source for its + duration, Espressif's documented method for exactly this pre-radio situation. Verified on hardware: + the RNG's output went from a fixed pattern to statistically ideal. +- **The timing-jitter backup source measured nothing on ESP32.** It sampled the CPU's cycle counter + against work paced by that same clock — deterministic, confirmed dead on hardware. It has been replaced + everywhere by a **two-clock beat** source: counting CPU cycles across fixed intervals of an + *independent* low-frequency oscillator, whose physical drift is genuinely random. The same mechanism + now runs uniformly on ESP32, nRF52 and MG24. (nRF was never in danger — its RNG is radio-independent — + this adds an equally strong second source there for defence in depth.) + +This was not signed off by inspection. A new on-device self-test (`tools/rng_selftest`) generated **1024 +identities per platform through the real generation path**: zero duplicate keys, and pairwise-difference +statistics matching ideal randomness — **including a torture run with the hardware RNG deliberately +disabled**, where the beat source alone still produced fully diverse keys. A node whose hardware RNG +silently dies no longer means a predictable identity. + +> [!TIP] +> **Upgrading does not regenerate your identity — the key made at first boot stays.** If your node is an +> ESP32 board and its identity was generated by an earlier release, that key was created under the old, +> weaker entropy. If you want one generated under the new code, **factory-reset / format the node** (or +> erase-flash and reflash) — the next boot creates a fresh identity through the hardened path. The cost is +> real: a new public key, so your contacts must re-add you, and contacts, channels and bonds start clean. +> Whether that trade is worth it is your call — for a casual node, probably not; for a node whose identity +> matters (an admin key, a well-known repeater), it is worth considering. nRF boards have no reason to +> regenerate. + ### GPS standby now actually saves power `CONFIG_PM_DEVICE` (device power management) is enabled for the first time, carefully scoped: system-managed @@ -170,4 +205,7 @@ ZephCore and are fine left alone. 3. **Everything else, from v1.16.2 onward:** just flash — bonds and data survive. 4. **From v1.16.1 or older:** just flash — self-migrates on first boot; re-bond once. 5. **From Official MeshCore:** just flash — auto-formats on first boot. -6. **Anything odd?** Format first. +6. **ESP32 node whose identity you care about?** Consider a one-time factory reset so the key is + regenerated under the hardened entropy — see the tip in the identity-randomness section. Optional, + and it changes your public key. +7. **Anything odd?** Format first. diff --git a/zephcore/adapters/rng/ZephyrRNG.cpp b/zephcore/adapters/rng/ZephyrRNG.cpp index 6d0fbf0..1606265 100644 --- a/zephcore/adapters/rng/ZephyrRNG.cpp +++ b/zephcore/adapters/rng/ZephyrRNG.cpp @@ -41,28 +41,23 @@ void ZephyrRNG::random(uint8_t *dest, size_t sz) Utils::cryptoPanicReboot("CSPRNG unavailable after retries"); } -/* ===== Jitter sampling + online health check ============================= +/* ===== Timing-entropy health check ======================================= * - * Stephan Müller "CPU Time Jitter Based Non-Physical True Random Number - * Generator" — Linux kernel's jitterentropy_rng. NIST SP 800-90B has - * a compliance class for this entropy source type. + * Online health check (NIST SP 800-90B style) for the two-clock beat source + * below: repetition count + a distinct-value check tracked across all samples + * in the window with scalar state — no per-sample buffer needed. Detects + * stuck-source catastrophic failure (e.g. a frozen slow clock). Does not + * statistically prove entropy quality — that's what the selftest + * output-diversity run is for. * - * 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 - * scalar state — no per-sample buffer needed. Detects stuck-source - * catastrophic failure (e.g. cycle counter not advancing). Does not - * statistically prove entropy quality — that's what the literature is - * for. */ + * (The former CPU-jitter fallback — Stephan Müller style k_cycle_get_32() + * delta sampling — was removed: it only ever carried entropy where the cycle + * counter was already cross-domain from the CPU, and every such board is + * exactly a board the beat covers. Where the beat is unavailable the counter + * is same-domain, the loop is deterministic, and jitter yields ~0 bits — + * measured dead on ESP32 hardware.) */ -/* Health statistics for one jitter window. Timing statistics only — never +/* Health statistics for one beat 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 @@ -86,152 +81,15 @@ 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 { +struct beat_stats { int n_samples; int n_distinct; /* distinct delta values seen, capped at 8 */ int max_consec; /* longest run of identical deltas */ uint32_t min_delta; uint32_t max_delta; - uint32_t mcv_count; /* occurrences of the most common tracked delta */ - uint32_t untracked; /* samples whose value missed the histogram */ bool ok; }; -/* log2(x) * 1000, integer math (no FP — printk here has no float support). - * Integer part from the leading bit; fraction by linear interpolation between - * adjacent powers of two. Linear interpolation UNDERSTATES log2 across that - * range (log2(1.5)=0.585 vs 0.5 linear), so the derived entropy figure errs - * low — the safe direction for an entropy claim. */ -static uint32_t log2_millibits(uint32_t x) -{ - if (x <= 1) return 0; - uint32_t ipart = 31u - (uint32_t)__builtin_clz(x); - uint32_t base = 1u << ipart; - uint32_t frac = (uint32_t)(((uint64_t)(x - base) * 1000u) / base); - return ipart * 1000u + frac; -} - -static bool sample_cpu_jitter(uint8_t *pool, size_t pool_size, - size_t pool_offset, uint32_t duration_ms, - struct jitter_stats *st = nullptr) -{ - uint32_t accum = k_cycle_get_32(); - int64_t deadline = k_uptime_get() + duration_ms; - size_t idx = pool_offset; - uint32_t min_delta = UINT32_MAX, max_delta = 0; - - /* Online health stats: 32 bytes total vs. the previous 512-byte - * deltas[] array. Tracks every sample, not just the first 128. */ - uint32_t prev_delta = 0; - int cur_consec = 0, max_consec = 0; - uint32_t distinct[8] = {0}; - int n_distinct = 0; - int n_samples = 0; - - /* Bounded histogram for the SP 800-90B Most Common Value estimator. - * Holds the first JITTER_HIST_SLOTS distinct deltas; anything beyond - * that is counted in `untracked`. A value frequent enough to dominate - * p_max shows up within the first few distinct observations with - * overwhelming probability, so this captures what the estimator needs - * — but `untracked` is reported so the assumption stays visible. */ - uint32_t hist_val[JITTER_HIST_SLOTS] = {0}; - uint32_t hist_cnt[JITTER_HIST_SLOTS] = {0}; - int n_hist = 0; - uint32_t untracked = 0; - - while (k_uptime_get() < deadline) { - uint32_t t1 = k_cycle_get_32(); - /* 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++) { - a = a * 1664525u + 1013904223u; - } - accum = a; - uint32_t t2 = k_cycle_get_32(); - uint32_t delta = t2 - t1; - - /* Mix into entropy pool */ - pool[idx++ % pool_size] ^= (uint8_t)delta; - pool[idx++ % pool_size] ^= (uint8_t)(delta >> 8); - pool[idx++ % pool_size] ^= (uint8_t)accum; - pool[idx++ % pool_size] ^= (uint8_t)(accum >> 8); - - /* Online repetition count */ - if (n_samples > 0 && delta == prev_delta) { - if (++cur_consec > max_consec) max_consec = cur_consec; - } else { - cur_consec = 1; - } - prev_delta = delta; - - /* Track first 8 distinct delta values */ - if (n_distinct < 8) { - bool found = false; - for (int j = 0; j < n_distinct; j++) { - if (distinct[j] == delta) { found = true; break; } - } - if (!found) distinct[n_distinct++] = delta; - } - - if (delta < min_delta) min_delta = delta; - if (delta > max_delta) max_delta = delta; - - /* MCV histogram */ - { - bool binned = false; - for (int j = 0; j < n_hist; j++) { - if (hist_val[j] == delta) { - hist_cnt[j]++; - binned = true; - break; - } - } - if (!binned) { - if (n_hist < JITTER_HIST_SLOTS) { - hist_val[n_hist] = delta; - hist_cnt[n_hist] = 1; - n_hist++; - } else { - untracked++; - } - } - } - - n_samples++; - } - - uint32_t mcv_count = 0; - for (int j = 0; j < n_hist; j++) { - if (hist_cnt[j] > mcv_count) mcv_count = hist_cnt[j]; - } - - bool ok = (n_samples >= 16) /* enough samples */ - && (max_consec < 32) /* not a stuck source */ - && (n_distinct >= 5); /* minimal variance */ - - 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 = mcv_count; - st->untracked = untracked; - st->ok = ok; - } - return ok; -} - /* ===== Universal two-clock beat entropy ================================== * * One physical entropy source for every board: count CPU cycles elapsed across @@ -256,14 +114,18 @@ static bool sample_cpu_jitter(uint8_t *pool, size_t pool_size, * 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). + * independent slow clock is identified and the timing stages are + * SKIPPED — a same-domain counter measures a deterministic loop + * (~0 bits, measured), so sampling it would only pretend to add + * entropy. Such boards (STM32WL SysTick, nRF54L 1 MHz GRTC) rely on + * their true TRNG via the CSPRNG stages, which is what the removed + * CPU-jitter fallback effectively did anyway. * * 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. + * non-HWRNG leg. * * 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. */ @@ -284,7 +146,7 @@ static inline uint64_t beat_slow_ticks(void) { return k_cycle_get_32(); } 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) + struct beat_stats *st = nullptr) { /* Enable the CPU cycle counter once (DWT on Cortex-M; no-op-ish on * Xtensa where CCOUNT always runs). */ @@ -354,8 +216,6 @@ static bool sample_two_clock_beat(uint8_t *pool, size_t pool_size, 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; @@ -376,51 +236,24 @@ static int distinct_bytes(const uint8_t *buf, size_t len) return n; } -/* One line per jitter window. `distinct` is capped at 8 by the sampler, so 8/8 +/* One line per beat window. `distinct` is capped at 8 by the sampler, so 8/8 * means "at least 8" — the pass threshold is 5. `maxrep` is the longest run of - * identical deltas; >=32 fails. min/max delta expose a resolution problem: if - * they are 0 and 1, the cycle counter is too coarse to measure the work at all - * and no amount of sampling will help. */ -static void report_jitter(const char *label, const struct jitter_stats *st) + * identical deltas; >=32 fails. The span/distinct/maxrep line IS the beat's + * health — per-sample entropy is characterised offline by the selftest window + * sweep, not estimated here (an in-path MCV estimate would need a 16k-slot + * histogram). Deliberately no statistics on the conditioned output: + * AES-256-CTR makes any input look uniform, so output statistics would read + * perfect even for a near-zero-entropy seed. Entropy is a property of the + * source. */ +#ifdef HAVE_TWO_CLOCK_BEAT +static void report_beat(const char *label, const struct beat_stats *st) { 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"); - - /* Min-entropy estimate, NIST SP 800-90B 6.3.1 Most Common Value: - * p_max = mcv_count / n_samples - * H_min per sample = -log2(p_max) = log2(n_samples / mcv_count) - * - * Computed in milli-bits with integer math. This REPLACES the 0.1-0.3 - * bits/sample the file header used to assume — assumption is only valid - * if deltas actually vary, which is the very thing that failed on ESP32. - * - * Deliberately NOT measured on the conditioned output: AES-256-CTR makes - * 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) { - /* 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; - } - - uint32_t ratio_q10 = (uint32_t)(((uint64_t)st->n_samples * 1024u) - / st->mcv_count); - uint32_t per_mb = log2_millibits(ratio_q10); - per_mb = (per_mb > 10000u) ? (per_mb - 10000u) : 0u; /* less log2(1024) */ - - uint64_t total_mb = (uint64_t)per_mb * (uint64_t)st->n_samples; - uint32_t total_bits = (uint32_t)(total_mb / 1000u); - - 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]" : ""); } +#endif /* HAVE_TWO_CLOCK_BEAT */ /* ===== Entropy extraction via AES-256-CTR ================================ * @@ -511,29 +344,6 @@ 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) { @@ -603,27 +413,33 @@ void ZephyrRNG::mixIdentitySeed(uint8_t *out, size_t out_len, RNG_RPT("[RNG] stage3 extra : %d bytes\n", (int)n); } - /* Stage 4: hardware-timing entropy, 200ms. RTC two-clock beat on ESP32, - * CPU cycle-counter jitter elsewhere (sample_platform_entropy). + /* Stage 4: hardware-timing entropy, 200ms — the two-clock beat. * - * 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 - * this sampling anyway. */ -#ifndef CONFIG_ARCH_POSIX - struct jitter_stats js = {}; - bool health_ok = sample_platform_entropy(pool, sizeof(pool), 112, 200, &js); - report_jitter("stage4 " ENTROPY_SRC_LABEL " 200ms", &js); + * Skipped where no independent slow clock exists (see the beat header + * comment): a same-domain counter would sample a deterministic loop and + * only pretend to add entropy. Those boards rely on their true TRNG via + * stages 1 and 5. + * + * Also 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 this sampling anyway. */ +#if defined(HAVE_TWO_CLOCK_BEAT) && !defined(CONFIG_ARCH_POSIX) + struct beat_stats js = {}; + bool health_ok = sample_two_clock_beat(pool, sizeof(pool), 112, 200, &js); + report_beat("stage4 beat 200ms", &js); if (!health_ok) { 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); + health_ok = sample_two_clock_beat(pool, sizeof(pool), 112, 400, &js); + report_beat("stage4 beat 400ms", &js); if (!health_ok) { RNG_RPT("[RNG] stage4 STILL FAILING — continuing with mixed sources\n"); } } -#endif /* CONFIG_ARCH_POSIX */ +#else + RNG_RPT("[RNG] stage4/6 skipped — no independent slow clock (TRNG via csrand only)\n"); +#endif /* HAVE_TWO_CLOCK_BEAT && !CONFIG_ARCH_POSIX */ /* Stage 5: late CSPRNG — catches any mid-boot radio init that * warmed the TRNG during the stage-4 window */ @@ -635,11 +451,11 @@ void ZephyrRNG::mixIdentitySeed(uint8_t *out, size_t out_len, rc5, distinct_bytes(pool + 368, 64)); /* Stage 6: second hardware-timing sample, independent window */ -#ifndef CONFIG_ARCH_POSIX - struct jitter_stats js6 = {}; - (void)sample_platform_entropy(pool, sizeof(pool), 432, 50, &js6); - report_jitter("stage6 " ENTROPY_SRC_LABEL " 50ms", &js6); -#endif /* CONFIG_ARCH_POSIX */ +#if defined(HAVE_TWO_CLOCK_BEAT) && !defined(CONFIG_ARCH_POSIX) + struct beat_stats js6 = {}; + (void)sample_two_clock_beat(pool, sizeof(pool), 432, 50, &js6); + report_beat("stage6 beat 50ms", &js6); +#endif /* HAVE_TWO_CLOCK_BEAT && !CONFIG_ARCH_POSIX */ /* Collection done — release the SAR ADC before anything else needs it. * Unconditional: every path below this point either returns normally or diff --git a/zephcore/adapters/rng/ZephyrRNG.h b/zephcore/adapters/rng/ZephyrRNG.h index 4544e8a..bdb637f 100644 --- a/zephcore/adapters/rng/ZephyrRNG.h +++ b/zephcore/adapters/rng/ZephyrRNG.h @@ -22,14 +22,16 @@ public: * 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. Hardware-timing entropy — RTC two-clock beat on ESP32, - * CPU-jitter on nRF (NIST 90B class) + * 4. Hardware-timing entropy — two-clock beat (ESP32 RTC-slow, + * nRF/MG24 32 kHz RTC); skipped on + * boards with no independent slow + * clock (they rely on their TRNG) * 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). * 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; + * CSPRNG and the beat 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 diff --git a/zephcore/app/ObserverMesh.cpp b/zephcore/app/ObserverMesh.cpp index 45a8e37..5f994e0 100644 --- a/zephcore/app/ObserverMesh.cpp +++ b/zephcore/app/ObserverMesh.cpp @@ -9,6 +9,7 @@ #include #include #include +#include /* generateFirstBootIdentity (hardened keygen) */ #include #include @@ -32,10 +33,10 @@ namespace mesh { /* ========== Construction ========== */ -ObserverMesh::ObserverMesh(Radio &radio, MillisecondClock &ms, RNG &rng, RTCClock &rtc) +ObserverMesh::ObserverMesh(Radio &radio, MillisecondClock &ms, RTCClock &rtc) : Dispatcher(radio, ms, _pkt_mgr), _last_rssi(0.0f), _last_score(0.0f), _last_raw_len(0), - _store(nullptr), _creds(nullptr), _rng(&rng), _rtc(&rtc), _start_uptime_secs(0) + _store(nullptr), _creds(nullptr), _rtc(&rtc), _start_uptime_secs(0) { memset(_pubkey_hex, 0, sizeof(_pubkey_hex)); memset(_packets_topic, 0, sizeof(_packets_topic)); @@ -62,15 +63,19 @@ void ObserverMesh::begin(RepeaterDataStore *store, struct ObserverCreds *creds) _store->savePrefs(_prefs); } - /* Load or generate node identity */ + /* Load or generate node identity. + * + * Use ZephyrRNG::generateFirstBootIdentity — the SAME hardened path the + * companion and repeater use (bootloader_random-seeded HWRNG + two-clock + * beat, conditioned via AES-256-CTR) — NOT LocalIdentity(_rng). The old + * form drew straight from ZephyrRNG::random() / sys_csrand_get, which on + * an ESP32 observer is unseeded (BLE never comes up to seed WDEV_RANDOM), + * so it derived a permanent key from a weak PRNG. generateFirstBootIdentity + * also owns the reserved-prefix retry (100 attempts + panic backstop), + * replacing the weaker 10-try loop that silently kept a reserved prefix. */ if (!_store->loadIdentity(_self_id)) { LOG_INF("No identity found — generating new keypair"); - int attempts = 0; - do { - _self_id = LocalIdentity(_rng); - attempts++; - } while (attempts < 10 && - (_self_id.pub_key[0] == 0x00 || _self_id.pub_key[0] == 0xFF)); + mesh::ZephyrRNG::generateFirstBootIdentity(_self_id); _store->saveIdentity(_self_id); LOG_INF("New observer identity saved"); } diff --git a/zephcore/app/ObserverMesh.h b/zephcore/app/ObserverMesh.h index 10eed5b..b76f67c 100644 --- a/zephcore/app/ObserverMesh.h +++ b/zephcore/app/ObserverMesh.h @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -45,7 +44,6 @@ class ObserverMesh : public Dispatcher { NodePrefs _prefs; RepeaterDataStore *_store; struct ObserverCreds *_creds; - RNG *_rng; RTCClock *_rtc; /* Pre-built MQTT topic strings (set in begin()) */ @@ -73,7 +71,7 @@ protected: DispatcherAction onRecvPacket(Packet *pkt) override; public: - ObserverMesh(Radio &radio, MillisecondClock &ms, RNG &rng, RTCClock &rtc); + ObserverMesh(Radio &radio, MillisecondClock &ms, RTCClock &rtc); /* Initialize: load/generate identity, load/init prefs, start radio RX. */ void begin(RepeaterDataStore *store, struct ObserverCreds *creds); diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index 6639221..2360907 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -245,7 +245,6 @@ static void time_sync_cb(uint32_t unix_ts) static mesh::ZephyrBoard s_board; static mesh::ZephyrMillisecondClock s_ms_clock; -static mesh::ZephyrRNG s_rng; static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); @@ -260,7 +259,7 @@ static mesh::SX127xRadio lora_radio(lora_dev, s_board, &s_radio_prefs); static mesh::SX126xRadio lora_radio(lora_dev, s_board, &s_radio_prefs); #endif -static mesh::ObserverMesh observer_mesh(lora_radio, s_ms_clock, s_rng, s_rtc_clock); +static mesh::ObserverMesh observer_mesh(lora_radio, s_ms_clock, s_rtc_clock); static RepeaterDataStore data_store; /* ========== main() ========== */ diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 35d35aa..06090e0 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -177,7 +177,8 @@ STM32WL caveats — different from every other ZephCore platform: AES tables live in ROM (`MBEDTLS_AES_ROM_TABLES`) to reclaim ~8KB SRAM. - **TRNG only (no HW CSPRNG):** the STM32 TRNG is enabled as the entropy source and `CSPRNG_ENABLED` auto-resolves on top; `ZephyrRNG` further conditions - identity seeds with jitter + AES-CTR. + identity seeds with AES-CTR (the timing stages are skipped — SysTick has no + independent slow clock — so the TRNG-fed CSPRNG stages carry the seed). - **No MCUboot / UF2:** single app partition at flash origin + a LittleFS volume (see `board.overlay`). Flash over SWD/ST-Link with `west flash` (OpenOCD).