Skip to content

fix(mls): keep a synced key package's record so expiry still owns it - #500

Merged
bahdotsh merged 6 commits into
mainfrom
fix/367-synced-key-package-keeps-its-record
Oct 2, 2026
Merged

bahdotsh merged 6 commits into
mainfrom
fix/367-synced-key-package-keeps-its-record

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Oct 2, 2026

Copy link
Copy Markdown
Member

What this does

mls_mark_key_package_synced deleted a key package's record and left its private init key in the MLS provider. Keeping the key was right, because a Welcome built from the uploaded copy has to open. Deleting the record was not, because the record is the only thing that carries expiry. A published package nobody claimed kept its key for the life of the install (#367).

  • Marking keeps the record and sets synced on it. Nothing has ever set that field, which has been on the bundle since the first MLS commit. The package now expires, and its key is destroyed past the 7-day grace window like any other.
  • A synced package is never handed out. The push path and the peer-less getter both skip it, through the same predicate that already skips a Nostr slot package. A stranger may hold it, and pointing a peer at it too is the shared-key failure ADR 0012 exists to prevent.
  • The pending list holds only packages the app may publish: unclaimed, unreserved and unsynced. It used to return every live package, so an app following the documented upload loop would have marked the engine's own slot and push packages synced too.
  • Marking an unknown, expired or consumed id does nothing. Marking a package the push path already gave a peer is allowed and logged.
  • A source guard refuses any new record-only delete of a key package outside the loader and the purge.

The second commit fixes a separate bug on the same methods. The React Native wrappers read createdAt and isSynced, but both native bridges send createdAtMs, expiresAtMs and synced. JavaScript has always received undefined for both fields. The three key package wrappers now share one mapper, and MlsKeyPackage gains expiresAt.

ADR 0012's gap section, the session lifecycle state machine, the MLS integration guide, the React Native method table and the changelog are updated.

Verification

  • cargo clippy --workspace -- -D warnings, cargo test --workspace --lib, cargo fmt --all -- --check and rustdoc under -D warnings pass. Doctests were not run locally.
  • The React Native typecheck and npm run test:js pass, including the new key-package-mapping.test.js.
  • Mutation: four mutants of the Rust fix and one of the old TypeScript mapping were applied in turn. Each fails at least one new test.
  • The UDL is unchanged, so no bindings were regenerated and the local API guard is untouched.
  • No device run. The change is in the MLS crate and the TypeScript layer, and the native bridges are unchanged.

Not in this PR

  • The website docs repository's mesh-sdk/methods.mdx still describes the old upload loop.
  • The push path can claim a package between an app's list call and its mark. The window is one discovery event wide, it predates this issue, and the outcome is the logged shared-package case above.

Closes #367.

mls_mark_key_package_synced deleted the key package record and
nothing else. The private init key stayed in the MLS provider, which
is half right: a stranger who fetched the uploaded copy may still
Welcome us, and only that Welcome consumes the key.

The other half is the problem. The record is the *only* thing that
carries expiry. Every withdrawal and every grace-window purge runs
through the loader, and the loader starts from the record. Delete the
record and an uploaded package nobody ever claimed keeps its init key
for the life of the install. ADR 0012 already called this the leak
most likely to come back. It never left.

It turns out the getter made it worse. The pending list returned
every live package, including the ones the push path had handed to a
peer and the ones standing in the engine's own Nostr slots. An app
following the documented upload loop would mark those synced too,
and each time the engine minted a successor the old key was stranded.

Let's do what the synced flag, which nothing has set since it was
added, was always supposed to mean. Marking now sets it on the record
and keeps the record, so the package expires and its key is destroyed
past the grace window like any other. A synced package is withheld
from both hand-out paths through the same predicate that withholds a
slot package, because pointing a peer at a key a stranger may hold is
the shared-key failure this whole ADR exists to prevent. The pending
list shrinks to what the app may actually publish. Marking an
unknown, expired or consumed id is a no-op.

