feat: build robust groups as NIP-17 instead of Marmot/MLS

Robust rooms are now plain NIP-17 group chats: no MLS group, no key
packages, no invites, no admin. Convenient rooms are unchanged and stay
Marmot/MLS.

Everything needed to *run* a NIP-17 group was already here; what was
missing was any way to start one.

* Inbound already worked for N participants, not just a pair.
  GiftWrapSeal derives the room id from the author plus every p-tag via
  ChatRoom.deriveChatRoomId (a musig2 aggregate over the member set),
  and NostrDao stands the room up with mlsGroupState = null and a
  Participant row per p-tag, inserting placeholder profiles and queueing
  a profile sync for anyone unknown.
* Outbound already worked too. sendChatMessage branches on
  mlsGroupState == null into gift wraps p-tagged to every participant,
  sealGiftWrapPayload wraps the payload once per participant, and
  NotaryViewModel drives that loop at runtime -- so the path is live,
  not merely present.
* The gap was creation. NostrNip17Dao.getOrCreateChatRoom only ever
  inserts the active user as a participant (the peer is literally
  commented out of its hexKeys set) and expects the room id to be handed
  to it, which suits an inbound message and nothing else.

database/dao/NostrNip17Dao.kt
* New createNip17ChatRoom(): derives the id with the SAME
  deriveChatRoomId the inbound path uses, so the creator and every
  recipient independently arrive at the same room, and building the same
  group twice is idempotent rather than duplicative. Then upserts the
  room with mlsGroupState = null -- which is precisely the flag
  sendChatMessage reads to choose gift wraps -- and a Participant row
  for the creator plus every picked member.

repository/ChatRepository.kt, database/repository/DatabaseChatRepository.kt
* Expose it, with the same try/catch-and-log-null shape its
  getOrCreateChatRoom sibling uses, plus the NO_OP stub for previews.

ui/view/model/SelectChatRoomTypeViewModel.kt
* createChatRoom() splits on the chosen type into createMarmotChatRoom()
  and createNip17ChatRoom(). The convenient path is the previous body
  verbatim. The robust path skips key package resolution, the 20-second
  relay budget and the whole sequential invite loop, because NIP-17
  membership IS the p-tag set -- there is nothing to invite anybody to,
  and so no partial-failure case either.
* Removes the .copy(adminPubkeys = ...) added in bf94ecb. Robust was the
  only thing that ever set a multi-admin list; with convenient now the
  only MLS path, MarmotGroupData.bootstrap() already stamps
  creator-only, so the override had become a branch that could not be
  taken.

Trade-offs this bakes in, recorded here and in a TODO on the new path:

* The quorum has LESS meaning under NIP-17, not more. There is no group
  state to change and so nothing to approve: membership is whatever a
  message is addressed to, and a different member set is a different
  musig aggregate, i.e. simply a different room. Under MLS there was at
  least an admin_pubkeys list to hang FROST off later; here there is no
  object for t-of-n to govern at all. The picker is still shown and
  still has nowhere to persist to.
* Members do not learn the room exists until the first message is sent.
  NIP-17 has no invite event -- the first gift wrap is the invitation.
* Robust rooms give up MLS forward secrecy and the sender ratchet. What
  they gain is that there is no privileged member and no shared group
  state to desync.

Verified:
  ./gradlew :composeApp:compileCommonMainKotlinMetadata
  ./gradlew :composeApp:compileDebugKotlinAndroid --rerun-tasks

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-08-29 18:39:43 +02:00
parent 65182ac892
commit ea33217203
4 changed files with 149 additions and 17 deletions

View File

