Files
mantra-kmp/docs/frost-batch-signing.md
Kgothatso Ngako e081d14f37 refactor: weigh the chapter's chunks against batch signing, and keep deriving
Batch signing landed on mantra while this branch was open, and it makes the
argument this change was built on obsolete as written. MantraChunk.chunksOf
said the chunks "cannot be events proposed on their own -- that would cost a
quorum per paragraph". They can now: proposeSigningBatch would carry the
chapter and a chunk per paragraph through one quorum, and every row would hold
a signature of its own.

Weighed and declined, and the KDoc now says so rather than leaning on a reason
that stopped being true. MAX_BATCH_SIZE is 64, which caps a batched chapter at
63 paragraphs and fails an ordinary one outright; the text would go on the wire
twice, whole on the chapter and again split across the chunks, and the proposal
is the term that cap is sized against; and all-or-nothing over k items would
make a long chapter less likely to be signed than a short one, for no reason a
member could see. The signature it would buy is redundant besides -- the
appendix rejects the manifest shape because an item then needs a lookup to be
checked, and here that lookup is a foreign key: a chunk is a pure function of
its chapter and cannot be stored without it.

docs/frost-batch-signing.md records this under the slot Phase 7 leaves open --
"deciding *what* to batch" -- because the next caller will reach for the same
shape. The rule it leaves behind: batch siblings, not derivations. Events that
could each have been authored separately are worth a batch; events that are a
function of another event in the same batch are worth deriving instead.

**The merge.** Only SignedChapterTest broke: the five per-item columns moved
off FrostSigningSession onto FrostSigningItem, so it builds an item and calls
signedEvent(item, sig), which is how SignedArtifactTest was ported in the same
commit. Nothing in the flow itself moved -- proposeSigning kept its signature
as the one-event form, and complete() applies each signed event through
ChatMessage.applyInnerEvent, so the chapter's chunk derivation works the same
whether the chapter arrives alone or as one item of somebody else's batch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:13:09 +02:00

27 KiB
Raw Blame History

Batch signing with FROST

How to have a group sign several events in one ceremony instead of one at a time, phased, with the cryptographic constraint that shapes every phase stated first.

Built. All seven phases are in, one commit each, and the phases below are kept as written -- they are the reasoning, and the code is easier to read against the argument it came from than against a summary of itself. Where the implementation chose differently from the first draft the section says so. FrostSigningManager.proposeSigningBatch and FrostSigningRepository.proposeSigningBatch are the entry points; nothing in the app calls them yet, which is Phase 7's point.

The headline: there is no such thing as one FROST signature over many messages, and no way to reuse a nonce across them. What can be batched is the ceremony — the rounds, the group events, and the approval a human is asked for. A batch of k events is k independent FROST instances run in lockstep, sharing one signer set, one transcript and one prompt. The saving is transport and UX, not crypto, and that is the saving worth having: today FrostSigningManager spends five MLS group events and one human decision per event signed.

The constraint

A Schnorr partial signature is s = k + e·x, with e = H(R‖P‖m). Two different messages under the same nonce R give two equations in one unknown and the secret share falls out. That is not a subtlety to be careful around; it is the one way a t-of-n key is lost, and it is already what the two write-once rules on FrostSigningSession exist to prevent.

So the rule every phase below is built to keep:

Every item in a batch has its own independent nonce, from every signer, and that nonce signs exactly one message for the life of the session.

fr.acinq.bitcoin.crypto.frost.Session.create takes the message and the aggregated nonce, so a batch is k Session objects sharing a signer set and a TweakCache. There is no batch primitive in the library and none is needed.

What is shared across items, safely, and what is not:

shared across the batch per item
signer set (signerIds) nonce seed (nonceRandom)
ceremony, threshold, participant count secret + public nonce
derivation path and TweakCache aggregated nonce
the human approval Session, partial signature, signature
the chat transcript the unsigned event and its id

Phase 1 — schema

