feat(subgroups): say what a ceremony is for, and stop rooms arriving nameless
Phase 4 of docs/subgroups.md. Two tags on the ritual proposal, and only on the proposal. Neither is believed by anything. **`parent_group` says the ceremony is opening a subgroup.** `acceptProposal` files it on `DkgSession.parentChatRoomId` and the ritual screen will read it. Anybody can claim any parent and nothing is granted on the claim -- the participant set is still the p-tags, the threshold is still the threshold, and a proposal naming a group the receiver has never heard of opens exactly the ceremony it would have opened without the tag. The claim that decides anything is the birth certificate two steps later, which the parent's own quorum signs and which any device can check against the parent's room id alone. Carrying it is still worth it. Without it a member selected for a subgroup watches a shared key ceremony open in a room they did not ask for, with nothing saying what it is for. Being told is worth having even when the telling is not evidence, and the KDoc on the parser, the column and the `acceptProposal` call site all say so, because this is exactly the sort of field somebody later reaches for as if it meant something. **`subject` fixes a bug that predates subgroups.** `NostrDao.getOrCreateNip17ChatRoom` has always built the receiving side's room with `subject = decryptedGiftWrapPayload.parseSubject()`, and nothing on the ritual path ever wrote a subject tag. Standing up a NIP-17 room sends nothing to anybody, so the ritual proposal is routinely the first anyone hears of the room -- which meant the member who typed the name saw it and every other member got an untitled chat with a key ceremony already running in it. `SelectChatRoomTypeViewModel` has been passing a subject to `createNip17ChatRoom` that reached the creator's own device and stopped there. `broadcast` now reads `localChatRoom.chatRoom.subject` for the proposal, so every robust group created from here on names itself on the way out. Read and written through quartz's own `SubjectTag`, because it is the same tag and the same field it lands in. **Both are on the proposal alone.** Every later message belongs to a session the receiver already has both facts for, so repeating them would be bytes per round to restate something settled -- and would give a later message a say in what an opened ceremony is for. `includeParent` and `subject` are parameters of `broadcast` alongside the existing `includeThreshold`, which already works this way. `proposeRitual` and `DkgRepository.proposeRitual` take an optional `parentChatRoomId`; every existing call site is unchanged and gets null. Three tests in `RobustRoomKeyCeremonyTest`, which already stands up a real NIP-17 room and a real ceremony: the proposal carries the room's name and no later message repeats it; an ordinary ceremony claims no parent on any message; and a subgroup ceremony names its parent on the proposal alone, records it on the session, and changes nothing about the threshold or the participant count. Each walks every queued payload rather than checking the proposal alone, since the failure worth catching is a tag written on the wrong message. 396 common tests and 686 jvm tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,14 +38,16 @@ class DatabaseDkgRepository(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey?
|
||||
): DkgSession? = try {
|
||||
ChillDkgRitualManager.proposeRitual(
|
||||
database = database,
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = userPublicKey,
|
||||
nostrPrivateKey = nostrPrivateKey,
|
||||
threshold = threshold
|
||||
threshold = threshold,
|
||||
parentChatRoomId = parentChatRoomId
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Error proposing DKG ritual for ${localChatRoom.chatRoom.id}", e)
|
||||
|
||||
@@ -122,7 +122,8 @@ object ChillDkgRitualManager {
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey? = null
|
||||
): DkgSession {
|
||||
// A room runs one ceremony at a time, and this is reachable twice now that
|
||||
// a robust group opens one as it is created: the room id is derived from
|
||||
@@ -175,7 +176,13 @@ object ChillDkgRitualManager {
|
||||
// member is not asked again to publish the host key they just committed
|
||||
// the group to. Rounds 1 and 2 are still asked for: those publish key
|
||||
// material, and by then the ceremony has other people in it.
|
||||
hostKeyApprovedAt = Clock.System.now()
|
||||
hostKeyApprovedAt = Clock.System.now(),
|
||||
// What this ceremony is for, if it is for anything in particular. The
|
||||
// coordinator's own copy of the claim they are about to put on the
|
||||
// proposal -- unverified there and unverified here, and read for
|
||||
// nothing but a screen's title and a way back into an unfinished
|
||||
// subgroup flow. See `DkgSession.parentChatRoomId`.
|
||||
parentChatRoomId = parentChatRoomId
|
||||
)
|
||||
database.dkgSessionDao().upsert(session)
|
||||
announceStarted(database, session)
|
||||
@@ -188,7 +195,19 @@ object ChillDkgRitualManager {
|
||||
session = session,
|
||||
kind = DkgRitualEvents.PROPOSAL,
|
||||
content = "",
|
||||
includeThreshold = true
|
||||
includeThreshold = true,
|
||||
// Only on the proposal. Every later message belongs to a session the
|
||||
// receiver already has both of these on, so repeating them would be
|
||||
// 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
|
||||
)
|
||||
|
||||
publishHostKey(database, localChatRoom, session)
|
||||
@@ -311,7 +330,15 @@ object ChillDkgRitualManager {
|
||||
participantCount = members.size,
|
||||
hostPublicKey = deriveHostPublicKey(nostrPrivateKey).value.toHex(),
|
||||
round1Random = RandomInstance.bytes(32).toHex(),
|
||||
round2AuxRandom = RandomInstance.bytes(32).toHex()
|
||||
round2AuxRandom = RandomInstance.bytes(32).toHex(),
|
||||
// Taken as said, because there is nothing here to check it against and
|
||||
// nothing is granted on it. A proposal claiming a parent this member has
|
||||
// never heard of, or one they are not in, still opens exactly the
|
||||
// ceremony it would have opened without the tag -- the participant set
|
||||
// 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)
|
||||
)
|
||||
database.dkgSessionDao().upsert(session)
|
||||
announceStarted(database, session)
|
||||
@@ -1119,7 +1146,9 @@ object ChillDkgRitualManager {
|
||||
session: DkgSession,
|
||||
kind: Kind,
|
||||
content: String,
|
||||
includeThreshold: Boolean = false
|
||||
includeThreshold: Boolean = false,
|
||||
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.
|
||||
@@ -1135,7 +1164,9 @@ object ChillDkgRitualManager {
|
||||
|
||||
val tags = receiverTags.toTypedArray() + DkgRitualEvents.assembleTags(
|
||||
sessionId = session.id,
|
||||
threshold = if (includeThreshold) session.threshold else null
|
||||
threshold = if (includeThreshold) session.threshold else null,
|
||||
parentChatRoomId = if (includeParent) session.parentChatRoomId else null,
|
||||
subject = subject
|
||||
)
|
||||
|
||||
val createdAt = Clock.System.now().epochSeconds
|
||||
|
||||
@@ -3,6 +3,8 @@ package press.mantra.compose.nostr.dkg
|
||||
import press.mantra.compose.nostr.dkg.tags.DkgSessionIdTag
|
||||
import press.mantra.compose.nostr.dkg.tags.DkgThresholdTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip14Subject.SubjectTag
|
||||
import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag
|
||||
|
||||
/**
|
||||
* The nostr kinds a ChillDKG ritual is carried on.
|
||||
@@ -72,10 +74,14 @@ object DkgRitualEvents {
|
||||
*/
|
||||
fun assembleTags(
|
||||
sessionId: String,
|
||||
threshold: Int? = null
|
||||
threshold: Int? = null,
|
||||
parentChatRoomId: String? = null,
|
||||
subject: String? = null
|
||||
): Array<Array<String>> = buildList {
|
||||
add(DkgSessionIdTag.assemble(sessionId))
|
||||
threshold?.let { add(DkgThresholdTag.assemble(it)) }
|
||||
parentChatRoomId?.let { add(SubgroupParentTag.assemble(it)) }
|
||||
subject?.ifBlank { null }?.let { add(SubjectTag.assemble(it)) }
|
||||
}.toTypedArray()
|
||||
|
||||
fun parseSessionId(tags: Array<Array<String>>): String? =
|
||||
@@ -83,4 +89,39 @@ object DkgRitualEvents {
|
||||
|
||||
fun parseThreshold(tags: Array<Array<String>>): Int? =
|
||||
tags.firstNotNullOfOrNull(DkgThresholdTag::parse)?.threshold
|
||||
|
||||
/**
|
||||
* The group this ceremony claims to be making a subgroup of, or null for an
|
||||
* ordinary one.
|
||||
*
|
||||
* **This authenticates nothing.** Anybody can put any room id here, and
|
||||
* nothing about the parent is checked when it is read: `acceptProposal` files
|
||||
* it on the session and the ritual screen shows it, and that is the whole of
|
||||
* what it is for. The claim that decides anything is the birth certificate,
|
||||
* two steps later, which the parent's own quorum signs and which any device
|
||||
* can check against the parent's room id alone.
|
||||
*
|
||||
* The reason to carry it at all is that a member selected for a subgroup
|
||||
* ceremony would otherwise see a shared key ceremony open in a room they did
|
||||
* not ask for, with nothing saying what it is for. Being told is worth having
|
||||
* even when the telling is not evidence.
|
||||
*/
|
||||
fun parseParentChatRoomId(tags: Array<Array<String>>): String? =
|
||||
tags.firstNotNullOfOrNull(SubgroupParentTag::parse)?.parentChatRoomId
|
||||
|
||||
/**
|
||||
* What the room a ceremony runs in should be called.
|
||||
*
|
||||
* `NostrDao.getOrCreateNip17ChatRoom` already reads this off an arriving
|
||||
* payload -- it has always built the receiving side's room with
|
||||
* `subject = parseSubject()` -- and nothing on the ritual path ever wrote
|
||||
* one. So a proposal was the first thing anybody heard of the room, and it
|
||||
* arrived nameless: the member who created it saw the name they typed and
|
||||
* every other member saw an untitled chat with a key ceremony running in it.
|
||||
*
|
||||
* Read through the same `SubjectTag` NIP-14 uses, because it is the same tag
|
||||
* and the same field it lands in.
|
||||
*/
|
||||
fun parseSubject(tags: Array<Array<String>>): String? =
|
||||
tags.firstNotNullOfOrNull(SubjectTag::parse)
|
||||
}
|
||||
|
||||
@@ -26,12 +26,19 @@ interface DkgRepository {
|
||||
|
||||
suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession?
|
||||
|
||||
/** Opens a ritual. Only meaningful for the room's creator. */
|
||||
/**
|
||||
* Opens a ritual. Only meaningful for the room's creator.
|
||||
*
|
||||
* [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`.
|
||||
*/
|
||||
suspend fun proposeRitual(
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey? = null
|
||||
): DkgSession?
|
||||
|
||||
/** What the ritual is waiting on this device's owner for, if anything. */
|
||||
@@ -101,7 +108,8 @@ interface DkgRepository {
|
||||
localChatRoom: LocalChatRoom,
|
||||
userPublicKey: HexKey,
|
||||
nostrPrivateKey: ByteArray,
|
||||
threshold: Int
|
||||
threshold: Int,
|
||||
parentChatRoomId: HexKey?
|
||||
): DkgSession? = null
|
||||
|
||||
override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = null
|
||||
|
||||
@@ -16,6 +16,7 @@ import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -93,13 +94,18 @@ class RobustRoomKeyCeremonyTest {
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun openCeremony(room: LocalChatRoom, threshold: Int = quorum) =
|
||||
private suspend fun openCeremony(
|
||||
room: LocalChatRoom,
|
||||
threshold: Int = quorum,
|
||||
parentChatRoomId: String? = null,
|
||||
) =
|
||||
ChillDkgRitualManager.proposeRitual(
|
||||
database = db,
|
||||
localChatRoom = room,
|
||||
userPublicKey = user,
|
||||
nostrPrivateKey = creatorPrivateKey,
|
||||
threshold = threshold,
|
||||
parentChatRoomId = parentChatRoomId,
|
||||
)
|
||||
|
||||
private suspend fun ritualPayloads(room: LocalChatRoom) =
|
||||
@@ -221,4 +227,85 @@ class RobustRoomKeyCeremonyTest {
|
||||
"the group proposes again",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The room's name reached nobody but the member who typed it.
|
||||
*
|
||||
* `NostrDao.getOrCreateNip17ChatRoom` has always built the receiving side's
|
||||
* room with `subject = parseSubject()`, and nothing on the ritual path ever
|
||||
* wrote a subject tag. Standing up a NIP-17 room sends nothing to anybody, so
|
||||
* the proposal is routinely the first anyone hears of the room -- and it
|
||||
* arrived untitled, with a key ceremony already running in it.
|
||||
*/
|
||||
@Test
|
||||
fun `the proposal carries the room's name, and no later message repeats it`() = runBlocking {
|
||||
val room = robustRoom()
|
||||
openCeremony(room)
|
||||
|
||||
val payloads = ritualPayloads(room)
|
||||
val proposal = payloads.first { it.kind == DkgRitualEvents.PROPOSAL }
|
||||
|
||||
assertEquals("Robust Group", DkgRitualEvents.parseSubject(proposal.tags))
|
||||
|
||||
// Every later message belongs to a session the receiver already has the
|
||||
// room for, so repeating the name would be bytes per round to say
|
||||
// something already settled.
|
||||
payloads.filterNot { it.kind == DkgRitualEvents.PROPOSAL }.forEach {
|
||||
assertNull(
|
||||
DkgRitualEvents.parseSubject(it.tags),
|
||||
"kind ${it.kind} should not repeat the room's name",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary ceremony claims no parent anywhere`() = runBlocking {
|
||||
val room = robustRoom()
|
||||
val session = openCeremony(room)
|
||||
|
||||
assertNull(session.parentChatRoomId)
|
||||
ritualPayloads(room).forEach {
|
||||
assertNull(
|
||||
DkgRitualEvents.parseParentChatRoomId(it.tags),
|
||||
"kind ${it.kind} should claim no parent",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A subgroup ceremony says what it is for, and says it once.
|
||||
*
|
||||
* The claim is unverified and nothing is granted on it -- the participant set
|
||||
* is still the p-tags and the threshold is still the threshold. What it buys
|
||||
* is that a member selected for a subgroup is not left watching a key ceremony
|
||||
* open in a room they did not ask for with nothing saying why. The parent's own
|
||||
* signature turns up two steps later, on the birth certificate.
|
||||
*/
|
||||
@Test
|
||||
fun `a subgroup ceremony names its parent, on the proposal alone`() = runBlocking {
|
||||
val room = robustRoom()
|
||||
val parent = "ab".repeat(32)
|
||||
|
||||
val session = openCeremony(room, parentChatRoomId = parent)
|
||||
|
||||
assertEquals(parent, session.parentChatRoomId)
|
||||
|
||||
val payloads = ritualPayloads(room)
|
||||
val proposal = payloads.first { it.kind == DkgRitualEvents.PROPOSAL }
|
||||
|
||||
assertEquals(parent, DkgRitualEvents.parseParentChatRoomId(proposal.tags))
|
||||
|
||||
// Only on the proposal: a later message with a say in what an opened
|
||||
// ceremony is for would be a second answer to a settled question.
|
||||
payloads.filterNot { it.kind == DkgRitualEvents.PROPOSAL }.forEach {
|
||||
assertNull(
|
||||
DkgRitualEvents.parseParentChatRoomId(it.tags),
|
||||
"kind ${it.kind} should not restate the parent",
|
||||
)
|
||||
}
|
||||
|
||||
// And it changes nothing about the ceremony itself.
|
||||
assertEquals(quorum, session.threshold)
|
||||
assertEquals(3, session.participantCount)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user