diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index efa2b704..0cd98645 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -383,6 +383,26 @@ data class ChatMessage( */ const val TYPE_SUBGROUP_CERTIFIED = "subgroupCertified" + /** + * Another group has certified **this** room as its child. + * + * The other side of the line above, and it appears in the *child's* room. + * A group is founded with a parent and may be adopted by another later -- + * see docs/subgroups-multiple-parents.md -- and an adoption is two + * signings: the new parent certifies, then this room's own quorum + * re-announces its key state accepting it. The parent commits first, so + * this line is the offer, and it is the only notice the child's admins + * get before a proposal asks for their signature. + * + * **An offer rather than a fact about the lineage.** Nothing is adopted + * until this room's quorum says so, and nothing is filed: a child device + * cannot store an event signed by a group it is not in, and does not need + * to. The certificate itself travels inside the key state at the second + * signing. The wording has to carry that, or a member reads it as a + * relationship that already exists. + */ + const val TYPE_SUBGROUP_ADOPTION_OFFERED = "subgroupAdoptionOffered" + /** * Every subgroup line, for the one check the transcript dispatches on. * @@ -392,6 +412,7 @@ data class ChatMessage( */ val SUBGROUP_TYPES = setOf( TYPE_SUBGROUP_CERTIFIED, + TYPE_SUBGROUP_ADOPTION_OFFERED, ) /** @@ -1336,29 +1357,61 @@ data class ChatMessage( // that was ever put to this room -- the child may since have been // renamed, and this line is a record of what was agreed rather // than a view of what is. - SubgroupBirthCertificateEvent.KIND -> { - SubgroupManager.record( - database = database, - chatRoomId = groupId, - innerEvent = event - ) ?: return null + // + // Two true things a certificate can be, and the room it arrived + // in is what tells them apart -- see `SubgroupManager.classify`. + // This room signed it about another, or another signed it about + // this one, which is a parent offering to adopt a group that + // already exists. Anything else is nobody's business here. + SubgroupBirthCertificateEvent.KIND -> + when (val arriving = SubgroupManager.classify(groupId, event)) { + is SubgroupManager.ArrivingCertificate.OurChild -> { + SubgroupManager.record( + database = database, + chatRoomId = groupId, + innerEvent = event + ) ?: return null - val name = SubgroupBirthCertificateEvent.parseName(event.tags) - val admins = SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags).size + val name = SubgroupBirthCertificateEvent.parseName(event.tags) + val admins = + SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags).size - ChatMessage( - giftWrapPayloadId = null, - messageType = TYPE_SUBGROUP_CERTIFIED, - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = "The group made ${name ?: "a subgroup"} a subgroup, " + - "run by $admins of its members" - ) - } + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_SUBGROUP_CERTIFIED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "The group made ${name ?: "a subgroup"} a subgroup, " + + "run by $admins of its members" + ) + } + + // Deliberately writes a line and files nothing. The offer + // is real and verified -- that group's quorum signed it -- + // but this room is not adopted until its own quorum says + // so, and the certificate reaches these devices for real + // inside the key state that says it. + is SubgroupManager.ArrivingCertificate.OfferToUs -> ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_SUBGROUP_ADOPTION_OFFERED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "${arriving.parentChatRoomId.take(16)} has offered to " + + "adopt this group as a subgroup. It is a subgroup of theirs " + + "only once this group agrees what it signs with again, " + + "naming them." + ) + + null -> return null + } else -> { ChatMessage( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 42e57358..26993788 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -431,6 +431,79 @@ object SubgroupManager { database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds() .orEmpty() + /** + * What a certificate arriving in a room is, or null if it is nothing this + * room should believe. + * + * Pure, and the decision the 30329 dispatch arm cannot make for itself. Two + * true things a certificate can be, and the room it arrived in is what tells + * them apart: + * + * - [OurChild] -- **this** room signed it, about another room. The founding + * case, and the one that was always here. + * - [OfferToUs] -- it is about **this** room, signed by the group it names. + * A parent offering to adopt an existing group, which is the half of an + * adoption the child's members would otherwise never hear about until a + * proposal asked for their signature. + * + * Both are fully verified before they are returned, and neither needs a + * lookup: `certifies` checks a signature against a room id, and a room id is + * a public key. So an offer can be believed on its own merits by a device that + * has never held the offering group's room. + * + * **Neither is stored as the offering group's event.** `GroupSignedEvent` + * declares a foreign key onto `ChatRoom`, so a child device with no row for + * the parent could not file one if it wanted to. It does not need to: the + * certificate reaches the child's devices for real inside the key state the + * child's own quorum signs, and what an offer buys here is notice, not + * evidence. + */ + sealed interface ArrivingCertificate { + /** This room certified [subgroupChatRoomId] as its child. */ + data class OurChild(val subgroupChatRoomId: String) : ArrivingCertificate + + /** [parentChatRoomId] has certified this room as *its* child. */ + data class OfferToUs(val parentChatRoomId: HexKey) : ArrivingCertificate + } + + fun classify(chatRoomId: String, innerEvent: Event): ArrivingCertificate? { + val subgroupChatRoomId = + SubgroupBirthCertificateEvent.parseSubgroupChatRoomId(innerEvent.tags) + ?: return null + + // A certificate names a parent or it is not one; `certifies` would refuse + // it either way, so reading the tag first costs nothing and lets the next + // check come before anything else. + val parentChatRoomId = + SubgroupBirthCertificateEvent.parseParentChatRoomId(innerEvent.tags) ?: return null + + // **A group is not its own child**, and this is the one self-referential + // certificate `certifies` waves through: a group can sign, truthfully by + // every check that function makes, that it is its own parent -- the + // author, the subject and the named parent are one room, the id derives + // from the key, and the signature verifies. It means nothing, and it has + // to be refused before either branch below rather than inside one, or the + // first branch reads it as an ordinary child and the group appears in its + // own list of subgroups. + if (subgroupChatRoomId.equals(parentChatRoomId, ignoreCase = true)) return null + + if (SubgroupBirthCertificateEvent.certifies(innerEvent, subgroupChatRoomId, chatRoomId)) { + return ArrivingCertificate.OurChild(subgroupChatRoomId) + } + + // Only a certificate about *this* room can be an offer to it. Checked + // before the signature so that a certificate about some third room, + // signed by some fourth, cannot be read as anything at all -- it is + // nobody's business in this room whoever signed it. + if (!subgroupChatRoomId.equals(chatRoomId, ignoreCase = true)) return null + + if (!SubgroupBirthCertificateEvent.certifies(innerEvent, chatRoomId, parentChatRoomId)) { + return null + } + + return ArrivingCertificate.OfferToUs(parentChatRoomId) + } + /** * Files a certificate that arrived, or drops it and says why. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 7b11f466..be902063 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -129,13 +129,45 @@ object ProposedEvent { // its work, and the only one a member sees before the room has done // anything. Shown as the path and the ceremony rather than as the key: // the key is the room's own id, which the member is already looking at. - GroupKeyStateEvent.KIND -> Summary( - label = "This group's shared key", - detail = listOfNotNull( + // + // ### A state naming parents is a different question, and must read like one + // + // A key state used to be proposed once, at founding, by the coordinator + // who chose its lineage -- so the path and a ceremony id were the whole of + // what a signer needed. A state can be **re-announced** now, and that is + // how a group is adopted: its own quorum accepts a parent by signing a + // state that names one. A member asked to approve that is being asked + // whether this group should answer to another, and showing them a + // derivation path answers a question nobody asked. + // + // So the parents lead when there are any. The proposal is their only + // chance to check -- after it a quorum has spoken -- and the certificates + // are right there in the tags the signature covers, so this costs a + // reader nothing it does not already hold. + // + // What this cannot say is which parents are being *dropped*, because a + // summary is pure and a disavowal is a difference against the row this + // device holds. That belongs to the screen, which has the room. + GroupKeyStateEvent.KIND -> { + val parents = GroupKeyStateEvent.parseLineage(event.tags)?.parentChatRoomIds + val shared = listOfNotNull( GroupKeyStateEvent.parsePath(event.tags)?.let(SharedKeyDerivation::formatPath), GroupKeyStateEvent.parseDkgSessionId(event.tags)?.take(12) ).joinToString(" · ") - ) + + if (parents.isNullOrEmpty()) { + Summary(label = "This group's shared key", detail = shared) + } else { + Summary( + label = if (parents.size == 1) { + "This group's shared key, and the group it answers to" + } else { + "This group's shared key, and the ${parents.size} groups it answers to" + }, + detail = (parents.map { it.take(16) } + shared).joinToString(" · ") + ) + } + } // The other thing a group signs that is about groups rather than about // work, and the only one where the *proposal* is the last chance to diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt index ca9c4bc6..bfefd1b6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -812,7 +812,8 @@ private fun RitualNotice( // An invite made and an invite sent are two separate steps on the deferred // path, so they get separate icons -- the whole reason both lines exist is // to be able to see that the first happened and the second did not. - ChatMessage.TYPE_SUBGROUP_CERTIFIED -> Icons.Default.AccountTree + ChatMessage.TYPE_SUBGROUP_CERTIFIED, + ChatMessage.TYPE_SUBGROUP_ADOPTION_OFFERED -> Icons.Default.AccountTree ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt index 8fc32ffd..a625bb39 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -18,6 +18,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.managers.SubgroupManager /** * What a birth certificate is allowed to convince a member of. @@ -350,6 +351,91 @@ class SubgroupBirthCertificateEventTest { * way to test that both are checked. [signature] replaces the real one * outright, for the cases where what arrives is not a signature at all. */ + // ---- What an arriving certificate is ------------------------------------ + // + // `SubgroupManager.classify` lives in a manager and is tested here, because + // every case it decides is a question about a certificate and this is where + // the three real quorums are. It needs no database: `certifies` checks a + // signature against a room id, and a room id is a public key. + + @Test + fun `in the parent's room, a certificate is about the room's own child`() { + assertEquals( + SubgroupManager.ArrivingCertificate.OurChild(subgroupChatRoomId), + SubgroupManager.classify(parentChatRoomId, certificate()) + ) + } + + @Test + fun `in the child's room, the same certificate is an offer to adopt it`() { + // The half of an adoption the child's members would otherwise never hear + // about until a proposal asked for their signature. One event, two + // meanings, and the room it arrived in is the whole of the difference. + assertEquals( + SubgroupManager.ArrivingCertificate.OfferToUs(parentChatRoomId), + SubgroupManager.classify(subgroupChatRoomId, certificate()) + ) + } + + @Test + fun `in a third party's room it is nothing at all`() { + val strangerRoom = SharedKeyDerivation.marmotGroupId(strangerKey, path) + + // Neither this room's act nor about this room. Whoever signed it, it is + // nobody's business here, and a line in this transcript would be noise + // about two groups the reader may not be in. + assertNull(SubgroupManager.classify(strangerRoom, certificate())) + } + + @Test + fun `an offer has to be signed by the group it names`() { + // The `parent_group` tag says the parent certified this room; the author + // says who actually signed. A stranger putting another group's name on + // its own signature is the forgery this check exists for. + val forged = certificate(author = strangerMaterial) + + assertNull(SubgroupManager.classify(subgroupChatRoomId, forged)) + } + + @Test + fun `a group is not its own child, however truthfully it says so`() { + // The one self-referential certificate `certifies` waves through: author, + // subject and named parent all one room, the id deriving from the key, + // the signature verifying. Refused before either branch -- read as an + // ordinary child it would put the group in its own list of subgroups. + val itself = certificate( + content = parentChatRoomId, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = parentChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertNull(SubgroupManager.classify(parentChatRoomId, itself)) + } + + @Test + fun `an offer must be about this room and not merely arrive in it`() { + val strangerRoom = SharedKeyDerivation.marmotGroupId(strangerKey, path) + val aboutSomebodyElse = certificate( + content = strangerRoom, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = strangerRoom, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = strangerKey, + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertNull(SubgroupManager.classify(subgroupChatRoomId, aboutSomebodyElse)) + } + private fun certificate( content: String = subgroupChatRoomId, tags: Array> = SubgroupBirthCertificateEvent.assembleTags( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 9ecddf7e..e8ff4ecf 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -3,6 +3,10 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import kotlin.test.Test import kotlin.test.assertEquals +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage +import press.mantra.compose.nostr.subgroup.SubgroupParentage /** * How an event a group signs is said to the member signing it. @@ -23,6 +27,91 @@ class ProposedEventTest { assertEquals("An article", summary.detail) } + @Test + fun `a key state with no lineage reads as the key alone`() { + // Every group made before subgroups, and every top-level group made + // after. The path and the ceremony are what a signer needs, because the + // only question is which key the room signs with. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags(room, "ceremony-1") + ) + ) + + assertEquals("This group's shared key", summary.label) + assertEquals("m/9420/0/0 · ceremony-1", summary.detail) + } + + @Test + fun `a key state that names a parent says so first`() { + // The one change in this mapping that is not cosmetic. A key state can be + // re-announced now, and that is how a group is adopted -- so a member + // approving this is deciding whether their group should answer to + // another, and a derivation path answers a question nobody asked. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = room, + dkgSessionId = "ceremony-1", + lineage = SubgroupLineage.of(parentage(parent)) + ) + ) + ) + + assertEquals("This group's shared key, and the group it answers to", summary.label) + assertEquals("${parent.take(16)} · m/9420/0/0 · ceremony-1", summary.detail) + } + + @Test + fun `a key state naming two parents counts them`() { + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = room, + dkgSessionId = "ceremony-1", + lineage = SubgroupLineage.of(listOf(parentage(parent), parentage(second))) + ) + ) + ) + + assertEquals("This group's shared key, and the 2 groups it answers to", summary.label) + // In the lineage's canonical order, so two devices describe one proposal + // the same way. + assertEquals( + "${parent.take(16)} · ${second.take(16)} · m/9420/0/0 · ceremony-1", + summary.detail + ) + } + + @Test + fun `a key state claiming a lineage it cannot state reads as the key alone`() { + // A half-claim, which `GroupKeyStateManager.stateFrom` would drop whole. + // The summary cannot say "and one group" about a parent it could not + // read, so it says nothing about lineage rather than guessing. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags(room, "ceremony-1") + + arrayOf(arrayOf("parent_group", parent)) + ) + ) + + assertEquals("This group's shared key", summary.label) + } + + private val room = "1".repeat(64) + private val parent = "a".repeat(64) + private val second = "b".repeat(64) + + /** A parentage whose certificate is nonsense; this mapping checks nothing. */ + private fun parentage(parentChatRoomId: String) = SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = event(SubgroupBirthCertificateEvent.KIND, content = parentChatRoomId) + ) + private fun event( kind: Int, tags: Array> = emptyArray(),