Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ jobs:

- uses: dtolnay/rust-toolchain@stable
with:
targets: thumbv8m.main-none-eabihf,riscv32imac-unknown-none-elf,riscv32imc-unknown-none-elf,thumbv6m-none-eabi
targets: thumbv8m.main-none-eabihf,riscv32imac-unknown-none-elf,riscv32imc-unknown-none-elf,thumbv6m-none-eabi,wasm32-wasip1
components: clippy, llvm-tools

- uses: Swatinem/rust-cache@v2
Expand Down Expand Up @@ -320,6 +320,38 @@ jobs:
- name: Build the leaf node standalone with default features
run: cargo build --package offline-protocol-leaf --locked

# The leaf on a WebAssembly host, on the WASI target. Two halves are
# pinned. The `no_std` half builds with no `bare-metal-rng`, because on
# `wasm32-wasip1` getrandom's backend is chosen by the target ahead of
# any feature and reads the runtime's `random_get`: the host's entropy
# is what the MLS library draws, and there is no symbol for the host to
# register. The `std` half builds through the host shim example, which
# is the shape a runtime would run, so the example cannot rot into a
# file nobody compiles.
#
# `wasm32-unknown-unknown` is deliberately not claimed. On that target
# the pinned mls-rs enables getrandom's `js` feature, and getrandom 0.2
# selects `js` ahead of `custom`, so a host that registered its own
# entropy backend would supply a symbol nothing calls and the module
# would import browser glue. A green build there proves nothing about a
# non-browser host, which is why this job builds the one target where
# the claim is true and no other.
- name: Build the leaf node for WASI
run: >
cargo build --package offline-protocol-leaf
--no-default-features --locked --target wasm32-wasip1

- name: Clippy the leaf node for WASI
run: >
cargo clippy --package offline-protocol-leaf
--no-default-features --locked --target wasm32-wasip1
-- -D warnings

- name: Build the WASI host shim example
run: >
cargo build --package offline-protocol-leaf --example wasi_host_shim
--locked --target wasm32-wasip1

# `embedded-footprint` has its own workspace, so the repo's Clippy job
# does not reach it. The leaf configurations are linted individually
# because each is a separate link with a different feature set.
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,18 @@ archived by series under [docs/changelog/](docs/changelog/); see the
package's job. Two mesh controller tests were failing: they registered two
peers in a mesh with room for four, so the eviction they assert was never
weighed. They now fill the mesh, as their Kotlin twins have since #120.
- **The leaf node builds for WASI, and CI gates it.** `offline-protocol-leaf`
compiles for `wasm32-wasip1` in both halves: without default features,
where `getrandom` reads the runtime's `random_get` and the firmware-style
`bare-metal-rng` feature is not used, and with `std` through the new
`wasi_host_shim` example, which drives a device from a runtime over its
standard streams with the time and the frames supplied by the host. The
claim is WASI only: `wasm32-unknown-unknown` is not gated, because there
the pinned MLS library enables `getrandom`'s `js` feature ahead of any
host-registered backend, so a green build on that target proves nothing
about a non-browser host. This is a compile result plus a native run of
the shim; nothing has run under a WebAssembly runtime.

