diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index cf20fd29..93386f5a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -110,7 +110,8 @@ object ChillDkgRitualManager { memberPublicKeys(localChatRoom).size >= MINIMUM_PARTICIPANTS /** - * Opens a ritual, making this device the coordinator. + * Opens a ritual, making this device the coordinator. A room already running + * one gets that one back rather than a second. * * Throws when the group or the threshold cannot support one; the UI checks * both before offering the button, so reaching either is a bug rather than a @@ -123,6 +124,26 @@ object ChillDkgRitualManager { nostrPrivateKey: ByteArray, threshold: Int ): DkgSession { + // A room runs one ceremony at a time, and this is reachable twice now that + // a robust group opens one as it is created: the room id is derived from + // its members, so making the same group again lands back in the same room + // and asks again. A second proposal is a second participant set for every + // member to reconcile, and the key the first one produced would be left + // with nothing pointing at it. A failed ritual is not running, and is + // there to be replaced. + // + // Checked before the arguments are, because a running ritual makes the + // requested threshold moot -- it settled that question when it opened. + database.dkgSessionDao().getLatestSessionForChatRoom(localChatRoom.chatRoom.id) + ?.takeIf { it.stage != DkgRitualStage.FAILED } + ?.let { running -> + logger.i( + "Room ${localChatRoom.chatRoom.id} is already running ritual " + + "${running.id}; not opening another" + ) + return running + } + // Built the way a receiver rebuilds it from the proposal — the p-tags // `broadcast` writes, plus this device — so both sides count the same `n` // even if the room's own rows have drifted. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 27131b7b..3d840986 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -70,6 +71,7 @@ fun SelectChatRoomTypeScreen( activeWalletStateFlow: StateFlow, nostrRepository: NostrRepository, chatRepository: ChatRepository, + dkgRepository: DkgRepository, onNavigateToRoute: (Route) -> Unit, ) { val selectChatRoomTypeViewModel: SelectChatRoomTypeViewModel = viewModel( @@ -81,7 +83,8 @@ fun SelectChatRoomTypeScreen( initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, activeWalletStateFlow = activeWalletStateFlow, nostrRepository = nostrRepository, - chatRepository = chatRepository + chatRepository = chatRepository, + dkgRepository = dkgRepository ) ) @@ -104,6 +107,7 @@ fun SelectChatRoomTypeScreen( val isActionPending = selectChatRoomTypeViewModel.isActionPending.value val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded + val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value val adminCount = selectChatRoomTypeViewModel.adminCount val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST @@ -188,6 +192,22 @@ fun SelectChatRoomTypeScreen( } } + if (keyCeremonyNotStarted) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text( + modifier = Modifier.padding(15.dp), + text = "$name was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.", + style = MaterialTheme.typography.bodyMedium + ) + } + } + Text( text = "This decides who can change the group later. You can't switch afterwards.", style = MaterialTheme.typography.labelMedium, @@ -216,7 +236,11 @@ fun SelectChatRoomTypeScreen( } else { "Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect." }, - footnote = "No single admin can change the group alone, and the group outlives any one of you.", + // Says what tapping create actually does, because it is the one + // thing here that asks something of everybody else: the group's + // first act is a ceremony each member has to approve their way + // through before there is a key. + footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.", isSelected = isRobustSelected, isEnabled = isRobustAvailable, disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason, @@ -447,6 +471,7 @@ private fun SelectChatRoomTypeScreenPreview() { activeWalletStateFlow = MutableStateFlow(null), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, 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 9332b751..b72d072c 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 @@ -543,6 +543,7 @@ fun MantraNavHost( activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, onNavigateToRoute = { chatRoomResultRoute -> navController.navigate( chatRoomResultRoute diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt index 038cd51f..fccb41f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt @@ -15,6 +15,7 @@ import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute @@ -59,6 +60,7 @@ class SelectChatRoomTypeViewModel( val activeWalletStateFlow: StateFlow, val nostrRepository: NostrRepository, val chatRepository: ChatRepository, + val dkgRepository: DkgRepository, ): ViewModel() { var selectChatRoomTypeUIState: SelectChatRoomTypeUIState by mutableStateOf(initialSelectChatRoomTypeUIState) @@ -81,6 +83,15 @@ class SelectChatRoomTypeViewModel( /** Members the group was created without, because no key package ever showed up. */ val membersNotAdded = mutableStateListOf() + /** + * Set when a [ChatRoomType.ROBUST] room was made but its key ceremony was not. + * + * The room is real and usable either way -- only the shared key is missing, and + * it can be started again from the group's details. Worth saying rather than + * navigating on, because the key is the whole of what robust means. + */ + val keyCeremonyNotStarted: MutableState = mutableStateOf(false) + /** * Everyone who administers the group under [ChatRoomType.ROBUST]: the picked * members plus the creator. @@ -188,13 +199,16 @@ class SelectChatRoomTypeViewModel( isActionPending.value = true + val nostrPrivateKeyBytes = nostrPrivateKey.value.toByteArray() val keyPair = KeyPair( - privKey = nostrPrivateKey.value.toByteArray() + privKey = nostrPrivateKeyBytes ) when (selectedChatRoomType.value) { ChatRoomType.CONVENIENT -> createMarmotChatRoom(keyPair, onNavigateToRoute) - ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, onNavigateToRoute) + // The ceremony needs the secret itself, not the pair: the ChillDKG host + // key is derived from it rather than being it. + ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, nostrPrivateKeyBytes, onNavigateToRoute) } } @@ -287,13 +301,23 @@ class SelectChatRoomTypeViewModel( * invite anyone to and no key package to wait on — everybody picked is in the room * the moment it exists, and learns about it from the first message. * - * TODO: the quorum the user picked has nowhere to live here. NIP-17 has no group - * state to change and so nothing to approve — the member set is whatever a message - * is addressed to, and a different set is simply a different room. Enforcing t-of-n - * needs a governance layer this protocol does not have. + * That first message is the key ceremony, opened here rather than left for somebody + * to find a button for. + * + * NIP-17 itself has nothing for the quorum to govern — no group state to change, and + * a different member set is simply a different room — so the only thing that can + * carry it is a key the group generates together and can only sign with t of n of + * them present. Everything that ceremony needs is settled by the time the room + * exists: who is in it, and how many of them have to agree. Waiting would mean + * handing the user the room they asked for minus the thing that makes it robust. + * + * Opening it is also how the rest of the group hears of the room at all. Standing up + * a NIP-17 room sends nothing to anybody; the proposal is the first event out, and + * the inbound path builds the same room on the other side from its p-tags. */ private fun createNip17ChatRoom( keyPair: KeyPair, + nostrPrivateKey: ByteArray, onNavigateToRoute: (Route) -> Unit ) { viewModelScope.launch(Dispatchers.IO) { @@ -304,18 +328,43 @@ class SelectChatRoomTypeViewModel( description = description ) - withContext(Dispatchers.Main) { - isActionPending.value = false - - if (localChatRoom == null) { + if (localChatRoom == null) { + withContext(Dispatchers.Main) { + isActionPending.value = false onNavigateToRoute.invoke( ImplementationPendingRoute("Something went wrong") ) + } + return@launch + } + + createdChatRoomId.value = localChatRoom.chatRoom.id + + // The room's membership is the set this screen was opened for, so the + // quorum picked against [adminCount] is the same t-of-n the ceremony is + // asked for. A room that already has a ceremony -- the id is derived from + // its members, so making the same group twice returns the same room -- + // hands that one back instead of opening a second. + val session = dkgRepository.proposeRitual( + localChatRoom = localChatRoom, + userPublicKey = keyPair.pubKey.toHexKey(), + nostrPrivateKey = nostrPrivateKey, + threshold = quorum.value + ) + + withContext(Dispatchers.Main) { + isActionPending.value = false + + if (session == null) { + // Nothing is rolled back: the room works, the group can talk in it, + // and the ceremony can be opened again from the group's details. + // Said here rather than navigated past, because a robust group + // without a shared key is not what the user asked for. + logger.e("Created ${localChatRoom.chatRoom.id} without a key ceremony") + keyCeremonyNotStarted.value = true return@withContext } - createdChatRoomId.value = localChatRoom.chatRoom.id - onNavigateToRoute.invoke( ChatRoomMessagingRoute( activeUserPublicKey = keyPair.pubKey.toHexKey(), @@ -393,7 +442,8 @@ class SelectChatRoomTypeViewModel( initialSelectChatRoomTypeUIState: SelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loading, activeWalletStateFlow: StateFlow, nostrRepository: NostrRepository, - chatRepository: ChatRepository + chatRepository: ChatRepository, + dkgRepository: DkgRepository ): ViewModelProvider.Factory = viewModelFactory { initializer { SelectChatRoomTypeViewModel( @@ -404,7 +454,8 @@ class SelectChatRoomTypeViewModel( initialSelectChatRoomTypeUIState = initialSelectChatRoomTypeUIState, activeWalletStateFlow = activeWalletStateFlow, nostrRepository = nostrRepository, - chatRepository = chatRepository + chatRepository = chatRepository, + dkgRepository = dkgRepository ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt new file mode 100644 index 00000000..b4808780 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt @@ -0,0 +1,224 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.runBlocking +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatMessage +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.database.model.types.DkgRitualStage +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * What a group gets for choosing "robust": the key ceremony its room opens with. + * + * The quorum picked while creating the group has nothing in NIP-17 to govern -- + * there is no group state to change, and a different member set is simply a + * different room -- so the only thing that can carry it is a t-of-n key the + * members generate together. Everything that ceremony needs is settled the + * moment the room exists, so `SelectChatRoomTypeViewModel.createNip17ChatRoom` + * opens one there and then rather than leaving it for somebody to find a button + * for. These are the properties that has to hold to. + * + * The proposal being the room's *first* event is not a nicety. Standing up a + * NIP-17 room sends nothing to anybody, so until something goes out the group + * exists on one device only; the proposal is what the other members hear the + * room from at all, which is why `NostrDao` builds the room from a DKG payload's + * p-tags on the way in. + * + * The view model itself is not exercised here -- it needs a wallet's key manager + * and a Compose runtime -- so what stays uncovered is the wiring: that the + * quorum reaching [ChillDkgRitualManager.proposeRitual] is the one the picker + * holds, and that a null session leaves the user on the creation screen. + */ +class RobustRoomKeyCeremonyTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + // Real keys throughout: the room id is derived by doing point work over the + // member set, and the host key each member is identified by for the ceremony + // is derived from their nostr secret. Hex filler exercises neither. + private val creator = KeyPair() + private val user = creator.pubKey.toHexKey() + private val creatorPrivateKey = creator.privKey!! + private val alice = KeyPair().pubKey.toHexKey() + private val bob = KeyPair().pubKey.toHexKey() + + /** The quorum a three-member group is offered by default, and picks here. */ + private val quorum = 2 + + private suspend fun seedProfile(publicKey: String) { + 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) + ) + } + + /** The room the creation screen leaves behind for a robust group. */ + private suspend fun robustRoom(): LocalChatRoom { + listOf(user, alice, bob).forEach { seedProfile(it) } + + return assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = listOf(alice, bob), + subject = "Robust Group", + ), + "createNip17ChatRoom returned null", + ) + } + + private suspend fun openCeremony(room: LocalChatRoom, threshold: Int = quorum) = + ChillDkgRitualManager.proposeRitual( + database = db, + localChatRoom = room, + userPublicKey = user, + nostrPrivateKey = creatorPrivateKey, + threshold = threshold, + ) + + private suspend fun ritualPayloads(room: LocalChatRoom) = + db.giftWrapPayloadDao().getByChatRoomAndKinds(room.chatRoom.id, DkgRitualEvents.ALL.toList()) + + /** The room's lines in the order they were written, id being the insertion order. */ + private suspend fun chatMessages(room: LocalChatRoom) = + db.chatMessageDao().getChatMessagesByChatRoomId(room.chatRoom.id) + .map { it.chatMessage } + .sortedBy { it.id } + + /** + * The whole of the change: a robust room has nothing in it until the ceremony, + * and the ceremony is the first thing in it. + */ + @Test + fun `a robust room's first message is the key ceremony it opens with`() = runBlocking { + val room = robustRoom() + + assertTrue(chatMessages(room).isEmpty(), "the room says nothing until something is sent") + assertTrue(ritualPayloads(room).isEmpty(), "and nothing has gone out yet") + + val session = openCeremony(room) + + assertEquals( + ChatMessage.TYPE_DKG_STARTED, + chatMessages(room).first().messageType, + "the first line in a robust room is its ceremony opening", + ) + + val proposal = ritualPayloads(room).first() + assertEquals(DkgRitualEvents.PROPOSAL, proposal.kind, "and the first event out is the proposal") + assertEquals(user, proposal.publicKey) + + // What every receiver rebuilds the ceremony from: `n` is the p-tag set plus + // the sender, and `t` is the quorum the group was created on. Both are read + // off this event rather than off the receiver's own view of the room, which + // is the thing that drifts. + assertEquals( + setOf(user, alice, bob), + proposal.participantPTags().map { it.pubKey }.toSet(), + "the proposal carries the whole membership", + ) + assertEquals(quorum, DkgRitualEvents.parseThreshold(proposal.tags)) + assertEquals(session.id, DkgRitualEvents.parseSessionId(proposal.tags)) + + assertEquals(quorum, session.threshold, "the ceremony runs at the quorum that was picked") + assertEquals(3, session.participantCount) + assertEquals(user, session.coordinatorPublicKey, "the room's creator coordinates") + + // Opening a ceremony is the act of agreeing to be in it, so this member is + // not asked again -- and their host key goes out with the proposal. + assertNotNull(session.hostKeyApprovedAt) + assertEquals( + 1, + ritualPayloads(room).count { it.kind == DkgRitualEvents.HOST_KEY }, + "the coordinator joins the ceremony it opened", + ) + } + + /** + * Creation is re-enterable -- the room id is derived from the member set, so the + * same group made twice is the same room -- and the ceremony has to be + * re-enterable with it. A second proposal is a second participant set for every + * member to reconcile, and the key the first one produced would be left with + * nothing pointing at it. + */ + @Test + fun `making the same group twice does not open a second ceremony`() = runBlocking { + val room = robustRoom() + val first = openCeremony(room) + + val again = assertNotNull( + db.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = user, + participantPublicKeys = listOf(alice, bob), + subject = "Robust Group", + ) + ) + assertEquals(room.chatRoom.id, again.chatRoom.id, "the same members are the same room") + + // Asked for a different quorum this time: the running ceremony settled that + // question when it opened, and answers with itself rather than the ask. + val second = openCeremony(again, threshold = 3) + + assertEquals(first.id, second.id) + assertEquals(quorum, second.threshold) + assertEquals( + 1, + ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL }, + "one proposal, not two", + ) + assertEquals( + 1, + chatMessages(room).count { it.messageType == ChatMessage.TYPE_DKG_STARTED }, + "and the room is told once", + ) + } + + /** + * A collapsed ceremony leaves the group with no key and a room they can still + * talk in, which is exactly the group that should be able to try again. Failed + * is the one state that does not hold the room's ceremony slot. + */ + @Test + fun `a failed ceremony is replaced rather than handed back`() = runBlocking { + val room = robustRoom() + val abandoned = openCeremony(room) + + db.dkgSessionDao().upsert(abandoned.copy(stage = DkgRitualStage.FAILED)) + + val replacement = openCeremony(room) + + assertNotEquals(abandoned.id, replacement.id) + assertEquals(DkgRitualStage.COLLECTING_HOST_KEYS, replacement.stage) + assertEquals( + 2, + ritualPayloads(room).count { it.kind == DkgRitualEvents.PROPOSAL }, + "the group proposes again", + ) + } +}