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..c951b66e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -242,6 +242,7 @@ archived by series under [docs/changelog/](docs/changelog/); see the 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 @@ -250,6 +251,27 @@ archived by series under [docs/changelog/](docs/changelog/); see the 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 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 + 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. + ## [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/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..c95a2cbf 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 55 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,12 +70,32 @@ 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. - -About a third of the `leaf` image is dead weight that a better crypto provider +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: 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. + +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 9d31fd38..ee7106ed 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)" ) @@ -90,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 @@ -111,15 +121,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..02a1f92b 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 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 + // 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(())