Repository navigation
Conversation
Tier 2 reseal advanced the sender's MLS encryption ratchet one generation
on every retry of an un-ACKed outbound DM. Under a lossy BLE link with
many frames in flight, the resulting ratchet-generation spread across
retries exceeded the receiver's SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE
window, so late or reordered frames failed decryption with
SecretTreeError::TooDistantInThePast and surfaced to the app as
INVALID_CIPHERTEXT. A 2-phone 20-minute federated-training run produced
128 message_decryption_failed events (coord iPhone receiving from worker
Pixel) and only 45 message_delivered events against 45,933 retry
attempts — the self-reinforcing ratchet-advance-on-retry loop could
never catch up.
Capture the recipient's MLS session fingerprint
(session_established_at_ms, epoch) at seal time and store it on the
in-memory OutboxReseal provenance. On retry, when both halves match the
recipient session's current values, replay the verbatim ciphertext
instead of re-sealing. The id-level duplicate the retransmit creates is
caught by the receiver's deduplicator at receive.rs before OpenMLS is
asked to decrypt, so no duplicate reaches the application layer; the
sender's ratchet stays at the generation originally emitted, so the
window never fills from retries alone.
Both halves of the fingerprint are required to short-circuit. The epoch
alone cannot distinguish "same session still at epoch N" from "fresh
session happens to be at epoch 0" (a torn-down-and-rebuilt 1:1 session
restarts at epoch 0 under new group state); the establishment stamp,
renewed on every create_session / join_session, distinguishes the two.
A None on either side at either comparison point falls through to the
pre-fix fresh re-seal, preserving Tier 2 recovery when state is
genuinely uncertain (legacy outbox entries restored from disk without
their in-memory provenance, MLS not initialized, read failures).
New lib tests:
test_reseal_replays_verbatim_within_same_session_and_epoch
pins the F3 shortcut: same (session, epoch) → verbatim bytes, same
message id, still decryptable on the receiver's unchanged session.
The existing test_reseal_on_resend_recovers_after_recipient_rekeys_to_new_epoch
still passes (session rebuild → different establishment stamp → fresh
re-seal).
Two existing tests were updated to encode F3's correct behavior:
a_resent_or_restored_sync_frame_keeps_its_no_alert_marker (data_sync)
asserted the resend's content changed; the real invariant is that
the no_alert marker survives, which it does under verbatim replay.
test_undecryptable_ack_reseals_once_and_never_reports_delivered
asserted the resend's content changed; within an unchanged session
fingerprint the resend replays verbatim, and the verbatim ciphertext
still decrypts on the receiver's session (first-time use of that
generation on the receiver's ratchet).
Refs: https://linear.app/offlineprotocol/issue/OFF-3247
- Collapse multiline let destructure that rustfmt wants on one line. - Replace broken intra-doc link to a non-existent Self method with prose.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Under a lossy BLE link with many frames in flight (federated training's gradient_chunk fan-out, control frames, CRDT shadow writes), the Tier 2 reseal was advancing the sender's MLS encryption ratchet one generation on every retry of an un-ACKed outbound DM. The resulting generation spread exceeded the receiver's
SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE = 32window, late/reordered frames failed decryption withSecretTreeError::TooDistantInThePast, and the app saw them asINVALID_CIPHERTEXTwith no actionable reason text. A 20-minute 2-phone federated-training session on 2026-10-19 reproduced this: iPhone (coord) ingesting from Pixel (worker) got 128message_decryption_failedevents against 45message_deliveredevents, with 45,933 retry attempts in between.This is the F3 fix shape from the diagnosis doc at
docs/scratch/off-3247-mls-ciphertext-dive.md(the diagnosis-only PR #584 stays open as reference material).Approach
MlsManager::session_epoch(peer) -> Result<Option<u64>>as a thin read path (bypassesget_group_info's O(roster) leaf-verification walk).(session_established_at_ms, epoch)at everyOutboxResealconstruction site (5 sites:send_message_with,forward_message,flush_pending,service_seal,data_sync). Store it on the in-memoryOutboxResealstruct (which is#[serde(skip)]so this adds no persisted state).reseal_resend_content, short-circuit to verbatim replay when both halves of the stored fingerprint equal the recipient session's current fingerprint. On a fresh re-seal, update the stored fingerprint to the new values.Why both halves? Epoch alone cannot tell "same session still at epoch N" from "fresh session happens to be at epoch 0" — a torn-down-and-rebuilt 1:1 session restarts at epoch 0 under new group state. The establishment stamp (renewed on every
create_session/join_session) distinguishes the two. ANoneon either side falls through to the pre-fix fresh re-seal, preserving Tier 2 recovery when state is genuinely uncertain (legacy outbox entries restored without memory provenance, MLS not initialized, lock poisoned, storage read fails).Safety
message_idis caught by the receiver's deduplicator atreceive.rs:166before OpenMLS is asked to decrypt, so no duplicate reaches the application layer.SecretReuseError, which maps to the samemessage_decryption_failedsurface the pre-fix code produced. No new silent-drop mode.test_reseal_on_resend_recovers_after_recipient_rekeys_to_new_epochstill green).Tests
test_reseal_replays_verbatim_within_same_session_and_epoch— pins the F3 shortcut: same(session, epoch)→ verbatim bytes, same message id, still decryptable on the receiver's unchanged session.test_reseal_on_resend_recovers_after_recipient_rekeys_to_new_epoch,test_reseal_is_noop_without_provenance.a_resent_or_restored_sync_frame_keeps_its_no_alert_marker— asserted the resend's content changed; the real invariant is that theno_alertmarker survives, which it does under verbatim replay.test_undecryptable_ack_reseals_once_and_never_reports_delivered— asserted the resend's content changed; within an unchanged session fingerprint the resend replays verbatim, and the verbatim ciphertext still decrypts on the receiver's session (first-time use of that generation).Full lib test suite: 2064 passed; 0 failed on
cargo test --package offline-protocol --lib. MLS suite: 149 passed; 0 failed.Manual-test plan for the hardware retest
The decisive test is the one that reproduced the bug: a 2-phone federated-training run. On both phones use builds that pin to this SDK branch (or wait for merge + a mesh-sdk tag bump).
Setup:
Run:
Check on the coord phone's debug log (ingesting worker's frames):
message_decryption_failedevents near zero for the full session. The 128/20min baseline should drop to single digits (ideally 0).message_deliveredcount climbs linearly with gradient_chunk emissions, not flat at ~45.group_epoch_fork_detected, zerosecure_session_failed, zerogroup_error— same as pre-fix. The fix doesn't touch those paths.If the metric drop isn't dramatic:
"Decryption failed"tracing lines. The warn-level log atmessage_dispatch.rs:1312-1316carries the OpenMLS error chain in logcat/oslog (not the JS console); if the remaining failures carry"An error occurred during AEAD decryption"instead of"Generation is too old to be processed", Hypothesis B (Welcome-wins race leftover) is in play and a separate fix is needed. If they carry"The requested secret was deleted"(SecretReuseError), the receiver's dedup is being bypassed somehow and that's a different investigation.Refs
docs/scratch/off-3247-mls-ciphertext-dive.mdon branchmb/off-3247-mls-ciphertext-decrypt-diagnosisHardware validation (2026-10-19)
Validated on a local tarball install (
npm pack→npm install --no-saveinIntelligenceMesh/companion-app/) running on iPhone 12 (coord) + Pixel SM02G4061956539 (worker). Scenario: 2-phone federated-training session runninggemma-270mon rust-candle, same shape as the Day 10 session that originally reproduced the bug.Pre-fix baseline (Day 10, 2026-10-09): session died within 20 min at ~45k retries, 128
message_decryption_failedevents, 45message_delivered, final state unresumable.Post-fix today: session held through 8 outer-step rounds before the user stopped it. During the run the sync-coord gradient_chunk retry queue grew into the thousands under the same round-pacing conditions, but the MLS session never degraded:
message_deliveredevents kept flowing on both directions throughout the run (training_join,round_start,loss_report,heartbeat,gradient_ack, in-flightgradient_chunk).message_decryption_failed.TooDistantInThePast/SessionNotReady/InvalidCiphertextsurfaced on either phone.Specifically what F3 blocked: without this fix, each retry of an un-ACKed gradient_chunk advanced the sender ratchet one generation; the Day 10 retry volume alone would have pushed the delivered-generation spread past
SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE = 32within minutes. Today the same retry volume accumulated with no generation advance, so late/reordered frames stayed inside the receiver's decryption window.The orthogonal pre-existing issue (worker finishes trainStep after the coord's 15 s round-wait window → every round shows
0/1 peers accepted; skipping) is round-pacing, not MLS, and is being filed separately. F3 is the fix for the MLS failure mode exercised by that pacing problem under real load.Note for reviewer: this validation was done against the fix as of commit
037da4(now plus the fmt+rustdoc cleanup38f467). The companion-app side has no changes required.