fix(push): close E2EE-flip race by re-checking room encryption at reply send time, pre-flight credentials before optimistic echo, share callId-dedup helpers between FCM and Worker

This commit is contained in:
v.lagerev 2026-05-17 02:41:27 +03:00
parent 75022b9331
commit b197e354f5
3 changed files with 79 additions and 22 deletions

View file

@ -12,6 +12,7 @@ import androidx.core.app.NotificationCompat;
import androidx.core.app.RemoteInput;
import org.json.JSONObject;
import org.json.JSONException;
import java.io.IOException;
import java.io.OutputStream;
@ -88,12 +89,11 @@ public class ReplyReceiver extends BroadcastReceiver {
final Context appContext = context.getApplicationContext();
// Optimistic local echo appends a self-Person message to the
// conversation and re-posts, so the user sees their reply in the
// shade before the HTTP completes.
long now = System.currentTimeMillis();
VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now);
// Pre-flight validation BEFORE the optimistic echo. Posting a self
// bubble first and then immediately stacking an error notif on top
// is jarring UX; for predictable failures (logged out, freshly
// encrypted room) we'd rather skip the echo and only surface the
// error.
final SharedPreferences prefs = appContext.getSharedPreferences(
VojoPollWorker.PREFS, Context.MODE_PRIVATE);
final String token = prefs.getString(VojoPollWorker.KEY_ACCESS_TOKEN, null);
@ -104,6 +104,28 @@ public class ReplyReceiver extends BroadcastReceiver {
return;
}
// Race guard for E2EE flip: the per-room metadata snapshot is
// refreshed by JS on m.room.encryption Timeline events, but a push
// delivered in the narrow window between the encryption state
// landing and the dump completing could still expose the reply
// action on a freshly-encrypted room. Re-read the snapshot
// synchronously here Synapse does NOT enforce "no cleartext in
// encrypted rooms" at the spec level, so without this guard we'd
// leak the user's reply into an E2EE timeline as plaintext.
if (isRoomEncryptedAtSendTime(prefs, roomId)) {
Log.w(TAG, "onReceive: room flipped to encrypted between render and send, abort");
postReplyError(appContext, roomId);
return;
}
// Optimistic local echo appends a self-Person message to the
// conversation and re-posts, so the user sees their reply in the
// shade before the HTTP completes. Only happens after pre-flight
// checks pass so the user doesn't see an echo for a reply we know
// will fail.
long now = System.currentTimeMillis();
VojoFirebaseMessagingService.appendOutgoingMessage(appContext, roomId, text, now);
final PendingResult pendingResult = goAsync();
final String txnId = "vojo-reply-" + UUID.randomUUID();
EXECUTOR.execute(() -> {
@ -180,7 +202,7 @@ public class ReplyReceiver extends BroadcastReceiver {
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null) return;
try {
String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM_PUBLIC;
String channel = VojoFirebaseMessagingService.CHANNEL_ID_DM;
NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, channel)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(PushStrings.replyFailed(ctx))
@ -197,4 +219,30 @@ public class ReplyReceiver extends BroadcastReceiver {
private static String trimTrailingSlash(String s) {
return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
}
/**
* Synchronous re-check of the room's encryption flag at send time.
* Mirrors VojoFirebaseMessagingService.loadRoomMetadata's tolerant
* parse: legacy string-shape entries and missing flags both default
* to encrypted=true (privacy-first refusing a reply on a falsely-
* flagged room is harmless; sending cleartext into a truly encrypted
* room is a privacy leak).
*/
private static boolean isRoomEncryptedAtSendTime(SharedPreferences prefs, String roomId) {
String raw = prefs.getString(VojoPollWorker.KEY_ROOM_NAMES, null);
if (raw == null || raw.isEmpty()) return true;
try {
JSONObject map = new JSONObject(raw);
if (!map.has(roomId) || map.isNull(roomId)) return true;
JSONObject obj = map.optJSONObject(roomId);
if (obj == null) {
// Legacy string-shape predates the encryption flag
// assume encrypted to err on the side of privacy.
return true;
}
return obj.optBoolean("isEncrypted", true);
} catch (JSONException je) {
return true;
}
}
}

View file

@ -66,12 +66,11 @@ public class VojoFirebaseMessagingService extends MessagingService {
// after creation on API 26+, so any future config change (sound, vibration
// pattern) bumps the version.
private static final String MESSAGE_CHANNEL_GROUP_ID = "vojo_messages_v1";
private static final String CHANNEL_ID_DM = "vojo_messages_dm_v1";
// Package-visible: ReplyReceiver reuses CHANNEL_ID_DM for its "reply
// failed" one-shot notification. Was previously a separate _PUBLIC
// alias; collapsed to a single package-private constant.
static final String CHANNEL_ID_DM = "vojo_messages_dm_v1";
private static final String CHANNEL_ID_GROUP = "vojo_messages_group_v1";
// Package-visible mirror of CHANNEL_ID_DM so ReplyReceiver can post its
// one-shot "reply failed" error notification on the same channel without
// duplicating the literal.
static final String CHANNEL_ID_DM_PUBLIC = CHANNEL_ID_DM;
private static final String GROUP_KEY = "vojo_messages";
// NotificationChannel settings (vibration pattern, sound, importance) are
@ -105,8 +104,12 @@ public class VojoFirebaseMessagingService extends MessagingService {
* m.rtc.notification events that share the parent re-ring for
* participant rejoin, FCM retry-with-fresh-event-id see the mark and
* silently skip the post.
*
* Package-private so VojoPollWorker can build the same key shape from
* its flatten path without duplicating the literal keeps the FCM and
* polling paths trivially in sync.
*/
private static String compositeCallDedupKey(String roomId, String callSessionId) {
static String compositeCallDedupKey(String roomId, String callSessionId) {
return "call:" + roomId + ":" + callSessionId;
}
@ -121,8 +124,12 @@ public class VojoFirebaseMessagingService extends MessagingService {
* deployment config. We probe both shapes and `content_call_id` as
* a third candidate for legacy MSC2746 calls so the dedup works
* regardless of which encoding the homeserver's Sygnal happens to use.
*
* Package-private: VojoPollWorker's flatten path writes the
* `content_m.relates_to_event_id` form, so this same helper resolves
* both FCM and polling payloads.
*/
private static String extractCallSessionId(Map<String, String> data) {
static String extractCallSessionId(Map<String, String> data) {
String[] candidates = new String[] {
"content_m.relates_to_event_id",
"content_m_relates_to_event_id",

View file

@ -291,13 +291,16 @@ public class VojoPollWorker extends Worker {
// missed-call. Without this, a session with one
// FCM live-alert ring + one re-ring through
// polling would surface as both a CallStyle and
// a missed-call card.
// a missed-call card. Helpers live in
// VojoFirebaseMessagingService so the key shape
// stays in lock-step across FCM and polling.
String roomIdField = flattened.get("room_id");
String sessionId = flattened.get("content_m.relates_to_event_id");
if (sessionId == null) sessionId = flattened.get("content_call_id");
String sessionId = VojoFirebaseMessagingService
.extractCallSessionId(flattened);
String composite = null;
if (roomIdField != null && sessionId != null) {
String composite =
"call:" + roomIdField + ":" + sessionId;
composite = VojoFirebaseMessagingService
.compositeCallDedupKey(roomIdField, sessionId);
if (NotificationDedup.wasNotified(ctx, composite)) {
if (ts > highestTsSeen) highestTsSeen = ts;
treatAsNotRenderable = true;
@ -311,12 +314,11 @@ public class VojoPollWorker extends Worker {
// CallStyle.
posted = VojoFirebaseMessagingService
.renderMissedCallNotification(ctx, flattened);
if (posted && roomIdField != null && sessionId != null) {
if (posted && composite != null) {
// Mark the composite so the next polling
// cycle observing a re-ring for the same
// session doesn't double-post.
NotificationDedup.markNotified(ctx,
"call:" + roomIdField + ":" + sessionId);
NotificationDedup.markNotified(ctx, composite);
}
}
} else if (isRtcType) {