feat: tell the group in chat when a shared key ceremony happens
A ChillDKG ritual was invisible to everyone it happened to. Kinds 30310-30316
are routed to ChillDkgRitualManager and never become ChatMessage rows, so a
member's device published a host key and joined a ceremony that fixes the
group's signing quorum for good, with nothing appearing anywhere they would
look. The only way to find out was to open the group's details and press Shared
Key on the off chance. Worse in the flow this is reached through: a group whose
first event is the ceremony now materialises as a room with no messages in it at
all and no explanation of why it appeared.
That matters more than it would for a chat feature, because a ritual cannot
finish until every member's device has taken part. The progress ladder on the
ritual screen exists to show that it is waiting on 2 of 3 -- but nothing told
member 3 they were the one being waited on.
Three milestones now land in the transcript: the ceremony starting, the group
getting a key, and the ceremony being abandoned (with the reason, which names
the culprit participant when ChillDKG identified one).
## Derived locally, not sent
Nothing new goes on the wire. Every member already receives the proposal, and
computes the completion and any failure for themselves, so each device writes
its own row from what it already has. That costs no traffic, needs no new event
kind, and -- the reason it is worth doing this way -- makes it impossible for the
transcript to disagree with the ritual it describes. A "ceremony started" message
that was itself sent could arrive without the proposal, or outlive a session that
never existed on that device.
The rows are written where the state changes: announceStarted() at both places a
DkgSession is created (proposeRitual for the member who opens it, acceptProposal
for everyone else), and announce() at the COMPLETE write and in fail().
They are written once by construction rather than by de-duplication, which is
worth spelling out because ChatMessage.id is autogenerated and a second insert
would simply be a second line. A session is created once, since acceptProposal
returns early when the row exists; advance() leaves a COMPLETE ritual alone; and
fail() now re-reads the session and returns if it is already FAILED. That last
one is also a fix in its own right -- a ritual can be failed from two directions,
a FAILURE message from a member and a fault raised locally, and while the second
write was previously harmless it would now have told the group twice.
## Rendered as a system line, not a bubble
ChatMessage.messageType already carries "message", "artifact", "pendingCommit"
and eleven others, so TYPE_DKG_STARTED / _COMPLETE / _FAILED join it with no
schema change. But the list renders every row as a bubble with the sender's name
and a delivery-status icon, and neither fits: "a shared key ceremony started" is
not something the coordinator said, and a row with no gift wrap behind it would
show the KeyOff "unsealed" icon as though it had failed to send.
RitualNotice renders them across the width instead -- icon, text, timestamp, no
author, no side, no delivery state -- and is tappable through to the ritual
screen, since the point of telling the group is to give them somewhere to go. It
branches out of the items() lambda with an early return so the existing bubble
layout is untouched.
The other informational types ("pendingCommit", "processedCommit",
"proposalStaged", "undecryptableOuterLayer") have the same problem and are
deliberately left alone: how MLS commit rows should read is a separate call from
making the key ceremony visible.
## Not covered by tests
The whole change is Room writes and Compose rendering, neither of which runs
under :composeApp:testDebugUnitTest -- there is no sqlite driver on the JVM test
classpath. Verified by compilation only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,24 @@ data class ChatMessage(
|
||||
): TimestampedEntity, LocalStoreEntity, UserViewableEntity, SoftDeletableEntity {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A ChillDKG ritual reached a point the group should be told about.
|
||||
*
|
||||
* These rows are not anybody's words: no [ChatMessage.giftWrapPayloadId], no
|
||||
* event behind them, and nothing sent to say them. Each device writes its own
|
||||
* from the ritual messages it already received, which is why they cost no
|
||||
* traffic and cannot disagree with the ritual they describe.
|
||||
*
|
||||
* [ChatMessageListViewModel] renders these as a system line rather than a
|
||||
* bubble, since attributing "a shared key ceremony started" to the
|
||||
* coordinator would read as something they said.
|
||||
*/
|
||||
const val TYPE_DKG_STARTED = "dkgStarted"
|
||||
const val TYPE_DKG_COMPLETE = "dkgComplete"
|
||||
const val TYPE_DKG_FAILED = "dkgFailed"
|
||||
|
||||
val DKG_TYPES = setOf(TYPE_DKG_STARTED, TYPE_DKG_COMPLETE, TYPE_DKG_FAILED)
|
||||
|
||||
suspend fun fromGroupEventResult(
|
||||
database: MantraDatabase,
|
||||
activeKeyPair: KeyPair,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.GiftWrapPayload
|
||||
@@ -150,6 +151,7 @@ object ChillDkgRitualManager {
|
||||
round2AuxRandom = RandomInstance.bytes(32).toHex()
|
||||
)
|
||||
database.dkgSessionDao().upsert(session)
|
||||
announceStarted(database, session)
|
||||
|
||||
logger.i("Proposing DKG ritual $sessionId: $threshold-of-${members.size}")
|
||||
|
||||
@@ -285,6 +287,7 @@ object ChillDkgRitualManager {
|
||||
round2AuxRandom = RandomInstance.bytes(32).toHex()
|
||||
)
|
||||
database.dkgSessionDao().upsert(session)
|
||||
announceStarted(database, session)
|
||||
|
||||
logger.i("Joined DKG ritual $sessionId ($threshold-of-${members.size})")
|
||||
|
||||
@@ -504,6 +507,13 @@ object ChillDkgRitualManager {
|
||||
recoveryData = output.recovery?.toHex()
|
||||
)
|
||||
}
|
||||
announce(
|
||||
database = database,
|
||||
session = session,
|
||||
messageType = ChatMessage.TYPE_DKG_COMPLETE,
|
||||
content = "The group has a shared key. It takes ${session.threshold} of " +
|
||||
"${session.participantCount} members to sign with it."
|
||||
)
|
||||
|
||||
logger.i("DKG ritual $sessionId complete")
|
||||
} catch (e: CancellationException) {
|
||||
@@ -696,9 +706,66 @@ object ChillDkgRitualManager {
|
||||
}
|
||||
|
||||
private suspend fun fail(database: MantraDatabase, session: DkgSession, reason: String) {
|
||||
update(database, session) {
|
||||
// Read before writing so the notice goes out once. A ritual can be failed
|
||||
// from two directions -- a FAILURE message from a member, and a fault raised
|
||||
// locally -- and the group does not need to be told twice.
|
||||
val current = database.dkgSessionDao().getSessionById(session.id) ?: session
|
||||
if (current.stage == DkgRitualStage.FAILED) return
|
||||
|
||||
update(database, current) {
|
||||
it.copy(stage = DkgRitualStage.FAILED, failureReason = reason)
|
||||
}
|
||||
announce(
|
||||
database = database,
|
||||
session = current,
|
||||
messageType = ChatMessage.TYPE_DKG_FAILED,
|
||||
content = "The shared key ceremony was abandoned — $reason. " +
|
||||
"No key was created, and it is safe to run it again."
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun announceStarted(database: MantraDatabase, session: DkgSession) = announce(
|
||||
database = database,
|
||||
session = session,
|
||||
messageType = ChatMessage.TYPE_DKG_STARTED,
|
||||
content = "A shared key ceremony started. It will take ${session.threshold} of " +
|
||||
"${session.participantCount} members to sign with the key, and it finishes once " +
|
||||
"everyone has taken part."
|
||||
)
|
||||
|
||||
/**
|
||||
* Puts a ritual milestone in the group's chat.
|
||||
*
|
||||
* The ritual is otherwise invisible: its kinds never become chat messages, so a
|
||||
* member's device would join a ceremony that fixes the group's signing quorum
|
||||
* for good without anything appearing where they would look. Each device writes
|
||||
* its own row from the ritual messages it has already received, so this costs no
|
||||
* traffic and cannot disagree with the ritual it describes.
|
||||
*
|
||||
* Written once per milestone by construction, not by de-duplication: a session
|
||||
* is created once (its caller returns early if the row exists), [advance] leaves
|
||||
* a completed ritual alone, and [fail] checks the stage before it writes. There
|
||||
* is no key on ChatMessage to make a second insert idempotent -- its id is
|
||||
* autogenerated -- so those guards are what keep the transcript honest.
|
||||
*/
|
||||
private suspend fun announce(
|
||||
database: MantraDatabase,
|
||||
session: DkgSession,
|
||||
messageType: String,
|
||||
content: String
|
||||
) {
|
||||
database.chatMessageDao().upsert(
|
||||
ChatMessage(
|
||||
senderPublicKey = session.coordinatorPublicKey,
|
||||
isUserMessage = session.isCoordinator(),
|
||||
giftWrapPayloadId = null,
|
||||
marmotGroupEventId = null,
|
||||
marmotInnerEventId = null,
|
||||
chatRoomId = session.chatRoomId,
|
||||
content = content,
|
||||
messageType = messageType
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun publishHostKey(
|
||||
|
||||
@@ -42,6 +42,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.theme.TorchTheme
|
||||
@@ -132,7 +133,16 @@ fun ChatRoomMessagingScreen(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth()
|
||||
) {
|
||||
key(true) {
|
||||
chatMessageListViewModel.RenderMessages()
|
||||
chatMessageListViewModel.RenderMessages(
|
||||
onOpenSharedKey = {
|
||||
onNavigateToRoute.invoke(
|
||||
DkgRitualRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -18,7 +19,11 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.KeyOff
|
||||
import androidx.compose.material.icons.filled.Pending
|
||||
import androidx.compose.material3.Card
|
||||
@@ -43,6 +48,7 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.NegentropySynchronizeRequest
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.SynchronizationFilter
|
||||
@@ -198,7 +204,7 @@ class ChatMessageListViewModel(
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RenderMessages() {
|
||||
fun RenderMessages(onOpenSharedKey: () -> Unit) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -295,6 +301,17 @@ class ChatMessageListViewModel(
|
||||
items = chatRoomDetailMessageListUIState.chatMessageList,
|
||||
key = { it.chatMessage.id }
|
||||
) { localChatMessage ->
|
||||
// Not somebody's words -- see ChatMessage.DKG_TYPES.
|
||||
// A bubble would attribute "a shared key ceremony
|
||||
// started" to the coordinator as if they had said it.
|
||||
if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) {
|
||||
RitualNotice(
|
||||
chatMessage = localChatMessage.chatMessage,
|
||||
onClick = onOpenSharedKey
|
||||
)
|
||||
return@items
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
@@ -446,4 +463,68 @@ class ChatMessageListViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A ChillDKG milestone, as a system line across the transcript.
|
||||
*
|
||||
* Deliberately not a bubble: nobody said this, and giving it a sender and a side
|
||||
* would make the coordinator appear to have announced it. It is tappable because
|
||||
* the point of telling the group is to give them somewhere to go — a ritual only
|
||||
* finishes once every member's device has taken part, and the ladder that shows
|
||||
* who it is waiting on lives on the shared-key screen.
|
||||
*/
|
||||
@Composable
|
||||
private fun RitualNotice(
|
||||
chatMessage: ChatMessage,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val icon = when (chatMessage.messageType) {
|
||||
ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle
|
||||
ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline
|
||||
else -> Icons.Default.Key
|
||||
}
|
||||
|
||||
val tint = when (chatMessage.messageType) {
|
||||
ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tint
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = chatMessage.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = tint
|
||||
)
|
||||
|
||||
Text(
|
||||
text = chatMessage.createdAt.toFormattedTimeAndDateString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = "Open the shared key ceremony",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user