diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 45d110c6..a65a7a5d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -365,4 +365,29 @@ Nothing matched that search. Key package published Key package rotated + Subgroups + Add subgroup + No subgroups have been made by this group. + Certified, not created yet + Created, you aren't a member + A subgroup + Parent group + The group this one is a subgroup of + Run by %1$s members + New subgroup + What is the subgroup called? + Who runs the subgroup? + A subgroup needs at least three admins, you included, so pick at least two more people. + Anyone in this group can run a subgroup, whether or not they administer this one. + Has no key package yet + Admin of this group + You coordinate this subgroup + Start the key ceremony + Checking who can be added… + The parent group has certified this subgroup + The parent group could not certify this subgroup + The parent group is certifying this subgroup — %1$s of %2$s admins have to sign + Before the subgroup exists, its parent signs for it. That signature is what lets anyone check where this group came from. + Ask the parent group to certify this subgroup + Create the subgroup diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 6ee41cc7..c4f36b34 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -5,8 +5,10 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.managers.ChronicleManager +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload @@ -101,6 +103,50 @@ class DatabaseChatRepository( null } + + override suspend fun subgroupsOf(chatRoomId: String): List = try { + SubgroupManager.subgroupsOf(database, chatRoomId) + } catch (e: Throwable) { + // A room that shows no subgroups is wrong and survivable; one that will + // not open because reading them threw is neither. + logger.e("Error reading the subgroups of $chatRoomId", e) + emptyList() + } + + override suspend fun parentOf(chatRoomId: String): HexKey? = try { + SubgroupManager.parentOf(database, chatRoomId) + } catch (e: Throwable) { + logger.e("Error reading the parent of $chatRoomId", e) + null + } + + override suspend fun canSign(chatRoomId: String): Boolean = try { + FrostSigningManager.canSign(database, chatRoomId) + } catch (e: Throwable) { + logger.e("Error reading whether $chatRoomId can sign", e) + false + } + + override suspend fun refuseSubgroup( + parentChatRoomId: String, + adminPublicKeys: Set, + coordinatorPublicKey: HexKey, + ): String? = try { + val parentRoom = database.chatRoomDao().findChatRoomById(parentChatRoomId) + ?: return "Couldn't load this group." + + SubgroupManager.refuseCeremonyRoom( + database = database, + parentRoom = parentRoom, + adminPublicKeys = adminPublicKeys, + coordinatorPublicKey = coordinatorPublicKey, + ) + } catch (e: Throwable) { + // A refusal that cannot be computed has to refuse. The alternative is + // opening a ceremony whose guards never ran. + logger.e("Error checking whether $parentChatRoomId can take a subgroup", e) + "Couldn't check this subgroup. Please try again." + } override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { return database.participantDao().findParticipantByPublicKey(publicKey) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index 4a1f1b26..89bac5fe 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -5,13 +5,16 @@ import press.mantra.compose.database.model.DkgParticipantMessage 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.intermdiate.LocalFrostSigningSession import press.mantra.compose.database.model.types.DkgApprovalStep import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.managers.GroupKeyStateManager +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.repository.DkgRepository import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -108,6 +111,78 @@ class DatabaseDkgRepository( null } + override suspend fun proposeBirthCertificate( + parentChatRoomId: String, + userPublicKey: HexKey, + session: DkgSession, + adminPublicKeys: List, + name: String, + ): FrostSigningSession? = try { + val parentRoom = database.chatRoomDao().findChatRoomById(parentChatRoomId) + ?: throw IllegalStateException("No parent room $parentChatRoomId to certify from") + + SubgroupManager.proposeBirthCertificate( + database = database, + parentRoom = parentRoom, + userPublicKey = userPublicKey, + key = session, + adminPublicKeys = adminPublicKeys, + name = name, + ) + } catch (e: Throwable) { + // A device with no share of the parent's key cannot open this session, + // and a ceremony with no key yet is a caller that got ahead of itself. + // Neither is worth failing anything over: nothing was published, and the + // screen reports it by leaving the rung unfinished with the button there. + logger.e("Error proposing a birth certificate in $parentChatRoomId", e) + null + } + + override fun observeBirthCertificate( + ceremonyChatRoomId: String, + parentChatRoomId: String, + ): Flow = + database.groupSignedEventDao() + .observeByKind(SubgroupBirthCertificateEvent.KIND) + .map { signedEvents -> + // The room the ceremony's key derives, resolved per emission + // rather than once: the ceremony has no key until it finishes, + // and this flow is running before it does. + val subgroupChatRoomId = database.dkgSessionDao() + .getLatestSessionForChatRoom(ceremonyChatRoomId) + ?.thresholdPublicKey + ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } + ?: return@map null + + signedEvents + .filter { + SubgroupBirthCertificateEvent.certifies( + event = it.toEvent(), + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + ) + } + .maxByOrNull { it.createdAt } + } + + override suspend fun proposeSubgroupKeyState( + ceremonyRoom: LocalChatRoom, + userPublicKey: HexKey, + session: DkgSession, + parentChatRoomId: HexKey, + ): FrostSigningSession? = try { + SubgroupManager.proposeSubgroupKeyState( + database = database, + ceremonyRoom = ceremonyRoom, + userPublicKey = userPublicKey, + key = session, + parentChatRoomId = parentChatRoomId, + ) + } catch (e: Throwable) { + logger.e("Error proposing the subgroup key state from ${ceremonyRoom.chatRoom.id}", e) + null + } + override suspend fun approve( localChatRoom: LocalChatRoom, sessionId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 150260b9..042a1d11 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -8,6 +8,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -58,6 +59,50 @@ interface ChatRepository { */ suspend fun signedGroupKeyStateEvent(chatRoomId: String): GroupSignedEvent? + /** + * Every subgroup this room has certified, as far as this device knows. + * + * Read off the parent's own signed certificates rather than off a table, so + * it includes children whose room this device does not have -- which is the + * normal position of a member who is not in the subgroup. See + * `SubgroupManager.subgroupsOf`. + */ + suspend fun subgroupsOf(chatRoomId: String): List + + /** + * The group this room is a subgroup of, verified, or null if it is nobody's + * child. + * + * Off the room's `GroupKeyState`, which only carries a parent whose + * certificate was checked. A member welcomed in after the founding holds no + * state and gets null here even when the room really is a subgroup. + */ + suspend fun parentOf(chatRoomId: String): HexKey? + + /** + * Whether this device holds a share of the group's key, and so could take + * part in signing for it. + * + * Read by the UI so nothing is offered that would throw: + * `FrostSigningManager.proposeSigningBatch` refuses outright for a device + * with no share, and a button that throws is not a button. + */ + suspend fun canSign(chatRoomId: String): Boolean + + /** + * Why these members cannot hold a subgroup ceremony together, or null when + * they can. + * + * Read by the picker so a subgroup that could only fail is never offered, and + * again on confirm, because a screen not drawing something is not a guard. + * See `SubgroupManager.refuseCeremonyRoom`. + */ + suspend fun refuseSubgroup( + parentChatRoomId: String, + adminPublicKeys: Set, + coordinatorPublicKey: HexKey, + ): String? + suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List @@ -210,6 +255,19 @@ interface ChatRepository { chatRoomId: String ): GroupSignedEvent? = null + override suspend fun subgroupsOf(chatRoomId: String): List = + emptyList() + + override suspend fun parentOf(chatRoomId: String): HexKey? = null + + override suspend fun canSign(chatRoomId: String): Boolean = false + + override suspend fun refuseSubgroup( + parentChatRoomId: String, + adminPublicKeys: Set, + coordinatorPublicKey: HexKey, + ): String? = "No database" + override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { return flow { } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index a947ef9e..c2d15dac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -4,6 +4,7 @@ import press.mantra.compose.database.model.DkgParticipantMessage 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.intermdiate.LocalFrostSigningSession import press.mantra.compose.database.model.types.DkgApprovalStep @@ -96,6 +97,47 @@ interface DkgRepository { /** Files the state the group signed for a room that now exists. */ suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? + /** + * Asks the parent's quorum to certify the subgroup this ceremony produced. + * + * Runs in the **parent's** room, not the ceremony's, so what comes back is + * signed by the parent's key and authored by the parent's own room id -- the + * one thing a lineage can be checked against. See + * `SubgroupManager.proposeBirthCertificate`. + */ + suspend fun proposeBirthCertificate( + parentChatRoomId: String, + userPublicKey: HexKey, + session: DkgSession, + adminPublicKeys: List, + name: String, + ): FrostSigningSession? + + /** + * Whether the parent has certified the room this ceremony's key derives. + * + * Answerable before that room exists, which is the point: it is what gates + * the key state, which is itself signed before the room is created. + */ + fun observeBirthCertificate( + ceremonyChatRoomId: String, + parentChatRoomId: String, + ): Flow + + /** + * Asks the subgroup's own quorum to sign its key state, carrying the + * certificate. + * + * Refuses without one, because every device that receives the state runs the + * same check and drops it -- see `GroupKeyStateManager.stateFrom`. + */ + suspend fun proposeSubgroupKeyState( + ceremonyRoom: LocalChatRoom, + userPublicKey: HexKey, + session: DkgSession, + parentChatRoomId: HexKey, + ): FrostSigningSession? + companion object { val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository { override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow = flowOf(null) @@ -135,6 +177,26 @@ interface DkgRepository { flowOf(null) override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null + + override suspend fun proposeBirthCertificate( + parentChatRoomId: String, + userPublicKey: HexKey, + session: DkgSession, + adminPublicKeys: List, + name: String, + ): FrostSigningSession? = null + + override fun observeBirthCertificate( + ceremonyChatRoomId: String, + parentChatRoomId: String, + ): Flow = flowOf(null) + + override suspend fun proposeSubgroupKeyState( + ceremonyRoom: LocalChatRoom, + userPublicKey: HexKey, + session: DkgSession, + parentChatRoomId: HexKey, + ): FrostSigningSession? = null } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index de4e9e98..1edfffee 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -85,6 +85,18 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.run_by_members +import mantra.composeapp.generated.resources.parent_group +import mantra.composeapp.generated.resources.a_subgroup +import mantra.composeapp.generated.resources.created_you_are_not_a_member +import mantra.composeapp.generated.resources.certified_not_yet_created +import mantra.composeapp.generated.resources.no_subgroups_have_been_made_by_this_group +import mantra.composeapp.generated.resources.add_subgroup +import mantra.composeapp.generated.resources.subgroups +import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute +import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute +import press.mantra.compose.managers.SubgroupManager import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.add_artifact_to_library @@ -237,6 +249,57 @@ fun ChatRoomDetailScreen( } } + // Directly under the signing key, because between them + // they are what the room *is*: the identity it signs as + // and whose child it is. Absent on a group that is + // nobody's child, and absent -- rather than guessed -- + // on a subgroup this device holds no key state for, + // since an unverified lineage is not one worth showing. + chatRoomDetailUIState.parentChatRoomId?.let { parentChatRoomId -> + item { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { + onNavigateToRoute.invoke( + ChatRoomDetailRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = parentChatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = Decorative + ) + }, + headlineContent = { + Text(text = stringResource(Res.string.parent_group)) + }, + supportingContent = { + Text( + text = parentChatRoomId + .take(16) + .inComparableGroups(), + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + } + ) + } + } + } + item { chatRoomDetailUIState.localChatRoom.chatRoom.description?.let { Text( @@ -444,6 +507,82 @@ fun ChatRoomDetailScreen( HorizontalDivider() } + item { + Text( + text = stringResource(Res.string.subgroups), + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.subgroups.isEmpty()) { + item { + Text(stringResource(Res.string.no_subgroups_have_been_made_by_this_group)) + } + } else { + items( + items = chatRoomDetailUIState.subgroups, + key = { subgroup -> subgroup.chatRoomId } + ) { subgroup -> + SubgroupCard( + subgroup = subgroup, + onOpen = { + // Into the child where this device has + // it; at the certificate where it does + // not, since that is the whole of what + // is known and it is checkable. + onNavigateToRoute.invoke( + subgroup.room?.let { + ChatRoomDetailRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = it.chatRoom.id, + relayHint = relayHint + ) + } ?: NostrEventDetailRoute( + activeUserPublicKey = activeUserPublicKey, + nostrEventId = subgroup.certificate.id + ) + ) + } + ) + } + } + + // Offered only where it can work: this device holds a + // share of the group's key, so it can open the + // certificate session, and the room is a Marmot one, so + // there is a group with admins to be a parent. Hidden + // rather than disabled, the way the shared key entry is. + if (chatRoomDetailUIState.canAddSubgroup) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + SelectSubgroupAdminsRoute( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.add_subgroup)) + } + } + } + + item { + HorizontalDivider() + } + item { Text( text = stringResource(Res.string.members_2), @@ -911,6 +1050,56 @@ internal fun GroupKeyStateSheetContent( * signed event, which is not grouped -- so the display can be shaped for reading * without a paste losing the value. */ +/** + * One subgroup, as the parent's record and this device's rooms together have it. + * + * **The title comes from the room where there is one, and only otherwise from the + * certificate.** The certificate's name and `p` tags are what the parent approved + * at founding, frozen: the child may since have been renamed or have gained + * members, and none of that reaches a signature already made. So where this + * device holds the room, the room is the live answer; where it does not, the + * certificate is all there is and the supporting line says which of the two + * absences this is. + */ +@Composable +private fun SubgroupCard( + subgroup: SubgroupManager.Subgroup, + onOpen: () -> Unit +) { + Card(onClick = onOpen) { + ListItem( + leadingContent = { + Icon(Icons.Default.AccountTree, contentDescription = Decorative) + }, + trailingContent = { + Icon(Icons.Default.ChevronRight, contentDescription = Decorative) + }, + headlineContent = { + Text( + text = subgroup.room?.chatRoom?.subject + ?: subgroup.name + ?: stringResource(Res.string.a_subgroup) + ) + }, + supportingContent = { + Text( + text = when { + subgroup.isJoined -> stringResource( + Res.string.run_by_members, + subgroup.adminPublicKeys.size.toString() + ) + // The room does not exist anywhere yet: the parent has + // certified it and nobody has created it. Between steps 2 + // and 4 of docs/subgroups.md this is every member's view. + subgroup.keyState == null -> stringResource(Res.string.certified_not_yet_created) + else -> stringResource(Res.string.created_you_are_not_a_member) + } + ) + } + ) + } +} + private fun String.inComparableGroups(): String = chunked(8).joinToString(" ") /** One labelled value of a key state, whole and comparable. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index bfc6a964..7cba6bf3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -81,6 +81,13 @@ import press.mantra.compose.ui.composable.widgets.Decorative import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource import mantra.composeapp.generated.resources.create_the_admins_group +import androidx.compose.material.icons.filled.AccountTree +import mantra.composeapp.generated.resources.create_the_subgroup +import mantra.composeapp.generated.resources.ask_the_parent_group_to_certify +import mantra.composeapp.generated.resources.before_the_subgroup_exists_its_parent_signs +import mantra.composeapp.generated.resources.the_parent_group_is_certifying_this_subgroup +import mantra.composeapp.generated.resources.the_parent_group_could_not_certify_this_subgroup +import mantra.composeapp.generated.resources.the_parent_group_has_certified_this_subgroup import mantra.composeapp.generated.resources.how_many_members_will_it_take_to_sign import mantra.composeapp.generated.resources.key import mantra.composeapp.generated.resources.members @@ -125,6 +132,11 @@ import press.mantra.compose.ui.theme.ConformancePreviews fun DkgRitualScreen( activeUserPublicKey: HexKey, chatRoomId: String, + /** + * Set when this ceremony is making a subgroup, which grows the ladder by one + * rung: the parent's birth certificate, between the key and the key state. + */ + parentChatRoomId: String? = null, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, activeWalletStateFlow: StateFlow, chatRepository: ChatRepository, @@ -135,6 +147,7 @@ fun DkgRitualScreen( factory = DkgRitualViewModel.factory( chatRoomId = chatRoomId, activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = parentChatRoomId, initialDkgRitualUIState = initialDkgRitualUIState, activeWalletStateFlow = activeWalletStateFlow, chatRepository = chatRepository, @@ -294,6 +307,10 @@ fun DkgRitualScreen( adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn, keyStateSession = dkgRitualUIState.keyStateSession, isKeyStateSigned = dkgRitualUIState.isKeyStateSigned, + parentChatRoomId = dkgRitualUIState.parentChatRoomId, + isCertified = dkgRitualUIState.isCertified, + certificateSession = dkgRitualUIState.certificateSession, + onProposeBirthCertificate = dkgRitualViewModel::proposeBirthCertificate, onProposeKeyState = dkgRitualViewModel::proposeKeyState, onCreateAdminGroup = { dkgRitualViewModel.createAdminGroup(onNavigateToRoute) @@ -343,6 +360,10 @@ private fun RitualProgress( adminGroupBlockedOn: List, keyStateSession: FrostSigningSession?, isKeyStateSigned: Boolean, + parentChatRoomId: String?, + isCertified: Boolean, + certificateSession: FrostSigningSession?, + onProposeBirthCertificate: () -> Unit, onProposeKeyState: () -> Unit, onCreateAdminGroup: () -> Unit, ) { @@ -500,6 +521,71 @@ private fun RitualProgress( // Shown to every member, not only the coordinator, because every // member has to sign it -- the request itself is a line in this // chat, which is where they answer it. + // A subgroup has one more thing to settle than a group does, and + // it comes first: the parent's signature over this room's id. The + // key state carries that certificate, so it cannot be proposed + // until this rung is done -- which is why the order on screen is + // the order the steps actually happen in rather than a layout + // choice. + if (parentChatRoomId != null) { + val certificateFailed = certificateSession?.stage == FrostSigningStage.FAILED + + when { + isCertified -> Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.CheckCircle, contentDescription = Decorative) + Text( + text = stringResource(Res.string.the_parent_group_has_certified_this_subgroup), + style = MaterialTheme.typography.labelMedium + ) + } + + certificateFailed -> Text( + text = stringResource(Res.string.the_parent_group_could_not_certify_this_subgroup), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error + ) + + certificateSession != null -> Text( + text = stringResource( + Res.string.the_parent_group_is_certifying_this_subgroup, + certificateSession.threshold, + certificateSession.participantCount + ), + style = MaterialTheme.typography.labelMedium + ) + + else -> Text( + text = stringResource(Res.string.before_the_subgroup_exists_its_parent_signs), + style = MaterialTheme.typography.labelMedium + ) + } + + // Offered to whoever holds a share of the parent's key, which + // the coordinator does by construction -- being a parent admin + // is how they got here. The other subgroup admins answer it in + // the parent's room, where the proposal lands, so there is + // nothing for them to press here. + if (session.isCoordinator() && !isCertified) { + Button( + onClick = { onProposeBirthCertificate() }, + enabled = !isActionPending && + (certificateSession == null || certificateFailed), + modifier = Modifier.fillMaxWidth() + ) { + if (isActionPending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Icon(Icons.Default.AccountTree, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) + Text(text = stringResource(Res.string.ask_the_parent_group_to_certify)) + } + } + } + } + val keyStateFailed = keyStateSession?.stage == FrostSigningStage.FAILED when { @@ -567,7 +653,13 @@ private fun RitualProgress( } else { Icon(Icons.Default.Groups, contentDescription = Decorative) Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) - Text(text = stringResource(Res.string.create_the_admins_group)) + Text( + text = if (parentChatRoomId != null) { + stringResource(Res.string.create_the_subgroup) + } else { + stringResource(Res.string.create_the_admins_group) + } + ) } } } else { @@ -579,7 +671,13 @@ private fun RitualProgress( // this is the safe retry by construction. Button( onClick = { onProposeKeyState() }, - enabled = !isActionPending && (keyStateSession == null || keyStateFailed), + // A subgroup's key state carries the certificate and + // is refused without one, so this stays shut until the + // rung above it is done rather than opening a session + // that would throw. + enabled = !isActionPending && + (keyStateSession == null || keyStateFailed) && + (parentChatRoomId == null || isCertified), modifier = Modifier.fillMaxWidth() ) { if (isActionPending) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt new file mode 100644 index 00000000..28932cde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectSubgroupAdminsScreen.kt @@ -0,0 +1,334 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.ActiveWallet +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_subgroup_needs_at_least_three_admins +import mantra.composeapp.generated.resources.admin_of_this_group +import mantra.composeapp.generated.resources.anyone_in_this_group_can_run_a_subgroup +import mantra.composeapp.generated.resources.has_no_key_package_yet +import mantra.composeapp.generated.resources.how_many_admins_have_to_approve_a_change +import mantra.composeapp.generated.resources.new_subgroup +import mantra.composeapp.generated.resources.of +import mantra.composeapp.generated.resources.start_the_key_ceremony +import mantra.composeapp.generated.resources.what_is_the_subgroup_called +import mantra.composeapp.generated.resources.who_runs_the_subgroup +import mantra.composeapp.generated.resources.you_coordinate_this_subgroup +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.SelectSubgroupAdminsViewModel +import press.mantra.compose.ui.view.state.SelectSubgroupAdminsUIState + +/** + * Picks who runs a subgroup, what it is called, and on what quorum. + * + * The three things that cannot be changed later, gathered before anything is + * published -- see `SelectSubgroupAdminsViewModel` for why the threshold in + * particular has nowhere else to live. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun SelectSubgroupAdminsScreen( + activeUserPublicKey: HexKey, + parentChatRoomId: String, + initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, + activeWalletStateFlow: StateFlow, + chatRepository: ChatRepository, + dkgRepository: DkgRepository, + onNavigateToRoute: (Route) -> Unit +) { + val viewModel: SelectSubgroupAdminsViewModel = viewModel( + factory = SelectSubgroupAdminsViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = parentChatRoomId, + initialUIState = initialUIState, + activeWalletStateFlow = activeWalletStateFlow, + chatRepository = chatRepository, + dkgRepository = dkgRepository + ) + ) + + LaunchedEffect(parentChatRoomId) { viewModel.initiate() } + + val snackbarHostState = LocalSnackbarHostState.current + val scope = rememberCoroutineScope() + + ScreenStateTransition(viewModel.uiState) { uiState -> + when (val state = uiState) { + is SelectSubgroupAdminsUIState.Error -> ErrorState( + message = state.message, + // Retrying reloads the group, which is the thing that failed. + onRetry = { viewModel.initiate() } + ) + + SelectSubgroupAdminsUIState.Loading -> LoadingDataIndicator() + + is SelectSubgroupAdminsUIState.Loaded -> Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar(title = { Text(stringResource(Res.string.new_subgroup)) }) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + scope.launch { + // The refusal is recomputed rather than cached on + // the button: the key-package answer can go stale + // while the screen is open, and confirm checks + // again anyway. + val refusal = viewModel.refusal() + if (refusal != null) { + snackbarHostState.showSnackbar(refusal) + } else { + viewModel.confirm(onNavigateToRoute) + } + } + }, + icon = { Icon(Icons.Default.AccountTree, contentDescription = Decorative) }, + text = { Text(stringResource(Res.string.start_the_key_ceremony)) } + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { + OutlinedTextField( + value = viewModel.name.value, + onValueChange = viewModel::setName, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(Res.string.what_is_the_subgroup_called)) } + ) + } + + item { + Text( + text = stringResource(Res.string.who_runs_the_subgroup), + style = MaterialTheme.typography.labelMedium + ) + } + + item { + // Stated rather than discovered by tapping a disabled + // button. Three is the floor a quorum stops being a check + // below, and the coordinator is one of the three whether + // or not anybody ticks them. + Text( + text = stringResource(Res.string.a_subgroup_needs_at_least_three_admins), + style = MaterialTheme.typography.bodySmall + ) + } + + item { + Text( + text = stringResource(Res.string.anyone_in_this_group_can_run_a_subgroup), + style = MaterialTheme.typography.bodySmall + ) + } + + // The coordinator, shown and not offered. They are a ChillDKG + // participant by construction and hold a share whether or not + // anybody ticked them, so leaving them off the list entirely + // would make "pick two more" read as a group of two. + item { + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + ProfileAvatar( + publicKey = activeUserPublicKey, + profile = null + ) + }, + headlineContent = { + Text(stringResource(Res.string.you_coordinate_this_subgroup)) + }, + trailingContent = { + Checkbox(checked = true, enabled = false, onCheckedChange = null) + } + ) + } + } + + items( + items = state.candidates, + key = { it.participant.participantPublicKey } + ) { candidate -> + val publicKey = candidate.participant.participantPublicKey + val isUnreachable = publicKey in state.withoutKeyPackage + + Card( + onClick = { viewModel.toggleMember(publicKey) }, + enabled = !isUnreachable + ) { + ListItem( + leadingContent = { + ProfileAvatar( + publicKey = publicKey, + profile = candidate.profile + ) + }, + headlineContent = { + Text( + text = candidate.profile?.humanReadableNameOrPubkey() + ?: publicKey, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + }, + supportingContent = { + // The remedy, not the diagnosis. A key package + // is one-time-use and only its owner can + // publish another, so the useful thing to say + // is whose door to knock on. + if (isUnreachable) { + Text(stringResource(Res.string.has_no_key_package_yet)) + } else if (candidate.participant.adminAt != null) { + Text(stringResource(Res.string.admin_of_this_group)) + } + }, + trailingContent = { + Checkbox( + checked = viewModel.isSelected(publicKey), + enabled = !isUnreachable, + onCheckedChange = { viewModel.toggleMember(publicKey) } + ) + } + ) + } + } + + item { + QuorumStepper( + quorum = viewModel.threshold.value, + adminCount = viewModel.adminCount(), + quorumRange = viewModel.quorumRange(), + onQuorumChange = viewModel::setThreshold + ) + } + } + } + } + } +} + +/** + * How many of the subgroup's admins have to sign for it. + * + * Stepped rather than typed, like the one on the group-creation screen: the range + * is small and both ends are bounded. Unlike that one it cannot be revisited -- + * ChillDKG hashes the threshold into the session identity, so this is the only + * moment it can be chosen at all. + */ +@Composable +private fun QuorumStepper( + quorum: Int, + adminCount: Int, + quorumRange: IntRange, + onQuorumChange: (Int) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = MaterialTheme.spacing.space50), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50) + ) { + Text( + text = stringResource(Res.string.how_many_admins_have_to_approve_a_change), + style = MaterialTheme.typography.titleSmall + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200), + verticalAlignment = Alignment.CenterVertically + ) { + FilledIconButton( + onClick = { onQuorumChange(quorum - 1) }, + enabled = quorum > quorumRange.first + ) { + Icon(Icons.Default.Remove, contentDescription = "Fewer approvals") + } + + Text( + text = stringResource(Res.string.of, quorum, adminCount), + style = MaterialTheme.typography.titleMedium + ) + + FilledIconButton( + onClick = { onQuorumChange(quorum + 1) }, + enabled = quorum < quorumRange.last + ) { + Icon(Icons.Default.Add, contentDescription = "More approvals") + } + } + } +} + +@ConformancePreviews +@Composable +private fun SelectSubgroupAdminsScreenPreview() { + TorchTheme { + Surface(modifier = Modifier.fillMaxSize()) { + SelectSubgroupAdminsScreen( + activeUserPublicKey = "a".repeat(64), + parentChatRoomId = "b".repeat(64), + activeWalletStateFlow = kotlinx.coroutines.flow.MutableStateFlow(null), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + onNavigateToRoute = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 759d5942..8a1627c4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -87,6 +87,8 @@ import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToC import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomMembersRoute +import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute +import press.mantra.compose.ui.composable.SelectSubgroupAdminsScreen import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute import press.mantra.compose.ui.composable.navigation.routes.SignInRoute @@ -530,12 +532,27 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SelectSubgroupAdminsScreen( + activeUserPublicKey = route.activeUserPublicKey, + parentChatRoomId = route.parentChatRoomId, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, + onNavigateToRoute = { nextRoute -> + navController.navigate(route = nextRoute) + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() DkgRitualScreen( activeUserPublicKey = route.activeUserPublicKey, chatRoomId = route.chatRoomId, + parentChatRoomId = route.parentChatRoomId, activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, chatRepository = databaseChatRepository, dkgRepository = databaseDkgRepository, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualRoute.kt index 0f36ee15..e7541478 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualRoute.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualRoute.kt @@ -2,9 +2,18 @@ package press.mantra.compose.ui.composable.navigation.routes import kotlinx.serialization.Serializable -/** The shared-key ceremony for one NIP-17 group. */ +/** + * The shared-key ceremony for one NIP-17 group. + * + * [parentChatRoomId] is set when the ceremony is making a subgroup, and turns the + * screen from a three-step ladder into a four-step one -- the extra rung being the + * parent's birth certificate. It is a claim rather than evidence, in the route as + * on the wire: what makes a lineage real is the certificate the parent's quorum + * signs, two steps later. See `docs/subgroups.md`. + */ @Serializable data class DkgRitualRoute( val activeUserPublicKey: String, - val chatRoomId: String + val chatRoomId: String, + val parentChatRoomId: String? = null ): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt new file mode 100644 index 00000000..939abe8e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectSubgroupAdminsRoute.kt @@ -0,0 +1,20 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * First step of making a subgroup: who runs it, what it is called, and on what + * quorum. + * + * All three have to be settled before anything is published. ChillDKG hashes the + * threshold and the host keys into the session identity, so `t` is fixed the + * moment the proposal goes out -- there is no later screen where it could be + * changed, and a group that disagrees about it gets no key at all rather than a + * weak one. + */ +@Serializable +data class SelectSubgroupAdminsRoute( + val activeUserPublicKey: String, + val parentChatRoomId: String, + val relayHint: String? = null +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index f1d0faad..3bfb3db2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -80,6 +80,10 @@ class ChatRoomDetailViewModel( dialects = mantraRepository.getDialects(chatRoomId), groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), + subgroups = chatRepository.subgroupsOf(chatRoomId), + parentChatRoomId = chatRepository.parentOf(chatRoomId), + canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.canSign(chatRoomId), ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 677dcc8c..01fd6639 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.Dispatchers.Main import press.mantra.compose.nostr.dkg.DkgRitualEvents import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.view.state.DkgRitualUIState @@ -49,6 +50,11 @@ import kotlinx.coroutines.launch class DkgRitualViewModel( val chatRoomId: String, val activeUserPublicKey: HexKey, + /** + * The group this ceremony is making a subgroup of, or null for an ordinary + * one. Off the route, which got it off the proposal -- a claim either way. + */ + val parentChatRoomId: HexKey? = null, initialDkgRitualUIState: DkgRitualUIState, val activeWalletStateFlow: StateFlow, val chatRepository: ChatRepository, @@ -118,7 +124,10 @@ class DkgRitualViewModel( return@launch } - dkgRitualUIState = DkgRitualUIState.Loaded(localChatRoom = localChatRoom) + dkgRitualUIState = DkgRitualUIState.Loaded( + localChatRoom = localChatRoom, + parentChatRoomId = parentChatRoomId + ) threshold.value = ChatRoomType .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size) .coerceIn(quorumRange()) @@ -127,7 +136,10 @@ class DkgRitualViewModel( dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { session -> val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded) - ?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom) + ?: DkgRitualUIState.Loaded( + localChatRoom = localChatRoom, + parentChatRoomId = parentChatRoomId + ) dkgRitualUIState = loaded.copy( session = session, @@ -206,6 +218,37 @@ class DkgRitualViewModel( dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null) } } + + // Only for a subgroup, and off the *parent's* signed events rather + // than this room's -- the certificate is made where the parent's key + // can sign it, which is never the ceremony's room. + parentChatRoomId?.let { parent -> + launch { + dkgRepository.observeBirthCertificate(chatRoomId, parent) + .collect { certificate -> + val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded + ?: return@collect + + dkgRitualUIState = loaded.copy(isCertified = certificate != null) + } + } + + launch { + dkgRepository.observeSigningSessions(parent).collect { sessions -> + val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect + + dkgRitualUIState = loaded.copy( + certificateSession = sessions.firstOrNull { local -> + local.items.any { item -> + runCatching { + Event.fromJson(item.unsignedEventJson).kind + }.getOrNull() == SubgroupBirthCertificateEvent.KIND + } + }?.session + ) + } + } + } } } @@ -236,11 +279,24 @@ class DkgRitualViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { - val signing = dkgRepository.proposeGroupKeyState( - localChatRoom = loaded.localChatRoom, - userPublicKey = activeUserPublicKey, - session = session - ) + // A subgroup's state carries the parent's certificate, and refuses + // to be proposed without one -- every device that receives it runs + // the same check and drops it, so proposing one this device would not + // believe spends a quorum's attention on nothing. + val signing = if (parentChatRoomId != null) { + dkgRepository.proposeSubgroupKeyState( + ceremonyRoom = loaded.localChatRoom, + userPublicKey = activeUserPublicKey, + session = session, + parentChatRoomId = parentChatRoomId + ) + } else { + dkgRepository.proposeGroupKeyState( + localChatRoom = loaded.localChatRoom, + userPublicKey = activeUserPublicKey, + session = session + ) + } isActionPending.value = false @@ -255,6 +311,58 @@ class DkgRitualViewModel( } } + /** + * Asks the parent's quorum to certify the subgroup this ceremony produced. + * + * Step 2 of four, and the only one that runs somewhere else: the certificate + * is signed in the *parent's* room, because it is the parent's signature. + * Offered to whoever holds a share of the parent's key, which the coordinator + * does by construction -- they are a parent admin, that is how they got here. + * + * Nothing waits for the answer. The session runs on arriving messages like + * every other, and [observeKeyState] follows it. + */ + fun proposeBirthCertificate() { + if (isActionPending.value) return + + val parent = parentChatRoomId ?: return + val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val session = loaded.session ?: return + if (session.thresholdPublicKey == null) return + + // Nothing to ask for, and nobody to ask twice. A failed session is the + // one case a second proposal is right, and it has to be a new session + // rather than that one resumed. + if (loaded.isCertified) return + loaded.certificateSession?.let { if (it.stage != FrostSigningStage.FAILED) return } + + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val signing = dkgRepository.proposeBirthCertificate( + parentChatRoomId = parent, + userPublicKey = activeUserPublicKey, + session = session, + adminPublicKeys = ChillDkgRitualManager + .memberPublicKeys(loaded.localChatRoom) + .toList(), + // The ceremony room was created with the name the coordinator + // typed, and the proposal carried it to everyone else -- so this + // is the same name every admin has been looking at. + name = loaded.localChatRoom.chatRoom.subject ?: "Subgroup" + ) + + isActionPending.value = false + + if (signing == null) { + logger.e("Failed to propose a birth certificate in $parent") + dkgRitualUIState = DkgRitualUIState.Error( + "Couldn't ask the group to certify this subgroup. Please try again." + ) + } + } + } + /** Opens a ritual, making this device its coordinator, and publishes the proposal. */ fun startRitual() { if (isActionPending.value) return @@ -367,7 +475,15 @@ class DkgRitualViewModel( // admins: the whole point of the room is that the members who hold shares of // the key can all act in it. val members = ChillDkgRitualManager.memberPublicKeys(loaded.localChatRoom) - val name = "${loaded.localChatRoom.chatRoom.subject ?: "Group"} (#admins)" + + // A subgroup is called what the coordinator called it; an admin room is + // called after the group it administers, because it has no name of its + // own to be given. + val name = if (parentChatRoomId != null) { + loaded.localChatRoom.chatRoom.subject ?: "Subgroup" + } else { + "${loaded.localChatRoom.chatRoom.subject ?: "Group"} (#admins)" + } isActionPending.value = true dkgRitualUIState = loaded.copy(adminGroupBlockedOn = emptyList()) @@ -376,10 +492,18 @@ class DkgRitualViewModel( val outcome = chatRepository.createMarmotGroup( groupId = groupId, name = name, - purpose = "Admins of ${loaded.localChatRoom.chatRoom.subject ?: "the group"}.", + purpose = if (parentChatRoomId != null) { + "A subgroup of the group that certified it." + } else { + "Admins of ${loaded.localChatRoom.chatRoom.subject ?: "the group"}." + }, adminPublicKeys = members, userPublicKey = activeUserPublicKey, - keyPair = keyPair + keyPair = keyPair, + // Verified by the time it is written: the key state this room was + // gated on carries the certificate, and `stateFrom` refused it + // otherwise. + parentChatRoomId = parentChatRoomId ) isActionPending.value = false @@ -442,6 +566,7 @@ class DkgRitualViewModel( fun factory( chatRoomId: String, activeUserPublicKey: HexKey, + parentChatRoomId: HexKey? = null, initialDkgRitualUIState: DkgRitualUIState = DkgRitualUIState.Loading, activeWalletStateFlow: StateFlow, chatRepository: ChatRepository, @@ -451,6 +576,7 @@ class DkgRitualViewModel( DkgRitualViewModel( chatRoomId = chatRoomId, activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = parentChatRoomId, initialDkgRitualUIState = initialDkgRitualUIState, activeWalletStateFlow = activeWalletStateFlow, chatRepository = chatRepository, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt new file mode 100644 index 00000000..566cb931 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectSubgroupAdminsViewModel.kt @@ -0,0 +1,285 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import fr.acinq.phoenix.data.ActiveWallet +import fr.acinq.phoenix.managers.nostrPrivateKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.database.model.types.ChatRoomType +import press.mantra.compose.managers.ChillDkgRitualManager +import press.mantra.compose.managers.MarmotGroupCreation +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.view.state.SelectSubgroupAdminsUIState + +/** + * Who runs a subgroup, what it is called, and on what quorum. + * + * All three are settled here and nowhere else. The name and the admin set are + * carried into the birth certificate, so they are what the parent's admins will + * be asked to approve; the threshold cannot move afterwards at all, because + * ChillDKG hashes it and the host keys into the session identity, so `t` is fixed + * the moment the proposal goes out and a group that disagrees about it gets no + * key rather than a weak one. + * + * It is also the one value picked *for* other people, and consent survives that: + * `t` rides on the proposal, every invitee's `acceptProposal` re-checks it against + * `quorumRange` and drops a proposal outside it, and the host-key approval gate is + * where each of them agrees to the `t`-of-`n` they can now see. + * + * Confirming opens the ceremony -- a NIP-17 room over the picked admins, and a + * ritual proposal in it -- and hands over to `DkgRitualViewModel`, which drives + * the remaining three steps. See `docs/subgroups.md`. + */ +class SelectSubgroupAdminsViewModel( + val activeUserPublicKey: HexKey, + val parentChatRoomId: String, + initialUIState: SelectSubgroupAdminsUIState, + val activeWalletStateFlow: StateFlow, + val chatRepository: ChatRepository, + val dkgRepository: DkgRepository, +): ViewModel() { + + var uiState: SelectSubgroupAdminsUIState by mutableStateOf(initialUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** Members ticked, in the order they were ticked. The coordinator is never here. */ + val selectedPublicKeys = mutableStateListOf() + + val name: MutableState = mutableStateOf("") + + val threshold: MutableState = mutableStateOf(ChatRoomType.MINIMUM_QUORUM) + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val parentRoom = chatRepository.getChatRoomByIdentifier(parentChatRoomId) + if (parentRoom == null) { + uiState = SelectSubgroupAdminsUIState.Error("Couldn't load this group") + return@launch + } + + // Everybody but this device, since the coordinator is a participant by + // construction and is not theirs to tick or untick. + val candidates = parentRoom.localParticipants + .distinctBy { it.participant.participantPublicKey } + .filterNot { it.participant.participantPublicKey == activeUserPublicKey } + + uiState = SelectSubgroupAdminsUIState.Loaded( + parentRoom = parentRoom, + candidates = candidates, + ) + + resolveKeyPackages(candidates.map { it.participant.participantPublicKey }) + } + } + + /** + * Asks every candidate's key package up front. + * + * The same move `SelectChatRoomMembersViewModel` makes for group creation, and + * here it is doing more than saving a round trip. A key package is + * one-time-use -- `NostrDao` marks the bundle consumed as the device processes + * its own Welcome -- so every group a member joins burns one, and a member who + * has not published since has none left. `MarmotGroupCreation` would refuse to + * create the room for that, correctly, but only after a ChillDKG, a parent + * quorum and a child quorum had all completed, each needing every selected + * admin present. Finding out here costs nothing and finding out there costs + * three ceremonies. + */ + private suspend fun resolveKeyPackages(publicKeys: List) { + val resolved = MarmotGroupCreation.keyPackagesFor(chatRepository, publicKeys) + val missing = resolved.filter { it.second == null }.map { it.first }.toSet() + + val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return + uiState = loaded.copy(withoutKeyPackage = missing, isResolvingKeyPackages = false) + + // A member who cannot be welcomed cannot be an admin of a room they are not + // in, so unpick them rather than leaving a selection that confirm will + // refuse. + selectedPublicKeys.removeAll(missing) + } + + fun toggleMember(publicKey: HexKey) { + if (isActionPending.value) return + + val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return + if (publicKey in loaded.withoutKeyPackage) return + + if (!selectedPublicKeys.remove(publicKey)) { + selectedPublicKeys.add(publicKey) + } + + // The range moves with the admin count, so a threshold picked at one size + // has to be pulled back into the range at another. + threshold.value = threshold.value.coerceIn(quorumRange()) + } + + fun isSelected(publicKey: HexKey): Boolean = selectedPublicKeys.contains(publicKey) + + fun setName(value: String) { + if (isActionPending.value) return + + name.value = value + } + + /** The subgroup's admins: everyone ticked, plus the coordinator. */ + fun adminCount(): Int = selectedPublicKeys.size + 1 + + fun quorumRange(): IntRange = + ChatRoomType.quorumRange(maxOf(adminCount(), ChatRoomType.MINIMUM_QUORUM)) + + fun setThreshold(value: Int) { + if (isActionPending.value) return + + threshold.value = value.coerceIn(quorumRange()) + } + + /** + * Why this cannot be confirmed yet, or null when it can. + * + * Every message names what to do rather than what is wrong, because each of + * these is something the user can fix from where they are standing. + */ + suspend fun refusal(): String? { + val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return "Still loading" + + if (name.value.isBlank()) return "Give the subgroup a name." + + if (loaded.isResolvingKeyPackages) return "Checking who can be added…" + + val unreachable = selectedPublicKeys.filter { it in loaded.withoutKeyPackage } + if (unreachable.isNotEmpty()) { + return "Some of the people picked have no key package. Ask them to " + + "publish one, then try again." + } + + return chatRepository.refuseSubgroup( + parentChatRoomId = parentChatRoomId, + adminPublicKeys = selectedPublicKeys.toSet(), + coordinatorPublicKey = activeUserPublicKey + ) + } + + /** + * Opens the ceremony and hands over to the ritual screen. + * + * The room is stood up first and the proposal is the first thing out of it, + * exactly as a robust group is created: standing up a NIP-17 room sends + * nothing to anybody, so the proposal is how the other admins hear of it at + * all -- which is why it carries the name and the parent. + */ + fun confirm(onNavigateToRoute: (Route) -> Unit) { + if (isActionPending.value) return + + val loaded = uiState as? SelectSubgroupAdminsUIState.Loaded ?: return + + val nostrPrivateKey = activeWalletStateFlow.value + ?.business?.walletManager?.keyManager?.value?.nostrPrivateKey() + if (nostrPrivateKey == null) { + uiState = SelectSubgroupAdminsUIState.Error("Couldn't read your keys. Please try again.") + return + } + + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + // Checked again here rather than trusted from the button: a screen not + // drawing something is not a guard, and the key-package answer can go + // stale while the screen is open. + val refusal = refusal() + if (refusal != null) { + isActionPending.value = false + uiState = SelectSubgroupAdminsUIState.Error(refusal) + return@launch + } + + val ceremonyRoom = chatRepository.createNip17ChatRoom( + userPublicKey = activeUserPublicKey, + participantPublicKeys = selectedPublicKeys.toList(), + subject = name.value, + description = "Making ${name.value} a subgroup of " + + (loaded.parentRoom.chatRoom.subject ?: "this group") + "." + ) + + if (ceremonyRoom == null) { + isActionPending.value = false + uiState = SelectSubgroupAdminsUIState.Error( + "Couldn't start the subgroup's key ceremony. Please try again." + ) + return@launch + } + + val session = dkgRepository.proposeRitual( + localChatRoom = ceremonyRoom, + userPublicKey = activeUserPublicKey, + nostrPrivateKey = nostrPrivateKey.value.toByteArray(), + threshold = threshold.value, + parentChatRoomId = parentChatRoomId + ) + + isActionPending.value = false + + if (session == null) { + logger.e("Created ${ceremonyRoom.chatRoom.id} without a key ceremony") + uiState = SelectSubgroupAdminsUIState.Error( + "Couldn't start the subgroup's key ceremony. Please try again." + ) + return@launch + } + + withContext(Dispatchers.Main) { + onNavigateToRoute( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = ceremonyRoom.chatRoom.id, + parentChatRoomId = parentChatRoomId + ) + ) + } + } + } + + companion object { + private const val TAG = "SelectSubgroupAdminsViewModel" + + fun factory( + activeUserPublicKey: HexKey, + parentChatRoomId: String, + initialUIState: SelectSubgroupAdminsUIState = SelectSubgroupAdminsUIState.Loading, + activeWalletStateFlow: StateFlow, + chatRepository: ChatRepository, + dkgRepository: DkgRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SelectSubgroupAdminsViewModel( + activeUserPublicKey = activeUserPublicKey, + parentChatRoomId = parentChatRoomId, + initialUIState = initialUIState, + activeWalletStateFlow = activeWalletStateFlow, + chatRepository = chatRepository, + dkgRepository = dkgRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 881da4b1..80f08814 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -1,6 +1,8 @@ package press.mantra.compose.ui.view.state +import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect @@ -29,6 +31,39 @@ sealed interface ChatRoomDetailUIState { * checked. Null on a device that holds the reading and not the event. */ val signedGroupKeyStateEvent: GroupSignedEvent? = null, + /** + * The groups this one has certified as its children. + * + * Off the parent's own signed certificates, so it lists a subgroup this + * device holds no room for -- which is the normal position of a member + * who is not in it, and of everybody between the certificate being + * signed and the room being created. + */ + val subgroups: List = emptyList(), + /** + * The group this room is a subgroup of, or null when it is nobody's + * child. + * + * Verified: it comes off the room's key state, which only carries a + * parent whose certificate was checked. A member welcomed in after the + * founding holds no state and so sees nothing here, even in a room that + * really is a subgroup. + */ + val parentChatRoomId: HexKey? = null, + /** + * Whether this device could open a subgroup here at all. + * + * Two conditions and both are about capability rather than permission. + * The room has to be a Marmot one, since a NIP-17 room has no admins to + * be a parent's. And this device has to hold a share of the group's key, + * because certifying a child is a signature by the parent and + * `FrostSigningManager.proposeSigningBatch` throws for a device that + * cannot make one -- throwing at a button is not a UI. + * + * The rest of the refusals are the picker's, where they can name who is + * missing and what to do about it. + */ + val canAddSubgroup: Boolean = false, ): ChatRoomDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt index abad6a01..11dee98a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt @@ -63,6 +63,25 @@ sealed interface DkgRitualUIState { * signed first. It is what gates creating the room. */ val isKeyStateSigned: Boolean = false, + /** + * The group this ceremony is making a subgroup of, or null for an + * ordinary one. + * + * A claim off the route and the proposal, not evidence. It decides which + * ladder the screen draws and nothing else -- what makes a lineage real + * is [isCertified] below, which is a signature by that group's quorum. + */ + val parentChatRoomId: HexKey? = null, + /** Whether the parent has certified the room this ceremony's key derives. */ + val isCertified: Boolean = false, + /** + * The session asking the parent to certify, while it is running. + * + * Runs in the *parent's* room rather than this one, so it is watched + * separately from [keyStateSession] and cannot be read off this room's + * sessions at all. + */ + val certificateSession: FrostSigningSession? = null, ): DkgRitualUIState { val hostKeyCount: Int get() = hostKeyParticipants.size val round1Count: Int get() = round1Participants.size diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectSubgroupAdminsUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectSubgroupAdminsUIState.kt new file mode 100644 index 00000000..7b139447 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectSubgroupAdminsUIState.kt @@ -0,0 +1,37 @@ +package press.mantra.compose.ui.view.state + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant + +sealed interface SelectSubgroupAdminsUIState { + data class Loaded( + val parentRoom: LocalChatRoom, + /** + * Who can be picked: the parent's own members, admins and non-admins + * alike. + * + * Not filtered to the parent's admins on purpose. The point of a subgroup + * is that it can be run by people the parent does not let run the parent, + * so a picker that only offered existing admins would offer the one thing + * subgroups are not for. + */ + val candidates: List = emptyList(), + /** + * Members with no unconsumed key package, who therefore cannot be + * welcomed into the room at step 4. + * + * Checked here rather than at creation because creation is three + * ceremonies later: a ChillDKG, a parent quorum and a child quorum all + * have to complete before `MarmotGroupCreation` would discover it, and + * each of those needed every selected admin to show up and approve. + */ + val withoutKeyPackage: Set = emptySet(), + /** Null while the lookup is still out; a set once it has settled. */ + val isResolvingKeyPackages: Boolean = true, + ): SelectSubgroupAdminsUIState + + data class Error(val message: String): SelectSubgroupAdminsUIState + + data object Loading: SelectSubgroupAdminsUIState +}