Half a day. No wire change, no behaviour change.

Five columns move off FrostSigningSession onto a new child table: they are the per-item ones in the table above.

@Entity(
    primaryKeys = ["sessionId", "itemIndex"],
    foreignKeys = [ForeignKey(
        entity = FrostSigningSession::class,
        parentColumns = ["id"],
        childColumns = ["sessionId"],
        onDelete = ForeignKey.CASCADE,
    )],
    indices = [Index("sessionId")],
)
data class FrostSigningItem(
    val sessionId: String,
    /** Position in the batch. Fixed at proposal; it is the wire ordering. */
    val itemIndex: Int,
    val unsignedEventJson: String,
    val eventId: HexKey,
    val nonceRandom: HexKey,
    val aggregatedNonce: HexKey? = null,
    val signature: HexKey? = null,
)

itemIndex is load-bearing rather than cosmetic: it is the order every device joins nonces and partial signatures in, so two devices that disagree about it produce aggregates nobody can verify. It is fixed by the proposal and never re-sorted. Spelled itemIndex rather than index because index needs quoting in every hand-written query it appears in, and one missing backtick is a compile error at best.

No itemCount column. The count is SELECT COUNT(*) FROM FrostSigningItem WHERE sessionId = :id, for the same reason signerIds is derived from the ceremony's participant order rather than stored: a denormalised count is one more thing that can disagree with the session it describes.

Migration 9 → 10

Not an auto-migration. Room can add a table but cannot backfill one, and this migration has to move data before it drops the columns it came from. Manual, in the shape of MIGRATION_3_4 — the existing precedent for a migration that is about data rather than shape.

CREATE TABLE FrostSigningItem (...);
INSERT INTO FrostSigningItem (sessionId, itemIndex, unsignedEventJson, eventId,
                              nonceRandom, aggregatedNonce, signature)
     SELECT id, 0, unsignedEventJson, eventId, nonceRandom, aggregatedNonce, signature
       FROM FrostSigningSession;
ALTER TABLE FrostSigningSession DROP COLUMN unsignedEventJson;  -- and the other four

DROP COLUMN, not a table rebuild. The usual SQLite way to remove columns — create a new table, copy, drop the old one, rename — is unsafe here and quietly so. FrostSignerMessage and the new FrostSigningItem both reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires cascades: with foreign keys enforced it would delete every signer message and every item the migration had just written. Whether it does depends on Room having turned foreign keys off around the migration, which is not worth depending on when ALTER TABLE ... DROP COLUMN cannot go wrong. It needs SQLite 3.35 and columns free of indices and constraints; these five qualify, and getRoomDatabase pins BundledSQLiteDriver on every platform, so the SQLite version is ours rather than the host's.

Why the backfill has to be exact. A session in flight at upgrade time holds its nonce seed and, possibly, its aggregated nonce in those columns. Losing either means regenerating a different nonce on the next pass — publishing a second partial signature over the same message against a different aggregate, which is the extraction case. Copying them verbatim into item 0 means an in-flight session resumes as though nothing happened. A migration that dropped them and let sessions restart would be the one dangerous way to write this.

Update the entity list and version = 10 in MantraDatabase.kt:175, regenerate composeApp/schemas/10.json, and add the DAO methods: getItems(sessionId), observeItems(sessionId), upsertItems(List<FrostSigningItem>), countItems(sessionId), countSignedItems(sessionId).

Test: extend FrostSigningSessionDaoJvmTest with item CRUD, the (sessionId, itemIndex) dedupe, index ordering and the cascade delete. Add a migration test that runs the migration over a v9 database holding a half-finished session and asserts the seed and aggregate survive at index 0 — the values, not merely that a row appeared.


Phase 2 — vectorise the manager at k = 1

Two days. Still no wire change, still no API change.

The whole point of this phase is that every existing test passes unchanged. proposeSigning still takes one event, still writes one item, and the messages on the wire are byte-identical to today's. What changes is that advance() loops.

In FrostSigningManager.advance:

  1. Load items once, ordered by index.
  2. Nonce generation becomes a map over items, each with its own session.nonceRandomitem.nonceRandom and its own message.
  3. Session.create becomes one per item; the signerIds, publicShares, nParticipants, threshold and tweakCache arguments are the same for all of them.
  4. sign and aggregateSigs become per item.
  5. complete() verifies each item's signature against item.eventId and calls applySignedEvent for each.

Three invariants to establish here, because Phase 3 depends on all three:

  • The signer set and every aggregated nonce are one write-once unit. The coordinator writes session.signerIds and all k item.aggregatedNonce values in a single @Transaction, so "some items aggregated" is unreachable. signerIds != null stays the gate the rest of advance() reads, exactly as today.
  • Signatures likewise. All k land in one transaction; countSignedItems == countItems is the settled test, replacing today's session.signature != null in both advance() and isAwaitingApproval.
  • Item order is the wire order. Every join and split goes through one pair of helpers, never an ad-hoc map at a call site.

The cost that appears here

advance() regenerates nonces and creates FROST sessions unconditionally on every inbound message, before checking whether this device has already published. At k=1 that is one native call per message and nobody notices. At k=64 it is 64 nonce generations, 64 Session.creates 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: 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 and SignedGroupKeyStateTest pass without edits beyond the schema move, the vectorisation is faithful.


Phase 3 — k > 1 on the wire

Two days. This is the phase with the compatibility trap in it.

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.

Per-item message rows were the obvious alternative and are worse. The current key is what makes a redelivered message overwrite rather than accumulate, which is what keeps the coordinator's signer set the right length; splitting by item multiplies the ways a partial delivery can look like a complete one. One group event carrying a signer's whole contribution also matches the transport: a signer publishes all k nonces or none.

The parse is strict. A payload whose element count is not the session's item count is dropped, not truncated and not padded:

private fun splitPayload(payload: String, expected: Int): List<String>? =
    payload.split(",").takeIf { it.size == expected }

Note where this runs. record() stores payloads without parsing them, which is what lets a nonce arrive before its proposal; the count check therefore belongs in orderedNonces and orderedPartialSignatures, where the session — and so the count — is known. Do not move it earlier to "fail faster"; that breaks the out-of-order replay that replayStoredMessages exists for.

Proposal encoding, and the compatibility trap

The proposal's content becomes a JSON array of unsigned events — but only when k > 1.

A single-event session must keep serialising as a bare JSON object, byte for byte as today. The reason is what an old build does with each form:

old build receives outcome
bare object (k=1) signs it, exactly as now
JSON array (k>1) Event.fromJsonOrNull returns null → "does not carry an event; dropping"

That is the correct failure. An old device refuses a batch rather than mis-signing part of one, and a group with mixed versions keeps single signing working throughout the rollout. Emitting an array unconditionally would break every k=1 session for old devices and buy nothing.

