Sealed groups on Android: create, fetch, invitations
Mirrors the iOS side. Create mints a gid, group key, owner signing key and one random tag per member, seals the roster and hands the relay a blob — no member id leaves the device. Fetch asks by TAG, never "what groups am I in", because a sealed relay cannot answer that. Reachability moved here from the relay, which needed to know who the members were in order to check it. ⚠️ An invitation is NOT subject to the roster check the group name goes through, and cannot be: that check asks the relay who is in the group, and for a group we have not joined yet we cannot fetch it to ask. What guards it instead is that an invitation is accepted only for a group we hold no secrets for — a stranger cannot overwrite a group we are really in, which would make every later roster fail to open and silently empty the conversation. The base64 stays inside the engine: `Bytes` is internal there on purpose, so `SealedRoster` grew `newGroupId`, `newOwnerKey`, `ownerPublic` and `signRoster` rather than the app reaching past that boundary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
58fb20246e
commit
d56ae82c59
@@ -86,6 +86,24 @@ class Conversations(
|
||||
fun consumeControlForTesting(fromUser: Long, content: Content) =
|
||||
consumeControl(fromUser, content)
|
||||
|
||||
/** A sealed-group invitation: the tag and key that open one group. */
|
||||
data class GroupInvitation(val tag: String, val key: ByteArray, val name: String) {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is GroupInvitation && tag == other.tag &&
|
||||
key.contentEquals(other.key) && name == other.name
|
||||
override fun hashCode(): Int = tag.hashCode() * 31 + key.contentHashCode()
|
||||
}
|
||||
|
||||
private val groupInvitations = mutableListOf<Triple<Long, String, GroupInvitation>>()
|
||||
|
||||
/** Take the pending invitations, leaving none behind. */
|
||||
fun takeGroupInvitations(): List<Triple<Long, String, GroupInvitation>> =
|
||||
synchronized(groupInvitations) {
|
||||
val out = groupInvitations.toList()
|
||||
groupInvitations.clear()
|
||||
out
|
||||
}
|
||||
|
||||
/** Take the pending claims, leaving none behind. */
|
||||
fun takeClaimedGroupNames(): List<Triple<Long, String, String>> =
|
||||
synchronized(claimedGroupNames) {
|
||||
@@ -498,6 +516,14 @@ class Conversations(
|
||||
theirs.forEach { trust.enforceKeyLogIfServed(it, toUserId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Public only so `Session` can send a sealed-group invitation, which is
|
||||
* an ordinary ratchet plaintext this layer has nothing to say about — it
|
||||
* carries a tag and a group key that only the app layer knows how to mint.
|
||||
*/
|
||||
fun sendPlaintextForGroupInvite(toUserId: Long, text: String): Sent =
|
||||
sendPlaintext(toUserId, text)
|
||||
|
||||
private fun sendPlaintext(toUserId: Long, text: String): Sent {
|
||||
// 🔴 **The DIRECTORY, not the key route.** `/e2e/keys` pops a one-time
|
||||
// prekey per device and is metered per contact pair — calling it on
|
||||
@@ -1381,6 +1407,20 @@ class Conversations(
|
||||
// actually in the group. `Session` drains this and checks.
|
||||
val gid = content.raw?.optString("gid")?.takeIf { it.isNotEmpty() }
|
||||
val gname = content.raw?.optString("gname")?.takeIf { it.isNotEmpty() }
|
||||
// §8.9b — an invitation carries the tag and key as well.
|
||||
val gtag = content.raw?.optString("gtag")?.takeIf { it.isNotEmpty() }
|
||||
val gkey = content.raw?.optString("gkey")?.takeIf { it.isNotEmpty() }
|
||||
?.let { runCatching { Wire.unb64(it) }.getOrNull() }
|
||||
?.takeIf { it.size == 32 }
|
||||
if (gid != null && gtag != null && gkey != null) {
|
||||
synchronized(groupInvitations) {
|
||||
groupInvitations += Triple(
|
||||
fromUser, gid, GroupInvitation(gtag, gkey, gname ?: ""))
|
||||
while (groupInvitations.size > maxClaimedGroupNames) {
|
||||
groupInvitations.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (gid != null && gname != null) {
|
||||
synchronized(claimedGroupNames) {
|
||||
claimedGroupNames += Triple(fromUser, gid, gname)
|
||||
|
||||
@@ -396,6 +396,68 @@ class RelayClient(
|
||||
Group.fromWire(postJson("/e2e/groups", JSONObject()
|
||||
.put("members", JSONArray(memberIds))))
|
||||
|
||||
// -- §8.9b Sealed groups -------------------------------------------------
|
||||
|
||||
/** A sealed group as the relay can describe it: a blob and a number. */
|
||||
data class SealedGroup(
|
||||
val id: String,
|
||||
val ownerKey: String,
|
||||
val rosterBlob: String,
|
||||
val rosterVersion: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create a group whose roster the relay cannot read.
|
||||
*
|
||||
* ⚠️ The gid is ours to choose. The owner's signature covers it, and a
|
||||
* client cannot sign an id the relay has not minted yet.
|
||||
*/
|
||||
fun createSealedGroup(
|
||||
gid: String, ownerKey: String, rosterBlob: String, tags: List<String>, sig: String
|
||||
): SealedGroup {
|
||||
val o = postJson("/e2e/groups/sealed", JSONObject()
|
||||
.put("gid", gid)
|
||||
.put("owner_key", ownerKey)
|
||||
.put("roster_blob", rosterBlob)
|
||||
.put("tags", JSONArray(tags))
|
||||
.put("sig", sig))
|
||||
return SealedGroup(o.getString("gid"), o.getString("owner_key"),
|
||||
o.getString("roster_blob"), o.optInt("roster_version", 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **"Give me the groups these tags open", never "what groups am I in".**
|
||||
* The relay cannot answer the second question about a sealed group, and a
|
||||
* client that asked it would be asking for a list only a relay holding the
|
||||
* social graph could produce.
|
||||
*/
|
||||
fun fetchSealedGroups(tags: List<String>): List<SealedGroup> {
|
||||
val list = postJson("/e2e/groups/fetch", JSONObject().put("tags", JSONArray(tags)))
|
||||
.optJSONArray("groups") ?: JSONArray()
|
||||
return (0 until list.length()).map {
|
||||
val o = list.getJSONObject(it)
|
||||
SealedGroup(o.getString("gid"), o.getString("owner_key"),
|
||||
o.getString("roster_blob"), o.optInt("roster_version", 1))
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceRoster(
|
||||
groupId: String, blob: String, version: Int, sig: String,
|
||||
tags: List<String>, newOwnerKey: String? = null,
|
||||
) {
|
||||
postJson("/e2e/groups/$groupId/roster", JSONObject()
|
||||
.put("roster_blob", blob)
|
||||
.put("roster_version", version)
|
||||
.put("sig", sig)
|
||||
.put("tags", JSONArray(tags))
|
||||
.also { o -> newOwnerKey?.let { o.put("new_owner_key", it) } })
|
||||
}
|
||||
|
||||
/** Leaving is surrendering the tag that let you in. */
|
||||
fun leaveSealedGroup(groupId: String, tag: String) {
|
||||
postJson("/e2e/groups/$groupId/leave-sealed", JSONObject().put("tag", tag))
|
||||
}
|
||||
|
||||
fun listGroups(): List<Group> {
|
||||
val list = getJson("/e2e/groups").optJSONArray("groups") ?: JSONArray()
|
||||
return (0 until list.length()).map { Group.fromWire(list.getJSONObject(it)) }
|
||||
|
||||
@@ -2,6 +2,7 @@ package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.Content
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.SealedRoster
|
||||
import io.flamenet.e2e.SealedSender
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
@@ -353,6 +354,64 @@ class GroupNameTest {
|
||||
assertTrue(2L in asked && 3L in asked, "a member was missed: $asked")
|
||||
}
|
||||
|
||||
// -- Sealed group invitations (§8.9b) --------------------------------------
|
||||
|
||||
/**
|
||||
* 🔴 **The invitation is the only way a member ever gets in.** The relay
|
||||
* holds neither the tag nor the group key, by construction, so somebody who
|
||||
* is not told cannot fetch the group at all — there is nobody left to ask.
|
||||
*/
|
||||
@Test
|
||||
fun `an invitation carries the tag and the group key`() {
|
||||
val conversations = conversations(vault())
|
||||
val key = SealedRoster.newGroupKey()
|
||||
val tag = SealedRoster.newTag()
|
||||
|
||||
conversations.consumeControlForTesting(7L, Content.parse(
|
||||
SealedSender.sealedGroupEnvelope(ByteArray(32), "g1", tag, key, "Book club")))
|
||||
|
||||
val invites = conversations.takeGroupInvitations()
|
||||
assertEquals(1, invites.size)
|
||||
assertEquals("g1", invites[0].second)
|
||||
assertEquals(tag, invites[0].third.tag)
|
||||
assertTrue(key.contentEquals(invites[0].third.key))
|
||||
assertEquals("Book club", invites[0].third.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A group key that is not 32 bytes is not a group key. Storing one would
|
||||
* make every roster for that group fail to open, which reads as a group
|
||||
* with no members rather than as a bad invitation.
|
||||
*/
|
||||
@Test
|
||||
fun `an invitation with a malformed key is ignored`() {
|
||||
val conversations = conversations(vault())
|
||||
// ⚠️ Built by hand rather than through `sealedGroupEnvelope`, which
|
||||
// only ever emits a well-formed key — the shape under test is one a
|
||||
// hostile or broken sender produces, not one this engine can make.
|
||||
fun b64(b: ByteArray) = java.util.Base64.getEncoder().encodeToString(b)
|
||||
val wire = """{"fnc":1,"kind":"delivery_key","key":"${b64(ByteArray(32))}",""" +
|
||||
""""gid":"g1","gname":"G","gtag":"${SealedRoster.newTag()}",""" +
|
||||
""""gkey":"${b64(ByteArray(8))}"}"""
|
||||
|
||||
conversations.consumeControlForTesting(7L, Content.parse(wire))
|
||||
|
||||
assertTrue(conversations.takeGroupInvitations().isEmpty())
|
||||
}
|
||||
|
||||
/** ⚠️ Drained once, so a poll does not re-apply the same invitation. */
|
||||
@Test
|
||||
fun `invitations are taken once`() {
|
||||
val conversations = conversations(vault())
|
||||
conversations.consumeControlForTesting(7L, Content.parse(
|
||||
SealedSender.sealedGroupEnvelope(
|
||||
ByteArray(32), "g1", SealedRoster.newTag(),
|
||||
SealedRoster.newGroupKey(), "G")))
|
||||
|
||||
assertEquals(1, conversations.takeGroupInvitations().size)
|
||||
assertTrue(conversations.takeGroupInvitations().isEmpty())
|
||||
}
|
||||
|
||||
// -- Where it is kept ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user