Skip to content

Fix one-time keys being re-uploaded on every to-device/device-list sync with matrix-sdk-crypto-wasm ≥ 18.5.0 - #5502

Closed
LpZ-squall wants to merge 1 commit into
matrix-org:developfrom
LpZ-squall:fix/one-time-key-counts
Closed

LpZ-squall wants to merge 1 commit into
matrix-org:developfrom
LpZ-squall:fix/one-time-key-counts

Conversation

@LpZ-squall

Copy link
Copy Markdown

Hello everyone.

This PR fixes #5501. The code was written with Claude Code (Claude Fable 5); I have tested it successfully in the Matrix client I am developing.

As explained in the issue, this is a pretty bad bug: an affected device keeps uploading a new batch of 50 one-time keys on almost every sync. The server accumulates tens of thousands of keys, and since vodozemac only keeps the private half of the most recent ~100, peers who claim an older key can no longer open an Olm session with the device — new conversations become permanently undecryptable, while existing sessions keep working, which makes it hard to diagnose.

Sable's unreleased dev branch already resolves crypto-wasm 18.5.0, so its next release would be affected, and other matrix-js-sdk clients will be as their lockfiles refresh. Element Web is not affected yet only because its lockfile still pins 18.4.0 — and, as the tests in this PR show, bumping it is currently blocked by this bug.

Why

RustCrypto calls OlmMachine.receiveSyncChanges up to three times per /sync response, and the to-device and device-list calls passed an empty one-time-key count because they have none of their own. Since matrix-sdk-crypto-wasm 18.5.0 (matrix-rust-sdk#6780) an empty count means "zero keys on the server", so each such sync made the OlmMachine generate and upload 50 new one-time keys. Details and measurements in the issue.

What

RustCrypto now remembers the count the server last reported and passes it on those calls. The OlmMachine learns that count from two places, so both are mirrored:

  • processKeyCounts stores the /sync count, and SyncApi.processSyncResponse now calls it before processing to-device events and device lists, so the stored count is the one from the same response.
  • OutgoingRequestProcessor reports the one_time_key_counts from each /keys/upload response (new optional callback), covering the time before /sync has reported a count.

The SyncCryptoCallbacks.processKeyCounts TSDoc documents the new ordering.

Testing

  • Unit tests (spec/unit/rust-crypto/rust-crypto.spec.ts, OutgoingRequestProcessor.spec.ts, new spec/unit/sync.spec.ts) assert the count handed to the OlmMachine on each path, the /keys/upload callback, and the call order in processSyncResponse.
  • End-to-end test in spec/integ/crypto/crypto.spec.ts with the real OlmMachine: a sync carrying only to-device events and device-list changes must not trigger a /keys/upload.
  • To see the failure, bump the binding: pnpm add -D @matrix-org/matrix-sdk-crypto-wasm@18.6.0 --ignore-scripts and run spec/integ/crypto. Without this change the new test fails (expected 2 to deeply equal 1) and 18 existing tests in crypto.spec.ts / verification.spec.ts time out, because the first to-device sync triggers a second key upload that E2EKeyReceiver deliberately never answers. With it, all 215 pass on 18.6.0, and the full suite passes on the lockfile's 18.4.0. So this also unblocks bumping the binding past 18.4.0.
  • Verified in a production third-party client against 18.5.0: one-time-key uploads stop after the initial batch.

Notes for reviewers

  • CI runs on 18.4.0, where an empty count is ignored, so the new end-to-end test passes either way today; it starts guarding the behaviour as soon as the binding is bumped.
  • The changelog gate needs the maintainer-only T-Defect label.
  • The code was written with the help of Claude Code; I reviewed and tested it.

Checklist

  • Tests written for new code (and old code if feasible).
  • New or updated public/exported symbols have accurate TSDoc documentation.
  • Linter and other CI checks pass.
  • Sign-off given on the changes (see CONTRIBUTING.md).

…c paths

`RustCrypto.receiveSyncChanges` defaulted the one-time key count to an
empty map, and two of its three callers (`preprocessToDeviceMessages` and
`processDeviceLists`) relied on that default because they have no count of
their own. Since matrix-sdk-crypto-wasm 18.5.0 (matrix-rust-sdk#6780),
`OlmMachine.receiveSyncChanges` treats a missing `signed_curve25519` count
as "zero keys on the server", so every sync carrying to-device events or
device-list changes reset the account's uploaded-key count and made it
generate and upload a fresh batch of 50 one-time keys. The private halves
of older batches get evicted, so peers claiming those keys could no longer
establish Olm sessions with the device.

Fix this by keeping track, in `RustCrypto`, of the count the server last
reported, and using it wherever we have to call `receiveSyncChanges`
without a count of our own. The `OlmMachine` learns that count from two
places, so we mirror both:

- `processKeyCounts` remembers the count from each `/sync` response, and
  `SyncApi.processSyncResponse` now calls it before processing to-device
  events and device lists, so that the remembered count is the one from
  the same response (including on the first sync after a restart).
- `OutgoingRequestProcessor` reports the `one_time_key_counts` from each
  `/keys/upload` response, which covers the time before `/sync` has
  reported a count (or servers which omit it).

Without the second part, 18 existing integration tests hang against
matrix-sdk-crypto-wasm >= 18.5.0: their sync responses carry no count, so
the first to-device sync triggered a second key upload, which the test
homeserver deliberately never answers.

Add unit tests asserting the count handed to the `OlmMachine` on each
path, the call ordering in `processSyncResponse`, the `/keys/upload`
callback, and an integration test with the real `OlmMachine` checking
that a sync carrying only to-device events and device-list changes does
not cause a key upload.

Fixes matrix-org#5501

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Romain Le Godais <romain.legodais@intersec.com>
private async receiveSyncChanges({
events,
oneTimeKeysCounts = new Map<string, number>(),
oneTimeKeysCounts = this.lastOneTimeKeysCounts ?? new Map<string, number>(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is quite right. For sync v2, device_one_time_keys_count is allowed to be absent if that particular algorithm has no OTKs. However, this pulls in the previous count, so there's no way for the client to ever realise it has run out of OTKs.

E.g. I upload 50 OTKs to the server, the count of which I cache locally. Over time, these get used up until the last key is claimed, at which point the server omits the device_one_time_keys_count field. However, this parameter defaults to the last count (i.e. 1), so we assume we have one remaining OTK for the rest of time.

@kaylendog

Copy link
Copy Markdown
Contributor

I have been working on a similar (although more complex) fix here: #5503

@richvdh

richvdh commented Aug 27, 2026

Copy link
Copy Markdown
Member

@LpZ-squall thanks for the contribution. The team were previously working on this so I'm going to close this in favour of #5503.

@richvdh richvdh closed this Aug 27, 2026
@LpZ-squall

Copy link
Copy Markdown
Author

Thanks for your reply and for the other fix 👍.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants