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<EventTemplate<*>>) 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
935a8fe37a
commit
59c34263b3
5 files changed
+496
-41
No files matched your search
+98
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user