Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SealedError>` exists for both `MlsError` and the engine's
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions tools/embedded-footprint/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 14 additions & 5 deletions tools/embedded-footprint/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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.
Expand Down
55 changes: 44 additions & 11 deletions tools/embedded-footprint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 26 additions & 15 deletions tools/embedded-footprint/measure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
)

Expand All @@ -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
Expand All @@ -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
Loading
Loading