Skip to content

fix(protocol): silent background frames and terminal answers for undecryptable frames - #543

Merged
bahdotsh merged 11 commits into
mainfrom
fix/silent-sdk-frames-and-undecryptable-ack
Oct 9, 2026
Merged

bahdotsh merged 11 commits into
mainfrom
fix/silent-sdk-frames-and-undecryptable-ack

Conversation

@mizanxali

@mizanxali mizanxali commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • no_alert on SDK-originated frames. Every frame the SDK sends on its own (data-sync offers, key packages, receipts, service discovery, typing, presence, group replication) 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 on every send.
  • Undecryptable ACK. A frame that can never decrypt (spent ratchet generation, dead epoch, or the pending-decryption queue gave up on it) is answered with a delivery ACK carrying 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 in message_failed at max retries.
  • Relay arrivals only. Every failure this answers happens before anything is authenticated (garbage ciphertext in a derivable session slot, or a parked frame whose sender is only a claim), so an answer on a mesh or peer-stream link would confirm to an injector in radio range that the target is live, which the threat model's acknowledgement rule withholds. The relay already knows the device is connected, and its mailbox is the one carrier that redelivers a dead frame forever. The exception and its residual are recorded in the threat model as R25.
  • Capability gate. Key packages advertise ack_versions (ACK_UNDECRYPTABLE_V1), which is persisted and restored per peer. The undecryptable ACK goes only to peers that advertised it.
  • Decrypt-failure timestamps. message_decryption_failed now carries sealed_at_ms (the sender's seal time) and session_established_at_ms (when the current 1:1 session started; recorded by offline-protocol-mls). Both are nullable, and both are added to the RN types.

Why

Production evidence:

  • Users got "New messages from X" alerts with nothing behind them. The relay cannot tell SDK background frames from user texts, because both are opaque MLS ciphertext to it, so it pushed for both.
  • A frame that could never decrypt was never acknowledged. Relay mailboxes redelivered it on every connect, and each failure tripped the app's split-brain breaker, which tore down healthy sessions.

Compatibility

  • Older peers and relays ignore no_alert and ack_versions.
  • The undecryptable ACK is gated on the capability and on arrival over the relay; a frame that failed off the relay is left unanswered, as before. A peer that has not advertised ack_versions entry 1 gets no answer, exactly as before, because an older SDK reads any ACK as a delivery.
  • The reason text on a retriable message_decryption_failed now reads "not delivery-ACKed, so the sender can re-seal it". It is free text; nothing should match on it.
  • The new event fields are nullable. Sessions established before this version report null for session_established_at_ms until 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: clean
  • cargo test -p offline-protocol: 2030 + 1 + 23 passed (1 ignored)
  • cargo test -p offline-protocol-uniffi: 210 passed
  • cargo test -p offline-protocol-mls: 149 passed (2 ignored)
  • cargo test -p offline-protocol-sealed: 80 passed
  • cargo test -p offline-protocol-leaf: 4 + 65 + 2 passed

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
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
mizanxali marked this pull request as ready for review October 9, 2026 12:57
@mizanxali
mizanxali requested a review from bahdotsh 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
bahdotsh merged commit d43f4ab into main Oct 9, 2026
24 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 9, 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.

2 participants