feat(leaf,core): a door lock speaks this protocol, not a smaller one - #399
Open
bahdotsh wants to merge 9 commits into
Open
feat(leaf,core): a door lock speaks this protocol, not a smaller one#399bahdotsh wants to merge 9 commits into
bahdotsh wants to merge 9 commits into
Conversation
bahdotsh
force-pushed
the
feat/leaf-node-crate
branch
from
August 21, 2026 17:21
7ae21f1 to
2ec262c
Compare
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. It runs the never-committing member profile: the phone creates
the group, adds the device and issues every commit, while the device joins,
opens what arrives, answers and persists.
`LeafDevice` is a frame-level state machine rather than a bag of
primitives. That was a choice, and the reason is that the choreography is
the security-critical part. 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, and
gets to discover on a bench which of them they got wrong.
What it refuses is the substance:
- A control frame must carry a signature whose key derives to the address
the frame claims. An 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 it.
- A Welcome must name that peer AND be for the group this pair would build,
or a relayed Welcome puts the device in a room it never chose.
- A sealed frame's MLS sender must be the peer the frame came from, checked
by re-deriving 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 rather than 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, which costs a load per frame and buys a device with
nothing in RAM for a power cut to desynchronize.
The mls-rs storage traits are adapted internally rather than exposed. 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.
Three obligations stay with the integrator, and the API is shaped so none
can be forgotten quietly. Every entry point needing a clock takes
`now_unix_secs`, because mls-rs stamps 1970 when it cannot read one and the
peer then refuses the package as expired: 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.
Sixteen tests run a real OpenMLS phone against this mls-rs device in one
process: pair, talk both ways, driven rekey through the session_reset
sequence, replay refusal, power-cycle resume, every gate 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.
Also adds `Message::from_parts` to 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 it 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, so there is one struct literal rather than two.
The bare-metal CI job gains the same three steps core and sealed have, and
earned its keep immediately: mls-rs implements Display on its error only
under std, so four `{e}` formats compiled on the host and failed for the
device.
bahdotsh
force-pushed
the
feat/leaf-node-crate
branch
from
August 21, 2026 17:22
2ec262c to
bb3ad35
Compare
Review of the new leaf crate turned up a handful of things that are all the same shape: fine on a bench, and a problem on a door lock that has to run for years without anyone touching it. The probe answer was the worst. A leaf acknowledged a confirmation probe whether or not it still had a session, and a peer treats that acknowledgement as proof the session is usable: it confirms and flushes everything it had queued into it. A device that lost its store therefore confirmed a session it could not decrypt one frame of, and then went quiet, which from the peer's side is indistinguishable from a quiet link. The phone has always gated its own answer on holding a session. Now the device does too, and the test that asserted the old behaviour was asserting the bug. Prior-epoch records were kept forever. It turns out mls-rs leaves retention to the storage provider — its own in-memory one trims to three — and this provider trimmed to nothing at all. That is flash filling up on a part that has a few hundred kilobytes of it, and it is every epoch's secrets sitting in there while it happens, so "how far out of order a message may arrive" had quietly become "how far back a stolen device reads". Trim to a window on write, and sweep the lot on unpair, which until now deleted the group state and the marker and left the actual secrets behind. Under a name the next session answers to, no less, since a pair's group id is derived from the two addresses and does not change on a re-pair. Provisioning wrote the secret first and the public key second. A cut between them left `resume` refusing for the missing public key and `provision` refusing for the present secret, and `open` has no third door. One power cut on a device's very first boot and it answers every call with an error, forever. Writing the secret last makes it the completion marker, and makes the torn state one the next boot simply overwrites. Everything that advances state now takes `&mut self`. Two seals racing loaded the same generation and emitted both frames under one AEAD nonce, which is exactly the failure this crate's whole persist-before-emit rule exists to prevent, reached without anyone losing power at all. A compile error is cheaper than a paragraph asking people not to do that. While at it: a Welcome is checked against the group it actually joined rather than the one its body claimed, a reset frame is acted on once so a captured one is not a repeatable teardown, and peer records and unspent key packages are bounded, because producing a frame that derives to its own address costs an attacker nothing. A full peer table refuses a stranger rather than evicting somebody the owner actually paired with. That reset dedup bounds a repeat and does not close replay. Nothing in the signed payload says *when*, so a frame older than the ring can still be spent once. Closing it is a freshness field on the wire and a change to both ends, so it goes in the spec as an open gap rather than getting quietly papered over here.
Review of this branch found one gate standing open and three smaller things that make a device harder to diagnose than it needs to be. The gate. An inbound __MLS_CONFIRM_ACK__ produced SessionEstablished for whoever sent it, with no check that a session existed. A leaf emits acknowledgements and never probes, so it never has one outstanding and every inbound one is unsolicited. The phone has always gated the same frame on holding a session of its own, and the profile in the spec lists that prefix under what a leaf emits rather than under what it accepts. Since producing a frame that derives to its own address costs an attacker nothing, this let anyone in range tell firmware a session exists that the device would then refuse to seal into. Same shape as an unsolicited connection_accepted, and it had no test at all, which is how it survived being written. Authorization is now the fourth obligation rather than an unstated one. Every gate in this crate answers "is this peer the address it claims to be", and none of them 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. That is firmware's call, and firmware can only make it if the crate says so. peers() is the accessor that goes with it: a reboot loses whatever the events said, and a bound nobody can read afterwards is not one an owner can act on. A group that would not load was reported as a missing session however it failed. Absent state is a device that never paired, and re-pairing repairs it. Present-but-unloadable state is a store handing back bytes this device did not write, and reporting that as a missing session sends a bench after the one repair that cannot work. The unpair sweep anchored at zero when its marker did not decode, which deleted one record, returned Ok, and left the rest of the epochs' secrets on flash under a name the next session with that peer answers to. The group state names the same epochs and is about to be deleted anyway, so it is the fallback anchor. The two gaps that are not device-side are issues now rather than comments: 402 for the acknowledgement asymmetry that has a phone retrying every frame until it gives up, each retry landing here as a replay refusal firmware cannot tell from an attack, and 403 for the missing freshness field that leaves a captured control frame verifying forever.
The storage adapter said a power cut mid-write "costs nothing". That is true of the epoch records. It is not true of the marker sitting beside them, and the marker is the one that matters. mls-rs sequences every epoch insert against max_epoch_id: the id has to be exactly one above what storage reports, or the insert is refused. Nothing in this crate caches either value, deliberately, so both come off flash on every operation. Land the marker, lose power before the state follows, and the two disagree forever. The retry offers the epoch id the marker has already counted, so every commit from that point on is refused. Reversing the order does not help, it moves the same wedge into the other window. On a door lock that is not a dropped frame. The device stops opening anything its peer sends until the peer's own recovery gives up and drives a full reset, and while that plays out firmware sees an error stream it cannot tell from an attack. So the marker goes inside the state entry, where the seam's per-entry atomicity covers both or neither. The separate high-water record stays, because it has a different job: it outlives the state and bounds the erasure sweep on unpair, which is the one thing the in-state marker cannot do. Three smaller ones from the same pass. `peers()` is documented as the authorization audit surface and did not list a peer that paired through a Welcome, which is the ordinary route; a session nobody can enumerate is one nobody can revoke. A confirmation probe was answered on bytes being present rather than loadable, which confirms a session the device cannot open a single frame of, and that is precisely what the gate was written to prevent. And `resume` trusted the stored public key instead of deriving it from the secret beside it, so a device could come back at an address no peer knows it by and say nothing about it. Every new test was checked against the pre-fix code and fails there. The sweep-anchor one earns its keep by mutation: delete the fallback it guards and it goes red. While at it, one helper for "this frame produced nothing" instead of three copies of it, and an empty [lib] section nobody ever filled in.
A radio hears everything in range, and `handle()` never once asked whether the frame in its hand was addressed to this device. It went straight to the prefix and started work. It turns out neither kind of frame answers that question on its own. A control frame's signature *covers* the recipient rather than checking it, so one honestly signed for somebody else verifies perfectly here. A sealed frame carries no signature at all, so its recipient is whatever the last hand to touch it wrote there. So an overheard key package admitted a peer, spent flash on a record, minted a private init key nobody asked this device for, and answered a phone that never addressed it. A sealed frame this device really can open was acted on after anyone who captured it rewrote the recipient, because that field is not inside the AEAD. And every other prefix came back as an identity binding failure, so two neighbours talking reached firmware wearing the shape of an attack, on a device whose only account of itself is that error stream. Ask the question first, before a signature is verified or a prefix is read. Ignored rather than refused, because overhearing is what a shared radio does, and firmware that carries frames for its neighbours needs "not mine" to be a fact it can act on rather than a failure it has to interpret. None of this ever let anyone read anyone else's ciphertext, to be clear: the group and credential gates held either way. It was a device spending flash and attention on other people's mail. While at it, the leaf section in the architecture doc was the one crate heading carrying no number, wedged between 6 and 7.
The key package adapter trimmed its index first and erased the evicted packages afterwards. Both of those are separate writes that fail independently, because this seam is atomic per entry and nothing more. Land the failure on that side and you leave private init key material on flash the index no longer names, and nothing ever reclaims it: unpair sweeps epoch records, this key type has no sweep of its own, and an eviction the index has already forgotten is never attempted again. The comment sitting directly above the code named that exact residue as the one to avoid. The code then did it anyway. Erase first, then trim. Either write can fail now and what survives is the harmless residue instead: an index entry naming a package that is not there, which costs one slot and is evicted in its turn. A delete that fails takes the whole mint down with it, which is a key package this device does not hand out rather than one it cannot account for.
ADR 0020 made core build without std on the reading that a constrained node "receives frames rather than minting them", and the changelog entry for it says exactly that. Two entries further down, in the same unreleased section, `Message::from_parts` shows up precisely because that reading is false the moment a node answers rather than only forwards. Both cannot ship in the same release notes. Reword the older one to say which half it meant, and point it at the constructor that covers the other half.
The device mints a key package, wraps it in a frame addressed to the peer it is pairing with, signs it, and puts it on the air. Anyone in range gets a copy, and a copy of a key package is exactly as spendable as the original. So a listener builds a group with it. Every gate on the Welcome it sends then passes, and passes *honestly*: it really does hold the key its own address derives from, it really is the inviter it names, and the group it built really is the one this pair's id would name, because that id is a function of the two addresses and it is one of them. The device joins and confirms to a node it was never introduced to. That is not a confidentiality failure. The group and credential gates hold, and the listener learns nothing it could not have got by pairing honestly, which nothing stops it doing anyway. What it costs is the *init key*, which is single use. Spent by the listener, the peer the package was minted for is left holding a Welcome that no longer opens, and `key_package_sent` means the device will not mint another until a driven reset. A listener that keeps doing it keeps the pairing broken. It also drove a hole through the peer bound. `index_peer` skips MAX_PEERS on the Welcome path, and the comment above it justified that by claiming a Welcome "only lands on a key package this device minted for that peer in the first place". Which was false. It landed on a key package this device minted, for whoever picked it up. Record the reference of the package at the moment it is minted, and require a Welcome to spend that one. Before the join, not after, because after is too late: the whole point is that the package is still there for the peer it belongs to. A peer with no recorded reference is refused for the same reason an unparseable identifier is refused elsewhere in this crate, which is that having nothing to compare is the bypass rather than a lenience. Note that "has this peer ever been given a package" is *not* the test, and it is worth being clear about why: a listener that has also paired holds a package of its own, and would sail through such a check while spending somebody else's. Both cases have a test, and both fail against the code before this commit. While at it, the comment above `index_peer` now says what is actually true, and says which single ordering still lets a peer reach a Welcome without a slot.
The Welcome gate refuses a group that is not the one this pair would build, which is what keeps a device out of a room it never chose. It runs exactly once, at the join. A commit changes the roster. It does not change the group id. And in the never-committing profile every commit belongs to the peer, so the peer can add whoever it likes to the pair's own group and the device will apply it, persist it, and report `CommitApplied` as if nothing happened. There is no later gate: the sealed-frame path binds the *sender* of the frame, and the sender is still the peer, honestly, while it relays a third member's commits under its own name. So the device follows its peer into a room one member at a time and never sees it happen. Which is a strange property for the one check in this crate that exists to stop exactly that. Re-read the roster on every commit, which is the only moment it can change, and require two members that derive to this device and the peer. Derived rather than read off the credential, because a basic credential is a bare assertion and this is the moment the shape of the group is in question. Two scalar multiplications on a cadence the peer sets is affordable. While at it, bind the committer the way an application message's sender is already bound. This reports rather than rolls back, and that is not laziness: a member cannot skip one commit and keep decrypting the next, so by the time there is a roster to read the commit is applied and durable. The choice is not whether to follow the peer. It is whether firmware gets told. A conforming phone will not trigger any of this. It refuses to join a `session:*` group as a third member (SEC-M6), so it cannot supply one. That is the phone protecting itself, in the phone's own code, which is worth precisely nothing to a device that cannot be reflashed and does not get to choose what it is talking to.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-leafis dual std/no_std and sits oncoreandsealedonly, never on the engine or the MLS crate, because nothing abovesealedbuilds withoutstd.LeafDeviceis a state machine, not a bag of primitivesThat 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
__MLS_CONFIRM_ACK__is never evidence of a sessionPersist-before-emit is structural, not documented
Every operation that advances ratchet state writes through
LeafStoreand 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 stampsnot_before = 0when 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 nogetrandombackend, because doing so would let firmware link and run with entropy this crate invented.LeafStoremust 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::peersis 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
MlsManageragainst this mls-rs device: pair, talk both ways, driven rekey through thesession_resetsequence, 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_partsin coreMessage::newwith its clock and its entropy made explicit. ADR 0020 made core build withoutstdon 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 aMessageat all, since the struct has a private field and no other constructor is reachable.newdelegates 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
coreandsealedhave. mls-rs implementsDisplayon its error only understd, 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-rngis 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
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.