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:
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user