feat: sign a chapter and every chunk of it in one session

A chapter proposal now carries the chapter and a chunk per paragraph, and the
group signs the lot at once. Every row a member ends up with is signed: a
translation is of a chunk, and a chunk that carries the group's signature over
its own words can be checked by anybody holding it, rather than only by
re-deriving it from the chapter it came out of.

This replaces the derivation two commits ago, which split the chunks out of the
signed chapter's text on each device and left them as rumors. That was the
right shape when a chunk could only have its own signature by having its own
quorum. Batch signing removed that, and this is the other side of the trade
`MantraChunk.chunksOf` was weighed against.

**A batch whose items name each other.** A chunk carries its chapter's id, and
that id is a hash over the group's key at the room's derivation path -- neither
resolved until the proposal runs. A caller computing it would be recomputing
`signingPath`, the one input in this protocol that must never come from a
proposer, since the path decides which key the group signs as. So
`proposeSigningBatch` gains a second form: a `lead` template, and a
`dependents` builder handed the lead *after* it is authored, returning the
events that reference it. Every id still comes out of `unsignedEventOf`, which
makes an item naming a chapter nobody signed something that cannot be built
rather than something to be tested for. `AddChapterViewModel` passes
`ChunkEvent::splitOf` and nothing else.

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 what is referenced is
signed first as well as named first.

**The cost, in front of whoever is typing.** `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. The form counts
chunks against the cap as the text is typed, colours the count when it is past,
says what to do about it, and will not propose -- because the alternative is an
IllegalArgumentException after the fact. The manager still refuses
independently; the screen is not what enforces it.

**What went away.** `MantraChunk.chunksOf` and the derivation it did inside
`ChatMessage.applyInnerEvent`. Chunks arrive as their own signed events now and
go through the `ChunkEvent.KIND` branch that was always there. `ChapterEvent`
still carries the whole text beside chunks that hold the same words: chunk
boundaries are a decision about how to divide the work, and a chapter that kept
only the pieces could never be divided differently again.

**Tests.** `ChapterChunkSplitTest` covers the split as a pure function -- what
each chunk names, counts and carries. `SignedChapterTest` signs a real batch,
one FROST instance per item, and checks every chunk row is authored by the room
and carries a signature over its own id. `ChapterBatchProposalJvmTest` runs the
real proposal against a real database, which is where the sharp edge is: item
order, the chunks naming the chapter as the group will author it, and both ends
of the cap -- 63 paragraphs proposes, 64 is refused and leaves no session
behind. Checked against broken implementations: putting the lead last, naming
the wrong chapter, and stamping the chunks off the clock are each caught, in
both suites.

`jvmTest` runs on linux again as of the merge, which is what made the
database-backed test possible.

Dropped a nonce-reuse test that was in the first draft of this: it asserted
over its own fixture, and `FrostSigningRoundTest` and `SignedGroupKeyStateTest`
already hold the manager to giving every item its own nonce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 10:34:12 +02:00
parent e081d14f37
commit 2e133dd337
15 changed files with 812 additions and 416 deletions

View File

@@ -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",

View File

@@ -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<MantraChunk> {
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<ChunkEvent>,
chatRoomId: HexKey,

View File

@@ -81,6 +81,21 @@ class DatabaseFrostSigningRepository(
)
}
override suspend fun proposeSigningBatch(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
lead: EventTemplate<*>,
dependents: (lead: Event) -> List<EventTemplate<*>>
): 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

View File

@@ -192,12 +192,43 @@ object FrostSigningManager {
userPublicKey: HexKey,
events: List<EventTemplate<*>>,
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<EventTemplate<*>>,
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(

View File

@@ -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

View File

@@ -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<EventTemplate<ChunkEvent>> {
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,

View File

@@ -92,6 +92,27 @@ interface FrostSigningRepository {
events: List<EventTemplate<*>>
): 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<EventTemplate<*>>
): 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<EventTemplate<*>>
): FrostSigningSession? = null
override suspend fun proposeSigningBatch(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
lead: EventTemplate<*>,
dependents: (lead: Event) -> List<EventTemplate<*>>
): FrostSigningSession? = null
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit

View File

@@ -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
}
)
}
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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
)
)
}
}

View File

@@ -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<Event>) = 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<Event> {
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<Event> = 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<ChunkEvent> = 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)
}
}

View File

@@ -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>) =
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))
}
}

View File

@@ -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<MantraDatabase>())
// 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<Event> =
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<IllegalArgumentException> {
proposeChapter(paragraphs(FrostSigningManager.MAX_BATCH_SIZE))
}
assertEquals(emptyList(), db.frostSigningSessionDao().getSessionsForChatRoom(roomId))
}
}