Files
firmware/src/platform/portduino/wasm
Clive BlackledgeandClaude Opus 5 389559bddb fix(NodeDB): re-derive my_node_num when ensurePkiKeys() mints the identity keypair (#11426)
* fix(pki): re-derive NodeNum when setting a region mints the identity key

A node's mesh address is derived from its identity key:

    my_node_num == crc32Buffer(config.security.public_key.bytes, 32)

NodeDB::createNewIdentity() is what establishes that, and NodeDB::
generateCryptoKeyPair() is the only thing that called it.

CryptoEngine::ensurePkiKeys() generates or re-derives the keypair and writes
security.public_key, security.private_key and user.public_key - but never
re-derives my_node_num. Boot-time keygen is suppressed while the LoRa region is
UNSET (generateCryptoKeyPair()'s regionBlocksKeygen guard), so on a fresh device
my_node_num is still the MAC-derived value from pickNewNodeNum(). The user then
sets the region - the stock onboarding flow - ensurePkiKeys() mints a key, and
the invariant is broken.

The node then signs its broadcasts (Router.cpp signs when !pki_encrypted &&
(owner.is_licensed || isBroadcast(p->to))). Every receiver runs
verifyFirstContactNodeInfo, fails crc32Buffer(user.public_key) != p->from, and
drops the NodeInfo. The node's identity beacons are invisible to the mesh.

Nothing reboots to repair it: AdminModule sets requiresReboot = false for LoRa
changes ("All LoRa radio changes apply live via configChanged observer") and
MenuHandler ends at service->reloadConfig(changes).

Four call sites reached ensurePkiKeys():

  1. AdminModule set_config LORA, region first set   (phone app - the common path)
  2. MenuHandler applyLoraRegion                     (on-device region picker)
  3. InkHUD MenuApplet applyLoRaRegion               (schedules a reboot, so it
                                                      self-healed at next boot)
  4. portduino wasm wasm_set_region

The reference implementation was already in the tree: the *licensed* branch of
call site 1, thirteen lines below the broken unlicensed one, calls
nodeDB->generateCryptoKeyPair() (which reaches createNewIdentity()) and widens
the persisted mask with SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE.

Rather than repeat that at four call sites, the key-mint is routed through one
chokepoint that owns both halves of the identity: NodeDB::ensurePkiIdentity()
calls crypto->ensurePkiKeys() and then createNewIdentity(). It lives in NodeDB
because createNewIdentity() operates on the devicestate/node-DB globals, which
CryptoEngine deliberately does not touch - ensurePkiKeys() takes the security
config and user by reference precisely so it stays free of that dependency, and
it is unit-tested against a standalone CryptoEngine.

ensurePkiIdentity() returns true only when my_node_num actually moved
(createNewIdentity() early-returns when the key is unchanged, so a repeat region
change does not disturb the self entry or force a needless flash write). Callers
use that to widen their save mask; my_node_num lives in devicestate and the self
row moves in the node DB, so both segments must be persisted or the fix would
revert at the next boot. SEGMENT_CONFIG, which carries the key itself, is
already unconditional on all four paths.

The InkHUD reboot is left as-is. It is now redundant for this invariant, but it
covers the rest of that menu's behaviour and a redundant reboot is not a bug.

Adds test_handleSetConfig_persistsUnlicensedFirstRegionIdentity, the unlicensed
twin of the existing licensed test, asserting both the segment mask and
my_node_num == crc32(public_key).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(NodeDB): trim identity-recovery comments and guard the WASM nodeDB deref

Two review asks, no behaviour change on any built target.

Copilot flagged the unguarded nodeDB deref in the WASM region setter; it is the
only ensurePkiIdentity() call site that did not check the pointer first.

The rest is comment length. AGENTS.md:83 caps code comments at two lines, and the
identity-recovery comments across the four call sites plus the NodeDB.h doc block
ran to four and six lines. The rationale they carried is in the commit messages
and the PR body, which is where AGENTS.md says it belongs.

The PR's own fix in AdminModule.cpp is deliberately untouched.

* fix(NodeDB): keep the identity move authoritative when the self record cannot be created

createNewIdentity() removes the old node entry and assigns myNodeInfo.my_node_num
before it tries to create the row for the new number. If getOrCreateMeshNode()
came back null it returned false, so the first-region callers left
SEGMENT_DEVICESTATE and SEGMENT_NODEDATABASE out of the save mask.

The number had already moved in RAM at that point, and the freshly minted key
goes to flash under SEGMENT_CONFIG regardless. The next boot therefore reloads
the old number alongside the new key, which is exactly the
crc32(public_key) != my_node_num break this path exists to prevent, reached
through the error branch instead of the happy one.

Rolling the number back is not an option either, since the key has already been
replaced by the time this runs. So the move is now reported as the fact it is and
the missing self record is logged separately; getOrCreateMeshNode() will recreate
that row on the next contact. Reachable when the self record is absent and the
table is full of protected nodes.

Reported by CodeRabbit on #11426.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:23:02 +00:00
..
…
…

ARCH_PORTDUINO_WASM - meshtasticd in WebAssembly (LoRa over WebUSB)

Builds the full portduino firmware (setup()/loop()) to WebAssembly with Emscripten, so a real Meshtastic node runs in a browser tab (or headless Node) and drives a LoRa radio over WebUSB through a CH341 USB-to-SPI bridge - the same Ch341Hal path the desktop meshtasticd uses, with the libusb backend swapped for a WebUSB one. The desktop/native portduino build is untouched.

Layout (this dir is excluded from the native PlatformIO build_src_filter)

file role
portduino_glue_wasm.cpp LoRa config (MeshToad default + wasm_set_lora_* setters, no YAML), VFS mount, region/MAC helpers, and the wasm_api_* PhoneAPI bridge
portduino_main_wasm.cpp wasm_setup() / wasm_loop_once() - JS drives the cooperative loop
libpinedio_webusb.c WebUSB libpinedio backend (sync C ↔ async WebUSB via Asyncify EM_ASYNC_JS)
include/libpinedio-usb.h the 12-fn libpinedio API the backend implements
stubs/ argp.h shim + jsoncpp serializer stub (MQTT-only, excluded)
js/ the WebUSB runtime: bridge.js (implements the C backend's imports), ch341.js (CH341 transport), protocol.js (framing)

In-tree, six firmware sources carry small #ifdef ARCH_PORTDUINO_WASM guards (single-threaded cooperative sleep, continuous RX, region default, RNG, etc.): src/main.cpp, src/mesh/{NodeDB,SX126xInterface,InterfacesTemplates,LR11x0Interface,HardwareRNG}.cpp, src/platform/portduino/PortduinoGlue.cpp. None affect non-wasm builds.

Build

This is a normal PlatformIO env ([env:native-wasm]) built with the meshtastic/platform-wasm platform (emcc/em++), exactly like any other board target.

Prereq: an Emscripten SDK on PATH - source <emsdk>/emsdk_env.sh (or export EMSDK=<path>) so the platform builder can locate emcc.

pio run -e native-wasm            # emcc compile + Asyncify link
pio run -e native-wasm -t clean   # wipe the build dir

Output: .pio/build/native-wasm/meshnode.mjs + meshnode.wasm (ES module, Asyncify, factory createMeshNode, exports _wasm_setup, _wasm_loop_once, _wasm_fs_sync, _wasm_set_region, _wasm_api_to_radio, _wasm_api_from_radio, _wasm_api_available, _wasm_api_is_connected, the _wasm_set_lora_* setters).

Run

The C backend imports webusb_* functions; js/bridge.js implements them on top of js/ch341.js. Minimal host flow:

import createMeshNode from "./meshnode.mjs";
import { CH341 } from "./js/ch341.js";
import { createCH341Bridge } from "./js/bridge.js";

const dev = (await CH341.request()).device; // WebUSB device picker (Chromium)
const Module = await createMeshNode({ noInitialRun: true });
Module.ch341 = createCH341Bridge(Module, dev); // wire WebUSB before boot
await Module.ccall("wasm_setup", null, [], [], { async: true });
const pump = async () => {
  await Module.ccall("wasm_loop_once", "number", [], [], { async: true });
  setTimeout(pump, 5);
};
pump();

API control: feed a ToRadio protobuf with wasm_api_to_radio(ptr,len) and drain FromRadio with wasm_api_from_radio(out,max) - the firmware's own PhoneAPI, unframed. The official @meshtastic/core SDK drives it through a ~40-line in-process transport (see the meshtasticd-wasm-node repo, which hosts the dev server, the SDK-UI page, the headless node-usb runner, and the TCP :4403 bridge for the Python CLI). WebUSB is Chromium-only.

Reboot: the firmware can't restart itself in wasm, so a reboot (admin/phone command, factory reset, or the 60 s stuck-TX watchdog) hands off to the host. In a browser it calls location.reload() - NodeDB state survives via IDBFS, so the node comes back with the same identity. Headless, provide a Module.onReboot callback to handle it (re-instantiate the module, process.exit() for a supervisor to restart, etc.); without one it just logs and keeps running.

const Module = await createMeshNode({ noInitialRun: true });
Module.onReboot = () => process.exit(0); // optional; headless restart policy