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 b24bbbb2..c2930909 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 @@ -13,6 +13,7 @@ import press.mantra.compose.database.model.traits.UserViewableEntity import press.mantra.compose.exceptions.MarmotUnprocessableInnerEventException import press.mantra.compose.managers.ChronicleManager import press.mantra.compose.managers.GroupKeyStateManager +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.extensions.toHex import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent @@ -22,6 +23,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent @@ -351,6 +353,28 @@ data class ChatMessage( const val TYPE_CHRONICLE_SENT = "chronicleSent" const val TYPE_CHRONICLE_RECEIVED = "chronicleReceived" + /** + * The group certified another group as its child. + * + * A system line rather than a bubble: nobody said it, the group signed + * it. It is written in the *parent's* room, which is the only place the + * certificate is ever made, and it is there for the members of that room + * who are not in the child -- for whom this is the only notice a subgroup + * exists at all. + */ + const val TYPE_SUBGROUP_CERTIFIED = "subgroupCertified" + + /** + * Every subgroup line, for the one check the transcript dispatches on. + * + * One member today, and a set anyway. A type missing from here renders as + * a chat bubble -- silently, and looking exactly like a member having said + * "The group made Translation team a subgroup". + */ + val SUBGROUP_TYPES = setOf( + TYPE_SUBGROUP_CERTIFIED, + ) + /** * Every chronicle line, for the one check the transcript dispatches on. * @@ -1240,6 +1264,46 @@ data class ChatMessage( null } + // The group has certified another group as its child. Verified + // again here rather than trusted: this arm is reached both from a + // completed signing session, where the signature has already been + // checked, and from an arriving inner event, where a member could + // have sent a rumor of this kind -- and it cannot tell which. + // + // Unlike the key state above this *does* write a line. A key state + // is standing state whose session already wrote the transcript; a + // subgroup being born is something that happened, and it happened + // on behalf of members of this room who are not in the child and + // will otherwise never be told it exists. + // + // The name is the one the parent certified, which is the only one + // 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 + + 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" + ) + } + else -> { ChatMessage( giftWrapPayloadId = null, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt index 9902817c..9ca34fd7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalChatRoom.kt @@ -116,7 +116,8 @@ data class LocalChatRoom( if (lastChatMessage.messageType in ChatMessage.DKG_TYPES || lastChatMessage.messageType in ChatMessage.FROST_TYPES || lastChatMessage.messageType in ChatMessage.CHRONICLE_TYPES || - lastChatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES + lastChatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES || + lastChatMessage.messageType in ChatMessage.SUBGROUP_TYPES ) { val isAuthored = lastChatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES || lastChatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt new file mode 100644 index 00000000..2d68d577 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -0,0 +1,396 @@ +package press.mantra.compose.managers + +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupParentage + +/** + * A group making another group, and the four steps that take. + * + * ``` + * 1. ChillDKG among the child's admins -> threshold key K, and so C = marmotGroupId(K) + * 2. birth certificate, in the parent -> the parent's quorum signs C + * 3. key state, among the child's admins -> the child's quorum signs "C signs with K", carrying it + * 4. the Marmot room -> somebody creates C and welcomes the admins + * ``` + * + * None of that order is a policy choice. Step 2 needs `C`, which does not exist + * until step 1 produces `K`; step 3 carries the certificate, so it needs step 2; + * and step 4 is gated on the key state for the same reason + * `DkgRitualViewModel.createAdminGroup` already is -- a room created before its + * group has agreed what it signs with is a room whose founding fact is settled + * after the founding. + * + * This orchestrates; it does not reimplement. Step 1 is `ChillDkgRitualManager`, + * steps 2 and 3 are `FrostSigningManager` sessions, and step 4 is + * `MarmotGroupCreation`. What lives here is the order, the guards, and the + * reading of what the parent has signed. + * + * ### Only step 1 belongs to the coordinator + * + * After the ceremony fixes the participant set, every remaining step is open to + * somebody else: the certificate to any parent admin holding a share of the + * parent's key, and the key state and the room to any of the child's admins, who + * by then all hold shares of `K` and know `C`. A coordinator whose phone dies + * after the ceremony strands nothing. That is why every function here reads its + * state off stored rows rather than off a session object handed down from the + * screen that started. + * + * See `docs/subgroups.md`. + */ +object SubgroupManager { + private const val TAG = "SubgroupManager" + + private val logger = Logger.withTag(TAG) + + /** + * The fewest admins a subgroup can be run by, the coordinator included. + * + * The same floor [press.mantra.compose.database.model.types.ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE] + * states and for the same reason: below three a quorum is not a check, since + * two admins means every decision needs both of them and one means the + * coordinator is deciding alone. + * + * The coordinator counts. They are a ChillDKG participant by construction and + * hold a share whether or not anybody ticked their name, so a picker that + * asked for three *others* would be quietly building a group of four. + */ + const val MINIMUM_ADMINS: Int = 3 + + /** + * The NIP-17 room a ceremony for these admins would run in. + * + * Pure, and the same aggregate `NostrNip17Dao.createNip17ChatRoom` and the + * inbound path both derive -- which is what makes a ceremony room findable + * without being announced, and what makes two members starting the same + * subgroup land in one room rather than two. + * + * It is also where the one hard limit comes from. A NIP-17 room's id is a + * pure function of its members, so **one admin set gets one ceremony room, + * forever**: picking a set that already holds a completed ceremony would hand + * back that ceremony's key, and the "new" subgroup would be the old group + * under a new name. [refuseCeremonyRoom] is where that is caught. + */ + fun ceremonyRoomIdFor(adminPublicKeys: Set, coordinatorPublicKey: HexKey): String = + ChatRoom.deriveChatRoomId(adminPublicKeys + coordinatorPublicKey) + + /** + * Why these admins cannot hold a ceremony together, or null when they can. + * + * Read by the picker so a subgroup that could only fail is never offered, and + * again before anything is published, because a screen not drawing something + * is not a guard. + * + * The message is the user's, so each one says what to do rather than what is + * wrong. + */ + suspend fun refuseCeremonyRoom( + database: MantraDatabase, + parentRoom: LocalChatRoom, + adminPublicKeys: Set, + coordinatorPublicKey: HexKey + ): String? { + val admins = adminPublicKeys + coordinatorPublicKey + + if (admins.size < MINIMUM_ADMINS) { + return "A subgroup needs at least $MINIMUM_ADMINS admins. Pick at least " + + "${MINIMUM_ADMINS - 1} more people." + } + + val members = ChillDkgRitualManager.memberPublicKeys(parentRoom) + if (!members.containsAll(admins)) { + return "Everyone in a subgroup has to be in this group first." + } + + // A proper subset, and this is not fussiness. The ceremony room is derived + // from its members, so selecting the whole group derives the room this + // group's own ceremony was held in -- and `proposeRitual` would hand back + // that ceremony, making the "child" this very group. + if (admins.size == members.size) { + return "A subgroup cannot be the whole group. Leave at least one member out." + } + + // Any completed or running ceremony in the room these admins derive is the + // same trap one step removed: two subgroups with exactly the same admins + // would share a key and therefore a room id. + val ceremonyRoomId = ceremonyRoomIdFor(adminPublicKeys, coordinatorPublicKey) + val running = database.dkgSessionDao().getLatestSessionForChatRoom(ceremonyRoomId) + if (running != null && running.stage != DkgRitualStage.FAILED) { + return "These members already hold a shared key together. Change who is in the subgroup." + } + + return null + } + + /** + * Step 2: asks the parent's quorum to certify the child. + * + * Runs in the **parent's** room, so the signature is by the parent's key and + * the author is the parent's own room id -- which is what makes a certificate + * checkable by anyone holding that id and nothing else. + * + * [key] is the *child's* completed ceremony, and it is read for what it + * produced rather than handed to the session: the parent signs with the + * parent's key, resolved from the parent's room the way every other signature + * there is. A caller naming a ceremony here would be a caller choosing what + * the group signs as. + * + * A session of one, never batched, for the reason `GroupKeyStateManager.propose` + * gives: a batch is only as available as its worst item, and everything after + * this depends on it. + */ + suspend fun proposeBirthCertificate( + database: MantraDatabase, + parentRoom: LocalChatRoom, + userPublicKey: HexKey, + key: DkgSession, + adminPublicKeys: List, + name: String, + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + ): FrostSigningSession { + val thresholdPublicKey = key.thresholdPublicKey + ?: throw IllegalStateException( + "Ceremony ${key.id} has produced no key for a subgroup to be certified on" + ) + + val subgroupChatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) + val parentChatRoomId = parentRoom.chatRoom.id + + require(name.isNotBlank()) { "A subgroup has to be called something" } + + logger.i("Asking $parentChatRoomId to certify $subgroupChatRoomId as its subgroup") + + return FrostSigningManager.proposeSigning( + database = database, + localChatRoom = parentRoom, + userPublicKey = userPublicKey, + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = thresholdPublicKey, + adminPublicKeys = adminPublicKeys, + name = name, + path = path + ), + content = subgroupChatRoomId + ) + } + + /** + * The newest certificate by [parentChatRoomId] that actually certifies + * [subgroupChatRoomId], or null if this device holds none. + * + * **Two certificates for one child is a normal outcome, not a conflict.** Two + * parent admins can press the button on the same admin set: the second lands + * in the same ceremony room and gets the first's ceremony back, but both may + * go on to propose a certificate and both sessions can complete. + * `GroupSignedEvent` is keyed on the event id, so the rows coexist -- and + * because both say the same true thing about the same child, which one wins + * does not matter. The `d` tag makes them replacements of each other rather + * than an accumulation, which is what it is for. + */ + suspend fun certificateFor( + database: MantraDatabase, + subgroupChatRoomId: String, + parentChatRoomId: String + ): GroupSignedEvent? = + database.groupSignedEventDao() + .getByChatRoomIdAndKind(parentChatRoomId, SubgroupBirthCertificateEvent.KIND) + .filter { + SubgroupBirthCertificateEvent.certifies( + event = it.toEvent(), + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + } + .maxByOrNull { it.createdAt } + + /** + * Step 3: asks the child's quorum to sign its key state, carrying the + * certificate. + * + * Runs where the ceremony ran, because it has to: `GroupKeyStateManager.propose` + * and `FrostSigningManager.signingPath` both tie a key state to the room its + * ceremony was held in, and the room the state is *about* does not exist yet. + * + * Refuses without a certificate that passes `certifies`. Every device that + * receives the state runs the same check and drops it when it fails, so + * proposing one this device would not believe spends a quorum's attention on + * a statement nobody will keep. + */ + suspend fun proposeSubgroupKeyState( + database: MantraDatabase, + ceremonyRoom: LocalChatRoom, + userPublicKey: HexKey, + key: DkgSession, + parentChatRoomId: HexKey, + path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + ): FrostSigningSession { + val thresholdPublicKey = key.thresholdPublicKey + ?: throw IllegalStateException("Ceremony ${key.id} has produced no key to sign a state for") + + val subgroupChatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) + + val certificate = certificateFor(database, subgroupChatRoomId, parentChatRoomId) + ?: throw IllegalStateException( + "$parentChatRoomId has not certified $subgroupChatRoomId, so its key state " + + "cannot name a parent yet" + ) + + return GroupKeyStateManager.propose( + database = database, + localChatRoom = ceremonyRoom, + userPublicKey = userPublicKey, + key = key, + path = path, + parent = SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = certificate.toEvent() + ) + ) + } + + /** + * A subgroup as the parent's own record has it, plus whatever this device + * happens to hold about it. + * + * [certificate] is the only part that is evidence. [name] and [adminPublicKeys] + * come off it too and are the **founding** roster -- what the parent approved, + * not who is in the room now -- so a screen showing them must say so or prefer + * [room] where there is one. + */ + data class Subgroup( + val chatRoomId: String, + val parentChatRoomId: String, + val certificate: GroupSignedEvent, + /** What the parent certified it as. Frozen; a rename does not reach it. */ + val name: String?, + /** Who the parent certified it for. Frozen; joins and leaves do not reach it. */ + val adminPublicKeys: List, + /** The room, when this device has one -- which is the live answer to both. */ + val room: LocalChatRoom?, + /** The child's key state, when this device holds one. */ + val keyState: GroupKeyState?, + ) { + /** Whether this device can open the subgroup, rather than only know of it. */ + val isJoined: Boolean get() = room != null + } + + /** + * Every subgroup [parentChatRoomId] has certified, as far as this device knows. + * + * Read off the parent's signed certificates rather than off a table of + * subgroups. The certificates *are* the record: they are the group's own + * signed statement, they are checkable without a lookup, and every device in + * the parent room already has them -- `FrostSigningManager.complete` files a + * `GroupSignedEvent` on every device that followed the session, not only on + * the signers'. A table beside them would be a second copy that can disagree. + * + * Includes children whose room this device does not have, which is the normal + * case for a parent member who is not in the subgroup, and children whose room + * nobody has created yet, which is the normal case between steps 2 and 4. + * + * Newest first, and one entry per child: a second certificate for the same + * child replaces the first rather than standing beside it. + */ + suspend fun subgroupsOf( + database: MantraDatabase, + parentChatRoomId: String + ): List = + database.groupSignedEventDao() + .getByChatRoomIdAndKind(parentChatRoomId, SubgroupBirthCertificateEvent.KIND) + .mapNotNull { signed -> + val event = signed.toEvent() + val chatRoomId = SubgroupBirthCertificateEvent.parseSubgroupChatRoomId(event.tags) + ?: return@mapNotNull null + + if (!SubgroupBirthCertificateEvent.certifies(event, chatRoomId, parentChatRoomId)) { + return@mapNotNull null + } + + signed to chatRoomId + } + .groupBy { (_, chatRoomId) -> chatRoomId } + .mapNotNull { (chatRoomId, certificates) -> + val newest = certificates.maxByOrNull { (signed, _) -> signed.createdAt } + ?: return@mapNotNull null + val (signed, _) = newest + val event = signed.toEvent() + + Subgroup( + chatRoomId = chatRoomId, + parentChatRoomId = parentChatRoomId, + certificate = signed, + name = SubgroupBirthCertificateEvent.parseName(event.tags), + adminPublicKeys = SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags), + room = database.chatRoomDao().findChatRoomById(chatRoomId), + keyState = database.groupKeyStateDao().getByChatRoomId(chatRoomId), + ) + } + .sortedByDescending { it.certificate.createdAt } + + /** + * The group a room is a subgroup of, verified, or null if it is nobody's + * child. + * + * Off the `GroupKeyState`, which only carries a parent whose certificate was + * checked -- see `GroupKeyStateManager.stateFrom`. A room whose state this + * device does not hold answers null even when it really is a subgroup, which + * is the position a member welcomed in after the founding is in; the + * unverified hint in the room's own description is the most they get, and it + * must never be written here. + */ + suspend fun parentOf(database: MantraDatabase, chatRoomId: String): HexKey? = + database.groupKeyStateDao().getByChatRoomId(chatRoomId)?.parentChatRoomId + + /** + * Files a certificate that arrived, or drops it and says why. + * + * Reached from two directions and it cannot tell them apart, which is why it + * checks. `FrostSigningManager.applySignedEvent` calls it with an event whose + * signature has already been verified against the session's key; the inbound + * Marmot path calls it with whatever a member put in the room. A member can + * send a rumor of this kind, so a certificate is believed here on its own + * merits or not at all. + * + * Nothing is written that is not already on file: the `GroupSignedEvent` row + * is `recordSignedEvents`' to write, and the parent link on the child's room + * comes off the child's own key state. What this adds is the check and the + * refusal. + */ + suspend fun record( + database: MantraDatabase, + chatRoomId: String, + innerEvent: Event + ): GroupSignedEvent? { + val subgroupChatRoomId = + SubgroupBirthCertificateEvent.parseSubgroupChatRoomId(innerEvent.tags) + if (subgroupChatRoomId == null) { + logger.w("A birth certificate in $chatRoomId names no subgroup; dropping") + return null + } + + if (!SubgroupBirthCertificateEvent.certifies(innerEvent, subgroupChatRoomId, chatRoomId)) { + logger.w( + "A birth certificate in $chatRoomId for $subgroupChatRoomId carries no " + + "signature by $chatRoomId; dropping" + ) + return null + } + + logger.i("$chatRoomId has certified $subgroupChatRoomId as its subgroup") + + return database.groupSignedEventDao().getById(innerEvent.id) + } +} 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 e1bb1990..7b11f466 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -3,6 +3,7 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent @@ -136,6 +137,24 @@ object ProposedEvent { ).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 + // check. A parent admin approving this is putting the group's signature + // to a room id produced by a ceremony most of them were not in, so the + // summary is who it is for and what it is called -- the two things they + // can actually weigh. The id is shown by the screen, and their device has + // already checked it derives from the key the certificate names. + SubgroupBirthCertificateEvent.KIND -> Summary( + label = "A new subgroup of this group", + detail = listOfNotNull( + SubgroupBirthCertificateEvent.parseName(event.tags), + SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags) + .size + .takeIf { it > 0 } + ?.let { "run by $it members" } + ).joinToString(" · ") + ) + else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) } } 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 263092f7..9d957852 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 @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle @@ -285,6 +286,27 @@ fun ChatTranscript( return@items } + // A subgroup being certified is the group's own + // signature, not a member's words, so it gets the + // same treatment. Answered and settled because it + // reports rather than asks: by the time this line + // exists a quorum has already signed, and there is + // nothing left for a reader to do about it. + // + // It leads nowhere for now. What a reader wants + // from one is the subgroup, and the subgroups + // section of the group's detail screen is where + // that lives. + if (localChatMessage.chatMessage.messageType in ChatMessage.SUBGROUP_TYPES) { + RitualNotice( + localChatMessage = localChatMessage, + isAnswered = true, + isSettled = true, + onClick = {} + ) + return@items + } + // Signing lines are the same kind of thing and get // the same treatment -- nobody said them either -- // but they lead somewhere else, because what a @@ -670,6 +692,7 @@ 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_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/chronicle/ChronicleEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt index 2f4f4996..ee16573b 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/chronicle/ChronicleEventTest.kt @@ -13,6 +13,7 @@ import press.mantra.compose.nostr.chronicle.tags.ChroniclePageTag import press.mantra.compose.nostr.chronicle.tags.ChronicleRecipientTag import press.mantra.compose.nostr.frost.FrostSigningEvents import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent @@ -228,6 +229,24 @@ class ChronicleEventTest { assertNull(ChronicleEvent.applyRank(GroupKeyStateEvent.KIND)) } + @Test + fun `a chronicle may not carry a birth certificate either`() { + // The same shape as the key state above: signed by the room, passes + // verification perfectly, and says something standing about groups rather + // than carrying the group's work. + // + // The argument for letting it through is better than it is for a key + // state -- "P certified C" is a fixed historical fact, where a key state + // decides what a live room signs with -- and the cost of leaving it out + // is real: a member added to a parent after a subgroup was made sees an + // empty subgroup list. It is still left out here, because admitting it + // needs an apply-order slot and a decision about whether a room's + // chronicle may carry an event its own key did not sign, which no + // chroniclable kind does. See docs/subgroups.md. + assertFalse(ChronicleEvent.isChroniclable(SubgroupBirthCertificateEvent.KIND)) + assertNull(ChronicleEvent.applyRank(SubgroupBirthCertificateEvent.KIND)) + } + @Test fun `a chronicle carries documents, not envelopes or protocol`() { assertFalse(ChronicleEvent.isChroniclable(SubmissionEvent.KIND)) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt new file mode 100644 index 00000000..332e5a26 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -0,0 +1,494 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent + +/** + * What a parent's own record says about its children, and what it refuses to say. + * + * There is no table of subgroups. The certificates *are* the record -- the + * group's own signed statement, checkable without a lookup, and held by every + * device that followed the signing session rather than only by the signers. So + * everything a parent knows about its children is a read over + * `GroupSignedEvent`, and these are the properties that read has to have. + * + * The negative cases carry the weight, as they do for chronicles. A list built + * from signed events is only as safe as its filter: a member can put a rumor of + * this kind into the room, and a real certificate signed by some *other* group is + * a valid signature over a true-looking statement with no standing behind it. + * Neither may reach the list. + * + * The one positive case that matters as much is a certified child whose room this + * device does not have. That is the normal position of a parent member who is not + * in the subgroup, and of everybody between the certificate being signed and the + * room being created -- so a list that could only show rooms would be empty + * exactly when it is most needed. + */ +class SubgroupManagerJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + private val participants = 3 + private val threshold = 2 + + /** The parent group, which does the certifying. */ + private val parentMaterial: KeyMaterial = keyMaterial( + "1c0ffee0000000000000000000000000000000000000000000000000000000a1" + ) + + /** The child. Its own ceremony, its own key, its own room. */ + private val childMaterial: KeyMaterial = keyMaterial( + "2bada550000000000000000000000000000000000000000000000000000000b2" + ) + + /** A second child, so "one entry per child" can be told from "one entry". */ + private val siblingMaterial: KeyMaterial = keyMaterial( + "3decade0000000000000000000000000000000000000000000000000000000c3" + ) + + /** A group with no standing here at all. */ + private val strangerMaterial: KeyMaterial = keyMaterial( + "4facade0000000000000000000000000000000000000000000000000000000d4" + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val parentRoomId = roomOf(parentMaterial) + private val childRoomId = roomOf(childMaterial) + private val siblingRoomId = roomOf(siblingMaterial) + + private val user = KeyPair().pubKey.toHexKey() + private val alice = KeyPair().pubKey.toHexKey() + private val bob = KeyPair().pubKey.toHexKey() + private val carol = KeyPair().pubKey.toHexKey() + + @Test + fun `a parent lists a child it certified, room or no room`(): Unit = runBlocking { + seedRoom(parentRoomId) + record(certificate()) + + val subgroups = SubgroupManager.subgroupsOf(db, parentRoomId) + + assertEquals(1, subgroups.size) + val subgroup = subgroups.single() + + assertEquals(childRoomId, subgroup.chatRoomId) + assertEquals(parentRoomId, subgroup.parentChatRoomId) + assertEquals("Translation team", subgroup.name) + assertEquals(listOf(user, alice, bob), subgroup.adminPublicKeys) + + // The whole point of reading certificates rather than rooms: this device + // holds no room for the child and knows about it anyway. That is the + // position of every parent member who is not in the subgroup. + assertNull(subgroup.room) + assertFalse(subgroup.isJoined) + assertNull(subgroup.keyState) + } + + @Test + fun `a child whose room this device holds reports it`(): Unit = runBlocking { + seedRoom(parentRoomId) + seedRoom(childRoomId) + record(certificate()) + + val subgroup = SubgroupManager.subgroupsOf(db, parentRoomId).single() + + assertTrue(subgroup.isJoined) + assertEquals(childRoomId, subgroup.room?.chatRoom?.id) + } + + @Test + fun `a certificate signed by another group is not listed`(): Unit = runBlocking { + seedRoom(parentRoomId) + + // A real quorum and a real signature by a group that has no standing to + // say anything about this parent's children. Filed in the parent's room + // -- which a member can cause -- and refused on read. + record(certificate(signer = strangerMaterial, author = strangerMaterial)) + + assertTrue(SubgroupManager.subgroupsOf(db, parentRoomId).isEmpty()) + assertNull(SubgroupManager.certificateFor(db, childRoomId, parentRoomId)) + } + + @Test + fun `a certificate nobody signed is not listed`(): Unit = runBlocking { + seedRoom(parentRoomId) + record(certificate(signature = "f".repeat(128))) + + assertTrue(SubgroupManager.subgroupsOf(db, parentRoomId).isEmpty()) + } + + @Test + fun `a certificate whose id does not derive from the key it names is not listed`(): Unit = runBlocking { + seedRoom(parentRoomId) + + record( + certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = childRoomId, + parentChatRoomId = parentRoomId, + thresholdPublicKey = strangerMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = listOf(user, alice, bob), + name = "Translation team", + path = path + ) + ) + ) + + assertTrue(SubgroupManager.subgroupsOf(db, parentRoomId).isEmpty()) + } + + @Test + fun `two certificates for one child collapse to the newest`(): Unit = runBlocking { + seedRoom(parentRoomId) + + // Two parent admins pressing the button on the same admin set. Both + // sessions can complete and both rows coexist, because GroupSignedEvent is + // keyed on the event id -- and both say the same true thing, so which one + // wins does not matter. What must not happen is two entries. + record(certificate(createdAt = 1_700_000_000, name = "First name")) + record(certificate(createdAt = 1_700_000_900, name = "Second name")) + + val subgroups = SubgroupManager.subgroupsOf(db, parentRoomId) + + assertEquals(1, subgroups.size) + assertEquals("Second name", subgroups.single().name) + assertEquals( + "Second name", + SubgroupBirthCertificateEvent.parseName( + assertNotNull(SubgroupManager.certificateFor(db, childRoomId, parentRoomId)).tags + ) + ) + } + + @Test + fun `two children are two entries, newest first`(): Unit = runBlocking { + seedRoom(parentRoomId) + + record(certificate(createdAt = 1_700_000_000, name = "Older")) + record( + certificate( + createdAt = 1_700_000_900, + name = "Newer", + subgroupMaterial = siblingMaterial + ) + ) + + assertEquals( + listOf(siblingRoomId, childRoomId), + SubgroupManager.subgroupsOf(db, parentRoomId).map { it.chatRoomId } + ) + } + + @Test + fun `record refuses a certificate the room did not sign`(): Unit = runBlocking { + seedRoom(parentRoomId) + + // The path a member takes to forge one: send a rumor of kind 30329 into + // the room and let the inbound arm file it. `record` is reached with the + // same arguments a completed session would use and cannot tell the two + // apart, so it checks. + val forged = certificate(signer = strangerMaterial, author = strangerMaterial) + record(forged) + + assertNull(SubgroupManager.record(db, parentRoomId, forged)) + assertNotNull(SubgroupManager.record(db, parentRoomId, certificate().also { record(it) })) + } + + @Test + fun `record refuses a certificate that names no subgroup`(): Unit = runBlocking { + seedRoom(parentRoomId) + + val nameless = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = childRoomId, + parentChatRoomId = parentRoomId, + thresholdPublicKey = childMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = listOf(user), + name = "Translation team", + path = path + ).filterNot { it[0] == "d" }.toTypedArray() + ) + + assertNull(SubgroupManager.record(db, parentRoomId, nameless)) + } + + // ---- the refusals ------------------------------------------------------ + + @Test + fun `a subgroup of fewer than three admins is refused`(): Unit = runBlocking { + val parent = parentWith(listOf(alice, bob, carol)) + + assertNotNull(refusal(parent, setOf(alice))) + assertNull(refusal(parent, setOf(alice, bob)), "the coordinator is the third") + } + + @Test + fun `a subgroup containing somebody who is not in the group is refused`(): Unit = runBlocking { + val parent = parentWith(listOf(alice, bob)) + val stranger = KeyPair().pubKey.toHexKey() + + assertNotNull(refusal(parent, setOf(alice, stranger))) + } + + @Test + fun `a subgroup that is the whole group is refused`(): Unit = runBlocking { + // The trap this exists for: the ceremony room is derived from its members, + // so selecting everybody derives the room this group's own ceremony was + // held in -- and `proposeRitual` would hand that ceremony back, making the + // "child" this very group. + val parent = parentWith(listOf(alice, bob)) + + assertNotNull(refusal(parent, setOf(alice, bob))) + } + + @Test + fun `an admin set that already holds a ceremony is refused`(): Unit = runBlocking { + val parent = parentWith(listOf(alice, bob, carol)) + + assertNull(refusal(parent, setOf(alice, bob))) + + // One admin set, one ceremony room, forever -- the id is a pure function + // of the members. A second subgroup with the same admins would come back + // with the first one's key and therefore the first one's room id. + // + // Stood up the way `openCeremony` does, room first: the ceremony room is + // a real NIP-17 room and `DkgSession.chatRoomId` is a foreign key onto it. + val ceremonyRoom = assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = listOf(alice, bob), + subject = "Translation team", + ) + ) + assertEquals( + SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user), + ceremonyRoom.chatRoom.id, + "the derived ceremony room is the one createNip17ChatRoom lands on", + ) + + db.dkgSessionDao().upsert( + DkgSession( + id = "s1", + chatRoomId = ceremonyRoom.chatRoom.id, + coordinatorPublicKey = user, + userPublicKey = user, + threshold = 2, + participantCount = 3, + hostPublicKey = user, + round1Random = "aa".repeat(32), + round2AuxRandom = "bb".repeat(32), + ) + ) + + assertNotNull(refusal(parent, setOf(alice, bob))) + } + + @Test + fun `the ceremony room is the same whichever way the admins are ordered`() { + // Every device has to land on the same room without being told, which is + // what makes a ceremony findable at all -- and what makes two members + // starting the same subgroup meet rather than fork. + assertEquals( + SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user), + SubgroupManager.ceremonyRoomIdFor(setOf(bob, alice), user) + ) + // And the coordinator is in it whether or not they were ticked. + assertEquals( + SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user), + SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob, user), user) + ) + } + + // ---- fixtures ---------------------------------------------------------- + + private fun keyMaterial(secret: String): KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey(ByteVector32(secret)), + nParticipants = participants, + threshold = threshold + ) + + private fun roomOf(material: KeyMaterial): String = + SharedKeyDerivation.marmotGroupId(material.thresholdPublicKey.value.toHex(), path) + + private suspend fun refusal(parent: LocalChatRoom, admins: Set): String? = + SubgroupManager.refuseCeremonyRoom( + database = db, + parentRoom = parent, + adminPublicKeys = admins, + coordinatorPublicKey = user + ) + + private suspend fun parentWith(members: List): LocalChatRoom { + (members + user).forEach { seedProfile(it) } + + return assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = members, + subject = "Ekklesia", + ) + ) + } + + private suspend fun seedProfile(publicKey: String) { + if (db.profileDao().getProfileByPublicKey(publicKey) != null) return + + val nostrEventId = publicKey.take(63) + "f" + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + db.profileDao().upsert( + Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId) + ) + } + + private suspend fun seedRoom(id: String) { + seedProfile(user) + db.chatRoomDao().upsert( + ChatRoom( + id = id, + userPublicKey = user, + subject = null, + description = null, + mlsGroupState = null, + ) + ) + } + + /** Files a certificate in the parent's room, the way a session would. */ + private suspend fun record(event: Event) = + db.groupSignedEventDao().upsert( + GroupSignedEvent.fromEvent( + event = event, + chatRoomId = parentRoomId, + derivationPath = SharedKeyDerivation.formatPath(path), + frostSigningSessionId = null, + ) + ) + + private fun certificate( + subgroupMaterial: KeyMaterial = childMaterial, + name: String = "Translation team", + adminPublicKeys: List = listOf(user, alice, bob), + tags: Array>? = null, + createdAt: Long = 1_700_000_000, + author: KeyMaterial = parentMaterial, + signer: KeyMaterial = author, + signature: String? = null + ): Event { + val subgroupChatRoomId = roomOf(subgroupMaterial) + val eventTags = tags ?: SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentRoomId, + thresholdPublicKey = subgroupMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = adminPublicKeys, + name = name, + path = path + ) + + val pubKey = SharedKeyDerivation + .derive(author.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = pubKey, + createdAt = createdAt, + kind = SubgroupBirthCertificateEvent.KIND, + tags = eventTags, + content = subgroupChatRoomId + ) + + return Event( + id = id, + pubKey = pubKey, + createdAt = createdAt, + kind = SubgroupBirthCertificateEvent.KIND, + tags = eventTags, + content = subgroupChatRoomId, + sig = signature ?: groupSignature(signer, id) + ) + } + + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +}