Reactive ghost re-key on client unwedge via MSC4203 to-device push (blocked upstream on Tuwunel) #55
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
dark/nether-voicebridge#55
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
When Tuwunel gains MSC4203 (to-device messages pushed to appservices in transactions — Jason has confirmed it's doable; Tuwunel tracking issue to be linked here once filed), the bridge should consume that push and reactively re-key a ghost the moment a client tries to recover a dead Olm session, instead of waiting for the blind periodic rotation (#52) to come around.
Today's behavior (the gap this closes)
#52 gave us blind outbound session rotation: membership-triggered (heals active calls in ≤90s) and max-age (heals static calls in ≤15 min). It works — definitively live-proven 2026-07-05 (#52, comment 458: one speaker inaudible on a static call, healed at the 15-minute rotation, no restart). But it's open-loop: the bridge never sees the client's side of the conversation.
What actually happens during a wedge: the client detects it can't decrypt a ghost's key message, claims fresh one-time keys for the ghost's device, and sends
m.dummyto-device to establish a new session. Today those messages land in a/syncinbox no appservice reads. The client did everything right; we're just deaf.Design sketch
to_devicefrom the MSC4203 appservice transaction push for ghost-namespaced recipients. Note: the bridge currently drives everything through per-bridge client/sync— receiving AS transactions means wiring (or adding) the appservice HTTP endpoint (PUT /_matrix/app/v1/transactions/{txnId}) and routing to-device events to the owning ghost's crypto.m.room.encrypted(Olm) or decryptedm.dummyaddressed to a ghost from a call participant's device = that device just re-established a session with us → immediately rotate/re-key that (ghost → device) pair and burst the current media key. Target heal time: seconds. This should bypass the 90s min-interval (it's client-initiated, not flap noise) — rate-limit per (ghost, device) instead.Dependencies / blocked on
to_device: Vec::new(), // TODOinsend_events_dest_appservice,src/service/sending/sender.rs). MSC3202 for OTK counts is the companion.References
OTK-publication audit (the NOT-blocked sub-item) — done 2026-07-05
Question: do the send-only ghost OlmMachines (#44) publish one-time keys at all, so a client's unwedge-claim toward a ghost device can succeed?
Answer: yes at bootstrap, and the pool is fully restored on every process restart and every #52 rotation — but it is never replenished within a run, cache-hit respawns don't re-upload, and no fallback key is ever published. So the pool is a finite ~50 that drains between restore events, with no backstop.
Code facts (
crates/matrix-rtc/src/rtc/key_transport.rs, matrix-sdk-crypto 0.18)Account::newpre-generates max OTKs, and the bootstrapdrain_outgoing(:408) uploads them —dispatch(:711) handlesKeysUpload/KeysQuery/KeysClaim/ToDeviceRequest.account_pickle_json) is serialized before the initial upload (:375), so the pickled OTKs stay flagged unpublished forever → everyrotate_sessionsrebuild (:467-495) and every restart re-uploads the same original key ids./sync, so the only count feedback is the upload echo (= max) —generate_one_time_keys_if_needednever re-fires, and there is no periodic pump (drains happen only at bootstrap, rotation, and per key-send).receive_sync_changes, which a send-only machine never calls; thefallback_keysmap in our uploads is always empty.GhostCryptoCachehit = no re-upload: a ghost respawn inside one process reuses the cached machine and skips bootstrap (ghost.rs:1287-1299).key_transport.rs:404("OTKs are not strictly needed — the ghost is a pure sender") is stale under this issue: an unwedging client claims the ghost's OTK, so the ghost is an Olm responder for recovery purposes.Live verification (nether.im, prod, 2026-07-05 ~07:00 UTC)
fallback: false, as predicted.Severity
Today this is latent: an empty ghost pool does not break audio (media-key delivery is ghost→client outbound; the ghost claims client keys, not vice versa), and pre-MSC4203 the ghost can't receive the unwedge
m.dummyanyway. What it costs today is failed claims/backoff noise on clients. Post-MSC4203 it becomes load-bearing: an unwedging client that hits an empty pool (long uptime + chatty room + no recent call ⇒ no rotations) fails silently — exactly the gap this issue exists to close.Recommended fix (small, worth doing with — or before — the receive path)
Publish a fallback key at bootstrap and on rotation. One fallback key answers unlimited claims, which kills the drain problem outright: feed the machine a synthetic
receive_sync_changeswithunused_fallback_key_types = Some(vec![])(no fallback in use) right after construction — the machine then generates and bundles a fallback key into the next/keys/uploadthatdrain_outgoingalready sends. ~10 lines inbootstrap+rotate_sessions, plus fixing the stale:404comment. Optionally the same call can carry a synthetic low OTK count to force regeneration, but with a fallback key that's belt-and-braces.Fallback-key fix from the audit above is built: branch
feat/issue-55-ghost-fallback-key(9e43518) —prime_fallback_keyfeeds the machine a synthetic "no unused fallback keys" sync signal after construction in bothbootstrapandrotate_sessions, so the existingdrain_outgoinguploads a fallback key that answers unlimited claims; priming failure is non-fatal (falls back to pre-fix OTK-only behavior). Unit test pins that the next/keys/uploadcarries the fallback key without disturbing the OTK batch; clippy + full workspace green.Live acceptance when it ships: after a deploy, drain-test a ghost device with repeated
/keys/claim— past the ~50 OTKs it should keep answering with the samefallback: truekey instead of going empty.Not merged yet (gated on live testing as usual). The MSC4203 receive path remains this issue's blocked main item.
Live acceptance PASSED (staging on the prod VPS, 2026-07-05 ~16:48 UTC, prod stopped for the window and restored after): fresh ghost crypto device
Cpntst58yon the encrypted Purple Voice bridge, drain-probed via/keys/claim— served exactly 50 normal OTKs, then every further claim returned the samefallback: truekey (signed_curve25519:AAAAAAAAAAA), verified stable across repeated claims. Zerofailed to primewarnings, zero errors in the staging run. Pre-fix behavior at claim 51 was a permanently empty response.Branch
feat/issue-55-ghost-fallback-key(9e43518) is ready to merge.Two issues opened on tuwunel's github:
Unblocked upstream by Tuwunel v1.8.2 (released 2026-07-17): appservice transaction extensions now deliver to-device events (MSC4203) — plus device-list changes and one-time-key counts (MSC3202) — to appservices. That's the exact capability this was waiting on.
Prod (
nether.im) is already running 1.8.2, so the mechanism is live for us now. Re-scoping this from "blocked upstream" → ready to implement: consume the to-device push and reactively re-key a ghost the moment a client tries to recover a dead Olm session, instead of waiting for the blind #52 periodic rotation.Release: https://github.com/matrix-construct/tuwunel/releases/tag/v1.8.2 — suggest retitling to drop "(blocked upstream on Tuwunel)". Implementation is separate, larger work.