Blog
Engineering

SDK 0.28: the leaf node compiles for ESP32 RISC-V, Cortex-M0 and WASI

SDK 0.28: the leaf node compiles for ESP32 RISC-V, Cortex-M0 and WASI

TL;DR

SDK 0.28.0 makes offline-protocol-leaf, a no-OS firmware crate running RFC 9420 MLS, build in CI for ESP32 RISC-V parts, every Cortex-M0 and WASI. Nothing has run on a board yet, and ESP32-C3 and ESP32-C2 firmware using esp-hal 1.2.2 does not build.

offline-protocol-leaf is the crate for a device that is not a phone: a door lock, a sensor, a mains-powered relay. Something with a radio, a few hundred kilobytes of flash and no operating system. It parses frames, validates addresses and runs RFC 9420 MLS through mls-rs, so the device holds an end-to-end encrypted conversation with a phone under the same guarantees a phone gets. The frames, the envelope and the trust checks are the ones phones use, with no reduced profile for small devices. It can afford that because a phone paired with one device is a two-member group. The phone creates the group and issues every commit; the device joins, opens, answers and persists, and never commits. The ratchet tree is three nodes, and a commit costs two elliptic-curve operations.

Until 0.28 the crate was built and gated for one family, the Cortex-M33. Version 0.28.0 adds ESP32 RISC-V parts, every Cortex-M0, and a WebAssembly host on WASI. All of these are compile results, and the rest of this post is about the difference between that and a device on a bench.

offline-protocol-leaf build targets in SDK 0.28

CI builds the crate and runs Clippy on it, with warnings as errors, for each of these targets on every change:

Chip familyRust targetBuilds in CIInside an esp-hal 1.2.2 firmware
Cortex-M33 (for example Silicon Labs xG24)thumbv8m.main-none-eabihfYesNot applicable
ESP32-C6, ESP32-H2riscv32imac-unknown-none-elfYesType-checks (checked once, by hand)
ESP32-C3, ESP32-C2riscv32imc-unknown-none-elfYesRefused (see below)
Cortex-M0 and M0+ (for example RP2040)thumbv6m-none-eabiYesNot applicable
ESP32, ESP32-S3, ESP32-S2Xtensa, outside rustupNot triedNot tried

Compiling is all the table claims. Nothing in it has been linked into a firmware image or run on a board. A device still needs a radio, a flash driver, a hardware entropy source and a time source, and those are the firmware’s to provide.

The last column is the first thing a firmware team on Espressif parts will ask about. A scratch firmware that depends on esp-hal 1.2.2 and on this crate type-checks for the ESP32-C6 and ESP32-H2. For the ESP32-C3 and ESP32-C2 it does not build at all, and nothing in this crate can fix that today: mls-rs selects portable-atomic’s critical-section backend on those chips, esp-hal selects unsafe-assume-single-core, and portable-atomic refuses to link the two together. The classic ESP32 and the ESP32-S3 are Xtensa parts that need a toolchain outside rustup, and nobody has tried them.

ESP32-C3 and Cortex-M0 support without compare-and-swap

CI now builds the crate for the chips that have no atomic compare-and-swap (the ESP32-C3, the ESP32-C2 and every Cortex-M0), and on those targets the crate takes its reference-counted store handle from portable-atomic-util. On those targets alloc::sync::Arc does not exist, so code that imports it compiles on every laptop and in every test, and fails only when someone builds for the chip. CI runs code generation as well as Clippy for them, since part of the atomics fallback is inline assembly that only code generation reaches.

The crate exports SharedStore and shared_store so firmware does not have to care which handle it got. Where atomics exist, SharedStore is still Arc<dyn LeafStore>, so existing code is unchanged. The crate does not choose an atomics backend, because which one is sound depends on the chip and the firmware. The pinned mls-rs selects critical-section on these chips regardless, so the firmware must link a critical-section implementation.

Depending on it from a firmware crate looks like this:

[dependencies.offline-protocol-leaf]
version = "0.28"
default-features = false
features = ["bare-metal-rng"]

bare-metal-rng lets getrandom compile on a target that has no entropy backend of its own. It does not supply randomness. The firmware registers an implementation wired to the part’s hardware RNG, and MLS key generation is exactly as strong as what that returns. The crate registers none on purpose, so that a firmware cannot link and run on randomness the crate invented.

WASI support and the browser WebAssembly target

The leaf builds for WASI but not for the browser target. It builds for wasm32-wasip1, and CI gates both halves of it. The no_std half builds without bare-metal-rng, because on WASI getrandom reads the runtime’s random_get and there is no symbol for the host to register. The std half builds through a new example, wasi_host_shim, which drives a device from a WebAssembly runtime over its standard streams, with the time and the inbound frames supplied by the host. From a checkout of the SDK repository:

rustup target add wasm32-wasip1
cargo build -p offline-protocol-leaf --example wasi_host_shim \
    --target wasm32-wasip1 --release

