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 240d56bf..8444f461 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -65,6 +65,20 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * The path comes from the room, never from a proposal -- [signingPath] -- because * it decides which key the group signs as. * + * ### It signs a batch + * + * A session carries one or more events as [FrostSigningItem] rows, and runs one + * FROST instance per event in lockstep: one signer set, one aggregate per item, + * one partial signature per item per signer, one approval. Four group events + * whatever the size, instead of four per event. + * + * That is all the batching there is, and all there can be. A Schnorr partial + * signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under one + * nonce give two equations in one unknown and the secret share falls out; a + * batch shares only what is outside that equation. Every item has its own seed, + * its own aggregate and its own `Session`, and [joinPayload] is the only place + * they travel together. + * * Three things are genuinely different from a ceremony, and each of them is why * this is a separate manager rather than another branch of that one. * @@ -80,8 +94,8 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * * `SecretNonce` cannot be serialised and refuses to be used twice. Storing the * randomness it is derived from and regenerating on demand is the only way a - * signing session can survive the app closing -- and it is safe only because a - * session signs one message and cannot be made to sign another. See + * signing session can survive the app closing -- and it is safe only because an + * item signs one message and cannot be made to sign another. See * [FrostSigningSession] for the two rules that hold that in place; both are * enforced here, in [acceptProposal] and in [record]. * @@ -89,8 +103,10 @@ import press.mantra.compose.nostr.frost.FrostSigningEvents * * A DKG asks three times because each step publishes something different and * commits the member to something different. Here every step serves one - * decision -- sign this event or do not -- and the event is fixed before the - * member is asked. A second prompt would be the same question again. + * decision -- sign these events or do not -- and they are fixed before the + * member is asked. A second prompt would be the same question again. That holds + * for a batch only as long as the member can see every event in it before + * answering, which is the screen's side of the bargain. */ object FrostSigningManager { private const val TAG = "FrostSigningManager" @@ -414,7 +430,10 @@ object FrostSigningManager { // holds all of them. if (current(database, session).signerIds != null) return true - if (applyAggregate(database, session, signerIds, listOf(innerEvent.content))) { + val aggregated = splitForSession(database, session, innerEvent.content) + ?: return true + + if (applyAggregate(database, session, signerIds, aggregated)) { announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -427,7 +446,10 @@ object FrostSigningManager { // could neither finish nor safely retry. if (isSigned(database, session)) return true - if (applySignatures(database, session, listOf(innerEvent.content))) { + val signatures = splitForSession(database, session, innerEvent.content) + ?: return true + + if (applySignatures(database, session, signatures)) { announceStep(database, session, innerEvent.kind, innerEvent.pubKey) } } @@ -515,42 +537,63 @@ object FrostSigningManager { // The events this session signs, in the order the proposal fixed. Every // join below is positional against it. val items = database.frostSigningSessionDao().getItems(sessionId) - val item = items.firstOrNull() ?: return - val message = ByteVector(item.eventId.hexToByteArray()) + if (items.isEmpty()) return - // Regenerated rather than stored -- SecretNonce refuses both. Safe - // because an item's message can never change; see the notes on - // FrostSigningSession. - val (secretNonce, publicNonce) = SecretNonce.generate( - sessionRandom = ByteVector32(item.nonceRandom), - secretShare = secretShare, - publicShare = publicShares?.getOrNull(session.signerId), - tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, - message = message, - extraInput = null - ) + val messages = items.map { ByteVector(it.eventId.hexToByteArray()) } - if (ownMessage(database, session, FrostSigningEvents.NONCE) == null) { + val ownNonces = ownMessage(database, session, FrostSigningEvents.NONCE) + val ownPartials = ownMessage(database, session, FrostSigningEvents.PARTIAL_SIGNATURE) + + // One nonce per item, regenerated rather than stored -- SecretNonce + // refuses both. Safe because an item's message can never change; see + // the notes on FrostSigningSession. + // + // On demand rather than up front, and that is the point of the `lazy`: + // `advance` runs on every arriving message, so at a batch of k this is + // k native key generations each time, usually to find there is nothing + // left to publish. A guard computed here instead would have to predict + // whether this device turns out to be a signer, which it cannot -- the + // coordinator settles the signer set further down this same pass. + val nonces by lazy { + items.mapIndexed { index, item -> + SecretNonce.generate( + sessionRandom = ByteVector32(item.nonceRandom), + secretShare = secretShare, + publicShare = publicShares?.getOrNull(session.signerId), + tweakedThresholdPublicKey = tweakCache.tweakedPublicKey, + message = messages[index], + extraInput = null + ) + } + } + + if (ownNonces == null) { publishOwn( database, localChatRoom, session, FrostSigningEvents.NONCE, - publicNonce.data.toHex() + joinPayload(nonces.map { (_, publicNonce) -> publicNonce.data.toHex() }) ) } if (session.isCoordinator() && session.signerIds == null) { - val offered = orderedNonces(database, session) ?: return + val offered = orderedNonces(database, session, items.size) ?: return val chosen = offered.take(session.threshold) - - val aggregated = IndividualNonce.aggregate(chosen.map { it.second }) - .orThrow("aggregating nonces") - val chosenIds = chosen.map { (id, _) -> id } - if (!applyAggregate(database, session, chosenIds, listOf(aggregated.toByteArray().toHex()))) { - return + + // One aggregate per item, each built from that item's nonce from + // each chosen signer. Reusing one across two items would be reusing + // R across two messages, which is the whole thing this design is + // arranged to make impossible. + val aggregated = items.indices.map { index -> + IndividualNonce.aggregate(chosen.map { (_, offeredNonces) -> offeredNonces[index] }) + .orThrow("aggregating nonces") + .toByteArray() + .toHex() } + + if (!applyAggregate(database, session, chosenIds, aggregated)) return session = current(database, session) broadcast( @@ -558,7 +601,7 @@ object FrostSigningManager { localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.SIGNER_SET, - content = aggregated.toByteArray().toHex(), + content = joinPayload(aggregated), signerIds = chosenIds ) announceStep( @@ -570,48 +613,70 @@ object FrostSigningManager { } val signerIds = session.signerIdList() ?: return - val aggregatedNonce = database.frostSigningSessionDao() - .getItem(sessionId, item.itemIndex)?.aggregatedNonce ?: return + // Re-read, because the aggregate above is written to the item rows. + val aggregated = database.frostSigningSessionDao().getItems(sessionId) session = moveTo(database, session, FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES) - val signingSession = Session.create( - aggregatedNonce = AggregatedNonce(aggregatedNonce.hexToByteArray()), - signerIds = signerIds.map { it.toUInt() }, - signerPublicShares = publicShares?.let { shares -> - signerIds.mapNotNull { shares.getOrNull(it) }.takeIf { it.size == signerIds.size } - }, - nParticipants = session.participantCount, - threshold = session.threshold, - tweakCache = tweakCache, - message = message - ) - // A member outside the chosen set has nothing to contribute and is not // holding anybody up. They stay in the session to receive the finished - // signature like everybody else. - if (session.isSigner() && ownMessage(database, session, FrostSigningEvents.PARTIAL_SIGNATURE) == null) { - val partialSignature = signingSession - .sign(secretNonce, secretShare, session.signerId.toUInt()) - .orThrow("signing") + // signatures like everybody else -- and leaving here rather than + // building k FROST sessions to do nothing with is the same saving the + // nonce short-circuit above makes. + val signing = session.isSigner() && ownPartials == null + val aggregating = session.isCoordinator() && !isSigned(database, session) + if (!signing && !aggregating) { + complete(database, session) + return + } + + // One FROST session per item. `Session.create` binds the message and + // the aggregate together, so only the signer set, the shares and the + // tweak cache are shared across a batch. + val signingSessions = aggregated.mapIndexed { index, item -> + Session.create( + aggregatedNonce = AggregatedNonce( + (item.aggregatedNonce ?: return).hexToByteArray() + ), + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = publicShares?.let { shares -> + signerIds.mapNotNull { shares.getOrNull(it) }.takeIf { it.size == signerIds.size } + }, + nParticipants = session.participantCount, + threshold = session.threshold, + tweakCache = tweakCache, + message = messages[index] + ) + } + + if (signing) { + val partialSignatures = signingSessions.mapIndexed { index, signingSession -> + signingSession + .sign(nonces[index].first, secretShare, session.signerId.toUInt()) + .orThrow("signing") + .toHex() + } publishOwn( database, localChatRoom, session, FrostSigningEvents.PARTIAL_SIGNATURE, - partialSignature.toHex() + joinPayload(partialSignatures) ) } - if (session.isCoordinator() && !isSigned(database, session)) { - val partials = orderedPartialSignatures(database, session, signerIds) ?: return + if (aggregating) { + val partials = orderedPartialSignatures(database, session, signerIds, aggregated.size) + ?: return - val signature = signingSession - .aggregateSigs(partials.map { ByteVector32(it) }) - .orThrow("aggregating partial signatures") - .toHex() + val signatures = signingSessions.mapIndexed { index, signingSession -> + signingSession + .aggregateSigs(partials.map { ByteVector32(it[index]) }) + .orThrow("aggregating partial signatures") + .toHex() + } - if (!applySignatures(database, session, listOf(signature))) return + if (!applySignatures(database, session, signatures)) return session = current(database, session) broadcast( @@ -619,7 +684,7 @@ object FrostSigningManager { localChatRoom = localChatRoom, session = session, kind = FrostSigningEvents.SIGNATURE, - content = signature + content = joinPayload(signatures) ) announceStep(database, session, FrostSigningEvents.SIGNATURE, session.userPublicKey) } @@ -872,17 +937,25 @@ object FrostSigningManager { */ private suspend fun orderedNonces( database: MantraDatabase, - session: FrostSigningSession - ): List>? { + session: FrostSigningSession, + items: Int + ): List>>? { val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null val idByMember = signerIds(database, key) val offered = database.frostSigningSessionDao() .getMessagesByKind(session.id, FrostSigningEvents.NONCE) .mapNotNull { message -> - idByMember[message.signerPublicKey]?.let { id -> - id to IndividualNonce(message.payload.hexToByteArray()) + val id = idByMember[message.signerPublicKey] ?: return@mapNotNull null + val values = splitPayload(message.payload, items) ?: run { + logger.w( + "Session ${session.id}: ${message.signerPublicKey.take(8)} offered a " + + "nonce payload that is not $items value(s); leaving them out" + ) + return@mapNotNull null } + + id to values.map { IndividualNonce(it.hexToByteArray()) } } .sortedBy { (id, _) -> id } @@ -902,8 +975,9 @@ object FrostSigningManager { private suspend fun orderedPartialSignatures( database: MantraDatabase, session: FrostSigningSession, - signerIds: List - ): List? { + signerIds: List, + items: Int + ): List>? { val key = database.dkgSessionDao().getSessionById(session.dkgSessionId) ?: return null val memberById = signerIds(database, key).entries.associate { (member, id) -> id to member } @@ -912,7 +986,9 @@ object FrostSigningManager { .associate { it.signerPublicKey to it.payload } val partials = signerIds.mapNotNull { id -> - memberById[id]?.let { payloadByMember[it] } + val payload = memberById[id]?.let { payloadByMember[it] } ?: return@mapNotNull null + + splitPayload(payload, items)?.map { it.hexToByteArray() } } if (partials.size < signerIds.size) { @@ -920,7 +996,50 @@ object FrostSigningManager { return null } - return partials.map { it.hexToByteArray() } + return partials + } + + /** + * A signer's whole contribution to a batch, as one payload. + * + * Comma separated, the encoding [FrostSigningSession.signerIds] already uses, + * and at a batch of one it is the bare value — which is what keeps a + * single-event session on exactly the wire it has always been on. + * + * One row per signer per kind rather than one per item, deliberately. + * [FrostSignerMessage]'s key is what makes a redelivered message replace its + * predecessor instead of adding a row, and splitting by item would multiply + * the ways a partial delivery can look like a complete one. + */ + private fun joinPayload(values: List): String = values.joinToString(",") + + /** + * The other half, and strict: a payload not carrying exactly [expected] + * values is refused rather than truncated or padded. + * + * Checked here rather than in [record] on purpose. Payloads are stored + * without being parsed, which is what lets a nonce arrive before the proposal + * that would give it a length to be checked against. This runs where the + * session — and so the length — is known. + */ + private fun splitPayload(payload: String, expected: Int): List? = + payload.split(",").map { it.trim() }.takeIf { it.size == expected } + + /** [splitPayload] against the number of events this session signs. */ + private suspend fun splitForSession( + database: MantraDatabase, + session: FrostSigningSession, + payload: String + ): List? { + val expected = database.frostSigningSessionDao().countItems(session.id) + + return splitPayload(payload, expected) ?: run { + logger.w( + "Session ${session.id}: payload carries ${payload.split(",").size} value(s) " + + "for $expected item(s); ignoring" + ) + null + } } /** diff --git a/docs/frost-batch-signing.md b/docs/frost-batch-signing.md index 1002c114..1a4a250b 100644 --- a/docs/frost-batch-signing.md +++ b/docs/frost-batch-signing.md @@ -179,11 +179,25 @@ k=64 it is 64 nonce generations, 64 `Session.create`s and 64 signs on every message the group sends — several hundred native calls to discover there is nothing to do. -Fix it in this phase, while it is still cheap to verify: short-circuit the -regenerate-and-sign block when this device has already published both its nonce -and its partial-signature messages, and skip `Session.create` for a device that -is not in the signer set and is not the coordinator. Both are pure -optimisations at k=1, which is the point of doing them before k>1 exists. +Fix it in this phase, while it is still cheap to verify: generate nonces on +demand, and leave before building any `Session` when this device is neither +signing nor aggregating. Both are pure optimisations at k=1, which is the point +of doing them before k>1 exists. + +**Generate them lazily, not behind a guard.** The obvious version — compute the +nonces only when `ownNonce == null || (isSigner() && ownPartial == null)` — is +wrong, and wrong in a way that passes a reading and fails every test. The +coordinator settles the signer set *further down the same pass*, so `isSigner()` +read at the top of `advance()` is false on the pass where the coordinator is +about to become a signer, and the nonces it then needs were never generated. A +`by lazy` has no such prediction to make: it generates at first use, at most +once per pass, and never on a pass with nothing to publish. + +### The payload codec, early + +`joinPayload`/`splitPayload` land here rather than in Phase 3, because at k=1 +they are the identity — a one-element comma join is the bare value — so they +change no byte on the wire and leave Phase 3 to the proposal encoding alone. **Test:** existing tests are the test. If [FrostSigningRoundTest](../composeApp/src/commonTest/kotlin/press/mantra/compose/managers/FrostSigningRoundTest.kt) @@ -199,6 +213,9 @@ pass without edits beyond the schema move, the vectorisation is faithful. ### Payload encoding +*(Landed in Phase 2 — see above. Restated here because the rest of this phase +depends on it.)* + `FrostSignerMessage` keeps its `(sessionId, signerPublicKey, kind)` primary key and **one row carries all `k` values**, comma-joined — the same encoding `FrostSigningSession.signerIds` already uses.