Skip to content

feat(leaf,core): a door lock speaks this protocol, not a smaller one - #399

Open
bahdotsh wants to merge 9 commits into
mainfrom
feat/leaf-node-crate
Open

feat(leaf,core): a door lock speaks this protocol, not a smaller one#399
bahdotsh wants to merge 9 commits into
mainfrom
feat/leaf-node-crate

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Aug 21, 2026

Copy link
Copy Markdown
Member

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

Gate Why it is a refusal and not a warning
A control frame's signing key must derive 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 Otherwise a package is relayed under a borrowed name
A Welcome must name that peer and be for the group this pair would build Otherwise a relayed Welcome puts the device in a room it never chose
A Welcome must spend the key package this device minted for that peer A 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 pair The 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 session A 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 from Re-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.

Base automatically changed from feat/offline-protocol-leaf to main August 21, 2026 17:19
@bahdotsh
bahdotsh force-pushed the feat/leaf-node-crate branch from 7ae21f1 to 2ec262c Compare August 21, 2026 17:21
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
bahdotsh force-pushed the feat/leaf-node-crate branch from 2ec262c to bb3ad35 Compare August 21, 2026 17:22
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant