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