Introduction

This book is a walk through the Wasefire project.

Vision

Provide a simple-to-use and secure-by-default ecosystem for firmware development.

Simple-to-use

The ecosystem aspires to be accessible to projects and developers regardless of their familiarity with developing secure firmware:

  • No embedded expertise required: Regular software engineers can develop firmware.
  • No security expertise required: The ecosystem provides secure-by-default options.
  • No enforced programming language: Developers may use the language of their choice, as long as it compiles to WebAssembly or the target architecture.
  • No enforced development environment or build system: Developers may use their usual setup.

Secure-by-default

Security is the responsibility of the ecosystem, not the developer. The following security mechanisms are (or are planned to be) in place:

  • Sandboxing of the firmware functions (called applets) from each other and from the firmware runtime (called platform).
  • Secure implementation within the platform boundaries (e.g. side-channel resistance, fault injection protection, etc).
  • Security reviews for the supported boards (e.g. side-channel attacks, fault injection, etc).
  • User documentation when the security must rely on user behavior, for example when a configuration is insecure.

Even though the default is to be secure, the ecosystem will provide options to opt-out from some of the security features for performance or footprint reasons. In other words, the developer is able to choose the trade-off between security and performance, defaulting to security without explicit action.

Symbiosis

Being both simple-to-use and secure-by-default actually goes hand in hand. It cannot be expected for humans to never do mistake, even if they have embedded and security expertise.

  • By being simple to use, developers will prefer using the ecosystem solution rather than implementing their own, thus using a secure-by-default solution rather than a possibly insecure implementation.
  • By not being concerned with security and embedded details, developers can be more productive and focus on the actual firmware behavior, following the well-lit path for all security and embedded questions.

Non-goals

The project is not trying to build a self-service applet store. In particular, users are developers. A self-service applet store may come at a later phase or may be done independently by another project. The project will provide anything needed for such applet store to be secure and easy to use.

Disclaimer

The project is still work in progress and many components might change in the future. However, the project follows the usual Rust semantic versioning to avoid unexpected breakages.

Overview

This chapter gives a high-level overview of the project.

Terminology

The project is split in the following components:

  • A Device is a final product.
  • A User is a person, team, or organization owning the Device.
  • A Board is the hardware of the Device.
  • An Applet is part of the software of the Device.
  • The Board API is the hardware abstraction layer.
  • The Applet API is the platform abstraction layer.
  • A Prelude is a library providing language-specific access to the Applet API.
  • A Runner is a binary implementing the Board API and running the Scheduler.
  • The Scheduler is the software implementing all the platform logic and fits between the Board API and the Applet API.
  • A Platform is the binary made of a Runner and the Scheduler.

Device

A Device encompasses the following (non-exhaustive list):

  • A hardware on which to run (chip, form factor, external devices, etc).
  • How this hardware is configured and initially provisioned.
  • A set of applets defining the firmware, and their configuration.
  • What is the funtionality expected from the firmware.
  • Should the Device be certified.
  • Where will the Device be installed.
  • Who will/should have access to the Device.

User

Users may delegate part of the Device design to other teams:

  • Developing hardware or selecting an existing hardware.
  • Developing one or more applets and/or selecting one or more applets.
  • Design of the functionality.
  • Lifetime management (provisioning, logging, monitoring, alerting, census, etc).

Users are responsible for all those steps. The project will however support them in those tasks for both development and security aspects. For example (non-exhaustive list):

  • The platform provides unique ids per device.
  • The platform supports secure updates.
  • The platform provides applet management (with versioning).
  • The platform supports some existing hardware (may add support for more based on demand).

Board

The project provides support for some boards through Runners. Additional boards may be supported depending on User needs.

Boards may be subject to some restrictions:

  • Only ARM Cortex-M and RISC-V architectures are considered for now.
  • Minimum flash and RAM requirements (to be defined).

Applet

The functionality (or business logic) of a Device is implemented as a set of concurrent applets. The project provides example applets in the different supported languages for the User to use as starting point. Eventually, an applet is compilet into WebAssembly which is the only language that the Scheduler supports.

Board API

The Board API may be partially implemented for 2 reasons:

  • The Board doesn't support some features in hardware and a software implementation is not feasible or desired.
  • The User knows that those features are not going to be used by any applets that will run on the Device. And the space saved by not implementing or building them is needed for the Device to properly function.

Applet API

The Applet API is currently the same as (or very close to) the Board API. This may change in the future, for example if capabilities require it. The Board API is low-level and doesn't have a notion of capabilities. It fully trusts the Scheduler. The Applet API on the other hand may need to prove to the Scheduler that it is allowed to access some dynamically-allocated resource.

The Board API and the Applet API provide portability of Applets across different Boards.

Prelude

The Applet API is defined at the WebAssembly level. Applet developers may use it directly, but depending on the language this usage may not be convenient. For example, in Rust, using the Applet API requires the unsafe keyword and thus some good understanding of what's going on. For such languages, a Prelude is provided to give a simpler and more natural interface to the Applet API. For example, in Rust, the Prelude provides a safe API with high-level Rust-specific types like &[u8] (instead of the u32 pointer and u32 length that the Applet API expects).

Runner

The project provides Runners for supported Boards. However, Board support doesn't need to be provided by the project. The User (for example the team developing the Board) may develop its own Runner for its own Board. Simplifying the development of Runners (by maximizing the code shared with other Runners) is part of the project vision. The project simplifies development of both Runners and Applets, not just Applets.

Scheduler

Together with the Board API and the Applet API, the Scheduler is the core contribution of the project and where most security properties are implemented. This is completely provided by the project and Users cannot alter it. However, they can configure it when configuring a Platform.

Platform

This is the firmware that runs on the Device. It doesn't provide any business logic, but provides some core functionalities like (non-exhaustive list):

  • Secure updates.
  • Applet management.
  • Debugging facilities.

Features

Features are implemented if their checkbox is checked.

If a feature is not implemented, it doesn't mean it will get implemented. It means this is a feature that could be implemented if there is a user need.

If a feature is not listed, it doesn't mean it won't get implemented. We may just not be aware of it, and a user need could justify an implementation.

Supported boards

A board is supported if it has a Runner.

  • Linux (for testing without hardware)
  • nRF52840
  • OpenTitan

Supported applet languages

An applet language is supported if it has a Prelude.

  • Rust
  • C

Note that when running multiple applets concurrently on the same platform, those applets don't need to be written in the same language to inter-operate.

Developer experience

For applets

  • Development doesn't require hardware (using the Linux board).
  • Testing facilities (probably on any board).
  • Fuzzing facilities (probably on Linux board only).
  • Rich debugger experience (probably on any board).

For runners

  • Testing facilities (probably a set of test applets).

