diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapPayloadDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapPayloadDao.kt index aaf6aacd..f881170d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapPayloadDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GiftWrapPayloadDao.kt @@ -5,6 +5,7 @@ import androidx.room3.Insert import androidx.room3.Query import androidx.room3.Upsert import press.mantra.compose.database.model.GiftWrapPayload +import com.vitorpamplona.quartz.nip01Core.core.Kind import kotlinx.coroutines.flow.Flow @Dao @@ -15,6 +16,21 @@ interface GiftWrapPayloadDao { @Query("SELECT * FROM GiftWrapPayload WHERE publicKey = :publicKey AND giftWrapSealId IS NULL") fun observeUnsealedGiftWrapPayloads(publicKey: String): Flow + /** + * Every payload of the given kinds this device holds for a room, oldest first. + * + * Lets a handler read back messages it could not act on when they arrived. + * Payloads are stored before the inbound path dispatches on kind, so one that + * turned up too early — a ChillDKG round message ahead of the proposal that + * opens the ritual — is here waiting rather than lost. + */ + @Query( + "SELECT * FROM GiftWrapPayload " + + "WHERE chatRoomId = :chatRoomId AND kind IN (:kinds) " + + "ORDER BY createdAt ASC" + ) + suspend fun getByChatRoomAndKinds(chatRoomId: String, kinds: List): List + @Upsert suspend fun upsert(giftWrapPayload: GiftWrapPayload) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/DkgRitualStage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/DkgRitualStage.kt index 240fdbc8..7b07583a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/DkgRitualStage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/types/DkgRitualStage.kt @@ -5,6 +5,10 @@ package press.mantra.compose.database.model.types * holding the row. Coordinator and participants move through the same ladder; * the coordinator simply has extra work to do at [COLLECTING_HOST_KEYS], * [COLLECTING_ROUND_1] and [COLLECTING_ROUND_2]. + * + * Declaration order is the ladder: `ChillDkgRitualManager` compares ordinals to + * keep the label moving forwards when messages arrive out of order, so the + * collecting stages must stay in the order the ritual runs them. */ enum class DkgRitualStage { /** Proposal seen; waiting on every member's host public key. */ diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index 95dc5f59..b4b9e6c1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -5,6 +5,7 @@ import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.GiftWrapPayload import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.dkg.DkgRitualEvents @@ -24,20 +25,23 @@ import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.crypto.dkg.chill.ChillDKG import fr.acinq.secp256k1.ChilldkgFault +import kotlinx.coroutines.CancellationException import kotlin.time.Clock import kotlin.time.Instant /** * Runs a ChillDKG ritual over a NIP-17 group. * - * The group is the participant set, the room's creator is the coordinator, and - * every protocol message travels as a gift-wrapped rumor on the kinds in - * [DkgRitualEvents] — the same sealing and broadcast pipeline chat messages - * already use, so there is no second transport to operate. + * The group is the participant set, the member who opens the ritual is the + * coordinator, and every protocol message travels as a gift-wrapped rumor on + * the kinds in [DkgRitualEvents] — the same sealing and broadcast pipeline chat + * messages already use, so there is no second transport to operate. * * The coordinator is a participant too. ChillDKG treats the coordinator as - * untrusted (it relays but cannot learn secrets or bias the key), so being the - * room's creator buys it no authority here — only the job of aggregating. + * untrusted (it relays but cannot learn secrets or bias the key), so opening + * the ritual buys it no authority here — only the job of aggregating. That is + * why any member may open one: there is nothing to gain by being the one who + * does, and a NIP-17 room records no creator to privilege. * * ### Why this is driven by arriving messages * @@ -45,6 +49,12 @@ import kotlin.time.Instant * there is no long-lived in-memory session to lose. Each inbound message is * persisted and then the ritual is asked whether it can move; if the app dies * mid-round it picks up exactly where it left off on the next message. + * + * [advance] therefore decides what to do from what is *stored* — "is my round-1 + * message out yet?" — and never from how far [DkgSession.stage] says the ritual + * got. The stage is a label for the UI. Gating the work on it would mean a + * device killed between "record the stage" and "publish the message" never + * publishes at all, and a DKG stalls for everyone when one member stalls. */ object ChillDkgRitualManager { private const val TAG = "ChillDkgRitualManager" @@ -59,6 +69,12 @@ object ChillDkgRitualManager { */ private const val HOST_KEY_DERIVATION_TAG = "mantra/chilldkg/host-key/v1" + /** + * Smallest group a ritual can run in. A shared key held by one person is + * just a key, and [ChatRoomType.quorumRange] has nothing to offer below two. + */ + const val MINIMUM_PARTICIPANTS = 2 + /** * This device's long-term ChillDKG host secret key. * @@ -74,8 +90,29 @@ object ChillDkgRitualManager { ChillDKG.hostPublicKey(deriveHostSecretKey(nostrPrivateKey)) /** - * Opens a ritual. Only the room's creator should call this; every other - * member joins by reacting to the proposal. + * The room's members, one entry per person. + * + * [LocalChatRoom.localParticipants] is a row list, not a set — `Participant` + * is keyed on an autogenerated id, so a room can carry the same member + * twice. The participant count is `n`, which every device has to agree on + * or ChillDKG rejects the session, so it must be counted over people. + */ + fun memberPublicKeys(localChatRoom: LocalChatRoom): Set = + localChatRoom.localParticipants.map { it.participant.participantPublicKey }.toSet() + + /** + * Whether a group of this size can hold a shared key at all. Read by the UI + * so a ritual that could only fail is never offered. + */ + fun canRunRitual(localChatRoom: LocalChatRoom): Boolean = + memberPublicKeys(localChatRoom).size >= MINIMUM_PARTICIPANTS + + /** + * Opens a ritual, making this device the coordinator. + * + * Throws when the group or the threshold cannot support one; the UI checks + * both before offering the button, so reaching either is a bug rather than a + * user error. */ suspend fun proposeRitual( database: MantraDatabase, @@ -84,8 +121,18 @@ object ChillDkgRitualManager { nostrPrivateKey: ByteArray, threshold: Int ): DkgSession { + // Built the way a receiver rebuilds it from the proposal — the p-tags + // `broadcast` writes, plus this device — so both sides count the same `n` + // even if the room's own rows have drifted. + val members = memberPublicKeys(localChatRoom) + userPublicKey + require(members.size >= MINIMUM_PARTICIPANTS) { + "A shared key needs at least $MINIMUM_PARTICIPANTS members, this room has ${members.size}" + } + require(threshold in ChatRoomType.quorumRange(members.size)) { + "Quorum $threshold is outside ${ChatRoomType.quorumRange(members.size)} for ${members.size} members" + } + val sessionId = RandomInstance.bytes(32).toHex() - val participantCount = localChatRoom.localParticipants.size val session = DkgSession( id = sessionId, @@ -93,7 +140,9 @@ object ChillDkgRitualManager { coordinatorPublicKey = userPublicKey, userPublicKey = userPublicKey, threshold = threshold, - participantCount = participantCount, + // The `n` every other device will derive from this proposal's p-tags, + // which `broadcast` assembles from exactly this set. + participantCount = members.size, hostPublicKey = deriveHostPublicKey(nostrPrivateKey).value.toHex(), // Fresh per session. Persisted because participantStep1/2 are pure in // it, which is what makes the whole ritual restart-safe. @@ -102,7 +151,7 @@ object ChillDkgRitualManager { ) database.dkgSessionDao().upsert(session) - logger.i("Proposing DKG ritual $sessionId: ${session.threshold}-of-$participantCount") + logger.i("Proposing DKG ritual $sessionId: $threshold-of-${members.size}") broadcast( database = database, @@ -136,8 +185,8 @@ object ChillDkgRitualManager { return } - val session = when (giftWrapPayload.kind) { - DkgRitualEvents.PROPOSAL -> acceptProposal( + val session = if (giftWrapPayload.kind == DkgRitualEvents.PROPOSAL) { + acceptProposal( database = database, localChatRoom = localChatRoom, giftWrapPayload = giftWrapPayload, @@ -145,13 +194,17 @@ object ChillDkgRitualManager { userPublicKey = userPublicKey, nostrPrivateKey = nostrPrivateKey ) - else -> database.dkgSessionDao().getSessionById(sessionId) + } else { + // Not knowing the session is normal: gift wraps carry a randomised + // created_at and relays hand them back in no particular order, so a + // round-1 message routinely lands before the proposal that opens the + // ritual. The payload is already stored, and [acceptProposal] replays + // it once the proposal turns up. + database.dkgSessionDao().getSessionById(sessionId) } if (session == null) { - // A message from a ritual this device never joined, or one it has - // already torn down. Nothing sane to do with it. - logger.w("No DKG session $sessionId for kind ${giftWrapPayload.kind}; dropping") + logger.i("No DKG session $sessionId for kind ${giftWrapPayload.kind}; leaving it stored") return } @@ -160,29 +213,7 @@ object ChillDkgRitualManager { return } - when (giftWrapPayload.kind) { - DkgRitualEvents.PROPOSAL -> Unit // handled above; host key already published - DkgRitualEvents.HOST_KEY, - DkgRitualEvents.ROUND_1, - DkgRitualEvents.ROUND_2 -> database.dkgSessionDao().upsert( - DkgParticipantMessage( - sessionId = session.id, - participantPublicKey = giftWrapPayload.publicKey, - kind = giftWrapPayload.kind, - payload = giftWrapPayload.content - ) - ) - DkgRitualEvents.COORDINATOR_ROUND_1 -> database.dkgSessionDao().upsert( - session.copy(coordinatorRound1 = giftWrapPayload.content, updatedAt = Clock.System.now()) - ) - DkgRitualEvents.CERTIFICATE -> database.dkgSessionDao().upsert( - session.copy(certificate = giftWrapPayload.content, updatedAt = Clock.System.now()) - ) - DkgRitualEvents.FAILURE -> { - fail(database, session, "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}") - return - } - } + if (!record(database, session, giftWrapPayload)) return advance( database = database, @@ -206,44 +237,177 @@ object ChillDkgRitualManager { ): DkgSession? { database.dkgSessionDao().getSessionById(sessionId)?.let { return it } + // The participant set is the proposal's own p-tags plus its sender, NOT + // this device's view of the room. `n` is hashed into the session identity, + // so two devices that disagree on it cannot complete a ritual together — + // and local membership is exactly the thing that drifts between devices. + val members = giftWrapPayload.participantPTags().map { it.pubKey }.toSet() + + if (userPublicKey !in members) { + logger.w("DKG proposal $sessionId does not include this device; dropping") + return null + } + + if (members.size < MINIMUM_PARTICIPANTS) { + logger.w("DKG proposal $sessionId has only ${members.size} participant(s); dropping") + return null + } + val threshold = DkgRitualEvents.parseThreshold(giftWrapPayload.tags) if (threshold == null) { logger.w("DKG proposal $sessionId carries no threshold; dropping") return null } - // Only the room's creator may open a ritual. Anyone else proposing one is - // trying to run a key generation the group did not ask for. - if (giftWrapPayload.publicKey != localChatRoom.chatRoom.userPublicKey && - giftWrapPayload.publicKey != coordinatorOf(localChatRoom) - ) { - logger.w("DKG proposal $sessionId from non-coordinator ${giftWrapPayload.publicKey}; dropping") + // A ritual is worth joining only at a quorum the group could have agreed + // to. `t = 1` is a threshold key any single member can sign with, and + // ChillDKG will happily generate one — the check has to be here. + if (threshold !in ChatRoomType.quorumRange(members.size)) { + logger.w( + "DKG proposal $sessionId asks for a $threshold-of-${members.size} key, " + + "outside ${ChatRoomType.quorumRange(members.size)}; dropping" + ) return null } val session = DkgSession( id = sessionId, chatRoomId = localChatRoom.chatRoom.id, + // Whoever opens the ritual coordinates it. ChillDKG gives the + // coordinator no power over the outcome, so there is nothing here to + // reserve for a "room creator" — which NIP-17 does not record anyway. coordinatorPublicKey = giftWrapPayload.publicKey, userPublicKey = userPublicKey, threshold = threshold, - participantCount = localChatRoom.localParticipants.size, + participantCount = members.size, hostPublicKey = deriveHostPublicKey(nostrPrivateKey).value.toHex(), round1Random = RandomInstance.bytes(32).toHex(), round2AuxRandom = RandomInstance.bytes(32).toHex() ) database.dkgSessionDao().upsert(session) - logger.i("Joined DKG ritual $sessionId (${threshold}-of-${session.participantCount})") + logger.i("Joined DKG ritual $sessionId ($threshold-of-${members.size})") publishHostKey(database, localChatRoom, session) + replayStoredMessages(database, session) return session } /** - * Takes whatever step the stored messages now allow. Called after every - * inbound message; a no-op until a round is actually complete. + * Feeds in every message of this ritual that arrived before the proposal did. + * + * 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. Without it a + * ritual whose proposal loses the race to its own round-1 messages waits + * forever on messages that already arrived. + */ + private suspend fun replayStoredMessages( + database: MantraDatabase, + session: DkgSession + ) { + val stored = database.giftWrapPayloadDao().getByChatRoomAndKinds( + chatRoomId = session.chatRoomId, + kinds = DkgRitualEvents.ALL.toList() + ).filter { payload -> + payload.kind != DkgRitualEvents.PROPOSAL && + DkgRitualEvents.parseSessionId(payload.tags) == session.id + } + + if (stored.isEmpty()) return + + logger.i("Replaying ${stored.size} stored message(s) for ritual ${session.id}") + + stored.forEach { payload -> + if (!record(database, session, payload)) return + } + } + + /** + * Files one ritual message. Returns false when the message ends the ritual, + * so the caller stops rather than trying to advance a dead session. + * + * The session row is re-read for every write: this is called in a loop during + * a replay, and each caller holding its own stale copy would have them + * clobber each other's columns. + */ + private suspend fun record( + database: MantraDatabase, + session: DkgSession, + giftWrapPayload: GiftWrapPayload + ): Boolean { + when (giftWrapPayload.kind) { + DkgRitualEvents.PROPOSAL -> Unit // handled by acceptProposal; host key already published + + DkgRitualEvents.HOST_KEY, + DkgRitualEvents.ROUND_1, + DkgRitualEvents.ROUND_2 -> database.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = session.id, + participantPublicKey = giftWrapPayload.publicKey, + kind = giftWrapPayload.kind, + payload = giftWrapPayload.content + ) + ) + + // Both aggregates come from the coordinator and only ever once. Taking + // them from anyone else lets any member stall the ritual by getting a + // bogus one in first, since the real one would then be ignored. + DkgRitualEvents.COORDINATOR_ROUND_1 -> { + if (!isFromCoordinator(session, giftWrapPayload)) return true + + update(database, session) { current -> + if (current.coordinatorRound1 == null) { + current.copy(coordinatorRound1 = giftWrapPayload.content) + } else { + current + } + } + } + + DkgRitualEvents.CERTIFICATE -> { + if (!isFromCoordinator(session, giftWrapPayload)) return true + + update(database, session) { current -> + if (current.certificate == null) { + current.copy(certificate = giftWrapPayload.content) + } else { + current + } + } + } + + DkgRitualEvents.FAILURE -> { + fail( + database, + session, + "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}" + ) + return false + } + } + + return true + } + + private fun isFromCoordinator(session: DkgSession, giftWrapPayload: GiftWrapPayload): Boolean { + if (giftWrapPayload.publicKey == session.coordinatorPublicKey) return true + + logger.w( + "Ritual ${session.id}: kind ${giftWrapPayload.kind} from " + + "${giftWrapPayload.publicKey.take(8)}, who is not the coordinator; ignoring" + ) + return false + } + + /** + * Takes every step the stored messages now allow, in order, stopping at the + * first one still waiting on somebody. + * + * Each step asks whether its output already exists rather than whether the + * stage says it has run, so re-entering after a crash — or after the same + * message is delivered twice — repeats no work and skips none. */ private suspend fun advance( database: MantraDatabase, @@ -251,71 +415,102 @@ object ChillDkgRitualManager { sessionId: String, nostrPrivateKey: ByteArray ) { - val session = database.dkgSessionDao().getSessionById(sessionId) ?: return + var session = database.dkgSessionDao().getSessionById(sessionId) ?: return if (session.stage == DkgRitualStage.COMPLETE || session.stage == DkgRitualStage.FAILED) return val hostSeckey = deriveHostSecretKey(nostrPrivateKey) try { + // Waiting on host keys: the participant set isn't settled, so there is + // nothing this device can compute yet. val hostPublicKeys = hostPublicKeys(database, session) ?: return - when (session.stage) { - DkgRitualStage.COLLECTING_HOST_KEYS -> { - // Every host key is in, so the participant set is settled and - // round 1 can be computed. Publish ours and move on. - val pmsg1 = ChillDKG.participantStep1( - hostSecretKey = hostSeckey, - hostPublicKeys = hostPublicKeys, - threshold = session.threshold, - random = ByteVector32(session.round1Random) - ).message - val moved = session.copy( - stage = DkgRitualStage.COLLECTING_ROUND_1, - updatedAt = Clock.System.now() - ) - database.dkgSessionDao().upsert(moved) - publishOwn(database, localChatRoom, moved, DkgRitualEvents.ROUND_1, pmsg1.toHex()) + val step1 = ChillDKG.participantStep1( + hostSecretKey = hostSeckey, + hostPublicKeys = hostPublicKeys, + threshold = session.threshold, + random = ByteVector32(session.round1Random) + ) - advance(database, localChatRoom, sessionId, nostrPrivateKey) - } - - DkgRitualStage.COLLECTING_ROUND_1 -> { - if (session.isCoordinator()) aggregateRound1(database, localChatRoom, session, hostPublicKeys) - - val coordinatorRound1 = database.dkgSessionDao() - .getSessionById(sessionId)?.coordinatorRound1 ?: return - - val step2 = ChillDKG.participantStep2( - hostSecretKey = hostSeckey, - state = participantState1(hostSeckey, hostPublicKeys, session), - coordinatorMessage = ByteVector(coordinatorRound1.hexToByteArray()), - auxRand = ByteVector32(session.round2AuxRandom) - ) - step2.fault.raiseIfFaulty("round 2") - val pmsg2 = step2.certEqSignature - - val moved = session.copy( - stage = DkgRitualStage.COLLECTING_ROUND_2, - coordinatorRound1 = coordinatorRound1, - updatedAt = Clock.System.now() - ) - database.dkgSessionDao().upsert(moved) - publishOwn(database, localChatRoom, moved, DkgRitualEvents.ROUND_2, pmsg2.toHex()) - - advance(database, localChatRoom, sessionId, nostrPrivateKey) - } - - DkgRitualStage.COLLECTING_ROUND_2 -> { - if (session.isCoordinator()) aggregateRound2(database, localChatRoom, session, hostPublicKeys) - - val certificate = database.dkgSessionDao() - .getSessionById(sessionId)?.certificate ?: return - - finalize(database, session, hostPublicKeys, hostSeckey, certificate) - } - - DkgRitualStage.COMPLETE, DkgRitualStage.FAILED -> Unit + if (ownMessage(database, session, DkgRitualEvents.ROUND_1) == null) { + publishOwn(database, localChatRoom, session, DkgRitualEvents.ROUND_1, step1.message.toHex()) } + session = moveTo(database, session, DkgRitualStage.COLLECTING_ROUND_1) + + if (session.isCoordinator() && session.coordinatorRound1 == null) { + val pmsgs1 = orderedPayloads(database, session, DkgRitualEvents.ROUND_1) ?: return + val cmsg1 = coordinatorStep1(pmsgs1, hostPublicKeys, session).message.toHex() + + session = update(database, session) { it.copy(coordinatorRound1 = cmsg1) } + broadcast(database, localChatRoom, session, DkgRitualEvents.COORDINATOR_ROUND_1, cmsg1) + } + + val coordinatorRound1 = session.coordinatorRound1 ?: return + + val step2 = ChillDKG.participantStep2( + hostSecretKey = hostSeckey, + state = step1.state, + coordinatorMessage = ByteVector(coordinatorRound1.hexToByteArray()), + auxRand = ByteVector32(session.round2AuxRandom) + ) + step2.fault.raiseIfFaulty("round 2") + + if (ownMessage(database, session, DkgRitualEvents.ROUND_2) == null) { + publishOwn( + database, + localChatRoom, + session, + DkgRitualEvents.ROUND_2, + step2.certEqSignature.toHex() + ) + } + session = moveTo(database, session, DkgRitualStage.COLLECTING_ROUND_2) + + if (session.isCoordinator() && session.certificate == null) { + val pmsgs2 = orderedPayloads(database, session, DkgRitualEvents.ROUND_2) ?: return + + // Rebuild the coordinator state from the round-1 messages rather + // than holding it across rounds — same inputs, same state, and it + // survives a restart between the two aggregations. + val pmsgs1 = orderedPayloads(database, session, DkgRitualEvents.ROUND_1) ?: return + val certified = ChillDKG.coordinatorFinalize( + state = coordinatorStep1(pmsgs1, hostPublicKeys, session).state, + certEqSignatures = pmsgs2.map { ByteVector64(it) }, + threshold = session.threshold + ) + certified.fault.raiseIfFaulty("certificate") + val cmsg2 = certified.certificate.toHex() + + session = update(database, session) { it.copy(certificate = cmsg2) } + broadcast(database, localChatRoom, session, DkgRitualEvents.CERTIFICATE, cmsg2) + } + + val certificate = session.certificate ?: return + + // The payoff: verify the certificate and keep the share. + val output = ChillDKG.participantFinalize( + state = step2.state, + certificate = ByteVector(certificate.hexToByteArray()), + nParticipants = hostPublicKeys.size, + threshold = session.threshold + ) + output.fault.raiseIfFaulty("finalize") + + update(database, session) { + it.copy( + stage = DkgRitualStage.COMPLETE, + thresholdPublicKey = output.thresholdPublicKey?.value?.toHex(), + secretShare = output.secretShare?.value?.toHex(), + recoveryData = output.recovery?.toHex() + ) + } + + logger.i("DKG ritual $sessionId complete") + } catch (e: CancellationException) { + // The sync was torn down mid-step, which says nothing about the ritual. + // Killing the session here would abandon it — and tell the whole group + // to abandon it — because this device closed a coroutine scope. + throw e } catch (e: Throwable) { // The session is over for this device. Either a protocol fault came // back from a ChillDKG step -- [raiseIfFaulty] turns those into @@ -334,112 +529,6 @@ object ChillDkgRitualManager { } } - /** Coordinator: fold every round-1 message into `CoordinatorMsg1` and publish it. */ - private suspend fun aggregateRound1( - database: MantraDatabase, - localChatRoom: LocalChatRoom, - session: DkgSession, - hostPublicKeys: List - ) { - if (session.coordinatorRound1 != null) return - - val pmsgs1 = orderedPayloads(database, session, DkgRitualEvents.ROUND_1) ?: return - - val step1 = coordinatorStep1(pmsgs1, hostPublicKeys, session) - val cmsg1 = step1.message - - database.dkgSessionDao().upsert( - session.copy(coordinatorRound1 = cmsg1.toHex(), updatedAt = Clock.System.now()) - ) - broadcast( - database = database, - localChatRoom = localChatRoom, - session = session, - kind = DkgRitualEvents.COORDINATOR_ROUND_1, - content = cmsg1.toHex() - ) - } - - /** Coordinator: fold every round-2 signature into the certificate and publish it. */ - private suspend fun aggregateRound2( - database: MantraDatabase, - localChatRoom: LocalChatRoom, - session: DkgSession, - hostPublicKeys: List - ) { - if (session.certificate != null) return - - val cmsg1 = session.coordinatorRound1 ?: return - val pmsgs2 = orderedPayloads(database, session, DkgRitualEvents.ROUND_2) ?: return - - // Rebuild the coordinator state from the round-1 messages rather than - // holding it across rounds — same inputs, same state, and it survives a - // restart between the two aggregations. - val pmsgs1 = orderedPayloads(database, session, DkgRitualEvents.ROUND_1) ?: return - val coordinatorState = coordinatorStep1(pmsgs1, hostPublicKeys, session).state - - val certified = ChillDKG.coordinatorFinalize( - state = coordinatorState, - certEqSignatures = pmsgs2.map { ByteVector64(it) }, - threshold = session.threshold - ) - certified.fault.raiseIfFaulty("certificate") - val cmsg2 = certified.certificate - - database.dkgSessionDao().upsert( - session.copy( - coordinatorRound1 = cmsg1, - certificate = cmsg2.toHex(), - updatedAt = Clock.System.now() - ) - ) - broadcast( - database = database, - localChatRoom = localChatRoom, - session = session, - kind = DkgRitualEvents.CERTIFICATE, - content = cmsg2.toHex() - ) - } - - /** Verify the certificate and store the share. This is the ritual's payoff. */ - private suspend fun finalize( - database: MantraDatabase, - session: DkgSession, - hostPublicKeys: List, - hostSeckey: PrivateKey, - certificate: HexKey - ) { - val step2 = ChillDKG.participantStep2( - hostSecretKey = hostSeckey, - state = participantState1(hostSeckey, hostPublicKeys, session), - coordinatorMessage = ByteVector((session.coordinatorRound1 ?: return).hexToByteArray()), - auxRand = ByteVector32(session.round2AuxRandom) - ) - step2.fault.raiseIfFaulty("round 2") - - val output = ChillDKG.participantFinalize( - state = step2.state, - certificate = ByteVector(certificate.hexToByteArray()), - nParticipants = session.participantCount, - threshold = session.threshold - ) - output.fault.raiseIfFaulty("finalize") - - logger.i("DKG ritual ${session.id} complete") - - database.dkgSessionDao().upsert( - session.copy( - stage = DkgRitualStage.COMPLETE, - certificate = certificate, - thresholdPublicKey = output.thresholdPublicKey?.value?.toHex(), - secretShare = output.secretShare?.value?.toHex(), - recoveryData = output.recovery?.toHex(), - updatedAt = Clock.System.now() - ) - ) - } - /** * The agreed participant set, or null while host keys are still outstanding. * Ordering is a bytewise sort of the host public keys: ChillDKG fails outright @@ -460,27 +549,21 @@ object ChillDkgRitualManager { return null } - return hostKeys.map { it.payload }.sorted().map { PublicKey(ByteVector(it.hexToByteArray())) } - } + // More host keys than the ritual was opened for means someone is running a + // different participant set than the proposal named. Every device would + // derive a different session identity from here on, so there is no key to + // be had — better to say so than to grind to a halt inside ChillDKG. + check(hostKeys.size == session.participantCount) { + "${hostKeys.size} participants joined a ritual opened for ${session.participantCount}" + } - /** - * This device's round-1 state, recomputed rather than stored. - * - * [ChillDKG.ParticipantState1] is serializable, so this could be persisted — - * but it is a pure function of the host key, the participant set and - * [DkgSession.round1Random], all of which are already stored, so recomputing - * keeps the ritual restart-safe without adding protocol state to the schema. - */ - private fun participantState1( - hostSeckey: PrivateKey, - hostPublicKeys: List, - session: DkgSession - ) = ChillDKG.participantStep1( - hostSecretKey = hostSeckey, - hostPublicKeys = hostPublicKeys, - threshold = session.threshold, - random = ByteVector32(session.round1Random) - ).state + // Case-folded before sorting: hex from another client could arrive upper + // case, and it is the *order* that has to match on every device, not the + // bytes — a stray capital would silently reorder the participant set. + return hostKeys.map { it.payload.lowercase() } + .sorted() + .map { PublicKey(ByteVector(it.hexToByteArray())) } + } /** * The coordinator's round-1 aggregation. Called twice — once to publish @@ -549,7 +632,9 @@ object ChillDkgRitualManager { // participant order. A message from someone who never published a host key // cannot be placed, and a short list would be rejected by ChillDKG anyway. val orderable = messages.mapNotNull { message -> - hostKeyByParticipant[message.participantPublicKey]?.let { hostKey -> hostKey to message.payload } + hostKeyByParticipant[message.participantPublicKey]?.let { hostKey -> + hostKey.lowercase() to message.payload + } } if (orderable.size < session.participantCount) { logger.d("Ritual ${session.id}: ${orderable.size}/${session.participantCount} of kind $kind are placeable") @@ -561,14 +646,59 @@ object ChillDkgRitualManager { .map { (_, payload) -> payload.hexToByteArray() } } + /** This device's own message of [kind], if it has published one. */ + private suspend fun ownMessage( + database: MantraDatabase, + session: DkgSession, + kind: Kind + ): DkgParticipantMessage? = database.dkgSessionDao().getMessage( + sessionId = session.id, + kind = kind, + participantPublicKey = session.userPublicKey + ) + + /** + * Applies [transform] to the *stored* session and returns what was written. + * + * Callers hold a session across several steps, each of which may write; going + * back to the row means a later write cannot silently undo an earlier one by + * copying from a stale snapshot. + */ + private suspend fun update( + database: MantraDatabase, + session: DkgSession, + transform: (DkgSession) -> DkgSession + ): DkgSession { + val current = database.dkgSessionDao().getSessionById(session.id) ?: session + val updated = transform(current) + + if (updated == current) return current + + val stamped = updated.copy(updatedAt = Clock.System.now()) + database.dkgSessionDao().upsert(stamped) + + return stamped + } + + /** + * Moves the progress label forward, never back. Stages are declared in the + * order the ritual runs them (see [DkgRitualStage]), so a message arriving + * out of order cannot walk the UI back down the ladder. + */ + private suspend fun moveTo( + database: MantraDatabase, + session: DkgSession, + stage: DkgRitualStage + ): DkgSession = update(database, session) { current -> + val settled = current.stage == DkgRitualStage.COMPLETE || current.stage == DkgRitualStage.FAILED + + if (settled || current.stage.ordinal >= stage.ordinal) current else current.copy(stage = stage) + } + private suspend fun fail(database: MantraDatabase, session: DkgSession, reason: String) { - database.dkgSessionDao().upsert( - session.copy( - stage = DkgRitualStage.FAILED, - failureReason = reason, - updatedAt = Clock.System.now() - ) - ) + update(database, session) { + it.copy(stage = DkgRitualStage.FAILED, failureReason = reason) + } } private suspend fun publishHostKey( @@ -581,6 +711,11 @@ object ChillDkgRitualManager { * Broadcasts one of this device's own protocol messages AND records it * locally. The local copy matters: the coordinator is a participant too, and * its own message has to be in the aggregation alongside everyone else's. + * + * Queued before it is recorded, because [advance] republishes any message it + * has no local copy of. A crash between the two therefore costs a duplicate + * broadcast — which every receiver folds away — rather than a message the + * group waits on forever. */ private suspend fun publishOwn( database: MantraDatabase, @@ -589,6 +724,8 @@ object ChillDkgRitualManager { kind: Kind, content: String ) { + broadcast(database, localChatRoom, session, kind, content) + database.dkgSessionDao().upsert( DkgParticipantMessage( sessionId = session.id, @@ -597,7 +734,6 @@ object ChillDkgRitualManager { payload = content ) ) - broadcast(database, localChatRoom, session, kind, content) } /** @@ -613,7 +749,10 @@ object ChillDkgRitualManager { content: String, includeThreshold: Boolean = false ) { + // One p-tag per member, and the sender is implied rather than tagged — + // together they are the participant set every receiver derives `n` from. val receiverTags = localChatRoom.localParticipants + .distinctBy { it.participant.participantPublicKey } .filter { it.participant.participantPublicKey != session.userPublicKey } .map { localParticipant -> PTag.assemble( @@ -648,7 +787,4 @@ object ChillDkgRitualManager { ) ) } - - /** The room's creator, which is the ritual's coordinator by construction. */ - fun coordinatorOf(localChatRoom: LocalChatRoom): HexKey = localChatRoom.chatRoom.userPublicKey } 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 8ab5a86b..ec209ce5 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 @@ -46,6 +46,7 @@ import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.DkgRepository import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -100,7 +101,7 @@ fun DkgRitualScreen( is DkgRitualUIState.Loaded -> { val session = dkgRitualUIState.session val participantCount = dkgRitualUIState.participantCount - val isCoordinator = dkgRitualViewModel.isCoordinator() + val canStartRitual = dkgRitualViewModel.canStartRitual() val isActionPending = dkgRitualViewModel.isActionPending.value Scaffold( @@ -116,9 +117,9 @@ fun DkgRitualScreen( ) }, bottomBar = { - // Only the coordinator can open a ritual, and only when there - // isn't one already running. - if (isCoordinator && (session == null || session.stage == DkgRitualStage.FAILED)) { + // Any member can open a ritual — the coordinator has no say in + // the key — but only when there isn't one already running. + if (canStartRitual) { BottomAppBar( floatingActionButton = { ExtendedFloatingActionButton( @@ -165,7 +166,7 @@ fun DkgRitualScreen( style = MaterialTheme.typography.bodyMedium ) - if (isCoordinator) { + if (canStartRitual) { QuorumStepper( threshold = dkgRitualViewModel.threshold.value, participantCount = participantCount, @@ -174,7 +175,8 @@ fun DkgRitualScreen( ) } else { Text( - text = "Waiting for the group's creator to start it.", + text = "A shared key needs at least " + + "${ChillDkgRitualManager.MINIMUM_PARTICIPANTS} members to split it between.", style = MaterialTheme.typography.labelLarge ) } 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 e929a12a..7c9cd771 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 @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.database.model.types.ChatRoomType import press.mantra.compose.managers.ChillDkgRitualManager import press.mantra.compose.nostr.dkg.DkgRitualEvents @@ -26,7 +27,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch /** - * Watches a group's ChillDKG ritual and, for the coordinator, starts one. + * Watches a group's ChillDKG ritual, and opens one on request. * * Nothing here drives the protocol — the ritual advances in * [ChillDkgRitualManager] as messages arrive on the inbound path, so this only @@ -61,16 +62,30 @@ class DkgRitualViewModel( */ val threshold: MutableState = mutableStateOf(ChatRoomType.MINIMUM_QUORUM) - fun isCoordinator(): Boolean { + /** + * Whether this device can open a ceremony right now. + * + * Any member may: ChillDKG gives the coordinator no say in the outcome, and a + * NIP-17 room records no creator to reserve the job for. What rules it out is + * a ceremony already running, or a group too small to split a key across. + */ + fun canStartRitual(): Boolean { val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return false - return ChillDkgRitualManager.coordinatorOf(loaded.localChatRoom) == activeUserPublicKey + if (loaded.session != null && loaded.session.stage != DkgRitualStage.FAILED) return false + + return ChillDkgRitualManager.canRunRitual(loaded.localChatRoom) } fun quorumRange(): IntRange { - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return ChatRoomType.quorumRange(ChatRoomType.MINIMUM_QUORUM) + val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded + ?: return ChatRoomType.quorumRange(ChatRoomType.MINIMUM_QUORUM) - return ChatRoomType.quorumRange(loaded.participantCount) + // Below the minimum the range runs backwards and is empty, which `coerceIn` + // rejects outright. There is no ritual to run at that size either. + return ChatRoomType.quorumRange( + maxOf(loaded.participantCount, ChatRoomType.MINIMUM_QUORUM) + ) } fun setThreshold(value: Int) { @@ -88,7 +103,9 @@ class DkgRitualViewModel( } dkgRitualUIState = DkgRitualUIState.Loaded(localChatRoom = localChatRoom) - threshold.value = ChatRoomType.defaultQuorum(localChatRoom.localParticipants.size) + threshold.value = ChatRoomType + .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size) + .coerceIn(quorumRange()) dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { session -> val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded) @@ -116,7 +133,7 @@ class DkgRitualViewModel( } } - /** Coordinator only: opens the ritual and publishes the proposal. */ + /** Opens a ritual, making this device its coordinator, and publishes the proposal. */ fun startRitual() { if (isActionPending.value) return 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 d84622b9..b5f2089d 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 @@ -2,6 +2,7 @@ package press.mantra.compose.ui.view.state import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.ChillDkgRitualManager sealed interface DkgRitualUIState { data class Loaded( @@ -13,7 +14,14 @@ sealed interface DkgRitualUIState { val round1Count: Int = 0, val round2Count: Int = 0, ): DkgRitualUIState { - val participantCount: Int get() = session?.participantCount ?: localChatRoom.localParticipants.size + /** + * The `n` of the t-of-n. Counted over people, not participant rows — a room + * can hold the same member twice — and taken from the session once one + * exists, because that is the count every device agreed to run the ritual on. + */ + val participantCount: Int + get() = session?.participantCount + ?: ChillDkgRitualManager.memberPublicKeys(localChatRoom).size } data class Error(