fix(groups): refuse a welcome for a group another profile on this device holds, and say so in the room

Phase 6 of docs/curated-to-mantra-profiles.md, the fourth decision's
follow-up, written natively before the several-profiles line reaches
users. ChatRoom is keyed by the group id alone, and the MLS state, the
DKG sessions and the FROST shares all hang off the room; before several
profiles on a device the second local member could not exist, and after
it a user can invite their own second profile into their own collective.

What the DAO did with that Welcome was already a refusal, by accident:
the welcome branch of indexNostrEvent checks for an existing room before
it writes one, so the joiner's state never overwrote the holder's -- but
the branch could not tell a redelivered Welcome for our own room from a
Welcome for a room another profile holds, logged "Chat Room already
exists" at warning level for both, and told nobody. The invitee never
joined, the inviter's device believed the invite had landed, and the
failure had the exact shape of "the other device never got it".

The branch now distinguishes the two by the room's userPublicKey. The
same profile's redelivery stays the quiet no-op it was. Another profile's
Welcome is refused with a warning naming both keys, and a membership line
is written into the room that exists -- the holder's, because it is the
only room on the device the group has and the holder is the one who can
act on it: leave, or tell the inviter which profile to invite instead.
The line is TYPE_WELCOME_REFUSED_OTHER_PROFILE, in MEMBERSHIP_TYPES so the
transcript draws it as a notice rather than a bubble, with the error tint
and a PersonOff icon since the person and not the invite is what it is
about; its content is a whole sentence naming the inviter, the invitee
and the holder, resolved at write time the way the other membership lines
are. The invitee's key package bundle is left unconsumed, as the branch
always left it; a second Welcome for the same package arrives here again
and writes a second line.

The test is two devices for real: an MLS group on the inviter's database,
a key package whose private keys the invitee's database holds -- the
fixture now returns the bundle beside the public package, and
marmotKeyPackageFor is a projection of it -- and the Welcome the invite
queued, sealed the way sealGiftWrapPayload seals one and stored through
storeNostrEvent under the invitee's key. Three cases: the group held by
another profile is refused, its row and MLS state untouched, the line in
the room naming all three and the bundle unconsumed; the same profile
welcomed twice writes nothing; and with no profile in the way the Welcome
is a join, which is what shows the fixture is real. jvmTest 868 -> 871,
both compilers clean, every audit budget met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-13 16:07:45 +02:00
parent 310d3bbbd3
commit 679d5249b2
5 changed files with 360 additions and 6 deletions

View File

@@ -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,

View File

@@ -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"

View File

@@ -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

View File

@@ -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(),
),
)
}

View File

@@ -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<MantraDatabase>())
/** The device that holds two profiles: `holder`, already in the group, and `invitee`. */
private val deviceDb: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder<MantraDatabase>())
@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<SealedRumorEvent>(
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",
)
}
}