logs.gokuls.in

4 pull requests merged across 1 repo

Offline-Protocol/offline-protocol-sdk

and #402, the two split-outs from the #399 review that were left open.

Two commits, independent of each other, both from the leaf work and both touching what a phone and a device have to agree on.

#396: a key package's validity window is bounded, in both directions

RFC 9420 puts this on the application: define a maximum total lifetime and reject any key package claiming more. Nothing here did. OpenMLS looks like it does the job and does not, declaring MAX_LEAF_NODE_LIFETIME_RANGE_SECONDS and shipping Lifetime::has_acceptable_range while KeyPackageIn::validate calls neither. What it checks is whether *now* falls between the two ends, which a package claiming a century satisfies on every day of the century. An imported package is cached in the install-scoped store and re-read for as long as it validates, so nothing else ever aged it out: one leaked init key stayed a working way to open a session as its owner, permanently.

MAX_ACCEPTED_KEY_PACKAGE_LIFETIME is the bound, applied at import and on every read of the contact cache. The cache read is not belt-and-braces, for the same reason verify_address_binding runs in both places: an entry written to that store out of band never passed the import gate.

The issue's premise turned out to be wrong, and it changed the fix. It said we mint 30 days. We were putting 84-day windows on the wire while documenting 30. KeyPackage::builder() was never told a lifetime, so OpenMLS applied its default of three months plus an hour, and DEFAULT_KEY_PACKAGE_LIFETIME_SECS governed only when the local record stopped being offered, not the window every other install judges. Measured, not read:

WIRE LIFETIME: range_secs=7261200 range_days=84.04
openmls cap = 7261200;  margin left = 0

That is also why the cap is 90 days rather than the constant OpenMLS declares. Three months plus an hour is *exactly* what an unconfigured build emits, so a cap set there admits every package this SDK has ever minted by a margin of zero seconds and refuses any peer whose skew allowance is a second wider.

So the commit fixes both ends: the mint now states 30 days, and two compile-time assertions hold both of our own lifetimes under the cap, because the alternative failure is a pairing that dies in the field on the one device that cannot report why.

tools/mls-interop had pinned the *absence* of this cap as correct behaviour. It now splits openmls_admits from phone_admits and asserts both halves, so the refusal is known to be ours and the cap cannot be deleted while the harness is green.

Upgrade impact: none for ordinary peers. 90 days admits every package any released version has put on the wire, and a leaf's 28 days clears it three times over. A peer on a stack that defaults to a year (mls-rs) is refused at import with an InvalidKeyPackage naming both widths.

#402: a leaf answers the frames it receives

A leaf owed no acknowledgement, so every frame a phone sent it ran the full retry ladder: ten retransmissions of a sealed frame over about thirteen minutes. Each arrived as a replay of a spent generation, so the device refused it correctly and firmware saw a run of LeafError::Mls indistinguishable from a deliberate replay attack. The one signal that would tell an integrator they are under attack was buried under traffic the protocol generated itself.

The issue left the direction open. The arithmetic decides it: an acknowledgement is empty content and one metadata entry, and what it prevents is ten full sealed envelopes. It is also the only way an application ever learns that the command it sent to a lock arrived.

Three rules go with it, each closing something answering naively would open:

  • Only a peer it holds a record for. Otherwise a stranger in radio range gets a way to make the device transmit on demand and an answer to "is there a node at this address", which against a lock is the first question worth asking.
  • Only what it accepted. An Err is never answered: a receipt handed to whoever just failed the signature gate tells them their frames are being processed. The phone's own rule.
  • A frame that arrives twice is answered twice, without being opened again. The answer is the frame most likely to have been lost, and the copy cannot be opened because the ratchet spent that generation. Bounded at four ids by flash, not by correctness: past it a replay is refused exactly as before, deliberately, because absorbing every replay would trade one invisible attack for another.

The memory is a list where a session reset needed only an integer, and the asymmetry is the point: a reset's timestamp is inside the signature, so a high-water mark over it cannot be moved, while a sealed frame's id and timestamp sit outside the AEAD and a watermark over either could be parked in the future by anyone in range.

ACK_FOR_KEY moves to offline-protocol-sealed, which is what that module is for: a value both ends must spell identically with nothing failing to build when they stop. The two ends are then tested in their own crates against that one declaration, including a new engine test that an acknowledgement carrying only ack_for settles the outbox and reports zero hops over BLE, which are the defaults a leaf relies on for the two entries it deliberately does not write.

Leaf image: 445.9 KiB to 448.4 KiB.

Also corrected

ADR 0023 and freshness.rs justified the 30-day freshness window partly on "a published key package's 30-day validity", which rested on the same misreading. Both now distinguish what we mint (30 days) from what we admit (90), and note that a published record is signed under the older payload so the window does not gate it either way.

ADR 0021's footprint table still showed the Stage 0 spike's 391.3 KiB as the current answer to "does it fit", ~55 KiB stale since #400. It is now labelled as the decision record and points at measure.sh for the live figure, matching how the harness README already frames it.

