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:
@@ -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,
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user