diff --git a/Cargo.lock b/Cargo.lock index ad832e1..bb1c215 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -725,7 +725,7 @@ version = "0.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f49d8fed880d473ea71efb9bf597651e77201bdd4893efe54c9e5d65ae04ce6f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -745,7 +745,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -820,9 +820,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "ble-scanner-trevm" @@ -1398,20 +1398,14 @@ dependencies = [ ] [[package]] -name = "cose_minicbor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9ec26940b5faee22a6a5802087cc1d0eb90d0f0d7dfecdd8232159b012f21d4" +name = "cose-nostd" +version = "0.1.0" +source = "git+https://codeberg.org/COLORADIO-Project/cose-nostd.git#47af660e945c2476499e2fce3c7adac1e718df5e" dependencies = [ - "heapless 0.9.2", - "hkdf", + "hex-literal", "minicbor", - "minicbor-adapters", "p256", "sha2", - "sha3", - "suit_cbor", - "thiserror", ] [[package]] @@ -1904,9 +1898,9 @@ dependencies = [ [[package]] name = "dress-up" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc99825ce4dc1c2e3ee5e497913b236f52f48127ae9dd2fcf9b972fe0cacce0d" +source = "git+https://github.com/ariel-os/dress-up?branch=async#0ec541ba6d20423799ba5b2e5f372efd74ab952c" dependencies = [ + "bitflags 2.11.1", "ctutils", "digest", "generic-array 1.4.1", @@ -1915,6 +1909,7 @@ dependencies = [ "minicbor", "num_enum 0.7.6", "sha2", + "sha3", "uuid", ] @@ -2080,7 +2075,7 @@ name = "embassy-nrf" version = "0.8.0" source = "git+https://github.com/ariel-os/embassy?rev=3940a79a29ae578a35625cae949bea473abf21d1#3940a79a29ae578a35625cae949bea473abf21d1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "cfg-if", "cortex-m", "cortex-m-rt", @@ -2156,7 +2151,7 @@ source = "git+https://github.com/ariel-os/embassy?rev=1b75c3d6e61a99a312e2d12f03 dependencies = [ "aligned", "bit_field", - "bitflags 2.10.0", + "bitflags 2.11.1", "block-device-driver", "cfg-if", "cortex-m", @@ -2616,7 +2611,7 @@ version = "1.0.0" source = "git+https://github.com/ariel-os/esp-hal?rev=531c629afdd80ea464682ce7f4db8baed97967a6#531c629afdd80ea464682ce7f4db8baed97967a6" dependencies = [ "bitfield 0.19.4", - "bitflags 2.10.0", + "bitflags 2.11.1", "bytemuck", "cfg-if", "critical-section", @@ -3482,6 +3477,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + [[package]] name = "hexfloat2" version = "0.1.3" @@ -4905,7 +4906,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", ] [[package]] @@ -5126,7 +5127,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.11.0", @@ -5302,6 +5303,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -5549,7 +5556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "suit-validation" +name = "suit-updatable" version = "0.0.0" dependencies = [ "ariel-os", @@ -5559,23 +5566,20 @@ dependencies = [ "coap-message", "coap-message-utils", "coap-numbers", - "cose_minicbor", + "coap-request", + "coap-request-implementations", + "cose-nostd", "dress-up", + "embassy-futures", + "embassy-sync 0.7.2", "embedded-nal-coap", - "minicbor", + "generic-array 1.4.1", + "once_cell", + "rand_core 0.6.4", + "uuid", "wasmtime", ] -[[package]] -name = "suit_cbor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dd16ad3caa7dc09d4a4c0c585968494ef61d260600119ba79094c047863552e" -dependencies = [ - "minicbor", - "thiserror", -] - [[package]] name = "svgbobdoc" version = "0.3.0" @@ -5975,6 +5979,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", + "sha1_smol", "wasm-bindgen", ] @@ -6122,7 +6127,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver 1.0.27", @@ -6134,7 +6139,7 @@ version = "0.245.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.1", "hashbrown 0.16.1", "indexmap 2.14.0", "semver 1.0.27", @@ -6159,7 +6164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf" dependencies = [ "async-trait", - "bitflags 2.10.0", + "bitflags 2.11.1", "bumpalo", "cc", "cfg-if", @@ -6340,7 +6345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2192a77a00b9a67800c2b4e1c70fb6abca79d6b529e53a2ef9dcdcc36090330d" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.11.1", "heck 0.5.0", "indexmap 2.14.0", "wit-parser 0.245.1", @@ -6675,7 +6680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", diff --git a/examples/laze.yml b/examples/laze.yml index 6b1f6ee..006dfa2 100644 --- a/examples/laze.yml +++ b/examples/laze.yml @@ -12,3 +12,4 @@ subdirs: - fake-sensor - sandbox-no-bindings - updatable-async + - suit-updatable diff --git a/examples/suit-updatable/Cargo.toml b/examples/suit-updatable/Cargo.toml new file mode 100644 index 0000000..6d2372d --- /dev/null +++ b/examples/suit-updatable/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "suit-updatable" +license.workspace = true +edition.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +ariel-os = { workspace = true, features = ["coap", "random", "i2c", "defmt"] } + +ariel-os-bindings = { path = "../../src/ariel-os-bindings", features = [ + "log", + "time", + "rng", +] } + +embedded-nal-coap = "=0.1.0-alpha.5" +coap-handler-implementations = "0.6.1" +coap-handler = "0.2.0" +coap-message = "0.3.2" +coap-numbers = "0.2" +# error_title allows sending out messages; error_request_body_error_position +# would also be welcome, but so far I don't think wasmtime exports that (plus +# it probably take a lot of source map magic to make real use of it). +coap-message-utils = { version = "0.3.0", features = ["error_title"] } +coap-request = "0.2.0-alpha.2" +coap-request-implementations = "0.1.0-alpha.4" + +cose-nostd = {git = "https://codeberg.org/COLORADIO-Project/cose-nostd.git", version = "0.1.0", features = ["sign1", "p-256", "verify"]} +dress-up = { git = "https://github.com/ariel-os/dress-up", branch = "async", features = ["async"] } +generic-array = {version = "1.4.1", default-features = false } +uuid = { version = "1.23.1", default-features = false, features = ["v5"] } +wasmtime = { workspace = true, default-features = false, features = [ + "pulley", + "runtime", + "component-model", + "async" +] } + +rand_core = { workspace = true } + +once_cell = { version = "1.21.3", default-features = false, features = [ + "critical-section", +] } + +embassy-sync = { version = "0.7.2" } +embassy-futures = { version = "0.1.1", default-features = false } diff --git a/examples/suit-updatable/README.md b/examples/suit-updatable/README.md new file mode 100644 index 0000000..8af7341 --- /dev/null +++ b/examples/suit-updatable/README.md @@ -0,0 +1,69 @@ +# SUIT updatable Capsule + +## About + +This example shows how to update an async WebAssembly capsule over CoAP using a signed SUIT manifest. + +The update is kept in memory only. It replaces the currently running capsule for the lifetime of the process, but it is not written to flash and does not survive a reboot. + +For lower peak memory usage, the example drops the currently running capsule after the SUIT envelope has been authenticated, but before the new `payload.cwasm` is fetched and validated. If the fetch or validation fails, the runner will be left without a loaded capsule until another valid update is sent. + +## Requirements + +The commands in this example require: + +- Arm's `suit-tool`: https://gitlab.arm.com/research/ietf-suit/suit-tool +- aiocoap's command-line tools: `aiocoap-fileserver`, `aiocoap-client` + +## How to run + +All commands below are intended to be run from the example root directory. + +Run the ESP: + +```sh +CONFIG_WIFI_NETWORK=... CONFIG_WIFI_PASSWORD=... laze build -b espressif-esp32-c6-devkitc-1 -s coap-server-config-unprotected run +``` + +Build the update payload: + +```sh +cargo +nightly -Z script ../../precompile_wasm.rs --path ../../payloads/async-bindings/Cargo.toml --config ../../payloads/.cargo/config.toml -o payload.cwasm --fuel +``` + +Edit `suit/manifest.json` and set the `uri` field to the host serving `payload.cwasm`, for example: + +```json +"uri": "coap://192.168.1.100:5683/payload.cwasm" +``` + +For local testing, the example accepts the same `manifest-sequence-number` more than once and logs a warning. Lower sequence numbers are rejected while the board is running. + +Regenerate and sign the SUIT manifest: + +```sh +suit-tool create -i suit/manifest.json -o suit/manifest.suit + +suit-tool sign -m suit/manifest.suit -k suit/demo-private-key.pem -o suit/manifest.signed.suit +``` + +Ensure that the host firewall allows inbound UDP traffic on port 5683. +Then serve the update payload: + +```sh +aiocoap-fileserver . +``` + +Send the signed manifest to the board: + +```sh +aiocoap-client -m PUT --payload @suit/manifest.signed.suit coap:///vm-control +``` + +The key pair in `suit/` is for this example only. Firmware verification uses `suit/demo-public-key-p256.bin`. + +## Testing + +This example has been tested on the following boards: + +- ESP32-C6-DevKitC-1 diff --git a/examples/suit-updatable/client.cosekey b/examples/suit-updatable/client.cosekey new file mode 100644 index 0000000..c7a7a63 --- /dev/null +++ b/examples/suit-updatable/client.cosekey @@ -0,0 +1 @@ +{1: 2, -1: 1, -4: h'fb13adeb6518cee5f88417660841142e830a81fe334380a953406a1305e8706b'} diff --git a/examples/suit-updatable/client.diag b/examples/suit-updatable/client.diag new file mode 100644 index 0000000..7843cbc --- /dev/null +++ b/examples/suit-updatable/client.diag @@ -0,0 +1,12 @@ +{ + "coap://*": { + "edhoc-oscore": { + "suite": 2, + "method": 3, + "own_cred_style": "by-key-id", + "own_cred": {14: {2: "42-50-31-FF-EF-37-32-39", 8: {1: {1: 2, 2: h'2b', -1: 1, -2: h'ac75e9ece3e50bfc8ed60399889522405c47bf16df96660a41298cb4307f7eb6', -3: h'6e5de611388a4b8a8211334ac7d37ecb52a387d257e6db3c2a93df21ff3affc8'}}}}, + "private_key_file": "client.cosekey", + "peer_cred": {14: {8:{1:{1:2, 2:h'', -1:1, -2:h'7ca4b31a9f2add90b012efe4f5fdc46618568c98aa4e5f040d9a8306df6dd578'}}}}, + } + }, +} diff --git a/examples/suit-updatable/laze.yml b/examples/suit-updatable/laze.yml new file mode 100644 index 0000000..e062982 --- /dev/null +++ b/examples/suit-updatable/laze.yml @@ -0,0 +1,18 @@ +apps: + - name: suit-updatable + context: + - espressif-esp32-c6-devkitc-1 + selects: + - coap-client + - coap-server + - ?coap-server-config-storage + - alloc + env: + global: + # Must be enough for two payloads, so requirements depend on the transmitted + # payloads' sizes. + heapsize_required: + - "200000" + RUSTFLAGS: + # Disable SIMD in Wasmtime's interpreter + - --cfg pulley_disable_interp_simd diff --git a/examples/suit-updatable/payload.cwasm b/examples/suit-updatable/payload.cwasm new file mode 100644 index 0000000..be1341c Binary files /dev/null and b/examples/suit-updatable/payload.cwasm differ diff --git a/examples/suit-updatable/peers.yml b/examples/suit-updatable/peers.yml new file mode 100644 index 0000000..70624cd --- /dev/null +++ b/examples/suit-updatable/peers.yml @@ -0,0 +1,12 @@ +- from: unauthenticated + scope: + /.well-known/core: GET + +- kccs: | + # The CWT Claims Set that needs to be used (by value or by reference) by + # the client to gain access to the device. + # + # It is expressed in CBOR diagnostic notation (which at the YAML level is + # just a string), and compatible with aiocoap's credentials. + {2: "42-50-31-FF-EF-37-32-39", 8: {1: {1: 2, 2: h'2b', -1: 1, -2: h'ac75e9ece3e50bfc8ed60399889522405c47bf16df96660a41298cb4307f7eb6', -3: h'6e5de611388a4b8a8211334ac7d37ecb52a387d257e6db3c2a93df21ff3affc8'}}} + scope: allow-all diff --git a/examples/suit-updatable/src/coap_fetch.rs b/examples/suit-updatable/src/coap_fetch.rs new file mode 100644 index 0000000..627d1f0 --- /dev/null +++ b/examples/suit-updatable/src/coap_fetch.rs @@ -0,0 +1,188 @@ +use core::net::SocketAddr; + +use alloc::vec::Vec; +use ariel_os::time::Duration; +use coap_message::Code; +use coap_message::{MessageOption, MinimalWritableMessage, OptionNumber, ReadableMessage}; +use coap_message_utils::OptionsExt; +use coap_request::Stack; +use coap_request_implementations::AsUriPath; + +use ariel_os::reexports::embassy_time::with_timeout; + +const BLOCK2_SZX: u32 = 6; // 2^(6 + 4) = 1024 bytes + +#[derive(Debug)] +pub enum CoapFetchError { + RequestFailed, + TooLarge, + Empty, + Timeout, + AllocationFailed { size: usize }, +} + +pub async fn get_blockwise( + addr: SocketAddr, + path: &str, + max_size: usize, + timeout: Duration, +) -> Result, CoapFetchError> { + let client = ariel_os::coap::coap_client().await; + + let mut body = Vec::new(); + + body.try_reserve_exact(max_size) + .map_err(|_| CoapFetchError::AllocationFailed { size: max_size })?; + let mut num = 0; + + loop { + let more = with_timeout( + timeout, + client.to(addr).request(GetBlock2 { + path, + num, + body: &mut body, + max_size, + }), + ) + .await + .map_err(|_| CoapFetchError::Timeout)? + .map_err(|_| CoapFetchError::RequestFailed)??; + + if !more { + break; + } + + num += 1; + } + + if body.is_empty() { + return Err(CoapFetchError::Empty); + } + + Ok(body) +} + +fn block2_value(num: u32) -> u32 { + // NUM | M=0 | SZX + (num << 4) | BLOCK2_SZX +} + +fn parse_block2(v: u32) -> Result<(u32, usize, bool), CoapFetchError> { + let szx = v & 0x7; + + if szx > 6 { + return Err(CoapFetchError::RequestFailed); + } + + let num = v >> 4; + let more = (v & 0x8) != 0; + let size = 1usize << (4 + szx); + + Ok((num, size, more)) +} + +struct GetBlock2<'a> { + path: &'a str, + num: u32, + body: &'a mut Vec, + max_size: usize, +} + +impl coap_request::Request for GetBlock2<'_> +where + S: coap_request::Stack + ?Sized, +{ + type Carry = (); + type Output = Result; + + async fn build_request( + &mut self, + req: &mut S::RequestMessage<'_>, + ) -> Result<(), S::RequestUnionError> { + let code = + as MinimalWritableMessage>::Code::new(coap_numbers::code::GET) + .map_err(S::RequestMessage::convert_code_error)?; + + req.set_code(code); + + for part in self.path.as_uri_path() { + req.add_option( + as MinimalWritableMessage>::OptionNumber::new( + coap_numbers::option::URI_PATH, + ) + .map_err(S::RequestMessage::convert_option_number_error)?, + part.as_bytes(), + ) + .map_err(S::RequestMessage::convert_add_option_error)?; + } + + req.add_option_uint( + as MinimalWritableMessage>::OptionNumber::new( + coap_numbers::option::BLOCK2, + ) + .map_err(S::RequestMessage::convert_option_number_error)?, + block2_value(self.num), + ) + .map_err(S::RequestMessage::convert_add_option_error)?; + + Ok(()) + } + + async fn process_response(&mut self, res: &S::ResponseMessage<'_>, _carry: ()) -> Self::Output { + let code: u8 = res.code().into(); + + if !matches!( + coap_numbers::code::classify(code), + coap_numbers::code::Range::Response(coap_numbers::code::Class::Success) + ) { + return Err(CoapFetchError::RequestFailed); + } + + let mut block2 = None; + + res.options() + .filter(|option| { + if option.number() == coap_numbers::option::BLOCK2 { + block2 = option.value_uint(); + false + } else { + true + } + }) + .ignore_elective_others() + .map_err(|_| CoapFetchError::RequestFailed)?; + + let payload = res.payload(); + + let more = if let Some(block2) = block2 { + let (num, size, more) = parse_block2(block2)?; + + if num != self.num || payload.len() > size { + return Err(CoapFetchError::RequestFailed); + } + + more + } else { + if self.num != 0 { + return Err(CoapFetchError::RequestFailed); + } + + false + }; + + let new_len = self + .body + .len() + .checked_add(payload.len()) + .ok_or(CoapFetchError::TooLarge)?; + + if new_len > self.max_size { + return Err(CoapFetchError::TooLarge); + } + + self.body.extend_from_slice(payload); + + Ok(more) + } +} diff --git a/examples/suit-updatable/src/main.rs b/examples/suit-updatable/src/main.rs new file mode 100644 index 0000000..e9ac621 --- /dev/null +++ b/examples/suit-updatable/src/main.rs @@ -0,0 +1,233 @@ +#![no_main] +#![no_std] +extern crate alloc; + +use core::ptr::NonNull; + +use alloc::vec::Vec; +use ariel_os::debug::log::{Debug2Format, error, info, warn}; + +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; + +use embassy_futures::select::{Either, select}; + +use wasmtime::component::{Component, HasSelf, Linker, bindgen}; +use wasmtime::{Config, Engine, Error as WasmtimeError, Store}; + +use ariel_os_bindings::wasm::ArielOSHost; + +use crate::suit::{UpdateError, build_and_authenticate_manifest, fetch_and_verify_update}; +use crate::vm_control::{VmEvent, wait_for_update_request}; + +mod coap_fetch; +mod suit; +mod vm_control; + +bindgen!({ + world: "example-async", + path: "../../wit/", + with: { + "ariel:wasm-bindings/log-api": ariel_os_bindings::wasm::log, + "ariel:wasm-bindings/time-api": ariel_os_bindings::wasm::time, + "ariel:wasm-bindings/rng-api": ariel_os_bindings::wasm::rng, + + }, + require_store_data_send: true, +}); + +static VM_DROP_REQUESTS: Channel = Channel::new(); +static VM_STATUS_SIGNAL: Channel = Channel::new(); +static UPDATE_RESULTS: Channel, ()>, 1> = Channel::new(); + +#[ariel_os::task(autostart)] +async fn suit_update_task() { + let mut accepted_sequence_number = None; + loop { + let envelope = wait_for_update_request().await; + info!("[SUIT] Received update request"); + + let (manifest, sequence_number) = match build_and_authenticate_manifest(&envelope) { + Ok(manifest) => manifest, + Err(e) => { + info!("[SUIT] Update rejected: {:?}", Debug2Format(&e)); + continue; + } + }; + + if let Some(current) = accepted_sequence_number { + if sequence_number < current { + warn!( + "[SUIT] Update rejected: {:?}", + Debug2Format(&UpdateError::RollbackDetected { + current, + attempted: sequence_number, + }) + ); + continue; + } + + if sequence_number == current { + warn!( + "[SUIT] accepting repeated manifest sequence number {:?} for testing", + sequence_number + ); + } + } + + info!("[SUIT] Update authenticated. Requesting drop of old capsule..."); + VM_DROP_REQUESTS.send(()).await; + match VM_STATUS_SIGNAL.receive().await { + VmEvent::Dropped => { + info!("[SUIT] Capsule dropped. Fetching new capsule..."); + } + other => { + info!("[SUIT] Unexpected VM event {:?}", Debug2Format(&other)); + continue; + } + } + + match fetch_and_verify_update(manifest).await { + Ok(capsule) => { + accepted_sequence_number = Some( + accepted_sequence_number + .map_or(sequence_number, |current| current.max(sequence_number)), + ); + + info!( + "[SUIT] Successfully fetched capsule with a length of {} bytes. Requesting install...", + capsule.len() + ); + UPDATE_RESULTS.send(Ok(capsule)).await + } + Err(e) => { + warn!("[SUIT] Failed to retrieve capsule: {:?}", Debug2Format(&e)); + UPDATE_RESULTS.send(Err(())).await + } + } + } +} + +#[ariel_os::task(autostart)] +async fn runner_task() { + let engine = make_engine(); + let initial_capsule = include_bytes!("../payload.cwasm").as_slice(); + let mut capsule: Vec = Vec::from(initial_capsule); + + let mut linker = Linker::new(&engine); + ExampleAsync::add_to_linker::<_, HasSelf<_>>(&mut linker, |state| state).unwrap(); + + let mut host = ArielOSHost::default(); + + loop { + let (returned_host, result) = run_capsule(&engine, &linker, capsule, host).await; + match result { + Ok(VmEvent::Dropped) => { + info!("Capsule stopped externally"); + } + Ok(VmEvent::Finished) => { + info!("Capsule finished on its own"); + } + Err(e) => { + error!("run_capsule crashed: {:?}", Debug2Format(&e)); + } + } + + host = returned_host; + + info!("Waiting for new capsule..."); + capsule = wait_for_capsule().await; + } +} + +fn make_engine() -> Engine { + let mut cfg = Config::default(); + cfg.wasm_custom_page_sizes(true); + cfg.target("pulley32").unwrap(); + + // Must match precompilation + cfg.table_lazy_init(false); + cfg.memory_reservation(0); + cfg.memory_init_cow(false); + cfg.memory_may_move(false); + + // Runtime-only tuning + cfg.max_wasm_stack(2048); + cfg.memory_reservation_for_growth(0); + cfg.async_stack_size(4096); + + cfg.consume_fuel(true); + + Engine::new(&cfg).unwrap() +} + +async fn wait_for_capsule() -> Vec { + loop { + let cmd_fut = VM_DROP_REQUESTS.receive(); + let update_fut = UPDATE_RESULTS.receive(); + + match select(cmd_fut, update_fut).await { + Either::First(()) => { + info!("No capsule loaded; acknowledging drop request"); + VM_STATUS_SIGNAL.send(VmEvent::Dropped).await; + } + Either::Second(Ok(capsule)) => { + info!("Received new capsule"); + return capsule; + } + Either::Second(Err(())) => { + info!("Update failed; still waiting for capsule"); + } + } + } +} + +async fn run_capsule( + engine: &Engine, + linker: &Linker, + mut capsule: Vec, + host: ArielOSHost, +) -> (ArielOSHost, Result) { + let component = + match unsafe { Component::deserialize_raw(&engine, NonNull::from(capsule.as_mut())) } { + Ok(component) => component, + Err(e) => { + error!("Failed to deserialize component: {:?}", Debug2Format(&e)); + + return (host, Err(e)); + } + }; + + let mut store = Store::new(&engine, host); + + store.set_fuel(u64::MAX).expect("failed to set fuel"); + + store + .fuel_async_yield_interval(Some(1_000)) + .expect("failed to set fuel async yield interval"); + + let bindings = match ExampleAsync::instantiate_async(&mut store, &component, linker).await { + Ok(bindings) => bindings, + Err(e) => { + let host = store.into_data(); + return (host, Err(e)); + } + }; + + let run_fut = bindings.run.call_async(&mut store, &[], &mut []); + let drop_requested_fut = VM_DROP_REQUESTS.receive(); + + let result = match select(drop_requested_fut, run_fut).await { + Either::First(_) => Ok(VmEvent::Dropped), + Either::Second(Ok(_)) => Ok(VmEvent::Finished), + Either::Second(Err(e)) => Err(e), + }; + + let host = store.into_data(); + + if matches!(result, Ok(VmEvent::Dropped)) { + VM_STATUS_SIGNAL.send(VmEvent::Dropped).await; + } + + (host, result) +} diff --git a/examples/suit-updatable/src/suit.rs b/examples/suit-updatable/src/suit.rs new file mode 100644 index 0000000..007bac8 --- /dev/null +++ b/examples/suit-updatable/src/suit.rs @@ -0,0 +1,447 @@ +use core::{cell::RefCell, net::SocketAddr, str::FromStr}; + +use alloc::vec::Vec; +use ariel_os::debug::log::{Debug2Format, error}; +use ariel_os::time::Duration; +use dress_up::manifest::Manifest; +use dress_up::{AsyncOperatingHooks, Authenticated, SuitManifest}; +use uuid::Uuid; + +use cose_nostd::{ + iana::{Algorithm, EllipticCurve, KeyOperation, KeyType, key_labels}, + key::CoseKeyBuilder, + signature::sign1::CoseSign1, +}; + +use crate::coap_fetch::{CoapFetchError, get_blockwise}; + +pub const MAX_CAPSULE_SIZE: usize = 100 * 1024; +const STAGING_SLOT: u64 = 1; + +pub const PUBKEY_P256: &[u8; 65] = include_bytes!("../suit/demo-public-key-p256.bin"); + +pub fn suit_vendor_id() -> Uuid { + Uuid::new_v5(&Uuid::NAMESPACE_DNS, "example.com".as_bytes()) +} + +pub fn suit_class_id() -> Uuid { + Uuid::new_v5(&suit_vendor_id(), "trevm-suit-updatable-demo".as_bytes()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SuitPhase { + ParseEnvelope, + Authentication, + PayloadFetch, + PayloadInstallation, + ImageValidation, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum UpdateError { + UnsupportedManifestVersion, + EmptyCapsule, + CapsuleTooLarge, + InvalidSlot, + OutOfBoundsRead, + MalformedUri, + CoapRequestFailed, + CoapTimeout, + AllocationFailed { + size: usize, + }, + + VendorIdMismatch { + expected: Uuid, + actual: Uuid, + }, + ClassIdMismatch { + expected: Uuid, + actual: Uuid, + }, + ComponentSlotMismatch { + expected: u64, + actual: u64, + }, + + RollbackDetected { + current: u64, + attempted: u64, + }, + UnsupportedWriteContent, + + SuitAuthenticationFailed, + SuitMissingAuthentication, + SuitMissingSequenceNumber, + SuitPayloadFetchConditionFailed { + position: usize, + }, + SuitPayloadInstallationConditionFailed { + position: usize, + }, + SuitImageValidationConditionFailed { + position: usize, + }, + SuitConditionFailed { + phase: SuitPhase, + position: usize, + }, + SuitExecutionFailed, + + DressUp { + phase: SuitPhase, + error: dress_up::error::Error, + }, +} + +impl UpdateError { + fn from_suit_error(phase: SuitPhase, e: dress_up::error::Error) -> Self { + match e { + dress_up::error::Error::UnsupportedManifestVersion => Self::UnsupportedManifestVersion, + dress_up::error::Error::AuthenticationFailure => Self::SuitAuthenticationFailed, + dress_up::error::Error::NoAuthObject => match phase { + SuitPhase::Authentication => Self::SuitMissingAuthentication, + _ => Self::SuitExecutionFailed, + }, + dress_up::error::Error::NoSequenceNumber => Self::SuitMissingSequenceNumber, + dress_up::error::Error::ConditionMatchFail { position } => match phase { + SuitPhase::PayloadFetch => Self::SuitPayloadFetchConditionFailed { position }, + SuitPhase::PayloadInstallation => { + Self::SuitPayloadInstallationConditionFailed { position } + } + SuitPhase::ImageValidation => Self::SuitImageValidationConditionFailed { position }, + _ => Self::SuitConditionFailed { phase, position }, + }, + dress_up::error::Error::EndOfInput => Self::EmptyCapsule, + _ => Self::DressUp { phase, error: e }, + } + } +} + +struct TrevmSuitHooks { + staging: RefCell>, + last_error: RefCell>, +} + +impl TrevmSuitHooks { + fn new() -> Self { + Self { + staging: RefCell::new(Vec::new()), + last_error: RefCell::new(None), + } + } + + fn into_capsule(self) -> Result, UpdateError> { + let capsule = self.staging.into_inner(); + if capsule.is_empty() { + return Err(UpdateError::EmptyCapsule); + } + + Ok(capsule) + } + + fn remember_error(&self, error: UpdateError) -> dress_up::error::Error { + *self.last_error.borrow_mut() = Some(error); + + match error { + UpdateError::EmptyCapsule => dress_up::error::Error::EndOfInput, + + UpdateError::CapsuleTooLarge + | UpdateError::InvalidSlot + | UpdateError::OutOfBoundsRead => { + dress_up::error::Error::ConditionMatchFail { position: 0 } + } + + _ => dress_up::error::Error::ConditionMatchFail { position: 0 }, + } + } + + fn remember_condition_mismatch(&self, error: UpdateError) { + *self.last_error.borrow_mut() = Some(error); + } + + fn take_last_error(&self) -> Option { + self.last_error.borrow_mut().take() + } + + fn clear_last_error(&self) { + *self.last_error.borrow_mut() = None; + } + + fn map_phase_error(&self, phase: SuitPhase, err: dress_up::error::Error) -> UpdateError { + match self.take_last_error() { + Some(e) => e, + None => UpdateError::from_suit_error(phase, err), + } + } +} + +impl AsyncOperatingHooks for TrevmSuitHooks { + type ReadWriteBufferSize = generic_array::typenum::U512; + async fn match_vendor_id( + &self, + uuid: Uuid, + _component: &dress_up::component::Component<'_>, + ) -> Result { + let ok = uuid == suit_vendor_id(); + if !ok { + self.remember_condition_mismatch(UpdateError::VendorIdMismatch { + expected: suit_vendor_id(), + actual: uuid, + }); + } + Ok(ok) + } + + async fn match_class_id( + &self, + uuid: Uuid, + _component: &dress_up::component::Component<'_>, + ) -> Result { + let ok = uuid == suit_class_id(); + if !ok { + self.remember_condition_mismatch(UpdateError::ClassIdMismatch { + expected: suit_class_id(), + actual: uuid, + }); + } + + Ok(ok) + } + + async fn match_component_slot( + &self, + _component: &dress_up::component::Component<'_>, + slot: u64, + ) -> Result { + let ok = slot == STAGING_SLOT; + if !ok { + self.remember_condition_mismatch(UpdateError::ComponentSlotMismatch { + expected: STAGING_SLOT, + actual: slot, + }); + } + Ok(ok) + } + + async fn component_capacity( + &self, + _component: &dress_up::component::Component<'_>, + ) -> Result { + Ok(MAX_CAPSULE_SIZE) + } + + async fn component_size( + &self, + _component: &dress_up::component::Component<'_>, + ) -> Result { + Ok(self.staging.borrow().len()) + } + + async fn component_read( + &self, + _component: &dress_up::component::Component<'_>, + slot: Option, + offset: usize, + bytes: &mut [u8], + ) -> Result<(), dress_up::error::Error> { + if slot.unwrap_or(STAGING_SLOT) != STAGING_SLOT { + return Err(self.remember_error(UpdateError::InvalidSlot)); + } + + let staging = self.staging.borrow(); + let end = offset + .checked_add(bytes.len()) + .ok_or_else(|| self.remember_error(UpdateError::OutOfBoundsRead))?; + + let src = staging + .get(offset..end) + .ok_or_else(|| self.remember_error(UpdateError::OutOfBoundsRead))?; + + bytes.copy_from_slice(src); + Ok(()) + } + + async fn component_write( + &self, + _component: &dress_up::component::Component<'_>, + _slot: Option, + _offset: usize, + _bytes: &[u8], + ) -> Result<(), dress_up::error::Error> { + *self.last_error.borrow_mut() = Some(UpdateError::UnsupportedWriteContent); + + Err(dress_up::error::Error::UnsupportedCommand { + command: dress_up::consts::SuitCommand::WriteContent.into(), + }) + } + + async fn fetch( + &self, + _component: &dress_up::component::Component<'_>, + slot: Option, + uri: &str, + ) -> Result<(), dress_up::error::Error> { + if slot.unwrap_or(STAGING_SLOT) != STAGING_SLOT { + return Err(self.remember_error(UpdateError::InvalidSlot)); + } + let path = uri + .strip_prefix("coap://") + .ok_or_else(|| self.remember_error(UpdateError::MalformedUri))?; + + let slash_idx = path + .find('/') + .ok_or_else(|| self.remember_error(UpdateError::MalformedUri))?; + let (addr_str, path) = path.split_at(slash_idx); + + let addr = SocketAddr::from_str(addr_str) + .map_err(|_| self.remember_error(UpdateError::MalformedUri))?; + + self.staging.borrow_mut().clear(); + + let body = get_blockwise(addr, path, MAX_CAPSULE_SIZE, Duration::from_secs(1)) + .await + .map_err(|e| self.remember_error(e.into()))?; + + *self.staging.borrow_mut() = body; + Ok(()) + } +} + +pub fn build_and_authenticate_manifest<'a>( + envelope_bytes: &'a impl AsRef<[u8]>, +) -> Result<(Manifest<'a, Authenticated>, u64), UpdateError> { + let suit = SuitManifest::from_bytes(envelope_bytes) + .authenticate(verify_cose_signature) + .map_err(|e| UpdateError::from_suit_error(SuitPhase::Authentication, e))?; + + let envelope = suit + .envelope() + .map_err(|e| UpdateError::from_suit_error(SuitPhase::ParseEnvelope, e))?; + + let manifest = envelope + .manifest() + .map_err(|e| UpdateError::from_suit_error(SuitPhase::ParseEnvelope, e))?; + + let version = manifest + .version() + .map_err(|e| UpdateError::from_suit_error(SuitPhase::ParseEnvelope, e))?; + + if version != 1 { + return Err(UpdateError::UnsupportedManifestVersion); + } + + let sequence_number = manifest + .sequence_number() + .map_err(|e| UpdateError::from_suit_error(SuitPhase::ParseEnvelope, e))?; + + Ok((manifest, sequence_number)) +} + +pub async fn fetch_and_verify_update( + manifest: Manifest<'_, Authenticated>, +) -> Result, UpdateError> { + let hooks = TrevmSuitHooks::new(); + + if manifest + .has_payload_fetch() + .map_err(|e| UpdateError::from_suit_error(SuitPhase::PayloadFetch, e))? + { + manifest + .async_execute_payload_fetch(&hooks) + .await + .map_err(|e| hooks.map_phase_error(SuitPhase::PayloadFetch, e))?; + } + hooks.clear_last_error(); + if manifest + .has_payload_installation() + .map_err(|e| UpdateError::from_suit_error(SuitPhase::PayloadInstallation, e))? + { + manifest + .async_execute_payload_installation(&hooks) + .await + .map_err(|e| hooks.map_phase_error(SuitPhase::PayloadInstallation, e))?; + } + hooks.clear_last_error(); + + if manifest + .has_image_validation() + .map_err(|e| UpdateError::from_suit_error(SuitPhase::ImageValidation, e))? + { + manifest + .async_execute_image_validation(&hooks) + .await + .map_err(|e| hooks.map_phase_error(SuitPhase::PayloadFetch, e))?; + } + + let capsule = hooks.into_capsule()?; + + Ok(capsule) +} + +fn verify_cose_signature( + cose_sign1: &[u8], + detached_payload: &[u8], +) -> Result { + // Expected SEC1 uncompressed form (as in the const above): + // 0x04 || x[32] || y[32] + if PUBKEY_P256.len() != 65 || PUBKEY_P256[0] != 0x04 { + error!("P-256 public key is not uncompressed SEC1 format"); + return Err(dress_up::error::Error::AuthenticationFailure); + } + + let x = &PUBKEY_P256[1..33]; + let y = &PUBKEY_P256[33..65]; + + let mut key_buf = [0u8; 128]; + + let verification_key = CoseKeyBuilder::new(key_buf.as_mut_slice(), 6) + .and_then(|b| { + b.add_generic_params( + KeyType::EC2, + None, + Some(Algorithm::Es256), + Some(&[KeyOperation::Verify]), + None, + ) + }) + .and_then(|b| b.add_param(key_labels::ec::CRV, EllipticCurve::P256)) + .and_then(|b| b.add_param_bytes(key_labels::ec::X, x)) + .and_then(|b| b.add_param_bytes(key_labels::ec::Y, y)) + .and_then(|b| b.build()) + .map_err(|e| { + error!( + "[SUIT] failed to build COSE verification key: {:?}", + Debug2Format(&e) + ); + dress_up::error::Error::AuthenticationFailure + })?; + + let verifier = CoseSign1::from_slice(cose_sign1).map_err(|e| { + error!("[SUIT] failed to decode COSE_Sign1: {:?}", Debug2Format(&e)); + dress_up::error::Error::AuthenticationFailure + })?; + + match verifier.verify_detached(detached_payload, &verification_key, None, None) { + Ok(_) => Ok(true), + Err(e) => { + error!( + "[SUIT] COSE_Sign1 verification failed: {:?}", + Debug2Format(&e) + ); + Ok(false) + } + } +} + +impl From for UpdateError { + fn from(value: CoapFetchError) -> Self { + match value { + CoapFetchError::Empty => UpdateError::EmptyCapsule, + CoapFetchError::TooLarge => UpdateError::CapsuleTooLarge, + CoapFetchError::RequestFailed => UpdateError::CoapRequestFailed, + CoapFetchError::Timeout => UpdateError::CoapTimeout, + CoapFetchError::AllocationFailed { size } => UpdateError::AllocationFailed { size }, + } + } +} diff --git a/examples/suit-updatable/src/vm_control.rs b/examples/suit-updatable/src/vm_control.rs new file mode 100644 index 0000000..aac264e --- /dev/null +++ b/examples/suit-updatable/src/vm_control.rs @@ -0,0 +1,155 @@ +use alloc::boxed::Box; +use alloc::vec::Vec; + +use ariel_os::coap::coap_run; +use ariel_os::debug::log::{Debug2Format, info}; + +use coap_handler::Handler; +use coap_handler_implementations::{HandlerBuilder, ReportingHandlerBuilder, new_dispatcher}; +use coap_message::{Code, OptionNumber}; +use coap_message_utils::Error as CoapError; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::signal::Signal; + +static SUIT_VERIFY_SIGNAL: Signal> = Signal::new(); + +#[derive(Debug)] +pub enum VmEvent { + Dropped, + Finished, +} + +struct VmControl { + payload: Vec, +} + +impl VmControl { + fn new() -> Self { + Self { + payload: Vec::new(), + } + } +} + +impl Handler for VmControl { + type RequestData = (Option, u8); + + type ExtractRequestError = coap_message_utils::Error; + type BuildResponseError = coap_message_utils::Error; + + fn extract_request_data( + &mut self, + request: &M, + ) -> Result { + use coap_message::MessageOption; + use coap_message_utils::OptionsExt; + + match request.code().into() { + coap_numbers::code::DELETE => { + info!("Received DELETE request for SUIT-Manifest"); + request.options().ignore_elective_others()?; + + self.payload.clear(); + + Ok((None, coap_numbers::code::DELETED)) + } + + coap_numbers::code::PUT => { + info!("Received PUT request for program "); + let mut block1: Option = None; + + request + .options() + .filter(|o| { + if o.number() == coap_numbers::option::BLOCK1 + && let Some(n) = o.value_uint() + && block1.is_none() + { + block1 = Some(n); + false + } else { + true + } + }) + .ignore_elective_others()?; + + // This is a bit of a simplification, but ignoring the block size and just + // appending is really kind'a fine IMO. + let block1_value = block1.unwrap_or(0); + + // FIXME there's probably a Size1 option; if so, reallocate to fail early. + + let szx = block1_value & 0x7; + let blocksize = 1usize << (4 + szx); + let offset = (block1_value >> 4) as usize * blocksize; + + if offset == 0 { + self.payload.clear(); + } + if self.payload.len() != offset { + return Ok((None, coap_numbers::code::REQUEST_ENTITY_INCOMPLETE)); + } + + let payload = request.payload(); + self.payload.try_reserve_exact(payload.len()).map_err(|e| { + info!( + "Failed to reserve memory for program: {:?}", + Debug2Format(&e) + ); + CoapError::internal_server_error() + })?; + self.payload.extend_from_slice(payload); + + if (block1_value & 0x8) == 0x8 { + Ok((block1, coap_numbers::code::CONTINUE)) + } else { + let image = core::mem::take(&mut self.payload); + SUIT_VERIFY_SIGNAL.signal(image.into_boxed_slice()); + Ok((block1, coap_numbers::code::CHANGED)) + } + } + + _ => Err(CoapError::method_not_allowed()), + } + } + + fn estimate_length(&mut self, _request: &Self::RequestData) -> usize { + 1 + } + + fn build_response( + &mut self, + response: &mut M, + request: Self::RequestData, + ) -> Result<(), Self::BuildResponseError> { + let (block1, code) = request; + response.set_code(M::Code::new(code).map_err(CoapError::from_unionerror)?); + + if let Some(block1) = block1 { + response + .add_option_uint( + M::OptionNumber::new(coap_numbers::option::BLOCK1) + .map_err(CoapError::from_unionerror)?, + block1 as u32, + ) + .map_err(CoapError::from_unionerror)?; + } + Ok(()) + } +} + +pub async fn wait_for_update_request() -> Box<[u8]> { + SUIT_VERIFY_SIGNAL.wait().await +} + +#[ariel_os::task(autostart)] +async fn coap_task() { + let control = VmControl::new(); + + let handler = new_dispatcher() + .at_with_attributes(&["vm-control"], &[], control) + .with_wkc(); + + info!("Starting CoAP handler"); + coap_run(handler).await; +} diff --git a/examples/suit-updatable/suit/demo-private-key.pem b/examples/suit-updatable/suit/demo-private-key.pem new file mode 100644 index 0000000..f4c7568 --- /dev/null +++ b/examples/suit-updatable/suit/demo-private-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIElku2UCKhI/1THKz0MOMSSIen5pL/+0NT2erMAu7DkZoAoGCCqGSM49 +AwEHoUQDQgAEVNid8XAeLEShMlPgFYnEYuxAss5kGdRaWsSBdg2g1e0+mk4YH5+B +sgutpmRShgLjRRXLADLm+UMU6CzHTLMvvA== +-----END EC PRIVATE KEY----- diff --git a/examples/suit-updatable/suit/demo-public-key-p256.bin b/examples/suit-updatable/suit/demo-public-key-p256.bin new file mode 100644 index 0000000..05b6289 Binary files /dev/null and b/examples/suit-updatable/suit/demo-public-key-p256.bin differ diff --git a/examples/suit-updatable/suit/demo-public-key-p256.pem b/examples/suit-updatable/suit/demo-public-key-p256.pem new file mode 100644 index 0000000..e53f168 --- /dev/null +++ b/examples/suit-updatable/suit/demo-public-key-p256.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEVNid8XAeLEShMlPgFYnEYuxAss5k +GdRaWsSBdg2g1e0+mk4YH5+BsgutpmRShgLjRRXLADLm+UMU6CzHTLMvvA== +-----END PUBLIC KEY----- diff --git a/examples/suit-updatable/suit/manifest.json b/examples/suit-updatable/suit/manifest.json new file mode 100644 index 0000000..54b76be --- /dev/null +++ b/examples/suit-updatable/suit/manifest.json @@ -0,0 +1,14 @@ +{ + "components" : [ + { + "install-id" : ["01"], + "file": "payload.cwasm", + "uri": "coap://:5683/payload.cwasm", + "vendor-id": "cfbff0d1-9375-5685-968c-48ce8b15ae17", + "class-id": "2ecbf570-9780-5c2a-bef7-4da60942464e" + } + ], + "manifest-version": 1, + "manifest-sequence-number": 1 +} + diff --git a/rebuild-all-payloads.sh b/rebuild-all-payloads.sh index 5597cb6..5d2178a 100755 --- a/rebuild-all-payloads.sh +++ b/rebuild-all-payloads.sh @@ -17,5 +17,6 @@ cargo +nightly-2026-01-20 -Z script precompile_wasm.rs --path payloads/simple-up cp examples/simple-updates/*.cwasm examples/insecure-updates/ cp examples/async-bindings/payload.cwasm examples/updatable-async/async-payload.cwasm +cp examples/async-bindings/payload.cwasm examples/suit-updatable/payload.cwasm cargo +nightly-2026-01-20 -Z script precompile_wasm.rs --path payloads/sensors/Cargo.toml -o examples/fake-sensor/payload.cwasm --config payloads/.cargo/config.toml --toolchain +nightly-2026-01-20