From 34ff319652337d7b5fbd1c6e027901c7ecdd247f Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 21 Aug 2026 22:31:38 +0530 Subject: [PATCH 1/2] refactor(bench): measure the leaf image that would actually ship The footprint harness's leaf image drove mls-rs directly, which meant it linked the MLS calls and nothing around them: no envelope codec, no control-frame signing, no address derivation. It priced an image nobody could ship. It now runs `offline-protocol-leaf`, so the number covers the code a device runs. The figure moves from 390.2 KiB to 435.7 KiB of flash, a little over a quarter of a 1536 KiB xG24. That is the honest number and it is worth being plain about how it relates to the 400 KiB in ADR 0021: that was a decision gate, set to answer whether MLS on a leaf node was viable at all before anything was built, and it did that job. It is not a budget the shipping image is being held to. The recovery lever recorded beside it is still worth more than the growth: about 111 KiB of this image is P-384 and P-256 arithmetic nothing uses, linked because the provider keeps four curves in one enum with no feature gating. The `leaf-min` image is deleted rather than fixed. It priced application messages with the resilience features off, and the crate requires all four mls-rs features, so cargo's unification made the variant measure the same bytes as `leaf`: two rows, one image, and a reader with no way to tell. A row reporting a number for a configuration nobody can build is worse than no row, and this is the second time a gate in this tree has gone vacuous without saying so. `tools/mls-interop` is deliberately NOT converted, and the plan that said it would be was working from a premise that does not hold. The harness operates at the MLS layer, trading key package bytes and MLS messages; `LeafDevice` operates at the frame layer. Converting it would force the harness to build signed control frames on the PHONE side too, and its phone is raw OpenMLS rather than the SDK engine, so that means a second implementation of the engine's frame building living in a harness. That is the exact failure ADR 0022 names, and the exact way this harness acquired its copies last time. The frame layer is covered instead by the leaf crate's own tests, which run a real MlsManager phone against the device in one process; the harness keeps doing the job only it can do, which is two pinned MLS libraries meeting out of process. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 24 +++- tools/embedded-footprint/Cargo.lock | 29 ++++ tools/embedded-footprint/Cargo.toml | 19 ++- tools/embedded-footprint/README.md | 45 ++++-- tools/embedded-footprint/measure.sh | 12 +- tools/embedded-footprint/src/bin/leaf.rs | 169 +++++++++++------------ 7 files changed, 192 insertions(+), 108 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 427ce203..0ed3b470 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -267,7 +267,7 @@ jobs: - name: Clippy the footprint harness working-directory: tools/embedded-footprint run: | - for features in "" "leaf-min" "leaf" "leaf-full"; do + for features in "" "leaf" "leaf-full"; do cargo clippy --release --locked --target thumbv8m.main-none-eabihf \ ${features:+--features "$features"} -- -D warnings done diff --git a/CHANGELOG.md b/CHANGELOG.md index 7437e6f5..c340f5c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,8 +241,28 @@ archived by series under [docs/changelog/](docs/changelog/); see the a bare-metal node could not produce a `Message` at all. `Message::new` delegates to it, so there is one struct literal rather than two that drift. -### Changed -- **The envelope codec and `GroupId::new` now return `SealedError`** rather +- **The embedded footprint harness measures the shipping crate.** Its leaf + image drove mls-rs directly, so it linked neither the envelope codec, nor the + control-frame signing, nor the address derivation: it priced an image nobody + could ship. It now runs `offline-protocol-leaf`, and the figure moved from + **390.2 KiB to 435.7 KiB** of flash, a little over a quarter of a 1536 KiB + xG24. + + The 400 KiB figure in [ADR 0021](./docs/adr/0021-a-leaf-node-speaks-mls.md) + was a decision gate, set to answer whether MLS on a leaf node was viable + before anything was built, and it did that job. It is not a budget the + shipping image is held to, and the recovery lever recorded beside it is still + worth more than the growth: about 111 KiB of the image is P-384 and P-256 + arithmetic that nothing uses, linked because the crypto provider keeps all + four curves in one enum with no feature gating. + + The `leaf-min` image is gone. It priced application messages with the + resilience features off, and once the workload moved onto the crate, which + requires all four mls-rs features, cargo's feature unification made it + measure the same bytes as `leaf`. A row reporting a number for a + configuration nobody can build is worse than no row. + +### Changed- **The envelope codec and `GroupId::new` now return `SealedError`** rather than `MlsError`, having moved into `offline-protocol-sealed`. Nothing else changes: `From` exists for both `MlsError` and the engine's `Error` and passes the inner message through, so every rendered error string, diff --git a/tools/embedded-footprint/Cargo.lock b/tools/embedded-footprint/Cargo.lock index b7d459d1..51cf2c00 100644 --- a/tools/embedded-footprint/Cargo.lock +++ b/tools/embedded-footprint/Cargo.lock @@ -387,6 +387,7 @@ dependencies = [ "mls-rs", "mls-rs-crypto-rustcrypto", "offline-protocol-core", + "offline-protocol-leaf", ] [[package]] @@ -757,6 +758,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "offline-protocol-leaf" +version = "0.23.0" +dependencies = [ + "base64", + "mls-rs", + "mls-rs-core", + "mls-rs-crypto-rustcrypto", + "offline-protocol-core", + "offline-protocol-sealed", + "serde", + "serde_json", + "thiserror", + "zeroize", +] + +[[package]] +name = "offline-protocol-sealed" +version = "0.23.0" +dependencies = [ + "base64", + "offline-protocol-core", + "serde", + "serde_json", + "sha2", + "thiserror", +] + [[package]] name = "once_cell" version = "1.21.4" diff --git a/tools/embedded-footprint/Cargo.toml b/tools/embedded-footprint/Cargo.toml index b925ab04..22af0bbe 100644 --- a/tools/embedded-footprint/Cargo.toml +++ b/tools/embedded-footprint/Cargo.toml @@ -13,6 +13,10 @@ description = "Measures what offline-protocol-core costs in flash and RAM on a C [dependencies] # The configuration a leaf node links: no std, no clock, no entropy. offline-protocol-core = { path = "../../crates/offline-protocol-core", default-features = false } +# The leaf image measures the shipping crate rather than a hand-written +# workload against mls-rs, so the number covers the envelope codec, the +# control-frame signing and the address derivation a device really links. +offline-protocol-leaf = { path = "../../crates/offline-protocol-leaf", default-features = false, optional = true } cortex-m-rt = "0.7.5" # The MLS half, optional because the two original binaries measure the protocol @@ -29,13 +33,22 @@ getrandom = { version = "0.2", default-features = false, features = [ [features] # Shared by every leaf configuration below. Never selected on its own: each # variant adds the mls-rs feature set that names it. -leaf-base = ["dep:mls-rs", "dep:mls-rs-crypto-rustcrypto", "dep:getrandom"] +leaf-base = ["dep:offline-protocol-leaf", "dep:mls-rs", "dep:mls-rs-crypto-rustcrypto", "dep:getrandom"] # What a never-committing leaf actually needs. Application messages are # `PrivateMessage`, so `private_message` is not optional; `out_of_order` and # `prior_epoch` are what let a node on a lossy radio still decrypt when frames # arrive late or straddle a commit, which is the normal case on this hardware, # not an edge one. +# +# These are restated here rather than left to `offline-protocol-leaf`, which +# already requires all four, so that this manifest still says what the leaf +# profile is. There used to be a `leaf-min` variant below them, pricing +# application messages with the resilience features off. It was removed when +# the workload moved onto the crate: the crate requires the four, cargo unifies +# features, and the variant silently started measuring the same image as this +# one. A row that reports a number for a configuration nobody can build is +# worse than no row. leaf = [ "leaf-base", "mls-rs/private_message", @@ -44,10 +57,6 @@ leaf = [ "mls-rs/by_ref_proposal", ] -# The floor: application messages and nothing else. Not a shippable -# configuration, measured to show what the resilience features above cost. -leaf-min = ["leaf-base", "mls-rs/private_message"] - # The ceiling: everything RFC 9420 asks of a full implementation, X.509 # credential support included. A leaf uses basic credentials, so this is an # upper bound for the report rather than a candidate. diff --git a/tools/embedded-footprint/README.md b/tools/embedded-footprint/README.md index 64e4e6dc..678bf02d 100644 --- a/tools/embedded-footprint/README.md +++ b/tools/embedded-footprint/README.md @@ -22,11 +22,24 @@ shape a shipping artifact has: JSON frame, re-encode it as binary wire v1, decode that, re-encode as JSON, parse an address and verify it is canonically spelled, and run the identifier policy. -- `leaf` is the same plus MLS: mint a key package, join from a Welcome, open - what arrives, seal an answer, and persist. It is built three times, against - the smallest mls-rs feature set that works, the set a real leaf needs, and - the full `rfc_compliant` set, because the spread between them is the part - that is actually a choice. +- `leaf` is the same plus a whole device: it drives `offline-protocol-leaf`, + which provisions an identity, mints a key package, handles an inbound frame, + and seals an answer. It is built twice, against the feature set a real leaf + needs and against the full `rfc_compliant` set, because the spread between + them is the part that is actually a choice. + + It measures the shipping crate rather than a hand-written workload against + mls-rs, and that changed the number. The earlier version called mls-rs + directly and so linked neither the envelope codec, nor the control-frame + signing, nor the address derivation: it priced an image nobody could ship. + The figure below is about 45 KiB larger for that reason and is the honest + one. + + There used to be a third image, `leaf-min`, pricing application messages with + the resilience features off. It is gone: `offline-protocol-leaf` requires all + four mls-rs features, cargo unifies features, and the variant silently began + measuring the same bytes as `leaf`. A row reporting a number for a + configuration nobody can build is worse than no row. The reported figure is the **delta**, so it excludes the runtime cost that any firmware pays whether or not it speaks this protocol. The leaf images report two @@ -57,10 +70,24 @@ For the `leaf` images specifically, two more things are missing and neither is small. **Heap is the first.** MLS group state is allocated, not static, so `.bss` barely moves and the working-set figure is simply not in this measurement; it has to come from running the thing. **Interoperability is the -second.** These images are linked and never executed, and the MLS calls are fed -bytes that are not a real Welcome, so nothing here says the stack can talk to -the phone. `tools/mls-interop` is what answers that, and it is the gate that -matters more. +second.** These images are linked and never executed, and the frame handed to +the device is an ordinary text message rather than a Welcome or a sealed +envelope, so nothing here says the stack can talk to the phone. +`tools/mls-interop` is what answers that, along with the in-process tests in +the leaf crate, and those are the gates that matter more. + +## Where the leaf image stands against the 400 KiB gate + +ADR 0021 set a 400 KiB gate to decide whether MLS on a leaf node was viable at +all, and the Stage 0 spike cleared it at 390.2 KiB. The shipping image measured +here is larger, because it links the code a device actually runs rather than +the MLS calls alone. The gate did its job, which was to answer a yes-or-no +question before anything was built; it is not a budget the shipping image is +being held to, and the number that matters now is the one this harness prints. + +On a 1536 KiB xG24 the shipping image is a little over a quarter of flash, and +the recovery lever below is worth more than the growth if a product ever needs +it back. About a third of the `leaf` image is dead weight that a better crypto provider would remove: `mls-rs-crypto-rustcrypto` holds all four curves in one diff --git a/tools/embedded-footprint/measure.sh b/tools/embedded-footprint/measure.sh index 9d31fd38..5b39137c 100755 --- a/tools/embedded-footprint/measure.sh +++ b/tools/embedded-footprint/measure.sh @@ -78,8 +78,7 @@ printf "| Configuration | Flash | vs baseline | vs protocol only |\n" printf "|---|--:|--:|--:|\n" declare -a LEAF_ROWS=( - "leaf-min:application messages only (not shippable)" - "leaf:never-committing leaf (candidate)" + "leaf:never-committing leaf, the shipping profile" "leaf-full:rfc_compliant, X.509 included (upper bound)" ) @@ -111,15 +110,16 @@ done if (( leaf_measured )); then cat <<'NOTE' -The candidate row is the number that answers "does it fit": on a 1536 KiB xG24 +The first row is the number that answers "does it fit": on a 1536 KiB xG24 that is the whole leaf image, protocol layer included, against a part that also has to hold a radio stack and an application. Two things this does not measure. Heap is the first: MLS group state is allocated, not static, so `.bss` stays flat here and the working-set figure has to come from running the thing, not linking it. Interoperability is the second: -these images are linked and never executed, and the MLS calls are fed bytes -that are not a real Welcome, so this says nothing about whether the stack talks -to the phone's OpenMLS. That question has its own harness. +these images are linked and never executed, and the frame handed to the device +is an ordinary text message rather than a Welcome, so this says nothing about +whether the stack talks to the phone's OpenMLS. That question has its own +harness, and so do the leaf crate's own tests. NOTE fi diff --git a/tools/embedded-footprint/src/bin/leaf.rs b/tools/embedded-footprint/src/bin/leaf.rs index f4e2dbd3..99e4443c 100644 --- a/tools/embedded-footprint/src/bin/leaf.rs +++ b/tools/embedded-footprint/src/bin/leaf.rs @@ -20,13 +20,23 @@ //! # What this image is not //! //! It is linked and measured, never executed, exactly like the other two. The -//! MLS calls below are fed `black_box`ed bytes that are not a real Welcome and -//! not a real ciphertext, so at runtime each would return `Err`. That is sound -//! for a code-size measurement, because the optimiser cannot prove the failure -//! and links every path, and it is worthless as a functional test. Proving that -//! this stack interoperates with the phone's OpenMLS is a separate exercise -//! with a separate harness. The guard against the measurement silently hollowing -//! out is the symbol count in `measure.sh`, not this file. +//! frame handed to the device below is an ordinary text message rather than a +//! Welcome or a sealed envelope, so at runtime the interesting arms would +//! return early. That is sound for a code-size measurement, because the +//! optimiser cannot prove which arm runs and links every path, and it is +//! worthless as a functional test. Proving that this stack interoperates with +//! the phone's OpenMLS is a separate exercise with a separate harness +//! (`tools/mls-interop`, plus the in-process tests in the leaf crate itself). +//! The guard against the measurement silently hollowing out is the symbol +//! count in `measure.sh`, not this file. +//! +//! # What it measures now +//! +//! The whole of `offline-protocol-leaf`, which is the code a device runs. An +//! earlier version of this file drove mls-rs directly and therefore linked +//! neither the envelope codec, nor the control-frame signing, nor the address +//! derivation: it priced an image nobody could ship. The figure is larger for +//! that reason and is the honest one. #![no_std] #![no_main] @@ -40,23 +50,12 @@ extern crate alloc; use embedded_footprint as _; use alloc::string::ToString; -use alloc::vec; +use alloc::sync::Arc; use alloc::vec::Vec; use core::hint::black_box; use cortex_m_rt::entry; use offline_protocol_core::{validate_id_chars, Address, Message, UserId}; - -use mls_rs::identity::basic::{BasicCredential, BasicIdentityProvider}; -use mls_rs::identity::SigningIdentity; -use mls_rs::time::MlsTime; -use mls_rs::{CipherSuite, CipherSuiteProvider, Client, CryptoProvider, MlsMessage}; -use mls_rs_crypto_rustcrypto::RustCryptoProvider; - -/// The suite the SDK pins in exactly one place -/// (`offline-protocol-mls/src/group.rs`). A leaf that negotiated anything else -/// could not talk to a phone, so the provider below is built with this one -/// enabled and the other three left out of the image. -const CIPHERSUITE: CipherSuite = CipherSuite::CURVE25519_AES128; +use offline_protocol_leaf::{LeafDevice, LeafStore, StoreError}; /// A real message, produced by the SDK on the host and pasted here. Both /// codecs round-trip it, which was checked before it was embedded. @@ -68,10 +67,6 @@ const SAMPLE_JSON: &str = r#"{"id":"ea22cfba-7e3f-4820-9ba5-fe33dd9ef33e","sende /// The sender from `SAMPLE_JSON`, canonically spelled. const SAMPLE_ADDRESS: &str = "off1qyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyr4s29s"; -/// Stands in for a frame arriving off the radio. Not a valid MLS message; see -/// the module note on why that does not affect what gets linked. -const INBOUND: &[u8] = &[0u8; 128]; - /// When the device was paired, in seconds since the epoch. /// /// This is a parameter of the workload rather than something read from a clock @@ -108,74 +103,78 @@ fn protocol_workload() { black_box(UserId::new(black_box(SAMPLE_ADDRESS)).is_ok()); } -/// Everything the device does with MLS, in the order it does it. -fn mls_workload() -> Option<()> { - // Only the pinned suite is enabled, so the other three curves never reach - // the image. - let crypto = RustCryptoProvider::with_enabled_cipher_suites(vec![CIPHERSUITE]); - let suite = crypto.cipher_suite_provider(CIPHERSUITE)?; - - // The device's long-term signature key. On real hardware this is generated - // once at provisioning and lives in whatever key storage the part offers, - // not regenerated per boot as it is here. - let (secret, public) = suite.signature_key_generate().ok()?; - - // The credential content is the device's own `off1` address, which is the - // shape the SDK already requires of every leaf credential. - let credential = BasicCredential::new(SAMPLE_ADDRESS.as_bytes().to_vec()); - let signing_identity = SigningIdentity::new(credential.into_credential(), public); - - let client: Client<_> = Client::builder() - .identity_provider(BasicIdentityProvider) - .crypto_provider(crypto) - .signing_identity(signing_identity, secret, CIPHERSUITE) - .build(); - - // Pairing: the device mints one key package for the phone to consume. This - // is the artifact a QR code must never carry, because an MLS init key is - // single-use and a sticker is not. - // - // The timestamp is supplied, not read. See `PAIRED_AT`: the `None` this - // call would otherwise take is the 1970 trap, and a workload that models - // the device's order of operations should not model the refused version of - // its first step. - if let Ok(key_package) = client.generate_key_package_message( - Default::default(), - Default::default(), - Some(MlsTime::from(PAIRED_AT)), - ) { - if let Ok(bytes) = key_package.to_bytes() { - black_box(&bytes); - } +/// A store that holds nothing. +/// +/// Enough to link every path through the crate's storage seam, and nothing a +/// device would ship: `load` always answers "not there", so the workload +/// provisions a fresh identity on every call rather than resuming one. Real +/// firmware implements this over the part's secure key storage, and owes the +/// durability and per-entry atomicity the trait documents, because that is +/// what keeps a power cut from rolling a ratchet back onto a used nonce. +struct NullStore; + +impl LeafStore for NullStore { + fn store(&self, _key_type: &str, _key_id: &str, _data: &[u8]) -> Result<(), StoreError> { + Ok(()) } - // Joining: the phone commits the Add and hands back a Welcome. - let welcome = MlsMessage::from_bytes(black_box(INBOUND)).ok()?; - let (mut group, info) = client.join_group(None, &welcome, None).ok()?; - black_box(&info); + fn load(&self, _key_type: &str, _key_id: &str) -> Result>, StoreError> { + Ok(None) + } - // Steady state: open what arrives (application messages, and the commits - // the phone issues), answer, and persist before the answer is emitted. - if let Ok(inbound) = MlsMessage::from_bytes(black_box(INBOUND)) { - if let Ok(received) = group.process_incoming_message(inbound) { - black_box(&received); - } + fn delete(&self, _key_type: &str, _key_id: &str) -> Result<(), StoreError> { + Ok(()) } +} - let answer = group - .encrypt_application_message(black_box(b"unlocked"), Vec::new()) - .ok()?; +/// Everything the device does, in the order it does it. +/// +/// This goes through `offline-protocol-leaf` rather than reaching for mls-rs +/// directly, which is what makes the figure below a measurement of the code a +/// device would actually run. An earlier version of this file called mls-rs +/// itself and linked neither the envelope codec nor the control-frame signing +/// nor the address derivation, so it priced an image nobody could ship. +fn mls_workload() -> Option<()> { + let store: Arc = Arc::new(NullStore); + + // Provisioning draws from the getrandom backend this harness registers, + // which is a counter. On hardware that symbol is the part's TRNG, and the + // device's identity is exactly as strong as what it returns. + let device = LeafDevice::open(store, black_box("com.example.lock")).ok()?; + black_box(device.address()); + + // Pairing: the device mints one key package and signs the frame carrying + // it. The timestamp is supplied rather than read, because a bare-metal + // device has no clock and the library stamps 1970 when it tries to find + // one. See `PAIRED_AT`. + if let Ok(advertisement) = device.key_package_frame(black_box(SAMPLE_ADDRESS), PAIRED_AT) { + if let Ok(json) = advertisement.to_json() { + black_box(&json); + } + } - // A leaf that emits before its ratchet state is durable will reuse an AEAD - // nonce after a power cut, which is a confidentiality failure and not a - // delivery hiccup. So the persist goes here, between sealing the answer and - // handing it to the radio, and the `black_box` below is the emit it has to - // come before. Writing the two in the other order would link the same code - // and teach the wrong thing to whoever uses this file as a skeleton. - group.write_to_storage().ok()?; + // Steady state: a frame arrives off the radio, is parsed by the protocol + // layer, and is handed to the device, which verifies it, opens it if it is + // sealed, persists, and hands back whatever it owes in reply. + if let Ok(inbound) = Message::from_json(black_box(SAMPLE_JSON)) { + if let Ok(handled) = device.handle(&inbound, PAIRED_AT) { + black_box(&handled.events); + for frame in &handled.outbound { + if let Ok(json) = frame.to_json() { + black_box(&json); + } + } + } + } - if let Ok(bytes) = answer.to_bytes() { - black_box(&bytes); + // Answering. The persist is inside `seal`, before it returns anything, so + // there is no ordering here for a reader of this file to get wrong: the + // crate does not offer the sealed bytes until the state behind them is + // durable. + if let Ok(answer) = device.seal(black_box(SAMPLE_ADDRESS), black_box("unlocked"), PAIRED_AT) { + if let Ok(json) = answer.to_json() { + black_box(&json); + } } Some(()) From f0785f7b5ef3d775702a23620209bcedbd3a1ffe Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Sun, 23 Aug 2026 00:50:27 +0530 Subject: [PATCH 2/2] fix(bench): measure a leaf image that compiles against merged #399 This branch was written and measured against #399 as it stood before review, then rebased onto the merged version. The harness source came through that rebase untouched, which is precisely the problem. Review turned `key_package_frame`, `seal` and `handle` from `&self` into `&mut self`, and put another ~3200 lines into the crate. So the rebased harness does not build at all (E0596 on a non-mut binding), and every flash figure it reported describes a leaf crate that no longer exists. A footprint harness quoting numbers from a tree you cannot check out is worse than one that quotes nothing. One `mut`, and re-measure: 391.3 KiB to 445.6 KiB, not 435.7. While correcting it, the old comparison turns out to have mixed columns, 390.2 being the delta above baseline and 435.7 the whole linked image, so it understated its own growth by a baseline. Both ends are whole-image figures now. While at it, the hollowing-out guard only counted mls-rs symbols, which is the half of the image that did not change. The entire point of this PR is that the leaf crate is linked, and that property had nothing watching it: a workload drifting back onto mls-rs directly would drop tens of kilobytes and pass the guard the whole way. It counts both halves now, and I checked that it fails when it should rather than trusting that it would. Also un-fuse `### Changed` from the bullet it had swallowed in the changelog, and file the harness entry under it rather than under Added. --- CHANGELOG.md | 24 +++++++++++--------- tools/embedded-footprint/README.md | 20 ++++++++++------ tools/embedded-footprint/measure.sh | 29 ++++++++++++++++-------- tools/embedded-footprint/src/bin/leaf.rs | 2 +- 4 files changed, 47 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c340f5c9..c951b66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,12 +241,22 @@ archived by series under [docs/changelog/](docs/changelog/); see the a bare-metal node could not produce a `Message` at all. `Message::new` delegates to it, so there is one struct literal rather than two that drift. +### Changed + +- **The envelope codec and `GroupId::new` now return `SealedError`** rather + than `MlsError`, having moved into `offline-protocol-sealed`. Nothing else + changes: `From` exists for both `MlsError` and the engine's + `Error` and passes the inner message through, so every rendered error string, + every FFI error code and every wire byte is what it was. This is visible only + to Rust code that matches on the error type of `EncryptedMessage::from_bytes`, + `EncryptedMessage::from_base64` or `GroupId::new` directly. + - **The embedded footprint harness measures the shipping crate.** Its leaf image drove mls-rs directly, so it linked neither the envelope codec, nor the control-frame signing, nor the address derivation: it priced an image nobody - could ship. It now runs `offline-protocol-leaf`, and the figure moved from - **390.2 KiB to 435.7 KiB** of flash, a little over a quarter of a 1536 KiB - xG24. + could ship. It now runs `offline-protocol-leaf`, and the whole-image figure + moved from **391.3 KiB to 445.6 KiB** of flash, a little over a quarter of a + 1536 KiB xG24. The 400 KiB figure in [ADR 0021](./docs/adr/0021-a-leaf-node-speaks-mls.md) was a decision gate, set to answer whether MLS on a leaf node was viable @@ -262,14 +272,6 @@ archived by series under [docs/changelog/](docs/changelog/); see the measure the same bytes as `leaf`. A row reporting a number for a configuration nobody can build is worse than no row. -### Changed- **The envelope codec and `GroupId::new` now return `SealedError`** rather - than `MlsError`, having moved into `offline-protocol-sealed`. Nothing else - changes: `From` exists for both `MlsError` and the engine's - `Error` and passes the inner message through, so every rendered error string, - every FFI error code and every wire byte is what it was. This is visible only - to Rust code that matches on the error type of `EncryptedMessage::from_bytes`, - `EncryptedMessage::from_base64` or `GroupId::new` directly. - ## [0.23.0] — 2026-08-20 > **The protocol carries state now, not only messages.** A new data layer adds diff --git a/tools/embedded-footprint/README.md b/tools/embedded-footprint/README.md index 678bf02d..c95a2cbf 100644 --- a/tools/embedded-footprint/README.md +++ b/tools/embedded-footprint/README.md @@ -32,7 +32,7 @@ shape a shipping artifact has: mls-rs, and that changed the number. The earlier version called mls-rs directly and so linked neither the envelope codec, nor the control-frame signing, nor the address derivation: it priced an image nobody could ship. - The figure below is about 45 KiB larger for that reason and is the honest + The figure below is about 55 KiB larger for that reason and is the honest one. There used to be a third image, `leaf-min`, pricing application messages with @@ -79,17 +79,23 @@ the leaf crate, and those are the gates that matter more. ## Where the leaf image stands against the 400 KiB gate ADR 0021 set a 400 KiB gate to decide whether MLS on a leaf node was viable at -all, and the Stage 0 spike cleared it at 390.2 KiB. The shipping image measured -here is larger, because it links the code a device actually runs rather than -the MLS calls alone. The gate did its job, which was to answer a yes-or-no -question before anything was built; it is not a budget the shipping image is -being held to, and the number that matters now is the one this harness prints. +all, and the Stage 0 spike cleared it: 391.3 KiB linked, of which 390.2 KiB sat +above the baseline firmware. The shipping image measured here is larger, +because it links the code a device actually runs rather than the MLS calls +alone. The gate did its job, which was to answer a yes-or-no question before +anything was built; it is not a budget the shipping image is being held to, and +the number that matters now is the one this harness prints. On a 1536 KiB xG24 the shipping image is a little over a quarter of flash, and the recovery lever below is worth more than the growth if a product ever needs it back. -About a third of the `leaf` image is dead weight that a better crypto provider +Figures quoted in prose, here and in `CHANGELOG.md`, are a snapshot rather than +a contract: nothing pins rustc for this harness, so a compiler upgrade moves +them with nothing announcing it. The table `measure.sh` prints is the source of +truth. The figures standing here were taken on rustc 1.97.1. + +About a quarter of the `leaf` image is dead weight that a better crypto provider would remove: `mls-rs-crypto-rustcrypto` holds all four curves in one `EcPrivateKey` enum with no feature gating, so P-384 and P-256 arithmetic link even when only ciphersuite 3 is enabled. That is roughly 111 KiB, measured by diff --git a/tools/embedded-footprint/measure.sh b/tools/embedded-footprint/measure.sh index 5b39137c..ee7106ed 100755 --- a/tools/embedded-footprint/measure.sh +++ b/tools/embedded-footprint/measure.sh @@ -89,16 +89,27 @@ for row in "${LEAF_ROWS[@]}"; do cargo build --release --locked --quiet --features "${feature}" f=$(flash "${OUT}/leaf") - # The guard the sample-honesty problem needs. If MLS ever stops being - # linked (a workload that optimises away, a dependency that silently drops - # out), the flash number falls toward `protocol` and reads as an + # The guard the sample-honesty problem needs. If either half ever stops + # being linked (a workload that optimises away, a dependency that silently + # drops out), the flash number falls toward `protocol` and reads as an # improvement. A symbol count cannot be fooled that way. - symbols=$("${LLVM_NM}" "${OUT}/leaf" 2>/dev/null | grep -c "mls_rs" || true) - if (( symbols < 50 )); then - echo "FAIL: only ${symbols} mls-rs symbols in the ${feature} image." >&2 - echo "The workload stopped linking MLS; the number below is not a footprint." >&2 - exit 1 - fi + # + # Both halves are counted, because they fall out independently. The leaf + # crate is the one this harness exists to price: a workload that drifted + # back onto mls-rs directly would link the envelope codec, the + # control-frame signing and the address derivation no more than the + # version this replaced did, drop tens of kilobytes, and pass an mls-rs + # count the whole way. + for probe in "mls_rs:MLS" "offline_protocol_leaf:the leaf crate"; do + symbol="${probe%%:*}" + what="${probe#*:}" + symbols=$("${LLVM_NM}" "${OUT}/leaf" 2>/dev/null | grep -c "${symbol}" || true) + if (( symbols < 50 )); then + echo "FAIL: only ${symbols} ${symbol} symbols in the ${feature} image." >&2 + echo "The workload stopped linking ${what}; the number below is not a footprint." >&2 + exit 1 + fi + done awk -v f="${f}" -v bf="${base_flash}" -v pf="${prot_flash}" -v l="${label}" 'BEGIN { k = 1024 diff --git a/tools/embedded-footprint/src/bin/leaf.rs b/tools/embedded-footprint/src/bin/leaf.rs index 99e4443c..02a1f92b 100644 --- a/tools/embedded-footprint/src/bin/leaf.rs +++ b/tools/embedded-footprint/src/bin/leaf.rs @@ -140,7 +140,7 @@ fn mls_workload() -> Option<()> { // Provisioning draws from the getrandom backend this harness registers, // which is a counter. On hardware that symbol is the part's TRNG, and the // device's identity is exactly as strong as what it returns. - let device = LeafDevice::open(store, black_box("com.example.lock")).ok()?; + let mut device = LeafDevice::open(store, black_box("com.example.lock")).ok()?; black_box(device.address()); // Pairing: the device mints one key package and signs the frame carrying