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.
- 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.
The phone app interprets path_len with an unconditional `& 63`, so
the OUT_PATH_SENT (0xFE) sentinel we were writing into the
offline-queue frame became "62 hops" + 4 bogus path bytes in the
app's UI. There are 0 LoRa hops between the sender (us) and the
phone viewing the message — path_len = 0 is both correct and
renders cleanly as "direct / 0 hops". The "(>>✓) " / "(>>✗) " body
prefix still distinguishes wio-originated messages from incoming.
OUT_PATH_SENT remains the marker for the joystick UI's own local
entries (UnreadScreen, _ch_previews) — formatHopCount handles it
explicitly so it's safe there.
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().
Outgoing DMs now go through a 4-slot pending-send table with per-slot
one-shot k_timer. On no-ACK the message retries (up to 5 attempts,
0-indexed); attempt 4 clears recipient.out_path_len + markContactsDirty
so the last try forces flood and future DMs re-discover the path.
ACK dispatch: CompanionMesh::processAck tries _ack_table first
(phone-initiated sends), then ui_joystick_try_match_ack() for joystick-
initiated sends, then falls through to connection-keepalive ACKs.
BLE-app mirror is now deferred until outcome is known. The body prefix
in the offline-queue frame is "(>>✓) " on delivery or "(>>✗) " on
failure, replacing the previous unconditional "(>>) ". UnreadScreen's
sent-entry origin prefix is updated in place via markSentEntryStatus()
to "(>>+) " / "(>>X) " (ASCII for the OLED font).
queueLocalSentContactMessage gains a 'delivered' parameter to pick the
prefix; markContactsDirtyPublic() exposes the lazy-write trigger so
the joystick's path-clear persists.
BaseChatMesh::sendGroupMessage wraps the body as "<sender_name>: <body>"
before transmitting (the receiver's onChannelMessageRecv sees the full
"USER: MSG" string, which CompanionMesh queues as-is for the phone).
queueLocalSentChannelMessage was queuing just the raw body, so the
phone parsed an empty sender and lost the body. Prepend
"<prefs.node_name>: " to mirror the wire format.
joystick UI: prefix DM mirrors to phone with sent-marker
DM offline-queue frames identify the sender by pubkey field, not by
in-text prefix. queueLocalSentContactMessage was queuing with the
contact's pubkey + raw body, so the phone app rendered wio-originated
DMs identically to incoming ones from that contact. Prepend "(>>) "
to the body so sent messages are visually distinguishable; matches
the joystick UI's own sent indicator (UnreadScreen::addPreview).
- Lock overlay: show battery % + unread count between title and unlock
sequence; ui_invalidate_battery_cache() on screen wake forces a fresh
ADC sample so the user sees current data immediately
- UnreadScreen::addPreview gains initially_read; received msgs pass
_ble_connected (don't count unread when phone is syncing); sent msgs
pass true (you sent it, you know it)
- CompanionMesh::queueLocalSent{Contact,Channel}Message + PUSH_CODE_MSG_WAITING
on wio-originated sends so a connected phone app sees them via the
normal offline-queue flow (path_len = OUT_PATH_SENT marker)
- OUT_PATH_SENT moved back from joystick_defs.h to ContactInfo.h
(now a wire-format value, not UI-only)
- #ifdef-gate _pending_joystick_{ping,admin}_tag fields + setters
under CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK (saves 8 bytes per
CompanionMesh instance on button-UI builds)
- gate logTx ui_notify_packet_sent() to joystick builds only;
was firing on every TX for any UI variant (dead code on button UI)
- drop redundant _pending_login manual set in CMD_SEND_LOGIN;
BaseChatMesh::sendLogin's onLoginSent hook owns it now, just
clear the other pending fields explicitly
Three small correctness/polish improvements from BLE audit Phase 2F:
1. Five handlers (CMD_APP_START, CMD_GET_CHANNEL, CMD_SET_CHANNEL,
CMD_DEVICE_QUERY, CMD_SEND_CHANNEL_TXT_MSG) previously responded
with ERR_UNSUPPORTED on short-frame validation failure (because
they fell through to the dispatcher's default break, which the
caller converts to "unknown command"). They now explicitly
sendPacketError(ERR_ILLEGAL_ARG) — the semantically correct code
for "known cmd, bad frame".
2. CMD_SET_TUNING_PARAMS previously returned PACKET_OK on short
frames without applying any change. Now sends ERR_ILLEGAL_ARG so
the phone learns the change didn't take.
3. Added static_assert that CONFIG_ZEPHCORE_BOARD_NAME fits in 40
bytes including its null terminator, so a future too-long board
name fails at build time instead of producing an unterminated
wire-format response.
The wire format reserves a 32-byte name field; if the phone sends 32
non-null bytes, ContactInfo::name has no terminator. Subsequent
LOG_INF/LOG_DBG sites using %s with contact.name then read past the
field into adjacent struct bytes (type, flags, out_path_len, ...)
until the first null. No memory corruption — serializeContact uses
StrHelper::strzcpy which is length-bounded — but log output gets
garbage and a paired peer could probe a few bytes of the struct
through log capture.
Sibling handler CMD_SET_CHANNEL at :1593-1594 already does this
defensively. Match the pattern.
Two polish items from BLE audit Phase 2B:
1. CMD_SEND_TELEMETRY_REQ self-response buffer was uint8_t rsp[96]
with a comment claiming 70 B worst case. Actual worst case at
POWER_MAX_CHANNELS=4 is 82 B; if the channel cap ever grew the
buffer would silently overflow. Replaced with a sizeof-style
expression that tracks POWER_MAX_CHANNELS, plus an 8-byte safety
pad. No size change today (90 vs. 96) but the upper bound auto-
tracks any future bump.
2. CMD_GET_CUSTOM_VARS used `dp += snprintf(dp, 20, ...)` which
advances by the would-be-written length, not bytes actually
written. Currently safe only because gps_interval is capped
≤86400, but if either cap drifted or a new key was added the
length passed to writeFrame would include uninitialized stack
bytes between the truncation point and the (over-advanced) dp.
Now tracks rsp_end, computes remaining per snprintf, and only
advances dp on real progress.
Both are correctness polish, not exploitable today.
Both mesh::Packet::writePath and ::copyPath did a raw memcpy of the
decoded hash_count*hash_size bytes from src to dest with no bound on
src. Two call sites used phone-supplied or LoRa-anon-supplied buffers
where the path_len byte was attacker-controlled:
- CompanionMesh CMD_SEND_CHANNEL_DATA accepted len>=4 and called
writePath with no src bound; a paired phone could leak up to ~65
bytes of syswq stack into the outgoing LoRa channel-data frame.
- RepeaterMesh handleAnonRegionsReq / handleAnonOwnerReq /
handleAnonClockReq read reply_path_len from an unauthenticated
LoRa anon-request payload and called copyPath without any src
bound. Any LoRa neighbor could leak repeater stack into the
reply path.
Hardened the API: both functions now require an explicit src_len
and reject (return 0) when the decoded byte count exceeds it.
Updated all 14 call sites across Packet/Mesh/Dispatcher/BaseChatMesh/
CompanionMesh/RepeaterMesh. Trusted callers (internal MAX_PATH_SIZE
buffers) pass MAX_PATH_SIZE; untrusted callers pass real remaining
length. Added len-5 plumbing through the anon-handler signatures.
CMD_SEND_CHANNEL_DATA also gained a local len>=5 + path_bytes
sanity check for early rejection.
1. USB takeover opcode mismatch
ZephyrCompanionUSB.cpp checked payload[0] == 0x03 with a comment
claiming CMD_APP_START, but CMD_APP_START is 0x01 (0x03 is
CMD_SEND_CHANNEL_TXT_MSG). The USB handshake silently dropped the
companion app's first frame on every connection; the app appeared
broken over USB until the user happened to send a channel message.
2. CMD_SET_ADVERT_NAME didn't propagate to BLE adv data
Name changes were persisted to prefs but the advertising payload
and GATT device name kept the old value until reboot. Added
zephcore_ble_update_name() and called it from the handler.
3. No advertising-health watchdog
If bt_le_adv_start() ever failed transiently (HCI timeout,
controller pacing), the device would silently stop advertising
and stay undiscoverable until reboot. Added an adv_running flag
and a 5s watchdog in the companion housekeeping handler that
nudges adv back on if it stops outside a connection. Tracks
Arduino nrf52's equivalent 10s watchdog.
- flood retransmit jitter now capped at min(2000ms, 6·airtime) instead of
fixed 2000ms — spreads tighter at SF7, unchanged at SF8
- reactive per-dupe backoff cap now min(2000ms, 12·airtime), keeps
semantic of "push past ~12 relay slots"
- contention ring 16 → 24 for 50-neighbor hilltops
- companions passively track heard floods (warms EMA without forwarding)
and spread their own TX by up to min(1000ms, 3·airtime), hopefully
fixing repeaters missing companion's first transmission
config cleanup:
- move BLE TX buffer bumps (ACL_TX=12 etc.) from zephcore_common.conf to
esp32_common.conf — the Espressif blob needs them, nRF doesn't, and
the bumps were overflowing nRF52840 RAM
- remove CONFIG_ZEPHCORE_MAX_CONTACTS=510 overrides from 5 nRF52840
companion boards; Kconfig default of 350 fits with comfortable margin
(wio prod: 91% → 79% RAM)