feat(subgroups): put the key ceremony on both transports, p-tags and all
`ChillDkgRitualManager` ran on gift wraps only. It now runs on whichever transport the room it is in has -- a gift-wrapped rumor in a NIP-17 room, an MLS application message in a Marmot one -- which is the shape `FrostSigningManager` has had since `113eda9f`, and for the same underlying reason: a group has to say something before it owns the room the saying is about. Nothing is wired to the new arm yet. A group's own ceremony still runs in its NIP-17 room and a subgroup's still runs in the sibling room derived from its admins; what changes here is that the manager stops assuming which. **`broadcast` reads the room and writes to the matching store.** MLS state present is what marks a room Marmot -- the same reading `sendChatMessage` makes when it chooses between a group event and gift wraps. `replayStoredMessages` has to make that reading again, because a ceremony's backlog is in whichever store its messages were queued in and looking in the wrong one is a stall with nothing to blame it on. **The p-tags stay on both, unlike `FrostSigningManager`'s, and this is the one thing here that would be a disaster to get wrong.** There a p-tag is an address and a group event needs none, because the message is encrypted to the whole tree and the signer set comes from the ceremony's host keys either way. Here the p-tag set *is* the participant set: `acceptProposal` builds `n` out of it on every device and `n` is hashed into the session identity, so a Marmot proposal without them leaves every receiver unable to say what they were invited to. In a room whose membership is wider than the ceremony they are also the only thing marking who is in it, and a member who publishes a host key is in that group's signing quorum for good. Inside MLS encryption, naming them leaks nothing. They are also read off the *session* rather than off the room now, via `participantsOf`, and sorted. A room whose membership is wider than the ceremony is exactly the case this is for. **`processRitualPayload` takes an `Event`.** The rumor as its sender wrote it, which is the shape both transports hand over -- `NostrDao` already rebuilt one from a decrypted gift wrap for the FROST arm and now does the same here. `sig` is empty on both and nothing reads it: what vouches for the author is the seal or the MLS frame, not a signature on the payload. `record` and `isFromCoordinator` follow. **`proposeRitual` grows two parameters and splits its guard.** `participantPublicKeys` defaults to the room's members, which is the whole story where the room is the participant set; `subject` defaults to the room's name, but only for a ceremony with no parent -- falling back there would name a child after its parent. The guard now asks two different questions. A ceremony with no parent is scoped by room as before. One with a parent is matched on `(room, parent, admins)` and folds only into a ceremony that is still *running*: a completed one means that subgroup was made, and a group is entitled to a second run by the same people. That was impossible while the ceremony room was derived from its admins -- asking again handed back the first ceremony's key, so the "new" subgroup was the old one under a new name. A new `require`, because the old invariant stops holding for free: everyone in a ceremony has to be able to hear it. A gift wrap is sealed per p-tag so this was true by construction; a group event reaches the MLS tree and nobody else, so a participant outside it is an `n` that can never be met. Placed behind the duplicate guard, so a running ceremony is still handed back whatever the room's rows have since done. **`NostrDao` dispatches the DKG kinds on the Marmot arm** beside the FROST ones it already did, and `ChatMessage.applyInnerEvent` returns null for them the way it does for `FrostSigningEvents.ALL` -- the manager writes the transcript of a ceremony itself, and an "unsupported" row would be a second, worse account of it. A member of the room who was not p-tagged never reaches either: their `acceptProposal` drops the proposal and no session is opened. **A ceremony with a parent says so in the transcript.** The line lands in a room that already has a key, and "started a shared key ceremony. It will take 2 of 3 members to sign with the key" reads there as though *this* group were getting a new one. It is not -- the quorum quoted is the child's, over the child's admins -- and that is the sort of misreading that gets somebody to approve a step they did not follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -878,7 +878,22 @@ abstract class NostrDao(
|
||||
ChillDkgRitualManager.processRitualPayload(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
giftWrapPayload = decryptedGiftWrapPayload,
|
||||
// The rumor as its sender wrote it, the
|
||||
// shape a Marmot inner event arrives in
|
||||
// too -- the manager reads one protocol
|
||||
// off both transports. `sig` is empty
|
||||
// because a gift-wrapped rumor carries
|
||||
// none; the seal is what vouches for the
|
||||
// author.
|
||||
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(),
|
||||
nostrPrivateKey = activeKeyPair.privKey!!
|
||||
)
|
||||
@@ -1320,12 +1335,20 @@ abstract class NostrDao(
|
||||
persistMarmotChatMessage(chatMessage)
|
||||
}
|
||||
|
||||
// A FROST signing message for this group. Driven from
|
||||
// here rather than from ChatMessage because the
|
||||
// A signing or ceremony message for this group. Driven
|
||||
// from here rather than from ChatMessage because each
|
||||
// manager needs the room to publish its own replies
|
||||
// into, and because it writes its transcript lines
|
||||
// itself. The manager is idempotent, so a redelivered
|
||||
// message re-runs a step it has already taken.
|
||||
// into, and because both write their transcript lines
|
||||
// themselves. Both are idempotent, so a redelivered
|
||||
// message re-runs a step already taken.
|
||||
//
|
||||
// A ceremony arrives here when the group is standing up
|
||||
// a subgroup: the child's ChillDKG runs in this room,
|
||||
// over the admins the proposal p-tags, because the
|
||||
// child has no room of its own until the ceremony has
|
||||
// produced the key its id is derived from. A member of
|
||||
// this room who was not picked drops the proposal in
|
||||
// `acceptProposal` and never opens a session.
|
||||
//
|
||||
// A GroupKeyStateEvent is deliberately not dispatched
|
||||
// here. It is no longer something a member says --
|
||||
@@ -1334,14 +1357,25 @@ abstract class NostrDao(
|
||||
// every other event the group has signed.
|
||||
if (groupEventResult is GroupEventResult.ApplicationMessage) {
|
||||
Event.fromJsonOrNull(groupEventResult.innerEventJson)
|
||||
?.takeIf { FrostSigningEvents.isFrostSigningKind(it.kind) }
|
||||
?.let { innerEvent ->
|
||||
FrostSigningManager.processSigningPayload(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
innerEvent = innerEvent,
|
||||
userPublicKey = activeKeyPair.pubKey.toHex()
|
||||
)
|
||||
when {
|
||||
FrostSigningEvents.isFrostSigningKind(innerEvent.kind) ->
|
||||
FrostSigningManager.processSigningPayload(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
innerEvent = innerEvent,
|
||||
userPublicKey = activeKeyPair.pubKey.toHex()
|
||||
)
|
||||
|
||||
DkgRitualEvents.isDkgRitualKind(innerEvent.kind) ->
|
||||
ChillDkgRitualManager.processRitualPayload(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
innerEvent = innerEvent,
|
||||
userPublicKey = activeKeyPair.pubKey.toHex(),
|
||||
nostrPrivateKey = activeKeyPair.privKey!!
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
@@ -1260,6 +1261,21 @@ data class ChatMessage(
|
||||
// account of the same thing.
|
||||
in FrostSigningEvents.ALL -> null
|
||||
|
||||
// A key ceremony's own protocol messages, for the same reason.
|
||||
// These reach a Marmot room when the group is standing up a
|
||||
// subgroup: the child's ChillDKG runs in the parent's room,
|
||||
// because the child has no room until the ceremony has made the
|
||||
// key its id is derived from. `ChillDkgRitualManager` applies
|
||||
// them and writes the ladder of lines naming who joined, who
|
||||
// contributed and who confirmed.
|
||||
//
|
||||
// Null for every member of the room, including the ones the
|
||||
// ceremony is not with. They were never in the participant set
|
||||
// and their device drops the proposal, so the alternative to no
|
||||
// line at all is an "unsupported" one for a ceremony that is
|
||||
// none of their business.
|
||||
in DkgRitualEvents.ALL -> null
|
||||
|
||||
// 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
|
||||
|
||||
@@ -5,6 +5,7 @@ import press.mantra.compose.database.model.ChatMessage
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.GiftWrapPayload
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.ChatRoomType
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
@@ -12,12 +13,14 @@ import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
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 com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import fr.acinq.bitcoin.ByteVector
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
@@ -32,18 +35,38 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Runs a ChillDKG ritual over a NIP-17 group.
|
||||
* Runs a ChillDKG ritual in a group's room, on whichever transport that room has.
|
||||
*
|
||||
* 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 participant set is who the proposal p-tags, the member who opens the ritual
|
||||
* is the coordinator, and every protocol message travels on the kinds in
|
||||
* [DkgRitualEvents] — as a gift-wrapped rumor in a NIP-17 room and as an MLS
|
||||
* application message in a Marmot one, which are the two pipelines chat messages
|
||||
* already use, so there is no second transport to operate.
|
||||
*
|
||||
* ### Two transports, because a subgroup's ceremony is held by its parent
|
||||
*
|
||||
* A group's own ceremony runs in the NIP-17 room its members share: there is no
|
||||
* MLS tree yet, and cannot be, since the room the key will make is derived from
|
||||
* the key the ceremony is about to produce.
|
||||
*
|
||||
* A *subgroup's* ceremony runs in the **parent's** Marmot room. The parent
|
||||
* already exists, already holds exactly the people a subgroup can be drawn from,
|
||||
* and already signs the child's birth certificate there — so the alternative was
|
||||
* a sibling NIP-17 room whose id was an aggregate of the child's admins, which
|
||||
* meant one admin set could hold one subgroup ever. [broadcast] is the only place
|
||||
* that knows the difference; everything above it is the same protocol either way.
|
||||
*
|
||||
* The p-tags stay on **both**, unlike `FrostSigningManager`'s. There they are an
|
||||
* address list and a Marmot message needs none; here the p-tag set *is* the
|
||||
* participant set every device derives `n` from, and in the parent's room that is
|
||||
* a subset of who can read the message. Inside MLS encryption, naming them leaks
|
||||
* nothing.
|
||||
*
|
||||
* The coordinator is a participant too. ChillDKG treats the coordinator as
|
||||
* 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.
|
||||
* does.
|
||||
*
|
||||
* ### Why this is driven by arriving messages
|
||||
*
|
||||
@@ -109,9 +132,38 @@ object ChillDkgRitualManager {
|
||||
fun canRunRitual(localChatRoom: LocalChatRoom): Boolean =
|
||||
memberPublicKeys(localChatRoom).size >= MINIMUM_PARTICIPANTS
|
||||
|
||||
/**
|
||||
* Everyone a ceremony is being run with — which is not the same question as
|
||||
* who is in the room it runs in.
|
||||
*
|
||||
* A subgroup's ceremony is held in the parent's room over a subset of the
|
||||
* parent's members, so the room's roster names people the ceremony has
|
||||
* nothing to do with. [DkgSession.participantPublicKeys] is the set the
|
||||
* ceremony was opened on, taken from the proposal's p-tags on every device
|
||||
* that did not open it.
|
||||
*
|
||||
* The room is the fallback, and only for rows written before that column
|
||||
* existed: every ceremony then ran in a room whose members were exactly its
|
||||
* participants, so reading the room gave the same answer.
|
||||
*/
|
||||
fun participantsOf(session: DkgSession, localChatRoom: LocalChatRoom): Set<HexKey> =
|
||||
session.participantPublicKeySet() ?: memberPublicKeys(localChatRoom)
|
||||
|
||||
/**
|
||||
* Opens a ritual, making this device the coordinator. A room already running
|
||||
* one gets that one back rather than a second.
|
||||
* one for the same purpose gets that one back rather than a second.
|
||||
*
|
||||
* [participantPublicKeys] is who the ceremony is with, and defaults to the
|
||||
* room's members -- which is the whole story for a group's own ceremony,
|
||||
* where the room *is* the participant set. A subgroup's ceremony runs in the
|
||||
* parent's room over a subset of it, so that caller names the set, and this
|
||||
* device is added to it either way: opening a ceremony is being in it.
|
||||
*
|
||||
* [subject] is what the room this ceremony's key derives will be called. A
|
||||
* group's own ceremony takes the name of the room it runs in, since the room
|
||||
* it makes administers that group; a subgroup's is named by the coordinator,
|
||||
* and is carried to the other admins on the proposal because there is no
|
||||
* longer a room of its own for them to read it off.
|
||||
*
|
||||
* 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
|
||||
@@ -123,8 +175,15 @@ object ChillDkgRitualManager {
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey? = null
|
||||
parentChatRoomId: HexKey? = null,
|
||||
participantPublicKeys: Set<HexKey>? = null,
|
||||
subject: String? = null
|
||||
): 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 = (participantPublicKeys ?: memberPublicKeys(localChatRoom)) + userPublicKey
|
||||
|
||||
// A room runs one ceremony at a time *for one purpose*, and this is
|
||||
// reachable twice now that a robust group opens one as it is created: the
|
||||
// room id is derived from its members, so making the same group again
|
||||
@@ -133,36 +192,66 @@ object ChillDkgRitualManager {
|
||||
// and the key the first one produced would be left with nothing pointing
|
||||
// at it. A failed ritual is not running, and is there to be replaced.
|
||||
//
|
||||
// Scoped by [parentChatRoomId] rather than by room alone. A subgroup's
|
||||
// ceremony room is derived from its admins, so a subgroup whose admins are
|
||||
// the whole group lands in the room the group's own ceremony was held in
|
||||
// -- and that is a legitimate subgroup, since a subgroup is a logical
|
||||
// division rather than a smaller membership. Refusing it on the room would
|
||||
// hand back the *group's* key and quietly make the child the parent.
|
||||
// What "the same purpose" means differs by which kind of ceremony this
|
||||
// is, because a parent's room hosts every subgroup ceremony the group
|
||||
// ever runs. Scoping a subgroup's on the room would refuse the parent's
|
||||
// *second* subgroup on the strength of its first.
|
||||
//
|
||||
// Checked before the arguments are, because a running ritual makes the
|
||||
// Checked before the threshold is, because a running ritual makes the
|
||||
// requested threshold moot -- it settled that question when it opened.
|
||||
database.dkgSessionDao().getLatestSessionFor(localChatRoom.chatRoom.id, parentChatRoomId)
|
||||
?.takeIf { it.stage != DkgRitualStage.FAILED }
|
||||
?.let { running ->
|
||||
logger.i(
|
||||
"Room ${localChatRoom.chatRoom.id} is already running ritual " +
|
||||
"${running.id}; not opening another"
|
||||
)
|
||||
return running
|
||||
val running = if (parentChatRoomId == null) {
|
||||
database.dkgSessionDao()
|
||||
.getLatestSessionFor(localChatRoom.chatRoom.id, null)
|
||||
?.takeIf { it.stage != DkgRitualStage.FAILED }
|
||||
} else {
|
||||
// Same parent, same admins -- and still running. A *completed* one is
|
||||
// deliberately not a reason to refuse: it means that subgroup was
|
||||
// made, and a group is entitled to a second one run by the same
|
||||
// people. That was impossible while the ceremony room was derived
|
||||
// from the admins, since asking again handed back the first
|
||||
// ceremony's key and the "new" subgroup was the old one renamed.
|
||||
database.dkgSessionDao().getLatestSubgroupSessionFor(
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
participantPublicKeys = DkgSession.formatParticipants(members).orEmpty()
|
||||
)?.takeIf {
|
||||
it.stage != DkgRitualStage.FAILED && it.stage != DkgRitualStage.COMPLETE
|
||||
}
|
||||
}
|
||||
|
||||
if (running != null) {
|
||||
logger.i(
|
||||
"Room ${localChatRoom.chatRoom.id} is already running ritual " +
|
||||
"${running.id}; not opening another"
|
||||
)
|
||||
return running
|
||||
}
|
||||
|
||||
// Everyone in a ceremony has to be able to hear it. In a NIP-17 room a
|
||||
// gift wrap is sealed per p-tag so this was true by construction; in a
|
||||
// Marmot room a message reaches the MLS tree and nobody else, so a
|
||||
// participant outside it is an `n` that can never be met. Checked here
|
||||
// because `SubgroupManager.refuseSubgroup` checking it is a screen not
|
||||
// drawing something, which is not a guard.
|
||||
val room = memberPublicKeys(localChatRoom) + localChatRoom.chatRoom.userPublicKey
|
||||
require(room.containsAll(members)) {
|
||||
"A ceremony in ${localChatRoom.chatRoom.id} cannot include " +
|
||||
"${(members - room).joinToString { it.take(8) }}, who are not in it"
|
||||
}
|
||||
|
||||
// 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}"
|
||||
"A shared key needs at least $MINIMUM_PARTICIPANTS members, this ceremony has ${members.size}"
|
||||
}
|
||||
require(threshold in ChatRoomType.quorumRange(members.size)) {
|
||||
"Quorum $threshold is outside ${ChatRoomType.quorumRange(members.size)} for ${members.size} members"
|
||||
}
|
||||
|
||||
// A subgroup is named by whoever is making it. Falling back to the room
|
||||
// there would name a child after its parent, which is the one name it
|
||||
// must not have -- the certificate the parent signs is over this string.
|
||||
val ceremonySubject = subject?.takeIf { it.isNotBlank() }
|
||||
?: localChatRoom.chatRoom.subject?.takeIf { parentChatRoomId == null }
|
||||
|
||||
val sessionId = RandomInstance.bytes(32).toHex()
|
||||
|
||||
val session = DkgSession(
|
||||
@@ -174,6 +263,10 @@ object ChillDkgRitualManager {
|
||||
// The `n` every other device will derive from this proposal's p-tags,
|
||||
// which `broadcast` assembles from exactly this set.
|
||||
participantCount = members.size,
|
||||
// And the set itself, because the room can no longer be asked: a
|
||||
// subgroup's ceremony runs in the parent's room over a subset of it.
|
||||
participantPublicKeys = DkgSession.formatParticipants(members),
|
||||
subject = ceremonySubject,
|
||||
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.
|
||||
@@ -208,13 +301,15 @@ object ChillDkgRitualManager {
|
||||
// bytes per round to say something already settled -- and would give a
|
||||
// later message a say in what an opened ceremony is for.
|
||||
includeParent = true,
|
||||
// The room's name, which until now reached nobody but its creator.
|
||||
// `NostrDao.getOrCreateNip17ChatRoom` has always read a subject off the
|
||||
// payload that first tells it about a room, and a ritual proposal is
|
||||
// routinely that payload -- so without this every member but the
|
||||
// coordinator watched an untitled chat appear with a key ceremony
|
||||
// already running in it.
|
||||
subject = localChatRoom.chatRoom.subject
|
||||
// What the room this key derives is to be called. For a group's own
|
||||
// ceremony that is the NIP-17 room's name, which until this tag
|
||||
// existed reached nobody but its creator --
|
||||
// `NostrDao.getOrCreateNip17ChatRoom` has always read a subject off
|
||||
// the payload that first tells it about a room, and a ritual proposal
|
||||
// is routinely that payload. For a subgroup it is the only way the
|
||||
// name travels at all, since the child has no room yet and the room
|
||||
// this runs in is the parent's.
|
||||
subject = ceremonySubject
|
||||
)
|
||||
|
||||
publishHostKey(database, localChatRoom, session)
|
||||
@@ -226,49 +321,55 @@ object ChillDkgRitualManager {
|
||||
* Feeds one inbound ritual message in and advances the ritual as far as it
|
||||
* will go. Safe to call twice with the same message: every write is keyed
|
||||
* and every step recomputed from stored inputs.
|
||||
*
|
||||
* [innerEvent] is the rumor as its sender wrote it, whichever transport
|
||||
* carried it -- the decrypted gift wrap in a NIP-17 room, the inner event of
|
||||
* a kind:445 in a Marmot one. `sig` is empty on both and nothing here reads
|
||||
* it: what vouches for the author is the seal or the MLS frame, not a
|
||||
* signature on the payload.
|
||||
*/
|
||||
suspend fun processRitualPayload(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
giftWrapPayload: GiftWrapPayload,
|
||||
innerEvent: Event,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray
|
||||
) {
|
||||
val sessionId = DkgRitualEvents.parseSessionId(giftWrapPayload.tags)
|
||||
val sessionId = DkgRitualEvents.parseSessionId(innerEvent.tags)
|
||||
if (sessionId == null) {
|
||||
logger.w("DKG payload ${giftWrapPayload.id} has no session tag; dropping")
|
||||
logger.w("DKG payload ${innerEvent.id} has no session tag; dropping")
|
||||
return
|
||||
}
|
||||
|
||||
val session = if (giftWrapPayload.kind == DkgRitualEvents.PROPOSAL) {
|
||||
val session = if (innerEvent.kind == DkgRitualEvents.PROPOSAL) {
|
||||
acceptProposal(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
giftWrapPayload = giftWrapPayload,
|
||||
innerEvent = innerEvent,
|
||||
sessionId = sessionId,
|
||||
userPublicKey = userPublicKey,
|
||||
nostrPrivateKey = nostrPrivateKey
|
||||
)
|
||||
} 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.
|
||||
// Not knowing the session is normal: relays hand events back in no
|
||||
// particular order -- and gift wraps carry a randomised created_at on
|
||||
// top of that -- 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) {
|
||||
logger.i("No DKG session $sessionId for kind ${giftWrapPayload.kind}; leaving it stored")
|
||||
logger.i("No DKG session $sessionId for kind ${innerEvent.kind}; leaving it stored")
|
||||
return
|
||||
}
|
||||
|
||||
if (session.stage == DkgRitualStage.FAILED) {
|
||||
logger.i("Ritual $sessionId already failed; ignoring kind ${giftWrapPayload.kind}")
|
||||
logger.i("Ritual $sessionId already failed; ignoring kind ${innerEvent.kind}")
|
||||
return
|
||||
}
|
||||
|
||||
if (!record(database, session, giftWrapPayload)) return
|
||||
if (!record(database, session, innerEvent)) return
|
||||
|
||||
advance(
|
||||
database = database,
|
||||
@@ -285,7 +386,7 @@ object ChillDkgRitualManager {
|
||||
private suspend fun acceptProposal(
|
||||
database: MantraDatabase,
|
||||
localChatRoom: LocalChatRoom,
|
||||
giftWrapPayload: GiftWrapPayload,
|
||||
innerEvent: Event,
|
||||
sessionId: String,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray
|
||||
@@ -296,10 +397,15 @@ object ChillDkgRitualManager {
|
||||
// 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()
|
||||
//
|
||||
// In the parent's room it is not even meant to match: a subgroup's
|
||||
// ceremony is run by some of the room, and every member outside the set
|
||||
// drops the proposal below rather than joining a ceremony they were not
|
||||
// picked for.
|
||||
val members = participantsFrom(innerEvent)
|
||||
|
||||
if (userPublicKey !in members) {
|
||||
logger.w("DKG proposal $sessionId does not include this device; dropping")
|
||||
logger.i("DKG proposal $sessionId does not include this device; dropping")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -308,7 +414,7 @@ object ChillDkgRitualManager {
|
||||
return null
|
||||
}
|
||||
|
||||
val threshold = DkgRitualEvents.parseThreshold(giftWrapPayload.tags)
|
||||
val threshold = DkgRitualEvents.parseThreshold(innerEvent.tags)
|
||||
if (threshold == null) {
|
||||
logger.w("DKG proposal $sessionId carries no threshold; dropping")
|
||||
return null
|
||||
@@ -330,11 +436,25 @@ object ChillDkgRitualManager {
|
||||
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,
|
||||
// reserve for a room's creator or its admins — and in the parent's
|
||||
// room, where a member who is neither can put a proposal on the wire,
|
||||
// that is what keeps this from needing a permission check. The worst
|
||||
// such a proposal can do is ask: the birth certificate two steps later
|
||||
// needs the parent's own quorum, which is the check that was always
|
||||
// going to be the check.
|
||||
coordinatorPublicKey = innerEvent.pubKey,
|
||||
userPublicKey = userPublicKey,
|
||||
threshold = threshold,
|
||||
participantCount = members.size,
|
||||
// The set the count is of, kept so the room never has to be asked.
|
||||
// In the parent's room asking it would answer with the parent's
|
||||
// membership, which is every person this ceremony is not with.
|
||||
participantPublicKeys = DkgSession.formatParticipants(members),
|
||||
// What the room this key derives is to be called, as claimed. Taken
|
||||
// as said for the same reason the parent below is: nothing is granted
|
||||
// on it, and the certificate the parent's quorum signs is over this
|
||||
// name, so an admin who dislikes it declines that.
|
||||
subject = DkgRitualEvents.parseSubject(innerEvent.tags),
|
||||
hostPublicKey = deriveHostPublicKey(nostrPrivateKey).value.toHex(),
|
||||
round1Random = RandomInstance.bytes(32).toHex(),
|
||||
round2AuxRandom = RandomInstance.bytes(32).toHex(),
|
||||
@@ -345,7 +465,7 @@ object ChillDkgRitualManager {
|
||||
// is the p-tags and the threshold is the threshold. What it changes is
|
||||
// that the screen can say what the ceremony is for. The parent's own
|
||||
// signature turns up two steps later, on the birth certificate.
|
||||
parentChatRoomId = DkgRitualEvents.parseParentChatRoomId(giftWrapPayload.tags)
|
||||
parentChatRoomId = DkgRitualEvents.parseParentChatRoomId(innerEvent.tags)
|
||||
)
|
||||
database.dkgSessionDao().upsert(session)
|
||||
announceStarted(database, session)
|
||||
@@ -358,7 +478,7 @@ object ChillDkgRitualManager {
|
||||
// the ritual can be shown and stored messages replayed; [advance] sends
|
||||
// nothing until [approve] has been called.
|
||||
announceApprovalNeeded(database, session, DkgApprovalStep.HOST_KEY)
|
||||
replayStoredMessages(database, session)
|
||||
replayStoredMessages(database, localChatRoom, session)
|
||||
|
||||
return session
|
||||
}
|
||||
@@ -371,15 +491,50 @@ object ChillDkgRitualManager {
|
||||
* 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.
|
||||
*
|
||||
* Out of whichever store the room's transport writes to, which has to be the
|
||||
* same reading [broadcast] makes: a ceremony 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: DkgSession
|
||||
) {
|
||||
val stored = database.giftWrapPayloadDao().getByChatRoomAndKinds(
|
||||
chatRoomId = session.chatRoomId,
|
||||
kinds = DkgRitualEvents.ALL.toList()
|
||||
).filter { payload ->
|
||||
val kinds = DkgRitualEvents.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 { payload ->
|
||||
payload.kind != DkgRitualEvents.PROPOSAL &&
|
||||
DkgRitualEvents.parseSessionId(payload.tags) == session.id
|
||||
}
|
||||
@@ -404,9 +559,9 @@ object ChillDkgRitualManager {
|
||||
private suspend fun record(
|
||||
database: MantraDatabase,
|
||||
session: DkgSession,
|
||||
giftWrapPayload: GiftWrapPayload
|
||||
innerEvent: Event
|
||||
): Boolean {
|
||||
when (giftWrapPayload.kind) {
|
||||
when (innerEvent.kind) {
|
||||
DkgRitualEvents.PROPOSAL -> Unit // handled by acceptProposal; host key already published
|
||||
|
||||
DkgRitualEvents.HOST_KEY,
|
||||
@@ -417,19 +572,19 @@ object ChillDkgRitualManager {
|
||||
// upsert below absorbs that, but a chat row has no key to absorb it
|
||||
// with.
|
||||
val known = database.dkgSessionDao()
|
||||
.getMessage(session.id, giftWrapPayload.kind, giftWrapPayload.publicKey) != null
|
||||
.getMessage(session.id, innerEvent.kind, innerEvent.pubKey) != null
|
||||
|
||||
database.dkgSessionDao().upsert(
|
||||
DkgParticipantMessage(
|
||||
sessionId = session.id,
|
||||
participantPublicKey = giftWrapPayload.publicKey,
|
||||
kind = giftWrapPayload.kind,
|
||||
payload = giftWrapPayload.content
|
||||
participantPublicKey = innerEvent.pubKey,
|
||||
kind = innerEvent.kind,
|
||||
payload = innerEvent.content
|
||||
)
|
||||
)
|
||||
|
||||
if (!known) {
|
||||
announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey)
|
||||
announceStep(database, session, innerEvent.kind, innerEvent.pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,38 +592,38 @@ object ChillDkgRitualManager {
|
||||
// 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
|
||||
if (!isFromCoordinator(session, innerEvent)) return true
|
||||
|
||||
val known = current(database, session).coordinatorRound1 != null
|
||||
|
||||
update(database, session) { current ->
|
||||
if (current.coordinatorRound1 == null) {
|
||||
current.copy(coordinatorRound1 = giftWrapPayload.content)
|
||||
current.copy(coordinatorRound1 = innerEvent.content)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
if (!known) {
|
||||
announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey)
|
||||
announceStep(database, session, innerEvent.kind, innerEvent.pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
DkgRitualEvents.CERTIFICATE -> {
|
||||
if (!isFromCoordinator(session, giftWrapPayload)) return true
|
||||
if (!isFromCoordinator(session, innerEvent)) return true
|
||||
|
||||
val known = current(database, session).certificate != null
|
||||
|
||||
update(database, session) { current ->
|
||||
if (current.certificate == null) {
|
||||
current.copy(certificate = giftWrapPayload.content)
|
||||
current.copy(certificate = innerEvent.content)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
if (!known) {
|
||||
announceStep(database, session, giftWrapPayload.kind, giftWrapPayload.publicKey)
|
||||
announceStep(database, session, innerEvent.kind, innerEvent.pubKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,8 +631,8 @@ object ChillDkgRitualManager {
|
||||
fail(
|
||||
database = database,
|
||||
session = session,
|
||||
reason = "Abandoned by ${giftWrapPayload.publicKey.take(8)}: ${giftWrapPayload.content}",
|
||||
culprit = giftWrapPayload.publicKey
|
||||
reason = "Abandoned by ${innerEvent.pubKey.take(8)}: ${innerEvent.content}",
|
||||
culprit = innerEvent.pubKey
|
||||
)
|
||||
return false
|
||||
}
|
||||
@@ -486,12 +641,12 @@ object ChillDkgRitualManager {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isFromCoordinator(session: DkgSession, giftWrapPayload: GiftWrapPayload): Boolean {
|
||||
if (giftWrapPayload.publicKey == session.coordinatorPublicKey) return true
|
||||
private fun isFromCoordinator(session: DkgSession, innerEvent: Event): Boolean {
|
||||
if (innerEvent.pubKey == session.coordinatorPublicKey) return true
|
||||
|
||||
logger.w(
|
||||
"Ritual ${session.id}: kind ${giftWrapPayload.kind} from " +
|
||||
"${giftWrapPayload.publicKey.take(8)}, who is not the coordinator; ignoring"
|
||||
"Ritual ${session.id}: kind ${innerEvent.kind} from " +
|
||||
"${innerEvent.pubKey.take(8)}, who is not the coordinator; ignoring"
|
||||
)
|
||||
return false
|
||||
}
|
||||
@@ -874,11 +1029,24 @@ object ChillDkgRitualManager {
|
||||
session = session,
|
||||
messageType = ChatMessage.TYPE_DKG_STARTED,
|
||||
// Reads after the actor's name -- see ChatMessage.DKG_AUTHORED_TYPES. Who
|
||||
// opened a ceremony matters: any member can, and it settles the group's
|
||||
// opened a ceremony matters: any member can, and it settles a group's
|
||||
// signing quorum for good.
|
||||
content = "started a shared key ceremony. It will take ${session.threshold} of " +
|
||||
"${session.participantCount} members to sign with the key, and it finishes once " +
|
||||
"everyone has taken part.",
|
||||
//
|
||||
// A subgroup's ceremony says so, because it lands in the *parent's* room
|
||||
// and the unqualified sentence reads there as though this group were
|
||||
// getting a new key. It is not: the quorum quoted is the child's, over
|
||||
// the child's admins, and the parent's own key is untouched. Getting that
|
||||
// wrong is how a member approves something they did not follow.
|
||||
content = if (session.parentChatRoomId != null) {
|
||||
"started a key ceremony for ${session.subject ?: "a subgroup"}, a subgroup of " +
|
||||
"this group. It will take ${session.threshold} of " +
|
||||
"${session.participantCount} of its admins to sign with the subgroup's key, " +
|
||||
"and it finishes once all of them have taken part."
|
||||
} else {
|
||||
"started a shared key ceremony. It will take ${session.threshold} of " +
|
||||
"${session.participantCount} members to sign with the key, and it finishes " +
|
||||
"once everyone has taken part."
|
||||
},
|
||||
actor = session.coordinatorPublicKey
|
||||
)
|
||||
|
||||
@@ -1147,9 +1315,28 @@ object ChillDkgRitualManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a ritual message as a gift-wrap payload. `NotaryViewModel` picks it
|
||||
* up, seals a copy per participant and broadcasts — the same path chat
|
||||
* messages take, which is why this needs no transport of its own.
|
||||
* Queues one of this device's ritual messages for the outbound pipeline, on
|
||||
* whichever transport the room it is in has.
|
||||
*
|
||||
* In a NIP-17 room it is a gift-wrap payload: `NotaryViewModel` picks it up,
|
||||
* seals a copy per participant and broadcasts. In a Marmot room it is an
|
||||
* unprocessed inner event, MLS-encrypted and broadcast as a kind:445 for the
|
||||
* room. Both are the paths chat messages already take, which is why this
|
||||
* needs no transport of its own.
|
||||
*
|
||||
* **The p-tags go on both**, which is where this parts company with
|
||||
* `FrostSigningManager.broadcast`. There a p-tag is an address, and a group
|
||||
* event needs none because it is encrypted to the whole tree. Here the p-tag
|
||||
* set *is* the participant set: `acceptProposal` builds `n` out of it on
|
||||
* every device, and `n` is hashed into the session identity, so dropping the
|
||||
* tags in a Marmot room would leave every receiver unable to say what
|
||||
* ceremony they had been invited to. In the parent's room they are also the
|
||||
* only thing separating the subgroup's admins from everybody else who can
|
||||
* read the message, and naming them inside MLS encryption leaks nothing.
|
||||
*
|
||||
* Off the session rather than off the room, for that same reason. A
|
||||
* subgroup's ceremony runs in the parent's room, so the room's roster would
|
||||
* invite the whole parent into the child's key.
|
||||
*/
|
||||
private suspend fun broadcast(
|
||||
database: MantraDatabase,
|
||||
@@ -1161,15 +1348,19 @@ object ChillDkgRitualManager {
|
||||
includeParent: Boolean = false,
|
||||
subject: String? = null
|
||||
) {
|
||||
// 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 ->
|
||||
// Sorted so a receiver comparing two devices' proposals is comparing the
|
||||
// same bytes, and the sender is implied rather than tagged -- together
|
||||
// they are the set every receiver derives `n` from.
|
||||
val relayHints = localChatRoom.localParticipants
|
||||
.associate { it.participant.participantPublicKey to it.participant.relayHint }
|
||||
|
||||
val receiverTags = participantsOf(session, localChatRoom)
|
||||
.filterNot { it == session.userPublicKey }
|
||||
.sorted()
|
||||
.map { participant ->
|
||||
PTag.assemble(
|
||||
localParticipant.participant.participantPublicKey,
|
||||
localParticipant.participant.relayHint?.let { NormalizedRelayUrl(it) }
|
||||
participant,
|
||||
relayHints[participant]?.let { NormalizedRelayUrl(it) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1181,7 +1372,10 @@ object ChillDkgRitualManager {
|
||||
)
|
||||
|
||||
val createdAt = Clock.System.now().epochSeconds
|
||||
val giftWrapPayloadId = EventHasher.hashId(
|
||||
|
||||
// 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,
|
||||
@@ -1189,16 +1383,45 @@ object ChillDkgRitualManager {
|
||||
kind = kind
|
||||
)
|
||||
|
||||
database.giftWrapPayloadDao().upsert(
|
||||
GiftWrapPayload(
|
||||
id = giftWrapPayloadId,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
createdAt = Instant.fromEpochSeconds(createdAt),
|
||||
content = content,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
publicKey = session.userPublicKey
|
||||
// No MLS state is what marks a room NIP-17 -- the same reading
|
||||
// `NostrNip17Dao.getOrCreateChatRoom` writes, `sendChatMessage` makes when
|
||||
// it chooses between a group event and gift wraps, and
|
||||
// [replayStoredMessages] has to make again to find this message later.
|
||||
if (localChatRoom.chatRoom.mlsGroupState != null) {
|
||||
database.marmotInnerEventDao().upsert(
|
||||
MarmotInnerEvent(
|
||||
id = id,
|
||||
publicKey = session.userPublicKey,
|
||||
kind = kind,
|
||||
createdAt = Instant.fromEpochSeconds(createdAt),
|
||||
tags = tags,
|
||||
content = content,
|
||||
chatRoomId = localChatRoom.chatRoom.id
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
database.giftWrapPayloadDao().upsert(
|
||||
GiftWrapPayload(
|
||||
id = id,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
createdAt = Instant.fromEpochSeconds(createdAt),
|
||||
content = content,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
publicKey = session.userPublicKey
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The participant set a proposal names: its p-tags plus its sender.
|
||||
*
|
||||
* The sender is in the set without tagging themselves -- they are running the
|
||||
* ceremony, and a device does not need to be told to send itself a message.
|
||||
* `GiftWrapPayload.participantPTags` reads exactly this off the other
|
||||
* transport, and the two have to agree or a ceremony cannot span them.
|
||||
*/
|
||||
private fun participantsFrom(innerEvent: Event): Set<HexKey> =
|
||||
innerEvent.tags.taggedUsers().map { it.pubKey }.toSet() + innerEvent.pubKey
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user