- **Services on the LAN, and the first Python service wrappers.** A new
specification chapter, `docs/spec/dns-sd-mapping.md`, lays a
`ServiceDescriptor` out as a DNS-SD instance under the subtype
Expand Down
15 changes: 13 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ cargo bench --package offline-protocol-bench
# halves; nothing else in the workspace compiles without `std`, so a stray
# `use std::` in one of them only fails here. So does an mls-rs error
# formatted with `{}`: mls-rs implements Display only under std.
# CI gates four targets: the Cortex-M33, RISC-V with atomics (ESP32-C6/H2),
# and two with no compare-and-swap at all (ESP32-C3/C2 and Cortex-M0).
# CI gates four bare-metal targets: the Cortex-M33, RISC-V with atomics
# (ESP32-C6/H2), and two with no compare-and-swap at all (ESP32-C3/C2 and
# Cortex-M0), plus WASI for the leaf (below).
# CI also runs `cargo build` on each, because clippy never reaches the atomics
# fallbacks' inline assembly; swap `clippy` for `build` to match it exactly.
TARGETS=(thumbv8m.main-none-eabihf riscv32imac-unknown-none-elf
Expand All @@ -84,6 +85,16 @@ for target in "${TARGETS[@]}"; do
cargo clippy -p offline-protocol-leaf --no-default-features \
--features bare-metal-rng --target "$target" -- -D warnings
done
# WASI: the leaf's no_std half with NO bare-metal-rng (the target selects
# getrandom's `random_get` backend ahead of any feature) and its std half
# through the host shim example. wasm32-unknown-unknown is not claimed: there
# mls-rs enables getrandom's `js` feature, which shadows a host-registered
# backend, so a green build proves nothing about a non-browser host.
rustup target add wasm32-wasip1
cargo build -p offline-protocol-leaf --no-default-features --target wasm32-wasip1
cargo clippy -p offline-protocol-leaf --no-default-features \
--target wasm32-wasip1 -- -D warnings
cargo build -p offline-protocol-leaf --example wasi_host_shim --target wasm32-wasip1
./tools/embedded-footprint/measure.sh # flash/RAM cost of the protocol layer
```

Expand Down
33 changes: 27 additions & 6 deletions crates/offline-protocol-leaf/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,19 @@ std = [
"thiserror/std",
]

# Selects `getrandom`'s custom backend, which a bare-metal target needs
# Enables `getrandom`'s custom backend, which a bare-metal target needs
# because `getrandom` has no backend for one and refuses to compile without
# this. Enabling it does **not** supply randomness: the firmware must register
# an implementation (`getrandom::register_custom_getrandom!`) wired to the
# part's hardware entropy source, and MLS key generation is exactly as strong
# as what that returns.
#
# Deliberately not implied by anything. Turning it on under `std` would replace
# the operating system's entropy with a symbol that is probably not defined,
# and the failure would be a link error at best and a predictable key at worst.
# It is not a selection. `getrandom` tries the target's own backend first and
# `custom` last, so on a target that already has one (an operating system,
# WASI) this feature changes nothing, and on a target with none it is the
# only route and the firmware owes the implementation. Deliberately not
# implied by anything, so that a bare-metal build names the obligation it
# takes on.
bare-metal-rng = ["dep:getrandom", "getrandom/custom"]

# Every dependency below is declared locally rather than inherited with
Expand Down Expand Up @@ -101,10 +104,28 @@ getrandom = { version = "0.2", default-features = false, optional = true }
[target.'cfg(not(target_has_atomic = "ptr"))'.dependencies]
portable-atomic-util = { version = "0.2", default-features = false, features = ["alloc"] }

# The tests' JSON, with `std` for the fixtures.
[dev-dependencies]
serde_json = { version = "1.0", default-features = false, features = ["std"] }

# The phone side, so the tests are a real OpenMLS peer talking to this
# crate's mls-rs one rather than this crate talking to itself. `cargo test`
# here is the only place in the workspace where the two implementations meet
# inside one process; `tools/mls-interop` is the other, out of process.
[dev-dependencies]
#
# Absent on WebAssembly, on purpose. An example is built with the crate's
# dev-dependencies, and OpenMLS does not compile for `wasm32-wasip1` (its key
# package lifetime reads a browser timer crate its `js` feature would supply),
# so an unconditional dev-dependency would stop the WASI host shim from
# building at all. The interop test that needs the phone gates itself off the
# same targets; nothing else here reads it.
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
offline-protocol-mls = { path = "../offline-protocol-mls", version = "0.27.0" }
serde_json = { version = "1.0", default-features = false, features = ["std"] }

# The host shim for a WebAssembly runtime (`examples/wasi_host_shim.rs`). It
# needs `std` for its standard streams, so `required-features` makes the
# bare-metal and `no_std` WASI builds skip it rather than fail on it, and CI
# builds it for `wasm32-wasip1` in its own step so it cannot rot.
[[example]]
name = "wasi_host_shim"
required-features = ["std"]
2 changes: 2 additions & 0 deletions crates/offline-protocol-leaf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ The last column was checked once by hand, with a scratch firmware crate that dep

Compiling is all this table claims. Nothing here has been linked into a firmware image or run on a board, and a device still owes a radio, a flash driver behind `LeafStore`, a hardware entropy source behind `getrandom`, and a time source at pairing.

The crate also builds for a WebAssembly host, on the WASI target `wasm32-wasip1`, and CI gates both halves there: the `no_std` build with no `bare-metal-rng`, because on WASI `getrandom` reads the runtime's `random_get` and there is no symbol for the host to register, and the `std` build through [`examples/wasi_host_shim.rs`](examples/wasi_host_shim.rs), a shim that takes the time and the frames from the runtime over its standard streams. `wasm32-unknown-unknown` is not claimed: on that target the pinned mls-rs enables `getrandom`'s `js` feature, which is selected ahead of a host-registered backend, so a green build there says nothing about a non-browser host. Entropy on WASI is the runtime's, and every key is exactly as strong as the runtime's source.

This crate is for firmware. Applications on a phone want the main [`offline-protocol`](https://crates.io/crates/offline-protocol) crate instead, or the [React Native](https://www.npmjs.com/package/@offline-protocol/mesh-sdk) or [Python](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/bindings/python/README.md) bindings.

## License
Expand Down
187 changes: 187 additions & 0 deletions crates/offline-protocol-leaf/examples/wasi_host_shim.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//! A WASI host shim for the leaf node.
//!
//! The leaf is written for firmware: it reads no clock, draws no entropy on
//! its own account, and owns no radio. Those are the host's, and on a chip the
//! host is the firmware around the crate. This example is the same crate with
//! a WebAssembly runtime as the host. The runtime hands in the time and the
//! frames, and takes back the frames the device owes, over WASI's standard
//! streams. Nothing in it is WASI-specific, so the same source also builds and
//! runs natively, which is how its output was recorded.
//!
//! # Build and run
//!
//! ```text
//! rustup target add wasm32-wasip1
//! cargo build -p offline-protocol-leaf --example wasi_host_shim --target wasm32-wasip1 --release
//! wasmtime run target/wasm32-wasip1/release/examples/wasi_host_shim.wasm \
//! com.example.lock off1qyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyr4s29s 1787314332 < frames.jsonl
//! ```
//!
//! Natively: `cargo run -p offline-protocol-leaf --example wasi_host_shim --
//! com.example.lock <peer address> <now_unix_secs> < frames.jsonl`.
//!
//! The arguments are the application id the device is provisioned under, the
//! address of the phone it is pairing with, and the current time in seconds
//! since the epoch. Standard input carries frames from that phone, one JSON
//! message per line, and may be empty.
//!
//! # What it does
//!
//! 1. Opens a device in a [`MemoryStore`], provisioning an identity on the
//! first run, and prints its address to standard error.
//! 2. Mints the key package frame for the peer at the supplied time and prints
//! it to standard output, one JSON frame per line. This is the frame a
//! device advertises to pair.
//! 3. Hands every frame on standard input to [`LeafDevice::handle`] at the
//! same time, prints the frames it owes in reply to standard output and the
//! events it raised to standard error. A frame the device refuses is
//! reported and skipped, as a device keeps running when a peer sends
//! something it does not accept. A line that is not a frame at all ends
//! the run: that is the host's stream being wrong, not the peer's.
//! 4. At the end of input, seals one message to the peer. When no Welcome from
//! the phone was among the frames, the crate refuses with `NoSession`, and
//! the shim prints that refusal. A sealed frame exists only for a session
//! that exists, and this example does not fake a peer to produce one.
//!
//! # What this deliberately does not show
//!
//! - **A pairing completing.** That takes a phone: the phone creates the group
//! from the key package and issues the Welcome, and this crate never commits
//! (ADR 0021). The two ends meet in the crate's `phone_interop` test and in
//! `tools/mls-interop`.
//! - **Durable storage.** [`MemoryStore`] forgets everything at exit. A host
//! implements [`offline_protocol_leaf::LeafStore`] over storage that is
//! atomic per entry, because a ratchet state rolled back by a crash reuses
//! an AEAD nonce, and the crate persists before it emits precisely so that
//! a durable store makes that impossible.
//! - **A clock.** WASI has one (`clock_time_get`), and the leaf never reads
//! it. The time is an argument here because it is the host's obligation on
//! every target: a device that lets the MLS library find a clock it does not
//! have stamps 1970 and has its key package refused as expired.
//! - **Authorization.** A session proves which address a peer is, never that
//! the owner meant them. Deciding when to accept a pairing and what a peer
//! may actuate is the host's, on a runtime as on a chip.
//!
//! # Entropy
//!
//! On WASI, `getrandom` reads the runtime's `random_get`. The device's
//! identity and every MLS key are exactly as strong as what that call returns,
//! and nothing in the crate can tell a strong source from a weak one. A
//! runtime configured to seed `random_get` deterministically, as some do for
//! reproducible runs, produces a predictable identity. The provisioning
//! specification's obligation that entropy is real applies to the runtime
//! here as it applies to a measurement stub on a bench: neither may reach a
//! deployment, and the runtime's source is what has to be audited first.
//!
//! # Why WASI, and not `wasm32-unknown-unknown`
//!
//! On the browser target the pinned MLS library enables `getrandom`'s `js`
//! feature, and `getrandom` 0.2 selects `js` ahead of `custom`. A host that
//! registered its own entropy backend there would supply a symbol nothing
//! calls, and the module would import browser glue instead. On WASI the
//! backend is chosen by the target, ahead of both features, so the host's
//! entropy is what the library reads. That is the claim CI pins, and the only
//! WebAssembly claim this crate makes.
//!
//! This example needs the crate's `std` feature for its standard streams.
//! The crate's `no_std` half is built for the same target by CI, separately,
//! because a host may prefer to drive it through its own exports rather than
//! through a process.

use std::io::{self, BufRead, Write};
use std::process::ExitCode;

use offline_protocol_core::Message;
use offline_protocol_leaf::{shared_store, LeafDevice, LeafError, MemoryStore};

const USAGE: &str = "usage: wasi_host_shim <app_id> <peer_address> <now_unix_secs> < frames.jsonl";

/// What the device says to the peer once a session exists.
const GREETING: &str = "hello from the leaf";

fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("wasi_host_shim: {message}");
ExitCode::FAILURE
}
}
}