Verification

  • cargo clippy --workspace -- -D warnings, cargo test --workspace, cargo fmt --all -- --check, RUSTDOCFLAGS="-D warnings" cargo doc all clean.
  • All three no_std gates (core, sealed, leaf on thumbv8m.main-none-eabihf). The leaf gate caught a real break in this branch: a to_string() that only resolves under std.
  • tools/mls-interop passes, with step 0.3 now asserting both halves of the cap.
  • Every new mechanism negative-controlled: removing the cap enforcement fails three tests; removing the explicit mint lifetime fails the window assertion with the old 84-day number; removing the ack emission fails three; removing the known-peer gate fails three (two pre-existing); removing the repeat path fails two; renaming the shared constant fails the interop-harness guard.
  • One mesh_forwarding flake seen once under full parallel load and not reproducible in 18 targeted runs across this branch and main. That is the known pre-existing flake, not this branch.

Stage 3 of the leaf payload crypto work, part D of four. Stacked on #399.

The measurement was pricing something unshippable

The footprint harness's leaf image drove mls-rs directly, so it linked the MLS calls and nothing around them: no envelope codec, no control-frame signing, no address derivation. It now runs offline-protocol-leaf, so the number covers the code a device actually runs.

Flashvs baselinevs protocol only
never-committing leaf, the shipping profile445.6 KiB444.5 KiB349.7 KiB
rfc_compliant, X.509 included (upper bound)457.7 KiB456.6 KiB361.7 KiB

The whole-image figure moved from 391.3 KiB to 445.6 KiB, a little over a quarter of a 1536 KiB xG24.

> Re-measured after review. The table above first went up with numbers taken before this branch

> was rebased onto the merged #399. That version of the crate had &self where the merged one has

> &mut self, so the rebased harness did not compile, and its figures priced a leaf crate ~3200 lines

> lighter than the one that shipped. Both are fixed in f0785f7b. The earlier comparison also mixed

> columns, 390.2 being the delta above baseline against 435.7 for the whole linked image; both ends

> are whole-image figures now.

Worth being plain about how that relates to the 400 KiB in ADR 0021: that was a decision gate, set to answer whether MLS on a leaf node was viable at all before anything was built, and it did that job. It is not a budget the shipping image is being held to. The recovery lever recorded beside it is still worth more than the growth: about 111 KiB of this image is P-384 and P-256 arithmetic nothing uses, linked because the crypto provider keeps all four curves in one enum with no feature gating.

leaf-min is deleted, not fixed

It priced application messages with the resilience features off. offline-protocol-leaf requires all four mls-rs features, cargo unifies features, and so the variant silently began measuring the same bytes as leaf: two rows, one image, and a reader with no way to tell.

A row reporting a number for a configuration nobody can build is worse than no row. This is the second time a gate in this tree has gone vacuous without announcing it, which is why the removal is called out here rather than done quietly.

tools/mls-interop is deliberately not converted

My plan said this PR would move the harness's leaf side onto the crate. That premise does not hold, and I'd rather say so than force it through.

The harness operates at the MLS layer, trading key package bytes and MLS messages. LeafDevice operates at the frame layer. Converting the harness would force it to build signed control frames on the *phone* side too, and its phone is raw OpenMLS rather than the SDK engine, so that means a second implementation of the engine's frame building living inside a harness. That is the exact failure ADR 0022 names, and the exact way this harness acquired its copies last time.

The frame layer is covered instead by the leaf crate's own tests (#399), which run a real MlsManager phone against the device in one process. The harness keeps doing the job only it can do: two pinned MLS libraries meeting out of process. It still passes untouched, all ten steps plus the three step-0 negative controls.

The remaining duplication is two lines (unwrapping the MLS message to a bare key package), and both copies are exercised against a real OpenMLS phone, so a drift would fail somewhere rather than pass everywhere. The values that could drift silently, the backdate and the lifetime, already come from offline-protocol-sealed.

Verification

./tools/embedded-footprint/measure.sh                      green, table above
cargo clippy --release --locked --target thumbv8m.main-none-eabihf \
    (base / --features leaf / --features leaf-full)        clean
cd tools/mls-interop && cargo run --release --locked       PASS, unchanged

The CI clippy loop drops leaf-min alongside the feature itself, so the job lints exactly the variants that exist.

Stage 3 of the leaf payload crypto work, part C of four. Stacked on #398 (base is that branch, so the diff here is the crate alone).

ADR 0021 decided that a leaf node runs real MLS through a second implementation, and measured that it fits: 390 KiB of flash for the candidate profile, about a quarter of an xG24. This is the crate.

offline-protocol-leaf is dual std/no_std and sits on core and sealed only, never on the engine or the MLS crate, because nothing above sealed builds without std.

LeafDevice is a state machine, not a bag of primitives

That was a choice. The alternative, exposing mint/join/seal/open and leaving the sequence to firmware, means every integrator re-derives the reset teardown, the confirmation that has to be a group-aware decrypt, and the gates below, then discovers on a bench which of them they got wrong. An inbound message goes in; the frames to send and what happened come out.

What it refuses is the substance

