diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index 0d8dc93b..4a1f1b26 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt index 93386f5a..f2acb856 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/ChillDkgRitualManager.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/dkg/DkgRitualEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/dkg/DkgRitualEvents.kt index dde1f320..2fe6ea3c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/dkg/DkgRitualEvents.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/dkg/DkgRitualEvents.kt @@ -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> = 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>): String? = @@ -83,4 +89,39 @@ object DkgRitualEvents { fun parseThreshold(tags: Array>): 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>): 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>): String? = + tags.firstNotNullOfOrNull(SubjectTag::parse) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index b0ace479..a947ef9e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -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 diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt index b4808780..bd0d7889 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/RobustRoomKeyCeremonyTest.kt @@ -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) + } }