Skip to content

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

Merged
bahdotsh merged 12 commits into
mainfrom
feat/leaf-node-crate
Aug 22, 2026
Merged

feat(leaf,core): a door lock speaks this protocol, not a smaller one#399
bahdotsh merged 12 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.
The roster check that keeps a leaf's group a pair ran on the commit
that can change it, and nowhere else. That sounds like the right
place until you notice what a commit is: by the time there is a
roster to read, it is already applied and already on flash. A member
cannot skip one commit and keep decrypting the next, so there is
nothing to roll back.

Which means the refusal was a returned error and nothing more.
Firmware may drop it. A power cycle certainly does. The device then
comes back up, loads a group with a third member sitting in it, and
seals its next message into that room as though nothing happened.
Same for what the peer sends afterwards, which binds to the peer
honestly and arrives as ordinary traffic. Same for a confirmation
probe, which got an acknowledgement promising a session the device
had already decided it would refuse.

What that costs is the interesting part. A leaf never commits, so
post-compromise security is entirely the peer's to drive. Spend a
phone's signature key *once* on an Add and the added member then
survives every later rekey as a legitimate member of the roster,
reading the device's traffic forever without touching the phone
again. A single error value, droppable, was the whole defence.

So ask the roster at every point the group is actually used: before
a seal, before an open, before an acknowledgement, and still on the
commit itself. It costs a roster read and two SHA-256 derivations,
which is symmetric work, so the profile's per-message cost claim
survives intact. More to the point, it puts the answer somewhere a
reboot cannot take it, which is the same reason nothing else in this
crate caches MLS state.

Three new tests, all three checked against the pre-fix code and
failing there: pre-fix the device really does hand back a sealed
frame in the widened epoch, really does surface the peer's next
message as an ordinary `MessageReceived`, and really does answer the
probe.
`unpair` is the one escape hatch a deployed device has, and it was
quietly leaving two different kinds of secret behind. Both are the
same bug wearing different clothes: something reclaims material only
at a moment that can be missed, and nothing ever revisits it.

The epoch sweep anchored at the top. It walks down from the highest
epoch any anchor names, because the storage seam cannot enumerate,
so a fixed window is all there is to work with. Except that the
adapter deliberately writes epoch records *before* the state entry
that sequences them, which is the right order for surviving a cut,
and it means a cut leaves a record sitting one epoch above what the
marker, the high-water record and the group's own epoch all agree
exists. The sweep deleted every record but that one and returned
`Ok`. An erasure that reports success and did not happen, holding
the secrets of the one epoch nothing else will ever name. Sweep the
slack upward as well as downward; a delete of a key that is not
there was already free.

The key package was worse, because it is a *private init key*. The
only pointer to it is the reference inside the peer record, and
`forget_peer` deleted that record without erasing what it named. An
init key is single use, so one belonging to a pairing that never
completed is never consumed by a join, no sweep covers that key
type, and the ring only evicts it after four more mints. On a lock
that gets paired twice a year, "eventually" means years, for a peer
the owner explicitly told it to forget. Read the reference first,
erase the package, then drop the record, which is the same
erase-before-you-stop-naming-it ordering the adapter's own eviction
already uses.

While at it: that read is best effort on purpose. A peer record that
will not decode has no reference to give, and failing the whole call
over it would close the escape hatch using exactly the corruption
the escape hatch exists to recover from. An unreclaimed package is
the smaller residue by a wide margin.

Four new tests, all four checked against the pre-fix code and
failing there. The sweep one is the satisfying one: pre-fix it
sweeps epochs 1 and 2 and leaves 3 sitting on flash.
A Welcome has to spend the key package this device minted for the
peer that sent it. That gate compares references, and a reference
matching is not the same claim as the package still being there.

Two ordinary ways for it to be gone. An earlier join consumed it,
because an init key is single use and a Welcome therefore is not
replayable. Or four later mints pushed it out of the bounded ring
that keeps unspent packages from filling flash. In both the peer is
exactly who it says, spending exactly what it was handed, and the
device simply no longer has the material.

What came out was `Mls("cannot join from the welcome:
WelcomeKeyPackageNotFound")`, from inside the join, several layers
below anything that knows what a pairing is. That reads like a
broken peer or a bad frame, and it sends whoever is holding the
bench to look at the wire. The wire is fine. The repair is a fresh
package.

So check that the package is still on flash, after the reference
comparison and before the join, and give it its own error. Deliberately
not `UnsolicitedWelcome`: that one means somebody is spending
something that was never theirs, and firmware may well want to treat
it as an attack. This is the opposite situation and should not be
made to look like it.

Same reasoning as the load_group split earlier in this branch. An
error is a message to a human who cannot attach a debugger, and on a
device that cannot be reflashed it is very nearly the only one.
@bahdotsh
bahdotsh merged commit 23354dc into main Aug 22, 2026
25 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 22, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant