fix: let a group actually finish a ChillDKG ritual

Nine defects, one of them enough on its own to stop any ritual from ever getting
past the member who opened it. They are committed together because the fixes
interlock: the coordinator fix rewrites the same guard that derives the
participant count, and the restart-safety rewrite replaces the control flow the
rest of them live in.

## The proposal was dropped by everyone it was sent to

acceptProposal accepted a proposal only from `localChatRoom.chatRoom.userPublicKey`
or from `coordinatorOf(localChatRoom)` -- which returned that same column, so the
two arms of the test were one test. And that column is not the room's creator: it
is the logged-in user, for multi-account support ("Logged in user PublicKey...
should help us have multiple user support"), which is why NostrDao sets it to
`activeKeyPair.pubKey.toHex()` on every room it builds. So the guard read "the
sender must be me", every recipient logged "DKG proposal from non-coordinator"
and dropped it, and the ritual never left the coordinator's device.

The same wrong notion made DkgRitualViewModel.isCoordinator() true on every
device, so every member was shown "Start key ceremony" and could open a competing
ritual.

There is no creator to recover. NIP-17 records none, ChatRoom has no such column,
and initialGiftWrapPayloadId is whichever member's message happened to arrive
first -- not the creator. So the coordinator is now simply whoever opens the
ritual, which is what ChillDKG assumes anyway: the coordinator relays, cannot
learn a secret and cannot bias the key, so being it confers nothing worth
reserving. The UI follows: canStartRitual() replaces isCoordinator(), and the
"Waiting for the group's creator to start it" copy is gone with the notion.

## Devices could disagree on n, which is fatal to a DKG

participantCount came from each device's own `localChatRoom.localParticipants.size`.
ChillDKG has no session-params object to agree on out of band -- every step hashes
the host public keys and the threshold into the session identity -- so two devices
that count n differently do not get a weaker key, they get no key. Local
membership is exactly the thing that drifts between devices.

n now comes from the proposal itself: its p-tags plus its sender, the set every
receiver sees identically. proposeRitual builds the same set the same way
(`memberPublicKeys(room) + userPublicKey`), so both sides count alike even if the
room's own rows have drifted.

Participant rows are also deduplicated by pubkey. Participant is keyed on an
autogenerated id, so upsert can leave the same member in a room twice, which
inflated n and double-p-tagged that member.

## A device killed mid-round stalled the ritual for everyone

advance() branched on DkgSession.stage, and each branch wrote the next stage
before publishing the message that stage stands for. A device that died in
between came back with the stage already advanced, skipped the branch, and never
published its round message -- and a DKG needs every member, so the whole group
waits forever on one that will never speak again.

advance() now asks what is *stored* -- "is my round-1 message out yet?" -- and
takes every step the stored messages allow, in order, stopping at the first one
still waiting on somebody. The stage is demoted to a label for the UI and only
ever moves forward (DkgRitualStage now documents that its declaration order is
the ladder, since the manager compares ordinals). publishOwn broadcasts before it
records, so a crash between the two costs a duplicate broadcast -- which every
receiver folds away on a keyed upsert -- rather than a message the group waits on
forever. The recursion is gone with the stage branching, and participantStep1's
result is reused instead of being recomputed for participantStep2.

## Messages that beat their proposal were thrown away

Gift wraps carry a randomised created_at (TimeUtils.randomWithTwoDays) and relays
hand them back in no particular order, so a round-1 message routinely lands
before the proposal that opens the ritual. Those arrived with no session to file
them under and were dropped, and nothing ever asked for them again.

They were never actually lost: the inbound path upserts every payload it decrypts
before it dispatches on kind. replayStoredMessages reads them back through the
new GiftWrapPayloadDao.getByChatRoomAndKinds as soon as the proposal creates the
session. No schema change -- the payloads were already there.

## Stale copies of the session clobbered each other

Handlers held a DkgSession across several writes and each `copy`d from its own
snapshot, so a later write silently reverted an earlier one -- aggregateRound1
storing cmsg1, then the failure path copying from a session fetched before it.
Every session write now goes through update(), which re-reads the row first, and
fail() with it, so a failed ritual keeps the progress it actually made.

## t = 1 was accepted from the wire

The threshold was taken straight off the proposal's tag. ChillDKG will happily
generate a 1-of-n "threshold" key that any single member can sign with, so the
check has to be ours: a proposal is now refused unless its threshold sits in
ChatRoomType.quorumRange(n), on the proposing side as well as the receiving one.
That also rules out t > n, which would otherwise throw inside ParticipantState1
and fail the session with a local input error.

## Anyone could pass themselves off as the coordinator

cmsg1 and the certificate were stored from whoever sent them. Both come from the
coordinator and only ever once, so a member could stall a ritual by getting a
bogus one in first: the real one would then be ignored as already-set. Both are
now accepted only from session.coordinatorPublicKey.

## Sorting host keys was not case-safe

The participant order is a sort of the host public keys, and it is the *order*
that has to match on every device, not the bytes. Hex from another client could
arrive upper case, parse fine, and sort into a different position -- silently
reordering the participant set and failing the session with no clue why. Both
sort sites now case-fold first.

## Cancelling the sync abandoned the ritual, and told the group to

advance() caught Throwable, which includes CancellationException, so tearing down
a coroutine scope marked the session FAILED and broadcast FAILURE to everyone.
NostrDao already rethrows cancellation for this reason; advance() now does too.

## Also

hostPublicKeys() and orderedPayloads() gated on `size < n` and then used whatever
they had; more host keys than the ritual was opened for now fails loudly instead
of running ChillDKG on a participant set nobody else has. quorumRange() no longer
returns a backwards, empty range for a room below the minimum, which coerceIn
rejects outright, and the ceremony is not offered at all below two members.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 00:13:46 +02:00
parent 739314ccb4
commit 90a28e9321
6 changed files with 441 additions and 258 deletions

View File

@@ -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<GiftWrapPayload?>
/**
* 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<Kind>): List<GiftWrapPayload>
@Upsert
suspend fun upsert(giftWrapPayload: GiftWrapPayload)

View File

@@ -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. */

View File

@@ -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<HexKey> =
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<PublicKey>
) {
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<PublicKey>
) {
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<PublicKey>,
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<PublicKey>,
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
}

View File

@@ -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
)
}

View File

@@ -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<Int> = 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

View File

@@ -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(