From 59c34263b3f3ff90d79598a6aaba111912fb0463 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 04:48:24 +0200 Subject: [PATCH] feat(frost): let one signing session carry a batch of events Phase 3 of docs/frost-batch-signing.md. A session can now be proposed over several events, and the whole batch is signed in one round of four group events with one approval. 356 jvmTest and 224 testDebugUnitTest pass. ## The wire, and the compatibility rule that shapes it FrostSigningEvents.encodeProposal serialises a batch of one as the bare event object it always was, and only a genuine batch as a JSON array. That is not tidiness. A build predating this reads an array with Event.fromJsonOrNull, gets null, and drops the proposal -- so an old device refuses a batch outright rather than signing part of one, while single signing keeps working right through a mixed-version rollout. Emitting an array unconditionally would break every one-event session for those devices and buy nothing. decodeProposal accepts both forms permanently: proposals in the old shape do not stop arriving because this build stopped writing them. It is all-or-nothing -- an array with one unreadable element is refused rather than silently shortened, because the batch's length is what every later payload is checked against, and a proposal that quietly lost an event would have every signer's contribution rejected for being the wrong size: a stall with nothing to blame. ## MAX_BATCH_SIZE, checked twice 64, enforced in proposeSigningBatch and again, independently, in acceptProposal. The second check is the one that matters. A proposal is the only place in this protocol where a remote party decides how much work everyone else does -- k native key generations, k signatures, and a group event carrying k payloads, from a single message -- and until batching that was bounded only by never being more than one. ## acceptProposal over a list Each element is rebuilt from its own fields under this device's own reading of the room's path and checked against the id it claims, exactly as before but per item, and the whole proposal is dropped if any one fails. The write-once rule widens from "the event this session signs" to "the ordered list of events this session signs": a second proposal under the same id whose list differs anywhere is logged and ignored. ## The API FrostSigningManager.proposeSigningBatch(events: List>) is public here rather than in Phase 4, because without it there is no way to produce a k>1 session and everything above would ship untested. proposeSigning keeps its signature as the one-event form, so no caller moves. Each template carries its own createdAt. ## Tests - FrostProposalCodecTest (new, commonTest): a batch of one is byte-for-byte the old JSON object -- the assertion that stands in for the old build nobody can run here -- plus order preservation, old-form decoding, and refusal of empty, malformed and partly-unreadable arrays. - SignedGroupKeyStateTest: a k=3 batch between two devices over two databases. Three signatures verifying against the room, three dialects applied on both devices in order, five messages from the coordinator and two from the other signer, and one approval line rather than three. - The negative test that matters: no two items of a batch share an aggregated nonce or a seed, and the two devices' seeds do not intersect. Every positive test still passes if two items share a nonce -- the signatures verify fine; what sharing costs is the secret share. - The cap is refused when proposed. Co-Authored-By: Claude Opus 5 --- .../compose/managers/FrostSigningManager.kt | 142 ++++++++++--- .../compose/nostr/frost/FrostSigningEvents.kt | 49 ++++- .../nostr/frost/FrostProposalCodecTest.kt | 98 +++++++++ .../managers/SignedGroupKeyStateTest.kt | 197 ++++++++++++++++++ docs/frost-batch-signing.md | 51 +++-- 5 files changed, 496 insertions(+), 41 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt 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 8444f461..f44508a2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -7,6 +7,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.utils.RandomInstance import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 @@ -113,6 +114,23 @@ object FrostSigningManager { private val logger = Logger.withTag(TAG) + /** + * The most events one session will sign. + * + * Enforced when proposing, and again -- independently -- when a proposal + * arrives. The second check is the one that matters. A proposal is the only + * place in this protocol where a remote party decides how much work everybody + * else does: k native key generations, k signatures, and a group event + * carrying k payloads, all from a single message. Until batching that was + * bounded by never being more than one. + * + * Sized against what a group event will carry rather than picked round. A + * signer's nonce message is k * 133 bytes of hex and commas and their partial + * message k * 65, neither of which binds; the proposal carries k whole + * events, which does. + */ + const val MAX_BATCH_SIZE: Int = 64 + /** * Opens a signing session, making this device the coordinator. * @@ -140,7 +158,46 @@ object FrostSigningManager { content: String, key: DkgSession? = null, createdAt: Long = Clock.System.now().epochSeconds + ): FrostSigningSession = proposeSigningBatch( + database = database, + localChatRoom = localChatRoom, + userPublicKey = userPublicKey, + events = listOf(EventTemplate(createdAt, kind, tags, content)), + key = key + ) + + /** + * Opens a session over several events at once, making this device the + * coordinator. + * + * One ceremony, one signer set, one approval and four group events, whatever + * the size -- but k independent FROST instances underneath, because that is + * the only thing a batch can be. See [FrostSigningItem] for why. + * + * A batch is all-or-nothing: if any item cannot be aggregated the session + * fails and none of its events are applied. That makes a batch **only as + * available as its worst item**, so events that do not belong together + * should not be proposed together. + * + * A failed batch is retried by proposing a *new* one, never by re-proposing + * this session. Its items' nonce seeds have already been published against an + * aggregate; reusing any of them for a second attempt would produce two + * partial signatures over one secret nonce, which is how a share is + * extracted. [itemsOver] mints fresh seeds precisely so that a retry is a new + * session by construction. + */ + suspend fun proposeSigningBatch( + database: MantraDatabase, + localChatRoom: LocalChatRoom, + 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}" + } + 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") @@ -149,7 +206,16 @@ object FrostSigningManager { ?: throw IllegalStateException("This device is not a participant in ceremony ${ceremony.id}") val path = signingPath(database, localChatRoom, ceremony) - val unsignedEvent = unsignedEventOf(ceremony, path, kind, tags, content, createdAt) + val unsignedEvents = events.map { template -> + unsignedEventOf( + key = ceremony, + path = path, + kind = template.kind, + tags = template.tags, + content = template.content, + createdAt = template.createdAt + ) + } val sessionId = RandomInstance.bytes(32).toHex() val session = FrostSigningSession( @@ -166,17 +232,20 @@ object FrostSigningManager { signApprovedAt = Clock.System.now() ) database.frostSigningSessionDao().upsert(session) - database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, listOf(unsignedEvent))) + database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, unsignedEvents)) announceStarted(database, session) - logger.i("Proposing signature $sessionId over event ${unsignedEvent.id} with key ${ceremony.id}") + logger.i( + "Proposing signature $sessionId over ${describe(unsignedEvents.size)} " + + "(${unsignedEvents.joinToString { it.id.take(8) }}) with key ${ceremony.id}" + ) broadcast( database = database, localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.PROPOSAL, - content = unsignedEvent.toJson(), + content = FrostSigningEvents.encodeProposal(unsignedEvents), includeKey = true ) @@ -253,12 +322,13 @@ object FrostSigningManager { // under the same id carrying different ones is either a mistake or an // attempt to get two signatures out of one secret nonce, which is how // a share is extracted -- so it is refused, not applied. - val proposed = Event.fromJsonOrNull(innerEvent.content) + val proposed = FrostSigningEvents.decodeProposal(innerEvent.content) val signing = database.frostSigningSessionDao().getItems(sessionId).map { it.eventId } - if (proposed != null && signing != listOf(proposed.id)) { + if (proposed != null && signing != proposed.map { it.id }) { logger.w( - "Session $sessionId re-proposed with event ${proposed.id}, " + - "but it is already signing ${signing.joinToString()}; ignoring" + "Session $sessionId re-proposed with ${describe(proposed.size)} " + + "(${proposed.joinToString { it.id.take(8) }}), but it is already signing " + + "${signing.joinToString { it.take(8) }}; ignoring" ) } return existing @@ -282,9 +352,19 @@ object FrostSigningManager { return null } - val proposed = Event.fromJsonOrNull(innerEvent.content) + val proposed = FrostSigningEvents.decodeProposal(innerEvent.content) if (proposed == null) { - logger.w("Signing proposal $sessionId does not carry an event; dropping") + logger.w("Signing proposal $sessionId does not carry any events; dropping") + return null + } + + // Checked here as well as when proposing, because this is where a remote + // party gets to decide how much work this device does. See [MAX_BATCH_SIZE]. + if (proposed.size > MAX_BATCH_SIZE) { + logger.w( + "Signing proposal $sessionId asks for ${proposed.size} events, " + + "more than the $MAX_BATCH_SIZE this device will sign at once; dropping" + ) return null } @@ -298,20 +378,29 @@ object FrostSigningManager { // could choose the key the group signs as, and every signer would put // their share behind an author none of them checked. val path = signingPath(database, localChatRoom, key) - val unsignedEvent = unsignedEventOf( - key = key, - path = path, - kind = proposed.kind, - tags = proposed.tags, - content = proposed.content, - createdAt = proposed.createdAt - ) - if (unsignedEvent.id != proposed.id) { - logger.w( - "Signing proposal $sessionId carries id ${proposed.id} but its fields hash " + - "to ${unsignedEvent.id}; dropping" + val unsignedEvents = proposed.map { event -> + unsignedEventOf( + key = key, + path = path, + kind = event.kind, + tags = event.tags, + content = event.content, + createdAt = event.createdAt ) - return null + } + + // All of them or none. A batch's length is what every later payload is + // checked against, so quietly dropping one bad event would leave a session + // that rejects every signer's contribution for being the wrong size -- + // a stall with nothing to blame it on. + unsignedEvents.forEachIndexed { index, rebuilt -> + if (rebuilt.id != proposed[index].id) { + logger.w( + "Signing proposal $sessionId carries id ${proposed[index].id} at $index " + + "but its fields hash to ${rebuilt.id}; dropping" + ) + return null + } } val session = FrostSigningSession( @@ -329,10 +418,13 @@ object FrostSigningManager { derivationPath = path?.let(SharedKeyDerivation::formatPath) ) database.frostSigningSessionDao().upsert(session) - database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, listOf(unsignedEvent))) + database.frostSigningSessionDao().upsertItems(itemsOver(sessionId, unsignedEvents)) announceStarted(database, session) - logger.i("Recorded signing session $sessionId over ${unsignedEvent.id}; awaiting approval") + logger.i( + "Recorded signing session $sessionId over ${describe(unsignedEvents.size)} " + + "(${unsignedEvents.joinToString { it.id.take(8) }}); awaiting approval" + ) announceApprovalNeeded(database, session) replayStoredMessages(database, session) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt index 04b99d1d..c26c79f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/FrostSigningEvents.kt @@ -1,6 +1,9 @@ package press.mantra.compose.nostr.frost +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlinx.serialization.json.jsonArray +import press.mantra.compose.network.serialization.CommonJson import press.mantra.compose.nostr.frost.tags.FrostKeyTag import press.mantra.compose.nostr.frost.tags.FrostSessionIdTag import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag @@ -47,8 +50,9 @@ import press.mantra.compose.nostr.frost.tags.FrostSignerIdsTag */ object FrostSigningEvents { /** - * Opens a session. Content is the unsigned nostr event, as JSON; the key to - * sign with is named in [FrostKeyTag]. + * Opens a session. Content is the unsigned nostr event -- or, for a batch, + * the array of them -- as JSON; the key to sign with is named in [FrostKeyTag]. + * See [encodeProposal] for why the two forms are not one. */ val PROPOSAL: Kind = 30320 @@ -104,4 +108,45 @@ object FrostSigningEvents { fun parseSignerIds(tags: Array>): List? = tags.firstNotNullOfOrNull(FrostSignerIdsTag::parse)?.signerIds + + /** + * The unsigned events of a proposal, as its content. + * + * A batch of one serialises as the bare event object it always did, and only + * a genuine batch becomes an array. That is not tidiness. A build predating + * batching reads an array with `Event.fromJsonOrNull`, gets null, and drops + * the proposal -- so an old device refuses a batch outright rather than + * signing part of one, while single signing keeps working right through a + * mixed-version rollout. Emitting an array unconditionally would break every + * one-event session for those devices and buy nothing. + * + * An empty list has no honest encoding and is not one, so callers do not + * produce it and [decodeProposal] does not accept it. + */ + fun encodeProposal(events: List): String = + events.singleOrNull()?.toJson() + ?: events.joinToString(separator = ",", prefix = "[", postfix = "]") { it.toJson() } + + /** + * The other half. Both forms are accepted, permanently: proposals in the old + * shape do not stop arriving just because this build stopped writing them. + * + * All-or-nothing. An array with one unreadable element is refused rather than + * silently shortened, because the batch's length is what every later payload + * is checked against -- a proposal that quietly lost an event would have every + * signer's contribution rejected for being the wrong size, which is a stall + * with no message to blame. + */ + fun decodeProposal(content: String): List? { + if (!content.trimStart().startsWith("[")) { + return Event.fromJsonOrNull(content)?.let { listOf(it) } + } + + val elements = runCatching { CommonJson.parseToJsonElement(content).jsonArray } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + ?: return null + + return elements.map { element -> Event.fromJsonOrNull(element.toString()) ?: return null } + } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt new file mode 100644 index 00000000..f63474ad --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/frost/FrostProposalCodecTest.kt @@ -0,0 +1,98 @@ +package press.mantra.compose.nostr.frost + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * How a proposal's events go on the wire, and what an old build makes of them. + * + * The whole reason this encoding has two shapes rather than one is + * compatibility, and compatibility is exactly the thing no other test in this + * area can see: a build that predates batching is not here to be run against. + * So the property is asserted from the other side -- a batch of one is byte for + * byte the JSON object it always was, which is the only form such a build can + * read. + */ +class FrostProposalCodecTest { + private fun event(content: String, kind: Int = 1) = Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000L, + kind = kind, + tags = arrayOf(arrayOf("alt", "test")), + content = content, + sig = "" + ) + + @Test + fun `a batch of one is the bare event, exactly as it always was`() { + val one = event("just this") + + // The load-bearing assertion. A device on a build that predates batching + // parses this with Event.fromJsonOrNull; anything but the bare object + // returns null there and the proposal is dropped, so every single-event + // session in a mixed-version group would stop working. + assertEquals(one.toJson(), FrostSigningEvents.encodeProposal(listOf(one))) + } + + @Test + fun `a real batch is an array`() { + val encoded = FrostSigningEvents.encodeProposal( + listOf(event("first"), event("second")) + ) + + assertTrue(encoded.startsWith("["), "a batch must be a JSON array") + assertTrue(encoded.endsWith("]")) + } + + @Test + fun `what is encoded is what comes back, in order`() { + val events = listOf(event("first"), event("second"), event("third")) + + val decoded = FrostSigningEvents.decodeProposal( + FrostSigningEvents.encodeProposal(events) + ) + + // Order is the batch's index, and every join of nonces and partial + // signatures is positional against it. + assertEquals( + events.map { it.content }, + decoded?.map { it.content } + ) + } + + @Test + fun `a bare event decodes as a batch of one, forever`() { + // Proposals in the old shape do not stop arriving just because this build + // stopped writing them. + val decoded = FrostSigningEvents.decodeProposal(event("from an older build").toJson()) + + assertEquals(1, decoded?.size) + assertEquals("from an older build", decoded?.single()?.content) + } + + @Test + fun `an array with one unreadable element is refused whole`() { + // Not shortened. The batch's length is what every later payload is checked + // against, so a proposal that quietly lost an event would have every + // signer's contribution rejected for being the wrong size. + val encoded = """[${event("fine").toJson()},{"not":"an event"}]""" + + assertNull(FrostSigningEvents.decodeProposal(encoded)) + } + + @Test + fun `an empty array is not a proposal`() { + assertNull(FrostSigningEvents.decodeProposal("[]")) + } + + @Test + fun `nonsense is not a proposal`() { + assertNull(FrostSigningEvents.decodeProposal("[this is not json")) + assertNull(FrostSigningEvents.decodeProposal("")) + assertNull(FrostSigningEvents.decodeProposal("null")) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 091ec8b4..0217cb68 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -14,11 +14,13 @@ 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.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgParticipantMessage @@ -458,6 +460,201 @@ class SignedGroupKeyStateTest { assertEquals(adminRoomId, absent.keyState()?.announcedBy) } + // ---- Signing several events in one session ----------------------------- + + /** + * The batch, end to end, between two devices over two databases: three + * events, one signer set, one approval, and three signatures that verify + * against the room. + * + * Everything about batching that could be wrong and still compile is wrong + * here or nowhere -- a shared nonce, a mismatched order, a payload split the + * wrong way. A signature that verifies is the only evidence any of it is + * wired up correctly, and there are three of them to disagree. + */ + @Test + fun `a batch of three is signed in one session`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + val items = device.items(session.id) + + assertEquals(3, items.size, "every device signs the whole batch") + assertEquals(FrostSigningStage.COMPLETE, device.session(session.id)?.stage) + + items.forEach { item -> + assertTrue( + Nip01Crypto.verify( + signature = assertNotNull(item.signature).hexToByteArray(), + hash = item.eventId.hexToByteArray(), + pubKey = adminRoomId.hexToByteArray() + ), + "item ${item.itemIndex} must verify against the room it was signed in" + ) + } + } + } + + /** + * The one that catches the mistake this whole design exists to prevent. + * + * Every positive test above still passes if two items share a nonce -- the + * signatures verify perfectly well. What sharing costs is the secret share, + * to anyone who sees both partial signatures. So the aggregates and the seeds + * are asserted pairwise distinct, which is cheap and is the only assertion + * here that an index bug cannot slip past. + */ + @Test + fun `no two events in a batch share a nonce`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + val items = device.items(session.id) + + assertEquals(3, items.mapNotNull { it.aggregatedNonce }.toSet().size) + assertEquals(3, items.map { it.nonceRandom }.toSet().size) + assertEquals(3, items.map { it.eventId }.toSet().size) + } + + // A seed is this device's own, so the two devices must not have arrived + // at the same ones either. + assertEquals( + emptySet(), + creator.items(session.id).map { it.nonceRandom }.toSet() + .intersect(other.items(session.id).map { it.nonceRandom }.toSet()) + ) + } + + /** Four messages for three events, which is the whole point of batching. */ + @Test + fun `a batch costs one round of messages, not one per event`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + // Proposal, nonce, signer set, partial signature, signature. + assertEquals( + listOf( + FrostSigningEvents.PROPOSAL, + FrostSigningEvents.NONCE, + FrostSigningEvents.SIGNER_SET, + FrostSigningEvents.PARTIAL_SIGNATURE, + FrostSigningEvents.SIGNATURE, + ), + outbox(creator).map { it.kind } + ) + + // The other member is a signer and not the coordinator: one nonce and one + // partial signature, each carrying all three values. + assertEquals( + listOf(FrostSigningEvents.NONCE, FrostSigningEvents.PARTIAL_SIGNATURE), + outbox(other).map { it.kind } + ) + outbox(other).forEach { queued -> + assertEquals(3, queued.content.split(",").size, "kind ${queued.kind} must carry three values") + } + + // And one question put to the member, not three. + assertEquals( + 1, + other.db.chatMessageDao().getChatMessagesByChatRoomId(other.roomId) + .count { it.chatMessage.messageType == ChatMessage.TYPE_FROST_APPROVAL_NEEDED } + ) + } + + /** Every event of a batch is applied, not just the first. */ + @Test + fun `every dialect in a batch lands on every device`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + val other = device(members[1], signerIndex = 1) + + val session = FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = dialects() + ) + pump(creator, other) + FrostSigningManager.approve(other.db, other.room, session.id) + pump(creator, other) + + listOf(creator, other).forEach { device -> + val names = device.items(session.id).map { item -> + assertNotNull( + device.db.mantraDialectDao().getDialectById(item.eventId), + "item ${item.itemIndex} should have been applied" + ).name + } + + assertEquals(listOf("Sepedi", "isiZulu", "Setswana"), names) + } + } + + /** + * A proposal is the one place a remote party decides how much work everybody + * else does, so the size cap is checked on arrival and not only when + * proposing. + */ + @Test + fun `a batch larger than the cap is refused when proposed`() = runBlocking { + val creator = device(members[0], signerIndex = 0) + + val tooMany = (0..FrostSigningManager.MAX_BATCH_SIZE).map { index -> + DialectEvent.build(name = "d$index", country = "ZA", language = "l$index") + } + + assertFailsWith { + FrostSigningManager.proposeSigningBatch( + database = creator.db, + localChatRoom = creator.room, + userPublicKey = creator.publicKey, + events = tooMany + ) + } + + Unit + } + + /** Three dialects, distinct enough that an order bug shows up as a wrong name. */ + private fun dialects() = listOf( + Triple("Sepedi", "ZA", "nso"), + Triple("isiZulu", "ZA", "zul"), + Triple("Setswana", "ZA", "tsn"), + ).map { (name, country, language) -> + DialectEvent.build(name = name, country = country, language = language) + } + // ---- What a room signs as, once it has a key state --------------------- @Test diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md index 1a4a250b..7bce1d6e 100644 --- a/docs/frost-batch-signing.md +++ b/docs/frost-batch-signing.md @@ -286,7 +286,20 @@ does, and it is currently bounded only by never having more than one item. Size it against the MLS group event limit rather than picking a round number: each signer's nonce message is `k × 133` bytes of hex-and-commas, the partial message `k × 65`, and the proposal itself carries `k` whole events, which is the -term that actually binds. +term that actually binds. 64 to start. + +### `proposeSigningBatch` on the manager, here + +The public API on the manager lands in this phase rather than the next one, for +a plain reason: there is no other way to produce a `k > 1` session, so without it +everything above ships untested. `proposeSigning` becomes its one-event form and +keeps its signature, so no caller moves. Phase 4 is then the repository, the call +sites and the failure policy. + +That also makes this the phase where the batch is proved end to end — a `k = 3` +session between two devices over two databases, plus the negative test that no +two items share a nonce. Both are described under Phase 6 and are worth reading +there; they simply run here, because this is where the thing they test exists. --- @@ -294,14 +307,16 @@ term that actually binds. **A day.** +*(`FrostSigningManager.proposeSigningBatch` itself landed in Phase 3 — see +above. This phase is what surrounds it.)* + ```kotlin suspend fun proposeSigningBatch( database: MantraDatabase, localChatRoom: LocalChatRoom, userPublicKey: HexKey, - events: List, // kind, tags, content + events: List>, // each carries its own createdAt key: DkgSession? = null, - createdAt: Long = Clock.System.now().epochSeconds ): FrostSigningSession ``` @@ -390,24 +405,32 @@ if missed from `FROST_TYPES`. **A day, and do not skip it.** +*(The first two ran in Phase 3, where the batch first existed. Kept here because +this is the section anybody adding to these tests will read.)* + Extend [SignedGroupKeyStateTest](../composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt), which already runs a real signing session between two devices over two databases, with a `k = 3` batch: both devices reach `COMPLETE`, all three -signatures verify against their own event ids under the room's key, and all -three events are applied locally on both. +signatures verify against their own event ids under the room's key, all three +events are applied locally on both, and the whole thing costs five messages from +the coordinator and two from the other signer rather than one round per event. -Then in -[FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt), -against real FROST and no database: +**The negative one that matters**: assert the three aggregated nonces are +pairwise distinct, and that the three seeds are. Every positive test above still +passes if two items share a nonce — the signatures verify perfectly well; what +sharing costs is the secret share, to anyone who sees both partial signatures. +It is the cheapest possible guard against the one mistake in this document that +loses the key, and it catches an index bug nothing else here would. + +Then, still to write: -- a `k = 3` batch producing three signatures nostr accepts, from one signer set; -- **the negative one that matters** — assert the three aggregated nonces are - pairwise distinct, and that the three seeds are. It is the cheapest possible - guard against the one mistake in this document that loses the key, and it will - catch an index bug that every positive test still passes; - a wrong-length payload is dropped rather than truncated; -- a second proposal under the same session id with a changed item is ignored. +- a second proposal under the same session id with a changed item is ignored; +- a `k = 3` batch in + [FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt), + against real FROST and no database, for the same reason that file exists at + all: the library calls are checked without a database in the way. ---