GateWhy it is a refusal and not a warning
A control frame's signing key must derive to the address the frame claimsAn identifier that is not an address is the same refusal rather than a skip: a claim with no derivation to check is not one to wave through, it is the bypass
A key package body must name the peer that signed the frame carrying itOtherwise a package is relayed under a borrowed name
A Welcome must name that peer and be for the group this pair would buildOtherwise a relayed Welcome puts the device in a room it never chose
A Welcome must spend the key package this device minted for that peerA key package is unencrypted on the air, so a copy is as spendable as the original and every other gate passes for the copier honestly. Checked before the join, because the join is what spends the init key
A commit must leave the group a pairThe Welcome gate runs once, and a commit changes the roster without changing the group id
An inbound __MLS_CONFIRM_ACK__ is never evidence of a sessionA leaf emits acknowledgements and never probes, so every inbound one is unsolicited. The phone gates the same frame on holding a session; acting on it would let any keypair holder tell firmware a session exists that the device would refuse to seal into
A sealed frame's MLS sender must be the peer the frame came fromRe-derived from the group member's own signature key. That is ADR 0010's binding, applied on the device so both ends are the same

Persist-before-emit is structural, not documented

Every operation that advances ratchet state writes through LeafStore and only then returns the frame, so a store that fails produces an error and no frame at all. A device that emitted first would come back from a power cut and reuse an AEAD nonce, which is a confidentiality failure and not a lost message.

No MLS state is cached in the device value either: every operation loads the group from storage. That costs a load per frame and buys a device with nothing in RAM for a power cut to desynchronize from flash.

The mls-rs storage traits are adapted internally rather than exposed, and their write ordering carries what an atomic transaction would: epoch records first, group state last, so a cut mid-write leaves the old state beside records it does not reference, rather than a new state whose prior epochs were never written, which is exactly the out-of-order tolerance a lossy radio needs.

Four obligations stay with the integrator

The API is shaped so none can be forgotten quietly. Every entry point needing a clock takes now_unix_secs, because mls-rs stamps not_before = 0 when it cannot read one and the peer then refuses the package as expired, so a device that ships that way never pairs at all. The crate registers no getrandom backend, because doing so would let firmware link and run with entropy this crate invented. LeafStore must be atomic per entry.

The fourth is authorization, and it is the one a test cannot fail for you. Every gate above answers "is this peer the address it claims to be" and none answers "did the owner mean this peer". Any address in radio range can complete a pairing, so a lock that opens for whatever arrives on an established session opens for anyone patient enough to pair with it, and every frame in that exchange verifies. Firmware decides when the radio accepts a pairing and what a given peer may actuate; LeafDevice::peers is how it audits what accumulated, since a reboot loses whatever the events said.

Tests: a real phone, in the same process

Thirty-eight tests run an actual OpenMLS MlsManager against this mls-rs device: pair, talk both ways, driven rekey through the session_reset sequence, replay refusal, power-cycle resume, every gate in the table above, and a negative control that arms a failing store and asserts both that no frame is produced and that the write was actually attempted, so it cannot pass by short-circuiting somewhere earlier.

One of these caught a mistake in my own test rather than the crate: the rekey test failed until it modelled the engine's real ordering, where the phone tears down its own session *before* sending the reset.

Message::from_parts in core

Message::new with its clock and its entropy made explicit. ADR 0020 made core build without std on the reading that a constrained node "receives frames rather than minting them", which is true of one that only forwards and false the moment one answers. Without this a bare-metal node cannot produce a Message at all, since the struct has a private field and no other constructor is reachable. new delegates to it, so there is one struct literal rather than two that drift.

The CI job earned its keep immediately

The bare-metal job gains the same three steps core and sealed have. mls-rs implements Display on its error only under std, so four {e} formats compiled on the host and failed for the device. That is precisely the class of bug this gate exists for, caught before merge.

bare-metal-rng is a new feature that selects getrandom's custom backend, which the target needs because getrandom has none for it and refuses to compile otherwise. It supplies no randomness; the firmware still registers the implementation.

Verification

cargo test -p offline-protocol-leaf                   39 passed + 1 doctest
cd tools/mls-interop && cargo run --release --locked     PASS
cargo test --workspace --lib                          2469 passed, 0 failed
cargo clippy --workspace --locked -- -D warnings      clean
RUSTDOCFLAGS="-D warnings" cargo doc --workspace      clean
cargo fmt --all -- --check                            clean
cargo build/clippy -p offline-protocol-leaf --no-default-features \
    --features bare-metal-rng --target thumbv8m.main-none-eabihf   clean
rustup run 1.87 cargo check --workspace --all-targets --locked     clean
./scripts/check-crate-readmes.sh / check-license-consistency.sh    OK
./scripts/generate-third-party-notices.sh                          no drift

Two risks from the plan, both checked rather than assumed. mls-rs enters the workspace lockfile for the first time: MSRV 1.87 passes (mls-rs declares 1.82), and its license is Apache-2.0 OR MIT, already on the allowlist. Notices drift: none, because the leaf crate is not in the uniffi dependency graph.