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:
Kgothatso Ngako
2026-09-06 04:48:24 +02:00
parent 935a8fe37a
commit 59c34263b3
5 changed files with 496 additions and 41 deletions

View File

@@ -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<EventTemplate>, // kind, tags, content
events: List<EventTemplate<*>>, // 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.
---