August 21, 2026
7 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
Stage 3 of the leaf payload crypto work, part B of four. Pure relocation, no behaviour change, and the prerequisite the staged plan never named.
Why
ADR 0022 put four things in one bare-metal-capable crate, on the rule that anything both ends of a sealed conversation must agree on lives there, once. Planning the leaf crate found two more still in the engine, inside a private module a leaf node cannot reach:
- The control-frame prefixes. A frame's type is the prefix its content begins with. Two ends that disagree about one do not have a conversation, and it surfaces as a session that never comes up rather than as an error anyone can read.
KeyPackagePayload. The only channel in this protocol by which capabilities are advertised, which is the reason ADR 0021 gave for a device running MLS at all. A leaf builds it with the other MLS implementation.
Without this move the leaf crate has exactly the two options ADR 0022 named: copy them, or move them. The copy is the route that ADR calls the likely one, because it is three lines rather than a dependency, and it is precisely what tools/mls-interop did before the sealed crate existed.
What moves, and what deliberately does not
offline-protocol-sealed gains prefixes (the six a pair speaks: key package, Welcome, encrypted, the two confirmation frames, and the encrypted confirmation that only ever travels inside an envelope), plus KeyPackagePayload and MLS_ENVELOPE_COMPACT_V1.
Reservation stays in the engine. INTERNAL_PREFIXES is what refuses application content beginning with a reserved prefix, and it is still generated from the one macro invocation that names them, so adding a prefix remains a single-line change in a single place. The engine now names six of them rather than spelling them out.
The other prefixes stay too. Group, connection, relay, presence and the sealed bodies never reach a device, and moving them would put relay vocabulary in an image budgeted in kilobytes. The split is "what a pair needs", not "every prefix".
Pure relocation
No wire byte, JSON field, error string or FFI signature changes. Every engine use site is untouched because both come back under their existing names, and all 457 internal_prefixes:: references compile unchanged.
Two things did change while moving, both noted in the commit: doc links pointing at constants that stay in the engine (RICH_PAYLOAD_V1, DATA_SYNC_V1, RichPayloadV1) become plain code spans, since sealed cannot reference the engine; and em dashes in the moved prose are replaced per the house style.
Two guard tests come with the prefixes
prefixes_are_pinnedcompares every literal byte for byte. Nothing else in the workspace compares them to a literal any more, now that the engine names rather than spells them, and these are wire constants shared with an implementation that is not compiled against this crate.no_prefix_shadows_anotherproves no prefix is a prefix of another. That is a live near miss between__MLS_ENC__and__MLS_ENC_CONFIRM__: dispatch takes the first match, so an overlap routes a frame to the wrong handler.
Verification
cargo clippy --workspace --locked -- -D warnings clean
cargo test --workspace --lib 2468 passed, 0 failed
RUSTDOCFLAGS="-D warnings" cargo doc --workspace clean
cargo clippy -p offline-protocol-sealed --no-default-features \
--target thumbv8m.main-none-eabihf -- -D warnings clean
./scripts/check-crate-readmes.sh OK
./scripts/check-license-consistency.sh OK
Note cargo clippy --workspace --all-targets fails on unwrap_used in test code across transport, mls and core. That is pre-existing on main and untouched here; CI runs the command without --all-targets.
Docs
ADR 0022 gains a short "What joined the layer afterwards" subsection rather than an edit to its table, so the decision record still says what was decided and the reader still gets an accurate list. CLAUDE.md picks up the two items in its routing table, dependency diagram, and the never-write-a-second-one rule.
Next
- PR C: the
offline-protocol-leafcrate itself, which consumes all of this. - PR D: point
tools/embedded-footprintandtools/mls-interopat the crate, so the measured image is the shipping code rather than a workload that links onlyoffline-protocol-core.
Stacks on top of #397 (docs only, no file overlap).
Stage 3 of the leaf payload crypto work, part A of three. This is the docs-only part, and it closes a gap found while planning the crate: Stage 1's spec half never shipped. ADR 0021 landed with #394, but neither #394 nor #395 touched docs/spec/ at all, so the leaf profile and the provisioning chapter that ADR called for do not exist. An ADR is the record of a decision; it is not the contract an implementer builds against.
No code changes.
What lands
docs/spec/leaf-provisioning.md (new chapter). The framing is that a leaf is a peer rather than a class of peer: same frames, same envelope, same trust gates, no second sealing path. That is the whole reason ADR 0021 was affordable, and it is the first thing an implementer should read.
What is genuinely different is four obligations, each stated with the failure it prevents, because all four are invisible in a passing build and expensive on a bench:
| Obligation | The failure it prevents |
|---|---|
A static artifact carries {address, pubkey}, never a key package | An init key is single use and a sticker is not. The second scan surfaces as an establishment that silently does not converge, which is the collision ADR 0012 removed from the push path |
| A key package is minted from a supplied time | An implementation that cannot read a clock stamps a validity window at the Unix epoch, so the peer refuses it as expired and a device shipping that way never pairs at all |
| State is persisted before a frame that advanced it is emitted | A device that answers and then loses power comes back and reuses an AEAD nonce |
| Entropy is real hardware | MLS key generation is exactly as strong as what that symbol returns, and the measurement harnesses in this tree register a counter in that slot |
The chapter also specifies the never-committing profile (what a leaf emits, what it accepts, what it must never emit) and one sequence a device has to survive for post-compromise security to arrive at all: a phone-driven rekey reaches a device as a __MLS_KEY_PKG__ with session_reset set, not as an unsolicited Welcome. A device that treats it as an ordinary key package refresh keeps a session the phone has already discarded, and every later frame from it decrypts to nothing.
The leaf profile in capability-negotiation.md. What a minimal device advertises, plus two rules that are easy to get backwards on a part where every kilobyte is argued over: a device that advertises nothing still interoperates (empty selects the floor, and the floor is a complete conversation), and parsing stays unconditional on a device too (a leaf that decodes only the form it advertised drops frames from a peer that legitimately believed it capable).
Two threat-model entries for the device class. A8, the provisioning-time adversary who handles a device or its label before its owner does, which has no cryptographic answer because every cryptographic check passes: the key on the swapped label does derive to the address on that label. Its anchor is the one an invite already relies on, and what the protocol adds is that the substitution is detectable afterwards, because an address is stable and self-certifying. And R12, which says plainly that boundary 3's "platform secure storage holds" assumption means an OS keystore on a phone and whatever the part provides on a microcontroller. One device, one key: a fleet sharing an identity key turns one laboratory extraction into every unit's identity.
Stage 2 of the leaf-payload-crypto plan: the relocation that lets a leaf node
link what it needs. No behaviour change, no wire change, no UDL change.
Why
ADR 0021 decided a leaf node speaks
MLS through a second implementation, and that what makes it affordable is that
the two ends agree on everything outside their MLS libraries. Four things sit
in that gap, and all four were in crates that need std:
| Piece | Was in | Why a leaf needs it |
|---|---|---|
EncryptedMessage + compact codec | offline-protocol-mls | the wire form of every sealed payload, both directions |
derive_address | offline-protocol-mls | every trust gate is derive(presented_key) == claimed_address |
| Canonical signing payload | offline-protocol | a leaf signs and verifies control frames |
| Sender-ratchet bounds | offline-protocol-mls | both ends must configure the same two numbers |
So a leaf implementation had two options: copy them, or move them. The cost of
copying was already visible: tools/mls-interop carried two of these copies,
with a manifest comment saying nothing pinned them to the originals.
What this does
Adds offline-protocol-sealed, a dual std/no_std crate between core and mls,
holding all four. Everything else re-exports or delegates:
MlsManager::derive_address is one line, mls/types.rs and group.rs
re-export, the engine's build_canonical_payload delegates, and
offline_protocol_mls::canonical was deleted rather than left as a shim.
tools/mls-interop imports the derivation and the constants instead of
restating them.
A new crate rather than a core feature, for two reasons that were checked
rather than assumed: core links zero crypto (sha2 would reach every consumer,
including a relay-only image that never derives an address), and core's API
deliberately carries no MLS vocabulary. See
Notes for review
The plan's premise about the control-plane payload was wrong. It is not
copied in the tools: producer and verifier already shared one builder, and the
remaining duplication (relay server, two bridges) is with codebases a shared
crate cannot reach. For that piece this PR is relocation only. Likewise
Address::from_hash_bytes and all bech32m were already in core, so the only
crypto that moved is a single Sha256::digest.
Error identity is the part worth reading closely. SealedError repeats the
Display strings of the MlsError variants it replaced, and is deliberately
not #[non_exhaustive] so that From<SealedError> for MlsError stays
exhaustive: a wildcard arm is how a new variant silently starts rendering as
another error's text (ADR 0013's
failure). FieldTooLarge is its own variant because the engine renders it bare
into Error::Other while the MLS crate wraps it in Serialization, and both
strings had to stay identical.
The only source-level change is that the moved constructors return
SealedError rather than MlsError. From<SealedError> exists for both
MlsError and the engine's Error and passes the inner string through, so
rendered text, FFI error codes and wire bytes are unchanged. Visible only to
Rust code matching on the error type of EncryptedMessage::from_bytes,
from_base64 or GroupId::new.
Verification
cargo fmt --all -- --check, workspace clippy and the no-default-features
clippy, both under -D warnings
cargo test --workspace: 2481 tests, 23 binaries, green. The existing
cross-crate pins are unchanged and are the no-behaviour-change proof: the
base64-vs-compact disambiguation test, the discovery/invite golden vectors,
the signing-domain non-prefixing test, and the RFC 8032 address vector (kept
in the MLS crate as a delegation pin as well as in the new crate).
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps- Bare-metal clippy for
offline-protocol-coreandoffline-protocol-sealed
on thumbv8m.main-none-eabihf; CI's embedded-core job now gates both.
tools/mls-interopstill passes end to end on the sealed crate's derivation
and constants, which is what proves the derivation is byte-identical: the
phone's derive-and-compare gate accepts the leaf's credential.
tools/embedded-footprint/measure.shstill reports the protocol layer at
exactly 97,152 bytes, so ADR 0020's published figure holds.
The new guard test gave itself a negative control: it failed while the harness
still had its copies, and passed once they were replaced. It reads the harness
source because tools/mls-interop is its own cargo workspace, so nothing else
in cargo test --workspace can see it at all.
Not in this PR
The import_key_package unbounded-lifetime gap recorded in ADR 0021, and the
RAM gate, which needs a running device. Next is Stage 3, the
offline-protocol-leaf crate itself.
Phase 1 of the embedded work, and the answer to the question
left open:
> A leaf node's payload cryptography is a separate decision, still open.
It is closed, and not in the direction the ADR expected. **A leaf node runs
real MLS.** Nothing on the wire changes, the engine keeps its single sealing
path, and there is no weaker second protocol to design, deploy, or defend.
Why the premise was wrong
ADR 0020 said MLS key-schedule and ratchet-tree state does not fit beside a
vendor radio stack in 256 KB. That is true of OpenMLS, and true of large
groups. A phone paired with one device is neither: **two members, a three-node
ratchet tree**, roughly 2 KB of logical state. The frightening numbers attached
to MLS state are O(N) numbers, and N here is 2.
And RFC 9420 exists in no_std Rust. mls-rs carries no_std as a CI-gated
configuration built for thumbv6m-none-eabi, a weaker part than our M33, and
its RustCrypto provider supports CURVE25519_AES128, which is exactly the one
ciphersuite this SDK pins and never negotiates.
So the question stopped being "what weaker thing does a leaf speak" and became
"does the same thing fit". Two harnesses answer that, and both ship here,
because a decision resting on numbers should ship the thing that reproduces
them.
It fits
| Configuration | Flash | vs baseline |
|---|---|---|
| Application messages only, not shippable | 361.7 KiB | 360.6 KiB |
| Never-committing leaf, the candidate | 391.3 KiB | 390.2 KiB |
rfc_compliant, X.509 included, upper bound | 403.8 KiB | 402.7 KiB |
About a quarter of an xG24's flash for a whole leaf image, protocol layer
included, against a budget that also holds a radio stack and an application.
Roughly 111 KiB of that is P-384 and P-256 arithmetic nothing here uses,
linked because mls-rs-crypto-rustcrypto keeps all four curves in one
EcPrivateKey enum with no feature gating; enabling a suite is a runtime
filter, not a compile-time one. A curve-gated provider is worth about 28% of
the image and is not needed to clear the bar. That figure is symbol
attribution from the real image, not an estimate.
The protocol-layer number is unchanged at 94.9 KiB (97,152 bytes), so ADR
0020's "about 95 KB" still holds. The new binary was written to leave it alone,
and that was verified by measuring with the new sources stashed.
It interoperates, and that is the gate that mattered
New tools/mls-interop runs OpenMLS 0.7.4 against mls-rs 0.56.0, both pinned
with =, with this SDK's ciphersuite, group configuration, and credential
shape. A never-committing member's whole life: pair, join from a Welcome with
no out-of-band tree, an application message each way, a commit from the phone,
a message after it. Nothing published covered that pair before this.
0.1 a leaf whose clock leads the phone's is refused (InvalidLifetime)
0.2 a leaf with no clock at all, stamping 1970 is refused (InvalidLifetime)
0.3 mls-rs's one-year lifetime is ACCEPTED: OpenMLS defines a cap and never applies it
1. leaf identity derives to off1qxlh2y9vz72x4lmwjsknr98mwprdgnz9ysafxwzy
2. leaf key package, 312 bytes
3. phone parsed and validated it
4. derive(presented_key) == claimed address, on the phone's copy
5. phone created the group and committed the Add
6. leaf joined from the Welcome with no out-of-band tree
7. leaf decrypted the phone's application message
8. phone decrypted the leaf's answer
9. leaf processed the phone's commit
10. leaf decrypted in the new epoch
Two corrections, none signposted by either library
Every one produces a key package the phone refuses, and every one would
otherwise have surfaced on a bring-up bench.
not_before must be backdated. OpenMLS tests not_before < now, strictly,
while mls-rs's client builder writes not_before as exactly the timestamp it
is handed, without the backdating its own Lifetime::seconds helper applies.
A package stamped with the current second is refused for being not yet valid.
The timestamp must be supplied, not read. This one has consequences past
the call site. A bare-metal leaf has no clock, and mls-rs stamps
not_before = 0 when it cannot read one, with a source comment saying the
value exists so tests can run. A device shipping that way emits a validity
window in 1970 and is refused as expired. **A leaf therefore needs a time
source at pairing.** It costs availability rather than security, since key
package validity is a freshness bound and not an authentication mechanism, but
it has to be designed rather than discovered.
There is also a framing difference, smaller and not a correction to either
side: the SDK puts a bare KeyPackage on the wire while mls-rs's convenience
API returns one wrapped in an MLSMessage. Both legal. They have to agree.
A third one that turned out to be imaginary
This PR originally claimed a third correction: shorten mls-rs's one-year key
package lifetime, because "OpenMLS refuses any leaf node whose total lifetime
range exceeds one hour plus three months". **That is not true of OpenMLS
0.7.4.** It declares the bound (MAX_LEAF_NODE_LIFETIME_RANGE_SECONDS) and the
predicate that tests it (Lifetime::has_acceptable_range), and nothing in the
crate calls the predicate. KeyPackageIn::validate checks only the
not_before < now < not_after window and names that failure InvalidLifetime,
which is what made a year-long lifetime look like it was refused for its range
when it was really refused for its not_before.
The original negative control broke all three defaults at once, so it could not
tell those apart. Restoring them one at a time surfaced it on the first run.
The 28-day lifetime stays, because RFC 9420 asks an application to define a
maximum and it bounds how long an unused init key is usable, but it is
leaf-side policy, not an interop requirement, and it is now documented as
policy. Step 0.3 pins the behaviour and fails if OpenMLS wires its cap up.
This also says something about the phone. Because no cap is applied,
MlsManager::import_key_package admits a key package from any peer with an
arbitrarily long lifetime, where RFC 9420 asks an implementation to reject it.
It is a freshness bound rather than an authentication one, so it is not urgent,
but it is ours to enforce and no library is doing it for us. Recorded in ADR
0021 and deliberately not fixed here, so that this PR keeps touching
nothing in crates/. Worth its own issue.
What this buys, and how it compares
Not a smaller compromise: the absence of one. Forward secrecy,
post-compromise security, replay defence, sender authentication, the three
derive(presented_key) == claimed_address gates, and ADR 0010's unconditional
leaf identity binding apply to a leaf exactly as to a phone, because it is the
same protocol and the same checks.
For this device class that is not merely adequate. Continuous ratcheting is
something no shipped smart-home protocol provides: Z-Wave S2 distributes static
network-lifetime keys and its own specification argues against ever rotating
them, while Matter and Aliro do an ephemeral exchange at establishment and then
run a non-forward-secret fast path for most sessions. No regulation requires
better; "forward secrecy" does not appear in ETSI EN 303 645 at all.
It also removes a problem rather than solving it. A device with no MLS has no
way to advertise a capability, because advertisement rides in a key package and
nothing else, so every frame sent to it would fall to the JSON floor forever. A
device that does MLS mints a signed key package like any peer.
Negative controls
Both harnesses can fail, and I checked that they do rather than assuming it.
Footprint. The leaf images are fed bytes that are deliberately not a valid
Welcome, so "it stopped parsing" is not a signal there the way it is for
protocol. measure.sh counts mls_rs symbols per image and fails below
fifty. Gutting mls_workload produces FAIL: only 0 mls-rs symbols, then the
file was restored from a copy rather than with git checkout, since it is
untracked.
Interop. The step 0 lines restore one default each and require the phone to
refuse the result. Each correction is a default someone will eventually tidy
back, and a harness that only proves the corrected path works cannot tell them
they broke it.
Restoring them one at a time rather than all at once is what corrected this
PR's own account of itself, and is the change reviewers should look at first.
See below.
What is deliberately not proven
The RAM figure. MLS group state is heap-allocated, so .bss barely moves
and the working set is simply not in a link-time measurement. It needs a
running device. Called out in the ADR and both READMEs rather than papered
over.
The 111 KiB curve saving is symbol attribution, not a build with the curves
removed.
Anything past the MLS boundary. No radio, no fragmentation, no flash
persistence across a power cut, no timing on the part, and no exercise of the
SDK's envelope layer.
Two risks are accepted with open eyes and written into the ADR: mls-rs has had
no third-party security audit and its only no_std crypto provider is the one
its own authors label experimental, and an interop result covers exactly the
two versions it pinned.
Changes
docs/adr/0021-a-leaf-node-speaks-mls.md(new). The decision, the
numbers, the three key package corrections, the obligations a device picks up
(a time source at pairing, durable state before emission, a real entropy
source), and a "what would undo this" naming a second sealing path.
- ADR 0020 closing paragraph now points at 0021 instead of leaving the
question open; ADR index updated.
tools/mls-interop/(new, own workspace, pinned versions).tools/embedded-footprint/: third image behind optional features, a
custom getrandom backend for linking, symbol guard, --core-only, README.
- CI: new
mls-interopjob. The footprint job needs no change;measure.sh
picks up the new images itself.
Nothing in crates/ is touched, which is why the workspace is untouched by
construction.
Verification
cargo clippy --workspace --locked -- -D warnings; cargo fmt --all --check;
cargo test --workspace --lib (2,459 passed, 0 failed);
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps;
`cargo clippy -p offline-protocol-core --no-default-features --target
thumbv8m.main-none-eabihf -- -D warnings`; both harnesses green with
--locked; both negative controls confirmed firing.
Next
Stage 2 is a relocation PR with no behaviour change: EncryptedMessage's
compact codec, derive_address, and the control-plane signing payload move to
a home reachable from both the engine and a no_std leaf. One open question
for that PR: whether that home is a new crate between core and mls, or a
feature on core. Core's documented doctrine is that it "never touches key
material", which argues for the new crate. Stage 3 is the offline-protocol-leaf
crate itself.
Phase 0 of the embedded work. offline-protocol-core now builds for
thumbv8m.main-none-eabihf, and the question "does this fit on a Cortex-M"
has a number instead of an argument.
| Measurement | Bytes | KiB |
|---|---|---|
| Baseline firmware (runtime, allocator, panic handler) | 1,128 | 1.1 |
With offline-protocol-core linked | 98,280 | 96.0 |
| Protocol layer, flash | 97,152 | 94.9 |
| Protocol layer, static RAM | 0 | 0.0 |
About 95 KiB, roughly 6% of an xG24's 1536 KB flash, and no static RAM beyond
the heap a node provisions for itself.
What changed and why it is small
The port is minor because core's std dependence was concentrated in three
places: constructors that mint state from the platform, one module of
poison-recovering lock helpers, and two HashMap field types. Everything that
parses, validates, re-encodes or compares was already portable in substance.
std is a feature, on by default. The published API and every existing
consumer are unchanged, which is why workspace clippy and all 2,459 lib tests
pass untouched.
std gates exactly what a bare-metal target cannot supply: a wall clock
(Timestamp::now, WallClockTimestamp::now), a monotonic clock
(LocalInstant), entropy (MessageId::new, and Message::new and
MessageBuilder with it), and threads (the sync module, also the crate's
only tracing consumer). A leaf node needs none of them, because it receives
frames rather than minting them.
Three things that will look like clutter later
Each is load-bearing, and ADR 0020 names the failure each one prevents.
Seven dependencies are declared locally rather than inherited. A member
crate *cannot* drop default features from a { workspace = true } dependency:
cargo accepts default-features = false beside it and silently ignores it, so
an inherited serde keeps pulling serde/std and the bare-metal build fails
with errors pointing at the dependency instead of the cause. A guard test fails
if the local versions drift from the workspace table.
uuid/v4 moved into the std feature. v4 pulls getrandom, which has no
backend on bare metal.
MetadataMap replaced two HashMap<String, String> field types. Under
std it *is* HashMap, so nothing downstream moves. Without it, BTreeMap,
because HashMap's default hasher seeds from entropy that is not there. This
cannot reach the wire in either direction: JSON objects are unordered by
definition, and the binary v1 codec carries metadata as an ordered
Vec<(String, String)>.
chrono is also gone from this crate. It backed two
Utc::now().timestamp_millis() calls, and SystemTime gives the same i64
once the pre-epoch case is spelled out rather than left to wrap.
The measurement
tools/embedded-footprint links two images and reports the delta, so the
vector table, allocator and panic handler that any firmware pays for do not
land in the protocol's column. The baseline allocates once on purpose: without
a live allocation the linker drops the heap array, .bss reads zero in the
baseline and 16 KiB in the protocol image, and the harness reports its own heap
as a protocol cost.
The workload is the receiving half: decode JSON, re-encode as binary wire v1,
decode that, re-encode as JSON, parse an address and check it is canonically
spelled, run the identifier policy. Everything passes through black_box,
without which LTO deletes the workload and the image measures the same as the
baseline.
Negative-controlled: Message::from_wire_v1_bytes is 10,468 bytes of the
result and 312 protocol symbols survive into the image, against 0 in the
baseline. The version-drift guard test was also confirmed to fail on a
deliberately mismatched version and pass again on restore.
What this is not
It does not make MLS run on a leaf node and should not be read as promising
that. OpenMLS is not a no_std crate, and MLS key-schedule and ratchet-tree
state does not fit beside a vendor radio stack in 256 KB. A leaf node's payload
cryptography is a separate, still-open decision.
The figure excludes signature verification and payload unsealing, the radio
driver, an RTOS and key storage. It is not the engine, which is std and
tokio bound and does not build for this target at all.
Rot gate
New embedded-core CI job. Nothing else in the workspace compiles core without
std, so a stray use std:: would otherwise pass every existing check and
surface on hardware. It also builds core standalone with default features,
which is not redundant with workspace clippy: a missing std re-add is
invisible inside the workspace, where another member's features paper over it
through feature unification, and would otherwise appear only during
cargo publish.
Size is report-only, printed to the job summary. A size threshold that fails a
build invites tuning the threshold.
Verification
All green locally: cargo fmt --all -- --check; `cargo clippy --workspace
--locked -- -D warnings; cargo clippy -p offline-protocol
--no-default-features; cargo test --workspace --lib` (2,459 passed, 0
failed); RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps;
cargo +1.87.0 check --workspace --all-targets (MSRV) and the same on the
bare-metal target; bare-metal build and clippy; and `cargo package -p
offline-protocol-core`, which builds the crate standalone from its own tarball
and is the real publish-isolation check.
What
Two new Criterion benches, mls_seal_dm and mls_open_dm, filling the term the engine send-path benchmark deliberately excludes: send_message opts out of encryption (SEC-M3 would fail-close the send otherwise), so its 3.86 us is the dispatch cost only, and the MLS crypto cost per message has never been measured.
Numbers (Apple Silicon core, in-memory key store)
| Bench | Time |
|---|---|
mls_seal_dm (encrypt a 5-byte DM over an established 1:1 session) | 79.5 us |
mls_open_dm (decrypt on the receiving side) | 91.3 us |
The seal dominates the dispatch path by ~20x, so a full encrypted send is about 83 us of software time end to end. Still far below the airtime of the message's own 4 BLE fragments, which is the claim these numbers exist to back.
Why the shapes are what they are
- The open bench feeds a fresh ciphertext through
iter_batchedevery iteration. The ratchet refuses replays, so a reused ciphertext would time an error return — exactly the trapsend_messagefell into before #391 (a confident 535 ns that was measuring a rejection). Both benchesexpect()instead of swallowing theResult, so a send that stops short of the crypto fails the run rather than quietly timing something else. - The plaintext is the same 5-byte DM the wire-size record measures (551 bytes / 4 BLE fragments), so the size table and the cost table describe one message.
- The session pair is built the way production does it: identity minted first, manager constructed at the derived address, key-package import, session create, Welcome join.
Caveats
- Storage is the in-memory test store; on device the session-state writes land in a platform keystore, so these are the crypto cost, not crypto plus persistence. Stated in the bench's module docs.
- Not an EFR32 measurement.
Verification
cargo bench --package offline-protocol-bench --bench mls_encryptionruns green with the numbers above.cargo clippy --workspace -- -D warningsandcargo fmt --all -- --checkpass. (cargo clippy -p offline-protocol-bench --benchesfails on main already, independent of this change: compiling bench targets pulls the transport crate'stest-utilsMockTransport code underunwrap_used. Same class as the known--all-targetsbreakage.)- No CHANGELOG entry, following #391's bench-only precedent.
Four benchmarks have not been measuring anything. message_throughput and protocol_performance guarded their real bodies behind #[cfg(test)] and paired each with an empty stub:
#[cfg(not(test))]
fn bench_send_message(_c: &mut Criterion) {
// Disabled - requires MockTransport which is test-only
}
A bench target never sees the transport crate's own cfg(test), so MockTransport was unreachable and send_message, process_loop, protocol_start_stop and transport_send_receive compiled to empty functions. cargo bench reported success throughout.
This PR reaches MockTransport the supported way instead, by enabling the transport crate's test-utils feature in the bench crate's dev-dependencies, exactly as offline-protocol already does for its own tests. The cfg gates and their stubs are deleted, so a benchmark that cannot compile now fails the build rather than silently measuring nothing.
It also swaps the deprecated criterion::black_box for std::hint::black_box across all five benches, which criterion 0.8 warns on.
Review correction: one benchmark was still hollow
The first revision of this PR un-gated the four benchmarks, but send_message came back measuring an error return rather than a send, and the table below originally reported that as a 535 ns result.
The bench builds its engine from a stock ProtocolConfig. Encryption fail-closes by default (SEC-M3), and the bench never calls initialize_mls(), so every iteration took the "MLS required but not initialized" arm in prepare_outbound_content and returned EncryptFailed before a Message was ever constructed. No DORS selection, no dispatch, no transport. The .ok() on the result swallowed it. The unit tests already avoid this trap: create_test_config() opts out of the fail-closed default and says why.
Fixed in 0717c2c9: a shared setup helper carries the require_encryption = false opt-out, and the result is expected rather than discarded, so a send that stops short of the transport now fails the run instead of quietly timing something else.
While there, transport_send_receive was never a round trip. MockTransport::send records outbound frames and does not loop them back, so receive popped an empty queue every iteration. It now feeds the queue and asserts the message returns.
The four revived benchmarks
Measured on an Apple Silicon core with --measurement-time 3. Not a target-hardware measurement.
| Benchmark | Result | Note |
|---|---|---|
send_message (full engine path) | 3.86 µs | was reported as 535 ns while it measured the rejection path |
process_loop | 1.26 µs | |
protocol_start_stop | 2.39 µs | |
transport_send_receive (round trip) | 1.59 to 1.74 µs | now a real round trip |
For reference, protocol_creation (never gated) sits at 1.69 µs.
Unrelated CI fix carried in this branch
80c0e615 adds a crate-root #![allow(clippy::large_const_arrays)] to offline-protocol-uniffi.
GitHub's stable toolchain rolled over to Rust 1.98, which promotes clippy::large_const_arrays into the set that our -D warnings turns into a hard error. UniFFI emits its metadata buffer as exactly that kind of array, in offline_protocol.uniffi.rs, which the build script generates and include_scaffolding! splices into the crate. There is no source line to annotate, so the allow has to sit at the crate root.
This affects every open PR and main itself; main only still looks green because it has not been re-run since the toolchain bump. Reproduced locally with rustup run 1.98.0 cargo clippy --workspace --locked -- -D warnings, which fails on ebeebb6e and passes with this commit.
Related issues
None.
Type of change
-
fix— bug fix
Checklist
-
cargo fmt --all -- --checkpasses -
cargo clippy --workspace -- -D warningspasses (verified on both 1.97.1 and 1.98.0) -
cargo test --workspace --libpasses (2,458 tests, 0 failures) - All five bench targets compile and run (
cargo bench --package offline-protocol-bench) -
cargo-denyis satisfied (no dependency changes beyond a feature flag on an existing workspace member) - Commits follow Conventional Commits
- Docs /
CHANGELOG.mdupdated where relevant — no entry, matching theda98f480precedent: the bench crate ispublish = falseand nothing about the shipped SDK changes - No new
unsafe - No UDL change
Breaking changes
None. The test-utils feature is enabled only in the bench crate's dev-dependencies, so it cannot reach a normal build of any published crate.
One caveat on the numbers above: they come from a laptop core, so treat them as protocol-overhead ceilings rather than target-hardware figures.