fn run() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let [app_id, peer, now] = args.as_slice() else {
return Err(USAGE.to_string());
};
let now_unix_secs: u64 = now
.parse()
.map_err(|_| format!("now_unix_secs must be a whole number of seconds, got {now:?}"))?;

// `shared_store` rather than `Arc::new`: it is the one constructor that
// builds on every target the crate supports, so it is the route to copy.
let store = shared_store(MemoryStore::new());

// Provisioning draws from the getrandom backend the target selects. On
// WASI that is the runtime's `random_get`; see the header.
let mut device =
LeafDevice::open(store, app_id).map_err(|error| format!("opening the device: {error}"))?;
eprintln!("address {}", device.address());

let stdout = io::stdout();
let mut out = stdout.lock();

// Pairing starts with the device's key package. The time is supplied, not
// read: with `now_unix_secs` the package's validity window is real, and
// without it the library would stamp 1970.
let advertisement = device
.key_package_frame(peer, now_unix_secs)
.map_err(|error| format!("minting the key package: {error}"))?;
emit(&mut out, &advertisement)?;

// Steady state: frames arrive, the device verifies each, opens it if it is
// sealed, persists, and only then hands back what it owes in reply.
for (index, line) in io::stdin().lock().lines().enumerate() {
let line = line.map_err(|error| format!("reading standard input: {error}"))?;
if line.trim().is_empty() {
continue;
}
let inbound = Message::from_json(&line)
.map_err(|error| format!("line {}: not a frame: {error}", index + 1))?;
match device.handle(&inbound, now_unix_secs) {
Ok(handled) => {
for event in &handled.events {
eprintln!("event {event:?}");
}
for frame in &handled.outbound {
emit(&mut out, frame)?;
}
}
Err(error) => eprintln!("line {}: refused: {error}", index + 1),
}
}