acceptProposal accepts both: array if the content starts with [, otherwise a one-element list. New devices understand old proposals forever; old devices understand new single proposals forever.

acceptProposal over a list

Each element is rebuilt from its own fields under the room's path and checked against the id it claims — the existing check, per item, and the whole proposal is dropped if any one fails. The write-once rule extends 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, never applied.

A cap on k

Enforce a maximum batch size — 64 is a sane starting number — in proposeSigning and independently in acceptProposal.

The second check is the one that matters. Without it a proposer can hand every member a batch of arbitrary size and have them do unbounded native work and publish an unbounded group event, from a single message. The proposal is the one place in this protocol where a remote party decides how much work everyone else 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. 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.


Phase 4 — the batch API and its call sites

A day.

(FrostSigningManager.proposeSigningBatch itself landed in Phase 3 — see above. This phase is what surrounds it.)

suspend fun proposeSigningBatch(
    database: MantraDatabase,
    localChatRoom: LocalChatRoom,
    userPublicKey: HexKey,
    events: List<EventTemplate<*>>,   // each carries its own createdAt
    key: DkgSession? = null,
): FrostSigningSession

proposeSigning stays, as the one-element call into it, so AddDialectViewModel, AddArtifactViewModel and GroupKeyStateManager.propose need no edit at all. GroupKeyStateManager in particular should never batch: a room's key state is the statement every other session is opened against, and bundling it with anything else would make it as available as its worst co-passenger.

Mirror both on FrostSigningRepository, including the NO_OP implementation. signedEvent(session): Event? becomes signedEvents(session): List<Event>.

Nonce seeds

k independent 32-byte seeds, generated at proposal, one per item. Deriving them from a single session seed by index would work and save nothing worth having: independent seeds mean an off-by-one in index handling produces a session that fails to aggregate, rather than one that signs two messages under one nonce.

Failure policy: all or nothing

A batch fails whole. If any item's aggregation fails, fail() runs as it does today, nothing is applied, and the group is told once.

This is a real cost and should be stated where callers can see it: a batch is only as available as its worst item, so bundling unrelated events makes both less likely to get signed. Batch things that belong together.

The retry rule is the one that must not be got wrong, and deserves a comment on proposeSigningBatch itself: a retry is a new session id with new seeds. Never re-propose a failed batch under its own id, and never reuse an item's nonceRandom. This is true of single sessions today; batches make it more tempting to get wrong, because a batch that failed on item 5 looks like it has four perfectly good nonces going spare. It does not — those four have already been published against an aggregate.

Per-item partial success is deliberately out of scope. It would need mixed-state UI, a transcript that can say "3 of 5", and a complete() that applies a subset, which is a lot of surface for an outcome that indicates a bug or a dishonest coordinator rather than a normal ending.


Phase 5 — UI

Two days, most of it in the screen.

  • FrostSigningUIState.Loaded gains items: List<FrostSigningItem>.
  • FrostSigningViewModel combines observeItems into its existing combine; nothing else changes, since it already re-renders on every session write.
  • FrostSigningScreen renders a list where it renders one event today.

The approval gate must show every event, not a count. The argument in FrostSigningManager's own header — one approval rather than three, because the event is fixed before the member is asked — only holds if the member can see what they are approving. "Sign 12 events?" behind a chevron is a worse prompt than the twelve prompts it replaces. A member who cannot scroll the whole list should not be able to approve it.

FrostSigningRoute is unchanged: it already carries a session id, and a batch is one session.

Transcript

No new ChatMessage types. The existing TYPE_FROST_* constants, FROST_REQUEST_FULFILMENTS, FROST_SETTLEMENTS and FROST_TYPES in ChatMessage.kt:220 all work as they stand — one line per step, per member, whatever k is. Only the wording in announceStep and announceStarted gains a count: "signed their part of 12 events". This is worth checking rather than assuming, because a new type would need adding to five sets and would silently render as a chat bubble if missed from FROST_TYPES.


Phase 6 — the test that actually proves it

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

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 two on the inbound path, driven by handing the manager a hand-built inner event rather than one the other device queued — which is the only way to be a faulty or dishonest member in this harness:

  • a wrong-length payload is left out, not truncated. The length check is all that stands between a batch and a signer whose contribution lines up against the wrong messages. Assert that a one-value nonce for a three-item batch does not count towards the threshold — and then that the real nonce replaces it and the batch finishes, so it is a stall rather than damage.
  • a second proposal under the same id changes nothing. Every item's seed is already committed to that item's message; a different batch under the same id would have those seeds produce a second partial signature over a second message.

And three in FrostSigningRoundTest, against real FROST with no database, for the same reason that file exists at all — the library calls are checked with nothing in the way: a k = 3 batch from one signer set where all three verify, an item's signature refusing to verify against its neighbour, and both halves of the no-shared-nonce property (one seed under two messages gives two nonces, and the seeds differ anyway — either alone is enough to be relied on by accident).


Phase 7 — rollout

No code.

Nothing here needs a feature flag. k = 1 is the entire behaviour of the app as shipped — no caller batches anything yet — and at k = 1 every message is byte-identical to the app before Phase 1:

message at k = 1
proposal encodeProposal returns the bare event object (asserted in FrostProposalCodecTest)
nonce, signer set, partial, signature joinPayload of one value is that value
chat transcript every line's plural branch is only taken above 1

Phases 1 and 2 are shippable on their own and were worth landing on their own — a schema move and a refactor — because a bisect over a signing bug then lands on one or the other rather than on all of it.

Before the first caller batches, confirm the group is on a build that understands array proposals. There is no negotiation for this and adding one is not worth it: the failure mode is a batch that never reaches threshold and is abandoned, which is visible in the transcript and costs nothing but a retry.

What is left, when a caller wants it

Nothing in the protocol. The remaining work is deciding what to batch, which is a product question this document deliberately does not answer — beyond the one rule that a batch is only as available as its worst item, so events that do not belong together should not travel together, and the one prohibition that GroupKeyStateManager.propose must never batch.

The first caller to weigh it, and decline

Adding a chapter is the case batching looks made for: a chapter is proposed with a chunk per paragraph, which before this work would have been one quorum each. It signs the chapter alone anyway, and splits the chunks back out of the signed text on every device (MantraChunk.chunksOf). Recorded here because the next caller will reach for the same shape:

  • The cap binds on ordinary content. MAX_BATCH_SIZE is 64, so a batched chapter is capped at 63 paragraphs. Prose runs past that, and the failure is a chapter that cannot be proposed at all.
  • The text would travel twice — whole in the chapter, again split across the chunks — and the proposal is the term the cap is sized against.
  • Availability falls with length. All-or-nothing over k items means a long chapter is less likely to get signed than a short one, for no reason a member could see.
  • The signature would be redundant. The appendix rejects the manifest shape because an item then needs a lookup to be checked. Here the lookup is a foreign key: a chunk is a pure function of its chapter and cannot be stored without it, so the chapter's signature already covers it.

The general rule this leaves behind: batch siblings, not derivations. Events that could each have been authored separately are worth a batch; events that are a function of another event in the same batch are worth deriving instead.


Appendix — what was considered and rejected

One signature over k messages. Does not exist for Schnorr. Aggregate signature schemes that do this (BLS) are a different curve and a different verification story, and nostr verifies BIP-340.

One nonce, k messages. This is the extraction attack, described at the top.

Nonce pre-processing — the FROST paper's own batching, where signers pre-publish a list of π nonce commitments before any message is known, and each later signature consumes one commitment per signer in a single online round. The library supports it: SecretNonce.generate takes message as nullable.

Rejected for now, and it is worth writing down why, because it is the thing somebody will suggest next. It cuts latency rather than message count, which is not the complaint. And it costs the property this whole design rests on: today a nonce seed is safe to store and regenerate from because a session signs one fixed message and cannot be made to sign another. Banked nonces have no message to be bound to at generation time, so their safety moves from a structural argument to a used/unused ledger that must be right across crashes, redeliveries and two devices. A bug there leaks a share. Revisit only if round-trip latency becomes the actual problem.

Frost.deterministicSign (BIP-445), where a signer going last derives its nonce from the other signers' aggregate and persists nothing, is orthogonal to batching but relevant to the same file. It would let one signer per session hold no nonce state at all. Not part of this work.

Signing one manifest event that commits to k items — a list of ids, or a Merkle root. One signature, no protocol change, and by far the cheapest thing on this page. Rejected as the general answer because each item stops carrying its own verifiable signature, and the whole point of shared-key-derivation.md is that a reader holding one signed dialect can check it against the room it was found in without a lookup. Still the right answer for any case where the items are only ever read together.