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