diff --git a/.gitignore b/.gitignore index b7c4bcb..0f226b1 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,5 @@ PSRAM_HANDOFF.md BLE_AUDIT_INDEX.md POWER_AUDIT_HANDOFF.md POWER_AUDIT_INDEX.md +CRYPTO_AUDIT_HANDOFF.md +CRYPTO_AUDIT_INDEX.md diff --git a/zephcore/adapters/rng/ZephyrRNG.cpp b/zephcore/adapters/rng/ZephyrRNG.cpp index 372c225..2209df7 100644 --- a/zephcore/adapters/rng/ZephyrRNG.cpp +++ b/zephcore/adapters/rng/ZephyrRNG.cpp @@ -3,18 +3,268 @@ */ #include "ZephyrRNG.h" +#include #include +#include #include +#include +#include +#include + +BUILD_ASSERT(IS_ENABLED(CONFIG_CSPRNG_ENABLED), + "ZephyrRNG requires CONFIG_CSPRNG_ENABLED for cryptographic key derivation"); namespace mesh { void ZephyrRNG::random(uint8_t *dest, size_t sz) { - int ret = sys_csrand_get(dest, sz); - if (ret != 0) { - printk("ZephyrRNG: CSPRNG failed (%d), using PRNG fallback\n", ret); - sys_rand_get(dest, sz); + /* Retry handles transient TRNG-warmup races; cold-reboot on persistent + * failure. Fabricating entropy here would silently produce weak keys + * forever (cf. Debian-OpenSSL 2008). k_msleep is illegal from ISR — + * all current callers run on main thread or syswq. */ + for (int attempt = 0; attempt < 4; attempt++) { + if (sys_csrand_get(dest, sz) == 0) return; + k_msleep(10); } + printk("ZephyrRNG: CSPRNG unavailable after retries — rebooting\n"); + k_msleep(2000); + sys_reboot(SYS_REBOOT_COLD); +} + +/* ===== Jitter sampling + 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. + * + * 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. + * + * Health check is the NIST SP 800-90B "repetition count" plus a + * variance check on the first 128 samples. Detects stuck-source + * catastrophic failure (e.g. cycle counter not advancing). Does not + * statistically prove entropy quality — that's what the literature is + * for. */ + +#define JITTER_TRACKED 128 + +static bool jitter_health_check(const uint32_t *deltas, size_t n) +{ + if (n < 16) return false; + + /* Repetition count: 32+ consecutive identical samples is failure. */ + int consec = 1, max_consec = 1; + for (size_t i = 1; i < n; i++) { + if (deltas[i] == deltas[i - 1]) { + consec++; + if (consec > max_consec) max_consec = consec; + } else { + consec = 1; + } + } + if (max_consec >= 32) return false; + + /* Variance: require at least 5 distinct delta values across the + * tracked window. A perfectly deterministic CPU would produce one + * value; even minimal jitter produces several. */ + uint32_t distinct[8] = {0}; + int n_distinct = 0; + for (size_t i = 0; i < n && n_distinct < 8; i++) { + bool found = false; + for (int j = 0; j < n_distinct; j++) { + if (distinct[j] == deltas[i]) { found = true; break; } + } + if (!found) distinct[n_distinct++] = deltas[i]; + } + return n_distinct >= 5; +} + +static bool sample_cpu_jitter(uint8_t *pool, size_t pool_size, + size_t pool_offset, uint32_t duration_ms) +{ + uint32_t deltas[JITTER_TRACKED]; + size_t tracked_idx = 0; + + uint32_t accum = k_cycle_get_32(); + int64_t deadline = k_uptime_get() + duration_ms; + size_t idx = pool_offset; + + while (k_uptime_get() < deadline) { + uint32_t t1 = k_cycle_get_32(); + /* Variable-time work — number of iterations depends on the + * accumulator, so timing depends on hardware nondeterminism + * (cache, branch prediction, ISR firing). */ + 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); + + if (tracked_idx < JITTER_TRACKED) deltas[tracked_idx++] = delta; + } + + return jitter_health_check(deltas, tracked_idx); +} + +/* ===== Entropy extraction via AES-256-CTR ================================ + * + * Per crypto consultant (MeshCore upstream PR#2280 author): the + * conditioning step is most correctly an XOF or stream cipher, not a + * truncated hash. For our 32-byte Ed25519-seed output the difference + * is design hygiene rather than security, but the cost is the same + * order of magnitude (~one SHA-512 vs SHA-256 + two AES-ECB blocks). + * + * Construction (NIST SP 800-108 KDF-in-Counter-Mode style): + * 1. Extract: SHA-256(pool) → 32-byte AES-256 key. + * 2. Expand: AES-256-ECB(counter_i) for counter_i = 0, 1, 2 ... + * output = concatenation of ciphertext blocks. + * Plaintext-XOR (true CTR mode) is omitted because plaintext would be + * all-zero — we want just the keystream. + * + * Uses PSA crypto API (already enabled via PSA_WANT_KEY_TYPE_AES + + * PSA_WANT_ALG_ECB_NO_PADDING in zephcore_common.conf). + */ +static int extract_via_aes_ctr(const uint8_t *pool, size_t pool_len, + uint8_t *out, size_t out_len) +{ + psa_status_t status; + uint8_t key[32]; + size_t key_len = 0; + + /* PSA is idempotent — already initialized via mbedTLS but a defensive + * call here costs nothing if it returns PSA_ERROR_ALREADY_EXISTS. */ + (void)psa_crypto_init(); + + /* Extract: SHA-256(pool) → AES key */ + status = psa_hash_compute(PSA_ALG_SHA_256, pool, pool_len, + key, sizeof(key), &key_len); + if (status != PSA_SUCCESS || key_len != sizeof(key)) { + return -1; + } + + /* Import key for AES-256-ECB */ + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attr, PSA_KEY_TYPE_AES); + psa_set_key_algorithm(&attr, PSA_ALG_ECB_NO_PADDING); + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_ENCRYPT); + psa_set_key_bits(&attr, 256); + + psa_key_id_t key_id = 0; + status = psa_import_key(&attr, key, sizeof(key), &key_id); + memset(key, 0, sizeof(key)); + if (status != PSA_SUCCESS) { + return -1; + } + + /* Expand: AES-ECB(counter_i) for i = 0, 1, ... */ + uint8_t counter[16] = {0}; + size_t pos = 0; + int ret = 0; + while (pos < out_len) { + uint8_t block[16]; + size_t block_out = 0; + status = psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, + counter, sizeof(counter), + block, sizeof(block), &block_out); + if (status != PSA_SUCCESS || block_out != sizeof(block)) { + ret = -1; + break; + } + size_t chunk = (out_len - pos < sizeof(block)) + ? (out_len - pos) : sizeof(block); + memcpy(out + pos, block, chunk); + pos += chunk; + + /* Increment 128-bit counter, big-endian — overflow rolls over. + * For our 32-byte output we only ever hit counters 0 and 1. */ + for (int i = sizeof(counter) - 1; i >= 0; i--) { + if (++counter[i] != 0) break; + } + memset(block, 0, sizeof(block)); + } + + psa_destroy_key(key_id); + memset(counter, 0, sizeof(counter)); + return ret; +} + +void ZephyrRNG::mixIdentitySeed(uint8_t *out, size_t out_len, + const uint8_t *extra, size_t extra_len) +{ + uint8_t pool[512]; + memset(pool, 0, sizeof(pool)); + + /* Stage 1: early CSPRNG (strong on nRF/MG24, weak on ESP32 pre-radio) */ + (void)sys_csrand_get(pool, 64); + + /* Stage 2: HWINFO unique device ID — uniqueness across devices */ + uint8_t devid[16] = {0}; + ssize_t devid_len = hwinfo_get_device_id(devid, sizeof(devid)); + for (ssize_t i = 0; i < devid_len && i < (ssize_t)sizeof(devid); i++) { + pool[64 + i] ^= devid[i]; + } + + /* Stage 3: caller-supplied entropy (e.g. ADC LSB noise) */ + 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]; + } + + /* Stage 4: CPU cycle-counter jitter, 200ms */ + bool health_ok = sample_cpu_jitter(pool, sizeof(pool), 112, 200); + if (!health_ok) { + printk("ZephyrRNG: jitter health check failed, resampling 400ms\n"); + health_ok = sample_cpu_jitter(pool, sizeof(pool), 112, 400); + if (!health_ok) { + printk("ZephyrRNG: jitter health still failing — continuing with mixed sources\n"); + } + } + + /* Stage 5: late CSPRNG — catches any mid-boot radio init that + * warmed the TRNG during the 200ms jitter window */ + (void)sys_csrand_get(pool + 368, 64); + + /* Stage 6: second jitter sample, independent timing window */ + (void)sample_cpu_jitter(pool, sizeof(pool), 432, 50); + + /* Final conditioning: AES-256-CTR over the pool. Extracts a 32-byte + * AES key via SHA-256(pool), then expands to out_len bytes via + * AES-ECB on a 128-bit counter. Per crypto consultant guidance — + * see extract_via_aes_ctr() for full rationale. */ + if (extract_via_aes_ctr(pool, sizeof(pool), out, out_len) != 0) { + printk("ZephyrRNG: AES-CTR extraction failed — rebooting\n"); + k_msleep(2000); + sys_reboot(SYS_REBOOT_COLD); + } + + /* Output sanity check — reject all-zero / all-0xFF (catastrophic + * failure of every source). Reboot to retry. */ + bool all_zero = true, all_ff = true; + for (size_t i = 0; i < out_len; i++) { + if (out[i] != 0x00) all_zero = false; + if (out[i] != 0xFF) all_ff = false; + } + if (all_zero || all_ff) { + printk("ZephyrRNG: degenerate seed output — rebooting\n"); + k_msleep(2000); + sys_reboot(SYS_REBOOT_COLD); + } + + /* Wipe sensitive intermediate buffers */ + memset(pool, 0, sizeof(pool)); + memset(devid, 0, sizeof(devid)); } } /* namespace mesh */ diff --git a/zephcore/adapters/rng/ZephyrRNG.h b/zephcore/adapters/rng/ZephyrRNG.h index ffc7962..9e84f92 100644 --- a/zephcore/adapters/rng/ZephyrRNG.h +++ b/zephcore/adapters/rng/ZephyrRNG.h @@ -6,12 +6,55 @@ #pragma once #include +#include +#include namespace mesh { class ZephyrRNG : public RNG { public: void random(uint8_t *dest, size_t sz) override; + + /* 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 + * 3. Optional caller-supplied data — e.g. ADC LSB 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 + * Conditioned via SHA-512. NIST-style health checks on jitter + * samples; reboots on degenerate output. + * Blocks for ~250-300ms — only called at first-boot identity gen. + * + * Output is suitable as an Ed25519 seed regardless of platform + * TRNG state at boot. */ + static void mixIdentitySeed(uint8_t *out, size_t out_len, + const uint8_t *extra = nullptr, + size_t extra_len = 0); +}; + +/* Thin wrapper that returns pre-supplied bytes from the mesh::RNG + * interface. Used to feed a mixed seed into mesh::LocalIdentity(RNG*) + * without changing the LocalIdentity API. One-shot — bytes are + * consumed sequentially across calls. */ +class SeededRNG : public RNG { +public: + SeededRNG(const uint8_t *seed_data, size_t len) + : _data(seed_data), _avail(len) {} + + void random(uint8_t *dest, size_t sz) override { + size_t n = (sz <= _avail) ? sz : _avail; + if (n > 0) memcpy(dest, _data, n); + _data += n; + _avail -= n; + if (n < sz) memset(dest + n, 0, sz - n); + } + +private: + const uint8_t *_data; + size_t _avail; }; } /* namespace mesh */ diff --git a/zephcore/helpers/ui-joystick/screens/system.cpp b/zephcore/helpers/ui-joystick/screens/system.cpp index 4ccd44a..9e2c19d 100644 --- a/zephcore/helpers/ui-joystick/screens/system.cpp +++ b/zephcore/helpers/ui-joystick/screens/system.cpp @@ -470,7 +470,9 @@ bool BLECodeScreen::handleInput(char c) } if (c == KEY_ENTER_LONG) { - uint32_t pin = 100000UL + (sys_rand32_get() % 900000UL); + uint32_t r; + sys_csrand_get(&r, sizeof(r)); + uint32_t pin = 100000UL + (r % 900000UL); NodePrefs *prefs = _task->getPrefs(); if (prefs) { prefs->ble_pin = pin; } snprintf(_pin_buf, sizeof(_pin_buf), "%06lu", (unsigned long)pin); diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index fce971b..7f05eab 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -22,6 +22,7 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include #include +#include #include #include #include "ui_task.h" @@ -55,7 +56,6 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); * This prints the file/line and reboots so we can actually see what happened. */ #if IS_ENABLED(CONFIG_BT_CTLR_ASSERT_HANDLER) -#include extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) { LOG_ERR("!!! BLE CONTROLLER ASSERT: %s:%u !!!", file ? file : "?", line); @@ -78,6 +78,12 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) #define MESH_EVENT_GPS_ACTION BIT(5) /* GPS state change (must run on main thread!) */ #define MESH_EVENT_TX_DRAIN BIT(6) /* Outbound packet delay expired, run checkSend */ #define MESH_EVENT_PREFS_DIRTY BIT(8) /* Prefs mutated off-main; main flushes to flash */ + +#ifdef ZEPHCORE_LORA +/* Forward decl — data_store + companion_mesh_ptr statics are defined further + * down in the file, so mesh_event_loop() can't reference them directly. */ +static void save_prefs_to_flash(void); +#endif #define MESH_EVENT_BASE (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | \ MESH_EVENT_BLE_RX | MESH_EVENT_HOUSEKEEPING | MESH_EVENT_UI_ACTION | \ MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_PREFS_DIRTY) @@ -354,8 +360,8 @@ static void mesh_event_loop(void) * to flash here so the synchronous LittleFS write doesn't block * the originating thread. Multiple posts coalesce into one * write of the latest _prefs values — desired behaviour. */ - if (events & MESH_EVENT_PREFS_DIRTY) { - data_store.savePrefs(companion_mesh.prefs); + if ((events & MESH_EVENT_PREFS_DIRTY) && companion_mesh_ptr) { + save_prefs_to_flash(); } #endif @@ -451,6 +457,14 @@ static mesh::SimpleMeshTables mesh_tables; static mesh::StaticPoolPacketManager packet_mgr; static CompanionMesh companion_mesh(lora_radio, ms_clock, zephyr_rng, rtc_clock, packet_mgr, mesh_tables, data_store); + +/* Defined here (after data_store + companion_mesh_ptr statics) and + * forward-declared near the top of the file so mesh_event_loop() can + * call it without the static decls being in scope. */ +static void save_prefs_to_flash(void) +{ + data_store.savePrefs(companion_mesh_ptr->prefs); +} #endif /* GPS enable callback - logs state changes @@ -646,17 +660,51 @@ int main(void) LOG_INF("Added default Public channel"); } - /* Load or generate identity */ + /* Load or generate identity. + * + * First-boot keygen uses ZephyrRNG::mixIdentitySeed() — a layered + * entropy mixer combining sys_csrand_get + HWINFO unique ID + ADC + * LSB noise + CPU cycle-counter jitter, conditioned via SHA-512. + * This compensates for ESP32's hardware TRNG being only seeded + * after WiFi/BT radio init (identity gen happens before bt_enable). */ mesh::LocalIdentity self_identity; if (!data_store.loadMainIdentity(self_identity)) { - self_identity = mesh::LocalIdentity(&zephyr_rng); - /* Ensure pub_key[0] is not reserved (0x00 or 0xFF in MeshCore protocol) */ - int count = 0; - while (count < 10 && (self_identity.pub_key[0] == 0x00 || self_identity.pub_key[0] == 0xFF)) { - self_identity = mesh::LocalIdentity(&zephyr_rng); - count++; + /* Sample ADC LSB noise — best-effort independent physical + * source. Boards without battery ADC return 0; jitter remains + * the primary entropy source either way. */ + uint8_t adc_noise[32] = {0}; + for (size_t i = 0; i < sizeof(adc_noise); i++) { + adc_noise[i] = (uint8_t)zephyr_board.getBattMilliVolts(); + k_msleep(1); } + + uint8_t seed[32]; + mesh::ZephyrRNG::mixIdentitySeed(seed, sizeof(seed), + adc_noise, sizeof(adc_noise)); + { + mesh::SeededRNG seed_rng(seed, sizeof(seed)); + self_identity = mesh::LocalIdentity(&seed_rng); + } + + /* Ensure pub_key[0] is not reserved (0x00 or 0xFF in MeshCore protocol). + * With a properly mixed seed this almost never triggers; the + * cap+reboot is a safety net against pathological entropy failure. */ + int attempt = 0; + while (self_identity.pub_key[0] == 0x00 || self_identity.pub_key[0] == 0xFF) { + if (++attempt > 100) { + LOG_ERR("Identity gen stuck on reserved prefix; rebooting"); + k_msleep(2000); + sys_reboot(SYS_REBOOT_COLD); + } + mesh::ZephyrRNG::mixIdentitySeed(seed, sizeof(seed)); + mesh::SeededRNG retry_rng(seed, sizeof(seed)); + self_identity = mesh::LocalIdentity(&retry_rng); + } + data_store.saveMainIdentity(self_identity); + + memset(seed, 0, sizeof(seed)); + memset(adc_noise, 0, sizeof(adc_noise)); } companion_mesh.self_id = self_identity; diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index c3158e4..bf4609b 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -454,19 +454,55 @@ int main(void) lora_radio.setTxDoneCallback(lora_tx_done_callback, nullptr); repeater_mesh.setTxQueuedCallback(tx_queued_callback, nullptr); - /* Load or generate identity BEFORE begin() */ + /* Load or generate identity BEFORE begin(). + * + * First-boot keygen uses ZephyrRNG::mixIdentitySeed() — a layered + * entropy mixer combining sys_csrand_get + HWINFO unique ID + ADC + * LSB noise + CPU cycle-counter jitter, conditioned via SHA-512. + * This compensates for ESP32's hardware TRNG being only seeded + * after WiFi/BT radio init (which on a repeater is on-demand for + * WiFi OTA — there's no guaranteed radio activity at boot). */ mesh::LocalIdentity self_identity; if (!data_store.loadIdentity(self_identity)) { LOG_INF("No identity found, generating new keypair..."); - self_identity = mesh::LocalIdentity(&zephyr_rng); - /* Ensure pub_key[0] is not reserved (0x00 or 0xFF) */ - int count = 0; - while (count < 10 && (self_identity.pub_key[0] == 0x00 || self_identity.pub_key[0] == 0xFF)) { - self_identity = mesh::LocalIdentity(&zephyr_rng); - count++; + + /* Sample ADC LSB noise — best-effort independent physical + * source. Boards without battery ADC return 0; jitter remains + * the primary entropy source either way. */ + uint8_t adc_noise[32] = {0}; + for (size_t i = 0; i < sizeof(adc_noise); i++) { + adc_noise[i] = (uint8_t)zephyr_board.getBattMilliVolts(); + k_msleep(1); } + + uint8_t seed[32]; + mesh::ZephyrRNG::mixIdentitySeed(seed, sizeof(seed), + adc_noise, sizeof(adc_noise)); + { + mesh::SeededRNG seed_rng(seed, sizeof(seed)); + self_identity = mesh::LocalIdentity(&seed_rng); + } + + /* Ensure pub_key[0] is not reserved (0x00 or 0xFF in MeshCore protocol). + * With a properly mixed seed this almost never triggers; the + * cap+reboot is a safety net against pathological entropy failure. */ + int attempt = 0; + while (self_identity.pub_key[0] == 0x00 || self_identity.pub_key[0] == 0xFF) { + if (++attempt > 100) { + LOG_ERR("Identity gen stuck on reserved prefix; rebooting"); + k_msleep(2000); + sys_reboot(SYS_REBOOT_COLD); + } + mesh::ZephyrRNG::mixIdentitySeed(seed, sizeof(seed)); + mesh::SeededRNG retry_rng(seed, sizeof(seed)); + self_identity = mesh::LocalIdentity(&retry_rng); + } + data_store.saveIdentity(self_identity); LOG_INF("New identity saved"); + + memset(seed, 0, sizeof(seed)); + memset(adc_noise, 0, sizeof(adc_noise)); } repeater_mesh.self_id = self_identity;