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 4786ac1..f240f7c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -753,10 +753,19 @@ class Session( // the repair path for it. The announcement sent at creation // reaches only who was online and only the devices that existed // then; a phone set up later would otherwise sit on the relay's - // generic placeholder for ever. The membership check above has - // already run, so this is not a way in for a stranger. - message.content.raw?.optString("gname")?.takeIf { it.isNotEmpty() } - ?.let { adoptGroupName(gid, it) } + // generic placeholder for ever. + // + // 🔴 **Proven senders only, exactly like the announcement.** + // `consumeControl` refuses an unproven device, and for a while + // this path did not — so the weaker of the two routes decided + // what a group is called. On an unproven device `fromUser` is a + // claim, and the roster check above then asks "is the person + // they are pretending to be in this group", which answers yes + // for any member they care to name. + if (message.senderProven) { + message.content.raw?.optString("gname")?.takeIf { it.isNotEmpty() } + ?.let { adoptGroupName(gid, it) } + } } // 🔴 **A federated message files under its ADDRESS.** Their relay // told ours nothing about who sent it, so `fromUser` is 0 — filing diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 5bb5330..185e332 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -93,6 +93,17 @@ class Conversations( * a sealed message's sender on a path that skips that. */ val sealed: Boolean = false, + /** + * Whether the ratchet tied this sender to an identity we already hold. + * + * 🔴 **False means [fromUser] is a CLAIM.** The device's binding says + * it belongs to somebody else, so anything keyed on the sender — + * "is this person a member of that group", "is this their key" — is + * asking about the person they are pretending to be. Control traffic is + * refused outright at this layer; callers that act on the sender for + * any other reason have to check this themselves. + */ + val senderProven: Boolean = true, /** * When the relay accepted this envelope, epoch millis, 0 = unknown. * Carried for call signalling — see [RelayClient.Incoming.createdAt]. @@ -1114,7 +1125,8 @@ class Conversations( } if (fromAddress != null) vault.rememberRemoteAddress(fromAddress) out += Received(message.id, fromUser, fromDevice, content, - sealed = sealed, at = message.createdAt, + sealed = sealed, senderProven = senderProven, + at = message.createdAt, fromAddress = fromAddress) } catch (_: Exception) { // Deliberately swallowed and skipped. One undecryptable message diff --git a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt index 72773ee..7d12458 100644 --- a/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt +++ b/client/src/test/kotlin/io/flamenet/client/GroupNameTest.kt @@ -292,6 +292,27 @@ class GroupNameTest { assertEquals("Book club, Tuesdays", current.groupName("g1")) } + /** + * 🔴 **`Received` carries whether the sender was proven, because a caller + * that acts on `fromUser` has to be able to ask.** An unproven device's + * sender id is a claim — the binding says it belongs to somebody else — + * so a roster check keyed on it asks "is the person they are pretending to + * be in this group", which answers yes for any member they name. + * `Session.receive` gates group-name adoption on this. + */ + @Test + fun `received messages say whether the sender was proven`() { + // The default is the safe-looking one, so the field is asserted at both + // values: a default that silently reads "proven" for everything would + // make every gate built on it a no-op. + val proven = Conversations.Received(1L, 42L, "dev", Content("hi")) + assertTrue(proven.senderProven) + + val unproven = Conversations.Received( + 1L, 42L, "dev", Content("hi"), senderProven = false) + assertFalse(unproven.senderProven) + } + // -- Where it is kept ------------------------------------------------------ @Test