wasm32-unknown-unknown, the browser target, is deliberately not claimed. On that target the pinned mls-rs enables getrandom’s browser backend, which getrandom selects ahead of any backend a host registers. A green build there would say nothing about a non-browser host, so CI builds only the target where the claim is true. The WASI result is a compile plus a native run of the shim. Nothing has run under a WebAssembly runtime yet.

Firmware requirements for offline-protocol-leaf

Firmware still has to provide a time source at pairing, real entropy, durable storage and authorization: four obligations the crate cannot discharge for you. The crate is a frame-level state machine. Hand it an inbound frame and the time, and it returns the frames to send and what happened:

use offline_protocol_core::Message;
use offline_protocol_leaf::{LeafDevice, LeafEvent};

fn on_frame(
    device: &mut LeafDevice,
    frame: &Message,
    now: u64,
) -> offline_protocol_leaf::Result<()> {
    let handled = device.handle(frame, now)?;
    for reply in &handled.outbound {
        transmit(reply); // every frame here is already persisted
    }
    for event in handled.events {
        if let LeafEvent::MessageReceived { peer, text } = event {
            // A session proves who `peer` is, not what it may do.
            if allowed_to_unlock(&peer) && text == "unlock" {
                // drive the actuator
            }
        }
    }
    Ok(())
}

Message comes from offline-protocol-core, which a firmware adds the same way, without default features. transmit and allowed_to_unlock are yours. Around that loop, the crate’s documentation lists those four obligations:

  • A time source at pairing. Every entry point that needs the time takes now_unix_secs as a parameter. A device that lets the MLS library read a clock it does not have stamps 1970, and the phone refuses its key package as expired.
  • Real entropy. Wire getrandom to the hardware RNG, as above.
  • Durable storage. A device implements LeafStore over its secure storage, and each write must be atomic. The crate persists every ratchet step before it emits the frame that depends on it, so a store that loses a write after a power cut is the one remaining way to reuse an AEAD nonce.
  • Authorization. Any address in radio range can complete a pairing, because a key that derives to its own address costs nothing to make. Firmware decides when the radio accepts a new pairing and what a given peer may actuate. LeafDevice::peers lists what a device has accumulated, and LeafDevice::unpair removes one.

Running offline-protocol-leaf on real hardware

Nothing has been executed on a board yet. The crate’s tests pair it with a real phone-side implementation, OpenMLS, in one process on a development machine, and a separate tool checks the same pair out of process with pinned library versions. On the Cortex-M33, a harness links the shipping crate into a release image and measures it: on a 1536 KiB xG24 the protocol layer is a little over a quarter of flash, before the radio stack, an RTOS, key storage and the heap. That image is linked and measured, never executed. There is no equivalent measurement for the RISC-V or Cortex-M0 targets yet, because that harness is built for the M33.

So this release is for firmware teams who want to start building on ESP32-C6, ESP32-H2, Cortex-M0 or M33 parts against a crate that CI now compiles for those parts, and who want CI to tell them when a change breaks their target. It is not a device you can pair with a phone today, and we would rather say so here than have you find out on a bench.

The crate is on crates.io. Its README carries the table above and is the place it will change first. The rest of 0.28 is in SDK 0.28 on servers and desktops and SDK 0.28 on iOS and Android, and the full notes are in the 0.28.0 changelog. If you have a board and a product in mind, talk to us.

Frequently asked questions

Can I use offline-protocol-leaf on the ESP32-C3 with esp-hal?

Not today. The crate builds for the ESP32-C3 and ESP32-C2 in CI, but a firmware that depends on esp-hal 1.2.2 and on the crate does not build, because mls-rs selects portable-atomic's critical-section backend, esp-hal selects unsafe-assume-single-core, and portable-atomic refuses to link the two together.

Does offline-protocol-leaf support the classic ESP32 and the ESP32-S3?

Not tried. The classic ESP32, the ESP32-S3 and the ESP32-S2 are Xtensa parts that need a toolchain outside rustup, and nobody has tried them.

Where does offline-protocol-leaf get its randomness on a microcontroller?

From the firmware. The bare-metal-rng feature only lets getrandom compile on a target with no entropy backend; the firmware registers an implementation wired to the part's hardware RNG, and MLS key generation is exactly as strong as what that returns.

Why does the leaf crate not claim wasm32-unknown-unknown?

On that target the pinned mls-rs enables getrandom's browser backend, which getrandom selects ahead of any backend a host registers. A green build there would say nothing about a non-browser host, so CI builds only wasm32-wasip1.

How much flash does the MLS leaf node need on a microcontroller?

On a 1536 KiB Silicon Labs xG24 (Cortex-M33), the protocol layer is a little over a quarter of flash, before the radio stack, an RTOS, key storage and the heap. That image is linked and measured, never executed, and there is no equivalent measurement for the RISC-V or Cortex-M0 targets yet.

Can any device in radio range pair with an offline-protocol-leaf device?

Any address in radio range can complete a pairing, because a key that derives to its own address costs nothing to make. The firmware decides when the radio accepts a new pairing and what a given peer may actuate.

More posts
Engineering Oct 6, 2026

SDK 0.28 on iOS and Android without React Native

Engineering Sep 30, 2026

MINE: coordinating the mesh through the hardware we already own