diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b63bb4f0d..86d7ae8fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7155bd5..7054a18ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 0c22d9a66..49a7497df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 ``` diff --git a/crates/offline-protocol-leaf/Cargo.toml b/crates/offline-protocol-leaf/Cargo.toml index 02ba09e35..f41a21ac8 100644 --- a/crates/offline-protocol-leaf/Cargo.toml +++ b/crates/offline-protocol-leaf/Cargo.toml @@ -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 @@ -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"] diff --git a/crates/offline-protocol-leaf/README.md b/crates/offline-protocol-leaf/README.md index d2c922e95..c88643aef 100644 --- a/crates/offline-protocol-leaf/README.md +++ b/crates/offline-protocol-leaf/README.md @@ -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 diff --git a/crates/offline-protocol-leaf/examples/wasi_host_shim.rs b/crates/offline-protocol-leaf/examples/wasi_host_shim.rs new file mode 100644 index 000000000..1d8fc8aeb --- /dev/null +++ b/crates/offline-protocol-leaf/examples/wasi_host_shim.rs @@ -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 < 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 < 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 = 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}")) +} diff --git a/crates/offline-protocol-leaf/tests/phone_interop.rs b/crates/offline-protocol-leaf/tests/phone_interop.rs index 2a82dca8b..fbc6d2955 100644 --- a/crates/offline-protocol-leaf/tests/phone_interop.rs +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -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}, diff --git a/docs/adr/0021-a-leaf-node-speaks-mls.md b/docs/adr/0021-a-leaf-node-speaks-mls.md index fe13b1d26..34d733dc3 100644 --- a/docs/adr/0021-a-leaf-node-speaks-mls.md +++ b/docs/adr/0021-a-leaf-node-speaks-mls.md @@ -271,6 +271,13 @@ time source at pairing, durable state before emission, and an entropy source that is real. They are written down here because they are invisible in a passing build and expensive on a bench. +The same crate is the leaf on a WebAssembly host, on the WASI target only: +there `getrandom` reads the runtime's `random_get`, so the entropy obligation +moves to the runtime unchanged. `wasm32-unknown-unknown` is not claimed, +because on that target the pinned MLS library enables `getrandom`'s `js` +feature, which is selected ahead of a host-registered backend, so a passing +build there says nothing about a non-browser host. + Two risks are accepted with open eyes. mls-rs has not had a third-party security audit and its only `no_std` crypto provider is the one its own authors label experimental; this is a monitored dependency, not a settled one. And an interop