logs.gokuls.in

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_pinned compares 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_another proves 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-leaf crate itself, which consumes all of this.
  • PR D: point tools/embedded-footprint and tools/mls-interop at the crate, so the measured image is the shipping code rather than a workload that links only offline-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:

ObligationThe failure it prevents
A static artifact carries {address, pubkey}, never a key packageAn 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 timeAn 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 emittedA device that answers and then loses power comes back and reuses an AEAD nonce
Entropy is real hardwareMLS 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:

PieceWas inWhy a leaf needs it
EncryptedMessage + compact codecoffline-protocol-mlsthe wire form of every sealed payload, both directions
derive_addressoffline-protocol-mlsevery trust gate is derive(presented_key) == claimed_address
Canonical signing payloadoffline-protocola leaf signs and verifies control frames
Sender-ratchet boundsoffline-protocol-mlsboth 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

ADR 0022.

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-core and offline-protocol-sealed

on thumbv8m.main-none-eabihf; CI's embedded-core job now gates both.

  • tools/mls-interop still 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.sh still 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

ADR 0020

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

ConfigurationFlashvs baseline
Application messages only, not shippable361.7 KiB360.6 KiB
Never-committing leaf, the candidate391.3 KiB390.2 KiB
rfc_compliant, X.509 included, upper bound403.8 KiB402.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-interop job. 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.

MeasurementBytesKiB
Baseline firmware (runtime, allocator, panic handler)1,1281.1
With offline-protocol-core linked98,28096.0
Protocol layer, flash97,15294.9
Protocol layer, static RAM00.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)

BenchTime
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_batched every iteration. The ratchet refuses replays, so a reused ciphertext would time an error return — exactly the trap send_message fell into before #391 (a confident 535 ns that was measuring a rejection). Both benches expect() instead of swallowing the Result, 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_encryption runs green with the numbers above.
  • cargo clippy --workspace -- -D warnings and cargo fmt --all -- --check pass. (cargo clippy -p offline-protocol-bench --benches fails on main already, independent of this change: compiling bench targets pulls the transport crate's test-utils MockTransport code under unwrap_used. Same class as the known --all-targets breakage.)
  • 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.

BenchmarkResultNote
send_message (full engine path)3.86 µswas reported as 535 ns while it measured the rejection path
process_loop1.26 µs
protocol_start_stop2.39 µs
transport_send_receive (round trip)1.59 to 1.74 µsnow 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 -- --check passes
  • cargo clippy --workspace -- -D warnings passes (verified on both 1.97.1 and 1.98.0)
  • cargo test --workspace --lib passes (2,458 tests, 0 failures)
  • All five bench targets compile and run (cargo bench --package offline-protocol-bench)
  • cargo-deny is satisfied (no dependency changes beyond a feature flag on an existing workspace member)
  • Commits follow Conventional Commits
  • Docs / CHANGELOG.md updated where relevant — no entry, matching the da98f480 precedent: the bench crate is publish = false and 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.