// Answering. The persist is inside `seal`, before it returns anything, so
// there is no ordering here to get wrong. Without the phone's Welcome
// there is no session, and the crate says so rather than sealing into a
// group that does not exist.
match device.seal(peer, GREETING, now_unix_secs) {
Ok(frame) => emit(&mut out, &frame),
Err(LeafError::NoSession(_)) => {
eprintln!(
"no session with {peer}: nothing sealed. A session exists only after the \
phone's Welcome, and no frame on standard input carried one."
);
Ok(())
}
Err(error) => Err(format!("sealing to {peer}: {error}")),
}
}

/// Writes one frame as one line of JSON.
fn emit(out: &mut impl Write, frame: &Message) -> Result<(), String> {
let json = frame
.to_json()
.map_err(|error| format!("encoding a frame: {error}"))?;
writeln!(out, "{json}").map_err(|error| format!("writing standard output: {error}"))
}
4 changes: 4 additions & 0 deletions crates/offline-protocol-leaf/tests/phone_interop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
//! `tools/mls-interop` covers the same pair out of process and pins the
//! library versions. This file covers the choreography that sits above them:
//! the gates, the reset sequence, and the persist-before-emit rule.
//!
//! Absent on WebAssembly: the phone side does not build there, and the
//! crate's manifest leaves it out of the WASI configuration for that reason.
#![cfg(not(target_arch = "wasm32"))]

use std::sync::{
atomic::{AtomicBool, Ordering},
Expand Down
Loading
Loading