A source guard now refuses any new record-only delete of a key
package outside the loader and the purge. The next person who writes
three lines of storage.delete gets a test failure instead of a leak.

Fixes #367.
Both native bridges have always sent a key package as createdAtMs,
expiresAtMs and synced. The TypeScript wrappers read createdAt and
isSynced. So every JavaScript caller got undefined for both, on all
three key package methods, since the day they were written.

Nothing noticed. The native record is typed any, so tsc is happy,
and the Rust text guards can see that a key is present in a bridge
but not that anybody reads it.

Route all three wrappers through one mapper that reads the names the
bridges actually send, keeping the plain names first the way the
session and group mappers already do. While at it, expose expiresAt:
it is the moment a key server should drop its copy, and the previous
commit makes that moment matter.

A harness test runs the compiled wrappers against a stubbed native
module, and fails all three cases against the old mapping.
The guard that refuses a record-only key package delete went red on
the Windows runner and nowhere else. It turns out *.rs is not pinned
to LF in .gitattributes, so a Windows checkout hands include_str!
CRLF text, and a search for "#[cfg(test)]\nmod tests" finds nothing.
The guard panicked before it checked a single function.

The tokenizer had a second hole too. It split on "    fn " and
"    pub fn " only, so a pub(crate) or async function was folded
into whatever came before it. Put one right after the purge and its
raw delete is attributed to an allowlisted name. Silently.

Let's just walk lines. str::lines() drops the \r for free, and a
header is recognised whatever its visibility or qualifiers. The
scanner gets a test of its own on CRLF text with offenders hidden
behind each header shape, so the next tokenizer bug fails here
instead of waving a leak through.

While at it, drop the exemption for any body that mentions
delete_key_package(. Nothing needs it, and it exempted by name
rather than by what the body actually does.
Marking is a load, a flag and a store of the package record. So is
the push path's claim. The FFI took the manager's *read* lock for
the mark, and the engine takes the read lock for the claim, so the
two can interleave freely.

If the claim's store lands second, the mark is gone. The package is
on the application's key server and advertised to a peer, which is
exactly the shared-key case the previous commit promised to log. It
is not logged: the warning only fires when the mark *sees* the
assignment, and here it never does. The package then falls out of
the pending list because it is assigned, so the app has no way to
notice its mark did not stick.

Make the mark take &mut self. Every caller sharing the manager
behind an RwLock now has to hold the write half, and the compiler
says so, rather than one wrapper having to remember. The FFI drops
the engine's inner lock before it touches the manager lock, and the
push path never takes the inner lock while holding the manager one,
so there is no ordering to invert.
ensure_min_key_packages exists to keep the push path stocked, and it
counted every live record to decide how many to mint. A slot package
was already never handed to a peer, and since marking now keeps a
synced package's record, that one is never handed out either. Each
of them still counted toward the minimum.

So the pool came up one package short for every package somebody
else holds. Nothing in production calls this today, which is the
only reason it is not a bug report.

Count the same pool take_push_key_package counts against its
ceiling. count_valid_key_packages keeps counting every live record,
and its doc now says so instead of implying it is the push pool.
The documented upload loop lists pending packages, uploads them and
marks them. Since the pending list stopped returning the engine's own
packages, nothing the SDK mints ever lands in it. On a fresh install
the loop finds an empty list and uploads nothing, without a word.

That is correct behaviour. Those packages were the engine's to hand
out, and uploading them is what got one init key pointed at two
parties. But an app that copied the old snippet goes from populating
its key server to silently not, and the docs did not say why.

Put the generate step in the loop and say it out loud, in the guide,
on the React Native method and in the changelog.
@bahdotsh
bahdotsh merged commit fbc91bb into main Oct 2, 2026
24 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 2, 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.

mark_key_package_synced deletes the record without purging the provider key

1 participant