Two hardware-found fixes after on-air testing on the GAT562 kit:
- Add a 500ms push timer in main_room_server.cpp so the post-sync
engine advances at its intended cadence. ZephCore is event-driven
(no Arduino superloop), so without this the engine only ran on the
5s housekeeping tick — posts dripped out every ~5s and transmits
bunched up, causing timeouts/resends.
- Lower the post-sync hold from upstream's conservative defaults
(POST_SYNC_DELAY 6s -> 2s, PUSH_NOTIFY 2000ms -> 1000ms). These are
server-side timing only (no wire-format change), and take measured
delivery from ~6-7s down to ~2s.
Verified on hardware (910.525/62.5/SF7/CR8, two clients): normal-pace
messages deliver in ~2s with clean ACKs. Rapid-fire bursts can still
drop out-of-order messages via the per-client timestamp replay check
(unchanged from upstream) — left as-is to stay upstream-compatible.
Add the MeshCore Room Server role to ZephCore — a store-and-forward
shared message room. Clients log in with an admin or guest password
and post messages; the server pushes each new post to all other
logged-in clients (round-robin, per-client sync cursor, ACK + retry,
3-strike eviction).
Ported from upstream MeshCore's simple_room_server, structured as a
near-clone of RepeaterMesh so it reuses the proven ACL, region
filtering, CLI, adverts and telemetry; the post buffer + push engine
are the only net-new pieces:
- RoomServerMesh: PostInfo ring (MAX_UNSYNCED_POSTS=32), addPost,
pushPostToClient, getUnsyncedCount, processAck, onAckRecv, the
loop() push driver; room login (onAnonDataRecv parses the
sync_since cursor); posts/admin-CLI/keep-alive (onPeerDataRecv);
ADV_TYPE_ROOM advert; ServerStats wire layout; disable_fwd=1.
- main_room_server.cpp: event-loop entry (USB serial CLI, no BLE).
- Kconfig: ZEPHCORE_ROLE_ROOM_SERVER + ZEPHCORE_MAX_UNSYNCED_POSTS.
- CMakeLists role gating; boards/common/room_server.conf.
Post frame, SHA-256 ACK and login-reply layouts match upstream for
MeshCore app compatibility. Builds for gat562_30s
(FLASH 30.95% / RAM 32.46%). Not yet hardware-tested.
Swap the vendored orlp/ed25519 (frozen ~2017 ref10) for Monocypher
4.0.2, an actively maintained, audited, single-file implementation.
The persisted private key keeps its 64-byte expanded layout
(clamped SHA-512(seed) scalar a || nonce prefix), so identities
written by older firmware load, sign, verify and key-exchange
unchanged -- no re-key, no storage migration, full wire
compatibility with the existing mesh and Arduino MeshCore.
Because the stored key carries no seed for Monocypher's high-level
EdDSA API, Identity drives signing from the low-level primitives
(crypto_sha512 + crypto_eddsa_reduce/scalarbase/mul_add); verify
uses crypto_ed25519_check and ECDH uses crypto_eddsa_to_x25519 +
crypto_x25519. Nonce material is now wiped after signing.
Validated byte-for-byte against the previous orlp output via a
known-answer harness (keygen, sign-from-expanded-key, verify
accept/reject, X25519-over-Ed25519 shared secret) before the swap.
Frees ~42 KB of flash: orlp linked ~55 KB (dominated by its ~30 KB
ref10 precomputed tables); the Monocypher Ed25519/X25519/SHA-512
paths link ~14 KB, with --gc-sections dropping all unused algos.
wio_tracker_l1 pristine build: FLASH 54.14%, links clean.
Monocypher is CC0-1.0 OR BSD-2-Clause.
Companion BLE/USB direct & zero-hop sends enqueue with delay 0 from
sysworkq, off the main loop. Since 57b971f dropped the per-frame RX
wake, fire the tx-queued callback for delay 0 too so they actually
drain (USB companion has no tx-idle backstop and would stall).
Production (LOG=n, ASSERT=n, RTT=n, reboot-on-fatal) is now the prj.conf
default; debug.conf is the opt-in bundle. Removed prod.conf and the
logging.conf auto-include; relocated RTT/ASSERT out of the always-on
platform confs so they no longer override the prod defaults.
Add CONFIG_ZEPHCORE_COMPANION_USB so the USB CDC companion transport
compiles independently of logging (default-y on USB-capable companions,
opt-in on ESP32-S3 via esp32s3_usb.conf). Gate all USB sites behind one
ZEPHCORE_USB_STACK macro.
Rework BLE/USB interface arbitration to first-come-first-served: neither
transport evicts a live session. Make active_iface mutation thread-safe
(mutex + atomic claim) across the BLE callback thread and USB workqueue.
Share the ESP32-S3 USB OTG / console DTS via common dtsi includes; enable
uart0 (GPIO43/44) on station_g2 and xiao so the console reroute works.
getEstAirtimeFor() set LDRO from `sf >= 11`, correct only at BW125. Now
matches the driver's should_enable_ldro() (t_sym > 16.38 ms) so the
estimate's DE tracks hardware DE on every SF/BW. Also drop dead
calcRxDelay() + unused MAX_RX_DELAY_MILLIS.
- Lift duplicated identity-gen block from main_companion.cpp +
main_repeater.cpp into ZephyrRNG::generateFirstBootIdentity().
Both mains shrink from ~40 lines to a 3-line helper call.
- Add LocalIdentity::fromSeed() so seed-derived keygen doesn't need
a one-shot RNG wrapper; delete SeededRNG.
- Drop the per-byte ADC sampling loop: getBattMilliVolts() does an
8-sample average + 10ms regulator settle internally, costing
300-480ms of real wall-time and actively destroying the LSB jitter
it was meant to harvest. Jitter mixer already dwarfs it.
- Centralize the printk + sys_reboot pattern as
Utils::cryptoPanicReboot(); drop the 2000ms pre-reboot k_msleep
(printk is synchronous, sleep just blocked the mesh thread on
the ZephyrRNG::random() retry-failure path).
- Inline sample_cpu_jitter health check via online scalars instead
of a 512-byte deltas[] array. Saves 1.5KB stack churn across boot
and tracks every sample instead of only the first 128.
- extract_via_aes_ctr now uses Utils::sha256 instead of open-coding
psa_hash_compute.
Audit-driven sweep found additional compiler-optimization-sensitive
patterns beyond the login password compare just fixed:
P4.F3 (HIGH) — Utils::MACThenDecrypt verified packet MACs with
plain memcmp. Runs on EVERY encrypted-then-MAC'd packet in the
mesh; a timing oracle here lets attackers forge MACs byte-by-byte
across the whole mesh layer. Replaced with constantTimeEqual.
P4.F4 (MEDIUM) — Multiple memset(secret, 0, ...) calls on
stack-resident crypto buffers (Ed25519 seed, ADC noise pool, AES
key derived in extract_via_aes_ctr, HWINFO unique ID) were
subject to dead-store elimination under -Os. GCC/Clang routinely
elide these when the buffer is never read after; the wipe vanishes
and the secret persists on stack until next call overwrites.
Replaced with secureZeroize using volatile pointer writes.
P4.F5 (LOW) — Identity::validatePrivateKey boot self-test compared
shared secrets with plain memcmp. Boot-only, no attacker
observation channel, but hygiene matters and the fix is one line.
Also added secret-wipe for ss1/ss2 on all return paths.
Promoted the local ct_memeq() previously added to RepeaterMesh.cpp
into Utils::constantTimeEqual + Utils::secureZeroize (Utils.h/cpp)
so the login compare and MAC compare share the same audited helper.
Both helpers verified by Thumb-2 disassembly on rak3401_1watt:
- constantTimeEqual: loop branches on iterator, accumulator
load-modify-stored to stack every iteration, final return uses
CLZ+LSR (no conditional branch on result).
- secureZeroize: STRB.W to memory in a counted loop, not replaced
with memset builtin and not eliminated.
Adds a layered entropy mixer for first-boot identity Ed25519 keygen,
primarily to address ESP32 where the hardware TRNG (WDEV_RND_REG) is
only fed real entropy once the internal WiFi/BT radio is enabled —
but identity gen runs before that on companion and indefinitely
before that on a bare repeater. ESP-IDF's bootloader_random_enable()
is not compiled by Zephyr-Espressif HAL, ruling out that workaround.
Design reviewed with nextgens (author of upstream meshcore-dev/
MeshCore#2280 which fixes the same issue via BT/WiFi init/pull/deinit).
ZephyrRNG::random — retry sys_csrand_get up to 4x with k_msleep
backoff; cold-reboot on persistent failure. Previously fell back
silently to sys_rand_get (xoshiro PRNG), which would have produced
a weak Ed25519 seed on CSPRNG error. BUILD_ASSERT enforces
CONFIG_CSPRNG_ENABLED.
ZephyrRNG::mixIdentitySeed — layered entropy mixer for one-shot
identity keygen. Combines sys_csrand_get (early + late),
HWINFO unique device ID, caller-supplied ADC LSB noise, 200ms of
CPU cycle-counter jitter (NIST SP 800-90B class source), and
50ms more jitter in an independent timing window. Conditioned via
AES-256-CTR (NIST SP 800-108 KDF-in-Counter-Mode): SHA-256 of the
pool extracts a 32-byte AES key; AES-256-ECB on an incrementing
128-bit counter expands to the requested output length. Uses PSA
crypto already enabled in zephcore_common.conf. NIST-style
repetition-count + variance health check on jitter samples;
reboot on degenerate output. ~280ms one-time cost at first boot.
LoRa radio TRNG was considered as an additional source but rejected
on expert advice — radio sources are attacker-influenceable
(jamming/spoofing).
ui-joystick BLE passkey — switch from sys_rand32_get (non-crypto
xoshiro) to sys_csrand_get. The 6-digit passkey is the MITM
protection the rest of the BLE config enforces; predictable PINs
weaken it.
Identity reserved-prefix loop — replace the silent 10-attempt cap
(which committed whatever it had on fall-through) with a
bounded-retry-then-reboot pattern.
Also: fix a pre-existing scope bug at main_companion.cpp:357 in
the MESH_EVENT_PREFS_DIRTY handler — data_store was referenced
inside mesh_event_loop() but declared 50+ lines later. Moved the
call into a forward-declared helper defined after the statics.
Unrelated to crypto work but uncovered during build verification;
every companion build was broken.
Single ZephyrUSBCDC module owns the usbd context, 1200-baud DFU
detection, and DTR transitions for both roles. The boot banner
now blocks on a k_event signalled by the usbd_msg_callback when
DTR transitions high — host attached → wakes immediately; no host
→ bounded timeout (2 s repeater, 1 s companion). Replaces the
fixed k_sleep delays in both mains.
Deletes the companion's 10 s DTR-polling work — line state changes
arrive as events now, same callback handles disconnect (resets V3
parser, flips active_iface) and DFU touch (reboots to bootloader).
Side effect: prod companion no longer enumerates a phantom CDC ACM
port (CONFIG_LOG=n skips the whole stack instead of auto-initing
an unused device).
- track real RTC sync source on the joystick Time screen
- gate joystick-only UI helpers behind stub headers so non-joystick builds skip the extra code without #ifdef at every call site.
After broadcasting a group message, wait 5s to see if any neighbor
repeated the flood and use the outcome to mark the on-device entry
and the BLE-app mirror.
- ContentionTracker gets extractDupeCount(hash): finds the tracked
entry, captures dupe_count, finalizes (folds into EMA, marks
inactive), returns the count or -1.
- BaseChatMesh::sendGroupMessage gains an optional out_hash param;
when set, the FNV-1a packet hash is also pre-registered with the
contention tracker so heard retransmits get counted (originated
floods weren't tracked before, only relays).
- Mesh::getContentionTracker() promoted to public so the UI can
query after the feedback window.
JoystickUITask grows a 4-slot pending-channel table with per-slot
k_timer (5s one-shot). startPendingChannel() broadcasts, adds the
local _ch_previews entry with path_len = OUT_PATH_SENT, and starts
the feedback timer. The timer ISR sets a feedback_due flag; the
loop's processPendingChannelFeedback() picks it up, calls
extractDupeCount(), and rewrites the preview's path_len to
OUT_PATH_SENT_HEARD (0xFD) or OUT_PATH_SENT_UNHEARD (0xFC) — which
formatHopCount renders as "sent+" / "sent?".
The deferred BLE-app mirror queues only on outcome with body prefix
"(>>✓) " (heard) or "(>>✗) " (not heard). queueLocalSentChannelMessage
gains a heard_repeat parameter for the selection.
Both channel send entry points (sendComposedMessage's channel branch
and sendChannelMessage) now route through startPendingChannel().