diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 766c6354..cafb4f60 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -805,17 +805,19 @@ data class ChatMessage( } } ChapterEvent.KIND -> { - val chapterEvent = ChapterEvent( - id = event.id, - pubKey = event.pubKey, - createdAt = event.createdAt, - tags = event.tags, - content = event.content, - sig = event.sig - ) - + // The chunks the chapter splits into are their own events, + // signed in the same session and applied after it -- see + // AddChapterViewModel.addChapter. Nothing to do here but the + // chapter itself. MantraChapter.fromChapterEvent( - chapterEvent = chapterEvent, + chapterEvent = ChapterEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig + ), chatRoomId = groupId, )?.let { mantraChapter -> database.mantraChapterDao().upsert( @@ -824,24 +826,6 @@ data class ChatMessage( ) ) - // A signed chapter arrives with the chunks it is made - // of, split out here rather than sent, so that every - // device holding the chapter holds the same chunks. - // Nothing hangs off a chapter directly -- a translation - // is of a chunk -- so a chapter without them cannot be - // worked on. A submitted chapter brought its own, which - // is why this splits only what the group signed. - MantraChunk.chunksOf( - chapterEvent = chapterEvent, - chatRoomId = groupId, - ).forEach { chunk -> - database.mantraChunkDao().upsert( - chunk.copy( - marmotGroupEventId = marmotGroupEventId, - ) - ) - } - ChatMessage( giftWrapPayloadId = null, messageType = "chapter", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt index 348d83d2..707e1b00 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt @@ -10,12 +10,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip31Alts.AltTag import press.mantra.compose.database.model.traits.OptionalNostrEventEntity import press.mantra.compose.database.model.traits.TimestampedEntity -import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag import press.mantra.compose.nostr.nip30303.tags.IndexTag import press.mantra.compose.nostr.nip30303.tags.WordStatisticsTag -import press.mantra.compose.text.Markdown import kotlin.time.Clock import kotlin.time.Instant @@ -92,65 +90,6 @@ data class MantraChunk( } companion object { - /** - * The chunks a chapter is made of, derived from the chapter. - * - * The group signs a chapter; it does not sign these. They cannot be - * invented by whichever device notices the chapter first, because an - * invented id differs on every device and none of them would agree - * about which chunk a translation is of. Deriving them from the signed - * chapter's own text gives every device the same rows from the same - * bytes, which is the only property that matters here. - * - * They could be signed. `FrostSigningManager.proposeSigningBatch` would - * carry the chapter and a chunk per paragraph through one quorum, and - * every row would then hold a signature of its own. It is not worth what - * it costs: `MAX_BATCH_SIZE` is 64, which caps a chapter at 63 - * paragraphs and fails an ordinary one outright; the text would go on - * the wire twice, whole on the chapter and again split across the - * chunks; and a batch is only as available as its worst item, so a - * chapter's odds of being signed would fall with its length. What the - * signature would prove is proved already -- a chunk is a pure function - * of the chapter it hangs off, and it cannot be held without that - * chapter, which the foreign key enforces. - * - * They are rumors -- empty signature -- because nobody signed them. - * What the group signed is the chapter they were split out of, and that - * is also the only chapter this splits: a chapter that arrived as a - * submission was sent with its own chunk events, written under the - * submitter's key, and deriving a second set beside them would leave - * every paragraph in the chapter twice under ids nothing reconciles. - * - * Empty when the chapter has no text to split, which is every chapter - * whose original text is blank. - */ - fun chunksOf( - chapterEvent: ChapterEvent, - chatRoomId: HexKey, - ): List { - if (chapterEvent.sig.isEmpty()) return emptyList() - - val originalText = chapterEvent.originalText() ?: return emptyList() - - return Markdown.splitParagraphs(originalText).mapIndexedNotNull { index, paragraph -> - fromChunkEventTemplate( - chunkEventTemplate = ChunkEvent.build( - chapterId = chapterEvent.id, - text = paragraph, - index = index, - wordCount = Markdown.wordCount(paragraph), - characterCount = Markdown.characterCount(paragraph), - // The chapter's own timestamp, not the reader's: a device - // applying the chapter an hour later has to arrive at the - // same ids as the one that applied it first. - createdAt = chapterEvent.createdAt, - ), - chatRoomId = chatRoomId, - userPublicKey = chapterEvent.pubKey, - ) - } - } - fun fromChunkEventTemplate( chunkEventTemplate: EventTemplate, chatRoomId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt index cc93b0f4..58ccf9c1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt @@ -81,6 +81,21 @@ class DatabaseFrostSigningRepository( ) } + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + lead: EventTemplate<*>, + dependents: (lead: Event) -> List> + ): FrostSigningSession? = proposing(localChatRoom) { + FrostSigningManager.proposeSigningBatch( + database = database, + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + lead = lead, + dependents = dependents + ) + } + /** * Proposing throws when the group has no key, when this device was not in the * ceremony, or when a batch is empty or over the cap. All four are states the diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 28521d5e..3bc0fca5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -192,12 +192,43 @@ object FrostSigningManager { userPublicKey: HexKey, events: List>, key: DkgSession? = null - ): FrostSigningSession { - require(events.isNotEmpty()) { "A signing session must be given something to sign" } - require(events.size <= MAX_BATCH_SIZE) { - "A signing session will sign at most $MAX_BATCH_SIZE events, not ${events.size}" - } + ): FrostSigningSession = proposeSigningBatch( + database = database, + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + lead = events.firstOrNull() + ?: throw IllegalArgumentException("A signing session must be given something to sign"), + dependents = { events.drop(1) }, + key = key + ) + /** + * Opens a session over an event and the events that name it. + * + * The flat form above cannot express this. A chunk carries the id of the + * chapter it belongs to, and that id is not knowable to a caller: it is a + * hash over the *group's* key at the *room's* derivation path, and neither + * is resolved until this function runs. A caller that computed it anyway + * would be recomputing [signingPath], which is the one input in this + * protocol that must never come from a proposer -- the path decides which + * key the group signs as. + * + * So [lead] is built here and handed to [dependents], which returns the + * events that reference it. Every id in the batch then comes from one place, + * and an item naming a chapter nobody signed is not a mistake that can be + * made rather than one that has to be tested for. + * + * The lead is item 0, so it is applied before anything that names it -- a + * chunk row whose chapter does not exist yet is a foreign key violation. + */ + suspend fun proposeSigningBatch( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + lead: EventTemplate<*>, + dependents: (lead: Event) -> List>, + key: DkgSession? = null + ): FrostSigningSession { val ceremony = key?.takeIf { it.stage == DkgRitualStage.COMPLETE && it.secretShare != null } ?: completedKey(database, localChatRoom.chatRoom.id) ?: throw IllegalStateException("This group has no shared key to sign with") @@ -206,7 +237,7 @@ object FrostSigningManager { ?: throw IllegalStateException("This device is not a participant in ceremony ${ceremony.id}") val path = signingPath(database, localChatRoom, ceremony) - val unsignedEvents = events.map { template -> + val unsignedEventOf = { template: EventTemplate<*> -> unsignedEventOf( key = ceremony, path = path, @@ -216,6 +247,14 @@ object FrostSigningManager { createdAt = template.createdAt ) } + + val leadEvent = unsignedEventOf(lead) + val unsignedEvents = listOf(leadEvent) + dependents(leadEvent).map(unsignedEventOf) + + require(unsignedEvents.size <= MAX_BATCH_SIZE) { + "A signing session will sign at most $MAX_BATCH_SIZE events, not ${unsignedEvents.size}" + } + val sessionId = RandomInstance.bytes(32).toHex() val session = FrostSigningSession( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt index 2bad537f..dbfe4f1c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChapterEvent.kt @@ -31,15 +31,13 @@ class ChapterEvent( fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name /** - * The markdown the chapter is, and the only place its chunks come from. + * The markdown the chapter is, whole. * - * Carried on the chapter rather than in an event per paragraph because the - * group signs the chapter and nothing else. Ids invented locally differ on - * every device holding the same chapter, so the chunks are split back out - * of this text when the signed chapter is applied (see - * MantraChunk.chunksOf), which gives every device the same rows from the - * same bytes. Signing them alongside the chapter as a batch is possible and - * was weighed; MantraChunk.chunksOf says what it would cost. + * The chapter carries its text even though the chunks cut from it are + * signed alongside it and carry the same words between them. It is the + * chapter as the group signed it: chunk boundaries are a decision about how + * to divide the work, and a chapter that kept only the pieces could never be + * divided again differently without losing what was agreed to. */ fun originalText() = tags.firstNotNullOfOrNull(OriginalTextTag::parse)?.originalText diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt index 35aa37c6..48876fc9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ChunkEvent.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt @@ -12,6 +13,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag import press.mantra.compose.nostr.nip30303.tags.IndexTag import press.mantra.compose.nostr.nip30303.tags.WordStatisticsTag +import press.mantra.compose.text.Markdown @Immutable @@ -36,6 +38,43 @@ class ChunkEvent( const val KIND = 30303 const val ALT_DESCRIPTION = "Chunk" + /** + * The chunks [chapter]'s text splits into, ready to be signed with it. + * + * A chapter and its chunks go to the group as one batch, so these are + * built from the chapter *after* it has been authored under the group's + * key -- [chapter] is the unsigned event the session will sign, which is + * where the id each chunk carries comes from. Splitting anywhere else + * would mean naming a chapter id before one exists. + * + * They take the chapter's own timestamp, so the batch reads as one act + * rather than as events that happen to share a session. + * + * Empty when the chapter has no text to split, which is a chapter with + * nothing in it to translate. + */ + fun splitOf(chapter: Event): List> { + val originalText = ChapterEvent( + id = chapter.id, + pubKey = chapter.pubKey, + createdAt = chapter.createdAt, + tags = chapter.tags, + content = chapter.content, + sig = chapter.sig + ).originalText() ?: return emptyList() + + return Markdown.splitParagraphs(originalText).mapIndexed { index, paragraph -> + build( + chapterId = chapter.id, + text = paragraph, + index = index, + wordCount = Markdown.wordCount(paragraph), + characterCount = Markdown.characterCount(paragraph), + createdAt = chapter.createdAt, + ) + } + } + fun build( chapterId: String, text: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt index 82ce1e3c..036fc23f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt @@ -92,6 +92,27 @@ interface FrostSigningRepository { events: List> ): FrostSigningSession? + /** + * Opens a session over an event and the events that name it, for a batch + * that is not a flat list: a chapter and its chunks, where each chunk + * carries the chapter's id. + * + * That id cannot be worked out by a caller -- it is a hash over the group's + * key at the room's derivation path, neither of which is resolved until the + * proposal is made -- so [lead] is built first and handed to [dependents], + * which returns the events referring to it. The lead is item 0 and so is + * applied first, which is what anything holding a foreign key to it needs. + * + * Everything the flat form says still holds: all-or-nothing, only as + * available as its worst item, and a retry is a new batch. + */ + suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + lead: EventTemplate<*>, + dependents: (lead: Event) -> List> + ): FrostSigningSession? + /** Agrees to sign, letting the session publish this device's part and run on. */ suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) @@ -136,6 +157,13 @@ interface FrostSigningRepository { events: List> ): FrostSigningSession? = null + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + lead: EventTemplate<*>, + dependents: (lead: Event) -> List> + ): FrostSigningSession? = null + override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index 8154e37e..a4b2c60a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -105,7 +105,17 @@ fun AddChapterScreen( // A chapter hangs off a version, and the group has to be able to // sign; without both there is nothing this screen can propose. val artifactVersion = addChapterUIState.artifactVersion - val canProposeChapter = artifactVersion != null && addChapterUIState.canSign + + // The chapter and a chunk per paragraph are signed in one session, + // and a session signs a bounded number of events. Past that the + // chapter has to be split in two, which is worth saying while there + // is still a cursor in the text rather than after a failed propose. + val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER + + val canProposeChapter = artifactVersion != null && + addChapterUIState.canSign && + paragraphCount > 0 && + !tooManyChunks // M3 gives a FAB no `enabled`, so borrow the disabled colours every // other button in the app uses rather than inventing a shade here. @@ -147,7 +157,7 @@ fun AddChapterScreen( buttonColors.disabledContentColor }, onClick = { - if (artifactVersion == null || !addChapterUIState.canSign) { + if (artifactVersion == null || !canProposeChapter) { return@ExtendedFloatingActionButton } @@ -205,6 +215,14 @@ fun AddChapterScreen( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) + } else if (tooManyChunks) { + Text( + text = "$paragraphCount chunks is more than the group can sign in " + + "one go. Split the chapter so that no part of it is over " + + "${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) } OutlinedTextField( @@ -228,8 +246,18 @@ fun AddChapterScreen( ) Text( - text = "$wordCount words · $characterCount characters · $paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"}", - style = MaterialTheme.typography.labelMedium + // The chunk count is what the group is asked to sign + // alongside the chapter, so it is a count of the work + // being proposed rather than a curiosity about the text. + text = "$wordCount words · $characterCount characters · " + + "$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " + + "of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}", + style = MaterialTheme.typography.labelMedium, + color = if (tooManyChunks) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.typography.labelMedium.color + } ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index dbd6694e..69999110 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -392,8 +392,8 @@ private fun OneThingBeingSigned(event: Event) { // The text is the substance of a chapter -- signing one is putting // the group's name to what everybody will translate from -- but it // is a whole chapter, so its size stands in for it here. The chunk - // count is what the text will actually split into, counted the same - // way the split itself counts (see MantraChunk.chunksOf). + // count says how many of the batch's other items came out of this + // text, which is the rest of what is being signed. val chunkCount = chapter.originalText()?.let { Markdown.splitParagraphs(it).size } listOfNotNull( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt index 8b414636..bee42019 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddChapterViewModel.kt @@ -18,7 +18,9 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.launch import press.mantra.compose.database.model.MantraArtifactVersion import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository @@ -63,21 +65,29 @@ class AddChapterViewModel( } /** - * Asks the group to sign a chapter into the artifact. + * Asks the group to sign a chapter, and every chunk it splits into, in one + * session. * - * The chapter is not created here and does not exist yet. What goes out is a - * proposal to sign it, and the chapter -- with the chunks it splits into -- - * appears on every member's device at once, authored by this room's own key - * rather than by whoever pasted the text, when enough members have signed. - * That author is the room's id: signing runs at the path the room was - * derived at, so a chapter says which group's artifact it belongs to simply - * by being signed. + * Nothing is created here. What goes out is a proposal to sign the lot, and + * the chapter and its chunks appear on every member's device at once, + * authored by this room's own key rather than by whoever pasted the text, + * when enough members have signed. That author is the room's id: signing + * runs at the path the room was derived at, so a chapter says which group's + * artifact it belongs to simply by being signed. * * That is the difference from submitting one. A submission says "I am * putting this in front of the group" and the group's only recourse * afterwards is social. A signature is the group saying it, and it takes a * quorum to say. The text everyone will translate from is the group's, so * the second is the honest one. + * + * The chunks travel as their own events rather than being split back out of + * the signed chapter on each device, so that each one carries the group's + * signature over its own text -- a translation is of a chunk, and a chunk + * that can be checked on its own is worth more than one that can only be + * checked by re-deriving it. What it costs is [MAX_CHUNKS_PER_CHAPTER]: the + * batch is capped, the chapter takes one of its places, and a chapter with + * more paragraphs than the rest cannot be proposed at all. */ fun addChapter( localChatRoom: LocalChatRoom, @@ -89,8 +99,12 @@ class AddChapterViewModel( ) { val name = nameField.text.toString() val originalText = originalTextField.text.toString() + val paragraphs = Markdown.splitParagraphs(originalText) - if (name.isBlank() || originalText.isBlank()) { + // The cap is the group's, not this screen's, and proposing past it + // throws rather than failing softly. The form says so as it is typed; + // this is the check that has to hold when it does not. + if (name.isBlank() || paragraphs.isEmpty() || paragraphs.size > MAX_CHUNKS_PER_CHAPTER) { onFailure.invoke() return } @@ -100,11 +114,6 @@ class AddChapterViewModel( isActionPending.value = true viewModelScope.launch(Dispatchers.IO) { - // The chunks are not proposed. They are split out of the chapter's - // own text when the signed chapter is applied (see - // MantraChunk.chunksOf), so one quorum buys the whole chapter rather - // than one per paragraph, and every device splits the same text the - // same way. val chapterEventTemplate = ChapterEvent.build( artifactVersionId = artifactVersion.id, name = name, @@ -119,12 +128,15 @@ class AddChapterViewModel( ) val session = runCatching { - frostSigningRepository.proposeSigning( + frostSigningRepository.proposeSigningBatch( localChatRoom = localChatRoom, userPublicKey = activeUserPublicKey, - kind = chapterEventTemplate.kind, - tags = chapterEventTemplate.tags, - content = chapterEventTemplate.content, + lead = chapterEventTemplate, + // A chunk names the chapter it belongs to, and that id is a + // hash over the group's key at the room's path -- neither of + // which this screen knows or should. The chapter comes back + // built, and the chunks are cut from the text it carries. + dependents = ChunkEvent::splitOf ) }.onFailure { error -> logger.e("Failed to propose a chapter for signing", error) @@ -150,6 +162,17 @@ class AddChapterViewModel( companion object { private const val TAG = "AddChapterViewModel" + /** + * The most paragraphs a chapter can be proposed with. + * + * A chapter is signed together with a chunk per paragraph, and the + * session signs at most [FrostSigningManager.MAX_BATCH_SIZE] events. The + * chapter is one of them, so the paragraphs get the rest. A longer + * chapter has to be split into two, which is a real limit and belongs in + * front of whoever is typing rather than in a failure afterwards. + */ + const val MAX_CHUNKS_PER_CHAPTER: Int = FrostSigningManager.MAX_BATCH_SIZE - 1 + fun factory( activeUserPublicKey: HexKey, artifactId: String, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ChapterChunksTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ChapterChunksTest.kt deleted file mode 100644 index df595fe2..00000000 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/database/model/ChapterChunksTest.kt +++ /dev/null @@ -1,220 +0,0 @@ -package press.mantra.compose.database.model - -import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertTrue -import press.mantra.compose.nostr.nip30303.ChapterEvent -import press.mantra.compose.nostr.nip30303.tags.OriginalTextTag - -/** - * The chunks of a chapter are derived, not delivered. - * - * The group signs a chapter and nothing else, so the chunks it splits into are - * not events anybody sent: every device builds those rows for itself out of the - * chapter it already holds. That only works while every device builds the *same* - * rows, and nothing about the ids would show it if they stopped — they are - * content hashes, opaque hex either way. What would show is two members - * translating what looks like the same paragraph into rows that never reconcile, - * with the chapter identical on both screens. - */ -class ChapterChunksTest { - private val groupKey = "a".repeat(64) - private val artifactVersionId = "b".repeat(64) - private val chatRoomId = "room" - - private val twoParagraphs = "The first paragraph.\n\nThe second paragraph." - - private fun signedChapter( - name: String = "Chapter 1", - originalText: String = twoParagraphs, - index: Int = 0, - createdAt: Long = 1_700_000_000, - ): ChapterEvent { - val template = ChapterEvent.build( - artifactVersionId = artifactVersionId, - name = name, - originalText = originalText, - index = index, - wordCount = 6, - characterCount = originalText.length, - createdAt = createdAt, - ) - - // Hashed rather than made up, so two fixtures that differ are two - // different chapters here for the same reason they would be in the app. - return ChapterEvent( - id = EventHasher.hashId( - pubKey = groupKey, - createdAt = template.createdAt, - kind = template.kind, - tags = template.tags, - content = template.content - ), - pubKey = groupKey, - createdAt = template.createdAt, - tags = template.tags, - content = template.content, - sig = "d".repeat(128) - ) - } - - @Test - fun `the derived chunks are a function of the chapter and nothing else`() { - // Every input has to come off the chapter. Reading the clock here would - // still agree with itself twice in a row -- and disagree between two - // devices that applied the same chapter minutes apart, which is the case - // nobody can reproduce on demand. So the timestamps are checked against - // the chapter's rather than against a second derivation. - val chapter = signedChapter(createdAt = 1_700_000_000) - - val chunks = MantraChunk.chunksOf(chapter, chatRoomId) - - assertEquals(2, chunks.size) - assertTrue(chunks.all { it.createdAt.epochSeconds == 1_700_000_000L }) - } - - @Test - fun `two devices derive the same chunks from the same chapter`() { - val chapter = signedChapter() - - val mine = MantraChunk.chunksOf(chapter, chatRoomId) - val theirs = MantraChunk.chunksOf(chapter, chatRoomId) - - assertEquals(mine.map { it.id }, theirs.map { it.id }) - assertEquals(mine.map { it.createdAt }, theirs.map { it.createdAt }) - } - - @Test - fun `a chapter signed at a different moment derives different chunks`() { - // The chapter's own timestamp is bound into the derived ids, so two - // proposals identical but for when they were made stay two chapters with - // two sets of chunks rather than colliding on one set of rows. - val first = MantraChunk.chunksOf(signedChapter(createdAt = 1_700_000_000), chatRoomId) - val second = MantraChunk.chunksOf(signedChapter(createdAt = 1_700_000_001), chatRoomId) - - assertNotEquals(first.map { it.id }, second.map { it.id }) - } - - @Test - fun `the derived chunks hang off the chapter and carry its text in order`() { - val chapter = signedChapter() - - val chunks = MantraChunk.chunksOf(chapter, chatRoomId) - - assertEquals(listOf(chapter.id, chapter.id), chunks.map { it.chapterId }) - assertEquals(listOf("The first paragraph.", "The second paragraph."), chunks.map { it.text }) - assertEquals(listOf(0, 1), chunks.map { it.index }) - // Authored by whoever authored the chapter -- the group, once signed -- - // and unsigned, because nobody signed these. - assertTrue(chunks.all { it.publicKey == groupKey }) - assertTrue(chunks.all { it.signature == "" }) - } - - @Test - fun `each chunk counts its own paragraph rather than the chapter's`() { - // The chapter's counts describe the whole text. A chunk that inherited - // them would report every paragraph as the length of the chapter, and - // any progress measured per chunk would be wrong by that factor. - val chunks = MantraChunk.chunksOf( - signedChapter(originalText = "One two three.\n\nFour."), - chatRoomId - ) - - assertEquals(listOf(3, 1), chunks.map { it.wordCount }) - assertEquals(listOf("One two three.".length, "Four.".length), chunks.map { it.characterCount }) - } - - @Test - fun `two identical paragraphs stay two chunks`() { - // Ids are content hashes, so repeated text is where they would collide - // and a chapter would silently lose a paragraph. The position is in the - // event, which is what keeps them apart. - val chunks = MantraChunk.chunksOf( - signedChapter(originalText = "Again.\n\nAgain."), - chatRoomId - ) - - assertEquals(2, chunks.size) - assertNotEquals(chunks[0].id, chunks[1].id) - } - - @Test - fun `where a chapter sits does not change the chunks it splits into`() { - // Two chapters with the same text at different positions in the book are - // two chapters, and their chunks hang off their own chapter. The check - // that matters is that the ids differ -- chunks of chapter three must not - // be chunks of chapter one. - val first = signedChapter(index = 0) - val second = signedChapter(index = 3) - - val firstChunks = MantraChunk.chunksOf(first, chatRoomId) - val secondChunks = MantraChunk.chunksOf(second, chatRoomId) - - assertNotEquals(first.id, second.id) - assertEquals(firstChunks.map { it.text }, secondChunks.map { it.text }) - assertNotEquals(firstChunks.map { it.id }, secondChunks.map { it.id }) - } - - @Test - fun `a chapter with no text to split derives no chunks`() { - assertEquals(emptyList(), MantraChunk.chunksOf(signedChapter(originalText = " "), chatRoomId)) - - // And a chapter that declares no text at all, which is malformed rather - // than empty: nothing to split, and nothing to throw over either. - val declared = signedChapter() - val silent = ChapterEvent( - id = declared.id, - pubKey = declared.pubKey, - createdAt = declared.createdAt, - tags = declared.tags.filterNot { it.firstOrNull() == OriginalTextTag.TAG_NAME } - .toTypedArray(), - content = declared.content, - sig = declared.sig - ) - - assertEquals(emptyList(), MantraChunk.chunksOf(silent, chatRoomId)) - } - - @Test - fun `a chapter that arrived as a submission derives nothing`() { - // Chapters used to travel as a submission with a chunk event per - // paragraph, and those rows are already on disk -- written under the - // submitter's key, with ids that have nothing to do with these. Splitting - // one again would leave every paragraph in it twice, and a replay of the - // room's group events would do it without anybody adding anything. - val submitted = signedChapter().let { chapter -> - ChapterEvent( - id = chapter.id, - pubKey = chapter.pubKey, - createdAt = chapter.createdAt, - tags = chapter.tags, - content = chapter.content, - sig = "" // A rumor: what a submission carries. - ) - } - - assertEquals(emptyList(), MantraChunk.chunksOf(submitted, chatRoomId)) - } - - @Test - fun `the derived chunk row and its event agree on the id`() { - // A chunk is read back off the wire by other code paths (an inbound - // ChunkEvent from a peer), so the row a device derives has to be the row - // that event would produce -- otherwise the same chunk arrives twice. - val chunk = MantraChunk.chunksOf(signedChapter(), chatRoomId).first() - - assertEquals(chunk.id, chunk.toChunkEvent().id) - assertEquals( - chunk.id, - EventHasher.hashId( - pubKey = chunk.publicKey, - createdAt = chunk.createdAt.epochSeconds, - kind = chunk.toChunkEvent().kind, - tags = chunk.toChunkEvent().tags, - content = chunk.toChunkEvent().content - ) - ) - } -} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt index 05fe1d72..5397cb69 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/SignedChapterTest.kt @@ -23,21 +23,28 @@ import press.mantra.compose.database.model.MantraChapter import press.mantra.compose.database.model.MantraChunk import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent /** - * A chapter the group signed, from proposal to rows, against real FROST. + * A chapter and its chunks the group signed, from proposal to rows, against + * real FROST. * - * Adding a chapter used to write a row and submit it, along with a submission - * per paragraph; the rows said the submitter wrote them, because they had. Now - * the group signs the chapter, and the claim this test exists to hold is that - * the chapter every device ends up with is the group's: authored by the - * threshold key, carrying a signature that verifies, with an id every member - * arrives at independently -- and that the chunks each device splits out of it - * are the same chunks. + * Adding a chapter used to write the rows and submit them, one submission for + * the chapter and one per paragraph; the rows said the submitter wrote them, + * because they had. Now the group signs the lot in one session, and the claim + * this test exists to hold is that every row a device ends up with is the + * group's: authored by the threshold key, carrying a signature that verifies + * over its own id, with the chunks naming the chapter that was signed beside + * them. * * None of that is visible when it breaks. A row whose author is the proposer * looks exactly like a row whose author is the group -- both are opaque hex -- - * and a group that disagrees about the ids has two chapters that look like one. + * and a chunk that names the wrong chapter is a foreign key away from vanishing + * with no sign of why. + * + * A batch is k independent FROST instances, so the signatures here are made one + * per item, each over its own message with its own nonces, which is the only + * way they can be made. */ class SignedChapterTest { private val participants = 3 @@ -108,22 +115,28 @@ class SignedChapterTest { sig = "" ) - private fun itemOver(unsignedEvent: Event) = FrostSigningItem( - sessionId = "s".repeat(64), - itemIndex = 0, - unsignedEventJson = unsignedEvent.toJson(), - eventId = unsignedEvent.id, - nonceRandom = "f".repeat(64) - ) + private fun itemsOver(unsignedEvents: List) = unsignedEvents.mapIndexed { index, event -> + FrostSigningItem( + sessionId = "s".repeat(64), + itemIndex = index, + unsignedEventJson = event.toJson(), + eventId = event.id, + // One seed per item, never shared: two messages under one nonce is + // how a share is extracted. + nonceRandom = "f".repeat(63) + "${index + 1}" + ) + } - /** A quorum signing the item's event, in the manager's order. */ + /** A quorum signing one item's event, in the manager's order. */ private fun groupSignature(item: FrostSigningItem): String { val message = ByteVector(item.eventId.hexToByteArray()) val signerIds = listOf(0, 1) val nonces = signerIds.map { signerId -> SecretNonce.generate( - sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + sessionRandom = ByteVector32( + "a".repeat(62) + "${item.itemIndex + 1}${signerId + 1}" + ), secretShare = keyMaterial.secretShares[signerId], publicShare = keyMaterial.publicShares[signerId], tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, @@ -153,16 +166,45 @@ class SignedChapterTest { return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() } + /** + * The batch a proposal makes: the chapter, then a chunk per paragraph, cut + * from the chapter after it is authored under the group's key -- exactly + * what `AddChapterViewModel` hands `proposeSigningBatch` as lead and + * dependents. + */ + private fun proposedBatch( + name: String = "Chapter 1", + text: String = originalText, + index: Int = 0, + ): List { + val chapter = unsignedEventOf(proposalTemplate(name, text, index)) + + return listOf(chapter) + ChunkEvent.splitOf(chapter).map(::unsignedEventOf) + } + /** Everything from the form to the rows a device holds afterwards. */ + private fun signedBatch( + name: String = "Chapter 1", + text: String = originalText, + index: Int = 0, + ): List = itemsOver(proposedBatch(name, text, index)).map { item -> + FrostSigningManager.signedEvent(item, groupSignature(item)) + } + private fun signedChapterEvent( name: String = "Chapter 1", text: String = originalText, index: Int = 0, - ): ChapterEvent { - val item = itemOver(unsignedEventOf(proposalTemplate(name, text, index))) - val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) + ): ChapterEvent = signedBatch(name, text, index).first().let { signed -> + ChapterEvent( + signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig + ) + } - return ChapterEvent( + private fun signedChunkEvents( + text: String = originalText, + ): List = signedBatch(text = text).drop(1).map { signed -> + ChunkEvent( signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, signed.sig ) } @@ -198,7 +240,7 @@ class SignedChapterTest { // to be the one that was signed rather than anything recomputed from the // proposer. Otherwise members converge on nothing and each holds its own // copy of what is meant to be one chapter. - val item = itemOver(unsignedEventOf(proposalTemplate())) + val item = itemsOver(proposedBatch()).first() val signed = FrostSigningManager.signedEvent(item, groupSignature(item)) val chapter = MantraChapter.fromChapterEvent( @@ -243,33 +285,70 @@ class SignedChapterTest { } @Test - fun `the chapter arrives with the chunks it splits into`() { - // Nothing sends these rows: each device derives them from the chapter it - // just applied. A translation is of a chunk rather than of a chapter, so - // a chapter that arrives without them cannot be worked on. - val signed = signedChapterEvent() + fun `the chapter is signed with a chunk for every paragraph`() { + // One session, one approval, and a row per paragraph at the end of it. + // A translation is of a chunk rather than of a chapter, so a chapter + // that arrived without them could not be worked on. + val batch = signedBatch() - val chapter = MantraChapter.fromChapterEvent(signed, chatRoomId) - val chunks = MantraChunk.chunksOf(signed, chatRoomId) - - assertEquals(2, chunks.size) - assertTrue(chunks.all { it.chapterId == chapter?.id }) - assertEquals(listOf("The first paragraph.", "The second paragraph."), chunks.map { it.text }) - assertTrue(chunks.all { it.publicKey == groupPubKey }) - // Derived, not signed: the group signed the chapter they were split from. - assertTrue(chunks.all { it.signature == "" }) + assertEquals(3, batch.size) + assertEquals(ChapterEvent.KIND, batch.first().kind) + assertTrue(batch.drop(1).all { it.kind == ChunkEvent.KIND }) } @Test - fun `two members derive the same chunks from the chapter their group signed`() { - // The reason the chunks can be derived at all. Both members hold the same - // signed bytes, so both have to land on the same rows -- if they do not, - // a translation one of them writes is of a chunk the other does not have. - val signed = signedChapterEvent() + fun `every chunk the group signed names the chapter it signed beside it`() { + // The batch is applied in item order, so the chapter row is written + // first and the chunks hang off it. A chunk naming anything else fails a + // foreign key and disappears with no sign of why. + val batch = signedBatch() + val chapter = MantraChapter.fromChapterEvent( + batch.first().let { + ChapterEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + }, + chatRoomId + ) - val mine = MantraChunk.chunksOf(signed, chatRoomId) - val theirs = MantraChunk.chunksOf(signed, chatRoomId) + val chunks = signedChunkEvents().map { MantraChunk.fromChunkEvent(it, chatRoomId) } - assertEquals(mine.map { it.id }, theirs.map { it.id }) + assertEquals(2, chunks.size) + assertTrue(chunks.all { it?.chapterId == chapter?.id }) + assertEquals(listOf("The first paragraph.", "The second paragraph."), chunks.map { it?.text }) + assertEquals(listOf(0, 1), chunks.map { it?.index }) + } + + @Test + fun `each chunk row carries a signature the group made over its own id`() { + // The point of signing the chunks rather than splitting them out of the + // chapter on arrival: a chunk can be checked on its own, by anybody + // holding it, without re-deriving it from anything. + signedChunkEvents().forEach { signed -> + val chunk = MantraChunk.fromChunkEvent(signed, chatRoomId) + + assertNotNull(chunk) + assertEquals(groupPubKey, chunk.publicKey) + assertNotEquals(proposer, chunk.publicKey) + assertTrue( + Nip01Crypto.verify( + signature = chunk.signature.hexToByteArray(), + hash = chunk.id.hexToByteArray(), + pubKey = chunk.publicKey.hexToByteArray() + ), + "a chunk row should carry a signature the group's key made over its own id" + ) + } + } + + @Test + fun `the chapter and its chunks are different messages to sign`() { + // A batch is k FROST instances precisely because it has to be: one nonce + // over two messages leaks the share. That the items are distinct + // messages is the premise -- `FrostSigningRoundTest` and + // `SignedGroupKeyStateTest` hold the manager to giving each its own + // nonce; what a chapter has to hold up is that its own items are not + // secretly one message repeated. + val batch = proposedBatch(text = "Again.\n\nAgain.") + + assertEquals(batch.size, batch.map { it.id }.toSet().size) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt new file mode 100644 index 00000000..e897939e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ChapterChunkSplitTest.kt @@ -0,0 +1,196 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue +import press.mantra.compose.nostr.nip30303.tags.OriginalTextTag +import press.mantra.compose.text.Markdown + +/** + * The chunks a chapter is proposed with are cut from the chapter itself. + * + * A chapter and a chunk per paragraph go to the group as one batch, and the + * chunks can only be built once the chapter has been authored under the group's + * key — a chunk carries the chapter's id, and that id does not exist before + * then. So the split takes the unsigned chapter the session is about to sign + * and cuts from what it holds. + * + * What breaks quietly here is a chunk that names the wrong chapter, or none: + * every id in sight is opaque hex, the batch signs whatever it is handed, and + * the first sign of trouble is a chapter that arrives with no chunks under it + * because every one of them failed a foreign key on the way in. + */ +class ChapterChunkSplitTest { + private val groupKey = "a".repeat(64) + private val artifactVersionId = "b".repeat(64) + + private val twoParagraphs = "The first paragraph.\n\nThe second paragraph." + + /** + * The chapter as the session will sign it: the proposer's fields re-authored + * under the group's key, with the id computed from them. + */ + private fun unsignedChapter( + name: String = "Chapter 1", + originalText: String = twoParagraphs, + index: Int = 0, + createdAt: Long = 1_700_000_000, + ): Event { + val template = ChapterEvent.build( + artifactVersionId = artifactVersionId, + name = name, + originalText = originalText, + index = index, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + createdAt = createdAt, + ) + + return Event( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "" + ) + } + + /** What a chunk template says once it is tags and a string, which is all it travels as. */ + private fun readBack(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate) = + ChunkEvent( + id = EventHasher.hashId( + pubKey = groupKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ), + pubKey = groupKey, + createdAt = template.createdAt, + tags = template.tags, + content = template.content, + sig = "" + ) + + @Test + fun `every chunk names the chapter it was cut from`() { + // The whole reason the split happens against the built chapter rather + // than against the form. A chunk naming anything else is a row that + // cannot be written. + val chapter = unsignedChapter() + + val chunks = ChunkEvent.splitOf(chapter).map(::readBack) + + assertEquals(2, chunks.size) + assertTrue(chunks.all { it.chapterId() == chapter.id }) + } + + @Test + fun `the chunks carry the chapter's text in order`() { + val chunks = ChunkEvent.splitOf(unsignedChapter()).map(::readBack) + + assertEquals(listOf("The first paragraph.", "The second paragraph."), chunks.map { it.content }) + assertEquals(listOf(0, 1), chunks.map { it.index() }) + } + + @Test + fun `each chunk counts its own paragraph rather than the chapter's`() { + // The chapter's counts describe the whole text. A chunk that inherited + // them would report every paragraph as the length of the chapter, and + // any progress measured per chunk would be wrong by that factor. + val chunks = ChunkEvent + .splitOf(unsignedChapter(originalText = "One two three.\n\nFour.")) + .map(::readBack) + + assertEquals(listOf(3, 1), chunks.map { it.wordCount() }) + assertEquals(listOf("One two three.".length, "Four.".length), chunks.map { it.characterCount() }) + } + + @Test + fun `the chunks are signed as of the chapter's own moment`() { + // Reading the clock here would put the batch's items minutes apart on a + // slow device, and a chapter signed at one time with chunks stamped at + // another is a batch that reads as two events that happened to share a + // session. + val chapter = unsignedChapter(createdAt = 1_700_000_000) + + val chunks = ChunkEvent.splitOf(chapter) + + assertTrue(chunks.all { it.createdAt == 1_700_000_000L }) + } + + @Test + fun `two identical paragraphs stay two chunks`() { + // Ids are content hashes, so repeated text is where two chunks would + // collide and the batch would carry one where the chapter has two. The + // position is in the event, which is what keeps them apart. + val chunks = ChunkEvent + .splitOf(unsignedChapter(originalText = "Again.\n\nAgain.")) + .map(::readBack) + + assertEquals(2, chunks.size) + assertNotEquals(chunks[0].id, chunks[1].id) + } + + @Test + fun `chapters that differ split into chunks that differ`() { + // Two chapters with the same words are two chapters, and their chunks + // belong to their own. Chunks of chapter three must not be chunks of + // chapter one. + val first = unsignedChapter(index = 0) + val second = unsignedChapter(index = 3) + + val firstChunks = ChunkEvent.splitOf(first).map(::readBack) + val secondChunks = ChunkEvent.splitOf(second).map(::readBack) + + assertNotEquals(first.id, second.id) + assertEquals(firstChunks.map { it.content }, secondChunks.map { it.content }) + assertNotEquals(firstChunks.map { it.id }, secondChunks.map { it.id }) + } + + @Test + fun `the split counts what the form counted`() { + // The screen shows a chunk count as the text is typed and refuses to + // propose past the batch cap on the strength of it. If the split + // disagreed with that count, the form would allow a batch the session + // then rejects -- or refuse one it would have taken. + val text = "One.\n\nTwo.\n\n\nThree.\n\n \n\nFour." + + assertEquals( + Markdown.splitParagraphs(text).size, + ChunkEvent.splitOf(unsignedChapter(originalText = text)).size + ) + } + + @Test + fun `a chapter with no text to split proposes no chunks`() { + assertEquals(emptyList(), ChunkEvent.splitOf(unsignedChapter(originalText = " "))) + + // And a chapter that declares no text at all, which is malformed rather + // than empty: nothing to split, and nothing to throw over either. + val declared = unsignedChapter() + val silent = Event( + id = declared.id, + pubKey = declared.pubKey, + createdAt = declared.createdAt, + kind = declared.kind, + tags = declared.tags.filterNot { it.firstOrNull() == OriginalTextTag.TAG_NAME } + .toTypedArray(), + content = declared.content, + sig = declared.sig + ) + + assertEquals(emptyList(), ChunkEvent.splitOf(silent)) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt new file mode 100644 index 00000000..a70f1ca2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/ChapterBatchProposalJvmTest.kt @@ -0,0 +1,238 @@ +package press.mantra.compose.managers + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +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 kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +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.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.extensions.toHex +import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.nip30303.ChapterEvent +import press.mantra.compose.nostr.nip30303.ChunkEvent +import press.mantra.compose.text.Markdown + +/** + * What a chapter proposal actually puts in front of the group. + * + * `SignedChapterTest` builds the batch by hand and signs it, which proves the + * crypto and the rows but assumes the shape. This runs the real proposal + * against a real database, because the shape is the part with the sharp edge: + * a chunk carries its chapter's id, and that id does not exist until the + * proposal authors the chapter under the group's key at the room's derivation + * path. Both are resolved inside `proposeSigningBatch` and neither is knowable + * to the caller, which is why the chunks are built from what it hands back. + * + * If that came apart, the chapter would still be signed and every chunk would + * still verify -- against a chapter id nobody has. They would fail a foreign + * key on the way in and the chapter would simply arrive empty. + */ +class ChapterBatchProposalJvmTest { + private val participants = 3 + private val threshold = 2 + + /** Stands in for a completed ceremony; the test is about the proposal, not the DKG. */ + private val keyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() + + /** The admin room: the group's key walked to the admin path, which is its id. */ + private val roomId = SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private val ceremonyId = "ceremony".padEnd(64, '0') + private val members = listOf("a", "b", "c").map { it.repeat(64) } + private val hostKeys = listOf("2a", "2b", "2c").map { it.padEnd(66, '0') } + + private val proposer = members.first() + private val artifactVersionId = "b".repeat(64) + + private lateinit var db: MantraDatabase + private lateinit var room: LocalChatRoom + + @AfterTest + fun closeDatabase() { + if (::db.isInitialized) db.close() + } + + /** The proposer's device: a room it can sign in, and the share to sign with. */ + private suspend fun openDevice() { + db = getRoomDatabase(Room.inMemoryDatabaseBuilder()) + + // Profile hangs off a nostr event, and a room off a profile. Neither is + // anything this test is about; they are the foreign keys in the way. + val nostrEventId = "e$proposer".take(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = proposer, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128) + ) + ) + db.profileDao().upsert(Profile(publicKey = proposer, nostrEventId = nostrEventId)) + + val chatRoom = ChatRoom( + id = roomId, + userPublicKey = proposer, + subject = "#admins", + description = SharedKeyDerivation.describe("Admins of the group."), + mlsGroupState = null + ) + db.chatRoomDao().upsert(chatRoom) + + db.dkgSessionDao().upsert( + DkgSession( + id = ceremonyId, + chatRoomId = roomId, + coordinatorPublicKey = proposer, + userPublicKey = proposer, + threshold = threshold, + participantCount = participants, + stage = DkgRitualStage.COMPLETE, + hostPublicKey = hostKeys[0], + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + thresholdPublicKey = thresholdPublicKey, + secretShare = keyMaterial.secretShares[0].value.toHex(), + publicShares = keyMaterial.publicShares.joinToString(",") { it.value.toHex() } + ) + ) + + members.forEachIndexed { index, member -> + db.dkgSessionDao().upsert( + DkgParticipantMessage( + sessionId = ceremonyId, + participantPublicKey = member, + kind = DkgRitualEvents.HOST_KEY, + payload = hostKeys[index] + ) + ) + } + + room = LocalChatRoom(chatRoom = chatRoom) + } + + private fun chapterTemplate(originalText: String, name: String = "Chapter 1") = + ChapterEvent.build( + artifactVersionId = artifactVersionId, + name = name, + originalText = originalText, + index = 0, + wordCount = Markdown.wordCount(originalText), + characterCount = Markdown.characterCount(originalText), + ) + + /** Exactly what `AddChapterViewModel.addChapter` proposes. */ + private suspend fun proposeChapter(originalText: String) = + FrostSigningManager.proposeSigningBatch( + database = db, + localChatRoom = room, + userPublicKey = proposer, + lead = chapterTemplate(originalText), + dependents = ChunkEvent::splitOf + ) + + private suspend fun itemEvents(sessionId: String): List = + db.frostSigningSessionDao().getItems(sessionId).map { Event.fromJson(it.unsignedEventJson) } + + private fun paragraphs(count: Int) = (1..count).joinToString("\n\n") { "Paragraph $it." } + + @Test + fun `the chapter leads the batch and its chunks follow`() = runBlocking { + openDevice() + + val session = proposeChapter("The first paragraph.\n\nThe second paragraph.") + val events = itemEvents(session.id) + + // Item order is apply order, and a chunk row whose chapter does not + // exist yet is a foreign key violation. The chapter has to be first. + assertEquals(3, events.size) + assertEquals(ChapterEvent.KIND, events.first().kind) + assertTrue(events.drop(1).all { it.kind == ChunkEvent.KIND }) + } + + @Test + fun `every chunk names the chapter as the group will author it`() = runBlocking { + openDevice() + + val session = proposeChapter("The first paragraph.\n\nThe second paragraph.") + val events = itemEvents(session.id) + val chapter = events.first() + + // The claim the whole `lead`/`dependents` form exists for. The id here is + // a hash over the group's key at the room's path; a caller that built the + // chunks before proposing could not have known it. + val chunks = events.drop(1).map { + ChunkEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + } + + assertTrue(chunks.all { it.chapterId() == chapter.id }) + assertEquals(listOf("The first paragraph.", "The second paragraph."), chunks.map { it.content }) + } + + @Test + fun `the whole batch is authored by the room, not by the proposer`() = runBlocking { + openDevice() + + val events = itemEvents(proposeChapter("One.\n\nTwo.").id) + + // Signing runs at the path the room was derived at, so the author of + // every item is the room's own id -- chunks included, which is what lets + // one be checked without being told which key to expect. + assertTrue(events.all { it.pubKey == roomId }) + assertTrue(events.none { it.pubKey == proposer }) + } + + @Test + fun `a chapter of the longest allowed length still proposes`() = runBlocking { + openDevice() + + val events = itemEvents( + proposeChapter(paragraphs(FrostSigningManager.MAX_BATCH_SIZE - 1)).id + ) + + // The arithmetic the form is built on: the chapter takes one of the + // batch's places and the paragraphs get the rest. If this stopped being + // true, `AddChapterScreen` would offer a chapter the session then refuses. + assertEquals(FrostSigningManager.MAX_BATCH_SIZE, events.size) + } + + @Test + fun `a chapter of one paragraph too many is refused`() = runBlocking { + openDevice() + + // Refused here rather than truncated: a batch that quietly dropped its + // last paragraphs would sign a chapter whose text nobody holds the + // chunks for. The screen checks first so this is never what a member + // sees, but the screen is not what enforces it. + assertFailsWith { + proposeChapter(paragraphs(FrostSigningManager.MAX_BATCH_SIZE)) + } + + assertEquals(emptyList(), db.frostSigningSessionDao().getSessionsForChatRoom(roomId)) + } +} diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md index c2949117..cc618f4b 100644 --- a/docs/frost-batch-signing.md +++ b/docs/frost-batch-signing.md @@ -9,8 +9,9 @@ kept as written -- they are the reasoning, and the code is easier to read against the argument it came from than against a summary of itself. Where the implementation chose differently from the first draft the section says so. `FrostSigningManager.proposeSigningBatch` and -`FrostSigningRepository.proposeSigningBatch` are the entry points; nothing in -the app calls them yet, which is Phase 7's point. +`FrostSigningRepository.proposeSigningBatch` are the entry points. Adding a +chapter is the first caller, and needed a second form of them; see +[The first caller](#the-first-caller-a-chapter-and-its-chunks). The headline: **there is no such thing as one FROST signature over many messages, and no way to reuse a nonce across them.** What can be batched is the @@ -487,31 +488,40 @@ one rule that a batch is only as available as its worst item, so events that do not belong together should not travel together, and the one prohibition that `GroupKeyStateManager.propose` must never batch. -### The first caller to weigh it, and decline +### The first caller: a chapter and its chunks -Adding a chapter is the case batching looks made for: a chapter is proposed -with a chunk per paragraph, which before this work would have been one quorum -each. It signs the chapter alone anyway, and splits the chunks back out of the -signed text on every device -([`MantraChunk.chunksOf`](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/MantraChunk.kt)). -Recorded here because the next caller will reach for the same shape: +Adding a chapter is the first thing in the app to batch. A chapter is proposed +together with a chunk per paragraph — before this work, one quorum each, which +is why the chunks were briefly derived on arrival from the signed chapter's text +instead. They are signed now, and each carries the group's signature over its own +words: a translation is of a chunk, and a chunk that can be checked on its own is +worth more than one that can only be checked by re-deriving it. -- **The cap binds on ordinary content.** `MAX_BATCH_SIZE` is 64, so a batched - chapter is capped at 63 paragraphs. Prose runs past that, and the failure is - a chapter that cannot be proposed at all. -- **The text would travel twice** — whole in the chapter, again split across - the chunks — and the proposal is the term the cap is sized against. -- **Availability falls with length.** All-or-nothing over `k` items means a - long chapter is less likely to get signed than a short one, for no reason a - member could see. -- **The signature would be redundant.** The appendix rejects the manifest shape - because an item then needs a lookup to be checked. Here the lookup is a - foreign key: a chunk is a pure function of its chapter and cannot be stored - without it, so the chapter's signature already covers it. +Two things it needed that a flat list could not give: -The general rule this leaves behind: **batch siblings, not derivations.** Events -that could each have been authored separately are worth a batch; events that are -a function of another event in the same batch are worth deriving instead. +**An item that names another item.** A chunk carries the id of its chapter, and +that id is a hash over the group's key at the room's derivation path — neither +resolved until the proposal is made. A caller computing it would be recomputing +`signingPath`, the one input that must never come from a proposer. So +`proposeSigningBatch` has a second form taking a `lead` template and a +`dependents` builder, which is handed the lead *after* it is authored and returns +the events referring to it. Every id still comes from `unsignedEventOf`, and an +item naming a chapter nobody signed stops being a mistake that can be made. + +**The lead is item 0.** Items apply in `itemIndex` order, and a chunk row whose +chapter does not exist yet is a foreign key violation, so the thing being +referenced has to be signed first in the batch as well as named first. + +**What it costs, in front of the user.** `MAX_BATCH_SIZE` is 64 and the chapter +takes one place, so a chapter is capped at 63 paragraphs and a longer one has to +be split in two. That is a real limit on real prose. `AddChapterScreen` shows the +chunk count against the cap as the text is typed and refuses to propose past it, +because the alternative is an `IllegalArgumentException` after the fact. + +The general rule this leaves behind: **a batch is for events that arrive +together and are checked apart.** If the items are only ever read through one of +them, deriving is cheaper and has no cap; if each is something a member might +hold, hand it its own signature. ---