diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt index 956d7fbd..f2263e8a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt @@ -29,6 +29,7 @@ import press.mantra.compose.exceptions.MarmotMissingKeyPackageBundleException import press.mantra.compose.exceptions.MarmotMissingNostrGroupDataExtension import press.mantra.compose.exceptions.MarmotNotMemberOfChatGroupException import press.mantra.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventIdException +import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.nostr.Relays @@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -915,6 +917,23 @@ abstract class NostrDao( // content = "Coming soon.", // TODO: Figure out what to do here... // ) // ) + } else if (localChatRoom.chatRoom.userPublicKey != activeKeyPair.pubKey.toHex()) { + // Another profile on this device already holds this + // group. ChatRoom is keyed by the group id alone, and + // every table that hangs off it -- the MLS state, the + // DKG sessions, the FROST shares -- is keyed by the + // room, so letting a second local member in would + // overwrite the first's state with the joiner's and + // break both. Refuse, and say so where the group is: + // in the room, under the profile that holds it. See + // docs/curated-to-mantra-profiles.md, the fourth + // decision. + refuseWelcomeForOtherProfile( + localChatRoom = localChatRoom, + inviteePublicKey = activeKeyPair.pubKey.toHex(), + inviterPublicKey = decryptedGiftWrapPayload.publicKey, + welcomePayloadId = decryptedGiftWrapPayload.id, + ) } else { logger.w("Chat Room already exists for $nostrGroupId") } @@ -1652,6 +1671,53 @@ abstract class NostrDao( * room needs. Creating a group locally sends nothing to anyone, which is why the * first arrival for a group is just as likely to be a key ceremony as a message. */ + /** + * A Welcome for a group this device already holds under another of its profiles, + * refused, and the refusal written into the room as a membership line. + * + * The line goes into the room that exists -- the other profile's -- because that is + * the only place on this device the group has, and the profile that holds it is the + * one who can do something about it: leave, or tell the inviter which profile to + * invite instead. The invitee's key package bundle is left unconsumed, as the + * "already exists" branch has always left it; nothing here changes what a second + * Welcome for the same package would do, which is arrive here again and write a + * second line. + * + * Content is a whole sentence with both names written in, the way the other + * membership lines are, so nothing prefixes an author to it: this is nobody's words. + */ + private suspend fun refuseWelcomeForOtherProfile( + localChatRoom: LocalChatRoom, + inviteePublicKey: HexKey, + inviterPublicKey: HexKey, + welcomePayloadId: HexKey, + ) { + val holder = localChatRoom.chatRoom.userPublicKey + logger.w( + "Refusing Welcome $welcomePayloadId for ${localChatRoom.chatRoom.id}: this device already holds the group " + + "as $holder, and $inviteePublicKey is another profile on it" + ) + + suspend fun nameOf(publicKey: HexKey): String = + database.profileDao().getProfileByPublicKey(publicKey)?.humanReadableNameOrPubkey() + ?: publicKey.shortened() + + database.chatMessageDao().upsert( + ChatMessage( + content = "${nameOf(inviterPublicKey)} invited ${nameOf(inviteePublicKey)}, another profile on this device, " + + "to this group. The invitation was not accepted: this device already holds the group as " + + "${nameOf(holder)}, and one device cannot hold a group twice.", + messageType = ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE, + chatRoomId = localChatRoom.chatRoom.id, + senderPublicKey = holder, + isUserMessage = true, + giftWrapPayloadId = welcomePayloadId, + marmotGroupEventId = null, + marmotInnerEventId = null, + ) + ) + } + private suspend fun getOrCreateNip17ChatRoom( decryptedGiftWrapPayload: GiftWrapPayload, activeKeyPair: KeyPair, 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 f6db7723..efa2b704 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 @@ -437,6 +437,20 @@ data class ChatMessage( const val TYPE_MEMBER_INVITE_SENT = "memberInviteSent" const val TYPE_MEMBER_INVITE_FAILED = "memberInviteFailed" + /** + * A Welcome that arrived for another profile on this device, for a group this + * device already holds, and was refused. + * + * Written by the invitee's side, into the room the other profile holds, since + * that is the only room on the device the group has. ChatRoom is keyed by the + * group id alone and everything that hangs off it -- MLS state, ceremonies, + * shares -- by the room, so a second local member would overwrite the first. + * The inviter's device sees nothing of this and believes the invite landed, + * which is why the line names both profiles: the one who was invited and the + * one who holds the group. See docs/curated-to-mantra-profiles.md. + */ + const val TYPE_WELCOME_REFUSED_OTHER_PROFILE = "welcomeRefusedOtherProfile" + /** * Every membership line, for the one check the transcript dispatches on. * @@ -447,6 +461,7 @@ data class ChatMessage( TYPE_MEMBER_INVITED, TYPE_MEMBER_INVITE_SENT, TYPE_MEMBER_INVITE_FAILED, + TYPE_WELCOME_REFUSED_OTHER_PROFILE, ) const val TYPE_UNDECRYPTABLE_OUTER_LAYER = "undecryptableOuterLayer" 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 75db2d21..ca9c4bc6 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 @@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.PanTool import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonOff import androidx.compose.material.icons.filled.Upload import androidx.compose.material.icons.filled.WorkspacePremium import androidx.compose.material.icons.filled.Info @@ -815,6 +816,9 @@ private fun RitualNotice( ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline + // Somebody on this device was kept out of a group it already holds; the + // person, not the invite, is what the line is about. + ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE -> Icons.Default.PersonOff else -> Icons.Default.PanTool } @@ -835,7 +839,8 @@ private fun RitualNotice( val tint = when { chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED || chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED || - chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED -> + chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED || + chatMessage.messageType == ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE -> MaterialTheme.colorScheme.error isRequest -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.onSurfaceVariant diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt index 08a4ef9e..ed4fc7e1 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MarmotKeyPackageFixture.kt @@ -14,6 +14,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.utils.TimeUtils import press.mantra.compose.database.model.MarmotKeyPackage +import press.mantra.compose.database.model.MarmotKeyPackageBundle +import press.mantra.compose.extensions.toHex /** * A real MLS key package for [publicKey], built the same way @@ -21,7 +23,19 @@ import press.mantra.compose.database.model.MarmotKeyPackage * three key generations, a signed leaf node and a signed key package. No relay, no network, * nothing to stub. */ -internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage { +internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage = marmotKeyPackageBundleFor(publicKey).keyPackage + +/** + * The key package and the private keys behind it, as the invitee's own device would hold + * them: [keyPackage] is what the inviter reads off a relay, [bundle] is the row the + * invitee's `processWelcome` opens the Welcome with. [id] is the kind 443 event id the + * Welcome names in its `e` tag, and both rows carry it. + */ +internal class MarmotKeyPackagePair(val keyPackage: MarmotKeyPackage, val bundle: MarmotKeyPackageBundle) { + val id: HexKey get() = keyPackage.id +} + +internal fun marmotKeyPackageBundleFor(publicKey: HexKey, id: HexKey = publicKey): MarmotKeyPackagePair { val initKp = X25519.generateKeyPair() val encKp = X25519.generateKeyPair() val sigKp = Ed25519.generateKeyPair() @@ -65,9 +79,22 @@ internal fun marmotKeyPackageFor(publicKey: HexKey): MarmotKeyPackage { ), ) - return MarmotKeyPackage( - id = publicKey, - publicKey = publicKey, - tlsEncodedMarmotKeyPackage = keyPackage.toTlsBytes(), + val tls = keyPackage.toTlsBytes() + return MarmotKeyPackagePair( + keyPackage = MarmotKeyPackage( + id = id, + publicKey = publicKey, + tlsEncodedMarmotKeyPackage = tls, + ), + // The same three private keys the app stores raw, hex-encoded -- see + // DatabaseMarmotRepository.publishMarmotKeyPackageBundle. + bundle = MarmotKeyPackageBundle( + id = id, + publicKey = publicKey, + tlsEncodedMarmotKeyPackage = tls, + ncryptsecInitPrivateKey = initKp.privateKey.toHex(), + ncryptsecEncryptionPrivateKey = encKp.privateKey.toHex(), + ncryptsecSignaturePrivateKey = sigKp.privateKey.toHex(), + ), ) } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/WelcomeForOtherProfileJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/WelcomeForOtherProfileJvmTest.kt new file mode 100644 index 00000000..a0ed8651 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/WelcomeForOtherProfileJvmTest.kt @@ -0,0 +1,241 @@ +package press.mantra.compose.database.dao + +import androidx.room3.Room +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +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.ChatRoom +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.Relays +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Two of a device's own profiles in one group, which docs/multiple-profiles.md made + * possible and docs/curated-to-mantra-profiles.md's fourth decision refuses. + * + * `ChatRoom` is keyed by the group id alone. Before several profiles on a device the + * second local member could not exist; after it, a user can invite their own second + * profile into their own collective, and a Welcome processed for it would file the + * joiner's MLS state over the holder's. The DAO already declined to overwrite -- "Chat + * Room already exists", at warning level, and nothing else -- so the invitee never + * joined and nobody was told. This pins the refusal and the line that now reports it. + * + * The setup is real: an MLS group on the inviter's database, a key package whose + * private keys the invitee's database holds, and a Welcome sealed the way + * `DatabaseChatRepository.sealGiftWrapPayload` seals one, delivered through + * `storeNostrEvent` under the invitee's key. + */ +class WelcomeForOtherProfileJvmTest { + + /** The inviter's device. */ + private val inviterDb: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + /** The device that holds two profiles: `holder`, already in the group, and `invitee`. */ + private val deviceDb: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + @AfterTest + fun closeDbs() { + inviterDb.close() + deviceDb.close() + } + + private val relay = Relays.DefaultDMRelayList.first().url + private val inviter = KeyPair() + private val invitee = KeyPair() + private val holder = KeyPair() + private val roomId = "b".repeat(64) + private val keyPackageEventId = "d".repeat(64) + + private suspend fun MantraDatabase.seedProfile(publicKey: HexKey, name: String, nostrEventId: String) { + nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + profileDao().upsert(Profile(publicKey = publicKey, userName = name, nostrEventId = nostrEventId)) + } + + /** A room with real MLS state on the inviter's device, the inviter its only member. */ + private suspend fun seedInviterRoom(): LocalChatRoom { + val inviterHex = inviter.pubKey.toHexKey() + inviterDb.seedProfile(inviterHex, "inviter", "1".repeat(64)) + inviterDb.seedProfile(invitee.pubKey.toHexKey(), "invitee", "2".repeat(64)) + val mlsGroup = MlsGroup.create( + identity = inviterHex.hexToByteArray(), + initialExtensions = listOf( + MarmotGroupData.bootstrap( + nostrGroupId = roomId, + creatorPubKey = inviterHex, + outboxRelays = listOf(relay), + ).toExtension() + ), + ) + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = inviterHex, + subject = "the collective", + description = null, + mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), + ) + inviterDb.chatRoomDao().upsert(chatRoom) + return LocalChatRoom(chatRoom = chatRoom) + } + + /** + * The inviter adds the invitee and the Welcome reaches the queue; this seals it for + * the invitee the way the notary would, and hands back the wrap as a relay would. + */ + private suspend fun welcomeWrapForInvitee(): NostrEvent { + // The room first: MarmotKeyPackage.publicKey is a foreign key onto Profile, and + // the room's seeding is what gives the invitee one on the inviter's device. + val localChatRoom = seedInviterRoom() + val pair = marmotKeyPackageBundleFor(invitee.pubKey.toHexKey(), id = keyPackageEventId) + inviterDb.marmotKeyPackageDao().upsert(pair.keyPackage) + deviceDb.marmotKeyPackageBundleDao().upsert(pair.bundle) + + inviterDb.marmotOutboundDao().inviteMemberToChatRoom( + localChatRoom = localChatRoom, + peerPublicKey = invitee.pubKey.toHexKey(), + peerKeyPackage = pair.keyPackage, + ) + val payload = inviterDb.giftWrapPayloadDao() + .getByChatRoomAndKinds(roomId, listOf(WelcomeEvent.KIND)) + .single() + + val signer = NostrSignerSync(inviter) + val rumor = RumorAssembler.assembleRumor( + pubKey = signer.pubKey, + ev = EventTemplate( + createdAt = payload.createdAt.epochSeconds, + kind = payload.kind, + tags = payload.tags, + content = payload.content, + ), + ) + val seal = signer.signNormal( + createdAt = 1_700_000_000, + kind = SealedRumorEvent.KIND, + tags = emptyArray(), + content = signer.nip44Encrypt(plaintext = rumor.toJson(), toPublicKey = invitee.pubKey.toHexKey()), + ) + val wrap = GiftWrapEvent.create( + event = seal, + recipientPubKey = invitee.pubKey.toHexKey(), + createdAt = 1_700_000_100, + ) + return NostrEvent( + id = wrap.id, + pubKey = wrap.pubKey, + kind = wrap.kind, + tags = wrap.tags, + content = wrap.content, + sig = wrap.sig, + createdAt = Instant.fromEpochSeconds(wrap.createdAt), + ) + } + + /** The group, already on the device under [owner], with state that is recognisably not the joiner's. */ + private suspend fun seedDeviceRoom(owner: KeyPair): ChatRoom { + val ownerHex = owner.pubKey.toHexKey() + deviceDb.seedProfile(ownerHex, "holder", "3".repeat(64)) + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = ownerHex, + subject = "the collective", + description = null, + mlsGroupState = "0123456789abcdef", + ) + deviceDb.chatRoomDao().upsert(chatRoom) + return chatRoom + } + + private suspend fun deliverToInvitee(wrap: NostrEvent) = deviceDb.nostrDao().storeNostrEvent( + nostrEvent = wrap, + relayURL = relay, + synchronizationRelayURLs = listOf(relay), + level = 0, + activeKeyPair = invitee, + ) + + @Test + fun `a welcome for a group another profile on the device holds is refused and said so`() = runBlocking { + val before = seedDeviceRoom(holder) + deviceDb.seedProfile(invitee.pubKey.toHexKey(), "second self", "4".repeat(64)) + // The holder is in the group with the inviter, so this device knows them by name. + deviceDb.seedProfile(inviter.pubKey.toHexKey(), "inviter", "5".repeat(64)) + + deliverToInvitee(welcomeWrapForInvitee()) + + val room = assertNotNull(deviceDb.chatRoomDao().findChatRoomById(roomId)).chatRoom + assertEquals(before.userPublicKey, room.userPublicKey, "the room changed hands") + assertEquals(before.mlsGroupState, room.mlsGroupState, "the holder's MLS state was overwritten") + + val line = deviceDb.chatMessageDao().getChatMessagesByChatRoomId(roomId).map { it.chatMessage } + .singleOrNull { it.messageType == ChatMessage.TYPE_WELCOME_REFUSED_OTHER_PROFILE } + assertNotNull(line, "the refusal left no line in the room") + assertTrue(line.content.contains("second self"), "the line does not name the invitee: ${line.content}") + assertTrue(line.content.contains("holder"), "the line does not name who holds the group: ${line.content}") + assertTrue(line.content.contains("inviter"), "the line does not name the inviter: ${line.content}") + assertTrue(line.messageType in ChatMessage.MEMBERSHIP_TYPES, "the line would render as a bubble") + + val bundle = assertNotNull(deviceDb.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleById(keyPackageEventId)) + assertFalse(bundle.consumed, "a refused welcome must not consume the key package") + } + + /** The branch the refusal was carved out of: the same profile, welcomed twice, is still a quiet no-op. */ + @Test + fun `a welcome for a group the same profile already holds writes nothing`() = runBlocking { + seedDeviceRoom(invitee) + + deliverToInvitee(welcomeWrapForInvitee()) + + assertEquals( + emptyList(), + deviceDb.chatMessageDao().getChatMessagesByChatRoomId(roomId).map { it.chatMessage.messageType }, + "a redelivered welcome for our own room is not a refusal", + ) + } + + /** And with nothing in the way, the Welcome is a join, which is what tells the fixture is real. */ + @Test + fun `the same welcome joins when no profile on the device holds the group`() = runBlocking { + deviceDb.seedProfile(invitee.pubKey.toHexKey(), "second self", "4".repeat(64)) + + deliverToInvitee(welcomeWrapForInvitee()) + + val room = assertNotNull(deviceDb.chatRoomDao().findChatRoomById(roomId), "the invitee did not join").chatRoom + assertEquals(invitee.pubKey.toHexKey(), room.userPublicKey) + assertNotNull(room.mlsGroupState, "the joiner's MLS state was not filed") + assertTrue( + assertNotNull(deviceDb.marmotKeyPackageBundleDao().getMarmotKeyPackageBundleById(keyPackageEventId)).consumed, + "a join consumes the key package", + ) + } +}