diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index dbbc9353..87c489ee 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -58,6 +58,11 @@
Create profile
Create project
Create the #admins group
+ Agree the group's signing key
+ The group is agreeing what the #admins room will sign with. It takes %1$s of %2$s members, and the request is in this chat.
+ The group has agreed what the #admins room will sign with.
+ The group could not agree what the #admins room will sign with. Ask again — a fresh request is the only safe way to retry.
+ Before the room exists, the group signs a statement of which key it will sign with. The room is then created already knowing it.
Creating new chat.
Currently no contacts. Please search and chat with a few people.
Currently no messages have been shared.\nBreak the ice.
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupSignedEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupSignedEventDao.kt
index da6e856c..94e6b741 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupSignedEventDao.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupSignedEventDao.kt
@@ -7,6 +7,7 @@ import androidx.room3.Upsert
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlin.time.Clock
+import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.model.GroupSignedEvent
@Dao
@@ -27,6 +28,28 @@ abstract class GroupSignedEventDao {
@Query("SELECT * FROM GroupSignedEvent WHERE chatRoomId = :chatRoomId AND kind = :kind ORDER BY createdAt ASC, id ASC")
abstract suspend fun getByChatRoomIdAndKind(chatRoomId: String, kind: Kind): List
+ /**
+ * Every signed event of one kind, whatever room it was signed in.
+ *
+ * The one query here that is not about a room, and it exists for the one
+ * statement that is not made in the room it is about: a group signs its
+ * `GroupKeyStateEvent` in the NIP-17 room its ceremony ran in, naming the
+ * Marmot room that does not exist yet -- see `GroupKeyStateManager.adopt`,
+ * which walks these to find the state for a room the moment there is one.
+ */
+ @Query("SELECT * FROM GroupSignedEvent WHERE kind = :kind ORDER BY createdAt ASC, id ASC")
+ abstract suspend fun getByKind(kind: Kind): List
+
+ /**
+ * The same, as it changes.
+ *
+ * Watched by the ceremony screen, which has to know the moment the group
+ * finishes agreeing its key state -- there being no room yet, and so no
+ * `GroupKeyState` row, to watch instead.
+ */
+ @Query("SELECT * FROM GroupSignedEvent WHERE kind = :kind ORDER BY createdAt ASC, id ASC")
+ abstract fun observeByKind(kind: Kind): Flow>
+
/** Everything one session signed, in the order the batch was proposed in. */
@Query("SELECT * FROM GroupSignedEvent WHERE frostSigningSessionId = :sessionId ORDER BY createdAt ASC, id ASC")
abstract suspend fun getByFrostSigningSessionId(sessionId: String): List
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 9496c6a2..c715be78 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
@@ -34,6 +34,7 @@ import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.nostr.dkg.DkgRitualEvents
import press.mantra.compose.nostr.frost.FrostSigningEvents
import press.mantra.compose.managers.FrostSigningManager
+import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.managers.MlsGroupCache
import press.mantra.compose.managers.MarmotInboundManager
import press.mantra.compose.managers.MarmotReindexSweep
@@ -821,6 +822,23 @@ abstract class NostrDao(
}
}
+ // The room arrives already knowing what it
+ // signs with, if this device was in the
+ // ceremony. The state was signed in the NIP-17
+ // room the ceremony ran in, before this room
+ // existed to hold it, so joining is the first
+ // moment there is a row to file it against --
+ // see `GroupKeyStateManager.adopt`.
+ //
+ // Nothing for a member who was not in the
+ // ceremony: they hold no signed state and no
+ // share either, so there is nothing for them to
+ // pick the wrong one of.
+ GroupKeyStateManager.adopt(
+ database = database,
+ chatRoomId = nostrGroupId
+ )
+
// Where is the decoded welcome message...
// TODO: Sync GroupEvents/Messages...
// database.chatMessageDao().upsert(
@@ -865,6 +883,49 @@ abstract class NostrDao(
nostrPrivateKey = activeKeyPair.privKey!!
)
}
+ } else if (FrostSigningEvents.isFrostSigningKind(decryptedGiftWrapPayload.kind)) {
+ // A FROST signing message for one of our NIP-17
+ // groups, which is where a group makes its first
+ // signature: the statement of what its #admins room
+ // signs with, agreed before that room is created.
+ // See `GroupKeyStateManager.propose`.
+ //
+ // The room is created on demand for the same reason
+ // a ritual message creates one, and the manager is
+ // idempotent, so a redelivered message re-runs a step
+ // it has already taken.
+ val localChatRoom = getOrCreateNip17ChatRoom(
+ decryptedGiftWrapPayload = decryptedGiftWrapPayload,
+ activeKeyPair = activeKeyPair,
+ nostrEventId = nostrEvent.id,
+ relayURL = relayURL
+ )
+
+ if (localChatRoom == null) {
+ logger.w("FROST payload for unknown chat room ${decryptedGiftWrapPayload.chatRoomId}")
+ } else {
+ FrostSigningManager.processSigningPayload(
+ database = database,
+ localChatRoom = localChatRoom,
+ // The rumor as its sender wrote it. `sig` is
+ // empty because a gift-wrapped rumor carries
+ // none -- the seal is what vouches for the
+ // author -- and nothing in a signing session
+ // reads it: what a session puts a signature
+ // to is the event inside the proposal, not
+ // the message carrying it.
+ innerEvent = Event(
+ id = decryptedGiftWrapPayload.id,
+ pubKey = decryptedGiftWrapPayload.publicKey,
+ createdAt = decryptedGiftWrapPayload.createdAt.epochSeconds,
+ kind = decryptedGiftWrapPayload.kind,
+ tags = decryptedGiftWrapPayload.tags,
+ content = decryptedGiftWrapPayload.content,
+ sig = ""
+ ),
+ userPublicKey = activeKeyPair.pubKey.toHex()
+ )
+ }
} else {
logger.w("Unsupported event: $decryptedGiftWrapPayload")
}
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 bb1f9ef2..b24bbbb2 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
@@ -1211,15 +1211,20 @@ data class ChatMessage(
// account of the same thing.
in FrostSigningEvents.ALL -> null
- // The group saying what key its room signs with, arriving here
- // the way every group-signed event does: applied by
+ // The group saying what key one of its rooms signs with, arriving
+ // here the way every group-signed event does: applied by
// `FrostSigningManager` once a quorum has signed the proposal it
// was carried in.
//
- // Recorded rather than believed. `GroupKeyStateManager` keeps it
- // only if the room rederives from the key it names *and* the
- // group holding that key signed it, so a state reaching this
- // line still has to earn the row.
+ // [groupId] is where the signing happened, which is usually not
+ // the room the state is about -- a group agrees this before the
+ // room exists, so there is routinely nothing to write yet and
+ // `GroupKeyStateManager.adopt` files it when the room appears.
+ //
+ // Recorded rather than believed either way. `GroupKeyStateManager`
+ // keeps a state only if the room it *names* rederives from the key
+ // it names, and the group holding that key signed it, so a state
+ // reaching this line still has to earn the row.
//
// No chat line: this is standing state rather than something
// that happened, and the session that produced it has already
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt
index 99d6c7aa..242fd067 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt
@@ -21,10 +21,13 @@ import press.mantra.compose.managers.SharedKeyDerivation
* than one share, and they are not interchangeable: a partial signature made
* with the wrong one cannot aggregate.
*
- * The row appears on every member's device at once, when the signing session
- * the room opened with produces a signature. Until then a room has no state and
- * signing resolves its key by rederiving, which is what every room did before
- * this table existed.
+ * The row appears as the room does. The group agrees the state before the room
+ * exists -- in the NIP-17 room its ceremony ran in -- and every device that took
+ * part files it the moment it has a room to file it against, whether it created
+ * the room or was handed it by a Welcome. See `GroupKeyStateManager.adopt`.
+ *
+ * A device that was not in the ceremony holds no state and signing resolves its
+ * key by rederiving, which is what every room did before this table existed.
*
* ### Why this is a row and not a rederivation
*
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt
index b80c539a..bf6cb6e6 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt
@@ -71,7 +71,16 @@ data class GroupSignedEvent(
@PrimaryKey
val id: HexKey,
- /** The room whose key signed it. Its id *is* [publicKey] for a derived room. */
+ /**
+ * The room the signature was made in. Its id *is* [publicKey] for a derived
+ * room, which is every room but one.
+ *
+ * The exception is a group's first signature: its `GroupKeyStateEvent` is
+ * signed in the NIP-17 room the ceremony ran in, and is authored by the
+ * #admins room that does not exist yet -- so this names where it happened
+ * and [publicKey] names who signed. [verifies] cannot pass on that row, for
+ * the reason it gives.
+ */
val chatRoomId: String,
/** The author: the group's key at [derivationPath], x-only 32-byte hex. */
@@ -134,14 +143,21 @@ data class GroupSignedEvent(
*
* **False does not always mean the row is corrupt.** The usual reading is
* that its columns have drifted from the event they came from, and that is
- * the case worth acting on. But a room that was never derived from its
- * group's key -- [derivationPath] null, the legacy shape `signingPath`
- * documents -- signs as the bare threshold key rather than as its own id, so
- * a perfectly good event in such a room fails here and cannot be made to
- * pass: the key it would have to be checked against is not on the row and
- * cannot be walked back to from one. Those rooms get an empty chronicle for
- * the same reason, which is a limit of the derivation rather than of this
- * check.
+ * the case worth acting on. Two kinds of row fail honestly.
+ *
+ * A room that was never derived from its group's key -- [derivationPath]
+ * null, the legacy shape `signingPath` documents -- signs as the bare
+ * threshold key rather than as its own id, so a perfectly good event in such
+ * a room fails here and cannot be made to pass: the key it would have to be
+ * checked against is not on the row and cannot be walked back to from one.
+ * Those rooms get an empty chronicle for the same reason, which is a limit
+ * of the derivation rather than of this check.
+ *
+ * And a group's first signature is made in the NIP-17 room its ceremony ran
+ * in, about the #admins room it is about to create, so [chatRoomId] is where
+ * it happened and [publicKey] is who signed. Check that one with
+ * `GroupKeyStateEvent.isSignedByGroup`, which asks the question this row
+ * cannot: whether the *named* room signed it.
*/
fun verifies(): Boolean = GroupKeyStateEvent.isSignedByRoom(toEvent(), chatRoomId)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt
index cddcf4d3..0d8dc93b 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt
@@ -4,15 +4,20 @@ import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
+import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.managers.GroupKeyStateManager
+import press.mantra.compose.managers.SharedKeyDerivation
+import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.repository.DkgRepository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
class DatabaseDkgRepository(
private val database: MantraDatabase,
@@ -62,12 +67,42 @@ class DatabaseDkgRepository(
key = session
)
} catch (e: Throwable) {
- // The room not deriving from the key it is about to propose is a bug
- // rather than a condition, and a ceremony with no key yet is a caller
- // that got ahead of itself. Neither is worth failing the room over: the
- // group still has a working chat, and signing simply falls back to the
- // rederivation scan it used before there was a state.
- logger.e("Error proposing the key state for ${localChatRoom.chatRoom.id}", e)
+ // A room with no standing to propose is a bug rather than a condition,
+ // and a ceremony with no key yet is a caller that got ahead of itself.
+ // Neither is worth failing the room over: the group still has a working
+ // chat, and the screen reports a proposal that did not open by leaving
+ // the group with nothing signed and the button still there.
+ logger.e("Error proposing the key state from ${localChatRoom.chatRoom.id}", e)
+ null
+ }
+
+ override fun observeSigningSessions(
+ chatRoomId: String
+ ): Flow> =
+ database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
+
+ override fun observeSignedGroupKeyState(chatRoomId: String): Flow =
+ database.groupSignedEventDao()
+ .observeByKind(GroupKeyStateEvent.KIND)
+ .map { signedEvents ->
+ // The room the ceremony's key derives, resolved per emission
+ // rather than once: the ceremony has no key until it finishes,
+ // and this flow is running before it does.
+ val adminRoomId = database.dkgSessionDao()
+ .getLatestSessionForChatRoom(chatRoomId)
+ ?.thresholdPublicKey
+ ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() }
+
+ adminRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) }
+ }
+
+ override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = try {
+ GroupKeyStateManager.adopt(database = database, chatRoomId = chatRoomId)
+ } catch (e: Throwable) {
+ // The room is made and usable either way; what it loses is the shortcut
+ // from room to ceremony, and `FrostSigningManager.completedKey` still
+ // rederives its way to the same key.
+ logger.e("Error adopting the signed key state for $chatRoomId", e)
null
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChronicleManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChronicleManager.kt
index fefb4be9..a78d27b6 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChronicleManager.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChronicleManager.kt
@@ -50,11 +50,18 @@ import press.mantra.compose.nostr.nip30303.TranslationChunkEvent
*
* **The allowlist does real work on the way out now.** The rebuild could only
* ever produce document kinds; the table holds everything the group has ever
- * signed, `GroupKeyStateEvent` included -- and every room signs one of those as
- * its first act. So [signedEventsOf] filters on [ChronicleEvent.isChroniclable]
- * before anything else, which is the same rule [applyPage] applies on the way
- * in. Without it `ChronicleEvent.build` would refuse the page, and a room's whole
- * chronicle would fail on the one event every room has.
+ * signed, `GroupKeyStateEvent` included. So [signedEventsOf] filters on
+ * [ChronicleEvent.isChroniclable] before anything else, which is the same rule
+ * [applyPage] applies on the way in. Without it `ChronicleEvent.build` would
+ * refuse the page and take the whole chronicle down with it.
+ *
+ * A room's own key state no longer sits in that table under the room -- it is
+ * signed in the NIP-17 room the ceremony ran in, before the room exists, and
+ * filed under that room; see `GroupKeyStateManager`. That makes the filter a
+ * narrower guard than it was and not a redundant one: what it is really stopping
+ * is a member replaying any group-signed statement about the record as though it
+ * were work, and [applyPage] refuses the same kinds coming the other way. The
+ * two are a pair and neither is safe to drop on the strength of the other.
*
* ### Nothing unverifiable leaves
*
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt
index 742a7f20..77cb8324 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt
@@ -8,6 +8,8 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
+import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.RandomInstance
import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
@@ -26,6 +28,7 @@ import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningItem
import press.mantra.compose.database.model.FrostSigningSession
+import press.mantra.compose.database.model.GiftWrapPayload
import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.MarmotInnerEvent
@@ -37,7 +40,7 @@ import press.mantra.compose.nostr.dkg.DkgRitualEvents
import press.mantra.compose.nostr.frost.FrostSigningEvents
/**
- * Signs a nostr event with a group's FROST threshold key, in its #admins room.
+ * Signs a nostr event with a group's FROST threshold key, in one of its rooms.
*
* The shape is [ChillDkgRitualManager]'s, deliberately: the member who proposes
* a signature coordinates it, protocol messages travel on the kinds in
@@ -46,14 +49,19 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
* so a device killed mid-round resumes on the next message. What that manager's
* own notes say about being message-driven applies here unchanged.
*
- * The transport is not the same one. A ceremony runs over NIP-17 because it has
- * to: its participants are not yet a Marmot group, and its whole purpose is to
- * produce the key one would be keyed on. Signing has the opposite problem
- * solved for it -- the #admins room already exists, its membership is exactly
- * the share holders, and its id is derived from the key -- so a signing message
- * is an ordinary Marmot inner event and needs no addressing of its own. One
- * encrypted group event reaches everyone, rather than one sealed wrap per
- * member per message.
+ * ### Two transports, because a group signs before it has a room
+ *
+ * Almost every signature is made in the group's #admins room, and there a
+ * signing message is an ordinary Marmot inner event needing no addressing of its
+ * own: the room already exists, its membership is exactly the share holders, and
+ * its id is derived from the key. One encrypted group event reaches everyone.
+ *
+ * The exception is the group's *first* signature. A room's `GroupKeyState` is
+ * now signed before the room it is about is created -- see
+ * `GroupKeyStateManager.propose` -- so that session runs in the NIP-17 room the
+ * ceremony ran in, where there is no MLS tree and a message goes out as one
+ * sealed gift wrap per member. [broadcast] is the only place that knows the
+ * difference; everything above it is the same protocol either way.
*
* ### What it signs as
*
@@ -64,6 +72,10 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
* holding a signed dialect therefore needs no lookup to check it: the author
* they expect is the id of the room they found it in.
*
+ * A session in a NIP-17 room signs as the room the ceremony's key derives --
+ * the #admins room that does not exist yet -- which is the same rule read
+ * forwards: what a group signs as is the room the signature belongs to.
+ *
* The path comes from the room, never from a proposal -- [signingPath] -- because
* it decides which key the group signs as.
*
@@ -467,7 +479,7 @@ object FrostSigningManager {
)
announceApprovalNeeded(database, session)
- replayStoredMessages(database, session)
+ replayStoredMessages(database, localChatRoom, session)
return session
}
@@ -478,27 +490,52 @@ object FrostSigningManager {
* They were dropped at the time for want of a session to file them under, but
* the inbound path stores every payload it decrypts before dispatching on
* kind, so nothing was actually lost — this reads them back out.
+ *
+ * Out of whichever store the room's transport writes to, which has to be the
+ * same reading [broadcast] makes: a session in a NIP-17 room has its backlog
+ * in the gift-wrap payloads and none at all in the inner events, and looking
+ * in the wrong one is a stall with nothing to blame it on.
*/
private suspend fun replayStoredMessages(
database: MantraDatabase,
+ localChatRoom: LocalChatRoom,
session: FrostSigningSession
) {
- val stored = database.marmotInnerEventDao().getByChatRoomAndKinds(
- chatRoomId = session.chatRoomId,
- kinds = FrostSigningEvents.ALL.toList()
- ).filter { stored ->
+ val kinds = FrostSigningEvents.ALL.toList()
+
+ val received = if (localChatRoom.chatRoom.mlsGroupState != null) {
+ database.marmotInnerEventDao()
+ .getByChatRoomAndKinds(chatRoomId = session.chatRoomId, kinds = kinds)
+ .map { stored ->
+ Event(
+ id = stored.id,
+ pubKey = stored.publicKey,
+ createdAt = stored.createdAt.epochSeconds,
+ kind = stored.kind,
+ tags = stored.tags,
+ content = stored.content,
+ sig = ""
+ )
+ }
+ } else {
+ database.giftWrapPayloadDao()
+ .getByChatRoomAndKinds(chatRoomId = session.chatRoomId, kinds = kinds)
+ .map { stored ->
+ Event(
+ id = stored.id,
+ pubKey = stored.publicKey,
+ createdAt = stored.createdAt.epochSeconds,
+ kind = stored.kind,
+ tags = stored.tags,
+ content = stored.content,
+ sig = ""
+ )
+ }
+ }
+
+ val stored = received.filter { stored ->
stored.kind != FrostSigningEvents.PROPOSAL &&
FrostSigningEvents.parseSessionId(stored.tags) == session.id
- }.map { stored ->
- Event(
- id = stored.id,
- pubKey = stored.publicKey,
- createdAt = stored.createdAt.epochSeconds,
- kind = stored.kind,
- tags = stored.tags,
- content = stored.content,
- sig = ""
- )
}
if (stored.isEmpty()) return
@@ -1257,10 +1294,10 @@ object FrostSigningManager {
/**
* The key a room signs with, or null when it has none.
*
- * Signing runs in the #admins room, which is not where the ceremony ran. A
- * ceremony needs a NIP-17 group -- every member an equal admin, no MLS tree
- * to be outside of -- while a group event needs an MLS one, so the two
- * cannot be the same room.
+ * Signing usually runs in the #admins room, which is not where the ceremony
+ * ran. A ceremony needs a NIP-17 group -- every member an equal admin, no
+ * MLS tree to be outside of -- while a group event needs an MLS one, so the
+ * two cannot be the same room.
*
* They are bound together by the room's [GroupKeyState]: the statement the
* room opened with and the group signed, naming the ceremony behind it. That
@@ -1280,11 +1317,13 @@ object FrostSigningManager {
* is not how the app wires things today but costs one lookup to keep honest.
*
* They are not only for rooms that predate the table. A room's key state is
- * itself signed by the group, so between creating a room and that session
- * completing there is no state to read, and the scan is what keeps the room
- * usable in the meantime -- including for the key-state session's own
- * members. `GroupKeyStateManager.propose` is the one caller that does not
- * come through here at all, because it names the ceremony outright.
+ * signed before the room exists and filed as the room is created, so a
+ * device that missed that session -- a member invited later, a reinstall --
+ * has a room and no state, and the scan is what keeps it usable.
+ * `GroupKeyStateManager.propose` is the one caller that does not come
+ * through here at all, because it names the ceremony outright: the room it
+ * runs in is the NIP-17 room the ceremony was held in, which signs nothing
+ * else and has no key state of its own.
*/
suspend fun completedKey(database: MantraDatabase, chatRoomId: String): DkgSession? {
GroupKeyStateManager.keyStateFor(database, chatRoomId)?.let { state ->
@@ -1327,11 +1366,27 @@ object FrostSigningManager {
* signs as, so a proposer able to choose it could have every signer put
* their share behind an author of the proposer's choosing.
*
- * Null is not a failure. `completedKey` will find a key for a room that was
- * never derived from it -- a ceremony held in that very room, which is the
- * fallback kept for rooms the app no longer makes -- and such a room has no
- * key of its own to sign as, so it signs as the group's threshold key, which
- * is what it always did.
+ * ### The room a group signs from before it has one
+ *
+ * One case cannot be self-checked, because there is nothing yet to check
+ * against: the NIP-17 room a ceremony ran in, signing the very statement
+ * that lets the group's Marmot room be created -- see
+ * `GroupKeyStateManager.propose`. A NIP-17 room's id is an aggregation of
+ * its members' keys, so it is not derived from anything and no path reaches
+ * it. What the group signs as there is the room it is about to make: the
+ * ceremony's key at the app's admin path.
+ *
+ * That is admitted only when the ceremony is *this room's own*, and the path
+ * is the constant rather than anything off the wire. Both inputs are read
+ * from this device's database, so a proposer still chooses nothing: naming
+ * some other ceremony this device holds a share for gets no path at all, and
+ * a session with no path signs as the bare threshold key, which is not an
+ * identity any room answers to.
+ *
+ * Null is not a failure. `completedKey` will find a key for a Marmot room
+ * that was never derived from it -- the fallback kept for rooms the app no
+ * longer makes -- and such a room has no key of its own to sign as, so it
+ * signs as the group's threshold key, which is what it always did.
*/
private suspend fun signingPath(
database: MantraDatabase,
@@ -1347,11 +1402,17 @@ object FrostSigningManager {
SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
)
- return candidates.firstOrNull { path ->
+ candidates.firstOrNull { path ->
runCatching {
SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path) == chatRoomId
}.getOrDefault(false)
+ }?.let { return it }
+
+ if (localChatRoom.chatRoom.mlsGroupState == null && key.chatRoomId == chatRoomId) {
+ return SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
}
+
+ return null
}
/**
@@ -1741,16 +1802,27 @@ object FrostSigningManager {
}
/**
- * Queues a signing message as an unprocessed marmot inner event.
- * `NotaryViewModel` picks it up, MLS-encrypts it and broadcasts it as a
- * kind:445 for the room — the same path every other event in a Marmot group
- * takes, which is why this needs no transport of its own.
+ * Queues one of this device's signing messages for the outbound pipeline,
+ * on whichever transport the room it is in has.
*
- * No p-tags. A gift wrap is addressed and sealed once per recipient, so the
- * ceremony has to name everybody on every message; a group event is
- * encrypted to the group, and who is in it is the MLS tree's business rather
- * than the message's. That also means the signer set genuinely comes from
- * the ceremony rather than from whoever happened to be tagged.
+ * In a Marmot room it is an unprocessed inner event: `NotaryViewModel` picks
+ * it up, MLS-encrypts it and broadcasts it as a kind:445 for the room -- the
+ * same path every other event in a Marmot group takes.
+ *
+ * In a NIP-17 room it is a gift-wrap payload, sealed once per recipient and
+ * broadcast the way a ritual message is. That is not a second-class path; it
+ * is the only one a group has before it owns a Marmot room, which is exactly
+ * where the group's first signature is made -- see
+ * `GroupKeyStateManager.propose`.
+ *
+ * The p-tags are the difference between the two, and they are not
+ * cosmetic. A group event is encrypted to the group and who is in it is the
+ * MLS tree's business, so a Marmot message names nobody; a gift wrap is
+ * addressed and sealed per recipient, so a NIP-17 message has to name
+ * everybody or the members it left out never see it. Neither shape lets a
+ * recipient list decide anything: the signer set comes from the ceremony's
+ * host keys either way, so tagging somebody does not put them in it, and
+ * failing to tag somebody only stops them hearing.
*/
private suspend fun broadcast(
database: MantraDatabase,
@@ -1761,32 +1833,81 @@ object FrostSigningManager {
includeKey: Boolean = false,
signerIds: List? = null
) {
- val tags = FrostSigningEvents.assembleTags(
+ val sessionTags = FrostSigningEvents.assembleTags(
sessionId = session.id,
dkgSessionId = if (includeKey) session.dkgSessionId else null,
signerIds = signerIds
)
+ // No MLS state is what marks a room NIP-17 -- the same reading
+ // `NostrNip17Dao.getOrCreateChatRoom` writes and `sendChatMessage` makes
+ // when it chooses between a group event and gift wraps.
+ val isMarmotRoom = localChatRoom.chatRoom.mlsGroupState != null
+
+ val tags = if (isMarmotRoom) {
+ sessionTags
+ } else {
+ recipientTags(localChatRoom, session.userPublicKey) + sessionTags
+ }
+
val createdAt = Clock.System.now().epochSeconds
- database.marmotInnerEventDao().upsert(
- MarmotInnerEvent(
- // The rumor id the outbound pipeline will recompute from these
- // same fields when it assembles the event to encrypt.
- id = EventHasher.hashId(
- pubKey = session.userPublicKey,
- createdAt = createdAt,
+ // The rumor id the outbound pipeline will recompute from these same
+ // fields when it assembles the event to encrypt, on either transport.
+ val id = EventHasher.hashId(
+ pubKey = session.userPublicKey,
+ createdAt = createdAt,
+ tags = tags,
+ content = content,
+ kind = kind
+ )
+
+ if (isMarmotRoom) {
+ database.marmotInnerEventDao().upsert(
+ MarmotInnerEvent(
+ id = id,
+ publicKey = session.userPublicKey,
+ kind = kind,
+ createdAt = Instant.fromEpochSeconds(createdAt),
tags = tags,
content = content,
- kind = kind
- ),
- publicKey = session.userPublicKey,
- kind = kind,
- createdAt = Instant.fromEpochSeconds(createdAt),
- tags = tags,
- content = content,
- chatRoomId = localChatRoom.chatRoom.id
+ chatRoomId = localChatRoom.chatRoom.id
+ )
)
- )
+ } else {
+ database.giftWrapPayloadDao().upsert(
+ GiftWrapPayload(
+ id = id,
+ publicKey = session.userPublicKey,
+ kind = kind,
+ createdAt = Instant.fromEpochSeconds(createdAt),
+ tags = tags,
+ content = content,
+ chatRoomId = localChatRoom.chatRoom.id
+ )
+ )
+ }
}
+
+ /**
+ * One p-tag per member of a NIP-17 room, the sender excepted.
+ *
+ * The sender is left out rather than tagged because a gift wrap is sealed
+ * per recipient and this device already has its own copy -- `publishOwn`
+ * records it locally. Deduplicated by member, since `localParticipants` is a
+ * row list and a room can carry the same person twice.
+ */
+ private fun recipientTags(
+ localChatRoom: LocalChatRoom,
+ senderPublicKey: HexKey
+ ): Array> = localChatRoom.localParticipants
+ .distinctBy { it.participant.participantPublicKey }
+ .filter { it.participant.participantPublicKey != senderPublicKey }
+ .map { localParticipant ->
+ PTag.assemble(
+ localParticipant.participant.participantPublicKey,
+ localParticipant.participant.relayHint?.let { NormalizedRelayUrl(it) }
+ )
+ }
+ .toTypedArray()
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt
index e1e094d9..2f47e13b 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt
@@ -9,35 +9,56 @@ import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.GroupKeyState
+import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
/**
* Puts what key a room signs with to the group, and files what the group says.
*
- * The room's creator [propose]s it as the new room's first message; every
- * device [record]s the state once the signing session behind it produces a
- * signature. Both ends land on the same [GroupKeyState] row, which is what a
- * signing request is resolved against -- see `FrostSigningManager.completedKey`.
+ * [propose] asks for it before the room exists; every device [record]s the state
+ * when the signing session behind it produces a signature, or [adopt]s it the
+ * moment the room it names comes into being. All three ends land on the same
+ * [GroupKeyState] row, which is what a signing request is resolved against --
+ * see `FrostSigningManager.completedKey`.
*
* ### Why this is proposed rather than announced
*
* It used to be announced: the coordinator wrote the row, said so in the room,
* and every receiver kept the statement if the room's id rederived from the key
* it named. That check is still here and still the thing safety rests on -- a
- * state that does not rederive its own room is dropped, whoever it came from --
- * but it left the *first* thing a group ever does as the one thing one member
- * decides alone.
+ * state that does not rederive the room it names is dropped, whoever it came
+ * from -- but it left the *first* thing a group ever does as the one thing one
+ * member decides alone.
*
* So it goes through the same door everything else the group says goes through.
- * A room's key state is now a `FrostSigningEvents.PROPOSAL` over a
+ * A room's key state is a `FrostSigningEvents.PROPOSAL` over a
* [GroupKeyStateEvent], and the state exists when a quorum has signed it, on
- * every device at once, authored by the group's own key. The first thing the
- * group does is now something the group did.
+ * every device at once, authored by the group's own key.
*
- * Nothing about that makes the room usable any later than before: signing falls
- * back to rederiving while the session runs, which is exactly what every room
- * did before this table existed.
+ * ### Why it is signed before the room is created
+ *
+ * ```
+ * ceremony completes in the NIP-17 room
+ * coordinator --[ 30320 proposal over a 30326 ]-> the same NIP-17 room
+ * ...a quorum signs, on gift wraps...
+ * every device holds the signed 30326, naming a room nobody has made yet
+ * coordinator creates the #admins room --> adopt() files the state as it appears
+ * ```
+ *
+ * It used to be the new room's first application message, which meant the group
+ * created a room and only afterwards agreed what it signs with. Two things were
+ * wrong with that order. The room's founding fact was settled after the
+ * founding, so a session that never reached a quorum left a live room whose
+ * every member had to fall back to rederiving; and the members who had to sign
+ * it were exactly the ones the room had just been created to hold, so a member
+ * whose key package could not be found was excluded from a decision they held a
+ * share of.
+ *
+ * Signed first, the state is a precondition of the room rather than an
+ * afterthought: the group agrees what it signs with while it is still only a
+ * ceremony and a NIP-17 chat, and the room is created already knowing. That is
+ * what puts `FrostSigningManager` on two transports -- see [propose].
*/
object GroupKeyStateManager {
private const val TAG = "GroupKeyStateManager"
@@ -49,33 +70,37 @@ object GroupKeyStateManager {
database.groupKeyStateDao().getByChatRoomId(chatRoomId)
/**
- * Asks the group to say what the freshly made room signs with.
+ * Asks the group to say what the room it is about to make signs with.
*
- * Called once, by the member who created the room, as its first application
- * message -- so a member arriving on a welcome finds the session waiting
- * rather than having to be told about the key separately.
+ * Called once, by the ceremony's coordinator, in the NIP-17 room the
+ * ceremony ran in -- before the #admins room exists. [localChatRoom] is
+ * therefore where the session *runs*, and is not usually the room the state
+ * is *about*: that one is derived here, from [key] at [path], because the
+ * room's id and the key it signs as are the same value.
*
- * No row is written here, and that is the whole change. The state is not
- * this device's to assert; it appears on every device together when
- * `FrostSigningManager` completes the session and applies the signed event,
- * which is the same path that turns a signed proposal into a dialect.
+ * No row is written. The state is not this device's to assert, and there is
+ * not yet a room to hang one on; it appears on every device together when
+ * the room is created and [adopt] files the event a quorum signed.
*
- * The session signs at the room's own path, so what comes back is a state
- * signed by the very key it names -- the room's id being that key. A reader
- * needs nothing but the event and the room it arrived in to check that.
+ * The session signs at the subject room's path, so what comes back is a
+ * state signed by the very key it names. A reader needs nothing but the
+ * event to check that -- not even the room, which is why this can be signed
+ * before the room exists at all.
*
- * Refuses to propose a state that does not describe the room, because a
- * state that fails [GroupKeyState.verifies] here is this device having
- * derived the room from one key and proposed another -- a bug worth failing
- * on rather than asking the group to sign.
+ * Refuses to propose from a room that is neither the subject nor the
+ * ceremony's own, because `FrostSigningManager.signingPath` will resolve no
+ * path for one and the group would sign as its bare threshold key -- an
+ * identity no room answers to. Refuses a state that fails
+ * [GroupKeyState.verifies] for the same class of reason: it would mean this
+ * device derived the subject room from one key and proposed another.
*
* [key] is handed to the signing session rather than looked up from the
- * room, because the room has no key state yet and looking one up is exactly
- * what this session exists to make possible.
+ * room, because no room here has a key state -- establishing one is exactly
+ * what this session exists to do.
*
* A session of one, always, and never batched with anything else. A batch is
* all-or-nothing, so it is only as available as its worst item -- and this is
- * the statement every other session in the room is opened against. Bundling
+ * the statement every other session in the group is opened against. Bundling
* it with a dialect would make the room's ability to sign at all depend on
* that dialect's aggregation succeeding.
*/
@@ -87,9 +112,21 @@ object GroupKeyStateManager {
path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH,
createdAt: Long = Clock.System.now().epochSeconds
): FrostSigningSession {
- val chatRoomId = localChatRoom.chatRoom.id
+ val signingRoomId = localChatRoom.chatRoom.id
val thresholdPublicKey = key.thresholdPublicKey
- ?: throw IllegalStateException("Ceremony ${key.id} has produced no key for $chatRoomId to sign with")
+ ?: throw IllegalStateException(
+ "Ceremony ${key.id} has produced no key for $signingRoomId to propose"
+ )
+
+ // The room this is about: the group's key walked to the admin path,
+ // which is that room's id. It need not exist yet, and in the flow this
+ // was written for it does not.
+ val chatRoomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey, path)
+
+ check(chatRoomId == signingRoomId || key.chatRoomId == signingRoomId) {
+ "Room $signingRoomId is neither $chatRoomId nor where ceremony ${key.id} was held, " +
+ "so it has no standing to propose a key state"
+ }
val state = GroupKeyState(
chatRoomId = chatRoomId,
@@ -104,7 +141,10 @@ object GroupKeyStateManager {
"Room $chatRoomId is not derived from $thresholdPublicKey at ${state.derivationPath}"
}
- logger.i("Proposing key ${state.thresholdPublicKey} for room $chatRoomId at ${state.derivationPath}")
+ logger.i(
+ "Proposing key ${state.thresholdPublicKey} for room $chatRoomId at " +
+ "${state.derivationPath}, from $signingRoomId"
+ )
return FrostSigningManager.proposeSigning(
database = database,
@@ -128,20 +168,97 @@ object GroupKeyStateManager {
* Storing is all this adds to [stateFrom], which is where the deciding
* happens -- kept apart so the check a member's safety rests on can be
* exercised without standing up a database.
+ *
+ * A state about a room this device does not have is kept rather than filed.
+ * That is the normal case now and not an error: the group signs its state
+ * before creating the room it describes, so the event routinely lands
+ * minutes before the room does. [adopt] is the other end of that, and the
+ * signed event waits on file in the meantime.
*/
suspend fun record(
database: MantraDatabase,
chatRoomId: String,
innerEvent: Event
- ): GroupKeyState? =
- stateFrom(chatRoomId, innerEvent)?.let { database.groupKeyStateDao().replace(it) }
+ ): GroupKeyState? {
+ val state = stateFrom(chatRoomId, innerEvent) ?: return null
+
+ if (database.chatRoomDao().findChatRoomById(state.chatRoomId) == null) {
+ logger.i(
+ "The group signed a key state for ${state.chatRoomId}, which does not exist " +
+ "yet; holding it until it does"
+ )
+ return null
+ }
+
+ return database.groupKeyStateDao().replace(state)
+ }
+
+ /**
+ * Files the state the group has already signed for a room that has just come
+ * into existence, if there is one.
+ *
+ * The counterpart to [record], and the reason a room can be created already
+ * knowing what it signs with. Both ways into a room end here: the member who
+ * creates it, and the member who arrives on a Welcome -- who was in the
+ * ceremony and so holds the signed event too, because it was signed in the
+ * NIP-17 room they were already in rather than in the room they have just
+ * joined.
+ *
+ * Nothing is trusted that [stateFrom] would not trust. This only looks
+ * somewhere else for the event: at what the group has signed, rather than at
+ * what has just arrived. A member who was not in the ceremony holds no such
+ * event and gets nothing, which is correct -- they have no share either, and
+ * `FrostSigningManager.completedKey` rederives for them as it always did.
+ *
+ * Idempotent: `replace` keeps the newest state per room, so adopting twice
+ * settles on the same row.
+ */
+ suspend fun adopt(database: MantraDatabase, chatRoomId: String): GroupKeyState? {
+ val state = signedStateFor(database, chatRoomId) ?: return null
+
+ logger.i("Room $chatRoomId adopts the key state its group signed at ${state.announcedAt}")
+
+ return database.groupKeyStateDao().replace(state)
+ }
+
+ /**
+ * The newest state the group has signed for [chatRoomId], read off this
+ * device's stored signed events rather than off the [GroupKeyState] table.
+ *
+ * Answerable for a room that does not exist, which is the whole point of it:
+ * between the group agreeing its key state and somebody creating the room,
+ * this is the only thing that can say the agreement was reached. It is what
+ * the ritual screen gates the "create the room" button on, and what [adopt]
+ * files once there is a room to file it against.
+ */
+ suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? =
+ stateAmong(database.groupSignedEventDao().getByKind(GroupKeyStateEvent.KIND), chatRoomId)
+
+ /**
+ * The newest state for [chatRoomId] among some signed events, or null if
+ * none of them is one.
+ *
+ * Takes the events rather than fetching them so a caller watching them
+ * arrive can ask the same question of each emission -- which is how the
+ * ceremony screen knows the group has finished agreeing.
+ */
+ fun stateAmong(signedEvents: List, chatRoomId: String): GroupKeyState? =
+ signedEvents
+ .asSequence()
+ .filter { it.kind == GroupKeyStateEvent.KIND }
+ // Named rather than inferred. Every other reader may fall back to
+ // the room an event arrived in; this one is walking events from
+ // every room at once, so a state that names no room names nothing.
+ .filter { GroupKeyStateEvent.parseChatRoomId(it.tags) == chatRoomId }
+ .mapNotNull { stateFrom(chatRoomId, it.toEvent()) }
+ .maxByOrNull { it.announcedAt }
/**
* The state an event amounts to, or null if it amounts to none.
*
* Two questions, and a state has to answer both. Is it true -- does the room
- * rederive from the key it names? And did the group say it -- does the
- * signature verify against the identity of that same key?
+ * it names rederive from the key it names? And did the group say it -- does
+ * the signature verify against the identity of that same key?
*
* The first is the one that cannot be given up. Acting on a state naming a
* key the room was not derived from means signing with a share that will not
@@ -153,13 +270,27 @@ object GroupKeyStateManager {
* The second is what the proposal flow buys. It does not make a state truer;
* it makes a state the group's, so that the record of what a room signs with
* is a thing a quorum agreed to rather than a thing its creator said.
+ *
+ * ### Which room the state is about
+ *
+ * The one its own `d` tag names, not the one it arrived in. Those used to be
+ * required to agree and a mismatch was dropped, which was the right rule
+ * while a state was made in the room it described -- and is the wrong one
+ * now that a group signs its state before creating that room, so the two are
+ * ordinarily different by design.
+ *
+ * Nothing is given up by that. The check the drop was standing in for is
+ * still made, and made against the *named* room: the state has to rederive
+ * it, and the signature has to be by the key that rederivation reaches. A
+ * state can therefore only ever be about a room it derives, whatever room it
+ * turned up in, so nobody can point one room at another room's key by
+ * putting it through the wrong door.
+ *
+ * [chatRoomId] -- where it arrived -- is only the fallback for a state that
+ * names no room at all, and appears in the logs.
*/
fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? {
- val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags)
- if (announced != null && announced != chatRoomId) {
- logger.w("Key state for room $announced arrived in $chatRoomId; dropping")
- return null
- }
+ val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) ?: chatRoomId
val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content)
if (thresholdPublicKey == null) {
@@ -192,7 +323,7 @@ object GroupKeyStateManager {
}
val state = GroupKeyState(
- chatRoomId = chatRoomId,
+ chatRoomId = announced,
dkgSessionId = dkgSessionId,
thresholdPublicKey = thresholdPublicKey,
derivationPath = SharedKeyDerivation.formatPath(path),
@@ -201,11 +332,11 @@ object GroupKeyStateManager {
)
// Half the trust model, in one line, and the half that does not care who
- // is speaking. Only the truth rederives the room it was stated in.
+ // is speaking. Only the truth rederives the room it names.
if (!state.verifies()) {
logger.w(
"Key state from ${innerEvent.pubKey} names $thresholdPublicKey at " +
- "${state.derivationPath}, which does not derive room $chatRoomId; dropping"
+ "${state.derivationPath}, which does not derive room $announced; dropping"
)
return null
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt
index c26c79f0..0118918e 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt
@@ -11,10 +11,14 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
/**
* The nostr kinds a FROST signing session is carried on.
*
- * These are **rumor** kinds: they only ever exist inside a Marmot group event,
- * MLS-encrypted to the group and then wrapped again under the group's exporter
- * secret, so no relay sees them and the replaceable semantics normally implied
- * by the 3xxxx range never apply.
+ * These are **rumor** kinds: they only ever exist inside an encryption a relay
+ * cannot open, so no relay sees them and the replaceable semantics normally
+ * implied by the 3xxxx range never apply. Almost always that is a Marmot group
+ * event -- MLS-encrypted to the group and then wrapped again under the group's
+ * exporter secret. The exception is a group's first session, which agrees what
+ * its #admins room will sign with *before* that room exists and so runs in the
+ * NIP-17 room its ceremony ran in, on gift wraps. See
+ * `GroupKeyStateManager.propose`.
*
* Who talks to whom, in order:
*
@@ -40,13 +44,17 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag
* on kind by the same inbound path. Starting at 30320 leaves that family room to
* grow into.
*
- * The DKG's 30310-30316 look like a clash and are not: those exist only inside
- * NIP-17 gift wraps, and nothing reads a kind across both transports. It is
- * worth knowing that the numbers already overlap there -- the DKG's proposal and
- * host-key kinds sit on 30310 and 30311 alongside two nip30303 kinds, and its
- * round-1 kind is 30312, alongside SubmissionEvent -- because that separation is
- * an accident of routing rather than a decision, and the next family added
- * should not rely on it.
+ * The DKG's 30310-30316 look like a clash and are not, but the reason is
+ * narrower than it used to be. The nip30303 kinds exist only inside Marmot inner
+ * events and the DKG's only inside NIP-17 gift wraps, so the numbers overlap
+ * where nothing reads across -- the DKG's proposal and host-key kinds sit on
+ * 30310 and 30311 alongside two nip30303 kinds, and its round-1 kind is 30312,
+ * alongside SubmissionEvent.
+ *
+ * These kinds are now the ones that *do* travel on both transports, which is
+ * exactly why they must not overlap with either family. They do not. But that
+ * separation was an accident of routing rather than a decision, and the next
+ * family added should not rely on it.
*/
object FrostSigningEvents {
/**
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt
index 135973e4..4c62807a 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt
@@ -14,17 +14,25 @@ import press.mantra.compose.nostr.frost.tags.FrostKeyTag
/**
* What key a Marmot room signs with, signed by the group whose key it is.
*
- * The room's creator does not announce this; they *propose* it. What goes into
- * the new room first is a [FrostSigningEvents.PROPOSAL] carrying this event
+ * Nobody announces this; the group *agrees* it, and does so before the room it
+ * is about exists. What the ceremony's coordinator puts into the NIP-17 room the
+ * ceremony ran in is a [FrostSigningEvents.PROPOSAL] carrying this event
* unsigned, and the state comes into existence when a quorum has signed it --
* the same shape a dialect or an artifact is created in.
*
* ```
- * creator --[ 30320 proposal over a 30326 ]-> everyone "shall we say this room signs with K?"
- * ...the session runs...
- * everyone --applies the signed 30326 locally-- "the group says this room signs with K"
+ * coordinator --[ 30320 proposal over a 30326 ]-> the ceremony's room
+ * "shall we say our room will sign with K?"
+ * ...the session runs, on gift wraps...
+ * everyone holds the signed 30326 "the group says that room signs with K"
+ * ...somebody creates the room...
+ * everyone files it as the room appears GroupKeyStateManager.adopt
* ```
*
+ * A room *is* its key, so this can be signed before the room exists and checked
+ * without it: everything a reader needs is the event. See
+ * `GroupKeyStateManager` for why the order was turned around.
+ *
* Content is the group's ChillDKG threshold public key; the tags name the
* ceremony that produced it and the path the room's id was derived at.
*
@@ -61,23 +69,24 @@ import press.mantra.compose.nostr.frost.tags.FrostKeyTag
*
* ### Replaceable, by this app rather than by a relay
*
- * Like every kind in [FrostSigningEvents] this lives inside a Marmot group, so
- * no relay ever sees it and the addressable semantics of the 3xxxx range never
- * fire. [DTag] is the room id and the newest state per room wins, which the
- * local store enforces on its own. Being able to say it twice is what matters
- * in practice: a session replayed after a crash, or a second proposal for the
- * same true state, folds away instead of accumulating.
+ * Like every kind in [FrostSigningEvents] this only ever lives inside an
+ * encryption a relay cannot open -- a Marmot group event, or the gift wrap the
+ * proposal for it travels in -- so no relay sees it and the addressable
+ * semantics of the 3xxxx range never fire. [DTag] is the room id and the newest
+ * state per room wins, which the local store enforces on its own. Being able to
+ * say it twice is what matters in practice: a session replayed after a crash, or
+ * a second proposal for the same true state, folds away instead of accumulating.
*
* One room only ever names one key today. A group that re-runs its ceremony
* derives a *different* room from the new key, so rotation in place does not
* arise -- and if it ever does, the verification above is what has to change
- * first, because a rotated key no longer derives the room it is announced in.
+ * first, because a rotated key no longer derives the room it names.
*/
object GroupKeyStateEvent {
/**
- * Sits with the signing family in the Marmot inner-event space. 30320-30325
- * are a signing session; this is the standing fact a session is opened
- * against, so it is adjacent rather than inside.
+ * Sits with the signing family in the inner-event space. 30320-30325 are a
+ * signing session; this is the standing fact a session is opened against, so
+ * it is adjacent rather than inside.
*/
val KIND: Kind = 30326
@@ -86,10 +95,12 @@ object GroupKeyStateEvent {
/**
* The tags for a state naming [dkgSessionId], for the room derived at [path].
*
- * The room id goes on as the `d` tag so the event is self-addressing: a
- * reader can tell which room a state belongs to without the envelope it
- * arrived in, which is what makes dropping a state announced into the wrong
- * room a check rather than an assumption.
+ * The room id goes on as the `d` tag so the event is self-addressing, and
+ * that is now load-bearing rather than convenient: a state is signed in a
+ * room that is not the one it is about, so the `d` tag is the only thing
+ * that says which room it belongs to. What stops it naming a room it has no
+ * business naming is the derivation, not the envelope -- see
+ * `GroupKeyStateManager.stateFrom`.
*/
fun assembleTags(
chatRoomId: String,
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
index f698e828..b0ace479 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt
@@ -3,7 +3,9 @@ package press.mantra.compose.repository
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
+import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession
import press.mantra.compose.database.model.types.DkgApprovalStep
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.Flow
@@ -44,14 +46,15 @@ interface DkgRepository {
)
/**
- * Asks the room to sign a statement of which ceremony's key it signs with,
- * as its first message.
+ * Asks the group to sign a statement of which ceremony's key its #admins
+ * room will sign with, before that room is created.
*
- * Called once by whoever creates the room. What comes back is the signing
- * session, not the state: the state exists when a quorum has signed, which
- * is the point of proposing it rather than announcing it. Null if the ritual
- * has produced no key yet, or if the room does not derive from the one it
- * produced -- both of which mean there is nothing true to propose.
+ * Called once by the ceremony's coordinator, in the NIP-17 room the ceremony
+ * ran in. What comes back is the signing session, not the state: the state
+ * exists when a quorum has signed, which is the point of proposing it rather
+ * than announcing it. Null if the ritual has produced no key yet, or if this
+ * room has no standing to propose one -- both of which mean there is nothing
+ * true to propose.
*/
suspend fun proposeGroupKeyState(
localChatRoom: LocalChatRoom,
@@ -59,6 +62,33 @@ interface DkgRepository {
session: DkgSession
): FrostSigningSession?
+ /**
+ * The signing sessions open in a room, newest first, with what each signs.
+ *
+ * Read by the ritual screen to follow the one session a NIP-17 room ever
+ * holds: the group agreeing its key state. The items travel with the session
+ * because "which session is this" is answered by the kind of the event it is
+ * signing, not by the row.
+ */
+ fun observeSigningSessions(chatRoomId: String): Flow>
+
+ /**
+ * The key state the group has signed for the #admins room this room's
+ * ceremony will make, whether or not that room exists yet.
+ *
+ * The gate on creating it. Null means the group has not finished agreeing
+ * what it will sign with, and a room created then would be one whose
+ * founding fact was never settled.
+ *
+ * A flow over the signed events rather than a read, because the moment it
+ * changes is the moment there is a room to make -- and the state it is
+ * about does not exist as a row until somebody makes one.
+ */
+ fun observeSignedGroupKeyState(chatRoomId: String): Flow
+
+ /** Files the state the group signed for a room that now exists. */
+ suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState?
+
companion object {
val NO_OP_DKG_REPOSITORY: DkgRepository = object : DkgRepository {
override fun observeLatestSessionForChatRoom(chatRoomId: String): Flow = flowOf(null)
@@ -88,6 +118,15 @@ interface DkgRepository {
userPublicKey: HexKey,
session: DkgSession
): FrostSigningSession? = null
+
+ override fun observeSigningSessions(
+ chatRoomId: String
+ ): Flow> = flowOf(emptyList())
+
+ override fun observeSignedGroupKeyState(chatRoomId: String): Flow =
+ flowOf(null)
+
+ override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
index e9ed0058..bfc6a964 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt
@@ -56,6 +56,7 @@ import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalParticipant
import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.extensions.memberName
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.repository.ChatRepository
@@ -71,6 +72,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.view.model.DkgRitualViewModel
import press.mantra.compose.ui.view.state.DkgRitualUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import press.mantra.compose.database.model.FrostSigningSession
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -91,6 +93,11 @@ import mantra.composeapp.generated.resources.start_key_ceremony
import mantra.composeapp.generated.resources.the_ceremony_was_abandoned
import mantra.composeapp.generated.resources.the_group_can_hold_one_key_together_split_so
import mantra.composeapp.generated.resources.the_group_has_a_shared_key
+import mantra.composeapp.generated.resources.the_group_has_agreed_what_the_admins_room
+import mantra.composeapp.generated.resources.the_group_could_not_agree_what_the_admins
+import mantra.composeapp.generated.resources.the_group_is_agreeing_what_the_admins_room
+import mantra.composeapp.generated.resources.agree_the_groups_signing_key
+import mantra.composeapp.generated.resources.before_the_room_exists_the_group_signs
import mantra.composeapp.generated.resources.this_is_fixed_once_the_ceremony_runs
import mantra.composeapp.generated.resources.try_again
import mantra.composeapp.generated.resources.your_share_of_it_is_on_this_device_only_your
@@ -285,6 +292,9 @@ fun DkgRitualScreen(
round2Participants = dkgRitualUIState.round2Participants,
isActionPending = isActionPending,
adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn,
+ keyStateSession = dkgRitualUIState.keyStateSession,
+ isKeyStateSigned = dkgRitualUIState.isKeyStateSigned,
+ onProposeKeyState = dkgRitualViewModel::proposeKeyState,
onCreateAdminGroup = {
dkgRitualViewModel.createAdminGroup(onNavigateToRoute)
}
@@ -331,6 +341,9 @@ private fun RitualProgress(
round2Participants: Set,
isActionPending: Boolean,
adminGroupBlockedOn: List,
+ keyStateSession: FrostSigningSession?,
+ isKeyStateSigned: Boolean,
+ onProposeKeyState: () -> Unit,
onCreateAdminGroup: () -> Unit,
) {
val stage = session.stage
@@ -479,6 +492,49 @@ private fun RitualProgress(
style = MaterialTheme.typography.labelMedium
)
+ // What the group has to settle before there is a room: which
+ // ceremony's key the #admins room signs with. Signed first and
+ // filed as the room is created, so the room's founding fact is
+ // never something it has to go and ask about afterwards.
+ //
+ // Shown to every member, not only the coordinator, because every
+ // member has to sign it -- the request itself is a line in this
+ // chat, which is where they answer it.
+ val keyStateFailed = keyStateSession?.stage == FrostSigningStage.FAILED
+
+ when {
+ isKeyStateSigned -> Row(
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.CheckCircle, contentDescription = Decorative)
+ Text(
+ text = stringResource(Res.string.the_group_has_agreed_what_the_admins_room),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+
+ keyStateFailed -> Text(
+ text = stringResource(Res.string.the_group_could_not_agree_what_the_admins),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+
+ keyStateSession != null -> Text(
+ text = stringResource(
+ Res.string.the_group_is_agreeing_what_the_admins_room,
+ keyStateSession.threshold,
+ keyStateSession.participantCount
+ ),
+ style = MaterialTheme.typography.labelMedium
+ )
+
+ else -> Text(
+ text = stringResource(Res.string.before_the_room_exists_the_group_signs),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+
// Offered to whoever ran the ceremony. Any member's device could
// derive the same room id and create it, but one of them has to go
// first, and the coordinator is the member the group already watched
@@ -496,17 +552,43 @@ private fun RitualProgress(
)
}
- Button(
- onClick = { onCreateAdminGroup() },
- enabled = !isActionPending,
- modifier = Modifier.fillMaxWidth()
- ) {
- if (isActionPending) {
- CircularProgressIndicator(modifier = Modifier.size(20.dp))
- } else {
- Icon(Icons.Default.Groups, contentDescription = Decorative)
- Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
- Text(text = stringResource(Res.string.create_the_admins_group))
+ // One button, two steps, in the order they have to happen:
+ // ask the group to agree the key, then make the room. The
+ // second is not offered early, because a room made before
+ // the group agreed would be the old order back again.
+ if (isKeyStateSigned) {
+ Button(
+ onClick = { onCreateAdminGroup() },
+ enabled = !isActionPending,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (isActionPending) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp))
+ } else {
+ Icon(Icons.Default.Groups, contentDescription = Decorative)
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
+ Text(text = stringResource(Res.string.create_the_admins_group))
+ }
+ }
+ } else {
+ // Offered again after a failure, and only then: a
+ // retry has to be a *new* session, never the same one
+ // resumed, because that session's nonce seeds have
+ // already been published against an aggregate.
+ // `propose` mints a fresh one every time, so tapping
+ // this is the safe retry by construction.
+ Button(
+ onClick = { onProposeKeyState() },
+ enabled = !isActionPending && (keyStateSession == null || keyStateFailed),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ if (isActionPending) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp))
+ } else {
+ Icon(Icons.Default.Key, contentDescription = Decorative)
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
+ Text(text = stringResource(Res.string.agree_the_groups_signing_key))
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
index 374a37cc..23979036 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt
@@ -11,6 +11,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import press.mantra.compose.database.model.types.DkgApprovalStep
import press.mantra.compose.database.model.types.DkgRitualStage
+import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.database.model.types.ChatRoomType
import press.mantra.compose.managers.ChillDkgRitualManager
import press.mantra.compose.extensions.toHex
@@ -31,10 +32,12 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.Dispatchers.Main
import press.mantra.compose.nostr.dkg.DkgRitualEvents
+import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.DkgRepository
import press.mantra.compose.ui.view.state.DkgRitualUIState
import co.touchlab.kermit.Logger
+import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import fr.acinq.phoenix.data.ActiveWallet
@@ -71,6 +74,9 @@ class DkgRitualViewModel(
/** Watches the current session's messages; replaced whenever the session changes. */
private var messageObserver: Job? = null
+ /** Watches the room's signing sessions, which is where the key state is agreed. */
+ private var keyStateObserver: Job? = null
+
/**
* The quorum the ritual will generate a key for. Pre-filled with the same
* majority default the group-creation screen offers.
@@ -126,6 +132,8 @@ class DkgRitualViewModel(
.defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size)
.coerceIn(quorumRange())
+ observeKeyState()
+
dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { session ->
val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded)
?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom)
@@ -165,6 +173,97 @@ class DkgRitualViewModel(
}
}
+ /**
+ * Follows the group agreeing what its #admins room will sign with.
+ *
+ * Nothing here watches a [GroupKeyState] row, because there is no room yet
+ * for one to belong to -- being able to follow this before the room exists
+ * is the point. What is watched instead is the session, for how the signing
+ * is going, and the signed event, for whether it finished.
+ *
+ * The room a ceremony runs in signs nothing else, so the newest session in
+ * it is the key state's -- but it is matched on the kind it carries anyway,
+ * so a room that one day signs something else does not confuse the two.
+ */
+ private fun observeKeyState() {
+ keyStateObserver?.cancel()
+ keyStateObserver = viewModelScope.launch(Dispatchers.IO) {
+ // Two flows because they answer two questions off two tables. How
+ // the signing is going is the session row; whether it finished is
+ // the signed event, and only that second one can be asked at all
+ // before there is a room -- which is the whole point of asking it.
+ launch {
+ dkgRepository.observeSigningSessions(chatRoomId).collect { sessions ->
+ val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
+
+ dkgRitualUIState = loaded.copy(
+ keyStateSession = sessions.firstOrNull { local ->
+ local.items.any { item ->
+ runCatching {
+ Event.fromJson(item.unsignedEventJson).kind
+ }.getOrNull() == GroupKeyStateEvent.KIND
+ }
+ }?.session
+ )
+ }
+ }
+
+ launch {
+ dkgRepository.observeSignedGroupKeyState(chatRoomId).collect { state ->
+ val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect
+
+ dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null)
+ }
+ }
+ }
+ }
+
+ /**
+ * Asks the group to sign what its #admins room will sign with.
+ *
+ * The step before the room exists, and the reason it can be created knowing
+ * its own key state rather than being told afterwards. Offered to the
+ * ceremony's coordinator, who is also the member who will create the room.
+ *
+ * Nothing here waits for the answer: the signing runs on arriving messages
+ * like everything else, and the screen follows it through [observeKeyState].
+ */
+ fun proposeKeyState() {
+ if (isActionPending.value) return
+
+ val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return
+ val session = loaded.session ?: return
+ if (session.thresholdPublicKey == null) return
+
+ // Nothing to ask for, and nobody to ask twice. A failed session is the
+ // one case a second proposal is right, and it has to be a new session
+ // rather than that one resumed -- see `FrostSigningManager`, which is
+ // why `propose` mints a fresh id every time.
+ if (loaded.isKeyStateSigned) return
+ loaded.keyStateSession?.let { if (it.stage != FrostSigningStage.FAILED) return }
+
+ isActionPending.value = true
+
+ viewModelScope.launch(Dispatchers.IO) {
+ val signing = dkgRepository.proposeGroupKeyState(
+ localChatRoom = loaded.localChatRoom,
+ userPublicKey = activeUserPublicKey,
+ session = session
+ )
+
+ isActionPending.value = false
+
+ if (signing == null) {
+ logger.e("Failed to propose the key state for $chatRoomId")
+ dkgRitualUIState = DkgRitualUIState.Error(
+ "Couldn't ask the group to agree its signing key. Please try again."
+ )
+ }
+ // On success the session flow above delivers the new row; no need to
+ // set it here and risk racing the observer.
+ }
+ }
+
/** Opens a ritual, making this device its coordinator, and publishes the proposal. */
fun startRitual() {
if (isActionPending.value) return
@@ -246,6 +345,12 @@ class DkgRitualViewModel(
* room is addressable without being announced, and two members racing to create
* it produce the same id instead of two rival rooms -- which is why this returns
* early to the existing room rather than minting a second one.
+ *
+ * Only once the group has signed what the room signs with. That statement is
+ * agreed in the NIP-17 room the ceremony ran in, before there is a Marmot
+ * room to hold it -- see `GroupKeyStateManager.propose` -- so by here it is
+ * a fact the room is created *knowing*, rather than the first thing it has
+ * to go and ask about.
*/
fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) {
if (isActionPending.value) return
@@ -254,6 +359,10 @@ class DkgRitualViewModel(
val session = loaded.session ?: return
val thresholdPublicKey = session.thresholdPublicKey ?: return
+ // The screen offers nothing until the group has signed, so this is a
+ // guard rather than a path anybody takes.
+ if (!loaded.isKeyStateSigned) return
+
val nostrPrivateKey = activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()
if (nostrPrivateKey == null) {
dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.")
@@ -378,44 +487,30 @@ class DkgRitualViewModel(
return@launch
}
+ // The room comes into existence already knowing what it signs with.
+ // The group agreed that before any of this ran, in the NIP-17 room
+ // the ceremony was held in, and this device has been holding the
+ // signed statement since -- the line above is simply the first
+ // moment there is a room row to file it against.
+ //
+ // Before the members are added rather than after, because nothing
+ // here goes on the wire: filing it is local, and doing it while the
+ // room is certain to exist beats doing it after a step that can
+ // partly fail.
+ //
+ // Every other member does the same on their Welcome, in NostrDao,
+ // off the same event. A member invited later holds no such event,
+ // and no share either, so they have nothing to pick wrongly between
+ // -- `FrostSigningManager.completedKey` rederives its way to the
+ // same key for them.
+ dkgRepository.adoptGroupKeyState(localChatRoom.chatRoom.id)
+
val notAdded = runCatching {
chatRepository.addMembers(localChatRoom = localChatRoom, peers = addable)
}.onFailure {
logger.e("Failed to add members to admin group $groupId", it)
}.getOrElse { addable.map { (publicKey, _) -> publicKey } }
- // The room's first message: a proposal that the group sign a
- // statement of which ceremony's key it signs with, and the path its
- // id was derived at. What a signer reaches for when a signing
- // request arrives and it has to pick one of its shares.
- //
- // Proposed rather than declared, so that the first thing on the
- // record in a group's own room is a thing the group did. Nothing
- // waits on it: until a quorum signs, a signing request resolves the
- // key by rederiving, which is what every room did before this event
- // existed.
- //
- // After the members are added rather than before, which is the only
- // order that works: adding them commits a new epoch, and MLS will not
- // let a member read what was encrypted before the epoch they joined
- // at. Proposed first, the proposal would reach nobody but its author
- // -- and a proposal nobody receives is one nobody can sign. It is
- // still the room's first *application* message; what comes before it
- // is handshake.
- //
- // A member invited later misses the session for the same reason, and
- // so misses the state: a completed session is applied by each device
- // that ran it and the signed event never goes on the wire. They are
- // left where every member was before this event existed, falling back
- // to FrostSigningManager.completedKey's rederivation. Re-proposing on
- // invite is the fix, and is cheap because a second signature over the
- // same true state folds away rather than accumulating.
- dkgRepository.proposeGroupKeyState(
- localChatRoom = localChatRoom,
- userPublicKey = activeUserPublicKey,
- session = session
- )
-
isActionPending.value = false
if (notAdded.isNotEmpty()) {
@@ -446,6 +541,7 @@ class DkgRitualViewModel(
override fun onCleared() {
messageObserver?.cancel()
+ keyStateObserver?.cancel()
super.onCleared()
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt
index cba81b4b..abad6a01 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualUIState.kt
@@ -1,6 +1,8 @@
package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.DkgSession
+import press.mantra.compose.database.model.FrostSigningSession
+import press.mantra.compose.database.model.GroupKeyState
import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.intermdiate.LocalParticipant
@@ -42,6 +44,25 @@ sealed interface DkgRitualUIState {
* state a retry starts from.
*/
val adminGroupBlockedOn: List = emptyList(),
+ /**
+ * The session in which the group is agreeing what its #admins room will
+ * sign with, once somebody has opened one.
+ *
+ * A ceremony's room holds exactly one of these and nothing else, because
+ * a NIP-17 room signs nothing else -- see `GroupKeyStateManager.propose`.
+ * Null before it is proposed, and it stays on the screen afterwards so
+ * the group can see how far the signing has got.
+ */
+ val keyStateSession: FrostSigningSession? = null,
+ /**
+ * Whether the group has finished signing that statement.
+ *
+ * Read from the signed event rather than from the [GroupKeyState] table,
+ * because the room the state is about does not exist yet -- being able
+ * to say this before there is a room is the whole reason the state is
+ * signed first. It is what gates creating the room.
+ */
+ val isKeyStateSigned: Boolean = false,
): DkgRitualUIState {
val hostKeyCount: Int get() = hostKeyParticipants.size
val round1Count: Int get() = round1Participants.size
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt
index ec09b9ab..51d26bb2 100644
--- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt
@@ -16,6 +16,7 @@ import fr.acinq.bitcoin.crypto.frost.Session
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
@@ -383,18 +384,45 @@ class GroupKeyStateTest {
}
@Test
- fun `a state naming another group's key is dropped`() {
- // Well-formed input that lies. Refused by the derivation rather than by
- // the signature -- the group here is signing about its own key, it is
- // simply not the key this room was made from.
+ fun `a state naming another group's key is never about this room`() {
+ // Well-formed input, honestly signed, and about somebody else. Another
+ // group saying what its own room signs with is a true statement, and
+ // putting it through this room's door does not make it a statement about
+ // this room -- the subject is the room the state names, and a state can
+ // only ever name a room it derives.
+ //
+ // This is the check that used to be "arrived in the wrong room, drop
+ // it". That rule had to go when a group started signing its state before
+ // the room it describes exists, so the two are ordinarily different by
+ // design. What it was protecting is here instead, and unconditionally:
+ // there is no input that makes a state about a room it does not derive.
val siblingRoom = SharedKeyDerivation.marmotGroupId(otherKey, path)
+ val state = GroupKeyStateManager.stateFrom(
+ chatRoomId,
+ signedKeyState(
+ content = otherKey,
+ tags = GroupKeyStateEvent.assembleTags(siblingRoom, "ceremony-1", path),
+ author = otherKeyMaterial
+ )
+ )
+
+ assertNotEquals(chatRoomId, state?.chatRoomId)
+ assertEquals(siblingRoom, state?.chatRoomId)
+ }
+
+ @Test
+ fun `a state naming this room but another group's key is dropped`() {
+ // The lie the derivation catches, with the room held fixed: this room,
+ // and a key it was not made from. Refused however well it is signed --
+ // the group signing is signing about its own key, which is simply not
+ // the key this room derives from.
assertNull(
GroupKeyStateManager.stateFrom(
chatRoomId,
signedKeyState(
content = otherKey,
- tags = GroupKeyStateEvent.assembleTags(siblingRoom, "ceremony-1", path),
+ tags = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path),
author = otherKeyMaterial
)
)
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt
index 16947836..2f65958d 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChronicleAssemblyJvmTest.kt
@@ -470,12 +470,17 @@ class ChronicleAssemblyJvmTest {
/**
* The failure this source made possible, and the allowlist that stops it.
*
- * A key state is group-signed and verifies perfectly, and every room signs
- * one as its first act -- so it is on file in every room that has ever
- * signed anything. The rebuild could not reach one because no `Mantra*` row
- * holds it; the record holds every kind. Without the filter on the way out
- * `ChronicleEvent.build` refuses the page and the room's whole chronicle fails
- * on the one event every room has.
+ * A key state is group-signed and verifies perfectly. The rebuild could not
+ * reach one because no `Mantra*` row holds it; the record holds every kind.
+ * Without the filter on the way out `ChronicleEvent.build` refuses the page
+ * and the room's whole chronicle fails on one event.
+ *
+ * A room no longer signs its own key state -- a group agrees that before the
+ * room exists, in the NIP-17 room its ceremony ran in, and files it under
+ * that room -- so this is put on file by hand rather than arriving by
+ * itself. That makes it a sharper test than it was, not a hypothetical one:
+ * what the filter stops is any group-signed statement *about* the record
+ * being replayed as work, and this is one.
*/
@Test
fun `a key state the room really signed is never chronicled`() = runBlocking {
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt
index 0112d3c6..27e3f847 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt
@@ -18,6 +18,7 @@ import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
+import kotlin.time.Instant
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.ChatMessage
@@ -26,7 +27,10 @@ import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.DkgParticipantMessage
import press.mantra.compose.database.model.DkgSession
import press.mantra.compose.database.model.FrostSigningSession
+import press.mantra.compose.database.model.GiftWrapPayload
+import press.mantra.compose.database.model.MarmotInnerEvent
import press.mantra.compose.database.model.NostrEvent
+import press.mantra.compose.database.model.Participant
import press.mantra.compose.database.model.Profile
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.DkgRitualStage
@@ -120,6 +124,41 @@ class SignedGroupKeyStateTest {
val roomId: String get() = room.chatRoom.id
+ /** Which transport this device's room puts a signing message on. */
+ val isMarmotRoom: Boolean get() = room.chatRoom.mlsGroupState != null
+
+ /**
+ * Files an arriving message where the inbound path would have filed it,
+ * before it is dispatched.
+ */
+ suspend fun store(queued: Queued) {
+ if (isMarmotRoom) {
+ db.marmotInnerEventDao().upsert(
+ MarmotInnerEvent(
+ id = queued.id,
+ publicKey = queued.publicKey,
+ kind = queued.kind,
+ createdAt = queued.createdAt,
+ tags = queued.tags,
+ content = queued.content,
+ chatRoomId = roomId
+ )
+ )
+ } else {
+ db.giftWrapPayloadDao().upsert(
+ GiftWrapPayload(
+ id = queued.id,
+ publicKey = queued.publicKey,
+ kind = queued.kind,
+ createdAt = queued.createdAt,
+ tags = queued.tags,
+ content = queued.content,
+ chatRoomId = roomId
+ )
+ )
+ }
+ }
+
suspend fun session(sessionId: String): FrostSigningSession? =
db.frostSigningSessionDao().getSessionById(sessionId)
@@ -141,34 +180,60 @@ class SignedGroupKeyStateTest {
signerIndex: Int,
roomId: String = adminRoomId,
description: String? = adminRoomDescription,
- ceremonyRoomId: String = roomId
+ ceremonyRoomId: String = roomId,
+ /**
+ * Whether the room is a Marmot one. Not a real saved group -- nothing
+ * here runs MLS -- but null against non-null is exactly the reading
+ * `FrostSigningManager.broadcast` makes when it chooses a transport, so
+ * it has to be the reading these devices offer too.
+ */
+ mlsGroupState: String? = "00"
): Device {
val db = getRoomDatabase(Room.inMemoryDatabaseBuilder())
// Profile hangs off a nostr event, and a room off a profile. Neither is
// anything this test is about; they are the foreign keys in the way.
- val nostrEventId = "e$publicKey".take(64)
- db.nostrEventDao().upsert(
- NostrEvent(
- id = nostrEventId,
- pubKey = publicKey,
- kind = 0,
- tags = emptyArray(),
- content = "{}",
- sig = "0".repeat(128)
+ // Everybody gets one, because a NIP-17 room's participant rows need a
+ // profile per member and a gift wrap is addressed per member.
+ members.forEach { member ->
+ val nostrEventId = "e$member".take(64)
+ db.nostrEventDao().upsert(
+ NostrEvent(
+ id = nostrEventId,
+ pubKey = member,
+ kind = 0,
+ tags = emptyArray(),
+ content = "{}",
+ sig = "0".repeat(128)
+ )
)
- )
- db.profileDao().upsert(Profile(publicKey = publicKey, nostrEventId = nostrEventId))
+ db.profileDao().upsert(Profile(publicKey = member, nostrEventId = nostrEventId))
+ }
val chatRoom = ChatRoom(
id = roomId,
userPublicKey = publicKey,
subject = "#admins",
description = description,
- mlsGroupState = null
+ mlsGroupState = mlsGroupState
)
db.chatRoomDao().upsert(chatRoom)
+ // A NIP-17 room's membership is its participant rows, and that is what a
+ // gift wrap is addressed from. A Marmot room's is the MLS tree, which
+ // nothing here runs and no signing message reads.
+ if (mlsGroupState == null) {
+ db.participantDao().upsert(
+ members.map { member ->
+ Participant(
+ participantPublicKey = member,
+ chatRoomId = roomId,
+ relayHint = null
+ )
+ }
+ )
+ }
+
db.dkgSessionDao().upsert(
DkgSession(
id = ceremonyId,
@@ -201,23 +266,65 @@ class SignedGroupKeyStateTest {
)
}
- return Device(publicKey, db, LocalChatRoom(chatRoom = chatRoom))
- .also { devices += it }
+ // Read back rather than assembled, so the room carries its participants
+ // -- which is what a gift-wrapped signing message is addressed from.
+ val localChatRoom = db.chatRoomDao().findChatRoomById(roomId)
+ ?: LocalChatRoom(chatRoom = chatRoom)
+
+ return Device(publicKey, db, localChatRoom).also { devices += it }
}
private suspend fun ceremonyOn(device: Device): DkgSession =
device.db.dkgSessionDao().getSessionById(ceremonyId)!!
- /** Every protocol row this device has queued, oldest first, protocol order within a second. */
- private suspend fun outbox(device: Device) = device.db.marmotInnerEventDao()
- .getByChatRoomAndKinds(
- chatRoomId = device.roomId,
- kinds = (FrostSigningEvents.ALL + GroupKeyStateEvent.KIND).toList()
+ /**
+ * One queued protocol message, whichever store it came out of.
+ *
+ * The two transports are two tables and one shape. Flattening them here is
+ * what lets every test below be written once and read the same, whether the
+ * room it is about is a Marmot one or the NIP-17 room a group signs its
+ * first statement in -- which is the point being made: nothing above
+ * `FrostSigningManager.broadcast` knows the difference.
+ */
+ private data class Queued(
+ val id: HexKey,
+ val publicKey: HexKey,
+ val kind: Int,
+ val tags: Array>,
+ val content: String,
+ val createdAt: Instant,
+ ) {
+ fun toEvent(): Event = Event(
+ id = id,
+ pubKey = publicKey,
+ createdAt = createdAt.epochSeconds,
+ kind = kind,
+ tags = tags,
+ content = content,
+ sig = ""
)
- .filter { it.publicKey == device.publicKey }
- // Two rows queued in the same second tie on createdAt. Kind breaks it in
- // the order a session runs, which is what a relay would have preserved.
- .sortedWith(compareBy({ it.createdAt }, { it.kind }))
+ }
+
+ /** Every protocol row this device has queued, oldest first, protocol order within a second. */
+ private suspend fun outbox(device: Device): List {
+ val kinds = (FrostSigningEvents.ALL + GroupKeyStateEvent.KIND).toList()
+
+ val queued = if (device.isMarmotRoom) {
+ device.db.marmotInnerEventDao()
+ .getByChatRoomAndKinds(chatRoomId = device.roomId, kinds = kinds)
+ .map { Queued(it.id, it.publicKey, it.kind, it.tags, it.content, it.createdAt) }
+ } else {
+ device.db.giftWrapPayloadDao()
+ .getByChatRoomAndKinds(chatRoomId = device.roomId, kinds = kinds)
+ .map { Queued(it.id, it.publicKey, it.kind, it.tags, it.content, it.createdAt) }
+ }
+
+ return queued
+ .filter { it.publicKey == device.publicKey }
+ // Two rows queued in the same second tie on createdAt. Kind breaks it in
+ // the order a session runs, which is what a relay would have preserved.
+ .sortedWith(compareBy({ it.createdAt }, { it.kind }))
+ }
/**
* Hands everything one device has queued to the other, until neither has
@@ -241,19 +348,11 @@ class SignedGroupKeyStateTest {
if (!to.received.add(queued.id)) return@forEach
moved = true
- to.db.marmotInnerEventDao().upsert(queued)
+ to.store(queued)
FrostSigningManager.processSigningPayload(
database = to.db,
localChatRoom = to.room,
- innerEvent = Event(
- id = queued.id,
- pubKey = queued.publicKey,
- createdAt = queued.createdAt.epochSeconds,
- kind = queued.kind,
- tags = queued.tags,
- content = queued.content,
- sig = ""
- ),
+ innerEvent = queued.toEvent(),
userPublicKey = to.publicKey
)
}
@@ -263,9 +362,14 @@ class SignedGroupKeyStateTest {
}
// ---- The key state, from proposal to row ------------------------------
+ //
+ // Signed in the room it is about, which is the shape a re-proposal takes and
+ // the one the mechanics are easiest to read in. The order the app actually
+ // creates a group in -- signed first, room second -- is the section after
+ // the next one.
@Test
- fun `creating a room proposes its key state rather than announcing it`() = runBlocking {
+ fun `a key state is proposed rather than announced`() = runBlocking {
val creator = device(members[0], signerIndex = 0)
val session = GroupKeyStateManager.propose(
@@ -282,7 +386,7 @@ class SignedGroupKeyStateTest {
val queued = outbox(creator)
val proposal = queued.firstOrNull { it.kind == FrostSigningEvents.PROPOSAL }
- assertNotNull(proposal, "the room's first message must be a signing proposal")
+ assertNotNull(proposal, "a key state must go out as a signing proposal")
val payload = Event.fromJson(proposal.content)
assertEquals(GroupKeyStateEvent.KIND, payload.kind)
@@ -291,7 +395,7 @@ class SignedGroupKeyStateTest {
}
@Test
- fun `the key state a room proposes is authored by the room`() = runBlocking {
+ fun `a key state is authored by the room it is about`() = runBlocking {
val creator = device(members[0], signerIndex = 0)
val session = GroupKeyStateManager.propose(
@@ -463,6 +567,271 @@ class SignedGroupKeyStateTest {
assertEquals(adminRoomId, absent.keyState()?.announcedBy)
}
+ // ---- The key state, signed before the room exists ----------------------
+
+ /**
+ * The room the ceremony was held in: NIP-17, so no MLS state, and an id that
+ * is an aggregation of its members rather than anything derived. This is
+ * where a group lives before it owns a Marmot room, and where it signs the
+ * statement that lets one be made.
+ */
+ private val ceremonyRoomId = "ceremonyroom".padEnd(64, '0')
+
+ /** A member's device as it is *before* the #admins room exists. */
+ private suspend fun ceremonyDevice(publicKey: HexKey, signerIndex: Int): Device =
+ device(
+ publicKey = publicKey,
+ signerIndex = signerIndex,
+ roomId = ceremonyRoomId,
+ // Not describe()d: a NIP-17 room carries no derivation path, so the
+ // path has to come from the ceremony rather than from the room.
+ description = null,
+ ceremonyRoomId = ceremonyRoomId,
+ mlsGroupState = null
+ )
+
+ /** Brings the #admins room into being on a device, as creating or joining it would. */
+ private suspend fun createAdminRoom(device: Device) {
+ device.db.chatRoomDao().upsert(
+ ChatRoom(
+ id = adminRoomId,
+ userPublicKey = device.publicKey,
+ subject = "#admins",
+ description = adminRoomDescription,
+ mlsGroupState = "00"
+ )
+ )
+ }
+
+ @Test
+ fun `a group signs its key state in the room its ceremony ran in`() = runBlocking {
+ val creator = ceremonyDevice(members[0], signerIndex = 0)
+
+ val session = GroupKeyStateManager.propose(
+ database = creator.db,
+ localChatRoom = creator.room,
+ userPublicKey = creator.publicKey,
+ key = ceremonyOn(creator)
+ )
+
+ // The proposal goes out on gift wraps, because that is the only
+ // transport a group has before it has a Marmot room.
+ val queued = outbox(creator)
+ val proposal = queued.firstOrNull { it.kind == FrostSigningEvents.PROPOSAL }
+ assertNotNull(proposal, "the proposal must be queued as a gift wrap")
+ assertTrue(
+ creator.db.marmotInnerEventDao()
+ .getByChatRoomAndKinds(ceremonyRoomId, FrostSigningEvents.ALL.toList())
+ .isEmpty(),
+ "a NIP-17 room has no inner events to put a signing message in"
+ )
+
+ // Addressed per member, the sender excepted -- a gift wrap that names
+ // nobody reaches nobody.
+ val addressed = proposal.tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
+ assertEquals(members.drop(1).toSet(), addressed.toSet())
+
+ // And what it is about is the room that does not exist yet.
+ val payload = Event.fromJson(creator.item(session.id).unsignedEventJson)
+ assertEquals(adminRoomId, payload.pubKey)
+ assertEquals(adminRoomId, GroupKeyStateEvent.parseChatRoomId(payload.tags))
+ assertEquals(SharedKeyDerivation.formatPath(), session.derivationPath)
+ }
+
+ @Test
+ fun `the state is signed before the room it names is created`() = runBlocking {
+ val creator = ceremonyDevice(members[0], signerIndex = 0)
+ val other = ceremonyDevice(members[1], signerIndex = 1)
+
+ val session = GroupKeyStateManager.propose(
+ database = creator.db,
+ localChatRoom = creator.room,
+ userPublicKey = creator.publicKey,
+ key = ceremonyOn(creator)
+ )
+ pump(creator, other)
+
+ FrostSigningManager.approve(other.db, other.room, session.id)
+ pump(creator, other)
+
+ listOf(creator, other).forEach { device ->
+ assertEquals(FrostSigningStage.COMPLETE, device.session(session.id)?.stage)
+
+ // Signed, and by the group -- the author is the id of a room nobody
+ // has made, which is exactly what makes it checkable without one.
+ val signed = FrostSigningManager.signedEvent(device.item(session.id))
+ assertNotNull(signed, "the group should have finished signing its key state")
+ assertEquals(adminRoomId, signed.pubKey)
+ assertTrue(GroupKeyStateEvent.isSignedByRoom(signed, adminRoomId))
+
+ // Nothing filed, because there is no room to file it against. This
+ // is the whole shape of the change: agreement first, room second.
+ assertNull(
+ device.db.groupKeyStateDao().getByChatRoomId(adminRoomId),
+ "a state cannot be filed against a room that does not exist"
+ )
+ }
+ }
+
+ @Test
+ fun `the room is created already knowing what it signs with`() = runBlocking {
+ val creator = ceremonyDevice(members[0], signerIndex = 0)
+ val other = ceremonyDevice(members[1], signerIndex = 1)
+
+ val session = GroupKeyStateManager.propose(
+ database = creator.db,
+ localChatRoom = creator.room,
+ userPublicKey = creator.publicKey,
+ key = ceremonyOn(creator)
+ )
+ pump(creator, other)
+ FrostSigningManager.approve(other.db, other.room, session.id)
+ pump(creator, other)
+
+ // One device creates the room; the other is handed it by a Welcome.
+ // Both file the same state off the event each already held, and neither
+ // is sent anything to do it.
+ listOf(creator, other).forEach { device ->
+ createAdminRoom(device)
+
+ val adopted = GroupKeyStateManager.adopt(device.db, adminRoomId)
+ assertNotNull(adopted, "a room should adopt the state its group signed for it")
+
+ assertEquals(adminRoomId, adopted.chatRoomId)
+ assertEquals(thresholdPublicKey, adopted.thresholdPublicKey)
+ assertEquals(ceremonyId, adopted.dkgSessionId)
+ assertEquals(SharedKeyDerivation.formatPath(), adopted.derivationPath)
+ assertEquals(adminRoomId, adopted.announcedBy)
+ assertTrue(adopted.verifies())
+
+ // On file, not merely returned. Compared field by field because the
+ // store keeps whole seconds and the row in hand does not.
+ val stored = device.db.groupKeyStateDao().getByChatRoomId(adminRoomId)
+ assertNotNull(stored)
+ assertEquals(adopted.chatRoomId, stored.chatRoomId)
+ assertEquals(adopted.thresholdPublicKey, stored.thresholdPublicKey)
+ assertEquals(adopted.dkgSessionId, stored.dkgSessionId)
+ assertEquals(adopted.derivationPath, stored.derivationPath)
+ assertEquals(adopted.announcedAt, stored.announcedAt)
+ }
+ }
+
+ @Test
+ fun `adopting twice settles on the same state`() = runBlocking {
+ val creator = ceremonyDevice(members[0], signerIndex = 0)
+ val other = ceremonyDevice(members[1], signerIndex = 1)
+
+ val session = GroupKeyStateManager.propose(
+ database = creator.db,
+ localChatRoom = creator.room,
+ userPublicKey = creator.publicKey,
+ key = ceremonyOn(creator)
+ )
+ pump(creator, other)
+ FrostSigningManager.approve(other.db, other.room, session.id)
+ pump(creator, other)
+
+ createAdminRoom(creator)
+
+ val first = GroupKeyStateManager.adopt(creator.db, adminRoomId)
+ val second = GroupKeyStateManager.adopt(creator.db, adminRoomId)
+
+ assertNotNull(first)
+ assertNotNull(second)
+ assertEquals(first.thresholdPublicKey, second.thresholdPublicKey)
+ assertEquals(first.dkgSessionId, second.dkgSessionId)
+ assertEquals(first.derivationPath, second.derivationPath)
+ assertEquals(first.announcedAt, second.announcedAt)
+ // The second adopt is a no-op rather than a rewrite: `replace` keeps the
+ // state already on file when nothing newer has been signed. To whole
+ // seconds, which is all the store keeps.
+ assertEquals(first.createdAt.epochSeconds, second.createdAt.epochSeconds)
+ }
+
+ @Test
+ fun `a nonce arriving before the proposal is replayed out of the gift wraps`() = runBlocking {
+ val creator = ceremonyDevice(members[0], signerIndex = 0)
+ val other = ceremonyDevice(members[1], signerIndex = 1)
+ val third = ceremonyDevice(members[2], signerIndex = 2)
+
+ val session = GroupKeyStateManager.propose(
+ database = creator.db,
+ localChatRoom = creator.room,
+ userPublicKey = creator.publicKey,
+ key = ceremonyOn(creator)
+ )
+
+ // `other` gets the proposal and answers it; `third` gets neither yet.
+ pump(creator, other)
+ FrostSigningManager.approve(other.db, other.room, session.id)
+
+ // Now hand `third` everything, worst order first: the nonce before the
+ // proposal that asks for it. A relay delivers a backlog in whatever
+ // order it likes, and a device syncing fresh sees exactly this.
+ val nonce = outbox(other).single { it.kind == FrostSigningEvents.NONCE }
+ third.received.add(nonce.id)
+ third.store(nonce)
+ FrostSigningManager.processSigningPayload(
+ database = third.db,
+ localChatRoom = third.room,
+ innerEvent = nonce.toEvent(),
+ userPublicKey = third.publicKey
+ )
+
+ // Filed nowhere, because there is no session to file it under.
+ assertNull(third.session(session.id))
+
+ pump(creator, other, third)
+
+ // The proposal arrives and the replay goes and finds it -- out of the
+ // gift-wrap payloads, which is the only place a NIP-17 room's backlog
+ // is. Looking in the inner events instead finds nothing and the nonce
+ // is lost.
+ val replayed = third.db.frostSigningSessionDao()
+ .getMessage(session.id, FrostSigningEvents.NONCE, other.publicKey)
+ assertNotNull(replayed, "a nonce that arrived early must be replayed once the proposal lands")
+ assertEquals(nonce.content, replayed.payload)
+ }
+
+ @Test
+ fun `a room nobody signed a state for adopts nothing`() = runBlocking {
+ val creator = ceremonyDevice(members[0], signerIndex = 0)
+
+ // The ceremony happened; the group never got as far as agreeing.
+ createAdminRoom(creator)
+
+ assertNull(GroupKeyStateManager.adopt(creator.db, adminRoomId))
+ }
+
+ @Test
+ fun `a room with no standing to propose a key state refuses to`() = runBlocking {
+ val member = ceremonyDevice(members[0], signerIndex = 0)
+
+ // Another NIP-17 room the same member is in, which is neither the room
+ // the state is about nor the one the ceremony ran in. `signingPath`
+ // would resolve nothing for it, so the group would sign as its bare
+ // threshold key -- an identity no room answers to. Refused before a
+ // single member is asked to sign anything.
+ val bystanderRoom = ChatRoom(
+ id = "bystanderroom".padEnd(64, '0'),
+ userPublicKey = member.publicKey,
+ subject = "some other chat",
+ description = null,
+ mlsGroupState = null
+ )
+ member.db.chatRoomDao().upsert(bystanderRoom)
+
+ assertFailsWith {
+ GroupKeyStateManager.propose(
+ database = member.db,
+ localChatRoom = LocalChatRoom(chatRoom = bystanderRoom),
+ userPublicKey = member.publicKey,
+ key = ceremonyOn(member)
+ )
+ }
+ Unit
+ }
+
// ---- Signing several events in one session -----------------------------
/**
@@ -992,11 +1361,15 @@ class SignedGroupKeyStateTest {
}
@Test
- fun `a room that is not derived from the key signs as the key itself`() = runBlocking {
+ fun `a Marmot room that is not derived from the key signs as the key itself`() = runBlocking {
// The fallback kept for rooms the app no longer makes: the ceremony ran
- // in this very room, so the room's id is a random 32 bytes rather than
- // anything walked to. There is no room key to sign as, so it signs as the
- // group's -- which is what it did before any of this.
+ // in this very Marmot room, so the room's id is a random 32 bytes rather
+ // than anything walked to. There is no room key to sign as, so it signs
+ // as the group's -- which is what it did before any of this.
+ //
+ // A NIP-17 room in the same shape signs as the #admins room the ceremony
+ // will make, which is the case below. The two are told apart by whether
+ // the room has MLS state, and by nothing a proposer can reach.
val undeerived = "d".repeat(64)
val creator = device(
publicKey = members[0],
@@ -1019,4 +1392,37 @@ class SignedGroupKeyStateTest {
assertEquals(emptyList(), session.pathIndices())
assertEquals(rootPublicKey, Event.fromJson(creator.item(session.id).unsignedEventJson).pubKey)
}
+
+ @Test
+ fun `a NIP-17 room that did not host the ceremony cannot sign at all`() = runBlocking {
+ // The other half of the rule that lets a ceremony's room sign as the
+ // room it is about to make. That room signs as the #admins room *its
+ // own* ceremony derives; a NIP-17 room this member merely happens to be
+ // in reaches no key and no path, so it never gets as far as choosing an
+ // identity to sign under.
+ val member = ceremonyDevice(members[0], signerIndex = 0)
+
+ val bystanderRoom = ChatRoom(
+ id = "bystanderroom".padEnd(64, '0'),
+ userPublicKey = member.publicKey,
+ subject = "some other chat",
+ description = null,
+ mlsGroupState = null
+ )
+ member.db.chatRoomDao().upsert(bystanderRoom)
+
+ val template = DialectEvent.build(name = "Sepedi", country = "ZA", language = "nso")
+
+ assertFailsWith {
+ FrostSigningManager.proposeSigning(
+ database = member.db,
+ localChatRoom = LocalChatRoom(chatRoom = bystanderRoom),
+ userPublicKey = member.publicKey,
+ kind = template.kind,
+ tags = template.tags,
+ content = template.content
+ )
+ }
+ Unit
+ }
}
diff --git a/docs/member-chronicle.md b/docs/member-chronicle.md
index 4196a5e5..c2ffcf8c 100644
--- a/docs/member-chronicle.md
+++ b/docs/member-chronicle.md
@@ -306,10 +306,15 @@ keeps the decision about transcript lines next to the decision about triggers.
>
> - **The allowlist now does real work on the way out.** The rebuild could only
> ever produce document kinds; the table holds everything the group has signed,
-> and every room signs a `GroupKeyStateEvent` as its first act. `assemble`
-> filters on `ChronicleEvent.isChroniclable` before anything else -- without it
-> `ChronicleEvent.build` refuses the page and a room's whole chronicle fails on the
-> one event every room has.
+> `GroupKeyStateEvent` included. `assemble` filters on
+> `ChronicleEvent.isChroniclable` before anything else -- without it
+> `ChronicleEvent.build` refuses the page and takes the whole chronicle with it.
+>
+> Written when a room signed its own key state as its first act. It no longer
+> does: the state is agreed before the room exists, in the NIP-17 room the
+> ceremony ran in, and is filed under that room -- see `GroupKeyStateManager`.
+> The filter is therefore a narrower guard than it was and still not a
+> redundant one; see "What only looks like it goes" below.
> - **An artifact whose initial version row is missing now chronicles.** The
> rebuild has to recover the version label from that row and logs and gives up
> without it; on file as an event, the label never left.
@@ -806,11 +811,20 @@ chronicle.
thing standing.** It is not part of the rebuild; it is there *because* of the
record. The rebuild could only ever produce document kinds, so nothing needed
filtering while it was the source. The table holds every kind the group has
-signed, and every room signs a `GroupKeyStateEvent` as its first act -- so
-removing that filter along with the walk turns every room's chronicle into an
-`IllegalArgumentException` from `ChronicleEvent.build`. Two cases in
-`ChronicleAssemblyJvmTest` fail with exactly that if it is dropped, which is the
-guard against removing it by association.
+signed, so removing that filter along with the walk turns a chronicle carrying
+any of them into an `IllegalArgumentException` from `ChronicleEvent.build`. Two
+cases in `ChronicleAssemblyJvmTest` fail with exactly that if it is dropped,
+which is the guard against removing it by association.
+
+What it is really stopping is a member replaying a group-signed statement *about*
+the record as though it were work. `applyPage` refuses the same kinds on the way
+in; the two are a pair, and neither is safe to drop on the strength of the other.
+
+The example used to be a room's own key state, which every room signed as its
+first act. That is no longer where one lives -- a group agrees its key state
+before the room exists and files it under the NIP-17 room its ceremony ran in --
+so the filter now guards a case that has to be reasoned about rather than one
+every room walks into.
**The verify filter in `assemble` stays.** With the rebuild gone it is checking
events that were verified before they were recorded, so it can never fail in