diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index f240f7c..dc456f0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -5,6 +5,7 @@ import io.flamenet.client.MessengerApi import io.flamenet.client.RelayClient import io.flamenet.e2e.Attachment import io.flamenet.e2e.ContactCard +import io.flamenet.e2e.SealedRoster import io.flamenet.e2e.Content import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault @@ -693,6 +694,7 @@ class Session( // Control traffic consumed during the poll may have carried group-name // claims. Checked against the roster here, where the roster is known. drainGroupNames() + drainGroupInvitations() everything.forEach { message -> // ⚠️ Local calls only. Signalling is addressed by user id the // whole way down — the answer, the ICE candidates, the hang-up — @@ -915,6 +917,94 @@ class Session( */ private var groupRosterCache: Pair>>? = null + // -- Sealed groups (§8.9b) ------------------------------------------------ + + /** + * Create a group whose roster the relay cannot read. + * + * 🔴 **No member id leaves this device.** The relay is handed a random gid, + * an owner public key, one opaque tag per member, and a blob. What it stored + * before was `gid -> user_id`, one readable row per membership. + * + * ⚠️ Reachability is checked HERE now, not by the relay — it used to ask + * "does this account have a device", which required knowing who the members + * were. The client suffers an unreachable member anyway, since fan-out is + * per device. + */ + fun createSealedGroup(name: String, memberIds: List): RelayClient.Group { + val members = (memberIds + userId).distinct().sorted() + for (member in members) { + if (member == userId) continue + require(relay.listDevices(member).length() > 0) { + "that member has no device to receive on" + } + } + + val gid = SealedRoster.newGroupId() + val groupKey = SealedRoster.newGroupKey() + val owner = SealedRoster.newOwnerKey() + val tags = members.associate { it.toString() to SealedRoster.newTag() } + + val blob = SealedRoster.seal( + SealedRoster.Contents(members, tags, name), groupKey) + val sig = SealedRoster.signRoster(owner.seed, gid, 1, blob) + + relay.createSealedGroup(gid, SealedRoster.ownerPublic(owner), blob, + tags.values.sorted(), sig) + + vault.saveGroupSecrets(gid, Vault.GroupSecrets( + tag = tags[userId.toString()] ?: "", key = groupKey, + ownerPrivate = owner.seed, version = 1)) + vault.saveGroupName(gid, name) + announceSealedGroup(gid, tags, groupKey, name, members) + + return RelayClient.Group(gid, name, userId, members) + } + + /** + * The groups this device can open, which is the only such list anywhere. + * + * 🔴 The relay is asked by TAG. It cannot be asked "what groups am I in", + * because after sealing it does not know. + */ + fun sealedGroups(): List { + val secrets = vault.allGroupSecrets() + if (secrets.isEmpty()) return emptyList() + val byTag = secrets.entries.associate { it.value.tag to it.key } + val fetched = runCatching { relay.fetchSealedGroups(byTag.keys.toList()) } + .getOrElse { return emptyList() } + + val out = ArrayList() + for (row in fetched) { + val secret = secrets[row.id] ?: continue + // ⚠️ A blob that will not open is skipped, never treated as an empty + // group: dropping every member from a conversation that still exists + // is worse than showing it stale. + val contents = runCatching { SealedRoster.open(row.rosterBlob, secret.key) } + .getOrNull() ?: continue + vault.saveGroupSecrets(row.id, secret.copy(version = row.rosterVersion)) + if (contents.name.isNotEmpty()) vault.saveGroupName(row.id, contents.name) + out += RelayClient.Group(row.id, contents.name, userId, contents.members) + } + return out + } + + /** Hand each member their tag and the group key, over the ratchet. */ + private fun announceSealedGroup( + gid: String, tags: Map, key: ByteArray, + name: String, members: List, + ) { + val mine = vault.loadDelivery()?.key ?: return + for (member in members) { + val tag = tags[member.toString()] ?: continue + val payload = io.flamenet.e2e.SealedSender.sealedGroupEnvelope( + mine, gid, tag, key, name) + // Our own other devices included, for the same reason names are: + // fan-out excludes us, so a second phone we own is told by nobody. + runCatching { conversations.sendPlaintextForGroupInvite(member, payload) } + } + } + /** * The groups this account is in, each carrying the name we hold locally. * @@ -978,6 +1068,29 @@ class Session( } } + /** + * §8.9b — invitations: a tag and the group key, arriving over the ratchet. + * + * 🔴 **The only way these ever reach us.** The relay holds neither, by + * construction, so a member who is not told cannot fetch the group at all. + * + * ⚠️ **Not subject to the roster check the 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 this 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. + */ + private fun drainGroupInvitations() { + for ((_, gid, invite) in conversations.takeGroupInvitations()) { + if (vault.groupSecrets(gid) != null) continue + vault.saveGroupSecrets(gid, Vault.GroupSecrets( + tag = invite.tag, key = invite.key, ownerPrivate = null, version = 1)) + if (invite.name.isNotEmpty()) vault.saveGroupName(gid, invite.name) + groupNamesUpdated = true + } + } + /** * Set when a group name changed, so the UI knows to re-read the list. * Control traffic touches no thread, so without a flag a name that arrived diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 6bd2166..ae9bf85 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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>() + + /** Take the pending invitations, leaving none behind. */ + fun takeGroupInvitations(): List> = + synchronized(groupInvitations) { + val out = groupInvitations.toList() + groupInvitations.clear() + out + } + /** Take the pending claims, leaving none behind. */ fun takeClaimedGroupNames(): List> = 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) diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index f9815ce..5b6c85f 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -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, 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): List { + 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, 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 { val list = getJson("/e2e/groups").optJSONArray("groups") ?: JSONArray() return (0 until list.length()).map { Group.fromWire(list.getJSONObject(it)) } diff --git a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt index 4b523b3..1df1872 100644 --- a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt @@ -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 diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt index 1f29ece..309e876 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealedRoster.kt @@ -100,6 +100,27 @@ object SealedRoster { * `Group.rosterCanonical` on the relay and `SealedRoster.canonical` on iOS * exactly, or every signature this client makes is refused. */ + /** + * A new group id: 32 random bytes as hex. + * + * ⚠️ Minted here rather than by the app so the app never needs the engine's + * internal encoding helpers — and so the shape stays in one place. It is + * the same shape the relay used to mint, before the owner's signature had + * to cover it. + */ + fun newGroupId(): String = + ByteArray(32).also { random.nextBytes(it) } + .joinToString("") { "%02x".format(it) } + + /** An owner keypair for a group we create. */ + fun newOwnerKey(): Ed25519.KeyPair = Ed25519.generate() + + fun ownerPublic(pair: Ed25519.KeyPair): String = Bytes.b64(pair.publicKey) + + /** Sign a roster as its owner. Base64, ready for the wire. */ + fun signRoster(ownerSeed: ByteArray, gid: String, version: Int, blob: String): String = + Bytes.b64(Ed25519.sign(ownerSeed, canonical(gid, version, blob))) + fun canonical(gid: String, version: Int, blob: String): ByteArray = "fngroup-v1|$gid|$version|$blob".toByteArray() } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt index 9b7d7da..7053f50 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt @@ -217,6 +217,25 @@ object SealedSender { * specifies. `delivery_key` is a kind they already consume in silence and * already ignore unknown fields on. */ + /** + * §8.9b — hand a member their tag and the group key. + * + * 🔴 **The only way these ever reach anybody.** The relay holds neither, by + * construction, so a member who is not told cannot fetch the group at all — + * there is nobody to ask. + * + * ⚠️ Rides `delivery_key` for the same reason the group name does: a new + * control kind renders as raw JSON on every phone running v5. + */ + fun sealedGroupEnvelope( + key: ByteArray, groupId: String, tag: String, groupKey: ByteArray, name: String + ): String = + """{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}"""" + + ""","gid":${JSONObject.quote(groupId)}""" + + ""","gname":${JSONObject.quote(name)}""" + + ""","gtag":${JSONObject.quote(tag)}""" + + ""","gkey":${JSONObject.quote(Bytes.b64(groupKey))}}""" + fun deliveryKeyEnvelope( key: ByteArray, tokens: List = emptyList(), diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 852f726..95e3abd 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -87,6 +87,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val REPLY_REMINDER = "reply-reminder" private const val ROSTER = "roster" private const val GROUP_NAMES = "group-names" + private const val GROUP_SECRETS = "group-secrets" private const val DEVICE_OWNER_PREFIX = "devowner/" private const val KT_ISSUER_PIN = "kt-issuer-pin" /** Kept because the whole log is re-sealed on every write. */ @@ -433,6 +434,73 @@ class Vault(private val storage: Storage, private val key: SecretKey) { return runCatching { Attachment.Ref.fromJson(Attachment.IMAGE, json, 0.0) }.getOrNull() } + // -- Sealed group secrets (§8.9b) ----------------------------------------- + + /** + * What this device holds for one sealed group. + * + * 🔴 **Lose these and the group is gone.** The relay cannot tell you what + * you are a member of — that is the entire point of sealing — so the tag is + * the only way to fetch the group and the key is the only way to read it. + * Both ride the encrypted history export and the group invitation, the same + * paths the group name already uses. + */ + data class GroupSecrets( + /** The opaque token that fetches this group from the relay. */ + val tag: String, + /** Opens the roster blob. */ + val key: ByteArray, + /** Ed25519 private key iff we own this group — only the owner may + * rewrite a roster, and only with this. */ + val ownerPrivate: ByteArray? = null, + /** Last roster version seen, so a rewrite increments rather than + * guesses: the relay refuses a version that does not increase. */ + val version: Int = 1, + ) { + override fun equals(other: Any?): Boolean = + other is GroupSecrets && tag == other.tag && key.contentEquals(other.key) && + version == other.version && + (ownerPrivate?.contentEquals(other.ownerPrivate ?: ByteArray(0)) + ?: (other.ownerPrivate == null)) + + override fun hashCode(): Int = tag.hashCode() * 31 + key.contentHashCode() + } + + fun groupSecrets(groupId: String): GroupSecrets? = + readJson(GROUP_SECRETS)?.optJSONObject(groupId)?.let { o -> + GroupSecrets( + tag = o.optString("tag"), + key = Bytes.unb64(o.optString("key")), + ownerPrivate = o.optString("owner").takeIf { it.isNotEmpty() }?.let(Bytes::unb64), + version = o.optInt("version", 1), + ) + } + + fun saveGroupSecrets(groupId: String, secrets: GroupSecrets) { + val json = readJson(GROUP_SECRETS) ?: JSONObject() + json.put(groupId, JSONObject() + .put("tag", secrets.tag) + .put("key", Bytes.b64(secrets.key)) + .put("version", secrets.version) + .also { o -> secrets.ownerPrivate?.let { o.put("owner", Bytes.b64(it)) } }) + writeJson(GROUP_SECRETS, json) + } + + /** Every group this device can open — the only such list that exists. */ + fun allGroupSecrets(): Map { + val json = readJson(GROUP_SECRETS) ?: return emptyMap() + return json.keys().asSequence().mapNotNull { gid -> + groupSecrets(gid)?.let { gid to it } + }.toMap() + } + + /** Forget a group on leaving. The tag is surrendered separately. */ + fun clearGroupSecrets(groupId: String) { + val json = readJson(GROUP_SECRETS) ?: return + json.remove(groupId) + writeJson(GROUP_SECRETS, json) + } + // -- Group names --------------------------------------------------------- /**