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}