@@ -29,6 +29,58 @@ abstract class NostrNip17Dao(
) {
val logger = Logger.withTag(TAG)
/**
* Stands up a NIP-17 group locally: no MLS group, no key packages, no invites.
* Membership under NIP-17 *is* the p-tag set on each message, so all that has to
* exist up front is the room and its participants — the first message carries the
* membership to everyone else.
*
* The id is [ChatRoom.deriveChatRoomId] over the members, the same aggregate the
* inbound path derives from an arriving gift wrap. That makes it idempotent: two
* people building the same group land on the same room rather than two.
*/
@Transaction
open suspend fun createNip17ChatRoom(
userPublicKey: HexKey,
participantPublicKeys: List<HexKey>,
subject: String? = null,
description: String? = null,
relayHint: String? = Relays.DefaultDMRelayList.first().url
): LocalChatRoom? {
val memberPublicKeys = (participantPublicKeys + userPublicKey).toSet()
val chatRoomId = ChatRoom.deriveChatRoomId(memberPublicKeys)
logger.d("createNip17ChatRoom: $chatRoomId for ${memberPublicKeys.size} member(s)")
database.chatRoomDao().findChatRoomById(chatRoomId)?.let { existingChatRoom ->
logger.i("Chat room $chatRoomId already exists; reusing it")
return existingChatRoom
}
val chatRoom = ChatRoom(
id = chatRoomId,
userPublicKey = userPublicKey,
subject = subject,
description = description,
// No MLS state is what marks this a NIP-17 room: `sendChatMessage` reads
// exactly this to decide between a group event and gift wraps.
mlsGroupState = null
)
database.chatRoomDao().upsert(chatRoom)
database.participantDao().upsert(
memberPublicKeys.map { participantPublicKey ->
Participant(
participantPublicKey = participantPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint
)
}
)
return database.chatRoomDao().findChatRoomById(chatRoomId)
}
@Transaction
open suspend fun getOrCreateChatRoom(
chatRoomId: HexKey,

View File

@@ -118,6 +118,23 @@ class DatabaseChatRepository(
return database.marmotKeyPackageDao().observeMarmotKeyPackageForPublicKey(publicKey)
}
override suspend fun createNip17ChatRoom(
userPublicKey: HexKey,
participantPublicKeys: List<HexKey>,
subject: String?,
description: String?
): LocalChatRoom? = try {
database.nostrNip17Dao().createNip17ChatRoom(
userPublicKey = userPublicKey,
participantPublicKeys = participantPublicKeys,
subject = subject,
description = description
)
} catch (e: Throwable) {
logger.e("Error creating nip-17 chat room", e)
null
}
override suspend fun createMlsDirectMessage(
name: String?,
description: String?,

View File

@@ -42,6 +42,17 @@ interface ChatRepository {
suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow<MarmotKeyPackage?>
/**
* Creates a NIP-17 group: participants and a room, no MLS group. Membership is
* the p-tag set carried by each message, so there is nothing to invite anyone to.
*/
suspend fun createNip17ChatRoom(
userPublicKey: HexKey,
participantPublicKeys: List<HexKey>,
subject: String? = null,
description: String? = null
): LocalChatRoom?
suspend fun createMlsDirectMessage(
name: String? = null,
description: String? = null,
@@ -133,6 +144,15 @@ interface ChatRepository {
TODO("Not yet implemented")
}
override suspend fun createNip17ChatRoom(
userPublicKey: HexKey,
participantPublicKeys: List<HexKey>,
subject: String?,
description: String?
): LocalChatRoom? {
return null
}
override suspend fun createMlsDirectMessage(
name: String?,
description: String?,

View File

@@ -192,22 +192,21 @@ class SelectChatRoomTypeViewModel(
privKey = nostrPrivateKey.value.toByteArray()
)
val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost...
// Convenient rooms keep the creator as the lone admin; robust rooms hand
// everyone the same authority. This is the whole behavioural difference
// between the two — the inbound path derives Participant.adminAt from
// exactly this list.
// TODO: Robust rooms still need the t-of-n approval itself, i.e. FROST
// signing over admin changes. Today the list is set but every admin can
// still commit on their own, and the quorum the user picked has nowhere to
// live: MIP-01's wire format is fixed, so it cannot ride in MarmotGroupData
// without breaking byte-compatibility with mdk/whitenoise. It needs a
// ChatRoom column (and the Room migration that comes with it).
val adminPubkeys = when (selectedChatRoomType.value) {
ChatRoomType.CONVENIENT -> listOf(keyPair.pubKey.toHexKey())
ChatRoomType.ROBUST -> (listOf(keyPair.pubKey.toHexKey()) + memberPublicKeys).distinct()
when (selectedChatRoomType.value) {
ChatRoomType.CONVENIENT -> createMarmotChatRoom(keyPair, onNavigateToRoute)
ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, onNavigateToRoute)
}
}
/**
* Convenient rooms are Marmot/MLS: one admin, forward secrecy, and members added
* by commit — which is why every invite needs the peer's key package first.
*/
private fun createMarmotChatRoom(
keyPair: KeyPair,
onNavigateToRoute: (Route) -> Unit
) {
val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost...
// Stamp initial metadata via the shared factory so UI + CLI stay
// byte-identical. Bake the MarmotGroupData extension into the
@@ -221,8 +220,6 @@ class SelectChatRoomTypeViewModel(
outboxRelays = Relays.DefaultDMRelayList.map { it.url },
name = name,
description = description.orEmpty()
).copy(
adminPubkeys = adminPubkeys
)
val extras = listOf(metadata.toExtension())
@@ -284,6 +281,52 @@ class SelectChatRoomTypeViewModel(
}
}
/**
* Robust rooms are NIP-17: no MLS group, no admin, no privileged member. Membership
* is simply the p-tag set every message carries, which is why there is nothing to
* 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.
*/
private fun createNip17ChatRoom(
keyPair: KeyPair,
onNavigateToRoute: (Route) -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.createNip17ChatRoom(
userPublicKey = keyPair.pubKey.toHexKey(),
participantPublicKeys = memberPublicKeys,
subject = name,
description = description
)
withContext(Dispatchers.Main) {
isActionPending.value = false
if (localChatRoom == null) {
onNavigateToRoute.invoke(
ImplementationPendingRoute("Something went wrong")
)
return@withContext
}
createdChatRoomId.value = localChatRoom.chatRoom.id
onNavigateToRoute.invoke(
ChatRoomMessagingRoute(
activeUserPublicKey = keyPair.pubKey.toHexKey(),
chatRoomId = localChatRoom.chatRoom.id,
relayHint = null
)
)
}
}
}
/**
* Adds every picked member to the freshly created group and returns the ones that
* could not be added.