Reproducible builds

  • Hermetic development environment for applets.
  • Hermetic development environment for platforms.

Secure platform upgrades

  • The platform can be upgraded.
  • The platform can be downgraded to the extent permitted by the User-configured rollback policy.
  • Platform upgrades are digitally signed and verified.

Applet sandboxing

  • Applets can't tamper with the platform.
  • Applets can't tamper with other applets (this is only missing preemptive concurrency).

Applet capabilities

  • Applets declare their permissions (i.e. function imports).
  • Applets declare their capabilities (more dynamic concept of permission).
  • Applets metadata (or manifest) is signed.

Platform side-channel attack testing and resistance

  • Crypto hardware accelerators are leveraged when available.
    • AES CCM (Bluetooth spec) on nRF52840
  • Otherwise fallback software crypto primitives are provided for main algorithms.
  • Both of those implementations are side-channel attack resilient.

Applet portability

  • Applets are portable at binary level (comes from Wasm and APIs).

Applet multiplexing

  • Multiple applets may be installed at the same time.
  • Multiple applets may run simultaneously (not in early versions).
  • Applets can be installed without running.
  • Applets define in their metadata their running condition (e.g. at boot, at USB, at idle, etc).

For now, a single applet is baked at compile-time in the platform.

Applet management

  • Applets are identified by a stable id, a version, and a digital signature (verified by the runtime).
  • Applets may be installed if not already present.
  • Applets may be uninstalled in which case all owned resources are deleted.
  • Applets may be upgraded (preserving resources) but not downgraded (probably modulo rollback policy).
  • Installed applets can be listed.

Certification

  • The runtime can run on certified hardware (FIPS-140-3 and CC).
  • TBD: The runtime might sustain being part of the security target for certification.

Low power

  • If the runtime is only waiting on external hardware events, the CPU is suspended.

Quick start

Please open an issue if a step doesn't work.

Repository setup

Local machine

Clone the repository and run the setup script1:

git clone https://github.com/google/wasefire
cd wasefire
./scripts/setup.sh

Github codespace

Run an applet

Depending on your hardware, you can run the hello applet with:

  • cargo xtask applet rust hello runner nordic to run on an nRF52840 dev-kit.
  • cargo xtask applet rust hello runner host to run on your desktop.
  • cargo xtask applet rust hello runner host --web to run on your desktop with a web UI.

The general format is cargo xtask applet LANGUAGE NAME runner BOARD. Example applets are listed in the examples directory by language then name. Boards are listed under the crates directory and are prefixed by runner-. You can find more details using cargo xtask help.

Feel free to stop and play around by directly editing the examples. Or continue reading for a deeper tutorial on how to write applets in Rust.

1

The setup script is best effort and is only meant for Unix systems so far. On Debian-based Linux systems, the script will directly invoke installation commands, possibly asking for sudo password. On other systems, the script will return an error and print a message each time a binary or library must be installed. Once the package is installed, the script must be run again. The script will eventually print nothing and return a success code indicating that no manual step is required and everything seems in order.

Github codespace tips

Editing an applet

For rust-analyzer to work properly, you need to open the applet in its own workspace, for example:

code examples/rust/exercises/part-1

However, this will also modify the working directory of the terminal. To be able to run cargo xtask, you will need to go back to the root of the git repository:

cd /workspaces/wasefire

You can then open the src/lib.rs file and benefit from documentation (hovering a name), auto-completion, diagnostics, and other rust-analyzer features.

Applet user guide

This chapter describes all you need to know to write an applet. Because we currently only support Rust for writing applets, this guide only describes Rust usage.

You need to have set the repository up according to the quick start instructions to be able to continue.

Create a new applet

This step will eventually be a simple wasefire new <applet-name> command out of tree. But for now we will build the applet within the project repository as an example applet. We'll use tutorial as the applet name throughout this tutorial.

You have 2 options to create and populate the applet directory. We'll go over both for pedagogical reasons.

Copy the hello applet

The first step is to copy the hello directory to the tutorial directory:

cp -r examples/rust/hello examples/rust/tutorial

The second step is to update the applet name in the Cargo.toml:

sed -i 's/hello/tutorial/' examples/rust/tutorial/Cargo.toml

Create the applet from scratch

Create the tutorial directory:

mkdir examples/rust/tutorial

Create the Cargo.toml file in the created directory with the following content:

[package]
name = "tutorial"
version = "0.1.0"
edition = "2021"

[dependencies]
wasefire = "*" # use the latest version

The wasefire dependency provides a high-level interface to the Applet API.

Then create the src/lib.rs file in the created directory with the following content:

#![no_std] // needed for building wasm (without wasi)
wasefire::applet!(); // imports the prelude and defines main as entry point

fn main() {
    debug!("hello world");
}

Note that because you need to use core or alloc instead of std.

Run an applet

We currently use cargo xtask as an alias to the local xtask crate to build, flash, and run platforms and applets. Eventually, this will be a wasefire command and will work out-of-tree. You can use cargo xtask help to discover the tool.

On host

We can run an applet (here the tutorial applet) on the host runner with the following command:

cargo xtask applet rust tutorial runner host

Type your password when asked. The host runner needs sudo to set USB/IP up, which is needed for applets that use USB. This is disabled in Github Codespace. After a bunch of compilation steps, you should see something that ends like:

     Running `.../wasefire/target/release/runner-host`
Executing: sudo modprobe vhci-hcd
[sudo] password for user:
Executing: sudo usbip attach -r localhost -b 1-1
Board initialized. Starting scheduler.
00000.000: hello world

The first line is output by cargo. The last 2 lines are output by the host runner. The last one was triggered by the applet. Debugging output is prefixed with a timestamp.

The host runner (like all runners) doesn't stop, even if all applets have completed. Instead, it goes to sleep. This is because all known use-cases are reactor-like (they react to external input). Besides, if the platform has applet management enabled, then the platform is ready to execute applet management commands. However, if there is a use-case that needs to shutdown, then the API or scheduler will be extended to provide this functionality.

Use Ctrl-C to terminate the runner. For hardware boards, you can just remove the power or let it run. The device is in sleep state (although if USB is enabled, then it wakes up every millisecond to keep the connection active).

On board

We currently only support the nRF52840-dk board from Nordic. If you have such a dev board, you can run an applet (here the tutorial applet) on the nordic runner with the following command:

cargo xtask applet rust tutorial runner nordic

After a bunch of compilation steps, you should see something that ends like:

