Merge branch 'mantra' into claude/subgroups-multiple-parents-053be4
This commit is contained in:
commit
cff2c3c329
22 files changed
+7271
-398
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -177,7 +177,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
UnsignedNostrEvent::class,
|
||||
Zap::class
|
||||
],
|
||||
version = 18,
|
||||
version = 19,
|
||||
autoMigrations = [
|
||||
// v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can
|
||||
// generate the migration itself — nothing existing changes shape.
|
||||
@@ -298,6 +298,20 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L)
|
||||
// clock, which is correct for them: nothing that predates subgroups ran
|
||||
// two ceremonies in one room.
|
||||
AutoMigration(from = 17, to = 18),
|
||||
// v19 adds two nullable DkgSession columns, both of which the ceremony's
|
||||
// room used to answer and no longer can: a subgroup's ChillDKG now runs
|
||||
// in the **parent's** Marmot room rather than in a NIP-17 room derived
|
||||
// from the child's admins. See docs/subgroups.md.
|
||||
//
|
||||
// `participantPublicKeys` is who the ceremony is with -- a subset of the
|
||||
// parent's members, so the room's roster is the wrong set -- and
|
||||
// `subject` is what the room it will make is to be called, which used to
|
||||
// be the ceremony room's own name. Both are read off the proposal, which
|
||||
// has carried both since before this move; what changed is where they
|
||||
// land. Rows written before this read back null and fall back to the
|
||||
// room, which is correct for them: a ceremony held in its own NIP-17 room
|
||||
// ran over exactly that room's members and was named after it.
|
||||
AutoMigration(from = 18, to = 19),
|
||||
]
|
||||
)
|
||||
@ColumnTypeConverters(MantraConverters::class)
|
||||
|
||||
+67
-10
@@ -27,21 +27,44 @@ interface DkgSessionDao {
|
||||
@Query("SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/**
|
||||
* The room's newest ceremony **of its own**, newest first -- never one it is
|
||||
* only hosting on a subgroup's behalf.
|
||||
*
|
||||
* The distinction did not exist while every ceremony ran in a NIP-17 room of
|
||||
* its own. It does now: a subgroup's ChillDKG runs in the parent's Marmot
|
||||
* room, so that room holds a completed ceremony whose key is the *child's*,
|
||||
* and a caller asking "what key does this room sign with" must not be handed
|
||||
* it. That is `FrostSigningManager.completedKey`'s last fallback, which is
|
||||
* reached by every parent member who holds no key-state row -- which is
|
||||
* every member welcomed after the parent's own ceremony.
|
||||
*
|
||||
* A ceremony run to make a subgroup is never the room's own key, and
|
||||
* `parentChatRoomId` is the only thing that has to be read to know it. It is
|
||||
* an unverified claim, but not a dangerous one here: the direction it can be
|
||||
* abused in is a member *excluding* a ceremony from this answer, and a
|
||||
* ceremony they can exclude is one they proposed, whose key they would
|
||||
* simply not have proposed at all.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND parentChatRoomId IS NULL " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
suspend fun getLatestOwnSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/**
|
||||
* The room's newest ceremony *for one purpose*: the group's own when
|
||||
* [parentChatRoomId] is null, or the subgroup of that parent when it is not.
|
||||
*
|
||||
* A room can hold more than one. The ceremony room for a set of admins is
|
||||
* derived from those admins, so a subgroup whose admins are everybody lands
|
||||
* in the room the group's own ceremony was held in -- and that is a legitimate
|
||||
* subgroup rather than a mistake, because a subgroup is a logical division and
|
||||
* not a smaller membership.
|
||||
* A room routinely holds more than one, and since the move it is the ordinary
|
||||
* case rather than a corner: a parent's Marmot room hosts every subgroup
|
||||
* ceremony the group ever runs, alongside whatever it holds of its own.
|
||||
* `DkgSession.parentChatRoomId` is what tells those apart.
|
||||
*
|
||||
* `DkgSession.parentChatRoomId` is what tells them apart, and it is enough
|
||||
* because it is the only thing that differs: two ceremonies in one room are
|
||||
* either one group's and one subgroup's, or two subgroups' of different
|
||||
* parents. Two subgroups of the *same* parent over the *same* admins would
|
||||
* still collide, and that pair is one subgroup asked for twice.
|
||||
* It does **not** tell two subgroups of the same parent apart -- they share
|
||||
* both columns. Use [getLatestSubgroupSessionFor] where the question is which
|
||||
* subgroup; this answers "does this room hold a ceremony for that parent at
|
||||
* all", which is what a screen scoped to one lineage wants.
|
||||
*
|
||||
* Everything else about a ceremony is already keyed by session id -- the
|
||||
* messages, the approvals, the transcript -- so nothing below this needed to
|
||||
@@ -63,6 +86,40 @@ interface DkgSessionDao {
|
||||
)
|
||||
fun observeLatestSessionFor(chatRoomId: String, parentChatRoomId: String?): Flow<DkgSession?>
|
||||
|
||||
/**
|
||||
* The newest ceremony making a subgroup of [parentChatRoomId] over exactly
|
||||
* these admins, or null if this device knows of none.
|
||||
*
|
||||
* What "may I open another" means for a subgroup, now that the ceremony runs
|
||||
* in the parent's room. It used to be answered by the room: the ceremony room
|
||||
* was derived from the admins, so the same admins were the same room and
|
||||
* [getLatestSessionFor] folded a second ask into the first. The parent's room
|
||||
* is the same room for every subgroup it ever makes, so that scoping would
|
||||
* refuse the parent's *second* subgroup on the strength of its first.
|
||||
*
|
||||
* [participantPublicKeys] is the same sorted string
|
||||
* `DkgSession.formatParticipants` writes, which is why it is sorted: the
|
||||
* coordinator builds the set from a picker and every other device from
|
||||
* p-tags, and the two have to match as strings for this to find anything.
|
||||
*
|
||||
* Deliberately not scoped by stage -- the caller decides what a *finished*
|
||||
* ceremony means. `ChillDkgRitualManager.proposeRitual` folds into one that
|
||||
* is still running and opens a new one otherwise, because a subgroup that
|
||||
* has been made is not a reason to refuse making another over the same
|
||||
* people.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT * FROM DkgSession WHERE chatRoomId = :chatRoomId AND " +
|
||||
"parentChatRoomId = :parentChatRoomId AND " +
|
||||
"participantPublicKeys = :participantPublicKeys " +
|
||||
"ORDER BY createdAt DESC LIMIT 1"
|
||||
)
|
||||
suspend fun getLatestSubgroupSessionFor(
|
||||
chatRoomId: String,
|
||||
parentChatRoomId: String,
|
||||
participantPublicKeys: String
|
||||
): DkgSession?
|
||||
|
||||
/**
|
||||
* Every ceremony this room holds, newest first.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,6 +62,43 @@ data class DkgSession(
|
||||
/** The `n`: how many members have to show up before params can be built. */
|
||||
val participantCount: Int,
|
||||
|
||||
/**
|
||||
* Everyone the ceremony is being run with, comma-separated hex, sorted --
|
||||
* or null on a row written before this column existed.
|
||||
*
|
||||
* The room used to answer this. It cannot any more: a subgroup's ceremony
|
||||
* runs in the **parent's** room, over a subset of the parent's members, so
|
||||
* the room's roster is the wrong set and reading it would put every parent
|
||||
* member in the child's admin list. The set is the proposal's p-tags plus
|
||||
* its sender, which is the same set every device derives `n` from, so what
|
||||
* is stored here cannot disagree with the ceremony it describes.
|
||||
*
|
||||
* Sorted so two devices that assembled the set in different orders store the
|
||||
* same string, which is what lets [DkgSessionDao.getLatestSubgroupSessionFor]
|
||||
* match on it.
|
||||
*
|
||||
* Null falls back to the room, which is right for every row that predates
|
||||
* this: a ceremony before subgroups moved ran in a room whose members were
|
||||
* exactly its participants.
|
||||
*/
|
||||
val participantPublicKeys: String? = null,
|
||||
|
||||
/**
|
||||
* What the room this ceremony's key derives is to be called, as the proposal
|
||||
* claimed -- or null for a ceremony that names nothing.
|
||||
*
|
||||
* Off the same `SubjectTag` the proposal has always carried. It used to land
|
||||
* on the ceremony's own NIP-17 room, which was the subgroup's name written
|
||||
* somewhere it could be read back; a ceremony held in the parent's room has
|
||||
* no room of its own to be named, and the parent's name is somebody else's.
|
||||
*
|
||||
* Unverified, like [parentChatRoomId] and for the same reason -- it is a
|
||||
* claim on a proposal. What checks it is the birth certificate, which the
|
||||
* parent's quorum signs over this very name, and which every admin sees
|
||||
* before agreeing to it.
|
||||
*/
|
||||
val subject: String? = null,
|
||||
|
||||
val stage: DkgRitualStage = DkgRitualStage.COLLECTING_HOST_KEYS,
|
||||
|
||||
/** This device's ChillDKG host public key (33-byte compressed, hex). */
|
||||
@@ -168,4 +205,30 @@ data class DkgSession(
|
||||
?.mapNotNull { hex -> hex.trim().takeIf { it.isNotEmpty() } }
|
||||
?.map { PublicKey(ByteVector(it.hexToByteArray())) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Everyone this ceremony is being run with, or null on a row that predates
|
||||
* the column and so still means "the room's members".
|
||||
*
|
||||
* Callers that need an answer either way go through
|
||||
* `ChillDkgRitualManager.participantsOf`, which falls back to the room.
|
||||
*/
|
||||
fun participantPublicKeySet(): Set<HexKey>? = participantPublicKeys
|
||||
?.split(",")
|
||||
?.mapNotNull { key -> key.trim().takeIf { it.isNotEmpty() } }
|
||||
?.toSet()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The stored form of a participant set: sorted, comma-separated hex.
|
||||
*
|
||||
* Sorted because the set is the same set whichever order it was
|
||||
* assembled in -- the coordinator builds it from a picker and every
|
||||
* other device from p-tags -- and two devices storing two strings for
|
||||
* one ceremony would make the column useless for matching on.
|
||||
*/
|
||||
fun formatParticipants(publicKeys: Set<HexKey>): String? =
|
||||
publicKeys.takeIf { it.isNotEmpty() }?.sorted()?.joinToString(",")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -135,7 +135,7 @@ class DatabaseChatRepository(
|
||||
val parentRoom = database.chatRoomDao().findChatRoomById(parentChatRoomId)
|
||||
?: return "Couldn't load this group."
|
||||
|
||||
SubgroupManager.refuseCeremonyRoom(
|
||||
SubgroupManager.refuseSubgroup(
|
||||
database = database,
|
||||
parentRoom = parentRoom,
|
||||
adminPublicKeys = adminPublicKeys,
|
||||
|
||||
+6
-2
@@ -62,7 +62,9 @@ class DatabaseDkgRepository(
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey?
|
||||
parentChatRoomId: HexKey?,
|
||||
participantPublicKeys: Set<HexKey>?,
|
||||
subject: String?
|
||||
): DkgSession? = try {
|
||||
ChillDkgRitualManager.proposeRitual(
|
||||
database = database,
|
||||
@@ -70,7 +72,9 @@ class DatabaseDkgRepository(
|
||||
userPublicKey = userPublicKey,
|
||||
nostrPrivateKey = nostrPrivateKey,
|
||||
threshold = threshold,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
participantPublicKeys = participantPublicKeys,
|
||||
subject = subject
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Error proposing DKG ritual for ${localChatRoom.chatRoom.id}", e)
|
||||
|
||||
+328
-105
@@ -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
|
||||
}
|
||||
+48
-22
@@ -58,10 +58,12 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
*
|
||||
* The exception is the group's *first* signature. A room's `GroupKeyState` is
|
||||
* now signed before the room it is about is created -- see
|
||||
* `GroupKeyStateManager.propose` -- so that session runs in the NIP-17 room the
|
||||
* ceremony ran in, where there is no MLS tree and a message goes out as one
|
||||
* sealed gift wrap per member. [broadcast] is the only place that knows the
|
||||
* difference; everything above it is the same protocol either way.
|
||||
* `GroupKeyStateManager.propose` -- so that session runs wherever the ceremony
|
||||
* ran: the members' NIP-17 room for a group's own key, where there is no MLS
|
||||
* tree and a message goes out as one sealed gift wrap per member, and the
|
||||
* parent's Marmot room for a subgroup's, where it is an ordinary group event.
|
||||
* [broadcast] is the only place that knows the difference; everything above it
|
||||
* is the same protocol either way.
|
||||
*
|
||||
* ### What it signs as
|
||||
*
|
||||
@@ -72,9 +74,9 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
* holding a signed dialect therefore needs no lookup to check it: the author
|
||||
* they expect is the id of the room they found it in.
|
||||
*
|
||||
* A session in a NIP-17 room signs as the room the ceremony's key derives --
|
||||
* the admin room that does not exist yet -- which is the same rule read
|
||||
* forwards: what a group signs as is the room the signature belongs to.
|
||||
* A session in a ceremony's own room signs as the room that ceremony's key
|
||||
* derives -- the admin room that does not exist yet -- which is the same rule
|
||||
* read forwards: what a group signs as is the room the signature belongs to.
|
||||
*
|
||||
* The path comes from the room, never from a proposal -- [signingPath] -- because
|
||||
* it decides which key the group signs as.
|
||||
@@ -1294,10 +1296,10 @@ object FrostSigningManager {
|
||||
/**
|
||||
* The key a room signs with, or null when it has none.
|
||||
*
|
||||
* Signing usually runs in the admin room, which is not where the ceremony
|
||||
* ran. A ceremony needs a NIP-17 group -- every member an equal admin, no
|
||||
* MLS tree to be outside of -- while a group event needs an MLS one, so the
|
||||
* two cannot be the same room.
|
||||
* Signing usually runs in the admin room, which is never where the ceremony
|
||||
* ran: the admin room's id is derived from the key the ceremony produces, so
|
||||
* it cannot exist until afterwards. A group's own ceremony runs in the NIP-17
|
||||
* room its members share; a subgroup's runs in its parent's Marmot room.
|
||||
*
|
||||
* They are bound together by the room's [GroupKeyState]: the statement the
|
||||
* room opened with and the group signed, naming the ceremony behind it. That
|
||||
@@ -1316,6 +1318,15 @@ object FrostSigningManager {
|
||||
* until one matches; the second is a ceremony held in this very room, which
|
||||
* is not how the app wires things today but costs one lookup to keep honest.
|
||||
*
|
||||
* That second one asks for the room's *own* ceremony, and the distinction is
|
||||
* load-bearing now rather than pedantic. A parent's Marmot room hosts the
|
||||
* ChillDKG of every subgroup it makes, so it holds completed ceremonies whose
|
||||
* key belongs to a child; handing one back here would have the parent sign as
|
||||
* its own subgroup, silently, for every member who holds no key-state row --
|
||||
* which is every member welcomed after the parent's own ceremony. A ceremony
|
||||
* naming a parent is never the room's own key, and that is all it takes to
|
||||
* tell them apart.
|
||||
*
|
||||
* They are not only for rooms that predate the table. A room's key state is
|
||||
* signed before the room exists and filed as the room is created, so a
|
||||
* device that missed that session -- a member invited later, a reinstall --
|
||||
@@ -1342,7 +1353,7 @@ object FrostSigningManager {
|
||||
}?.let { return it }
|
||||
|
||||
return database.dkgSessionDao()
|
||||
.getLatestSessionForChatRoom(chatRoomId)
|
||||
.getLatestOwnSessionForChatRoom(chatRoomId)
|
||||
?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null }
|
||||
}
|
||||
|
||||
@@ -1369,20 +1380,32 @@ object FrostSigningManager {
|
||||
* ### The room a group signs from before it has one
|
||||
*
|
||||
* One case cannot be self-checked, because there is nothing yet to check
|
||||
* against: the NIP-17 room a ceremony ran in, signing the very statement
|
||||
* that lets the group's Marmot room be created -- see
|
||||
* `GroupKeyStateManager.propose`. A NIP-17 room's id is an aggregation of
|
||||
* its members' keys, so it is not derived from anything and no path reaches
|
||||
* it. What the group signs as there is the room it is about to make: the
|
||||
* ceremony's key at the app's admin path.
|
||||
* against: the room a ceremony ran in, signing the very statement that lets
|
||||
* the room the ceremony's key derives be created -- see
|
||||
* `GroupKeyStateManager.propose`. What the group signs as there is the room
|
||||
* it is about to make: the ceremony's key at the app's admin path.
|
||||
*
|
||||
* That is admitted only when the ceremony is *this room's own*, and the path
|
||||
* is the constant rather than anything off the wire. Both inputs are read
|
||||
* from this device's database, so a proposer still chooses nothing: naming
|
||||
* Two rooms are that room. A group's own ceremony runs in the NIP-17 room its
|
||||
* members share, whose id is an aggregation of their keys, so it is derived
|
||||
* from nothing and no path reaches it. A *subgroup's* ceremony runs in the
|
||||
* parent's Marmot room, which is derived from the parent's key and so reaches
|
||||
* itself at some path -- just never from the child's, which is the key being
|
||||
* signed under here.
|
||||
*
|
||||
* Each is admitted on the narrowest thing that identifies it, and the path is
|
||||
* the constant rather than anything off the wire. Every input is read from
|
||||
* this device's own database, so a proposer still chooses nothing: naming
|
||||
* some other ceremony this device holds a share for gets no path at all, and
|
||||
* a session with no path signs as the bare threshold key, which is not an
|
||||
* identity any room answers to.
|
||||
*
|
||||
* `key.parentChatRoomId` is the unverified claim off a proposal, and admitting
|
||||
* it here grants nothing. The signature it enables is by the child's key over
|
||||
* the child's own id -- both derived from a ceremony every signer contributed
|
||||
* to -- so a member who put a false parent on a proposal gets a key state for
|
||||
* a room made from a key they helped make, which is what they would have got
|
||||
* by telling the truth.
|
||||
*
|
||||
* Null is not a failure. `completedKey` will find a key for a Marmot room
|
||||
* that was never derived from it -- the fallback kept for rooms the app no
|
||||
* longer makes -- and such a room has no key of its own to sign as, so it
|
||||
@@ -1408,7 +1431,10 @@ object FrostSigningManager {
|
||||
}.getOrDefault(false)
|
||||
}?.let { return it }
|
||||
|
||||
if (localChatRoom.chatRoom.mlsGroupState == null && key.chatRoomId == chatRoomId) {
|
||||
val isCeremonyRoom = key.chatRoomId == chatRoomId
|
||||
val isSubgroupCeremony = key.parentChatRoomId == chatRoomId
|
||||
|
||||
if (isCeremonyRoom && (localChatRoom.chatRoom.mlsGroupState == null || isSubgroupCeremony)) {
|
||||
return SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.FrostSigningSession
|
||||
import press.mantra.compose.database.model.GroupKeyState
|
||||
@@ -24,6 +23,11 @@ import press.mantra.compose.nostr.subgroup.SubgroupParentage
|
||||
* 4. the Marmot room -> somebody creates C and welcomes the admins
|
||||
* ```
|
||||
*
|
||||
* **All four happen in the parent's room**, bar the last, which makes a room of
|
||||
* its own. Steps 1 and 3 are held there because the child has nowhere else to be:
|
||||
* its id is derived from the key step 1 produces, so it cannot exist until
|
||||
* afterwards. Step 2 is there because the signature is the parent's.
|
||||
*
|
||||
* None of that order is a policy choice. Step 2 needs `C`, which does not exist
|
||||
* until step 1 produces `K`; step 3 carries the certificate, so it needs step 2;
|
||||
* and step 4 is gated on the key state for the same reason
|
||||
@@ -67,23 +71,6 @@ object SubgroupManager {
|
||||
*/
|
||||
const val MINIMUM_ADMINS: Int = 3
|
||||
|
||||
/**
|
||||
* The NIP-17 room a ceremony for these admins would run in.
|
||||
*
|
||||
* Pure, and the same aggregate `NostrNip17Dao.createNip17ChatRoom` and the
|
||||
* inbound path both derive -- which is what makes a ceremony room findable
|
||||
* without being announced, and what makes two members starting the same
|
||||
* subgroup land in one room rather than two.
|
||||
*
|
||||
* It is also where the one hard limit comes from. A NIP-17 room's id is a
|
||||
* pure function of its members, so **one admin set gets one ceremony room,
|
||||
* forever**: picking a set that already holds a completed ceremony would hand
|
||||
* back that ceremony's key, and the "new" subgroup would be the old group
|
||||
* under a new name. [refuseCeremonyRoom] is where that is caught.
|
||||
*/
|
||||
fun ceremonyRoomIdFor(adminPublicKeys: Set<HexKey>, coordinatorPublicKey: HexKey): String =
|
||||
ChatRoom.deriveChatRoomId(adminPublicKeys + coordinatorPublicKey)
|
||||
|
||||
/**
|
||||
* Why these admins cannot hold a ceremony together, or null when they can.
|
||||
*
|
||||
@@ -94,7 +81,7 @@ object SubgroupManager {
|
||||
* The message is the user's, so each one says what to do rather than what is
|
||||
* wrong.
|
||||
*/
|
||||
suspend fun refuseCeremonyRoom(
|
||||
suspend fun refuseSubgroup(
|
||||
database: MantraDatabase,
|
||||
parentRoom: LocalChatRoom,
|
||||
adminPublicKeys: Set<HexKey>,
|
||||
@@ -130,8 +117,8 @@ object SubgroupManager {
|
||||
// group agreed, and is right on both sides from the first moment.
|
||||
// Named for the parent, because `adminPublicKeys` in this function is the
|
||||
// *subgroup's* picked admins and the two must never be confused -- the
|
||||
// ceremony room is derived from the second, and deriving it from the
|
||||
// first would silently make a different room.
|
||||
// ceremony is opened over the second while running in a room governed by
|
||||
// the first.
|
||||
val parentAdminPublicKeys = runCatching {
|
||||
parentRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys
|
||||
}.getOrNull()
|
||||
@@ -160,23 +147,30 @@ object SubgroupManager {
|
||||
// A subgroup *may* be the whole group. It is a logical division -- a group
|
||||
// deciding that some of its work belongs to a differently-keyed room --
|
||||
// rather than a smaller membership, so "everybody" is a normal answer and
|
||||
// was never this function's business to refuse.
|
||||
// never this function's business to refuse.
|
||||
//
|
||||
// What the old rule was standing in for is real and is checked below,
|
||||
// precisely: the ceremony room is derived from its admins, so a subgroup
|
||||
// over everybody lands in the room the group's own ceremony was held in.
|
||||
// Sharing the room is fine; sharing a *ceremony* is not, and
|
||||
// `DkgSession.parentChatRoomId` is what keeps them apart.
|
||||
// The only thing left to refuse is a ceremony already under way for the
|
||||
// same subgroup, and it is refused because a person cannot answer for two
|
||||
// of them: every admin would be asked twice, on two ladders, for two keys,
|
||||
// one of which nobody will ever make a room from.
|
||||
//
|
||||
// Checking on set size was wrong twice over. It refused a legitimate
|
||||
// subgroup, and it refused it on a proxy: a parent whose membership has
|
||||
// changed since its own ceremony derives a different room, so the sizes
|
||||
// could match with no collision at all, and could differ with one.
|
||||
val ceremonyRoomId = ceremonyRoomIdFor(adminPublicKeys, coordinatorPublicKey)
|
||||
val running = database.dkgSessionDao()
|
||||
.getLatestSessionFor(ceremonyRoomId, parentChatRoomId)
|
||||
if (running != null && running.stage != DkgRitualStage.FAILED) {
|
||||
return "This group already has a subgroup run by exactly these members."
|
||||
// A *finished* one is not refused. It means that subgroup was made, and a
|
||||
// group is entitled to a second run by the same people -- which was
|
||||
// impossible while the ceremony ran in a room derived from its admins,
|
||||
// because asking again handed back the first ceremony's key and the "new"
|
||||
// subgroup was the old one renamed. Moving the ceremony into the parent's
|
||||
// room is what retired that, and this is the whole of what is left.
|
||||
val running = database.dkgSessionDao().getLatestSubgroupSessionFor(
|
||||
chatRoomId = parentChatRoomId,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
participantPublicKeys = DkgSession.formatParticipants(admins).orEmpty()
|
||||
)
|
||||
if (running != null &&
|
||||
running.stage != DkgRitualStage.FAILED &&
|
||||
running.stage != DkgRitualStage.COMPLETE
|
||||
) {
|
||||
return "A key ceremony for a subgroup run by exactly these members is " +
|
||||
"already under way."
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -258,9 +252,9 @@ object SubgroupManager {
|
||||
* [subgroupChatRoomId], or null if this device holds none.
|
||||
*
|
||||
* **Two certificates for one child is a normal outcome, not a conflict.** Two
|
||||
* parent admins can press the button on the same admin set: the second lands
|
||||
* in the same ceremony room and gets the first's ceremony back, but both may
|
||||
* go on to propose a certificate and both sessions can complete.
|
||||
* parent admins can press the button on the same admin set: the second's
|
||||
* `proposeRitual` folds into the ceremony the first opened, but both may go on
|
||||
* to propose a certificate and both sessions can complete.
|
||||
* `GroupSignedEvent` is keyed on the event id, so the rows coexist -- and
|
||||
* because both say the same true thing about the same child, which one wins
|
||||
* does not matter. The `d` tag makes them replacements of each other rather
|
||||
@@ -286,9 +280,15 @@ object SubgroupManager {
|
||||
* Step 3: asks the child's quorum to sign its key state, carrying the
|
||||
* certificate.
|
||||
*
|
||||
* Runs where the ceremony ran, because it has to: `GroupKeyStateManager.propose`
|
||||
* and `FrostSigningManager.signingPath` both tie a key state to the room its
|
||||
* ceremony was held in, and the room the state is *about* does not exist yet.
|
||||
* Runs where the ceremony ran -- the parent's room -- because it has to:
|
||||
* `GroupKeyStateManager.propose` and `FrostSigningManager.signingPath` both tie
|
||||
* a key state to the room its ceremony was held in, and the room the state is
|
||||
* *about* does not exist yet.
|
||||
*
|
||||
* Signed by the **child's** quorum for all that, not the parent's. The room is
|
||||
* only the transport; who signs comes from the ceremony's host keys, and the
|
||||
* key it signs with is the one that ceremony produced. A parent admin outside
|
||||
* the subgroup reads the messages and holds no share to answer them with.
|
||||
*
|
||||
* Refuses without a certificate that passes `certifies`. Every device that
|
||||
* receives the state runs the same check and drops it when it fails, so
|
||||
|
||||
@@ -95,7 +95,7 @@ interface ChatRepository {
|
||||
*
|
||||
* Read by the picker so a subgroup that could only fail is never offered, and
|
||||
* again on confirm, because a screen not drawing something is not a guard.
|
||||
* See `SubgroupManager.refuseCeremonyRoom`.
|
||||
* See `SubgroupManager.refuseSubgroup`.
|
||||
*/
|
||||
suspend fun refuseSubgroup(
|
||||
parentChatRoomId: String,
|
||||
|
||||
@@ -25,9 +25,8 @@ interface DkgRepository {
|
||||
* [parentChatRoomId] is null, or that parent's subgroup when it is not.
|
||||
* Abandoned attempts are superseded by the newest.
|
||||
*
|
||||
* A room can hold two: a subgroup whose admins are the whole group derives
|
||||
* the room the group's own ceremony ran in. See
|
||||
* `DkgSessionDao.getLatestSessionFor`.
|
||||
* A room routinely holds several: a parent's room hosts the ceremony of every
|
||||
* subgroup the group makes. See `DkgSessionDao.getLatestSessionFor`.
|
||||
*/
|
||||
fun observeLatestSessionForChatRoom(
|
||||
chatRoomId: String,
|
||||
@@ -67,18 +66,26 @@ interface DkgRepository {
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/**
|
||||
* Opens a ritual. Only meaningful for the room's creator.
|
||||
* Opens a ritual in [localChatRoom], making this device the coordinator.
|
||||
*
|
||||
* [parentChatRoomId] says what the ceremony is for when it is for a subgroup.
|
||||
* It rides on the proposal and is believed by nobody -- see
|
||||
* `DkgSession.parentChatRoomId`.
|
||||
*
|
||||
* [participantPublicKeys] and [subject] are what a subgroup needs and a
|
||||
* group's own ceremony does not: the room it runs in is the *parent's*, so
|
||||
* the room can say neither who the ceremony is with nor what the room it
|
||||
* makes is to be called. Both default to the room, which is the right answer
|
||||
* for a group opening its own.
|
||||
*/
|
||||
suspend fun proposeRitual(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey? = null
|
||||
parentChatRoomId: HexKey? = null,
|
||||
participantPublicKeys: Set<HexKey>? = null,
|
||||
subject: String? = null
|
||||
): DkgSession?
|
||||
|
||||
/** What the ritual is waiting on this device's owner for, if anything. */
|
||||
@@ -96,8 +103,8 @@ interface DkgRepository {
|
||||
* Asks the group to sign a statement of which ceremony's key its admin
|
||||
* room will sign with, before that room is created.
|
||||
*
|
||||
* Called once by the ceremony's coordinator, in the NIP-17 room the ceremony
|
||||
* ran in. What comes back is the signing session, not the state: the state
|
||||
* Called once by the ceremony's coordinator, in the room the ceremony ran
|
||||
* in. What comes back is the signing session, not the state: the state
|
||||
* exists when a quorum has signed, which is the point of proposing it rather
|
||||
* than announcing it. Null if the ritual has produced no key yet, or if this
|
||||
* room has no standing to propose one -- both of which mean there is nothing
|
||||
@@ -112,10 +119,11 @@ interface DkgRepository {
|
||||
/**
|
||||
* The signing sessions open in a room, newest first, with what each signs.
|
||||
*
|
||||
* Read by the ritual screen to follow the one session a NIP-17 room ever
|
||||
* holds: the group agreeing its key state. The items travel with the session
|
||||
* because "which session is this" is answered by the kind of the event it is
|
||||
* signing, not by the row.
|
||||
* Read by the ritual screen to follow the session the group's key state is
|
||||
* agreed in. The items travel with the session because "which session is
|
||||
* this" is answered by the kind of the event it is signing, not by the row --
|
||||
* which is what lets the same reading work in a parent's room, where a
|
||||
* subgroup's key state is one session among however many the room is running.
|
||||
*/
|
||||
fun observeSigningSessions(chatRoomId: String): Flow<List<LocalFrostSigningSession>>
|
||||
|
||||
@@ -149,10 +157,10 @@ interface DkgRepository {
|
||||
/**
|
||||
* Asks the parent's quorum to certify the subgroup this ceremony produced.
|
||||
*
|
||||
* Runs in the **parent's** room, not the ceremony's, so what comes back is
|
||||
* signed by the parent's key and authored by the parent's own room id -- the
|
||||
* one thing a lineage can be checked against. See
|
||||
* `SubgroupManager.proposeBirthCertificate`.
|
||||
* Signed by the parent's key and authored by the parent's own room id -- the
|
||||
* one thing a lineage can be checked against. The ceremony runs in that room
|
||||
* too now, so this is no longer a change of venue, only of whose quorum is
|
||||
* being asked. See `SubgroupManager.proposeBirthCertificate`.
|
||||
*/
|
||||
suspend fun proposeBirthCertificate(
|
||||
parentChatRoomId: String,
|
||||
@@ -210,7 +218,9 @@ interface DkgRepository {
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey?
|
||||
parentChatRoomId: HexKey?,
|
||||
participantPublicKeys: Set<HexKey>?,
|
||||
subject: String?
|
||||
): DkgSession? = null
|
||||
|
||||
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = null
|
||||
|
||||
+14
-8
@@ -590,9 +590,16 @@ fun ChatRoomDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// A shared threshold key only means something where every
|
||||
// member is an equal admin, which is the NIP-17 (robust) case.
|
||||
// MLS rooms have one admin and no group key to share.
|
||||
// Offering a ceremony to a room is offering it a key of
|
||||
// its own, and only a NIP-17 room is in a position to
|
||||
// want one: a Marmot room's id is derived from a key it
|
||||
// already has, so a second ceremony there would produce
|
||||
// a key the room could never answer to.
|
||||
//
|
||||
// A Marmot room does host ceremonies -- its subgroups'
|
||||
// -- and those are reached from the transcript line that
|
||||
// names the ceremony they are about, never from here,
|
||||
// which has no way to say which.
|
||||
if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState == null) {
|
||||
item {
|
||||
TextButton(
|
||||
@@ -619,11 +626,10 @@ fun ChatRoomDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Every room, not only the one that holds the key. A
|
||||
// ceremony needs a NIP-17 group, but a signing message is
|
||||
// a marmot inner event -- see `FrostSigningManager.broadcast`
|
||||
// -- so the room a group actually proposes in is the MLS
|
||||
// one, which is the last place this should be missing from.
|
||||
// Every room, not only the one that holds the key. Most
|
||||
// of what a group signs it signs in its Marmot room --
|
||||
// its subgroups' certificates and key states included --
|
||||
// so that is the last place this should be missing from.
|
||||
//
|
||||
// The transcript carries a proposal past as it happens;
|
||||
// this is where a member goes to find one that has scrolled
|
||||
|
||||
+25
-18
@@ -443,13 +443,17 @@ class DkgRitualViewModel(
|
||||
parentChatRoomId = parent,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
session = session,
|
||||
// The ceremony's participants, not the room's members. The
|
||||
// ceremony runs in the parent's room, so the room's roster is
|
||||
// everybody the subgroup is *not* -- putting it here would ask the
|
||||
// parent to certify itself as its own child.
|
||||
adminPublicKeys = ChillDkgRitualManager
|
||||
.memberPublicKeys(loaded.localChatRoom)
|
||||
.participantsOf(session, loaded.localChatRoom)
|
||||
.toList(),
|
||||
// The ceremony room was created with the name the coordinator
|
||||
// typed, and the proposal carried it to everyone else -- so this
|
||||
// is the same name every admin has been looking at.
|
||||
name = loaded.localChatRoom.chatRoom.subject ?: "Subgroup"
|
||||
// The name the coordinator typed, carried to every other admin on
|
||||
// the proposal -- so this is the same name they have all been
|
||||
// looking at, and the one the parent's quorum is about to sign.
|
||||
name = session.subject ?: "Subgroup"
|
||||
)
|
||||
|
||||
isActionPending.value = false
|
||||
@@ -535,9 +539,10 @@ class DkgRitualViewModel(
|
||||
* Opens the group's admin room: a Marmot/MLS chat whose every member is an
|
||||
* admin, keyed on an id derived from the shared key.
|
||||
*
|
||||
* The room the ceremony ran in is NIP-17, where nobody administers anything.
|
||||
* This gives the same people a room where every one of them can act, which is
|
||||
* the shape a group that has just made a t-of-n key is asking for.
|
||||
* The room the ceremony ran in is somebody else's -- a NIP-17 room where
|
||||
* nobody administers anything, or the parent whose subgroup this is. This
|
||||
* gives the people who hold shares a room where every one of them can act,
|
||||
* which is the shape a group that has just made a t-of-n key is asking for.
|
||||
*
|
||||
* Derived rather than random, unlike every other Marmot room. Every member's
|
||||
* device can compute the id from the ceremony they all took part in, so the
|
||||
@@ -546,10 +551,10 @@ class DkgRitualViewModel(
|
||||
* early to the existing room rather than minting a second one.
|
||||
*
|
||||
* Only once the group has signed what the room signs with. That statement is
|
||||
* agreed in the NIP-17 room the ceremony ran in, before there is a Marmot
|
||||
* room to hold it -- see `GroupKeyStateManager.propose` -- so by here it is
|
||||
* a fact the room is created *knowing*, rather than the first thing it has
|
||||
* to go and ask about.
|
||||
* agreed in the room the ceremony ran in, before there is a room of its own
|
||||
* to hold it -- see `GroupKeyStateManager.propose` -- so by here it is a fact
|
||||
* the room is created *knowing*, rather than the first thing it has to go and
|
||||
* ask about.
|
||||
*/
|
||||
fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) {
|
||||
if (isActionPending.value) return
|
||||
@@ -574,17 +579,19 @@ class DkgRitualViewModel(
|
||||
// Everyone the ceremony was run with, this device included. These are the
|
||||
// admins: the whole point of the room is that the members who hold shares of
|
||||
// the key can all act in it.
|
||||
val members = ChillDkgRitualManager.memberPublicKeys(loaded.localChatRoom)
|
||||
//
|
||||
// Off the ceremony rather than off the room, because for a subgroup the
|
||||
// room is the parent's and its roster is the wrong set entirely.
|
||||
val members = ChillDkgRitualManager.participantsOf(session, loaded.localChatRoom)
|
||||
|
||||
// A subgroup is called what the coordinator called it; an admin room is
|
||||
// called after the group it administers, because it has no name of its
|
||||
// own to be given. Either way the bare name goes down and
|
||||
// `MarmotGroupCreation` puts it under `MarmotGroupName`, so the NIP-17
|
||||
// room the ceremony ran in stays `Ekklesia` and the Marmot room it stands
|
||||
// up is `#Ekklesia`.
|
||||
// `MarmotGroupCreation` puts it under `MarmotGroupName`, so a NIP-17 room
|
||||
// called `Ekklesia` stands up a Marmot room called `#Ekklesia`.
|
||||
val subgroupParent = loaded.parentChatRoomId
|
||||
|
||||
val name = loaded.localChatRoom.chatRoom.subject
|
||||
val name = session.subject
|
||||
?: if (subgroupParent != null) "Subgroup" else "Group"
|
||||
|
||||
isActionPending.value = true
|
||||
@@ -597,7 +604,7 @@ class DkgRitualViewModel(
|
||||
purpose = if (subgroupParent != null) {
|
||||
"A subgroup of the group that certified it."
|
||||
} else {
|
||||
"Admins of ${loaded.localChatRoom.chatRoom.subject ?: "the group"}."
|
||||
"Admins of ${session.subject ?: "the group"}."
|
||||
},
|
||||
adminPublicKeys = members,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
|
||||
+38
-45
@@ -20,9 +20,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import press.mantra.compose.database.model.types.ChatRoomType
|
||||
import press.mantra.compose.managers.ChillDkgRitualManager
|
||||
import press.mantra.compose.managers.MarmotGroupCreation
|
||||
import press.mantra.compose.managers.MarmotGroupName
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.DkgRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
|
||||
@@ -44,9 +42,10 @@ import press.mantra.compose.ui.view.state.SelectSubgroupAdminsUIState
|
||||
* `quorumRange` and drops a proposal outside it, and the host-key approval gate is
|
||||
* where each of them agrees to the `t`-of-`n` they can now see.
|
||||
*
|
||||
* Confirming opens the ceremony -- a NIP-17 room over the picked admins, and a
|
||||
* ritual proposal in it -- and hands over to `DkgRitualViewModel`, which drives
|
||||
* the remaining three steps. See `docs/subgroups.md`.
|
||||
* Confirming opens the ceremony -- a ritual proposal in the **parent's** room,
|
||||
* p-tagged to the picked admins -- and hands back to the parent's transcript,
|
||||
* where every admin answers the ceremony from the same place. See
|
||||
* `docs/subgroups.md`.
|
||||
*/
|
||||
class SelectSubgroupAdminsViewModel(
|
||||
val activeUserPublicKey: HexKey,
|
||||
@@ -191,12 +190,14 @@ class SelectSubgroupAdminsViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the ceremony and hands over to the ritual screen.
|
||||
* Opens the ceremony in the parent's room and hands back to its transcript.
|
||||
*
|
||||
* The room is stood up first and the proposal is the first thing out of it,
|
||||
* exactly as a robust group is created: standing up a NIP-17 room sends
|
||||
* nothing to anybody, so the proposal is how the other admins hear of it at
|
||||
* all -- which is why it carries the name and the parent.
|
||||
* No sibling room is made. The ceremony runs where the parent's members
|
||||
* already are: the proposal names the picked admins in its p-tags, which is
|
||||
* the participant set every device derives `n` from, and a parent member who
|
||||
* is not in it drops the proposal rather than joining. It carries the name
|
||||
* and the parent for the same reason it always did -- the child has no room
|
||||
* of its own to read either off until the ceremony has produced its key.
|
||||
*/
|
||||
fun confirm(onNavigateToRoute: (Route) -> Unit) {
|
||||
if (isActionPending.value) return
|
||||
@@ -223,45 +224,36 @@ class SelectSubgroupAdminsViewModel(
|
||||
return@launch
|
||||
}
|
||||
|
||||
// The ceremony room keeps the bare name. It is a NIP-17 room -- where
|
||||
// the subgroup is made, not the subgroup -- and the `#` is what tells
|
||||
// those two rows apart afterwards, so putting it on both would spend
|
||||
// the mark to say nothing.
|
||||
// The bare name goes on the ceremony, and the `#` is added where the
|
||||
// name is used: `proposeBirthCertificate` normalises the name the
|
||||
// parent signs and `MarmotGroupCreation` normalises the name the room
|
||||
// carries, both reading it back off this session.
|
||||
// `MarmotGroupName.of` is idempotent, so those are the same string --
|
||||
// which they have to be, or the room is not called what its parent
|
||||
// certified.
|
||||
//
|
||||
// The subgroup gets it twice over, and never from here:
|
||||
// `proposeBirthCertificate` normalises the name the parent signs and
|
||||
// `MarmotGroupCreation` normalises the name the room carries, both
|
||||
// reading this subject. `MarmotGroupName.of` is idempotent, so those
|
||||
// are the same string -- which they have to be, or the room is not
|
||||
// called what its parent certified.
|
||||
val ceremonyRoom = chatRepository.createNip17ChatRoom(
|
||||
userPublicKey = activeUserPublicKey,
|
||||
participantPublicKeys = selectedPublicKeys.toList(),
|
||||
subject = name.value.trim(),
|
||||
description = "Making ${MarmotGroupName.of(name.value)} a subgroup of " +
|
||||
(loaded.parentRoom.chatRoom.subject ?: "this group") + "."
|
||||
)
|
||||
|
||||
if (ceremonyRoom == null) {
|
||||
isActionPending.value = false
|
||||
uiState = SelectSubgroupAdminsUIState.Error(
|
||||
"Couldn't start the subgroup's key ceremony. Please try again."
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Carried on the session rather than on a room, because there is no
|
||||
// longer a room to carry it: the ceremony is held in the parent's,
|
||||
// and the parent's name is the one name a child must not take.
|
||||
val session = dkgRepository.proposeRitual(
|
||||
localChatRoom = ceremonyRoom,
|
||||
localChatRoom = loaded.parentRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
nostrPrivateKey = nostrPrivateKey.value.toByteArray(),
|
||||
threshold = threshold.value,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
// The admins, and only them. The room is the parent's, so the
|
||||
// ceremony has to say who it is with -- this is the set that
|
||||
// becomes the proposal's p-tags and every device's `n`. The
|
||||
// coordinator is added by `proposeRitual`: opening a ceremony is
|
||||
// being in it, and is not theirs to tick.
|
||||
participantPublicKeys = selectedPublicKeys.toSet(),
|
||||
subject = name.value.trim()
|
||||
)
|
||||
|
||||
isActionPending.value = false
|
||||
|
||||
if (session == null) {
|
||||
logger.e("Created ${ceremonyRoom.chatRoom.id} without a key ceremony")
|
||||
logger.e("Could not open a subgroup ceremony in $parentChatRoomId")
|
||||
uiState = SelectSubgroupAdminsUIState.Error(
|
||||
"Couldn't start the subgroup's key ceremony. Please try again."
|
||||
)
|
||||
@@ -269,11 +261,12 @@ class SelectSubgroupAdminsViewModel(
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// The transcript, not the shared-key screen. The ceremony is
|
||||
// already open and its first line is already in this room, so what
|
||||
// the coordinator needs is to watch it and answer the requests as
|
||||
// they arrive -- which is the same path every other member takes,
|
||||
// through the ritual notices that name their own ceremony.
|
||||
// The parent's transcript, not the shared-key screen. The ceremony
|
||||
// is already open and its first line is already there, so what the
|
||||
// coordinator needs is to watch it and answer the requests as they
|
||||
// arrive -- which is now literally the same place every other
|
||||
// admin answers from, through the ritual notices that name their
|
||||
// own ceremony.
|
||||
//
|
||||
// Sending them to the shared-key screen instead was actively
|
||||
// harmful: that screen's job is to *offer* a ceremony to a room
|
||||
@@ -285,7 +278,7 @@ class SelectSubgroupAdminsViewModel(
|
||||
onNavigateToRoute(
|
||||
ChatRoomMessagingRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = ceremonyRoom.chatRoom.id,
|
||||
chatRoomId = parentChatRoomId,
|
||||
relayHint = null
|
||||
)
|
||||
)
|
||||
|
||||
+33
-10
@@ -102,20 +102,43 @@ sealed interface DkgRitualUIState {
|
||||
/**
|
||||
* Everyone the ceremony involves, as far as this device can tell.
|
||||
*
|
||||
* The room's members, plus anyone who has actually taken part. The union
|
||||
* matters because the two can disagree: `n` is fixed from the proposal's
|
||||
* p-tags, while the room's rows are local and can drift, and a participant
|
||||
* who has published a host key is in the ceremony whatever the room says.
|
||||
* The ceremony's own participants, plus anyone who has actually taken
|
||||
* part. The union matters because the two can disagree: `n` is fixed from
|
||||
* the proposal's p-tags, and a participant who has published a host key is
|
||||
* in the ceremony whatever any local row says.
|
||||
*
|
||||
* The **ceremony's** set, not the room's, and the difference is the whole
|
||||
* ladder. A subgroup's ceremony runs in the parent's room, so the room's
|
||||
* roster would draw a row per parent member and report the ceremony
|
||||
* waiting on people it was never with. `participantsOf` falls back to the
|
||||
* room for a ceremony recorded before that set was stored, which is right
|
||||
* for those: they ran in a room whose members were exactly their
|
||||
* participants.
|
||||
*
|
||||
* Profiles still come off the room, since the room is where the rows with
|
||||
* names in them are -- for a subgroup that is the parent, which holds
|
||||
* every admin the ceremony can have.
|
||||
*/
|
||||
val ritualMembers: List<LocalParticipant> get() {
|
||||
val known = localChatRoom.localParticipants.distinctBy { it.participant.participantPublicKey }
|
||||
val knownKeys = known.map { it.participant.participantPublicKey }.toSet()
|
||||
val roomMembers = localChatRoom.localParticipants
|
||||
.distinctBy { it.participant.participantPublicKey }
|
||||
.associateBy { it.participant.participantPublicKey }
|
||||
|
||||
val strangers = (hostKeyParticipants + round1Participants + round2Participants)
|
||||
.filterNot { it in knownKeys }
|
||||
.map { LocalParticipant(Participant(participantPublicKey = it, chatRoomId = localChatRoom.chatRoom.id, relayHint = null), null) }
|
||||
val participants = session
|
||||
?.let { ChillDkgRitualManager.participantsOf(it, localChatRoom) }
|
||||
?: roomMembers.keys
|
||||
|
||||
return known + strangers
|
||||
return (participants + hostKeyParticipants + round1Participants + round2Participants)
|
||||
.map { publicKey ->
|
||||
roomMembers[publicKey] ?: LocalParticipant(
|
||||
Participant(
|
||||
participantPublicKey = publicKey,
|
||||
chatRoomId = localChatRoom.chatRoom.id,
|
||||
relayHint = null
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
package press.mantra.compose.managers
|
||||
|
||||
import androidx.room3.Room
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
|
||||
import fr.acinq.bitcoin.ByteVector32
|
||||
import fr.acinq.bitcoin.PrivateKey
|
||||
import fr.acinq.bitcoin.crypto.frost.Frost
|
||||
import fr.acinq.bitcoin.crypto.frost.KeyMaterial
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Instant
|
||||
import press.mantra.compose.database.MantraDatabase
|
||||
import press.mantra.compose.database.builder.getRoomDatabase
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.DkgParticipantMessage
|
||||
import press.mantra.compose.database.model.DkgSession
|
||||
import press.mantra.compose.database.model.MarmotInnerEvent
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Participant
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.database.model.types.DkgApprovalStep
|
||||
import press.mantra.compose.database.model.types.DkgRitualStage
|
||||
import press.mantra.compose.extensions.toHex
|
||||
import press.mantra.compose.nostr.Relays
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
|
||||
/**
|
||||
* A subgroup's key ceremony, held in the room of the group making it.
|
||||
*
|
||||
* It used to be held in a sibling NIP-17 room whose id was an aggregate of the
|
||||
* child's admins. That bought a transport tolerant of reordering, and it cost a
|
||||
* room per subgroup and a hard limit -- one admin set could hold one subgroup
|
||||
* ever, because asking again derived the same room and handed back the same key.
|
||||
* Now the ceremony runs where the parent already is, and everything the sibling
|
||||
* room used to answer has to be answered by the session instead.
|
||||
*
|
||||
* Three of those answers are what this covers, because each one silently reads
|
||||
* the wrong thing if it comes off the room:
|
||||
*
|
||||
* - **who the ceremony is with** is a subset of the parent's members, so the
|
||||
* room's roster names every person the subgroup is not;
|
||||
* - **what the room it makes is called** is not the parent's name, which is the
|
||||
* one name a child must not take;
|
||||
* - **what key the parent signs with** is not the child's, which is now a
|
||||
* completed ceremony sitting in the parent's own room.
|
||||
*
|
||||
* The MLS group here is real but not joint: messages are handed to the manager
|
||||
* directly rather than encrypted and decrypted between two trees. That is the
|
||||
* right seam for these -- what is under test is which store a message is queued
|
||||
* in and which set it names, not whether quartz can encrypt it -- and
|
||||
* `SignedGroupKeyStateTest` is where a session is run end to end.
|
||||
*/
|
||||
class SubgroupCeremonyInParentRoomTest {
|
||||
|
||||
private val parent: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
/** A second device's database, so a proposal can be received rather than made. */
|
||||
private val receiver: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() {
|
||||
parent.close()
|
||||
receiver.close()
|
||||
}
|
||||
|
||||
/** The parent's own key, which its room's id is derived from. */
|
||||
private val parentMaterial: KeyMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
|
||||
),
|
||||
nParticipants = 4,
|
||||
threshold = 3
|
||||
)
|
||||
|
||||
private val parentRoomId =
|
||||
SharedKeyDerivation.marmotGroupId(parentMaterial.thresholdPublicKey.value.toHex())
|
||||
|
||||
// Real keys throughout: a host key is derived from a nostr secret, and the
|
||||
// participant set is sorted by hex, so filler exercises neither.
|
||||
private val coordinator = KeyPair()
|
||||
private val user = coordinator.pubKey.toHexKey()
|
||||
|
||||
private val aliceKeys = KeyPair()
|
||||
private val alice = aliceKeys.pubKey.toHexKey()
|
||||
private val bob = KeyPair().pubKey.toHexKey()
|
||||
|
||||
/** A parent member who is not picked for the subgroup. */
|
||||
private val mallory = KeyPair()
|
||||
private val malloryPublicKey = mallory.pubKey.toHexKey()
|
||||
|
||||
private val members = listOf(alice, bob, malloryPublicKey)
|
||||
|
||||
/** The subgroup's admins: three of the parent's four members. */
|
||||
private val admins = setOf(alice, bob)
|
||||
|
||||
private val quorum = 2
|
||||
|
||||
private suspend fun openCeremony(room: LocalChatRoom) = ChillDkgRitualManager.proposeRitual(
|
||||
database = parent,
|
||||
localChatRoom = room,
|
||||
userPublicKey = user,
|
||||
nostrPrivateKey = coordinator.privKey!!,
|
||||
threshold = quorum,
|
||||
parentChatRoomId = parentRoomId,
|
||||
participantPublicKeys = admins,
|
||||
subject = "Translation"
|
||||
)
|
||||
|
||||
private suspend fun innerEvents(database: MantraDatabase) = database.marmotInnerEventDao()
|
||||
.getByChatRoomAndKinds(parentRoomId, DkgRitualEvents.ALL.toList())
|
||||
|
||||
/** The rumor as the manager queued it, which is what the other side receives. */
|
||||
private fun MarmotInnerEvent.asEvent() = Event(
|
||||
id = id,
|
||||
pubKey = publicKey,
|
||||
createdAt = createdAt.epochSeconds,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = content,
|
||||
sig = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* The ceremony goes out as group events, and names only the admins.
|
||||
*
|
||||
* Both halves matter. The transport is the change; the p-tags are what stops
|
||||
* the change being a disaster, because they are the participant set every
|
||||
* device derives `n` from -- so leaving them off in a Marmot room, the way
|
||||
* `FrostSigningManager` correctly does, would enrol the whole parent in the
|
||||
* child's signing quorum.
|
||||
*/
|
||||
@Test
|
||||
fun `a subgroup's ceremony rides the parent's transport and names its own admins`(): Unit =
|
||||
runBlocking {
|
||||
val room = parentRoom()
|
||||
|
||||
assertTrue(innerEvents(parent).isEmpty(), "nothing has gone out yet")
|
||||
|
||||
val session = openCeremony(room)
|
||||
|
||||
assertTrue(
|
||||
parent.giftWrapPayloadDao()
|
||||
.getByChatRoomAndKinds(parentRoomId, DkgRitualEvents.ALL.toList())
|
||||
.isEmpty(),
|
||||
"a ceremony in a Marmot room queues no gift wraps",
|
||||
)
|
||||
|
||||
val proposal = assertNotNull(
|
||||
innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL },
|
||||
"the proposal is an inner event for the parent's room",
|
||||
)
|
||||
assertEquals(user, proposal.publicKey)
|
||||
assertEquals(parentRoomId, proposal.chatRoomId)
|
||||
|
||||
// The p-tags are the roster, not an address list -- the sender is in
|
||||
// the set without tagging themselves.
|
||||
assertEquals(
|
||||
admins,
|
||||
proposal.tags.taggedUsers().map { it.pubKey }.toSet(),
|
||||
"the proposal names the picked admins and nobody else",
|
||||
)
|
||||
assertEquals(
|
||||
admins + user,
|
||||
session.participantPublicKeySet(),
|
||||
"and the session records the same set, the coordinator included",
|
||||
)
|
||||
assertEquals(3, session.participantCount)
|
||||
assertEquals(parentRoomId, session.parentChatRoomId)
|
||||
|
||||
// The coordinator's host key goes out on the same transport, since
|
||||
// opening a ceremony is the act of agreeing to be in it.
|
||||
assertEquals(
|
||||
1,
|
||||
innerEvents(parent).count { it.kind == DkgRitualEvents.HOST_KEY },
|
||||
"the coordinator joins the ceremony it opened",
|
||||
)
|
||||
assertNotNull(session.hostKeyApprovedAt)
|
||||
}
|
||||
|
||||
/**
|
||||
* The child's name has nowhere else to live.
|
||||
*
|
||||
* It used to be the ceremony room's subject: a NIP-17 room called `Translation`
|
||||
* whose proposal carried the name to everyone else, and which every later step
|
||||
* read back off the room. The parent's room is called something else, and the
|
||||
* one name a child must not take is its parent's.
|
||||
*/
|
||||
@Test
|
||||
fun `the subgroup's name travels on the proposal and lands on the session`(): Unit = runBlocking {
|
||||
val room = parentRoom()
|
||||
val session = openCeremony(room)
|
||||
|
||||
assertEquals("Translation", session.subject)
|
||||
assertEquals("Ekklesia", room.chatRoom.subject, "the room keeps its own name")
|
||||
|
||||
val proposal = assertNotNull(
|
||||
innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL }
|
||||
)
|
||||
assertEquals("Translation", DkgRitualEvents.parseSubject(proposal.tags))
|
||||
|
||||
// Only on the proposal. Every later message belongs to a session the
|
||||
// receiver already has this on, and a later say in what a ceremony makes
|
||||
// would be a second answer to a settled question.
|
||||
innerEvents(parent).filterNot { it.kind == DkgRitualEvents.PROPOSAL }.forEach {
|
||||
assertNull(DkgRitualEvents.parseSubject(it.tags), "kind ${it.kind} repeats the name")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everybody in the parent's room can read the proposal. Only the admins are in
|
||||
* it.
|
||||
*
|
||||
* This is the price of the parent's transport and the thing that makes it
|
||||
* safe: an MLS message reaches the whole tree, so the ceremony has to say who
|
||||
* it is with, and a member who is not named drops it rather than joining. A
|
||||
* member who published a host key would be in the group's permanent signing
|
||||
* quorum, so "reads the message" and "is in the ceremony" must not be the same
|
||||
* question.
|
||||
*/
|
||||
@Test
|
||||
fun `a parent member who was not picked drops the proposal`(): Unit = runBlocking {
|
||||
val room = parentRoom()
|
||||
val session = openCeremony(room)
|
||||
|
||||
val proposal = assertNotNull(
|
||||
innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL }
|
||||
)
|
||||
|
||||
receiverRoom()
|
||||
ChillDkgRitualManager.processRitualPayload(
|
||||
database = receiver,
|
||||
localChatRoom = assertNotNull(receiver.chatRoomDao().findChatRoomById(parentRoomId)),
|
||||
innerEvent = proposal.asEvent(),
|
||||
userPublicKey = malloryPublicKey,
|
||||
nostrPrivateKey = mallory.privKey!!
|
||||
)
|
||||
|
||||
assertNull(
|
||||
receiver.dkgSessionDao().getSessionById(session.id),
|
||||
"a member outside the p-tags opens no session",
|
||||
)
|
||||
assertTrue(
|
||||
innerEvents(receiver).isEmpty(),
|
||||
"and publishes nothing, so their host key never joins the quorum",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A picked admin joins, on the same transport, and only when they say so.
|
||||
*
|
||||
* The approval gate is unchanged by the move and has to stay that way: a
|
||||
* relay delivering a group event to a phone in a pocket must not enrol its
|
||||
* owner in a permanent signing quorum.
|
||||
*/
|
||||
@Test
|
||||
fun `a picked admin joins the ceremony, and not before approving`(): Unit = runBlocking {
|
||||
val room = parentRoom()
|
||||
openCeremony(room)
|
||||
|
||||
val proposal = assertNotNull(
|
||||
innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL }
|
||||
)
|
||||
|
||||
val theirRoom = receiverRoom()
|
||||
ChillDkgRitualManager.processRitualPayload(
|
||||
database = receiver,
|
||||
localChatRoom = theirRoom,
|
||||
innerEvent = proposal.asEvent(),
|
||||
userPublicKey = alice,
|
||||
nostrPrivateKey = aliceKeys.privKey!!
|
||||
)
|
||||
|
||||
val joined = assertNotNull(
|
||||
receiver.dkgSessionDao().getLatestSessionForChatRoom(parentRoomId),
|
||||
"a picked admin records the ceremony",
|
||||
)
|
||||
assertEquals(3, joined.participantCount)
|
||||
assertEquals(quorum, joined.threshold)
|
||||
assertEquals(parentRoomId, joined.parentChatRoomId)
|
||||
assertEquals("Translation", joined.subject)
|
||||
assertEquals(admins + user, joined.participantPublicKeySet())
|
||||
|
||||
assertTrue(innerEvents(receiver).isEmpty(), "nothing goes out before they agree")
|
||||
assertEquals(
|
||||
DkgApprovalStep.HOST_KEY,
|
||||
ChillDkgRitualManager.pendingApproval(receiver, joined),
|
||||
)
|
||||
|
||||
ChillDkgRitualManager.approve(
|
||||
database = receiver,
|
||||
localChatRoom = theirRoom,
|
||||
sessionId = joined.id,
|
||||
step = DkgApprovalStep.HOST_KEY,
|
||||
nostrPrivateKey = aliceKeys.privKey!!
|
||||
)
|
||||
|
||||
val published = innerEvents(receiver)
|
||||
assertEquals(1, published.size, "one message, and it is an inner event")
|
||||
assertEquals(DkgRitualEvents.HOST_KEY, published.first().kind)
|
||||
assertEquals(
|
||||
admins + user - alice,
|
||||
published.first().tags.taggedUsers().map { it.pubKey }.toSet(),
|
||||
"a later message carries the roster too, minus its own sender",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A message that beat the proposal is replayed out of the store its transport
|
||||
* writes to.
|
||||
*
|
||||
* The ritual's own resume machinery is what makes it safe to run anywhere:
|
||||
* every payload is stored as it decrypts and read back once there is a session
|
||||
* to file it under. That lookup has to make the same reading of the room that
|
||||
* `broadcast` does -- a ceremony in a Marmot room has its backlog in the inner
|
||||
* events and none at all in the gift wraps, and looking in the wrong one is a
|
||||
* stall with nothing to blame it on.
|
||||
*/
|
||||
@Test
|
||||
fun `a host key that arrived before the proposal is replayed`(): Unit = runBlocking {
|
||||
val room = parentRoom()
|
||||
val session = openCeremony(room)
|
||||
|
||||
val proposal = assertNotNull(
|
||||
innerEvents(parent).firstOrNull { it.kind == DkgRitualEvents.PROPOSAL }
|
||||
)
|
||||
|
||||
val theirRoom = receiverRoom()
|
||||
|
||||
// Bob's host key, stored by the inbound path and dropped at the time for
|
||||
// want of a session to file it under.
|
||||
val early = MarmotInnerEvent(
|
||||
id = "ee".repeat(32),
|
||||
publicKey = bob,
|
||||
kind = DkgRitualEvents.HOST_KEY,
|
||||
createdAt = Instant.fromEpochSeconds(1),
|
||||
tags = DkgRitualEvents.assembleTags(sessionId = session.id),
|
||||
content = "02".repeat(33),
|
||||
chatRoomId = parentRoomId
|
||||
)
|
||||
receiver.marmotInnerEventDao().upsert(early)
|
||||
|
||||
ChillDkgRitualManager.processRitualPayload(
|
||||
database = receiver,
|
||||
localChatRoom = theirRoom,
|
||||
innerEvent = proposal.asEvent(),
|
||||
userPublicKey = alice,
|
||||
nostrPrivateKey = aliceKeys.privKey!!
|
||||
)
|
||||
|
||||
assertNotNull(
|
||||
receiver.dkgSessionDao().getMessage(session.id, DkgRitualEvents.HOST_KEY, bob),
|
||||
"the message that lost the race is not lost",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent does not start signing as its own child.
|
||||
*
|
||||
* `completedKey`'s last fallback is a ceremony held in this very room, and it
|
||||
* is load-bearing: it is what every member welcomed after the group's own
|
||||
* ceremony falls back to, since they hold no key state and no share. The
|
||||
* parent's room now holds a completed ceremony whose key belongs to the child,
|
||||
* and handing that back would have the parent author events as its subgroup,
|
||||
* silently.
|
||||
*/
|
||||
@Test
|
||||
fun `a subgroup's completed ceremony is not the parent's key`(): Unit = runBlocking {
|
||||
// A member with no key state and no share of the parent's key: exactly the
|
||||
// position that reaches the fallback.
|
||||
parentRoom(withOwnKey = false)
|
||||
|
||||
val childMaterial = Frost.trustedDealerKeygen(
|
||||
thresholdSecretKey = PrivateKey(
|
||||
ByteVector32("2bada550000000000000000000000000000000000000000000000000000000b2")
|
||||
),
|
||||
nParticipants = 3,
|
||||
threshold = 2
|
||||
)
|
||||
|
||||
parent.dkgSessionDao().upsert(
|
||||
DkgSession(
|
||||
id = "child-ceremony",
|
||||
chatRoomId = parentRoomId,
|
||||
coordinatorPublicKey = user,
|
||||
userPublicKey = user,
|
||||
threshold = quorum,
|
||||
participantCount = 3,
|
||||
participantPublicKeys = DkgSession.formatParticipants(admins + user),
|
||||
subject = "Translation",
|
||||
stage = DkgRitualStage.COMPLETE,
|
||||
hostPublicKey = user,
|
||||
round1Random = "aa".repeat(32),
|
||||
round2AuxRandom = "bb".repeat(32),
|
||||
thresholdPublicKey = childMaterial.thresholdPublicKey.value.toHex(),
|
||||
secretShare = "cc".repeat(32),
|
||||
parentChatRoomId = parentRoomId
|
||||
)
|
||||
)
|
||||
|
||||
assertNull(
|
||||
FrostSigningManager.completedKey(parent, parentRoomId),
|
||||
"a ceremony run to make a subgroup is never the room's own key",
|
||||
)
|
||||
|
||||
// And the child's own room still resolves it, by rederiving the id from
|
||||
// the key -- which is the check that actually binds a room to a key.
|
||||
assertEquals(
|
||||
"child-ceremony",
|
||||
FrostSigningManager.completedKey(
|
||||
parent,
|
||||
SharedKeyDerivation.marmotGroupId(
|
||||
childMaterial.thresholdPublicKey.value.toHex()
|
||||
)
|
||||
)?.id,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent's admin room, as the device that made it holds it.
|
||||
*
|
||||
* [withOwnKey] seeds the group's own completed ceremony, which is how a real
|
||||
* admin room resolves the key it signs with. Turned off for the member
|
||||
* welcomed later, who holds neither a share nor a key state.
|
||||
*/
|
||||
private suspend fun parentRoom(withOwnKey: Boolean = true): LocalChatRoom {
|
||||
(members + user).forEach { seedProfile(parent, it) }
|
||||
|
||||
val metadata = MarmotGroupData(
|
||||
nostrGroupId = parentRoomId,
|
||||
name = "Ekklesia",
|
||||
description = SharedKeyDerivation.describe("Admins of Ekklesia."),
|
||||
adminPubkeys = members + user,
|
||||
relays = Relays.DefaultDMRelayList.map { it.url }
|
||||
)
|
||||
val mlsGroup = MlsGroup.create(
|
||||
KeyPair().pubKey,
|
||||
Ed25519.generateKeyPair().privateKey,
|
||||
listOf(metadata.toExtension())
|
||||
)
|
||||
|
||||
parent.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = parentRoomId,
|
||||
userPublicKey = user,
|
||||
subject = "Ekklesia",
|
||||
description = metadata.description,
|
||||
mlsGroupState = mlsGroup.saveState().encodeTls().toHex(),
|
||||
)
|
||||
)
|
||||
parent.participantDao().upsert(
|
||||
(members + user).map {
|
||||
Participant(participantPublicKey = it, chatRoomId = parentRoomId, relayHint = null)
|
||||
}
|
||||
)
|
||||
|
||||
if (withOwnKey) {
|
||||
parent.dkgSessionDao().upsert(
|
||||
DkgSession(
|
||||
id = "parent-ceremony",
|
||||
// Filed under the parent's own room. In life it ran in the
|
||||
// NIP-17 room the group's members shared, and either way it
|
||||
// claims no parent -- which is the only thing that has to be
|
||||
// true for it to be found as the room's own key.
|
||||
chatRoomId = parentRoomId,
|
||||
coordinatorPublicKey = user,
|
||||
userPublicKey = user,
|
||||
threshold = 3,
|
||||
participantCount = 4,
|
||||
stage = DkgRitualStage.COMPLETE,
|
||||
hostPublicKey = user,
|
||||
round1Random = "aa".repeat(32),
|
||||
round2AuxRandom = "bb".repeat(32),
|
||||
thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(),
|
||||
secretShare = "cc".repeat(32),
|
||||
)
|
||||
)
|
||||
(members + user).forEachIndexed { index, member ->
|
||||
parent.dkgSessionDao().upsert(
|
||||
DkgParticipantMessage(
|
||||
sessionId = "parent-ceremony",
|
||||
participantPublicKey = member,
|
||||
kind = DkgRitualEvents.HOST_KEY,
|
||||
payload = "0${index + 1}".repeat(33)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return assertNotNull(parent.chatRoomDao().findChatRoomById(parentRoomId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The same room on somebody else's device.
|
||||
*
|
||||
* Its own MLS group rather than a copy of the coordinator's: nothing here is
|
||||
* encrypted between the two, and a shared state object would make the two
|
||||
* databases one device wearing two hats.
|
||||
*/
|
||||
private suspend fun receiverRoom(): LocalChatRoom {
|
||||
(members + user).forEach { seedProfile(receiver, it) }
|
||||
|
||||
val metadata = MarmotGroupData(
|
||||
nostrGroupId = parentRoomId,
|
||||
name = "Ekklesia",
|
||||
description = SharedKeyDerivation.describe("Admins of Ekklesia."),
|
||||
adminPubkeys = members + user,
|
||||
relays = Relays.DefaultDMRelayList.map { it.url }
|
||||
)
|
||||
val mlsGroup = MlsGroup.create(
|
||||
KeyPair().pubKey,
|
||||
Ed25519.generateKeyPair().privateKey,
|
||||
listOf(metadata.toExtension())
|
||||
)
|
||||
|
||||
receiver.chatRoomDao().upsert(
|
||||
ChatRoom(
|
||||
id = parentRoomId,
|
||||
userPublicKey = alice,
|
||||
subject = "Ekklesia",
|
||||
description = metadata.description,
|
||||
mlsGroupState = mlsGroup.saveState().encodeTls().toHex(),
|
||||
)
|
||||
)
|
||||
receiver.participantDao().upsert(
|
||||
(members + user).map {
|
||||
Participant(participantPublicKey = it, chatRoomId = parentRoomId, relayHint = null)
|
||||
}
|
||||
)
|
||||
|
||||
return assertNotNull(receiver.chatRoomDao().findChatRoomById(parentRoomId))
|
||||
}
|
||||
|
||||
private suspend fun seedProfile(database: MantraDatabase, publicKey: String) {
|
||||
if (database.profileDao().getProfileByPublicKey(publicKey) != null) return
|
||||
|
||||
val nostrEventId = publicKey.take(63) + "f"
|
||||
database.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = publicKey,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
database.profileDao().upsert(
|
||||
Profile(publicKey = publicKey, userName = "member", nostrEventId = nostrEventId)
|
||||
)
|
||||
}
|
||||
}
|
||||
+59
-56
@@ -346,61 +346,73 @@ class SubgroupManagerJvmTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the group's own ceremony does not block a subgroup in the same room`(): Unit =
|
||||
fun `a ceremony in the parent's room for something else does not block one`(): Unit =
|
||||
runBlocking {
|
||||
// The collision the size rule was standing in for, made concrete. A
|
||||
// subgroup over everybody derives the room the group's own ceremony was
|
||||
// held in -- so the room is shared, and only `parentChatRoomId` keeps
|
||||
// the two ceremonies apart. Without that scoping `proposeRitual` would
|
||||
// hand back the *group's* key and quietly make the child the parent.
|
||||
val parent = parentWith(listOf(alice, bob))
|
||||
val ceremonyRoomId = SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user)
|
||||
// The parent's room hosts every ceremony the group ever runs, so "is
|
||||
// this room already running one" stopped being a question worth
|
||||
// asking the moment the ceremony moved here. Scoping on the room
|
||||
// would refuse a group's second subgroup on the strength of its
|
||||
// first, which is most of what the move was for.
|
||||
val parent = parentWith(listOf(alice, bob, carol))
|
||||
|
||||
seedCeremonyRoom(ceremonyRoomId, listOf(alice, bob, user))
|
||||
db.dkgSessionDao().upsert(ownCeremony(ceremonyRoomId))
|
||||
db.dkgSessionDao().upsert(
|
||||
subgroupCeremony(id = "other-admins", admins = setOf(alice, carol, user))
|
||||
)
|
||||
db.dkgSessionDao().upsert(ownCeremony(parentRoomId))
|
||||
|
||||
assertNull(
|
||||
refusal(parent, setOf(alice, bob)),
|
||||
"the group's own ceremony is not this subgroup's",
|
||||
"another subgroup's ceremony, and the room's own, are not this one",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same parent cannot have two subgroups over the same admins`(): Unit = runBlocking {
|
||||
fun `a subgroup ceremony already under way over the same admins is refused`(): Unit =
|
||||
runBlocking {
|
||||
val parent = parentWith(listOf(alice, bob, carol))
|
||||
|
||||
assertNull(refusal(parent, setOf(alice, bob)))
|
||||
|
||||
db.dkgSessionDao().upsert(
|
||||
subgroupCeremony(id = "running", admins = setOf(alice, bob, user))
|
||||
)
|
||||
|
||||
// Not because two subgroups over the same people are forbidden --
|
||||
// they are not, since the move -- but because nobody can answer for
|
||||
// two live ceremonies at once: every admin would be asked twice, on
|
||||
// two ladders, for two keys, one of which nobody will ever make a
|
||||
// room from.
|
||||
assertNotNull(refusal(parent, setOf(alice, bob)))
|
||||
|
||||
// A different admin set is a different subgroup, and untouched.
|
||||
assertNull(refusal(parent, setOf(alice, carol)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a finished subgroup does not stop a second over the same admins`(): Unit = runBlocking {
|
||||
// The limitation that went away with the sibling room. A ceremony room
|
||||
// derived from its admins meant one admin set got one subgroup forever:
|
||||
// asking again handed back the first ceremony's key, so the "new"
|
||||
// subgroup was the old one under a new name. In the parent's room a
|
||||
// second ask is a second ceremony, with a key of its own.
|
||||
val parent = parentWith(listOf(alice, bob, carol))
|
||||
|
||||
assertNull(refusal(parent, setOf(alice, bob)))
|
||||
|
||||
// The one collision that survives, and it is degenerate: same parent,
|
||||
// same admins. The ceremony room is derived from the admins and the
|
||||
// ceremonies in it are told apart by their parent, so this pair has
|
||||
// nothing left to distinguish them -- which is another way of saying it
|
||||
// is one subgroup asked for twice.
|
||||
val ceremonyRoomId = SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user)
|
||||
seedCeremonyRoom(ceremonyRoomId, listOf(alice, bob, user))
|
||||
db.dkgSessionDao().upsert(
|
||||
ownCeremony(ceremonyRoomId).copy(id = "s1", parentChatRoomId = parentRoomId)
|
||||
subgroupCeremony(id = "done", admins = setOf(alice, bob, user))
|
||||
.copy(stage = DkgRitualStage.COMPLETE)
|
||||
)
|
||||
|
||||
assertNotNull(refusal(parent, setOf(alice, bob)))
|
||||
|
||||
// And a different admin set is a different room, so it is untouched.
|
||||
assertNull(refusal(parent, setOf(alice, carol)))
|
||||
assertNull(refusal(parent, setOf(alice, bob)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ceremony room is the same whichever way the admins are ordered`() {
|
||||
// Every device has to land on the same room without being told, which is
|
||||
// what makes a ceremony findable at all -- and what makes two members
|
||||
// starting the same subgroup meet rather than fork.
|
||||
fun `the stored participant set is the same whichever way the admins were picked`() {
|
||||
// The coordinator assembles the set from a picker and every other device
|
||||
// from the proposal's p-tags. They have to match as strings or the guard
|
||||
// above finds nothing and every ask opens a ceremony.
|
||||
assertEquals(
|
||||
SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user),
|
||||
SubgroupManager.ceremonyRoomIdFor(setOf(bob, alice), user)
|
||||
)
|
||||
// And the coordinator is in it whether or not they were ticked.
|
||||
assertEquals(
|
||||
SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob), user),
|
||||
SubgroupManager.ceremonyRoomIdFor(setOf(alice, bob, user), user)
|
||||
DkgSession.formatParticipants(setOf(alice, bob, user)),
|
||||
DkgSession.formatParticipants(setOf(user, bob, alice))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -488,24 +500,15 @@ class SubgroupManagerJvmTest {
|
||||
* The NIP-17 room a ceremony runs in, stood up the way the picker does it --
|
||||
* room first, because `DkgSession.chatRoomId` is a foreign key onto it.
|
||||
*/
|
||||
private suspend fun seedCeremonyRoom(expectedId: String, members: List<String>) {
|
||||
members.forEach { seedProfile(it) }
|
||||
|
||||
val room = assertNotNull(
|
||||
db.nostrNip17Dao().createNip17ChatRoom(
|
||||
userPublicKey = user,
|
||||
participantPublicKeys = members.filterNot { it == user },
|
||||
subject = "Translation team",
|
||||
)
|
||||
/** A ceremony held in the parent's room to make a subgroup run by [admins]. */
|
||||
private fun subgroupCeremony(id: String, admins: Set<String>) =
|
||||
ownCeremony(parentRoomId).copy(
|
||||
id = id,
|
||||
parentChatRoomId = parentRoomId,
|
||||
participantPublicKeys = DkgSession.formatParticipants(admins),
|
||||
participantCount = admins.size,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
expectedId,
|
||||
room.chatRoom.id,
|
||||
"the derived ceremony room is the one createNip17ChatRoom lands on",
|
||||
)
|
||||
}
|
||||
|
||||
/** A ceremony held in [chatRoomId] for no subgroup: the group's own. */
|
||||
private fun ownCeremony(chatRoomId: String) = DkgSession(
|
||||
id = "own-ceremony",
|
||||
@@ -546,7 +549,7 @@ class SubgroupManagerJvmTest {
|
||||
SharedKeyDerivation.marmotGroupId(material.thresholdPublicKey.value.toHex(), path)
|
||||
|
||||
private suspend fun refusal(parent: LocalChatRoom, admins: Set<String>): String? =
|
||||
SubgroupManager.refuseCeremonyRoom(
|
||||
SubgroupManager.refuseSubgroup(
|
||||
database = db,
|
||||
parentRoom = parent,
|
||||
adminPublicKeys = admins,
|
||||
@@ -557,7 +560,7 @@ class SubgroupManagerJvmTest {
|
||||
* A parent this device could actually make a subgroup of: an admin of it, and
|
||||
* holding a share of its key.
|
||||
*
|
||||
* Both are conditions of `refuseCeremonyRoom` before it looks at the picking
|
||||
* Both are conditions of `refuseSubgroup` before it looks at the picking
|
||||
* at all, so a fixture without them tests only the first refusal. [canSign]
|
||||
* and [isAdmin] are here so the tests that are *about* those two can turn
|
||||
* each off on its own.
|
||||
@@ -574,7 +577,7 @@ class SubgroupManagerJvmTest {
|
||||
* that null.
|
||||
*
|
||||
* [canSign] and [isAdmin] are switchable so the tests about those two can turn
|
||||
* each off on its own; both are conditions `refuseCeremonyRoom` checks before
|
||||
* each off on its own; both are conditions `refuseSubgroup` checks before
|
||||
* it looks at the picking at all.
|
||||
*/
|
||||
private suspend fun parentWith(
|
||||
|
||||
@@ -109,6 +109,12 @@ Several flows publish in bursts, and each of them is a reliable trigger:
|
||||
twenty-one events at once, and only the ones that happen to arrive in ascending
|
||||
generation order survive.
|
||||
|
||||
`proposeRitual` is the one with the worst *consequence*. It reached this table on
|
||||
gift wraps, where the bug does not apply; since `docs/subgroups.md` Phase 10 a
|
||||
subgroup's ceremony rides group events, and a ChillDKG cannot finish until every
|
||||
participant has taken part — so one lost round-1 message stalls it permanently for
|
||||
everybody, rather than costing one member a line of chat.
|
||||
|
||||
## The fix, in quartz
|
||||
|
||||
Carry the skipped keys through `saveState`/`restore` alongside the ratchet
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
# The shared key ceremony
|
||||
|
||||
A group creates a `t`-of-`n` FROST key by running ChillDKG over its NIP-17 chat.
|
||||
No trusted dealer, no single device ever holding the whole key.
|
||||
A group creates a `t`-of-`n` FROST key by running ChillDKG over its own chat. No
|
||||
trusted dealer, no single device ever holding the whole key.
|
||||
|
||||
Implemented in `ChillDkgRitualManager`, on `fr.acinq.bitcoin.crypto.dkg.chill.ChillDKG`.
|
||||
|
||||
## Shape
|
||||
|
||||
The group is the participant set, the member who opens the ceremony coordinates
|
||||
it, and every protocol message travels as a gift-wrapped rumor on the same NIP-17
|
||||
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
|
||||
ceremony coordinates it, and every protocol message travels on a pipeline chat
|
||||
messages already use — a gift-wrapped rumor in a NIP-17 room, an MLS application
|
||||
message in a Marmot one — so there is no second transport to operate.
|
||||
|
||||
A group's own ceremony runs in the NIP-17 room its members share, and there the
|
||||
participant set and the room's membership are the same thing. A **subgroup's**
|
||||
runs in its parent's Marmot room over a subset of it, which is why the set is the
|
||||
p-tags rather than the room: see [subgroups.md](./subgroups.md).
|
||||
|
||||
The coordinator is a participant too, and ChillDKG treats it as untrusted: it
|
||||
relays and aggregates but cannot learn secrets or bias the key. Being the room's
|
||||
|
||||
+170
-43
@@ -9,9 +9,12 @@ Read [shared-key-derivation.md](./shared-key-derivation.md) and
|
||||
built on the property they state — that a room's id *is* the key it signs with —
|
||||
and on the ceremony they describe, which a subgroup runs again from scratch.
|
||||
|
||||
**Built**, phases 1-9, one commit each. The phases are kept as written because
|
||||
they are the reasoning, and the code reads better against the argument it came
|
||||
from than against a summary of itself. Where the implementation chose differently
|
||||
**Built**, phases 1-9, one commit each, and then **Phase 10 moved the child's
|
||||
ceremony into the parent's Marmot room** -- the thing [Phase 4](#why-not-the-parents-marmot-room)
|
||||
deferred and said to revisit. Phases 1-9 are kept as written because they are the
|
||||
reasoning, and the code reads better against the argument it came from than
|
||||
against a summary of itself; where Phase 10 overtook one of them the section says
|
||||
so and points forward. Where the implementation chose differently
|
||||
the section says so, and it did so six times worth reading:
|
||||
|
||||
| what the plan said | what it turned out to be |
|
||||
@@ -116,14 +119,27 @@ opens the screen and can act, not only to the member who started.
|
||||
|
||||
| step | runs in | signed by | transport |
|
||||
|---|---|---|---|
|
||||
| ChillDKG | a NIP-17 room over the child's admins | n/a — every participant | gift wraps |
|
||||
| ChillDKG | the **parent** Marmot room, over the child's admins | n/a — every participant | MLS group events |
|
||||
| birth certificate | the **parent** Marmot room | the parent's quorum, as the parent's key | MLS group events |
|
||||
| key state | the child's NIP-17 ceremony room | the **child's** quorum, as the child's key | gift wraps |
|
||||
| key state | the **parent** Marmot room | the **child's** quorum, as the child's key | MLS group events |
|
||||
|
||||
The middle row is the only new shape, and it is not new machinery:
|
||||
`FrostSigningManager` already runs on both transports and already gates each
|
||||
signer's approval behind the existing proposal UI. What is new is a kind for it
|
||||
to carry and a reason for the parent's admins to say yes.
|
||||
One room, three ceremonies, two quorums. The middle row was the only new shape
|
||||
and it is not new machinery: `FrostSigningManager` already runs on both
|
||||
transports and already gates each signer's approval behind the existing proposal
|
||||
UI. What is new is a kind for it to carry and a reason for the parent's admins to
|
||||
say yes.
|
||||
|
||||
The first and third rows read like that since [Phase 10](#phase-10--the-ceremony-moves-into-the-parents-room).
|
||||
They used to run in a sibling NIP-17 room whose id was an aggregate of the child's
|
||||
admins, and the reasons for moving them -- and the two ways it can silently go
|
||||
wrong -- are there.
|
||||
|
||||
The parent's room hosting a ceremony its own members are not all in is the one
|
||||
thing that needs saying twice. An MLS message reaches the whole tree, so the
|
||||
ceremony has to name who it is with: the proposal's p-tags are the participant
|
||||
set, and a parent member outside them drops the proposal rather than joining.
|
||||
"Can read the message" and "is in the ceremony" are different questions, and a
|
||||
member who published a host key would be in the child's signing quorum for good.
|
||||
|
||||
## Informed consent, and what the parent's admins are actually signing
|
||||
|
||||
@@ -353,6 +369,11 @@ state's.
|
||||
|
||||
## Phase 4 — where the subgroup's ceremony runs
|
||||
|
||||
> **Overtaken by [Phase 10](#phase-10--the-ceremony-moves-into-the-parents-room).**
|
||||
> The ceremony now runs in the parent's Marmot room. Everything below is what was
|
||||
> built first and why; the two tags it adds are unchanged and still travel on the
|
||||
> proposal, and the collision it argues about is the thing Phase 10 retired.
|
||||
|
||||
The child's ChillDKG needs a room whose participant set is exactly the child's
|
||||
admins, and whose transport is gift wraps. There is already one:
|
||||
`chatRepository.createNip17ChatRoom` over the selected admins plus the
|
||||
@@ -391,6 +412,11 @@ ever reaches the creator's own device.
|
||||
|
||||
### The collision this buys, and it is real
|
||||
|
||||
> Retired by [Phase 10](#phase-10--the-ceremony-moves-into-the-parents-room):
|
||||
> there is no member-derived ceremony room left to collide. What survives is a
|
||||
> narrower refusal -- a ceremony for the same admins under the same parent that is
|
||||
> still *running* -- and the reasoning below is why that one is worth keeping.
|
||||
|
||||
A NIP-17 room's id is a pure function of its members, so **one admin set gets one
|
||||
ceremony room, forever**. Two consequences, and both need a guard rather than a
|
||||
comment:
|
||||
@@ -411,6 +437,11 @@ admins. See [that phase](#phase-8--the-refusals).
|
||||
|
||||
### Why not the parent's Marmot room
|
||||
|
||||
> **It landed** — see [Phase 10](#phase-10--the-ceremony-moves-into-the-parents-room),
|
||||
> which is this section's shopping list carried out. Kept as written because the
|
||||
> three mechanical costs it enumerates are exactly the three that had to be paid,
|
||||
> and two of them fail silently if they are not.
|
||||
|
||||
The obvious alternative is to hold all three steps in the parent room: no sibling
|
||||
room, no member-derived id, and the collision above disappears. It is a better
|
||||
design and it is not available yet. What follows is why, because "we did not"
|
||||
@@ -492,10 +523,10 @@ reimplement them.
|
||||
```kotlin
|
||||
object SubgroupManager {
|
||||
/** The NIP-17 room a ceremony for these admins would run in. Pure. */
|
||||
fun ceremonyRoomIdFor(adminPublicKeys: Set<HexKey>, coordinator: HexKey): String
|
||||
fun ceremonyRoomIdFor(adminPublicKeys: Set<HexKey>, coordinator: HexKey): String // deleted in Phase 10
|
||||
|
||||
/** Step 1: stand up the ceremony room and open the ritual in it. */
|
||||
suspend fun openCeremony(...): DkgSession
|
||||
suspend fun openCeremony(...): DkgSession // never built
|
||||
|
||||
/** Step 2: ask the parent's quorum to certify the child. Runs in the parent room. */
|
||||
suspend fun proposeBirthCertificate(
|
||||
@@ -547,9 +578,9 @@ signed statement, and every device in the parent room already has them —
|
||||
followed the session, not only on the signers'.
|
||||
|
||||
**Two certificates for one child is a normal outcome, not a conflict.** Two parent
|
||||
admins can press the button on the same admin set: the second lands in the same
|
||||
NIP-17 ceremony room and gets the first's ceremony back, but both may go on to
|
||||
propose a certificate, and both sessions can complete. `GroupSignedEvent` is keyed
|
||||
admins can press the button on the same admin set: the second's `proposeRitual`
|
||||
folds into the ceremony the first opened, but both may go on to propose a
|
||||
certificate, and both sessions can complete. `GroupSignedEvent` is keyed
|
||||
on the event id, so the rows coexist. `certificateFor` therefore takes the
|
||||
**newest** of those that pass `certifies`, the way `GroupKeyStateManager.signedAmong`
|
||||
already picks the newest state — and because both certificates say the same true
|
||||
@@ -706,11 +737,11 @@ certificate so the parent's admins approve something legible.
|
||||
|
||||
`MarmotGroupName.of` puts the `#` on it. The field shows the prefix beside what is
|
||||
typed rather than in it, so the caret does not move under somebody mid-word, and
|
||||
the ceremony room keeps the bare name — it is a NIP-17 room, and the mark is what
|
||||
tells it apart from the subgroup it stands up. The subgroup's own name is derived
|
||||
from that subject twice, by two unrelated callers: `proposeBirthCertificate`
|
||||
normalises the name the parent signs, `MarmotGroupCreation` normalises the name the
|
||||
room carries. Those two have to be the same string or the subgroup is not called
|
||||
the bare name is what goes down — since Phase 10 onto `DkgSession.subject`, which
|
||||
is where a ceremony held in somebody else's room has to keep it. The subgroup's
|
||||
own name is derived from that twice, by two unrelated callers:
|
||||
`proposeBirthCertificate` normalises the name the parent signs,
|
||||
`MarmotGroupCreation` normalises the name the room carries. Those two have to be the same string or the subgroup is not called
|
||||
what its parent certified, which is what the rule being idempotent buys.
|
||||
|
||||
### Key packages, checked here rather than discovered at step 4
|
||||
@@ -780,9 +811,12 @@ rendered only when a parent is present, and it is where the coordinator proposes
|
||||
the certificate and watches the parent's quorum answer.
|
||||
|
||||
The approvals themselves need **no new UI**. The certificate is a
|
||||
`FrostSigningEvents.PROPOSAL` in the parent room and the key state one in the
|
||||
ceremony room; `ProposalListScreen` and `FrostSigningScreen` already show and
|
||||
approve both, on both transports.
|
||||
`FrostSigningEvents.PROPOSAL` in the parent room and so, since Phase 10, is the
|
||||
key state; `ProposalListScreen` and `FrostSigningScreen` already show and approve
|
||||
both. Which quorum each asks is not the room's business either way — a proposal
|
||||
names the ceremony it signs under, and `signerIdOf` drops one for a ceremony this
|
||||
device was not in, so a parent admin outside the subgroup never sees the child's
|
||||
key state as theirs to answer.
|
||||
|
||||
## Phase 8 — the refusals
|
||||
|
||||
@@ -798,7 +832,7 @@ a room nobody can replace.
|
||||
| an empty or blank name | Phase 6 has nothing to put in `MarmotGroupData.name`, and the parent's admins would be certifying an unnamed hash |
|
||||
| a selected member with no unconsumed key package | they cannot be welcomed into the room at step 4, and finding that out at step 4 wastes three ceremonies. Checked at the picker and again at confirm |
|
||||
| ~~the selection is not a proper subset of the parent's members~~ | **withdrawn.** A subgroup is a logical division, not a smaller membership, so "everybody" is a normal answer. What this was standing in for is the row below, checked precisely rather than by set size |
|
||||
| the ceremony room already holds a non-`FAILED` `DkgSession` **for this same parent** | `proposeRitual` would return that ceremony, and the "new" subgroup would be the old one — same key, same id. Scoped by `DkgSession.parentChatRoomId`, so a room may hold the group's own ceremony *and* a subgroup's |
|
||||
| a ceremony for **this parent over these same admins** is still running | every admin would be asked twice, on two ladders, for two keys, one of which nobody will make a room from. Matched on `DkgSession.participantPublicKeys` since Phase 10, and only while the ceremony is live: a *finished* one means that subgroup was made, and a second run by the same people is a legitimate ask |
|
||||
| this device holds no share of the parent's key | it cannot open the certificate session; `proposeSigningBatch` throws, and throwing at the button is not a UI |
|
||||
| this device is not an admin of the parent | a non-admin proposing the parent's signature is a proposal the parent's admins have to decline by hand |
|
||||
| the parent has no signed key state and no resolvable key | there is nothing for the certificate to be signed with |
|
||||
@@ -830,6 +864,10 @@ forbid the selection. A room may now hold two ceremonies, and everything below
|
||||
that lookup already keyed off the session id, so nothing else had to learn about
|
||||
the second one.
|
||||
|
||||
Phase 10 finished the thought. A parent's room hosts *every* subgroup ceremony
|
||||
the group ever runs, so `(room, parent)` stopped telling two of them apart and the
|
||||
lookup moved to `(room, parent, admins)` — `getLatestSubgroupSessionFor`.
|
||||
|
||||
One collision survives, and it is degenerate: the same parent, over the same
|
||||
admins, twice. Those two have nothing left to distinguish them, which is another
|
||||
way of saying they are one subgroup asked for twice, and that is what the message
|
||||
@@ -857,11 +895,105 @@ Two that need a database, in jvmTest, where `secp256k1` loads and Room runs:
|
||||
mutation of it.
|
||||
- `SubgroupDaoJvmTest` — schema 17 round-trips the four columns, and
|
||||
`subgroupsOf` returns a certified child whose room does not exist locally.
|
||||
- `SubgroupCeremonyInParentRoomTest` (Phase 10) — the ceremony queued as inner
|
||||
events rather than gift wraps, the p-tags naming only the picked admins, a
|
||||
parent member outside them opening no session, a backlog replayed out of the
|
||||
inner-event store, and `completedKey` refusing to hand the parent its child's
|
||||
key.
|
||||
|
||||
One end-to-end, and it is the one that would have caught the collision in Phase
|
||||
4: two ceremonies proposed over the same admin set land in the same room, and the
|
||||
second is refused rather than silently returning the first's key.
|
||||
|
||||
## Phase 10 — the ceremony moves into the parent's room
|
||||
|
||||
The thing [Phase 4](#why-not-the-parents-marmot-room) deferred, done, because the
|
||||
reason to wait was `docs/mls-skipped-keys.md` and that is being fixed. The child's
|
||||
ChillDKG and its key state now run in the parent's Marmot room on group events;
|
||||
the sibling NIP-17 room is gone, and with it the rule that one admin set could
|
||||
hold one subgroup forever.
|
||||
|
||||
Schema 18 → 19, two nullable columns, both on `DkgSession`, and both are things
|
||||
the sibling room used to answer:
|
||||
|
||||
| column | why the room cannot answer it any more |
|
||||
|---|---|
|
||||
| `participantPublicKeys` | the ceremony is with a *subset* of the parent's members, so the room's roster names every person the subgroup is not |
|
||||
| `subject` | the room this ceremony's key derives is not called what the parent is called, and the parent's name is the one name a child must not take |
|
||||
|
||||
Both are read off the proposal, which has carried both since Phase 4 — the p-tags
|
||||
and the `subject` tag. What changed is where they land: on the session, which is
|
||||
the only thing in a shared room that knows which ceremony it is.
|
||||
|
||||
### Three costs, and two of them are silent
|
||||
|
||||
Phase 4 enumerated them. All three had to be paid, and the two that fail silently
|
||||
are worth stating as rules rather than as changes.
|
||||
|
||||
**1. `ChillDkgRitualManager` grows the two-transport shape.** `broadcast` writes a
|
||||
`MarmotInnerEvent` where the room has MLS state and a `GiftWrapPayload` where it
|
||||
does not, `replayStoredMessages` reads the backlog out of whichever store that
|
||||
same reading names, `processRitualPayload` takes an `Event` rather than a
|
||||
gift-wrap payload, and `NostrDao`'s Marmot arm dispatches the DKG kinds beside
|
||||
the FROST ones it already did.
|
||||
|
||||
**The p-tags stay on both transports**, unlike `FrostSigningManager`'s. There a
|
||||
p-tag is an address and a group event needs none; here the p-tag set *is* the
|
||||
participant set every device derives `n` from — and in the parent's room it is
|
||||
also the only thing separating the subgroup's admins from everyone else who can
|
||||
read the message. Dropping them the way the signing manager correctly does would
|
||||
enrol the whole parent in the child's permanent signing quorum. Inside MLS
|
||||
encryption, naming them leaks nothing.
|
||||
|
||||
**2. `DkgSession.chatRoomId` stopped identifying a ceremony**, and the failure is
|
||||
silent in the one place it matters. `FrostSigningManager.completedKey`'s last
|
||||
fallback is "a ceremony held in this very room" — the fallback every parent member
|
||||
welcomed after the group's own ceremony lands on, since they hold neither a share
|
||||
nor a key state. The parent's room now holds a *completed* ceremony whose key is
|
||||
the child's, and handing that back would have the parent sign as its own subgroup
|
||||
with nothing to see. `getLatestOwnSessionForChatRoom` is that fallback scoped to
|
||||
`parentChatRoomId IS NULL`: a ceremony run to make a subgroup is never the room's
|
||||
own key, and that is the whole of what has to be read to know it.
|
||||
|
||||
`proposeRitual`'s guard moved for the same reason and in the other direction —
|
||||
see the Phase 8 row above.
|
||||
|
||||
**3. `signingPath` admits a Marmot room**, narrowly. It has one case that cannot
|
||||
be self-checked: the room a ceremony ran in, signing the statement that lets the
|
||||
room the ceremony's key derives be created. That used to mean "a NIP-17 room whose
|
||||
ceremony is its own"; it now also means "the parent's room, where the ceremony
|
||||
claims that parent". The claim is unverified and grants nothing — the signature it
|
||||
enables is by the *child's* key over the *child's* own id, both derived from a
|
||||
ceremony every signer contributed to, so a member who lied about the parent gets
|
||||
what telling the truth would have got them.
|
||||
|
||||
### What it buys
|
||||
|
||||
- **A subgroup no longer costs a room.** Nothing appears in anybody's chat list
|
||||
that is not a group.
|
||||
- **One admin set can run many subgroups.** The permanent collision is gone,
|
||||
because there is no member-derived id left to collide; what is refused is only a
|
||||
ceremony for the same people under the same parent that is still running.
|
||||
- **Every step of a subgroup happens in one place.** The picker hands back to the
|
||||
parent's transcript, and the ceremony, the certificate and the key state are all
|
||||
answered from there — which is where `observeCeremoniesAwaitingYou` and
|
||||
`observeProposalsAwaitingYou` were already looking.
|
||||
- **`DkgSession.parentChatRoomId` is no longer a hint alone.** It still
|
||||
authenticates nothing, and nothing may be granted on it that would not be
|
||||
granted without it — but two lookups now scope on it, so the note where it is
|
||||
declared has to be read before either is widened.
|
||||
|
||||
### What it costs
|
||||
|
||||
A ceremony now rides the transport where a message arriving a moment late is
|
||||
dropped for good, which is the whole of Phase 4's objection and is not answered by
|
||||
this phase — it is answered by the quartz fix `docs/mls-skipped-keys.md`
|
||||
describes. A DKG is the worst payload for that bug: it cannot finish until every
|
||||
participant takes part, so one lost round-1 message stalls it for everybody.
|
||||
`replayStoredMessages` covers a message that lost a race to the proposal, since
|
||||
the inner event is stored before it is dispatched; it cannot cover a message the
|
||||
tree could never decrypt.
|
||||
|
||||
## Rollout
|
||||
|
||||
Nothing here changes an existing flow's behaviour. The two shared pieces are the
|
||||
@@ -911,20 +1043,13 @@ about whether the child's chronicle may carry an event its own key did not sign,
|
||||
which no chroniclable kind does today. That last one is the real work, and it is
|
||||
why this is a follow-up rather than a line in Phase 5.
|
||||
|
||||
**One parent, one admin set, one subgroup.** Narrower than it first shipped: a
|
||||
room may hold two ceremonies, told apart by `DkgSession.parentChatRoomId`, so a
|
||||
group's own ceremony and a subgroup over the same people coexist. What is still
|
||||
refused is the same parent asking twice over the same admins, which have nothing
|
||||
left to distinguish them.
|
||||
|
||||
Widening that further needs the NIP-17 ceremony room to be distinguishable by
|
||||
something other than its members, and `ChatRoom.deriveChatRoomId` is a pure
|
||||
aggregate of member keys that the inbound path recomputes. Changing it is a change
|
||||
to how every NIP-17 room in the app is addressed.
|
||||
|
||||
It is also the limitation that disappears on its own: move the ceremony into the
|
||||
parent's Marmot room, as [Phase 4](#why-not-the-parents-marmot-room) defers doing,
|
||||
and there is no member-derived id left to collide.
|
||||
**~~One parent, one admin set, one subgroup.~~ Gone in Phase 10.** It narrowed
|
||||
twice and then disappeared. First to "the same parent asking twice over the same
|
||||
admins", once `DkgSession.parentChatRoomId` told two ceremonies in a room apart.
|
||||
Then to nothing permanent at all, because moving the ceremony into the parent's
|
||||
Marmot room left no member-derived id to collide: what is refused now is only a
|
||||
ceremony for those admins under that parent that is **still running**, and a
|
||||
finished one is a subgroup that exists rather than a slot that is used up.
|
||||
|
||||
**Every selected admin has to show up, twice.** A ChillDKG cannot finish without
|
||||
all `n`, and the key state then needs a quorum of them. A member who never
|
||||
@@ -964,12 +1089,14 @@ every parent member, and verifiable without the table. A table would be a second
|
||||
copy that can disagree with them, and the only thing it would add is flow state
|
||||
for the coordinator — which `DkgSession.parentChatRoomId` covers with one column.
|
||||
|
||||
**Running the child's ChillDKG and key state inside the parent's Marmot room.**
|
||||
The better design, deferred rather than rejected, and argued in full in
|
||||
[Phase 4](#why-not-the-parents-marmot-room). Short version: the two move
|
||||
together, the mechanical cost is three enumerable changes, and the reason to wait
|
||||
is `docs/mls-skipped-keys.md` — a ceremony needs every participant, and group
|
||||
events lose one of any two that arrive back to back.
|
||||
**~~Running the child's ChillDKG and key state inside the parent's Marmot room.~~**
|
||||
Not rejected — deferred, argued in full in
|
||||
[Phase 4](#why-not-the-parents-marmot-room), and then
|
||||
[built](#phase-10--the-ceremony-moves-into-the-parents-room) once the reason to
|
||||
wait was being fixed. The two did move together, the mechanical cost was the
|
||||
three changes Phase 4 enumerated, and `docs/mls-skipped-keys.md` remains the thing
|
||||
this leans on: a ceremony needs every participant, and group events lose one of
|
||||
any two that arrive back to back until that lands.
|
||||
|
||||
**A dedicated subgroup derivation path.** `m/9420/0/0` is reused, because the
|
||||
path is walked from the *child's own* key and two different keys at the same path
|
||||
|
||||
Reference in new issue
Block a user