Skip to main content

wasi_host_shim/
wasi_host_shim.rs

1//! A WASI host shim for the leaf node.
2//!
3//! The leaf is written for firmware: it reads no clock, draws no entropy on
4//! its own account, and owns no radio. Those are the host's, and on a chip the
5//! host is the firmware around the crate. This example is the same crate with
6//! a WebAssembly runtime as the host. The runtime hands in the time and the
7//! frames, and takes back the frames the device owes, over WASI's standard
8//! streams. Nothing in it is WASI-specific, so the same source also builds and
9//! runs natively, which is how its output was recorded.
10//!
11//! # Build and run
12//!
13//! ```text
14//! rustup target add wasm32-wasip1
15//! cargo build -p offline-protocol-leaf --example wasi_host_shim --target wasm32-wasip1 --release
16//! wasmtime run target/wasm32-wasip1/release/examples/wasi_host_shim.wasm \
17//!     com.example.lock off1qyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyr4s29s 1787314332 < frames.jsonl
18//! ```
19//!
20//! Natively: `cargo run -p offline-protocol-leaf --example wasi_host_shim --
21//! com.example.lock <peer address> <now_unix_secs> < frames.jsonl`.
22//!
23//! The arguments are the application id the device is provisioned under, the
24//! address of the phone it is pairing with, and the current time in seconds
25//! since the epoch. Standard input carries frames from that phone, one JSON
26//! message per line, and may be empty.
27//!
28//! # What it does
29//!
30//! 1. Opens a device in a [`MemoryStore`], provisioning an identity on the
31//!    first run, and prints its address to standard error.
32//! 2. Mints the key package frame for the peer at the supplied time and prints
33//!    it to standard output, one JSON frame per line. This is the frame a
34//!    device advertises to pair.
35//! 3. Hands every frame on standard input to [`LeafDevice::handle`] at the
36//!    same time, prints the frames it owes in reply to standard output and the
37//!    events it raised to standard error. A frame the device refuses is
38//!    reported and skipped, as a device keeps running when a peer sends
39//!    something it does not accept. A line that is not a frame at all ends
40//!    the run: that is the host's stream being wrong, not the peer's.
41//! 4. At the end of input, seals one message to the peer. When no Welcome from
42//!    the phone was among the frames, the crate refuses with `NoSession`, and
43//!    the shim prints that refusal. A sealed frame exists only for a session
44//!    that exists, and this example does not fake a peer to produce one.
45//!
46//! # What this deliberately does not show
47//!
48//! - **A pairing completing.** That takes a phone: the phone creates the group
49//!   from the key package and issues the Welcome, and this crate never commits
50//!   (ADR 0021). The two ends meet in the crate's `phone_interop` test and in
51//!   `tools/mls-interop`.
52//! - **Durable storage.** [`MemoryStore`] forgets everything at exit. A host
53//!   implements [`offline_protocol_leaf::LeafStore`] over storage that is
54//!   atomic per entry, because a ratchet state rolled back by a crash reuses
55//!   an AEAD nonce, and the crate persists before it emits precisely so that
56//!   a durable store makes that impossible.
57//! - **A clock.** WASI has one (`clock_time_get`), and the leaf never reads
58//!   it. The time is an argument here because it is the host's obligation on
59//!   every target: a device that lets the MLS library find a clock it does not
60//!   have stamps 1970 and has its key package refused as expired.
61//! - **Authorization.** A session proves which address a peer is, never that
62//!   the owner meant them. Deciding when to accept a pairing and what a peer
63//!   may actuate is the host's, on a runtime as on a chip.
64//!
65//! # Entropy
66//!
67//! On WASI, `getrandom` reads the runtime's `random_get`. The device's
68//! identity and every MLS key are exactly as strong as what that call returns,
69//! and nothing in the crate can tell a strong source from a weak one. A
70//! runtime configured to seed `random_get` deterministically, as some do for
71//! reproducible runs, produces a predictable identity. The provisioning
72//! specification's obligation that entropy is real applies to the runtime
73//! here as it applies to a measurement stub on a bench: neither may reach a
74//! deployment, and the runtime's source is what has to be audited first.
75//!
76//! # Why WASI, and not `wasm32-unknown-unknown`
77//!
78//! On the browser target the pinned MLS library enables `getrandom`'s `js`
79//! feature, and `getrandom` 0.2 selects `js` ahead of `custom`. A host that
80//! registered its own entropy backend there would supply a symbol nothing
81//! calls, and the module would import browser glue instead. On WASI the
82//! backend is chosen by the target, ahead of both features, so the host's
83//! entropy is what the library reads. That is the claim CI pins, and the only
84//! WebAssembly claim this crate makes.
85//!
86//! This example needs the crate's `std` feature for its standard streams.
87//! The crate's `no_std` half is built for the same target by CI, separately,
88//! because a host may prefer to drive it through its own exports rather than
89//! through a process.
90
91use std::io::{self, BufRead, Write};
92use std::process::ExitCode;
93
94use offline_protocol_core::Message;
95use offline_protocol_leaf::{shared_store, LeafDevice, LeafError, MemoryStore};
96
97const USAGE: &str = "usage: wasi_host_shim <app_id> <peer_address> <now_unix_secs> < frames.jsonl";
98
99/// What the device says to the peer once a session exists.
100const GREETING: &str = "hello from the leaf";
101
102fn main() -> ExitCode {
103    match run() {
104        Ok(()) => ExitCode::SUCCESS,
105        Err(message) => {
106            eprintln!("wasi_host_shim: {message}");
107            ExitCode::FAILURE
108        }
109    }
110}
111
112fn run() -> Result<(), String> {
113    let args: Vec<String> = std::env::args().skip(1).collect();
114    let [app_id, peer, now] = args.as_slice() else {
115        return Err(USAGE.to_string());
116    };
117    let now_unix_secs: u64 = now
118        .parse()
119        .map_err(|_| format!("now_unix_secs must be a whole number of seconds, got {now:?}"))?;
120
121    // `shared_store` rather than `Arc::new`: it is the one constructor that
122    // builds on every target the crate supports, so it is the route to copy.
123    let store = shared_store(MemoryStore::new());
124
125    // Provisioning draws from the getrandom backend the target selects. On
126    // WASI that is the runtime's `random_get`; see the header.
127    let mut device =
128        LeafDevice::open(store, app_id).map_err(|error| format!("opening the device: {error}"))?;
129    eprintln!("address {}", device.address());
130
131    let stdout = io::stdout();
132    let mut out = stdout.lock();
133
134    // Pairing starts with the device's key package. The time is supplied, not
135    // read: with `now_unix_secs` the package's validity window is real, and
136    // without it the library would stamp 1970.
137    let advertisement = device
138        .key_package_frame(peer, now_unix_secs)
139        .map_err(|error| format!("minting the key package: {error}"))?;
140    emit(&mut out, &advertisement)?;
141
142    // Steady state: frames arrive, the device verifies each, opens it if it is
143    // sealed, persists, and only then hands back what it owes in reply.
144    for (index, line) in io::stdin().lock().lines().enumerate() {
145        let line = line.map_err(|error| format!("reading standard input: {error}"))?;
146        if line.trim().is_empty() {
147            continue;
148        }
149        let inbound = Message::from_json(&line)
150            .map_err(|error| format!("line {}: not a frame: {error}", index + 1))?;
151        match device.handle(&inbound, now_unix_secs) {
152            Ok(handled) => {
153                for event in &handled.events {
154                    eprintln!("event {event:?}");
155                }
156                for frame in &handled.outbound {
157                    emit(&mut out, frame)?;
158                }
159            }
160            Err(error) => eprintln!("line {}: refused: {error}", index + 1),
161        }
162    }
163
164    // Answering. The persist is inside `seal`, before it returns anything, so
165    // there is no ordering here to get wrong. Without the phone's Welcome
166    // there is no session, and the crate says so rather than sealing into a
167    // group that does not exist.
168    match device.seal(peer, GREETING, now_unix_secs) {
169        Ok(frame) => emit(&mut out, &frame),
170        Err(LeafError::NoSession(_)) => {
171            eprintln!(
172                "no session with {peer}: nothing sealed. A session exists only after the \
173                 phone's Welcome, and no frame on standard input carried one."
174            );
175            Ok(())
176        }
177        Err(error) => Err(format!("sealing to {peer}: {error}")),
178    }
179}
180
181/// Writes one frame as one line of JSON.
182fn emit(out: &mut impl Write, frame: &Message) -> Result<(), String> {
183    let json = frame
184        .to_json()
185        .map_err(|error| format!("encoding a frame: {error}"))?;
186    writeln!(out, "{json}").map_err(|error| format!("writing standard output: {error}"))
187}