".../wrapper.sh" "probe-rs" "run" "--chip=nRF52840_xxAA" "target/.../runner-nordic"
     Erasing sectors [00:00:05] [################################]
 Programming pages   [00:00:04] [################################]
0.090527: hello world

The first line is from cargo xtask. The rest is from probe-rs run. The last line is triggered by the applet. Debugging output is prefixed by a timestamp in seconds.

API

The applet API is documented here.

Note that you probably don't want to use the API directly, but instead want to use the prelude of your programming language. The API is low-level and corresponds to the interface at WebAssembly level.

Prelude

This chapter illustrates how to use some parts of the prelude.

The prelude documentation is available here.

LEDs

In this section, we will walk through the blink example in Rust. It will blink in order each LED of the board every second in an infinite loop (going back to the first LED after the last LED).

The number of LEDs available on the board is advertised by the led::count() function. We want to make sure there is at least one LED available:

    // Make sure there is at least one LED.
    let num_leds = led::count();
    assert!(num_leds > 0, "Board has no LEDs.");

We start the infinite loop cycling through all LEDs in order:

    // Cycle indefinitely through all LEDs in order.
    for led_index in (0 .. num_leds).cycle() {

Within the infinite loop (notice the indentation), we first turn on the current LED using the led::set() function:

        // Turn on the current LED.
        led::set(led_index, led::On);

We now wait for half a second because we want to blink each LED for one second which means half a second on and half a second off:

        // Wait before turning it off.
        timer::sleep(Duration::from_millis(500));

Finally, we repeat the same process but turning the LED off before looping to the next LED:

        // Turn it off and wait before turning on the next LED.
        led::set(led_index, led::Off);
        timer::sleep(Duration::from_millis(500));

The final code looks like this:

#![no_std]
wasefire::applet!();

use core::time::Duration;

fn main() {
    // Make sure there is at least one LED.
    let num_leds = led::count();
    assert!(num_leds > 0, "Board has no LEDs.");

    // Cycle indefinitely through all LEDs in order.
    for led_index in (0 .. num_leds).cycle() {
        // Turn on the current LED.
        led::set(led_index, led::On);
        // Wait before turning it off.
        timer::sleep(Duration::from_millis(500));

        // Turn it off and wait before turning on the next LED.
        led::set(led_index, led::Off);
        timer::sleep(Duration::from_millis(500));
    }
}

Testing

The host runner currently has 1 LED and prints its state on stdout as an info-level log. Eventually, the number of LEDs will be configurable and how they are represented will be improved (for example through some graphical interface).

To test the applet on the host runner, you'll thus need to use:

cargo xtask applet rust blink runner host --log=info

The --log=info flag specifies that we want info-level (or more severe) logging. By default, only errors are printed.

Buttons

In this section, we will walk through 2 Rust examples:

  • The button example illustrates stateless usage but lets us introduce how to handle events with callbacks.
  • The led example illustrates stateful usage and thus how to access state in callbacks.

Stateless usage

This example prints to the debug output the new state of a button each time that button changed state and so for all buttons.

Similarly to LEDs, there is a button::count() function to discover the number of buttons available on the board:

    // Make sure there is at least one button.
    let count = button::count();
    assert!(count > 0, "Board has no buttons.");

We want to listen on events for all available buttons, so we loop over all button indices (starting at 0):

    // For each button on the board.
    for index in 0 .. count {

For each button, we define a handler that prints the new button state to the debug output. The handler takes the new button state as argument. Since button::State implements Debug, we simply use {state:?} to print the new state.

        // We define a button handler printing the new state.
        let handler = move |state| debug!("Button {index} has been {state:?}.");

We can now start listening for events. This is done by creating a button::Listener which will call the provided handler each time the button changes state. We specify the button we want to listen to by its index and the handler as a closure.

        // We start listening for state changes with the handler.
        let listener = button::Listener::new(index, handler)?;

A listener continues to listen for events until it is dropped. Since we want to indefinitely listen, we must not drop the listener. A simple way to do that is to leak it. This is equivalent to calling core::mem::forget().

        // We leak the listener to continue listening for events.
        listener.leak();

Finally, we just endlessly wait for callbacks. This step is optional: waiting for callbacks indefinitely is the implicit behavior when main exits. In some way, main can be seen as a callback setup procedure. The scheduling::wait_for_callback() function puts the applet to sleep until a callback is scheduled and scheduled::wait_indefinitely() is just an infinite loop around wait_for_callback().

    // We indefinitely wait for callbacks.
    scheduling::wait_indefinitely();

The final code looks like this:

#![no_std]
wasefire::applet!();

fn main() -> Result<(), Error> {
    // Make sure there is at least one button.
    let count = button::count();
    assert!(count > 0, "Board has no buttons.");

    // For each button on the board.
    for index in 0 .. count {
        // We define a button handler printing the new state.
        let handler = move |state| debug!("Button {index} has been {state:?}.");

        // We start listening for state changes with the handler.
        let listener = button::Listener::new(index, handler)?;

        // We leak the listener to continue listening for events.
        listener.leak();
    }

    // We indefinitely wait for callbacks.
    scheduling::wait_indefinitely();
}

Testing

The host runner currently has 1 button and is controlled by typing button on a line on stdin. Eventually, the number of buttons will be configurable and how they are controlled will be improved (for example through some graphical interface).

Stateful usage

This example combines all the LEDs and buttons available on the board by maintaining a dynamic mapping between them. Initially, all buttons map to the first LED. Each time a button is pressed or released, the LED it is mapped to is toggled. And when a button is released, it maps to the next LED (or the first one if it was mapping to the last one).

In particular:

  • A single button can toggle all LEDs.
  • Multiple buttons can toggle the same LED.
  • A button may stay pressed while another button is pressed.
  • All buttons eventually toggle all LEDs.

We skip over the setup which doesn't illustrate anything new:

    // Make sure there is at least one button.
    let num_buttons = button::count();
    assert!(num_buttons > 0, "Board has no buttons.");

    // Make sure there is at least one LED.
    let num_leds = led::count();
    assert!(num_leds > 0, "Board has no LEDs.");

    // For each button on the board.
    for button_index in 0 .. num_buttons {

Because buttons dynamically map to a LED, we need a state to store this information. We create this state on the heap because we will eventually leak the listener and exit the main function to indefinitely listen for button events. This state simply contains the index of the LED to which this buttons maps to. We have to use Cell because the handler is called as &self (and thus closures must be Fn not FnMut)1.

        // We create the state containing the LED to which this button maps to.
        let led_pointer = Box::new(Cell::new(0));

When defining the button handler, we must move (and thus transfer ownership of) the state we just created to the handler, such that the handler can read and write the state when called.

        // We define the button handler and move the state to there.
        let handler = move |button_state| {

When the handler is called, we first toggle the associated LED:

            // We toggle the LED.
            let led_index = led_pointer.get();
            led::set(led_index, !led::get(led_index));

And then if the button is released, we update the dynamic mapping to point to the next LED (wrapping if needed):

            // If the button is released, we point it to the next LED.
            if matches!(button_state, button::Released) {
                led_pointer.set((led_index + 1) % num_leds);
            }

Finally, we create a button listener with the defined handler. And we leak it to continue listening after it going out of scope and in particular after main returns.

        // We indefinitely listen by creating and leaking a listener.
        button::Listener::new(button_index, handler)?.leak();

The final code looks like this:

#![no_std]
wasefire::applet!();

use alloc::boxed::Box;
use core::cell::Cell;

fn main() -> Result<(), Error> {
    // Make sure there is at least one button.
    let num_buttons = button::count();
    assert!(num_buttons > 0, "Board has no buttons.");

    // Make sure there is at least one LED.
    let num_leds = led::count();
    assert!(num_leds > 0, "Board has no LEDs.");

    // For each button on the board.
    for button_index in 0 .. num_buttons {
        // We create the state containing the LED to which this button maps to.
        let led_pointer = Box::new(Cell::new(0));

        // We define the button handler and move the state to there.
        let handler = move |button_state| {
            // We toggle the LED.
            let led_index = led_pointer.get();
            led::set(led_index, !led::get(led_index));

            // If the button is released, we point it to the next LED.
            if matches!(button_state, button::Released) {
                led_pointer.set((led_index + 1) % num_leds);
            }
        };

        // We indefinitely listen by creating and leaking a listener.
        button::Listener::new(button_index, handler)?.leak();
    }

    Ok(())
}
1

This is because the handler could wait for callbacks itself (which the prelude has no way to know, or is there?) and thus the handler may be reentered. This would essentially copy a mutable reference which is unsound.

Timers

In this section, we will walk through the button_abort example in Rust. It uses the first button and the first LED of the board. On a short press, the LED will start blinking. On a long press, the LED will stop blinking. While the button is pressed, the LED indicates whether the press is short or long:

  • The LED is on while the press is short.
  • The LED turns off once the press is long.

This applet will need a shared state to know whether the LED must be blinking or not. We cannot simply use a boolean because the state will be shared. We cannot use Cell<bool> neither because the state must be in the heap1. So we use Rc<Cell<bool>> which is a common pattern when using callbacks:

    // We define a shared state to decide whether we must blink.
    let blinking = Rc::new(Cell::new(false));

We can now allocate a timer for the blinking behavior using timer::Timer::new. This function takes the handler that will be called each time the timer fires. The handler simply toggles the LED if we must be blinking. Note how we must move a clone of the state to the callback. This is also a common pattern when using callbacks, because callbacks must be 'static1:

    // Allocate a timer for blinking the LED.
    let blink = timer::Timer::new({
        // Move a clone of the state to the callback.
        let blinking = blinking.clone();
        move || {
            // Toggle the LED if blinking.
            if blinking.get() {
                led::set(0, !led::get(0));
            }
        }
    });

The rest of the code is done in an infinite loop:

    loop {

At each iteration, we start by setting up a button::Listener to record whether the button was pressed and then released. The logic is similar to the callback setup for the timer. The small difference is that we won't need to call any function on the listener so we prefix its variable name _button with an underscore. We cannot simply omit the variable name because we don't want to drop it until the end of the loop iteration, otherwise we would stop listening to button events.

        // Setup button listeners.
        let pressed = Rc::new(Cell::new(false));
        let released = Rc::new(Cell::new(false));
        let _button = button::Listener::new(0, {
            let pressed = pressed.clone();
            let released = released.clone();
            move |state| match state {
                button::Pressed => pressed.set(true),
                button::Released if pressed.get() => released.set(true),
                button::Released => (),
            }
        })?;

We then wait until the button is pressed using scheduling::wait_until(). This function takes a condition as argument and only executes callbacks until the condition is satisfied.

        // Wait for the button to be pressed.
        scheduling::wait_until(|| pressed.get());

According to the specification of this example applet, when the button is pressed we must turn on the LED (and stop blinking if we were blinking) to signal a possible short press. Note that callbacks can only executed when a scheduling function is called, so we are essentially in a critical section. As such, the order in which we do those 3 lines doesn't matter. However, a callback might be scheduled before we stop the blink timer. It will execute next time we call a scheduling function. This is ok because when that will happen, the blinking state will be false and the blink handler will do nothing.

        // Turn the LED on.
        blink.stop();
        blinking.set(false);
        led::set(0, led::On);

To detect a long press, we need to start a timer. There is nothing new here except the Timer::start() function which takes the timer mode (one-shot or periodic) and its duration.

        // Start the timeout to decide between short and long press.
        let timed_out = Rc::new(Cell::new(false));
        let timer = timer::Timer::new({
            let timed_out = timed_out.clone();
            move || timed_out.set(true)
        });
        timer.start(timer::Oneshot, Duration::from_secs(1));

We now wait for the first event between the button being released and the timeout firing. This is simply done by using a condition which is a disjunction of the events of interest. This is a common pattern when implementing behavior with a timeout.

        // Wait for the button to be released or the timeout to fire.
        scheduling::wait_until(|| released.get() || timed_out.get());

To signal that the timeout was reached or the button was released, we turn off the LED.

        // Turn the LED off.
        led::set(0, led::Off);

Finally, if the press was short (i.e. the button was released before the timeout), we start blinking. This demonstrates the use of periodic timers.

        // Start blinking if short press.
        if !timed_out.get() {
            blinking.set(true);
            blink.start(timer::Periodic, Duration::from_millis(200));
        }

There are a few things to note:

  • The code is implicit in Rust, but the button handler and the timer handler within the loop iteration are dropped before the next iteration. This means that their callbacks are unregistered. This could be done explicitly by calling core::mem::drop() on their variable if needed.
  • It is not needed to start and stop the blink timer within the loop as long as it is started before entering the loop. This is just an optimization to avoid calling the handler when we know that the blinking shared state is false, because the handler would do nothing in that case.

The final code looks like this:

#![no_std]
wasefire::applet!();

use alloc::rc::Rc;
use core::cell::Cell;
use core::time::Duration;

fn main() -> Result<(), Error> {
    assert!(button::count() > 0, "Board has no buttons.");
    assert!(led::count() > 0, "Board has no LEDs.");

    // We define a shared state to decide whether we must blink.
    let blinking = Rc::new(Cell::new(false));

    // Allocate a timer for blinking the LED.
    let blink = timer::Timer::new({
        // Move a clone of the state to the callback.
        let blinking = blinking.clone();
        move || {
            // Toggle the LED if blinking.
            if blinking.get() {
                led::set(0, !led::get(0));
            }
        }
    });

    loop {
        // Setup button listeners.
        let pressed = Rc::new(Cell::new(false));
        let released = Rc::new(Cell::new(false));
        let _button = button::Listener::new(0, {
            let pressed = pressed.clone();
            let released = released.clone();
            move |state| match state {
                button::Pressed => pressed.set(true),
                button::Released if pressed.get() => released.set(true),
                button::Released => (),
            }
        })?;

        // Wait for the button to be pressed.
        scheduling::wait_until(|| pressed.get());

        // Turn the LED on.
        blink.stop();
        blinking.set(false);
        led::set(0, led::On);

        // Start the timeout to decide between short and long press.
        let timed_out = Rc::new(Cell::new(false));
        let timer = timer::Timer::new({
            let timed_out = timed_out.clone();
            move || timed_out.set(true)
        });
        timer.start(timer::Oneshot, Duration::from_secs(1));

        // Wait for the button to be released or the timeout to fire.
        scheduling::wait_until(|| released.get() || timed_out.get());

        // Turn the LED off.
        led::set(0, led::Off);

        // Start blinking if short press.
        if !timed_out.get() {
            blinking.set(true);
            blink.start(timer::Periodic, Duration::from_millis(200));
        }
    }
}

Testing

As for the LEDs and buttons examples, to test the applet on the host runner, you'll need to use:

cargo xtask applet rust button_abort runner host --log=info

However, in addition to button which does a press and release sequence, you can use press and release to independently press and release the button. In particular, button may be used to start blinking and press may be used to stop blinking. There's no need to explicitly release because the applet supports missing callbacks for robustness.

1

If the state were on the stack and a callback were pointing to that state, it would become a safety requirement to unregister the callback before popping the state from the stack. However, it is safe to leak a callback with core::mem::forget() and thus not drop it. So we enforce callbacks to be 'static and thus not depend on references to the stack.

USB

For now only USB serial is supported. Eventually, the idea would be for applets to describe the USB interfaces they need in some init function. The scheduler would then create the USB device based on those information. And only then start the applets with capabilities to the interfaces they asked for.

In this section, we will illustrate USB serial usage by walking through the memory_game example. The game is essentially an infinite loop of memory questions. The player has 3 seconds to memorize a random base32 string (the length is the current level in the game and thus represents the difficulty). The player than has 7 seconds to type it back. On success they go to the next level, otherwise to the previous level.

The applet has only 2 states across loop iterations:

  • The level of the game (and thus the length of the string to remember) starting at 3.
  • The next prompt to show to the player while they get ready for the next question.
    let mut level = 3; // length of the string to remember
    let mut prompt = "Press ENTER when you are ready.";

Everything else is in the infinite loop:

    loop {

First thing we do is print the prompt and wait for the player to press Enter. We use ANSI escape codes to overwrite whatever was there before. As an invariant throughout the game, we always use a single line of the terminal. This is particularly important to overwrite the question since the player has to guess it. We write to the USB serial using serial::write_all(). This function is generic over objects implementing serial::Serial, in this case usb::serial::UsbSerial.

        serial::write_all(&UsbSerial, format!("\r\x1b[K{prompt}").as_bytes()).unwrap();

We then wait until the player press Enter. We can read a single byte from the USB serial using serial::read_byte(). The terminal sends 0x0d when Enter is pressed.

        // Make sure the player is ready.
        while serial::read_byte(&UsbSerial).unwrap() != 0x0d {}

To generate the next question, we use rng::fill_bytes() which fills a buffer with random bytes. We provide a buffer with the length of the current level. For the string to be printable we truncate the entropy of each byte from 8 to 5 bits and convert it to a base32 symbol.

        // Generate a question for this level.
        let mut question = vec![0; level];
        rng::fill_bytes(&mut question).unwrap();
        for byte in &mut question {
            const BASE32: [u8; 32] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
            *byte = BASE32[(*byte & 0x1f) as usize];
        }
        let mut question = String::from_utf8(question).unwrap();

We can now show the question to the player. We do so using a process helper function that we will also use for the answer. We instantiate this function such that the player has 3 seconds to memorize the question and may hit Enter at any time to start answering.

        // Display the question.
        process(3, "Memorize this", &mut question, |_, x| x == 0x0d);

After 3 seconds have elapsed or if the player hit Enter, we read the answer from the player. We give them 7 seconds to type the answer. We also convert lower-case letters to upper-case for convenience (it's easier to read upper-case but easier to type lower-case). We also support backspace which the terminal sends as 0x7f. And same as for the question, we let the player exit early with Enter to avoid waiting until the timeout.

        // Read the answer.
        let mut answer = String::new();
        process(7, "Type what you remember", &mut answer, |answer, byte| {
            match byte {
                b'A' ..= b'Z' | b'2' ..= b'7' => answer.push(byte as char),
                b'a' ..= b'z' => answer.push(byte.to_ascii_uppercase() as char),
                0x7f => drop(answer.pop()),
                0x0d => return true,
                _ => (),
            }
            false
        });

Once we have the answer, we check if it matches the question. If it does, we promote the player to the next level. If it doesn't, we demote the player to the previous level. However, if there are no previous level because the player is at level 1, then we let them retry the level to show our support. We use ANSI escape codes to highlight the result.

        // Check the answer.
        if answer == question {
            level += 1;
            prompt = "\x1b[1;32mPromotion!\x1b[m Press ENTER for next level.";
        } else if level > 1 {
            level -= 1;
            prompt = "\x1b[1;31mDemotion...\x1b[m Press ENTER for previous level.";
        } else {
            prompt = "\x1b[1;41mRetry?\x1b[m Press ENTER to retry.";
        }

Now that we're done with the main loop, let's look at the process helper. It takes 4 arguments:

  • max_secs: usize: the maximum display time in seconds.
  • prompt: &str: the message shown at the beginning of the line.
  • data: &mut String: the data shown after the prompt, which may be updated (see below).
  • update: impl Fn(&mut String, u8) -> bool: the closure called on each input byte possibly updating the data and returning whether processing should end immediately without waiting for the maximum display time.
fn process(
    max_secs: usize, prompt: &str, data: &mut String, update: impl Fn(&mut String, u8) -> bool,
) {

The helper counts the number of elapsed seconds in shared variable secs and updates it using a periodic timer every second.

    let secs = Rc::new(Cell::new(0));
    let timer = timer::Timer::new({
        let time = secs.clone();
        move || time.set(time.get() + 1)
    });
    timer.start(timer::Periodic, Duration::from_secs(1));

The helper loops as long as the update function didn't say to stop (tracked by the done variable) and there is still time available.

    let mut done = false;
    while !done && secs.get() < max_secs {

We update the line in the terminal with the prompt, time left, and current data. We use ANSI escape codes to highlight the data and help readability.

        let secs = max_secs - secs.get();
        let message = format!("\r\x1b[K{prompt} ({secs} seconds remaining): \x1b[1m{data}\x1b[m");
        serial::write_all(&UsbSerial, message.as_bytes()).unwrap();

To be able to update the time left in the terminal we must read from the USB serial asynchronously using serial::Reader. We create a reader by providing a mutable buffer to which the reader will write the received bytes.

        let mut buffer = [0; 8];
        let reader = serial::Reader::new(&UsbSerial, &mut buffer);

We then sleep until a callback is executed using scheduling::wait_for_callback(). This callback may either be the timer firing the next second or the reader getting input from the USB serial.

        scheduling::wait_for_callback();

We call Reader::result() to know how many bytes were read from USB serial and written to the buffer (or if an error occurred). We then simply iterate over the received bytes and update the data and early exit status according to the provided closure. Same as with timers, when a reader is dropped, its callback is canceled.

        let len = reader.result().unwrap();
        for &byte in &buffer[.. len] {
            done |= update(data, byte);
        }

The final code looks like this:

#![no_std]
wasefire::applet!();

use alloc::rc::Rc;
use alloc::string::String;
use alloc::{format, vec};
use core::cell::Cell;
use core::time::Duration;

use wasefire::usb::serial::UsbSerial;

fn main() {
    let mut level = 3; // length of the string to remember
    let mut prompt = "Press ENTER when you are ready.";
    loop {
        serial::write_all(&UsbSerial, format!("\r\x1b[K{prompt}").as_bytes()).unwrap();

        // Make sure the player is ready.
        while serial::read_byte(&UsbSerial).unwrap() != 0x0d {}

        // Generate a question for this level.
        let mut question = vec![0; level];
        rng::fill_bytes(&mut question).unwrap();
        for byte in &mut question {
            const BASE32: [u8; 32] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
            *byte = BASE32[(*byte & 0x1f) as usize];
        }
        let mut question = String::from_utf8(question).unwrap();

        // Display the question.
        process(3, "Memorize this", &mut question, |_, x| x == 0x0d);

        // Read the answer.
        let mut answer = String::new();
        process(7, "Type what you remember", &mut answer, |answer, byte| {
            match byte {
                b'A' ..= b'Z' | b'2' ..= b'7' => answer.push(byte as char),
                b'a' ..= b'z' => answer.push(byte.to_ascii_uppercase() as char),
                0x7f => drop(answer.pop()),
                0x0d => return true,
                _ => (),
            }
            false
        });

        // Check the answer.
        if answer == question {
            level += 1;
            prompt = "\x1b[1;32mPromotion!\x1b[m Press ENTER for next level.";
        } else if level > 1 {
            level -= 1;
            prompt = "\x1b[1;31mDemotion...\x1b[m Press ENTER for previous level.";
        } else {
            prompt = "\x1b[1;41mRetry?\x1b[m Press ENTER to retry.";
        }
    }
}

fn process(
    max_secs: usize, prompt: &str, data: &mut String, update: impl Fn(&mut String, u8) -> bool,
) {
    let secs = Rc::new(Cell::new(0));
    let timer = timer::Timer::new({
        let time = secs.clone();
        move || time.set(time.get() + 1)
    });
    timer.start(timer::Periodic, Duration::from_secs(1));
    let mut done = false;
    while !done && secs.get() < max_secs {
        let secs = max_secs - secs.get();
        let message = format!("\r\x1b[K{prompt} ({secs} seconds remaining): \x1b[1m{data}\x1b[m");
        serial::write_all(&UsbSerial, message.as_bytes()).unwrap();
        let mut buffer = [0; 8];
        let reader = serial::Reader::new(&UsbSerial, &mut buffer);
        scheduling::wait_for_callback();
        let len = reader.result().unwrap();
        for &byte in &buffer[.. len] {
            done |= update(data, byte);
        }
    }
}

You can connect to the USB serial with the following command:

picocom -q /dev/ttyACM1

UART

Using the UART is similar to using the USB serial, because the uart::Uart::new(uart_id) object implements serial::Serial, where uart_id is the UART id. The uart::count() function returns how many UARTs are available on the device. UART ids must be smaller than this count.

It is usually a good idea to write generic code over any serial without assuming a particular implementation. This can be done by using a serial variable implementing serial::Serial. This variable may be instantiated differently based on a compilation feature:

#[cfg(feature = "serial_uart")]
let serial = uart::Uart::new(0).unwrap();
#[cfg(feature = "serial_usb")]
let serial = usb::serial::UsbSerial;
// ...
serial::write_all(&serial, b"hello").unwrap();

When using the host runner, you can connect to the UART with:

socat -,cfmakeraw UNIX-CONNECT:target/wasefire/uart0

Storage

For now only a key-value store is supported for persistent storage. Eventually, additional facilities may be added: a cyclic logging journal, a file-system, raw flash access, etc.

In this section, we will illustrate the key-value store usage by walking through the store example which provides direct store access through USB serial.

We first define a helper to write a line to the USB serial.

fn writeln(buf: &[u8]) {
    serial::write_all(&UsbSerial, buf).unwrap();
    serial::write_all(&UsbSerial, b"\r\n").unwrap();
}

Because values may be at most 1023 bytes, there is a system to store large entries as multiple fragments of at most 1023 bytes each using multiple keys. To support those large entries, we define an abstract notion of keys. An abstract key is either exactly one key, or a contiguous range of keys.

enum Key {
    Exact(usize),
    Range(Range<usize>),
}

We then define helpers to dispatch to the regular or fragmented version based on the abstract key. The insert, find, and remove functions will be explained later.

fn insert(key: &Key, value: &[u8]) -> Result<(), Error> {
    match key {
        Key::Exact(key) => store::insert(*key, value),
        Key::Range(keys) => store::fragment::insert(keys.clone(), value),
    }
}

fn find(key: &Key) -> Result<Option<Box<[u8]>>, Error> {
    match key {
        Key::Exact(key) => store::find(*key),
        Key::Range(keys) => store::fragment::find(keys.clone()),
    }
}

fn remove(key: &Key) -> Result<(), Error> {
    match key {
        Key::Exact(key) => store::remove(*key),
        Key::Range(keys) => store::fragment::remove(keys.clone()),
    }
}

The first thing we do when the applet starts is print a short help describing how to use the applet.

fn main() {
    writeln(b"Usage: insert <key>[..<key>] <value>");
    writeln(b"Usage: find <key>[..<key>]");
    writeln(b"Usage: remove <key>[..<key>]");

We can then start the infinite loop processing exactly one command per iteration.

    loop {

We start a loop iteration by reading a command from the user. Only space, lower-case alphabetic characters, digits, and backspace are supported. We exit as soon as the user hits Enter.

        // Read the command.
        let mut command = String::new();
        loop {
            serial::write_all(&UsbSerial, format!("\r\x1b[K> {command}").as_bytes()).unwrap();
            match serial::read_byte(&UsbSerial).unwrap() {
                c @ (b' ' | b'.' | b'a' ..= b'z' | b'0' ..= b'9') => command.push(c as char),
                0x7f => drop(command.pop()),
                0x0d => break,
                _ => (),
            }
        }
        serial::write_all(&UsbSerial, b"\r\n").unwrap();

We then parse the command (described later). If the command is invalid, we print a message and continue to the next loop iteration.

        // Parse the command.
        let command = match Command::parse(&command) {
            Some(x) => x,
            None => {
                writeln(b"Failed: InvalidCommand");
                continue;
            }
        };

And we finally process the command (described later). If processing failed, we print a message with the error. Regardless of error, this is the end of the loop (and thus the main function) and we continue to the next iteration.

        // Process the command.
        if let Err(error) = command.process() {
            writeln(format!("Failed: {error:?}").as_bytes());
        }

To ease parsing and processing, we define a straightforward type for commands.

enum Command<'a> {
    Insert { key: Key, value: &'a str },
    Find { key: Key },
    Remove { key: Key },
}

The parsing function is also straightforward.

impl<'a> Command<'a> {
    fn parse(input: &'a str) -> Option<Self> {
        Some(match *input.split_whitespace().collect::<Vec<_>>().as_slice() {
            ["insert", key, value] => Command::Insert { key: Key::parse(key)?, value },
            ["find", key] => Command::Find { key: Key::parse(key)? },
            ["remove", key] => Command::Remove { key: Key::parse(key)? },
            _ => return None,
        })
    }

The process function is a Command method which may return a store error.

    fn process(&self) -> Result<(), Error> {

For insert commands, we simply forward to the store::insert() function (resp. store::fragment::insert() for fragmented entries) which maps a key (resp. a range of keys) to a value. If the key (resp. range of keys) was already mapped, it is overwritten. A key must be a number smaller than 4096. A range of keys must be non-empty. A value must be a slice of at most 1023 bytes (resp. 1023 bytes times the number of fragments).

        match self {
            Command::Insert { key, value } => insert(key, value.as_bytes()),

Remove commands are also straightforward. We use store::remove() which maps a key to nothing. It's not an error if the key wasn't mapped before.

            Command::Remove { key } => remove(key),

Finally, find commands are implemented using store::find() which takes a key and return the mapped value if any.

            Command::Find { key } => {
                match find(key)? {

We print a message if no value was found.

                    None => writeln(b"Not found."),

Otherwise, we try to convert the byte slice to a string slice. This should succeed for values that were inserted by this applet since we only accept alphanumeric characters. In that case, we simply print the value.

                    Some(value) => match core::str::from_utf8(&value) {
                        Ok(value) => writeln(format!("Found: {value}").as_bytes()),

However, because the store is persistent and keys are not yet partitioned by applets, we could read the values written by a previous applet for that key. And those values don't need to be valid UTF-8. In those cases, we print the value as a byte slice.

                        Err(_) => writeln(format!("Found (not UTF-8): {value:02x?}").as_bytes()),

The final code looks like this:

#![no_std]
wasefire::applet!();

use alloc::boxed::Box;
use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;
use core::num::ParseIntError;
use core::ops::Range;
use core::str::FromStr;

use wasefire::usb::serial::UsbSerial;

fn main() {
    writeln(b"Usage: insert <key>[..<key>] <value>");
    writeln(b"Usage: find <key>[..<key>]");
    writeln(b"Usage: remove <key>[..<key>]");
    loop {
        // Read the command.
        let mut command = String::new();
        loop {
            serial::write_all(&UsbSerial, format!("\r\x1b[K> {command}").as_bytes()).unwrap();
            match serial::read_byte(&UsbSerial).unwrap() {
                c @ (b' ' | b'.' | b'a' ..= b'z' | b'0' ..= b'9') => command.push(c as char),
                0x7f => drop(command.pop()),
                0x0d => break,
                _ => (),
            }
        }
        serial::write_all(&UsbSerial, b"\r\n").unwrap();

        // Parse the command.
        let command = match Command::parse(&command) {
            Some(x) => x,
            None => {
                writeln(b"Failed: InvalidCommand");
                continue;
            }
        };

        // Process the command.
        if let Err(error) = command.process() {
            writeln(format!("Failed: {error:?}").as_bytes());
        }
    }
}

enum Command<'a> {
    Insert { key: Key, value: &'a str },
    Find { key: Key },
    Remove { key: Key },
}

impl<'a> Command<'a> {
    fn parse(input: &'a str) -> Option<Self> {
        Some(match *input.split_whitespace().collect::<Vec<_>>().as_slice() {
            ["insert", key, value] => Command::Insert { key: Key::parse(key)?, value },
            ["find", key] => Command::Find { key: Key::parse(key)? },
            ["remove", key] => Command::Remove { key: Key::parse(key)? },
            _ => return None,
        })
    }

    fn process(&self) -> Result<(), Error> {
        match self {
            Command::Insert { key, value } => insert(key, value.as_bytes()),
            Command::Find { key } => {
                match find(key)? {
                    None => writeln(b"Not found."),
                    Some(value) => match core::str::from_utf8(&value) {
                        Ok(value) => writeln(format!("Found: {value}").as_bytes()),
                        Err(_) => writeln(format!("Found (not UTF-8): {value:02x?}").as_bytes()),
                    },
                }
                Ok(())
            }
            Command::Remove { key } => remove(key),
        }
    }
}

enum Key {
    Exact(usize),
    Range(Range<usize>),
}

impl FromStr for Key {
    type Err = ParseIntError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.split_once("..") {
            Some((start, end)) => Ok(Key::Range(start.parse()? .. end.parse()?)),
            None => Ok(Key::Exact(s.parse()?)),
        }
    }
}

impl Key {
    fn parse(key: &str) -> Option<Self> {
        let key: Key = key.parse().ok()?;
        let valid = match &key {
            Key::Exact(key) => *key < 4096,
            Key::Range(keys) => !keys.is_empty() && keys.end < 4096,
        };
        if !valid {
            return None;
        }
        Some(key)
    }
}

fn insert(key: &Key, value: &[u8]) -> Result<(), Error> {
    match key {
        Key::Exact(key) => store::insert(*key, value),
        Key::Range(keys) => store::fragment::insert(keys.clone(), value),
    }
}

fn find(key: &Key) -> Result<Option<Box<[u8]>>, Error> {
    match key {
        Key::Exact(key) => store::find(*key),
        Key::Range(keys) => store::fragment::find(keys.clone()),
    }
}

fn remove(key: &Key) -> Result<(), Error> {
    match key {
        Key::Exact(key) => store::remove(*key),
        Key::Range(keys) => store::fragment::remove(keys.clone()),
    }
}

fn writeln(buf: &[u8]) {
    serial::write_all(&UsbSerial, buf).unwrap();
    serial::write_all(&UsbSerial, b"\r\n").unwrap();
}

Exercises

The examples/rust/exercises contains exercises to implement an applet that behaves like a security key over UART.

The part-<n> directories contain the successive parts towards the final applet. You will need to modify those applets by fixing the different TODO comments. The exercise description is at the top of the src/lib.rs file.

The part-<n>-sol directories contain the solution for each part. You don't need to modify those applets. You can look at them for hints while working part-<n>.

The client directory contains a binary to communicate with the applet. You don't need to modify this binary. You can use it by running cargo run in that directory. In particular you can get help with cargo run -- help and run send specific requests to the applet with cargo run -- register foo for example.

The interface directory contains a library defining the interface between the applet and the client. You don't need to modify this library but you need to read its documentation. You will use it from the applet.

You can run an applet on the host runner with:

cargo xtask applet rust exercises/part-1 runner host --web

Examples

There are a few existing applets that demonstrate simple usage of the prelude and should cover all functionalities in the prelude. Each example starts with a short documentation in its src/lib.rs file.

Noticeable examples are:

  • hsm implements some simple HSM-like API using the crypto, store, and usb::serial modules of the prelude. It comes with a companion program to interact with the applet (see the documentation in the src/lib.rs of the applet).
  • ctap implements some simple CTAP-like API using the button, led, timer,scheduling, store, and usb::serial modules of the prelude. It describes its usage when connecting to the USB serial interface.
  • memory_game implements some memory game using the usb::serial module of the prelude. It describes its usage when connecting to the USB serial interface.

Runner user guide

This chapter will describe how you can add support for a board. It is not yet written though and only provides a link to the API to implement.

There's currently only 2 supported boards:

  • nordic for nRF52840-dk
  • host for Linux (not clear if more or less is actually supported)

API

The board API is documented here.

Frequently asked questions

This section tries to answer common questions. If your question is not listed here, please open an issue.

Are applets trusted to be correct?

No. The platform does not trust applets and applets do not trust each other. However, if an applet has a valid signature, then the platform trusts the permissions required by the applet.

Are applets executed as native code?

No. Applets are installed as WebAssembly byte-code. This is required since the static guarantees provided by WebAssembly apply to the byte-code and the platform checks those guarantees. For execution, applets are interpreted: either directly from the byte-code, or for performance purposes from an optimized representation in flash or RAM which may be computed ahead-of-time or on-demand.

Why is performance not an issue?

The main bets are:

  • Computing intensive code (like cryptography) is done in hardware or native code (in the platform).
  • Applets are supposed to only do business logic which is assumed to not be computing intensive.
  • The platform targets users who can't write embedded code, so the main concern is not performance but making firmware development accessible.

How does this fit on micro-controllers?

The interpreter currently fits in 22kB when optimized for size and 66kB when optimized for speed. The interpreter is also designed to have minimal RAM overhead. However, most optimizations (for both performance and overhead) are not yet implemented, which may increase the binary size.

Why implement a new interpreter?

The following runtimes have been quickly rejected:

  • wasmtime and wasmer don't support no-std
  • wasmi consumes too much RAM for embedded

wasm3 has been used during the initial phase of the project but got eventually replaced with a custom interpreter for the following reasons:

  • It doesn't perform validation yet. We probably need proper validation.
  • It only compiles to RAM (not flash). We want to be able to preprocess a module and persist the pre-computation in flash such that it is only done when a module is installed and not each time it is instantiated.
  • It takes control of the execution flow. All runtimes I'm aware of behave like that. To simplify scheduling, we want the interpreter to give back control when asked or when a host function is called.
  • It is written in C. Although the code has tests and fuzzing, we want additional security provided by the language.

The interpreter we implemented is written in Rust, doesn't take control of the execution thread, doesn't pre-compute anything yet (but will be able to pre-compute to flash), and performs validation.

Applet footprint compared to native code?

WebAssembly byte-code is compact so there should be a footprint benefit compared to native code. However, no benchmarks have been done in that regard.

Is it possible to share code between applets?

Yes (although not yet implemented). Applets are represented at runtime by a WebAssembly store which is unique per applet. Applets behavior is defined by a set of WebAssembly modules which are instantiated to the applet store. Applets may share those modules. A typical example would be an allocator module. Multiple applets may use the same allocator byte-code (from the module) to manage their own linear memory (from the module instance in the applet store).

What third-party dependencies are used?

The minimum set of third-party dependencies is currently:

  • num_enum for the interpreter
  • usb-device and usbd-serial for the board API

Additional dependencies are used by:

  • the actual board implementation:
    • (e.g. cortex-m-rt, nrf52840-hal, panic-abort for nordic)
    • (e.g. tokio, usbip-device, aes, rand for linux)
  • compilation (e.g. proc-macro2, quote)
  • debugging (e.g. defmt, defmt-rtt, log, env_logger)
  • tooling (e.g. anyhow, clap)

In particular, the project doesn't need any operating system (e.g. TockOS) but may use one as part of a board implementation.

Links

Project

Presentations

  • Hardwear.io NL 2023 workshop slides

Documentation

Acknowledgments

This project would not be where it is now if it couldn't build upon the following projects1:

ProjectAuthorUsage
defmtKnurlingLogging for embedded
probe-runKnurling(now replaced by probe-rs)
probe-rsprobe.rsFlashing and debugging
cortex-mRust EmbeddedCortex-M support
riscvRust EmbeddedRISC-V support
critical-sectionRust EmbeddedMutex support
portable-atomictaiki-eAtomic support
usb-deviceRust Embedded CommunityGeneric USB interface
usbd-serialRust Embedded CommunityUSB serial implementation
usbip-deviceSawchordUSB/IP implementation
nrf-halnRF RustnRF52840 support
nrf-usbdnRF RustnRF52840 USB implementation
hashesRust CryptoSHA-256 and 384
MACsRust CryptoHMAC-SHA-256 and 384
AEADsRust CryptoAES-128-CCM and 256-GCM
elliptic-curvesRust CryptoNIST P-256 and 384
rust-analyzerRustRust development
wasm3Wasm3 Labs(not used anymore)

We would like to thank all authors and contributors of those projects (as well as those we use but we forgot to mention) and more generally the community around Rust for embedded development. We are also grateful to those who answered our issues [1, 2, 3, 4, 5, 6, 7, 8, 9] and reviewed our pull requests [10, 11, 12, 13].

1

We tried to focus on the projects that were critical to get where we are now. Some projects are thus deliberately left out (and some projects that we don't use anymore are still listed). But it is also possible that we forgot to list some, in which case we apologize and would be happy to fix our oversight if notified.