Repository navigation
fix(protocol): silent background frames and terminal answers for undecryptable frames - #543
Merged
Merged
Conversation
The relay could not tell the SDK's own background frames (data-sync offers, key packages, receipts, service discovery, typing, presence) from a user's text: both are opaque MLS ciphertext to it, so an offline peer got "New messages from X" pushes with nothing behind them. Every frame the SDK sends on its own now carries metadata no_alert "1" in the cleartext envelope, outside the control signature. User content (plain, rich, forwarded, media), connection requests and accepts, group chat messages and group invites never carry it. A 1:1 Welcome alerts only while a user message waits behind it, decided again on each send.
A frame that could never decrypt (spent ratchet generation, dead epoch, or given up by the pending-decryption queue) was never acknowledged, so a relay mailbox holding it redelivered the same dead ciphertext on every connect, and each failure tripped the app's split-brain breaker. Receivers now answer such a frame with a delivery ACK carrying ack_status "undecryptable", but only to peers whose key package advertises ack_versions entry 1 (ACK_UNDECRYPTABLE_V1), since older SDKs read any ACK as delivered. The sender never reports it delivered: it re-seals the DM once under its current session, or fails the message when the plaintext is gone. message_decryption_failed now carries sealed_at_ms and session_established_at_ms so the app can ignore frames sealed before the current session existed.
mizanxali
marked this pull request as draft
October 9, 2026 08:41
The committed vectors gained ack_versions by hand, so the provenance check failed: the generator still produced the old file. The generator now emits the field in every parse case and the emission example.
A sender without the plaintext (an outbox entry restored after a restart) failed the message on an undecryptable answer, on the theory that every retry would replay dead ciphertext. That holds for a spent ratchet generation, but not for a frame the recipient's pending-decryption queue dropped on TTL or overflow: it was never attempted, so the same ciphertext decrypts once the session is up. A parked frame's sender is unauthenticated, so anyone able to fill that queue could turn a delay into a lost message. The entry now takes the same one-shot resend, verbatim when it cannot re-seal, and is then left to its retry ladder, which still ends in message_failed at max retries.
…he relay Every failure the undecryptable ACK answers happens before anything is authenticated: garbage ciphertext in a derivable session slot fails as a CryptoFailure, and a parked frame's sender is only a claim. Answering on a mesh or peer-stream link confirmed to an injector in radio range that the target is live and processing, which the threat model's acknowledgement rule withholds. The answer now goes only to a frame that arrived over the internet relay, the one carrier whose mailbox redelivers a dead frame forever and which already knows the device is connected. The threat model records the exception and the residual as R25.
delete_session removed the MLS group first and the establishment stamp second, so a failure on the stamp returned Err with the session already gone. manual_mls_delete_session clears confirmed_sessions, the welcome lifecycle and the both-create gate only on Ok, which left the peer recorded as confirmed with no session. The stamp now goes first: an Err means the session is untouched, and the reverse failure costs only a null session_established_at_ms.
report_dropped_pending still said an evicted frame is never ACKed, which stopped being true for relay arrivals once the queue answered them undecryptable. Also rewrites the em dashes this branch introduced, per the documentation style.
A snapshot carriage or an attachment answer travels as media chunks built by create_media_message, which never set the marker, and media prefers the relay. So a document sync pushed "New messages from X" exactly like the frames the earlier commit silenced. Every chunk of a data-purposed transfer is now marked; the purpose rides sealed in chunk 0 only, so the sender is the one place that knows it for the rest.
ensure_session_establishment sent its Welcome with user_send hard-coded true, on the theory that only a user's send creates a session there. The media path also reaches it, including a snapshot carriage the data layer starts on its own toward a peer with no confirmed session, so that Welcome pushed "New messages from X" with nothing behind it. The caller now says whether a user's send prompted it: text sends do, and a media send does unless it carries a data purpose.
mizanxali
marked this pull request as ready for review
October 9, 2026 12:57
…-and-undecryptable-ack # Conflicts: # crates/offline-protocol-leaf/src/keypkg.rs # crates/offline-protocol-leaf/tests/phone_interop.rs # crates/offline-protocol-sealed/src/keypackage.rs # crates/offline-protocol-sealed/src/lib.rs # crates/offline-protocol-sealed/tests/data/key-package-v1.vectors.json # crates/offline-protocol/src/protocol/blocking.rs # crates/offline-protocol/src/protocol/message_dispatch.rs # crates/offline-protocol/src/protocol/tests/mod.rs # crates/offline-protocol/src/protocol/types.rs # docs/spec/capability-negotiation.md # tools/spec-vectors/generate.py
The undecryptable ACK is only allowed over the internet relay. Every failure it answers happens before anything is authenticated, so an answer on a radio link tells an injector in range that the target is live. The threat model (R25) says the answer never goes there. It turns out the gate only checked where the frame *came from*. The answer itself went through the ordinary ACK ladder, which falls back to the mesh and to whatever DORS picks when the relay is down. A queue drain, prune or eviction running while the relay socket was down sent the answer straight out over BLE. Holding it would not have helped either: a held ACK re-runs the same ladder on the next carrier edge. Build the ACK separately from routing it, and send the undecryptable answer only through the relay, or not at all. Dropping it costs nothing. The relay mailbox redelivers the frame on the next connect, and the live receive path answers it then. While at it, fix the delivery-and-acks table, which still listed a transport failure as unanswered even though it is classified with crypto failures. Also fix a test comment that said the verbatim replay "fails" the message when it keeps it.
bahdotsh
approved these changes
Oct 9, 2026
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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
no_alerton SDK-originated frames. Every frame the SDK sends on its own (data-sync offers, key packages, receipts, service discovery, typing, presence, group replication) now carriesmetadata["no_alert"] = "1"in the cleartext envelope, outside the control signature. User content (plain, rich, forwarded, media), connection requests and accepts, group chat messages and group invites never carry it. A 1:1 Welcome alerts only while a user message waits behind it, decided on every send.ack_status: "undecryptable", but only when it arrived over the internet relay. The sender never reports it delivered: it resends the DM once with the same id, re-sealed under its current session when it still holds the plaintext and verbatim when it does not (an outbox entry restored after a restart; a frame the recipient's queue dropped unattempted still decrypts). A second answer for the same id leaves the entry to its retry ladder, which ends inmessage_failedat max retries.ack_versions(ACK_UNDECRYPTABLE_V1), which is persisted and restored per peer. The undecryptable ACK goes only to peers that advertised it.message_decryption_failednow carriessealed_at_ms(the sender's seal time) andsession_established_at_ms(when the current 1:1 session started; recorded byoffline-protocol-mls). Both are nullable, and both are added to the RN types.Why
Production evidence:
Compatibility
no_alertandack_versions.ack_versionsentry 1 gets no answer, exactly as before, because an older SDK reads any ACK as a delivery.reasontext on a retriablemessage_decryption_failednow reads "not delivery-ACKed, so the sender can re-seal it". It is free text; nothing should match on it.nullforsession_established_at_msuntil they are rebuilt.Testing
cargo build,cargo fmt --all -- --check,cargo clippy --workspace --locked -- -D warnings,cargo clippy --package offline-protocol --no-default-features --locked -- -D warnings: cleancargo test -p offline-protocol: 2030 + 1 + 23 passed (1 ignored)cargo test -p offline-protocol-uniffi: 210 passedcargo test -p offline-protocol-mls: 149 passed (2 ignored)cargo test -p offline-protocol-sealed: 80 passedcargo test -p offline-protocol-leaf: 4 + 65 + 2 passed