Interop driver: report the session version a responder settled on
`session_respond` returned no version at all, so the cross-engine test could not assert the one thing that mattered — that a session this device ANSWERS agrees with the initiator about which protocol it runs. `session_initiate` and `session_encrypt` report it too, so a version that changes across a store-and-reopen is visible rather than inferred. Sealed groups: file the message instead of dropping it in silence. `groupRoster` asked the relay for the membership of a group the relay cannot know — §8.9b exists so it holds opaque tags and no member list — so the sender was never "in the roster" and EVERY message in EVERY sealed group was discarded on arrival by the check meant to protect it. The sealed roster is the authoritative list and the only one that exists. A refusal now says which group and which sender. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ee17d773b7
commit
2402f7a665
@@ -762,6 +762,13 @@ class Session(
|
|||||||
if (gid != null) {
|
if (gid != null) {
|
||||||
val known = groupRoster(gid)
|
val known = groupRoster(gid)
|
||||||
if (known == null || message.fromUser !in known) {
|
if (known == null || message.fromUser !in known) {
|
||||||
|
// ⚠️ Refusing is correct — but refusing in silence is what
|
||||||
|
// made a sealed group look like a group nobody could talk
|
||||||
|
// in. Say which group and which sender, never the message.
|
||||||
|
System.err.println(
|
||||||
|
"fnmsg-recv: NOT FILED into group ${gid.take(8)} — " +
|
||||||
|
"sender ${message.fromUser} is not in the roster " +
|
||||||
|
"(roster ${known?.size ?: -1} member(s))")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// ⚠️ Every group envelope carries the group's name, and this is
|
// ⚠️ Every group envelope carries the group's name, and this is
|
||||||
@@ -1244,6 +1251,16 @@ class Session(
|
|||||||
@Volatile var groupNamesUpdated: Boolean = false
|
@Volatile var groupNamesUpdated: Boolean = false
|
||||||
|
|
||||||
private fun groupRoster(gid: String): Set<Long>? {
|
private fun groupRoster(gid: String): Set<Long>? {
|
||||||
|
// 🔴 **A SEALED group's roster is not the relay's to give.** §8.9b
|
||||||
|
// exists so the relay holds opaque tags and no membership at all, so
|
||||||
|
// `listGroups()` returns an EMPTY member list for one — and the caller
|
||||||
|
// reads that as "the sender is not a member" and drops the message.
|
||||||
|
// Every message in every sealed group was discarded on arrival, in
|
||||||
|
// silence, by the check meant to protect it. The sealed roster is the
|
||||||
|
// authoritative list here and the only one that exists.
|
||||||
|
if (vault.groupSecrets(gid) != null) {
|
||||||
|
return sealedGroups().firstOrNull { it.id == gid }?.memberIds?.toSet()
|
||||||
|
}
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val cached = groupRosterCache
|
val cached = groupRosterCache
|
||||||
if (cached != null && now - cached.first < 30_000) return cached.second[gid]
|
if (cached != null && now - cached.first < 30_000) return cached.second[gid]
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ private fun sessionInitiate(req: JSONObject): JSONObject {
|
|||||||
// layer where the one bug this protocol has actually shipped lives: a
|
// layer where the one bug this protocol has actually shipped lives: a
|
||||||
// client and a relay disagreeing about a field name, caught by nothing
|
// client and a relay disagreeing about a field name, caught by nothing
|
||||||
// until a live round trip.
|
// until a live round trip.
|
||||||
|
.put("version", session.version)
|
||||||
.put("envelope", session.encrypt(req.getString("text")).toJson())
|
.put("envelope", session.encrypt(req.getString("text")).toJson())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +372,12 @@ private fun sessionRespond(req: JSONObject): JSONObject {
|
|||||||
return JSONObject()
|
return JSONObject()
|
||||||
.put("session", id)
|
.put("session", id)
|
||||||
.put("ad", b64(session.associatedData))
|
.put("ad", b64(session.associatedData))
|
||||||
|
// 🔴 **What the session ANSWERED with, not what the harness assumes.**
|
||||||
|
// Without this the interop test could not see the version a responder
|
||||||
|
// settled on, and a responder that capped itself below the initiator
|
||||||
|
// shipped: iOS ran the session as v3, this side as v2, and every
|
||||||
|
// message sent into it was refused by the peer as a downgrade.
|
||||||
|
.put("version", session.version)
|
||||||
.put("text", session.decrypt(envelope))
|
.put("text", session.decrypt(envelope))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,9 +414,12 @@ private fun openSealed(req: JSONObject): JSONObject {
|
|||||||
.put("envelope", opened.envelope.toJson())
|
.put("envelope", opened.envelope.toJson())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sessionEncrypt(req: JSONObject): JSONObject = JSONObject()
|
private fun sessionEncrypt(req: JSONObject): JSONObject =
|
||||||
.put("envelope", liveSessions.getValue(req.getString("session"))
|
liveSessions.getValue(req.getString("session")).let { session ->
|
||||||
.encrypt(req.getString("text")).toJson())
|
JSONObject()
|
||||||
|
.put("version", session.version)
|
||||||
|
.put("envelope", session.encrypt(req.getString("text")).toJson())
|
||||||
|
}
|
||||||
|
|
||||||
private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject()
|
private fun sessionDecrypt(req: JSONObject): JSONObject = JSONObject()
|
||||||
.put("text", liveSessions.getValue(req.getString("session"))
|
.put("text", liveSessions.getValue(req.getString("session"))
|
||||||
|
|||||||
Reference in New Issue
Block a user